@koda-sl/baker-cli 0.98.0-dev.62be5b016 → 0.98.1

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,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- AssetRef,
4
3
  ELEVENLABS_MAX_MUSIC_LENGTH_MS,
5
4
  IMAGE_GENERATE_MODELS,
6
5
  MODEL_REGISTRY,
@@ -8,10 +7,9 @@ import {
8
7
  ValidationError,
9
8
  createEngineFromEnv,
10
9
  defaultRegistry,
11
- extForMime,
12
10
  generateCatalog,
13
11
  validateCanvasDeep
14
- } from "./chunk-IKMDQQ4M.js";
12
+ } from "./chunk-3JVYU72O.js";
15
13
 
16
14
  // src/cli.ts
17
15
  import { defineCommand as defineCommand149, runMain } from "citty";
@@ -149,9 +147,9 @@ async function handleResponse(response) {
149
147
  throw new ApiError("INTERNAL_ERROR", "Failed to parse API response as JSON");
150
148
  }
151
149
  }
152
- async function apiGet(path8, params) {
150
+ async function apiGet(path11, params) {
153
151
  const env = getEnv();
154
- const url = new URL(path8, env.BAKER_API_URL);
152
+ const url = new URL(path11, env.BAKER_API_URL);
155
153
  if (params) {
156
154
  const clean = sanitizeParams(params);
157
155
  for (const [key, value] of Object.entries(clean)) {
@@ -176,12 +174,12 @@ async function apiGet(path8, params) {
176
174
  }
177
175
  return handleResponse(response);
178
176
  }
179
- async function apiPost(path8, body, opts) {
177
+ async function apiPost(path11, body, opts) {
180
178
  const env = getEnv();
181
179
  const timeoutMs = opts?.timeoutMs ?? 6e4;
182
180
  let response;
183
181
  try {
184
- response = await fetchWithRateLimitRetry(new URL(path8, env.BAKER_API_URL).toString(), {
182
+ response = await fetchWithRateLimitRetry(new URL(path11, env.BAKER_API_URL).toString(), {
185
183
  method: "POST",
186
184
  headers: {
187
185
  Authorization: `Bearer ${env.BAKER_API_KEY}`,
@@ -1329,31 +1327,31 @@ function cachePath(category, key) {
1329
1327
  return join(dir, `${hashKey(key)}.json`);
1330
1328
  }
1331
1329
  function cacheGet(category, key) {
1332
- const path8 = cachePath(category, key);
1333
- if (!existsSync(path8)) {
1330
+ const path11 = cachePath(category, key);
1331
+ if (!existsSync(path11)) {
1334
1332
  return null;
1335
1333
  }
1336
1334
  try {
1337
- const raw = readFileSync(path8, "utf-8");
1335
+ const raw = readFileSync(path11, "utf-8");
1338
1336
  const entry = JSON.parse(raw);
1339
1337
  if (entry.expiresAt < Date.now()) {
1340
- rmSync(path8, { force: true });
1338
+ rmSync(path11, { force: true });
1341
1339
  return null;
1342
1340
  }
1343
1341
  return entry;
1344
1342
  } catch {
1345
- rmSync(path8, { force: true });
1343
+ rmSync(path11, { force: true });
1346
1344
  return null;
1347
1345
  }
1348
1346
  }
1349
1347
  function cacheSet(category, key, data, ttlMs, fields) {
1350
- const path8 = cachePath(category, key);
1348
+ const path11 = cachePath(category, key);
1351
1349
  const entry = {
1352
1350
  expiresAt: Date.now() + ttlMs,
1353
1351
  data,
1354
1352
  fields
1355
1353
  };
1356
- writeFileSync(path8, JSON.stringify(entry), "utf-8");
1354
+ writeFileSync(path11, JSON.stringify(entry), "utf-8");
1357
1355
  }
1358
1356
  var HOUR = 60 * 60 * 1e3;
1359
1357
  var MINUTE = 60 * 1e3;
@@ -8063,232 +8061,14 @@ var catalogCommand = defineCommand78({
8063
8061
  }
8064
8062
  });
8065
8063
 
8066
- // src/commands/canvas/gallery.ts
8067
- import { readdir, readFile } from "fs/promises";
8068
- import path from "path";
8069
- import { defineCommand as defineCommand79 } from "citty";
8070
-
8071
- // src/engine/gallery/descriptor.ts
8072
- var KNOWN_RATIOS = [
8073
- ["9:16", 9 / 16],
8074
- ["4:5", 4 / 5],
8075
- ["1:1", 1],
8076
- ["1.91:1", 1.91],
8077
- ["16:9", 16 / 9],
8078
- ["4:1", 4]
8079
- ];
8080
- var RATIO_TOLERANCE = 0.06;
8081
- function aspectLabel(width, height) {
8082
- if (!width || !height) {
8083
- return "other";
8084
- }
8085
- const ratio = width / height;
8086
- let best = "other";
8087
- let bestErr = Number.POSITIVE_INFINITY;
8088
- for (const [label, value] of KNOWN_RATIOS) {
8089
- const err = Math.abs(ratio - value) / value;
8090
- if (err < bestErr) {
8091
- bestErr = err;
8092
- best = label;
8093
- }
8094
- }
8095
- return bestErr <= RATIO_TOLERANCE ? best : `${width}x${height}`;
8096
- }
8097
- function visualRef(value) {
8098
- const parsed = AssetRef.safeParse(value);
8099
- if (!parsed.success) {
8100
- return null;
8101
- }
8102
- if (parsed.data.kind !== "image" && parsed.data.kind !== "video") {
8103
- return null;
8104
- }
8105
- return parsed.data;
8106
- }
8107
- function deliverableFor(ref, stem, resolveLocal) {
8108
- const width = "width" in ref ? ref.width : void 0;
8109
- const height = "height" in ref ? ref.height : void 0;
8110
- return {
8111
- kind: ref.kind,
8112
- format: aspectLabel(width, height),
8113
- // Remote-node outputs already carry a public R2 url; local composites are
8114
- // resolved against the mounted run dir's public base.
8115
- url: ref.url ?? resolveLocal(`${stem}.${extForMime(ref.mime)}`),
8116
- width,
8117
- height,
8118
- label: stem
8119
- };
8120
- }
8121
- function deliverablesFromOutput(output, resolveLocal) {
8122
- if (Array.isArray(output)) {
8123
- const out = [];
8124
- output.forEach((entry, i) => {
8125
- const ref2 = visualRef(entry);
8126
- if (ref2) {
8127
- out.push(deliverableFor(ref2, `_final__${i}`, resolveLocal));
8128
- }
8129
- });
8130
- return out;
8131
- }
8132
- const ref = visualRef(output);
8133
- return ref ? [deliverableFor(ref, "_final", resolveLocal)] : [];
8134
- }
8135
- function buildGeneration(runId, manifest, resolveLocal) {
8136
- const m = manifest ?? {};
8137
- const credits = typeof m.stats?.total_credits === "number" ? m.stats.total_credits : 0;
8138
- return {
8139
- runId,
8140
- createdAt: typeof m.completed_at === "number" ? m.completed_at : 0,
8141
- credits,
8142
- deliverables: deliverablesFromOutput(m.output, resolveLocal)
8143
- };
8144
- }
8145
- function buildGalleryDescriptor(input) {
8146
- const generations = [...input.generations].sort((a, b) => b.createdAt - a.createdAt);
8147
- return {
8148
- slug: input.slug,
8149
- title: input.definition.title,
8150
- platform: input.definition.platform,
8151
- status: input.definition.status,
8152
- reference: input.definition.reference,
8153
- selectedRun: input.definition.selectedRun,
8154
- generations
8155
- };
8156
- }
8157
- function titleFromSlug(slug) {
8158
- return slug.split(/[-_/]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
8159
- }
8160
- function stripQuotes(raw) {
8161
- const trimmed = raw.trim();
8162
- if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
8163
- return trimmed.slice(1, -1);
8164
- }
8165
- return trimmed;
8166
- }
8167
- function parseInlineList(raw) {
8168
- return raw.slice(1, -1).split(",").map((item) => stripQuotes(item)).filter(Boolean);
8169
- }
8170
- function parseFrontmatter(markdown) {
8171
- const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
8172
- const block = match?.[1];
8173
- if (!block) {
8174
- return {};
8175
- }
8176
- const out = {};
8177
- let listKey = null;
8178
- for (const line of block.split(/\r?\n/)) {
8179
- const item = line.match(/^\s+-\s+(.*)$/)?.[1];
8180
- if (listKey && item !== void 0) {
8181
- out[listKey].push(stripQuotes(item));
8182
- continue;
8183
- }
8184
- const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
8185
- if (!kv?.[1]) {
8186
- continue;
8187
- }
8188
- listKey = null;
8189
- const key = kv[1];
8190
- const value = (kv[2] ?? "").trim();
8191
- if (value === "") {
8192
- out[key] = [];
8193
- listKey = key;
8194
- } else if (value.startsWith("[") && value.endsWith("]")) {
8195
- out[key] = parseInlineList(value);
8196
- } else {
8197
- out[key] = stripQuotes(value);
8198
- }
8199
- }
8200
- return out;
8201
- }
8202
- function asString(value) {
8203
- if (typeof value === "string" && value.length > 0) {
8204
- return value;
8205
- }
8206
- return void 0;
8207
- }
8208
- function asList(value) {
8209
- if (Array.isArray(value)) {
8210
- return value;
8211
- }
8212
- return typeof value === "string" && value.length > 0 ? [value] : [];
8213
- }
8214
- function parseCreativeDefinition(markdown, slug) {
8215
- const fm = parseFrontmatter(markdown);
8216
- return {
8217
- title: asString(fm.title) ?? titleFromSlug(slug),
8218
- platform: asList(fm.platform),
8219
- formats: asList(fm.formats),
8220
- status: asString(fm.status) ?? "draft",
8221
- reference: asString(fm.reference),
8222
- selectedRun: asString(fm.selected_run)
8223
- };
8224
- }
8225
-
8226
- // src/commands/canvas/gallery.ts
8227
- async function readJson(file) {
8228
- try {
8229
- return JSON.parse(await readFile(file, "utf8"));
8230
- } catch {
8231
- return null;
8232
- }
8233
- }
8234
- async function listRunDirs(runsDir) {
8235
- try {
8236
- const entries = await readdir(runsDir, { withFileTypes: true });
8237
- return entries.filter((e) => e.isDirectory()).map((e) => e.name);
8238
- } catch {
8239
- return [];
8240
- }
8241
- }
8242
- var galleryCommand = defineCommand79({
8243
- meta: {
8244
- name: "gallery",
8245
- description: "Read a creative's _definition.md + every persisted run manifest and emit the gallery descriptor (JSON) the dashboard renders."
8246
- },
8247
- args: {
8248
- dir: { type: "positional", required: true, description: "Creative folder, e.g. src/creatives/<slug>" },
8249
- "workspace-dir": { type: "string", description: "R2-mounted workspace root (default ./.creatives-workspace)" },
8250
- "public-url": { type: "string", description: "R2 public base (default $R2_PUBLIC_URL)" },
8251
- "company-id": { type: "string", description: "Company id for the R2 prefix (default $BAKER_COMPANY_ID)" }
8252
- },
8253
- async run({ args }) {
8254
- const creativeDir = path.resolve(String(args.dir));
8255
- const slug = path.basename(creativeDir);
8256
- const workspaceDir = path.resolve(String(args["workspace-dir"] ?? ".creatives-workspace"));
8257
- const runsDir = path.join(workspaceDir, slug, "runs");
8258
- const runtimeEnv = process.env;
8259
- const publicUrl = (args["public-url"] ?? runtimeEnv.R2_PUBLIC_URL ?? "").replace(/\/+$/, "");
8260
- const companyId = String(args["company-id"] ?? runtimeEnv.BAKER_COMPANY_ID ?? "");
8261
- const definitionPath = path.join(creativeDir, "_definition.md");
8262
- let definitionMd = "";
8263
- try {
8264
- definitionMd = await readFile(definitionPath, "utf8");
8265
- } catch {
8266
- }
8267
- const definition = parseCreativeDefinition(definitionMd, slug);
8268
- const generations = [];
8269
- for (const runId of await listRunDirs(runsDir)) {
8270
- const manifest = await readJson(path.join(runsDir, runId, "manifest.json"));
8271
- if (!manifest) {
8272
- continue;
8273
- }
8274
- const runDir = path.join(runsDir, runId);
8275
- const resolveLocal = (filename) => publicUrl && companyId ? `${publicUrl}/creatives/${companyId}/${slug}/runs/${runId}/${filename}` : path.join(runDir, filename);
8276
- generations.push(buildGeneration(runId, manifest, resolveLocal));
8277
- }
8278
- const descriptor = buildGalleryDescriptor({ slug, definition, generations });
8279
- process.stdout.write(`${JSON.stringify({ ok: true, descriptor }, null, 2)}
8280
- `);
8281
- }
8282
- });
8283
-
8284
8064
  // src/commands/canvas/inspect.ts
8285
8065
  import { execFile } from "child_process";
8286
- import { readdir as readdir2, readFile as readFile2, stat } from "fs/promises";
8287
- import path2 from "path";
8066
+ import { readdir, readFile, stat } from "fs/promises";
8067
+ import path from "path";
8288
8068
  import { promisify } from "util";
8289
- import { defineCommand as defineCommand80 } from "citty";
8069
+ import { defineCommand as defineCommand79 } from "citty";
8290
8070
  var execFileAsync = promisify(execFile);
8291
- var inspectCommand = defineCommand80({
8071
+ var inspectCommand = defineCommand79({
8292
8072
  meta: {
8293
8073
  name: "inspect",
8294
8074
  description: "Dump a one-page summary of a canvas run: per-node duration + cache status, list of output files in the run dir, and optionally three thumbnail frames per video output. Pass either a run_id (resolved against --outputs-dir) or an absolute run directory."
@@ -8302,7 +8082,7 @@ var inspectCommand = defineCommand80({
8302
8082
  }
8303
8083
  },
8304
8084
  async run({ args }) {
8305
- const outputsDir = path2.resolve(String(args["outputs-dir"] ?? "canvas"));
8085
+ const outputsDir = path.resolve(String(args["outputs-dir"] ?? "canvas"));
8306
8086
  const runArg = String(args.run);
8307
8087
  const runDir = await resolveRunDir(runArg, outputsDir);
8308
8088
  const manifest = await loadManifest(runDir);
@@ -8314,7 +8094,7 @@ var inspectCommand = defineCommand80({
8314
8094
  }
8315
8095
  const summary = {
8316
8096
  ok: true,
8317
- run_id: manifest.run_id ?? path2.basename(runDir),
8097
+ run_id: manifest.run_id ?? path.basename(runDir),
8318
8098
  run_dir: runDir,
8319
8099
  stats: manifest.stats ?? null,
8320
8100
  output: manifest.output ?? null,
@@ -8327,20 +8107,20 @@ var inspectCommand = defineCommand80({
8327
8107
  }
8328
8108
  });
8329
8109
  async function resolveRunDir(run, outputsDir) {
8330
- if (path2.isAbsolute(run)) {
8110
+ if (path.isAbsolute(run)) {
8331
8111
  const s2 = await stat(run).catch(() => null);
8332
8112
  if (s2?.isDirectory()) return run;
8333
8113
  throw new Error(`inspect: ${run} is not a directory`);
8334
8114
  }
8335
- const candidate = path2.join(outputsDir, run);
8115
+ const candidate = path.join(outputsDir, run);
8336
8116
  const s = await stat(candidate).catch(() => null);
8337
8117
  if (s?.isDirectory()) return candidate;
8338
8118
  throw new Error(`inspect: no run directory at ${candidate}`);
8339
8119
  }
8340
8120
  async function loadManifest(runDir) {
8341
- const manifestPath = path2.join(runDir, "manifest.json");
8121
+ const manifestPath = path.join(runDir, "manifest.json");
8342
8122
  try {
8343
- const raw = await readFile2(manifestPath, "utf-8");
8123
+ const raw = await readFile(manifestPath, "utf-8");
8344
8124
  return JSON.parse(raw);
8345
8125
  } catch {
8346
8126
  return {};
@@ -8348,9 +8128,9 @@ async function loadManifest(runDir) {
8348
8128
  }
8349
8129
  async function listRunFiles(runDir) {
8350
8130
  const out = [];
8351
- const names = await readdir2(runDir);
8131
+ const names = await readdir(runDir);
8352
8132
  for (const name of names) {
8353
- const abs = path2.join(runDir, name);
8133
+ const abs = path.join(runDir, name);
8354
8134
  const s = await stat(abs).catch(() => null);
8355
8135
  if (!s?.isFile()) continue;
8356
8136
  out.push({ name, path: abs, size: s.size });
@@ -8395,9 +8175,9 @@ async function probeDuration(filePath) {
8395
8175
  }
8396
8176
 
8397
8177
  // src/commands/canvas/run.ts
8398
- import { readFile as readFile3 } from "fs/promises";
8399
- import path3 from "path";
8400
- import { defineCommand as defineCommand81 } from "citty";
8178
+ import { readFile as readFile2 } from "fs/promises";
8179
+ import path4 from "path";
8180
+ import { defineCommand as defineCommand80 } from "citty";
8401
8181
 
8402
8182
  // src/commands/canvas/placeholders.ts
8403
8183
  function unsuppliedPlaceholderAssets(canvas) {
@@ -8415,19 +8195,74 @@ function unsuppliedPlaceholderAssets(canvas) {
8415
8195
  return out;
8416
8196
  }
8417
8197
 
8198
+ // src/commands/canvas/resolve-paths.ts
8199
+ import path2 from "path";
8200
+ function resolveRelativeCanvasPaths(canvas, baseDir) {
8201
+ if (!canvas || typeof canvas !== "object") return canvas;
8202
+ const c = canvas;
8203
+ if (!Array.isArray(c.nodes)) return canvas;
8204
+ return { ...canvas, nodes: c.nodes.map((n) => resolveNode(n, baseDir)) };
8205
+ }
8206
+ function resolveNode(node, baseDir) {
8207
+ if (!node || typeof node !== "object") return node;
8208
+ const n = node;
8209
+ const params = n.params;
8210
+ if (!params || typeof params !== "object") return node;
8211
+ if (n.type === "ingest" && params.source === "path" && isResolvableRelative(params.path)) {
8212
+ return { ...node, params: { ...params, path: path2.resolve(baseDir, params.path) } };
8213
+ }
8214
+ if (n.type === "hyperframe_render" && isResolvableRelative(params.composition)) {
8215
+ return { ...node, params: { ...params, composition: path2.resolve(baseDir, params.composition) } };
8216
+ }
8217
+ return node;
8218
+ }
8219
+ function isResolvableRelative(value) {
8220
+ return typeof value === "string" && value.length > 0 && !value.includes("[TODO") && !path2.isAbsolute(value);
8221
+ }
8222
+
8223
+ // src/commands/canvas/run-retention.ts
8224
+ import { rm } from "fs/promises";
8225
+ import path3 from "path";
8226
+ function runDirsToPrune(entries, keep, currentRunId) {
8227
+ const runs = entries.filter((e) => /^r_[0-9A-Za-z]+$/.test(e) && e !== currentRunId).sort();
8228
+ if (keep <= 0) return runs;
8229
+ return runs.slice(0, Math.max(0, runs.length - keep));
8230
+ }
8231
+ async function pruneOldRuns(outputsDir, keep, currentRunId, log) {
8232
+ const { readdir: readdir3 } = await import("fs/promises");
8233
+ let entries;
8234
+ try {
8235
+ entries = await readdir3(outputsDir);
8236
+ } catch {
8237
+ return;
8238
+ }
8239
+ const toPrune = runDirsToPrune(entries, keep, currentRunId);
8240
+ if (toPrune.length === 0) return;
8241
+ for (const dir of toPrune) {
8242
+ await rm(path3.join(outputsDir, dir), { recursive: true, force: true }).catch(
8243
+ (e) => log(`[prune ] could not remove ${dir}: ${e.message}`)
8244
+ );
8245
+ }
8246
+ log(`[prune ] removed ${toPrune.length} old run dir(s), kept the ${keep} newest`);
8247
+ }
8248
+
8418
8249
  // src/commands/canvas/run.ts
8419
- var runCommand = defineCommand81({
8250
+ var runCommand = defineCommand80({
8420
8251
  meta: { name: "run", description: "Validate and execute a canvas JSON file." },
8421
8252
  args: {
8422
8253
  file: { type: "positional", required: true, description: "Path to canvas JSON" },
8423
8254
  "cache-dir": { type: "string", description: "Cache root (default ./canvas/.cache)" },
8424
8255
  "outputs-dir": { type: "string", description: "Per-run outputs root (default ./canvas)" },
8425
8256
  "run-id": { type: "string", description: "Override run id" },
8426
- "cache-policy": { type: "string", description: "read_write | bypass | read_only" }
8257
+ "cache-policy": { type: "string", description: "read_write | bypass | read_only" },
8258
+ "keep-runs": {
8259
+ type: "string",
8260
+ description: "After the run, prune old r_* run dirs, keeping the N newest (off by default)"
8261
+ }
8427
8262
  },
8428
8263
  async run({ args }) {
8429
- const filePath = path3.resolve(String(args.file));
8430
- const raw = await readFile3(filePath, "utf8");
8264
+ const filePath = path4.resolve(String(args.file));
8265
+ const raw = await readFile2(filePath, "utf8");
8431
8266
  let parsed;
8432
8267
  try {
8433
8268
  parsed = JSON.parse(raw);
@@ -8437,6 +8272,7 @@ var runCommand = defineCommand81({
8437
8272
  `);
8438
8273
  process.exit(2);
8439
8274
  }
8275
+ parsed = resolveRelativeCanvasPaths(parsed, path4.dirname(filePath));
8440
8276
  const pending = unsuppliedPlaceholderAssets(parsed);
8441
8277
  if (pending.length > 0) {
8442
8278
  process.stderr.write(
@@ -8468,6 +8304,12 @@ var runCommand = defineCommand81({
8468
8304
  run_id: args["run-id"] ? String(args["run-id"]) : void 0,
8469
8305
  cache_policy: policy
8470
8306
  });
8307
+ const keepRuns = args["keep-runs"] !== void 0 ? Number(args["keep-runs"]) : void 0;
8308
+ if (keepRuns !== void 0 && Number.isFinite(keepRuns)) {
8309
+ const outputsDir = args["outputs-dir"] ? path4.resolve(String(args["outputs-dir"])) : path4.resolve("canvas");
8310
+ await pruneOldRuns(outputsDir, keepRuns, result.run_id, (line) => process.stdout.write(`${line}
8311
+ `));
8312
+ }
8471
8313
  process.stdout.write(
8472
8314
  `${JSON.stringify(
8473
8315
  {
@@ -8499,9 +8341,9 @@ var runCommand = defineCommand81({
8499
8341
  });
8500
8342
 
8501
8343
  // src/commands/canvas/scaffold-static-ad.ts
8502
- import { readFile as readFile4, writeFile } from "fs/promises";
8503
- import path4 from "path";
8504
- import { defineCommand as defineCommand82 } from "citty";
8344
+ import { readFile as readFile3, writeFile } from "fs/promises";
8345
+ import path5 from "path";
8346
+ import { defineCommand as defineCommand81 } from "citty";
8505
8347
 
8506
8348
  // src/engine/scaffold/staticAd.ts
8507
8349
  import { z as z2 } from "zod";
@@ -8714,7 +8556,7 @@ var SELECT_SYSTEM = 'You identify the MAIN, identity-critical visual elements of
8714
8556
  var SELECT_PROMPT = 'AD BLUEPRINT (from image_describe):\n{{blueprint}}\n\nFrom this blueprint, list ONLY the elements that are prominent, important, and identity-bearing \u2014 the ones a reproduction must ground in a real asset:\n- the brand logo/wordmark (from brands_logos with function_in_image = advertiser_brand) -> type "logo"\n- trust/rating/certification/app-store/review badges (brands_logos with function_in_image = trust_badge | review_platform | certification_or_seal | app_store_badge | payment_method) -> type "badge"\n- a showcased/hero product or package (a foreground entry in subjects that the ad is selling) -> type "product"\n- a foreground person whose identity matters (from people) -> type "person"\n- a foreground animal/character with a specific expression (from subjects) -> type "animal"\n\nDROP background extras, decorative props, generic scenery, and anything small or incidental. Keep at most ~6. If there are none, return an empty list.\n\nFor 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), "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.';
8715
8557
  async function loadAssetText(ref, label) {
8716
8558
  const r = ref;
8717
- if (typeof r?.path === "string") return readFile4(r.path, "utf8");
8559
+ if (typeof r?.path === "string") return readFile3(r.path, "utf8");
8718
8560
  if (typeof r?.url === "string") {
8719
8561
  const res = await fetch(r.url);
8720
8562
  if (!res.ok) throw new Error(`failed to fetch ${label} (${res.status})`);
@@ -8820,7 +8662,7 @@ async function runVisionPasses(canvas) {
8820
8662
  return fail("read_outputs", e instanceof Error ? e.message : String(e));
8821
8663
  }
8822
8664
  }
8823
- var scaffoldStaticAdCommand = defineCommand82({
8665
+ var scaffoldStaticAdCommand = defineCommand81({
8824
8666
  meta: {
8825
8667
  name: "scaffold-static-ad",
8826
8668
  description: "Turn a source/inspiration image into a runnable static-ad canvas. Runs billed passes \u2014 image_describe (the blueprint, baked to prompt.json as the editable 'prompt'), an AI selection of the image's MAIN identity elements, and a structured global-layout pass (the column/row grid with per-region bounds and text sizes) \u2014 then scaffolds a canvas that wires one [TODO] ingest slot per element (logo/product/subject/badge + brand font) into image_generate. Edit prompt.json and drop the real assets, then `baker canvas run` it."
@@ -8837,10 +8679,10 @@ var scaffoldStaticAdCommand = defineCommand82({
8837
8679
  "skip-font": { type: "boolean", description: "Skip the brand-font \u2192 type-specimen slot" }
8838
8680
  },
8839
8681
  async run({ args }) {
8840
- const imagePath = path4.resolve(String(args.file));
8841
- const outPath = args.out ? path4.resolve(String(args.out)) : path4.join(path4.dirname(imagePath), "static-ad.canvas.json");
8842
- const outDir = path4.dirname(outPath);
8843
- const blueprintPath = path4.join(outDir, "prompt.json");
8682
+ const imagePath = path5.resolve(String(args.file));
8683
+ const outPath = args.out ? path5.resolve(String(args.out)) : path5.join(path5.dirname(imagePath), "static-ad.canvas.json");
8684
+ const outDir = path5.dirname(outPath);
8685
+ const blueprintPath = path5.join(outDir, "prompt.json");
8844
8686
  const { describeModel, selectModel, layoutModel, genModel } = resolveModels(args);
8845
8687
  const describeCanvas = buildDescribeCanvas(
8846
8688
  imagePath,
@@ -8897,7 +8739,7 @@ var scaffoldStaticAdCommand = defineCommand82({
8897
8739
  run_estimated_credits: validation.estimatedCredits
8898
8740
  },
8899
8741
  checklist: {
8900
- edit_prompt: `Edit ${path4.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.`,
8742
+ edit_prompt: `Edit ${path5.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.`,
8901
8743
  assets_to_supply: report.elements,
8902
8744
  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)",
8903
8745
  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."
@@ -8913,12 +8755,12 @@ var scaffoldStaticAdCommand = defineCommand82({
8913
8755
 
8914
8756
  // src/commands/canvas/scaffold-video.ts
8915
8757
  import { cp, mkdir, readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
8916
- import path6 from "path";
8917
- import { defineCommand as defineCommand83 } from "citty";
8758
+ import path8 from "path";
8759
+ import { defineCommand as defineCommand82 } from "citty";
8918
8760
 
8919
8761
  // src/engine/nodes/local/lib/sceneDetect.ts
8920
8762
  import { execFile as execFile2 } from "child_process";
8921
- import { mkdtemp, readdir as readdir3, readFile as readFile5, rm } from "fs/promises";
8763
+ import { mkdtemp, readdir as readdir2, readFile as readFile4, rm as rm2 } from "fs/promises";
8922
8764
  import { tmpdir } from "os";
8923
8765
  import { join as join2 } from "path";
8924
8766
  import { promisify as promisify2 } from "util";
@@ -8982,11 +8824,11 @@ async function runSceneDetectOnce(filePath, threshold, minSceneLenS, timeoutMs)
8982
8824
  ],
8983
8825
  { encoding: "utf-8", maxBuffer: 32 * 1024 * 1024, timeout: timeoutMs }
8984
8826
  );
8985
- const csvName = (await readdir3(outDir)).find((f) => f.toLowerCase().endsWith(".csv"));
8827
+ const csvName = (await readdir2(outDir)).find((f) => f.toLowerCase().endsWith(".csv"));
8986
8828
  if (!csvName) return [];
8987
- return parsePySceneDetectCsvCuts(await readFile5(join2(outDir, csvName), "utf-8"));
8829
+ return parsePySceneDetectCsvCuts(await readFile4(join2(outDir, csvName), "utf-8"));
8988
8830
  } finally {
8989
- await rm(outDir, { recursive: true, force: true });
8831
+ await rm2(outDir, { recursive: true, force: true });
8990
8832
  }
8991
8833
  }
8992
8834
  async function detectSceneCutsPySceneDetect(filePath, opts = {}) {
@@ -9242,6 +9084,13 @@ function stillHoldArgs(durationS, dims) {
9242
9084
  `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=increase,crop=${dims.w}:${dims.h},setsar=1,format=yuv420p`,
9243
9085
  "-c:v",
9244
9086
  "libx264",
9087
+ // Near-visually-lossless re-encode. libx264 DEFAULTS (crf 23, preset medium)
9088
+ // roughly halve the source bitrate and add banding on smooth surfaces; the
9089
+ // spine concats these by stream-copy, so any loss here ships to the final cut.
9090
+ "-crf",
9091
+ "18",
9092
+ "-preset",
9093
+ "slow",
9245
9094
  "-pix_fmt",
9246
9095
  "yuv420p",
9247
9096
  "{{out.video}}"
@@ -9257,6 +9106,13 @@ function trimArgs(durationS, offsetS = 0) {
9257
9106
  "-an",
9258
9107
  "-c:v",
9259
9108
  "libx264",
9109
+ // Preserve the seedance source quality through the trim. libx264 DEFAULTS
9110
+ // (crf 23) halve the bitrate (measured 9.57→4.40 Mbps) and band on motion;
9111
+ // the spine stream-copies the result, so the loss is permanent without this.
9112
+ "-crf",
9113
+ "18",
9114
+ "-preset",
9115
+ "slow",
9260
9116
  "-pix_fmt",
9261
9117
  "yuv420p",
9262
9118
  "{{out.video}}"
@@ -9323,6 +9179,13 @@ var Scene = z3.object({
9323
9179
  // The scene's role in the ad's persuasion arc (DECON-supplied); drives the
9324
9180
  // script re-craft checklist. Inferred from position when absent.
9325
9181
  narrative_role: z3.string().optional(),
9182
+ // DECON-supplied on the HOOK scene: the engineered physical/emotional state that
9183
+ // makes the first frame stop the scroll (sweaty/breathless/urgent …). Injected
9184
+ // into the hook's start-frame description so the generator renders that state,
9185
+ // not a calm influencer (CCA-11).
9186
+ hook_mechanic: z3.object({ mechanic: z3.string().optional(), why_it_stops_scroll: z3.string().optional() }).loose().optional(),
9187
+ // DECON-supplied per-scene location (so a gym hook isn't flattened to "home").
9188
+ scene_setting: z3.string().optional(),
9326
9189
  // How this scene cuts to the next (DECON-supplied). A recognized non-cut type
9327
9190
  // (fade/whip/zoom/dissolve/swipe) is reproduced as an ffmpeg xfade at the
9328
9191
  // boundary; cut/match_cut/none/other stay hard cuts. The last scene's value is
@@ -9370,10 +9233,21 @@ var VideoBlueprint = z3.object({
9370
9233
  mode: z3.string().optional(),
9371
9234
  voice_description: z3.string().optional(),
9372
9235
  persona: z3.string().optional()
9373
- }).loose().optional()
9236
+ }).loose().optional(),
9237
+ // Visual palette — read only to colour a clean brand-card/CTA plate (the
9238
+ // first hex is the dominant brand colour); never to drive frame generation.
9239
+ style: z3.object({ palette: z3.array(z3.object({ hex: z3.string().optional() }).loose()).optional() }).loose().optional()
9374
9240
  }).loose().optional(),
9375
9241
  scenes: z3.array(Scene).min(1)
9376
9242
  }).loose();
9243
+ function injectHookPhysicality(blueprint) {
9244
+ for (const scene of blueprint.scenes) {
9245
+ const why = scene.hook_mechanic?.why_it_stops_scroll?.trim();
9246
+ const prompt = scene.start_frame_prompt?.trim();
9247
+ if (!why || !prompt || prompt.includes(why)) continue;
9248
+ scene.start_frame_prompt = `${prompt} The subject's physical state IS the scroll-stopper \u2014 render it explicitly, not a calm pose: ${why}.`;
9249
+ }
9250
+ }
9377
9251
  var AppearsItem = z3.union([z3.number(), z3.object({ scene: z3.number(), edge: z3.string().optional() }).loose()]);
9378
9252
  var RecurringElement = z3.object({
9379
9253
  // person | animal | product | logo | badge | other
@@ -9399,7 +9273,8 @@ function sanitizeId2(raw, fallback) {
9399
9273
  return /^[a-z]/.test(id) ? id : `${fallback}_${id}`.replace(/_+$/g, "") || fallback;
9400
9274
  }
9401
9275
  function labelFor2(el, used) {
9402
- const base = (el.label ?? el.type ?? "ELEMENT").toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "ELEMENT";
9276
+ const raw = el.type?.toLowerCase() === "logo" ? "BRAND_LOGO" : el.label ?? el.type ?? "ELEMENT";
9277
+ const base = raw.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "ELEMENT";
9403
9278
  let label = base;
9404
9279
  let n = 2;
9405
9280
  while (used.has(label)) label = `${base}_${n++}`;
@@ -9566,6 +9441,8 @@ function buildElementSheets(slots, nodes) {
9566
9441
  if (slot.sameAs) continue;
9567
9442
  if (slot.presence.size < 1) continue;
9568
9443
  const sheetId = `${slot.id}_sheet`;
9444
+ const slotType = slot.type.toLowerCase();
9445
+ const isCast = slotType === "person" || slotType === "animal";
9569
9446
  nodes.push({
9570
9447
  id: sheetId,
9571
9448
  type: "image_reference_sheet",
@@ -9578,7 +9455,14 @@ function buildElementSheets(slots, nodes) {
9578
9455
  // 4K: the sheet packs up to 8 cells (angles + tight face/detail close-ups), and
9579
9456
  // it's the ONE reference every frame grounds on — per-cell sharpness here
9580
9457
  // propagates to every clip, so it's worth the highest tier on this single asset.
9581
- image_size: "4K"
9458
+ image_size: "4K",
9459
+ // The sheet is the look that propagates to EVERY grounded frame, so a glossy
9460
+ // studio turnaround makes the whole UGC ad read as "produced" (the #1 AI tell).
9461
+ // Force a flat, real, front-camera look on the cast sheet so the actor stays
9462
+ // authentic, not an airbrushed influencer (CCA-02).
9463
+ ...isCast ? {
9464
+ style: "authentic UGC look: flat, even, natural front-camera lighting \u2014 no studio key/rim light, no seamless backdrop, no shallow depth of field; real skin texture and pores, no airbrushing or beauty retouch; true-to-life everyday styling"
9465
+ } : {}
9582
9466
  }
9583
9467
  });
9584
9468
  slot.ref = `$ref:${sheetId}.sheet`;
@@ -9686,8 +9570,7 @@ function buildFrameRef(edge, url, framePrompt, present, ctx, nodes) {
9686
9570
  const t = s.type.toLowerCase();
9687
9571
  return t === "person" || t === "animal";
9688
9572
  });
9689
- const castIdentityLocked = castSlots.every((s) => s.sheetBacked);
9690
- const useOriginalAnchor = Boolean(url) && (castSlots.length === 0 || castIdentityLocked);
9573
+ const useOriginalAnchor = Boolean(url) && castSlots.length === 0;
9691
9574
  const hasOriginal = useOriginalAnchor;
9692
9575
  const originalRef = useOriginalAnchor && url ? ingestFrameRef(url, edge, ctx, nodes) : void 0;
9693
9576
  const reference = [...present.map((s) => s.ref), ...originalRef ? [originalRef] : []];
@@ -9898,6 +9781,39 @@ function isUiOnlyComposite(regions) {
9898
9781
  const ui = regions.filter(regionIsUiSurface).length;
9899
9782
  return ui >= 1 && regions.length - ui <= 1;
9900
9783
  }
9784
+ function sceneIsFullScreenUi(scene, present) {
9785
+ if (scene.narrative_role?.trim() === "cta") return false;
9786
+ const hasCast = present.some((s) => {
9787
+ const t = s.type.toLowerCase();
9788
+ return t === "person" || t === "animal";
9789
+ });
9790
+ if (hasCast) return false;
9791
+ const hay = `${scene.summary ?? ""} ${scene.start_frame_prompt ?? ""} ${scene.end_frame_prompt ?? ""} ${scene.action_detail ?? ""}`;
9792
+ return UI_SURFACE_RE.test(hay);
9793
+ }
9794
+ function screenStillArgs(durationS, dims) {
9795
+ return [
9796
+ "-loop",
9797
+ "1",
9798
+ "-i",
9799
+ "{{in.frame}}",
9800
+ "-t",
9801
+ durationS.toFixed(3),
9802
+ "-r",
9803
+ "30",
9804
+ "-vf",
9805
+ `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=decrease,pad=${dims.w}:${dims.h}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,format=yuv420p`,
9806
+ "-c:v",
9807
+ "libx264",
9808
+ "-crf",
9809
+ "18",
9810
+ "-preset",
9811
+ "slow",
9812
+ "-pix_fmt",
9813
+ "yuv420p",
9814
+ "{{out.video}}"
9815
+ ];
9816
+ }
9901
9817
  function layeredComposition(scene) {
9902
9818
  const comp = scene.composition;
9903
9819
  const layout = (comp?.layout ?? "").toLowerCase();
@@ -10068,6 +9984,77 @@ function emitFlashHold(i, scene, slots, ctx, lengths, out, ar, nodes, clips) {
10068
9984
  });
10069
9985
  clips.push({ ref: `$ref:s${i}_clip.video`, scene_s: lengths.dur, out });
10070
9986
  }
9987
+ function emitScreenScene(i, scene, lengths, out, ar, nodes, clips) {
9988
+ const label = commentSafe((scene.summary || scene.start_frame_prompt || "the app screen").slice(0, 120));
9989
+ const refId = `s${i}_screen_ref`;
9990
+ nodes.push({
9991
+ id: refId,
9992
+ type: "ingest",
9993
+ params: {
9994
+ source: "path",
9995
+ 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]`,
9996
+ expect: "image"
9997
+ }
9998
+ });
9999
+ nodes.push({
10000
+ id: `s${i}_clip`,
10001
+ type: "ffmpeg",
10002
+ inputs: { frame: `$ref:${refId}.asset` },
10003
+ params: {
10004
+ args: screenStillArgs(lengths.trimTarget, canvasDims(ar)),
10005
+ outputs: { video: { kind: "video", ext: "mp4" } }
10006
+ }
10007
+ });
10008
+ clips.push({ ref: `$ref:s${i}_clip.video`, scene_s: lengths.dur, out });
10009
+ }
10010
+ 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;
10011
+ function sceneIsBrandCard(scene, present, isCta) {
10012
+ if (!isCta) return false;
10013
+ const hasCast = present.some((s) => {
10014
+ const t = s.type.toLowerCase();
10015
+ return t === "person" || t === "animal";
10016
+ });
10017
+ if (hasCast) return false;
10018
+ const hay = `${scene.summary ?? ""} ${scene.start_frame_prompt ?? ""} ${scene.end_frame_prompt ?? ""}`;
10019
+ return BRAND_CARD_RE.test(hay);
10020
+ }
10021
+ var HEX6_RE = /^#?[0-9a-fA-F]{6}$/;
10022
+ function brandPlateColor(blueprint) {
10023
+ const palette = blueprint.global?.style?.palette;
10024
+ const hex = palette?.map((p) => p?.hex).find((h) => typeof h === "string" && HEX6_RE.test(h));
10025
+ return hex ? `0x${hex.replace(/^#/, "").toUpperCase()}` : "0x000000";
10026
+ }
10027
+ function colorPlateArgs(durationS, dims, color) {
10028
+ return [
10029
+ "-f",
10030
+ "lavfi",
10031
+ "-i",
10032
+ `color=c=${color}:s=${dims.w}x${dims.h}:r=30`,
10033
+ "-t",
10034
+ durationS.toFixed(3),
10035
+ "-c:v",
10036
+ "libx264",
10037
+ "-crf",
10038
+ "18",
10039
+ "-preset",
10040
+ "slow",
10041
+ "-pix_fmt",
10042
+ "yuv420p",
10043
+ "{{out.video}}"
10044
+ ];
10045
+ }
10046
+ function emitBrandCardScene(i, lengths, out, ar, color, nodes, clips) {
10047
+ nodes.push({
10048
+ id: `s${i}_clip`,
10049
+ type: "ffmpeg",
10050
+ inputs: {},
10051
+ params: {
10052
+ args: colorPlateArgs(lengths.trimTarget, canvasDims(ar), color),
10053
+ outputs: { video: { kind: "video", ext: "mp4" } }
10054
+ }
10055
+ });
10056
+ clips.push({ ref: `$ref:s${i}_clip.video`, scene_s: lengths.dur, out });
10057
+ }
10071
10058
  function musicArcDigest(blueprint) {
10072
10059
  const roles = blueprint.scenes.map((s) => s.narrative_role).filter((r) => Boolean(r));
10073
10060
  const arc = roles.length > 0 ? roles.join(" \u2192 ") : "";
@@ -10187,7 +10174,7 @@ function makePresenterPresent(slots, canonical, opts = {}) {
10187
10174
  const solePerson = !opts.strict && personSlots.length === 1 ? personSlots[0].presence : null;
10188
10175
  return (speaker, sceneIndex) => {
10189
10176
  const presence = bySpeaker.get(speaker) ?? solePerson;
10190
- if (!presence) return opts.strict ? false : true;
10177
+ if (!presence) return !opts.strict;
10191
10178
  return presence.has(sceneIndex);
10192
10179
  };
10193
10180
  }
@@ -10552,6 +10539,15 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
10552
10539
  shootMode: mode,
10553
10540
  ingestCache: env.ingestCache
10554
10541
  };
10542
+ if (!env.reuse && sceneIsFullScreenUi(scene, present)) {
10543
+ emitScreenScene(i, scene, lengths, lengths.out, env.ar, nodes, out.clips);
10544
+ return void 0;
10545
+ }
10546
+ const isCta = scene.narrative_role?.trim() === "cta" || isLast;
10547
+ if (!env.reuse && sceneIsBrandCard(scene, present, isCta)) {
10548
+ emitBrandCardScene(i, lengths, lengths.out, env.ar, brandPlateColor(env.blueprint), nodes, out.clips);
10549
+ return void 0;
10550
+ }
10555
10551
  if (!ambientBroll && lengths.dur <= FLASH_HOLD_MAX_S) {
10556
10552
  emitFlashHold(i, scene, env.slots, ctx, lengths, lengths.out, env.ar, nodes, out.clips);
10557
10553
  return void 0;
@@ -10817,7 +10813,7 @@ function overlayElement(ov, at, dur) {
10817
10813
  const normAnim = normalizeAnim(ov.animation);
10818
10814
  const anim = normAnim ? ` data-anim="${normAnim}"` : "";
10819
10815
  const detail = ov.animation_detail ? ` data-anim-detail="${escapeHtml(ov.animation_detail)}"` : "";
10820
- return `<div class="ov ${positionClass(ov.position)}" data-start="${at}" data-dur="${dur}"${role}${anim}${detail}>${escapeHtml(ov.text.trim())}</div>`;
10816
+ return `<div class="ov clip ${positionClass(ov.position)}" data-start="${at}" data-dur="${dur}"${role}${anim}${detail}>${escapeHtml(ov.text.trim())}</div>`;
10821
10817
  }
10822
10818
  var RICH_OVERLAY_RE = /notif|tweet|\bx post\b|post\b|comment|message|chat|bubble|card|review|rating|stat|counter|toast|popup/;
10823
10819
  function sourceHint(fe) {
@@ -10847,7 +10843,7 @@ function floatingStub(fe, sceneStart) {
10847
10843
  const slug = (fe.kind ?? "element").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "element";
10848
10844
  return [
10849
10845
  `<!-- ${kind}: ${label} @ ${at}s for ${dur}s (${positionClass(fe.position)}). Source a real asset: ${hint} \u2014 drop it in this dir and uncomment:`,
10850
- `<img class="ov ${positionClass(fe.position)}" src="your-${slug}.png" data-start="${at}" data-dur="${dur}" alt="" /> -->`
10846
+ `<img class="ov clip ${positionClass(fe.position)}" src="your-${slug}.png" data-start="${at}" data-dur="${dur}" alt="" /> -->`
10851
10847
  ].join("\n");
10852
10848
  }
10853
10849
  function uiPipStub(scene) {
@@ -10867,7 +10863,7 @@ function uiPipStub(scene) {
10867
10863
  " \u2014 OR hand-build a brand-accurate HTML screen; then frame it in a phone mockup:",
10868
10864
  " npx hyperframes add phone-scroll (writes compositions/phone-scroll.html)",
10869
10865
  " drop the screenshot as screenshot.png in this dir and nest it as a PIP clip:",
10870
- ` <div data-composition-src="compositions/phone-scroll.html" data-start="${at}" data-duration="${dur}" data-track-index="2" data-width="1080" data-height="1920"></div> -->`
10866
+ ` <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> -->`
10871
10867
  ].join("\n");
10872
10868
  }
10873
10869
  function buildOverlayHtml(input) {
@@ -10963,6 +10959,7 @@ function buildSpine(clips, nodes) {
10963
10959
  }
10964
10960
  function scaffoldVideoCanvas(input, elementsInput, opts) {
10965
10961
  const blueprint = VideoBlueprint.parse(input);
10962
+ injectHookPhysicality(blueprint);
10966
10963
  const elements = RecurringElements.parse(elementsInput);
10967
10964
  const nodes = [];
10968
10965
  nodes.push({
@@ -11374,18 +11371,44 @@ function videoReport(input, elementsInput) {
11374
11371
 
11375
11372
  // src/commands/canvas/composition-path.ts
11376
11373
  import { existsSync as existsSync3 } from "fs";
11377
- import path5 from "path";
11374
+ import path6 from "path";
11378
11375
  function resolveShippedCanvasDir(name, startDir, exists = existsSync3, maxDepth = 8) {
11379
- const rel = path5.join("canvas", name);
11376
+ const rel = path6.join("canvas", name);
11380
11377
  let dir = startDir;
11381
11378
  for (let i = 0; i < maxDepth; i++) {
11382
- const candidate = path5.join(dir, rel);
11383
- if (exists(path5.join(candidate, "meta.json"))) return candidate;
11384
- const parent = path5.dirname(dir);
11379
+ const candidate = path6.join(dir, rel);
11380
+ if (exists(path6.join(candidate, "meta.json"))) return candidate;
11381
+ const parent = path6.dirname(dir);
11385
11382
  if (parent === dir) break;
11386
11383
  dir = parent;
11387
11384
  }
11388
- return path5.resolve(startDir, "../../../", rel);
11385
+ return path6.resolve(startDir, "../../../", rel);
11386
+ }
11387
+
11388
+ // src/commands/canvas/gitignore.ts
11389
+ import { appendFile, readFile as readFile5 } from "fs/promises";
11390
+ import path7 from "path";
11391
+ function missingGitignoreEntries(existing, entries) {
11392
+ const present = new Set(
11393
+ existing.split("\n").map((l) => l.trim().replace(/\/+$/, "")).filter((l) => l.length > 0 && !l.startsWith("#"))
11394
+ );
11395
+ return entries.filter((e) => !present.has(e.trim().replace(/\/+$/, "")));
11396
+ }
11397
+ async function ensureGitignore(dir, entries) {
11398
+ const file = path7.join(dir, ".gitignore");
11399
+ let existing;
11400
+ try {
11401
+ existing = await readFile5(file, "utf8");
11402
+ } catch {
11403
+ return;
11404
+ }
11405
+ const missing = missingGitignoreEntries(existing, entries);
11406
+ if (missing.length === 0) return;
11407
+ const prefix = existing.endsWith("\n") || existing.length === 0 ? "" : "\n";
11408
+ await appendFile(file, `${prefix}
11409
+ # Baker canvas (engine cache + scaffold working files)
11410
+ ${missing.join("\n")}
11411
+ `);
11389
11412
  }
11390
11413
 
11391
11414
  // src/commands/canvas/scaffold-video.ts
@@ -11433,7 +11456,7 @@ async function loadTranscriptBestEffort(ref) {
11433
11456
  async function stageCaptions(outDir, transcript) {
11434
11457
  const text = transcript?.trim();
11435
11458
  if (!text || text === "[]") return {};
11436
- const compositionPath = path6.join(outDir, "tiktok-captions-composition");
11459
+ const compositionPath = path8.join(outDir, "tiktok-captions-composition");
11437
11460
  await cp(SHIPPED_CAPTIONS_DIR, compositionPath, { recursive: true });
11438
11461
  return { compositionPath };
11439
11462
  }
@@ -11565,7 +11588,7 @@ async function runAnalysisPasses(deconstructCanvas, selectModel) {
11565
11588
  return fail2("deconstruct", e instanceof Error ? e.message : String(e));
11566
11589
  }
11567
11590
  }
11568
- var scaffoldVideoCommand = defineCommand83({
11591
+ var scaffoldVideoCommand = defineCommand82({
11569
11592
  meta: {
11570
11593
  name: "scaffold-video",
11571
11594
  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`."
@@ -11595,11 +11618,11 @@ var scaffoldVideoCommand = defineCommand83({
11595
11618
  }
11596
11619
  },
11597
11620
  async run({ args }) {
11598
- const videoPath = path6.resolve(String(args.file));
11599
- const base = path6.basename(videoPath, path6.extname(videoPath));
11600
- const outPath = args.out ? path6.resolve(String(args.out)) : path6.join(path6.dirname(videoPath), `${base}.video.canvas.json`);
11601
- const outDir = path6.dirname(outPath);
11602
- const blueprintPath = path6.join(outDir, "prompt.json");
11621
+ const videoPath = path8.resolve(String(args.file));
11622
+ const base = path8.basename(videoPath, path8.extname(videoPath));
11623
+ const outPath = args.out ? path8.resolve(String(args.out)) : path8.join(path8.dirname(videoPath), `${base}.video.canvas.json`);
11624
+ const outDir = path8.dirname(outPath);
11625
+ const blueprintPath = path8.join(outDir, "prompt.json");
11603
11626
  const frames = args.frames === "reuse" ? "reuse" : "generate";
11604
11627
  const maxScenes = args["max-scenes"] ? Number(args["max-scenes"]) : void 0;
11605
11628
  if (Number.isFinite(maxScenes)) {
@@ -11622,9 +11645,9 @@ var scaffoldVideoCommand = defineCommand83({
11622
11645
  const annotated = annotateBlueprintWithElements(blueprint, elements);
11623
11646
  await writeFile2(blueprintPath, `${JSON.stringify(annotated, null, 2)}
11624
11647
  `, "utf8");
11625
- const compositionDest = path6.join(outDir, "video-overlay-composition");
11648
+ const compositionDest = path8.join(outDir, "video-overlay-composition");
11626
11649
  await cp(SHIPPED_COMPOSITION_DIR, compositionDest, { recursive: true });
11627
- const indexPath = path6.join(compositionDest, "index.html");
11650
+ const indexPath = path8.join(compositionDest, "index.html");
11628
11651
  const overlayHtml = buildOverlayHtml(blueprint);
11629
11652
  const indexHtml = await readFile6(indexPath, "utf8");
11630
11653
  const injected = indexHtml.replace("<!--OVERLAYS-->", () => overlayHtml);
@@ -11639,9 +11662,9 @@ var scaffoldVideoCommand = defineCommand83({
11639
11662
  const opts = {
11640
11663
  imageModel,
11641
11664
  videoModel,
11642
- overlayCompositionPath: compositionDest,
11643
- captionsCompositionPath: captions.compositionPath,
11644
- blueprintPath,
11665
+ overlayCompositionPath: path8.relative(outDir, compositionDest),
11666
+ captionsCompositionPath: captions.compositionPath ? path8.relative(outDir, captions.compositionPath) : void 0,
11667
+ blueprintPath: path8.relative(outDir, blueprintPath),
11645
11668
  frames,
11646
11669
  ambient: Boolean(args.ambient),
11647
11670
  ...args.resolution ? { resolution: String(args.resolution) } : {}
@@ -11654,7 +11677,7 @@ var scaffoldVideoCommand = defineCommand83({
11654
11677
  } catch (e) {
11655
11678
  return fail2("scaffold", e instanceof Error ? e.message : String(e));
11656
11679
  }
11657
- const validation = await validateCanvasDeep(canvas, defaultRegistry());
11680
+ const validation = await validateCanvasDeep(resolveRelativeCanvasPaths(canvas, outDir), defaultRegistry());
11658
11681
  if (!validation.ok) {
11659
11682
  process.stderr.write(
11660
11683
  `${JSON.stringify({ ok: false, error: { code: "validation", issues: validation.issues } }, null, 2)}
@@ -11664,6 +11687,7 @@ var scaffoldVideoCommand = defineCommand83({
11664
11687
  }
11665
11688
  await writeFile2(outPath, `${JSON.stringify(canvas, null, 2)}
11666
11689
  `, "utf8");
11690
+ await ensureGitignore(process.cwd(), ["canvas/", ".context/"]);
11667
11691
  process.stdout.write(
11668
11692
  `${JSON.stringify(
11669
11693
  {
@@ -11681,7 +11705,7 @@ var scaffoldVideoCommand = defineCommand83({
11681
11705
  run_estimated_credits: validation.estimatedCredits
11682
11706
  },
11683
11707
  checklist: {
11684
- edit_prompt: `Edit ${path6.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.`,
11708
+ edit_prompt: `Edit ${path8.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.`,
11685
11709
  recurring_elements_to_supply: report.elements,
11686
11710
  voices_to_confirm: report.dialogue.map((d) => ({
11687
11711
  scene: d.scene,
@@ -11706,9 +11730,84 @@ var scaffoldVideoCommand = defineCommand83({
11706
11730
  }
11707
11731
  });
11708
11732
 
11733
+ // src/commands/canvas/set-prompt.ts
11734
+ import { readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
11735
+ import path9 from "path";
11736
+ import { defineCommand as defineCommand83 } from "citty";
11737
+ function setNodePrompt(canvas, nodeId, text) {
11738
+ const nodes = canvas?.nodes;
11739
+ if (!Array.isArray(nodes)) throw new Error("canvas has no nodes array");
11740
+ const idx = nodes.findIndex((n) => n?.id === nodeId);
11741
+ if (idx < 0) {
11742
+ const ids = nodes.map((n) => n?.id).filter((id) => typeof id === "string");
11743
+ throw new Error(`node "${nodeId}" not found. Known nodes: ${ids.join(", ")}`);
11744
+ }
11745
+ const node = nodes[idx];
11746
+ const newNode = { ...node, params: { ...node.params ?? {}, prompt: text } };
11747
+ const newNodes = [...nodes];
11748
+ newNodes[idx] = newNode;
11749
+ return { ...canvas, nodes: newNodes };
11750
+ }
11751
+ var setPromptCommand = defineCommand83({
11752
+ meta: {
11753
+ name: "set-prompt",
11754
+ description: "Safely set a node's params.prompt (a frame description, motion prompt, etc.) without hand-editing the JSON. Prefer --text-file for multi-line/accented copy \u2014 it preserves UTF-8 exactly, unlike shell-quoted jq."
11755
+ },
11756
+ args: {
11757
+ file: { type: "positional", required: true, description: "Path to canvas JSON" },
11758
+ node: { type: "positional", required: true, description: "Node id to edit (e.g. s0_start)" },
11759
+ text: { type: "string", description: "New prompt text (inline)" },
11760
+ "text-file": { type: "string", description: "Read the new prompt from a UTF-8 file (preserves accents/newlines)" }
11761
+ },
11762
+ async run({ args }) {
11763
+ const filePath = path9.resolve(String(args.file));
11764
+ let canvas;
11765
+ try {
11766
+ canvas = JSON.parse(await readFile7(filePath, "utf8"));
11767
+ } catch (e) {
11768
+ process.stderr.write(`${JSON.stringify({ ok: false, error: { code: "parse", message: String(e) } }, null, 2)}
11769
+ `);
11770
+ process.exit(2);
11771
+ }
11772
+ let text;
11773
+ if (args["text-file"]) text = await readFile7(path9.resolve(String(args["text-file"])), "utf8");
11774
+ else if (args.text !== void 0) text = String(args.text);
11775
+ else {
11776
+ process.stderr.write(
11777
+ `${JSON.stringify({ ok: false, error: { code: "no_text", message: "pass --text or --text-file" } }, null, 2)}
11778
+ `
11779
+ );
11780
+ process.exit(2);
11781
+ return;
11782
+ }
11783
+ let updated;
11784
+ try {
11785
+ updated = setNodePrompt(canvas, String(args.node), text);
11786
+ } catch (e) {
11787
+ process.stderr.write(
11788
+ `${JSON.stringify({ ok: false, error: { code: "node_not_found", message: String(e.message) } }, null, 2)}
11789
+ `
11790
+ );
11791
+ process.exit(2);
11792
+ return;
11793
+ }
11794
+ const validation = await validateCanvasDeep(resolveRelativeCanvasPaths(updated, path9.dirname(filePath)), defaultRegistry());
11795
+ if (!validation.ok) {
11796
+ process.stderr.write(`${JSON.stringify({ ok: false, error: { code: "validation", issues: validation.issues } }, null, 2)}
11797
+ `);
11798
+ process.exit(2);
11799
+ return;
11800
+ }
11801
+ await writeFile3(filePath, `${JSON.stringify(updated, null, 2)}
11802
+ `, "utf8");
11803
+ process.stdout.write(`${JSON.stringify({ ok: true, node: String(args.node), bytes: text.length }, null, 2)}
11804
+ `);
11805
+ }
11806
+ });
11807
+
11709
11808
  // src/commands/canvas/validate.ts
11710
- import { readFile as readFile7 } from "fs/promises";
11711
- import path7 from "path";
11809
+ import { readFile as readFile8 } from "fs/promises";
11810
+ import path10 from "path";
11712
11811
  import { defineCommand as defineCommand84 } from "citty";
11713
11812
  var validateCommand = defineCommand84({
11714
11813
  meta: {
@@ -11717,8 +11816,8 @@ var validateCommand = defineCommand84({
11717
11816
  },
11718
11817
  args: { file: { type: "positional", required: true, description: "Path to canvas JSON" } },
11719
11818
  async run({ args }) {
11720
- const filePath = path7.resolve(String(args.file));
11721
- const raw = await readFile7(filePath, "utf8");
11819
+ const filePath = path10.resolve(String(args.file));
11820
+ const raw = await readFile8(filePath, "utf8");
11722
11821
  let parsed;
11723
11822
  try {
11724
11823
  parsed = JSON.parse(raw);
@@ -11728,6 +11827,7 @@ var validateCommand = defineCommand84({
11728
11827
  `);
11729
11828
  process.exit(2);
11730
11829
  }
11830
+ parsed = resolveRelativeCanvasPaths(parsed, path10.dirname(filePath));
11731
11831
  const result = await validateCanvasDeep(parsed, defaultRegistry());
11732
11832
  if (!result.ok) {
11733
11833
  process.stderr.write(`${JSON.stringify({ ok: false, issues: result.issues }, null, 2)}
@@ -11764,7 +11864,6 @@ Subcommands:
11764
11864
  baker canvas run <file.json> \u2014 execute the canvas, write outputs to ./canvas/<run_id>/
11765
11865
  baker canvas catalog \u2014 print the agent-facing node + composition catalog (JSON Schema)
11766
11866
  baker canvas inspect <run_id> \u2014 one-page summary of a completed run
11767
- baker canvas gallery <dir> \u2014 read a creative folder's _definition.md + run manifests into the dashboard gallery descriptor (JSON)
11768
11867
  baker canvas scaffold-video <video> \u2014 turn a reference video into a runnable reproduction canvas (deconstruct + recurring-element detection)
11769
11868
  baker canvas scaffold-static-ad <image> \u2014 turn a source image into a runnable static-ad canvas (describe + element detection)`
11770
11869
  },
@@ -11773,9 +11872,9 @@ Subcommands:
11773
11872
  validate: validateCommand,
11774
11873
  catalog: catalogCommand,
11775
11874
  inspect: inspectCommand,
11776
- gallery: galleryCommand,
11777
11875
  "scaffold-video": scaffoldVideoCommand,
11778
- "scaffold-static-ad": scaffoldStaticAdCommand
11876
+ "scaffold-static-ad": scaffoldStaticAdCommand,
11877
+ "set-prompt": setPromptCommand
11779
11878
  }
11780
11879
  });
11781
11880
 
@@ -12609,7 +12708,7 @@ function cropSprite(input, region) {
12609
12708
 
12610
12709
  // src/lib/image/io.ts
12611
12710
  import { randomBytes } from "crypto";
12612
- import { glob as fsGlob, readFile as readFile8, rename, stat as stat2, writeFile as writeFile3 } from "fs/promises";
12711
+ import { glob as fsGlob, readFile as readFile9, rename, stat as stat2, writeFile as writeFile4 } from "fs/promises";
12613
12712
  import { dirname, extname, join as join3, resolve as resolve4 } from "path";
12614
12713
  var REMOTE_RE = /^https?:\/\//i;
12615
12714
  var GLOB_RE = /[*?[\]{}]/;
@@ -12645,11 +12744,11 @@ async function readImageBuffer(pathOrUrl) {
12645
12744
  }
12646
12745
  return Buffer.from(await response.arrayBuffer());
12647
12746
  }
12648
- return readFile8(pathOrUrl);
12747
+ return readFile9(pathOrUrl);
12649
12748
  }
12650
- async function isDirectory(path8) {
12749
+ async function isDirectory(path11) {
12651
12750
  try {
12652
- const s = await stat2(path8);
12751
+ const s = await stat2(path11);
12653
12752
  return s.isDirectory();
12654
12753
  } catch {
12655
12754
  return false;
@@ -12668,7 +12767,7 @@ async function atomicWrite(targetPath, data) {
12668
12767
  const absolute = resolve4(targetPath);
12669
12768
  const dir = dirname(absolute);
12670
12769
  const tmp = join3(dir, `.baker-image-${randomBytes(8).toString("hex")}.tmp`);
12671
- await writeFile3(tmp, data);
12770
+ await writeFile4(tmp, data);
12672
12771
  await rename(tmp, absolute);
12673
12772
  }
12674
12773
 
@@ -13009,7 +13108,7 @@ var findCommand = defineCommand98({
13009
13108
  });
13010
13109
 
13011
13110
  // src/commands/images/generate.ts
13012
- import { readFile as readFile9 } from "fs/promises";
13111
+ import { readFile as readFile10 } from "fs/promises";
13013
13112
  import { defineCommand as defineCommand99 } from "citty";
13014
13113
  import sharp2 from "sharp";
13015
13114
  var GENERATE_TIMEOUT_MS = 18e4;
@@ -13092,7 +13191,7 @@ async function resolveReferences(spec) {
13092
13191
  }
13093
13192
  let raw;
13094
13193
  try {
13095
- raw = await readFile9(entry);
13194
+ raw = await readFile10(entry);
13096
13195
  } catch {
13097
13196
  throw new ApiError("VALIDATION_ERROR", `Reference file not found: ${entry}`);
13098
13197
  }
@@ -14858,7 +14957,7 @@ function makeTagsCommand(command, label, endpoint) {
14858
14957
  var tagsCommand2 = makeTagsCommand("images", "image", "/api/images/tags");
14859
14958
 
14860
14959
  // src/commands/images/upload.ts
14861
- import { readFile as readFile10 } from "fs/promises";
14960
+ import { readFile as readFile11 } from "fs/promises";
14862
14961
  import { extname as extname2 } from "path";
14863
14962
  import { defineCommand as defineCommand114 } from "citty";
14864
14963
  var MIME_MAP = {
@@ -14998,7 +15097,7 @@ async function uploadLocal(target, args) {
14998
15097
  });
14999
15098
  return;
15000
15099
  }
15001
- const fileBuffer = await readFile10(target);
15100
+ const fileBuffer = await readFile11(target);
15002
15101
  const base64 = fileBuffer.toString("base64");
15003
15102
  const body = { base64, contentType };
15004
15103
  if (args.source) body.source = args.source;
@@ -16975,7 +17074,7 @@ var searchCommand3 = defineCommand143({
16975
17074
  var tagsCommand4 = makeTagsCommand("videos", "video", "/api/videos/tags");
16976
17075
 
16977
17076
  // src/commands/videos/upload.ts
16978
- import { readFile as readFile11, stat as stat3 } from "fs/promises";
17077
+ import { readFile as readFile12, stat as stat3 } from "fs/promises";
16979
17078
  import { extname as extname3 } from "path";
16980
17079
  import { defineCommand as defineCommand144 } from "citty";
16981
17080
  var MIME_MAP2 = {
@@ -17040,7 +17139,7 @@ var uploadCommand2 = defineCommand144({
17040
17139
  return;
17041
17140
  }
17042
17141
  const { uploadUrl, videoId } = await apiPost("/api/videos/upload", {});
17043
- const fileBuffer = await readFile11(filePath);
17142
+ const fileBuffer = await readFile12(filePath);
17044
17143
  const uploadResponse = await fetch(uploadUrl, {
17045
17144
  method: "PUT",
17046
17145
  headers: { "Content-Type": contentType },