@koda-sl/baker-cli 0.129.1-dev.972f3c9aa → 0.131.0-dev.597b50181

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -223,6 +223,8 @@ baker ads google query --list-presets
223
223
  | `keyword-analysis` | Keyword performance with match type | LAST_30_DAYS |
224
224
  | `positive-keywords` | Positive (targeting) keywords only | ALL_TIME |
225
225
  | `negative-keywords` | Negative (blocking) keywords only | ALL_TIME |
226
+ | `negative-keyword-lists` | Shared negative lists + their terms | ALL_TIME |
227
+ | `negative-list-attachments`| Which campaigns each shared list covers | ALL_TIME |
226
228
  | `search-terms` | Actual user queries triggering ads | LAST_7_DAYS |
227
229
  | `ad-copy-performance` | Ad headline/description effectiveness | LAST_30_DAYS |
228
230
  | `asset-performance` | PMax asset performance labels | LAST_30_DAYS |
@@ -397,262 +399,6 @@ baker ads google keywords metrics --customer-id 1234567890 --keywords "running s
397
399
 
398
400
  ---
399
401
 
400
- ### Google Ads Library (`baker ads google library`)
401
-
402
- Manage and search the Google Ads Transparency Center. Track competitor advertisers, browse their ad creatives, and discover who's bidding on keywords.
403
-
404
- **Typical workflow:** `search-advertiser` → `track` → `search-ads`
405
-
406
- ---
407
-
408
- ### `baker ads google library search-advertiser "query"`
409
-
410
- Search for an advertiser on the Google Ads Transparency Center.
411
-
412
- > **Recommended:** use the domain running the ads (e.g. `example.com`) for more accurate results.
413
-
414
- ```bash
415
- baker ads google library search-advertiser "example.com"
416
- baker ads google library search-advertiser "Nike"
417
- ```
418
-
419
- **Response:**
420
-
421
- ```json
422
- {
423
- "ok": true,
424
- "data": {
425
- "results": [
426
- { "advertiserId": "AR12345678901234567", "name": "Nike, Inc.", "region": "US", "format": "TEXT_IMAGE_VIDEO" }
427
- ]
428
- }
429
- }
430
- ```
431
-
432
- **Flags:**
433
-
434
- | Flag | Description |
435
- |------------|--------------------------------|
436
- | `--output` | Format: `json` \| `csv` \| `md` |
437
-
438
- ---
439
-
440
- ### `baker ads google library track <id> <name>`
441
-
442
- Track a new Google advertiser and wait for the initial ad sync to complete. Polls every 5 seconds with a 10-minute timeout. Progress is written to stderr.
443
-
444
- ```bash
445
- baker ads google library track AR12345678901234567 "Nike, Inc."
446
- baker ads google library track AR12345678901234567 "Nike, Inc." --json
447
- ```
448
-
449
- **Response (with `--json`):**
450
-
451
- ```json
452
- {
453
- "ok": true,
454
- "data": {
455
- "advertiserId": "ar_abc123",
456
- "accountId": "acc_def456",
457
- "totalAdCount": 342,
458
- "activeAdCount": 89
459
- }
460
- }
461
- ```
462
-
463
- **Flags:**
464
-
465
- | Flag | Description |
466
- |----------|----------------------|
467
- | `--json` | Output in JSON format |
468
-
469
- ---
470
-
471
- ### `baker ads google library list-advertisers`
472
-
473
- List all tracked Google advertisers and their accounts.
474
-
475
- ```bash
476
- baker ads google library list-advertisers
477
- baker ads google library list-advertisers --output md
478
- ```
479
-
480
- **Flags:**
481
-
482
- | Flag | Description |
483
- |------------|--------------------------------|
484
- | `--output` | Format: `json` \| `csv` \| `md` |
485
-
486
- ---
487
-
488
- ### `baker ads google library sync-status <accountId>`
489
-
490
- Check the sync status and ad counts of a tracked account.
491
-
492
- ```bash
493
- baker ads google library sync-status acc_def456
494
- ```
495
-
496
- **Response:**
497
-
498
- ```json
499
- {
500
- "ok": true,
501
- "data": {
502
- "syncStatus": null,
503
- "totalAdCount": 342,
504
- "activeAdCount": 89
505
- }
506
- }
507
- ```
508
-
509
- `syncStatus` is `null` when idle, `"syncing"` during a sync, or `"error"` if the last sync failed.
510
-
511
- ---
512
-
513
- ### `baker ads google library search-ads <accountId>`
514
-
515
- Search and filter ads for a tracked account. Supports pagination.
516
-
517
- ```bash
518
- baker ads google library search-ads acc_def456
519
- baker ads google library search-ads acc_def456 --search "summer sale" --isActive --mediaType image
520
- baker ads google library search-ads acc_def456 --sort newest --limit 50
521
- baker ads google library search-ads acc_def456 --cursor "eyJwYWdl..."
522
- ```
523
-
524
- **Response:**
525
-
526
- ```json
527
- {
528
- "ok": true,
529
- "data": {
530
- "page": [
531
- {
532
- "_id": "abc123",
533
- "platform": "google",
534
- "externalId": "CR_1234567890",
535
- "isActive": true,
536
- "mediaType": "image",
537
- "headline": "Summer Sale — 50% Off Everything",
538
- "description": "Shop our biggest sale of the year. Free shipping on all orders.",
539
- "destinationUrl": "https://example.com/summer-sale",
540
- "bodyText": "Summer Sale — 50% Off Everything",
541
- "pageName": "Example Store",
542
- "impressionsMin": 100000,
543
- "impressionsMax": 200000,
544
- "startDate": "2025-06-01",
545
- "endDate": "2025-06-30",
546
- "firstSeenAt": 1717200000000,
547
- "lastSeenAt": 1719792000000,
548
- "publisherPlatforms": ["GOOGLE_ADS"],
549
- "regionCodes": ["US", "GB"],
550
- "variations": [
551
- {
552
- "headline": "Summer Sale — 50% Off",
553
- "description": "Shop our biggest sale of the year.",
554
- "destinationUrl": "https://example.com/summer-sale",
555
- "imageUrl": "https://...",
556
- "visibleUrl": "example.com"
557
- }
558
- ],
559
- "regions": [
560
- { "code": "US", "name": "United States" }
561
- ],
562
- "analysisStatus": "completed",
563
- "aiAnalysis": {
564
- "aiSummary": "Promotional display ad for a seasonal sale with urgency-driven CTA",
565
- "hookAngle": "Discount/Price",
566
- "offerType": "Percentage Discount",
567
- "ctaStrategy": "Shop Now",
568
- "funnelStage": "Bottom",
569
- "targetAudience": "Price-sensitive shoppers",
570
- "adFormat": "responsive_display",
571
- "tags": ["sale", "discount", "ecommerce"],
572
- "trustSignals": ["Free shipping"],
573
- "keyMessages": ["50% off", "Free shipping"],
574
- "competitiveAngle": "Price leadership",
575
- "dominantColors": ["#FF5733", "#FFFFFF"],
576
- "analyzedAt": 1719792000000
577
- }
578
- }
579
- ],
580
- "continueCursor": "eyJwYWdl...",
581
- "isDone": false
582
- }
583
- }
584
- ```
585
-
586
- **Key response fields:**
587
-
588
- | Field | Description |
589
- |-------|-------------|
590
- | `headline`, `description` | Top-level ad copy (first variation) |
591
- | `variations[]` | All ad variations with copy, images, videos, and URLs |
592
- | `regions[]` | Geographic targeting regions |
593
- | `impressionsMin/Max` | Estimated impression range (Google Ads Transparency data) |
594
- | `publisherPlatforms` | Where the ad ran (GOOGLE_ADS, YOUTUBE, etc.) |
595
- | `analysisStatus` | AI analysis state: `pending`, `processing`, `completed`, `failed` |
596
- | `aiAnalysis` | AI-generated creative analysis (only present when `analysisStatus` is `completed`) |
597
- | `aiAnalysis.aiSummary` | One-line AI summary of the ad |
598
- | `aiAnalysis.hookAngle` | Creative hook (Discount, Fear, Social Proof, etc.) |
599
- | `aiAnalysis.funnelStage` | Funnel position: Top, Middle, Bottom |
600
- | `aiAnalysis.tags` | AI-generated tags for filtering |
601
-
602
- **Flags:**
603
-
604
- | Flag | Description |
605
- |---------------|------------------------------------------------|
606
- | `--search` | Search term for ad text |
607
- | `--isActive` | Filter by active ads only |
608
- | `--mediaType` | Filter by media type: `image`, `video`, `text` |
609
- | `--sort` | Sort: `newest` or `oldest` |
610
- | `--limit` | Max results per page (default 20, max 100) |
611
- | `--cursor` | Pagination cursor from previous response |
612
- | `--output` | Format: `json` \| `csv` \| `md` |
613
-
614
- ---
615
-
616
- ### `baker ads google library sync <accountId>`
617
-
618
- Trigger an immediate re-sync for a tracked account. Polls every 5 seconds until complete (10-minute timeout). Progress is written to stderr.
619
-
620
- ```bash
621
- baker ads google library sync acc_def456
622
- ```
623
-
624
- **Response:**
625
-
626
- ```json
627
- {
628
- "ok": true,
629
- "data": {
630
- "totalAdCount": 350,
631
- "activeAdCount": 92
632
- }
633
- }
634
- ```
635
-
636
- ---
637
-
638
- ### `baker ads google library search-competitors "keyword"`
639
-
640
- Search for competitors running Google ads for a keyword. Uses DataForSEO (same data as `baker research advertisers`).
641
-
642
- ```bash
643
- baker ads google library search-competitors "running shoes"
644
- baker ads google library search-competitors "crm software" --location uk
645
- ```
646
-
647
- **Flags:**
648
-
649
- | Flag | Description |
650
- |--------------|----------------------------|
651
- | `--location` | Location name or code |
652
- | `--json` | Output in JSON format |
653
-
654
- ---
655
-
656
402
  ### Staged writes (`baker ads google budgets|campaigns|...`)
