scenri 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/serve.js +294 -70
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.1](https://github.com/tonygorb/Scenri/compare/v0.7.0...v0.7.1) (2026-08-30)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * condition a presenter on their face, not only on their figure ([b556b91](https://github.com/tonygorb/Scenri/commit/b556b91403e53ee424ceb0c937d5d0f968cc77fe))
9
+ * condition a presenter on their face, not only on their figure ([4f1e02b](https://github.com/tonygorb/Scenri/commit/4f1e02b9a0512bf588c7f8b46f7d469f22ecae67))
10
+ * decode a presenter's references once, and let one e2e test finish ([e862212](https://github.com/tonygorb/Scenri/commit/e862212898d96a46148f5bf3cb6106a624bfcf67))
11
+ * draw presenters a portrait frame, and stop the crop copying the sweep ([a41eb27](https://github.com/tonygorb/Scenri/commit/a41eb271c03a3130085637ac88d030034d552d3e))
12
+ * make a multi-image run one coherent set ([a463074](https://github.com/tonygorb/Scenri/commit/a463074c6fe28e048b95d5848e660146f5c6b9f9))
13
+ * make a multi-image run one coherent set ([d4ae447](https://github.com/tonygorb/Scenri/commit/d4ae4479965f779b18c41d4d518b84c5a69428a9))
14
+ * preview the same identity payload a generation sends ([15a8d30](https://github.com/tonygorb/Scenri/commit/15a8d304807e271c21f28929976551b8bee914f4))
15
+ * record which view every attachment is, and settle the portrait untouched ([9f1a1a4](https://github.com/tonygorb/Scenri/commit/9f1a1a4bff8b5237f7e322ab6fcffd62a6d836b9))
16
+ * send the presenter portrait that was already on disk ([bbd3def](https://github.com/tonygorb/Scenri/commit/bbd3defbfd936cd0bc672e0e66b5399637ec2d89))
17
+
3
18
  ## [0.7.0](https://github.com/tonygorb/Scenri/compare/v0.6.13...v0.7.0) (2026-08-30)
4
19
 
5
20
 
package/dist/serve.js CHANGED
@@ -1315,6 +1315,7 @@ var EDIT_REFERENCE_ROLE_DIRECTIVE = {
1315
1315
  style: "a reference for treatment and mood only",
1316
1316
  reference: "a reference for composition, lighting and treatment only"
1317
1317
  };
1318
+ var BUDGET_EXHAUSTED = "scenri:budget-exhausted";
1318
1319
  var ASPECT_TOLERANCE = 0.15;
1319
1320
  var NAMED_RATIOS = [
1320
1321
  ["1:1", 1],
@@ -1363,6 +1364,7 @@ function createCore(homeDir = defaultHome()) {
1363
1364
  };
1364
1365
  }
1365
1366
  var ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
1367
+ var PER_IMAGE_TIMEOUT_MS = 3e5;
1366
1368
  var DEFAULT_MODEL = "google/gemini-2.5-flash-image";
1367
1369
  var DEFAULT_COST_PER_IMAGE_USD = 0.04;
1368
1370
  function dataUrl(path) {
@@ -1401,6 +1403,7 @@ function createOpenRouterEngine(opts) {
1401
1403
  return key;
1402
1404
  }
1403
1405
  async function post(key, body, signal) {
1406
+ const bound = AbortSignal.timeout(PER_IMAGE_TIMEOUT_MS);
1404
1407
  const res = await fetchImpl(ENDPOINT, {
1405
1408
  method: "POST",
1406
1409
  headers: {
@@ -1408,7 +1411,7 @@ function createOpenRouterEngine(opts) {
1408
1411
  "Content-Type": "application/json"
1409
1412
  },
1410
1413
  body: JSON.stringify(body),
1411
- signal
1414
+ signal: signal ? AbortSignal.any([signal, bound]) : bound
1412
1415
  });
1413
1416
  const text = await res.text();
1414
1417
  if (!res.ok) {
@@ -1451,7 +1454,11 @@ function createOpenRouterEngine(opts) {
1451
1454
  localOnly: false,
1452
1455
  supportsEdit: true,
1453
1456
  supportsMask: false,
1454
- maxReferenceImages: 4
1457
+ maxReferenceImages: 4,
1458
+ // N sequential calls, one image each: the server budgets the node by
1459
+ // that shape instead of handing the whole run one flat ten minutes.
1460
+ perImageTimeoutMs: PER_IMAGE_TIMEOUT_MS,
1461
+ imageConcurrency: 1
1455
1462
  };
1456
1463
  },
1457
1464
  async isAvailable() {
@@ -1492,7 +1499,12 @@ function createOpenRouterEngine(opts) {
1492
1499
  let reportedCost = 0;
1493
1500
  let sawReportedCost = false;
1494
1501
  for (let i = 0; i < req.count; i++) {
1495
- const json = await post(key, body, signal);
1502
+ const variation = req.variations?.[i];
1503
+ const json = await post(
1504
+ key,
1505
+ variation ? { ...body, messages: [{ role: "user", content: [...content, { type: "text", text: variation }] }] } : body,
1506
+ signal
1507
+ );
1496
1508
  raws.push(json);
1497
1509
  for (const buf of extractImages(json)) hashes.push(opts.saveImage(buf));
1498
1510
  if (typeof json?.usage?.cost === "number") {
@@ -2527,9 +2539,6 @@ function createCodexSetup(opts = {}) {
2527
2539
 
2528
2540
  // ../engines/codex/src/index.ts
2529
2541
  var CODEX_POOL = 2;
2530
- function codexNodeBudgetMs(count) {
2531
- return Math.ceil(Math.max(1, count) / CODEX_POOL) * DEFAULT_TIMEOUT_MS2 + 6e4;
2532
- }
2533
2542
  function orientationOf(width, height) {
2534
2543
  return width === height ? "square" : width > height ? "landscape" : "portrait";
2535
2544
  }
@@ -2561,7 +2570,7 @@ function createCodexEngine(opts) {
2561
2570
  return /* @__PURE__ */ new Set();
2562
2571
  }
2563
2572
  }
2564
- async function collectImages(dir, before = null) {
2573
+ async function collectImages(dir, before = null, claimed) {
2565
2574
  const entries = await readdir(dir);
2566
2575
  const outFiles = entries.filter((name) => /^out-.*\.png$/.test(name)).sort((a, b) => {
2567
2576
  const na = Number(/^out-(\d+)\.png$/.exec(a)?.[1] ?? NaN);
@@ -2571,7 +2580,7 @@ function createCodexEngine(opts) {
2571
2580
  });
2572
2581
  if (outFiles.length === 0) {
2573
2582
  if (before) {
2574
- const recovered = await recoverFromGenerated(before);
2583
+ const recovered = await recoverFromGenerated(before, claimed);
2575
2584
  if (recovered) return [recovered];
2576
2585
  }
2577
2586
  throw new Error("Codex finished but produced no images");
@@ -2584,11 +2593,11 @@ function createCodexEngine(opts) {
2584
2593
  }
2585
2594
  return hashes;
2586
2595
  }
2587
- async function recoverFromGenerated(before) {
2596
+ async function recoverFromGenerated(before, claimed) {
2588
2597
  const home = generatedImagesDir();
2589
2598
  let names;
2590
2599
  try {
2591
- names = (await readdir(home)).filter((n) => !before.has(n));
2600
+ names = (await readdir(home)).filter((n) => !before.has(n) && !claimed?.has(n));
2592
2601
  } catch {
2593
2602
  return null;
2594
2603
  }
@@ -2596,6 +2605,7 @@ function createCodexEngine(opts) {
2596
2605
  const stamped = await Promise.all(names.map(async (n) => ({ n, mtime: (await stat(join(home, n))).mtimeMs })));
2597
2606
  stamped.sort((a, b) => b.mtime - a.mtime);
2598
2607
  const pick2 = stamped[0].n;
2608
+ claimed?.add(pick2);
2599
2609
  console.warn(`codex: workdir empty, recovered ${pick2} from ${home}`);
2600
2610
  return saveImage(await readFile(join(home, pick2)));
2601
2611
  }
@@ -2645,7 +2655,12 @@ function createCodexEngine(opts) {
2645
2655
  * budget — a full-resolution phone-photo PNG is tens of megabytes that
2646
2656
  * buy nothing. Same cap as brand marks (MARK_MAX_EDGE).
2647
2657
  */
2648
- maxReferenceEdge: 2048
2658
+ maxReferenceEdge: 2048,
2659
+ // One exec per image at CODEX_POOL at a time, each carrying its own
2660
+ // full timer. The server turns these two numbers into the node bound,
2661
+ // which is the same arithmetic codexNodeBudgetMs states above.
2662
+ perImageTimeoutMs: DEFAULT_TIMEOUT_MS2,
2663
+ imageConcurrency: CODEX_POOL
2649
2664
  };
2650
2665
  },
2651
2666
  isAvailable() {
@@ -2659,8 +2674,9 @@ function createCodexEngine(opts) {
2659
2674
  const refs = req.referenceImages ?? [];
2660
2675
  const roles = req.referenceRoles ?? refs.map(() => "reference");
2661
2676
  const inner = new AbortController();
2662
- const onOuterAbort = () => inner.abort();
2663
- if (signal?.aborted) inner.abort();
2677
+ const claimed = /* @__PURE__ */ new Set();
2678
+ const onOuterAbort = () => inner.abort(signal?.reason);
2679
+ if (signal?.aborted) inner.abort(signal.reason);
2664
2680
  else signal?.addEventListener("abort", onOuterAbort, { once: true });
2665
2681
  const jobs = Array.from(
2666
2682
  { length: count },
@@ -2679,7 +2695,7 @@ function createCodexEngine(opts) {
2679
2695
  stdin: buildPrompt2(req, i, roles),
2680
2696
  label: `gen v${i + 1}/${count} refs=${refs.length} refKB=${Math.round(refBytes / 1024)}`
2681
2697
  });
2682
- return collectImages(dir, before);
2698
+ return collectImages(dir, before, claimed);
2683
2699
  })
2684
2700
  );
2685
2701
  const results = new Array(count);
@@ -2693,7 +2709,7 @@ function createCodexEngine(opts) {
2693
2709
  try {
2694
2710
  results[i] = await jobs[i]();
2695
2711
  } catch (err) {
2696
- if (signal?.aborted) throw err;
2712
+ if (signal?.aborted && signal.reason !== BUDGET_EXHAUSTED) throw err;
2697
2713
  results[i] = [];
2698
2714
  failures.push(err);
2699
2715
  if (fatal == null && isFatalSetupError(err)) {
@@ -2776,10 +2792,10 @@ function createCodexEngine(opts) {
2776
2792
  );
2777
2793
  }
2778
2794
  function buildPrompt2(req, index, roles) {
2795
+ const variation = req.variations?.[index] ?? "";
2779
2796
  const roleDirective = REFERENCE_ROLE_DIRECTIVE;
2780
2797
  const names = refFileNames(roles, roles.length);
2781
2798
  const refDirectives = roles.map((role, i) => `${names[i]} shows ${roleDirective[role]}.`).join(" ");
2782
- const count = Math.max(1, req.count);
2783
2799
  const native = codexNativeSize(req.width, req.height);
2784
2800
  return (
2785
2801
  // "professional-grade", not "flawless": the audit of the waxy-presenter
@@ -2793,13 +2809,19 @@ function createCodexEngine(opts) {
2793
2809
  // to the requested one, and the aspect check passed BECAUSE of the shear
2794
2810
  // - the reported crushed faces. Copy/move stays licensed because the
2795
2811
  // win32 recovery path moves files out of generated_images.
2796
- ` Do not browse the web or explore files. Save the tool's output in the current directory as out-1.png, byte-for-byte unchanged: you may run the commands needed to copy or move the file, but never resize, scale, stretch, pad, crop or re-encode it \u2014 deliver the tool's own pixels at the tool's own size. Nothing else.` + // Every take in a batch gets the SAME-shaped clause. Take 1 used to get
2797
- // nothing - so the first output was literally asked for the most
2798
- // reference-faithful decode - and later takes were licensed to a
2799
- // "different composition", which read as permission to drift from the
2800
- // directives. Reported as: output #1 copies the scene reference, output
2801
- // #2 mixes identities. A single generation stays byte-stable.
2802
- (count > 1 ? ` (take ${index + 1} of ${count} \u2014 same brief, same identities and constraints, a naturally different moment and framing of the same shoot)` : "")
2812
+ ` Do not browse the web or explore files. Save the tool's output in the current directory as out-1.png, byte-for-byte unchanged: you may run the commands needed to copy or move the file, but never resize, scale, stretch, pad, crop or re-encode it \u2014 deliver the tool's own pixels at the tool's own size. Nothing else.` + // The set clause, built once by the server for the whole run and handed
2813
+ // over index-aligned with the output slots. It carries the photographic
2814
+ // move this frame explores and the locks every frame shares.
2815
+ //
2816
+ // What used to be here was a COUNTER "take 3 of 4". Every take got the
2817
+ // same-shaped sentence, so this was already the hardened version, and the
2818
+ // drift survived it: a rising take number is not neutral text. In shoot
2819
+ // language it reads as "we have already done that, go further", a licence
2820
+ // to deviate that grows with the output index, which is precisely the
2821
+ // reported shape (output 1 holds the presenter, 2 and 3 and 4 drift). No
2822
+ // frame is described in terms of any other frame now, and no number
2823
+ // reaches the model at all.
2824
+ (variation ? ` ${variation}` : "")
2803
2825
  );
2804
2826
  }
2805
2827
  }
@@ -3015,14 +3037,22 @@ function presenterRefPath(templatesRoot, id, slot) {
3015
3037
  function presenterAvatarPath(templatesRoot, id) {
3016
3038
  return contentFile(templatesRoot, "previews", "presenters", id, "avatar.jpg");
3017
3039
  }
3040
+ var resolvedRefs = /* @__PURE__ */ new Map();
3041
+ async function refHash(core, path) {
3042
+ const hit = resolvedRefs.get(path);
3043
+ if (hit && core.images.has(hit)) return hit;
3044
+ const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
3045
+ resolvedRefs.set(path, hash);
3046
+ return hash;
3047
+ }
3018
3048
  async function resolvePresenterImages(core, templatesRoot, presenter) {
3019
3049
  const shots = [];
3050
+ const avatar = presenterAvatarPath(templatesRoot, presenter.id);
3051
+ if (existsSync(avatar)) shots.push({ file: `asset:${await refHash(core, avatar)}`, angle: "portrait", locked: true });
3020
3052
  for (const [slot, angle] of PRESENTER_ANGLES) {
3021
3053
  const path = presenterRefPath(templatesRoot, presenter.id, slot);
3022
3054
  if (!existsSync(path)) continue;
3023
- const png = await sharp20(readFileSync(path)).png().toBuffer();
3024
- const hash = core.images.save(png);
3025
- shots.push({ file: `asset:${hash}`, angle, locked: true });
3055
+ shots.push({ file: `asset:${await refHash(core, path)}`, angle, locked: true });
3026
3056
  }
3027
3057
  if (!shots.length) return null;
3028
3058
  return {
@@ -3031,6 +3061,8 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
3031
3061
  ...presenter.identityNotes ? { identityNotes: presenter.identityNotes } : {},
3032
3062
  ...presenter.negativeConstraints?.length ? { negativeConstraints: presenter.negativeConstraints } : {},
3033
3063
  ...presenter.skin ? { skin: presenter.skin } : {},
3064
+ ...presenter.facial ? { facial: presenter.facial } : {},
3065
+ ...presenter.build ? { build: presenter.build } : {},
3034
3066
  shots
3035
3067
  };
3036
3068
  }
@@ -3288,6 +3320,7 @@ async function capReferenceEdge(core, path, maxEdge) {
3288
3320
  out = core.images.pathFor(core.images.save(buf));
3289
3321
  }
3290
3322
  } catch {
3323
+ return path;
3291
3324
  }
3292
3325
  cappedRefs.set(key, out);
3293
3326
  return out;
@@ -3557,17 +3590,25 @@ function compileBrief(brief, ctx) {
3557
3590
  append(p.promptName ?? p.name);
3558
3591
  const primary = tok.angle && p.shots?.find((s) => s.angle === tok.angle) || p.shots?.[0];
3559
3592
  const orderedShots = [primary, ...(p.shots ?? []).filter((s) => s && s !== primary)];
3560
- const phashes = [];
3593
+ const pshots = [];
3561
3594
  for (const s of orderedShots) {
3562
- if (phashes.length >= PRODUCT_REF_MAX) break;
3595
+ if (pshots.length >= PRODUCT_REF_MAX) break;
3563
3596
  const h = assetHash2(s?.file);
3564
- if (h && ctx.images.has(h) && !phashes.includes(h)) phashes.push(h);
3597
+ if (h && ctx.images.has(h) && !pshots.some((x) => x.h === h))
3598
+ pshots.push({ h, ...s?.angle ? { angle: String(s.angle) } : {} });
3565
3599
  }
3566
- if (phashes.length) {
3567
- phashes.forEach((h, i) => {
3568
- attachments.push({ role: "product", id: p.id, label: p.name, hash: h, essential: i === 0 });
3600
+ if (pshots.length) {
3601
+ pshots.forEach(({ h, angle }, i) => {
3602
+ attachments.push({
3603
+ role: "product",
3604
+ id: p.id,
3605
+ label: p.name,
3606
+ hash: h,
3607
+ essential: i === 0,
3608
+ ...angle ? { angle } : {}
3609
+ });
3569
3610
  });
3570
- productDirectives.push(productFidelityDirective(phashes.length));
3611
+ productDirectives.push(productFidelityDirective(pshots.length));
3571
3612
  productDirectives.push(...productFactDirectives(p));
3572
3613
  if (p.description && !p.dimensions)
3573
3614
  productDirectives.push(
@@ -3586,10 +3627,17 @@ function compileBrief(brief, ctx) {
3586
3627
  }
3587
3628
  hasPerson = true;
3588
3629
  append(c.promptName ?? c.name);
3589
- const chashes = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) => assetHash2(s?.file)).filter((h) => !!h && ctx.images.has(h));
3590
- if (chashes.length) {
3591
- chashes.forEach((chash, i) => {
3592
- attachments.push({ role: "character", id: c.id, label: c.name, hash: chash, essential: i === 0 });
3630
+ 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));
3631
+ if (cshots.length) {
3632
+ cshots.forEach(({ h, angle }, i) => {
3633
+ attachments.push({
3634
+ role: "character",
3635
+ id: c.id,
3636
+ label: c.name,
3637
+ hash: h,
3638
+ essential: i === 0,
3639
+ ...angle ? { angle } : {}
3640
+ });
3593
3641
  });
3594
3642
  personDirectives.push(
3595
3643
  `${c.promptName ?? c.name} is in this photograph: a real person, clearly visible in the frame. Do not leave them out, crop them out, or reduce them to a reflection or a shadow.`
@@ -3602,6 +3650,11 @@ function compileBrief(brief, ctx) {
3602
3650
  personDirectives.push(
3603
3651
  `${c.promptName ?? c.name}'s skin, exactly as the reference photographs show it: ${c.skin}.`
3604
3652
  );
3653
+ if (c.facial)
3654
+ personDirectives.push(
3655
+ `${c.promptName ?? c.name}'s face, which must survive every generation unchanged: ${c.facial}.`
3656
+ );
3657
+ if (c.build) personDirectives.push(`${c.promptName ?? c.name}'s build: ${c.build}.`);
3605
3658
  } else {
3606
3659
  warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
3607
3660
  }
@@ -5984,7 +6037,8 @@ function presenterRecordFrom(input, base) {
5984
6037
  const has = (k) => input[k] !== void 0;
5985
6038
  const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
5986
6039
  if (!name) return { ok: false, error: "a presenter needs a name" };
5987
- const shots = has("shotHashes") ? strList(input.shotHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file, locked: true })) : base?.shots ?? [];
6040
+ const angles = has("shotAngles") ? strList(input.shotAngles, 8, 32) : [];
6041
+ const shots = has("shotHashes") ? strList(input.shotHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file, i) => angles[i] ? { file, angle: angles[i], locked: true } : { file, locked: true }) : base?.shots ?? [];
5988
6042
  if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
5989
6043
  const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
5990
6044
  const presenter = {
@@ -6122,6 +6176,26 @@ async function runBuild(deps, job, hashes, instruction, signal) {
6122
6176
  }
6123
6177
  }
6124
6178
  var STUDIO_FRAMES = [
6179
+ /*
6180
+ * The identity frame, and it comes first because that is the order a brief
6181
+ * attaches: `shots[0]` is the essential character reference.
6182
+ *
6183
+ * Every other frame here is full-length head-to-toe, which is right for
6184
+ * build, proportion and wardrobe and useless for a face — in a 1024x1280
6185
+ * full-length frame the face is about 105px brow to chin, while a portrait
6186
+ * output renders it at four times that. Measured 2026-08-30 against the
6187
+ * reported failure: four outputs of one brief, four different jaws, and
6188
+ * drift that tracked nothing but how big the face was in the output.
6189
+ *
6190
+ * Drawn `from: 'sources'` rather than chained off the front view, because
6191
+ * the user's own photographs are the only real face evidence in the system
6192
+ * and a chain would just enlarge the same 105px.
6193
+ */
6194
+ {
6195
+ angle: "portrait",
6196
+ from: "sources",
6197
+ subject: (who) => `${who}, head-and-shoulders portrait framing from just above the top of the head down to the collarbone, facing the camera straight-on, relaxed neutral expression, eyes to the lens, their own hair exactly as the references show it, the same plain studio backdrop and even frontal light`
6198
+ },
6125
6199
  {
6126
6200
  angle: "front",
6127
6201
  from: "sources",
@@ -6174,22 +6248,28 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
6174
6248
  }
6175
6249
  if (signal.aborted) throw new Error("cancelled");
6176
6250
  let shotHashes = hashes;
6251
+ let shotAngles = [];
6177
6252
  const warnings = [];
6178
6253
  if (deps.engine) {
6179
6254
  patch(job, { stage: "building", steps: STUDIO_FRAMES.length, message: "Building the studio views" });
6180
6255
  const built2 = await generateStudioSet(deps, job, whoIs(job.name, draft), sourcePaths, signal);
6181
- if (built2.length) shotHashes = built2;
6182
- else warnings.push("The studio views could not be drawn, so the photos are being used directly.");
6256
+ if (built2.hashes.length) {
6257
+ shotHashes = built2.hashes;
6258
+ shotAngles = built2.angles;
6259
+ } else warnings.push("The studio views could not be drawn, so the photos are being used directly.");
6183
6260
  } else {
6184
6261
  warnings.push("No engine could draw the studio views, so the photos are being used directly.");
6185
6262
  }
6186
6263
  if (signal.aborted) throw new Error("cancelled");
6187
6264
  patch(job, { stage: "saving", message: null });
6188
6265
  const generated = shotHashes !== hashes;
6189
- const { previewHash, avatarHash } = await presenterCrops(core, shotHashes[0], generated ? "generated" : "upload");
6266
+ const frontIndex = shotAngles.indexOf("front");
6267
+ const cardSource = frontIndex === -1 ? shotHashes[0] : shotHashes[frontIndex];
6268
+ const { previewHash, avatarHash } = await presenterCrops(core, cardSource, generated ? "generated" : "upload");
6190
6269
  const built = presenterRecordFrom({
6191
6270
  name: job.name,
6192
6271
  shotHashes,
6272
+ shotAngles,
6193
6273
  sourceHashes: hashes,
6194
6274
  previewHash,
6195
6275
  avatarHash,
@@ -6212,16 +6292,16 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
6212
6292
  stage: "done",
6213
6293
  step: job.steps,
6214
6294
  assetId: built.presenter.id,
6215
- previewHash: previewHash ?? shotHashes[0] ?? null,
6295
+ previewHash: previewHash ?? cardSource ?? null,
6216
6296
  warnings: [...job.warnings, ...warnings],
6217
6297
  finished: true
6218
6298
  });
6219
6299
  }
6220
6300
  async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6221
6301
  const engine = deps.engine;
6222
- if (!engine) return [];
6302
+ if (!engine) return { hashes: [], angles: [] };
6223
6303
  const caps = engine.capabilities();
6224
- if (!caps.maxReferenceImages) return [];
6304
+ if (!caps.maxReferenceImages) return { hashes: [], angles: [] };
6225
6305
  const byAngle = /* @__PURE__ */ new Map();
6226
6306
  for (const frame of STUDIO_FRAMES) {
6227
6307
  if (signal.aborted) throw new Error("cancelled");
@@ -6248,7 +6328,8 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6248
6328
  patch(job, { warnings: [...job.warnings, `The ${frame.angle} view could not be drawn.`] });
6249
6329
  }
6250
6330
  }
6251
- return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
6331
+ const kept = STUDIO_FRAMES.filter((f) => byAngle.get(f.angle));
6332
+ return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
6252
6333
  }
6253
6334
  async function edgeBarGeometry(buf) {
6254
6335
  const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
@@ -6346,6 +6427,64 @@ async function avatarCrop(core, hash) {
6346
6427
  AVATAR_MAX_PX
6347
6428
  );
6348
6429
  }
6430
+ var IDENTITY_FIGURE_FRACTION = 0.26;
6431
+ var IDENTITY_HEADROOM = 0.08;
6432
+ var IDENTITY_ASPECT = 0.66;
6433
+ var IDENTITY_TARGET_HEIGHT = 1280;
6434
+ var IDENTITY_MAX_UPSCALE = 3;
6435
+ var STANDING_FIGURE_RATIO = 2.2;
6436
+ async function identityCrop(core, hash) {
6437
+ if (!hash || !core.images.has(hash)) return void 0;
6438
+ const hit = identityCrops.get(hash);
6439
+ if (hit && core.images.has(hit)) return hit;
6440
+ let box = null;
6441
+ try {
6442
+ box = await figureBox(core.images.read(hash));
6443
+ } catch {
6444
+ box = null;
6445
+ }
6446
+ if (!box) return void 0;
6447
+ if (box.height / Math.max(1, box.width) < STANDING_FIGURE_RATIO) return void 0;
6448
+ let nativeHeight = 0;
6449
+ const out = await crop(core, hash, (w, h) => {
6450
+ const height = Math.min(h, Math.max(16, Math.round(box.height * IDENTITY_FIGURE_FRACTION)));
6451
+ const width = Math.min(w, Math.max(16, Math.round(height * IDENTITY_ASPECT)));
6452
+ nativeHeight = height;
6453
+ const top = Math.min(Math.max(0, Math.round(box.top - height * IDENTITY_HEADROOM)), h - height);
6454
+ const left = Math.min(Math.max(0, Math.round(box.left + box.width / 2 - width / 2)), w - width);
6455
+ return { left, top, width, height };
6456
+ });
6457
+ if (!out) return void 0;
6458
+ try {
6459
+ const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
6460
+ const png = await sharp20(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
6461
+ const scaled = core.images.save(png);
6462
+ identityCrops.set(hash, scaled);
6463
+ return scaled;
6464
+ } catch {
6465
+ identityCrops.set(hash, out);
6466
+ return out;
6467
+ }
6468
+ }
6469
+ var identityCrops = /* @__PURE__ */ new Map();
6470
+ async function brandJsonWithIdentityCrops(core, json, characterIds) {
6471
+ const wanted = new Set(characterIds);
6472
+ const roster = json?.characters ?? [];
6473
+ if (!wanted.size || !roster.length) return json;
6474
+ let changed = false;
6475
+ const characters = await Promise.all(
6476
+ roster.map(async (c) => {
6477
+ if (!wanted.has(c?.id) || !c?.shots?.length) return c;
6478
+ if (c.shots[0]?.angle === "portrait") return c;
6479
+ const front = String(c.shots[0]?.file ?? "").replace(/^asset:/, "") || null;
6480
+ const cropped = await identityCrop(core, front ?? void 0);
6481
+ if (!cropped) return c;
6482
+ changed = true;
6483
+ return { ...c, shots: [{ file: `asset:${cropped}`, angle: "identity", locked: true }, ...c.shots] };
6484
+ })
6485
+ );
6486
+ return changed ? { ...json, characters } : json;
6487
+ }
6349
6488
  async function figureBox(buf) {
6350
6489
  const meta = await sharp20(buf).metadata();
6351
6490
  const W = meta.width ?? 0;
@@ -6616,6 +6755,49 @@ function inheritedIdentityTokens(parentId, getNode) {
6616
6755
  return { tokens: [], truncated: id !== null };
6617
6756
  }
6618
6757
 
6758
+ // src/variationPlan.ts
6759
+ var OPEN_LADDER = [
6760
+ "Frame this one as the direction describes it, the straight read of the brief.",
6761
+ "Step the camera to one side of where the direction places it, and let the pose settle with the move.",
6762
+ "Frame tighter on the subject than the straight read, same lens character.",
6763
+ "Drop the eye line a little and leave more air in the frame.",
6764
+ "Step back for a wider read of the same setup.",
6765
+ "Come round to a three-quarter view of the same arrangement.",
6766
+ "Take it from slightly above, the same distance.",
6767
+ "Hold the same framing and let the subject carry a different beat of the same moment."
6768
+ ];
6769
+ var FIXED_LADDER = [
6770
+ "Frame this one as the direction describes it, the straight read of the brief.",
6771
+ "Keep the camera the direction asks for and shift it a little laterally.",
6772
+ "Keep the camera the direction asks for and let the weight and hands settle differently.",
6773
+ "Keep the camera the direction asks for and change the head angle slightly.",
6774
+ "Keep the camera the direction asks for and let the light fall a touch differently across the same setup.",
6775
+ "Keep the camera the direction asks for and give the expression a different beat of the same moment.",
6776
+ "Keep the camera the direction asks for and rearrange the near foreground slightly.",
6777
+ "Keep the camera the direction asks for and let the pose breathe a little wider."
6778
+ ];
6779
+ function locks(ctx) {
6780
+ const parts = [
6781
+ "Every frame in this run belongs to one continuous shoot: the same location, the same light, and the same wardrobe garment for garment, changing only as the pose moves the cloth."
6782
+ ];
6783
+ if (ctx.hasPresenter)
6784
+ parts.push(
6785
+ "The person is the one in the character references and nobody else, unchanged in face, hair, build and skin."
6786
+ );
6787
+ if (ctx.hasProduct)
6788
+ parts.push(
6789
+ "The product is the one in the product references and no other, unchanged in geometry, packaging, label and colour."
6790
+ );
6791
+ if (ctx.hasMark) parts.push("The brand mark stays exactly as drawn.");
6792
+ return parts.join(" ");
6793
+ }
6794
+ function variationPlan(count, ctx) {
6795
+ if (!Number.isFinite(count) || count <= 1) return [];
6796
+ const ladder = ctx.cameraFixed ? FIXED_LADDER : OPEN_LADDER;
6797
+ const shared = locks(ctx);
6798
+ return Array.from({ length: Math.floor(count) }, (_, i) => `${ladder[i % ladder.length]} ${shared}`);
6799
+ }
6800
+
6619
6801
  // src/editScopeRules.ts
6620
6802
  var GLOBAL_CUES = [
6621
6803
  ["light", /\b(light|lighting|lit|relight|exposure|white ?balance|backlit|shadows everywhere)\b/i],
@@ -8467,6 +8649,25 @@ function registerImageRoutes(app, deps) {
8467
8649
 
8468
8650
  // src/release/notes.data.ts
8469
8651
  var RELEASES = [
8652
+ {
8653
+ version: "0.7.1",
8654
+ date: "2026-08-30",
8655
+ title: "Four images from one brief are one set.",
8656
+ sections: [
8657
+ {
8658
+ heading: "Create",
8659
+ body: "Asking for two, three or four images returns variations of one shot rather than four readings of it. The presenter, the product, the scene and the brand hold across the set, and so does the wardrobe. What changes is the photography: each frame explores a different camera position, crop or pose within the brief you wrote."
8660
+ },
8661
+ {
8662
+ heading: "Presenters",
8663
+ body: "A selected presenter now reaches generation as a portrait, not only as full-length views, so their face carries into every image of a run instead of being rebuilt each time. Presenters built in Scenri gain a head-and-shoulders reference of their own, and their casting notes reach the shot."
8664
+ },
8665
+ {
8666
+ heading: "Fixes",
8667
+ body: "A run that takes too long keeps the images that already finished instead of throwing them away with the rest. Refining a shot conditions on the same presenter portrait the generation used."
8668
+ }
8669
+ ]
8670
+ },
8470
8671
  {
8471
8672
  version: "0.7.0",
8472
8673
  date: "2026-08-30",
@@ -9502,18 +9703,22 @@ function buildServer(opts) {
9502
9703
  );
9503
9704
  const inheritedTokens = borrowed.filter((t) => !already.has(identityTokenKey(t)));
9504
9705
  const combined = [...brief.tokens, ...inheritedTokens];
9505
- const brandJson = await brandJsonWithResolvedPresenters(
9706
+ const brandJson = await brandJsonWithIdentityCrops(
9506
9707
  core,
9507
- templatesRoot,
9508
- presenters,
9509
- await brandJsonWithResolvedDemoProducts(
9708
+ await brandJsonWithResolvedPresenters(
9510
9709
  core,
9511
9710
  templatesRoot,
9512
- demoProducts,
9513
- brandJsonWithCatalogProducts(core, brandId),
9711
+ presenters,
9712
+ await brandJsonWithResolvedDemoProducts(
9713
+ core,
9714
+ templatesRoot,
9715
+ demoProducts,
9716
+ brandJsonWithCatalogProducts(core, brandId),
9717
+ combined
9718
+ ),
9514
9719
  combined
9515
9720
  ),
9516
- combined
9721
+ combined.filter((t) => t.t === "character").map((t) => t.id)
9517
9722
  );
9518
9723
  const sceneById = sceneFor(brandJson);
9519
9724
  const uncapped = { ...engineCaps, maxReferenceImages: 32 };
@@ -9640,18 +9845,22 @@ function buildServer(opts) {
9640
9845
  referenceCount: edit.merged.kept.length
9641
9846
  };
9642
9847
  }
9643
- const brandJson = await brandJsonWithResolvedPresenters(
9848
+ const brandJson = await brandJsonWithIdentityCrops(
9644
9849
  core,
9645
- templatesRoot,
9646
- presenters,
9647
- await brandJsonWithResolvedDemoProducts(
9850
+ await brandJsonWithResolvedPresenters(
9648
9851
  core,
9649
9852
  templatesRoot,
9650
- demoProducts,
9651
- brandJsonWithCatalogProducts(core, brand.id),
9853
+ presenters,
9854
+ await brandJsonWithResolvedDemoProducts(
9855
+ core,
9856
+ templatesRoot,
9857
+ demoProducts,
9858
+ brandJsonWithCatalogProducts(core, brand.id),
9859
+ brief.tokens
9860
+ ),
9652
9861
  brief.tokens
9653
9862
  ),
9654
- brief.tokens
9863
+ (brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
9655
9864
  );
9656
9865
  const sceneById = sceneFor(brandJson);
9657
9866
  const compiled2 = compileBrief(brief, {
@@ -9795,7 +10004,7 @@ function buildServer(opts) {
9795
10004
  let watchdogFired = false;
9796
10005
  const watchdog = setTimeout(() => {
9797
10006
  watchdogFired = true;
9798
- ctrl.abort();
10007
+ ctrl.abort(BUDGET_EXHAUSTED);
9799
10008
  }, bound);
9800
10009
  const startedAt = Date.now();
9801
10010
  try {
@@ -9944,18 +10153,22 @@ function buildServer(opts) {
9944
10153
  if (!compiled2.prompt.trim() && reshape !== "extend")
9945
10154
  return reply.status(400).send({ error: "the brief is empty" });
9946
10155
  } else {
9947
- const brandJson = await brandJsonWithResolvedPresenters(
10156
+ const brandJson = await brandJsonWithIdentityCrops(
9948
10157
  core,
9949
- templatesRoot,
9950
- presenters,
9951
- await brandJsonWithResolvedDemoProducts(
10158
+ await brandJsonWithResolvedPresenters(
9952
10159
  core,
9953
10160
  templatesRoot,
9954
- demoProducts,
9955
- brandJsonWithCatalogProducts(core, project.brandId),
10161
+ presenters,
10162
+ await brandJsonWithResolvedDemoProducts(
10163
+ core,
10164
+ templatesRoot,
10165
+ demoProducts,
10166
+ brandJsonWithCatalogProducts(core, project.brandId),
10167
+ brief.tokens
10168
+ ),
9956
10169
  brief.tokens
9957
10170
  ),
9958
- brief.tokens
10171
+ (brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
9959
10172
  );
9960
10173
  const sceneById = sceneFor(brandJson);
9961
10174
  compiled2 = compileBrief(brief, {
@@ -10021,6 +10234,7 @@ function buildServer(opts) {
10021
10234
  }
10022
10235
  if (kind === "generation") {
10023
10236
  const cap2 = engine.capabilities().maxReferenceImages;
10237
+ const wantedCount = Math.min(Math.max(1, Number(count)), 8);
10024
10238
  const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
10025
10239
  if (lostIdentity.length) {
10026
10240
  const names = joinNames(lostIdentity.map((d) => d.label));
@@ -10032,14 +10246,23 @@ function buildServer(opts) {
10032
10246
  const maxEdge = engine.capabilities().maxReferenceEdge;
10033
10247
  const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
10034
10248
  const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
10249
+ const sentRoles = referenceRoles && cap2 > 0 ? referenceRoles.slice(0, cap2) : referenceRoles ?? [];
10250
+ const briefText = Array.isArray(brief?.tokens) ? brief.tokens.filter((t) => t?.t === "text").map((t) => String(t?.v ?? "")).join(" ") : String(prompt ?? "");
10251
+ const variations = variationPlan(wantedCount, {
10252
+ hasPresenter: sentRoles.includes("character"),
10253
+ hasProduct: sentRoles.includes("product"),
10254
+ hasMark: sentRoles.includes("brand"),
10255
+ cameraFixed: shotSpecifiesCamera(briefText)
10256
+ });
10035
10257
  const genReq = {
10036
10258
  prompt: finalPrompt,
10037
10259
  brand: ctx,
10038
10260
  width: Number(width),
10039
10261
  height: Number(height),
10040
- count: Math.min(Math.max(1, Number(count)), 8),
10262
+ count: wantedCount,
10041
10263
  ...sentRefs ? { referenceImages: sentRefs } : {},
10042
- ...referenceRoles && cap2 > 0 ? { referenceRoles: referenceRoles.slice(0, cap2) } : {}
10264
+ ...sentRoles.length && cap2 > 0 ? { referenceRoles: sentRoles } : {},
10265
+ ...variations.length ? { variations } : {}
10043
10266
  };
10044
10267
  estimate = await engine.costEstimate(genReq);
10045
10268
  work = (signal) => engine.generate(genReq, signal);
@@ -10328,7 +10551,8 @@ function buildServer(opts) {
10328
10551
  }
10329
10552
  return enforceEditCanvas(staged);
10330
10553
  } : kind === "generation" && compiled2?.width && compiled2?.height ? conformToCanvas(node.id, { width: compiled2.width, height: compiled2.height }) : void 0;
10331
- const nodeBudgetMs = kind === "generation" && runEngine.capabilities().id === "codex-cli" ? codexNodeBudgetMs(Math.min(Math.max(1, Number(count)), 8)) : void 0;
10554
+ const runCaps = runEngine.capabilities();
10555
+ 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;
10332
10556
  void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
10333
10557
  (err) => app.log.error({ err }, "node run failed")
10334
10558
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",