@koda-sl/baker-cli 0.121.0 → 0.122.0-dev.4a85b9f30

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
@@ -1,19 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ BackendClient,
3
4
  ELEVENLABS_MAX_MUSIC_LENGTH_MS,
4
5
  IMAGE_GENERATE_MODELS,
5
6
  LayerExecutionError,
6
7
  MODEL_REGISTRY,
8
+ REF_PREFIX,
7
9
  SEEDANCE_DURATIONS,
8
10
  ValidationError,
11
+ collectAssetRefLikes,
9
12
  createEngineFromEnv,
10
13
  defaultRegistry,
11
14
  describeFailureReason,
12
15
  elementMentionKeywords,
13
16
  generateCatalog,
17
+ isPersistedAssetRef,
18
+ parseRefExpr,
19
+ requireCredentialsFromEnv,
14
20
  resolveConcurrency,
21
+ sha256Hex,
22
+ toModelSafeImage,
23
+ ulid,
15
24
  validateCanvasDeep
16
- } from "./chunk-IWPAXJC3.js";
25
+ } from "./chunk-43KBQLP5.js";
17
26
  import {
18
27
  csvOrJson,
19
28
  daysAgoIso,
@@ -848,6 +857,7 @@ var LINKEDIN_LIMITS = {
848
857
  choiceOptionsMax: 30,
849
858
  choiceOptionTextMax: 100,
850
859
  thankYouMessageMax: 300,
860
+ privacyPolicyTextMax: 2e3,
851
861
  legalDisclaimerMax: 2e3,
852
862
  consentsMax: 5,
853
863
  // Campaign Manager caps disclosure checkboxes at 5
@@ -1442,6 +1452,7 @@ var leadFormFields = {
1442
1452
  /** Form language, e.g. { country: "US", language: "en" }. Defaults to the account locale on LinkedIn. */
1443
1453
  locale: z2.object({ country: z2.string().length(2), language: z2.string().length(2) }).optional(),
1444
1454
  privacyPolicyUrl: httpsUrlSchema,
1455
+ privacyPolicyText: z2.string().max(LEAD.privacyPolicyTextMax).optional(),
1445
1456
  questions: z2.array(leadFormQuestionSchema).min(1).max(LEAD.questionsMax),
1446
1457
  consents: z2.array(leadFormConsentSchema).max(LEAD.consentsMax).optional(),
1447
1458
  hiddenFields: z2.array(leadFormHiddenFieldSchema).max(LEAD.hiddenFieldsMax).optional(),
@@ -4662,11 +4673,11 @@ function rawTextEntries(value) {
4662
4673
  const values = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
4663
4674
  return values.filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
4664
4675
  }
4665
- function rawFileEntries(path12) {
4666
- if (typeof path12 !== "string" || path12.length === 0) {
4676
+ function rawFileEntries(path15) {
4677
+ if (typeof path15 !== "string" || path15.length === 0) {
4667
4678
  return [];
4668
4679
  }
4669
- return readFileSync2(path12, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
4680
+ return readFileSync2(path15, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
4670
4681
  }
4671
4682
  function keywordEntries(args) {
4672
4683
  const defaultMatch = typeof args["match-type"] === "string" ? args["match-type"].toUpperCase() : void 0;
@@ -4689,19 +4700,19 @@ function keywordEntries(args) {
4689
4700
  }
4690
4701
  return entries;
4691
4702
  }
4692
- function loadJsonFileArg(path12) {
4693
- if (typeof path12 !== "string" || path12.length === 0) {
4703
+ function loadJsonFileArg(path15) {
4704
+ if (typeof path15 !== "string" || path15.length === 0) {
4694
4705
  return {};
4695
4706
  }
4696
4707
  try {
4697
- const parsed = JSON.parse(readFileSync2(path12, "utf8"));
4708
+ const parsed = JSON.parse(readFileSync2(path15, "utf8"));
4698
4709
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4699
- failWriteValidation(`${path12} must contain a JSON object`);
4710
+ failWriteValidation(`${path15} must contain a JSON object`);
4700
4711
  }
4701
4712
  return parsed;
4702
4713
  } catch (err) {
4703
4714
  if (err instanceof SyntaxError) {
4704
- failWriteValidation(`${path12} is not valid JSON: ${err.message}`);
4715
+ failWriteValidation(`${path15} is not valid JSON: ${err.message}`);
4705
4716
  }
4706
4717
  throw err;
4707
4718
  }
@@ -4812,10 +4823,10 @@ async function stageUpdate(kind, customerId, target, payload) {
4812
4823
  async function stageTarget(kind, customerId, target) {
4813
4824
  await stageGoogleOp({ kind, customerId, target });
4814
4825
  }
4815
- async function draftAction(path12, body) {
4826
+ async function draftAction(path15, body) {
4816
4827
  try {
4817
4828
  const chatId = requireChatId();
4818
- const response = await apiPost(path12, { chatId, ...body });
4829
+ const response = await apiPost(path15, { chatId, ...body });
4819
4830
  writeJsonEnvelope(response);
4820
4831
  } catch (err) {
4821
4832
  handleGoogleError(err);
@@ -8570,19 +8581,19 @@ function failWriteValidation2(message) {
8570
8581
  writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
8571
8582
  process.exit(1);
8572
8583
  }
8573
- function loadJsonFileArg2(path12) {
8574
- if (typeof path12 !== "string" || path12.length === 0) {
8584
+ function loadJsonFileArg2(path15) {
8585
+ if (typeof path15 !== "string" || path15.length === 0) {
8575
8586
  return {};
8576
8587
  }
8577
8588
  try {
8578
- const parsed = JSON.parse(readFileSync6(path12, "utf8"));
8589
+ const parsed = JSON.parse(readFileSync6(path15, "utf8"));
8579
8590
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8580
- failWriteValidation2(`${path12} must contain a JSON object`);
8591
+ failWriteValidation2(`${path15} must contain a JSON object`);
8581
8592
  }
8582
8593
  return parsed;
8583
8594
  } catch (err) {
8584
8595
  if (err instanceof SyntaxError) {
8585
- failWriteValidation2(`${path12} is not valid JSON: ${err.message}`);
8596
+ failWriteValidation2(`${path15} is not valid JSON: ${err.message}`);
8586
8597
  }
8587
8598
  throw err;
8588
8599
  }
@@ -8667,15 +8678,15 @@ function parseLocaleFlag(value) {
8667
8678
  }
8668
8679
  return { language: match[1], country: match[2].toUpperCase() };
8669
8680
  }
8670
- function loadTargetingFileArg(path12) {
8671
- if (typeof path12 !== "string" || path12.length === 0) {
8681
+ function loadTargetingFileArg(path15) {
8682
+ if (typeof path15 !== "string" || path15.length === 0) {
8672
8683
  return void 0;
8673
8684
  }
8674
- const parsed = loadJsonFileArg2(path12);
8685
+ const parsed = loadJsonFileArg2(path15);
8675
8686
  const criteria = parsed.targetingCriteria ?? parsed;
8676
8687
  if (!criteria.include) {
8677
8688
  failWriteValidation2(
8678
- `${path12} must contain targeting criteria with an "include" block (see baker schema ads.linkedin.campaigns.create)`
8689
+ `${path15} must contain targeting criteria with an "include" block (see baker schema ads.linkedin.campaigns.create)`
8679
8690
  );
8680
8691
  }
8681
8692
  return criteria;
@@ -8710,14 +8721,14 @@ function parseCsvLine(line) {
8710
8721
  cells.push(current);
8711
8722
  return cells.map((cell) => cell.trim());
8712
8723
  }
8713
- function parseListFileArg(path12, maxRows) {
8714
- if (typeof path12 !== "string" || path12.length === 0) {
8724
+ function parseListFileArg(path15, maxRows) {
8725
+ if (typeof path15 !== "string" || path15.length === 0) {
8715
8726
  return void 0;
8716
8727
  }
8717
- const raw = readFileSync6(path12, "utf8");
8728
+ const raw = readFileSync6(path15, "utf8");
8718
8729
  const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
8719
8730
  if (lines.length < 2) {
8720
- failWriteValidation2(`${path12} needs a header row and at least one data row`);
8731
+ failWriteValidation2(`${path15} needs a header row and at least one data row`);
8721
8732
  }
8722
8733
  const columns = parseCsvLine(lines[0]).map((column) => column.trim());
8723
8734
  const rows = [];
@@ -8736,7 +8747,7 @@ function parseListFileArg(path12, maxRows) {
8736
8747
  }
8737
8748
  }
8738
8749
  if (rows.length > maxRows) {
8739
- failWriteValidation2(`${path12} has ${rows.length} rows \u2014 the inline limit is ${maxRows}. Split the list.`);
8750
+ failWriteValidation2(`${path15} has ${rows.length} rows \u2014 the inline limit is ${maxRows}. Split the list.`);
8740
8751
  }
8741
8752
  return { columns, rows };
8742
8753
  }
@@ -9381,7 +9392,7 @@ var leadFormsCreateCommand = defineCommand38({
9381
9392
  Required: name, headline (\u226460), privacyPolicyUrl, questions[] (\u226412; playbook: \u22644 for completion).
9382
9393
  Each question is a predefined profile field ({ name, predefinedField: "EMAIL" }) or a custom question ({ name, questionType: "MULTIPLE_CHOICE", options: [...] }; \u22643 custom).
9383
9394
  Best-practice fields the preview will nudge for if missing: 1-3 qualifying questions, consents[] (disclosure checkboxes), thankYou.message + thankYou.landingUrl|appointmentUrl.
9384
- Also supported: locale, formImageId|formImageUrn, hiddenFields[], legalDisclaimer, thankYou.cta. Example: baker ads linkedin lead-forms create --file form.json`
9395
+ Also supported: locale, formImageId|formImageUrn, privacyPolicyText, hiddenFields[], legalDisclaimer, thankYou.cta. Example: baker ads linkedin lead-forms create --file form.json`
9385
9396
  },
9386
9397
  args: {
9387
9398
  ...accountArgs,
@@ -10819,11 +10830,11 @@ var updateStatusSchema = z9.enum(UPDATE_STATUSES);
10819
10830
  function currencyMinimums2(currencyCode) {
10820
10831
  return CURRENCY_MINIMUMS2[currencyCode] ?? DEFAULT_CURRENCY_MINIMUM2;
10821
10832
  }
10822
- function validateDailyBudgetFloor(money, ctx, path12) {
10833
+ function validateDailyBudgetFloor(money, ctx, path15) {
10823
10834
  if (money?.currencyCode) {
10824
10835
  const min = currencyMinimums2(money.currencyCode).dailyBudgetMin;
10825
10836
  if (Number(money.amount) < min) {
10826
- ctx.addIssue({ code: "custom", path: path12, message: `below the ${min} ${money.currencyCode} daily minimum` });
10837
+ ctx.addIssue({ code: "custom", path: path15, message: `below the ${min} ${money.currencyCode} daily minimum` });
10827
10838
  }
10828
10839
  }
10829
10840
  }
@@ -11311,19 +11322,19 @@ function failWriteValidation3(message) {
11311
11322
  writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
11312
11323
  process.exit(1);
11313
11324
  }
11314
- function loadJsonFileArg3(path12) {
11315
- if (typeof path12 !== "string" || path12.length === 0) {
11325
+ function loadJsonFileArg3(path15) {
11326
+ if (typeof path15 !== "string" || path15.length === 0) {
11316
11327
  return {};
11317
11328
  }
11318
11329
  try {
11319
- const parsed = JSON.parse(readFileSync8(path12, "utf8"));
11330
+ const parsed = JSON.parse(readFileSync8(path15, "utf8"));
11320
11331
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
11321
- failWriteValidation3(`${path12} must contain a JSON object`);
11332
+ failWriteValidation3(`${path15} must contain a JSON object`);
11322
11333
  }
11323
11334
  return parsed;
11324
11335
  } catch (err) {
11325
11336
  if (err instanceof SyntaxError) {
11326
- failWriteValidation3(`${path12} is not valid JSON: ${err.message}`);
11337
+ failWriteValidation3(`${path15} is not valid JSON: ${err.message}`);
11327
11338
  }
11328
11339
  throw err;
11329
11340
  }
@@ -14385,8 +14396,8 @@ async function probeDuration(filePath) {
14385
14396
  }
14386
14397
 
14387
14398
  // src/commands/canvas/run.ts
14388
- import { readFile as readFile2 } from "fs/promises";
14389
- import path4 from "path";
14399
+ import { readFile as readFile3 } from "fs/promises";
14400
+ import path6 from "path";
14390
14401
  import { defineCommand as defineCommand88 } from "citty";
14391
14402
 
14392
14403
  // src/commands/canvas/placeholders.ts
@@ -14430,9 +14441,290 @@ function isResolvableRelative(value) {
14430
14441
  return typeof value === "string" && value.length > 0 && !value.includes("[TODO") && !path2.isAbsolute(value);
14431
14442
  }
14432
14443
 
14444
+ // src/commands/canvas/run-record.ts
14445
+ import path3 from "path";
14446
+ var MAX_RUN_NODES = 200;
14447
+ var MAX_OUTPUTS_PER_NODE = 10;
14448
+ var MAX_FINAL_OUTPUTS = 10;
14449
+ var MAX_PARAMS_PREVIEW_LENGTH = 4e3;
14450
+ function paramsPreviewFromParams(params) {
14451
+ if (params === void 0 || params === null) return void 0;
14452
+ const text = humanParamText(params, "prompt") ?? humanParamText(params, "source") ?? compactJson(params);
14453
+ if (!text) return void 0;
14454
+ return text.length > MAX_PARAMS_PREVIEW_LENGTH ? `${text.slice(0, MAX_PARAMS_PREVIEW_LENGTH - 1)}\u2026` : text;
14455
+ }
14456
+ function humanParamText(params, key) {
14457
+ const value = params[key];
14458
+ if (typeof value !== "string") return void 0;
14459
+ const trimmed = value.trim();
14460
+ return trimmed && !trimmed.startsWith("$ref:") ? trimmed : void 0;
14461
+ }
14462
+ function compactJson(params) {
14463
+ try {
14464
+ const json = JSON.stringify(params);
14465
+ return json && json !== "{}" ? json : void 0;
14466
+ } catch {
14467
+ return void 0;
14468
+ }
14469
+ }
14470
+ var MAX_CREATIVE_SLUG_LENGTH = 100;
14471
+ function creativeSlugFromCanvasPath(filePath) {
14472
+ const normalized = filePath.split(path3.sep).join("/");
14473
+ const match = normalized.match(/(?:^|\/)src\/creatives\/([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\//);
14474
+ const slug = match?.[1] ?? null;
14475
+ return slug && slug.length <= MAX_CREATIVE_SLUG_LENGTH ? slug : null;
14476
+ }
14477
+ var OUTPUT_KINDS = /* @__PURE__ */ new Set(["image", "video", "audio", "json", "text", "font"]);
14478
+ function toRecordOutput(slot, value) {
14479
+ const refs = collectAssetRefLikes(value);
14480
+ const ref = refs.length === 1 ? refs[0] : null;
14481
+ if (!ref || !isPersistedAssetRef(ref)) return null;
14482
+ const kind = typeof ref.kind === "string" && OUTPUT_KINDS.has(ref.kind) ? ref.kind : null;
14483
+ if (!kind) return null;
14484
+ return {
14485
+ slot,
14486
+ kind,
14487
+ sha256: ref.sha256,
14488
+ url: ref.url,
14489
+ mime: ref.mime,
14490
+ width: typeof ref.width === "number" ? ref.width : void 0,
14491
+ height: typeof ref.height === "number" ? ref.height : void 0,
14492
+ durationMs: typeof ref.duration_ms === "number" ? ref.duration_ms : void 0
14493
+ };
14494
+ }
14495
+ function nodeOutputsToRecord(nodeOutputs) {
14496
+ const out = [];
14497
+ for (const [slot, value] of Object.entries(nodeOutputs)) {
14498
+ if (Array.isArray(value)) {
14499
+ value.forEach((item, i) => {
14500
+ const rec = toRecordOutput(`${slot}#${i}`, item);
14501
+ if (rec) out.push(rec);
14502
+ });
14503
+ } else {
14504
+ const rec = toRecordOutput(slot, value);
14505
+ if (rec) out.push(rec);
14506
+ }
14507
+ }
14508
+ return out.slice(0, MAX_OUTPUTS_PER_NODE);
14509
+ }
14510
+ function finalOutputsToRecord(output) {
14511
+ if (Array.isArray(output)) {
14512
+ return output.map((item, i) => toRecordOutput(`final#${i}`, item)).filter((rec2) => rec2 !== null).slice(0, MAX_FINAL_OUTPUTS);
14513
+ }
14514
+ const rec = toRecordOutput("final", output);
14515
+ return rec ? [rec] : [];
14516
+ }
14517
+ function buildRunRecord(result, meta, plan) {
14518
+ const nodes = result.node_runs.slice(0, MAX_RUN_NODES).map((run) => {
14519
+ const planned = plan?.get(run.node_id);
14520
+ return {
14521
+ nodeId: run.node_id,
14522
+ nodeType: run.node_type,
14523
+ cached: run.cached,
14524
+ credits: run.credits,
14525
+ durationMs: run.duration_ms,
14526
+ outputs: nodeOutputsToRecord(result.outputs_by_node[run.node_id] ?? {}),
14527
+ deps: planned?.deps,
14528
+ status: plan ? "completed" : void 0,
14529
+ paramsPreview: planned?.paramsPreview
14530
+ };
14531
+ });
14532
+ const finalOutputs = finalOutputsToRecord(result.output);
14533
+ return {
14534
+ runId: result.run_id,
14535
+ creativeSlug: meta.creativeSlug,
14536
+ canvasPath: meta.canvasPath,
14537
+ canvasSha: meta.canvasSha,
14538
+ chatId: meta.chatId,
14539
+ status: "completed",
14540
+ stats: {
14541
+ totalNodes: result.stats.total_nodes,
14542
+ cachedNodes: result.stats.cached_nodes,
14543
+ totalCredits: result.stats.total_credits,
14544
+ durationMs: result.stats.duration_ms
14545
+ },
14546
+ nodes,
14547
+ finalOutputs: finalOutputs.length > 0 ? finalOutputs : void 0
14548
+ };
14549
+ }
14550
+ function buildFailedRunRecord(runId, errorMessage, meta) {
14551
+ return {
14552
+ runId,
14553
+ creativeSlug: meta.creativeSlug,
14554
+ canvasPath: meta.canvasPath,
14555
+ canvasSha: meta.canvasSha,
14556
+ chatId: meta.chatId,
14557
+ status: "failed",
14558
+ errorMessage: errorMessage.slice(0, 2e3),
14559
+ stats: { totalNodes: 0, cachedNodes: 0, totalCredits: 0, durationMs: 0 },
14560
+ nodes: []
14561
+ };
14562
+ }
14563
+
14564
+ // src/commands/canvas/run-progress.ts
14565
+ var RunProgressTracker = class {
14566
+ runId;
14567
+ meta;
14568
+ startedAt;
14569
+ nodes = /* @__PURE__ */ new Map();
14570
+ planned = false;
14571
+ constructor(runId, meta) {
14572
+ this.runId = runId;
14573
+ this.meta = meta;
14574
+ this.startedAt = Date.now();
14575
+ }
14576
+ apply(event) {
14577
+ if (event.kind === "plan") {
14578
+ for (const node of event.nodes) {
14579
+ this.nodes.set(node.node_id, {
14580
+ nodeId: node.node_id,
14581
+ nodeType: node.node_type,
14582
+ cached: false,
14583
+ credits: 0,
14584
+ durationMs: 0,
14585
+ outputs: [],
14586
+ deps: node.deps,
14587
+ status: "pending",
14588
+ paramsPreview: paramsPreviewFromParams(node.params)
14589
+ });
14590
+ }
14591
+ this.planned = true;
14592
+ return;
14593
+ }
14594
+ if (event.kind === "node_start") {
14595
+ this.patchNode(event.node_id, { status: "running" });
14596
+ return;
14597
+ }
14598
+ if (event.kind === "node_settled") {
14599
+ this.patchNode(event.run.node_id, {
14600
+ status: "completed",
14601
+ cached: event.run.cached,
14602
+ credits: event.run.credits,
14603
+ durationMs: event.run.duration_ms,
14604
+ outputs: nodeOutputsToRecord(event.outputs)
14605
+ });
14606
+ return;
14607
+ }
14608
+ this.patchNode(event.node_id, { status: "failed" });
14609
+ }
14610
+ /** True once the plan event landed — before that there is nothing worth posting. */
14611
+ hasPlan() {
14612
+ return this.planned;
14613
+ }
14614
+ /** Plan facts (deps + params preview) for stamping the terminal record's nodes. */
14615
+ planInfo() {
14616
+ const info = /* @__PURE__ */ new Map();
14617
+ for (const node of this.nodes.values()) {
14618
+ info.set(node.nodeId, { deps: node.deps ?? [], paramsPreview: node.paramsPreview });
14619
+ }
14620
+ return info;
14621
+ }
14622
+ /** The current in-flight state as a postable full record. */
14623
+ snapshot() {
14624
+ const nodes = [...this.nodes.values()];
14625
+ return {
14626
+ runId: this.runId,
14627
+ ...this.meta,
14628
+ status: "running",
14629
+ stats: {
14630
+ totalNodes: nodes.length,
14631
+ cachedNodes: nodes.filter((n) => n.cached).length,
14632
+ totalCredits: nodes.reduce((sum, n) => sum + n.credits, 0),
14633
+ durationMs: Date.now() - this.startedAt
14634
+ },
14635
+ nodes
14636
+ };
14637
+ }
14638
+ /**
14639
+ * Terminal record for a failed run, preserving what each node got to —
14640
+ * completed nodes keep their outputs so the graph shows exactly where the
14641
+ * run died instead of an empty husk.
14642
+ */
14643
+ failedSnapshot(errorMessage) {
14644
+ const snapshot = this.snapshot();
14645
+ return {
14646
+ ...snapshot,
14647
+ status: "failed",
14648
+ errorMessage: errorMessage.slice(0, 2e3),
14649
+ stats: { ...snapshot.stats, durationMs: Date.now() - this.startedAt }
14650
+ };
14651
+ }
14652
+ patchNode(nodeId, patch) {
14653
+ const existing = this.nodes.get(nodeId);
14654
+ if (!existing) return;
14655
+ this.nodes.set(nodeId, { ...existing, ...patch, status: patch.status ?? existing.status });
14656
+ }
14657
+ };
14658
+ var RunRecordPoster = class {
14659
+ post;
14660
+ latest = null;
14661
+ inflight = null;
14662
+ warned = false;
14663
+ keepaliveTimer = null;
14664
+ constructor(post) {
14665
+ this.post = post;
14666
+ }
14667
+ /** Queue a progress snapshot; returns immediately. */
14668
+ enqueue(payload) {
14669
+ this.latest = payload;
14670
+ if (!this.inflight) this.inflight = this.pump();
14671
+ }
14672
+ /**
14673
+ * Re-post the latest snapshot on an interval even with no new node events, so
14674
+ * the backend's `canvasRuns.updatedAt` heartbeat stays fresh during a long
14675
+ * single-clip poll (a video_generate clip can run minutes with no
14676
+ * intervening node events). When this process dies the keepalive stops → the
14677
+ * run's `updatedAt` goes stale → the backend reconciliation sweep force-fails
14678
+ * it as interrupted and surfaces the clips that finished. `produce` returns
14679
+ * null before the plan lands (nothing worth posting yet).
14680
+ */
14681
+ startKeepalive(produce, intervalMs = 6e4) {
14682
+ if (this.keepaliveTimer) return;
14683
+ this.keepaliveTimer = setInterval(() => {
14684
+ const snapshot = produce();
14685
+ if (snapshot) this.enqueue(snapshot);
14686
+ }, intervalMs);
14687
+ this.keepaliveTimer.unref?.();
14688
+ }
14689
+ stopKeepalive() {
14690
+ if (this.keepaliveTimer) {
14691
+ clearInterval(this.keepaliveTimer);
14692
+ this.keepaliveTimer = null;
14693
+ }
14694
+ }
14695
+ /**
14696
+ * Post the terminal record (awaited, errors surfaced to the caller). Any
14697
+ * queued progress snapshot is superseded — the terminal record is the full
14698
+ * state — but an in-flight POST is awaited first so it can't land after.
14699
+ */
14700
+ async flush(terminal) {
14701
+ this.stopKeepalive();
14702
+ this.latest = null;
14703
+ if (this.inflight) await this.inflight;
14704
+ await this.post(terminal);
14705
+ }
14706
+ async pump() {
14707
+ while (this.latest) {
14708
+ const payload = this.latest;
14709
+ this.latest = null;
14710
+ try {
14711
+ await this.post(payload);
14712
+ } catch (e) {
14713
+ if (!this.warned) {
14714
+ this.warned = true;
14715
+ const msg = e instanceof Error ? e.message : String(e);
14716
+ process.stderr.write(`[warn] live run progress not streaming (${msg})
14717
+ `);
14718
+ }
14719
+ }
14720
+ }
14721
+ this.inflight = null;
14722
+ }
14723
+ };
14724
+
14433
14725
  // src/commands/canvas/run-retention.ts
14434
14726
  import { rm } from "fs/promises";
14435
- import path3 from "path";
14727
+ import path4 from "path";
14436
14728
  function runDirsToPrune(entries, keep, currentRunId) {
14437
14729
  const runs = entries.filter((e) => /^r_[0-9A-Za-z]+$/.test(e) && e !== currentRunId).sort();
14438
14730
  if (keep <= 0) return runs;
@@ -14449,13 +14741,52 @@ async function pruneOldRuns(outputsDir, keep, currentRunId, log) {
14449
14741
  const toPrune = runDirsToPrune(entries, keep, currentRunId);
14450
14742
  if (toPrune.length === 0) return;
14451
14743
  for (const dir of toPrune) {
14452
- await rm(path3.join(outputsDir, dir), { recursive: true, force: true }).catch(
14744
+ await rm(path4.join(outputsDir, dir), { recursive: true, force: true }).catch(
14453
14745
  (e) => log(`[prune ] could not remove ${dir}: ${e.message}`)
14454
14746
  );
14455
14747
  }
14456
14748
  log(`[prune ] removed ${toPrune.length} old run dir(s), kept the ${keep} newest`);
14457
14749
  }
14458
14750
 
14751
+ // src/commands/canvas/run-resume.ts
14752
+ import { mkdir, readFile as readFile2, rm as rm2, writeFile } from "fs/promises";
14753
+ import path5 from "path";
14754
+ function markerPath(outputsDir, canvasPath) {
14755
+ const key = sha256Hex(Buffer.from(path5.resolve(canvasPath))).slice(0, 32);
14756
+ return path5.join(outputsDir, ".inflight", `${key}.json`);
14757
+ }
14758
+ async function resolveRunId(opts) {
14759
+ if (opts.explicitRunId) return { runId: opts.explicitRunId, resumed: false };
14760
+ if (!opts.fresh) {
14761
+ const existing = await readMarkerRunId(opts.outputsDir, opts.canvasPath);
14762
+ if (existing) return { runId: existing, resumed: true };
14763
+ }
14764
+ return { runId: `r_${ulid()}`, resumed: false };
14765
+ }
14766
+ async function readMarkerRunId(outputsDir, canvasPath) {
14767
+ try {
14768
+ const raw = await readFile2(markerPath(outputsDir, canvasPath), "utf8");
14769
+ const parsed = JSON.parse(raw);
14770
+ return typeof parsed.runId === "string" && parsed.runId.length > 0 ? parsed.runId : null;
14771
+ } catch {
14772
+ return null;
14773
+ }
14774
+ }
14775
+ async function markRunInFlight(outputsDir, canvasPath, runId) {
14776
+ try {
14777
+ const file = markerPath(outputsDir, canvasPath);
14778
+ await mkdir(path5.dirname(file), { recursive: true });
14779
+ await writeFile(file, JSON.stringify({ runId, canvasPath: path5.resolve(canvasPath), startedAt: Date.now() }));
14780
+ } catch {
14781
+ }
14782
+ }
14783
+ async function clearRunMarker(outputsDir, canvasPath) {
14784
+ try {
14785
+ await rm2(markerPath(outputsDir, canvasPath), { force: true });
14786
+ } catch {
14787
+ }
14788
+ }
14789
+
14459
14790
  // src/commands/canvas/run.ts
14460
14791
  var runCommand = defineCommand88({
14461
14792
  meta: { name: "run", description: "Validate and execute a canvas JSON file." },
@@ -14463,8 +14794,17 @@ var runCommand = defineCommand88({
14463
14794
  file: { type: "positional", required: true, description: "Path to canvas JSON" },
14464
14795
  "cache-dir": { type: "string", description: "Cache root (default ./canvas/.cache)" },
14465
14796
  "outputs-dir": { type: "string", description: "Per-run outputs root (default ./canvas)" },
14466
- "run-id": { type: "string", description: "Override run id" },
14797
+ "run-id": { type: "string", description: "Override run id (also resumes that run, re-attaching its in-flight jobs)" },
14798
+ fresh: {
14799
+ type: "boolean",
14800
+ default: false,
14801
+ description: "Ignore any interrupted-run marker and start a new run id instead of resuming"
14802
+ },
14467
14803
  "cache-policy": { type: "string", description: "read_write | bypass | read_only" },
14804
+ regenerate: {
14805
+ type: "string",
14806
+ 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."
14807
+ },
14468
14808
  concurrency: {
14469
14809
  type: "string",
14470
14810
  description: "Max nodes per layer in flight at once (default 5; env BAKER_CANVAS_CONCURRENCY)"
@@ -14476,11 +14816,23 @@ var runCommand = defineCommand88({
14476
14816
  "keep-runs": {
14477
14817
  type: "string",
14478
14818
  description: "After the run, prune old r_* run dirs, keeping the N newest (off by default)"
14819
+ },
14820
+ "remote-cache": {
14821
+ type: "string",
14822
+ description: "on | off \u2014 company-scoped remote cache + durable asset persistence (default on; env BAKER_CANVAS_REMOTE_CACHE)"
14823
+ },
14824
+ // citty consumes any `--no-<flag>` as a negation of `<flag>`, so the
14825
+ // opt-out spelling `--no-record` requires the flag to be named `record`
14826
+ // (a literal "no-record" arg would never receive a value).
14827
+ record: {
14828
+ type: "boolean",
14829
+ default: true,
14830
+ description: "Post the durable run-history record to Baker (disable with --no-record)"
14479
14831
  }
14480
14832
  },
14481
14833
  async run({ args }) {
14482
- const filePath = path4.resolve(String(args.file));
14483
- const raw = await readFile2(filePath, "utf8");
14834
+ const filePath = path6.resolve(String(args.file));
14835
+ const raw = await readFile3(filePath, "utf8");
14484
14836
  let parsed;
14485
14837
  try {
14486
14838
  parsed = JSON.parse(raw);
@@ -14490,7 +14842,7 @@ var runCommand = defineCommand88({
14490
14842
  `);
14491
14843
  process.exit(2);
14492
14844
  }
14493
- parsed = resolveRelativeCanvasPaths(parsed, path4.dirname(filePath));
14845
+ parsed = resolveRelativeCanvasPaths(parsed, path6.dirname(filePath));
14494
14846
  const pending = unsuppliedPlaceholderAssets(parsed);
14495
14847
  if (pending.length > 0) {
14496
14848
  process.stderr.write(
@@ -14510,26 +14862,86 @@ var runCommand = defineCommand88({
14510
14862
  );
14511
14863
  process.exit(2);
14512
14864
  }
14865
+ let regenerate;
14866
+ if (args.regenerate !== void 0) {
14867
+ const requested = String(args.regenerate).split(",").map((id) => id.trim()).filter((id) => id.length > 0);
14868
+ const known = new Set(canvasNodeIds(parsed));
14869
+ const unknown = requested.filter((id) => !known.has(id));
14870
+ if (unknown.length > 0) {
14871
+ process.stderr.write(
14872
+ `${JSON.stringify(
14873
+ {
14874
+ ok: false,
14875
+ error: {
14876
+ code: "unknown_regenerate_node",
14877
+ message: `--regenerate names node id(s) not in this canvas: ${unknown.join(", ")}. Known ids: ${[...known].join(", ")}`
14878
+ }
14879
+ },
14880
+ null,
14881
+ 2
14882
+ )}
14883
+ `
14884
+ );
14885
+ process.exit(2);
14886
+ }
14887
+ if (requested.length > 0) {
14888
+ regenerate = new Set(requested);
14889
+ process.stdout.write(`[regenerate] forcing fresh this run: ${[...regenerate].join(", ")} (+ downstream)
14890
+ `);
14891
+ }
14892
+ }
14893
+ const remoteCache = args["remote-cache"] !== void 0 ? String(args["remote-cache"]) !== "off" : void 0;
14513
14894
  const engine = createEngineFromEnv({
14514
14895
  cacheDir: args["cache-dir"] ? String(args["cache-dir"]) : void 0,
14515
14896
  outputsDir: args["outputs-dir"] ? String(args["outputs-dir"]) : void 0,
14516
14897
  log: (line) => process.stdout.write(`${line}
14517
- `)
14898
+ `),
14899
+ remoteCache
14900
+ });
14901
+ const outputsDir = args["outputs-dir"] ? path6.resolve(String(args["outputs-dir"])) : path6.resolve("canvas");
14902
+ const { runId, resumed } = await resolveRunId({
14903
+ explicitRunId: args["run-id"] ? String(args["run-id"]) : void 0,
14904
+ fresh: args.fresh === true,
14905
+ outputsDir,
14906
+ canvasPath: filePath
14518
14907
  });
14908
+ if (resumed) {
14909
+ process.stdout.write(`[resume] continuing interrupted run ${runId} \u2014 in-flight jobs re-attach, cached nodes skip
14910
+ `);
14911
+ }
14912
+ await markRunInFlight(outputsDir, filePath, runId);
14913
+ const recordMeta = {
14914
+ creativeSlug: creativeSlugFromCanvasPath(filePath) ?? void 0,
14915
+ canvasPath: path6.relative(process.cwd(), filePath) || void 0,
14916
+ canvasSha: sha256Hex(Buffer.from(raw)),
14917
+ chatId: getEnv().BAKER_CHAT_ID || void 0
14918
+ };
14919
+ const record = args.record === false ? null : buildRecorder();
14920
+ const progress = record ? new RunProgressTracker(runId, recordMeta) : null;
14921
+ const poster = record ? new RunRecordPoster(record) : null;
14922
+ if (progress && poster) {
14923
+ poster.startKeepalive(() => progress.hasPlan() ? progress.snapshot() : null);
14924
+ }
14519
14925
  try {
14520
14926
  const policy = args["cache-policy"] ?? "read_write";
14521
14927
  const result = await engine.run(parsed, {
14522
- run_id: args["run-id"] ? String(args["run-id"]) : void 0,
14928
+ run_id: runId,
14523
14929
  cache_policy: policy,
14524
14930
  concurrency: resolveConcurrency(
14525
14931
  // --concurrency wins; --parallel is the discoverable alias for the same bound.
14526
14932
  (args.concurrency ?? args.parallel) !== void 0 ? String(args.concurrency ?? args.parallel) : void 0,
14527
14933
  process.env.BAKER_CANVAS_CONCURRENCY
14528
- )
14934
+ ),
14935
+ regenerate,
14936
+ onProgress: progress && poster ? (event) => {
14937
+ progress.apply(event);
14938
+ if (progress.hasPlan()) poster.enqueue(progress.snapshot());
14939
+ } : void 0
14529
14940
  });
14941
+ await clearRunMarker(outputsDir, filePath);
14942
+ if (poster) await poster.flush(buildRunRecord(result, recordMeta, progress?.planInfo()));
14530
14943
  const keepRuns = args["keep-runs"] !== void 0 ? Number(args["keep-runs"]) : void 0;
14531
14944
  if (keepRuns !== void 0 && Number.isFinite(keepRuns)) {
14532
- const outputsDir = args["outputs-dir"] ? path4.resolve(String(args["outputs-dir"])) : path4.resolve("canvas");
14533
14945
  await pruneOldRuns(outputsDir, keepRuns, result.run_id, (line) => process.stdout.write(`${line}
14534
14946
  `));
14535
14947
  }
@@ -14548,6 +14960,7 @@ var runCommand = defineCommand88({
14548
14960
  `
14549
14961
  );
14550
14962
  } catch (e) {
14963
+ await clearRunMarker(outputsDir, filePath);
14551
14964
  if (e instanceof ValidationError) {
14552
14965
  process.stderr.write(
14553
14966
  `${JSON.stringify({ ok: false, error: { code: "validation", issues: e.issues } }, null, 2)}
@@ -14555,8 +14968,10 @@ var runCommand = defineCommand88({
14555
14968
  );
14556
14969
  process.exit(2);
14557
14970
  }
14971
+ const failedPayload = (message) => progress?.hasPlan() ? progress.failedSnapshot(message) : buildFailedRunRecord(runId, message, recordMeta);
14558
14972
  if (e instanceof LayerExecutionError) {
14559
14973
  const failures = e.failures.map((f) => ({ node_id: f.nodeId, message: describeFailureReason(f.reason) }));
14974
+ if (poster) await poster.flush(failedPayload(e.message));
14560
14975
  process.stderr.write(
14561
14976
  `${JSON.stringify({ ok: false, error: { code: "runtime", message: e.message, failures } }, null, 2)}
14562
14977
  `
@@ -14564,16 +14979,35 @@ var runCommand = defineCommand88({
14564
14979
  process.exit(1);
14565
14980
  }
14566
14981
  const msg = e instanceof Error ? e.message : String(e);
14982
+ if (poster) await poster.flush(failedPayload(msg));
14567
14983
  process.stderr.write(`${JSON.stringify({ ok: false, error: { code: "runtime", message: msg } }, null, 2)}
14568
14984
  `);
14569
14985
  process.exit(1);
14570
14986
  }
14571
14987
  }
14572
14988
  });
14989
+ function canvasNodeIds(parsed) {
14990
+ const nodes = parsed?.nodes;
14991
+ if (!Array.isArray(nodes)) return [];
14992
+ return nodes.map((node) => node?.id).filter((id) => typeof id === "string");
14993
+ }
14994
+ function buildRecorder() {
14995
+ return async (payload) => {
14996
+ try {
14997
+ const creds = requireCredentialsFromEnv();
14998
+ const client = new BackendClient({ baseUrl: creds.url, apiKey: creds.apiKey });
14999
+ await client.recordRun(payload);
15000
+ } catch (e) {
15001
+ const msg = e instanceof Error ? e.message : String(e);
15002
+ process.stderr.write(`[warn] run record not persisted (${msg})
15003
+ `);
15004
+ }
15005
+ };
15006
+ }
14573
15007
 
14574
15008
  // src/commands/canvas/scaffold-static-ad.ts
14575
- import { readFile as readFile3, writeFile } from "fs/promises";
14576
- import path6 from "path";
15009
+ import { access, mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
15010
+ import path9 from "path";
14577
15011
  import { defineCommand as defineCommand89 } from "citty";
14578
15012
 
14579
15013
  // src/engine/scaffold/staticAd.ts
@@ -14759,18 +15193,175 @@ function staticAdReport(input, elementsInput, opts) {
14759
15193
  };
14760
15194
  }
14761
15195
 
15196
+ // src/commands/canvas/creative-definition.ts
15197
+ import path7 from "path";
15198
+ var PLATFORM_VALUES = ["meta", "google", "linkedin", "tiktok", "youtube", "x", "other"];
15199
+ var FORMAT_VALUES = ["1:1", "4:5", "9:16", "16:9", "1.91:1"];
15200
+ function titleFromSlug(slug) {
15201
+ const title = slug.split("-").filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
15202
+ return title || slug;
15203
+ }
15204
+ function resolvePlatform(platform) {
15205
+ const value = platform?.trim();
15206
+ return value && PLATFORM_VALUES.includes(value) ? value : "meta";
15207
+ }
15208
+ function resolveFormats(aspect) {
15209
+ const value = aspect?.trim();
15210
+ return value && FORMAT_VALUES.includes(value) ? [value] : ["4:5"];
15211
+ }
15212
+ function referenceRelativePath(kind, ext) {
15213
+ const name = kind === "video" ? "source" : "original";
15214
+ return `references/${name}${ext}`;
15215
+ }
15216
+ function describeBlueprintIntent(blueprint) {
15217
+ const intent = blueprint?.ad_intent;
15218
+ if (typeof intent === "string" && intent.trim()) return intent.trim();
15219
+ if (intent && typeof intent === "object") {
15220
+ const summary = intent.summary ?? intent.feeling;
15221
+ if (typeof summary === "string" && summary.trim()) return summary.trim();
15222
+ }
15223
+ return void 0;
15224
+ }
15225
+ function yamlScalar(value) {
15226
+ return JSON.stringify(value);
15227
+ }
15228
+ function buildCreativeDefinition(input) {
15229
+ const lines = ["---", `title: ${yamlScalar(input.title)}`, `kind: ${input.kind}`, `platform: ${input.platform}`];
15230
+ lines.push(`formats: [${input.formats.map(yamlScalar).join(", ")}]`);
15231
+ lines.push(`status: ${input.status ?? "draft"}`);
15232
+ if (input.sourceReferenceUrl) lines.push(`sourceReferenceUrl: ${yamlScalar(input.sourceReferenceUrl)}`);
15233
+ if (input.sourceAdvertiser) lines.push(`sourceAdvertiser: ${yamlScalar(input.sourceAdvertiser)}`);
15234
+ if (input.sourceKind) lines.push(`sourceKind: ${input.sourceKind}`);
15235
+ if (input.sourcePath) lines.push(`sourcePath: ${yamlScalar(input.sourcePath)}`);
15236
+ lines.push("---", "");
15237
+ lines.push(input.description?.trim() || `${input.title} \u2014 canvas-built ${input.kind} ad for ${input.platform}.`);
15238
+ lines.push("");
15239
+ return lines.join("\n");
15240
+ }
15241
+
14762
15242
  // src/commands/canvas/scaffold-static-ad-paths.ts
14763
- import path5 from "path";
14764
- function resolveScaffoldStaticAdPaths(rawFile, out, cwd = process.cwd()) {
15243
+ import path8 from "path";
15244
+ function resolveScaffoldStaticAdPaths(rawFile, out, cwd = process.cwd(), slug) {
14765
15245
  const file = rawFile.trim();
14766
15246
  const imageIsUrl = /^https?:\/\//i.test(file);
14767
- const imageSource = imageIsUrl ? file : path5.resolve(cwd, file);
14768
- const outPath = out ? path5.resolve(cwd, out) : imageIsUrl ? path5.join(cwd, "static-ad.canvas.json") : path5.join(path5.dirname(imageSource), "static-ad.canvas.json");
14769
- const blueprintPath = path5.join(path5.dirname(outPath), "prompt.json");
14770
- return { imageIsUrl, imageSource, outPath, blueprintPath };
15247
+ const imageSource = imageIsUrl ? file : path8.resolve(cwd, file);
15248
+ const outPath = out ? path8.resolve(cwd, out) : slug ? path8.join(cwd, "src", "creatives", slug, `${slug}.canvas.json`) : imageIsUrl ? path8.join(cwd, "static-ad.canvas.json") : path8.join(path8.dirname(imageSource), "static-ad.canvas.json");
15249
+ const blueprintPath = path8.join(path8.dirname(outPath), "prompt.json");
15250
+ const creativeDir = slug ? path8.dirname(outPath) : null;
15251
+ const definitionPath = creativeDir ? path8.join(creativeDir, "_definition.md") : null;
15252
+ const referencesDir = creativeDir ? path8.join(creativeDir, "references") : null;
15253
+ return { imageIsUrl, imageSource, outPath, blueprintPath, creativeDir, definitionPath, referencesDir };
15254
+ }
15255
+ var SCAFFOLD_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
15256
+ var SCAFFOLD_SLUG_MAX_LENGTH = 100;
15257
+ function isValidScaffoldSlug(slug) {
15258
+ return slug.length <= SCAFFOLD_SLUG_MAX_LENGTH && SCAFFOLD_SLUG_PATTERN.test(slug);
15259
+ }
15260
+
15261
+ // src/commands/canvas/definition-graph.ts
15262
+ var MAX_NODES = 300;
15263
+ function walkStrings(value, cb) {
15264
+ if (typeof value === "string") {
15265
+ cb(value);
15266
+ return;
15267
+ }
15268
+ if (Array.isArray(value)) {
15269
+ for (const v of value) walkStrings(v, cb);
15270
+ return;
15271
+ }
15272
+ if (value && typeof value === "object") {
15273
+ for (const v of Object.values(value)) walkStrings(v, cb);
15274
+ }
15275
+ }
15276
+ function canvasToDefinitionGraph(canvas) {
15277
+ const rawNodes = canvas?.nodes;
15278
+ if (!Array.isArray(rawNodes)) return null;
15279
+ const parsed = [];
15280
+ for (const raw of rawNodes) {
15281
+ const id = raw?.id;
15282
+ const type = raw?.type;
15283
+ if (typeof id !== "string" || typeof type !== "string") continue;
15284
+ parsed.push({ id, type, inputs: raw.inputs, params: raw.params });
15285
+ if (parsed.length >= MAX_NODES) break;
15286
+ }
15287
+ if (parsed.length === 0) return null;
15288
+ const ids = new Set(parsed.map((n) => n.id));
15289
+ const nodes = parsed.map(({ id, type, inputs, params }) => {
15290
+ const deps = /* @__PURE__ */ new Set();
15291
+ const collect = (s) => {
15292
+ if (!s.startsWith(REF_PREFIX)) return;
15293
+ const expr = parseRefExpr(s);
15294
+ if (expr && expr.nodeId !== id && ids.has(expr.nodeId)) deps.add(expr.nodeId);
15295
+ };
15296
+ walkStrings(inputs, collect);
15297
+ walkStrings(params, collect);
15298
+ return deps.size > 0 ? { id, type, deps: [...deps] } : { id, type };
15299
+ });
15300
+ const rawOutput = canvas?.output;
15301
+ const outNode = rawOutput?.node;
15302
+ const outSlot = rawOutput?.output;
15303
+ const output = typeof outNode === "string" && typeof outSlot === "string" ? { node: outNode, output: outSlot } : void 0;
15304
+ return { nodes, output };
15305
+ }
15306
+
15307
+ // src/commands/canvas/sync-definition.ts
15308
+ async function syncCreativeDefinitionBestEffort(input) {
15309
+ const chatId = process.env.BAKER_CHAT_ID;
15310
+ if (!chatId) return;
15311
+ const graph = canvasToDefinitionGraph(input.canvas);
15312
+ if (!graph || graph.nodes.length === 0) return;
15313
+ try {
15314
+ const creds = requireCredentialsFromEnv();
15315
+ const client = new BackendClient({ baseUrl: creds.url, apiKey: creds.apiKey });
15316
+ await client.syncCreativeDefinition({
15317
+ slug: input.slug,
15318
+ title: input.title,
15319
+ platform: input.platform,
15320
+ formats: input.formats,
15321
+ sourceReferenceUrl: input.sourceReferenceUrl,
15322
+ graph,
15323
+ chatId
15324
+ });
15325
+ process.stdout.write(`[definition] synced workflow graph (${graph.nodes.length} nodes) \u2014 view it in the dashboard
15326
+ `);
15327
+ } catch (e) {
15328
+ const msg = e instanceof Error ? e.message : String(e);
15329
+ process.stderr.write(`[warn] workflow graph not synced (${msg})
15330
+ `);
15331
+ }
14771
15332
  }
14772
15333
 
14773
15334
  // src/commands/canvas/scaffold-static-ad.ts
15335
+ async function fileExists(target) {
15336
+ try {
15337
+ await access(target);
15338
+ return true;
15339
+ } catch {
15340
+ return false;
15341
+ }
15342
+ }
15343
+ var MODEL_SAFE_EXT_BY_MIME = {
15344
+ "image/png": ".png",
15345
+ "image/jpeg": ".jpg",
15346
+ "image/gif": ".gif",
15347
+ "image/webp": ".webp"
15348
+ };
15349
+ async function copySourceIntoReferences(source, isUrl, referencesDir) {
15350
+ await mkdir2(referencesDir, { recursive: true });
15351
+ let bytes;
15352
+ if (isUrl) {
15353
+ const res = await fetch(source);
15354
+ if (!res.ok) throw new Error(`failed to download source image (${res.status})`);
15355
+ bytes = Buffer.from(await res.arrayBuffer());
15356
+ } else {
15357
+ bytes = await readFile4(source);
15358
+ }
15359
+ const safe = await toModelSafeImage(bytes);
15360
+ const relPath = referenceRelativePath("image", MODEL_SAFE_EXT_BY_MIME[safe.mime] ?? ".png");
15361
+ const dest = path9.join(referencesDir, path9.basename(relPath));
15362
+ await writeFile2(dest, safe.bytes);
15363
+ return relPath;
15364
+ }
14774
15365
  function resolveModel(kind, preferred) {
14775
15366
  const ids = Object.keys(MODEL_REGISTRY[kind]);
14776
15367
  return ids.includes(preferred) ? preferred : ids[0] ?? preferred;
@@ -14813,7 +15404,7 @@ DROP background extras, decorative props, generic scenery, and anything small or
14813
15404
  For each kept element return: { "type": one of logo|product|person|animal|badge, "label": a short UPPER_SNAKE_CASE name (e.g. LOGO, PRODUCT, HERO_DOG, TRUSTPILOT), "description": a concrete reusable description to source/shoot the real asset (include the exact expression for a living subject, and its castable attributes \u2014 breed/species for an animal, apparent age band, apparent origin/ethnicity, and wardrobe/setting for a person \u2014 so it can be recast to fit OUR audience/market), "expression": the facial expression for a living subject or null, "reason": why it is identity-critical, "locator": the blueprint entry this element came from as { "collection": one of "subjects" | "people" | "brands_logos", "index": its 0-based position in that array } (people -> people; logos/badges -> brands_logos; products/animals/objects -> subjects). Output ONLY the JSON object.`;
14814
15405
  async function loadAssetText(ref, label) {
14815
15406
  const r = ref;
14816
- if (typeof r?.path === "string") return readFile3(r.path, "utf8");
15407
+ if (typeof r?.path === "string") return readFile4(r.path, "utf8");
14817
15408
  if (typeof r?.url === "string") {
14818
15409
  const res = await fetch(r.url);
14819
15410
  if (!res.ok) throw new Error(`failed to fetch ${label} (${res.status})`);
@@ -14930,6 +15521,16 @@ var scaffoldStaticAdCommand = defineCommand89({
14930
15521
  file: { type: "positional", required: true, description: "Path or http(s) URL to the source/inspiration image" },
14931
15522
  context: { type: "string", description: "Known provenance (advertiser, category, market) to ground the describe" },
14932
15523
  out: { type: "string", description: "Output canvas path (default <image-dir>/static-ad.canvas.json)" },
15524
+ slug: {
15525
+ type: "string",
15526
+ description: "Creative slug \u2014 writes the canvas to src/creatives/<slug>/<slug>.canvas.json (repo convention)"
15527
+ },
15528
+ title: { type: "string", description: "Creative title for _definition.md (default: title-cased slug)" },
15529
+ platform: {
15530
+ type: "string",
15531
+ description: "Ad platform for _definition.md (meta|google|linkedin|tiktok|youtube|x|other; default meta)"
15532
+ },
15533
+ advertiser: { type: "string", description: "Source advertiser recorded in _definition.md" },
14933
15534
  "describe-model": { type: "string", description: "Override the image_describe model id" },
14934
15535
  "select-model": { type: "string", description: "Override the text_generate model id for element selection" },
14935
15536
  "layout-model": { type: "string", description: "Override the text_generate model id for the layout pass" },
@@ -14938,10 +15539,21 @@ var scaffoldStaticAdCommand = defineCommand89({
14938
15539
  "skip-font": { type: "boolean", description: "Skip the brand-font \u2192 type-specimen slot" }
14939
15540
  },
14940
15541
  async run({ args }) {
14941
- const { imageIsUrl, imageSource, outPath, blueprintPath } = resolveScaffoldStaticAdPaths(
15542
+ const slug = args.slug ? String(args.slug) : void 0;
15543
+ if (slug && !isValidScaffoldSlug(slug)) {
15544
+ process.stderr.write(
15545
+ `${JSON.stringify({ ok: false, error: { code: "invalid_slug", message: "--slug must be lowercase kebab (a-z, 0-9, hyphens), max 100 chars" } }, null, 2)}
15546
+ `
15547
+ );
15548
+ process.exit(2);
15549
+ }
15550
+ const { imageIsUrl, imageSource, outPath, blueprintPath, definitionPath, referencesDir } = resolveScaffoldStaticAdPaths(
14942
15551
  String(args.file),
14943
- args.out ? String(args.out) : void 0
15552
+ args.out ? String(args.out) : void 0,
15553
+ process.cwd(),
15554
+ slug
14944
15555
  );
15556
+ await mkdir2(path9.dirname(outPath), { recursive: true });
14945
15557
  const { describeModel, selectModel, layoutModel, genModel } = resolveModels(args);
14946
15558
  const describeCanvas = buildDescribeCanvas(
14947
15559
  imageSource,
@@ -14956,13 +15568,23 @@ var scaffoldStaticAdCommand = defineCommand89({
14956
15568
  if (layout && annotated && typeof annotated === "object") {
14957
15569
  annotated.layout = layout;
14958
15570
  }
14959
- await writeFile(blueprintPath, `${JSON.stringify(annotated, null, 2)}
15571
+ await writeFile2(blueprintPath, `${JSON.stringify(annotated, null, 2)}
14960
15572
  `, "utf8");
15573
+ let canvasImagePath = imageSource;
15574
+ let canvasImageIsUrl = imageIsUrl;
15575
+ let canvasBlueprintPath = blueprintPath;
15576
+ let sourceRelPath;
15577
+ if (referencesDir) {
15578
+ sourceRelPath = await copySourceIntoReferences(imageSource, imageIsUrl, referencesDir);
15579
+ canvasImagePath = sourceRelPath;
15580
+ canvasImageIsUrl = false;
15581
+ canvasBlueprintPath = "./prompt.json";
15582
+ }
14961
15583
  const opts = {
14962
15584
  genModel,
14963
- imagePath: imageSource,
14964
- imageIsUrl,
14965
- blueprintPath,
15585
+ imagePath: canvasImagePath,
15586
+ imageIsUrl: canvasImageIsUrl,
15587
+ blueprintPath: canvasBlueprintPath,
14966
15588
  aspectRatio: args.aspect ? String(args.aspect) : void 0,
14967
15589
  includeFont: !args["skip-font"]
14968
15590
  };
@@ -14982,14 +15604,43 @@ var scaffoldStaticAdCommand = defineCommand89({
14982
15604
  );
14983
15605
  process.exit(2);
14984
15606
  }
14985
- await writeFile(outPath, `${JSON.stringify(canvas, null, 2)}
15607
+ await writeFile2(outPath, `${JSON.stringify(canvas, null, 2)}
14986
15608
  `, "utf8");
15609
+ if (definitionPath && !await fileExists(definitionPath)) {
15610
+ await writeFile2(
15611
+ definitionPath,
15612
+ buildCreativeDefinition({
15613
+ title: args.title ? String(args.title) : titleFromSlug(slug ?? ""),
15614
+ kind: "static",
15615
+ platform: resolvePlatform(args.platform ? String(args.platform) : void 0),
15616
+ formats: resolveFormats(args.aspect ? String(args.aspect) : report.aspect_ratio),
15617
+ sourceReferenceUrl: imageIsUrl ? imageSource : void 0,
15618
+ sourceAdvertiser: args.advertiser ? String(args.advertiser) : args.context ? String(args.context) : void 0,
15619
+ sourceKind: "image",
15620
+ sourcePath: sourceRelPath,
15621
+ description: describeBlueprintIntent(blueprint)
15622
+ }),
15623
+ "utf8"
15624
+ );
15625
+ }
15626
+ if (slug) {
15627
+ await syncCreativeDefinitionBestEffort({
15628
+ slug,
15629
+ title: args.title ? String(args.title) : titleFromSlug(slug),
15630
+ platform: resolvePlatform(args.platform ? String(args.platform) : void 0),
15631
+ formats: resolveFormats(args.aspect ? String(args.aspect) : report.aspect_ratio),
15632
+ sourceReferenceUrl: imageIsUrl ? imageSource : void 0,
15633
+ canvas
15634
+ });
15635
+ }
14987
15636
  process.stdout.write(
14988
15637
  `${JSON.stringify(
14989
15638
  {
14990
15639
  ok: true,
14991
15640
  canvas_path: outPath,
14992
15641
  prompt_path: blueprintPath,
15642
+ definition_path: definitionPath ?? void 0,
15643
+ source_reference: sourceRelPath ?? void 0,
14993
15644
  output: canvas.output,
14994
15645
  models: { describe: describeModel, select: selectModel, layout: layoutModel, gen: opts.genModel },
14995
15646
  aspect_ratio: report.aspect_ratio,
@@ -15000,10 +15651,10 @@ var scaffoldStaticAdCommand = defineCommand89({
15000
15651
  run_estimated_credits: validation.estimatedCredits
15001
15652
  },
15002
15653
  checklist: {
15003
- edit_prompt: `Edit ${path6.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.`,
15654
+ 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.`,
15004
15655
  assets_to_supply: report.elements,
15005
15656
  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)",
15006
- 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."
15657
+ 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."
15007
15658
  }
15008
15659
  },
15009
15660
  null,
@@ -15015,13 +15666,14 @@ var scaffoldStaticAdCommand = defineCommand89({
15015
15666
  });
15016
15667
 
15017
15668
  // src/commands/canvas/scaffold-video.ts
15018
- import { cp, mkdir, readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
15019
- import path9 from "path";
15669
+ import { access as access2, cp, mkdir as mkdir3, readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
15670
+ import { tmpdir as tmpdir2 } from "os";
15671
+ import path12 from "path";
15020
15672
  import { defineCommand as defineCommand90 } from "citty";
15021
15673
 
15022
15674
  // src/engine/nodes/local/lib/sceneDetect.ts
15023
15675
  import { execFile as execFile2 } from "child_process";
15024
- import { mkdtemp, readdir as readdir2, readFile as readFile4, rm as rm2 } from "fs/promises";
15676
+ import { mkdtemp, readdir as readdir2, readFile as readFile5, rm as rm3 } from "fs/promises";
15025
15677
  import { tmpdir } from "os";
15026
15678
  import { join as join2 } from "path";
15027
15679
  import { promisify as promisify2 } from "util";
@@ -15097,9 +15749,9 @@ async function runSceneDetectOnce(filePath, threshold, minSceneLenS, timeoutMs)
15097
15749
  );
15098
15750
  const csvName = (await readdir2(outDir)).find((f) => f.toLowerCase().endsWith(".csv"));
15099
15751
  if (!csvName) return [];
15100
- return parsePySceneDetectCsvCuts(await readFile4(join2(outDir, csvName), "utf-8"));
15752
+ return parsePySceneDetectCsvCuts(await readFile5(join2(outDir, csvName), "utf-8"));
15101
15753
  } finally {
15102
- await rm2(outDir, { recursive: true, force: true });
15754
+ await rm3(outDir, { recursive: true, force: true });
15103
15755
  }
15104
15756
  }
15105
15757
  async function detectSceneCutsPySceneDetect(filePath, opts = {}) {
@@ -15724,6 +16376,13 @@ function slimBlueprintForSelection(blueprintInput) {
15724
16376
  }
15725
16377
  return out;
15726
16378
  }
16379
+ function slimBlueprintForFrameStyle(blueprintInput) {
16380
+ if (!blueprintInput || typeof blueprintInput !== "object" || Array.isArray(blueprintInput)) return blueprintInput;
16381
+ const bp = blueprintInput;
16382
+ const out = {};
16383
+ for (const k of ["version", "source", "global", "reference_elements"]) if (k in bp) out[k] = bp[k];
16384
+ return out;
16385
+ }
15727
16386
  function roleForType2(type) {
15728
16387
  switch (type.toLowerCase()) {
15729
16388
  case "logo":
@@ -15981,9 +16640,10 @@ function buildFrameRef(edge, url, framePrompt, present, ctx, nodes) {
15981
16640
  id: genId,
15982
16641
  type: "image_generate",
15983
16642
  // `params.prompt` is this frame's authoritative, edit-per-frame description.
15984
- // `target_blueprint` is the shared ad spec (cast identity, palette, brand, type)
15985
- // the frame must stay consistent with editing one frame never touches another.
15986
- inputs: { target_blueprint: "$ref:prompt.asset", ...reference.length > 0 ? { reference } : {} },
16643
+ // `target_blueprint` is the SLIM shared ad spec (global cast identity, palette, brand,
16644
+ // type — no per-scene content) the frame must stay consistent with; editing one frame
16645
+ // never touches another, and no image inlines the whole film to render one frame.
16646
+ inputs: { target_blueprint: "$ref:prompt_style.asset", ...reference.length > 0 ? { reference } : {} },
15987
16647
  params: genParams
15988
16648
  });
15989
16649
  return `$ref:${genId}.images#0`;
@@ -16505,18 +17165,24 @@ function emitFlashHold(i, scene, slots, ctx, lengths, out, outAr, nodes, clips)
16505
17165
  });
16506
17166
  clips.push({ ref: `$ref:s${i}_clip.video`, scene_s: lengths.dur, out });
16507
17167
  }
16508
- function emitScreenScene(i, scene, lengths, out, outAr, nodes, clips) {
16509
- const label = commentSafe((scene.summary || scene.start_frame_prompt || "the app screen").slice(0, 120));
16510
- const refId = `s${i}_screen_ref`;
16511
- nodes.push({
16512
- id: refId,
16513
- type: "ingest",
16514
- params: {
16515
- source: "path",
16516
- 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]`,
16517
- expect: "image"
16518
- }
16519
- });
17168
+ function emitScreenScene(i, scene, lengths, out, outAr, surfaceIngests, nodes, clips) {
17169
+ const regions = (scene.composition?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
17170
+ const surfaceId = regions.find((r) => r.surface_id)?.surface_id;
17171
+ let refId = surfaceId ? surfaceIngests.get(surfaceId) : void 0;
17172
+ if (!refId) {
17173
+ const label = commentSafe((scene.summary || scene.start_frame_prompt || "the app screen").slice(0, 120));
17174
+ refId = `s${i}_screen_ref`;
17175
+ nodes.push({
17176
+ id: refId,
17177
+ type: "ingest",
17178
+ params: {
17179
+ source: "path",
17180
+ 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]`,
17181
+ expect: "image"
17182
+ }
17183
+ });
17184
+ if (surfaceId) surfaceIngests.set(surfaceId, refId);
17185
+ }
16520
17186
  nodes.push({
16521
17187
  id: `s${i}_clip`,
16522
17188
  type: "ffmpeg",
@@ -16760,9 +17426,16 @@ function makePresenterPresent(slots, canonical, opts = {}) {
16760
17426
  return presence.has(sceneIndex);
16761
17427
  };
16762
17428
  }
16763
- var PAUSE_GAP_S = 0.6;
16764
17429
  var SEEDANCE_SAFE_MAX_S = SEEDANCE_DURATIONS.find((d) => d >= 10) ?? 10;
16765
17430
  var PHRASE_MAX_S = SEEDANCE_SAFE_MAX_S;
17431
+ var PAUSE_GAP_S = 0.6;
17432
+ function isAdjacentShownCut(ln, lastShownScene, scenes) {
17433
+ if (!ln.shown || lastShownScene === null) return false;
17434
+ return ln.sceneIndex === lastShownScene + 1 && scenes[ln.sceneIndex]?.continues_previous !== true;
17435
+ }
17436
+ function breaksPhrase(cur, ln, lineCover, lineClipStart, scenes) {
17437
+ 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;
17438
+ }
16766
17439
  var JOIN_DEDUP_MAX_WORDS = 4;
16767
17440
  function joinKey(word) {
16768
17441
  return word.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
@@ -16805,10 +17478,7 @@ function collapseVoiceover(blueprint) {
16805
17478
  const presenter = [...presenters][0];
16806
17479
  return (speaker) => NARRATOR_SPEAKERS.has(speaker.toLowerCase()) ? presenter : speaker;
16807
17480
  }
16808
- function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, presentStrict) {
16809
- const casts = castIdSet(blueprint);
16810
- const cameraOn = onCameraDialogue(blueprint);
16811
- const sceneEndS = (i) => blueprint.scenes[i]?.end_s ?? blueprint.scenes[i]?.start_s ?? 0;
17481
+ function multiSpeakerScenes(blueprint, casts, cameraOn, canonical, presentStrict) {
16812
17482
  const multiSpeaker = /* @__PURE__ */ new Set();
16813
17483
  blueprint.scenes.forEach((scene, i) => {
16814
17484
  const onCamAll = new Set(
@@ -16818,32 +17488,45 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
16818
17488
  const effective = onCamPresent.length > 0 ? new Set(onCamPresent) : onCamAll;
16819
17489
  if (effective.size >= 2) multiSpeaker.add(i);
16820
17490
  });
16821
- const lines = blueprint.scenes.flatMap(
16822
- (scene, sceneIndex) => compositeScenes.has(sceneIndex) ? [] : (scene.dialogue ?? []).filter((l) => Boolean(l.line?.trim())).map((l) => {
17491
+ return multiSpeaker;
17492
+ }
17493
+ function lineClipWindow(ln, scenes) {
17494
+ if (!ln.shown) return { cover: ln.end, clipStart: ln.start };
17495
+ const sc = scenes[ln.sceneIndex];
17496
+ const sceneEnd = sc?.end_s ?? sc?.start_s ?? 0;
17497
+ return { cover: Math.max(ln.end, sceneEnd), clipStart: Math.min(ln.start, sc?.start_s ?? ln.start) };
17498
+ }
17499
+ function dialogueLines(blueprint, ctx) {
17500
+ return blueprint.scenes.flatMap((scene, sceneIndex) => {
17501
+ if (ctx.compositeScenes.has(sceneIndex)) return [];
17502
+ return (scene.dialogue ?? []).filter((l) => Boolean(l.line?.trim())).map((l) => {
16823
17503
  const raw = l.speaker ?? "voiceover";
16824
- const sp = canonical(raw);
16825
17504
  const text = l.line.trim();
16826
17505
  const start = l.start_s ?? scene.start_s ?? 0;
17506
+ const shown = l.on_camera !== false && !sceneIsAllGraphic(scene) && isOnCameraSpeaker(raw, ctx.casts, ctx.cameraOn) && !ctx.multiSpeaker.has(sceneIndex) && ctx.presenterPresent(ctx.canonical(raw), sceneIndex);
16827
17507
  return {
16828
17508
  sceneIndex,
16829
- speaker: sp,
16830
- // Shown = a cast member speaking AND their element is actually on screen
16831
- // here (not a cutaway). A b-roll cutaway mid-phrase fails this and gets
16832
- // its own clip while the phrase voice plays under it. An explicit
16833
- // deconstruct voiceover stamp (`on_camera: false`) wins over element
16834
- // presence — a speaker pictured in a photo is "present" but not talking.
16835
- // An all-graphic composition (no camera region) is voiceover by
16836
- // definition: nobody is on screen to lip-sync.
16837
- shown: l.on_camera !== false && !sceneIsAllGraphic(scene) && isOnCameraSpeaker(raw, casts, cameraOn) && !multiSpeaker.has(sceneIndex) && presenterPresent(sp, sceneIndex),
17509
+ speaker: ctx.canonical(raw),
17510
+ shown,
16838
17511
  start,
16839
- // Real speech end. When the deconstruct gives no end_s, estimate it from
16840
- // the words — NOT the scene end (which would fabricate continuity across
16841
- // a long silent b-roll gap and wrongly merge two separate phrases).
16842
17512
  end: l.end_s ?? start + estSpeechS(text),
16843
17513
  text
16844
17514
  };
16845
- })
16846
- ).sort((a, b) => a.start - b.start);
17515
+ });
17516
+ }).sort((a, b) => a.start - b.start);
17517
+ }
17518
+ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, presentStrict) {
17519
+ const casts = castIdSet(blueprint);
17520
+ const cameraOn = onCameraDialogue(blueprint);
17521
+ const multiSpeaker = multiSpeakerScenes(blueprint, casts, cameraOn, canonical, presentStrict);
17522
+ const lines = dialogueLines(blueprint, {
17523
+ compositeScenes,
17524
+ multiSpeaker,
17525
+ canonical,
17526
+ casts,
17527
+ cameraOn,
17528
+ presenterPresent
17529
+ });
16847
17530
  const phrases = [];
16848
17531
  let cur = null;
16849
17532
  const flush = () => {
@@ -16861,12 +17544,8 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
16861
17544
  cur = null;
16862
17545
  };
16863
17546
  for (const ln of lines) {
16864
- const lineCover = ln.shown ? Math.max(ln.end, sceneEndS(ln.sceneIndex)) : ln.end;
16865
- const lineClipStart = ln.shown ? Math.min(ln.start, blueprint.scenes[ln.sceneIndex]?.start_s ?? ln.start) : ln.start;
16866
- 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
16867
- // more than one Seedance clip splits into the next take here (at this scene's
16868
- // boundary, never mid-scene), so no segment ever reads past the generated clip.
16869
- Math.max(cur.coverEnd, lineCover) - Math.min(cur.clipStart, lineClipStart) > PHRASE_MAX_S;
17547
+ const { cover: lineCover, clipStart: lineClipStart } = lineClipWindow(ln, blueprint.scenes);
17548
+ const breakRun = !cur || breaksPhrase(cur, ln, lineCover, lineClipStart, blueprint.scenes);
16870
17549
  if (breakRun || !cur) {
16871
17550
  flush();
16872
17551
  cur = {
@@ -16877,6 +17556,7 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
16877
17556
  coverEnd: lineCover,
16878
17557
  clipStart: lineClipStart,
16879
17558
  texts: [ln.text],
17559
+ lastShownScene: ln.shown ? ln.sceneIndex : null,
16880
17560
  shown: /* @__PURE__ */ new Set()
16881
17561
  };
16882
17562
  } else {
@@ -16884,6 +17564,7 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
16884
17564
  cur.end = Math.max(cur.end, ln.end);
16885
17565
  cur.coverEnd = Math.max(cur.coverEnd, lineCover);
16886
17566
  cur.clipStart = Math.min(cur.clipStart, lineClipStart);
17567
+ if (ln.shown) cur.lastShownScene = ln.sceneIndex;
16887
17568
  }
16888
17569
  if (ln.shown) cur.shown.add(ln.sceneIndex);
16889
17570
  }
@@ -16991,19 +17672,12 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
16991
17672
  inputs: { clip: clipRef },
16992
17673
  params: { args: audioExtractArgs(extractLen, speechOffset), outputs: { audio: { kind: "audio", ext: "mp3" } } }
16993
17674
  });
16994
- const convId = `s${anchor}_conv`;
16995
- nodes.push({
16996
- id: convId,
16997
- type: "audio_voice_convert",
16998
- inputs: { audio: `$ref:s${anchor}_voextract.audio`, voice_ref: `$ref:${voiceNode}.voice_id` },
16999
- params: { model: FIXED_VOICE_CONVERT_MODEL, voice: "{{voice_ref}}" }
17000
- });
17001
- out.voTracks.push({
17002
- slot: convId,
17003
- ref: `$ref:${convId}.audio`,
17675
+ const convId = `${voiceNode}_conv`;
17676
+ out.nativeSegments.push({
17677
+ voiceNode,
17678
+ ref: `$ref:s${anchor}_voextract.audio`,
17004
17679
  start_s: phrase.start_s,
17005
- end_s: phrase.end_s,
17006
- kind: "vo"
17680
+ end_s: phrase.start_s + extractLen
17007
17681
  });
17008
17682
  out.voSegments.push({
17009
17683
  slot: convId,
@@ -17019,18 +17693,35 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
17019
17693
  est_speech_s: Math.round(estSpeechWindowS(phrase.text, phrase.start_s, phrase.end_s) * 100) / 100,
17020
17694
  speech_words: wordCount(phrase.text)
17021
17695
  });
17022
- for (const s of phrase.shownScenes) {
17023
- const sc = env.blueprint.scenes[s];
17024
- if (!sc) continue;
17025
- const rawOffset = (sc.start_s ?? clipStart) - clipStart;
17026
- out.sceneSlice.set(s, {
17696
+ registerMergedPhraseSlices(phrase, clipRef, clipStart, genDur, Boolean(chained), env, out);
17697
+ }
17698
+ function registerMergedPhraseSlices(phrase, clipRef, clipStart, genDur, chained, env, out) {
17699
+ const shown = [...phrase.shownScenes].sort((a, b) => a - b);
17700
+ let r = 0;
17701
+ let firstRegistered = true;
17702
+ while (r < shown.length) {
17703
+ const first = shown[r];
17704
+ let last = first;
17705
+ while (r + 1 < shown.length && shown[r + 1] === last + 1) last = shown[++r];
17706
+ r++;
17707
+ const firstSc = env.blueprint.scenes[first];
17708
+ if (!firstSc) continue;
17709
+ const firstStart = firstSc.start_s ?? clipStart;
17710
+ const rawOffset = firstStart - clipStart;
17711
+ const runEnd = env.blueprint.scenes[last]?.end_s ?? firstStart + sceneDurationS(firstSc);
17712
+ out.sceneSlice.set(first, {
17027
17713
  clipRef,
17028
- // Snap a sub-frame offset (line-start vs scene-start drift) to 0 so a single-scene
17029
- // phrase hits the whole-clip fast path instead of a needless re-encode + tiny shift.
17714
+ // Snap a sub-frame offset (line-start vs scene-start drift) to 0 so a run that tiles
17715
+ // the clip hits the whole-clip fast path instead of a needless re-encode + tiny shift.
17030
17716
  offset: rawOffset < 0.05 ? 0 : rawOffset,
17031
- len: sceneDurationS(sc),
17032
- clipDur: genDur
17717
+ len: Math.max(0.5, runEnd - firstStart),
17718
+ clipDur: genDur,
17719
+ ...firstRegistered && chained ? { continuesFrame: true } : {}
17033
17720
  });
17721
+ firstRegistered = false;
17722
+ for (let s = first + 1; s <= last; s++) {
17723
+ out.sceneSlice.set(s, { clipRef, offset: 0, len: 0, clipDur: genDur, skip: true });
17724
+ }
17034
17725
  }
17035
17726
  }
17036
17727
  function emitPhraseTts(phrase, voiceNode, idx, used, nodes, out, languageCode) {
@@ -17173,7 +17864,7 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
17173
17864
  return void 0;
17174
17865
  }
17175
17866
  if (!env.reuse && sceneIsFullScreenUi(scene, present)) {
17176
- emitScreenScene(i, scene, lengths, lengths.out, env.outAr, nodes, out.clips);
17867
+ emitScreenScene(i, scene, lengths, lengths.out, env.outAr, env.surfaceIngests, nodes, out.clips);
17177
17868
  return void 0;
17178
17869
  }
17179
17870
  const isCta = scene.narrative_role?.trim() === "cta" || isLast;
@@ -17185,7 +17876,8 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
17185
17876
  emitFlashHold(i, scene, env.slots, ctx, lengths, lengths.out, env.outAr, nodes, out.clips);
17186
17877
  return void 0;
17187
17878
  }
17188
- const first = scene.continues_previous && prevEndFrame ? prevEndFrame : buildFrameRef(
17879
+ const sharesPrevFrame = Boolean(scene.continues_previous && prevEndFrame);
17880
+ const first = sharesPrevFrame && prevEndFrame ? prevEndFrame : buildFrameRef(
17189
17881
  "start",
17190
17882
  scene.start_frame_asset?.url,
17191
17883
  scene.start_frame_prompt,
@@ -17233,9 +17925,26 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
17233
17925
  out.nativeSegments
17234
17926
  );
17235
17927
  }
17236
- out.clips.push(clip);
17928
+ out.clips.push(sharesPrevFrame ? { ...clip, continuesFrame: true } : clip);
17237
17929
  return last;
17238
17930
  }
17931
+ function emitPresenterSliceClip(i, slice, env, nodes, out) {
17932
+ if (slice.skip) return;
17933
+ const cont = slice.continuesFrame ? { continuesFrame: true } : {};
17934
+ const normDims = env.genAr !== env.outAr ? canvasDims(env.outAr) : void 0;
17935
+ const whole = slice.offset === 0 && Math.abs(slice.len - slice.clipDur) <= 0.05 && !normDims;
17936
+ if (whole) {
17937
+ out.clips.push({ ref: slice.clipRef, scene_s: slice.len, out: null, ...cont });
17938
+ return;
17939
+ }
17940
+ nodes.push({
17941
+ id: `s${i}_seg`,
17942
+ type: "ffmpeg",
17943
+ inputs: { clip: slice.clipRef },
17944
+ params: { args: trimArgs(slice.len, slice.offset, normDims), outputs: { video: { kind: "video", ext: "mp4" } } }
17945
+ });
17946
+ out.clips.push({ ref: `$ref:s${i}_seg.video`, scene_s: slice.len, out: null, ...cont });
17947
+ }
17239
17948
  function buildTimeline(blueprint, slots, opts, nodes) {
17240
17949
  const reuse = opts.frames === "reuse";
17241
17950
  const uiRouted = uiRoutedSceneSet(blueprint);
@@ -17308,22 +18017,7 @@ function buildTimeline(blueprint, slots, opts, nodes) {
17308
18017
  }
17309
18018
  const slice = out.sceneSlice.get(i);
17310
18019
  if (slice) {
17311
- const normDims = env.genAr !== env.outAr ? canvasDims(env.outAr) : void 0;
17312
- const whole = slice.offset === 0 && Math.abs(slice.len - slice.clipDur) <= 0.05 && !normDims;
17313
- if (whole) {
17314
- out.clips.push({ ref: slice.clipRef, scene_s: slice.len, out: null });
17315
- } else {
17316
- nodes.push({
17317
- id: `s${i}_seg`,
17318
- type: "ffmpeg",
17319
- inputs: { clip: slice.clipRef },
17320
- params: {
17321
- args: trimArgs(slice.len, slice.offset, normDims),
17322
- outputs: { video: { kind: "video", ext: "mp4" } }
17323
- }
17324
- });
17325
- out.clips.push({ ref: `$ref:s${i}_seg.video`, scene_s: slice.len, out: null });
17326
- }
18020
+ emitPresenterSliceClip(i, slice, env, nodes, out);
17327
18021
  prevEndFrame = void 0;
17328
18022
  return;
17329
18023
  }
@@ -17609,25 +18303,40 @@ function lastSceneEnd(blueprint) {
17609
18303
  for (const s of blueprint.scenes) end = Math.max(end, s.end_s ?? 0);
17610
18304
  return end > 0 ? end : 8;
17611
18305
  }
17612
- function concatArgs(count) {
18306
+ function seamDropOps(clips, i, seam) {
18307
+ if (seam === "off") return null;
18308
+ if (seam === "head" && i > 0 && clips[i]?.continuesFrame) return "trim=start_frame=1,setpts=PTS-STARTPTS";
18309
+ if (seam === "tail" && clips[i + 1]?.continuesFrame) return "reverse,trim=start_frame=1,setpts=PTS-STARTPTS,reverse";
18310
+ return null;
18311
+ }
18312
+ function concatArgs(clips, seam) {
17613
18313
  const inputs = [];
17614
- let labels = "";
17615
- for (let i = 0; i < count; i++) {
18314
+ const pre = [];
18315
+ const labels = [];
18316
+ clips.forEach((_, i) => {
17616
18317
  inputs.push("-i", `{{in.c${i}}}`);
17617
- labels += `[${i}:v]`;
17618
- }
17619
- return [...inputs, "-filter_complex", `${labels}concat=n=${count}:v=1:a=0[v]`, "-map", "[v]", "{{out.video}}"];
18318
+ const ops = seamDropOps(clips, i, seam);
18319
+ if (ops) {
18320
+ pre.push(`[${i}:v]${ops}[c${i}]`);
18321
+ labels.push(`[c${i}]`);
18322
+ } else {
18323
+ labels.push(`[${i}:v]`);
18324
+ }
18325
+ });
18326
+ const graph = [...pre, `${labels.join("")}concat=n=${clips.length}:v=1:a=0[v]`].join(";");
18327
+ return [...inputs, "-filter_complex", graph, "-map", "[v]", "{{out.video}}"];
17620
18328
  }
17621
18329
  function clipInputLen(c) {
17622
18330
  return c.scene_s + (c.out?.dur ?? 0);
17623
18331
  }
17624
- function xfadeSpineArgs(clips) {
18332
+ function xfadeSpineArgs(clips, seam) {
17625
18333
  const n = clips.length;
17626
18334
  const inputs = [];
17627
18335
  const filt = [];
17628
18336
  for (let i = 0; i < n; i++) {
17629
18337
  inputs.push("-i", `{{in.c${i}}}`);
17630
- filt.push(`[${i}:v]format=yuv420p,fps=30,setsar=1,settb=AVTB[c${i}]`);
18338
+ const ops = seamDropOps(clips, i, seam);
18339
+ filt.push(`[${i}:v]format=yuv420p,fps=30,setsar=1,settb=AVTB${ops ? `,${ops}` : ""}[c${i}]`);
17631
18340
  }
17632
18341
  let cur = "c0";
17633
18342
  let accLen = clipInputLen(clips[0]);
@@ -17649,13 +18358,13 @@ function xfadeSpineArgs(clips) {
17649
18358
  }
17650
18359
  return [...inputs, "-filter_complex", filt.join(";"), "-map", "[v]", "{{out.video}}"];
17651
18360
  }
17652
- function buildSpine(clips, nodes) {
18361
+ function buildSpine(clips, seam, nodes) {
17653
18362
  const inputs = {};
17654
18363
  clips.forEach((c, i) => {
17655
18364
  inputs[`c${i}`] = c.ref;
17656
18365
  });
17657
18366
  const hasTransition = clips.length > 1 && clips.some((c) => c.out);
17658
- const args = hasTransition ? xfadeSpineArgs(clips) : concatArgs(clips.length);
18367
+ const args = hasTransition ? xfadeSpineArgs(clips, seam) : concatArgs(clips, seam);
17659
18368
  nodes.push({
17660
18369
  id: "spine",
17661
18370
  type: "ffmpeg",
@@ -17664,16 +18373,24 @@ function buildSpine(clips, nodes) {
17664
18373
  });
17665
18374
  return "$ref:spine.video";
17666
18375
  }
17667
- function scaffoldVideoCanvas(input, elementsInput, opts) {
17668
- const blueprint = VideoBlueprint.parse(input);
17669
- injectHookPhysicality(blueprint);
17670
- const elements = RecurringElements.parse(elementsInput);
17671
- const nodes = [];
18376
+ function emitBlueprintIngests(opts, nodes) {
17672
18377
  nodes.push({
17673
18378
  id: "prompt",
17674
18379
  type: "ingest",
17675
18380
  params: { source: "path", path: opts.blueprintPath ?? "./prompt.json", expect: "json" }
17676
18381
  });
18382
+ nodes.push({
18383
+ id: "prompt_style",
18384
+ type: "ingest",
18385
+ params: { source: "path", path: opts.blueprintStylePath ?? "./prompt.style.json", expect: "json" }
18386
+ });
18387
+ }
18388
+ function scaffoldVideoCanvas(input, elementsInput, opts) {
18389
+ const blueprint = VideoBlueprint.parse(input);
18390
+ injectHookPhysicality(blueprint);
18391
+ const elements = RecurringElements.parse(elementsInput);
18392
+ const nodes = [];
18393
+ emitBlueprintIngests(opts, nodes);
17677
18394
  const slots = buildElementSlots(elements);
17678
18395
  extendPresenceByPromptMentions(slots, blueprint);
17679
18396
  slots.forEach((slot, i) => {
@@ -17685,7 +18402,7 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
17685
18402
  });
17686
18403
  buildElementSheets(slots, nodes);
17687
18404
  const { clips, voTracks, vo_segments, talking_scenes } = buildTimeline(blueprint, slots, opts, nodes);
17688
- let videoRef = buildSpine(clips, nodes);
18405
+ let videoRef = buildSpine(clips, opts.seamDedup ?? "head", nodes);
17689
18406
  let videoNode = "spine";
17690
18407
  const overlays = blueprint.scenes.flatMap((s) => s.overlays ?? []);
17691
18408
  const floating = blueprint.scenes.flatMap((s) => s.floating_elements ?? []);
@@ -17927,7 +18644,7 @@ function buildMotionBoard(blueprint) {
17927
18644
  });
17928
18645
  }
17929
18646
  var VIDEO_GUIDE = [
17930
- "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`.",
18647
+ "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`.",
17931
18648
  "",
17932
18649
  "WHAT TO DO NEXT:",
17933
18650
  "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.",
@@ -18011,9 +18728,9 @@ function buildVideoTodo(report, overlayCount, floatingCount, opts, blueprint) {
18011
18728
  voice_description: d.voice_description,
18012
18729
  line: d.line
18013
18730
  })),
18014
- 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.",
18015
- 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.",
18016
- 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).",
18731
+ 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.",
18732
+ 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.",
18733
+ 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).",
18017
18734
  craft: {
18018
18735
  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.",
18019
18736
  principles: [
@@ -18124,23 +18841,23 @@ function videoReport(input, elementsInput) {
18124
18841
 
18125
18842
  // src/commands/canvas/composition-path.ts
18126
18843
  import { existsSync as existsSync3 } from "fs";
18127
- import path7 from "path";
18844
+ import path10 from "path";
18128
18845
  function resolveShippedCanvasDir(name, startDir, exists = existsSync3, maxDepth = 8) {
18129
- const rel = path7.join("canvas", name);
18846
+ const rel = path10.join("canvas", name);
18130
18847
  let dir = startDir;
18131
18848
  for (let i = 0; i < maxDepth; i++) {
18132
- const candidate = path7.join(dir, rel);
18133
- if (exists(path7.join(candidate, "meta.json"))) return candidate;
18134
- const parent = path7.dirname(dir);
18849
+ const candidate = path10.join(dir, rel);
18850
+ if (exists(path10.join(candidate, "meta.json"))) return candidate;
18851
+ const parent = path10.dirname(dir);
18135
18852
  if (parent === dir) break;
18136
18853
  dir = parent;
18137
18854
  }
18138
- return path7.resolve(startDir, "../../../", rel);
18855
+ return path10.resolve(startDir, "../../../", rel);
18139
18856
  }
18140
18857
 
18141
18858
  // src/commands/canvas/gitignore.ts
18142
- import { appendFile, readFile as readFile5 } from "fs/promises";
18143
- import path8 from "path";
18859
+ import { appendFile, readFile as readFile6 } from "fs/promises";
18860
+ import path11 from "path";
18144
18861
  function missingGitignoreEntries(existing, entries) {
18145
18862
  const present = new Set(
18146
18863
  existing.split("\n").map((l) => l.trim().replace(/\/+$/, "")).filter((l) => l.length > 0 && !l.startsWith("#"))
@@ -18148,10 +18865,10 @@ function missingGitignoreEntries(existing, entries) {
18148
18865
  return entries.filter((e) => !present.has(e.trim().replace(/\/+$/, "")));
18149
18866
  }
18150
18867
  async function ensureGitignore(dir, entries) {
18151
- const file = path8.join(dir, ".gitignore");
18868
+ const file = path11.join(dir, ".gitignore");
18152
18869
  let existing;
18153
18870
  try {
18154
- existing = await readFile5(file, "utf8");
18871
+ existing = await readFile6(file, "utf8");
18155
18872
  } catch {
18156
18873
  return;
18157
18874
  }
@@ -18190,7 +18907,7 @@ ONE PERSON, MULTIPLE LOOKS: if a single individual plays MULTIPLE personas or wa
18190
18907
  For each kept element return: { "type": one of person|animal|product|logo|badge|location, "label": a short UPPER_SNAKE_CASE name (e.g. HERO, CREATOR_SKEPTIC, INSURANCE_CARD, LOGO), "description": a concrete reusable description to source/shoot the real asset \u2014 for a person/animal give a NEUTRAL castable role (e.g. "hero pet-owner, woman in her 30s" or "a small beagle"), NOT the original individual's literal face/identity: we RECAST with a FRESH person/animal, so never tell the agent to reuse the original. "expression": a living subject's typical expression or null, "cast_id": the global.cast id if it maps to one else null, "same_as": the label of another element this is the SAME individual as (different wardrobe/persona) else null, "scenes": the 0-based indices of ONLY the scenes where the element is ACTUALLY VISIBLE ON SCREEN \u2014 judged from that scene's start_frame_prompt / end_frame_prompt subjects and its action_detail, NOT from who is merely speaking. A narrator heard over b-roll is NOT present in that b-roll scene; a dog-running cutaway does NOT contain the couch creator just because she talks across it. Do NOT pad the list \u2014 an element wrongly listed in a scene makes the reproduction render the wrong subject there (e.g. the creator appearing in a pure-dog b-roll). When in doubt, leave a scene OUT. Output ONLY the JSON object.`;
18191
18908
  async function loadAssetText2(ref, label) {
18192
18909
  const r = ref;
18193
- if (typeof r?.path === "string") return readFile6(r.path, "utf8");
18910
+ if (typeof r?.path === "string") return readFile7(r.path, "utf8");
18194
18911
  if (typeof r?.url === "string") {
18195
18912
  const res = await fetch(r.url);
18196
18913
  if (!res.ok) throw new Error(`failed to fetch ${label} (${res.status})`);
@@ -18209,7 +18926,7 @@ async function loadTranscriptBestEffort(ref) {
18209
18926
  async function stageCaptions(outDir, transcript) {
18210
18927
  const text = transcript?.trim();
18211
18928
  if (!text || text === "[]") return {};
18212
- const compositionPath = path9.join(outDir, "tiktok-captions-composition");
18929
+ const compositionPath = path12.join(outDir, "tiktok-captions-composition");
18213
18930
  await cp(SHIPPED_CAPTIONS_DIR, compositionPath, { recursive: true });
18214
18931
  return { compositionPath };
18215
18932
  }
@@ -18227,12 +18944,12 @@ function patchCompositionHtml(html, dims) {
18227
18944
  return html.replace(/(<meta\s+name="viewport"\s+content="width=)\d+(,\s*height=)\d+(")/i, `$1${dims.w}$2${dims.h}$3`).replace(/(width:\s*)\d+(px;\s*height:\s*)\d+(px;)/i, `$1${dims.w}$2${dims.h}$3`).replace(/(data-width=")\d+(")/i, `$1${dims.w}$2`).replace(/(data-height=")\d+(")/i, `$1${dims.h}$2`);
18228
18945
  }
18229
18946
  async function stampCompositionDims(compositionDir, dims) {
18230
- const metaPath = path9.join(compositionDir, "meta.json");
18231
- const rawMeta = await readFile6(metaPath, "utf8");
18232
- await writeFile2(metaPath, patchCompositionMeta(rawMeta, dims), "utf8");
18233
- const htmlPath = path9.join(compositionDir, "index.html");
18234
- const rawHtml = await readFile6(htmlPath, "utf8");
18235
- await writeFile2(htmlPath, patchCompositionHtml(rawHtml, dims), "utf8");
18947
+ const metaPath = path12.join(compositionDir, "meta.json");
18948
+ const rawMeta = await readFile7(metaPath, "utf8");
18949
+ await writeFile3(metaPath, patchCompositionMeta(rawMeta, dims), "utf8");
18950
+ const htmlPath = path12.join(compositionDir, "index.html");
18951
+ const rawHtml = await readFile7(htmlPath, "utf8");
18952
+ await writeFile3(htmlPath, patchCompositionHtml(rawHtml, dims), "utf8");
18236
18953
  }
18237
18954
  function parseElements2(raw) {
18238
18955
  const parsed = JSON.parse(raw);
@@ -18272,6 +18989,62 @@ function fail2(code, message) {
18272
18989
  `);
18273
18990
  process.exit(2);
18274
18991
  }
18992
+ var VIDEO_EXT_BY_MIME = {
18993
+ "video/mp4": ".mp4",
18994
+ "video/quicktime": ".mov",
18995
+ "video/webm": ".webm",
18996
+ "video/x-matroska": ".mkv"
18997
+ };
18998
+ function referenceVideoExt(url, contentType) {
18999
+ const fromPath = path12.extname(new URL(url).pathname).toLowerCase();
19000
+ if (fromPath && fromPath.length <= 5) return fromPath;
19001
+ const mime = (contentType ?? "").split(";")[0]?.trim().toLowerCase();
19002
+ return mime && VIDEO_EXT_BY_MIME[mime] || ".mp4";
19003
+ }
19004
+ async function fileExists2(target) {
19005
+ return access2(target).then(
19006
+ () => true,
19007
+ () => false
19008
+ );
19009
+ }
19010
+ function videoSourceReference(blueprint, fileArg2) {
19011
+ const bp = blueprint ?? {};
19012
+ const durable = typeof bp.source?.url === "string" ? bp.source.url : void 0;
19013
+ const original = /^https?:\/\//i.test(fileArg2) ? fileArg2 : void 0;
19014
+ const brand = bp.global?.branding?.brand_name;
19015
+ return { url: durable ?? original, advertiser: typeof brand === "string" && brand.trim() ? brand.trim() : void 0 };
19016
+ }
19017
+ function videoDefinitionDescription(blueprint) {
19018
+ const g = (blueprint ?? {}).global ?? {};
19019
+ const notes = g.reproduction_notes;
19020
+ if (typeof notes === "string" && notes.trim()) return notes.trim();
19021
+ const product = g.branding?.product;
19022
+ return typeof product === "string" && product.trim() ? product.trim() : void 0;
19023
+ }
19024
+ async function materializeReferenceVideo(fileArg2) {
19025
+ if (!/^https?:\/\//i.test(fileArg2)) return path12.resolve(fileArg2);
19026
+ let res;
19027
+ try {
19028
+ res = await fetch(fileArg2);
19029
+ } catch (e) {
19030
+ throw new Error(`failed to download reference video: ${e instanceof Error ? e.message : String(e)}`);
19031
+ }
19032
+ if (!res.ok) throw new Error(`failed to download reference video (${res.status} ${res.statusText})`);
19033
+ const bytes = Buffer.from(await res.arrayBuffer());
19034
+ if (bytes.length === 0) throw new Error("reference video download was empty");
19035
+ const dest = path12.join(
19036
+ tmpdir2(),
19037
+ `baker-ref-${sha256Hex(bytes).slice(0, 16)}${referenceVideoExt(fileArg2, res.headers.get("content-type"))}`
19038
+ );
19039
+ await writeFile3(dest, bytes);
19040
+ return dest;
19041
+ }
19042
+ function resolveSeamDedup(raw) {
19043
+ if (raw === void 0) return "head";
19044
+ const v = String(raw);
19045
+ if (v === "head" || v === "tail" || v === "off") return v;
19046
+ throw new Error(`--seam-dedup must be "head", "tail", or "off" (got "${v}")`);
19047
+ }
18275
19048
  function resolveModels2(args) {
18276
19049
  const pick = (flag, kind, fallback) => args[flag] ? String(args[flag]) : resolveModel2(kind, fallback);
18277
19050
  return {
@@ -18368,13 +19141,25 @@ var scaffoldVideoCommand = defineCommand90({
18368
19141
  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`."
18369
19142
  },
18370
19143
  args: {
18371
- file: { type: "positional", required: true, description: "Path to the reference video" },
19144
+ file: {
19145
+ type: "positional",
19146
+ required: true,
19147
+ 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."
19148
+ },
18372
19149
  out: { type: "string", description: "Output canvas path (default <video-dir>/<name>.video.canvas.json)" },
19150
+ slug: {
19151
+ type: "string",
19152
+ description: "Creative slug \u2014 writes the canvas to src/creatives/<slug>/<slug>.canvas.json (repo convention)"
19153
+ },
18373
19154
  frames: { type: "string", description: '"generate" (default, anchored regen) or "reuse" (wire real frames in)' },
18374
19155
  ambient: {
18375
19156
  type: "boolean",
18376
19157
  description: "Give silent b-roll scenes native diegetic ambient mixed deep under the music bed (off by default)"
18377
19158
  },
19159
+ "seam-dedup": {
19160
+ type: "string",
19161
+ 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).`
19162
+ },
18378
19163
  "max-scenes": { type: "string", description: "Cap the number of scenes the deconstruct emits" },
18379
19164
  "shot-threshold": {
18380
19165
  type: "string",
@@ -18382,6 +19167,14 @@ var scaffoldVideoCommand = defineCommand90({
18382
19167
  },
18383
19168
  language: { type: "string", description: "Transcript/dialogue language hint (e.g. fr, en)" },
18384
19169
  focus: { type: "string", description: "Known provenance/emphasis to ground the deconstruct" },
19170
+ advertiser: {
19171
+ type: "string",
19172
+ description: "Source advertiser recorded in _definition.md (default: the brand the deconstruct identified)"
19173
+ },
19174
+ platform: {
19175
+ type: "string",
19176
+ description: "Ad platform for _definition.md (meta|google|linkedin|tiktok|youtube|x|other; default meta)"
19177
+ },
18385
19178
  "deconstruct-model": { type: "string", description: "Override the video_deconstruct model id" },
18386
19179
  "select-model": { type: "string", description: "Override the text_generate model id for element selection" },
18387
19180
  "image-model": { type: "string", description: "Override the image_generate model id for frames" },
@@ -18396,11 +19189,33 @@ var scaffoldVideoCommand = defineCommand90({
18396
19189
  }
18397
19190
  },
18398
19191
  async run({ args }) {
18399
- const videoPath = path9.resolve(String(args.file));
18400
- const base = path9.basename(videoPath, path9.extname(videoPath));
18401
- const outPath = args.out ? path9.resolve(String(args.out)) : path9.join(path9.dirname(videoPath), `${base}.video.canvas.json`);
18402
- const outDir = path9.dirname(outPath);
18403
- const blueprintPath = path9.join(outDir, "prompt.json");
19192
+ const fileArg2 = String(args.file);
19193
+ const slug = args.slug ? String(args.slug) : void 0;
19194
+ if (slug && !isValidScaffoldSlug(slug)) {
19195
+ process.stderr.write(
19196
+ `${JSON.stringify({ ok: false, error: { code: "invalid_slug", message: "--slug must be lowercase kebab (a-z, 0-9, hyphens), max 100 chars" } }, null, 2)}
19197
+ `
19198
+ );
19199
+ process.exit(2);
19200
+ }
19201
+ const isUrl = /^https?:\/\//i.test(fileArg2);
19202
+ if (isUrl && !slug && !args.out) {
19203
+ return fail2(
19204
+ "missing_output_target",
19205
+ "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."
19206
+ );
19207
+ }
19208
+ let videoPath;
19209
+ try {
19210
+ videoPath = await materializeReferenceVideo(fileArg2);
19211
+ } catch (e) {
19212
+ return fail2("download", e instanceof Error ? e.message : String(e));
19213
+ }
19214
+ const base = path12.basename(videoPath, path12.extname(videoPath));
19215
+ 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`);
19216
+ const outDir = path12.dirname(outPath);
19217
+ const blueprintPath = path12.join(outDir, "prompt.json");
19218
+ const blueprintStylePath = path12.join(outDir, "prompt.style.json");
18404
19219
  const frames = args.frames === "reuse" ? "reuse" : "generate";
18405
19220
  const maxScenes = args["max-scenes"] ? Number(args["max-scenes"]) : void 0;
18406
19221
  if (Number.isFinite(maxScenes)) {
@@ -18419,10 +19234,16 @@ var scaffoldVideoCommand = defineCommand90({
18419
19234
  shotCuts
18420
19235
  });
18421
19236
  const { blueprint, elements, transcript, creditsSpent } = await runAnalysisPasses(deconstructCanvas, selectModel);
18422
- await mkdir(outDir, { recursive: true });
19237
+ await mkdir3(outDir, { recursive: true });
18423
19238
  const annotated = annotateBlueprintWithElements(blueprint, elements);
18424
- await writeFile2(blueprintPath, `${JSON.stringify(annotated, null, 2)}
19239
+ await writeFile3(blueprintPath, `${JSON.stringify(annotated, null, 2)}
18425
19240
  `, "utf8");
19241
+ await writeFile3(
19242
+ blueprintStylePath,
19243
+ `${JSON.stringify(slimBlueprintForFrameStyle(annotated), null, 2)}
19244
+ `,
19245
+ "utf8"
19246
+ );
18426
19247
  let aspect;
18427
19248
  try {
18428
19249
  aspect = resolveAspect(
@@ -18440,12 +19261,12 @@ var scaffoldVideoCommand = defineCommand90({
18440
19261
  `
18441
19262
  );
18442
19263
  }
18443
- const compositionDest = path9.join(outDir, "video-overlay-composition");
19264
+ const compositionDest = path12.join(outDir, "video-overlay-composition");
18444
19265
  await cp(SHIPPED_COMPOSITION_DIR, compositionDest, { recursive: true });
18445
19266
  await stampCompositionDims(compositionDest, outDims);
18446
- const indexPath = path9.join(compositionDest, "index.html");
19267
+ const indexPath = path12.join(compositionDest, "index.html");
18447
19268
  const overlayHtml = buildOverlayHtml(blueprint, { captionsActive: Boolean(transcript) });
18448
- const indexHtml = await readFile6(indexPath, "utf8");
19269
+ const indexHtml = await readFile7(indexPath, "utf8");
18449
19270
  const injected = indexHtml.replace("<!--OVERLAYS-->", () => overlayHtml);
18450
19271
  if (injected === indexHtml && overlayHtml.trim()) {
18451
19272
  fail2(
@@ -18453,17 +19274,19 @@ var scaffoldVideoCommand = defineCommand90({
18453
19274
  `video-overlay-composition/index.html is missing the <!--OVERLAYS--> marker \u2014 cannot inject the overlay layer`
18454
19275
  );
18455
19276
  }
18456
- await writeFile2(indexPath, injected, "utf8");
19277
+ await writeFile3(indexPath, injected, "utf8");
18457
19278
  const captions = await stageCaptions(outDir, transcript);
18458
19279
  if (captions.compositionPath) await stampCompositionDims(captions.compositionPath, outDims);
18459
19280
  const opts = {
18460
19281
  imageModel,
18461
19282
  videoModel,
18462
- overlayCompositionPath: path9.relative(outDir, compositionDest),
18463
- captionsCompositionPath: captions.compositionPath ? path9.relative(outDir, captions.compositionPath) : void 0,
18464
- blueprintPath: path9.relative(outDir, blueprintPath),
19283
+ overlayCompositionPath: path12.relative(outDir, compositionDest),
19284
+ captionsCompositionPath: captions.compositionPath ? path12.relative(outDir, captions.compositionPath) : void 0,
19285
+ blueprintPath: path12.relative(outDir, blueprintPath),
19286
+ blueprintStylePath: path12.relative(outDir, blueprintStylePath),
18465
19287
  frames,
18466
19288
  ambient: Boolean(args.ambient),
19289
+ seamDedup: resolveSeamDedup(args["seam-dedup"]),
18467
19290
  ...args.aspect ? { aspect: String(args.aspect) } : {},
18468
19291
  ...args.resolution ? { resolution: String(args.resolution) } : {}
18469
19292
  };
@@ -18482,7 +19305,7 @@ var scaffoldVideoCommand = defineCommand90({
18482
19305
  todo.blocking_validation_issues = validation.issues;
18483
19306
  meta.todo = todo;
18484
19307
  }
18485
- await writeFile2(outPath, `${JSON.stringify(canvas, null, 2)}
19308
+ await writeFile3(outPath, `${JSON.stringify(canvas, null, 2)}
18486
19309
  `, "utf8");
18487
19310
  if (!validation.ok) {
18488
19311
  process.stderr.write(
@@ -18502,12 +19325,42 @@ var scaffoldVideoCommand = defineCommand90({
18502
19325
  process.exit(2);
18503
19326
  }
18504
19327
  await ensureGitignore(process.cwd(), ["canvas/", ".context/"]);
19328
+ const sourceRef = videoSourceReference(blueprint, fileArg2);
19329
+ if (slug) {
19330
+ const definitionPath = path12.join(outDir, "_definition.md");
19331
+ if (!await fileExists2(definitionPath)) {
19332
+ await writeFile3(
19333
+ definitionPath,
19334
+ buildCreativeDefinition({
19335
+ title: titleFromSlug(slug),
19336
+ kind: "video",
19337
+ platform: resolvePlatform(args.platform ? String(args.platform) : void 0),
19338
+ formats: resolveFormats(aspect.outAr),
19339
+ sourceReferenceUrl: sourceRef.url,
19340
+ sourceAdvertiser: args.advertiser ? String(args.advertiser) : sourceRef.advertiser,
19341
+ sourceKind: "video",
19342
+ description: videoDefinitionDescription(blueprint)
19343
+ }),
19344
+ "utf8"
19345
+ );
19346
+ }
19347
+ }
19348
+ if (slug) {
19349
+ await syncCreativeDefinitionBestEffort({
19350
+ slug,
19351
+ title: titleFromSlug(slug),
19352
+ formats: [aspect.outAr],
19353
+ canvas,
19354
+ sourceReferenceUrl: sourceRef.url
19355
+ });
19356
+ }
18505
19357
  process.stdout.write(
18506
19358
  `${JSON.stringify(
18507
19359
  {
18508
19360
  ok: true,
18509
19361
  canvas_path: outPath,
18510
19362
  prompt_path: blueprintPath,
19363
+ source_reference: sourceRef.url,
18511
19364
  composition_dir: compositionDest,
18512
19365
  output: canvas.output,
18513
19366
  frames_mode: frames,
@@ -18519,7 +19372,7 @@ var scaffoldVideoCommand = defineCommand90({
18519
19372
  run_estimated_credits: validation.estimatedCredits
18520
19373
  },
18521
19374
  checklist: {
18522
- edit_prompt: `Edit ${path9.basename(blueprintPath)} \u2014 the blueprint deconstructed from your video; rewrite it into the ad you want (cast, palette, copy, claims). Every scene frame reads it via target_blueprint.`,
19375
+ edit_prompt: `Edit ${path12.basename(blueprintPath)} \u2014 the blueprint deconstructed from your video; rewrite it into the ad you want (cast, palette, copy, claims). Every scene frame reads it via target_blueprint.`,
18523
19376
  recurring_elements_to_supply: report.elements,
18524
19377
  voices_to_confirm: report.dialogue.map((d) => ({
18525
19378
  scene: d.scene,
@@ -18533,6 +19386,13 @@ var scaffoldVideoCommand = defineCommand90({
18533
19386
  scenes_clamped_to_15s: report.clamped_scenes,
18534
19387
  oversize_scenes: report.oversize_scenes,
18535
19388
  overstuffed_scenes: report.overstuffed_scenes,
19389
+ // A photoreal on-camera person/animal on Seedance can trip ByteDance's
19390
+ // real-person-likeness filter (422 content_policy_blocked, NON-retryable — no
19391
+ // prompt reframe clears it). Surface the escape BEFORE the billed run so a
19392
+ // face-heavy ad isn't discovered broken mid-render.
19393
+ ...report.elements.some((e) => e.type === "person" || e.type === "animal") && /seedance/i.test(videoModel) ? {
19394
+ 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."
19395
+ } : {},
18536
19396
  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."
18537
19397
  }
18538
19398
  },
@@ -18545,8 +19405,8 @@ var scaffoldVideoCommand = defineCommand90({
18545
19405
  });
18546
19406
 
18547
19407
  // src/commands/canvas/set-prompt.ts
18548
- import { readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
18549
- import path10 from "path";
19408
+ import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
19409
+ import path13 from "path";
18550
19410
  import { defineCommand as defineCommand91 } from "citty";
18551
19411
  function setNodePrompt(canvas, nodeId, text) {
18552
19412
  const nodes = canvas?.nodes;
@@ -18574,17 +19434,17 @@ var setPromptCommand = defineCommand91({
18574
19434
  "text-file": { type: "string", description: "Read the new prompt from a UTF-8 file (preserves accents/newlines)" }
18575
19435
  },
18576
19436
  async run({ args }) {
18577
- const filePath = path10.resolve(String(args.file));
19437
+ const filePath = path13.resolve(String(args.file));
18578
19438
  let canvas;
18579
19439
  try {
18580
- canvas = JSON.parse(await readFile7(filePath, "utf8"));
19440
+ canvas = JSON.parse(await readFile8(filePath, "utf8"));
18581
19441
  } catch (e) {
18582
19442
  process.stderr.write(`${JSON.stringify({ ok: false, error: { code: "parse", message: String(e) } }, null, 2)}
18583
19443
  `);
18584
19444
  process.exit(2);
18585
19445
  }
18586
19446
  let text;
18587
- if (args["text-file"]) text = await readFile7(path10.resolve(String(args["text-file"])), "utf8");
19447
+ if (args["text-file"]) text = await readFile8(path13.resolve(String(args["text-file"])), "utf8");
18588
19448
  else if (args.text !== void 0) text = String(args.text);
18589
19449
  else {
18590
19450
  process.stderr.write(
@@ -18605,14 +19465,14 @@ var setPromptCommand = defineCommand91({
18605
19465
  process.exit(2);
18606
19466
  return;
18607
19467
  }
18608
- const validation = await validateCanvasDeep(resolveRelativeCanvasPaths(updated, path10.dirname(filePath)), defaultRegistry());
19468
+ const validation = await validateCanvasDeep(resolveRelativeCanvasPaths(updated, path13.dirname(filePath)), defaultRegistry());
18609
19469
  if (!validation.ok) {
18610
19470
  process.stderr.write(`${JSON.stringify({ ok: false, error: { code: "validation", issues: validation.issues } }, null, 2)}
18611
19471
  `);
18612
19472
  process.exit(2);
18613
19473
  return;
18614
19474
  }
18615
- await writeFile3(filePath, `${JSON.stringify(updated, null, 2)}
19475
+ await writeFile4(filePath, `${JSON.stringify(updated, null, 2)}
18616
19476
  `, "utf8");
18617
19477
  process.stdout.write(`${JSON.stringify({ ok: true, node: String(args.node), bytes: text.length }, null, 2)}
18618
19478
  `);
@@ -18620,8 +19480,8 @@ var setPromptCommand = defineCommand91({
18620
19480
  });
18621
19481
 
18622
19482
  // src/commands/canvas/validate.ts
18623
- import { readFile as readFile8 } from "fs/promises";
18624
- import path11 from "path";
19483
+ import { readFile as readFile9 } from "fs/promises";
19484
+ import path14 from "path";
18625
19485
  import { defineCommand as defineCommand92 } from "citty";
18626
19486
  var validateCommand = defineCommand92({
18627
19487
  meta: {
@@ -18630,8 +19490,8 @@ var validateCommand = defineCommand92({
18630
19490
  },
18631
19491
  args: { file: { type: "positional", required: true, description: "Path to canvas JSON" } },
18632
19492
  async run({ args }) {
18633
- const filePath = path11.resolve(String(args.file));
18634
- const raw = await readFile8(filePath, "utf8");
19493
+ const filePath = path14.resolve(String(args.file));
19494
+ const raw = await readFile9(filePath, "utf8");
18635
19495
  let parsed;
18636
19496
  try {
18637
19497
  parsed = JSON.parse(raw);
@@ -18641,7 +19501,7 @@ var validateCommand = defineCommand92({
18641
19501
  `);
18642
19502
  process.exit(2);
18643
19503
  }
18644
- parsed = resolveRelativeCanvasPaths(parsed, path11.dirname(filePath));
19504
+ parsed = resolveRelativeCanvasPaths(parsed, path14.dirname(filePath));
18645
19505
  const result = await validateCanvasDeep(parsed, defaultRegistry());
18646
19506
  if (!result.ok) {
18647
19507
  process.stderr.write(`${JSON.stringify({ ok: false, issues: result.issues }, null, 2)}
@@ -18700,7 +19560,7 @@ import { defineCommand as defineCommand95 } from "citty";
18700
19560
  import { defineCommand as defineCommand94 } from "citty";
18701
19561
 
18702
19562
  // src/commands/images/api.ts
18703
- import { readFile as readFile9 } from "fs/promises";
19563
+ import { readFile as readFile10 } from "fs/promises";
18704
19564
  import { extname } from "path";
18705
19565
  var imageProcessingTimeoutMs = 18e4;
18706
19566
  var imageReadyPollIntervalMs = 2e3;
@@ -18714,7 +19574,7 @@ var mimeMap = {
18714
19574
  ".avif": "image/avif"
18715
19575
  };
18716
19576
  var defaultImageApiDeps = {
18717
- readFile: readFile9,
19577
+ readFile: readFile10,
18718
19578
  post: apiPost,
18719
19579
  get: apiGet,
18720
19580
  sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms))
@@ -18778,6 +19638,16 @@ registerSchema({
18778
19638
  type: "string",
18779
19639
  description: "Optional URL of the original reference ad",
18780
19640
  required: false
19641
+ },
19642
+ slug: {
19643
+ type: "string",
19644
+ description: "Creative slug (src/creatives/<slug>/) \u2014 attaches the image to that creative's row",
19645
+ required: false
19646
+ },
19647
+ runId: {
19648
+ type: "string",
19649
+ description: "Canvas run id (r_\u2026) of the approved generation to pin as published",
19650
+ required: false
18781
19651
  }
18782
19652
  }
18783
19653
  });
@@ -18787,6 +19657,13 @@ function detectCreativeContentType(filePath) {
18787
19657
  unsupportedMessage: "Unsupported creative image extension. Use PNG, JPG, or WebP."
18788
19658
  });
18789
19659
  }
19660
+ function chatIdFromEnv() {
19661
+ try {
19662
+ return getEnv().BAKER_CHAT_ID || void 0;
19663
+ } catch {
19664
+ return void 0;
19665
+ }
19666
+ }
18790
19667
  function parseOptionalUrl(value) {
18791
19668
  if (value === void 0 || value.trim() === "") {
18792
19669
  return void 0;
@@ -18817,7 +19694,11 @@ async function publishCreative(args, deps = defaultImageApiDeps) {
18817
19694
  return publishImageAsCreative(deps, {
18818
19695
  imageId: upload.imageId,
18819
19696
  title,
18820
- sourceReferenceUrl
19697
+ sourceReferenceUrl,
19698
+ slug: args.slug,
19699
+ runId: args.runId,
19700
+ // Attribute the publish to the driving chat (injected by the bridge).
19701
+ chatId: chatIdFromEnv()
18821
19702
  });
18822
19703
  }
18823
19704
  var publishCommand = defineCommand94({
@@ -18833,6 +19714,16 @@ var publishCommand = defineCommand94({
18833
19714
  type: "string",
18834
19715
  description: "Optional URL of the original reference ad",
18835
19716
  required: false
19717
+ },
19718
+ slug: {
19719
+ type: "string",
19720
+ description: "Creative slug (src/creatives/<slug>/) \u2014 attaches the image to that creative's row",
19721
+ required: false
19722
+ },
19723
+ runId: {
19724
+ type: "string",
19725
+ description: "Canvas run id (r_\u2026) of the approved generation to pin as published",
19726
+ required: false
18836
19727
  }
18837
19728
  },
18838
19729
  run: async ({ args }) => {
@@ -18851,7 +19742,9 @@ var publishCommand = defineCommand94({
18851
19742
  file,
18852
19743
  title,
18853
19744
  context: args.context,
18854
- sourceReferenceUrl: args.sourceReferenceUrl
19745
+ sourceReferenceUrl: args.sourceReferenceUrl,
19746
+ slug: args.slug,
19747
+ runId: args.runId
18855
19748
  });
18856
19749
  writeJson({ ok: true, data });
18857
19750
  } catch (err) {
@@ -19711,7 +20604,7 @@ function cropSprite(input, region) {
19711
20604
 
19712
20605
  // src/lib/image/io.ts
19713
20606
  import { randomBytes } from "crypto";
19714
- import { glob as fsGlob, readFile as readFile10, rename, stat as stat2, writeFile as writeFile4 } from "fs/promises";
20607
+ import { glob as fsGlob, readFile as readFile11, rename, stat as stat2, writeFile as writeFile5 } from "fs/promises";
19715
20608
  import { dirname as dirname2, extname as extname2, join as join3, resolve as resolve4 } from "path";
19716
20609
  var REMOTE_RE = /^https?:\/\//i;
19717
20610
  var GLOB_RE = /[*?[\]{}]/;
@@ -19747,11 +20640,11 @@ async function readImageBuffer(pathOrUrl) {
19747
20640
  }
19748
20641
  return Buffer.from(await response.arrayBuffer());
19749
20642
  }
19750
- return readFile10(pathOrUrl);
20643
+ return readFile11(pathOrUrl);
19751
20644
  }
19752
- async function isDirectory(path12) {
20645
+ async function isDirectory(path15) {
19753
20646
  try {
19754
- const s = await stat2(path12);
20647
+ const s = await stat2(path15);
19755
20648
  return s.isDirectory();
19756
20649
  } catch {
19757
20650
  return false;
@@ -19770,7 +20663,7 @@ async function atomicWrite(targetPath, data) {
19770
20663
  const absolute = resolve4(targetPath);
19771
20664
  const dir = dirname2(absolute);
19772
20665
  const tmp = join3(dir, `.baker-image-${randomBytes(8).toString("hex")}.tmp`);
19773
- await writeFile4(tmp, data);
20666
+ await writeFile5(tmp, data);
19774
20667
  await rename(tmp, absolute);
19775
20668
  }
19776
20669
 
@@ -20113,7 +21006,7 @@ var findCommand = defineCommand108({
20113
21006
  });
20114
21007
 
20115
21008
  // src/commands/images/generate.ts
20116
- import { readFile as readFile11 } from "fs/promises";
21009
+ import { readFile as readFile12 } from "fs/promises";
20117
21010
  import { defineCommand as defineCommand109 } from "citty";
20118
21011
  import sharp2 from "sharp";
20119
21012
  var GENERATE_TIMEOUT_MS = 18e4;
@@ -20203,7 +21096,7 @@ async function resolveReferences(spec) {
20203
21096
  }
20204
21097
  let raw;
20205
21098
  try {
20206
- raw = await readFile11(entry);
21099
+ raw = await readFile12(entry);
20207
21100
  } catch {
20208
21101
  throw new ApiError("VALIDATION_ERROR", `Reference file not found: ${entry}`);
20209
21102
  }
@@ -24230,7 +25123,7 @@ var searchCommand3 = defineCommand154({
24230
25123
  var tagsCommand4 = makeTagsCommand("videos", "video", "/api/videos/tags");
24231
25124
 
24232
25125
  // src/commands/videos/upload.ts
24233
- import { readFile as readFile12, stat as stat3 } from "fs/promises";
25126
+ import { readFile as readFile13, stat as stat3 } from "fs/promises";
24234
25127
  import { extname as extname3 } from "path";
24235
25128
  import { defineCommand as defineCommand155 } from "citty";
24236
25129
  var MIME_MAP = {
@@ -24295,7 +25188,7 @@ var uploadCommand2 = defineCommand155({
24295
25188
  return;
24296
25189
  }
24297
25190
  const { uploadUrl, videoId } = await apiPost("/api/videos/upload", {});
24298
- const fileBuffer = await readFile12(filePath);
25191
+ const fileBuffer = await readFile13(filePath);
24299
25192
  const uploadResponse = await fetch(uploadUrl, {
24300
25193
  method: "PUT",
24301
25194
  headers: { "Content-Type": contentType },