# Creative Module — Architecture & Flow

This document explains how AI-generated ad creatives (images and videos) are
produced in this application: the wizard UX, the underlying contracts/services,
the database schema, and how creatives eventually connect (or don't yet) to
campaign publishing.

## 1. File Inventory

### Contracts (`app/Contracts/`)
| File | Purpose |
|---|---|
| `AdCopyGenerator.php` | Generates N ad copy variations across marketing angles |
| `CreativeBriefResolver.php` | Resolves a normalized `CreativeBrief` from URL, prompt, or images |
| `CreativeTitleGenerator.php` | Generates a short human title for a creative |
| `ImageCreativeGenerator.php` | Generates the image asset and returns disk path |
| `ScriptGenerator.php` | Generates video ad scripts (Hook/Problem/Product/Benefits/CTA) |
| `StyleSuggester.php` | Suggests a visual style phrase from a brief |
| `VideoAvatarProvider.php` | Renders a talking-avatar video (Veo/Sora) |
| `VoiceSynthesizer.php` | TTS voice preview/synthesis |
| `CompetitorAdProvider.php` | Competitor ad sourcing (stubbed) |
| `PlatformAdapter.php` | Publishes campaign/creative to an ad platform |

### DTOs
| File | Purpose |
|---|---|
| `app/DTOs/CreativeBrief.php` | Immutable normalized brief: productName, benefits, features, images, messaging, cta |
| `VideoRenderResult.php` | Result of a video render: assetPath, thumbnailPath, meta |

### Models (`app/Models/`)
| File | Purpose |
|---|---|
| `Creative.php` | Core creative record (image/video); relations to user/parent/variations/avatar/voice/adCopies/campaignCreatives |
| `AdCopy.php` | Ad copy variation tied to a user/creative |
| `CampaignCreative.php` | Pivot linking a `Campaign` + `Creative` + `AdCopy` for publishing |
| `Avatar.php` | Stock/uploaded avatar catalogue entry |
| `Voice.php` | Stock TTS voice catalogue entry |
| `Asset.php` | User-uploaded file, distinct from AI-generated `Creative` (see §6) |

### Livewire
| File | Purpose |
|---|---|
| `app/Livewire/Creatives/ChatWizard.php` | Step-by-step chat-style wizard driving the whole creative-creation UX |
| `resources/views/livewire/creatives/chat-wizard.blade.php` | Its view |

### Services (`app/Services/AI/`)
| File | Purpose |
|---|---|
| `LaravelAiCreativeBriefResolver.php` | `CreativeBriefResolver` impl using `laravel/ai` structured agent |
| `LaravelAiImageGenerator.php` | `ImageCreativeGenerator` impl via `Laravel\Ai\Image` |
| `LaravelAiCreativeTitleGenerator.php` | `CreativeTitleGenerator` impl |
| `LaravelAiStyleSuggester.php` | `StyleSuggester` impl |
| `LaravelAiScriptGenerator.php` | `ScriptGenerator` impl, duration-aware word budget |
| `LaravelAiAdCopyGenerator.php` | `AdCopyGenerator` impl, 5 fixed angles |
| `LaravelAiVoiceSynthesizer.php` | `VoiceSynthesizer` impl (TTS preview) |
| `GeminiVeoVideoAvatarProvider.php` | `VideoAvatarProvider` impl via raw Gemini/Veo REST API (≤8s clips) |
| `OpenAiSoraVideoAvatarProvider.php` | `VideoAvatarProvider` impl via OpenAI Sora Videos API (>8s, base+extension) |
| `NullVideoAvatarProvider.php` | Stub fallback, throws `ProviderNotConfiguredException` |
| `VideoAvatarProviderFactory.php` | Routes Veo vs Sora by requested duration |
| `NullCompetitorAdProvider.php` | Stub for competitor-ad sourcing |

### Services (`app/Services/Platforms/`, `app/Services/Meta/`)
| File | Purpose |
|---|---|
| `PlatformAdapterFactory.php` | Resolves `PlatformAdapter` by `config('creative.platform_adapters')` |
| `MetaAdapter.php` | `PlatformAdapter` impl for Meta (create campaign, attach creative→ad) |
| `UnimplementedPlatformAdapter.php` (+ Google/TikTok/Taboola/LinkedIn) | Stubs throwing `PlatformNotImplementedException` |
| `CreativeSpec.php` | Value object for one Meta ad creative (image hash, copy, CTA) |
| `Meta.php`, `GraphApi.php`, `AdsMcp.php`, `ObjectId.php` | Meta Graph API/MCP plumbing used when publishing creatives to Meta |

### Jobs (`app/Jobs/`)
| File | Purpose |
|---|---|
| `GenerateImageCreativeJob.php` | Queued path for image generation (legacy `CreativeController::store` flow) |
| `RenderVideoCreativeJob.php` | Queued video render, dispatched by `ChatWizard::confirmGenerate()` to avoid request timeouts |
| `GenerateAdCopyJob.php` | Generates `AdCopy` rows from a brief/creative |

### Controllers / Config / Providers
| File | Purpose |
|---|---|
| `app/Http/Controllers/CreativeController.php` | `index`/`store`/`show`/`destroy` for the creative library (legacy `create` form removed, superseded by wizard) |
| `config/creative.php` | Video provider routing, duration threshold, competitor provider, platform adapter map |
| `app/Providers/CreativeServiceProvider.php` | Binds all Creative-related contracts to implementations |

### Agent tools (`app/Agent/Tools/Campaign/`)
| File | Purpose |
|---|---|
| `AttachCreatives.php` | Attaches uploaded `Asset` rows (not `Creative` model) to campaign ads |
| `ReviewAssets.php` | Lists creatives/assets attached to the conversation |
| `WriteAdCopy.php` | Generates ad copy inside the campaign-building agent flow |

### Migrations
- `2026_09_04_124928_create_avatars_table.php`
- `2026_09_04_124929_create_voices_table.php`
- `2026_09_04_124930_create_creatives_table.php`
- `2026_09_04_124931_create_ad_copies_table.php`
- `2026_09_04_124934_create_campaign_creatives_table.php`
- `2026_09_07_073736_add_brief_to_creatives_table.php`
- `2026_09_10_132951_add_title_to_creatives_table.php`

---

## 2. Key Contracts & Implementations

```php
interface CreativeBriefResolver {
    public function fromProductUrl(string $url): CreativeBrief;   // scrapes+strips HTML, AI-extracts brief
    public function fromPrompt(string $prompt): CreativeBrief;     // AI infers brief from free text
    public function fromImages(array $imagePaths): CreativeBrief;  // vision AI over uploaded images (public disk paths)
}
```
`LaravelAiCreativeBriefResolver` implements all three via `Laravel\Ai\agent()` with a JSON schema (`product_name`, `benefits`, `features`, `images`, `messaging`, `cta`); `fromImages` attaches `Laravel\Ai\Files\LocalImage` objects.

```php
interface ImageCreativeGenerator {
    public function generate(CreativeBrief $brief, string $ratio, ?string $style = null): string; // returns stored disk path
}
```
`LaravelAiImageGenerator` builds a text prompt from the brief+style, calls `Laravel\Ai\Image::of()->size()->quality('high')->generate()->storePublicly('creatives/images','public')`.

```php
interface ScriptGenerator {
    public function generate(CreativeBrief $brief, ?int $durationSeconds = null): array;                 // one Hook/Problem/Product/Benefits/CTA script
    public function generateVariations(CreativeBrief $brief, int $count, ?int $durationSeconds = null): array; // N variations, distinct hooks
}
```
`LaravelAiScriptGenerator` budgets spoken word count at ~2.2 words/sec when `$durationSeconds` given.

```php
interface StyleSuggester { public function suggest(CreativeBrief $brief): string; } // short style phrase
interface CreativeTitleGenerator { public function generate(CreativeBrief $brief, string $type): string; } // <=100 char title
interface AdCopyGenerator { public function generate(CreativeBrief $brief, int $count): array; } // angle-diverse copy variants
```

```php
interface VideoAvatarProvider {
    public function render(Avatar $avatar, Voice $voice, array $script, array $storyboard, int $duration, string $ratio, ?string $style = null): VideoRenderResult;
}
```
Two implementations, chosen by `VideoAvatarProviderFactory::forDuration($duration)`:
- `GeminiVeoVideoAvatarProvider` — Google Veo via raw Gemini REST (`predictLongRunning` → poll → download), supports durations 4/6/8s (`SUPPORTED_DURATIONS`).
- `OpenAiSoraVideoAvatarProvider` — OpenAI Sora Videos API (`POST /videos` → poll → `GET .../content`; base clips 4/8/12s, extended via `POST /videos/extensions` for longer). Sora's audio-decoding issue is now fixed, and it's reachable from the wizard via the 12/30s duration presets (anything above `veo_max_duration`, currently 8s).

Both fold avatar/voice into the prompt as descriptive hints only (no true likeness/voice-dub) and emit a `Dialogue:` block so the model actually voices lines; both log raw vendor errors and surface only a generic failure message.

```php
interface PlatformAdapter {
    public function createCampaign(Campaign $campaign): string;
    public function attachCreative(Campaign $campaign, Creative $creative, ?AdCopy $adCopy = null): string;
    public function publish(Campaign $campaign): void;
}
```
`MetaAdapter` is the only real implementation (creates campaign PAUSED, creates ad w/ `CreativeSpec`); others (`Google/TikTok/Taboola/LinkedIn`) extend `UnimplementedPlatformAdapter` and throw.

---

## 3. Database Schema

**`creatives`**
```
id, user_id (FK users), type (image|video), status (queued|generating|processing|completed|failed),
source (prompt|product_url|upload|clone|variation|competitor),
parent_creative_id (FK creatives, nullable, self-referential),
variation_group_id (ulid, nullable, indexed — shared across a batch quantity>1),
ratio (nullable string, preset or "WxH"), duration (nullable uint), style (nullable),
title (nullable string(100), added later),
avatar_id (FK avatars, nullable), voice_id (FK voices, nullable),
product_url (nullable), prompt (nullable text),
script (json, nullable), storyboard (json, nullable),
brief (json, nullable — added later, caches resolved CreativeBrief),
asset_path, thumbnail_path (nullable strings),
provider (json, nullable — vendor render metadata),
failure_reason (nullable text), timestamps
```

**`ad_copies`**: `id, user_id, creative_id (FK, nullable), product_url, prompt, primary_text, headline, description, cta, angle (enum-ish string), status (draft|approved), timestamps`

**`avatars`**: `id, name, gender, accent, type (stock|uploaded), source_path, provider_ref, meta (json), timestamps`

**`voices`**: `id, name, gender, accent, tone, provider, provider_voice_id, sample_path, timestamps`

**`campaign_creatives`** (pivot): `id, campaign_id (FK), creative_id (FK), ad_copy_id (FK nullable), external_ad_ref (nullable), status (draft|published|failed), timestamps`; unique on `(campaign_id, creative_id)`.

---

## 4. End-to-End Flow (chat wizard, the primary path)

1. **Entry**: user opens the wizard route → `ChatWizard` Livewire component, `step = 'type'`.
2. **type** → `selectType('image'|'video')` — sets `#[Locked] $type`; video presets ratio `9:16`/quantity `1`.
3. **source** → `selectSource('prompt'|'product_url'|'upload'|'existing')`.
   - `existing` jumps to `clone_select`; `selectCloneFrom($creativeId)` loads a completed `Creative` owned by the user, copies `type/ratio/style/prompt-or-url/brief`, sets `#[Locked] $cloneFromId`, skips straight to `ratio`.
4. **input** → `submitInput()` validates raw text/URL or handles `WithFileUploads` (`uploadedImages` stored to `creatives/uploads` on `public` disk, mime-restricted to jpeg/png/webp/gif — svg excluded for XSS reasons).
5. **ratio** (+ **duration** for video) → `selectRatio()` / `submitCustomRatio()` / `selectDuration()`; video restricted to `videoRatios`; duration presets are `[8, 12, 30]` — 8s stays on Veo; 12s/30s route to Sora (12s is an exact Sora base-clip length needing no extension call, making it the more reliable "longer than Veo" option).
6. **style** → `submitStyle()`, `selectStyle()`, `selectAutoStyle()`, or `suggestStyle()` (calls `CreativeBriefResolver` + `StyleSuggester::suggest()`, caching the resolved brief on `$this->brief`).
7. *(video only)* **script** → `generateScripts()` calls `ScriptGenerator::generateVariations($brief, 3, $duration)`; `selectScript($index)` picks one → `avatar` step.
8. *(video only)* **avatar** → `selectAvatar($avatarId)` (loads `Avatar`, clears mismatched-gender voice) → **voice** → `previewVoice()` (calls `VoiceSynthesizer::preview()`) → `selectVoice()` builds a 5-scene `storyboard` from the script and advances to **storyboard** → `confirmStoryboard()`.
9. **quantity** → `selectQuantity()` / `submitCustomQuantity()` (video presets `[1,2,3]`/max 3; image presets `[1,2,3,4]`/max 20 custom).
10. **summary** → review screen, `goToStep()` allows jumping back to edit earlier answers.
11. **confirmGenerate(CreativeBriefResolver, ImageCreativeGenerator, CreativeTitleGenerator)**:
    - `assertGenerationStateIsValid()` re-validates all locked state server-side before spending money.
    - Resolves/reuses the cached `CreativeBrief` (`resolveBrief()`), folding in `formatNotes`.
    - Generates one shared `title` via `CreativeTitleGenerator::generate()` for the whole batch.
    - Loops `quantity` times, each creating a `Creative::create([...])` row (`status = 'generating'`, shared `variation_group_id` if quantity>1, `parent_creative_id` = clone source).
    - **Image path**: calls `ImageCreativeGenerator::generate()` synchronously inline, then updates the row to `completed` + `asset_path`.
    - **Video path**: dispatches `RenderVideoCreativeJob::dispatch($creative->id, avatarId, voiceId, script, storyboard, duration, ratio)` and returns immediately (`generating` status) — avoids request-timeout 502s.
    - Failures are caught, logged with full exception, and the creative is marked `failed` with a generic `failure_reason` (never leaking raw vendor errors).
12. **done** step — polls `wire:poll.3s="refreshResults"` while `hasPendingResults()` is true (video only; image is already synchronous), re-reading each `Creative`'s `status`/`asset_path`/`failure_reason`.
13. **`RenderVideoCreativeJob::handle()`** (out-of-request): sets `processing`, resolves `VideoAvatarProviderFactory::forDuration($duration)` (Veo or Sora), calls `render()`, then updates the creative to `completed` with `asset_path`/`thumbnail_path`/`provider` metadata (or `failed` on exception; a `failed()` hook also catches worker-kill scenarios so a creative never gets stuck on `processing`).

### Status machine
```
queued -> generating -> processing -> completed
                                    -> failed
```

## 4a. Video Creative — Full Flow (start to end)

1. **Type/source/input**: user picks `video`, a source (`prompt`/`product_url`/`upload`/`existing`), and provides the raw input. `selectType('video')` defaults `ratio = '9:16'`, `quantity = 1`.
2. **Format**: `submitFormat()` validates `ratio` (one of `videoRatios = ['1:1','4:3','9:16','16:9']`), `duration` (one of `[8, 12, 30]`), and `quantity` (max 3, since video renders are slow/billable per clip).
3. **Style**: `selectStyle()`/`toggleStyle()` (multi-select up to `quantity` when `quantity > 1`) or `selectAutoStyle()` (no preference) or `suggestStyle()` (AI-suggested from the resolved brief). Options come from `App\Support\StyleCatalogue::CARDS` plus a video-only `UGC` card.
4. **Script generation**: `generateScripts()` calls `ScriptGenerator::generateVariations($brief, 3, $duration)` (`LaravelAiScriptGenerator`) — this budgets the dialogue to roughly **1.9 words/second** of the selected duration (measured, natural presenter pace), producing 3 Hook/Problem/Product/Benefits/CTA variations. `selectScript($index)` picks one (or several, capped at `quantity`, cycled across variations if fewer scripts than clips are requested).
5. **Avatar & voice**: `selectAvatar($avatarId)` picks a stock `Avatar` (clears any voice whose gender no longer matches); `previewVoice()` synthesizes a short sample via `VoiceSynthesizer`; `selectVoice()` builds a 5-scene `storyboard` (one scene per script section) and advances to a `storyboard` confirmation step.
6. **Quantity → Summary → Generate**: same as the shared flow above. `confirmGenerate()`:
   - Re-validates everything server-side (`assertGenerationStateIsValid()`), including that at least one script was selected.
   - Generates one shared AI title via `CreativeTitleGenerator`, then per variation prefixes it with the ratio, e.g. `9:16-Chromium Speed Ad` (capped at 100 chars total).
   - Creates a `Creative` row per variation (`status = 'generating'`), cycling picked styles/scripts/storyboards across the batch (`styleForIndex()`/`scriptForIndex()`/`storyboardForIndex()`).
   - Dispatches `RenderVideoCreativeJob::dispatch($creative->id, $avatarId, $voiceId, $script, $storyboard, $duration, $ratio)` — **out of the web request**, since Veo/Sora render calls can take minutes and would otherwise exceed the nginx/php-fpm request timeout.
7. **`RenderVideoCreativeJob::handle()`** (queue worker, `database` queue):
   - Sets the creative to `processing`.
   - `VideoAvatarProviderFactory::forDuration($duration)` picks the vendor: durations at or below `config('creative.veo_max_duration')` (default 8s) use **Gemini Veo**; longer durations (12/30s) use **OpenAI Sora**, since Sora's base clips reach 12s and can be stretched with a `/videos/extensions` call. 12s needs no extension call (an exact Sora base-clip length), so it's the more reliable of the two longer options; 30s always needs a base + extension render.
   - The chosen provider's `buildPrompt()` builds a cinematic prompt: presenter description (avatar name/gender/accent, voice tone), an exact verbatim **Dialogue** block (the same ~1.9 words/sec-budgeted spoken line used for script generation, so the script and the rendered video stay in sync), the 5-scene storyboard, and a style-derived scene description (`StyleCatalogue::descriptionFor($style)`, or a special phone-shot "UGC" framing, or a plain studio-polished default).
   - The provider submits the render job to the vendor API and polls until done (`gemini_veo`/`openai_sora` config: poll interval + max attempts). Sora renders beyond the base 12s trigger an extension call; if the extension times out, the job falls back to the shorter base clip rather than failing the whole creative.
   - On success: `asset_path`, `thumbnail_path`, and `provider` metadata (`{provider, model, ...}`) are saved and status becomes `completed`. On any exception (including `MaxAttemptsExceededException` if the job retries too many times): status becomes `failed` with a generic `failure_reason`; a `failed()` job hook also catches worker-kill scenarios so nothing is left stuck on `processing`.
8. **Done step**: the wizard's `done` step (and the `creatives.show` detail page, via a 3s `<meta refresh>`) polls until every batch item is `completed`/`failed`, then shows the rendered `<video>` (with `thumbnail_path` as `poster`), the `Model` detail row (from `provider.model`), the avatar/voice used, and the full script.

## 4b. Image Creative — Full Flow (start to end)

1. **Type/source/input**: user picks `image`; `selectType('image')` also defaults `ratio = '9:16'`, `quantity = 1` (images no longer start ratio-less).
2. **Format**: `submitFormat()` validates `ratio` (one of `ratios = ['1:1','9:16','16:9','4:5']`, or a custom `WxH` pixel size) and `quantity` (presets `[1,2,3,4]`, custom up to 20). No duration step (images aren't time-constrained).
   - **Select all sizes**: clicking the "Select all sizes" chip (`selectAllSizes()`) sets `selectedRatios` to all 4 standard ratios and bumps `quantity` to 4 — one variation per size.
   - When `quantity > 1`, clicking individual ratio cards toggles them into `$selectedRatios` (`toggleRatio()`, capped at `quantity` picks) instead of single-selecting; the UI shows a "Selected X/N" counter, mirroring the style step's multi-select UX.
3. **Style**: same style step as video (minus the `UGC` card), single- or multi-select depending on `quantity`.
4. **Quantity → Summary → Generate**: `confirmGenerate()`:
   - Re-validates state server-side, including every ratio in `selectedRatios` against the allowed list.
   - Generates one shared AI title via `CreativeTitleGenerator`, then per variation prefixes it with that variation's own ratio (`ratioForIndex($i)`), e.g. `1:1-Chromium Speed Ad` / `9:16-Chromium Speed Ad`, capped at 100 chars — so a "select all sizes" batch reads as one set but each row stays distinguishable by size.
   - Creates a `Creative` row per variation (`status = 'generating'`), cycling picked styles (`styleForIndex()`) and picked ratios (`ratioForIndex()`, cycling if fewer sizes than `quantity` were chosen) across the batch.
   - Calls `ImageCreativeGenerator::generate($brief, $itemRatio, $itemStyle)` **synchronously, inline** (no queue job — image generation is fast enough to fit the request), then immediately updates the row to `completed` with `asset_path` and `provider` metadata (`config('ai.default_for_images')`).
5. **`LaravelAiImageGenerator::generate()`**:
   - Builds a prompt via `buildPrompt()`: product name, benefits, a style line resolved through `StyleCatalogue::descriptionFor($style)` (the same shared catalogue video uses, so a picked style's actual mood/lighting description reaches the model instead of just its bare name), messaging tone, and CTA guidance (visual support only, not literal rendered text unless it reads naturally as packaging/signage) — plus shared production-safety guardrails (`BuildsProductionSafePrompts`).
   - Calls `Laravel\Ai\Image::of($prompt)->size($ratio)->quality('high')->generate()->storePublicly('creatives/images', 'public')`.
   - Returns the stored disk path, or throws if storage failed — caught by `confirmGenerate()`'s try/catch, marking that one variation `failed` (the rest of the batch still proceeds).
6. **Done step**: since generation already happened inline, the wizard's `done` step (and `creatives.show`) immediately shows each completed image (or a generic failure message per row) — no polling needed for images, unlike video.

### Publishing path (creative → campaign, agent-driven)
The AI campaign-building agent (separate `app/Agent/Tools/Campaign` toolset) does **not**
consume `App\Models\Creative` directly — it operates on `App\Models\Asset` (user-uploaded
files attached to the conversation). `AttachCreatives` tool pairs `Asset`s to campaign `Ad`s
(round-robin `spread()` or explicit `mapping`), then `PublishGate`/`Publisher` (in
`app/Campaigns/`) validate/execute the actual platform push via
`PlatformAdapter::attachCreative()` (e.g. `MetaAdapter` building a `CreativeSpec` and calling
Meta's Graph API).

The `CampaignCreative` pivot table exists to link a generated `Creative` to a
`Campaign`/`AdCopy` for reuse-tracking, but per `docs/creative-tasks.md`, nothing currently
populates/queries it — matching an approved creative to a new campaign, and adding a
wizard-generated creative directly to a campaign, are both unimplemented. So today the
wizard-generated `Creative` records and the agent's campaign-launch flow are two separate,
not-yet-connected pipelines.

### Legacy/queued path (`CreativeController`)
`store()` still exists for a plain form-less flow: creates a `Creative` row and dispatches
`GenerateImageCreativeJob` (idempotent — skips if already `completed`; resolves brief if not
cached, generates title if missing, then calls `ImageCreativeGenerator`). `index`/`show`/
`destroy` remain the library/detail/delete views for both wizard- and legacy-created
creatives.

---

## 5. Design Patterns

- **Strategy pattern**: `PlatformAdapter` (per-platform campaign publishing) and
  `VideoAvatarProvider` (per-vendor video rendering), each resolved from `config/creative.php`
  via a factory (`PlatformAdapterFactory`, `VideoAvatarProviderFactory`) rather than
  hard-coded classes.
- **Duration-based strategy routing**: `VideoAvatarProviderFactory::forDuration()` picks Veo
  vs Sora purely by a config threshold (`creative.veo_max_duration`), letting the wizard stay
  agnostic to which vendor actually renders.
- **DTO/value object**: `CreativeBrief` (readonly, `fromArray`/`toArray`) is the single
  normalized shape all AI generators consume, decoupling "how a brief was derived"
  (URL/prompt/images/clone) from "what generators need".
- **Brief caching**: the resolved `CreativeBrief` is persisted on `creatives.brief` and reused
  across a batch (`quantity > 1`) and by `GenerateAdCopyJob`, avoiding redundant AI calls/cost.
- **Idempotent jobs**: both `GenerateImageCreativeJob` and `RenderVideoCreativeJob`
  short-circuit if the creative is already `completed`, and `RenderVideoCreativeJob` sets
  `$tries = 1` deliberately (a retry would re-trigger a new billable render, not resume).
- **Fail-safe status machine**: `queued → generating → processing → completed/failed`, with a
  `failed()` job hook covering worker-kill scenarios so nothing gets silently stuck on
  `processing`.
- **Security-conscious server-side re-validation**: Livewire `#[Locked]` properties (`type`,
  `ratio`, `quantity`, `duration`, `cloneFromId`, `avatarId`, `voiceId`) plus
  `assertGenerationStateIsValid()` re-checked immediately before the billable
  `confirmGenerate()` action, rather than trusting earlier step methods.
- **Separation of AI-generated vs user-uploaded creative**: `Creative` (AI pipeline, wizard)
  vs `Asset` (raw upload, used by the campaign-agent's `AttachCreatives` tool) are
  intentionally distinct models — not yet reconciled via `CampaignCreative`.

## 6. Known Gaps (see `docs/creative-tasks.md`)

- `CampaignCreative` pivot is defined but unused — no code populates or queries it.
- No flow yet to attach a wizard-generated `Creative` to a campaign, or to match an
  approved creative to a new campaign.

## 7. Details Page (`creatives.show`)

`CreativeController::show()` eager-loads the `avatar`/`voice` relations. The Details panel
now surfaces, when available:
- **Model** — read from `creative.provider['model']` (video: `gemini-veo`/Veo model name or
  `sora-2` from `GeminiVeoVideoAvatarProvider`/`OpenAiSoraVideoAvatarProvider`'s render `meta`;
  image: `config('ai.default_for_images')`, set by `ChatWizard::confirmGenerate()` on the image path).
- **Avatar** / **Voice** — the `Avatar`/`Voice` catalogue entry names, video creatives only.
  Avatar renders its `source_path` photo (28px thumb) when the record has one.

## 8. Avatar & Voice Catalogue

`Avatar`/`Voice` are simple stock catalogues (see `AvatarSeeder`/`VoiceSeeder`), not live
lookups against a vendor API — Veo/Sora don't expose a persistent avatar/voice identity, so
these are purely descriptive hints folded into the render prompt (see §2).

- The wizard's **avatar** step (`chat-wizard.blade.php`) and the creative details page
  (`resources/views/creatives/show.blade.php`) render each `Avatar`'s `source_path` photo via
  plain `asset($avatar->source_path)` when set, falling back to an initials badge otherwise.
  Avatar photos are AI-generated once via `php artisan avatars:generate-photos` (an
  `App\Console\Commands\GenerateAvatarPhotos` one-off/backfill command using the same
  `Laravel\Ai\Image` pipeline as creative images) and stored as static, git-tracked assets
  under `public/assets/avatars/<random>.png` — **not** the `storage/app/public` disk, since
  Laravel's default `.gitignore` there excludes everything but `.gitignore` itself, which
  would otherwise silently drop the photos from version control.
- Both catalogues are seeded via `firstOrCreate` (`name`+`type` for avatars, `name`+`provider`
  for voices), so re-running `php artisan db:seed --class=AvatarSeeder` /
  `--class=VoiceSeeder` after adding entries is safe and won't duplicate existing rows.
- Voice catalogue currently maps 1:1 to whichever TTS vendor `config('ai.default_for_audio')`
  points at (currently OpenAI's `alloy`/`echo`/`nova`/etc. voice ids, stored as
  `provider_voice_id`) — `LaravelAiVoiceSynthesizer` doesn't read `Voice.provider` per-call,
  it just calls `Audio::of()->voice($voice->provider_voice_id)` against that single global
  provider. So switching `default_for_audio` means re-seeding `Voice` rows with that vendor's
  voice ids (see `VoiceSeeder`, which deletes any non-matching `provider` rows before
  re-seeding).

## 9. Known Fixes & Production-Safety Guardrails

- **Bug: a 30s duration selection silently delivered only a 12s clip.** Root-caused via
  `storage/logs/laravel.log` to two compounding issues in
  `OpenAiSoraVideoAvatarProvider::pollUntilDone()`: (1) a single transient
  `Illuminate\Http\Client\ConnectionException` (cURL error 28) during a status poll was
  treated as an immediate fatal error instead of being retried, and (2) the polling ceiling
  (`config('creative.openai_sora.max_poll_attempts')`, 30 attempts × 10s = 300s) was too
  short for the *extension* render step, which is a full new render pass and routinely takes
  longer than the base clip. Either failure aborted the extension request, which triggered
  `ChatWizard::confirmGenerate()`'s intentional-but-silent fallback to the already-billed base
  clip — so the user got a shorter video with no visible error. Fixed by: retrying
  `ConnectionException`s inside the poll loop instead of throwing immediately, and raising
  `max_poll_attempts` to 60 (600s per render step). `RenderVideoCreativeJob::$timeout` was
  raised from 900s to 1500s to give both the base-clip and extension polling phases headroom
  to complete within the job's own timeout.
- **Production-safety prompt guardrails.** All three generators
  (`LaravelAiImageGenerator`, `GeminiVeoVideoAvatarProvider`, `OpenAiSoraVideoAvatarProvider`)
  append a shared clause via `App\Services\AI\Concerns\BuildsProductionSafePrompts` instructing
  the model to: only depict facts/features/benefits present in the resolved `CreativeBrief`
  (no invented stats/certifications/claims), avoid rendering third-party brand names/logos,
  avoid on-screen text/captions/price tags (AI-generated text is often garbled/misspelled),
  and keep anatomy/continuity photorealistic and artifact-free — since these creatives are
  used directly in live ad campaigns with no manual fact-check step. The avatar-photo
  generation prompt (`GenerateAvatarPhotos`) similarly asks for a "fictional, non-identifiable"
  person with no text/logos/watermarks.


