@koda-sl/baker-cli 0.70.0 → 0.71.2

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,7 +9,7 @@ import {
9
9
  defaultRegistry,
10
10
  generateCatalog,
11
11
  validateCanvasDeep
12
- } from "./chunk-XFDZVKLF.js";
12
+ } from "./chunk-JIDZ37KG.js";
13
13
 
14
14
  // src/cli.ts
15
15
  import { defineCommand as defineCommand138, runMain } from "citty";
@@ -147,9 +147,9 @@ async function handleResponse(response) {
147
147
  throw new ApiError("INTERNAL_ERROR", "Failed to parse API response as JSON");
148
148
  }
149
149
  }
150
- async function apiGet(path6, params) {
150
+ async function apiGet(path7, params) {
151
151
  const env = getEnv();
152
- const url = new URL(path6, env.BAKER_API_URL);
152
+ const url = new URL(path7, env.BAKER_API_URL);
153
153
  if (params) {
154
154
  const clean = sanitizeParams(params);
155
155
  for (const [key, value] of Object.entries(clean)) {
@@ -174,12 +174,12 @@ async function apiGet(path6, params) {
174
174
  }
175
175
  return handleResponse(response);
176
176
  }
177
- async function apiPost(path6, body, opts) {
177
+ async function apiPost(path7, body, opts) {
178
178
  const env = getEnv();
179
179
  const timeoutMs = opts?.timeoutMs ?? 6e4;
180
180
  let response;
181
181
  try {
182
- response = await fetchWithRateLimitRetry(new URL(path6, env.BAKER_API_URL).toString(), {
182
+ response = await fetchWithRateLimitRetry(new URL(path7, env.BAKER_API_URL).toString(), {
183
183
  method: "POST",
184
184
  headers: {
185
185
  Authorization: `Bearer ${env.BAKER_API_KEY}`,
@@ -918,31 +918,31 @@ function cachePath(category, key) {
918
918
  return join(dir, `${hashKey(key)}.json`);
919
919
  }
920
920
  function cacheGet(category, key) {
921
- const path6 = cachePath(category, key);
922
- if (!existsSync(path6)) {
921
+ const path7 = cachePath(category, key);
922
+ if (!existsSync(path7)) {
923
923
  return null;
924
924
  }
925
925
  try {
926
- const raw = readFileSync(path6, "utf-8");
926
+ const raw = readFileSync(path7, "utf-8");
927
927
  const entry = JSON.parse(raw);
928
928
  if (entry.expiresAt < Date.now()) {
929
- rmSync(path6, { force: true });
929
+ rmSync(path7, { force: true });
930
930
  return null;
931
931
  }
932
932
  return entry;
933
933
  } catch {
934
- rmSync(path6, { force: true });
934
+ rmSync(path7, { force: true });
935
935
  return null;
936
936
  }
937
937
  }
938
938
  function cacheSet(category, key, data, ttlMs, fields) {
939
- const path6 = cachePath(category, key);
939
+ const path7 = cachePath(category, key);
940
940
  const entry = {
941
941
  expiresAt: Date.now() + ttlMs,
942
942
  data,
943
943
  fields
944
944
  };
945
- writeFileSync(path6, JSON.stringify(entry), "utf-8");
945
+ writeFileSync(path7, JSON.stringify(entry), "utf-8");
946
946
  }
947
947
  var HOUR = 60 * 60 * 1e3;
948
948
  var MINUTE = 60 * 1e3;
@@ -7714,6 +7714,24 @@ async function probeDuration(filePath) {
7714
7714
  import { readFile as readFile2 } from "fs/promises";
7715
7715
  import path2 from "path";
7716
7716
  import { defineCommand as defineCommand74 } from "citty";
7717
+
7718
+ // src/commands/canvas/placeholders.ts
7719
+ function unsuppliedPlaceholderAssets(canvas) {
7720
+ const nodes = canvas?.nodes;
7721
+ if (!Array.isArray(nodes)) return [];
7722
+ const out = [];
7723
+ for (const n of nodes) {
7724
+ const node = n;
7725
+ if (node?.type !== "ingest") continue;
7726
+ const params = node.params;
7727
+ if (params?.source === "path" && typeof params.path === "string" && params.path.includes("[TODO")) {
7728
+ out.push({ node: String(node.id ?? "?"), placeholder: params.path });
7729
+ }
7730
+ }
7731
+ return out;
7732
+ }
7733
+
7734
+ // src/commands/canvas/run.ts
7717
7735
  var runCommand = defineCommand74({
7718
7736
  meta: { name: "run", description: "Validate and execute a canvas JSON file." },
7719
7737
  args: {
@@ -7735,6 +7753,25 @@ var runCommand = defineCommand74({
7735
7753
  `);
7736
7754
  process.exit(2);
7737
7755
  }
7756
+ const pending = unsuppliedPlaceholderAssets(parsed);
7757
+ if (pending.length > 0) {
7758
+ process.stderr.write(
7759
+ `${JSON.stringify(
7760
+ {
7761
+ ok: false,
7762
+ error: {
7763
+ code: "unsupplied_assets",
7764
+ message: "This canvas still has placeholder asset slots \u2014 supply a real image (or video) at each before running. Each `el_*` ingest is a [TODO] you fill with the real logo/subject/product, then re-run.",
7765
+ assets: pending
7766
+ }
7767
+ },
7768
+ null,
7769
+ 2
7770
+ )}
7771
+ `
7772
+ );
7773
+ process.exit(2);
7774
+ }
7738
7775
  const engine = createEngineFromEnv({
7739
7776
  cacheDir: args["cache-dir"] ? String(args["cache-dir"]) : void 0,
7740
7777
  outputsDir: args["outputs-dir"] ? String(args["outputs-dir"]) : void 0,
@@ -8192,7 +8229,7 @@ var scaffoldStaticAdCommand = defineCommand75({
8192
8229
 
8193
8230
  // src/commands/canvas/scaffold-video.ts
8194
8231
  import { cp, mkdir, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
8195
- import path4 from "path";
8232
+ import path5 from "path";
8196
8233
  import { defineCommand as defineCommand76 } from "citty";
8197
8234
 
8198
8235
  // src/engine/scaffold/video.ts
@@ -8200,7 +8237,19 @@ import { z as z3 } from "zod";
8200
8237
  var FIXED_TTS_MODEL = "elevenlabs/eleven_v3";
8201
8238
  var FIXED_SFX_MODEL = "elevenlabs/eleven_text_to_sound_v2";
8202
8239
  var FIXED_MUSIC_MODEL = "elevenlabs/music-v1";
8240
+ var FIXED_LIPSYNC_MODEL = "fal/veed-lipsync";
8203
8241
  var MUSIC_BED_GAIN_DB = -12;
8242
+ var NARRATOR_SPEAKERS = /* @__PURE__ */ new Set([
8243
+ "voiceover",
8244
+ "voice_over",
8245
+ "narrator",
8246
+ "narration",
8247
+ "vo",
8248
+ "announcer",
8249
+ "off_screen",
8250
+ "offscreen",
8251
+ "off-screen"
8252
+ ]);
8204
8253
  var SHARED_ASPECT_RATIOS = /* @__PURE__ */ new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "21:9"]);
8205
8254
  var EDGES = ["start", "end"];
8206
8255
  function snapToSeedance(durationS) {
@@ -8216,11 +8265,39 @@ function snapToSeedance(durationS) {
8216
8265
  }
8217
8266
  return best;
8218
8267
  }
8268
+ function ceilToSeedance(durationS) {
8269
+ const max = SEEDANCE_DURATIONS[SEEDANCE_DURATIONS.length - 1];
8270
+ if (!Number.isFinite(durationS) || durationS <= 0) return SEEDANCE_DURATIONS[0];
8271
+ for (const d of SEEDANCE_DURATIONS) if (d >= durationS) return d;
8272
+ return max;
8273
+ }
8274
+ function sceneDurationS(scene) {
8275
+ const raw = scene.duration_s ?? (scene.end_s != null && scene.start_s != null ? scene.end_s - scene.start_s : 5);
8276
+ const max = SEEDANCE_DURATIONS[SEEDANCE_DURATIONS.length - 1];
8277
+ return Math.min(Math.max(raw, 0.5), max);
8278
+ }
8279
+ function trimArgs(durationS) {
8280
+ return [
8281
+ "-i",
8282
+ "{{in.clip}}",
8283
+ "-t",
8284
+ durationS.toFixed(3),
8285
+ "-an",
8286
+ "-c:v",
8287
+ "libx264",
8288
+ "-pix_fmt",
8289
+ "yuv420p",
8290
+ "{{out.video}}"
8291
+ ];
8292
+ }
8219
8293
  var FrameAsset = z3.object({ url: z3.string().optional() }).loose().optional();
8220
8294
  var DialogueLine = z3.object({
8221
8295
  speaker: z3.string().optional(),
8222
8296
  line: z3.string().optional(),
8297
+ // Absolute seconds on the source timeline (the deconstruct emits both).
8223
8298
  start_s: z3.number().optional(),
8299
+ end_s: z3.number().optional(),
8300
+ delivery: z3.string().optional(),
8224
8301
  voice_description: z3.string().optional()
8225
8302
  }).loose();
8226
8303
  var Sfx = z3.object({
@@ -8260,7 +8337,12 @@ var VideoBlueprint = z3.object({
8260
8337
  identified_track: z3.object({ title: z3.string().optional(), artist: z3.string().optional() }).loose().nullish()
8261
8338
  }).loose().optional(),
8262
8339
  cast: z3.array(z3.object({ id: z3.string().optional(), description: z3.string().optional() }).loose()).optional(),
8263
- voiceover: z3.object({ voice_description: z3.string().optional() }).loose().optional()
8340
+ voiceover: z3.object({
8341
+ // on_camera | mixed → mouths are on screen (lip-sync candidates);
8342
+ // voiceover | none → narration over the picture (no lip-sync).
8343
+ mode: z3.string().optional(),
8344
+ voice_description: z3.string().optional()
8345
+ }).loose().optional()
8264
8346
  }).loose().optional(),
8265
8347
  scenes: z3.array(Scene).min(1)
8266
8348
  }).loose();
@@ -8392,7 +8474,17 @@ function buildFramePrompt(edge, sceneIndex, framePrompt, present, hasAnchor) {
8392
8474
  ].join("\n");
8393
8475
  const description = framePrompt?.trim() || `the ${edge} frame of scene ${sceneIndex + 1} \u2014 describe the full composition, subjects, setting, action, lighting, and palette here. (Edit this line to change ONLY this frame.)`;
8394
8476
  return [
8395
- `Render the ${EDGE} frame of scene ${sceneIndex + 1} as a single still image. This prompt is self-contained and edit-per-frame: change the FRAME DESCRIPTION below to alter ONLY this frame. EXCLUDE all overlay text, captions, stickers, and watermarks \u2014 they are composited on top later.`,
8477
+ `Render the ${EDGE} frame of scene ${sceneIndex + 1} as a single still image. This prompt is self-contained and edit-per-frame: change the FRAME DESCRIPTION below to alter ONLY this frame.`,
8478
+ "",
8479
+ "CRITICAL \u2014 RENDER A CLEAN PLATE WITH ZERO TEXT OR GRAPHICS:",
8480
+ "This frame is a background plate. ALL words, captions, headlines, lower-third bars,",
8481
+ "news tickers/crawls, chyrons, on-screen logos/wordmarks, station bugs, watermarks,",
8482
+ "subtitles, UI and numbers are added afterwards as a separate HTML layer. Render NONE",
8483
+ "of them \u2014 no legible text anywhere in the image, not even in the background, on the",
8484
+ "news desk, on screens, or as part of a 'broadcast look'. If a reference image (a logo,",
8485
+ "a desk, a studio) contains any text or graphics, DO NOT reproduce that text \u2014 render",
8486
+ "the subject/scene only, with blank surfaces where text would be. Imperfect/garbled",
8487
+ "letterforms are the worst outcome; leave those areas clean.",
8396
8488
  "",
8397
8489
  "REFERENCE IMAGES (in the order provided):",
8398
8490
  legend,
@@ -8400,7 +8492,7 @@ function buildFramePrompt(edge, sceneIndex, framePrompt, present, hasAnchor) {
8400
8492
  "FRAME DESCRIPTION (this frame's editable prompt):",
8401
8493
  description,
8402
8494
  "",
8403
- "Keep every recurring element identical to its reference image across all frames. Use the GLOBAL STYLE REFERENCE only for shared cast identity, palette, typography mood, and aspect ratio \u2014 do NOT copy another scene's composition from it; this frame's content is the FRAME DESCRIPTION above.",
8495
+ "Keep every recurring element identical to its reference image across all frames. Use the GLOBAL STYLE REFERENCE only for shared cast identity, palette, typography mood, and aspect ratio \u2014 do NOT copy another scene's composition from it; this frame's content is the FRAME DESCRIPTION above. Again: NO rendered text or graphic overlays \u2014 clean plate only.",
8404
8496
  "",
8405
8497
  "GLOBAL STYLE REFERENCE (shared across frames; not this frame's content):",
8406
8498
  "{{target_blueprint}}"
@@ -8451,7 +8543,7 @@ function buildSeedancePrompt(scene, sceneIndex, present) {
8451
8543
  if (transcript) parts.push(`Transcript: ${transcript}`);
8452
8544
  return parts.join("\n");
8453
8545
  }
8454
- function buildSceneVisuals(blueprint, slots, opts, nodes) {
8546
+ function buildSceneVisuals(blueprint, slots, opts, nodes, sceneTurns) {
8455
8547
  const ar = aspectRatioParam(blueprint);
8456
8548
  const reuse = opts.frames === "reuse";
8457
8549
  const clipRefs = [];
@@ -8473,10 +8565,11 @@ function buildSceneVisuals(blueprint, slots, opts, nodes) {
8473
8565
  ctx,
8474
8566
  nodes
8475
8567
  );
8568
+ const dur = sceneDurationS(scene);
8476
8569
  const clipParams = {
8477
8570
  model: opts.videoModel,
8478
8571
  prompt: buildSeedancePrompt(scene, i, slotsForScene(slots, i)),
8479
- duration: snapToSeedance(scene.duration_s ?? 5)
8572
+ duration: ceilToSeedance(dur)
8480
8573
  };
8481
8574
  if (ar) clipParams.aspect_ratio = ar;
8482
8575
  nodes.push({
@@ -8485,7 +8578,29 @@ function buildSceneVisuals(blueprint, slots, opts, nodes) {
8485
8578
  inputs: { first_frame: firstFrame, last_frame: lastFrame },
8486
8579
  params: clipParams
8487
8580
  });
8488
- clipRefs.push(`$ref:s${i}_clip.video`);
8581
+ let base = `$ref:s${i}_clip.video`;
8582
+ const onCam = (sceneTurns.get(i) ?? []).filter((t) => t.onCamera);
8583
+ const solo = onCam.length === 1 ? onCam[0] : void 0;
8584
+ if (solo) {
8585
+ nodes.push({
8586
+ id: `s${i}_lipsync`,
8587
+ type: "video_lipsync",
8588
+ inputs: { video: base, audio: solo.audioRef },
8589
+ params: { model: FIXED_LIPSYNC_MODEL }
8590
+ });
8591
+ base = `$ref:s${i}_lipsync.video`;
8592
+ }
8593
+ if (ceilToSeedance(dur) === dur) {
8594
+ clipRefs.push(base);
8595
+ } else {
8596
+ nodes.push({
8597
+ id: `s${i}_trim`,
8598
+ type: "ffmpeg",
8599
+ inputs: { clip: base },
8600
+ params: { args: trimArgs(dur), outputs: { video: { kind: "video", ext: "mp4" } } }
8601
+ });
8602
+ clipRefs.push(`$ref:s${i}_trim.video`);
8603
+ }
8489
8604
  });
8490
8605
  return clipRefs;
8491
8606
  }
@@ -8498,8 +8613,21 @@ function musicBedPrompt(blueprint, musicPrompt) {
8498
8613
 
8499
8614
  Reference vibe: the original used "${title}"${by} (identified via AudD). Match its mood, tempo, and energy with ORIGINAL music \u2014 do not reproduce the track.`;
8500
8615
  }
8501
- function buildAudio(blueprint, nodes) {
8616
+ function onCameraDialogue(blueprint) {
8617
+ const mode = blueprint.global?.voiceover?.mode;
8618
+ return mode !== "voiceover" && mode !== "none";
8619
+ }
8620
+ var castIdSet = (blueprint) => new Set((blueprint.global?.cast ?? []).map((c) => c.id).filter((id) => Boolean(id)));
8621
+ function isOnCameraSpeaker(speaker, casts, cameraOn) {
8622
+ if (!cameraOn) return false;
8623
+ if (NARRATOR_SPEAKERS.has(speaker.toLowerCase())) return false;
8624
+ return casts.has(speaker);
8625
+ }
8626
+ function buildDialogue(blueprint, nodes) {
8502
8627
  const tracks = [];
8628
+ const sceneTurns = /* @__PURE__ */ new Map();
8629
+ const casts = castIdSet(blueprint);
8630
+ const cameraOn = onCameraDialogue(blueprint);
8503
8631
  const voiceNodeBySpeaker = /* @__PURE__ */ new Map();
8504
8632
  const speakerDescription = (speaker) => {
8505
8633
  for (const scene of blueprint.scenes) {
@@ -8518,41 +8646,62 @@ function buildAudio(blueprint, nodes) {
8518
8646
  voiceNodeBySpeaker.set(speaker, id);
8519
8647
  return id;
8520
8648
  };
8521
- const scriptBySpeaker = /* @__PURE__ */ new Map();
8522
- const orderedSpeakers = [];
8523
- for (const scene of blueprint.scenes) {
8524
- for (const line of scene.dialogue ?? []) {
8525
- if (!line.line) continue;
8526
- const speaker = line.speaker ?? "voiceover";
8527
- const start = line.start_s ?? scene.start_s ?? 0;
8528
- const existing = scriptBySpeaker.get(speaker);
8529
- if (existing) {
8530
- existing.lines.push(line.line);
8531
- existing.start = Math.min(existing.start, start);
8532
- } else {
8533
- scriptBySpeaker.set(speaker, { lines: [line.line], start });
8534
- orderedSpeakers.push(speaker);
8535
- }
8536
- }
8537
- }
8538
8649
  const usedVoIds = /* @__PURE__ */ new Set();
8539
- orderedSpeakers.forEach((speaker, idx) => {
8540
- const script = scriptBySpeaker.get(speaker);
8541
- if (!script) return;
8542
- const voiceNode = ensureVoiceNode(speaker);
8543
- let id = sanitizeId2(`vo_${speaker}`, `vo_${idx}`);
8544
- while (usedVoIds.has(id)) id = `${id}_${idx}`;
8545
- usedVoIds.add(id);
8546
- nodes.push({
8547
- id,
8548
- type: "tts",
8549
- inputs: { voice_ref: `$ref:${voiceNode}.voice_id` },
8550
- // Join lines with a space: each line keeps its own terminal punctuation, so
8551
- // sentence boundaries (and the pauses they imply) survive into one read.
8552
- params: { model: FIXED_TTS_MODEL, text: script.lines.join(" "), voice: "{{voice_ref}}" }
8650
+ blueprint.scenes.forEach((scene, sceneIndex) => {
8651
+ const lines = (scene.dialogue ?? []).filter((l) => Boolean(l.line?.trim())).slice().sort((a, b) => (a.start_s ?? 0) - (b.start_s ?? 0));
8652
+ if (lines.length === 0) return;
8653
+ const groups = [];
8654
+ for (const line of lines) {
8655
+ const speaker = line.speaker ?? "voiceover";
8656
+ const last = groups[groups.length - 1];
8657
+ if (last && last.speaker === speaker) last.lines.push(line);
8658
+ else groups.push({ speaker, lines: [line] });
8659
+ }
8660
+ const list = [];
8661
+ groups.forEach((group, gi) => {
8662
+ const first = group.lines[0];
8663
+ const last = group.lines[group.lines.length - 1];
8664
+ if (!first || !last) return;
8665
+ const start = first.start_s ?? scene.start_s ?? 0;
8666
+ const end = last.end_s ?? last.start_s ?? scene.end_s ?? start;
8667
+ const voiceNode = ensureVoiceNode(group.speaker);
8668
+ let id = sanitizeId2(`vo_s${sceneIndex}_${group.speaker}`, `vo_${sceneIndex}_${gi}`);
8669
+ if (usedVoIds.has(id)) {
8670
+ let n = 2;
8671
+ while (usedVoIds.has(`${id}_${n}`)) n++;
8672
+ id = `${id}_${n}`;
8673
+ }
8674
+ usedVoIds.add(id);
8675
+ nodes.push({
8676
+ id,
8677
+ type: "tts",
8678
+ inputs: { voice_ref: `$ref:${voiceNode}.voice_id` },
8679
+ // Lines join with a space; each keeps its terminal punctuation so eleven_v3
8680
+ // reads the sentence boundaries (and their pauses) within the one turn.
8681
+ params: {
8682
+ model: FIXED_TTS_MODEL,
8683
+ text: group.lines.map((l) => l.line.trim()).join(" "),
8684
+ voice: "{{voice_ref}}"
8685
+ }
8686
+ });
8687
+ const turn = {
8688
+ sceneIndex,
8689
+ speaker: group.speaker,
8690
+ onCamera: isOnCameraSpeaker(group.speaker, casts, cameraOn),
8691
+ start_s: start,
8692
+ end_s: end,
8693
+ ttsId: id,
8694
+ audioRef: `$ref:${id}.audio`
8695
+ };
8696
+ list.push(turn);
8697
+ tracks.push({ slot: id, ref: turn.audioRef, start_s: start, end_s: end, kind: "vo" });
8553
8698
  });
8554
- tracks.push({ slot: id, ref: `$ref:${id}.audio`, start_s: script.start });
8699
+ sceneTurns.set(sceneIndex, list);
8555
8700
  });
8701
+ return { tracks, sceneTurns };
8702
+ }
8703
+ function buildSfxMusic(blueprint, nodes) {
8704
+ const tracks = [];
8556
8705
  blueprint.scenes.forEach((scene, i) => {
8557
8706
  (scene.sfx ?? []).forEach((sfx, k) => {
8558
8707
  const text = sfx.sound_effect_prompt ?? sfx.description;
@@ -8561,7 +8710,12 @@ function buildAudio(blueprint, nodes) {
8561
8710
  const params = { model: FIXED_SFX_MODEL, text };
8562
8711
  if (typeof sfx.duration_s === "number") params.duration_seconds = Math.min(Math.max(sfx.duration_s, 0.5), 30);
8563
8712
  nodes.push({ id, type: "sound_effect", params });
8564
- tracks.push({ slot: `sfx_s${i}_${k}`, ref: `$ref:${id}.audio`, start_s: sfx.at_s ?? scene.start_s ?? 0 });
8713
+ tracks.push({
8714
+ slot: `sfx_s${i}_${k}`,
8715
+ ref: `$ref:${id}.audio`,
8716
+ start_s: sfx.at_s ?? scene.start_s ?? 0,
8717
+ kind: "sfx"
8718
+ });
8565
8719
  });
8566
8720
  });
8567
8721
  const musicPrompt = blueprint.global?.music?.music_prompt;
@@ -8573,10 +8727,90 @@ function buildAudio(blueprint, nodes) {
8573
8727
  type: "music",
8574
8728
  params: { model: FIXED_MUSIC_MODEL, prompt: musicBedPrompt(blueprint, musicPrompt), music_length_ms: musicMs }
8575
8729
  });
8576
- tracks.unshift({ slot: "music", ref: "$ref:music_bed.audio", start_s: 0, gain_db: MUSIC_BED_GAIN_DB });
8730
+ tracks.push({ slot: "music", ref: "$ref:music_bed.audio", start_s: 0, gain_db: MUSIC_BED_GAIN_DB, kind: "music" });
8577
8731
  }
8578
8732
  return tracks;
8579
8733
  }
8734
+ var OverlayStyle = z3.object({ color_hex: z3.string().optional(), background: z3.string().optional(), size: z3.string().optional() }).loose();
8735
+ var Overlay = z3.object({
8736
+ text: z3.string().optional(),
8737
+ appears_at_s: z3.number().optional(),
8738
+ duration_s: z3.number().optional(),
8739
+ position: z3.string().optional(),
8740
+ role: z3.string().optional(),
8741
+ animation: z3.string().optional(),
8742
+ animation_detail: z3.string().optional(),
8743
+ style: OverlayStyle.optional()
8744
+ }).loose();
8745
+ var FloatingElement = z3.object({
8746
+ kind: z3.string().optional(),
8747
+ description: z3.string().optional(),
8748
+ brand_name: z3.string().nullish(),
8749
+ what_it_represents: z3.string().optional(),
8750
+ appears_at_s: z3.number().optional(),
8751
+ duration_s: z3.number().optional(),
8752
+ position: z3.string().optional()
8753
+ }).loose();
8754
+ function escapeHtml(s) {
8755
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
8756
+ }
8757
+ function commentSafe(s) {
8758
+ return escapeHtml(s).replace(/-{2,}/g, "\u2013");
8759
+ }
8760
+ var SUPPORTED_ANIMS = /* @__PURE__ */ new Set(["fade", "slide_up", "slide_down", "pop"]);
8761
+ function normalizeAnim(animation) {
8762
+ if (!animation || animation === "none") return void 0;
8763
+ const mapped = animation === "slide" ? "slide_up" : animation;
8764
+ return SUPPORTED_ANIMS.has(mapped) ? mapped : void 0;
8765
+ }
8766
+ function positionClass(position) {
8767
+ const p = (position ?? "bottom_center").toLowerCase().replace(/[^a-z]+/g, "-");
8768
+ return `pos-${p}`;
8769
+ }
8770
+ function overlayElement(ov, sceneStart) {
8771
+ if (!ov.text?.trim()) return "";
8772
+ const at = ov.appears_at_s ?? sceneStart;
8773
+ const dur = ov.duration_s ?? 2.5;
8774
+ const role = ov.role ? ` data-role="${escapeHtml(ov.role)}"` : "";
8775
+ const normAnim = normalizeAnim(ov.animation);
8776
+ const anim = normAnim ? ` data-anim="${normAnim}"` : "";
8777
+ const detail = ov.animation_detail ? ` data-anim-detail="${escapeHtml(ov.animation_detail)}"` : "";
8778
+ return `<div class="ov ${positionClass(ov.position)}" data-start="${at}" data-dur="${dur}"${role}${anim}${detail}>${escapeHtml(ov.text.trim())}</div>`;
8779
+ }
8780
+ function floatingStub(fe, sceneStart) {
8781
+ const at = fe.appears_at_s ?? sceneStart;
8782
+ const dur = fe.duration_s ?? 2.5;
8783
+ const kind = commentSafe(fe.kind ?? "element");
8784
+ const label = commentSafe(fe.brand_name || fe.what_it_represents || fe.description || fe.kind || "element");
8785
+ const slug = (fe.kind ?? "element").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "element";
8786
+ return [
8787
+ `<!-- ${kind}: ${label} @ ${at}s for ${dur}s (${positionClass(fe.position)}). Drop an image in this dir and uncomment:`,
8788
+ `<img class="ov ${positionClass(fe.position)}" src="your-${slug}.png" data-start="${at}" data-dur="${dur}" alt="" /> -->`
8789
+ ].join("\n");
8790
+ }
8791
+ function buildOverlayHtml(input) {
8792
+ const blueprint = VideoBlueprint.parse(input);
8793
+ const blocks = [
8794
+ [
8795
+ "<!-- \u2B07 OVERLAY LAYER \u2014 this is YOUR HTML to paint. The reference's overlays are",
8796
+ " seeded below as plain elements (text + position class + data-start/data-dur).",
8797
+ " Restyle freely in <style>, regroup, animate, swap a logo placeholder for a",
8798
+ " real <img> you drop in this dir. The runtime only shows/hides by timestamp;",
8799
+ " it makes NO styling decisions. Positions: edit the .pos-* classes or add your own. -->"
8800
+ ].join("\n")
8801
+ ];
8802
+ for (const scene of blueprint.scenes) {
8803
+ const sceneStart = scene.start_s ?? 0;
8804
+ const overlays = z3.array(Overlay).safeParse(scene.overlays ?? []);
8805
+ const floats = z3.array(FloatingElement).safeParse(scene.floating_elements ?? []);
8806
+ const parts = [
8807
+ ...overlays.success ? overlays.data.map((ov) => overlayElement(ov, sceneStart)) : [],
8808
+ ...floats.success ? floats.data.map((fe) => floatingStub(fe, sceneStart)) : []
8809
+ ].filter(Boolean);
8810
+ if (parts.length > 0) blocks.push(parts.join("\n"));
8811
+ }
8812
+ return blocks.join("\n\n");
8813
+ }
8580
8814
  function lastSceneEnd(blueprint) {
8581
8815
  let end = 0;
8582
8816
  for (const s of blueprint.scenes) end = Math.max(end, s.end_s ?? 0);
@@ -8608,7 +8842,8 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
8608
8842
  params: { source: "path", path: todoPath2(elements[i], slot.label), expect: "image" }
8609
8843
  });
8610
8844
  });
8611
- const clipRefs = buildSceneVisuals(blueprint, slots, opts, nodes);
8845
+ const { tracks: voTracks, sceneTurns } = buildDialogue(blueprint, nodes);
8846
+ const clipRefs = buildSceneVisuals(blueprint, slots, opts, nodes, sceneTurns);
8612
8847
  const concatInputs = {};
8613
8848
  clipRefs.forEach((ref, i) => {
8614
8849
  concatInputs[`c${i}`] = ref;
@@ -8628,12 +8863,12 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
8628
8863
  id: "overlaid",
8629
8864
  type: "hyperframe_render",
8630
8865
  inputs: { background: videoRef },
8631
- params: { composition: opts.overlayCompositionPath, overlays, floating_elements: floating }
8866
+ params: { composition: opts.overlayCompositionPath }
8632
8867
  });
8633
8868
  videoRef = "$ref:overlaid.video";
8634
8869
  videoNode = "overlaid";
8635
8870
  }
8636
- const tracks = buildAudio(blueprint, nodes);
8871
+ const tracks = [...voTracks, ...buildSfxMusic(blueprint, nodes)];
8637
8872
  if (tracks.length > 0) {
8638
8873
  const mixInputs = {};
8639
8874
  for (const t of tracks) mixInputs[t.slot] = t.ref;
@@ -8666,8 +8901,18 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
8666
8901
  "1:a:0",
8667
8902
  "-c:v",
8668
8903
  "copy",
8904
+ // The raw mix is a quiet mono track (tts + ducked bed), which reads as
8905
+ // "no sound" in casual players. Normalize integrated loudness to the
8906
+ // social/broadcast target (-14 LUFS, -1.5 dBTP) and upmix to stereo so
8907
+ // every rendered ad is loud and plays everywhere.
8908
+ "-af",
8909
+ "loudnorm=I=-14:TP=-1.5:LRA=11,aformat=channel_layouts=stereo",
8669
8910
  "-c:a",
8670
8911
  "aac",
8912
+ "-b:a",
8913
+ "192k",
8914
+ "-ar",
8915
+ "48000",
8671
8916
  "-shortest",
8672
8917
  "{{out.video}}"
8673
8918
  ],
@@ -8681,43 +8926,69 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
8681
8926
  metadata: {
8682
8927
  name: "video reproduction",
8683
8928
  description: VIDEO_GUIDE,
8684
- todo: buildVideoTodo(videoReport(input, elementsInput), overlays.length, floating.length, opts)
8929
+ todo: buildVideoTodo(videoReport(input, elementsInput), overlays.length, floating.length, opts),
8930
+ // The timing plan `baker canvas validate` checks before any billed render:
8931
+ // sequenced voiceover turns (no overlap), audio ≈ video length, and which
8932
+ // scenes must be lip-synced.
8933
+ video: buildVideoMeta(blueprint, sceneTurns)
8685
8934
  },
8686
8935
  nodes,
8687
8936
  output: { node: videoNode, output: "video" }
8688
8937
  };
8689
8938
  }
8939
+ function buildVideoMeta(blueprint, sceneTurns) {
8940
+ const vo_segments = [];
8941
+ const talking_scenes = [];
8942
+ for (const [scene, turns] of [...sceneTurns.entries()].sort((a, b) => a[0] - b[0])) {
8943
+ for (const t of turns) {
8944
+ vo_segments.push({ slot: t.ttsId, start_s: t.start_s, end_s: t.end_s, scene, speaker: t.speaker });
8945
+ }
8946
+ if (turns.filter((t) => t.onCamera).length === 1) {
8947
+ talking_scenes.push({ scene, lipsync_node: `s${scene}_lipsync` });
8948
+ }
8949
+ }
8950
+ return {
8951
+ duration_s: blueprint.source?.duration_s ?? lastSceneEnd(blueprint),
8952
+ vo_segments,
8953
+ talking_scenes
8954
+ };
8955
+ }
8690
8956
  var VIDEO_GUIDE = [
8691
- "Scaffolded by `baker canvas scaffold-video` \u2014 a runnable reproduction of your reference video. Each scene is two AI-generated boundary frames (start/end) animated into a clip, concatenated, overlaid with timed text, and mixed with voiceover + SFX + music. Edit it, supply the real assets, then run.",
8957
+ "Scaffolded by `baker canvas scaffold-video` \u2014 a runnable reproduction of your reference video. Per scene: two AI-generated CLEAN-PLATE frames (no baked text) \u2192 a clip \u2192 trimmed to the real scene length so the picture stays on the audio timeline \u2192 optional lip-sync \u2192 concatenated. On-screen text is a separate HTML layer you paint; audio is sequenced voiceover + SFX + a ducked music bed, normalized stereo. It is a DRAFT: edit it, supply the real assets, then validate and run.",
8692
8958
  "",
8693
8959
  "WHAT TO DO NEXT:",
8694
8960
  "1. Edit each frame's prompt IN PLACE. Every `s<i>_start` / `s<i>_end` node has its OWN self-contained `params.prompt` (the FRAME DESCRIPTION) \u2014 editing one changes only that frame. Rewrite the cast, product, claims, palette into the ad you want.",
8695
- "2. Drop ONE real source image at each `el_*` ingest `[TODO]` path. Each recurring element (person/product/logo) is reused across every frame it appears in, so the same identity stays consistent.",
8696
- "3. Confirm the `voice_select` casting (one per speaker). The voiceover is ONE continuous `tts` per speaker \u2014 punctuation, ALL-CAPS, and spacing are read verbatim by eleven_v3 for emphasis/pauses, so edit `params.text` to shape delivery.",
8697
- "4. Text overlays are composited on top (not baked into frames) by the `overlaid` node \u2014 edit the `overlays` array there. For on-brand type, drop `brand-bold.otf` / `brand-regular.otf` into the `video-overlay-composition/` dir (referenced via @font-face); otherwise a system font is used. You don't always need text \u2014 it's often cleaner to overlay it than bake it in.",
8698
- "5. `baker canvas validate` then `baker canvas run`. Running generates many billed image/video/audio assets \u2014 it is not free.",
8961
+ "2. Drop ONE real source image at each `el_*` ingest `[TODO]` path. Each recurring element (person/product/logo) is reused across every frame it appears in, so the same identity stays consistent. `baker canvas run` REFUSES to start until every `[TODO]` slot holds a real source \u2014 so this is mandatory, not optional.",
8962
+ "3. Confirm the `voice_select` casting (one per speaker). Voiceover is SEQUENCED: each contiguous same-speaker turn is its own `tts` placed at its real start, so dialogue alternates instead of stacking. Edit a turn's `params.text` (punctuation / ALL-CAPS / line breaks are read verbatim by eleven_v3 for emphasis and pauses) to shape delivery; re-author the words to be TRUE for your brand.",
8963
+ "4. Lip-sync: scenes with a single on-camera speaker route their clip through `video_lipsync` (~20 cr each) so the mouth matches the line. Two-speaker scenes are left un-synced \u2014 split them or pick a primary speaker if you want sync. Drop the node to skip.",
8964
+ "5. Overlays are REAL HTML you paint. Open `video-overlay-composition/index.html`: the reference's overlays are seeded inside `#overlay-root` as plain elements (text + a `.pos-*` class + `data-start`/`data-dur`). Restyle the CSS freely \u2014 build lower-thirds, a ticker, whatever the look needs \u2014 and replace a logo placeholder with a real `<img>` you drop in that dir. The runtime only shows/hides by timestamp; it makes no styling decisions. Drop `brand-bold.otf` / `brand-regular.otf` there for on-brand type.",
8965
+ "6. `baker canvas validate` (proves audio/lip-sync timing for free) then `baker canvas run` (generates many billed image/video/audio assets \u2014 not free).",
8699
8966
  "",
8700
8967
  "Tip: `prompt.json` is the deconstruction provenance + the demoted GLOBAL STYLE REFERENCE each frame reads for shared palette/cast cohesion. It is NOT the per-frame editing surface \u2014 the frame nodes are."
8701
8968
  ].join("\n");
8702
8969
  function buildVideoTodo(report, overlayCount, floatingCount, opts) {
8703
8970
  return {
8704
- edit_frames_in_place: "Each s<i>_start / s<i>_end node has its own editable params.prompt (FRAME DESCRIPTION). Edit per frame; the blueprint is only a shared style reference.",
8971
+ edit_frames_in_place: "Each s<i>_start / s<i>_end node has its own editable params.prompt (FRAME DESCRIPTION). Edit per frame; the blueprint is only a shared style reference. Frames are CLEAN PLATES \u2014 they render no on-screen text; all text is the overlay HTML layer.",
8705
8972
  frames_mode: opts.frames ?? "generate",
8973
+ assets_required: "MANDATORY: drop a real image at every el_* [TODO] ingest before running \u2014 `baker canvas run` refuses to start (and bills nothing) until each placeholder holds a real source.",
8706
8974
  recurring_elements_to_supply: report.elements,
8975
+ text_strategy: "Decide per ad: text is either baked by the generated creative OR painted via the overlay HTML \u2014 not both. Default here is clean text-free frames + the HTML overlay layer (video-overlay-composition/index.html) as the single text source, which you fully control.",
8976
+ timeline: "Automatic: each clip is generated at >= its scene length then trimmed back to the real scene duration, so the concatenated picture stays on the same timeline as the absolute-timed audio (this is what makes the lips line up). You don't manage it.",
8707
8977
  voices_to_confirm: report.dialogue.map((d) => ({
8708
8978
  scene: d.scene,
8709
8979
  speaker: d.speaker,
8710
8980
  voice_description: d.voice_description,
8711
8981
  line: d.line
8712
8982
  })),
8713
- voiceover_note: "One continuous tts per speaker; same voice locked via voice_select.voice_id. Use punctuation / ALL CAPS / line breaks in params.text for emphasis and pacing (read verbatim).",
8983
+ voiceover_note: "Sequenced: one tts per contiguous same-speaker TURN, placed at its real start_s so turns alternate (no parallel monologues); same voice locked via voice_select.voice_id. Edit a turn's params.text (punctuation / ALL CAPS / line breaks read verbatim) to shape delivery.",
8984
+ lip_sync_note: "Scenes with a single on-camera speaker route their clip through video_lipsync (~20 cr each) so the mouth matches the line. Two-speaker scenes are left un-synced (one track can't drive two faces) \u2014 split or pick a primary. `baker canvas validate` checks every talking scene is synced.",
8714
8985
  text_overlays: {
8715
8986
  count: overlayCount,
8716
- note: "Composited by the `overlaid` node, animated per the blueprint (fade/pop/slide/typewriter/karaoke). Edit the `overlays` array. Drop brand-*.otf into video-overlay-composition/ for on-brand type."
8987
+ note: "Seeded as editable HTML inside `#overlay-root` in video-overlay-composition/index.html (text + a .pos-* class + data-start/data-dur). PAINT it: restyle the CSS, build lower-thirds/tickers, drop brand-*.otf for on-brand type. The runtime only shows/hides by timestamp."
8717
8988
  },
8718
8989
  floating_elements: {
8719
8990
  count: floatingCount,
8720
- note: floatingCount > 0 ? "Rendered as labeled placeholders. Replace with the real logo/sticker/cutout art (recurring logos are better handled as an el_* element baked into frames)." : "none detected"
8991
+ note: floatingCount > 0 ? "Seeded as labeled placeholders in index.html \u2014 replace each with a real <img> you drop into video-overlay-composition/. Recurring logos are also handled well as an el_* element baked into frames." : "none detected"
8721
8992
  },
8722
8993
  sound_effects: { count: report.sfx_count },
8723
8994
  music: {
@@ -8769,8 +9040,24 @@ function videoReport(input, elementsInput) {
8769
9040
  };
8770
9041
  }
8771
9042
 
9043
+ // src/commands/canvas/composition-path.ts
9044
+ import { existsSync as existsSync3 } from "fs";
9045
+ import path4 from "path";
9046
+ function resolveShippedCanvasDir(name, startDir, exists = existsSync3, maxDepth = 8) {
9047
+ const rel = path4.join("canvas", name);
9048
+ let dir = startDir;
9049
+ for (let i = 0; i < maxDepth; i++) {
9050
+ const candidate = path4.join(dir, rel);
9051
+ if (exists(path4.join(candidate, "meta.json"))) return candidate;
9052
+ const parent = path4.dirname(dir);
9053
+ if (parent === dir) break;
9054
+ dir = parent;
9055
+ }
9056
+ return path4.resolve(startDir, "../../../", rel);
9057
+ }
9058
+
8772
9059
  // src/commands/canvas/scaffold-video.ts
8773
- var SHIPPED_COMPOSITION_DIR = path4.resolve(import.meta.dirname, "../../../canvas/video-overlay-composition");
9060
+ var SHIPPED_COMPOSITION_DIR = resolveShippedCanvasDir("video-overlay-composition", import.meta.dirname);
8774
9061
  function resolveModel2(kind, preferred) {
8775
9062
  const ids = Object.keys(MODEL_REGISTRY[kind]);
8776
9063
  return ids.includes(preferred) ? preferred : ids[0] ?? preferred;
@@ -8891,13 +9178,19 @@ var scaffoldVideoCommand = defineCommand76({
8891
9178
  "video-model": { type: "string", description: "Override the video_generate model id for clips" }
8892
9179
  },
8893
9180
  async run({ args }) {
8894
- const videoPath = path4.resolve(String(args.file));
8895
- const base = path4.basename(videoPath, path4.extname(videoPath));
8896
- const outPath = args.out ? path4.resolve(String(args.out)) : path4.join(path4.dirname(videoPath), `${base}.video.canvas.json`);
8897
- const outDir = path4.dirname(outPath);
8898
- const blueprintPath = path4.join(outDir, "prompt.json");
9181
+ const videoPath = path5.resolve(String(args.file));
9182
+ const base = path5.basename(videoPath, path5.extname(videoPath));
9183
+ const outPath = args.out ? path5.resolve(String(args.out)) : path5.join(path5.dirname(videoPath), `${base}.video.canvas.json`);
9184
+ const outDir = path5.dirname(outPath);
9185
+ const blueprintPath = path5.join(outDir, "prompt.json");
8899
9186
  const frames = args.frames === "reuse" ? "reuse" : "generate";
8900
9187
  const maxScenes = args["max-scenes"] ? Number(args["max-scenes"]) : void 0;
9188
+ if (Number.isFinite(maxScenes)) {
9189
+ process.stderr.write(
9190
+ `\u26A0\uFE0F --max-scenes ${maxScenes} caps the deconstruct: any scenes beyond ${maxScenes} are MERGED away, reducing fidelity (fewer cuts, lost beats). Omit it to reproduce every scene.
9191
+ `
9192
+ );
9193
+ }
8901
9194
  const { deconstructModel, selectModel, imageModel, videoModel } = resolveModels2(args);
8902
9195
  const analysisCanvas = buildAnalysisCanvas(videoPath, deconstructModel, selectModel, {
8903
9196
  maxScenes: Number.isFinite(maxScenes) ? maxScenes : void 0,
@@ -8909,8 +9202,19 @@ var scaffoldVideoCommand = defineCommand76({
8909
9202
  const annotated = annotateBlueprintWithElements(blueprint, elements);
8910
9203
  await writeFile2(blueprintPath, `${JSON.stringify(annotated, null, 2)}
8911
9204
  `, "utf8");
8912
- const compositionDest = path4.join(outDir, "video-overlay-composition");
9205
+ const compositionDest = path5.join(outDir, "video-overlay-composition");
8913
9206
  await cp(SHIPPED_COMPOSITION_DIR, compositionDest, { recursive: true });
9207
+ const indexPath = path5.join(compositionDest, "index.html");
9208
+ const overlayHtml = buildOverlayHtml(blueprint);
9209
+ const indexHtml = await readFile4(indexPath, "utf8");
9210
+ const injected = indexHtml.replace("<!--OVERLAYS-->", () => overlayHtml);
9211
+ if (injected === indexHtml && overlayHtml.trim()) {
9212
+ fail2(
9213
+ "composition_marker_missing",
9214
+ `video-overlay-composition/index.html is missing the <!--OVERLAYS--> marker \u2014 cannot inject the overlay layer`
9215
+ );
9216
+ }
9217
+ await writeFile2(indexPath, injected, "utf8");
8914
9218
  const opts = {
8915
9219
  imageModel,
8916
9220
  videoModel,
@@ -8953,7 +9257,7 @@ var scaffoldVideoCommand = defineCommand76({
8953
9257
  run_estimated_credits: validation.estimatedCredits
8954
9258
  },
8955
9259
  checklist: {
8956
- edit_prompt: `Edit ${path4.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.`,
9260
+ edit_prompt: `Edit ${path5.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.`,
8957
9261
  recurring_elements_to_supply: report.elements,
8958
9262
  voices_to_confirm: report.dialogue.map((d) => ({
8959
9263
  scene: d.scene,
@@ -8978,7 +9282,7 @@ var scaffoldVideoCommand = defineCommand76({
8978
9282
 
8979
9283
  // src/commands/canvas/validate.ts
8980
9284
  import { readFile as readFile5 } from "fs/promises";
8981
- import path5 from "path";
9285
+ import path6 from "path";
8982
9286
  import { defineCommand as defineCommand77 } from "citty";
8983
9287
  var validateCommand = defineCommand77({
8984
9288
  meta: {
@@ -8987,7 +9291,7 @@ var validateCommand = defineCommand77({
8987
9291
  },
8988
9292
  args: { file: { type: "positional", required: true, description: "Path to canvas JSON" } },
8989
9293
  async run({ args }) {
8990
- const filePath = path5.resolve(String(args.file));
9294
+ const filePath = path6.resolve(String(args.file));
8991
9295
  const raw = await readFile5(filePath, "utf8");
8992
9296
  let parsed;
8993
9297
  try {
@@ -9222,7 +9526,7 @@ Examples:
9222
9526
  });
9223
9527
 
9224
9528
  // src/commands/ga4/query.ts
9225
- import { appendFileSync as appendFileSync2, existsSync as existsSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
9529
+ import { appendFileSync as appendFileSync2, existsSync as existsSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
9226
9530
  import { resolve as resolve2 } from "path";
9227
9531
  import { defineCommand as defineCommand81 } from "citty";
9228
9532
 
@@ -9296,7 +9600,7 @@ function writeRowsToFile2(filePath, rows, append) {
9296
9600
  const fields = extractFields2(rows);
9297
9601
  const ext = filePath.split(".").pop()?.toLowerCase();
9298
9602
  if (ext === "csv") {
9299
- if (!append || !existsSync3(filePath)) {
9603
+ if (!append || !existsSync4(filePath)) {
9300
9604
  writeFileSync4(filePath, `${toCsvRow(fields)}
9301
9605
  `, "utf-8");
9302
9606
  }
@@ -9307,12 +9611,12 @@ function writeRowsToFile2(filePath, rows, append) {
9307
9611
  const lines = rows.map((row) => JSON.stringify(row));
9308
9612
  const content = `${lines.join("\n")}
9309
9613
  `;
9310
- if (append && existsSync3(filePath)) {
9614
+ if (append && existsSync4(filePath)) {
9311
9615
  appendFileSync2(filePath, content, "utf-8");
9312
9616
  } else {
9313
9617
  writeFileSync4(filePath, content, "utf-8");
9314
9618
  }
9315
- } else if (append && existsSync3(filePath)) {
9619
+ } else if (append && existsSync4(filePath)) {
9316
9620
  const existing = JSON.parse(readFileSync6(filePath, "utf-8"));
9317
9621
  writeFileSync4(filePath, JSON.stringify([...existing, ...rows], null, 2), "utf-8");
9318
9622
  } else {
@@ -9453,7 +9757,7 @@ Examples:
9453
9757
  import { defineCommand as defineCommand86 } from "citty";
9454
9758
 
9455
9759
  // src/commands/gsc/query.ts
9456
- import { appendFileSync as appendFileSync3, existsSync as existsSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
9760
+ import { appendFileSync as appendFileSync3, existsSync as existsSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
9457
9761
  import { resolve as resolve3 } from "path";
9458
9762
  import { defineCommand as defineCommand83 } from "citty";
9459
9763
 
@@ -9582,7 +9886,7 @@ function writeRowsToFile3(filePath, rows, append) {
9582
9886
  const fields = extractFields3(rows);
9583
9887
  const ext = filePath.split(".").pop()?.toLowerCase();
9584
9888
  if (ext === "csv") {
9585
- if (!append || !existsSync4(filePath)) {
9889
+ if (!append || !existsSync5(filePath)) {
9586
9890
  writeFileSync5(filePath, `${toCsvRow(fields)}
9587
9891
  `, "utf-8");
9588
9892
  }
@@ -9592,12 +9896,12 @@ function writeRowsToFile3(filePath, rows, append) {
9592
9896
  } else if (ext === "jsonl") {
9593
9897
  const content = `${rows.map((row) => JSON.stringify(row)).join("\n")}
9594
9898
  `;
9595
- if (append && existsSync4(filePath)) {
9899
+ if (append && existsSync5(filePath)) {
9596
9900
  appendFileSync3(filePath, content, "utf-8");
9597
9901
  } else {
9598
9902
  writeFileSync5(filePath, content, "utf-8");
9599
9903
  }
9600
- } else if (append && existsSync4(filePath)) {
9904
+ } else if (append && existsSync5(filePath)) {
9601
9905
  const existing = JSON.parse(readFileSync7(filePath, "utf-8"));
9602
9906
  writeFileSync5(filePath, JSON.stringify([...existing, ...rows], null, 2), "utf-8");
9603
9907
  } else {
@@ -9915,9 +10219,9 @@ async function readImageBuffer(pathOrUrl) {
9915
10219
  }
9916
10220
  return readFile6(pathOrUrl);
9917
10221
  }
9918
- async function isDirectory(path6) {
10222
+ async function isDirectory(path7) {
9919
10223
  try {
9920
- const s = await stat2(path6);
10224
+ const s = await stat2(path7);
9921
10225
  return s.isDirectory();
9922
10226
  } catch {
9923
10227
  return false;