657
403
 
658
404
  Write commands **never touch the Google Ads API at stage time**. Each command stages a create/update/pause/resume/remove op against the current chat's draft (`BAKER_CHAT_ID`); the dashboard shows it as a pending "Google Ads" change, and the whole draft applies as one atomic `GoogleAdsService.Mutate` when the chat is published. Feature-flagged per company (`companies.googleAdsWriteEnabled`) — off by default = a fully simulated publish with zero real API calls.
@@ -1944,14 +1690,14 @@ baker images generate "flat geometric mascot, brand palette" \
1944
1690
  --model recraft/recraft-v4.1-pro-vector --rgb-colors "[[10,10,10],[255,80,0]]" --bg-rgb "[255,255,255]"
1945
1691
  ```
1946
1692
 
1947
- **Models** (`--model`, default `openai/gpt-5.4-image-2`):
1693
+ **Models** (`--model`, default `google/gemini-3.1-flash-image-preview`):
1948
1694
 
1949
1695
  | Model | Best for | Aspect ratios | Sizes |
1950
1696
  |---|---|---|---|
1951
- | `openai/gpt-5.4-image-2` **(default)** | Photoreal + cleanest in-image text ad/landing reproduction | standard set | `1K` `2K` `4K` |
1952
- | `google/gemini-3-pro-image-preview` | Highest fidelity (Nano Banana Pro) | standard set | `1K` `2K` `4K` |
1697
+ | `google/gemini-3.1-flash-image-preview` **(default)** | Fast iteration, reference editing, extreme aspect ratios (Nano Banana flash) | standard **+** `1:4` `4:1` `1:8` `8:1` | `0.5K`–`4K` |
1953
1698
  | `google/gemini-3.5-flash` | Fast; extreme aspect ratios | standard **+** `1:4` `4:1` `1:8` `8:1` | `0.5K`–`4K` |
1954
- | `google/gemini-3.1-flash-image-preview` | Same as 3.5 flash (preview) | extreme set | `0.5K`–`4K` |
1699
+ | `google/gemini-3-pro-image-preview` | Highest fidelity (Nano Banana Pro) — strong prompt adherence and reference grounding | standard set | `1K` `2K` `4K` |
1700
+ | `openai/gpt-5.4-image-2` | Photoreal + cleanest in-image text — ad/landing reproduction | standard set | `1K` `2K` `4K` |
1955
1701
  | `recraft/recraft-v4.1-pro-vector` | Vector/flat/SVG-style with palette control | standard set | `1K` `2K` `4K` |
1956
1702
 
1957
1703
  Standard aspect ratios: `1:1` `2:3` `3:2` `3:4` `4:3` `4:5` `5:4` `9:16` `16:9` `21:9`.
@@ -1960,7 +1706,7 @@ Standard aspect ratios: `1:1` `2:3` `3:2` `3:4` `4:3` `4:5` `5:4` `9:16` `16:9`
1960
1706
 
1961
1707
  | Flag | Description |
1962
1708
  |---|---|
1963
- | `--model` | Model id (default `openai/gpt-5.4-image-2`) |
1709
+ | `--model` | Model id (default `google/gemini-3.1-flash-image-preview`) |
1964
1710
  | `--aspect-ratio` | Output aspect ratio (default `1:1`) |
1965
1711
  | `--image-size` | Resolution: `1K` (default) `2K` `4K` (Gemini flash also `0.5K`) |
1966
1712
  | `--reference` | Comma-separated visual references, each either a **public image URL** (Pinterest / stock / library `imageUrl`) **or a local file path** (a sandbox image — brand logo, product shot, cropped photo, screenshot). Local files are downscaled (≤1536px) and inlined automatically — no manual upload. Applied in order; the biggest quality lever for photographed, on-brand output. Split is on `,`, so a URL containing a literal comma in its query string would be torn in two (rare for image CDNs — pass it alone if it occurs); a single `data:` URL is taken whole. |
@@ -2419,13 +2165,17 @@ baker testimonials tags
2419
2165
 
2420
2166
  ### Winning Ads (`baker winning-ads`)
2421
2167
 
2422
- Search the **ad-dna** corpus of scored "winning" competitor ads for reference creatives to reproduce (e.g. with `baker canvas`). Each result carries a presigned media URL (~1h TTL), the ad's DNA summary, and scores. The CLI authenticates with the normal `BAKER_API_KEY`; the Baker backend proxies the request to the ad-dna service with a server-held token — no extra credential in the sandbox.
2168
+ Search the **ad-dna** corpus of scored "winning" competitor ads for reference creatives to reproduce (e.g. with `baker canvas`), and manage the brands your library tracks (`follow` / `following` / `unfollow`). Each result carries a presigned media URL (~1h TTL), the ad's DNA summary, and scores. The CLI authenticates with the normal `BAKER_API_KEY`; the Baker backend proxies the request to the ad-dna service with a server-held token — no extra credential in the sandbox.
2169
+
2170
+ > The corpus has **Meta + LinkedIn** connectors, so `--platform` inputs are limited to `meta,linkedin`. (Older result rows may still carry a legacy platform string.)
2423
2171
 
2424
2172
  > Backend env: the Convex deployment must have `AD_DNA_API_TOKEN` set (`npx convex env set AD_DNA_API_TOKEN …`). `AD_DNA_API_URL` is optional and defaults to `https://ads.withbaker.com`.
2425
2173
 
2174
+ > Replaces the old `baker ads google library` tree, which has been removed. Competitor-by-keyword discovery still lives at `baker research advertisers`.
2175
+
2426
2176
  ### `baker winning-ads search <query>`
2427
2177
 
