scenri 0.7.3 → 0.7.5

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/serve.js CHANGED
@@ -212,11 +212,13 @@ function widenNodeStatusCheck(db) {
212
212
  overlays TEXT NOT NULL DEFAULT '{}',
213
213
  brief TEXT,
214
214
  archived INTEGER NOT NULL DEFAULT 0,
215
- duration_ms INTEGER
215
+ duration_ms INTEGER,
216
+ batch_id TEXT,
217
+ batch_index INTEGER NOT NULL DEFAULT 0
216
218
  );
217
219
  INSERT INTO nodes_new
218
220
  SELECT id, project_id, parent_id, kind, prompt, engine_id, status, images, cost_usd, kept, error,
219
- created_at, overlays, brief, archived, duration_ms
221
+ created_at, overlays, brief, archived, duration_ms, batch_id, batch_index
220
222
  FROM nodes;
221
223
  DROP TABLE nodes;
222
224
  ALTER TABLE nodes_new RENAME TO nodes;
@@ -295,11 +297,104 @@ function collapseProjects(db) {
295
297
  })();
296
298
  }
297
299
  }
298
- var SCHEMA_VERSION = 1;
300
+ function splitMultiImageNodes(db) {
301
+ const rows = db.prepare("SELECT * FROM nodes").all();
302
+ const multi = rows.filter((r) => {
303
+ try {
304
+ return JSON.parse(r.images).length > 1;
305
+ } catch {
306
+ return false;
307
+ }
308
+ });
309
+ if (!multi.length) return;
310
+ const parse2 = (s) => {
311
+ if (!s) return null;
312
+ try {
313
+ return JSON.parse(s);
314
+ } catch {
315
+ return null;
316
+ }
317
+ };
318
+ const stampOf = (iso, minusMs) => {
319
+ const t = (/* @__PURE__ */ new Date(`${iso.replace(" ", "T")}Z`)).getTime() - minusMs;
320
+ const d = new Date(t);
321
+ const p = (n, w = 2) => String(n).padStart(w, "0");
322
+ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
323
+ };
324
+ const updateOriginal = db.prepare(
325
+ "UPDATE nodes SET images=?, overlays=?, brief=?, batch_id=?, batch_index=0 WHERE id=?"
326
+ );
327
+ const insertSibling = db.prepare(
328
+ `INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, status, images, cost_usd, kept,
329
+ error, created_at, overlays, brief, archived, duration_ms, batch_id, batch_index)
330
+ VALUES (?,?,?,?,?,?,?,?,0,?,?,?,?,?,?,NULL,?,?)`
331
+ );
332
+ const childrenOf = db.prepare("SELECT id, brief FROM nodes WHERE parent_id=?");
333
+ const repoint = db.prepare("UPDATE nodes SET parent_id=? WHERE id=?");
334
+ const setsOf = db.prepare("SELECT set_id FROM set_nodes WHERE node_id=?");
335
+ const addMember = db.prepare("INSERT OR IGNORE INTO set_nodes (set_id, node_id) VALUES (?,?)");
336
+ db.transaction(() => {
337
+ for (const r of multi) {
338
+ const images = JSON.parse(r.images);
339
+ const overlays = parse2(r.overlays) ?? {};
340
+ const brief = parse2(r.brief);
341
+ const sizes = Array.isArray(brief?.rendered?.sizes) ? brief.rendered.sizes : null;
342
+ const briefFor = (i) => {
343
+ if (!brief) return null;
344
+ const b = { ...brief, variants: images.length };
345
+ if (brief.rendered) {
346
+ const { requested: _req, variantIndexes: _vi, ...rendered } = brief.rendered;
347
+ b.rendered = { ...rendered, ...sizes ? { sizes: sizes[i] !== void 0 ? [sizes[i]] : [] } : {} };
348
+ }
349
+ return JSON.stringify(b);
350
+ };
351
+ const siblingIds = [r.id];
352
+ updateOriginal.run(
353
+ JSON.stringify([images[0]]),
354
+ JSON.stringify(overlays["0"] !== void 0 ? { "0": overlays["0"] } : {}),
355
+ briefFor(0),
356
+ r.id,
357
+ r.id
358
+ );
359
+ for (let i = 1; i < images.length; i++) {
360
+ const id = randomUUID();
361
+ siblingIds.push(id);
362
+ insertSibling.run(
363
+ id,
364
+ r.project_id,
365
+ r.parent_id,
366
+ r.kind,
367
+ r.prompt,
368
+ r.engine_id,
369
+ r.status,
370
+ JSON.stringify([images[i]]),
371
+ r.kept,
372
+ r.error,
373
+ stampOf(r.created_at, i),
374
+ JSON.stringify(overlays[String(i)] !== void 0 ? { "0": overlays[String(i)] } : {}),
375
+ briefFor(i),
376
+ r.archived,
377
+ r.id,
378
+ i
379
+ );
380
+ }
381
+ for (const child of childrenOf.all(r.id)) {
382
+ const src = parse2(child.brief)?.sourceImage;
383
+ if (typeof src !== "string") continue;
384
+ const at = images.indexOf(src);
385
+ if (at > 0) repoint.run(siblingIds[at], child.id);
386
+ }
387
+ for (const s of setsOf.all(r.id)) {
388
+ for (let i = 1; i < siblingIds.length; i++) addMember.run(s.set_id, siblingIds[i]);
389
+ }
390
+ }
391
+ })();
392
+ }
393
+ var SCHEMA_VERSION = 2;
299
394
  var SchemaTooNewError = class extends Error {
300
- constructor(found, supported) {
395
+ constructor(found, supported, backupsDir) {
301
396
  super(
302
- `This library was written by a newer Scenri (schema ${found}; this build understands ${supported}). Update and retry: npx scenri@latest`
397
+ `This library was written by a newer Scenri (schema ${found}; this build understands ${supported}). Update and retry: npx scenri@latest` + (backupsDir ? ` (a pre-migration snapshot of the library is kept in ${backupsDir})` : "")
303
398
  );
304
399
  this.name = "SchemaTooNewError";
305
400
  }
@@ -329,7 +424,7 @@ function openDb(homeDir) {
329
424
  const found = db.pragma("user_version", { simple: true });
330
425
  if (found > SCHEMA_VERSION) {
331
426
  db.close();
332
- throw new SchemaTooNewError(found, SCHEMA_VERSION);
427
+ throw new SchemaTooNewError(found, SCHEMA_VERSION, join(homeDir, "backups"));
333
428
  }
334
429
  if (preExisting && found < SCHEMA_VERSION) backupBeforeMigration(db, homeDir, found);
335
430
  db.exec(MIGRATIONS);
@@ -346,6 +441,12 @@ function openDb(homeDir) {
346
441
  if (!nodeCols.includes("duration_ms")) {
347
442
  db.exec("ALTER TABLE nodes ADD COLUMN duration_ms INTEGER");
348
443
  }
444
+ if (!nodeCols.includes("batch_id")) {
445
+ db.exec("ALTER TABLE nodes ADD COLUMN batch_id TEXT");
446
+ }
447
+ if (!nodeCols.includes("batch_index")) {
448
+ db.exec("ALTER TABLE nodes ADD COLUMN batch_index INTEGER NOT NULL DEFAULT 0");
449
+ }
349
450
  const projectCols = db.pragma("table_info(projects)").map((c) => c.name);
350
451
  if (!projectCols.includes("slug")) {
351
452
  db.exec("ALTER TABLE projects ADD COLUMN slug TEXT");
@@ -362,6 +463,7 @@ function openDb(homeDir) {
362
463
  widenNodeStatusCheck(db);
363
464
  backfillSlugs(db);
364
465
  collapseProjects(db);
466
+ splitMultiImageNodes(db);
365
467
  db.prepare(
366
468
  "UPDATE nodes SET status='error', error='interrupted: server restarted mid-generation' WHERE status='running'"
367
469
  ).run();
@@ -482,9 +584,21 @@ function rowToNode(r) {
482
584
  createdAt: r.created_at,
483
585
  overlays: JSON.parse(r.overlays ?? "{}"),
484
586
  brief: r.brief ? JSON.parse(r.brief) : null,
485
- archived: !!r.archived
587
+ archived: !!r.archived,
588
+ batchId: r.batch_id ?? null,
589
+ batchIndex: r.batch_index ?? 0
486
590
  };
487
591
  }
592
+ var lastBatchStamp = 0;
593
+ function batchStamps(count) {
594
+ const base = Math.max(Date.now(), lastBatchStamp + count);
595
+ lastBatchStamp = base;
596
+ const p = (n, w = 2) => String(n).padStart(w, "0");
597
+ return Array.from({ length: count }, (_, i) => {
598
+ const d = new Date(base - i);
599
+ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
600
+ });
601
+ }
488
602
  function createStore(db) {
489
603
  return {
490
604
  // brands
@@ -648,6 +762,42 @@ function createStore(db) {
648
762
  ).run(id, input.projectId, input.parentId, input.kind, input.prompt, input.engineId);
649
763
  return this.getNode(id);
650
764
  },
765
+ /**
766
+ * One multi-shot request, N first-class sibling nodes, one transaction.
767
+ * Slot 0 gets the newest stamp (see batchStamps) so the newest-first feed
768
+ * reads the batch in request order; batch_id is the first node's id, held
769
+ * by every sibling including the first, and stays null for a single send
770
+ * — one shot is not a batch.
771
+ */
772
+ addNodes(input) {
773
+ if (input.parentId) {
774
+ const parent = this.getNode(input.parentId);
775
+ if (!parent || parent.projectId !== input.projectId) throw new Error("parent node not found in project");
776
+ }
777
+ const count = Math.max(1, Math.floor(input.count));
778
+ const ids = Array.from({ length: count }, () => randomUUID());
779
+ const stamps = batchStamps(count);
780
+ const batchId = count > 1 ? ids[0] : null;
781
+ const insert = db.prepare(
782
+ "INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, created_at, batch_id, batch_index) VALUES (?,?,?,?,?,?,?,?,?)"
783
+ );
784
+ db.transaction(() => {
785
+ for (let i = 0; i < count; i++) {
786
+ insert.run(
787
+ ids[i],
788
+ input.projectId,
789
+ input.parentId,
790
+ input.kind,
791
+ input.prompt,
792
+ input.engineId,
793
+ stamps[i],
794
+ batchId,
795
+ i
796
+ );
797
+ }
798
+ })();
799
+ return ids.map((id) => this.getNode(id));
800
+ },
651
801
  completeNode(id, result) {
652
802
  db.prepare("UPDATE nodes SET status='done', images=?, cost_usd=?, duration_ms=? WHERE id=?").run(
653
803
  JSON.stringify(result.images),
@@ -1454,7 +1604,17 @@ function createOpenRouterEngine(opts) {
1454
1604
  localOnly: false,
1455
1605
  supportsEdit: true,
1456
1606
  supportsMask: false,
1607
+ // Four is OUR conservative constant, not a provider fact: OpenRouter
1608
+ // multiplexes many image models and their input limits differ, so
1609
+ // there is no single upstream number to cite the way codex's five is
1610
+ // cited. Four keeps a full identity payload (product essential +
1611
+ // angle, presenter, mark) inside every model we have routed to.
1457
1612
  maxReferenceImages: 4,
1613
+ // Same uplink argument as codex: the provider reads references at
1614
+ // reduced resolution anyway, and these ride base64-inlined inside a
1615
+ // JSON body — a full-resolution phone photo is tens of megabytes of
1616
+ // request for nothing.
1617
+ maxReferenceEdge: 2048,
1458
1618
  // N sequential calls, one image each: the server budgets the node by
1459
1619
  // that shape instead of handing the whole run one flat ten minutes.
1460
1620
  perImageTimeoutMs: PER_IMAGE_TIMEOUT_MS,
@@ -2037,6 +2197,36 @@ function execArgs(dir, effort = "low") {
2037
2197
  "-"
2038
2198
  ];
2039
2199
  }
2200
+ var TAIL_BYTES = 4096;
2201
+ var DETAIL_CHARS = 800;
2202
+ function keepTail(buf, chunk) {
2203
+ const next = buf + chunk;
2204
+ return next.length > TAIL_BYTES ? next.slice(next.length - TAIL_BYTES) : next;
2205
+ }
2206
+ var BANNER_RULE = /^-{3,}\s*$/;
2207
+ var BANNER_LINE = /^(OpenAI Codex v|workdir:|model:|provider:|approval:|sandbox:|reasoning |-{3,}\s*$|\s*$)/;
2208
+ function afterBanner(stderr) {
2209
+ const lines = stderr.split(/\r?\n/);
2210
+ const rules = [];
2211
+ for (const [i2, line] of lines.entries()) if (BANNER_RULE.test(line)) rules.push(i2);
2212
+ if (rules.length >= 2)
2213
+ return lines.slice(rules[1] + 1).join("\n").trim();
2214
+ let i = 0;
2215
+ while (i < lines.length && BANNER_LINE.test(lines[i])) i++;
2216
+ return lines.slice(i).join("\n").trim();
2217
+ }
2218
+ function tailOf(text) {
2219
+ if (text.length <= DETAIL_CHARS) return text;
2220
+ const cut = text.slice(text.length - DETAIL_CHARS);
2221
+ const nl = cut.indexOf("\n");
2222
+ return (nl >= 0 ? cut.slice(nl + 1) : cut).trim();
2223
+ }
2224
+ function codexFailureDetail(stderr, stdout) {
2225
+ const body = afterBanner(stderr);
2226
+ const errorAt = body.lastIndexOf("\nERROR:");
2227
+ const marked = body.startsWith("ERROR:") ? body : errorAt >= 0 ? body.slice(errorAt + 1) : "";
2228
+ return tailOf(marked) || tailOf(body) || tailOf(stdout.trim()) || tailOf(stderr.trim());
2229
+ }
2040
2230
  function killTree(child, platform, spawnImpl) {
2041
2231
  if (platform === "win32" && child.pid) {
2042
2232
  try {
@@ -2098,6 +2288,7 @@ function createRunner(opts = {}) {
2098
2288
  }
2099
2289
  let settled = false;
2100
2290
  let stderr = "";
2291
+ let stdout = "";
2101
2292
  const spawnedAt = Date.now();
2102
2293
  let firstByteAt = 0;
2103
2294
  let lastByteAt = 0;
@@ -2124,9 +2315,12 @@ function createRunner(opts = {}) {
2124
2315
  }
2125
2316
  lastByteAt = now;
2126
2317
  }
2127
- child.stdout?.on("data", sawActivity);
2318
+ child.stdout?.on("data", (d) => {
2319
+ stdout = keepTail(stdout, String(d));
2320
+ sawActivity();
2321
+ });
2128
2322
  child.stderr?.on("data", (d) => {
2129
- stderr += String(d);
2323
+ stderr = keepTail(stderr, String(d));
2130
2324
  sawActivity();
2131
2325
  });
2132
2326
  if (io?.stdin != null) {
@@ -2183,7 +2377,7 @@ function createRunner(opts = {}) {
2183
2377
  );
2184
2378
  return;
2185
2379
  }
2186
- const snippet2 = stderr.trim().slice(0, 200);
2380
+ const snippet2 = codexFailureDetail(stderr, stdout);
2187
2381
  finish(
2188
2382
  `exit-${code ?? "unknown"}`,
2189
2383
  () => reject(new Error(`codex exited with code ${code ?? "unknown"}${snippet2 ? `: ${snippet2}` : ""}`))
@@ -3208,9 +3402,9 @@ var ROLE_PRIORITY = {
3208
3402
  product: 0,
3209
3403
  character: 1,
3210
3404
  brand: 2,
3211
- scene: 3,
3212
- composition: 4,
3213
- reference: 5,
3405
+ reference: 3,
3406
+ scene: 4,
3407
+ composition: 5,
3214
3408
  style: 6
3215
3409
  };
3216
3410
  function allocateAttachments(attachments, cap2) {
@@ -3560,6 +3754,7 @@ function validateBrief(brief) {
3560
3754
  function compileBrief(brief, ctx) {
3561
3755
  const warnings = [];
3562
3756
  const attachments = [];
3757
+ const unattachable = [];
3563
3758
  const rawSceneFallback = [];
3564
3759
  const productDirectives = [];
3565
3760
  const personDirectives = [];
@@ -3611,7 +3806,7 @@ function compileBrief(brief, ctx) {
3611
3806
  ...angle ? { angle } : {}
3612
3807
  });
3613
3808
  });
3614
- productDirectives.push(productFidelityDirective(pshots.length));
3809
+ productDirectives.push({ need: "fidelity", id: p.id });
3615
3810
  productDirectives.push(...productFactDirectives(p));
3616
3811
  if (p.description && !p.dimensions)
3617
3812
  productDirectives.push(
@@ -3619,6 +3814,7 @@ function compileBrief(brief, ctx) {
3619
3814
  );
3620
3815
  } else {
3621
3816
  warnings.push(`${p.name} has no usable photo, so it is named but not attached.`);
3817
+ unattachable.push({ role: "product", id: p.id, label: p.name, hash: "", essential: true, reason: "missing" });
3622
3818
  }
3623
3819
  break;
3624
3820
  }
@@ -3660,6 +3856,14 @@ function compileBrief(brief, ctx) {
3660
3856
  if (c.build) personDirectives.push(`${c.promptName ?? c.name}'s build: ${c.build}.`);
3661
3857
  } else {
3662
3858
  warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
3859
+ unattachable.push({
3860
+ role: "character",
3861
+ id: c.id,
3862
+ label: c.name,
3863
+ hash: "",
3864
+ essential: true,
3865
+ reason: "missing"
3866
+ });
3663
3867
  }
3664
3868
  break;
3665
3869
  }
@@ -3679,7 +3883,12 @@ function compileBrief(brief, ctx) {
3679
3883
  break;
3680
3884
  }
3681
3885
  attachments.push({ role: "reference", label: "Reference shot", hash: tok.imageHash });
3682
- otherDirectives.push("Match the composition, lighting and treatment of the attached reference.");
3886
+ otherDirectives.push({
3887
+ need: "attachment",
3888
+ role: "reference",
3889
+ hash: tok.imageHash,
3890
+ text: "Match the composition, lighting and treatment of the attached reference."
3891
+ });
3683
3892
  break;
3684
3893
  }
3685
3894
  case "mark": {
@@ -3699,9 +3908,12 @@ function compileBrief(brief, ctx) {
3699
3908
  );
3700
3909
  } catch {
3701
3910
  }
3702
- otherDirectives.push(
3703
- "The attached brand mark is this brand's own mark. If the direction asks for the logo to appear, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions, never redrawn or re-lettered. Every character it carries appears intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled. Otherwise take only its colour and treatment from it."
3704
- );
3911
+ otherDirectives.push({
3912
+ need: "attachment",
3913
+ role: "brand",
3914
+ hash: tok.imageHash,
3915
+ text: "The attached brand mark is this brand's own mark. If the direction asks for the logo to appear, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions, never redrawn or re-lettered. Every character it carries appears intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled. Otherwise take only its colour and treatment from it."
3916
+ });
3705
3917
  break;
3706
3918
  }
3707
3919
  case "template": {
@@ -3781,8 +3993,32 @@ function compileBrief(brief, ctx) {
3781
3993
  if (i !== -1) attachments.splice(i, 1);
3782
3994
  }
3783
3995
  }
3996
+ const identityHashes = /* @__PURE__ */ new Map();
3997
+ for (const a of attachments)
3998
+ if ((a.role === "product" || a.role === "character") && !identityHashes.has(a.hash))
3999
+ identityHashes.set(a.hash, a.label);
4000
+ for (let i = attachments.length - 1; i >= 0; i--) {
4001
+ const a = attachments[i];
4002
+ if (a.role === "reference" && identityHashes.has(a.hash)) {
4003
+ attachments.splice(i, 1);
4004
+ warnings.push(
4005
+ `That reference is the same image as ${identityHashes.get(a.hash)}'s own photo, so it rides once, as the identity.`
4006
+ );
4007
+ }
4008
+ }
3784
4009
  const max = ctx.engineCaps.maxReferenceImages;
3785
- const { kept, dropped } = allocateAttachments(attachments, max);
4010
+ const { kept, dropped: budgetDropped } = allocateAttachments(attachments, max);
4011
+ const presentKeys = new Set((ctx.presentAttachments ?? kept).map((a) => `${a.role}:${a.hash}`));
4012
+ const resolveDirective = (d) => {
4013
+ if (typeof d === "string") return d;
4014
+ if (d.need === "fidelity") {
4015
+ const n = attachments.filter(
4016
+ (a) => a.role === "product" && a.id === d.id && presentKeys.has(`product:${a.hash}`)
4017
+ ).length;
4018
+ return n > 0 ? productFidelityDirective(n) : null;
4019
+ }
4020
+ return presentKeys.has(`${d.role}:${d.hash}`) ? d.text : null;
4021
+ };
3786
4022
  const guard = scene ? sceneGuardDirectives({
3787
4023
  hasProduct: !!productId,
3788
4024
  hasPerson,
@@ -3804,8 +4040,9 @@ function compileBrief(brief, ctx) {
3804
4040
  treatment: scene.figureTreatment,
3805
4041
  hasPerson,
3806
4042
  // The treatment's fictional-brands rule needs to know a real mark is
3807
- // deliberately in play; attachments are fully collected by this point.
3808
- hasMark: attachments.some((a) => a.role === "brand")
4043
+ // deliberately in play - and only one that actually rides counts,
4044
+ // same honesty rule as the photo guard above.
4045
+ hasMark: [...presentKeys].some((k) => k.startsWith("brand:"))
3809
4046
  }) : [];
3810
4047
  if (hasPerson) personDirectives.push(personSkinDirective());
3811
4048
  const closeUpDirectives = hasPerson && /\bclose[- ]?up\b|\bmacro\b|\bzoom(?:ed)?\b|\bDOF\b|\bdepth of field\b/i.test(sentence) ? [
@@ -3843,23 +4080,26 @@ function compileBrief(brief, ctx) {
3843
4080
  ...refGuard,
3844
4081
  ...preservation
3845
4082
  ];
3846
- if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
3847
- if (dropped.some((d) => d.role !== "scene")) {
4083
+ const spoken = dedupe(allDirectives.map(resolveDirective).filter((s) => s !== null));
4084
+ if (spoken.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${spoken.join(" ")}`;
4085
+ if (budgetDropped.some((d) => d.role !== "scene")) {
3848
4086
  const keptLabels = new Set(kept.map((a) => a.label));
3849
- const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))].filter(
4087
+ const names = [...new Set(budgetDropped.filter((d) => d.role !== "scene").map((d) => d.label))].filter(
3850
4088
  (l) => !keptLabels.has(l)
3851
4089
  );
3852
4090
  if (names.length) {
3853
4091
  const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
3854
4092
  warnings.push(
3855
- `${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
4093
+ `${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out \u2014 ${ctx.engineCaps.displayName} ${reads}.`
3856
4094
  );
3857
4095
  }
3858
4096
  }
3859
4097
  return {
3860
4098
  prompt: prompt.trim(),
3861
4099
  referenceImages: kept.map((a) => ctx.images.pathFor(a.hash)),
3862
- dropped,
4100
+ // The missing-photo identities lead: they are essential, and the refusal
4101
+ // path reads this list. Budget losses carry their reason for the chips.
4102
+ dropped: [...unattachable, ...budgetDropped.map((d) => ({ ...d, reason: "budget" }))],
3863
4103
  width,
3864
4104
  height,
3865
4105
  attachments: kept,
@@ -8726,6 +8966,35 @@ function registerImageRoutes(app, deps) {
8726
8966
 
8727
8967
  // src/release/notes.data.ts
8728
8968
  var RELEASES = [
8969
+ {
8970
+ version: "0.7.5",
8971
+ date: "2026-09-01",
8972
+ title: "Every shot is one card.",
8973
+ sections: [
8974
+ {
8975
+ heading: "Create",
8976
+ body: "Every shot is now its own card with its own image. Asking for several shots gives you that many cards, made together and standing on their own, and older multi-image shots split into separate cards the first time this version opens. The shot panel is rebuilt around the brief itself, with inline ingredient chips and versions in a single strip under the picture."
8977
+ },
8978
+ {
8979
+ heading: "Refining",
8980
+ body: "The shot being refined appears as a regular chip in the composer, and the card it points at is marked in the feed. Scenes sit out of the attach panel while a refine is armed, with a note saying why, instead of quietly trading the refine for a new shot."
8981
+ },
8982
+ {
8983
+ heading: "Fixes",
8984
+ body: "Generation requests carry exactly the reference images they claim to carry, and a product or presenter whose photo is missing stops the shot with a clear message instead of running without it. Cards that are still rendering show a simple counter and a cancel button."
8985
+ }
8986
+ ]
8987
+ },
8988
+ {
8989
+ version: "0.7.4",
8990
+ date: "2026-08-31",
8991
+ sections: [
8992
+ {
8993
+ heading: "Fixes",
8994
+ body: "A shot that Codex could not finish now says why it failed. Failures used to quote the banner Codex prints as it starts, which names the working folder and the model but never the reason. A Windows machine that cannot start the Codex tool host is now told which setting to change."
8995
+ }
8996
+ ]
8997
+ },
8729
8998
  {
8730
8999
  version: "0.7.3",
8731
9000
  date: "2026-08-31",
@@ -9856,7 +10125,7 @@ function buildServer(opts) {
9856
10125
  }
9857
10126
  }
9858
10127
  if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
9859
- const compiled2 = compileBrief(brief, {
10128
+ const compileCtx = {
9860
10129
  brand: brandJson,
9861
10130
  images: core.images,
9862
10131
  engineCaps: uncapped,
@@ -9874,7 +10143,8 @@ function buildServer(opts) {
9874
10143
  // Only the explicit op drops the dimension promise: an implicit legacy
9875
10144
  // expansion keeps its historical prompt byte for byte.
9876
10145
  ...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
9877
- });
10146
+ };
10147
+ const compiled2 = compileBrief(brief, compileCtx);
9878
10148
  let inheritedAttachments = [];
9879
10149
  let identityWarnings = [];
9880
10150
  if (inheritedTokens.length) {
@@ -9895,27 +10165,22 @@ function buildServer(opts) {
9895
10165
  }
9896
10166
  const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
9897
10167
  const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
10168
+ const prompt = compileBrief(brief, { ...compileCtx, presentAttachments: merged.kept }).prompt;
9898
10169
  const warnings = [...compiled2.warnings, ...identityWarnings.filter((w) => !compiled2.warnings.includes(w))];
9899
10170
  if (inherited.truncated)
9900
10171
  warnings.push("This thread is deeper than 64 steps, so identity attached before that could not be carried.");
9901
10172
  if (merged.dropped.length) {
9902
10173
  if (engineCaps.maxReferenceImages <= 1) {
9903
- warnings.push(
9904
- `${engineCaps.displayName} cannot carry reference images, so the identity rides on the source frame alone.`
9905
- );
10174
+ warnings.push(`The identity rides on the shot itself \u2014 ${engineCaps.displayName} reads no other images.`);
9906
10175
  } else {
9907
10176
  const keptLabels = new Set(merged.kept.map((a) => a.label));
9908
10177
  const names = [...new Set(merged.dropped.map((d) => d.label))].filter((l) => !keptLabels.has(l));
9909
10178
  if (names.length)
9910
- warnings.push(
9911
- `${engineCaps.displayName} reads ${engineCaps.maxReferenceImages} reference images and the frame being refined keeps one, so ${names.join(
9912
- " and "
9913
- )} ${names.length === 1 ? "was" : "were"} left out.`
9914
- );
10179
+ warnings.push(`${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out of this refinement.`);
9915
10180
  }
9916
10181
  }
9917
10182
  return {
9918
- compiled: compiled2,
10183
+ compiled: { ...compiled2, prompt },
9919
10184
  inheritedTokens,
9920
10185
  merged,
9921
10186
  warnings,
@@ -9942,7 +10207,9 @@ function buildServer(opts) {
9942
10207
  return {
9943
10208
  ...rest2,
9944
10209
  attachments: edit.merged.kept,
9945
- dropped: edit.merged.dropped,
10210
+ // The own compile runs uncapped, so its dropped list holds exactly the
10211
+ // missing-photo identities; the budget losses live on the merge.
10212
+ dropped: [...edit.compiled.dropped, ...edit.merged.dropped],
9946
10213
  warnings: edit.warnings,
9947
10214
  referenceCount: edit.merged.kept.length
9948
10215
  };
@@ -10097,11 +10364,11 @@ function buildServer(opts) {
10097
10364
  }
10098
10365
  }
10099
10366
  const NODE_TIMEOUT_MS = 6e5;
10100
- async function runNode(nodeId, engine, estimate, work, expect, post, timeoutMs) {
10367
+ async function runNode(nodeIds, engine, estimate, work, expect, postFor, timeoutMs) {
10101
10368
  const engineId = engine?.capabilities().id ?? "local";
10102
10369
  reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
10103
10370
  const ctrl = new AbortController();
10104
- runningGenerations.set(nodeId, ctrl);
10371
+ for (const id of nodeIds) runningGenerations.set(id, ctrl);
10105
10372
  const bound = opts.nodeTimeoutMs ?? timeoutMs ?? NODE_TIMEOUT_MS;
10106
10373
  let watchdogFired = false;
10107
10374
  const watchdog = setTimeout(() => {
@@ -10109,43 +10376,68 @@ function buildServer(opts) {
10109
10376
  ctrl.abort(BUDGET_EXHAUSTED);
10110
10377
  }, bound);
10111
10378
  const startedAt = Date.now();
10379
+ const settled = /* @__PURE__ */ new Set();
10112
10380
  try {
10113
10381
  const result = await work(ctrl.signal);
10114
10382
  clearTimeout(watchdog);
10115
- result.images = await normalizePngs(result.images);
10116
- if (post) result.images = await post(result.images);
10117
- if (expect) await assertAspect(result.images, expect);
10118
- try {
10119
- const sizes = [];
10120
- for (const h of result.images) {
10121
- const meta2 = await sharp20(core.images.read(h)).metadata();
10122
- if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
10383
+ const images = await normalizePngs(result.images);
10384
+ const raw = result.raw;
10385
+ const bySlot = new Array(nodeIds.length);
10386
+ images.forEach((h, k) => {
10387
+ const slot = raw?.variantIndexes?.[k] ?? k;
10388
+ if (slot < nodeIds.length && bySlot[slot] === void 0) bySlot[slot] = h;
10389
+ });
10390
+ const wall = Date.now() - startedAt;
10391
+ const failures = [...raw?.partialFailures ?? []];
10392
+ for (let slot = 0; slot < nodeIds.length; slot++) {
10393
+ const id = nodeIds[slot];
10394
+ const hash = bySlot[slot];
10395
+ if (hash === void 0) {
10396
+ core.store.failNode(id, failures.shift() ?? "the engine returned no image for this shot");
10397
+ settled.add(id);
10398
+ continue;
10123
10399
  }
10124
- const node = core.store.getNode(nodeId);
10125
- if (node && sizes.length) {
10126
- const brief = node.brief ?? {};
10127
- const raw = result.raw;
10128
- const survivors = typeof raw?.requested === "number" && Array.isArray(raw.variantIndexes) ? { requested: raw.requested, variantIndexes: raw.variantIndexes } : {};
10129
- const asked = expect ? { requestedSize: [expect.width, expect.height] } : {};
10130
- core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors, ...asked } });
10131
- if (node.kind === "generation" && engineId === "codex-cli" && expect && expect.width !== expect.height && sizes.some(([w, h]) => w === expect.width && h === expect.height))
10132
- app.log.warn(
10133
- { nodeId },
10134
- "codex delivered exactly the requested pixels; its image tool cannot pin size - suggests a forbidden shell resize"
10135
- );
10400
+ try {
10401
+ let own = [hash];
10402
+ const post = postFor?.(id);
10403
+ if (post) own = await post(own);
10404
+ if (expect) await assertAspect(own, expect);
10405
+ try {
10406
+ const meta2 = await sharp20(core.images.read(own[0])).metadata();
10407
+ const node = core.store.getNode(id);
10408
+ if (node && meta2.width && meta2.height) {
10409
+ const brief = node.brief ?? {};
10410
+ const asked = expect ? { requestedSize: [expect.width, expect.height] } : {};
10411
+ core.store.setBrief(id, { ...brief, rendered: { sizes: [[meta2.width, meta2.height]], ...asked } });
10412
+ if (node.kind === "generation" && engineId === "codex-cli" && expect && expect.width !== expect.height && meta2.width === expect.width && meta2.height === expect.height)
10413
+ app.log.warn(
10414
+ { nodeId: id },
10415
+ "codex delivered exactly the requested pixels; its image tool cannot pin size - suggests a forbidden shell resize"
10416
+ );
10417
+ }
10418
+ } catch {
10419
+ }
10420
+ core.store.completeNode(id, {
10421
+ images: own,
10422
+ costUsd: slot === 0 ? result.costUsd : 0,
10423
+ durationMs: wall
10424
+ });
10425
+ } catch (err) {
10426
+ core.store.failNode(id, String(err?.message ?? err));
10136
10427
  }
10137
- } catch {
10428
+ settled.add(id);
10138
10429
  }
10139
- core.store.completeNode(nodeId, { ...result, durationMs: Date.now() - startedAt });
10140
- core.ledger.recordCost(engineId, nodeId, result.costUsd);
10430
+ core.ledger.recordCost(engineId, nodeIds[0], result.costUsd);
10141
10431
  } catch (err) {
10142
- if (watchdogFired)
10143
- core.store.failNode(nodeId, `generation timed out after ${Math.round(bound / 6e4)} minutes`);
10144
- else if (ctrl.signal.aborted) core.store.cancelNode(nodeId);
10145
- else core.store.failNode(nodeId, String(err?.message ?? err));
10432
+ for (const id of nodeIds) {
10433
+ if (settled.has(id)) continue;
10434
+ if (watchdogFired) core.store.failNode(id, `generation timed out after ${Math.round(bound / 6e4)} minutes`);
10435
+ else if (ctrl.signal.aborted) core.store.cancelNode(id);
10436
+ else core.store.failNode(id, String(err?.message ?? err));
10437
+ }
10146
10438
  } finally {
10147
10439
  clearTimeout(watchdog);
10148
- runningGenerations.delete(nodeId);
10440
+ for (const id of nodeIds) runningGenerations.delete(id);
10149
10441
  const left = (reserved.get(engineId) ?? 0) - estimate;
10150
10442
  if (left > 1e-9) reserved.set(engineId, left);
10151
10443
  else reserved.delete(engineId);
@@ -10194,7 +10486,7 @@ function buildServer(opts) {
10194
10486
  images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
10195
10487
  costUsd: 0
10196
10488
  });
10197
- void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
10489
+ void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
10198
10490
  (err) => app.log.error({ err }, "crop run failed")
10199
10491
  );
10200
10492
  return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
@@ -10351,6 +10643,14 @@ function buildServer(opts) {
10351
10643
  const wantedCount = Math.min(Math.max(1, Number(count)), 8);
10352
10644
  const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
10353
10645
  if (lostIdentity.length) {
10646
+ const missing = lostIdentity.filter((d) => d.reason === "missing");
10647
+ if (missing.length) {
10648
+ const names2 = joinNames(missing.map((d) => d.label));
10649
+ const kindWord2 = missing[0].role === "product" ? "product" : "presenter";
10650
+ return reply.code(400).send({
10651
+ error: `${names2} ${missing.length === 1 ? "has" : "have"} no usable photo, so the result would not be your ${kindWord2}. Re-add ${missing.length === 1 ? "its" : "their"} photo, or remove ${names2} from the brief.`
10652
+ });
10653
+ }
10354
10654
  const names = joinNames(lostIdentity.map((d) => d.label));
10355
10655
  const kindWord = lostIdentity[0].role === "product" ? "product" : "presenter";
10356
10656
  return reply.code(400).send({
@@ -10361,6 +10661,19 @@ function buildServer(opts) {
10361
10661
  const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
10362
10662
  const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
10363
10663
  const sentRoles = referenceRoles && cap2 > 0 ? referenceRoles.slice(0, cap2) : referenceRoles ?? [];
10664
+ if (process.env.SCENRI_DEBUG) {
10665
+ const sent = {};
10666
+ for (const r of sentRoles) sent[r] = (sent[r] ?? 0) + 1;
10667
+ app.log.info(
10668
+ {
10669
+ engine: engine.capabilities().id,
10670
+ cap: cap2,
10671
+ sent,
10672
+ dropped: (compiled2?.dropped ?? []).map((d) => `${d.role}:${d.label} (${d.reason ?? "budget"})`)
10673
+ },
10674
+ "reference transport"
10675
+ );
10676
+ }
10364
10677
  const briefText = Array.isArray(brief?.tokens) ? brief.tokens.filter((t) => t?.t === "text").map((t) => String(t?.v ?? "")).join(" ") : String(prompt ?? "");
10365
10678
  const variations = variationPlan(wantedCount, {
10366
10679
  hasPresenter: sentRoles.includes("character"),
@@ -10392,6 +10705,20 @@ function buildServer(opts) {
10392
10705
  const editRefs = mergedEdit ? mergedEdit.kept.map((a) => ({ path: core.images.pathFor(a.hash), role: a.role })) : (referenceImages ?? []).map((path, i) => ({ path, role: referenceRoles?.[i] })).slice(0, Math.max(0, engine.capabilities().maxReferenceImages - 1));
10393
10706
  const editEdge = engine.capabilities().maxReferenceEdge;
10394
10707
  if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
10708
+ if (process.env.SCENRI_DEBUG) {
10709
+ const sent = {};
10710
+ for (const r of editRefs) sent[String(r.role ?? "reference")] = (sent[String(r.role ?? "reference")] ?? 0) + 1;
10711
+ app.log.info(
10712
+ {
10713
+ engine: engine.capabilities().id,
10714
+ cap: Math.max(0, engine.capabilities().maxReferenceImages - 1),
10715
+ sourceFrame: true,
10716
+ sent,
10717
+ dropped: (mergedEdit?.dropped ?? []).map((d) => `${d.role}:${d.label} (${d.reason ?? "budget"})`)
10718
+ },
10719
+ "reference transport"
10720
+ );
10721
+ }
10395
10722
  const srcBuf = core.images.read(String(srcHash));
10396
10723
  const srcMeta = await sharp20(srcBuf).metadata();
10397
10724
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
@@ -10559,15 +10886,26 @@ function buildServer(opts) {
10559
10886
  }
10560
10887
  const billedId = runEngine.capabilities().id;
10561
10888
  core.ledger.assertUnderCap(billedId, estimate + (reserved.get(billedId) ?? 0));
10562
- const node = core.store.addNode({
10889
+ const nodes = kind === "generation" ? core.store.addNodes({
10563
10890
  projectId: project.id,
10564
10891
  parentId: resolvedParentId,
10565
10892
  kind,
10566
10893
  prompt: finalPrompt,
10567
- engineId: billedId
10568
- });
10569
- if (brief)
10570
- core.store.setBrief(node.id, {
10894
+ engineId: billedId,
10895
+ // the same clamp the engine request and the watchdog use
10896
+ count: Math.min(Math.max(1, Number(count)), 8)
10897
+ }) : [
10898
+ core.store.addNode({
10899
+ projectId: project.id,
10900
+ parentId: resolvedParentId,
10901
+ kind,
10902
+ prompt: finalPrompt,
10903
+ engineId: billedId
10904
+ })
10905
+ ];
10906
+ const node = nodes[0];
10907
+ for (const sibling of brief ? nodes : [])
10908
+ core.store.setBrief(sibling.id, {
10571
10909
  ...briefInputsOnly(brief),
10572
10910
  ...editedFrom ? { sourceImage: editedFrom } : {},
10573
10911
  ...kind === "edit" && reshape ? { reshape } : {},
@@ -10714,14 +11052,23 @@ function buildServer(opts) {
10714
11052
  staged = await conformToCanvas(node.id, expectShape)(staged);
10715
11053
  }
10716
11054
  return enforceEditCanvas(staged);
10717
- } : kind === "generation" && compiled2?.width && compiled2?.height ? conformToCanvas(node.id, { width: compiled2.width, height: compiled2.height }) : void 0;
11055
+ } : void 0;
11056
+ const genW = compiled2?.width;
11057
+ const genH = compiled2?.height;
11058
+ const postFor = post !== void 0 ? () => post : kind === "generation" && genW && genH ? (id) => conformToCanvas(id, { width: genW, height: genH }) : void 0;
10718
11059
  const runCaps = runEngine.capabilities();
10719
11060
  const nodeBudgetMs = kind === "generation" && runCaps.perImageTimeoutMs ? Math.ceil(Math.min(Math.max(1, Number(count)), 8) / Math.max(1, runCaps.imageConcurrency ?? 1)) * runCaps.perImageTimeoutMs + 6e4 : void 0;
10720
- void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
10721
- (err) => app.log.error({ err }, "node run failed")
10722
- );
11061
+ void runNode(
11062
+ nodes.map((n) => n.id),
11063
+ runEngine,
11064
+ estimate,
11065
+ work,
11066
+ expectShape,
11067
+ postFor,
11068
+ nodeBudgetMs
11069
+ ).catch((err) => app.log.error({ err }, "node run failed"));
10723
11070
  const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
10724
- return reply.status(202).send(allWarnings.length ? { ...node, warnings: allWarnings } : node);
11071
+ return reply.status(202).send({ ...node, siblings: nodes, ...allWarnings.length ? { warnings: allWarnings } : {} });
10725
11072
  });
10726
11073
  app.post("/api/nodes/:id/cancel", async (req, reply) => {
10727
11074
  const id = req.params.id;
@@ -10794,7 +11141,10 @@ function buildServer(opts) {
10794
11141
  runtime,
10795
11142
  stageImpl: opts.stageImpl,
10796
11143
  exitImpl: opts.exitImpl,
10797
- busyCount: () => runningGenerations.size + runningImportCount() + runningAssetBuildCount()
11144
+ // one physical run counts once, however many sibling nodes share its
11145
+ // controller — an update gate held open by a 4-shot batch is still held
11146
+ // open by exactly one piece of work
11147
+ busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
10798
11148
  });
10799
11149
  let drained = null;
10800
11150
  app.decorate("drain", () => {