scenri 0.7.4 → 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,
@@ -3242,9 +3402,9 @@ var ROLE_PRIORITY = {
3242
3402
  product: 0,
3243
3403
  character: 1,
3244
3404
  brand: 2,
3245
- scene: 3,
3246
- composition: 4,
3247
- reference: 5,
3405
+ reference: 3,
3406
+ scene: 4,
3407
+ composition: 5,
3248
3408
  style: 6
3249
3409
  };
3250
3410
  function allocateAttachments(attachments, cap2) {
@@ -3594,6 +3754,7 @@ function validateBrief(brief) {
3594
3754
  function compileBrief(brief, ctx) {
3595
3755
  const warnings = [];
3596
3756
  const attachments = [];
3757
+ const unattachable = [];
3597
3758
  const rawSceneFallback = [];
3598
3759
  const productDirectives = [];
3599
3760
  const personDirectives = [];
@@ -3645,7 +3806,7 @@ function compileBrief(brief, ctx) {
3645
3806
  ...angle ? { angle } : {}
3646
3807
  });
3647
3808
  });
3648
- productDirectives.push(productFidelityDirective(pshots.length));
3809
+ productDirectives.push({ need: "fidelity", id: p.id });
3649
3810
  productDirectives.push(...productFactDirectives(p));
3650
3811
  if (p.description && !p.dimensions)
3651
3812
  productDirectives.push(
@@ -3653,6 +3814,7 @@ function compileBrief(brief, ctx) {
3653
3814
  );
3654
3815
  } else {
3655
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" });
3656
3818
  }
3657
3819
  break;
3658
3820
  }
@@ -3694,6 +3856,14 @@ function compileBrief(brief, ctx) {
3694
3856
  if (c.build) personDirectives.push(`${c.promptName ?? c.name}'s build: ${c.build}.`);
3695
3857
  } else {
3696
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
+ });
3697
3867
  }
3698
3868
  break;
3699
3869
  }
@@ -3713,7 +3883,12 @@ function compileBrief(brief, ctx) {
3713
3883
  break;
3714
3884
  }
3715
3885
  attachments.push({ role: "reference", label: "Reference shot", hash: tok.imageHash });
3716
- 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
+ });
3717
3892
  break;
3718
3893
  }
3719
3894
  case "mark": {
@@ -3733,9 +3908,12 @@ function compileBrief(brief, ctx) {
3733
3908
  );
3734
3909
  } catch {
3735
3910
  }
3736
- otherDirectives.push(
3737
- "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."
3738
- );
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
+ });
3739
3917
  break;
3740
3918
  }
3741
3919
  case "template": {
@@ -3815,8 +3993,32 @@ function compileBrief(brief, ctx) {
3815
3993
  if (i !== -1) attachments.splice(i, 1);
3816
3994
  }
3817
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
+ }
3818
4009
  const max = ctx.engineCaps.maxReferenceImages;