2428
- Semantic search (dense recall + BM25 + rerank). The CLI projects each result to a **lean, decision-focused** shape so the agent's context stays small — default fields: `advertiser`, `advertiser_id`, `platform`, `format`, `relevance`, `winner_score`, `summary` (what the ad is about), `media_url`; plus top-level `pool_size` and `match_confidence`. `--full` adds DNA detail (`angle`, `target_persona`, `hook_archetype`, `awareness_stage`, `industry`) + longevity (`days_active`, `reach`, `active`, `winner_category`, `media_kind`). `--output json` (default) returns the lean objects; `--output md` prints a table.
2178
+ Semantic search (dense recall + BM25 + rerank) → `POST /api/ad-library/winners/search`. The CLI projects each result to a **lean, decision-focused** shape so the agent's context stays small — default fields: `advertiser`, `advertiser_id`, `platform`, `format`, `relevance`, `winner_score`, `summary` (what the ad is about), `media_url`; plus top-level `pool_size`, `match_confidence` (`high|medium|low`), and `below_floor_count` (matches dropped under the relevance floor). `--full` adds DNA detail (`angle`, `target_persona`, `hook_archetype`, `awareness_stage`, `industry`) + longevity (`days_active`, `reach`, `active`, `winner_category`, `media_kind`). `--output json` (default) returns the lean objects; `--output md` prints a table.
2429
2179
 
2430
2180
  > `media_url` is the creative itself: for `static` it's the image, for `video` it's the video file. ad-dna stores **no separate poster** for videos, so a video result has only the video URL.
2431
2181
 
@@ -2447,7 +2197,7 @@ baker winning-ads search --ref-ad-id a_12345 --first-seen-after 2026-01-01T00:00
2447
2197
  | `--limit <n>` | Max results 1–100 (**default 10** — shortlist size) |
2448
2198
  | `--max-per-advertiser <n>` | Cap results per advertiser 1–50 (default 3) |
2449
2199
  | `--min-relevance <0-1>` | Relevance floor; trims weak matches |
2450
- | `--platform <list>` | One or many of `meta,tiktok,linkedin,google_search,google_display,youtube,reddit,x,pinterest,snapchat` — pass a single value to search **only** that platform |
2200
+ | `--platform <list>` | `meta,linkedin` — pass a single value to search **only** that platform |
2451
2201
  | `--format <list>` | `video,static,carousel` |
2452
2202
  | `--winner-category <list>` | `winner,scaled_winner,evergreen,rising,untested,dud,…` (default: all) |
2453
2203
  | `--awareness <list>` | `unaware,problem_aware,solution_aware,product_aware,most_aware` |
@@ -2461,20 +2211,21 @@ baker winning-ads search --ref-ad-id a_12345 --first-seen-after 2026-01-01T00:00
2461
2211
 
2462
2212
  Reading the scores: **`relevance`** (0–1) = match of the creative to your query; **`winner_score`** = how proven the ad is in-market. Pick references that are both relevant *and* proven.
2463
2213
 
2464
- ### `baker winning-ads hooks|mechanisms|patterns`
2214
+ ### `baker winning-ads hooks|mechanisms|formats`
2465
2215
 
2466
- Read the **winner-weighted rollup** of the ad-dna corpus — the hook / mechanism / static-format patterns that
2467
- actually win, ranked by `winner_score`, keyed on the closed seeds/enums the extractor emits (no clustering).
2468
- Ground a creative in a **proven** pattern for the exact segment instead of guessing.
2216
+ Read the **winner-weighted rollup** of the ad-dna corpus → `GET /api/ad-library/seed-catalog` — the hook /
2217
+ mechanism / static-format patterns that actually win, ranked by `winner_score`, keyed on the closed seeds/enums
2218
+ the extractor emits (no clustering). Ground a creative in a **proven** pattern for the exact segment instead of
2219
+ guessing. (Distinct from `winning-ads patterns`, which contrasts two ad cohorts.)
2469
2220
 
2470
2221
  ```bash
2471
2222
  # Top winning hook seeds for problem-aware SaaS on Meta:
2472
2223
  baker winning-ads hooks --platform meta --awareness problem_aware --industry saas --output md
2473
2224
  # Then narrow a reference search to that proven hook:
2474
2225
  baker winning-ads search "onboarding demo" --hook-archetype callout --output md
2475
- # Also: mechanisms (persuasion), patterns (static ad layouts):
2226
+ # Also: mechanisms (persuasion), formats (static ad layouts):
2476
2227
  baker winning-ads mechanisms --platform linkedin --output md
2477
- baker winning-ads patterns --awareness solution_aware --output md
2228
+ baker winning-ads formats --awareness solution_aware --output md
2478
2229
  ```
2479
2230
 
2480
2231
  | Flag | Purpose |
@@ -2490,13 +2241,66 @@ proven grand-slam pattern floats to the top and a measured dud is floored.
2490
2241
 
2491
2242
  ### `baker winning-ads advertisers <brand>`
2492
2243
 
2493
- Resolve a brand name → `advertiser_id`(s) in the corpus. Use it to find **your own** advertiser (to `--exclude-advertiser`) or a **competitor** (to `--advertiser-id`). Returns `advertiser_id`, `label`, `active_ads`, `total_ads`.
2244
+ List corpus brands by name or domain → `GET /api/ad-library/advertisers`. Use it to find **your own** advertiser (to `--exclude-advertiser`) or a **competitor** (to `--advertiser-id` / `winners`). Lean default fields: `advertiser_id`, `label`, `platform_count`, `family_count`, `active_ad_count`, `total_ad_count`; `--full` adds `scraped_name`, `image_url`, `platforms`, `total_reach`, `last_synced_at`. Supports `--platform meta|linkedin`, `--limit`, `--offset`.
2494
2245
 
2495
2246
  ```bash
2496
2247
  baker winning-ads advertisers "Acme" --output md # find our own advertiser id
2497
2248
  baker winning-ads advertisers "Deel" --platform meta --output md
2498
2249
  ```
2499
2250
 
2251
+ ### `baker winning-ads follow "<domain | profile URL | brand>" --platform meta|linkedin`
2252
+
2253
+ Add **all** of a brand's ads to your library — every platform (Meta + LinkedIn) and every country → `POST /api/ad-library/follow`. `--platform` is **required**, but it only tells us how to read your `input` (Facebook vs LinkedIn URL); it does **not** limit what we track. A bare domain is best: we resolve both the Meta page and the LinkedIn company from it and track both. Discovery is never region-scoped. The result `status` is one of:
2254
+
2255
+ - `following` — the brand is already in the corpus; you're now subscribed (no wait).
2256
+ - `added` — a new brand was queued for ingestion; its ads appear as discovery completes (a `hints[]` note flags this).
2257
+ - `ambiguous` — the input mapped to multiple brands; pick one from `candidates` and re-run with a more specific domain/URL.
2258
+
2259
+ ```bash
2260
+ baker winning-ads follow "deel.com" --platform meta
2261
+ baker winning-ads follow "https://www.linkedin.com/company/acme" --platform linkedin --label "Acme (competitor)"
2262
+ ```
2263
+
2264
+ ### `baker winning-ads following`
2265
+
2266
+ List the brands you follow → `GET /api/ad-library/following`. Each row shows `status` (`ready` vs `adding…`) plus cached counts (`active_ad_count`, `total_ad_count`, `family_count`) and discovery progress (`adding_discovered`, `adding_enqueued`). `--full` adds `image_url` + `platforms`.
2267
+
2268
+ ```bash
2269
+ baker winning-ads following --output md
2270
+ ```
2271
+
2272
+ ### `baker winning-ads winners <advertiser>`
2273
+
2274
+ Top winning ads for one advertiser id → `GET /api/ad-library/advertiser-winners`. Same lean winner cards as `search` (add `--full` for DNA + longevity). Supports `--top N` and `--platform meta|linkedin`.
2275
+
2276
+ ```bash
2277
+ baker winning-ads winners adv_123 --top 15 --output md
2278
+ ```
2279
+
2280
+ ### `baker winning-ads unfollow <advertiser>`
2281
+
2282
+ Stop following a brand by advertiser id → `POST /api/ad-library/unfollow`. Returns `{ removed }`.
2283
+
2284
+ ```bash
2285
+ baker winning-ads unfollow adv_123
2286
+ ```
2287
+
2288
+ ### `baker winning-ads brief`
2289
+
2290
+ Generate a creative brief grounded in strategically-similar winners → `POST /api/ad-library/brief`. Optionally describe the target creative with `--dna` (a JSON object), steer with `--notes`, and cap references with `--k`. Returns `brief_markdown` + `reference_ad_ids`.
2291
+
2292
+ ```bash
2293
+ baker winning-ads brief --dna '{"angle":"cost savings","awareness_stage":"solution_aware"}' --notes "B2B, LinkedIn video" --k 8
2294
+ ```
2295
+
2296
+ ### `baker winning-ads patterns --winners <adIds> --duds <adIds>`
2297
+
2298
+ Mine what separates two cohorts of ads → `POST /api/ad-library/patterns`. Pass a comma-list of winning ad ids (`--winners`, cohort A) and weaker/dud ad ids (`--duds`, cohort B), plus optional `--top-n`. Returns each discriminating DNA field with the cohort it `favors` (`winners`/`duds`), a `score`, and the top values on each side.
2299
+
2300
+ ```bash
2301
+ baker winning-ads patterns --winners a_1,a_2,a_3 --duds a_9,a_8 --top-n 10 --output md
2302
+ ```
2303
+
2500
2304
  ---
