# AI Video Studio — Phases A/B/C/D + Hardening — Handoff

**Status:** Complete, reviewed, on branch `dev-chen` (not yet committed). **Kept local** like the other AI Video docs.
**Date:** 2026-06-24. **Owner:** Chen. **Reviewer / merge:** Lee Jie.
**Read first:** [`ai-video-aixcut-blueprint.md`](ai-video-aixcut-blueprint.md) (the 4-phase direction) and [`ai-video-phase0-handoff.md`](ai-video-phase0-handoff.md) (the Project-container foundation this builds on).

This session built out the rest of the "Aixcut-style" plan on top of Phase 0 (the Projects container):

| Item | What it is | = blueprint |
|---|---|---|
| **A** | Bridge the project asset **pool → generation** (single-clip + storyboard) | the missing half of Phase 0b |
| **B** | **Edit a finished cut → re-render for free** (no Seedance) | Phase 1 |
| **Follow-up** | `VideoProject` ownership isolation (`created_by`) | §5 smaller follow-up |
| **C** | **Conversational drafting + AI-generated images** + **style templates** | Phase 2 |
| **D** | **Uploaded real-footage editor** (STT → score → text-based cut) | Phase 3 |
| **Hardening** | Generation-level ownership scoping + the 9-image cap | review findings (P1/P2/P3) |

Everything was TDD'd and independently code-reviewed; all review findings were fixed (see §Review).

---

## 1. What shipped, by item

### A — Pool → generation
A project's shared **asset pool** (Phase 0) can now feed generation. Picked pool assets are **copied** into the generation's input media (collection `video_input`) — the same established pattern as `reuse_media` (revisions) and brochure/PDF images.
- **Single-clip generator** (`VideoGenerator.vue` → `store`): pick pool **images** (Seedance only takes image refs); they count toward the 9-image cap + `@ImageN` tray.
- **Storyboard studio** (`StoryboardStudio.vue` → `draft`): pick pool **images + clips**; images feed scene drafting, clips become CLIP-scene material. Pool-only drafts are allowed; a clips-only draft is rejected up front (the drafter needs ≥1 image).
- `?project=` studio exposes the pool as the `poolAssets` prop.

### B — Edit a finished cut → free re-render
`RepackageVideoJob` re-renders a scene-driven cut from its **already-stored clips** (collection `video_clip`, matched by `meta.scene_id`), with **NO Seedance call** — so editing captions / voiceover / scene order costs nothing.
- `POST /manage/video/{id}/repackage` saves the edited scenes then dispatches the job. Guards: READY-only, every GENERATED scene must already have a stored clip (no net-new generated scenes; "use Revise"), atomic `claimForRepackage` against double-submit. Output is replaced **store-new-then-delete-old**.
- **Invariant relaxed**: `VideoSceneRepository::sync` now allows editing scenes on a **DRAFT or READY** cut (`assertEditable`), not DRAFT-only — required for in-place editing. `createMany` stays DRAFT-only.
- `GenerateVideoJob` now tags each stored clip with `meta.scene_id` so repackage survives reorder/drop.
- Front door: a finished scene-driven cut shows an **Edit** button → `?edit=<uuid>` opens the storyboard editor in `editMode` with a **"Re-render (free)"** button.

### Follow-up — VideoProject ownership
`VideoProject` is scoped to its creator (`created_by = auth()->id()`). `index` lists only your projects; `show/update/destroy/assets/chat/footage` + the studio's `?project=` / `resolveProjectId` all 404 for non-owners (`ownedProject()` helper). Decision: **per-account isolation** ("每个账号看自己的"). petav3 has no per-user `business_id`, so `created_by` is the isolation key.

### C — Conversational drafting + AI images + templates
- **C-1/C-2 AI images (Seedream).** `SeedreamClient` (BytePlus ARK `POST /images/generations`, synchronous) → `POST /manage/video/projects/{id}/assets/generate` generates an image from a prompt into the pool (`meta.source = ai_generated`). Reuses the Seedance ARK key. **Smoke-verified** (see §4).
- **C-3 Conversational drafting.** `PropertyInterviewService` runs a multi-turn interview (client holds the transcript, re-sent each turn): returns the next question, or `{ready, brief, direction}` when it has enough. `POST /manage/video/projects/{id}/chat` runs one turn and persists `brief` to the project when done. The **AI Draft** tab in the project drafts a storyboard by posting the gathered `direction` + the project's pool images to the existing `/storyboard/draft` (reuses A2's pool drafting — no new draft logic).
- **C-4 Style templates.** `VideoTemplate` is a config-backed registry (`config('video.templates')`): the on-screen presenter persona is now template-driven at draft time. The current style is `default`; a second `male_pro` ships to prove it's pluggable — **add a config entry → it appears in the studio's template picker**, no code change. `StoryboardDraftService::draft(..., $template)` resolves the persona via `VideoTemplate::presenter($template)`; the chosen template is stored in `options.template` and validated (`Rule::in`).

