@koda-sl/baker-cli 0.116.0 → 0.118.0

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
@@ -9,10 +9,11 @@ import {
9
9
  createEngineFromEnv,
10
10
  defaultRegistry,
11
11
  describeFailureReason,
12
+ elementMentionKeywords,
12
13
  generateCatalog,
13
14
  resolveConcurrency,
14
15
  validateCanvasDeep
15
- } from "./chunk-ZWMMCLJ4.js";
16
+ } from "./chunk-MF34WJ7M.js";
16
17
 
17
18
  // src/cli.ts
18
19
  import { defineCommand as defineCommand157, runMain } from "citty";
@@ -13623,6 +13624,16 @@ function isLikelyOverSegmented(cuts, opts = {}) {
13623
13624
  const median = gaps.length % 2 ? gaps[mid] : (gaps[mid - 1] + gaps[mid]) / 2;
13624
13625
  return median < maxMedianGap;
13625
13626
  }
13627
+ function mergeRecheckCuts(firstPass, recheck, isolationS = 0.75) {
13628
+ const kept = [...new Set(recheck)].sort((a, b) => a - b);
13629
+ const extras = [...new Set(firstPass)].filter((c) => kept.every((k) => Math.abs(k - c) >= isolationS)).sort((a, b) => a - b);
13630
+ const isolated = extras.filter((c, i) => {
13631
+ const prev = extras[i - 1];
13632
+ const next = extras[i + 1];
13633
+ return (prev === void 0 || c - prev >= isolationS) && (next === void 0 || next - c >= isolationS);
13634
+ });
13635
+ return [.../* @__PURE__ */ new Set([...kept, ...isolated])].sort((a, b) => a - b);
13636
+ }
13626
13637
  function timecodeToSeconds(tc) {
13627
13638
  const m = tc.trim().match(/^(\d+):(\d{1,2}):(\d{1,2}(?:\.\d+)?)$/);
13628
13639
  if (!m) return null;
@@ -13676,12 +13687,13 @@ async function detectSceneCutsPySceneDetect(filePath, opts = {}) {
13676
13687
  const timeoutMs = opts.timeout_ms ?? 12e4;
13677
13688
  const cuts = await runSceneDetectOnce(filePath, threshold, minSceneLenS, timeoutMs);
13678
13689
  if (!pinned && isLikelyOverSegmented(cuts)) {
13679
- return await runSceneDetectOnce(
13690
+ const rechecked = await runSceneDetectOnce(
13680
13691
  filePath,
13681
13692
  PYSCENEDETECT_RECHECK_THRESHOLD,
13682
13693
  PYSCENEDETECT_RECHECK_MIN_SCENE_LEN_S,
13683
13694
  timeoutMs
13684
13695
  );
13696
+ return mergeRecheckCuts(cuts, rechecked);
13685
13697
  }
13686
13698
  return cuts;
13687
13699
  }
@@ -13819,9 +13831,22 @@ function videoResolutionParam(videoModel, resolution) {
13819
13831
  return { resolution: resolution ?? DEFAULT_VIDEO_RESOLUTION };
13820
13832
  }
13821
13833
  var WORDS_PER_SECOND = 2.5;
13834
+ function wordCount(text) {
13835
+ return text.trim().split(/\s+/).filter(Boolean).length;
13836
+ }
13822
13837
  function estSpeechS(text) {
13823
- const words = text.trim().split(/\s+/).filter(Boolean).length;
13824
- return words / WORDS_PER_SECOND;
13838
+ return wordCount(text) / WORDS_PER_SECOND;
13839
+ }
13840
+ var OBSERVED_WPS_MIN = 1;
13841
+ var OBSERVED_WPS_MAX = 6;
13842
+ function estSpeechWindowS(text, startS, endS) {
13843
+ const words = wordCount(text);
13844
+ const window = (endS ?? 0) - (startS ?? 0);
13845
+ if (words > 0 && window > 0.3) {
13846
+ const wps = words / window;
13847
+ if (wps >= OBSERVED_WPS_MIN && wps <= OBSERVED_WPS_MAX) return window;
13848
+ }
13849
+ return estSpeechS(text);
13825
13850
  }
13826
13851
  var NARRATOR_SPEAKERS = /* @__PURE__ */ new Set([
13827
13852
  "voiceover",
@@ -14007,7 +14032,13 @@ var DialogueLine = z11.object({
14007
14032
  start_s: z11.number().optional(),
14008
14033
  end_s: z11.number().optional(),
14009
14034
  delivery: z11.string().optional(),
14010
- voice_description: z11.string().optional()
14035
+ voice_description: z11.string().optional(),
14036
+ // DECON-supplied: is this speaker's FACE visibly speaking in THIS scene? Element
14037
+ // presence alone can't answer that — a founder pictured in a polaroid close-up is
14038
+ // "present" yet the line is voiceover, and treating it as on-camera produced a
14039
+ // native Seedance lip-sync clip of a still photograph. `false` pins the line to
14040
+ // the VO path; absent keeps the presence-based decision (old blueprints).
14041
+ on_camera: z11.boolean().optional()
14011
14042
  }).loose();
14012
14043
  var Sfx = z11.object({
14013
14044
  at_s: z11.number().optional(),
@@ -14023,6 +14054,22 @@ var CompositionRegion = z11.object({
14023
14054
  is_presenter: z11.boolean().optional(),
14024
14055
  // The cast id shown/speaking in this region (routes lip-sync + element refs).
14025
14056
  cast_ref: z11.string().optional(),
14057
+ // What the region's content IS: camera | screen_capture | static_graphic |
14058
+ // generated. Authoritative for routing when present (regex-over-prose fallback
14059
+ // otherwise): screen_capture/static_graphic are rebuilt from REAL surfaces on the
14060
+ // overlay layer, never AI-generated.
14061
+ kind: z11.string().optional(),
14062
+ // Opaque id naming the SPECIFIC on-screen document/note/app-state this
14063
+ // screen_capture region shows. Two scenes share it only when they show the SAME
14064
+ // recording continuing (scrolling/typing/waiting within it) — a genuinely
14065
+ // DIFFERENT document/note/recording (a source video splicing two screen captures)
14066
+ // gets a different id. Breaks a persistent-layout run into separate surface stubs
14067
+ // instead of asking the operator for one screenshot that can't cover both.
14068
+ surface_id: z11.string().optional(),
14069
+ // Camera bubble(s)/inset(s) embedded INSIDE this region's surface (a Loom-style
14070
+ // presenter bubble inside a screen recording) — video-in-video the reproduction
14071
+ // must re-composite, not paint into the surface.
14072
+ nested: z11.array(z11.object({}).loose()).optional(),
14026
14073
  summary: z11.string().optional(),
14027
14074
  frame_prompt: z11.string().optional(),
14028
14075
  motion_prompt: z11.string().optional()
@@ -14076,6 +14123,11 @@ var Scene = z11.object({
14076
14123
  sfx: z11.array(Sfx).optional(),
14077
14124
  overlays: z11.array(z11.unknown()).optional(),
14078
14125
  floating_elements: z11.array(z11.unknown()).optional(),
14126
+ // DECON-supplied: how much the picture itself moves within the shot. Gates the
14127
+ // flash-hold optimization — a sub-2s b-roll flash with REAL subject motion
14128
+ // (pouring, spreading, hands working) must stay a real clip; freezing it turns
14129
+ // a montage into a slideshow. Absent (old blueprints) keeps the cheap still.
14130
+ motion_level: z11.enum(["static", "subtle", "dynamic"]).optional(),
14079
14131
  transcript_slice: z11.array(TranscriptWord).optional(),
14080
14132
  start_frame_asset: FrameAsset,
14081
14133
  end_frame_asset: FrameAsset,
@@ -14315,6 +14367,33 @@ function buildElementSlots(elements) {
14315
14367
  function slotsForFrame(slots, sceneIndex, edge) {
14316
14368
  return slots.filter((s) => s.presence.get(sceneIndex)?.has(edge));
14317
14369
  }
14370
+ var MENTION_WIRED_TYPES = /* @__PURE__ */ new Set(["logo", "badge"]);
14371
+ function promptMentionsSlot(prompt, slot) {
14372
+ return elementMentionKeywords(slot).some(
14373
+ (w) => new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(prompt)
14374
+ );
14375
+ }
14376
+ function edgePromptText(scene, edge) {
14377
+ const regionPrompts = (compositeRegionsOf(scene) ?? []).map((r) => r.frame_prompt ?? "").filter(Boolean).join("\n");
14378
+ const own = (edge === "start" ? scene.start_frame_prompt : scene.end_frame_prompt) ?? "";
14379
+ return [own, regionPrompts].join("\n").trim();
14380
+ }
14381
+ function extendPresenceByPromptMentions(slots, blueprint) {
14382
+ const extendable = slots.filter((s) => MENTION_WIRED_TYPES.has(s.type.toLowerCase()));
14383
+ if (extendable.length === 0) return;
14384
+ blueprint.scenes.forEach((scene, i) => {
14385
+ for (const edge of EDGES) {
14386
+ const text = edgePromptText(scene, edge);
14387
+ if (!text) continue;
14388
+ for (const slot of extendable) {
14389
+ if (!promptMentionsSlot(text, slot)) continue;
14390
+ const set = slot.presence.get(i) ?? /* @__PURE__ */ new Set();
14391
+ set.add(edge);
14392
+ slot.presence.set(i, set);
14393
+ }
14394
+ }
14395
+ });
14396
+ }
14318
14397
  var ACTOR_SHEET_MODEL = "google/gemini-3-pro-image-preview";
14319
14398
  var SHEET_SUBJECT_TYPE = {
14320
14399
  person: "person",
@@ -14497,21 +14576,94 @@ function seedanceAudioLine(scene, mode, audio, nativeLine) {
14497
14576
  }
14498
14577
  return null;
14499
14578
  }
14500
- function buildSeedancePrompt(scene, sceneIndex, present, mode, audio, nativeLine, nativeLang) {
14501
- const loc = (s) => nativeLine ? localizeNumeralsForNative(s, nativeLang) : s;
14579
+ var FLOAT_STOP_WORDS = /* @__PURE__ */ new Set([
14580
+ "with",
14581
+ "across",
14582
+ "around",
14583
+ "above",
14584
+ "below",
14585
+ "over",
14586
+ "under",
14587
+ "screen",
14588
+ "frame",
14589
+ "scene",
14590
+ "video",
14591
+ "element",
14592
+ "elements",
14593
+ "graphic",
14594
+ "overlay",
14595
+ "animated",
14596
+ "floating",
14597
+ "small",
14598
+ "large",
14599
+ "white",
14600
+ "black",
14601
+ "color",
14602
+ "colored"
14603
+ ]);
14604
+ function floatTokens(desc) {
14605
+ return desc.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4 && !FLOAT_STOP_WORDS.has(w));
14606
+ }
14607
+ function scrubFloatSentences(text, floatDescs) {
14608
+ if (floatDescs.length === 0 || !text) return text;
14609
+ const tokenSets = floatDescs.map((d) => new Set(floatTokens(d)));
14610
+ const kept = text.split(/(?<=[.!?])\s+/).filter((sentence) => {
14611
+ const words = new Set(floatTokens(sentence));
14612
+ return !tokenSets.some((ts) => {
14613
+ let hits = 0;
14614
+ for (const w of words) if (ts.has(w)) hits++;
14615
+ return hits >= 2;
14616
+ });
14617
+ }).join(" ").trim();
14618
+ return kept;
14619
+ }
14620
+ function sceneFloatDescs(scene) {
14621
+ const floats = z11.array(FloatingElement).safeParse(scene.floating_elements ?? []);
14622
+ if (!floats.success) return [];
14623
+ return floats.data.map((f) => f.description?.trim() ?? "").filter(Boolean);
14624
+ }
14625
+ function visualBriefParts(scene, sceneIndex, plate) {
14502
14626
  const parts = [];
14503
- const summary = scene.summary?.trim();
14504
- parts.push(summary ? `Scene ${sceneIndex + 1}: ${loc(summary)}` : `Scene ${sceneIndex + 1}`);
14505
- if (scene.action_detail) parts.push(`Action: ${loc(scene.action_detail)}`);
14627
+ const floats = sceneFloatDescs(scene);
14628
+ const scrub = (s) => {
14629
+ if (!s) return s;
14630
+ const out = scrubFloatSentences(s, floats);
14631
+ return out || void 0;
14632
+ };
14633
+ const summary = scrub((plate ? plate.summary : scene.summary)?.trim());
14634
+ parts.push(summary ? `Scene ${sceneIndex + 1}: ${summary}` : `Scene ${sceneIndex + 1}`);
14635
+ const action = !plate ? scrub(scene.action_detail) : void 0;
14636
+ if (action) parts.push(`Action: ${action}`);
14506
14637
  const cm = scene.camera_motion;
14507
14638
  if (cm) {
14508
14639
  const camera = [cm.movement, cm.detail].filter(Boolean).join(" \u2014 ");
14509
14640
  if (camera) parts.push(`Camera: ${camera}`);
14510
14641
  }
14511
- if (scene.motion_prompt) parts.push(`Motion: ${loc(scene.motion_prompt)}`);
14512
- if (present.length > 0) {
14642
+ const motion = scrub(plate ? plate.motion_prompt : scene.motion_prompt);
14643
+ if (motion) parts.push(`Motion: ${motion}`);
14644
+ const uiFloats = floats.filter((d) => UI_SURFACE_RE.test(d));
14645
+ const fxFloats = floats.filter((d) => !UI_SURFACE_RE.test(d));
14646
+ if (plate || uiFloats.length > 0) {
14513
14647
  parts.push(
14514
- `Keep these consistent with their references: ${present.map((s) => `${s.label} (${s.description ?? s.type})`).join("; ")}`
14648
+ "NEVER render any screen, phone, app, website, or UI element \u2014 this clip is the clean background plate; the screen surface is composited on top later as a separate real layer."
14649
+ );
14650
+ }
14651
+ if (fxFloats.length > 0) {
14652
+ parts.push(
14653
+ "NEVER render floating decorative elements \u2014 no hearts, sparkles, confetti, badges, stickers, icons, or particle effects. They are composited later as a separate real overlay layer; the picture stays clean of them."
14654
+ );
14655
+ }
14656
+ return parts;
14657
+ }
14658
+ function buildSeedancePrompt(scene, sceneIndex, present, mode, audio, nativeLine, nativeLang, uiRouted) {
14659
+ const loc = (s) => nativeLine ? localizeNumeralsForNative(s, nativeLang) : s;
14660
+ const routed = uiRouted ?? (compositeRegionsOf(scene) !== null && isUiOnlyComposite(compositeRegionsOf(scene) ?? []));
14661
+ const plate = routed ? compositePlateRegion(scene) ?? {} : null;
14662
+ const parts = visualBriefParts(scene, sceneIndex, plate);
14663
+ const refs = plate ? present.filter((s) => !UI_SURFACE_RE.test(s.description ?? "")) : present;
14664
+ if (refs.length > 0) {
14665
+ parts.push(
14666
+ `Keep these consistent with their references: ${refs.map((s) => `${s.label} (${s.description ?? s.type})`).join("; ")}`
14515
14667
  );
14516
14668
  }
14517
14669
  if (nativeLine) {
@@ -14642,7 +14794,8 @@ function emitSceneClip(i, scene, present, mode, nativeTurn, ambientBroll, frames
14642
14794
  mode,
14643
14795
  Boolean(nativeTurn) || ambientBroll,
14644
14796
  nativeTurn?.text,
14645
- opts.nativeLang
14797
+ opts.nativeLang,
14798
+ opts.uiRouted
14646
14799
  ),
14647
14800
  duration: lengths.genDur,
14648
14801
  ...videoResolutionParam(opts.videoModel, opts.resolution),
@@ -14671,12 +14824,59 @@ function emitSceneClip(i, scene, present, mode, nativeTurn, ambientBroll, frames
14671
14824
  var COMPOSITE_LAYOUTS = /* @__PURE__ */ new Set(["split_screen", "pip", "keyed_overlay"]);
14672
14825
  var UI_SURFACE_RE = /\b(?:app|ui|web ?site|web ?page|website|browser|chat|interface|mock-?up|in[- ]?app|dashboard|app screen|phone screen|screen[- ]?(?:recording|capture|grab|share))\b/i;
14673
14826
  function regionIsUiSurface(r) {
14674
- return UI_SURFACE_RE.test(`${r.panel ?? ""} ${r.summary ?? ""} ${r.frame_prompt ?? ""}`);
14827
+ const kind = (r.kind ?? "").trim().toLowerCase();
14828
+ if (kind === "screen_capture" || kind === "static_graphic") return true;
14829
+ if (kind === "camera") return false;
14830
+ return UI_SURFACE_RE.test(`${r.panel ?? ""} ${r.summary ?? ""}`);
14675
14831
  }
14676
14832
  function isUiOnlyComposite(regions) {
14677
14833
  const ui = regions.filter(regionIsUiSurface).length;
14678
14834
  return ui >= 1 && regions.length - ui <= 1;
14679
14835
  }
14836
+ function compositeRegionsOf(scene) {
14837
+ const comp = scene.composition;
14838
+ const layout = (comp?.layout ?? "").toLowerCase();
14839
+ if (!COMPOSITE_LAYOUTS.has(layout)) return null;
14840
+ const regions = (comp?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
14841
+ return regions.length >= 2 ? regions : null;
14842
+ }
14843
+ function compositePlateRegion(scene) {
14844
+ const regions = compositeRegionsOf(scene);
14845
+ if (!regions) return null;
14846
+ return regions.find((r) => r.is_presenter) ?? regions.find((r) => !regionIsUiSurface(r)) ?? null;
14847
+ }
14848
+ function uiRoutedSceneSet(input) {
14849
+ return new Set(uiRoutedRuns(input).flat());
14850
+ }
14851
+ function uiRoutedRuns(input) {
14852
+ const blueprint = VideoBlueprint.parse(input);
14853
+ const info = blueprint.scenes.map((scene) => {
14854
+ const regions = compositeRegionsOf(scene);
14855
+ if (!regions) return null;
14856
+ const comp = scene.composition;
14857
+ const panels = regions.map((r) => (r.panel ?? "").toLowerCase()).sort().join(",");
14858
+ const surfaceIds = regions.filter(regionIsUiSurface).map((r) => (r.surface_id ?? "").trim()).filter(Boolean).sort().join(",");
14859
+ const sig = surfaceIds ? `sid:${surfaceIds}` : `${(comp?.layout ?? "").toLowerCase()}|${(comp?.split_axis ?? "").toLowerCase()}|${panels}`;
14860
+ return { sig, ui: isUiOnlyComposite(regions) };
14861
+ });
14862
+ const runs = [];
14863
+ let run = [];
14864
+ const flushRun = () => {
14865
+ if (run.some((i) => info[i]?.ui)) runs.push(run);
14866
+ run = [];
14867
+ };
14868
+ info.forEach((inf, i) => {
14869
+ if (!inf) {
14870
+ flushRun();
14871
+ return;
14872
+ }
14873
+ const prev = i > 0 ? info[i - 1] : null;
14874
+ if (!prev || prev.sig !== inf.sig) flushRun();
14875
+ run.push(i);
14876
+ });
14877
+ flushRun();
14878
+ return runs;
14879
+ }
14680
14880
  function sceneIsFullScreenUi(scene, present) {
14681
14881
  if (scene.narrative_role?.trim() === "cta") return false;
14682
14882
  const hasCast = present.some((s) => {
@@ -14710,14 +14910,11 @@ function screenStillArgs(durationS, dims) {
14710
14910
  "{{out.video}}"
14711
14911
  ];
14712
14912
  }
14713
- function layeredComposition(scene) {
14714
- const comp = scene.composition;
14715
- const layout = (comp?.layout ?? "").toLowerCase();
14716
- if (!COMPOSITE_LAYOUTS.has(layout)) return null;
14717
- const regions = (comp?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
14718
- if (regions.length < 2) return null;
14719
- if (isUiOnlyComposite(regions)) return null;
14720
- return { layout, regions, comp: comp ?? {} };
14913
+ function layeredComposition(scene, uiRouted) {
14914
+ const regions = compositeRegionsOf(scene);
14915
+ if (!regions) return null;
14916
+ if (uiRouted) return null;
14917
+ return { layout: (scene.composition?.layout ?? "").toLowerCase(), regions, comp: scene.composition ?? {} };
14721
14918
  }
14722
14919
  function splitAxisOf(comp, regions) {
14723
14920
  const panels = regions.map((r) => (r.panel ?? "").toLowerCase());
@@ -14808,7 +15005,7 @@ function buildCompositeScene(layout, regions, comp, scene, i, present, mode, nat
14808
15005
  const bgIdx = regions.findIndex((_, k) => k !== presIdx);
14809
15006
  const bgRef = regionRefs[bgIdx >= 0 ? bgIdx : 0];
14810
15007
  let presRef = regionRefs[presIdx >= 0 ? presIdx : 1];
14811
- if (layout === "keyed_overlay" && presIdx >= 0) {
15008
+ if (layout === "keyed_overlay") {
14812
15009
  const keyId = `s${i}_key`;
14813
15010
  nodes.push({ id: keyId, type: "video_background_remove", inputs: { video: presRef }, params: {} });
14814
15011
  presRef = `$ref:${keyId}.video`;
@@ -14909,6 +15106,67 @@ function emitScreenScene(i, scene, lengths, out, outAr, nodes, clips) {
14909
15106
  });
14910
15107
  clips.push({ ref: `$ref:s${i}_clip.video`, scene_s: lengths.dur, out });
14911
15108
  }
15109
+ function sceneIsAllGraphic(scene) {
15110
+ const regions = (scene.composition?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
15111
+ if (regions.length === 0 || regions.some((r) => (r.kind ?? "").toLowerCase() === "camera")) return false;
15112
+ return regions.some((r) => (r.kind ?? "").toLowerCase() === "static_graphic");
15113
+ }
15114
+ function sceneIsFullFrameGraphic(scene) {
15115
+ return sceneIsAllGraphic(scene);
15116
+ }
15117
+ function fullFrameGraphicScenes(blueprint) {
15118
+ const scenes = blueprint.scenes;
15119
+ const graphic = new Set(scenes.flatMap((s, i) => sceneIsFullFrameGraphic(s) ? [i] : []));
15120
+ const blocked = (s) => {
15121
+ const regions = (s.composition?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
15122
+ if (regions.some((r) => (r.kind ?? "").toLowerCase() === "camera")) return true;
15123
+ return (s.dialogue ?? []).some((d) => d.on_camera === true);
15124
+ };
15125
+ let changed = true;
15126
+ while (changed) {
15127
+ changed = false;
15128
+ scenes.forEach((s, i) => {
15129
+ if (graphic.has(i)) return;
15130
+ const chainsToPrev = s.continues_previous && graphic.has(i - 1);
15131
+ const next = scenes[i + 1];
15132
+ const chainsToNext = Boolean(next?.continues_previous) && graphic.has(i + 1);
15133
+ if ((chainsToPrev || chainsToNext) && !blocked(s)) {
15134
+ graphic.add(i);
15135
+ changed = true;
15136
+ }
15137
+ });
15138
+ }
15139
+ return graphic;
15140
+ }
15141
+ function emitGraphicScene(i, scene, lengths, out, outAr, surfaceIngests, nodes, clips) {
15142
+ const regions = (scene.composition?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
15143
+ const surfaceId = regions.find((r) => r.surface_id)?.surface_id;
15144
+ let refId = surfaceId ? surfaceIngests.get(surfaceId) : void 0;
15145
+ if (!refId) {
15146
+ const label = commentSafe((scene.summary || scene.start_frame_prompt || "the designed panel").slice(0, 120));
15147
+ refId = `s${i}_graphic_ref`;
15148
+ nodes.push({
15149
+ id: refId,
15150
+ type: "ingest",
15151
+ params: {
15152
+ source: "path",
15153
+ path: `[TODO: supply the REAL designed graphic for "${label}" \u2014 NEVER AI-generate a designed panel (its typography/wordmarks garble). Rebuild it as brand HTML in video-overlay-composition (the scene's text/logos are already seeded there), export the design from the brand kit, or source stock art; text rides the overlay layer, not this plate]`,
15154
+ expect: "image"
15155
+ }
15156
+ });
15157
+ if (surfaceId) surfaceIngests.set(surfaceId, refId);
15158
+ }
15159
+ nodes.push({
15160
+ id: `s${i}_clip`,
15161
+ type: "ffmpeg",
15162
+ inputs: { frame: `$ref:${refId}.asset` },
15163
+ params: {
15164
+ args: screenStillArgs(lengths.trimTarget, canvasDims(outAr)),
15165
+ outputs: { video: { kind: "video", ext: "mp4" } }
15166
+ }
15167
+ });
15168
+ clips.push({ ref: `$ref:s${i}_clip.video`, scene_s: lengths.dur, out });
15169
+ }
14912
15170
  var BRAND_CARD_RE = /\b(?:solid|plain|flat|brand|logo|wordmark|end[- ]?card|cta card|title card|colou?r background|background colou?r)\b/i;
14913
15171
  function sceneIsBrandCard(scene, present, isCta) {
14914
15172
  if (!isCta) return false;
@@ -15149,8 +15407,12 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
15149
15407
  speaker: sp,
15150
15408
  // Shown = a cast member speaking AND their element is actually on screen
15151
15409
  // here (not a cutaway). A b-roll cutaway mid-phrase fails this and gets
15152
- // its own clip while the phrase voice plays under it.
15153
- shown: isOnCameraSpeaker(raw, casts, cameraOn) && !multiSpeaker.has(sceneIndex) && presenterPresent(sp, sceneIndex),
15410
+ // its own clip while the phrase voice plays under it. An explicit
15411
+ // deconstruct voiceover stamp (`on_camera: false`) wins over element
15412
+ // presence — a speaker pictured in a photo is "present" but not talking.
15413
+ // An all-graphic composition (no camera region) is voiceover by
15414
+ // definition: nobody is on screen to lip-sync.
15415
+ shown: l.on_camera !== false && !sceneIsAllGraphic(scene) && isOnCameraSpeaker(raw, casts, cameraOn) && !multiSpeaker.has(sceneIndex) && presenterPresent(sp, sceneIndex),
15154
15416
  start,
15155
15417
  // Real speech end. When the deconstruct gives no end_s, estimate it from
15156
15418
  // the words — NOT the scene end (which would fabricate continuity across
@@ -15248,7 +15510,8 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
15248
15510
  shootMode: mode,
15249
15511
  ingestCache: env.ingestCache
15250
15512
  };
15251
- const first = buildFrameRef(
15513
+ const chained = anchorScene.continues_previous && out.phraseEnd?.scene === anchor - 1 ? out.phraseEnd.ref : void 0;
15514
+ const first = chained ?? buildFrameRef(
15252
15515
  "start",
15253
15516
  anchorScene.start_frame_asset?.url,
15254
15517
  anchorScene.start_frame_prompt,
@@ -15275,7 +15538,16 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
15275
15538
  const genDur = ceilToSeedance(phraseLen);
15276
15539
  const clipParams = {
15277
15540
  model: env.opts.videoModel,
15278
- prompt: buildSeedancePrompt(anchorScene, anchor, present, mode, true, phrase.text, env.ttsLanguageCode),
15541
+ prompt: buildSeedancePrompt(
15542
+ anchorScene,
15543
+ anchor,
15544
+ present,
15545
+ mode,
15546
+ true,
15547
+ phrase.text,
15548
+ env.ttsLanguageCode,
15549
+ env.uiRouted.has(anchor)
15550
+ ),
15279
15551
  duration: genDur,
15280
15552
  ...videoResolutionParam(env.opts.videoModel, env.opts.resolution),
15281
15553
  generate_audio: true
@@ -15287,6 +15559,7 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
15287
15559
  inputs: { first_frame: first, last_frame: last },
15288
15560
  params: clipParams
15289
15561
  });
15562
+ out.phraseEnd = { ref: last, scene: lastShown };
15290
15563
  const clipRef = `$ref:s${anchor}_clip.video`;
15291
15564
  const speechOffset = Math.max(0, phrase.start_s - clipStart);
15292
15565
  const extractLen = Math.min(Math.max(0.5, phrase.end_s - phrase.start_s), Math.max(0.5, genDur - speechOffset));
@@ -15321,7 +15594,8 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
15321
15594
  scene: anchor,
15322
15595
  voice_convert_node: convId,
15323
15596
  scene_s: Math.round(phraseLen * 100) / 100,
15324
- est_speech_s: Math.round(estSpeechS(phrase.text) * 100) / 100
15597
+ est_speech_s: Math.round(estSpeechWindowS(phrase.text, phrase.start_s, phrase.end_s) * 100) / 100,
15598
+ speech_words: wordCount(phrase.text)
15325
15599
  });
15326
15600
  for (const s of phrase.shownScenes) {
15327
15601
  const sc = env.blueprint.scenes[s];
@@ -15379,7 +15653,8 @@ function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical, en
15379
15653
  scene: i,
15380
15654
  voice_convert_node: `${voiceNode}_conv`,
15381
15655
  scene_s: Math.round(sceneDurationS(scene) * 100) / 100,
15382
- est_speech_s: Math.round(estSpeechS(text) * 100) / 100
15656
+ est_speech_s: Math.round(estSpeechWindowS(text, start, end) * 100) / 100,
15657
+ speech_words: wordCount(text)
15383
15658
  });
15384
15659
  }
15385
15660
  const mode = sceneShootMode(scene, present, nativeTurn, env.cameraOn, env.casts);
@@ -15471,6 +15746,10 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
15471
15746
  shootMode: mode,
15472
15747
  ingestCache: env.ingestCache
15473
15748
  };
15749
+ if (!env.reuse && env.graphicScenes.has(i)) {
15750
+ emitGraphicScene(i, scene, lengths, lengths.out, env.outAr, env.surfaceIngests, nodes, out.clips);
15751
+ return void 0;
15752
+ }
15474
15753
  if (!env.reuse && sceneIsFullScreenUi(scene, present)) {
15475
15754
  emitScreenScene(i, scene, lengths, lengths.out, env.outAr, nodes, out.clips);
15476
15755
  return void 0;
@@ -15480,7 +15759,7 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
15480
15759
  emitBrandCardScene(i, lengths, lengths.out, env.outAr, brandPlateColor(env.blueprint), nodes, out.clips);
15481
15760
  return void 0;
15482
15761
  }
15483
- if (!ambientBroll && lengths.dur <= FLASH_HOLD_MAX_S) {
15762
+ if (!ambientBroll && lengths.dur <= FLASH_HOLD_MAX_S && scene.motion_level !== "dynamic") {
15484
15763
  emitFlashHold(i, scene, env.slots, ctx, lengths, lengths.out, env.outAr, nodes, out.clips);
15485
15764
  return void 0;
15486
15765
  }
@@ -15515,7 +15794,8 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
15515
15794
  genAr: env.genAr,
15516
15795
  videoModel: env.opts.videoModel,
15517
15796
  resolution: env.opts.resolution,
15518
- nativeLang: env.ttsLanguageCode
15797
+ nativeLang: env.ttsLanguageCode,
15798
+ uiRouted: env.uiRouted.has(i)
15519
15799
  },
15520
15800
  nodes
15521
15801
  );
@@ -15536,10 +15816,11 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
15536
15816
  }
15537
15817
  function buildTimeline(blueprint, slots, opts, nodes) {
15538
15818
  const reuse = opts.frames === "reuse";
15819
+ const uiRouted = uiRoutedSceneSet(blueprint);
15539
15820
  const compositeScenes = /* @__PURE__ */ new Set();
15540
15821
  if (!reuse) {
15541
15822
  blueprint.scenes.forEach((s, i) => {
15542
- if (layeredComposition(s)) compositeScenes.add(i);
15823
+ if (layeredComposition(s, uiRouted.has(i))) compositeScenes.add(i);
15543
15824
  });
15544
15825
  }
15545
15826
  const canonical = collapseVoiceover(blueprint);
@@ -15555,6 +15836,9 @@ function buildTimeline(blueprint, slots, opts, nodes) {
15555
15836
  cameraOn: onCameraDialogue(blueprint),
15556
15837
  casts: castIdSet(blueprint),
15557
15838
  ingestCache: /* @__PURE__ */ new Map(),
15839
+ uiRouted,
15840
+ graphicScenes: fullFrameGraphicScenes(blueprint),
15841
+ surfaceIngests: /* @__PURE__ */ new Map(),
15558
15842
  ttsLanguageCode: deriveTtsLanguageCode(blueprint)
15559
15843
  };
15560
15844
  const out = {
@@ -15583,7 +15867,7 @@ function buildTimeline(blueprint, slots, opts, nodes) {
15583
15867
  const lastIndex = blueprint.scenes.length - 1;
15584
15868
  let prevEndFrame;
15585
15869
  blueprint.scenes.forEach((scene, i) => {
15586
- const composite = compositeScenes.has(i) ? layeredComposition(scene) : null;
15870
+ const composite = compositeScenes.has(i) ? layeredComposition(scene, uiRouted.has(i)) : null;
15587
15871
  if (composite) {
15588
15872
  emitCompositeInTimeline(
15589
15873
  composite,
@@ -15780,9 +16064,42 @@ function sourceHint(fe) {
15780
16064
  return `baker images icon "${desc}"`;
15781
16065
  }
15782
16066
  }
15783
- function floatingStub(fe, sceneStart) {
15784
- const at = fe.appears_at_s ?? sceneStart;
15785
- const dur = fe.duration_s ?? 2.5;
16067
+ function floatKey(fe) {
16068
+ return [
16069
+ (fe.kind ?? "element").toLowerCase(),
16070
+ (fe.brand_name || fe.what_it_represents || fe.description || "").toLowerCase().trim(),
16071
+ positionClass(fe.position)
16072
+ ].join("|");
16073
+ }
16074
+ function floatIsRoutedSurface(fe, sceneIsUiRouted) {
16075
+ return sceneIsUiRouted && (fe.kind ?? "").toLowerCase() === "ui_element" && UI_SURFACE_RE.test(fe.description ?? "");
16076
+ }
16077
+ function addFloatDetection(windows, fe, at, end) {
16078
+ const wins = windows.get(floatKey(fe)) ?? [];
16079
+ const last = wins[wins.length - 1];
16080
+ if (last && at <= last.end + OVERLAY_REDETECT_GAP_S) {
16081
+ last.end = Math.max(last.end, end);
16082
+ return;
16083
+ }
16084
+ wins.push({ fe, at, end });
16085
+ windows.set(floatKey(fe), wins);
16086
+ }
16087
+ function collectFloatWindows(blueprint, uiRouted) {
16088
+ const windows = /* @__PURE__ */ new Map();
16089
+ blueprint.scenes.forEach((scene, i) => {
16090
+ const sceneStart = scene.start_s ?? 0;
16091
+ const floats = z11.array(FloatingElement).safeParse(scene.floating_elements ?? []);
16092
+ if (!floats.success) return;
16093
+ for (const fe of floats.data) {
16094
+ const at = fe.appears_at_s ?? sceneStart;
16095
+ const dur = fe.duration_s ?? 2.5;
16096
+ if (floatIsRoutedSurface(fe, uiRouted.has(i)) || dur < OVERLAY_MIN_DUR_S) continue;
16097
+ addFloatDetection(windows, fe, at, at + dur);
16098
+ }
16099
+ });
16100
+ return [...windows.values()].flat().sort((a, b) => a.at - b.at);
16101
+ }
16102
+ function floatingStub(fe, at, dur) {
15786
16103
  const kind = commentSafe(fe.kind ?? "element");
15787
16104
  const label = commentSafe(fe.brand_name || fe.what_it_represents || fe.description || fe.kind || "element");
15788
16105
  const hint = commentSafe(sourceHint(fe));
@@ -15792,27 +16109,49 @@ function floatingStub(fe, sceneStart) {
15792
16109
  `<img class="ov clip ${positionClass(fe.position)}" src="your-${slug}.png" data-start="${at}" data-dur="${dur}" alt="" /> -->`
15793
16110
  ].join("\n");
15794
16111
  }
15795
- function uiPipStub(scene) {
15796
- const comp = scene.composition;
15797
- const layout = (comp?.layout ?? "").toLowerCase();
15798
- if (!COMPOSITE_LAYOUTS.has(layout)) return "";
15799
- const regions = (comp?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
15800
- if (regions.length < 2 || !isUiOnlyComposite(regions)) return "";
15801
- const ui = regions.find(regionIsUiSurface);
15802
- const at = scene.start_s ?? 0;
15803
- const dur = Math.max(0.5, Math.round(((scene.end_s ?? at + 2.5) - at) * 100) / 100);
16112
+ function uiSurfaceStub(blueprint, run) {
16113
+ const scenes = run.map((i) => blueprint.scenes[i]).filter((s) => Boolean(s));
16114
+ if (scenes.length === 0) return "";
16115
+ const first = scenes[0];
16116
+ const last = scenes[scenes.length - 1];
16117
+ const surfaceOf = (scene) => {
16118
+ const regions = compositeRegionsOf(scene) ?? [];
16119
+ return regions.find(regionIsUiSurface) ?? regions.find((r) => !r.is_presenter);
16120
+ };
16121
+ const ui = scenes.map(surfaceOf).find(Boolean);
16122
+ const at = first.start_s ?? 0;
16123
+ const end = last.end_s ?? at + 2.5;
16124
+ const dur = Math.max(0.5, Math.round((end - at) * 100) / 100);
15804
16125
  const label = commentSafe(ui?.summary || ui?.frame_prompt || ui?.panel || "the app screen");
15805
- return [
15806
- `<!-- PHONE UI @ ${at}s for ${dur}s \u2014 the app/site screen this scene shows: ${label}.`,
16126
+ const states = scenes.length > 1 ? scenes.map((s) => {
16127
+ const beat = surfaceOf(s)?.summary || s.summary || "\u2026";
16128
+ return ` @ ${s.start_s ?? 0}s \u2014 ${commentSafe(beat)}`;
16129
+ }) : [];
16130
+ const isGraphic = scenes.some((s) => (compositeRegionsOf(s) ?? []).some((r) => r.kind === "static_graphic"));
16131
+ const sourcing = isGraphic ? [
16132
+ " Build it as a REAL designed panel, NEVER AI-generate its text/typography:",
16133
+ " hand-build a brand-accurate HTML block in this composition (restyle the seeded",
16134
+ " overlay text, or `npx hyperframes add <block>` for a ready-made card),",
16135
+ " \u2014 OR drop the design asset (export it from the brand kit) as panel.png here;",
16136
+ " then nest it as a timed clip:"
16137
+ ] : [
15807
16138
  " Build it as a REAL surface, NEVER AI: capture the live page \u2014",
15808
16139
  " baker images screenshot https://<brand-domain>/<path> (image-library skill)",
15809
16140
  " \u2014 OR hand-build a brand-accurate HTML screen; then frame it in a phone mockup:",
15810
16141
  " npx hyperframes add phone-scroll (writes compositions/phone-scroll.html)",
15811
- " drop the screenshot as screenshot.png in this dir and nest it as a PIP clip:",
16142
+ " drop the screenshot as screenshot.png in this dir and nest it as a PIP clip:"
16143
+ ];
16144
+ const kindLabel = isGraphic ? "GRAPHIC PANEL" : "PHONE UI";
16145
+ const noun = isGraphic ? "the designed graphic panel this window shows" : "the app/site screen this window shows";
16146
+ return [
16147
+ `<!-- ${kindLabel} @ ${at}s for ${dur}s \u2014 ${noun}: ${label}.`,
16148
+ ...states.length > 0 ? [" ONE continuous surface; its states over the window:", ...states] : [],
16149
+ ...sourcing,
15812
16150
  ` <div class="clip" data-composition-src="compositions/phone-scroll.html" data-start="${at}" data-duration="${dur}" data-track-index="2" data-width="1080" data-height="1920"></div> -->`
15813
16151
  ].join("\n");
15814
16152
  }
15815
- function buildOverlayHtml(input) {
16153
+ var CAPTION_ROLE_RE = /^(?:caption|captions|subtitle|subtitles|karaoke)$/i;
16154
+ function buildOverlayHtml(input, opts = {}) {
15816
16155
  const blueprint = VideoBlueprint.parse(input);
15817
16156
  const blocks = [
15818
16157
  [
@@ -15831,33 +16170,15 @@ function buildOverlayHtml(input) {
15831
16170
  " Positions: edit the .pos-* classes or add your own. -->"
15832
16171
  ].join("\n")
15833
16172
  ];
15834
- const ovParts = mergeCaptions(blueprint).map((e) => overlayElement(e.ov, e.at, Math.round((e.end - e.at) * 1e3) / 1e3)).filter(Boolean);
16173
+ const ovParts = mergeCaptions(blueprint).filter((e) => !(opts.captionsActive && CAPTION_ROLE_RE.test(e.ov.role ?? ""))).map((e) => overlayElement(e.ov, e.at, Math.round((e.end - e.at) * 1e3) / 1e3)).filter(Boolean);
15835
16174
  if (ovParts.length > 0) blocks.push(ovParts.join("\n"));
15836
- const seenFloats = /* @__PURE__ */ new Map();
15837
- const keepFloat = (fe, sceneStart) => {
15838
- const at = fe.appears_at_s ?? sceneStart;
15839
- const dur = fe.duration_s ?? 2.5;
15840
- if (dur < OVERLAY_MIN_DUR_S) return false;
15841
- const key = [
15842
- (fe.kind ?? "element").toLowerCase(),
15843
- (fe.brand_name || fe.what_it_represents || fe.description || "").toLowerCase().trim(),
15844
- positionClass(fe.position)
15845
- ].join("|");
15846
- const lastEnd = seenFloats.get(key);
15847
- if (lastEnd !== void 0 && at <= lastEnd + OVERLAY_REDETECT_GAP_S) {
15848
- seenFloats.set(key, Math.max(lastEnd, at + dur));
15849
- return false;
15850
- }
15851
- seenFloats.set(key, at + dur);
15852
- return true;
15853
- };
15854
- for (const scene of blueprint.scenes) {
15855
- const sceneStart = scene.start_s ?? 0;
15856
- const floats = z11.array(FloatingElement).safeParse(scene.floating_elements ?? []);
15857
- const parts = (floats.success ? floats.data.filter((fe) => keepFloat(fe, sceneStart)).map((fe) => floatingStub(fe, sceneStart)) : []).filter(Boolean);
15858
- const pip = uiPipStub(scene);
15859
- if (pip) parts.push(pip);
15860
- if (parts.length > 0) blocks.push(parts.join("\n"));
16175
+ const runs = uiRoutedRuns(blueprint);
16176
+ const uiRouted = new Set(runs.flat());
16177
+ const floatParts = collectFloatWindows(blueprint, uiRouted).map((w) => floatingStub(w.fe, w.at, Math.round((w.end - w.at) * 100) / 100)).filter(Boolean);
16178
+ if (floatParts.length > 0) blocks.push(floatParts.join("\n"));
16179
+ for (const run of runs) {
16180
+ const stub = uiSurfaceStub(blueprint, run);
16181
+ if (stub) blocks.push(stub);
15861
16182
  }
15862
16183
  return blocks.join("\n\n");
15863
16184
  }
@@ -15932,6 +16253,7 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
15932
16253
  params: { source: "path", path: opts.blueprintPath ?? "./prompt.json", expect: "json" }
15933
16254
  });
15934
16255
  const slots = buildElementSlots(elements);
16256
+ extendPresenceByPromptMentions(slots, blueprint);
15935
16257
  slots.forEach((slot, i) => {
15936
16258
  nodes.push({
15937
16259
  id: slot.id,
@@ -15945,7 +16267,7 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
15945
16267
  let videoNode = "spine";
15946
16268
  const overlays = blueprint.scenes.flatMap((s) => s.overlays ?? []);
15947
16269
  const floating = blueprint.scenes.flatMap((s) => s.floating_elements ?? []);
15948
- const hasUiPip = blueprint.scenes.some((s) => uiPipStub(s) !== "");
16270
+ const hasUiPip = uiRoutedRuns(blueprint).length > 0;
15949
16271
  if (overlays.length > 0 || floating.length > 0 || hasUiPip) {
15950
16272
  nodes.push({
15951
16273
  id: "overlaid",
@@ -16328,7 +16650,10 @@ function collectClipAdvisories(scene, i, out) {
16328
16650
  const window = sceneDurationS(scene);
16329
16651
  if (window > SEEDANCE_SAFE_MAX_S)
16330
16652
  out.oversize.push({ scene: i, scene_s: round22(window), clip_s: ceilToSeedance(window) });
16331
- const speech = (scene.dialogue ?? []).reduce((s, line) => s + (line.line ? estSpeechS(line.line) : 0), 0);
16653
+ const speech = (scene.dialogue ?? []).reduce(
16654
+ (s, line) => s + (line.line ? estSpeechWindowS(line.line, line.start_s, line.end_s) : 0),
16655
+ 0
16656
+ );
16332
16657
  if (speech > window * OVERSTUFF_RATIO)
16333
16658
  out.overstuffed.push({ scene: i, scene_s: round22(window), est_speech_s: round22(speech) });
16334
16659
  }
@@ -16517,7 +16842,7 @@ async function detectShotCutsBestEffort(videoPath, threshold) {
16517
16842
  process.stderr.write(`Shot-cut detection skipped (${msg}); using LLM boundaries.
16518
16843
  `);
16519
16844
  }
16520
- return [];
16845
+ return void 0;
16521
16846
  }
16522
16847
  }
16523
16848
  function fail2(code, message) {
@@ -16546,7 +16871,7 @@ function buildDeconstructCanvas(videoPath, deconstructModel, opts) {
16546
16871
  if (typeof opts.maxScenes === "number") deconstructParams.max_scenes = opts.maxScenes;
16547
16872
  if (opts.language) deconstructParams.language = opts.language;
16548
16873
  if (opts.focus) deconstructParams.focus = opts.focus;
16549
- if (opts.shotCuts && opts.shotCuts.length > 0) deconstructParams.shot_cuts = opts.shotCuts;
16874
+ if (opts.shotCuts) deconstructParams.shot_cuts = opts.shotCuts;
16550
16875
  deconstructParams.max_clip_s = SEEDANCE_DURATIONS[SEEDANCE_DURATIONS.length - 1];
16551
16876
  return {
16552
16877
  schema: "baker-canvas/1",
@@ -16697,7 +17022,7 @@ var scaffoldVideoCommand = defineCommand87({
16697
17022
  await cp(SHIPPED_COMPOSITION_DIR, compositionDest, { recursive: true });
16698
17023
  await stampCompositionDims(compositionDest, outDims);
16699
17024
  const indexPath = path9.join(compositionDest, "index.html");
16700
- const overlayHtml = buildOverlayHtml(blueprint);
17025
+ const overlayHtml = buildOverlayHtml(blueprint, { captionsActive: Boolean(transcript) });
16701
17026
  const indexHtml = await readFile6(indexPath, "utf8");
16702
17027
  const injected = indexHtml.replace("<!--OVERLAYS-->", () => overlayHtml);
16703
17028
  if (injected === indexHtml && overlayHtml.trim()) {
@@ -16729,15 +17054,31 @@ var scaffoldVideoCommand = defineCommand87({
16729
17054
  return fail2("scaffold", e instanceof Error ? e.message : String(e));
16730
17055
  }
16731
17056
  const validation = await validateCanvasDeep(resolveRelativeCanvasPaths(canvas, outDir), defaultRegistry());
17057
+ if (!validation.ok) {
17058
+ const meta = canvas.metadata;
17059
+ const todo = meta.todo ?? {};
17060
+ todo.blocking_validation_issues = validation.issues;
17061
+ meta.todo = todo;
17062
+ }
17063
+ await writeFile2(outPath, `${JSON.stringify(canvas, null, 2)}
17064
+ `, "utf8");
16732
17065
  if (!validation.ok) {
16733
17066
  process.stderr.write(
16734
- `${JSON.stringify({ ok: false, error: { code: "validation", issues: validation.issues } }, null, 2)}
17067
+ `${JSON.stringify(
17068
+ {
17069
+ ok: false,
17070
+ error: { code: "validation", issues: validation.issues },
17071
+ canvas_path: outPath,
17072
+ prompt_path: blueprintPath,
17073
+ note: "The canvas WAS written despite the blocking issue(s) \u2014 fix them in place, then `baker canvas validate` before running."
17074
+ },
17075
+ null,
17076
+ 2
17077
+ )}
16735
17078
  `
16736
17079
  );
16737
17080
  process.exit(2);
16738
17081
  }
16739
- await writeFile2(outPath, `${JSON.stringify(canvas, null, 2)}
16740
- `, "utf8");
16741
17082
  await ensureGitignore(process.cwd(), ["canvas/", ".context/"]);
16742
17083
  process.stdout.write(
16743
17084
  `${JSON.stringify(