Skip to content

Fallback Capture Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Users generate the single static fallback image for a creative from a modal in the Delivery tab: timeline-scrub the flow/video to a chosen state, capture the preview iframe pixel-perfectly, store it as an asset referenced on the creative.

Architecture: A FallbackCaptureDialog mounts its own LocalBuildPreview instance (100% zoom, unique iframe name) and a FlowTimeline driven by CE's existing devtools flow bridge. Capture uses getDisplayMedia + Region Capture (manual crop fallback), downscales to ad size, uploads via the existing asset pipeline, and stores a fallbackImage reference in creativeProperties. A FallbackSection card in Delivery shows status and opens the dialog.

Tech Stack: Vue 2 Options API, Vuex, Vuetify v-dialog, Screen Capture API + Region Capture, Vitest.

Spec: Cavai-Documentation/src/DocumentationTexts/specs/2026-08-18-fallback-capture-design.mdFeasibility/spike results: Cavai-Documentation/src/DocumentationTexts/todos/FallbackCapture/feasibility.md

Global Constraints

  • Follow Application-Frontend/.claude/rules/coding-conventions.md and Creative-Engine/.claude/rules/coding-conventions.md verbatim (i18n via $t(), no native <select>, arrow functions for utils, early returns, breathing room, type not interface, scoped SCSS with CSS/SCSS variables).
  • NEVER push, and NEVER commit without showing the diff to Nicolay first (repo rule). Each task ends at a review checkpoint, not an automatic commit.
  • Branch: add-fallback-capture — already exists in Application-Frontend (contains the DevTools spike). Application-Backend gets the same branch name for Task 2.
  • All user-visible strings: i18n keys nested under delivery.fallback in src/assets/i18n/en.js (inside the delivery: section, line ~261, alongside existing nested groups vastUrls/bulkExport).
  • No new backend endpoints — only one optional param on the existing upload endpoint (Task 2).
  • Commit messages: imperative, capitalized, no co-author lines.
  • File paths below are relative to /Users/nicolaykjaernet/CavaiProduct/Application-Frontend unless prefixed with AB: (Application-Backend).

Task 1: Pure capture utils (fallbackCapture.ts) + unit tests

Extract the spike's capture logic into a pure, shared util module. The DevTools spike panel keeps working by consuming it (refactor in Task 7).

Files:

  • Create: src/utils/fallbackCapture.ts
  • Test: src/utils/__tests__/fallbackCapture.test.ts

Interfaces:

  • Produces (used by Tasks 5, 6, 7):

    • computeManualCropRect(elementRect: DOMRectLike, frameWidth: number, frameHeight: number, viewportWidth: number, viewportHeight: number): CropRect
    • computeTargetDimensions(format: { width: number; height: number }): { width: number; height: number } (pass-through today; single place to clamp later)
    • buildFallbackFileName(creativeName: string, width: number, height: number): string
    • startCaptureSession(): Promise<CaptureSession> where CaptureSession = { grabFrame(iframe: HTMLIFrameElement): Promise<CaptureFrame>, stop(): void, cropApplied: boolean } and CaptureFrame = { canvas: HTMLCanvasElement }
    • downscaleCanvas(source: HTMLCanvasElement, targetWidth: number, targetHeight: number): HTMLCanvasElement
    • canvasToPngFile(canvas: HTMLCanvasElement, fileName: string): Promise<File>
  • [ ] Step 1: Write failing tests for the pure math/naming functions

Vitest globals are configured (no imports of test/expect needed) — style copied from src/utils/__tests__/boxShadow.test.ts:

ts
import { buildFallbackFileName, computeManualCropRect, computeTargetDimensions } from '../fallbackCapture'

test('manual crop rect scales element rect from viewport to frame pixels', () => {
  const rect = { left: 100, top: 50, width: 300, height: 600 }
  // frame is 2x the viewport (retina tab capture)
  const crop = computeManualCropRect(rect, 2000, 1500, 1000, 750)

  expect(crop).toEqual({ x: 200, y: 100, width: 600, height: 1200 })
})

test('manual crop rect handles asymmetric scale', () => {
  const rect = { left: 10, top: 20, width: 100, height: 200 }
  const crop = computeManualCropRect(rect, 1500, 750, 1000, 750)

  expect(crop).toEqual({ x: 15, y: 20, width: 150, height: 200 })
})

