# Creative Tasks

Module scope: generating and managing ad creative — images, video, avatars/voice, ad copy, variants, approval, versioning, storage, reuse, and matching an approved creative to a new campaign's requirements. Campaign assembly/launch lives in `docs/campaign-tasks.md`; per-platform delivery lives in `docs/platform-tasks.md`.

**Assignee: Koushik.** Corresponds to `App\Models\Creative` / `App\Models\AdCopy` / `App\Models\Avatar` / `App\Models\Voice`, the `App\Contracts\{CreativeBriefResolver,ImageCreativeGenerator,ScriptGenerator,VoiceSynthesizer,AdCopyGenerator,VideoAvatarProvider,CompetitorAdProvider}` interfaces and their `App\Services\AI\*` implementations, `App\Jobs\Generate*Job`, `App\Http\Controllers\CreativeController`, and the full build sequence in `docs/SERP-1827-PLAN.md`. See `docs/TASKS.md` for the full-program index.

| Tasks | Hours |
|---|---|
| 2 | 54 |

---

## Id: 09
- **Phase:** MVP
- **Priority:** P0
- **Area:** Creative System
- **Task:** Creative generation workflow, variants, approval/rejection, versioning, storage and reuse
- **Description:** Build the pipeline that generates ad creative (copy/images/variants), lets a human approve or reject each one, keeps version history, stores approved assets, and allows a creative to be reused across future campaigns instead of being regenerated every time.
- **Primary Role/Assignee:** Koushik
- **Estimated Hours:** 36

## Id: 10
- **Phase:** MVP
- **Priority:** P0
- **Area:** Creative Selection
- **Task:** Match approved creatives to campaign/platform requirements; ask user to create if none exist
- **Description:** Given a new campaign's platform and format requirements, search the library of already-approved creatives for a match; if nothing fits, prompt the user to create one via the creative generation workflow (task 09) instead of silently failing.
- **Primary Role/Assignee:** Unassigned
- **Estimated Hours:** 18

---

## Scope note: this ticket includes Meta campaign launch

`docs/TICKETS.md` (SERP-1827) is assigned to Koushik end-to-end, including §18 "Add Creative to Existing Campaign" and §19 "Create New Campaign" — i.e. the `CampaignLauncher` flow in `docs/SERP-1827-PLAN.md` Phase 2 (launching **via `MetaAdapter` only**) is in scope for this ticket, even though the generic program-wide backlog (`docs/TASKS.md` Task 12 "Campaign Builder" / Task 20 "Campaign Dashboard", filed under `docs/campaign-tasks.md`) categorizes campaign-building as separate, unassigned work. Resolve this as: the **Meta-only, single-campaign launch flow** needed to ship SERP-1827 is this ticket's responsibility; the **general multi-platform campaign builder/dashboard/budget-allocation** work in `campaign-tasks.md` (and all non-Meta adapters in `platform-tasks.md`) remains separate, unassigned program work.

## Full scope checklist (from `docs/TICKETS.md` §1–22)

Task 09/10 above are the two-line backlog summary; this is the granular checklist against the actual ticket spec.