### D — Uploaded real-footage editor
Upload footage → transcribe → score → pick segments → build a text-based cut, reusing a `VideoGeneration` (`options.kind = 'footage'`) as the container.
- **D-1 STT.** `FootageSegmentService` parses a Deepgram result into `{start,end,text}` (utterances → word-grouping fallback). `TranscribeFootageJob` runs `DeepgramService` (reused from Calls) → segments → scoring → stores `options.segments`.
- **D-2 Scoring.** `FootageScoringService` asks Gemini to score each segment 0-100 + a `keep` suggestion (keeps everything if scoring fails).
- **D-3 Cut + assemble.** The footage editor (in `Index.vue`, `?footage=<uuid>`) shows segments with keep checkboxes; `POST /manage/video/{id}/build-footage` (kept indices) dispatches `BuildFootageEditJob` → `FootageClipper` cuts each kept span (**keeping original audio**, frame-accurate) and concatenates → replaces the output. Entry: **"Edit real footage"** button on the project.

### Hardening (review findings)
- **P1 — generation routes scoped.** Every `VideoGenerationsController` route that loads a gen by uuid (`status/destroy/restore/forceDestroy/updateStoryboard/repackage/generate/revise` + the `?revise/?draft/?edit/?footage` deep links + the My-videos/Drafts/Bin lists) now filters `created_by = auth()->id()`. Non-owners get 404 and don't see others' cuts.
- **P2 — revision media-copy lockdown.** `store()` resolves `revised_from` scoped to `created_by`, so a foreign gen uuid can't be used to copy someone else's input/output media.
- **P3 — 9-image cap server-side.** `store()` now counts pool + reused images (resolved server-side) toward the 9-reference-image cap, not just the FormRequest's upload count.

---

## 2. Key files

| Area | New | Changed |
|---|---|---|
| A | — | `VideoGenerationsController` (store/draft/index + pool copy), `StoreRequest`, `DraftRequest`, Vue: `VideoGenerator`, `StoryboardStudio`, `Index` |
| B | `app/Jobs/Video/RepackageVideoJob.php` | `VideoGenerationsController` (repackage, mapScenesInput, ?edit), `VideoGenerationRepository` (claimForRepackage), `VideoSceneRepository` (assertEditable), `GenerateVideoJob` (scene_id tag), `PresentsVideoGenerations` (has_scenes), Vue: `VideoCard`, `StoryboardStudio`, `Index`, `Projects/Show` |
| Follow-up | — | `ProjectsController` (ownedProject + scoping), `VideoGenerationsController` (project resolution scoping) |
| C | `app/Helpers/SeedreamClient.php`, `src/Video/Services/PropertyInterviewService.php`, `src/Video/VideoTemplate.php`, `app/Http/Requests/Manage/Video/Projects/{GenerateAsset,Chat}Request.php` | `ProjectsController` (generateAsset, chat), `VideoGenerationsController` (draft template), `StoryboardDraftService` (template), `DraftRequest` (template), `config/services.php` (seedream), `config/video.php` (templates), Vue: `Projects/Show`, `StoryboardStudio`, `Index` |
| D | `app/Helpers/FootageClipper.php`, `src/Video/Services/{FootageSegmentService,FootageScoringService}.php`, `app/Jobs/Video/{TranscribeFootageJob,BuildFootageEditJob}.php`, `app/Http/Requests/Manage/Video/Projects/FootageUploadRequest.php`, `app/Http/Requests/Manage/Video/Generations/BuildFootageRequest.php` | `ProjectsController` (storeFootage), `VideoGenerationsController` (buildFootage, ?footage), `VideoGenerationRepository` (claimForFootageBuild), Vue: `Projects/Show`, `Index` |
| Hardening | — | `VideoGenerationsController` (created_by everywhere) |
| Routes | — | `routes/web.php`: `projects.assets.generate`, `projects.chat`, `projects.footage.store`, `generations.repackage`, `generations.build-footage` |

New tests: `RepackageVideoJobTest`, `SeedreamClientTest`, `PropertyInterviewServiceTest`, `VideoTemplateTest`, `FootageSegmentServiceTest`, `FootageScoringServiceTest`, `TranscribeFootageJobTest`, `BuildFootageEditJobTest` + many added to `VideoGenerationsControllerTest` / `ProjectsControllerTest` / `StoryboardRequestTest`.

---

## 3. Run / test / verify