test('fallback file name is slugged and dimensioned', () => {
  expect(buildFallbackFileName('My Fancy Creative!', 300, 600)).toBe('fallback_my-fancy-creative_300x600.png')
})

test('fallback file name falls back when creative name is empty', () => {
  expect(buildFallbackFileName('', 300, 250)).toBe('fallback_creative_300x250.png')
})

test('target dimensions round to integers', () => {
  expect(computeTargetDimensions({ width: 300.4, height: 599.6 })).toEqual({ width: 300, height: 600 })
})
  • [ ] Step 2: Run tests to verify they fail

Run: npx vitest run src/utils/__tests__/fallbackCapture.test.ts Expected: FAIL — module not found.

  • [ ] Step 3: Implement the module

src/utils/fallbackCapture.ts — pure module: no Vue reactivity, no store access. Port the proven logic from src/components/DevTools/FallbackCapture.vue (ensureCaptureStream, waitForVideoFrames, drawManualCrop):

ts
// -- Types --

export type CropRect = {
  x: number
  y: number
  width: number
  height: number
}

export type DOMRectLike = {
  left: number
  top: number
  width: number
  height: number
}

export type CaptureFrame = {
  canvas: HTMLCanvasElement
}

export type CaptureSession = {
  grabFrame: (iframe: HTMLIFrameElement) => Promise<CaptureFrame>
  stop: () => void
  cropApplied: boolean
}

// -- Pure helpers --

/**
 * The tab-capture frame maps 1:1 to the visual viewport, so scaling by
 * frame/viewport handles devicePixelRatio and browser zoom in one factor.
 */
export const computeManualCropRect = (
  elementRect: DOMRectLike,
  frameWidth: number,
  frameHeight: number,
  viewportWidth: number,
  viewportHeight: number,
): CropRect => {
  const scaleX = frameWidth / viewportWidth
  const scaleY = frameHeight / viewportHeight

  return {
    x: elementRect.left * scaleX,
    y: elementRect.top * scaleY,
    width: elementRect.width * scaleX,
    height: elementRect.height * scaleY,
  }
}

export const computeTargetDimensions = (format: { width: number; height: number }) => ({
  width: Math.round(format.width),
  height: Math.round(format.height),
})

export const buildFallbackFileName = (creativeName: string, width: number, height: number): string => {
  const slug = creativeName
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')

  return `fallback_${slug || 'creative'}_${width}x${height}.png`
}

// -- Canvas helpers --

const getCanvasContext = (canvas: HTMLCanvasElement): CanvasRenderingContext2D => {
  const context = canvas.getContext('2d')
  if (!context) {
    throw new Error('Could not create canvas context')
  }

  return context
}

export const downscaleCanvas = (
  source: HTMLCanvasElement,
  targetWidth: number,
  targetHeight: number,
): HTMLCanvasElement => {
  const canvas = document.createElement('canvas')
  canvas.width = targetWidth
  canvas.height = targetHeight

  const context = getCanvasContext(canvas)
  context.imageSmoothingEnabled = true
  context.imageSmoothingQuality = 'high'
  context.drawImage(source, 0, 0, targetWidth, targetHeight)

  return canvas
}

export const canvasToPngFile = (canvas: HTMLCanvasElement, fileName: string): Promise<File> =>
  new Promise((resolve, reject) => {
    canvas.toBlob((blob) => {
      if (!blob) {
        reject(new Error('Could not encode capture as PNG'))
        return
      }

      resolve(new File([blob], fileName, { type: 'image/png' }))
    }, 'image/png')
  })

// -- Capture session --

const waitForVideoFrames = (streamVideo: HTMLVideoElement, count: number): Promise<void> =>
  new Promise((resolve) => {
    const step = (remaining: number) => {
      if (remaining <= 0) {
        resolve()
        return
      }

      if (typeof streamVideo.requestVideoFrameCallback === 'function') {
        streamVideo.requestVideoFrameCallback(() => step(remaining - 1))
      } else {
        setTimeout(() => step(remaining - 1), 100)
      }
    }
    step(count)
  })

const supportsRegionCapture = (): boolean =>
  typeof (window as any).CropTarget?.fromElement === 'function'

/**
 * One native permission prompt per session. Product flow: start session,
 * grab one frame, stop immediately (spec: no lingering share indicator).
 */