- [x] §1A Create from product URL
- [x] §1B Create from text prompt
- [x] §1C Create from uploaded product images — chat wizard "Upload images" source; `CreativeBriefResolver::fromImages()` resolves a brief from the attached images via vision-capable agent prompt
- [~] §2 Clone existing creative / Create Variation (hook, script, avatar, voice, CTA, style, background, presentation, scenes) — chat wizard "Existing creative" source lets you pick a completed creative, pre-fills ratio/style/brief, and sets `parent_creative_id`/`source=clone|variation`; only ratio/style/script/avatar/voice are overridable today, not a full per-axis combination matrix (§12)
- [ ] §3 Competitor ads via Meta Ad Library (search, view, select, swap-based variation) — `NullCompetitorAdProvider` stub only, out of scope for this pass
- [x] §4 Select creative type: Image and Video both selectable in the chat wizard
- [x] §5 Image ratios (1:1, 4:5, 9:16, 16:9) — chat wizard ratio step + validated on the `Creative` row
- [~] §6 Video ratio + duration (spec asks for 15/30/60s) — chat wizard adds a duration step for video type, shares the same ratio step as image; **presets are 4/6/8s**, not 15/30/60s. All presets render via Google Veo (`GeminiVeoVideoAvatarProvider::SUPPORTED_DURATIONS`, its hard clip-length cap). OpenAI Sora (`OpenAiSoraVideoAvatarProvider`, still routable via `VideoAvatarProviderFactory` for durations above `config('creative.veo_max_duration')`) is intentionally unreachable from the wizard for now — see the Sept 2026 silent-audio entry below. Longer durations (15/30/60s) are not offered until Sora's audio issue is resolved or another long-form vendor is chosen.
- [x] §7 Avatar selection (existing stock list) — `AvatarSeeder` seeds a stock catalogue; chat wizard "avatar" step lists them. Upload-your-own avatar still not built.
- [x] §8 Voice selection + preview — `VoiceSeeder` seeds a stock catalogue; chat wizard "voice" step lists them with a Preview button calling `VoiceSynthesizer::preview()`; voice is paired with the chosen avatar for that creative
- [x] §9/§10 AI script generation + multiple script variations per creative — chat wizard "script" step calls `ScriptGenerator::generateVariations()` (3 variations) and lets the user pick one before continuing; for video, the requested duration is passed through and budgeted at ~2.2 words/sec so the script fits the clip length (same rate the video providers use for the rendered dialogue line)
- [x] §11 Creative quantity picker (1/2/3/5/10/custom, capped at 20) — chat wizard quantity step; each quantity dispatches its own `GenerateImageCreativeJob`, sharing one `variation_group_id` when > 1
- [ ] §12 Variation-combination system (product/hook/script/avatar/voice/style/CTA/ratio/duration treated as independent axes) — not built; §2 only re-uses a parent's brief/ratio/style, it doesn't expose an axis-by-axis combination matrix
- [ ] §13 Style presets (UGC / High-End Commercial) — `style` is still a free-text field, not a preset picker; the "Skip" button was replaced with an "Auto-suggest (AI)" button (`ChatWizard::suggestStyle()` + `StyleSuggester`) that fills in an AI-suggested style phrase instead of leaving it blank, but there is still no fixed preset list to choose from
- [x] §14 Storyboard review before final video render — chat wizard auto-builds a 5-scene storyboard from the chosen script (hook/problem/product/benefits/cta), editable per-scene, with a "Looks good, continue" confirmation before generating
- [x] §15 Generation summary/confirmation screen before generating — chat wizard summary step (type/source/ratio/style/quantity) before "Generate"
- [~] §16 Generation status (Queued→Generating→Processing→Completed/Failed) — statuses are set synchronously during the wizard's `confirmGenerate` and the result renders immediately (no polling needed for that path); `creatives.show`/`creatives.index` for older/failed creatives still use `<meta refresh>`, not Livewire polling, and **no per-creative retry** exists anywhere (a failed creative has no retry action, in the wizard or on the show page)
- [~] §17 Review screen actions: Preview ✅, Delete ✅ — Download/Edit/Regenerate/Use-in-Campaign ❌; Clone/Create Variation now available from the wizard's "Existing creative" source (not yet as a button on `creatives.show` itself)
- [ ] §18/§19 Add to existing campaign / Create new campaign (Meta only) — not built; see scope note above
- [~] §20 Creative Library — basic paginated list exists (`creatives.index`); missing avatar/voice/script/"campaign used in" columns and clone/variation/use-in-campaign actions
- [~] §21 End-to-end flows — Option 1 (prompt), Option 2 (URL), Option 3 (uploaded images), and Option 4 (clone/variation of an existing creative) all work end-to-end via the chat wizard for both image and video creatives; video renders via either `GeminiVeoVideoAvatarProvider` (Google Veo, ≤8s requests) or `OpenAiSoraVideoAvatarProvider` (OpenAI Sora, >8s requests), routed by `VideoAvatarProviderFactory`; competitor-ad-based creation (§3) remains unbuilt
- [~] §22 Ad copy generation — `LaravelAiAdCopyGenerator` + `GenerateAdCopyJob` generate variations, now reusing the wizard-resolved brief instead of re-resolving; no review UI to edit/regenerate/copy/select a variation

`[x]` done · `[~]` partially done · `[ ]` not started

---

## Current implementation status (from `docs/SERP-1827-PLAN.md`)

