scenri 0.7.0 → 0.7.2

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
@@ -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;
@@ -3318,6 +3351,9 @@ function productFidelityDirective(attached) {
3318
3351
  }
3319
3352
  return "The attached product images all show the exact product to feature: preserve its label, shape and proportions faithfully, do not redesign it, and never treat an extra image as an additional product. The first product image is the authority for its color, finish and material. Where another image differs in color or finish, it shows the same product in another colorway \u2014 never blend colorways, and render the one the first image shows. Any face not visible in them is unknown \u2014 keep it plain and consistent with the materials the first image shows, and do not invent detail on it. If the direction above explicitly asks for more than one colorway, that explicit request wins.";
3320
3353
  }
3354
+ function extendPreservationDirective() {
3355
+ return "This grows the frame of a photograph that already exists; it does not restage it. The photograph in hand is the shot: the same person with the same face and the same clothing, the same product with the same label, geometry and colour, each at the same size, in the same place, under the same light. New area only continues the same scene past the original edges. Do not redesign the product, replace the person, change what anyone wears, or move the camera nearer or further away.";
3356
+ }
3321
3357
  function editPreservationDirective(scope, opts) {
3322
3358
  if (scope === "local") {
3323
3359
  const removal = opts?.removal ? " What is removed leaves nothing behind: the surface and the scene continue as if it had never been there, with no outline, silhouette, residue or ghost of it." : "";
@@ -3557,17 +3593,25 @@ function compileBrief(brief, ctx) {
3557
3593
  append(p.promptName ?? p.name);
3558
3594
  const primary = tok.angle && p.shots?.find((s) => s.angle === tok.angle) || p.shots?.[0];
3559
3595
  const orderedShots = [primary, ...(p.shots ?? []).filter((s) => s && s !== primary)];
3560
- const phashes = [];
3596
+ const pshots = [];
3561
3597
  for (const s of orderedShots) {
3562
- if (phashes.length >= PRODUCT_REF_MAX) break;
3598
+ if (pshots.length >= PRODUCT_REF_MAX) break;
3563
3599
  const h = assetHash2(s?.file);
3564
- if (h && ctx.images.has(h) && !phashes.includes(h)) phashes.push(h);
3600
+ if (h && ctx.images.has(h) && !pshots.some((x) => x.h === h))
3601
+ pshots.push({ h, ...s?.angle ? { angle: String(s.angle) } : {} });
3565
3602
  }
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 });
3603
+ if (pshots.length) {
3604
+ pshots.forEach(({ h, angle }, i) => {
3605
+ attachments.push({
3606
+ role: "product",
3607
+ id: p.id,
3608
+ label: p.name,
3609
+ hash: h,
3610
+ essential: i === 0,
3611
+ ...angle ? { angle } : {}
3612
+ });
3569
3613
  });
3570
- productDirectives.push(productFidelityDirective(phashes.length));
3614
+ productDirectives.push(productFidelityDirective(pshots.length));
3571
3615
  productDirectives.push(...productFactDirectives(p));
3572
3616
  if (p.description && !p.dimensions)
3573
3617
  productDirectives.push(
@@ -3586,10 +3630,17 @@ function compileBrief(brief, ctx) {
3586
3630
  }
3587
3631
  hasPerson = true;
3588
3632
  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 });
3633
+ 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));
3634
+ if (cshots.length) {
3635
+ cshots.forEach(({ h, angle }, i) => {
3636
+ attachments.push({
3637
+ role: "character",
3638
+ id: c.id,
3639
+ label: c.name,
3640
+ hash: h,
3641
+ essential: i === 0,
3642
+ ...angle ? { angle } : {}
3643
+ });
3593
3644
  });
3594
3645
  personDirectives.push(
3595
3646
  `${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 +3653,11 @@ function compileBrief(brief, ctx) {
3602
3653
  personDirectives.push(
3603
3654
  `${c.promptName ?? c.name}'s skin, exactly as the reference photographs show it: ${c.skin}.`
3604
3655
  );
3656
+ if (c.facial)
3657
+ personDirectives.push(
3658
+ `${c.promptName ?? c.name}'s face, which must survive every generation unchanged: ${c.facial}.`
3659
+ );
3660
+ if (c.build) personDirectives.push(`${c.promptName ?? c.name}'s build: ${c.build}.`);
3605
3661
  } else {
3606
3662
  warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
3607
3663
  }
@@ -3757,7 +3813,13 @@ function compileBrief(brief, ctx) {
3757
3813
  ] : [];
3758
3814
  const brandLines = brandRuleDirectives(ctx.brand);
3759
3815
  const preservation = ctx.mode === "edit" ? [
3760
- ...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
3816
+ // An extend gets its own preservation language: the global
3817
+ // directive's "same framing, same dimensions" lines contradict a
3818
+ // frame that is deliberately growing, but dropping preservation
3819
+ // altogether left the redrawn-frame arm with nothing protecting
3820
+ // the person, the product or the wardrobe. See
3821
+ // extendPreservationDirective.
3822
+ ...ctx.editReshape === "extend" ? [extendPreservationDirective()] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
3761
3823
  ...ctx.inheritedIdentity ? [inheritedIdentityDirective(ctx.inheritedIdentity === true ? void 0 : ctx.inheritedIdentity)] : [],
3762
3824
  ...ctx.inheritedDirectives ?? []
3763
3825
  ] : [];