export const startCaptureSession = async (): Promise<CaptureSession> => {
  const stream: MediaStream = await (navigator.mediaDevices as any).getDisplayMedia({
    video: true,
    audio: false,
    preferCurrentTab: true,
  })

  const [track] = stream.getVideoTracks()

  const streamVideo = document.createElement('video')
  streamVideo.srcObject = stream
  streamVideo.muted = true
  await streamVideo.play()

  const stop = () => {
    stream.getTracks().forEach((mediaTrack) => mediaTrack.stop())
  }

  const session: CaptureSession = {
    cropApplied: false,
    stop,
    grabFrame: async (iframe: HTMLIFrameElement): Promise<CaptureFrame> => {
      if (supportsRegionCapture() && typeof (track as any).cropTo === 'function' && !session.cropApplied) {
        try {
          const cropTarget = await (window as any).CropTarget.fromElement(iframe)
          await (track as any).cropTo(cropTarget)
          session.cropApplied = true
        } catch {
          session.cropApplied = false
        }
      }

      await waitForVideoFrames(streamVideo, 3)

      if (session.cropApplied) {
        const canvas = document.createElement('canvas')
        canvas.width = streamVideo.videoWidth
        canvas.height = streamVideo.videoHeight
        getCanvasContext(canvas).drawImage(streamVideo, 0, 0)

        return { canvas }
      }

      const rect = iframe.getBoundingClientRect()
      const crop = computeManualCropRect(rect, streamVideo.videoWidth, streamVideo.videoHeight, window.innerWidth, window.innerHeight)

      const canvas = document.createElement('canvas')
      canvas.width = Math.round(crop.width)
      canvas.height = Math.round(crop.height)
      getCanvasContext(canvas).drawImage(streamVideo, crop.x, crop.y, crop.width, crop.height, 0, 0, canvas.width, canvas.height)

      return { canvas }
    },
  }

  return session
}
  • [ ] Step 4: Run tests to verify they pass

Run: npx vitest run src/utils/__tests__/fallbackCapture.test.ts Expected: PASS (5 tests).

  • [ ] Step 5: Lint and checkpoint

Run: npx eslint src/utils/fallbackCapture.ts src/utils/__tests__/fallbackCapture.test.ts --no-fix Expected: clean. STOP — show diff to Nicolay before committing (Add fallbackCapture util with capture session and crop math).


Task 2: Backend — optional skip_optimization on asset upload (Application-Backend)

The backend auto-converts PNG→WebP (AB:app/Controllers/Http/AssetLibraryController.ts:118-144). Ad servers require PNG/JPG backup images, so the fallback upload must opt out.

Files:

  • Modify: AB:app/Validators/StoreAssetValidator.ts (schema, ~line 10-19)
  • Modify: AB:app/Controllers/Http/AssetLibraryController.ts (store, ~line 109-180)

Interfaces:

  • Consumes: existing POST asset-library/upload endpoint.

  • Produces: multipart field skip_optimization — when 'true', PNG/JPG is stored as-is (no WebP conversion). Response unchanged.

  • [ ] Step 1: Create branch in Application-Backend

bash
cd /Users/nicolaykjaernet/CavaiProduct/Application-Backend && git checkout -b add-fallback-capture
  • [ ] Step 2: Add the validator field

In StoreAssetValidator.ts, add alongside the existing optional fields (match file style):

ts
skip_optimization: schema.string.optional(),
  • [ ] Step 3: Guard the conversion in the controller

In AssetLibraryController.ts store, read the flag and wrap the existing WebP-conversion block (lines ~118-144) so it is skipped when the flag is 'true':

ts
const skipOptimization = request.input('skip_optimization') === 'true'

if (!skipOptimization && /* existing png/jpg condition */) {
  // existing conversion block, unchanged
}

Do not change anything else in the flow (metadata extraction, storage path, response).

  • [ ] Step 4: Verify manually