2501
2305
 
2502
2306
  ### Scheduled Actions (`baker scheduled-actions`)
@@ -2754,6 +2558,16 @@ baker canvas run my-canvas.json --regenerate gen_4x5,gen_9x16
2754
2558
  # (e.g. "regenerate": 2) and re-run; the fresh render is reproducible in any
2755
2559
  # later session. Bump it again (3, 4, …) for each additional draw.
2756
2560
 
2561
+ # 2e. Push a creative's current node-graph + wired input thumbnails to the
2562
+ # dashboard WITHOUT rendering — so its workflow view reflects an edit (a
2563
+ # swapped logo/hero, a rewired ref, a prompt tweak) the moment you save,
2564
+ # with no credits spent. Runs automatically on every canvas.json/prompt.json
2565
+ # edit (a file watcher), but you can invoke it directly. Accepts the
2566
+ # .canvas.json, its prompt.json, or the creative folder. The rendered image
2567
+ # still only changes on a real `run`, so the dashboard shows an "edited since
2568
+ # last render" badge in the meantime.
2569
+ baker canvas sync-definition src/creatives/<slug>/
2570
+
2757
2571
  # 3. Inspect a finished run (per-node timing, file list, optional video thumbs)
2758
2572
  baker canvas inspect <run_id>
2759
2573
 
@@ -4057,16 +3871,18 @@ Validate, then execute the graph. Blocks until done. Logs one line per node. Ret
4057
3871
 
4058
3872
  Turn a reference video into a **runnable, self-validated reproduction canvas** in one command — the video counterpart of `scaffold-static-ad`. The `<video>` positional is a **local path OR an http(s) URL** (a `baker winning-ads` `media_url`, a library URL, any reel link) — a URL is downloaded for you (no manual `curl` first), so pass `--slug`/`--out` with it to give the canvas a home. It runs **billed passes** up front:
4059
3873
 