**Done (Phase 1 foundation):**
- Migrations + models: `Creative`, `AdCopy`, `Avatar`, `Voice`, `CampaignCreative`, `CompetitorAdReference`.
- Contracts + `CreativeServiceProvider` bindings for all AI capabilities.
- `LaravelAiCreativeBriefResolver`, `LaravelAiImageGenerator`, `LaravelAiScriptGenerator`, `LaravelAiVoiceSynthesizer`, `LaravelAiAdCopyGenerator` (real, backed by `laravel/ai`).
- `NullCompetitorAdProvider` (stub, throws `ProviderNotConfiguredException` — no vendor chosen yet). `NullVideoAvatarProvider` still exists as a fallback stub but is no longer the bound implementation (see Sept 2026 video entry below).
- `GenerateImageCreativeJob`, `GenerateAdCopyJob` — idempotent, catch-and-record `failure_reason`, tested in `tests/Feature/GenerateImageCreativeJobTest.php`.
- `CreativeController` (index/create/store/show/destroy) + `resources/views/creatives/*.blade.php` — basic prompt/product-url → queued image creative flow, no wizard UI yet.

**Done (chat wizard, Sept 2026):**
- `App\Livewire\Creatives\ChatWizard` (`resources/views/livewire/creatives/chat-wizard.blade.php`) replaces the old plain form at `GET /creatives/create` — a step-by-step chat UI: type → source → raw input → ratio → style → quantity → summary → generate. Deterministic UI steps cost no tokens; the only AI calls happen on "Generate".
- **Generation runs synchronously inside the Livewire action** (`confirmGenerate`), not via a queued job — per explicit product direction, the result renders as a chat message in the same window instead of redirecting away or requiring a queue worker. `GenerateImageCreativeJob`/`GenerateAdCopyJob` still exist and are unit-tested, but the wizard no longer dispatches them; it calls `CreativeBriefResolver`/`ImageCreativeGenerator` directly and updates the `Creative` row itself (queued→generating→processing→completed/failed, same statuses).
  - **Caveat:** this relies on PHP having no execution-time limit for the request (true under `artisan serve`'s CLI SAPI — confirmed `max_execution_time=0`). Under php-fpm/nginx in production, `max_execution_time` and `fastcgi_read_timeout` will need raising for this route, especially for quantity > 1 (each image can take 30-90s, generated one after another in the same request).
- Brief-caching fix: `creatives.brief` (json) stores the resolved `CreativeBrief` once per batch and is reused across all N creatives in a quantity>1 request instead of re-resolving per creative; `GenerateAdCopyJob` also reuses a creative's cached brief if one exists.
- Quantity > 1 creates N `Creative` rows sharing one `variation_group_id`, generated in a loop; a failure on one creative is caught and recorded without aborting the rest of the batch.
- `CreativeController::create()` and its Blade view were removed (superseded by the wizard); `index`/`store`/`show`/`destroy` unchanged.
- Covered by `tests/Feature/Creatives/ChatWizardTest.php` (11 tests, using `Image::fake()`/`StructuredAnonymousAgent::fake()`) — route rendering, full step walk, video-blocked, validation, custom quantity, synchronous single/multi-creative generation with shared `variation_group_id`, and a failure-mid-batch case.

**Done (upload/clone/video/avatar/voice/script, Sept 2026):**
- Chat wizard "source" step gained two new options: **Upload images** (`WithFileUploads`, stores to `creatives/uploads` on the `public` disk, validated as `mimes:jpeg,jpg,png,webp,gif|max:10240` up to 5 files \u2014 svg/bmp excluded, see the PR review fixes entry below) and **Existing creative** (lists the user's completed creatives, cloning brief/ratio/style and setting `parent_creative_id` + `source=clone|variation` on the new row).
- `CreativeBriefResolver::fromImages(array $imagePaths)` added to the contract and implemented in `LaravelAiCreativeBriefResolver` using `Laravel\Ai\Files\LocalImage` attachments on the structured agent prompt.
- **Video is now selectable end-to-end in the wizard** (no longer disabled): type → source → input → ratio → **duration (8/15/30s)** → style → **script (3 AI variations, pick one)** → **avatar (seeded stock list)** → **voice (seeded stock list + inline preview player)** → **storyboard (auto-built 5-scene review, editable)** → quantity → summary → generate.
- `AvatarSeeder` / `VoiceSeeder` (called from `DatabaseSeeder`) seed a stock avatar and voice catalogue so the new selection steps have real data.
- `confirmGenerate()` now branches on `type`: image path is unchanged; video path calls `VideoAvatarProvider::render($avatar, $voice, $script, $storyboard)`, bound (via `config('creative.video_provider')`) to `App\Services\AI\GeminiVeoVideoAvatarProvider` — a real adapter that drives Google's Veo model directly over the raw Gemini REST API (`predictLongRunning` → poll → download), reusing the already-configured `GEMINI_API_KEY`. **Caveat:** Veo has no concept of a persistent avatar identity or of speaking in one of our own synthesized `Voice` tracks — the chosen avatar/voice are folded into the prompt only as descriptive hints (appearance/gender/accent/tone), not a guaranteed likeness or actual audio dub. If a recurring avatar identity + our own TTS voice is required, a dedicated avatar-video vendor (HeyGen/Synthesia/D-ID) is still the better long-term fit. `ProviderNotConfiguredException` is still thrown (and caught by the same failure-handling path as image errors) if `GEMINI_API_KEY` is ever blank.
- Covered by `tests/Feature/Creatives/ChatWizardTest.php` (full video step walk with `Http::fake()` simulating the Veo API, asserting a `completed` status and stored `asset_path`) and `tests/Unit/Services/GeminiVeoVideoAvatarProviderTest.php` (happy path, missing-API-key error, polling-timeout error — all via `Http::fake()`, no real network calls).

**Done (video duration routing to OpenAI Sora, Sept 2026):**
- Veo hard-caps a single clip at 8s (only `durationSeconds` 4/6/8 accepted), so `15`/`30` duration presets are now routed to a second vendor instead of silently clamping down to 8s. `App\Services\AI\VideoAvatarProviderFactory::forDuration()` picks `config('creative.video_provider')` (Veo) for requests at/under `config('creative.veo_max_duration')` (8) and `config('creative.video_provider_long')` (Sora) above it; `ChatWizard::confirmGenerate()` resolves the factory instead of the `VideoAvatarProvider` contract directly.
- `App\Services\AI\OpenAiSoraVideoAvatarProvider` calls OpenAI's Videos API directly (`POST /videos` → poll `GET /videos/{id}` → `GET /videos/{id}/content`), reusing the already-configured `OPENAI_API_KEY`. Uses the cheaper/faster `sora-2` model (not `sora-2-pro`) deliberately. Per OpenAI's Videos API reference, `POST /videos` only accepts base clip lengths of 4/8/12s; if the requested duration exceeds 12s, one `POST /videos/extensions` call (accepts 4/8/12/16/20s segments) is made against the completed base clip, choosing the nearest supported extension length (smaller on a tie) to keep cost/time down — e.g. a 30s request becomes a 12s base clip + a 16s extension = 28s total, in two vendor calls, not a chain of many.
- Same avatar/voice caveat as Veo: Sora has no persistent avatar identity or our-own-TTS dubbing concept either — `$avatar`/`$voice` are folded into the prompt as descriptive hints only.
- **Sora polling 404-tolerance fix (Sept 2026):** freshly created/extended video IDs can briefly 404 on `GET /videos/{id}` before becoming queryable (vendor-side eventual consistency), which previously surfaced as a hard `"Video with id '...' not found."` failure mid-poll. `pollUntilDone()` now treats a 404 as "not ready yet" and retries (same interval/attempt budget as any other in-progress poll) instead of failing immediately; a 404 on the very last allowed attempt still throws (now with a clearer "was never found after N polling attempts" message), and any non-404 failure response still throws immediately as before (no behavior change for genuine vendor errors).
- Covered by `tests/Unit/Services/OpenAiSoraVideoAvatarProviderTest.php` (missing-key error, single base-clip path, base+extension path, polling-timeout error, retry-on-transient-404-then-succeed, fail-fast-on-non-404) and `tests/Unit/Services/VideoAvatarProviderFactoryTest.php` (routing by duration + configurable threshold), all via `Http::fake()` — no real network calls, since `OPENAI_API_KEY` is a live key with no `.env.testing` override. `ChatWizardTest` gained a second video test covering the 15s→Sora path end-to-end.
- **Silent-video prompt fix, superseded by Veo-only default (Sept 2026):** rendered videos (both Veo and Sora) played with no audio at all. First fix: `buildPrompt()` dumped the script into the prose as labeled fields (`Hook: ...\nProblem: ...`), which neither model treats as literal words to voice — both vendors only generate spoken audio for lines placed in a `Dialogue:` block with quoted speech (per OpenAI's Sora 2 prompting guide). `buildPrompt()` in both providers was reworked to emit `Dialogue:\n- {avatar name}: "{spoken line}"`, and the spoken line is now budgeted to the clip's actual duration (~2.2 words/sec, prioritizing hook+CTA) instead of cramming the whole script into one line. **This did not fix Sora's audio** — four live-verified renders (two prompt formats, `sora-2` and `sora-2-pro`, base-only and base+extension) all came back with zero decodable/audible audio (`webkitAudioDecodedByteCount: 0`, `0` max amplitude via Web Audio analyser, despite the video itself playing correctly). The cause appears to be on OpenAI's side (account/API-tier), not prompt engineering. Given this, **the wizard no longer offers durations that route to Sora** (see below) — the `Dialogue:` block + duration-budgeted spoken line fix remains in place for Veo and for whenever Sora is revisited.

**Done (Veo-only default while Sora audio is unresolved, Sept 2026):**
- `ChatWizard::$durationPresets` changed from `[8, 15, 30]` to `[4, 6, 8]` — Veo's exact supported clip lengths (see `GeminiVeoVideoAvatarProvider::SUPPORTED_DURATIONS`). Since every wizard-selectable duration is now `<= config('creative.veo_max_duration')` (8), `VideoAvatarProviderFactory` always resolves to Veo; Sora is unreachable from the UI until its audio issue is resolved with OpenAI.
- The Sora provider, its config, and its unit tests (`OpenAiSoraVideoAvatarProviderTest`, `VideoAvatarProviderFactoryTest`) are untouched and still pass — only the wizard-level path to it was closed off. The wizard-level "duration routes to Sora" feature test was removed since that flow is no longer reachable through the UI.

**Done (duration-aware scripts + AI style suggestion, Sept 2026):**
- `ScriptGenerator::generate()`/`generateVariations()` gained an optional `$durationSeconds` param; `LaravelAiScriptGenerator` adds a line to the AI prompt budgeting the combined spoken script to ~2.2 words/sec of the clip length (matching the video providers' `buildSpokenLine()` rate), so scripts no longer run long for short 4/6/8s clips. `ChatWizard::generateScripts()` passes `$this->duration` through (always set for video by the time this step runs).
- New `App\Contracts\StyleSuggester` + `App\Services\AI\LaravelAiStyleSuggester` (bound in `CreativeServiceProvider`): asks AI for a short visual-style phrase from the resolved brief. `ChatWizard::suggestStyle()` calls it and fills `style` before continuing. The style step's button was relabeled from "Skip" to "Auto-suggest (AI)"; the old `skipStyle()` (leaves style blank) is kept only for existing tests that bypass the step, no longer wired to the UI.
- Covered by a new `ChatWizardTest::test_suggest_style_asks_ai_and_fills_in_a_style` test (`StructuredAnonymousAgent::fake()`); full suite 84/84 passing.

**Done (PR review fixes, Sept 2026):**
- **Upload validation:** `uploadedImages.*` now validates `mimes:jpeg,jpg,png,webp,gif` instead of the `image` rule, which also allowed `.svg`/`.bmp` — svg is served unauthenticated straight off the `public` disk and can carry executable script when opened as a direct navigation, so it's excluded.
- **Video ratio mismatch:** both video providers only ever render `9:16` or `16:9` (anything else silently collapsed to `16:9`), but the wizard offered all four image ratios (`1:1`/`4:5` included) for video too. `ChatWizard` now has a separate `$videoRatios = ['9:16', '16:9']` list, and `selectRatio()` validates against it (same pattern as `selectDuration()`/`selectQuantity()`) when `type === 'video'`.
- **Cost guardrail:** video renders are slow and billable per clip, but quantity was shared with images (up to 20 via custom quantity). Added `$videoQuantityPresets = [1, 2, 3]` and capped custom quantity at 3 for video in `selectQuantity()`/`submitCustomQuantity()`.
- **Vendor error leakage:** both providers previously embedded the raw HTTP response body/error payload from Google/OpenAI directly into the exception message, which became `creatives.failure_reason` and was displayed as-is to end users. Both now log the full body via `logger()->error()` and throw a generic message instead.
- Covered by three new `ChatWizardTest` cases (svg rejection, video ratio restriction, video quantity cap); full suite 87/87 passing.

**Done (async video rendering + Sora 404-retry fix, Sept 2026):**
- **502-on-Generate fix:** video rendering (Veo or Sora, including Sora's poll + extension cycle, which can take several minutes for 15s/30s durations) previously ran synchronously inside the Livewire `confirmGenerate` request, which could exceed nginx's/php-fpm's request timeout and surface as a 502 to the user. `App\Jobs\RenderVideoCreativeJob` now does the rendering; `confirmGenerate()` dispatches it (`generating` \u2192 job picks it up \u2192 `processing` \u2192 `completed`/`failed`, same status column/values as before) and returns immediately instead of blocking the HTTP request. `$tries = 1` deliberately \u2014 a retried attempt would re-trigger a new billable vendor render rather than safely resuming.
- The wizard's "done" step now polls (`wire:poll.3s="refreshResults"`) while any result is still pending, showing a spinner + "Rendering your video\u2026 this can take a few minutes." (or "Generating\u2026" for images) until the job flips the creative to `completed`/`failed`. Image creatives are unaffected \u2014 that path is still synchronous per existing product direction.
- **No Redis/Supervisor in this environment**, so the queue uses the **database** driver (`QUEUE_CONNECTION=database`, `jobs` table) instead of a long-running worker daemon. `routes/console.php` schedules `queue:work --queue=default --stop-when-empty --max-time=50` `everyMinute()->withoutOverlapping()->runInBackground()`, and a user-level crontab entry (`* * * * * cd <app> && php artisan schedule:run`) drives Laravel's scheduler. `withoutOverlapping()` means a still-running invocation (e.g. mid-Sora-poll) blocks the next minute's attempt rather than starting a second overlapping worker.
- Verified live end-to-end against the real OpenAI Sora API (not just `Http::fake()`): a 15s video request routed to Sora, dispatched via the wizard's "Generate" button, returned immediately with no 502, was picked up by the cron-driven worker within a minute (`jobs.attempts` incremented, `creatives.status` \u2192 `processing`), and rendered without hitting the 404-not-found error the 404-retry fix targets.
- Covered by `tests/Feature/RenderVideoCreativeJobTest.php` (success/failure/idempotent-if-already-completed) and the rewritten video tests in `tests/Feature/Creatives/ChatWizardTest.php` (`Queue::fake()` + `Queue::assertPushed(RenderVideoCreativeJob::class, ...)`, asserting `generating` status immediately after `confirmGenerate` instead of `completed`).

**Not yet done (this module's remaining scope, task 09/10):**
- Approval/rejection UI + status transitions beyond `queued/generating/completed/failed`.
- Versioning for regenerated/edited creatives.
- Full per-axis variation-combination system (§12) — §2 clone/variation only reuses a parent's brief/ratio/style; it doesn't expose independent axis toggles (background/presentation/scenes, etc.).
- Style presets (§13) — still free-text, not a picker.
- Avatar/voice **upload** (only the seeded stock catalogue is selectable today).
- Real `CompetitorAdProvider` (`MetaAdLibraryProvider`) + competitor-ad-based creative sourcing (§3) — explicitly out of scope for this pass.
- A dedicated avatar-identity/voice-dub video vendor (HeyGen/Synthesia/D-ID) — `GeminiVeoVideoAvatarProvider` is real and working but cannot preserve a specific avatar's likeness or dub our own synthesized voice track; only worth revisiting if that gap matters in practice.

- Per-creative retry + live status polling (§16) — `creatives.show` still uses a `<meta refresh>` tag, not Livewire polling.
- Creative Selection (task 10): matching an approved creative to a new campaign's platform/format requirements — not started; today `CampaignCreative` pivot exists but nothing populates/queries it for reuse.
- `AdCopy` approval workflow (model/job exist; no review UI).

**Suggested next steps, in order:** (1) if the Veo avatar/voice-fidelity gap turns out to matter, revisit with a dedicated avatar-video vendor (HeyGen/Synthesia/D-ID) instead of Veo; move video rendering to a queued job + polling UI if synchronous in-request timeouts become a real problem; (2) a retry action for a failed result within the wizard's "done" step (currently the only recovery is `startOver` and re-entering everything); (3) approval/versioning + review-screen actions (§17) on top of the existing `creatives` table; (4) Meta-only `CampaignLauncher` (§18/19, see scope note above); (5) task 10 selection/reuse once there's more than one creative to select from; (6) full per-axis variation-combination system (§12) and style presets (§13).

**Note on the 54-hour estimate:** that figure comes from `docs/TASKS.md` Task 09+10's generic description and does not account for the full granularity in the checklist above (avatar/voice UI, quantity/variation system, style presets, storyboard, retry, Meta launch flow). Treat it as stale once you scope real sprints off the checklist instead.