@@ -5984,7 +6046,8 @@ function presenterRecordFrom(input, base) {
5984
6046
  const has = (k) => input[k] !== void 0;
5985
6047
  const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
5986
6048
  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 ?? [];
6049
+ const angles = has("shotAngles") ? strList(input.shotAngles, 8, 32) : [];
6050
+ 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
6051
  if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
5989
6052
  const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
5990
6053
  const presenter = {
@@ -6122,6 +6185,26 @@ async function runBuild(deps, job, hashes, instruction, signal) {
6122
6185
  }
6123
6186
  }
6124
6187
  var STUDIO_FRAMES = [
6188
+ /*
6189
+ * The identity frame, and it comes first because that is the order a brief
6190
+ * attaches: `shots[0]` is the essential character reference.
6191
+ *
6192
+ * Every other frame here is full-length head-to-toe, which is right for
6193
+ * build, proportion and wardrobe and useless for a face — in a 1024x1280
6194
+ * full-length frame the face is about 105px brow to chin, while a portrait
6195
+ * output renders it at four times that. Measured 2026-08-30 against the
6196
+ * reported failure: four outputs of one brief, four different jaws, and
6197
+ * drift that tracked nothing but how big the face was in the output.
6198
+ *
6199
+ * Drawn `from: 'sources'` rather than chained off the front view, because
6200
+ * the user's own photographs are the only real face evidence in the system
6201
+ * and a chain would just enlarge the same 105px.
6202
+ */
6203
+ {
6204
+ angle: "portrait",
6205
+ from: "sources",
6206
+ 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`
6207
+ },
6125
6208
  {
6126
6209
  angle: "front",
6127
6210
  from: "sources",
@@ -6174,22 +6257,28 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
6174
6257
  }
6175
6258
  if (signal.aborted) throw new Error("cancelled");
6176
6259
  let shotHashes = hashes;
6260
+ let shotAngles = [];
6177
6261
  const warnings = [];
6178
6262
  if (deps.engine) {
6179
6263
  patch(job, { stage: "building", steps: STUDIO_FRAMES.length, message: "Building the studio views" });
6180
6264
  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.");
6265
+ if (built2.hashes.length) {
6266
+ shotHashes = built2.hashes;
6267
+ shotAngles = built2.angles;
6268
+ } else warnings.push("The studio views could not be drawn, so the photos are being used directly.");
6183
6269
  } else {
6184
6270
  warnings.push("No engine could draw the studio views, so the photos are being used directly.");
6185
6271
  }
6186
6272
  if (signal.aborted) throw new Error("cancelled");
6187
6273
  patch(job, { stage: "saving", message: null });
6188
6274
  const generated = shotHashes !== hashes;
6189
- const { previewHash, avatarHash } = await presenterCrops(core, shotHashes[0], generated ? "generated" : "upload");
6275
+ const frontIndex = shotAngles.indexOf("front");
6276
+ const cardSource = frontIndex === -1 ? shotHashes[0] : shotHashes[frontIndex];
6277
+ const { previewHash, avatarHash } = await presenterCrops(core, cardSource, generated ? "generated" : "upload");
6190
6278
  const built = presenterRecordFrom({
6191
6279
  name: job.name,
6192
6280
  shotHashes,
6281
+ shotAngles,
6193
6282
  sourceHashes: hashes,
6194
6283
  previewHash,
6195
6284
  avatarHash,
@@ -6212,16 +6301,16 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
6212
6301
  stage: "done",
6213
6302
  step: job.steps,
6214
6303
  assetId: built.presenter.id,
6215
- previewHash: previewHash ?? shotHashes[0] ?? null,
6304
+ previewHash: previewHash ?? cardSource ?? null,
6216
6305
  warnings: [...job.warnings, ...warnings],
6217
6306
  finished: true
6218
6307
  });
6219
6308
  }
6220
6309
  async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6221
6310
  const engine = deps.engine;
6222
- if (!engine) return [];
6311
+ if (!engine) return { hashes: [], angles: [] };
6223
6312
  const caps = engine.capabilities();
6224
- if (!caps.maxReferenceImages) return [];
6313
+ if (!caps.maxReferenceImages) return { hashes: [], angles: [] };
6225
6314
  const byAngle = /* @__PURE__ */ new Map();
6226
6315
  for (const frame of STUDIO_FRAMES) {
6227
6316
  if (signal.aborted) throw new Error("cancelled");
@@ -6248,7 +6337,8 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6248
6337
  patch(job, { warnings: [...job.warnings, `The ${frame.angle} view could not be drawn.`] });
6249
6338
  }
6250
6339
  }
6251
- return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
6340
+ const kept = STUDIO_FRAMES.filter((f) => byAngle.get(f.angle));
6341
+ return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
6252
6342
  }
6253
6343
  async function edgeBarGeometry(buf) {
6254
6344
  const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
@@ -6346,6 +6436,64 @@ async function avatarCrop(core, hash) {
6346
6436
  AVATAR_MAX_PX
6347
6437
  );
6348
6438
  }
6439
+ var IDENTITY_FIGURE_FRACTION = 0.26;
6440
+ var IDENTITY_HEADROOM = 0.08;
6441
+ var IDENTITY_ASPECT = 0.66;
6442
+ var IDENTITY_TARGET_HEIGHT = 1280;
6443
+ var IDENTITY_MAX_UPSCALE = 3;
6444
+ var STANDING_FIGURE_RATIO = 2.2;
6445
+ async function identityCrop(core, hash) {
6446
+ if (!hash || !core.images.has(hash)) return void 0;
6447
+ const hit = identityCrops.get(hash);
6448
+ if (hit && core.images.has(hit)) return hit;
6449
+ let box = null;
6450
+ try {
6451
+ box = await figureBox(core.images.read(hash));
6452
+ } catch {
6453
+ box = null;
6454
+ }
6455
+ if (!box) return void 0;
6456
+ if (box.height / Math.max(1, box.width) < STANDING_FIGURE_RATIO) return void 0;
6457
+ let nativeHeight = 0;
6458
+ const out = await crop(core, hash, (w, h) => {
6459
+ const height = Math.min(h, Math.max(16, Math.round(box.height * IDENTITY_FIGURE_FRACTION)));
6460
+ const width = Math.min(w, Math.max(16, Math.round(height * IDENTITY_ASPECT)));
6461
+ nativeHeight = height;
6462
+ const top = Math.min(Math.max(0, Math.round(box.top - height * IDENTITY_HEADROOM)), h - height);
6463
+ const left = Math.min(Math.max(0, Math.round(box.left + box.width / 2 - width / 2)), w - width);
6464
+ return { left, top, width, height };
6465
+ });
6466
+ if (!out) return void 0;
6467
+ try {
6468
+ const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
6469
+ const png = await sharp20(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
6470
+ const scaled = core.images.save(png);
6471
+ identityCrops.set(hash, scaled);
6472
+ return scaled;
6473
+ } catch {
6474
+ identityCrops.set(hash, out);
6475
+ return out;
6476
+ }
6477
+ }
6478
+ var identityCrops = /* @__PURE__ */ new Map();
6479
+ async function brandJsonWithIdentityCrops(core, json, characterIds) {
6480
+ const wanted = new Set(characterIds);
6481
+ const roster = json?.characters ?? [];
6482
+ if (!wanted.size || !roster.length) return json;
6483
+ let changed = false;
6484
+ const characters = await Promise.all(
6485
+ roster.map(async (c) => {
6486
+ if (!wanted.has(c?.id) || !c?.shots?.length) return c;
6487
+ if (c.shots[0]?.angle === "portrait") return c;
6488
+ const front = String(c.shots[0]?.file ?? "").replace(/^asset:/, "") || null;
6489
+ const cropped = await identityCrop(core, front ?? void 0);
6490
+ if (!cropped) return c;
6491
+ changed = true;
6492
+ return { ...c, shots: [{ file: `asset:${cropped}`, angle: "identity", locked: true }, ...c.shots] };
6493
+ })
6494
+ );
6495
+ return changed ? { ...json, characters } : json;
6496
+ }
6349
6497
  async function figureBox(buf) {
6350
6498
  const meta = await sharp20(buf).metadata();
6351
6499
  const W = meta.width ?? 0;
@@ -6616,6 +6764,49 @@ function inheritedIdentityTokens(parentId, getNode) {
6616
6764
  return { tokens: [], truncated: id !== null };
6617
6765
  }
6618
6766
 
6767
+ // src/variationPlan.ts
6768
+ var OPEN_LADDER = [
6769
+ "Frame this one as the direction describes it, the straight read of the brief.",
6770
+ "Step the camera to one side of where the direction places it, and let the pose settle with the move.",
6771
+ "Frame tighter on the subject than the straight read, same lens character.",
6772
+ "Drop the eye line a little and leave more air in the frame.",
6773
+ "Step back for a wider read of the same setup.",
6774
+ "Come round to a three-quarter view of the same arrangement.",
6775
+ "Take it from slightly above, the same distance.",
6776
+ "Hold the same framing and let the subject carry a different beat of the same moment."
6777
+ ];
6778
+ var FIXED_LADDER = [
6779
+ "Frame this one as the direction describes it, the straight read of the brief.",
6780
+ "Keep the camera the direction asks for and shift it a little laterally.",
6781
+ "Keep the camera the direction asks for and let the weight and hands settle differently.",
6782
+ "Keep the camera the direction asks for and change the head angle slightly.",
6783
+ "Keep the camera the direction asks for and let the light fall a touch differently across the same setup.",
6784
+ "Keep the camera the direction asks for and give the expression a different beat of the same moment.",
6785
+ "Keep the camera the direction asks for and rearrange the near foreground slightly.",
6786
+ "Keep the camera the direction asks for and let the pose breathe a little wider."
6787
+ ];
6788
+ function locks(ctx) {
6789
+ const parts = [
6790
+ "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."
6791
+ ];
6792
+ if (ctx.hasPresenter)
6793
+ parts.push(
6794
+ "The person is the one in the character references and nobody else, unchanged in face, hair, build and skin."
6795
+ );
6796
+ if (ctx.hasProduct)
6797
+ parts.push(
6798
+ "The product is the one in the product references and no other, unchanged in geometry, packaging, label and colour."
6799
+ );
6800
+ if (ctx.hasMark) parts.push("The brand mark stays exactly as drawn.");
6801
+ return parts.join(" ");
6802
+ }
6803
+ function variationPlan(count, ctx) {
6804
+ if (!Number.isFinite(count) || count <= 1) return [];
6805
+ const ladder = ctx.cameraFixed ? FIXED_LADDER : OPEN_LADDER;
6806
+ const shared = locks(ctx);
6807
+ return Array.from({ length: Math.floor(count) }, (_, i) => `${ladder[i % ladder.length]} ${shared}`);
6808
+ }
6809
+
6619
6810
  // src/editScopeRules.ts
6620
6811
  var GLOBAL_CUES = [
6621
6812
  ["light", /\b(light|lighting|lit|relight|exposure|white ?balance|backlit|shadows everywhere)\b/i],
@@ -6875,6 +7066,74 @@ function planCrop(source, targetRatio) {
6875
7066
  const height = Math.max(1, Math.min(source.height, Math.round(source.width / targetRatio)));
6876
7067
  return { left: 0, top: Math.floor((source.height - height) / 2), width: source.width, height, axis: "height" };
6877
7068
  }
7069
+ function defaultReshapeOp(sourceRatio, targetRatio) {
7070
+ if (!(sourceRatio > 0 && targetRatio > 0)) return "extend";
7071
+ return Math.abs(Math.log(targetRatio)) < Math.abs(Math.log(sourceRatio)) - 0.01 ? "crop" : "extend";
7072
+ }
7073
+
7074
+ // src/outpaint/growth.ts
7075
+ var SINGLE_PASS_MAX = 1.5;
7076
+ var CROP_ASSIST_ABOVE = 2;
7077
+ var CROP_ASSIST_MAX = 0.15;
7078
+ var STAGE_MAX = 1.4;
7079
+ function planGrowth(source, targetRatio) {
7080
+ if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
7081
+ const current = source.width / source.height;
7082
+ if (Math.abs(current - targetRatio) / targetRatio < 0.01) return null;
7083
+ const axis = targetRatio > current ? "width" : "height";
7084
+ const growth = axis === "width" ? targetRatio / current : current / targetRatio;
7085
+ let cropAssist = 0;
7086
+ if (growth > CROP_ASSIST_ABOVE) {
7087
+ const wanted = 1 - CROP_ASSIST_ABOVE / growth;
7088
+ cropAssist = Math.min(CROP_ASSIST_MAX, wanted);
7089
+ }
7090
+ const effective = growth * (1 - cropAssist);
7091
+ const stages = effective <= SINGLE_PASS_MAX ? 1 : Math.ceil(Math.log(effective) / Math.log(STAGE_MAX));
7092
+ return { growth, axis, stages, cropAssist, effective };
7093
+ }
7094
+ function cropAssistWindow(source, plan) {
7095
+ if (plan.cropAssist <= 0) return null;
7096
+ if (plan.axis === "width") {
7097
+ const height = Math.max(1, Math.round(source.height * (1 - plan.cropAssist)));
7098
+ return { left: 0, top: Math.floor((source.height - height) / 2), width: source.width, height };
7099
+ }
7100
+ const width = Math.max(1, Math.round(source.width * (1 - plan.cropAssist)));
7101
+ return { left: Math.floor((source.width - width) / 2), top: 0, width, height: source.height };
7102
+ }
7103
+
7104
+ // src/reshapeRules.ts
7105
+ var EXTEND_MAX = CROP_ASSIST_ABOVE;
7106
+ function classifyReshape(source, targetRatio, requested) {
7107
+ if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return { op: "none" };
7108
+ const growth = planGrowth(source, targetRatio);
7109
+ if (!growth) return { op: "none" };
7110
+ const op = requested ?? defaultReshapeOp(source.width / source.height, targetRatio);
7111
+ if (op === "crop") return { op: "crop", forced: false };
7112
+ if (growth.effective > EXTEND_MAX + 1e-9) return { op: "crop", forced: true, growth };
7113
+ return { op: "extend", growth, assist: cropAssistWindow(source, growth) };
7114
+ }
7115
+ function fitExpandToBudget(plan, source, pixelBudget) {
7116
+ if (!pixelBudget || plan.width * plan.height <= pixelBudget) return { plan, source, scale: 1 };
7117
+ const frame = budgetSize(plan.width, plan.height, pixelBudget);
7118
+ if (plan.axis === "width") {
7119
+ const scale2 = frame.height / plan.height;
7120
+ const width = Math.min(frame.width, Math.max(1, Math.round(source.width * scale2)));
7121
+ const left = Math.min(Math.max(0, Math.round(plan.left * scale2)), frame.width - width);
7122
+ return {
7123
+ plan: { width: frame.width, height: frame.height, left, top: 0, axis: "width" },
7124
+ source: { width, height: frame.height },
7125
+ scale: scale2
7126
+ };
7127
+ }
7128
+ const scale = frame.width / plan.width;
7129
+ const height = Math.min(frame.height, Math.max(1, Math.round(source.height * scale)));
7130
+ const top = Math.min(Math.max(0, Math.round(plan.top * scale)), frame.height - height);
7131
+ return {
7132
+ plan: { width: frame.width, height: frame.height, left: 0, top, axis: "height" },
7133
+ source: { width: frame.width, height },
7134
+ scale
7135
+ };
7136
+ }
6878
7137
  async function attentionCropOrigin(srcBuf, source, plan) {
6879
7138
  try {
6880
7139
  const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
@@ -8467,6 +8726,40 @@ function registerImageRoutes(app, deps) {
8467
8726
 
8468
8727
  // src/release/notes.data.ts
8469
8728
  var RELEASES = [
8729
+ {
8730
+ version: "0.7.2",
8731
+ date: "2026-08-30",
8732
+ title: "Changing a shot to a new shape keeps the photograph.",
8733
+ sections: [
8734
+ {
8735
+ heading: "Create",
8736
+ body: "Refining a shot into a different aspect ratio no longer costs it quality. The frame is planned at the size the engine can genuinely draw, so nothing is enlarged afterwards to fill a canvas its pixels could not reach. The presenter, the product, the wardrobe, the light and the subject scale carry across the new shape, and the stored size is now the size that was really drawn."
8737
+ },
8738
+ {
8739
+ heading: "Fixes",
8740
+ body: "A target shape that is tighter than the shot now crops it, instantly and without a generation, instead of building out around it. A shape too far from the current one to reach in a single step crops as well, and says so, rather than attempting a stretch that could not work. The composer tells you which of the two will happen before you run it."
8741
+ }
8742
+ ]
8743
+ },
8744
+ {
8745
+ version: "0.7.1",
8746
+ date: "2026-08-30",
8747
+ title: "Four images from one brief are one set.",
8748
+ sections: [
8749
+ {
8750
+ heading: "Create",
8751
+ 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."
8752
+ },
8753
+ {
8754
+ heading: "Presenters",
8755
+ 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."
8756
+ },
8757
+ {
8758
+ heading: "Fixes",
8759
+ 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."
8760
+ }
8761
+ ]
8762
+ },
8470
8763
  {
8471
8764
  version: "0.7.0",
8472
8765
  date: "2026-08-30",
@@ -9502,18 +9795,22 @@ function buildServer(opts) {
9502
9795
  );
9503
9796
  const inheritedTokens = borrowed.filter((t) => !already.has(identityTokenKey(t)));
9504
9797
  const combined = [...brief.tokens, ...inheritedTokens];
9505
- const brandJson = await brandJsonWithResolvedPresenters(
9798
+ const brandJson = await brandJsonWithIdentityCrops(
9506
9799
  core,
9507
- templatesRoot,
9508
- presenters,
9509
- await brandJsonWithResolvedDemoProducts(
9800
+ await brandJsonWithResolvedPresenters(
9510
9801
  core,
9511
9802
  templatesRoot,
9512
- demoProducts,
9513
- brandJsonWithCatalogProducts(core, brandId),
9803
+ presenters,
9804
+ await brandJsonWithResolvedDemoProducts(
9805
+ core,
9806
+ templatesRoot,
9807
+ demoProducts,
9808
+ brandJsonWithCatalogProducts(core, brandId),
9809
+ combined
9810
+ ),
9514
9811
  combined
9515
9812
  ),
9516
- combined
9813
+ combined.filter((t) => t.t === "character").map((t) => t.id)
9517
9814
  );
9518
9815
  const sceneById = sceneFor(brandJson);
9519
9816
  const uncapped = { ...engineCaps, maxReferenceImages: 32 };
@@ -9640,18 +9937,22 @@ function buildServer(opts) {
9640
9937
  referenceCount: edit.merged.kept.length
9641
9938
  };
9642
9939
  }
9643
- const brandJson = await brandJsonWithResolvedPresenters(
9940
+ const brandJson = await brandJsonWithIdentityCrops(
9644
9941
  core,
9645
- templatesRoot,
9646
- presenters,
9647
- await brandJsonWithResolvedDemoProducts(
9942
+ await brandJsonWithResolvedPresenters(
9648
9943
  core,
9649
9944
  templatesRoot,
9650
- demoProducts,
9651
- brandJsonWithCatalogProducts(core, brand.id),
9945
+ presenters,
9946
+ await brandJsonWithResolvedDemoProducts(
9947
+ core,
9948
+ templatesRoot,
9949
+ demoProducts,
9950
+ brandJsonWithCatalogProducts(core, brand.id),
9951
+ brief.tokens
9952
+ ),
9652
9953
  brief.tokens
9653
9954
  ),
9654
- brief.tokens
9955
+ (brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
9655
9956
  );
9656
9957
  const sceneById = sceneFor(brandJson);
9657
9958
  const compiled2 = compileBrief(brief, {
@@ -9795,7 +10096,7 @@ function buildServer(opts) {
9795
10096
  let watchdogFired = false;
9796
10097
  const watchdog = setTimeout(() => {
9797
10098
  watchdogFired = true;
9798
- ctrl.abort();
10099
+ ctrl.abort(BUDGET_EXHAUSTED);
9799
10100
  }, bound);
9800
10101
  const startedAt = Date.now();
9801
10102
  try {
@@ -9858,33 +10159,15 @@ function buildServer(opts) {
9858
10159
  if (!project) return reply.status(404).send({ error: "project not found" });
9859
10160
  const rawReshape = req.body.reshape ?? req.body.brief?.reshape;
9860
10161
  const reshape = rawReshape === "crop" ? "crop" : rawReshape === "extend" ? "extend" : void 0;
9861
- if (kind === "edit" && reshape === "crop") {
9862
- const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
9863
- if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
9864
- const cropParentId = parentId ? String(parentId) : rootForCrop.id;
9865
- if (brief && Array.isArray(brief.tokens)) {
9866
- const briefErrors = validateBrief(brief);
9867
- if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
9868
- }
9869
- const fmt = Array.isArray(brief?.tokens) ? brief.tokens.find(
9870
- (t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
9871
- ) : void 0;
9872
- if (!fmt) return reply.status(400).send({ error: "a crop needs a target format" });
9873
- const parent = core.store.getNode(cropParentId);
9874
- const srcHash = req.body.sourceImage ?? parent?.images[0];
9875
- if (!srcHash || !core.images.has(String(srcHash)))
9876
- return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
9877
- const srcBuf = core.images.read(String(srcHash));
9878
- const srcMeta = await sharp20(srcBuf).metadata();
9879
- if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
9880
- const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
10162
+ const runCropNode = async (args) => {
10163
+ const plan2 = planCrop(args.srcSize, Number(args.fmt.w) / Number(args.fmt.h));
9881
10164
  if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
9882
- const origin = await attentionCropOrigin(srcBuf, { width: srcMeta.width, height: srcMeta.height }, plan2);
10165
+ const origin = await attentionCropOrigin(args.srcBuf, args.srcSize, plan2);
9883
10166
  const window = { left: origin.left, top: origin.top, width: plan2.width, height: plan2.height };
9884
- const label = FORMATS.find((f) => f.id === fmt.id)?.label ?? `${fmt.w}x${fmt.h}`;
10167
+ const label = FORMATS.find((f) => f.id === args.fmt.id)?.label ?? `${args.fmt.w}x${args.fmt.h}`;
9885
10168
  const node2 = core.store.addNode({
9886
10169
  projectId: project.id,
9887
- parentId: cropParentId,
10170
+ parentId: args.parentId,
9888
10171
  kind: "edit",
9889
10172
  prompt: `Cropped to ${label}`,
9890
10173
  // No provider was asked; recording the engine the client HAPPENED to
@@ -9893,18 +10176,45 @@ function buildServer(opts) {
9893
10176
  });
9894
10177
  core.store.setBrief(node2.id, {
9895
10178
  ...briefInputsOnly(brief ?? {}),
9896
- sourceImage: String(srcHash),
10179
+ sourceImage: args.srcHash,
9897
10180
  reshape: "crop",
9898
10181
  crop: window
9899
10182
  });
9900
10183
  const work2 = async () => ({
9901
- images: [core.images.save(await sharp20(srcBuf).extract(window).png().toBuffer())],
10184
+ images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
9902
10185
  costUsd: 0
9903
10186
  });
9904
10187
  void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
9905
10188
  (err) => app.log.error({ err }, "crop run failed")
9906
10189
  );
9907
- return reply.status(202).send(node2);
10190
+ return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
10191
+ };
10192
+ if (kind === "edit" && reshape === "crop") {
10193
+ const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
10194
+ if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
10195
+ const cropParentId = parentId ? String(parentId) : rootForCrop.id;
10196
+ if (brief && Array.isArray(brief.tokens)) {
10197
+ const briefErrors = validateBrief(brief);
10198
+ if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
10199
+ }
10200
+ const fmt = Array.isArray(brief?.tokens) ? brief.tokens.find(
10201
+ (t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
10202
+ ) : void 0;
10203
+ if (!fmt) return reply.status(400).send({ error: "a crop needs a target format" });
10204
+ const parent = core.store.getNode(cropParentId);
10205
+ const srcHash = req.body.sourceImage ?? parent?.images[0];
10206
+ if (!srcHash || !core.images.has(String(srcHash)))
10207
+ return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
10208
+ const srcBuf = core.images.read(String(srcHash));
10209
+ const srcMeta = await sharp20(srcBuf).metadata();
10210
+ if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
10211
+ return runCropNode({
10212
+ parentId: cropParentId,
10213
+ fmt,
10214
+ srcHash: String(srcHash),
10215
+ srcBuf,
10216
+ srcSize: { width: srcMeta.width, height: srcMeta.height }
10217
+ });
9908
10218
  }
9909
10219
  const engine = engines.get(String(engineId));
9910
10220
  if (!engine) return reply.status(400).send({ error: `unknown engine ${engineId}` });
@@ -9925,6 +10235,9 @@ function buildServer(opts) {
9925
10235
  let sentSize;
9926
10236
  const extraWarnings = [];
9927
10237
  let expandPlan = null;
10238
+ let expandSent = null;
10239
+ let expandAssist = null;
10240
+ let expandWorkHash = null;
9928
10241
  let expandSourceHash = null;
9929
10242
  if (brief && Array.isArray(brief.tokens)) {
9930
10243
  const briefErrors = validateBrief(brief);
@@ -9944,18 +10257,22 @@ function buildServer(opts) {
9944
10257
  if (!compiled2.prompt.trim() && reshape !== "extend")
9945
10258
  return reply.status(400).send({ error: "the brief is empty" });
9946
10259
  } else {
9947
- const brandJson = await brandJsonWithResolvedPresenters(
10260
+ const brandJson = await brandJsonWithIdentityCrops(
9948
10261
  core,
9949
- templatesRoot,
9950
- presenters,
9951
- await brandJsonWithResolvedDemoProducts(
10262
+ await brandJsonWithResolvedPresenters(
9952
10263
  core,
9953
10264
  templatesRoot,
9954
- demoProducts,
9955
- brandJsonWithCatalogProducts(core, project.brandId),
10265
+ presenters,
10266
+ await brandJsonWithResolvedDemoProducts(
10267
+ core,
10268
+ templatesRoot,
10269
+ demoProducts,
10270
+ brandJsonWithCatalogProducts(core, project.brandId),
10271
+ brief.tokens
10272
+ ),
9956
10273
  brief.tokens
9957
10274
  ),
9958
- brief.tokens
10275
+ (brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
9959
10276
  );
9960
10277
  const sceneById = sceneFor(brandJson);
9961
10278
  compiled2 = compileBrief(brief, {
@@ -10021,6 +10338,7 @@ function buildServer(opts) {
10021
10338
  }
10022
10339
  if (kind === "generation") {
10023
10340
  const cap2 = engine.capabilities().maxReferenceImages;
10341
+ const wantedCount = Math.min(Math.max(1, Number(count)), 8);
10024
10342
  const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
10025
10343
  if (lostIdentity.length) {
10026
10344
  const names = joinNames(lostIdentity.map((d) => d.label));
@@ -10032,14 +10350,23 @@ function buildServer(opts) {
10032
10350
  const maxEdge = engine.capabilities().maxReferenceEdge;
10033
10351
  const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
10034
10352
  const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
10353
+ const sentRoles = referenceRoles && cap2 > 0 ? referenceRoles.slice(0, cap2) : referenceRoles ?? [];
10354
+ const briefText = Array.isArray(brief?.tokens) ? brief.tokens.filter((t) => t?.t === "text").map((t) => String(t?.v ?? "")).join(" ") : String(prompt ?? "");
10355
+ const variations = variationPlan(wantedCount, {
10356
+ hasPresenter: sentRoles.includes("character"),
10357
+ hasProduct: sentRoles.includes("product"),
10358
+ hasMark: sentRoles.includes("brand"),
10359
+ cameraFixed: shotSpecifiesCamera(briefText)
10360
+ });
10035
10361
  const genReq = {
10036
10362
  prompt: finalPrompt,
10037
10363
  brand: ctx,
10038
10364
  width: Number(width),
10039
10365
  height: Number(height),
10040
- count: Math.min(Math.max(1, Number(count)), 8),
10366
+ count: wantedCount,
10041
10367
  ...sentRefs ? { referenceImages: sentRefs } : {},
10042
- ...referenceRoles && cap2 > 0 ? { referenceRoles: referenceRoles.slice(0, cap2) } : {}
10368
+ ...sentRoles.length && cap2 > 0 ? { referenceRoles: sentRoles } : {},
10369
+ ...variations.length ? { variations } : {}
10043
10370
  };
10044
10371
  estimate = await engine.costEstimate(genReq);
10045
10372
  work = (signal) => engine.generate(genReq, signal);
@@ -10065,14 +10392,41 @@ function buildServer(opts) {
10065
10392
  width: srcMeta.width,
10066
10393
  height: srcMeta.height
10067
10394
  });
10068
- if (reshapeIntended && srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
10069
- expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
10395
+ let workBuf = srcBuf;
10396
+ let workSize = srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : null;
10397
+ if (reshapeIntended && workSize && compiled2?.width && compiled2?.height) {
10398
+ const targetRatio = compiled2.width / compiled2.height;
10399
+ const decision = classifyReshape(workSize, targetRatio, reshape);
10400
+ if (decision.op === "crop") {
10401
+ if (reshape === "extend")
10402
+ return reply.status(400).send({
10403
+ error: `growing a ${workSize.width}x${workSize.height} frame to this shape would invent more of the photograph than it keeps; crop instead`
10404
+ });
10405
+ const fmt = (brief?.tokens ?? []).find(
10406
+ (t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
10407
+ );
10408
+ if (fmt)
10409
+ return runCropNode({
10410
+ parentId: resolvedParentId,
10411
+ fmt,
10412
+ srcHash: String(srcHash),
10413
+ srcBuf,
10414
+ srcSize: workSize,
10415
+ note: decision.forced ? "That shape is further than one extend can reach, so the picture was cropped to it instead." : void 0
10416
+ });
10417
+ } else if (decision.op === "extend") {
10418
+ if (decision.assist) {
10419
+ expandAssist = { width: decision.assist.width, height: decision.assist.height };
10420
+ workBuf = await sharp20(srcBuf).extract(decision.assist).png().toBuffer();
10421
+ workSize = { width: decision.assist.width, height: decision.assist.height };
10422
+ }
10423
+ expandPlan = planExpand(workSize, targetRatio);
10424
+ }
10070
10425
  }
10071
10426
  if (reshape === "extend" && !expandPlan)
10072
10427
  return reply.status(400).send({ error: "the picture is already this shape" });
10073
- if (expandPlan && srcMeta.width && srcMeta.height) {
10074
- const size = { width: srcMeta.width, height: srcMeta.height };
10075
- expandPlan = placeExpand(expandPlan, size, await subjectFraction(srcBuf, size, expandPlan.axis));
10428
+ if (expandPlan && workSize) {
10429
+ expandPlan = placeExpand(expandPlan, workSize, await subjectFraction(workBuf, workSize, expandPlan.axis));
10076
10430
  }
10077
10431
  if (expandPlan) {
10078
10432
  const route = await resolveOutpaintRoute(engines.all(), engine);
@@ -10080,11 +10434,25 @@ function buildServer(opts) {
10080
10434
  expandMethod = route.method;
10081
10435
  }
10082
10436
  const canOutpaint2 = expandMethod === "outpaint";
10437
+ if (expandPlan && workSize) {
10438
+ const fit = fitExpandToBudget(expandPlan, workSize, runEngine.capabilities().editPixelBudget);
10439
+ if (fit.scale < 1) {
10440
+ expandPlan = fit.plan;
10441
+ workBuf = await sharp20(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
10442
+ workSize = fit.source;
10443
+ extraWarnings.push(
10444
+ `${runEngine.capabilities().displayName} draws about ${((runEngine.capabilities().editPixelBudget ?? 0) / 1e6).toFixed(1)} megapixels, so this shape continues as a ${fit.plan.width}x${fit.plan.height} frame with the photograph riding inside it at ${fit.source.width}x${fit.source.height}. Nothing is upscaled; the stored size is the size the engine truly drew.`
10445
+ );
10446
+ }
10447
+ expandSent = workSize;
10448
+ }
10083
10449
  let reframeSourceHash;
10084
10450
  if (expandPlan) {
10085
10451
  if (!canOutpaint2) {
10086
- expandSourceHash = core.images.save(await expandCanvas(srcBuf, expandPlan));
10087
- reframeSourceHash = core.images.save(await conditioningCanvas(srcBuf, expandPlan, "edge"));
10452
+ expandSourceHash = core.images.save(await expandCanvas(workBuf, expandPlan));
10453
+ reframeSourceHash = core.images.save(await conditioningCanvas(workBuf, expandPlan, "edge"));
10454
+ } else {
10455
+ expandWorkHash = core.images.save(workBuf);
10088
10456
  }
10089
10457
  expectShape = { width: expandPlan.width, height: expandPlan.height };
10090
10458
  }
@@ -10101,7 +10469,7 @@ function buildServer(opts) {
10101
10469
  }
10102
10470
  const editReq = {
10103
10471
  instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
10104
- sourceImage: core.images.pathFor(String(expandSourceHash ?? budgetSourceHash ?? srcHash)),
10472
+ sourceImage: core.images.pathFor(String(expandSourceHash ?? expandWorkHash ?? budgetSourceHash ?? srcHash)),
10105
10473
  brand: ctx,
10106
10474
  ...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
10107
10475
  ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
@@ -10118,8 +10486,10 @@ function buildServer(opts) {
10118
10486
  expand: {
10119
10487
  left: expandPlan.left,
10120
10488
  top: expandPlan.top,
10121
- width: srcMeta.width ?? 0,
10122
- height: srcMeta.height ?? 0
10489
+ // The sent copy's own size: after crop assist and the budget
10490
+ // fit these are the pixels the offsets actually refer to.
10491
+ width: workSize?.width ?? srcMeta.width ?? 0,
10492
+ height: workSize?.height ?? srcMeta.height ?? 0
10123
10493
  },
10124
10494
  // Derived from the picture and the shape asked for, so the same
10125
10495
  // extend of the same shot is the same picture every time. Without
@@ -10131,8 +10501,8 @@ function buildServer(opts) {
10131
10501
  };
10132
10502
  estimate = await runEngine.costEstimate(editReq);
10133
10503
  const plan2 = expandPlan;
10134
- const srcSize = { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
10135
- const original2 = srcBuf;
10504
+ const srcSize = workSize ?? { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
10505
+ const original2 = workBuf;
10136
10506
  const reframeReq = plan2 && !canOutpaint2 && reframeSourceHash ? {
10137
10507
  ...editReq,
10138
10508
  instruction: reframeInstruction(plan2, srcSize, finalPrompt),
@@ -10202,7 +10572,13 @@ function buildServer(opts) {
10202
10572
  // Placement is no longer always centred, so a reader that
10203
10573
  // assumes it is would be looking in the wrong place.
10204
10574
  left: expandPlan.left,
10205
- top: expandPlan.top
10575
+ top: expandPlan.top,
10576
+ // The planned frame and the size the photograph was sent at,
10577
+ // so requested-versus-drawn is a readable fact — and the
10578
+ // assist window when a slice of the other axis was given up.
10579
+ frame: [expandPlan.width, expandPlan.height],
10580
+ ...expandSent ? { source: [expandSent.width, expandSent.height] } : {},
10581
+ ...expandAssist ? { assist: [expandAssist.width, expandAssist.height] } : {}
10206
10582
  }
10207
10583
  } : {},
10208
10584
  // What the refinement carried, recorded apart from what it asked for:
@@ -10275,7 +10651,8 @@ function buildServer(opts) {
10275
10651
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
10276
10652
  "expand: engine size differs from plan"
10277
10653
  );
10278
- const { image, aligned } = await compositeExpand(answer, original, plan);
10654
+ const pasted = expandWorkHash ? core.images.read(expandWorkHash) : original;
10655
+ const { image, aligned } = await compositeExpand(answer, pasted, plan);
10279
10656
  if (!aligned) app.log.warn({ nodeId: node.id }, "expand: engine frame did not align, kept the bed");
10280
10657
  out.push(core.images.save(image));
10281
10658
  }
@@ -10328,7 +10705,8 @@ function buildServer(opts) {
10328
10705
  }
10329
10706
  return enforceEditCanvas(staged);
10330
10707
  } : 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;
10708
+ const runCaps = runEngine.capabilities();
10709
+ 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
10710
  void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
10333
10711
  (err) => app.log.error({ err }, "node run failed")
10334
10712
  );