4060
- 1. **`video_deconstruct`** (`~google/gemini-pro-latest`, full mode) — reverse-engineers the video into a scene-by-scene blueprint + word-level transcript, written next to the canvas as **`prompt.json`** (the human-editable source of truth). Each scene's `start_frame_prompt`/`end_frame_prompt` are inlined into the frame nodes (see below); the shared **global style reference** every frame reads via `target_blueprint` is a **slim projection** written alongside as **`prompt.style.json`** (`global` cast/palette/brand + `reference_elements` only, no per-scene array). A 33-scene blueprint is ~200 KB — inlining it into every one of a dozen frame prompts was pure waste and let a frame blend in another scene's content; the slim is ~5 KB. `prompt.style.json` is a **derived file**: `baker canvas validate` and `run` regenerate it from `prompt.json` whenever they diverge (reported as `style_projection` in validate's output), so editing `prompt.json` is all it takes — global cast/palette/brand edits reach every frame on the next validate/run, and the affected frames re-bill. Never edit the derived file by hand. Per-scene `scene_setting`/`ambient` deltas are baked into that scene's own frame prompts as a `SCENE STYLE` block.
3874
+ 1. **`video_deconstruct`** (`~google/gemini-pro-latest`, full mode) — reverse-engineers the video into a scene-by-scene blueprint + word-level transcript. The blueprint is **split into small, single-purpose files** so you edit ONE at a time instead of a 2000-line monolith: **`prompt.json`** holds only the **global** slice (cast, palette, brand, music, overall copy), and each scene is written to its own **`scenes/sNN.json`** (`s00.json`, `s01.json`, … in scene order). To change a scene's dialogue / action / frame prompts / overlays, edit that one scene file; to change global cast/palette/brand, edit `prompt.json`. `baker canvas validate` and `run` re-assemble the full blueprint and **re-flow every edited scene back into the graph before billing** (reported as `scene_projection` in validate's output) — so scene edits reach the render automatically, and only the changed scenes re-bill. A frozen **`prompt.rebuild.json`** holds the rebuild context that makes this re-flow deterministic and model-free; never hand-edit it. The shared **global style reference** every frame reads via `target_blueprint` is a **slim projection** written alongside as **`prompt.style.json`** (`global` cast/palette/brand + `reference_elements` only, no per-scene array). `prompt.style.json` is a **derived file**: `validate`/`run` regenerate it from `prompt.json` whenever they diverge (reported as `style_projection`), so global cast/palette/brand edits reach every frame on the next validate/run and the affected frames re-bill. Never edit the derived files (`prompt.style.json`, `prompt.rebuild.json`) or the inlined node prompts by hand — they are regenerated. Per-scene `scene_setting`/`ambient` deltas are baked into that scene's own frame prompts as a `SCENE STYLE` block.
3875
+
3876
+ **Structural edits still need a re-scaffold.** The per-scene re-flow covers rewriting a scene's *content*. Changing scene *timing*, adding/removing/re-ordering scenes, or flipping a scene's shot type is a structural change the graph's timing/spine nodes don't track from a prompt edit — `validate`/`run` print a drift warning telling you to re-run `baker canvas scaffold-video`.
4061
3877
  2. **recurring-element selection** (`~google/gemini-flash-latest`) — picks only the **recurring, identity-critical** elements (each `global.cast` person, a recurring animal, a showcased product, the brand logo) and the scene indices each appears in. One real reference image grounds each element across **every** frame it appears in, so the same actor stays consistent the whole video. This selection runs as a **second pass over a slimmed blueprint** (cast/branding + each scene's frame prompts only) — a long ad's full blueprint can exceed the engine's inline-prompt limit, so the heavy per-scene detail (dialogue, overlays, transcript) the selector never reads is dropped before the prompt.
4062
3878
 
4063
3879
  Before the deconstruct it runs a **local shot-cut pass** on the source file with **[PySceneDetect](https://www.scenedetect.com)** (`scenedetect` CLI, `detect-content` — the battle-tested HSV content detector, installed in the canvas sandbox) and passes the cut timestamps as `video_deconstruct`'s `shot_cuts`. The deconstruct snaps its scene boundaries onto those real cuts and **splits any scene that spans one**, so a scene's frames can never straddle a hard cut (the failure where a scene's start frame was the couch and its end frame the b-roll). Two knobs tuned for fast social ads: the content **threshold defaults to 18** (PySceneDetect's own default of 27 misses soft reframes) and the **minimum scene length is dropped to 0.25s** (its default ~0.6s merges away rapid montage flashes) — so super-fast cuts survive and become cheap still-holds downstream. The threshold is **adaptive**: if the first pass looks like a continuous shot shredded into many close micro-cuts (a talking-head selfie's natural motion), it re-runs at PySceneDetect's own default of 27 and **merges the two passes** — the high-threshold set is the base, and the low pass's *isolated* extras (real soft blur-morph transitions that vanish at 27) are added back while clustered extras (motion shred) stay dropped. Pinning **`--shot-threshold N`** disables the re-check (lower = more cuts). The backend snap window is likewise **adaptive** (up to 1s onto an unambiguous nearest cut, shrinking around dense cut pairs so a boundary never jumps past the wrong cut), any scene spanning an interior cut is split, and the residual-sliver coalesce is **cut-aware**: a drift sliver folds backward across its non-cut edge and never re-merges across a real cut. If `scenedetect` is unavailable it warns loudly and degrades to LLM-only boundaries.
4064
3880
 
4065
3881
  A shot longer than the video model's per-clip ceiling (Seedance's 15s, passed as `video_deconstruct`'s `max_clip_s`) is split into equal **continuation sub-scenes** that share their splice boundary exactly — so a long shot is reproduced in **full** (no truncation) and joins seamlessly. Each sub-scene carries `continues_previous`.
4066
3882
 
4067
- It then scaffolds the full pipeline like an **editing timeline**: each clip gets a **static-ad-grade start AND end keyframe** (`image_generate`, each with its **own self-contained `params.prompt`** — edit a frame node to change only that frame; the slim `prompt.style.json` wired as the **shared `target_blueprint`** style reference, plus a per-element reference legend). Each keyframe is **fully recast** to the dropped `el_*` reference images. The original extracted frame is kept LAST as a **pure composition anchor** (framing / camera angle / shot size / pose) whenever identity is safely locked — i.e. a frame with no person/animal, OR every cast member present is **sheet-backed** (a multi-view turnaround owns identity, so the anchor can reproduce the source's framing without dictating the face). Since every base element is now sheet-backed by default, cast frames keep their framing anchor too — this is what reproduces the source's composition (a side-profile stays a side-profile, the camera angle holds scene to scene) instead of drifting to a fresh guess. The anchor's legend forbids taking identity/text/palette from it. It is dropped only when a cast member rests on a weak lone-snapshot reference (e.g. a `same_as` second-look slot), where the original frame could re-leak the source actor. Both keyframes feed `video_generate` (`first_frame`+`last_frame`, so Seedance interpolates real in-shot motion; ultra-detailed motion brief; duration snapped to the nearest allowed clip length). Every keyframe grounds **only on its own extracted frame + `el_*` slots** — no reference to any other generated frame — so all images render **in parallel** (no cascade). Source-frame URLs are **deduped** (each ingested once). `--frames reuse` wires the real source frame straight in.
3883
+ It then scaffolds the full pipeline like an **editing timeline**: each clip gets a **static-ad-grade start AND end keyframe** (`image_generate`, each with its **own `params.prompt`** regenerated from that scene's `scenes/sNN.json` — edit the scene file to change that frame, never the node prompt by hand; the slim `prompt.style.json` wired as the **shared `target_blueprint`** style reference, plus a per-element reference legend). Each keyframe is **fully recast** to the dropped `el_*` reference images. The original extracted frame is kept LAST as a **pure composition anchor** (framing / camera angle / shot size / pose) whenever identity is safely locked — i.e. a frame with no person/animal, OR every cast member present is **sheet-backed** (a multi-view turnaround owns identity, so the anchor can reproduce the source's framing without dictating the face). Since every base element is now sheet-backed by default, cast frames keep their framing anchor too — this is what reproduces the source's composition (a side-profile stays a side-profile, the camera angle holds scene to scene) instead of drifting to a fresh guess. The anchor's legend forbids taking identity/text/palette from it. It is dropped only when a cast member rests on a weak lone-snapshot reference (e.g. a `same_as` second-look slot), where the original frame could re-leak the source actor. Both keyframes feed `video_generate` (`first_frame`+`last_frame`, so Seedance interpolates real in-shot motion; ultra-detailed motion brief; duration snapped to the nearest allowed clip length). Every keyframe grounds **only on its own extracted frame + `el_*` slots** — no reference to any other generated frame — so all images render **in parallel** (no cascade). Source-frame URLs are **deduped** (each ingested once). `--frames reuse` wires the real source frame straight in.
4068
3884
 
4069
- **Composited scenes (split-screen / picture-in-picture / keyed presenter).** Real ads aren't always one full-frame shot — a frame can be **persistently divided** (b-roll on top, a presenter talking on the bottom) or **layer a presenter** over background footage (boxed in a corner, or green-screen keyed). The deconstruct now reports this per scene as `scene.composition` (`layout: split_screen | pip | keyed_overlay`, with one `region` per stream — each its own clean-plate frame + motion brief, the talking-head region flagged `is_presenter`). The scaffold reproduces a composited scene by building **one clip per region** (`s<i>_r0_*`, `s<i>_r1_*`, …) and compositing them with ffmpeg: a split-screen `vstack`/`hstack` (stack direction read from the region **panels**, so a top/bottom split always stacks vertically), or a picture-in-picture `overlay` of the presenter inset at its corner. A **keyed** presenter is first cut to transparency by `video_background_remove` (`s<i>_key`), then overlaid. The presenter region carries the native lip-synced voice; b-roll/render panels stay silent. To change a layout, edit `composition` in `prompt.json` and re-scaffold, or hand-edit the `s<i>_composite` ffmpeg args. Plain full-frame scenes (the default) are unaffected.
3885
+ **Composited scenes (split-screen / picture-in-picture / keyed presenter).** Real ads aren't always one full-frame shot — a frame can be **persistently divided** (b-roll on top, a presenter talking on the bottom) or **layer a presenter** over background footage (boxed in a corner, or green-screen keyed). The deconstruct now reports this per scene as `scene.composition` (`layout: split_screen | pip | keyed_overlay`, with one `region` per stream — each its own clean-plate frame + motion brief, the talking-head region flagged `is_presenter`). The scaffold reproduces a composited scene by building **one clip per region** (`s<i>_r0_*`, `s<i>_r1_*`, …) and compositing them with ffmpeg: a split-screen `vstack`/`hstack` (stack direction read from the region **panels**, so a top/bottom split always stacks vertically), or a picture-in-picture `overlay` of the presenter inset at its corner. A **keyed** presenter is first cut to transparency by `video_background_remove` (`s<i>_key`), then overlaid. The presenter region carries the native lip-synced voice; b-roll/render panels stay silent. To change a layout, edit `composition` in that scene's `scenes/sNN.json` and re-scaffold, or hand-edit the `s<i>_composite` ffmpeg args. Plain full-frame scenes (the default) are unaffected.
4070
3886
 
4071
3887
  **Typed region kinds & real screen surfaces.** Each composition region now carries a `kind` — `camera` (filmed footage, re-generated), `screen_capture` (app/site/document screen recording), `static_graphic` (designed text/graphic panel), or `generated` (3D/motion graphics) — plus an optional `nested` list for video-in-video (a Loom-style camera bubble inside a screen share). `kind` is authoritative for routing (prose keywords remain the fallback for older blueprints): `screen_capture`/`static_graphic` regions are **never generated by the video model** — the scene renders as a clean background plate (its clip prompt is scrubbed of all screen narration and forbids rendering UI) and the real surface is composited on the overlay layer. The route is decided **once per persistent layout run** (consecutive scenes sharing one composition signature), so a layout that runs unbroken across many scenes can't flip between pipelines on wording differences. A persistent surface seeds **ONE grouped stub** in `video-overlay-composition/index.html` spanning its whole window, with a per-scene **state timeline** — build one continuous screen recording/mockup, not one screenshot per scene. A `screen_capture` region also carries `surface_id`: a source video routinely **splices two unrelated screen recordings** under one persistent layout (a live app-processing capture, then an unrelated pre-made demo note) — the deconstruct assigns a stable id while the SAME recording continues and a new one when the on-screen content genuinely changes, so the run splits into **separate stubs** at the splice instead of asking for one screenshot that can't cover both. Full-frame screen scenes reuse the same `surface_id`: consecutive full-frame UI beats of one screen (e.g. a 3-scene import flow) share **ONE `s<i>_screen_ref` ingest** — the operator supplies that screenshot once instead of dropping the same capture into a dozen identical `[TODO]`s (distinct surfaces stay distinct). `baker canvas validate` additionally warns (`VIDEO_UI_IN_PROMPT`) if any clip prompt still narrates a screen surface, and (`VIDEO_BRANDMARK_IN_PROMPT`) if a generate prompt asks the model to paint a brand logo/wordmark (generation garbles marks; source the real one with `baker images logo` and composite it on the overlay layer).
4072
3888
 
@@ -4078,9 +3894,9 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
4078
3894
 
4079
3895
  **True output aspect, even when the video model can't generate it.** The scaffold resolves TWO ratios: the ad's **output** ratio (the source video's detected `aspect_ratio`, or **`--aspect`**, e.g. `4:5` → a 1080×1350 canvas) and the **generation** ratio — the nearest ratio the video model supports (Seedance has no 4:5, so clips + conditioning keyframes generate at `3:4`). When they differ the scaffold warns loudly, records `metadata.todo.aspect_remap`, normalizes **every clip** onto the output canvas (scale-to-fill, center-crop — keep subjects centered), sizes every ffmpeg plate/still/composite to the output dims, and stamps the copied overlay/caption compositions' `meta.json` to match. Nothing is silently dropped: an unknown ratio snaps to the nearest known one and stays explicit on every node.
4080
3896
 
4081
- **Montage flashes held as stills — unless the picture really moves.** A rapid-cut beat shorter than ~2s with no spoken line is a **flash** — Seedance's shortest clip is 4s, so generating one (then trimming away most of it) burns credits for motion no viewer perceives. The scaffold instead **holds one keyframe as a still** for the scene length (a cheap ffmpeg loop, no billed `video_generate`), same look at a fraction of the cost. The deconstruct now stamps each scene's **`motion_level`** (`static` / `subtle` / `dynamic`): a **dynamic** flash (pouring chocolate, hands working, walking) keeps a **real trimmed clip** — freezing a moving montage turns it into a slideshow — while genuinely static beats (a logo card, a pinned photo, a product still) keep the cheap hold. Talking/ambient beats always keep a real clip (they need motion + native audio). The deconstruct also stamps each dialogue line's **`on_camera`** flag — a voice playing over b-roll, a graphic, or a mere *photo* of the speaker stays voiceover, so the scaffold never lip-syncs a scene with no speaking face (the polaroid close-up failure).
3897
+ **Montage flashes held as stills — unless the picture really moves.** A rapid-cut beat shorter than ~2s with no spoken line is a **flash** — Seedance's shortest clip is 4s, so generating one (then trimming away most of it) burns credits for motion no viewer perceives. The scaffold instead **holds one keyframe as a still** for the scene length (looped directly on the spine's input — no billed `video_generate`, no extra node), same look at a fraction of the cost. A beat the deconstruct **explicitly** stamps `static`/`subtle` holds a still up to **~3.5s** (just under the Seedance 4s floor); an unstamped beat keeps the conservative 2s ceiling. The deconstruct now stamps each scene's **`motion_level`** (`static` / `subtle` / `dynamic`): a **dynamic** flash (pouring chocolate, hands working, walking) keeps a **real trimmed clip** — freezing a moving montage turns it into a slideshow — while genuinely static beats (a logo card, a pinned photo, a product still) keep the cheap hold. Talking/ambient beats always keep a real clip (they need motion + native audio). The deconstruct also stamps each dialogue line's **`on_camera`** flag — a voice playing over b-roll, a graphic, or a mere *photo* of the speaker stays voiceover, so the scaffold never lip-syncs a scene with no speaking face (the polaroid close-up failure).
4082
3898
 
4083
- **One clip per shot — separated at complete breaks.** A video is a sequence of clear **shots** with **complete breaks** (hard cuts) between them, and that is what the scaffold separates by: **two adjacent presenter shots at a hard cut become TWO clips**, never glued into one invented take just because the speech runs continuously across the cut. Each presenter shot is one Seedance clip (`s<anchor>_clip`, native lip-sync + audio) re-voiced to the brand voice. What is **NOT** split: a **voiceover** narration stays ONE ElevenLabs `tts` read across the b-roll it plays over, and a **b-roll cutaway** between two on-camera moments leaves the presenter shot continuous — the shown scenes aren't adjacent (the insert sits between them), so the clip covers both on-camera windows (sliced as `s<i>_seg`, an ffmpeg `-ss`/`-t` cut — video+audio from the *same* clip so lip-sync holds) while the cutaway plays its own silent clip over the continuing voice. "Shown" is decided by the **presenter element's per-scene presence**, not just who's speaking — a scene where a cast member narrates over b-roll (their element absent) is a cutaway, so the talking head never appears where the original cut away. A single shot longer than the **gateway-safe ~10s clip ceiling** (Seedance's *API* max is 15s, but the gateway often times out — **HTTP 524** — past ~10s) **splits into contiguous takes joined by a shared boundary frame**; the spine then **seam-dedups** that duplicated frame so the concat doesn't freeze on it (`--seam-dedup head|tail|off`, default `head` = drop the second clip's first frame), and clone-pads one frame back so the drop never shortens the picture against the absolute-timed audio. Timbre stays consistent across all the separate shot clips because every clip's native audio is re-voiced in **one merged per-speaker pass** (not per clip). A b-roll cutaway *inside* a phrase lands at an **approximate** time (Seedance exposes no word timing) — nudge the scene boundary if it's off its beat.
3899
+ **One clip per shot — separated at complete breaks.** A video is a sequence of clear **shots** with **complete breaks** (hard cuts) between them, and that is what the scaffold separates by: **two adjacent presenter shots at a hard cut become TWO clips**, never glued into one invented take just because the speech runs continuously across the cut. Each presenter shot is one Seedance clip (`s<anchor>_clip`, native lip-sync + audio) re-voiced to the brand voice. What is **NOT** split: a **voiceover** narration stays ONE ElevenLabs `tts` read across the b-roll it plays over, and a **b-roll cutaway** between two on-camera moments leaves the presenter shot continuous — the shown scenes aren't adjacent (the insert sits between them), so the clip covers both on-camera windows (each on-camera window cut out of the clip inside the spine's per-input `trim=` chain — video+audio from the *same* clip so lip-sync holds, no extra nodes) while the cutaway plays its own silent clip over the continuing voice. "Shown" is decided by the **presenter element's per-scene presence**, not just who's speaking — a scene where a cast member narrates over b-roll (their element absent) is a cutaway, so the talking head never appears where the original cut away. A single shot longer than the **gateway-safe ~10s clip ceiling** (Seedance's *API* max is 15s, but the gateway often times out — **HTTP 524** — past ~10s) **splits into contiguous takes joined by a shared boundary frame**; the spine then **seam-dedups** that duplicated frame so the concat doesn't freeze on it (`--seam-dedup head|tail|off`, default `head` = drop the second clip's first frame), and clone-pads one frame back so the drop never shortens the picture against the absolute-timed audio. Timbre stays consistent across all the separate shot clips because every clip's native audio is re-voiced in **one merged per-speaker pass** (not per clip). A b-roll cutaway *inside* a phrase lands at an **approximate** time (Seedance exposes no word timing) — nudge the scene boundary if it's off its beat.
4084
3900
 
4085
3901
  **A starting point, not a locked render.** The canvas mirrors the reference's structure to give you a faithful scaffold, but `metadata.todo.full_flexibility` makes explicit that the agent has **full editing freedom**: add / delete / reorder / split / merge scenes, re-prompt any frame or motion brief, change a scene's layout (full-frame ↔ composite), or rewrite any line — the content-addressed cache re-bills only what changes, and `baker canvas validate` re-checks timing/lip-sync after any edit.
4086
3902
 
@@ -4092,7 +3908,7 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
4092
3908
 
4093
3909
  **Timing-faithful clip + extract (no overlap).** Each phrase clip is generated to its **coverage window** (the deconstruct's real scene/line timing, capped at the gateway-safe ~10s ceiling) and its converted voice is extracted to the **spoken window** (pause to pause) — *not* padded to a word-count estimate. Padding past the window was what ran the voice the clip's whole length and overlapped the next phrase; trusting the deconstruct's timing keeps consecutive phrases back-to-back and lets Seedance pace the quoted text to fit. `metadata.video.talking_scenes` records each phrase's `scene_s` vs `est_speech_s`; on top of that the scaffold flags any scene whose estimated speech overruns its window by more than ~1.3× as **`metadata.todo.overstuffed_scenes`** (also in the stdout checklist) — a loud advisory to shorten the copy or lengthen the scene before rendering, since an over-stuffed line pushes the picture off the audio timeline. It similarly flags **`oversize_scenes`** — a single scene whose own footage exceeds the gateway-safe ~10s clip ceiling (a b-roll shot or one-shot monologue). The phrase splitter only breaks at scene boundaries, so it can't shrink a single over-long scene; its clip would 524 at the gateway, so the advisory tells you to split that scene into two before rendering.
4094
3910
 
4095
- **Timeline-accurate picture — ONE clock.** Seedance can't render under 4s, so each clip is generated at the smallest allowed duration ≥ the scene length and then **trimmed back to the exact scene duration** before concat. The spine is the ONE clock: every voice, SFX, music, and overlay placement is mapped onto where each scene's picture actually sits in the butted concat (not the reference video's own timestamps, which can carry dead air the picture doesn't reproduce), the audio mix's `total_ms` is pinned to the spine length, and every concat input is normalized to one raster clock (`yuv420p`, 30fps, square pixels, shared timebase) so a generated clip's unadvertised frame rate can never stretch the picture off the audio. The contract is stamped as `metadata.video.timeline` and `baker canvas validate` re-proves it from the live node params after any hand edit. Frames are also prompted as **clean text-free plates** (no baked captions/lower-thirds/tickers/logos-as-text) so the overlay layer is the single source of on-screen text.
3911
+ **Timeline-accurate picture — ONE clock.** Seedance can't render under 4s, so each clip is generated at the smallest allowed duration ≥ the scene length and **cut back to the exact scene duration inside the spine's own per-input filter chain** (no standalone trim nodes — one fewer re-encode per scene, and the graph stays a third smaller). Real screenshots/graphics loop fit-padded, generated stills loop fill-cropped, on the same chains. The spine is the ONE clock: every voice, SFX, music, and overlay placement is mapped onto where each scene's picture actually sits in the butted concat (not the reference video's own timestamps, which can carry dead air the picture doesn't reproduce), the audio mix's `total_ms` is pinned to the spine length, and every concat input is normalized to one raster clock (`yuv420p`, 30fps, square pixels, shared timebase) so a generated clip's unadvertised frame rate can never stretch the picture off the audio. The contract is stamped as `metadata.video.timeline` and `baker canvas validate` re-proves it from the live node params after any hand edit. Frames are also prompted as **clean text-free plates** (no baked captions/lower-thirds/tickers/logos-as-text) so the overlay layer is the single source of on-screen text.
4096
3912
 
4097
3913
  **Scene transitions.** When the deconstruct flags a boundary as `fade`/`whip`/`zoom`/`dissolve`/`swipe` (`scene.transition_out`), the spine reproduces it as an ffmpeg **`xfade`** instead of a hard cut; plain `cut`/`match_cut` stay hard cuts. The overlap is consumed from **extra generated footage** (each transitioning clip is trimmed to `scene_s + transition` and the xfade `offset` lands on the cumulative scene start), so the total length still equals the sum of the scene lengths — the picture stays exactly on the audio timeline.
4098
3914
 
@@ -4104,11 +3920,11 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
4104
3920
 
4105
3921
  **The inspiration video is preserved.** Like `scaffold-static-ad` keeps its reference image, the video command now auto-writes a **`_definition.md`** (so the creative joins the `creatives` collection) recording the source it was built from: `sourceKind: video`, `sourceAdvertiser` (the brand the deconstruct identified, or `--advertiser`), `platform` (`--platform`, default `meta`), and **`sourceReferenceUrl`** — the **durable, content-addressed R2 URL** the deconstruct already uploaded the source to (`prompt.json`'s `source.url`), which the dashboard's Inspiration card plays inline. Unlike the static flow it does **not** commit the video into `references/`: a reference clip can be up to 2 GiB and the video canvas never re-ingests the source at run time (it uses the extracted frame URLs), so a git copy would be pure bloat — the durable R2 URL is the reference. The `_definition.md` is preserved on re-scaffold, and the same `sourceReferenceUrl` is synced to the backend so the creative shows "built from this ad."
4106
3922
 
4107
- The emitted canvas is validated (`validateCanvasDeep`) before it's written, so it always runs. It also carries a **`metadata.video`** timing plan that `baker canvas validate` proves **statically, before any billed render**: no two voiceover turns overlap, the audio length ≈ the video length, every single-on-camera-speaker scene is a native talking head (its clip carries `generate_audio` and is wired to an `audio_voice_convert` node), **no re-crafted line physically overruns its clip** (`VIDEO_SPEECH_OVERRUN`) **or its extract window** (`VIDEO_SPEECH_EXCEEDS_EXTRACT` — the spoken window is the real audio budget; a 3s line in a 1.2s window passes the clip check and then gets cut on the spine), and **every clip agrees on one aspect ratio** (`VIDEO_ASPECT_MISMATCH`). The one-clock contract is proven from the LIVE graph after any hand edit: `VIDEO_TIMELINE_TOTAL_MISMATCH` (picture vs pinned audio length — what `-shortest` would silently truncate), `VIDEO_NATIVE_SEG_OVERLAP` (two same-speaker voice windows playing at once — echo), plus advisories `VIDEO_SPINE_UNNORMALIZED` (bare concat inputs), `VIDEO_ODD_DIMENSIONS` (libx264-fatal odd sizes), `VIDEO_REGION_DROPPED` (billed region clips a composite never consumes), `VIDEO_OVERLAY_OUT_OF_BOUNDS` (an overlay window past the video end), and `VIDEO_PROMPT_PROFILE_MISSING` (a video model with no clip-prompt profile — prompts are authored per model family now: Seedance's [brackets] delivery cues vs Veo's prose + no-subtitles rule). When a **photoreal on-camera cast** generates on **Seedance**, the checklist carries a **`content_policy_risk`** note: ByteDance's real-person-likeness filter can reject a photoreal AI face with a **non-retryable 422** (`content_policy_blocked`) that **no prompt reframe clears** — the escapes are regenerating on Veo (`--video-model google/veo-3.1-fast`) or a less-photoreal frame. Surfaced before the billed run so a face-heavy ad isn't discovered broken mid-render. The full editable checklist is embedded as **`metadata.todo`** (with a step-by-step guide in `metadata.description`). The checklist also carries a **`prompt_discipline`** note steering the edit toward the six-decision prompt structure (Route / Spec / Beats / Copy / Technique / Negatives) and on-brand **motion** — easing, transition (cut vs fade), pacing, and accent pulled from `BRAND.md` § Brand in Motion so the overlays move like the client's brand, not the reference's. stdout returns `{ ok, canvas_path, prompt_path, models, stats, checklist }`.
3923
+ The emitted canvas is validated (`validateCanvasDeep`) before it's written, so it always runs. It also carries a **`metadata.video`** timing plan that `baker canvas validate` proves **statically, before any billed render**: no two voiceover turns overlap, the audio length ≈ the video length, every single-on-camera-speaker scene is a native talking head (its clip carries `generate_audio` and is wired to an `audio_voice_convert` node), **no re-crafted line physically overruns its clip** (`VIDEO_SPEECH_OVERRUN`) **or its extract window** (`VIDEO_SPEECH_EXCEEDS_EXTRACT` — the spoken window is the real audio budget; a 3s line in a 1.2s window passes the clip check and then gets cut on the spine), and **every clip agrees on one aspect ratio** (`VIDEO_ASPECT_MISMATCH`). The one-clock contract is proven from the LIVE graph after any hand edit: `VIDEO_TIMELINE_TOTAL_MISMATCH` (picture vs pinned audio length — what `-shortest` would silently truncate), `VIDEO_NATIVE_SEG_OVERLAP` (two same-speaker voice windows playing at once — echo), plus advisories `VIDEO_SPINE_UNNORMALIZED` (bare concat inputs), `VIDEO_ODD_DIMENSIONS` (libx264-fatal odd sizes), `VIDEO_REGION_DROPPED` (billed region clips a composite never consumes), `VIDEO_OVERLAY_OUT_OF_BOUNDS` (an overlay window past the video end), and `VIDEO_PROMPT_PROFILE_MISSING` (a video model with no clip-prompt profile — prompts are authored per model family now: Seedance's [brackets] delivery cues vs Veo's prose + no-subtitles rule). When a **photoreal on-camera cast** generates on **Seedance**, the checklist carries a **`content_policy_risk`** note: ByteDance's real-person-likeness filter can reject a photoreal AI face with a **non-retryable 422** (`content_policy_blocked`) that **no prompt reframe clears** — the escapes are regenerating on Veo (`--video-model google/veo-3.1-fast`) or a less-photoreal frame. Surfaced before the billed run so a face-heavy ad isn't discovered broken mid-render. The full editable checklist is embedded as **`metadata.todo`** (with a step-by-step guide in `metadata.description`). The checklist also carries a **`prompt_discipline`** note steering the edit toward the six-decision prompt structure (Route / Spec / Beats / Copy / Technique / Negatives) and on-brand **motion** — easing, transition (cut vs fade), pacing, and accent pulled from `BRAND.md` § Brand in Motion so the overlays move like the client's brand, not the reference's. A short (≤4s) b-roll take also skips its **end keyframe** (one billed image instead of two — over so short a span the second keyframe adds no motion guidance), unless the next scene continues the take and needs the shared splice frame. The canvas stamps **`metadata.video.graph_stats`** (node totals + histogram + one route per scene: `clip` / `still_hold` / `screen_still` / `graphic_plate` / `brand_card` / `phrase_slice` / `phrase_run` / `composite`) — the first thing to read when you wonder why the graph has a node — mirrored in stdout under `stats.graph`. `baker canvas validate` additionally warns `ORPHAN_NODE` on any non-ingest node unreachable from the output (it would execute — and bill — for nothing). stdout returns `{ ok, canvas_path, prompt_path, models, stats, checklist }`.
4108
3924
 
4109
3925
  ```bash
4110
3926
  baker canvas scaffold-video ./reference-ad.mp4 --focus "competitor UGC ad for <brand>"
4111
- # → writes ./reference-ad.video.canvas.json + ./prompt.json (+ video-overlay-composition/)
3927
+ # → writes ./reference-ad.video.canvas.json + ./prompt.json (global) + ./scenes/sNN.json (per scene) + ./prompt.rebuild.json (+ video-overlay-composition/)
4112
3928
  # edit each frame's own params.prompt + drop ONE real source image at each el_* [TODO], confirm voices, then:
4113
3929
  baker canvas validate ./reference-ad.video.canvas.json
4114
3930
  baker canvas run ./reference-ad.video.canvas.json
@@ -4140,7 +3956,7 @@ roster and prints the choice as `models.video_route_reason` in the run report (m
4140
3956
  shifts a Veo pick to the fast tier. The scaffolder also bakes **per-intent param recipes** so the hero/reveal
4141
3957
  beat claims the 1080p cine ceiling automatically and a Kling hook beat maxes prompt adherence.
4142
3958
 
4143
- Each scene is captured in a **shoot mode** — `ugc_selfie` (talking heads, the default look), `ugc_broll`, `studio_product` (pack shot), `lifestyle_cinematic`, or `screen_ui`. The scaffold derives one per scene (UGC by default; the cinematic and screen lanes are opt-in) and bakes its capture block into the frame and a camera default into the clip; override per scene with a `shoot_mode` field in `prompt.json`. Capture aesthetic + depth-of-field follow the mode (UGC stays flat; studio/lifestyle allow shallow DoF). Clips also carry **diegetic native audio** — the scene's own ambience described in the Seedance prompt, never music (the music bed is a separate, ducked track); set a scene's `ambient` field to steer it.
3959
+ Each scene is captured in a **shoot mode** — `ugc_selfie` (talking heads, the default look), `ugc_broll`, `studio_product` (pack shot), `lifestyle_cinematic`, or `screen_ui`. The scaffold derives one per scene (UGC by default; the cinematic and screen lanes are opt-in) and bakes its capture block into the frame and a camera default into the clip; override per scene with a `shoot_mode` field in that scene's `scenes/sNN.json`. Capture aesthetic + depth-of-field follow the mode (UGC stays flat; studio/lifestyle allow shallow DoF). Clips also carry **diegetic native audio** — the scene's own ambience described in the Seedance prompt, never music (the music bed is a separate, ducked track); set a scene's `ambient` field to steer it.
4144
3960
 
4145
3961
  **Automatic by default (no flags).** Every recast **base element — person, pet, product, AND location/set** — is fused into ONE rich multi-view sheet (`image_reference_sheet`, one subject per sheet, **4K**, up to 8 cells) that every frame it appears in grounds on, so the same face/pet/pack/room is rendered from a multi-angle canvas instead of a lone flat snapshot (a one-scene hero element is sheeted too). Each sheet pairs a **full turnaround** (angles, for proportions/wardrobe/layout) with tight **close-ups** so the generator is prepared for ANY framing a scene needs: a **person** gets body cells + face close-ups (front/¾/profile) and a mid-sentence speaking expression (identity pinned, natural skin — no airbrushing); an **animal** gets a body turnaround + head close-ups + an eyes/face macro; a **product** gets a turnaround + label and material detail macros; a **location/set** gets several camera angles of the same room + a key-surface detail. Generated clips are pinned to **1080p** (see `--resolution`) so the video keeps the keyframe's sharpness, and each cast frame keeps the source frame as a **composition anchor** (identity stays on the sheet) so the original framing/camera is reproduced, not re-guessed. An **app/website/chat screen** is never sent to the video model — the scaffold drops the scene to a clean talking-head and seeds a phone-mockup PIP stub to fill with a real `baker images screenshot` or brand HTML block (Seedance garbles UI and a split leaves a seam). The **music bed is instrumental** (the script is never fed to the music model — it would sing over the voice), enters only after the hook, and is **sidechain-ducked** under the voice. **Word-synced TikTok captions** are wired whenever the ad has speech — and they are **transcribed from the rendered audio's CLEAN VOICE BUS** (a `video_transcribe` of the vo tracks alone — no music bed, no SFX, which smear Whisper's word timings and hallucinate tokens), never the deconstruct's original transcript. This is a correctness boundary: wiring the source transcript would burn the **competitor's** words (their brand name, a claim we can't make) over the ad once the script is re-authored, whereas transcribing the generated audio can only ever show what is actually spoken, so the captions always track the re-written lines. Seeded overlays are pushed **off the subject's face** (dead-center → bottom band).
4146
3962