scenri 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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),
@@ -656,6 +806,16 @@ function createStore(db) {
656
806
  id
657
807
  );
658
808
  },
809
+ /**
810
+ * The run's money, written once it is known. A batch's first sibling used
811
+ * to be charged inside completeNode, at the end of the whole call; a
812
+ * sibling now completes the moment its own image lands, and the cost is
813
+ * only known when the call resolves, so it is written afterwards, onto a
814
+ * node that finished. A failed or running node keeps 0, as before.
815
+ */
816
+ chargeNode(id, costUsd) {
817
+ db.prepare("UPDATE nodes SET cost_usd=? WHERE id=? AND status='done'").run(costUsd, id);
818
+ },
659
819
  failNode(id, error) {
660
820
  db.prepare("UPDATE nodes SET status='error', error=? WHERE id=?").run(error, id);
661
821
  },
@@ -1454,7 +1614,17 @@ function createOpenRouterEngine(opts) {
1454
1614
  localOnly: false,
1455
1615
  supportsEdit: true,
1456
1616
  supportsMask: false,
1617
+ // Four is OUR conservative constant, not a provider fact: OpenRouter
1618
+ // multiplexes many image models and their input limits differ, so
1619
+ // there is no single upstream number to cite the way codex's five is
1620
+ // cited. Four keeps a full identity payload (product essential +
1621
+ // angle, presenter, mark) inside every model we have routed to.
1457
1622
  maxReferenceImages: 4,
1623
+ // Same uplink argument as codex: the provider reads references at
1624
+ // reduced resolution anyway, and these ride base64-inlined inside a
1625
+ // JSON body — a full-resolution phone photo is tens of megabytes of
1626
+ // request for nothing.
1627
+ maxReferenceEdge: 2048,
1458
1628
  // N sequential calls, one image each: the server budgets the node by
1459
1629
  // that shape instead of handing the whole run one flat ten minutes.
1460
1630
  perImageTimeoutMs: PER_IMAGE_TIMEOUT_MS,
@@ -1470,7 +1640,7 @@ function createOpenRouterEngine(opts) {
1470
1640
  const count = "count" in req ? req.count : 1;
1471
1641
  return count * perImageUsd;
1472
1642
  },
1473
- async generate(req, signal) {
1643
+ async generate(req, signal, onImage) {
1474
1644
  const key = requireKey();
1475
1645
  const roles = req.referenceRoles ?? [];
1476
1646
  const refs = req.referenceImages ?? [];
@@ -1506,7 +1676,9 @@ function createOpenRouterEngine(opts) {
1506
1676
  signal
1507
1677
  );
1508
1678
  raws.push(json);
1509
- for (const buf of extractImages(json)) hashes.push(opts.saveImage(buf));
1679
+ const own = extractImages(json).map((buf) => opts.saveImage(buf));
1680
+ hashes.push(...own);
1681
+ if (own[0]) onImage?.(i, own[0]);
1510
1682
  if (typeof json?.usage?.cost === "number") {
1511
1683
  reportedCost += json.usage.cost;
1512
1684
  sawReportedCost = true;
@@ -2703,7 +2875,7 @@ function createCodexEngine(opts) {
2703
2875
  async costEstimate() {
2704
2876
  return 0;
2705
2877
  },
2706
- async generate(req, signal) {
2878
+ async generate(req, signal, onImage) {
2707
2879
  const count = Math.max(1, req.count);
2708
2880
  const refs = req.referenceImages ?? [];
2709
2881
  const roles = req.referenceRoles ?? refs.map(() => "reference");
@@ -2742,6 +2914,7 @@ function createCodexEngine(opts) {
2742
2914
  const i = next++;
2743
2915
  try {
2744
2916
  results[i] = await jobs[i]();
2917
+ if (results[i][0]) onImage?.(i, results[i][0]);
2745
2918
  } catch (err) {
2746
2919
  if (signal?.aborted && signal.reason !== BUDGET_EXHAUSTED) throw err;
2747
2920
  results[i] = [];
@@ -2881,7 +3054,30 @@ function createEngineRegistry(core, extra = []) {
2881
3054
  codexRunner
2882
3055
  };
2883
3056
  }
2884
- function createDemoEngine(saveImage) {
3057
+ function demoOptionsFromEnv(env) {
3058
+ const out = {};
3059
+ const stagger = Number(env.SCENRI_DEMO_STAGGER_MS);
3060
+ if (env.SCENRI_DEMO_STAGGER_MS && Number.isFinite(stagger) && stagger > 0) out.staggerMs = stagger;
3061
+ if (env.SCENRI_DEMO_ORDER === "reverse") out.order = "reverse";
3062
+ const fail = Number(env.SCENRI_DEMO_FAIL_SLOT);
3063
+ if (env.SCENRI_DEMO_FAIL_SLOT && Number.isInteger(fail) && fail >= 0) out.failSlot = fail;
3064
+ return out;
3065
+ }
3066
+ function sleep2(ms, signal) {
3067
+ return new Promise((resolve) => {
3068
+ if (signal?.aborted) return resolve();
3069
+ const onAbort = () => {
3070
+ clearTimeout(timer);
3071
+ resolve();
3072
+ };
3073
+ const timer = setTimeout(() => {
3074
+ signal?.removeEventListener("abort", onAbort);
3075
+ resolve();
3076
+ }, ms);
3077
+ signal?.addEventListener("abort", onAbort, { once: true });
3078
+ });
3079
+ }
3080
+ function createDemoEngine(saveImage, opts = {}) {
2885
3081
  const paletteOf = (req) => {
2886
3082
  const p = req.brand?.brand?.palette;
2887
3083
  const hexes = [p?.primary?.hex, p?.secondary?.hex, ...(p?.accent ?? []).map((a) => a?.hex)].filter(
@@ -2927,13 +3123,31 @@ function createDemoEngine(saveImage) {
2927
3123
  async costEstimate() {
2928
3124
  return 0;
2929
3125
  },
2930
- async generate(req) {
3126
+ async generate(req, signal, onImage) {
2931
3127
  const colors = paletteOf(req);
2932
- const images = [];
2933
- for (let i = 0; i < Math.max(1, req.count); i++) {
2934
- images.push(saveImage(await render(colors, req.prompt, req.width, req.height, i + req.prompt.length)));
2935
- }
2936
- return { images, costUsd: 0 };
3128
+ const count = Math.max(1, req.count);
3129
+ const slots = Array.from({ length: count }, (_, i) => i);
3130
+ if (opts.order === "reverse") slots.reverse();
3131
+ const landed = /* @__PURE__ */ new Map();
3132
+ const failures = [];
3133
+ for (const [position, slot] of slots.entries()) {
3134
+ if (position > 0 && opts.staggerMs) await sleep2(opts.staggerMs, signal);
3135
+ if (signal?.aborted) {
3136
+ if (signal.reason === BUDGET_EXHAUSTED) break;
3137
+ throw Object.assign(new Error("generation cancelled"), { name: "AbortError" });
3138
+ }
3139
+ if (slot === opts.failSlot) {
3140
+ failures.push(`demo: slot ${slot + 1} refused`);
3141
+ continue;
3142
+ }
3143
+ const hash = saveImage(await render(colors, req.prompt, req.width, req.height, slot + req.prompt.length));
3144
+ landed.set(slot, hash);
3145
+ onImage?.(slot, hash);
3146
+ }
3147
+ const done = [...landed.keys()].sort((a, b) => a - b);
3148
+ const images = done.map((slot) => landed.get(slot));
3149
+ if (done.length === count) return { images, costUsd: 0 };
3150
+ return { images, costUsd: 0, raw: { requested: count, variantIndexes: done, partialFailures: failures } };
2937
3151
  },
2938
3152
  async edit(req) {
2939
3153
  const colors = paletteOf(req);
@@ -3238,13 +3452,22 @@ function defaultDemoProductsDir() {
3238
3452
  }
3239
3453
 
3240
3454
  // src/attachmentBudget.ts
3455
+ var SEAT_TIER = {
3456
+ brand: 0,
3457
+ reference: 0,
3458
+ product: 0,
3459
+ character: 0,
3460
+ scene: 0,
3461
+ composition: 1,
3462
+ style: 2
3463
+ };
3241
3464
  var ROLE_PRIORITY = {
3242
3465
  product: 0,
3243
3466
  character: 1,
3244
3467
  brand: 2,
3245
- scene: 3,
3246
- composition: 4,
3247
- reference: 5,
3468
+ reference: 3,
3469
+ scene: 4,
3470
+ composition: 5,
3248
3471
  style: 6
3249
3472
  };
3250
3473
  function allocateAttachments(attachments, cap2) {
@@ -3260,11 +3483,8 @@ function allocateAttachments(attachments, cap2) {
3260
3483
  kept.add(x.i);
3261
3484
  keptGroups.add(groupOf(x.a));
3262
3485
  };
3263
- for (const x of legacyOrder) {
3264
- if (kept.size >= max) break;
3265
- if (x.a.essential) admit(x);
3266
- }
3267
- for (const x of legacyOrder) {
3486
+ const seatOrder = [...indexed].sort((x, y) => SEAT_TIER[x.a.role] - SEAT_TIER[y.a.role] || x.i - y.i);
3487
+ for (const x of seatOrder) {
3268
3488
  if (kept.size >= max) break;
3269
3489
  if (!kept.has(x.i) && !keptGroups.has(groupOf(x.a))) admit(x);
3270
3490
  }
@@ -3289,7 +3509,13 @@ function allocateAttachments(attachments, cap2) {
3289
3509
  }
3290
3510
  return {
3291
3511
  kept: legacyOrder.filter((x) => kept.has(x.i)).map((x) => x.a),
3292
- dropped: legacyOrder.filter((x) => !kept.has(x.i)).map((x) => x.a)
3512
+ dropped: legacyOrder.filter((x) => !kept.has(x.i)).map((x) => x.a),
3513
+ // The same images in the order the brief placed them, for a caller that
3514
+ // will allocate again: `kept` is re-sorted by role for the consumers
3515
+ // below, and feeding that back into a second, tighter allocation put
3516
+ // every product ahead of every face on a refinement whatever the line
3517
+ // said.
3518
+ seated: seatOrder.filter((x) => kept.has(x.i)).map((x) => x.a)
3293
3519
  };
3294
3520
  }
3295
3521
  function mergeEditAttachments(own, inherited, cap2) {
@@ -3454,12 +3680,19 @@ function shotSpecifiesCamera(text) {
3454
3680
  text
3455
3681
  );
3456
3682
  }
3683
+ function namesAreNotLetteringDirective() {
3684
+ return "The names in this brief identify what to show and are never text to render: no caption, label, signage, engraving or lettering spells a product name or a person's name, in any language or script, anywhere in the picture. Printing that is part of a product's own packaging stays exactly as photographed, and nothing else spells a name unless the direction above explicitly asks for it to be written.";
3685
+ }
3457
3686
  function sceneFigureDirectives(opts) {
3458
3687
  const figure = opts.figure.trim().replace(/[.\s]+$/, "");
3459
3688
  if (!figure) return [];
3460
3689
  const treatment = (opts.treatment ?? "").trim().replace(/[.\s]+$/, "");
3461
3690
  const out = [];
3462
- if (opts.hasPerson) {
3691
+ if (opts.hasPerson && (opts.people ?? 1) > 1) {
3692
+ out.push(
3693
+ `This world is built around one figure: ${figure}. The attached presenters share that role: the first named presenter takes the figure position and the others stand with them in the same frame, every one of them clearly visible. Any person the scene direction describes IS one of the attached presenters and never an extra person, and each identity comes from their own attached photograph alone, never from anything the scene direction says about a body.`
3694
+ );
3695
+ } else if (opts.hasPerson) {
3463
3696
  out.push(
3464
3697
  `This world is built around one figure: ${figure}. The attached presenter is that figure. Any person the scene direction describes IS the presenter and never a second person, and their identity comes from their own attached photograph alone, never from anything the scene direction says about a body.`
3465
3698
  );
@@ -3580,6 +3813,8 @@ function validateBrief(brief) {
3580
3813
  case "ref":
3581
3814
  case "mark":
3582
3815
  if (!str4(t.imageHash)) errors.push(`${at}.imageHash must be a non-empty string`);
3816
+ if (t.t === "ref" && t.label !== void 0 && typeof t.label !== "string")
3817
+ errors.push(`${at}.label must be a string when present`);
3583
3818
  break;
3584
3819
  case "format":
3585
3820
  if (!Number.isFinite(t.w) || !Number.isFinite(t.h) || Number(t.w) <= 0 || Number(t.h) <= 0)
@@ -3594,6 +3829,7 @@ function validateBrief(brief) {
3594
3829
  function compileBrief(brief, ctx) {
3595
3830
  const warnings = [];
3596
3831
  const attachments = [];
3832
+ const unattachable = [];
3597
3833
  const rawSceneFallback = [];
3598
3834
  const productDirectives = [];
3599
3835
  const personDirectives = [];
@@ -3605,6 +3841,7 @@ function compileBrief(brief, ctx) {
3605
3841
  const characters = ctx.brand?.characters ?? [];
3606
3842
  const inlineTemplates = [];
3607
3843
  let hasPerson = false;
3844
+ let people = 0;
3608
3845
  let sentence = "";
3609
3846
  const append = (s) => {
3610
3847
  sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
@@ -3645,7 +3882,7 @@ function compileBrief(brief, ctx) {
3645
3882
  ...angle ? { angle } : {}
3646
3883
  });
3647
3884
  });
3648
- productDirectives.push(productFidelityDirective(pshots.length));
3885
+ productDirectives.push({ need: "fidelity", id: p.id });
3649
3886
  productDirectives.push(...productFactDirectives(p));
3650
3887
  if (p.description && !p.dimensions)
3651
3888
  productDirectives.push(
@@ -3653,6 +3890,7 @@ function compileBrief(brief, ctx) {
3653
3890
  );
3654
3891
  } else {
3655
3892
  warnings.push(`${p.name} has no usable photo, so it is named but not attached.`);
3893
+ unattachable.push({ role: "product", id: p.id, label: p.name, hash: "", essential: true, reason: "missing" });
3656
3894
  }
3657
3895
  break;
3658
3896
  }
@@ -3663,6 +3901,7 @@ function compileBrief(brief, ctx) {
3663
3901
  break;
3664
3902
  }
3665
3903
  hasPerson = true;
3904
+ people += 1;
3666
3905
  append(c.promptName ?? c.name);
3667
3906
  const cshots = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) => ({ h: assetHash2(s?.file), angle: s?.angle ? String(s.angle) : void 0 })).filter((x) => !!x.h && ctx.images.has(x.h));
3668
3907
  if (cshots.length) {
@@ -3694,13 +3933,23 @@ function compileBrief(brief, ctx) {
3694
3933
  if (c.build) personDirectives.push(`${c.promptName ?? c.name}'s build: ${c.build}.`);
3695
3934
  } else {
3696
3935
  warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
3936
+ unattachable.push({
3937
+ role: "character",
3938
+ id: c.id,
3939
+ label: c.name,
3940
+ hash: "",
3941
+ essential: true,
3942
+ reason: "missing"
3943
+ });
3697
3944
  }
3698
3945
  break;
3699
3946
  }
3700
3947
  case "color": {
3701
3948
  const hex = tok.hex.toUpperCase();
3702
- append(tok.name ? `${tok.name} (${hex})` : hex);
3703
- otherDirectives.push(`Use ${hex} as a defining color in the composition.`);
3949
+ append(tok.name ? `(brand color ${tok.name} ${hex})` : `(brand color ${hex})`);
3950
+ otherDirectives.push(
3951
+ `Use ${hex} as a defining color in the composition, in surfaces, materials and light, never as lettering.`
3952
+ );
3704
3953
  break;
3705
3954
  }
3706
3955
  case "ref": {
@@ -3713,7 +3962,12 @@ function compileBrief(brief, ctx) {
3713
3962
  break;
3714
3963
  }
3715
3964
  attachments.push({ role: "reference", label: "Reference shot", hash: tok.imageHash });
3716
- otherDirectives.push("Match the composition, lighting and treatment of the attached reference.");
3965
+ otherDirectives.push({
3966
+ need: "attachment",
3967
+ role: "reference",
3968
+ hash: tok.imageHash,
3969
+ text: "Match the composition, lighting and treatment of the attached reference."
3970
+ });
3717
3971
  break;
3718
3972
  }
3719
3973
  case "mark": {
@@ -3733,9 +3987,12 @@ function compileBrief(brief, ctx) {
3733
3987
  );
3734
3988
  } catch {
3735
3989
  }
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
- );
3990
+ otherDirectives.push({
3991
+ need: "attachment",
3992
+ role: "brand",
3993
+ hash: tok.imageHash,
3994
+ 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."
3995
+ });
3739
3996
  break;
3740
3997
  }
3741
3998
  case "template": {
@@ -3815,8 +4072,50 @@ function compileBrief(brief, ctx) {
3815
4072
  if (i !== -1) attachments.splice(i, 1);
3816
4073
  }
3817
4074
  }
4075
+ const identityHashes = /* @__PURE__ */ new Map();
4076
+ for (const a of attachments)
4077
+ if ((a.role === "product" || a.role === "character") && !identityHashes.has(a.hash))
4078
+ identityHashes.set(a.hash, a.label);
4079
+ for (let i = attachments.length - 1; i >= 0; i--) {
4080
+ const a = attachments[i];
4081
+ if (a.role === "reference" && identityHashes.has(a.hash)) {
4082
+ attachments.splice(i, 1);
4083
+ warnings.push(
4084
+ `That reference is the same image as ${identityHashes.get(a.hash)}'s own photo, so it rides once, as the identity.`
4085
+ );
4086
+ }
4087
+ }
3818
4088
  const max = ctx.engineCaps.maxReferenceImages;
3819
- const { kept, dropped } = allocateAttachments(attachments, max);
4089
+ const { kept, dropped: budgetDropped, seated } = allocateAttachments(attachments, max);
4090
+ const presentKeys = new Set((ctx.presentAttachments ?? kept).map((a) => `${a.role}:${a.hash}`));
4091
+ const resolveDirective = (d) => {
4092
+ if (typeof d === "string") return d;
4093
+ if (d.need === "fidelity") {
4094
+ const n = attachments.filter(
4095
+ (a) => a.role === "product" && a.id === d.id && presentKeys.has(`product:${a.hash}`)
4096
+ ).length;
4097
+ return n > 0 ? productFidelityDirective(n) : null;
4098
+ }
4099
+ return presentKeys.has(`${d.role}:${d.hash}`) ? d.text : null;
4100
+ };
4101
+ const absentDirectives = [];
4102
+ const absentSeen = /* @__PURE__ */ new Set();
4103
+ for (const a of attachments) {
4104
+ const key = `${a.role}:${a.hash}`;
4105
+ if (presentKeys.has(key) || absentSeen.has(key)) continue;
4106
+ if (a.role === "reference") {
4107
+ absentSeen.add(key);
4108
+ const words = ctx.wordsFor?.(a.hash) ?? null;
4109
+ absentDirectives.push(
4110
+ words ? `A reference shot was not attached this time; it showed ${words}. Match that composition, lighting and treatment.` : "A reference image was attached but not sent this time."
4111
+ );
4112
+ } else if (a.role === "brand") {
4113
+ absentSeen.add(key);
4114
+ absentDirectives.push(
4115
+ `The brand mark "${a.label}" was not attached this time; keep every branded surface plain and do not invent a logo.`
4116
+ );
4117
+ }
4118
+ }
3820
4119
  const guard = scene ? sceneGuardDirectives({
3821
4120
  hasProduct: !!productId,
3822
4121
  hasPerson,
@@ -3833,13 +4132,16 @@ function compileBrief(brief, ctx) {
3833
4132
  "Any earlier instruction that bans props, hands, people, or a presenter from the frame is a solo-packshot rule for this product and does not apply to this shot: the attached presenter is deliberate and must appear as directed.",
3834
4133
  productHandlingDirective()
3835
4134
  ] : [];
4135
+ const nameDirectives = productId || hasPerson ? [namesAreNotLetteringDirective()] : [];
3836
4136
  const figureDirectives = scene?.figure ? sceneFigureDirectives({
3837
4137
  figure: scene.figure,
3838
4138
  treatment: scene.figureTreatment,
3839
4139
  hasPerson,
4140
+ people,
3840
4141
  // 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")
4142
+ // deliberately in play - and only one that actually rides counts,
4143
+ // same honesty rule as the photo guard above.
4144
+ hasMark: [...presentKeys].some((k) => k.startsWith("brand:"))
3843
4145
  }) : [];
3844
4146
  if (hasPerson) personDirectives.push(personSkinDirective());
3845
4147
  const closeUpDirectives = hasPerson && /\bclose[- ]?up\b|\bmacro\b|\bzoom(?:ed)?\b|\bDOF\b|\bdepth of field\b/i.test(sentence) ? [
@@ -3864,12 +4166,16 @@ function compileBrief(brief, ctx) {
3864
4166
  }) ? [garmentDisplayDirective()] : [];
3865
4167
  const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard()] : [];
3866
4168
  const allDirectives = [
4169
+ // First, beside the sentence that names things: the model reads the names
4170
+ // and this line in one breath, before any spec repeats them.
4171
+ ...nameDirectives,
3867
4172
  ...productDirectives,
3868
4173
  ...personDirectives,
3869
4174
  ...pairDirectives,
3870
4175
  ...figureDirectives,
3871
4176
  ...closeUpDirectives,
3872
4177
  ...otherDirectives,
4178
+ ...absentDirectives,
3873
4179
  ...cameraDirectives,
3874
4180
  ...apparelUnworn,
3875
4181
  ...brandLines,
@@ -3877,31 +4183,59 @@ function compileBrief(brief, ctx) {
3877
4183
  ...refGuard,
3878
4184
  ...preservation
3879
4185
  ];
3880
- if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
3881
- if (dropped.some((d) => d.role !== "scene")) {
4186
+ const spoken = dedupe(allDirectives.map(resolveDirective).filter((s) => s !== null));
4187
+ if (spoken.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${spoken.join(" ")}`;
4188
+ if (max === 0 && budgetDropped.some((d) => d.role !== "scene")) {
3882
4189
  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(
4190
+ const names = [...new Set(budgetDropped.filter((d) => d.role !== "scene").map((d) => d.label))].filter(
3884
4191
  (l) => !keptLabels.has(l)
3885
4192
  );
3886
4193
  if (names.length) {
3887
- const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
3888
4194
  warnings.push(
3889
- `${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
4195
+ `${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out \u2014 ${ctx.engineCaps.displayName} reads no reference images.`
3890
4196
  );
3891
4197
  }
3892
4198
  }
3893
4199
  return {
3894
4200
  prompt: prompt.trim(),
3895
4201
  referenceImages: kept.map((a) => ctx.images.pathFor(a.hash)),
3896
- dropped,
4202
+ // The missing-photo identities lead: they are essential, and the refusal
4203
+ // path reads this list. Budget losses carry their reason for the chips.
4204
+ dropped: [...unattachable, ...budgetDropped.map((d) => ({ ...d, reason: "budget" }))],
3897
4205
  width,
3898
4206
  height,
3899
4207
  attachments: kept,
4208
+ seated,
3900
4209
  warnings,
3901
4210
  productId
3902
4211
  };
3903
4212
  }
3904
4213
  var dedupe = (xs) => [...new Set(xs)];
4214
+
4215
+ // src/shotWords.ts
4216
+ function shotWords(prompt, max = 160) {
4217
+ if (!prompt) return null;
4218
+ const head = prompt.split(/\. (?=[A-Z])/)[0]?.trim() ?? "";
4219
+ if (!head) return null;
4220
+ const clipped = head.length > max ? `${head.slice(0, max).replace(/\s+\S*$/, "")}\u2026` : head;
4221
+ return clipped.replace(/[.\s]+$/, "");
4222
+ }
4223
+ function shotWordsFor(core, brandId) {
4224
+ let byHash = null;
4225
+ return (hash) => {
4226
+ if (!byHash) {
4227
+ byHash = /* @__PURE__ */ new Map();
4228
+ for (const p of core.store.listProjects(brandId)) {
4229
+ for (const n of core.store.treeFor(p.id)) {
4230
+ const words = shotWords(n.prompt);
4231
+ if (!words) continue;
4232
+ for (const h of n.images ?? []) if (!byHash.has(h)) byHash.set(h, words);
4233
+ }
4234
+ }
4235
+ }
4236
+ return byHash.get(hash) ?? null;
4237
+ };
4238
+ }
3905
4239
  var DEFAULT_CONTENT_URL = "https://github.com/tonygorb/scenri/releases/download/content-latest/scenri-content.zip";
3906
4240
  var TIMEOUT_MS = 10 * 60 * 1e3;
3907
4241
  function resolveContentUrl(env = process.env, override) {
@@ -4828,7 +5162,7 @@ function dedupeProducts(products) {
4828
5162
 
4829
5163
  // ../catalog/src/http/fetch.ts
4830
5164
  var USER_AGENT = "scenri-catalog/0.1 (+https://scenri.co)";
4831
- function sleep2(ms) {
5165
+ function sleep3(ms) {
4832
5166
  return new Promise((r) => setTimeout(r, ms));
4833
5167
  }
4834
5168
  async function httpGet(url, opts = {}) {
@@ -4852,7 +5186,7 @@ async function httpGet(url, opts = {}) {
4852
5186
  }
4853
5187
  });
4854
5188
  if ((res.status === 429 || res.status >= 500) && attempt < retries) {
4855
- await sleep2(400 * 2 ** attempt);
5189
+ await sleep3(400 * 2 ** attempt);
4856
5190
  continue;
4857
5191
  }
4858
5192
  return res;
@@ -4860,7 +5194,7 @@ async function httpGet(url, opts = {}) {
4860
5194
  lastErr = err;
4861
5195
  if (opts.signal?.aborted) throw err;
4862
5196
  if (attempt < retries) {
4863
- await sleep2(400 * 2 ** attempt);
5197
+ await sleep3(400 * 2 ** attempt);
4864
5198
  continue;
4865
5199
  }
4866
5200
  throw err;
@@ -7830,11 +8164,6 @@ function registerLogoRoutes(app, deps) {
7830
8164
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
7831
8165
  return core.store.updateBrand(brand.id, json);
7832
8166
  });
7833
- app.get("/api/brands/:id/directives", async (req, reply) => {
7834
- const brand = core.store.getBrand(req.params.id);
7835
- if (!brand) return reply.status(404).send({ error: "brand not found" });
7836
- return { directives: brandRuleDirectives(brand.json) };
7837
- });
7838
8167
  app.delete("/api/brands/:id/logos/:hash", async (req, reply) => {
7839
8168
  const brand = core.store.getBrand(req.params.id);
7840
8169
  if (!brand) return reply.status(404).send({ error: "brand not found" });
@@ -8760,6 +9089,48 @@ function registerImageRoutes(app, deps) {
8760
9089
 
8761
9090
  // src/release/notes.data.ts
8762
9091
  var RELEASES = [
9092
+ {
9093
+ version: "0.8.0",
9094
+ date: "2026-09-02",
9095
+ title: "The composer, rebuilt around what a shot is made of.",
9096
+ sections: [
9097
+ {
9098
+ heading: "Create",
9099
+ body: "Ingredients are compact chips now, each carrying the picture it stands for, and hovering one shows you what it is holding. A shot can hold twelve of them: the ones your engine can photograph are lit, and the rest ride along in words rather than being dropped, with every chip saying which it is. Photos go out in the order you wrote them, so the first thing you named is the first thing pictured. The asset panel beside the brief is a second door into it, so a tile ticks when its chip is in and clicking it again takes the chip out."
9100
+ },
9101
+ {
9102
+ heading: "Refine",
9103
+ body: "The panel beside an open shot has been redrawn, and its edge can be dragged to the width you want. Above the brief it shows what the picture is actually made of, resolved through the whole chain of refinements rather than just the last one, so nothing is repeated and nothing is lost at depth."
9104
+ },
9105
+ {
9106
+ heading: "Brand",
9107
+ body: "A product name is something to show in a shot, never lettering to paint into it, and a brand colour is now spoken as a note rather than an instruction, which keeps invented signage out of your pictures. Brand rules apply to every shot from Settings, so the composer no longer carries a row to say so."
9108
+ },
9109
+ {
9110
+ heading: "Fixes",
9111
+ body: "A finished shot appears the moment it lands instead of waiting for the rest of its batch, and a batch sent from a set files every shot in it. Typing around a chip behaves: the chip owns the space beside it, one press removes it, and two chips always keep the single space between them. Two presenters in a scene no longer compose one of them out."
9112
+ }
9113
+ ]
9114
+ },
9115
+ {
9116
+ version: "0.7.5",
9117
+ date: "2026-09-01",
9118
+ title: "Every shot is one card.",
9119
+ sections: [
9120
+ {
9121
+ heading: "Create",
9122
+ 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."
9123
+ },
9124
+ {
9125
+ heading: "Refining",
9126
+ 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."
9127
+ },
9128
+ {
9129
+ heading: "Fixes",
9130
+ 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."
9131
+ }
9132
+ ]
9133
+ },
8763
9134
  {
8764
9135
  version: "0.7.4",
8765
9136
  date: "2026-08-31",
@@ -9900,9 +10271,10 @@ function buildServer(opts) {
9900
10271
  }
9901
10272
  }
9902
10273
  if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
9903
- const compiled2 = compileBrief(brief, {
10274
+ const compileCtx = {
9904
10275
  brand: brandJson,
9905
10276
  images: core.images,
10277
+ wordsFor: shotWordsFor(core, brandId),
9906
10278
  engineCaps: uncapped,
9907
10279
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
9908
10280
  templateById: sceneById,
@@ -9918,7 +10290,8 @@ function buildServer(opts) {
9918
10290
  // Only the explicit op drops the dimension promise: an implicit legacy
9919
10291
  // expansion keeps its historical prompt byte for byte.
9920
10292
  ...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
9921
- });
10293
+ };
10294
+ const compiled2 = compileBrief(brief, compileCtx);
9922
10295
  let inheritedAttachments = [];
9923
10296
  let identityWarnings = [];
9924
10297
  if (inheritedTokens.length) {
@@ -9938,30 +10311,19 @@ function buildServer(opts) {
9938
10311
  }).map((a) => ({ ...a, inherited: true }));
9939
10312
  }
9940
10313
  const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
9941
- const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
10314
+ const merged = mergeEditAttachments(compiled2.seated, inheritedAttachments, cap2);
10315
+ const prompt = compileBrief(brief, { ...compileCtx, presentAttachments: merged.kept }).prompt;
9942
10316
  const warnings = [...compiled2.warnings, ...identityWarnings.filter((w) => !compiled2.warnings.includes(w))];
9943
10317
  if (inherited.truncated)
9944
10318
  warnings.push("This thread is deeper than 64 steps, so identity attached before that could not be carried.");
9945
- if (merged.dropped.length) {
9946
- if (engineCaps.maxReferenceImages <= 1) {
9947
- warnings.push(
9948
- `${engineCaps.displayName} cannot carry reference images, so the identity rides on the source frame alone.`
9949
- );
9950
- } else {
9951
- const keptLabels = new Set(merged.kept.map((a) => a.label));
9952
- const names = [...new Set(merged.dropped.map((d) => d.label))].filter((l) => !keptLabels.has(l));
9953
- 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
- );
9959
- }
9960
- }
10319
+ if (merged.dropped.length && engineCaps.maxReferenceImages <= 1)
10320
+ warnings.push(`The identity rides on the shot itself \u2014 ${engineCaps.displayName} reads no other images.`);
9961
10321
  return {
9962
- compiled: compiled2,
10322
+ compiled: { ...compiled2, prompt },
9963
10323
  inheritedTokens,
9964
10324
  merged,
10325
+ /** The seats this refinement had: the engine's, less the source frame. */
10326
+ cap: cap2,
9965
10327
  warnings,
9966
10328
  editScope: verdict.scope,
9967
10329
  editRemoval: verdict.removal ?? false
@@ -9986,9 +10348,15 @@ function buildServer(opts) {
9986
10348
  return {
9987
10349
  ...rest2,
9988
10350
  attachments: edit.merged.kept,
9989
- dropped: edit.merged.dropped,
10351
+ // The own compile runs uncapped, so its dropped list holds exactly the
10352
+ // missing-photo identities; the budget losses live on the merge.
10353
+ dropped: [...edit.compiled.dropped, ...edit.merged.dropped],
9990
10354
  warnings: edit.warnings,
9991
- referenceCount: edit.merged.kept.length
10355
+ referenceCount: edit.merged.kept.length,
10356
+ // How many photo groups this refine can carry in total: the engine's
10357
+ // slots less the one the source frame holds. The composer refuses a
10358
+ // pick past it rather than warning after the fact.
10359
+ cap: edit.cap
9992
10360
  };
9993
10361
  }
9994
10362
  const brandJson = await brandJsonWithIdentityCrops(
@@ -10012,12 +10380,13 @@ function buildServer(opts) {
10012
10380
  const compiled2 = compileBrief(brief, {
10013
10381
  brand: brandJson,
10014
10382
  images: core.images,
10383
+ wordsFor: shotWordsFor(core, brand.id),
10015
10384
  engineCaps: engine.capabilities(),
10016
10385
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
10017
10386
  templateById: sceneById
10018
10387
  });
10019
10388
  const { referenceImages, ...rest } = compiled2;
10020
- return { ...rest, referenceCount: referenceImages.length };
10389
+ return { ...rest, referenceCount: referenceImages.length, cap: engine.capabilities().maxReferenceImages };
10021
10390
  });
10022
10391
  registerProjectRoutes(app, { core });
10023
10392
  registerCodexSetupRoutes(app, { codexSetup: opts.codexSetup, codexRunner: engines.codexRunner });
@@ -10141,11 +10510,11 @@ function buildServer(opts) {
10141
10510
  }
10142
10511
  }
10143
10512
  const NODE_TIMEOUT_MS = 6e5;
10144
- async function runNode(nodeId, engine, estimate, work, expect, post, timeoutMs) {
10513
+ async function runNode(nodeIds, engine, estimate, work, expect, postFor, timeoutMs) {
10145
10514
  const engineId = engine?.capabilities().id ?? "local";
10146
10515
  reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
10147
10516
  const ctrl = new AbortController();
10148
- runningGenerations.set(nodeId, ctrl);
10517
+ for (const id of nodeIds) runningGenerations.set(id, ctrl);
10149
10518
  const bound = opts.nodeTimeoutMs ?? timeoutMs ?? NODE_TIMEOUT_MS;
10150
10519
  let watchdogFired = false;
10151
10520
  const watchdog = setTimeout(() => {
@@ -10153,43 +10522,78 @@ function buildServer(opts) {
10153
10522
  ctrl.abort(BUDGET_EXHAUSTED);
10154
10523
  }, bound);
10155
10524
  const startedAt = Date.now();
10156
- try {
10157
- const result = await work(ctrl.signal);
10158
- 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);
10525
+ const settled = /* @__PURE__ */ new Set();
10526
+ const settleSlot = async (slot, hash) => {
10527
+ const id = nodeIds[slot];
10162
10528
  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]);
10529
+ let own = await normalizePngs([hash]);
10530
+ const post = postFor?.(id);
10531
+ if (post) own = await post(own);
10532
+ if (expect) await assertAspect(own, expect);
10533
+ try {
10534
+ const meta2 = await sharp20(core.images.read(own[0])).metadata();
10535
+ const node = core.store.getNode(id);
10536
+ if (node && meta2.width && meta2.height) {
10537
+ const brief = node.brief ?? {};
10538
+ const asked = expect ? { requestedSize: [expect.width, expect.height] } : {};
10539
+ core.store.setBrief(id, { ...brief, rendered: { sizes: [[meta2.width, meta2.height]], ...asked } });
10540
+ if (node.kind === "generation" && engineId === "codex-cli" && expect && expect.width !== expect.height && meta2.width === expect.width && meta2.height === expect.height)
10541
+ app.log.warn(
10542
+ { nodeId: id },
10543
+ "codex delivered exactly the requested pixels; its image tool cannot pin size - suggests a forbidden shell resize"
10544
+ );
10545
+ }
10546
+ } catch {
10167
10547
  }
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
- );
10548
+ core.store.completeNode(id, { images: own, costUsd: 0, durationMs: Date.now() - startedAt });
10549
+ } catch (err) {
10550
+ core.store.failNode(id, String(err?.message ?? err));
10551
+ } finally {
10552
+ settled.add(id);
10553
+ }
10554
+ };
10555
+ const landing = /* @__PURE__ */ new Map();
10556
+ let accepting = true;
10557
+ const onImage = (slot, hash) => {
10558
+ if (!accepting || !Number.isInteger(slot) || slot < 0 || slot >= nodeIds.length || landing.has(slot)) return;
10559
+ landing.set(slot, settleSlot(slot, hash));
10560
+ };
10561
+ try {
10562
+ const result = await work(ctrl.signal, onImage);
10563
+ accepting = false;
10564
+ clearTimeout(watchdog);
10565
+ await Promise.allSettled(landing.values());
10566
+ const raw = result.raw;
10567
+ const bySlot = new Array(nodeIds.length);
10568
+ result.images.forEach((h, k) => {
10569
+ const slot = raw?.variantIndexes?.[k] ?? k;
10570
+ if (slot < nodeIds.length && bySlot[slot] === void 0) bySlot[slot] = h;
10571
+ });
10572
+ const failures = [...raw?.partialFailures ?? []];
10573
+ for (let slot = 0; slot < nodeIds.length; slot++) {
10574
+ if (landing.has(slot)) continue;
10575
+ const hash = bySlot[slot];
10576
+ if (hash === void 0) {
10577
+ core.store.failNode(nodeIds[slot], failures.shift() ?? "the engine returned no image for this shot");
10578
+ settled.add(nodeIds[slot]);
10579
+ continue;
10180
10580
  }
10181
- } catch {
10581
+ await settleSlot(slot, hash);
10182
10582
  }
10183
- core.store.completeNode(nodeId, { ...result, durationMs: Date.now() - startedAt });
10184
- core.ledger.recordCost(engineId, nodeId, result.costUsd);
10583
+ core.store.chargeNode(nodeIds[0], result.costUsd);
10584
+ core.ledger.recordCost(engineId, nodeIds[0], result.costUsd);
10185
10585
  } 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));
10586
+ accepting = false;
10587
+ await Promise.allSettled(landing.values());
10588
+ for (const id of nodeIds) {
10589
+ if (settled.has(id)) continue;
10590
+ if (watchdogFired) core.store.failNode(id, `generation timed out after ${Math.round(bound / 6e4)} minutes`);
10591
+ else if (ctrl.signal.aborted) core.store.cancelNode(id);
10592
+ else core.store.failNode(id, String(err?.message ?? err));
10593
+ }
10190
10594
  } finally {
10191
10595
  clearTimeout(watchdog);
10192
- runningGenerations.delete(nodeId);
10596
+ for (const id of nodeIds) runningGenerations.delete(id);
10193
10597
  const left = (reserved.get(engineId) ?? 0) - estimate;
10194
10598
  if (left > 1e-9) reserved.set(engineId, left);
10195
10599
  else reserved.delete(engineId);
@@ -10238,7 +10642,7 @@ function buildServer(opts) {
10238
10642
  images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
10239
10643
  costUsd: 0
10240
10644
  });
10241
- void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
10645
+ void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
10242
10646
  (err) => app.log.error({ err }, "crop run failed")
10243
10647
  );
10244
10648
  return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
@@ -10332,6 +10736,7 @@ function buildServer(opts) {
10332
10736
  compiled2 = compileBrief(brief, {
10333
10737
  brand: brandJson,
10334
10738
  images: core.images,
10739
+ wordsFor: shotWordsFor(core, project.brandId),
10335
10740
  engineCaps: engine.capabilities(),
10336
10741
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
10337
10742
  templateById: sceneById
@@ -10369,6 +10774,7 @@ function buildServer(opts) {
10369
10774
  compiled2 = compileBrief(legacyBrief, {
10370
10775
  brand: brandJson,
10371
10776
  images: core.images,
10777
+ wordsFor: shotWordsFor(core, project.brandId),
10372
10778
  engineCaps: engine.capabilities(),
10373
10779
  templateById: sceneFor(brandJson)
10374
10780
  });
@@ -10393,8 +10799,17 @@ function buildServer(opts) {
10393
10799
  if (kind === "generation") {
10394
10800
  const cap2 = engine.capabilities().maxReferenceImages;
10395
10801
  const wantedCount = Math.min(Math.max(1, Number(count)), 8);
10396
- const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
10802
+ const blind = engine.capabilities().maxReferenceImages === 0;
10803
+ const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential && (d.reason === "missing" || blind));
10397
10804
  if (lostIdentity.length) {
10805
+ const missing = lostIdentity.filter((d) => d.reason === "missing");
10806
+ if (missing.length) {
10807
+ const names2 = joinNames(missing.map((d) => d.label));
10808
+ const kindWord2 = missing[0].role === "product" ? "product" : "presenter";
10809
+ return reply.code(400).send({
10810
+ 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.`
10811
+ });
10812
+ }
10398
10813
  const names = joinNames(lostIdentity.map((d) => d.label));
10399
10814
  const kindWord = lostIdentity[0].role === "product" ? "product" : "presenter";
10400
10815
  return reply.code(400).send({
@@ -10405,6 +10820,19 @@ function buildServer(opts) {
10405
10820
  const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
10406
10821
  const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
10407
10822
  const sentRoles = referenceRoles && cap2 > 0 ? referenceRoles.slice(0, cap2) : referenceRoles ?? [];
10823
+ if (process.env.SCENRI_DEBUG) {
10824
+ const sent = {};
10825
+ for (const r of sentRoles) sent[r] = (sent[r] ?? 0) + 1;
10826
+ app.log.info(
10827
+ {
10828
+ engine: engine.capabilities().id,
10829
+ cap: cap2,
10830
+ sent,
10831
+ dropped: (compiled2?.dropped ?? []).map((d) => `${d.role}:${d.label} (${d.reason ?? "budget"})`)
10832
+ },
10833
+ "reference transport"
10834
+ );
10835
+ }
10408
10836
  const briefText = Array.isArray(brief?.tokens) ? brief.tokens.filter((t) => t?.t === "text").map((t) => String(t?.v ?? "")).join(" ") : String(prompt ?? "");
10409
10837
  const variations = variationPlan(wantedCount, {
10410
10838
  hasPresenter: sentRoles.includes("character"),
@@ -10423,7 +10851,7 @@ function buildServer(opts) {
10423
10851
  ...variations.length ? { variations } : {}
10424
10852
  };
10425
10853
  estimate = await engine.costEstimate(genReq);
10426
- work = (signal) => engine.generate(genReq, signal);
10854
+ work = (signal, onImage) => engine.generate(genReq, signal, onImage);
10427
10855
  expectShape = { width, height };
10428
10856
  } else {
10429
10857
  const parent = core.store.getNode(resolvedParentId);
@@ -10436,6 +10864,20 @@ function buildServer(opts) {
10436
10864
  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
10865
  const editEdge = engine.capabilities().maxReferenceEdge;
10438
10866
  if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
10867
+ if (process.env.SCENRI_DEBUG) {
10868
+ const sent = {};
10869
+ for (const r of editRefs) sent[String(r.role ?? "reference")] = (sent[String(r.role ?? "reference")] ?? 0) + 1;
10870
+ app.log.info(
10871
+ {
10872
+ engine: engine.capabilities().id,
10873
+ cap: Math.max(0, engine.capabilities().maxReferenceImages - 1),
10874
+ sourceFrame: true,
10875
+ sent,
10876
+ dropped: (mergedEdit?.dropped ?? []).map((d) => `${d.role}:${d.label} (${d.reason ?? "budget"})`)
10877
+ },
10878
+ "reference transport"
10879
+ );
10880
+ }
10439
10881
  const srcBuf = core.images.read(String(srcHash));
10440
10882
  const srcMeta = await sharp20(srcBuf).metadata();
10441
10883
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
@@ -10511,8 +10953,9 @@ function buildServer(opts) {
10511
10953
  expectShape = { width: expandPlan.width, height: expandPlan.height };
10512
10954
  }
10513
10955
  const editPixelBudget = runEngine.capabilities().editPixelBudget;
10514
- if (!expandPlan && editPixelBudget && srcMeta.width && srcMeta.height && srcMeta.width * srcMeta.height > editPixelBudget) {
10515
- sentSize = budgetSize(srcMeta.width, srcMeta.height, editPixelBudget);
10956
+ const stepped = !expandPlan && editPixelBudget && srcMeta.width && srcMeta.height && srcMeta.width * srcMeta.height > editPixelBudget ? budgetSize(srcMeta.width, srcMeta.height, editPixelBudget) : null;
10957
+ if (editPixelBudget && stepped && (stepped.width !== srcMeta.width || stepped.height !== srcMeta.height)) {
10958
+ sentSize = stepped;
10516
10959
  budgetSourceHash = core.images.save(
10517
10960
  await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
10518
10961
  );
@@ -10603,15 +11046,26 @@ function buildServer(opts) {
10603
11046
  }
10604
11047
  const billedId = runEngine.capabilities().id;
10605
11048
  core.ledger.assertUnderCap(billedId, estimate + (reserved.get(billedId) ?? 0));
10606
- const node = core.store.addNode({
11049
+ const nodes = kind === "generation" ? core.store.addNodes({
10607
11050
  projectId: project.id,
10608
11051
  parentId: resolvedParentId,
10609
11052
  kind,
10610
11053
  prompt: finalPrompt,
10611
- engineId: billedId
10612
- });
10613
- if (brief)
10614
- core.store.setBrief(node.id, {
11054
+ engineId: billedId,
11055
+ // the same clamp the engine request and the watchdog use
11056
+ count: Math.min(Math.max(1, Number(count)), 8)
11057
+ }) : [
11058
+ core.store.addNode({
11059
+ projectId: project.id,
11060
+ parentId: resolvedParentId,
11061
+ kind,
11062
+ prompt: finalPrompt,
11063
+ engineId: billedId
11064
+ })
11065
+ ];
11066
+ const node = nodes[0];
11067
+ for (const sibling of brief ? nodes : [])
11068
+ core.store.setBrief(sibling.id, {
10615
11069
  ...briefInputsOnly(brief),
10616
11070
  ...editedFrom ? { sourceImage: editedFrom } : {},
10617
11071
  ...kind === "edit" && reshape ? { reshape } : {},
@@ -10636,7 +11090,7 @@ function buildServer(opts) {
10636
11090
  }
10637
11091
  } : {},
10638
11092
  // What the refinement carried, recorded apart from what it asked for:
10639
- // the detail view shows both, and remix reads tokens alone.
11093
+ // the detail view shows both, and reuse setup merges both (mergeCarried).
10640
11094
  ...kind === "edit" && inheritedTokens.length ? { inherited: inheritedTokens } : {}
10641
11095
  });
10642
11096
  const plan = expandPlan;
@@ -10758,14 +11212,23 @@ function buildServer(opts) {
10758
11212
  staged = await conformToCanvas(node.id, expectShape)(staged);
10759
11213
  }
10760
11214
  return enforceEditCanvas(staged);
10761
- } : kind === "generation" && compiled2?.width && compiled2?.height ? conformToCanvas(node.id, { width: compiled2.width, height: compiled2.height }) : void 0;
11215
+ } : void 0;
11216
+ const genW = compiled2?.width;
11217
+ const genH = compiled2?.height;
11218
+ const postFor = post !== void 0 ? () => post : kind === "generation" && genW && genH ? (id) => conformToCanvas(id, { width: genW, height: genH }) : void 0;
10762
11219
  const runCaps = runEngine.capabilities();
10763
11220
  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
- );
11221
+ void runNode(
11222
+ nodes.map((n) => n.id),
11223
+ runEngine,
11224
+ estimate,
11225
+ work,
11226
+ expectShape,
11227
+ postFor,
11228
+ nodeBudgetMs
11229
+ ).catch((err) => app.log.error({ err }, "node run failed"));
10767
11230
  const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
10768
- return reply.status(202).send(allWarnings.length ? { ...node, warnings: allWarnings } : node);
11231
+ return reply.status(202).send({ ...node, siblings: nodes, ...allWarnings.length ? { warnings: allWarnings } : {} });
10769
11232
  });
10770
11233
  app.post("/api/nodes/:id/cancel", async (req, reply) => {
10771
11234
  const id = req.params.id;
@@ -10838,7 +11301,10 @@ function buildServer(opts) {
10838
11301
  runtime,
10839
11302
  stageImpl: opts.stageImpl,
10840
11303
  exitImpl: opts.exitImpl,
10841
- busyCount: () => runningGenerations.size + runningImportCount() + runningAssetBuildCount()
11304
+ // one physical run counts once, however many sibling nodes share its
11305
+ // controller — an update gate held open by a 4-shot batch is still held
11306
+ // open by exactly one piece of work
11307
+ busyCount: () => new Set(runningGenerations.values()).size + runningImportCount() + runningAssetBuildCount()
10842
11308
  });
10843
11309
  let drained = null;
10844
11310
  app.decorate("drain", () => {
@@ -10907,7 +11373,7 @@ async function serve() {
10907
11373
  }
10908
11374
  async function run() {
10909
11375
  const core = createCore();
10910
- const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b))] : [];
11376
+ const stubs = process.env.SCENRI_DEMO_ENGINE === "1" ? [createDemoEngine((b) => core.images.save(b), demoOptionsFromEnv(process.env))] : [];
10911
11377
  const engines = createEngineRegistry(core, stubs);
10912
11378
  const here = dirname(fileURLToPath(import.meta.url));
10913
11379
  const candidates = [join(here, "..", "..", "..", "apps", "studio", "dist"), join(here, "..", "studio-dist")];