3819
- 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
+ };
3820
4022
  const guard = scene ? sceneGuardDirectives({
3821
4023
  hasProduct: !!productId,
3822
4024
  hasPerson,
@@ -3838,8 +4040,9 @@ function compileBrief(brief, ctx) {
3838
4040
  treatment: scene.figureTreatment,
3839
4041
  hasPerson,
3840
4042
  // The treatment's fictional-brands rule needs to know a real mark is
3841
- // deliberately in play; attachments are fully collected by this point.
3842
- 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:"))
3843
4046
  }) : [];
3844
4047
  if (hasPerson) personDirectives.push(personSkinDirective());
3845
4048
  const closeUpDirectives = hasPerson && /\bclose[- ]?up\b|\bmacro\b|\bzoom(?:ed)?\b|\bDOF\b|\bdepth of field\b/i.test(sentence) ? [
@@ -3877,23 +4080,26 @@ function compileBrief(brief, ctx) {
3877
4080
  ...refGuard,
3878
4081
  ...preservation
3879
4082
  ];
3880
- if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
3881
- 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")) {
3882
4086
  const keptLabels = new Set(kept.map((a) => a.label));
3883
- 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(
3884
4088
  (l) => !keptLabels.has(l)
3885
4089
  );
3886
4090
  if (names.length) {
3887
4091
  const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
3888
4092
  warnings.push(
3889
- `${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}.`
3890
4094
  );
3891
4095
  }
3892
4096
  }
3893
4097
  return {
3894
4098
  prompt: prompt.trim(),
3895
4099
  referenceImages: kept.map((a) => ctx.images.pathFor(a.hash)),
3896
- 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" }))],
3897
4103
  width,
3898
4104
  height,
3899
4105
  attachments: kept,
@@ -8760,6 +8966,25 @@ function registerImageRoutes(app, deps) {
8760
8966
 
8761
8967
  // src/release/notes.data.ts
8762
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
+ },
8763
8988
  {
8764
8989
  version: "0.7.4",
8765
8990
  date: "2026-08-31",
@@ -9900,7 +10125,7 @@ function buildServer(opts) {
9900
10125
  }
9901
10126
  }
9902
10127
  if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
9903
- const compiled2 = compileBrief(brief, {
10128
+ const compileCtx = {
9904
10129
  brand: brandJson,
9905
10130
  images: core.images,
9906
10131
  engineCaps: uncapped,
@@ -9918,7 +10143,8 @@ function buildServer(opts) {
9918
10143
  // Only the explicit op drops the dimension promise: an implicit legacy
9919
10144
  // expansion keeps its historical prompt byte for byte.
9920
10145
  ...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
9921
- });
10146
+ };
10147
+ const compiled2 = compileBrief(brief, compileCtx);
9922
10148
  let inheritedAttachments = [];
9923
10149
  let identityWarnings = [];
9924
10150
  if (inheritedTokens.length) {
@@ -9939,27 +10165,22 @@ function buildServer(opts) {
9939
10165
  }
9940
10166
  const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
9941
10167
  const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
10168
+ const prompt = compileBrief(brief, { ...compileCtx, presentAttachments: merged.kept }).prompt;
9942
10169
  const warnings = [...compiled2.warnings, ...identityWarnings.filter((w) => !compiled2.warnings.includes(w))];
9943
10170
  if (inherited.truncated)
9944
10171
  warnings.push("This thread is deeper than 64 steps, so identity attached before that could not be carried.");
9945
10172
  if (merged.dropped.length) {
9946
10173
  if (engineCaps.maxReferenceImages <= 1) {
9947
- warnings.push(
9948
- `${engineCaps.displayName} cannot carry reference images, so the identity rides on the source frame alone.`
9949
- );
10174
+ warnings.push(`The identity rides on the shot itself \u2014 ${engineCaps.displayName} reads no other images.`);
9950
10175
  } else {
9951
10176
  const keptLabels = new Set(merged.kept.map((a) => a.label));
9952
10177
  const names = [...new Set(merged.dropped.map((d) => d.label))].filter((l) => !keptLabels.has(l));
9953
10178
  if (names.length)
9954
- warnings.push(
9955
- `${engineCaps.displayName} reads ${engineCaps.maxReferenceImages} reference images and the frame being refined keeps one, so ${names.join(
9956
- " and "
9957
- )} ${names.length === 1 ? "was" : "were"} left out.`
9958
- );
10179
+ warnings.push(`${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out of this refinement.`);
9959
10180
  }
9960
10181
  }
9961
10182
  return {
9962
- compiled: compiled2,
10183
+ compiled: { ...compiled2, prompt },
9963
10184
  inheritedTokens,
9964
10185
  merged,
9965
10186
  warnings,
@@ -9986,7 +10207,9 @@ function buildServer(opts) {
9986
10207
  return {
9987
10208
  ...rest2,
9988
10209
  attachments: edit.merged.kept,
9989
- 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],
9990
10213
  warnings: edit.warnings,
9991
10214
  referenceCount: edit.merged.kept.length
9992
10215
  };
@@ -10141,11 +10364,11 @@ function buildServer(opts) {
10141
10364
  }
10142
10365
  }
10143
10366
  const NODE_TIMEOUT_MS = 6e5;
10144
- async function runNode(nodeId, engine, estimate, work, expect, post, timeoutMs) {
10367
+ async function runNode(nodeIds, engine, estimate, work, expect, postFor, timeoutMs) {
10145
10368
  const engineId = engine?.capabilities().id ?? "local";
10146
10369
  reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
10147
10370
  const ctrl = new AbortController();
10148
- runningGenerations.set(nodeId, ctrl);
10371
+ for (const id of nodeIds) runningGenerations.set(id, ctrl);
10149
10372
  const bound = opts.nodeTimeoutMs ?? timeoutMs ?? NODE_TIMEOUT_MS;
10150
10373
  let watchdogFired = false;
10151
10374
  const watchdog = setTimeout(() => {
@@ -10153,43 +10376,68 @@ function buildServer(opts) {
10153
10376
  ctrl.abort(BUDGET_EXHAUSTED);
10154
10377
  }, bound);
10155
10378
  const startedAt = Date.now();
10379
+ const settled = /* @__PURE__ */ new Set();
10156
10380
  try {
10157
10381
  const result = await work(ctrl.signal);
10158
10382
  clearTimeout(watchdog);
10159
- result.images = await normalizePngs(result.images);
10160
- if (post) result.images = await post(result.images);
10161
- if (expect) await assertAspect(result.images, expect);
10162
- try {
10163
- const sizes = [];
10164
- for (const h of result.images) {
10165
- const meta2 = await sharp20(core.images.read(h)).metadata();
10166
- 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;
10167
10399
  }
10168
- const node = core.store.getNode(nodeId);
10169
- if (node && sizes.length) {
10170
- const brief = node.brief ?? {};
10171
- const raw = result.raw;
10172
- const survivors = typeof raw?.requested === "number" && Array.isArray(raw.variantIndexes) ? { requested: raw.requested, variantIndexes: raw.variantIndexes } : {};
10173
- const asked = expect ? { requestedSize: [expect.width, expect.height] } : {};
10174
- core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors, ...asked } });
10175
- if (node.kind === "generation" && engineId === "codex-cli" && expect && expect.width !== expect.height && sizes.some(([w, h]) => w === expect.width && h === expect.height))
10176
- app.log.warn(
10177
- { nodeId },
10178
- "codex delivered exactly the requested pixels; its image tool cannot pin size - suggests a forbidden shell resize"
10179
- );
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));
10180
10427
  }
10181
- } catch {
10428
+ settled.add(id);
10182
10429
  }
10183
- core.store.completeNode(nodeId, { ...result, durationMs: Date.now() - startedAt });
10184
- core.ledger.recordCost(engineId, nodeId, result.costUsd);
10430
+ core.ledger.recordCost(engineId, nodeIds[0], result.costUsd);
10185
10431
  } catch (err) {
10186
- if (watchdogFired)
10187
- core.store.failNode(nodeId, `generation timed out after ${Math.round(bound / 6e4)} minutes`);
10188
- else if (ctrl.signal.aborted) core.store.cancelNode(nodeId);
10189
- 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
+ }
10190
10438
  } finally {
10191
10439
  clearTimeout(watchdog);
10192
- runningGenerations.delete(nodeId);
10440
+ for (const id of nodeIds) runningGenerations.delete(id);
10193
10441
  const left = (reserved.get(engineId) ?? 0) - estimate;
10194
10442
  if (left > 1e-9) reserved.set(engineId, left);
10195
10443
  else reserved.delete(engineId);
@@ -10238,7 +10486,7 @@ function buildServer(opts) {
10238
10486
  images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
10239
10487
  costUsd: 0
10240
10488
  });
10241
- 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(
10242
10490
  (err) => app.log.error({ err }, "crop run failed")
10243
10491
  );
10244
10492
  return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
@@ -10395,6 +10643,14 @@ function buildServer(opts) {
10395
10643
  const wantedCount = Math.min(Math.max(1, Number(count)), 8);
10396
10644
  const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
10397
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
+ }
10398
10654
  const names = joinNames(lostIdentity.map((d) => d.label));
10399
10655
  const kindWord = lostIdentity[0].role === "product" ? "product" : "presenter";
10400
10656
  return reply.code(400).send({
@@ -10405,6 +10661,19 @@ function buildServer(opts) {
10405
10661
  const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
10406
10662
  const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
10407
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
+ }
10408
10677
  const briefText = Array.isArray(brief?.tokens) ? brief.tokens.filter((t) => t?.t === "text").map((t) => String(t?.v ?? "")).join(" ") : String(prompt ?? "");
10409
10678
  const variations = variationPlan(wantedCount, {
10410
10679
  hasPresenter: sentRoles.includes("character"),
@@ -10436,6 +10705,20 @@ function buildServer(opts) {
10436
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));
10437
10706
  const editEdge = engine.capabilities().maxReferenceEdge;
10438
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
+ }
10439
10722
  const srcBuf = core.images.read(String(srcHash));
10440
10723
  const srcMeta = await sharp20(srcBuf).metadata();
10441
10724
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
@@ -10603,15 +10886,26 @@ function buildServer(opts) {
10603
10886
  }
10604
10887
  const billedId = runEngine.capabilities().id;
10605
10888
  core.ledger.assertUnderCap(billedId, estimate + (reserved.get(billedId) ?? 0));
10606
- const node = core.store.addNode({
10889
+ const nodes = kind === "generation" ? core.store.addNodes({
10607
10890
  projectId: project.id,
10608
10891
  parentId: resolvedParentId,
10609
10892
  kind,
10610
10893
  prompt: finalPrompt,
10611
- engineId: billedId
10612
- });
10613
- if (brief)
10614
- 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, {
10615
10909
  ...briefInputsOnly(brief),
10616
10910
  ...editedFrom ? { sourceImage: editedFrom } : {},
10617
10911
  ...kind === "edit" && reshape ? { reshape } : {},
@@ -10758,14 +11052,23 @@ function buildServer(opts) {
10758
11052
  staged = await conformToCanvas(node.id, expectShape)(staged);
10759
11053
  }
10760
11054
  return enforceEditCanvas(staged);
10761
- } : 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;
10762
11059
  const runCaps = runEngine.capabilities();
10763
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;
10764
- void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
10765
- (err) => app.log.error({ err }, "node run failed")
10766
- );
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"));
10767
11070
  const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
10768
- return reply.status(202).send(allWarnings.length ? { ...node, warnings: allWarnings } : node);
11071
+ return reply.status(202).send({ ...node, siblings: nodes, ...allWarnings.length ? { warnings: allWarnings } : {} });
10769
11072
  });
10770
11073
  app.post("/api/nodes/:id/cancel", async (req, reply) => {
10771
11074
  const id = req.params.id;
@@ -10838,7 +11141,10 @@ function buildServer(opts) {
10838
11141
  runtime,
10839
11142
  stageImpl: opts.stageImpl,
10840
11143
  exitImpl: opts.exitImpl,
10841
- 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()
10842
11148
  });
10843
11149
  let drained = null;
10844
11150
  app.decorate("drain", () => {