- Frontend: `cd ~/petav3 && npm run dev` (don't background it); `npm run build` is clean.
- Tests: `"$HOME/Library/Application Support/Herd/bin/php" -d memory_limit=512M vendor/bin/phpunit tests/Unit/Video tests/Feature/Video` → **173 green**. (Test DB `petav3_testing`, RefreshDatabase — **never `migrate:fresh` the dev DB**.)
- Full suite: **591 tests, 7 pre-existing failures** in Leads/Membership/Portal/Concierge/Zoom — **unrelated** to this work (none of those files were touched; all changes are Video-scoped + routes + the Video-only `PresentsVideoGenerations` trait).
- **Queue:** generation/repackage/transcribe/build all run on Horizon (`php artisan horizon`). After editing any `app/Jobs/**` or a Service a job uses, `php artisan horizon:terminate` then restart — the worker caches code.
- **Test pattern note:** `VideoGenerationsControllerTest::admin()` is now **memoized** and `setUp` acts as it, so generations/projects created in a test are owned by the requesting admin (needed for the `created_by` scoping). Use `freshAdmin()` for a second, distinct admin in cross-user tests.

---

## 4. External integrations & smoke status

| Integration | Status | Notes |
|---|---|---|
| **Seedream (AI images)** | ✅ **Smoke-verified** | Real ARK call succeeded: model **`seedream-5-0-260128`**, size `1600x2848` (9:16 @2K, valid), returned a JPEG URL, downloaded ~580 KB (`ffd8ff` = JPEG). Body params confirmed correct: `sequential_image_generation:disabled` (there is **no `n`** param), `response_format:url`, `output_format:jpeg`, `watermark:false`. |
| **Deepgram (footage STT)** | ⚠️ stubbed in tests, **not yet real-footage smoked** | Reuses `App\Helpers\Calls\DeepgramService`. `FootageSegmentService` reads `raw.merged_result` (the bilingual merge) and defensively falls back; if the real shape differs, segments come back empty and the editor shows an error. Needs a real-footage run. |
| **ffmpeg (footage cut/concat)** | ⚠️ stubbed in tests, **not yet real-footage smoked** | `FootageClipper` cuts with output-seeking `-ss` after `-i` (frame-accurate) and concats with `filter_complex concat` keeping audio. Needs a real-footage run on this box (use ffmpeg-full; the slim build lacks some filters). |
| **Frontend (A/B/C/D UI)** | build-clean, **needs manual e2e** | Per the project's workflow, Chen e2e's the browser flows. |

### Config / .env
- `config('services.seedream')`: `model` default **`seedream-5-0-260128`** (set `SEEDREAM_MODEL` to override, e.g. a specific `ep-…` endpoint id), `size` default `1600x2848`, key/base **reuse the Seedance ARK credentials** (`SEEDANCE_API_KEY`). No new env var is required to run Seedream.
  - **Gotcha:** "Dola-Seedream-5.0-lite" (the console display name) is **not** the API id. The real id was found via `GET {base}/api/v3/models` and is `seedream-5-0-260128` (date-suffixed, like `dreamina-seedance-2-0-260128`). The active image models on this account are `seedream-4-0-250828`, `seedream-4-5-251128`, `seedream-5-0-260128`.
- `config('video.templates')`: `default` (= `config('video.presenter')`) + `male_pro`. Add more here to extend the picker.

---

## 5. Review status

Five independent code-review passes (A×2, B-backend, C, D) — all findings fixed:
- **B:** new-generated-scene-with-no-clip orphan (guarded), double-dispatch (claimForRepackage), delete-then-store output ordering (reversed). The `assertEditable` invariant relaxation was reviewed and deemed correct.
- **C:** Seedream failure now logged (was swallowed). LOW: `downloadBytes` doesn't origin-check the URL (HTTPS to trusted provider; noted, not fixed).
- **D:** **2 HIGH fixed** — `buildFootage` had no owner scope (added `created_by`) and no `kind=='footage'` guard (a storyboard cut uuid would have been flipped to FAILED). Plus: double-dispatch (claimForFootageBuild), transcription-failure no longer hangs the UI (`transcription_error`), frame-accurate `-ss`.
- **P1/P2/P3** (a later review pass): the generation-level scoping, foreign-`revised_from` lockdown, and server-side 9-image cap — all fixed + regression-tested.

---

## 6. Remaining / deferred (none blocking)

- **B "Find best cut"** for storyboard scenes (Gemini scores scenes → suggested keep-set) — **not built**; deemed optional polish. The footage version (D-2 `FootageScoringService`) exists and could be adapted.
- **Footage memory** — `BuildFootageEditJob` buffers the source (≤300 MB) and the output fully into PHP memory (same pattern as `FfmpegStitcher`). Large files could OOM a worker; stream if it bites.
- **`SeedreamClient::downloadBytes`** — no origin allow-list (LOW).
- **Cutover/ops** (unchanged from earlier): the cutover items, dowayai accounts, Horizon deploy from prior handoffs still stand.

## 7. Gotchas to carry forward

- **Horizon caches job code** — `horizon:terminate` + restart after touching any job/service.
- **Seedance/Seedream model ids are date-suffixed** and account-specific — discover the exact id with `GET {ark base}/models`, don't trust the console display name.
- **Footage edits keep original audio** (`FootageClipper`); the AI-presenter pipeline (`FfmpegStitcher`) drops it (`-an`) — they are deliberately different renderers.
- **Repackage only re-sources clips tagged with `scene_id`** (new cuts). Old cuts (pre-this-PR clips, position-only) are excluded from repackage by the controller guard to avoid mis-mapping after reorder.
- **Generations are now owner-scoped** (`created_by`) — any new gen route must scope the same way (and any test that pre-creates a gen must act as its owner first; see the memoized-`admin()` pattern).
- **Committing** — RTK injects `--no-verify` into `git commit`, which the `block-no-verify` hook rejects; workaround: `c=commit; git "$c" -m "…"`.
