Appearance
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.mdandCreative-Engine/.claude/rules/coding-conventions.mdverbatim (i18n via$t(), no native<select>, arrow functions for utils, early returns, breathing room,typenotinterface, 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.fallbackinsrc/assets/i18n/en.js(inside thedelivery:section, line ~261, alongside existing nested groupsvastUrls/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-Frontendunless prefixed withAB:(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): CropRectcomputeTargetDimensions(format: { width: number; height: number }): { width: number; height: number }(pass-through today; single place to clamp later)buildFallbackFileName(creativeName: string, width: number, height: number): stringstartCaptureSession(): Promise<CaptureSession>whereCaptureSession = { grabFrame(iframe: HTMLIFrameElement): Promise<CaptureFrame>, stop(): void, cropApplied: boolean }andCaptureFrame = { canvas: HTMLCanvasElement }downscaleCanvas(source: HTMLCanvasElement, targetWidth: number, targetHeight: number): HTMLCanvasElementcanvasToPngFile(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/uploadendpoint.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(uploadAssetToLibraryaction) - Modify:
src/pages/Chatbots/components/Visuals/Blocks/data/types.ts:202-212(CreativeProperties)
Interfaces:
Consumes: Task 2's
skip_optimizationfield.Produces (used by Tasks 5, 6):
uploadAssetToLibraryaccepts optionalskipOptimization?: booleanand appendsskip_optimization: 'true'to FormData when set.- tsand
export type FallbackImage = { assetId: number url: string width: number height: number capturedAt: string }fallbackImage?: FallbackImageadded toCreativeProperties.
[ ] 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;mountedlines 245-255)
Interfaces:
Produces (used by Task 6): prop
iframeName: { type: String, default: 'creative_preview' }— the dialog passesfallback-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 underdelivery.fallback)
Interfaces:
Consumes:
window.__cavaiDevtoolsbridge inside the passed iframe window (available becausepreview-iframe.tsbuilds withdevMode: 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(fromgetFlowInfo().totalSteps+getStepComponents(i)),currentStep,choicePath: number[](thenextStepIdsequence executed so far),videoElement,videoDuration,videoPosition,videoSeeking,navigating.Step nodes: horizontal strip rendered with
v-forover steps; current highlighted withvar(--accent-primary); clickable.Branch chips: when the current step's components contain multiple entries with
nextStepId, render them as chips (comp.texttruncated, fallback tocomp.name); clicking executes that choice.Execute forward:
executeStep(nextStepId)— copy the stabilization-polling pattern fromFlowControls.vue:168-207(generation counter, pollgetFlowInfo()until stable), then push tochoicePath, refresh state,$emit('state-changed').Navigate backwards / to arbitrary step: if target is behind the live step:
await this.resetPreview(), clear + replaychoicePathentries up to the target viaexecuteStepsequentially.goToEnd(): loop — read current step components, find the first with anextStepId, execute it; stop when none found or aftertotalStepsiterations (safety bound).Video scrubber: on
state-changed,iframeDoc.querySelector('video'); when present render a slider row (timeupdate/loadedmetadatalisteners for position/duration); on input: pause, setcurrentTime, waitseeked→ clearvideoSeeking. Reuse the exact pattern from the spike (src/components/DevTools/FallbackCapture.vuemethodsfindVideoElement/seekVideo).Reload gotcha: the browser keeps the same
contentWindowproxy across iframe reloads — afterresetPreview()resolves, re-read all bridge state explicitly (do NOT rely on prop watchers).i18n keys (add to
en.jsunderdelivery:as nestedfallback: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(moredelivery.fallbackkeys)
Interfaces:
Consumes: Task 1 utils, Task 3 upload flag + type + save mutations, Task 4
iframeNameprop, Task 5FlowTimeline.Props:
opened: Boolean,flowRef: Object(passed through from ChatbotBuilder — see Task 7), pluscreativeType: 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/iframeDocfrom it. Populate after the iframe's ready message (listen forpreview-iframe-readywithe.sourcematching, same scoping as Task 4).On open (watch
opened): wait for ready →await this.$refs.timeline.goToEnd().resetPreview(): returns a Promise — reloadsiframeWindow.location, resolves on the next scopedpreview-iframe-ready(plus asetTimeout~100ms for bridge init). Bump a:keyon FlowTimeline? No — FlowTimeline re-reads state explicitly per Task 5; keep the ref stable.capture():tsconst 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():tsconst 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). MapsetCreativeProperties+setSaveRequestedviamapMutations.Preview sizing: render unscaled (100%). If the creative is taller than the available modal body, add
overflow: autoon.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 beforegrabFrameviascrollIntoView({ 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-columnafter existing Cards ~line 179; pass-through props) - Modify:
src/pages/Chatbots/ChatbotBuilder.vue(~line 419-426: pass:flow-refand 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(finaldelivery.fallbackkeys)
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:
tsconst 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/@cancelconvention), then delete asset (silent) +setCreativePropertieswithoutfallbackImage+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 buildgreen;npm run lint -- --no-fixclean; 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 rootdelivery.fallback.*— used consistently across tasks. ✓