Start AB dev server; upload a PNG with and without the flag (curl or via Task 6's flow later). With flag: stored file keeps .png and image/png mime. Without: existing WebP behavior intact.

  • [ ] Step 5: Checkpoint

STOP — show diff to Nicolay (Add skip_optimization flag to asset upload).


Task 3: Frontend plumbing — upload flag, FallbackImage type, save helper

Files:

  • Modify: src/store/modules/assetLibrary.ts:206-219 (uploadAssetToLibrary action)
  • Modify: src/pages/Chatbots/components/Visuals/Blocks/data/types.ts:202-212 (CreativeProperties)

Interfaces:

  • Consumes: Task 2's skip_optimization field.

  • Produces (used by Tasks 5, 6):

    • uploadAssetToLibrary accepts optional skipOptimization?: boolean and appends skip_optimization: 'true' to FormData when set.
    • ts
      export type FallbackImage = {
        assetId: number
        url: string
        width: number
        height: number
        capturedAt: string
      }
      and fallbackImage?: FallbackImage added to CreativeProperties.
  • [ ] Step 1: Extend the upload action

In assetLibrary.ts, extend the payload type and FormData build:

ts
{ file, brandId, campaignId, skipOptimization }: { file: File; brandId: string; campaignId: string | null; skipOptimization?: boolean }
ts
if (skipOptimization) {
  formData.append('skip_optimization', 'true')
}
  • [ ] Step 2: Add the type

In types.ts, define FallbackImage (exported, above CreativeProperties) and add fallbackImage?: FallbackImage to CreativeProperties.

  • [ ] Step 3: Verify

Run: npx vitest run (existing suites still green) and npx eslint on both files. Note: saving happens via setCreativeProperties({ ...creativeProperties, fallbackImage }) + setSaveRequested(true) (mutations at src/store/modules/blocks.ts:699 and src/store/modules/builder.ts:112) — the dialog does this in Task 6; no auto-save exists, so setSaveRequested is mandatory.

  • [ ] Step 4: Checkpoint

STOP — show diff to Nicolay (Add skipOptimization to asset upload and FallbackImage type).


Task 4: LocalBuildPreview multi-instance guardrails

Two preview instances must coexist (Visuals + dialog). Today both would share name="creative_preview" (DevTools querySelector targets the first) and both react to any preview-iframe-ready message.

Files:

  • Modify: src/pages/Chatbots/components/Visuals/Preview/LocalBuildPreview.vue (template lines 9-17 and 36-44; mounted lines 245-255)

Interfaces:

  • Produces (used by Task 6): prop iframeName: { type: String, default: 'creative_preview' } — the dialog passes fallback-capture-preview.

  • [ ] Step 1: Parametrize the iframe name

Add the iframeName prop; change both <iframe ... name="creative_preview" occurrences to :name="iframeName".

  • [ ] Step 2: Scope the ready-listener to this instance's iframe

In mounted, only rebuild when the message comes from our own iframe (refactor-as-you-go — this also stops cross-instance rebuild noise):

ts
this._onIframeReady = (e: MessageEvent) => {
  const frame = this.$refs.creativePreview as HTMLIFrameElement | undefined

  if (e.data?.type === 'preview-iframe-ready' && e.source === frame?.contentWindow) {
    this.localBuildCreative()
  }
}
  • [ ] Step 3: Verify no regressions

Manual: builder Visuals preview renders and live-updates as before; DevTools DOM Inspector and the spike panel still find the Visuals iframe (their iframe[name="creative_preview"] query is unchanged and now unambiguous). Run: npm run build — green.

  • [ ] Step 4: Checkpoint

STOP — show diff to Nicolay (Add iframeName prop and scope ready listener in LocalBuildPreview).


Task 5: FlowTimeline.vue — product timeline component

The user-facing timeline: step nodes, branch chips, go-to-end, video scrubber. Wraps the CE flow bridge the same way src/components/DevTools/FlowControls.vue does (getFlowInfo/getStepComponents/stepTo + stabilization polling — reuse its patterns; read it first).

Files:

  • Create: src/pages/Chatbots/components/Delivery/FlowTimeline.vue
  • Modify: src/assets/i18n/en.js (keys under delivery.fallback)

Interfaces:

  • Consumes: window.__cavaiDevtools bridge inside the passed iframe window (available because preview-iframe.ts builds with devMode: true).

  • Props: iframeWindow: { default: null }, iframeDoc: { default: null }, resetPreview: { type: Function, required: true } — dialog-provided async function that reloads the iframe and resolves when the bridge is ready again.

  • Emits: state-changed (after any navigation/seek settles — dialog uses it to re-enable capture), busy (Boolean, while navigating).

  • Produces (used by Task 6): public method goToEnd(): Promise<void> (called via ref on dialog open).

  • [ ] Step 1: Implement the component

Key implementation points (Options API, scoped SCSS with CSS variables — builder design language, not devtools chrome):

  • State: steps (from getFlowInfo().totalSteps + getStepComponents(i)), currentStep, choicePath: number[] (the nextStepId sequence executed so far), videoElement, videoDuration, videoPosition, videoSeeking, navigating.

  • Step nodes: horizontal strip rendered with v-for over steps; current highlighted with var(--accent-primary); clickable.

  • Branch chips: when the current step's components contain multiple entries with nextStepId, render them as chips (comp.text truncated, fallback to comp.name); clicking executes that choice.

  • Execute forward: executeStep(nextStepId) — copy the stabilization-polling pattern from FlowControls.vue:168-207 (generation counter, poll getFlowInfo() until stable), then push to choicePath, refresh state, $emit('state-changed').

  • Navigate backwards / to arbitrary step: if target is behind the live step: await this.resetPreview(), clear + replay choicePath entries up to the target via executeStep sequentially.

  • goToEnd(): loop — read current step components, find the first with a nextStepId, execute it; stop when none found or after totalSteps iterations (safety bound).

  • Video scrubber: on state-changed, iframeDoc.querySelector('video'); when present render a slider row (timeupdate/loadedmetadata listeners for position/duration); on input: pause, set currentTime, wait seeked → clear videoSeeking. Reuse the exact pattern from the spike (src/components/DevTools/FallbackCapture.vue methods findVideoElement/seekVideo).

  • Reload gotcha: the browser keeps the same contentWindow proxy across iframe reloads — after resetPreview() resolves, re-read all bridge state explicitly (do NOT rely on prop watchers).

  • i18n keys (add to en.js under delivery: as nested fallback: group): timeline_step ('Step {0}'), timeline_go_to_end ('Go to end'), timeline_choice_hint ('Pick a path'), video_position ('Video frame'), seeking ('Seeking...').

  • [ ] Step 2: Verify standalone

Temporarily mount inside the spike DevTools panel OR proceed to Task 6 and verify there (timeline is hard to test without a host). Required manual checks: steps render, chips at branches, go-to-end lands on final state, backwards navigation resets + replays correctly, video slider seeks.

  • [ ] Step 3: Lint + checkpoint

npx eslint on new/changed files. STOP — show diff to Nicolay (Add FlowTimeline component).


Task 6: FallbackCaptureDialog.vue — the capture modal

Files:

  • Create: src/pages/Chatbots/components/Delivery/FallbackCaptureDialog.vue
  • Modify: src/assets/i18n/en.js (more delivery.fallback keys)

Interfaces:

  • Consumes: Task 1 utils, Task 3 upload flag + type + save mutations, Task 4 iframeName prop, Task 5 FlowTimeline.

  • Props: opened: Boolean, flowRef: Object (passed through from ChatbotBuilder — see Task 7), plus creativeType: String, creativeFormat: [String, Object], width: Number, height: Number (same values Delivery's parent has).

  • Emits: cancel (close), saved (after successful save — parent refreshes status card).

  • [ ] Step 1: Implement the dialog

Model on src/components/dialogs/AssetLibraryDialog.vue (v-dialog + v-card.modal_content, opened prop + dialogOpen computed emitting cancel, DialogCloseButton, lazy v-if="opened" content, @import '../../styles/Dialog.scss' — adjust relative path). Structure:

v-dialog (max-width ~1000, persistent while capturing)
  v-card.modal_content
    header: title $t('delivery.fallback.dialog_title') + DialogCloseButton
    body (v-if="opened"):
      .preview-wrap (centered, checkered/neutral bg)
        LocalBuildPreview(ref="preview" :width :height :creative-type :creative-format :flow-ref="flowRef" iframe-name="fallback-capture-preview")
      FlowTimeline(ref="timeline" :iframe-window :iframe-doc :reset-preview="resetPreview" @state-changed="onStateChanged" @busy="timelineBusy = $event")
      .capture-bar
        TheButton(type="primary" :disabled="timelineBusy || capturing" @click="capture") $t('delivery.fallback.capture')
      .result (v-if="capturedCanvas")
        img preview (downscaled result) + dimensions
        TheButton [$t('delivery.fallback.use')] (@click="save"), TheButton [$t('delivery.fallback.retake')]

Key logic:

  • Iframe access: iframeElement()(this.$refs.preview?.$el as HTMLElement)?.querySelector('iframe[name="fallback-capture-preview"]'); iframeWindow/iframeDoc from it. Populate after the iframe's ready message (listen for preview-iframe-ready with e.source matching, same scoping as Task 4).

  • On open (watch opened): wait for ready → await this.$refs.timeline.goToEnd().

  • resetPreview(): returns a Promise — reloads iframeWindow.location, resolves on the next scoped preview-iframe-ready (plus a setTimeout ~100ms for bridge init). Bump a :key on FlowTimeline? No — FlowTimeline re-reads state explicitly per Task 5; keep the ref stable.

  • capture():

    ts
    const session = await startCaptureSession()
    try {
      // hide dialog chrome overlapping the iframe is unnecessary: region crop
      // targets the iframe element; only elements ON TOP of the iframe matter —
      // ensure no tooltip/overlay covers .preview-wrap during grabFrame
      const frame = await session.grabFrame(this.iframeElement())
      const target = computeTargetDimensions({ width: this.width, height: this.height })
      this.capturedCanvas = downscaleCanvas(frame.canvas, target.width, target.height)
      this.capturedPreviewUrl = this.capturedCanvas.toDataURL('image/png')
    } finally {
      session.stop() // spec: auto-stop after every capture
    }
  • save():

    ts
    const target = computeTargetDimensions({ width: this.width, height: this.height })
    const fileName = buildFallbackFileName(this.creativeProperties.name || '', target.width, target.height)
    const file = await canvasToPngFile(this.capturedCanvas, fileName)
    const previous = this.creativeProperties.fallbackImage
    
    const asset = await this.$store.dispatch('uploadAssetToLibrary', {
      file,
      brandId: String(this.creativeProperties.brandId),
      campaignId: this.creativeProperties.campaignId ? String(this.creativeProperties.campaignId) : null,
      skipOptimization: true,
    })
    
    this.setCreativeProperties({
      ...this.creativeProperties,
      fallbackImage: {
        assetId: asset.id,
        url: asset.url,
        width: target.width,
        height: target.height,
        capturedAt: new Date().toISOString(),
      },
    })
    this.setSaveRequested(true)
    
    if (previous?.assetId) {
      await this.$store.dispatch('deleteAssetFromLibrary', { assetId: String(previous.assetId), silent: true })
    }
    
    toastSuccess(this.$t('delivery.fallback.saved') as string)
    this.$emit('saved')
    this.$emit('cancel')

    Wrap in try/catch → toastError (@/utils/toasts). Map setCreativeProperties + setSaveRequested via mapMutations.

  • Preview sizing: render unscaled (100%). If the creative is taller than the available modal body, add overflow: auto on .preview-wrap — do NOT CSS-scale the iframe in v1 (scaling reduces capture resolution; a scroll container keeps pixels 1:1, and Region Capture crops the element regardless of scroll position — verify during manual testing; if clipping occurs when partially scrolled, scroll the iframe fully into view before grabFrame via scrollIntoView({ block: 'nearest' })).

  • i18n keys: dialog_title ('Fallback image'), capture ('Capture frame'), use ('Use as fallback'), retake ('Retake'), saved ('Fallback image saved'), capture_failed ('Capture failed'), share_hint ('Your browser will ask to share this tab — that is how the picture is taken.').

  • [ ] Step 2: Verify manually

From Task 7's section (or a temporary button): open dialog → auto-navigates to end → capture → result shows at exact ad dimensions → save → toast → creative save fires (network tab shows update with fallbackImage in blob) → asset exists (PNG, not WebP).

  • [ ] Step 3: Lint + checkpoint

STOP — show diff to Nicolay (Add FallbackCaptureDialog).


Task 7: FallbackSection.vue in Delivery + wiring + spike refactor

Files:

  • Create: src/pages/Chatbots/components/Delivery/FallbackSection.vue
  • Modify: src/pages/Chatbots/components/Delivery/Delivery.vue (add section in .options-column after existing Cards ~line 179; pass-through props)
  • Modify: src/pages/Chatbots/ChatbotBuilder.vue (~line 419-426: pass :flow-ref and format/size props to Delivery — reuse exactly what the Visuals/PreviewPanel branch passes at ~line 378-396)
  • Modify: src/components/DevTools/FallbackCapture.vue (replace inlined capture logic with Task 1 util imports; keep panel functional)
  • Modify: src/assets/i18n/en.js (final delivery.fallback keys)

Interfaces:

  • Consumes: creativeProperties.fallbackImage (Task 3), FallbackCaptureDialog (Task 6).

  • [ ] Step 1: Implement FallbackSection.vue

Pattern-match TagCode.vue: <Card :title="$t('delivery.fallback.title')">. Content:

  • Has fallback: thumbnail (<img :src="fallbackImage.url">, max-height ~120px, $border-radius-base), a meta line rendering width×height and a formatted capturedAt timestamp. Actions row (TheButton): Replace (opens dialog), Download, Remove.

  • Empty state: short explainer $t('delivery.fallback.empty_hint') + Create button (primary).

  • Download: fetch-to-blob with graceful fallback:

    ts
    const downloadFallback = async () => {
      try {
        const response = await fetch(this.fallbackImage.url)
        const blob = await response.blob()
        const url = URL.createObjectURL(blob)
        const link = document.createElement('a')
        link.href = url
        link.download = buildFallbackFileName(this.creativeProperties.name || '', this.fallbackImage.width, this.fallbackImage.height)
        link.click()
        URL.revokeObjectURL(url)
      } catch {
        window.open(this.fallbackImage.url, '_blank')
      }
    }
  • Remove: confirm via existing ConfirmDialog (src/components/dialogs/ConfirmDialog.vue, :opened + @confirm/@cancel convention), then delete asset (silent) + setCreativeProperties without fallbackImage + setSaveRequested(true) + toastSuccess($t('delivery.fallback.removed')).

  • No master/child override locking in v1: fallback is inherently per-creative (each size needs its own image).

  • i18n keys: title ('Fallback image'), empty_hint ('Static backup image for ad servers and no-JS placements. Capture one from the creative's end state.'), create ('Create fallback'), replace ('Replace'), download ('Download'), remove ('Remove'), removed ('Fallback image removed'), remove_confirm ('Remove the fallback image? The stored asset is deleted.').

  • [ ] Step 2: Wire into Delivery.vue and ChatbotBuilder.vue

Delivery.vue: render <FallbackSection> + <FallbackCaptureDialog> (dialog hosted here, opened via section event), forwarding flowRef, creativeType, creativeFormat, width, height props received from ChatbotBuilder. ChatbotBuilder: locate the exact prop values the Visuals branch passes (~line 378-396) and pass the same to <Delivery>.

  • [ ] Step 3: Refactor the DevTools spike to consume fallbackCapture.ts

Replace FallbackCapture.vue's inlined ensureCaptureStream/waitForVideoFrames/drawManualCrop/getCanvasContext with startCaptureSession + downscaleCanvas imports. Keep its keep-alive-session behavior (call session.stop() only via its Stop button / destroy) — the util supports both patterns.

  • [ ] Step 4: Full manual verification (per repo conventions)

  • Create/replace/remove flow end-to-end in builder; verify PNG (not WebP) asset; Download yields correct file; reload creative → fallback persists (blob round-trip).

  • Branching creative: timeline chips, backwards scrub, go-to-end. Video creative: scrub + capture matches frame.

  • Visuals preview + DevTools unaffected (two-instance check: open dialog while DevTools DOM Inspector is open).

  • No console errors; npm run build green; npm run lint -- --no-fix clean; open+save all changed files in IDE (lint trigger).

  • [ ] Step 5: Checkpoint + PR prep

STOP — show full diff to Nicolay. After his review/testing + explicit approval: commits per logical unit, then draft PRs per workflow (AF PR with template + running log, Closes #1930; AB PR with Relates to Cavai/Application-Frontend#<pr>), update todos/FallbackCapture/feasibility.md status + dashboard entry.


Self-review notes

  • Spec coverage: Delivery section (T7), modal + timeline + end-state default (T5/T6), capture pipeline + auto-stop + downscale (T1/T6), storage via asset + creativeProperties + no new endpoints except upload flag (T2/T3), multi-instance guardrails (T4), spike kept as util consumer (T7), unit tests (T1), out-of-scope items untouched. ✓
  • PNG requirement forced the one backend task (T2) — deviation from "zero backend" noted in spec's "verify during planning" clause. ✓
  • Type/name consistency: startCaptureSession/grabFrame/stop/cropApplied, FallbackImage, iframeName='fallback-capture-preview', i18n root delivery.fallback.* — used consistently across tasks. ✓

Internal documentation