@koda-sl/baker-cli 0.123.0-dev.70bf43ce4 → 0.123.0-dev.8e4328629

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/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  IMAGE_GENERATE_MODELS,
6
6
  LayerExecutionError,
7
7
  MODEL_REGISTRY,
8
+ REF_PREFIX,
8
9
  SEEDANCE_DURATIONS,
9
10
  ValidationError,
10
11
  collectAssetRefLikes,
@@ -14,13 +15,14 @@ import {
14
15
  elementMentionKeywords,
15
16
  generateCatalog,
16
17
  isPersistedAssetRef,
18
+ parseRefExpr,
17
19
  requireCredentialsFromEnv,
18
20
  resolveConcurrency,
19
21
  sha256Hex,
20
22
  toModelSafeImage,
21
23
  ulid,
22
24
  validateCanvasDeep
23
- } from "./chunk-VSVGPYJK.js";
25
+ } from "./chunk-43KBQLP5.js";
24
26
  import {
25
27
  csvOrJson,
26
28
  daysAgoIso,
@@ -855,7 +857,6 @@ var LINKEDIN_LIMITS = {
855
857
  choiceOptionsMax: 30,
856
858
  choiceOptionTextMax: 100,
857
859
  thankYouMessageMax: 300,
858
- privacyPolicyTextMax: 2e3,
859
860
  legalDisclaimerMax: 2e3,
860
861
  consentsMax: 5,
861
862
  // Campaign Manager caps disclosure checkboxes at 5
@@ -1450,7 +1451,6 @@ var leadFormFields = {
1450
1451
  /** Form language, e.g. { country: "US", language: "en" }. Defaults to the account locale on LinkedIn. */
1451
1452
  locale: z2.object({ country: z2.string().length(2), language: z2.string().length(2) }).optional(),
1452
1453
  privacyPolicyUrl: httpsUrlSchema,
1453
- privacyPolicyText: z2.string().max(LEAD.privacyPolicyTextMax).optional(),
1454
1454
  questions: z2.array(leadFormQuestionSchema).min(1).max(LEAD.questionsMax),
1455
1455
  consents: z2.array(leadFormConsentSchema).max(LEAD.consentsMax).optional(),
1456
1456
  hiddenFields: z2.array(leadFormHiddenFieldSchema).max(LEAD.hiddenFieldsMax).optional(),
@@ -9390,7 +9390,7 @@ var leadFormsCreateCommand = defineCommand38({
9390
9390
  Required: name, headline (\u226460), privacyPolicyUrl, questions[] (\u226412; playbook: \u22644 for completion).
9391
9391
  Each question is a predefined profile field ({ name, predefinedField: "EMAIL" }) or a custom question ({ name, questionType: "MULTIPLE_CHOICE", options: [...] }; \u22643 custom).
9392
9392
  Best-practice fields the preview will nudge for if missing: 1-3 qualifying questions, consents[] (disclosure checkboxes), thankYou.message + thankYou.landingUrl|appointmentUrl.
9393
- Also supported: locale, formImageId|formImageUrn, privacyPolicyText, hiddenFields[], legalDisclaimer, thankYou.cta. Example: baker ads linkedin lead-forms create --file form.json`
9393
+ Also supported: locale, formImageId|formImageUrn, hiddenFields[], legalDisclaimer, thankYou.cta. Example: baker ads linkedin lead-forms create --file form.json`
9394
9394
  },
9395
9395
  args: {
9396
9396
  ...accountArgs,
@@ -14439,23 +14439,28 @@ function isResolvableRelative(value) {
14439
14439
  return typeof value === "string" && value.length > 0 && !value.includes("[TODO") && !path2.isAbsolute(value);
14440
14440
  }
14441
14441
 
14442
- // src/commands/canvas/run-record.ts
14443
- import path3 from "path";
14444
- var MAX_RUN_NODES = 200;
14445
- var MAX_OUTPUTS_PER_NODE = 10;
14446
- var MAX_FINAL_OUTPUTS = 10;
14442
+ // src/commands/canvas/node-preview.ts
14447
14443
  var MAX_PARAMS_PREVIEW_LENGTH = 4e3;
14448
- function paramsPreviewFromParams(params) {
14449
- if (params === void 0 || params === null) return void 0;
14450
- const text = humanParamText(params, "prompt") ?? humanParamText(params, "source") ?? compactJson(params);
14451
- if (!text) return void 0;
14452
- return text.length > MAX_PARAMS_PREVIEW_LENGTH ? `${text.slice(0, MAX_PARAMS_PREVIEW_LENGTH - 1)}\u2026` : text;
14453
- }
14454
- function humanParamText(params, key) {
14444
+ var SPEC_KEYS = [
14445
+ "model",
14446
+ "aspect_ratio",
14447
+ "image_size",
14448
+ "resolution",
14449
+ "duration",
14450
+ "subject_type",
14451
+ "font_size",
14452
+ "expect",
14453
+ "source",
14454
+ "path",
14455
+ "url"
14456
+ ];
14457
+ var BODY_KEYS = ["prompt", "text", "subject_description"];
14458
+ function scalarField(params, key) {
14455
14459
  const value = params[key];
14460
+ if (typeof value === "number") return String(value);
14456
14461
  if (typeof value !== "string") return void 0;
14457
14462
  const trimmed = value.trim();
14458
- return trimmed && !trimmed.startsWith("$ref:") ? trimmed : void 0;
14463
+ return trimmed && !trimmed.startsWith(REF_PREFIX) ? trimmed : void 0;
14459
14464
  }
14460
14465
  function compactJson(params) {
14461
14466
  try {
@@ -14465,6 +14470,32 @@ function compactJson(params) {
14465
14470
  return void 0;
14466
14471
  }
14467
14472
  }
14473
+ function cap(text) {
14474
+ return text.length > MAX_PARAMS_PREVIEW_LENGTH ? `${text.slice(0, MAX_PARAMS_PREVIEW_LENGTH - 1)}\u2026` : text;
14475
+ }
14476
+ function nodeParamsPreview(params, deps) {
14477
+ const refsLine = deps && deps.length > 0 ? `refs: ${deps.join(", ")}` : void 0;
14478
+ if (!params || typeof params !== "object") return refsLine;
14479
+ const record = params;
14480
+ const spec = [];
14481
+ for (const key of SPEC_KEYS) {
14482
+ const value = scalarField(record, key);
14483
+ if (value !== void 0) spec.push(`${key}: ${value}`);
14484
+ }
14485
+ if (refsLine) spec.push(refsLine);
14486
+ const body = BODY_KEYS.map((key) => scalarField(record, key)).find(Boolean);
14487
+ const header = spec.join("\n");
14488
+ const composed = header && body ? `${header}
14489
+
14490
+ ${body}` : header || body || compactJson(record);
14491
+ return composed ? cap(composed) : void 0;
14492
+ }
14493
+
14494
+ // src/commands/canvas/run-record.ts
14495
+ import path3 from "path";
14496
+ var MAX_RUN_NODES = 200;
14497
+ var MAX_OUTPUTS_PER_NODE = 10;
14498
+ var MAX_FINAL_OUTPUTS = 10;
14468
14499
  var MAX_CREATIVE_SLUG_LENGTH = 100;
14469
14500
  function creativeSlugFromCanvasPath(filePath) {
14470
14501
  const normalized = filePath.split(path3.sep).join("/");
@@ -14583,7 +14614,7 @@ var RunProgressTracker = class {
14583
14614
  outputs: [],
14584
14615
  deps: node.deps,
14585
14616
  status: "pending",
14586
- paramsPreview: paramsPreviewFromParams(node.params)
14617
+ paramsPreview: nodeParamsPreview(node.params, node.deps)
14587
14618
  });
14588
14619
  }
14589
14620
  this.planned = true;
@@ -14658,6 +14689,7 @@ var RunRecordPoster = class {
14658
14689
  latest = null;
14659
14690
  inflight = null;
14660
14691
  warned = false;
14692
+ keepaliveTimer = null;
14661
14693
  constructor(post) {
14662
14694
  this.post = post;
14663
14695
  }
@@ -14666,12 +14698,36 @@ var RunRecordPoster = class {
14666
14698
  this.latest = payload;
14667
14699
  if (!this.inflight) this.inflight = this.pump();
14668
14700
  }
14701
+ /**
14702
+ * Re-post the latest snapshot on an interval even with no new node events, so
14703
+ * the backend's `canvasRuns.updatedAt` heartbeat stays fresh during a long
14704
+ * single-clip poll (a video_generate clip can run minutes with no
14705
+ * intervening node events). When this process dies the keepalive stops → the
14706
+ * run's `updatedAt` goes stale → the backend reconciliation sweep force-fails
14707
+ * it as interrupted and surfaces the clips that finished. `produce` returns
14708
+ * null before the plan lands (nothing worth posting yet).
14709
+ */
14710
+ startKeepalive(produce, intervalMs = 6e4) {
14711
+ if (this.keepaliveTimer) return;
14712
+ this.keepaliveTimer = setInterval(() => {
14713
+ const snapshot = produce();
14714
+ if (snapshot) this.enqueue(snapshot);
14715
+ }, intervalMs);
14716
+ this.keepaliveTimer.unref?.();
14717
+ }
14718
+ stopKeepalive() {
14719
+ if (this.keepaliveTimer) {
14720
+ clearInterval(this.keepaliveTimer);
14721
+ this.keepaliveTimer = null;
14722
+ }
14723
+ }
14669
14724
  /**
14670
14725
  * Post the terminal record (awaited, errors surfaced to the caller). Any
14671
14726
  * queued progress snapshot is superseded — the terminal record is the full
14672
14727
  * state — but an in-flight POST is awaited first so it can't land after.
14673
14728
  */
14674
14729
  async flush(terminal) {
14730
+ this.stopKeepalive();
14675
14731
  this.latest = null;
14676
14732
  if (this.inflight) await this.inflight;
14677
14733
  await this.post(terminal);
@@ -14774,6 +14830,10 @@ var runCommand = defineCommand88({
14774
14830
  description: "Ignore any interrupted-run marker and start a new run id instead of resuming"
14775
14831
  },
14776
14832
  "cache-policy": { type: "string", description: "read_write | bypass | read_only" },
14833
+ regenerate: {
14834
+ type: "string",
14835
+ description: "Comma-separated node ids to force fresh THIS run (e.g. --regenerate gen_4x5,gen_9x16), bypassing the content cache for just those nodes + everything downstream. For a persistent re-render, bump a node's `regenerate` field in the canvas JSON instead."
14836
+ },
14777
14837
  concurrency: {
14778
14838
  type: "string",
14779
14839
  description: "Max nodes per layer in flight at once (default 5; env BAKER_CANVAS_CONCURRENCY)"
@@ -14831,6 +14891,34 @@ var runCommand = defineCommand88({
14831
14891
  );
14832
14892
  process.exit(2);
14833
14893
  }
14894
+ let regenerate;
14895
+ if (args.regenerate !== void 0) {
14896
+ const requested = String(args.regenerate).split(",").map((id) => id.trim()).filter((id) => id.length > 0);
14897
+ const known = new Set(canvasNodeIds(parsed));
14898
+ const unknown = requested.filter((id) => !known.has(id));
14899
+ if (unknown.length > 0) {
14900
+ process.stderr.write(
14901
+ `${JSON.stringify(
14902
+ {
14903
+ ok: false,
14904
+ error: {
14905
+ code: "unknown_regenerate_node",
14906
+ message: `--regenerate names node id(s) not in this canvas: ${unknown.join(", ")}. Known ids: ${[...known].join(", ")}`
14907
+ }
14908
+ },
14909
+ null,
14910
+ 2
14911
+ )}
14912
+ `
14913
+ );
14914
+ process.exit(2);
14915
+ }
14916
+ if (requested.length > 0) {
14917
+ regenerate = new Set(requested);
14918
+ process.stdout.write(`[regenerate] forcing fresh this run: ${[...regenerate].join(", ")} (+ downstream)
14919
+ `);
14920
+ }
14921
+ }
14834
14922
  const remoteCache = args["remote-cache"] !== void 0 ? String(args["remote-cache"]) !== "off" : void 0;
14835
14923
  const engine = createEngineFromEnv({
14836
14924
  cacheDir: args["cache-dir"] ? String(args["cache-dir"]) : void 0,
@@ -14860,6 +14948,9 @@ var runCommand = defineCommand88({
14860
14948
  const record = args.record === false ? null : buildRecorder();
14861
14949
  const progress = record ? new RunProgressTracker(runId, recordMeta) : null;
14862
14950
  const poster = record ? new RunRecordPoster(record) : null;
14951
+ if (progress && poster) {
14952
+ poster.startKeepalive(() => progress.hasPlan() ? progress.snapshot() : null);
14953
+ }
14863
14954
  try {
14864
14955
  const policy = args["cache-policy"] ?? "read_write";
14865
14956
  const result = await engine.run(parsed, {
@@ -14870,6 +14961,7 @@ var runCommand = defineCommand88({
14870
14961
  (args.concurrency ?? args.parallel) !== void 0 ? String(args.concurrency ?? args.parallel) : void 0,
14871
14962
  process.env.BAKER_CANVAS_CONCURRENCY
14872
14963
  ),
14964
+ regenerate,
14873
14965
  onProgress: progress && poster ? (event) => {
14874
14966
  progress.apply(event);
14875
14967
  if (progress.hasPlan()) poster.enqueue(progress.snapshot());
@@ -14923,6 +15015,11 @@ var runCommand = defineCommand88({
14923
15015
  }
14924
15016
  }
14925
15017
  });
15018
+ function canvasNodeIds(parsed) {
15019
+ const nodes = parsed?.nodes;
15020
+ if (!Array.isArray(nodes)) return [];
15021
+ return nodes.map((node) => node?.id).filter((id) => typeof id === "string");
15022
+ }
14926
15023
  function buildRecorder() {
14927
15024
  return async (payload) => {
14928
15025
  try {
@@ -14946,6 +15043,12 @@ import { defineCommand as defineCommand89 } from "citty";
14946
15043
  import { z as z11 } from "zod";
14947
15044
  var GEN_ASPECT_RATIOS = /* @__PURE__ */ new Set(["1:1", "4:5", "9:16", "16:9", "4:3", "3:4", "2:3", "3:2", "21:9"]);
14948
15045
  var DEFAULT_ASPECT_RATIO = "9:16";
15046
+ var SHEET_SUBJECT_TYPE = {
15047
+ person: "person",
15048
+ animal: "character"
15049
+ };
15050
+ var ACTOR_SHEET_MODEL = "google/gemini-3-pro-image-preview";
15051
+ var ACTOR_SHEET_IMAGE_SIZE = "4K";
14949
15052
  var Blueprint = z11.object({
14950
15053
  meta: z11.object({ estimated_aspect_ratio: z11.string().optional() }).loose().optional(),
14951
15054
  text_content: z11.array(z11.object({ text: z11.string().optional() }).loose()).optional()
@@ -15017,6 +15120,11 @@ function todoPath(el, label) {
15017
15120
  const lock = t === "person" || t === "animal" ? " \u2014 REQUIRED: this is the emotional hero; ground it in a real reference (image-library / video-library / Pinterest), do not delete this slot and free-generate" : "";
15018
15121
  return `[TODO: drop a real image for ${label} (${el.type})${desc}${expr}${lock}]`;
15019
15122
  }
15123
+ function sheetSubjectDescription(el) {
15124
+ const desc = el.description ?? `the ${el.type}`;
15125
+ const expr = el.expression ? `, ${el.expression} expression` : "";
15126
+ return `${desc}${expr} \u2014 clean plate: no phone-camera UI chrome, no app interface, no watermarks, no captions or on-image text`;
15127
+ }
15020
15128
  function aspectRatio(blueprint, opts) {
15021
15129
  const requested = opts.aspectRatio ?? blueprint.meta?.estimated_aspect_ratio;
15022
15130
  return requested && GEN_ASPECT_RATIOS.has(requested) ? requested : DEFAULT_ASPECT_RATIO;
@@ -15040,6 +15148,7 @@ function scaffoldStaticAd(input, elementsInput, opts) {
15040
15148
  type: "ingest",
15041
15149
  params: opts.imageIsUrl ? { source: "url", url: opts.imagePath, expect: "image" } : { source: "path", path: opts.imagePath, expect: "image" }
15042
15150
  });
15151
+ const includeActorSheets = opts.includeActorSheets !== false;
15043
15152
  const usedIds = /* @__PURE__ */ new Set(["prompt", "original", "gen", "brandfont", "type_ref"]);
15044
15153
  const elementSlots = [];
15045
15154
  assignElementLabels(elements).forEach(({ el, label }, i) => {
@@ -15051,7 +15160,25 @@ function scaffoldStaticAd(input, elementsInput, opts) {
15051
15160
  type: "ingest",
15052
15161
  params: { source: "path", path: todoPath(el, label), expect: "image" }
15053
15162
  });
15054
- elementSlots.push({ ref: `$ref:${id}.asset`, label, type: el.type });
15163
+ let ref = `$ref:${id}.asset`;
15164
+ const subjectType = includeActorSheets ? SHEET_SUBJECT_TYPE[el.type.toLowerCase()] : void 0;
15165
+ if (subjectType) {
15166
+ const sheetId = `${id}_sheet`;
15167
+ usedIds.add(sheetId);
15168
+ nodes.push({
15169
+ id: sheetId,
15170
+ type: "image_reference_sheet",
15171
+ inputs: { references: [ref] },
15172
+ params: {
15173
+ model: ACTOR_SHEET_MODEL,
15174
+ subject_description: sheetSubjectDescription(el),
15175
+ subject_type: subjectType,
15176
+ image_size: ACTOR_SHEET_IMAGE_SIZE
15177
+ }
15178
+ });
15179
+ ref = `$ref:${sheetId}.sheet`;
15180
+ }
15181
+ elementSlots.push({ ref, label, type: el.type });
15055
15182
  });
15056
15183
  const hasFont = includeFont;
15057
15184
  if (hasFont) {
@@ -15112,6 +15239,7 @@ function scaffoldStaticAd(input, elementsInput, opts) {
15112
15239
  function staticAdReport(input, elementsInput, opts) {
15113
15240
  const blueprint = Blueprint.parse(input);
15114
15241
  const elements = MainElements.parse(elementsInput);
15242
+ const includeActorSheets = opts.includeActorSheets !== false;
15115
15243
  return {
15116
15244
  element_count: elements.length,
15117
15245
  elements: assignElementLabels(elements).map(({ el, label }) => ({
@@ -15121,6 +15249,7 @@ function staticAdReport(input, elementsInput, opts) {
15121
15249
  asset_todo: todoPath(el, label)
15122
15250
  })),
15123
15251
  includes_font: opts.includeFont !== false,
15252
+ actor_sheets: includeActorSheets ? assignElementLabels(elements).filter(({ el }) => SHEET_SUBJECT_TYPE[el.type.toLowerCase()]).map(({ label }) => label) : [],
15124
15253
  aspect_ratio: aspectRatio(blueprint, opts)
15125
15254
  };
15126
15255
  }
@@ -15190,6 +15319,84 @@ function isValidScaffoldSlug(slug) {
15190
15319
  return slug.length <= SCAFFOLD_SLUG_MAX_LENGTH && SCAFFOLD_SLUG_PATTERN.test(slug);
15191
15320
  }
15192
15321
 
15322
+ // src/commands/canvas/definition-graph.ts
15323
+ var MAX_NODES = 300;
15324
+ function walkStrings(value, cb) {
15325
+ if (typeof value === "string") {
15326
+ cb(value);
15327
+ return;
15328
+ }
15329
+ if (Array.isArray(value)) {
15330
+ for (const v of value) walkStrings(v, cb);
15331
+ return;
15332
+ }
15333
+ if (value && typeof value === "object") {
15334
+ for (const v of Object.values(value)) walkStrings(v, cb);
15335
+ }
15336
+ }
15337
+ function canvasToDefinitionGraph(canvas) {
15338
+ const rawNodes = canvas?.nodes;
15339
+ if (!Array.isArray(rawNodes)) return null;
15340
+ const parsed = [];
15341
+ for (const raw of rawNodes) {
15342
+ const id = raw?.id;
15343
+ const type = raw?.type;
15344
+ if (typeof id !== "string" || typeof type !== "string") continue;
15345
+ parsed.push({ id, type, inputs: raw.inputs, params: raw.params });
15346
+ if (parsed.length >= MAX_NODES) break;
15347
+ }
15348
+ if (parsed.length === 0) return null;
15349
+ const ids = new Set(parsed.map((n) => n.id));
15350
+ const nodes = parsed.map(({ id, type, inputs, params }) => {
15351
+ const deps = /* @__PURE__ */ new Set();
15352
+ const collect = (s) => {
15353
+ if (!s.startsWith(REF_PREFIX)) return;
15354
+ const expr = parseRefExpr(s);
15355
+ if (expr && expr.nodeId !== id && ids.has(expr.nodeId)) deps.add(expr.nodeId);
15356
+ };
15357
+ walkStrings(inputs, collect);
15358
+ walkStrings(params, collect);
15359
+ const depList = [...deps];
15360
+ const paramsPreview = nodeParamsPreview(params, depList);
15361
+ const base = { id, type };
15362
+ if (depList.length > 0) base.deps = depList;
15363
+ if (paramsPreview) base.paramsPreview = paramsPreview;
15364
+ return base;
15365
+ });
15366
+ const rawOutput = canvas?.output;
15367
+ const outNode = rawOutput?.node;
15368
+ const outSlot = rawOutput?.output;
15369
+ const output = typeof outNode === "string" && typeof outSlot === "string" ? { node: outNode, output: outSlot } : void 0;
15370
+ return { nodes, output };
15371
+ }
15372
+
15373
+ // src/commands/canvas/sync-definition.ts
15374
+ async function syncCreativeDefinitionBestEffort(input) {
15375
+ const chatId = process.env.BAKER_CHAT_ID;
15376
+ if (!chatId) return;
15377
+ const graph = canvasToDefinitionGraph(input.canvas);
15378
+ if (!graph || graph.nodes.length === 0) return;
15379
+ try {
15380
+ const creds = requireCredentialsFromEnv();
15381
+ const client = new BackendClient({ baseUrl: creds.url, apiKey: creds.apiKey });
15382
+ await client.syncCreativeDefinition({
15383
+ slug: input.slug,
15384
+ title: input.title,
15385
+ platform: input.platform,
15386
+ formats: input.formats,
15387
+ sourceReferenceUrl: input.sourceReferenceUrl,
15388
+ graph,
15389
+ chatId
15390
+ });
15391
+ process.stdout.write(`[definition] synced workflow graph (${graph.nodes.length} nodes) \u2014 view it in the dashboard
15392
+ `);
15393
+ } catch (e) {
15394
+ const msg = e instanceof Error ? e.message : String(e);
15395
+ process.stderr.write(`[warn] workflow graph not synced (${msg})
15396
+ `);
15397
+ }
15398
+ }
15399
+
15193
15400
  // src/commands/canvas/scaffold-static-ad.ts
15194
15401
  async function fileExists(target) {
15195
15402
  try {
@@ -15225,7 +15432,7 @@ function resolveModel(kind, preferred) {
15225
15432
  const ids = Object.keys(MODEL_REGISTRY[kind]);
15226
15433
  return ids.includes(preferred) ? preferred : ids[0] ?? preferred;
15227
15434
  }
15228
- var DESCRIBE_FOCUS = `the GLOBAL LAYOUT GEOMETRY above all \u2014 the column/row grid, each region's approximate bounds as a percentage of frame width and height, any panel SPLITS and their proportions (e.g. a left column split into a ~60% top photo and a ~20% bottom price box), what occupies every region, and for EVERY text block its string verbatim, relative size (x_large | large | medium | small), weight, case, color, and alignment \u2014 including small bottom-corner fine print. Also: the EXPRESSION and emotion of every person/animal (capture exaggerated or AI-edited faces), the ad_intent (what feeling it engineers and how), per-color brand_ownership (which colors are the advertiser's brand vs borrowed-functional like a red comparison column), logos by brand, and all visible text verbatim. CRITICAL \u2014 name the WINNING MECHANISM(S): the special sauce, the specific thing that makes THIS ad a candidate winner rather than a generic ad. A great ad is rarely great by accident, and the mechanism is invisible unless named, so a faithful rewrite quietly discards it. It may be VERBAL (a rhyme, pun, alliteration, rhythm/meter, repetition, antithesis, double meaning), VISUAL (an unexpected crop or scale, a visual gag or pun, a striking juxtaposition, a pattern interrupt, an exaggerated/AI-edited expression, a before/after or comparison, bold negative space, a surprising focal point), or STRUCTURAL (an unusual hook, order, or reveal). Record each as a \`winning_mechanisms\` array of { kind: verbal|visual|structural, device, why_it_works } (e.g. { kind: verbal, device: "rhyme \u2014 'today' / 'go away'", why_it_works: "memorable, playful, reframes cost as relief" }) so a rewrite can rebuild the SAUCE for our brand instead of adapting only the surface and losing what made it convert`;
15435
+ var DESCRIBE_FOCUS = "the GLOBAL LAYOUT GEOMETRY above all \u2014 the column/row grid, each region's approximate bounds as a percentage of frame width and height, any panel SPLITS and their proportions (e.g. a left column split into a ~60% top photo and a ~20% bottom price box), what occupies every region, and for EVERY text block its string verbatim, relative size (x_large | large | medium | small), weight, case, color, and alignment \u2014 including small bottom-corner fine print. Also: the EXPRESSION and emotion of every person/animal (capture exaggerated or AI-edited faces), the ad_intent (what feeling it engineers and how), per-color brand_ownership (which colors are the advertiser's brand vs borrowed-functional like a red comparison column), logos by brand, and all visible text verbatim. the TYPOGRAPHY as a first-class identity signal \u2014 for EVERY distinct typeface in the ad record a `fonts` entry: which text blocks use it, its classification (serif | slab-serif | sans | script | display | mono), a best-guess family or a close lookalike (e.g. 'a Tiempos-like high-contrast serif', 'Circular-like geometric sans'), and its weight/case/styling \u2014 because a faithful rewrite that keeps the words but loses the letterforms loses the brand; ALSO record whether the advertiser's LOGO appears in more than one lockup (a square/icon MARK and a horizontal WORDMARK are two different assets \u2014 capture BOTH). CRITICAL \u2014 name the WINNING MECHANISM(S): the special sauce, the specific thing that makes THIS ad a candidate winner rather than a generic ad. A great ad is rarely great by accident, and the mechanism is invisible unless named, so a faithful rewrite quietly discards it. It may be VERBAL (a rhyme, pun, alliteration, rhythm/meter, repetition, antithesis, double meaning), VISUAL (an unexpected crop or scale, a visual gag or pun, a striking juxtaposition, a pattern interrupt, an exaggerated/AI-edited expression, a before/after or comparison, bold negative space, a surprising focal point), or STRUCTURAL (an unusual hook, order, or reveal). Record each as a `winning_mechanisms` array of { kind: verbal|visual|structural, device, why_it_works } (e.g. { kind: verbal, device: \"rhyme \u2014 'today' / 'go away'\", why_it_works: \"memorable, playful, reframes cost as relief\" }) so a rewrite can rebuild the SAUCE for our brand instead of adapting only the surface and losing what made it convert";
15229
15436
  var LAYOUT_SYSTEM = "You convert an advertisement's JSON blueprint into a precise, structured LAYOUT MAP of the frame \u2014 the spatial grid a designer would rebuild it from. Be exhaustive and quantitative: every region, its bounds as a percentage of the frame, and every text block with its relative size. Output ONLY a JSON object, no prose.";
15230
15437
  var LAYOUT_PROMPT = `AD BLUEPRINT (from image_describe):
15231
15438
  {{blueprint}}
@@ -15251,7 +15458,7 @@ var SELECT_PROMPT = `AD BLUEPRINT (from image_describe):
15251
15458
  {{blueprint}}
15252
15459
 
15253
15460
  From this blueprint, list ONLY the elements that are prominent, important, and identity-bearing \u2014 the ones a reproduction must ground in a real asset:
15254
- - the brand logo/wordmark (from brands_logos with function_in_image = advertiser_brand) -> type "logo"
15461
+ - the brand logo/wordmark (from brands_logos with function_in_image = advertiser_brand) -> type "logo". A brand often ships its logo in TWO lockups \u2014 a square/icon MARK and a horizontal WORDMARK \u2014 and an ad may use both (e.g. an icon in a product mock plus the wordmark in the sign-off). When more than one distinct lockup of the SAME advertiser appears, emit a SEPARATE logo element for each (label them by lockup, e.g. LOGO_MARK and LOGO_WORDMARK) so the reproduction can drop the right file in each slot instead of stretching one logo to cover both.
15255
15462
  - trust/rating/certification/app-store/review badges (brands_logos with function_in_image = trust_badge | review_platform | certification_or_seal | app_store_badge | payment_method) -> type "badge"
15256
15463
  - a showcased/hero product or package (a foreground entry in subjects that the ad is selling) -> type "product"
15257
15464
  - a foreground person (from people) \u2014 keep it when its identity matters OR it is the emotional/hero focal point of the ad, even a generic one with no brand identity -> type "person"
@@ -15395,7 +15602,11 @@ var scaffoldStaticAdCommand = defineCommand89({
15395
15602
  "layout-model": { type: "string", description: "Override the text_generate model id for the layout pass" },
15396
15603
  "gen-model": { type: "string", description: "Override the image_generate model id" },
15397
15604
  aspect: { type: "string", description: "Force the output aspect ratio (else inferred from the image)" },
15398
- "skip-font": { type: "boolean", description: "Skip the brand-font \u2192 type-specimen slot" }
15605
+ "skip-font": { type: "boolean", description: "Skip the brand-font \u2192 type-specimen slot" },
15606
+ "skip-actor-sheets": {
15607
+ type: "boolean",
15608
+ description: "Ground each person/animal on its lone dropped photo instead of a generated multi-view sheet"
15609
+ }
15399
15610
  },
15400
15611
  async run({ args }) {
15401
15612
  const slug = args.slug ? String(args.slug) : void 0;
@@ -15445,7 +15656,8 @@ var scaffoldStaticAdCommand = defineCommand89({
15445
15656
  imageIsUrl: canvasImageIsUrl,
15446
15657
  blueprintPath: canvasBlueprintPath,
15447
15658
  aspectRatio: args.aspect ? String(args.aspect) : void 0,
15448
- includeFont: !args["skip-font"]
15659
+ includeFont: !args["skip-font"],
15660
+ includeActorSheets: !args["skip-actor-sheets"]
15449
15661
  };
15450
15662
  let canvas;
15451
15663
  let report;
@@ -15482,6 +15694,16 @@ var scaffoldStaticAdCommand = defineCommand89({
15482
15694
  "utf8"
15483
15695
  );
15484
15696
  }
15697
+ if (slug) {
15698
+ await syncCreativeDefinitionBestEffort({
15699
+ slug,
15700
+ title: args.title ? String(args.title) : titleFromSlug(slug),
15701
+ platform: resolvePlatform(args.platform ? String(args.platform) : void 0),
15702
+ formats: resolveFormats(args.aspect ? String(args.aspect) : report.aspect_ratio),
15703
+ sourceReferenceUrl: imageIsUrl ? imageSource : void 0,
15704
+ canvas
15705
+ });
15706
+ }
15485
15707
  process.stdout.write(
15486
15708
  `${JSON.stringify(
15487
15709
  {
@@ -15502,8 +15724,9 @@ var scaffoldStaticAdCommand = defineCommand89({
15502
15724
  checklist: {
15503
15725
  edit_prompt: `Edit ${path9.basename(blueprintPath)} \u2014 it is the blueprint generated from your image; rewrite it into the ad you want (palette, copy, claims, subjects). It feeds the generator directly.`,
15504
15726
  assets_to_supply: report.elements,
15505
- font_slot: report.includes_font ? "Drop a brand font at the [TODO] brandfont path, or delete the brandfont + type_ref nodes to skip it." : "skipped (--skip-font)",
15506
- note: "Replace every [TODO] ingest path with a real file, then `baker canvas validate` and `baker canvas run`. Running generates a billed image \u2014 it is not free."
15727
+ font_slot: report.includes_font ? "Drop a brand font at the [TODO] brandfont path (the describe pass recorded the ad's typefaces under `fonts` in prompt.json \u2014 match those). The font is wired into the render as a TYPE SPECIMEN reference so generated text takes the brand letterforms. Delete the brandfont + type_ref nodes to skip it." : "skipped (--skip-font)",
15728
+ actor_sheets: report.actor_sheets.length > 0 ? `Each living hero (${report.actor_sheets.join(", ")}) is fused into a generated multi-view reference sheet (image_reference_sheet) that the render grounds on \u2014 so drop ONE clean photo at that hero's ingest and the sheet builds the consistent turnaround. Pass --skip-actor-sheets to ground on the lone photo instead.` : "none (no person/animal heroes detected, or --skip-actor-sheets)",
15729
+ note: "Populate as you go: for each [TODO] ingest slot, source its real asset and wire it into the slot right away \u2014 one at a time, not all sourced first then reconciled at the end. When every slot is filled, `baker canvas validate` then `baker canvas run`. Running generates a billed image \u2014 it is not free."
15507
15730
  }
15508
15731
  },
15509
15732
  null,
@@ -15515,7 +15738,8 @@ var scaffoldStaticAdCommand = defineCommand89({
15515
15738
  });
15516
15739
 
15517
15740
  // src/commands/canvas/scaffold-video.ts
15518
- import { cp, mkdir as mkdir3, readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
15741
+ import { access as access2, cp, mkdir as mkdir3, readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
15742
+ import { tmpdir as tmpdir2 } from "os";
15519
15743
  import path12 from "path";
15520
15744
  import { defineCommand as defineCommand90 } from "citty";
15521
15745
 
@@ -16224,6 +16448,13 @@ function slimBlueprintForSelection(blueprintInput) {
16224
16448
  }
16225
16449
  return out;
16226
16450
  }
16451
+ function slimBlueprintForFrameStyle(blueprintInput) {
16452
+ if (!blueprintInput || typeof blueprintInput !== "object" || Array.isArray(blueprintInput)) return blueprintInput;
16453
+ const bp = blueprintInput;
16454
+ const out = {};
16455
+ for (const k of ["version", "source", "global", "reference_elements"]) if (k in bp) out[k] = bp[k];
16456
+ return out;
16457
+ }
16227
16458
  function roleForType2(type) {
16228
16459
  switch (type.toLowerCase()) {
16229
16460
  case "logo":
@@ -16316,8 +16547,8 @@ function extendPresenceByPromptMentions(slots, blueprint) {
16316
16547
  }
16317
16548
  });
16318
16549
  }
16319
- var ACTOR_SHEET_MODEL = "google/gemini-3-pro-image-preview";
16320
- var SHEET_SUBJECT_TYPE = {
16550
+ var ACTOR_SHEET_MODEL2 = "google/gemini-3-pro-image-preview";
16551
+ var SHEET_SUBJECT_TYPE2 = {
16321
16552
  person: "person",
16322
16553
  animal: "character",
16323
16554
  product: "product",
@@ -16325,7 +16556,7 @@ var SHEET_SUBJECT_TYPE = {
16325
16556
  };
16326
16557
  function buildElementSheets(slots, nodes) {
16327
16558
  for (const slot of slots) {
16328
- const subjectType = SHEET_SUBJECT_TYPE[slot.type.toLowerCase()];
16559
+ const subjectType = SHEET_SUBJECT_TYPE2[slot.type.toLowerCase()];
16329
16560
  if (!subjectType) continue;
16330
16561
  if (slot.sameAs) continue;
16331
16562
  if (slot.presence.size < 1) continue;
@@ -16338,7 +16569,7 @@ function buildElementSheets(slots, nodes) {
16338
16569
  // The lone dropped ingest is the source; the sheet fans it into a turnaround.
16339
16570
  inputs: { references: [slot.ref] },
16340
16571
  params: {
16341
- model: ACTOR_SHEET_MODEL,
16572
+ model: ACTOR_SHEET_MODEL2,
16342
16573
  // The clean-plate clause mirrors the frame prompts' CLEAN PLATE block: a sheet
16343
16574
  // that comes back with a fake camera app baked in (P2-22) poisons EVERY frame
16344
16575
  // grounded on it, so the suppression must live on the sheet too.
@@ -16481,9 +16712,10 @@ function buildFrameRef(edge, url, framePrompt, present, ctx, nodes) {
16481
16712
  id: genId,
16482
16713
  type: "image_generate",
16483
16714
  // `params.prompt` is this frame's authoritative, edit-per-frame description.
16484
- // `target_blueprint` is the shared ad spec (cast identity, palette, brand, type)
16485
- // the frame must stay consistent with editing one frame never touches another.
16486
- inputs: { target_blueprint: "$ref:prompt.asset", ...reference.length > 0 ? { reference } : {} },
16715
+ // `target_blueprint` is the SLIM shared ad spec (global cast identity, palette, brand,
16716
+ // type — no per-scene content) the frame must stay consistent with; editing one frame
16717
+ // never touches another, and no image inlines the whole film to render one frame.
16718
+ inputs: { target_blueprint: "$ref:prompt_style.asset", ...reference.length > 0 ? { reference } : {} },
16487
16719
  params: genParams
16488
16720
  });
16489
16721
  return `$ref:${genId}.images#0`;
@@ -17005,18 +17237,24 @@ function emitFlashHold(i, scene, slots, ctx, lengths, out, outAr, nodes, clips)
17005
17237
  });
17006
17238
  clips.push({ ref: `$ref:s${i}_clip.video`, scene_s: lengths.dur, out });
17007
17239
  }
17008
- function emitScreenScene(i, scene, lengths, out, outAr, nodes, clips) {
17009
- const label = commentSafe((scene.summary || scene.start_frame_prompt || "the app screen").slice(0, 120));
17010
- const refId = `s${i}_screen_ref`;
17011
- nodes.push({
17012
- id: refId,
17013
- type: "ingest",
17014
- params: {
17015
- source: "path",
17016
- path: `[TODO: supply the REAL screen for "${label}" \u2014 NEVER AI-generate a UI. Capture a clean, text-free screenshot with \`baker images screenshot https://<brand-domain>/<path>\` (image-library skill); spoken/overlay text rides the overlay layer, not the screenshot]`,
17017
- expect: "image"
17018
- }
17019
- });
17240
+ function emitScreenScene(i, scene, lengths, out, outAr, surfaceIngests, nodes, clips) {
17241
+ const regions = (scene.composition?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
17242
+ const surfaceId = regions.find((r) => r.surface_id)?.surface_id;
17243
+ let refId = surfaceId ? surfaceIngests.get(surfaceId) : void 0;
17244
+ if (!refId) {
17245
+ const label = commentSafe((scene.summary || scene.start_frame_prompt || "the app screen").slice(0, 120));
17246
+ refId = `s${i}_screen_ref`;
17247
+ nodes.push({
17248
+ id: refId,
17249
+ type: "ingest",
17250
+ params: {
17251
+ source: "path",
17252
+ path: `[TODO: supply the REAL screen for "${label}" \u2014 NEVER AI-generate a UI. Capture a clean, text-free screenshot with \`baker images screenshot https://<brand-domain>/<path>\` (image-library skill); spoken/overlay text rides the overlay layer, not the screenshot]`,
17253
+ expect: "image"
17254
+ }
17255
+ });
17256
+ if (surfaceId) surfaceIngests.set(surfaceId, refId);
17257
+ }
17020
17258
  nodes.push({
17021
17259
  id: `s${i}_clip`,
17022
17260
  type: "ffmpeg",
@@ -17260,9 +17498,16 @@ function makePresenterPresent(slots, canonical, opts = {}) {
17260
17498
  return presence.has(sceneIndex);
17261
17499
  };
17262
17500
  }
17263
- var PAUSE_GAP_S = 0.6;
17264
17501
  var SEEDANCE_SAFE_MAX_S = SEEDANCE_DURATIONS.find((d) => d >= 10) ?? 10;
17265
17502
  var PHRASE_MAX_S = SEEDANCE_SAFE_MAX_S;
17503
+ var PAUSE_GAP_S = 0.6;
17504
+ function isAdjacentShownCut(ln, lastShownScene, scenes) {
17505
+ if (!ln.shown || lastShownScene === null) return false;
17506
+ return ln.sceneIndex === lastShownScene + 1 && scenes[ln.sceneIndex]?.continues_previous !== true;
17507
+ }
17508
+ function breaksPhrase(cur, ln, lineCover, lineClipStart, scenes) {
17509
+ return cur.speaker !== ln.speaker || ln.start - cur.end > PAUSE_GAP_S || isAdjacentShownCut(ln, cur.lastShownScene, scenes) || Math.max(cur.coverEnd, lineCover) - Math.min(cur.clipStart, lineClipStart) > PHRASE_MAX_S;
17510
+ }
17266
17511
  var JOIN_DEDUP_MAX_WORDS = 4;
17267
17512
  function joinKey(word) {
17268
17513
  return word.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
@@ -17305,10 +17550,7 @@ function collapseVoiceover(blueprint) {
17305
17550
  const presenter = [...presenters][0];
17306
17551
  return (speaker) => NARRATOR_SPEAKERS.has(speaker.toLowerCase()) ? presenter : speaker;
17307
17552
  }
17308
- function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, presentStrict) {
17309
- const casts = castIdSet(blueprint);
17310
- const cameraOn = onCameraDialogue(blueprint);
17311
- const sceneEndS = (i) => blueprint.scenes[i]?.end_s ?? blueprint.scenes[i]?.start_s ?? 0;
17553
+ function multiSpeakerScenes(blueprint, casts, cameraOn, canonical, presentStrict) {
17312
17554
  const multiSpeaker = /* @__PURE__ */ new Set();
17313
17555
  blueprint.scenes.forEach((scene, i) => {
17314
17556
  const onCamAll = new Set(
@@ -17318,32 +17560,45 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
17318
17560
  const effective = onCamPresent.length > 0 ? new Set(onCamPresent) : onCamAll;
17319
17561
  if (effective.size >= 2) multiSpeaker.add(i);
17320
17562
  });
17321
- const lines = blueprint.scenes.flatMap(
17322
- (scene, sceneIndex) => compositeScenes.has(sceneIndex) ? [] : (scene.dialogue ?? []).filter((l) => Boolean(l.line?.trim())).map((l) => {
17563
+ return multiSpeaker;
17564
+ }
17565
+ function lineClipWindow(ln, scenes) {
17566
+ if (!ln.shown) return { cover: ln.end, clipStart: ln.start };
17567
+ const sc = scenes[ln.sceneIndex];
17568
+ const sceneEnd = sc?.end_s ?? sc?.start_s ?? 0;
17569
+ return { cover: Math.max(ln.end, sceneEnd), clipStart: Math.min(ln.start, sc?.start_s ?? ln.start) };
17570
+ }
17571
+ function dialogueLines(blueprint, ctx) {
17572
+ return blueprint.scenes.flatMap((scene, sceneIndex) => {
17573
+ if (ctx.compositeScenes.has(sceneIndex)) return [];
17574
+ return (scene.dialogue ?? []).filter((l) => Boolean(l.line?.trim())).map((l) => {
17323
17575
  const raw = l.speaker ?? "voiceover";
17324
- const sp = canonical(raw);
17325
17576
  const text = l.line.trim();
17326
17577
  const start = l.start_s ?? scene.start_s ?? 0;
17578
+ const shown = l.on_camera !== false && !sceneIsAllGraphic(scene) && isOnCameraSpeaker(raw, ctx.casts, ctx.cameraOn) && !ctx.multiSpeaker.has(sceneIndex) && ctx.presenterPresent(ctx.canonical(raw), sceneIndex);
17327
17579
  return {
17328
17580
  sceneIndex,
17329
- speaker: sp,
17330
- // Shown = a cast member speaking AND their element is actually on screen
17331
- // here (not a cutaway). A b-roll cutaway mid-phrase fails this and gets
17332
- // its own clip while the phrase voice plays under it. An explicit
17333
- // deconstruct voiceover stamp (`on_camera: false`) wins over element
17334
- // presence — a speaker pictured in a photo is "present" but not talking.
17335
- // An all-graphic composition (no camera region) is voiceover by
17336
- // definition: nobody is on screen to lip-sync.
17337
- shown: l.on_camera !== false && !sceneIsAllGraphic(scene) && isOnCameraSpeaker(raw, casts, cameraOn) && !multiSpeaker.has(sceneIndex) && presenterPresent(sp, sceneIndex),
17581
+ speaker: ctx.canonical(raw),
17582
+ shown,
17338
17583
  start,
17339
- // Real speech end. When the deconstruct gives no end_s, estimate it from
17340
- // the words — NOT the scene end (which would fabricate continuity across
17341
- // a long silent b-roll gap and wrongly merge two separate phrases).
17342
17584
  end: l.end_s ?? start + estSpeechS(text),
17343
17585
  text
17344
17586
  };
17345
- })
17346
- ).sort((a, b) => a.start - b.start);
17587
+ });
17588
+ }).sort((a, b) => a.start - b.start);
17589
+ }
17590
+ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, presentStrict) {
17591
+ const casts = castIdSet(blueprint);
17592
+ const cameraOn = onCameraDialogue(blueprint);
17593
+ const multiSpeaker = multiSpeakerScenes(blueprint, casts, cameraOn, canonical, presentStrict);
17594
+ const lines = dialogueLines(blueprint, {
17595
+ compositeScenes,
17596
+ multiSpeaker,
17597
+ canonical,
17598
+ casts,
17599
+ cameraOn,
17600
+ presenterPresent
17601
+ });
17347
17602
  const phrases = [];
17348
17603
  let cur = null;
17349
17604
  const flush = () => {
@@ -17361,12 +17616,8 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
17361
17616
  cur = null;
17362
17617
  };
17363
17618
  for (const ln of lines) {
17364
- const lineCover = ln.shown ? Math.max(ln.end, sceneEndS(ln.sceneIndex)) : ln.end;
17365
- const lineClipStart = ln.shown ? Math.min(ln.start, blueprint.scenes[ln.sceneIndex]?.start_s ?? ln.start) : ln.start;
17366
- const breakRun = !cur || cur.speaker !== ln.speaker || ln.start - cur.end > PAUSE_GAP_S || // Cap by SCENE COVERAGE span, not line end — a presenter run whose sliced scenes span
17367
- // more than one Seedance clip splits into the next take here (at this scene's
17368
- // boundary, never mid-scene), so no segment ever reads past the generated clip.
17369
- Math.max(cur.coverEnd, lineCover) - Math.min(cur.clipStart, lineClipStart) > PHRASE_MAX_S;
17619
+ const { cover: lineCover, clipStart: lineClipStart } = lineClipWindow(ln, blueprint.scenes);
17620
+ const breakRun = !cur || breaksPhrase(cur, ln, lineCover, lineClipStart, blueprint.scenes);
17370
17621
  if (breakRun || !cur) {
17371
17622
  flush();
17372
17623
  cur = {
@@ -17377,6 +17628,7 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
17377
17628
  coverEnd: lineCover,
17378
17629
  clipStart: lineClipStart,
17379
17630
  texts: [ln.text],
17631
+ lastShownScene: ln.shown ? ln.sceneIndex : null,
17380
17632
  shown: /* @__PURE__ */ new Set()
17381
17633
  };
17382
17634
  } else {
@@ -17384,6 +17636,7 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
17384
17636
  cur.end = Math.max(cur.end, ln.end);
17385
17637
  cur.coverEnd = Math.max(cur.coverEnd, lineCover);
17386
17638
  cur.clipStart = Math.min(cur.clipStart, lineClipStart);
17639
+ if (ln.shown) cur.lastShownScene = ln.sceneIndex;
17387
17640
  }
17388
17641
  if (ln.shown) cur.shown.add(ln.sceneIndex);
17389
17642
  }
@@ -17491,19 +17744,12 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
17491
17744
  inputs: { clip: clipRef },
17492
17745
  params: { args: audioExtractArgs(extractLen, speechOffset), outputs: { audio: { kind: "audio", ext: "mp3" } } }
17493
17746
  });
17494
- const convId = `s${anchor}_conv`;
17495
- nodes.push({
17496
- id: convId,
17497
- type: "audio_voice_convert",
17498
- inputs: { audio: `$ref:s${anchor}_voextract.audio`, voice_ref: `$ref:${voiceNode}.voice_id` },
17499
- params: { model: FIXED_VOICE_CONVERT_MODEL, voice: "{{voice_ref}}" }
17500
- });
17501
- out.voTracks.push({
17502
- slot: convId,
17503
- ref: `$ref:${convId}.audio`,
17747
+ const convId = `${voiceNode}_conv`;
17748
+ out.nativeSegments.push({
17749
+ voiceNode,
17750
+ ref: `$ref:s${anchor}_voextract.audio`,
17504
17751
  start_s: phrase.start_s,
17505
- end_s: phrase.end_s,
17506
- kind: "vo"
17752
+ end_s: phrase.start_s + extractLen
17507
17753
  });
17508
17754
  out.voSegments.push({
17509
17755
  slot: convId,
@@ -17519,18 +17765,35 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
17519
17765
  est_speech_s: Math.round(estSpeechWindowS(phrase.text, phrase.start_s, phrase.end_s) * 100) / 100,
17520
17766
  speech_words: wordCount(phrase.text)
17521
17767
  });
17522
- for (const s of phrase.shownScenes) {
17523
- const sc = env.blueprint.scenes[s];
17524
- if (!sc) continue;
17525
- const rawOffset = (sc.start_s ?? clipStart) - clipStart;
17526
- out.sceneSlice.set(s, {
17768
+ registerMergedPhraseSlices(phrase, clipRef, clipStart, genDur, Boolean(chained), env, out);
17769
+ }
17770
+ function registerMergedPhraseSlices(phrase, clipRef, clipStart, genDur, chained, env, out) {
17771
+ const shown = [...phrase.shownScenes].sort((a, b) => a - b);
17772
+ let r = 0;
17773
+ let firstRegistered = true;
17774
+ while (r < shown.length) {
17775
+ const first = shown[r];
17776
+ let last = first;
17777
+ while (r + 1 < shown.length && shown[r + 1] === last + 1) last = shown[++r];
17778
+ r++;
17779
+ const firstSc = env.blueprint.scenes[first];
17780
+ if (!firstSc) continue;
17781
+ const firstStart = firstSc.start_s ?? clipStart;
17782
+ const rawOffset = firstStart - clipStart;
17783
+ const runEnd = env.blueprint.scenes[last]?.end_s ?? firstStart + sceneDurationS(firstSc);
17784
+ out.sceneSlice.set(first, {
17527
17785
  clipRef,
17528
- // Snap a sub-frame offset (line-start vs scene-start drift) to 0 so a single-scene
17529
- // phrase hits the whole-clip fast path instead of a needless re-encode + tiny shift.
17786
+ // Snap a sub-frame offset (line-start vs scene-start drift) to 0 so a run that tiles
17787
+ // the clip hits the whole-clip fast path instead of a needless re-encode + tiny shift.
17530
17788
  offset: rawOffset < 0.05 ? 0 : rawOffset,
17531
- len: sceneDurationS(sc),
17532
- clipDur: genDur
17789
+ len: Math.max(0.5, runEnd - firstStart),
17790
+ clipDur: genDur,
17791
+ ...firstRegistered && chained ? { continuesFrame: true } : {}
17533
17792
  });
17793
+ firstRegistered = false;
17794
+ for (let s = first + 1; s <= last; s++) {
17795
+ out.sceneSlice.set(s, { clipRef, offset: 0, len: 0, clipDur: genDur, skip: true });
17796
+ }
17534
17797
  }
17535
17798
  }
17536
17799
  function emitPhraseTts(phrase, voiceNode, idx, used, nodes, out, languageCode) {
@@ -17673,7 +17936,7 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
17673
17936
  return void 0;
17674
17937
  }
17675
17938
  if (!env.reuse && sceneIsFullScreenUi(scene, present)) {
17676
- emitScreenScene(i, scene, lengths, lengths.out, env.outAr, nodes, out.clips);
17939
+ emitScreenScene(i, scene, lengths, lengths.out, env.outAr, env.surfaceIngests, nodes, out.clips);
17677
17940
  return void 0;
17678
17941
  }
17679
17942
  const isCta = scene.narrative_role?.trim() === "cta" || isLast;
@@ -17685,7 +17948,8 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
17685
17948
  emitFlashHold(i, scene, env.slots, ctx, lengths, lengths.out, env.outAr, nodes, out.clips);
17686
17949
  return void 0;
17687
17950
  }
17688
- const first = scene.continues_previous && prevEndFrame ? prevEndFrame : buildFrameRef(
17951
+ const sharesPrevFrame = Boolean(scene.continues_previous && prevEndFrame);
17952
+ const first = sharesPrevFrame && prevEndFrame ? prevEndFrame : buildFrameRef(
17689
17953
  "start",
17690
17954
  scene.start_frame_asset?.url,
17691
17955
  scene.start_frame_prompt,
@@ -17733,9 +17997,26 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
17733
17997
  out.nativeSegments
17734
17998
  );
17735
17999
  }
17736
- out.clips.push(clip);
18000
+ out.clips.push(sharesPrevFrame ? { ...clip, continuesFrame: true } : clip);
17737
18001
  return last;
17738
18002
  }
18003
+ function emitPresenterSliceClip(i, slice, env, nodes, out) {
18004
+ if (slice.skip) return;
18005
+ const cont = slice.continuesFrame ? { continuesFrame: true } : {};
18006
+ const normDims = env.genAr !== env.outAr ? canvasDims(env.outAr) : void 0;
18007
+ const whole = slice.offset === 0 && Math.abs(slice.len - slice.clipDur) <= 0.05 && !normDims;
18008
+ if (whole) {
18009
+ out.clips.push({ ref: slice.clipRef, scene_s: slice.len, out: null, ...cont });
18010
+ return;
18011
+ }
18012
+ nodes.push({
18013
+ id: `s${i}_seg`,
18014
+ type: "ffmpeg",
18015
+ inputs: { clip: slice.clipRef },
18016
+ params: { args: trimArgs(slice.len, slice.offset, normDims), outputs: { video: { kind: "video", ext: "mp4" } } }
18017
+ });
18018
+ out.clips.push({ ref: `$ref:s${i}_seg.video`, scene_s: slice.len, out: null, ...cont });
18019
+ }
17739
18020
  function buildTimeline(blueprint, slots, opts, nodes) {
17740
18021
  const reuse = opts.frames === "reuse";
17741
18022
  const uiRouted = uiRoutedSceneSet(blueprint);
@@ -17808,22 +18089,7 @@ function buildTimeline(blueprint, slots, opts, nodes) {
17808
18089
  }
17809
18090
  const slice = out.sceneSlice.get(i);
17810
18091
  if (slice) {
17811
- const normDims = env.genAr !== env.outAr ? canvasDims(env.outAr) : void 0;
17812
- const whole = slice.offset === 0 && Math.abs(slice.len - slice.clipDur) <= 0.05 && !normDims;
17813
- if (whole) {
17814
- out.clips.push({ ref: slice.clipRef, scene_s: slice.len, out: null });
17815
- } else {
17816
- nodes.push({
17817
- id: `s${i}_seg`,
17818
- type: "ffmpeg",
17819
- inputs: { clip: slice.clipRef },
17820
- params: {
17821
- args: trimArgs(slice.len, slice.offset, normDims),
17822
- outputs: { video: { kind: "video", ext: "mp4" } }
17823
- }
17824
- });
17825
- out.clips.push({ ref: `$ref:s${i}_seg.video`, scene_s: slice.len, out: null });
17826
- }
18092
+ emitPresenterSliceClip(i, slice, env, nodes, out);
17827
18093
  prevEndFrame = void 0;
17828
18094
  return;
17829
18095
  }
@@ -18109,25 +18375,40 @@ function lastSceneEnd(blueprint) {
18109
18375
  for (const s of blueprint.scenes) end = Math.max(end, s.end_s ?? 0);
18110
18376
  return end > 0 ? end : 8;
18111
18377
  }
18112
- function concatArgs(count) {
18378
+ function seamDropOps(clips, i, seam) {
18379
+ if (seam === "off") return null;
18380
+ if (seam === "head" && i > 0 && clips[i]?.continuesFrame) return "trim=start_frame=1,setpts=PTS-STARTPTS";
18381
+ if (seam === "tail" && clips[i + 1]?.continuesFrame) return "reverse,trim=start_frame=1,setpts=PTS-STARTPTS,reverse";
18382
+ return null;
18383
+ }
18384
+ function concatArgs(clips, seam) {
18113
18385
  const inputs = [];
18114
- let labels = "";
18115
- for (let i = 0; i < count; i++) {
18386
+ const pre = [];
18387
+ const labels = [];
18388
+ clips.forEach((_, i) => {
18116
18389
  inputs.push("-i", `{{in.c${i}}}`);
18117
- labels += `[${i}:v]`;
18118
- }
18119
- return [...inputs, "-filter_complex", `${labels}concat=n=${count}:v=1:a=0[v]`, "-map", "[v]", "{{out.video}}"];
18390
+ const ops = seamDropOps(clips, i, seam);
18391
+ if (ops) {
18392
+ pre.push(`[${i}:v]${ops}[c${i}]`);
18393
+ labels.push(`[c${i}]`);
18394
+ } else {
18395
+ labels.push(`[${i}:v]`);
18396
+ }
18397
+ });
18398
+ const graph = [...pre, `${labels.join("")}concat=n=${clips.length}:v=1:a=0[v]`].join(";");
18399
+ return [...inputs, "-filter_complex", graph, "-map", "[v]", "{{out.video}}"];
18120
18400
  }
18121
18401
  function clipInputLen(c) {
18122
18402
  return c.scene_s + (c.out?.dur ?? 0);
18123
18403
  }
18124
- function xfadeSpineArgs(clips) {
18404
+ function xfadeSpineArgs(clips, seam) {
18125
18405
  const n = clips.length;
18126
18406
  const inputs = [];
18127
18407
  const filt = [];
18128
18408
  for (let i = 0; i < n; i++) {
18129
18409
  inputs.push("-i", `{{in.c${i}}}`);
18130
- filt.push(`[${i}:v]format=yuv420p,fps=30,setsar=1,settb=AVTB[c${i}]`);
18410
+ const ops = seamDropOps(clips, i, seam);
18411
+ filt.push(`[${i}:v]format=yuv420p,fps=30,setsar=1,settb=AVTB${ops ? `,${ops}` : ""}[c${i}]`);
18131
18412
  }
18132
18413
  let cur = "c0";
18133
18414
  let accLen = clipInputLen(clips[0]);
@@ -18149,13 +18430,13 @@ function xfadeSpineArgs(clips) {
18149
18430
  }
18150
18431
  return [...inputs, "-filter_complex", filt.join(";"), "-map", "[v]", "{{out.video}}"];
18151
18432
  }
18152
- function buildSpine(clips, nodes) {
18433
+ function buildSpine(clips, seam, nodes) {
18153
18434
  const inputs = {};
18154
18435
  clips.forEach((c, i) => {
18155
18436
  inputs[`c${i}`] = c.ref;
18156
18437
  });
18157
18438
  const hasTransition = clips.length > 1 && clips.some((c) => c.out);
18158
- const args = hasTransition ? xfadeSpineArgs(clips) : concatArgs(clips.length);
18439
+ const args = hasTransition ? xfadeSpineArgs(clips, seam) : concatArgs(clips, seam);
18159
18440
  nodes.push({
18160
18441
  id: "spine",
18161
18442
  type: "ffmpeg",
@@ -18164,16 +18445,24 @@ function buildSpine(clips, nodes) {
18164
18445
  });
18165
18446
  return "$ref:spine.video";
18166
18447
  }
18167
- function scaffoldVideoCanvas(input, elementsInput, opts) {
18168
- const blueprint = VideoBlueprint.parse(input);
18169
- injectHookPhysicality(blueprint);
18170
- const elements = RecurringElements.parse(elementsInput);
18171
- const nodes = [];
18448
+ function emitBlueprintIngests(opts, nodes) {
18172
18449
  nodes.push({
18173
18450
  id: "prompt",
18174
18451
  type: "ingest",
18175
18452
  params: { source: "path", path: opts.blueprintPath ?? "./prompt.json", expect: "json" }
18176
18453
  });
18454
+ nodes.push({
18455
+ id: "prompt_style",
18456
+ type: "ingest",
18457
+ params: { source: "path", path: opts.blueprintStylePath ?? "./prompt.style.json", expect: "json" }
18458
+ });
18459
+ }
18460
+ function scaffoldVideoCanvas(input, elementsInput, opts) {
18461
+ const blueprint = VideoBlueprint.parse(input);
18462
+ injectHookPhysicality(blueprint);
18463
+ const elements = RecurringElements.parse(elementsInput);
18464
+ const nodes = [];
18465
+ emitBlueprintIngests(opts, nodes);
18177
18466
  const slots = buildElementSlots(elements);
18178
18467
  extendPresenceByPromptMentions(slots, blueprint);
18179
18468
  slots.forEach((slot, i) => {
@@ -18185,7 +18474,7 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
18185
18474
  });
18186
18475
  buildElementSheets(slots, nodes);
18187
18476
  const { clips, voTracks, vo_segments, talking_scenes } = buildTimeline(blueprint, slots, opts, nodes);
18188
- let videoRef = buildSpine(clips, nodes);
18477
+ let videoRef = buildSpine(clips, opts.seamDedup ?? "head", nodes);
18189
18478
  let videoNode = "spine";
18190
18479
  const overlays = blueprint.scenes.flatMap((s) => s.overlays ?? []);
18191
18480
  const floating = blueprint.scenes.flatMap((s) => s.floating_elements ?? []);
@@ -18427,7 +18716,7 @@ function buildMotionBoard(blueprint) {
18427
18716
  });
18428
18717
  }
18429
18718
  var VIDEO_GUIDE = [
18430
- "Scaffolded by `baker canvas scaffold-video` \u2014 a runnable reproduction of your reference video, built like an editing timeline. The VOICE is cut at PAUSES, not at visual cuts: each continuous-speech PHRASE is ONE Seedance clip (native lip-sync + audio) re-voiced to one brand voice, so a sentence never breaks mid-word across a cut. Each scene's PICTURE is independent: a scene that SHOWS the speaker slices its window out of the phrase clip; a b-roll cutaway gets its own silent clip (or a still hold for a sub-2s flash) laid over the continuing voice; a pure-voiceover stretch is one ElevenLabs tts read. Every clip gets a CLEAN-PLATE start AND end keyframe (no baked text), RECAST to your dropped reference assets \u2014 Seedance interpolates real in-shot motion between them. Each frame grounds ONLY on its own extracted frame + el_* slots (never another generated frame), so all frames render in PARALLEL (no cross-frame cascade). A SPLIT-SCREEN / PICTURE-IN-PICTURE / KEYED-PRESENTER scene is reproduced as one clip PER REGION, stacked or overlaid (see `metadata.todo.composition`). On-screen text/graphics are a separate HTML overlay layer you paint; audio is the voice + SFX + a ducked music bed, normalized stereo. It is a STARTING POINT, not a locked render: add, delete, reorder, split, merge, or re-time scenes freely (a b-roll cutaway INSIDE a phrase lands at an approximate beat \u2014 nudge it) \u2014 see `metadata.todo.full_flexibility`.",
18719
+ "Scaffolded by `baker canvas scaffold-video` \u2014 a runnable reproduction of your reference video, built like an editing timeline. It is a sequence of clear SHOTS separated at COMPLETE BREAKS (hard cuts): two adjacent presenter shots at a cut are TWO clips, never glued into one take. What stays continuous is the VOICE \u2014 a voiceover narration is ONE read across the b-roll it plays over, and a b-roll CUTAWAY between two on-camera moments leaves the presenter shot continuous with the insert sliced in \u2014 and each person keeps ONE brand voice (all their clips' native audio re-voiced in a single per-speaker pass), so timbre holds across the cuts. A presenter shot is ONE Seedance clip (native lip-sync + audio); a pure-voiceover stretch is one ElevenLabs tts read; a sub-2s flash is a still hold. A single shot too long for one clip splits into takes that share a boundary frame \u2014 the spine drops the duplicated frame (`--seam-dedup head|tail|off`). Every clip gets a CLEAN-PLATE start AND end keyframe (no baked text), RECAST to your dropped reference assets \u2014 Seedance interpolates real in-shot motion between them. Each frame grounds ONLY on its own extracted frame + el_* slots (never another generated frame), so all frames render in PARALLEL (no cross-frame cascade). A SPLIT-SCREEN / PICTURE-IN-PICTURE / KEYED-PRESENTER scene is reproduced as one clip PER REGION, stacked or overlaid (see `metadata.todo.composition`). On-screen text/graphics are a separate HTML overlay layer you paint; audio is the voice + SFX + a ducked music bed, normalized stereo. It is a STARTING POINT, not a locked render: add, delete, reorder, split, merge, or re-time scenes freely \u2014 see `metadata.todo.full_flexibility`.",
18431
18720
  "",
18432
18721
  "WHAT TO DO NEXT:",
18433
18722
  "0. RE-CRAFT THE SCRIPT FIRST (don't clone). This reference already won in-market, but copying a video is much harder than a static: the hook is targeting and may not transfer, and the message must become TRUE for our brand. Work the `metadata.todo.script_recraft` checklist \u2014 for each scene judge its role (hook/body/CTA), decide keep/cut/reorder/replace, and re-author every line for OUR customer's pain + OUR offer. See `references/script-craft.md` (hook/body/CTA framework) and the `meta-ads-playbook` skill. Most of the work lives here.",
@@ -18511,9 +18800,9 @@ function buildVideoTodo(report, overlayCount, floatingCount, opts, blueprint) {
18511
18800
  voice_description: d.voice_description,
18512
18801
  line: d.line
18513
18802
  })),
18514
- talking_head_note: "PHRASE-NATIVE: a continuous-speech phrase where the speaker is shown is ONE Seedance clip (the full phrase quoted in s<anchor>_clip's prompt + generate_audio) so lips+voice are generated together \u2014 no tts, no veed-lipsync. Scenes that show the speaker slice their window out of that clip (s<i>_seg); edit the phrase line in the s<anchor>_clip prompt to re-author it. A pure-voiceover phrase (speaker never shown) is one ElevenLabs tts read instead.",
18515
- voice_note: "ONE voice per person: a single voice_select is reused across all that person's phrases (on-camera AND off \u2014 the deconstruct's `voiceover` label folds into the sole presenter). Each presenter phrase's native audio is re-voiced to that brand voice via audio_voice_convert (eleven_multilingual_sts_v2, one convert per phrase, timing preserved so lips stay matched). Set voice_select.voice_id's gender/language to match the creator.",
18516
- native_timing: "The voice is cut at PAUSES, not at visual cuts, so a sentence spanning a cut stays one continuous read (no mid-word break). The clip is generated long enough for the estimated speech; if a line runs longer than its phrase window the voice continues a beat into the following pause (natural VO continuity). `metadata.video.talking_scenes` carries each phrase's scene_s vs est_speech_s. CAVEAT: a b-roll cutaway INSIDE a phrase lands at an approximate (proportional) time \u2014 Seedance exposes no word timing \u2014 so if a cutaway is off its beat, nudge the scene boundary (it's a starting point).",
18803
+ talking_head_note: "SHOT-NATIVE: a presenter shot is ONE Seedance clip (its line quoted in s<anchor>_clip's prompt + generate_audio) so lips+voice are generated together \u2014 no tts, no veed-lipsync. Two adjacent presenter shots at a hard cut are SEPARATE clips; a cutaway phrase (the presenter on camera, cut to b-roll, back on camera) stays one clip and slices its on-camera windows (s<i>_seg). Edit the line in the s<anchor>_clip prompt to re-author it. A pure-voiceover phrase (speaker never shown) is one ElevenLabs tts read instead.",
18804
+ voice_note: "ONE voice per person: a single voice_select is reused across all that person's shots (on-camera AND off \u2014 the deconstruct's `voiceover` label folds into the sole presenter). Every presenter clip's native audio is extracted and re-voiced to that brand voice through a SINGLE merged audio_voice_convert per speaker (<voice>_conv, eleven_multilingual_sts_v2, timing preserved so lips stay matched) \u2014 so timbre stays consistent across the separate shot clips. Set voice_select.voice_id's gender/language to match the creator.",
18805
+ native_timing: "Clips separate at COMPLETE BREAKS between shots, but the VOICE stays continuous where it should: a voiceover narration is ONE read across the b-roll it plays over, and a cutaway leaves the presenter's read continuous under the insert. Each clip is generated long enough for its estimated speech. `metadata.video.talking_scenes` carries each shot's scene_s vs est_speech_s. CAVEAT: a b-roll cutaway INSIDE a phrase lands at an approximate (proportional) time \u2014 Seedance exposes no word timing \u2014 so if a cutaway is off its beat, nudge the scene boundary (it's a starting point).",
18517
18806
  craft: {
18518
18807
  note: "Production-craft principles that raise every clip's realism. Full rationale: references/video-craft.md (production craft); references/script-craft.md + meta-ads-playbook for the hook/message layer.",
18519
18808
  principles: [
@@ -18772,6 +19061,62 @@ function fail2(code, message) {
18772
19061
  `);
18773
19062
  process.exit(2);
18774
19063
  }
19064
+ var VIDEO_EXT_BY_MIME = {
19065
+ "video/mp4": ".mp4",
19066
+ "video/quicktime": ".mov",
19067
+ "video/webm": ".webm",
19068
+ "video/x-matroska": ".mkv"
19069
+ };
19070
+ function referenceVideoExt(url, contentType) {
19071
+ const fromPath = path12.extname(new URL(url).pathname).toLowerCase();
19072
+ if (fromPath && fromPath.length <= 5) return fromPath;
19073
+ const mime = (contentType ?? "").split(";")[0]?.trim().toLowerCase();
19074
+ return mime && VIDEO_EXT_BY_MIME[mime] || ".mp4";
19075
+ }
19076
+ async function fileExists2(target) {
19077
+ return access2(target).then(
19078
+ () => true,
19079
+ () => false
19080
+ );
19081
+ }
19082
+ function videoSourceReference(blueprint, fileArg2) {
19083
+ const bp = blueprint ?? {};
19084
+ const durable = typeof bp.source?.url === "string" ? bp.source.url : void 0;
19085
+ const original = /^https?:\/\//i.test(fileArg2) ? fileArg2 : void 0;
19086
+ const brand = bp.global?.branding?.brand_name;
19087
+ return { url: durable ?? original, advertiser: typeof brand === "string" && brand.trim() ? brand.trim() : void 0 };
19088
+ }
19089
+ function videoDefinitionDescription(blueprint) {
19090
+ const g = (blueprint ?? {}).global ?? {};
19091
+ const notes = g.reproduction_notes;
19092
+ if (typeof notes === "string" && notes.trim()) return notes.trim();
19093
+ const product = g.branding?.product;
19094
+ return typeof product === "string" && product.trim() ? product.trim() : void 0;
19095
+ }
19096
+ async function materializeReferenceVideo(fileArg2) {
19097
+ if (!/^https?:\/\//i.test(fileArg2)) return path12.resolve(fileArg2);
19098
+ let res;
19099
+ try {
19100
+ res = await fetch(fileArg2);
19101
+ } catch (e) {
19102
+ throw new Error(`failed to download reference video: ${e instanceof Error ? e.message : String(e)}`);
19103
+ }
19104
+ if (!res.ok) throw new Error(`failed to download reference video (${res.status} ${res.statusText})`);
19105
+ const bytes = Buffer.from(await res.arrayBuffer());
19106
+ if (bytes.length === 0) throw new Error("reference video download was empty");
19107
+ const dest = path12.join(
19108
+ tmpdir2(),
19109
+ `baker-ref-${sha256Hex(bytes).slice(0, 16)}${referenceVideoExt(fileArg2, res.headers.get("content-type"))}`
19110
+ );
19111
+ await writeFile3(dest, bytes);
19112
+ return dest;
19113
+ }
19114
+ function resolveSeamDedup(raw) {
19115
+ if (raw === void 0) return "head";
19116
+ const v = String(raw);
19117
+ if (v === "head" || v === "tail" || v === "off") return v;
19118
+ throw new Error(`--seam-dedup must be "head", "tail", or "off" (got "${v}")`);
19119
+ }
18775
19120
  function resolveModels2(args) {
18776
19121
  const pick = (flag, kind, fallback) => args[flag] ? String(args[flag]) : resolveModel2(kind, fallback);
18777
19122
  return {
@@ -18868,7 +19213,11 @@ var scaffoldVideoCommand = defineCommand90({
18868
19213
  description: "Turn a reference video into a runnable reproduction canvas in one command. Runs billed passes \u2014 video_deconstruct (the full scene-by-scene blueprint + transcript, baked to prompt.json as the editable 'prompt') and an AI selection of the video's RECURRING identity elements (person/animal/product/logo) \u2014 then scaffolds a pipeline where every scene boundary is a static-ad-grade frame (the blueprint as target_blueprint, a reference legend, the real frame as anchor) and each recurring element gets ONE shared [TODO] ingest slot wired into every frame it appears in. The clips feed Seedance an ultra-detailed motion brief (action, camera, dialogue, transcript). Edit prompt.json, drop the real source images, then `baker canvas run`."
18869
19214
  },
18870
19215
  args: {
18871
- file: { type: "positional", required: true, description: "Path to the reference video" },
19216
+ file: {
19217
+ type: "positional",
19218
+ required: true,
19219
+ description: "Reference video \u2014 a local path OR an http(s) URL (e.g. a winning-ads link). A URL is downloaded for you; pass --slug or --out with it."
19220
+ },
18872
19221
  out: { type: "string", description: "Output canvas path (default <video-dir>/<name>.video.canvas.json)" },
18873
19222
  slug: {
18874
19223
  type: "string",
@@ -18879,6 +19228,10 @@ var scaffoldVideoCommand = defineCommand90({
18879
19228
  type: "boolean",
18880
19229
  description: "Give silent b-roll scenes native diegetic ambient mixed deep under the music bed (off by default)"
18881
19230
  },
19231
+ "seam-dedup": {
19232
+ type: "string",
19233
+ description: `How to dedup the frame two clips SHARE when a long shot is split for length: "head" (default, drop the second clip's first frame), "tail" (drop the first clip's last frame), or "off" (keep both).`
19234
+ },
18882
19235
  "max-scenes": { type: "string", description: "Cap the number of scenes the deconstruct emits" },
18883
19236
  "shot-threshold": {
18884
19237
  type: "string",
@@ -18886,6 +19239,14 @@ var scaffoldVideoCommand = defineCommand90({
18886
19239
  },
18887
19240
  language: { type: "string", description: "Transcript/dialogue language hint (e.g. fr, en)" },
18888
19241
  focus: { type: "string", description: "Known provenance/emphasis to ground the deconstruct" },
19242
+ advertiser: {
19243
+ type: "string",
19244
+ description: "Source advertiser recorded in _definition.md (default: the brand the deconstruct identified)"
19245
+ },
19246
+ platform: {
19247
+ type: "string",
19248
+ description: "Ad platform for _definition.md (meta|google|linkedin|tiktok|youtube|x|other; default meta)"
19249
+ },
18889
19250
  "deconstruct-model": { type: "string", description: "Override the video_deconstruct model id" },
18890
19251
  "select-model": { type: "string", description: "Override the text_generate model id for element selection" },
18891
19252
  "image-model": { type: "string", description: "Override the image_generate model id for frames" },
@@ -18900,8 +19261,7 @@ var scaffoldVideoCommand = defineCommand90({
18900
19261
  }
18901
19262
  },
18902
19263
  async run({ args }) {
18903
- const videoPath = path12.resolve(String(args.file));
18904
- const base = path12.basename(videoPath, path12.extname(videoPath));
19264
+ const fileArg2 = String(args.file);
18905
19265
  const slug = args.slug ? String(args.slug) : void 0;
18906
19266
  if (slug && !isValidScaffoldSlug(slug)) {
18907
19267
  process.stderr.write(
@@ -18910,9 +19270,24 @@ var scaffoldVideoCommand = defineCommand90({
18910
19270
  );
18911
19271
  process.exit(2);
18912
19272
  }
19273
+ const isUrl = /^https?:\/\//i.test(fileArg2);
19274
+ if (isUrl && !slug && !args.out) {
19275
+ return fail2(
19276
+ "missing_output_target",
19277
+ "When the reference is a URL, pass --slug (writes src/creatives/<slug>/) or --out <path> so the scaffolded canvas has a home in the repo."
19278
+ );
19279
+ }
19280
+ let videoPath;
19281
+ try {
19282
+ videoPath = await materializeReferenceVideo(fileArg2);
19283
+ } catch (e) {
19284
+ return fail2("download", e instanceof Error ? e.message : String(e));
19285
+ }
19286
+ const base = path12.basename(videoPath, path12.extname(videoPath));
18913
19287
  const outPath = args.out ? path12.resolve(String(args.out)) : slug ? path12.join(process.cwd(), "src", "creatives", slug, `${slug}.canvas.json`) : path12.join(path12.dirname(videoPath), `${base}.video.canvas.json`);
18914
19288
  const outDir = path12.dirname(outPath);
18915
19289
  const blueprintPath = path12.join(outDir, "prompt.json");
19290
+ const blueprintStylePath = path12.join(outDir, "prompt.style.json");
18916
19291
  const frames = args.frames === "reuse" ? "reuse" : "generate";
18917
19292
  const maxScenes = args["max-scenes"] ? Number(args["max-scenes"]) : void 0;
18918
19293
  if (Number.isFinite(maxScenes)) {
@@ -18935,6 +19310,12 @@ var scaffoldVideoCommand = defineCommand90({
18935
19310
  const annotated = annotateBlueprintWithElements(blueprint, elements);
18936
19311
  await writeFile3(blueprintPath, `${JSON.stringify(annotated, null, 2)}
18937
19312
  `, "utf8");
19313
+ await writeFile3(
19314
+ blueprintStylePath,
19315
+ `${JSON.stringify(slimBlueprintForFrameStyle(annotated), null, 2)}
19316
+ `,
19317
+ "utf8"
19318
+ );
18938
19319
  let aspect;
18939
19320
  try {
18940
19321
  aspect = resolveAspect(
@@ -18974,8 +19355,10 @@ var scaffoldVideoCommand = defineCommand90({
18974
19355
  overlayCompositionPath: path12.relative(outDir, compositionDest),
18975
19356
  captionsCompositionPath: captions.compositionPath ? path12.relative(outDir, captions.compositionPath) : void 0,
18976
19357
  blueprintPath: path12.relative(outDir, blueprintPath),
19358
+ blueprintStylePath: path12.relative(outDir, blueprintStylePath),
18977
19359
  frames,
18978
19360
  ambient: Boolean(args.ambient),
19361
+ seamDedup: resolveSeamDedup(args["seam-dedup"]),
18979
19362
  ...args.aspect ? { aspect: String(args.aspect) } : {},
18980
19363
  ...args.resolution ? { resolution: String(args.resolution) } : {}
18981
19364
  };
@@ -19014,12 +19397,42 @@ var scaffoldVideoCommand = defineCommand90({
19014
19397
  process.exit(2);
19015
19398
  }
19016
19399
  await ensureGitignore(process.cwd(), ["canvas/", ".context/"]);
19400
+ const sourceRef = videoSourceReference(blueprint, fileArg2);
19401
+ if (slug) {
19402
+ const definitionPath = path12.join(outDir, "_definition.md");
19403
+ if (!await fileExists2(definitionPath)) {
19404
+ await writeFile3(
19405
+ definitionPath,
19406
+ buildCreativeDefinition({
19407
+ title: titleFromSlug(slug),
19408
+ kind: "video",
19409
+ platform: resolvePlatform(args.platform ? String(args.platform) : void 0),
19410
+ formats: resolveFormats(aspect.outAr),
19411
+ sourceReferenceUrl: sourceRef.url,
19412
+ sourceAdvertiser: args.advertiser ? String(args.advertiser) : sourceRef.advertiser,
19413
+ sourceKind: "video",
19414
+ description: videoDefinitionDescription(blueprint)
19415
+ }),
19416
+ "utf8"
19417
+ );
19418
+ }
19419
+ }
19420
+ if (slug) {
19421
+ await syncCreativeDefinitionBestEffort({
19422
+ slug,
19423
+ title: titleFromSlug(slug),
19424
+ formats: [aspect.outAr],
19425
+ canvas,
19426
+ sourceReferenceUrl: sourceRef.url
19427
+ });
19428
+ }
19017
19429
  process.stdout.write(
19018
19430
  `${JSON.stringify(
19019
19431
  {
19020
19432
  ok: true,
19021
19433
  canvas_path: outPath,
19022
19434
  prompt_path: blueprintPath,
19435
+ source_reference: sourceRef.url,
19023
19436
  composition_dir: compositionDest,
19024
19437
  output: canvas.output,
19025
19438
  frames_mode: frames,
@@ -19045,6 +19458,13 @@ var scaffoldVideoCommand = defineCommand90({
19045
19458
  scenes_clamped_to_15s: report.clamped_scenes,
19046
19459
  oversize_scenes: report.oversize_scenes,
19047
19460
  overstuffed_scenes: report.overstuffed_scenes,
19461
+ // A photoreal on-camera person/animal on Seedance can trip ByteDance's
19462
+ // real-person-likeness filter (422 content_policy_blocked, NON-retryable — no
19463
+ // prompt reframe clears it). Surface the escape BEFORE the billed run so a
19464
+ // face-heavy ad isn't discovered broken mid-render.
19465
+ ...report.elements.some((e) => e.type === "person" || e.type === "animal") && /seedance/i.test(videoModel) ? {
19466
+ content_policy_risk: "This ad has a photoreal on-camera cast generating on Seedance. ByteDance's real-person-likeness filter can reject a photoreal AI face with a NON-retryable 422 (content_policy_blocked) \u2014 no prompt change clears it. If clips fail that way, regenerate on Veo (re-run with `--video-model google/veo-3.1-fast`) or make the frame less photoreal."
19467
+ } : {},
19048
19468
  note: "Drop ONE real source image at each el_* [TODO] (reused across every frame that element appears in), confirm each voice_select casting, then `baker canvas validate` and `baker canvas run`. Running generates many billed image/video/audio assets \u2014 it is not free."
19049
19469
  }
19050
19470
  },