scenri 0.6.13 → 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 +35 -0
  2. package/dist/serve.js +665 -179
  3. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -10,7 +10,7 @@ import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, write
10
10
  import { fileURLToPath } from 'url';
11
11
  import { readFile, copyFile, stat, mkdtemp, rm, readdir, writeFile } from 'fs/promises';
12
12
  import { spawn } from 'child_process';
13
- import sharp19 from 'sharp';
13
+ import sharp20 from 'sharp';
14
14
  import Fastify from 'fastify';
15
15
  import fastifyStatic from '@fastify/static';
16
16
  import fastifyMultipart from '@fastify/multipart';
@@ -1295,7 +1295,13 @@ var REFERENCE_ROLE_DIRECTIVE = {
1295
1295
  // names staged objects as stand-ins rather than just "no product": a bare
1296
1296
  // prohibition still left the demo object in the frame, because the model had
1297
1297
  // nowhere to put what the photograph so vividly showed.
1298
- scene: "a reference for this world and for the treatment applied to the figure in it \u2014 match the environment, the light, and the material, density, scale, finish and spread of that treatment, including which parts of the form it covers and how far it reaches; take no identity from the person in it, and treat any product, garment or prop staged in it as a stand-in whose place the attached subject takes \u2014 never an object to reproduce",
1298
+ //
1299
+ // The carve-out leads. It used to sit forty words in, one subordinate
1300
+ // clause after a paragraph of "match this" - and the tester case (a close
1301
+ // portrait as the scene image beside a selected presenter) showed which
1302
+ // half the model heard. Every treatment clause is retained word for word;
1303
+ // only the order and the register of the identity refusal changed.
1304
+ scene: "a reference for this world, never for a person: take no identity from the person in it \u2014 not their face, not their likeness \u2014 they are an anonymous stand-in whose place the attached subject takes. Match the environment, the light, and the material, density, scale, finish and spread of the treatment applied to the figure, including which parts of the form it covers and how far it reaches; treat any product, garment or prop staged in it as the same kind of stand-in, demonstrating placement and scale \u2014 never an object to reproduce",
1299
1305
  composition: "a reference for framing, camera angle and pose only \u2014 take no subject, color, material or branding from it",
1300
1306
  style: "a reference for overall treatment and mood only \u2014 take no composition, subject or product detail from it",
1301
1307
  reference: "a reference to match in composition, lighting and treatment"
@@ -1304,11 +1310,12 @@ var EDIT_REFERENCE_ROLE_DIRECTIVE = {
1304
1310
  product: "the exact product: keep or restore its label, shape and design faithfully",
1305
1311
  character: "the exact person: keep their face, facial structure, skin, hair and build faithfully; take no clothing, pose or background from this reference, and keep the source image's existing outfit unless the instruction changes it",
1306
1312
  brand: "the brand's own mark: reproduce it exactly as drawn wherever it appears \u2014 every character down to the smallest secondary lettering, in its original script and reading direction \u2014 never redrawn, re-lettered, translated or transliterated",
1307
- scene: "a reference for environment and light only",
1313
+ scene: "a reference for environment, light and treatment only \u2014 take no identity from any person in it",
1308
1314
  composition: "a reference for framing and pose only",
1309
1315
  style: "a reference for treatment and mood only",
1310
1316
  reference: "a reference for composition, lighting and treatment only"
1311
1317
  };
1318
+ var BUDGET_EXHAUSTED = "scenri:budget-exhausted";
1312
1319
  var ASPECT_TOLERANCE = 0.15;
1313
1320
  var NAMED_RATIOS = [
1314
1321
  ["1:1", 1],
@@ -1332,6 +1339,14 @@ function ratioLabel(width, height) {
1332
1339
  const d = gcd(width, height) || 1;
1333
1340
  return `${Math.round(width / d)}:${Math.round(height / d)}`;
1334
1341
  }
1342
+ function budgetSize(width, height, pixelBudget) {
1343
+ const ratio = width / height;
1344
+ if (!(ratio > 0) || !Number.isFinite(ratio) || !(pixelBudget > 0)) return { width, height };
1345
+ return {
1346
+ width: Math.round(Math.sqrt(pixelBudget * ratio)),
1347
+ height: Math.round(Math.sqrt(pixelBudget / ratio))
1348
+ };
1349
+ }
1335
1350
 
1336
1351
  // ../core/src/index.ts
1337
1352
  function defaultHome() {
@@ -1349,6 +1364,7 @@ function createCore(homeDir = defaultHome()) {
1349
1364
  };
1350
1365
  }
1351
1366
  var ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
1367
+ var PER_IMAGE_TIMEOUT_MS = 3e5;
1352
1368
  var DEFAULT_MODEL = "google/gemini-2.5-flash-image";
1353
1369
  var DEFAULT_COST_PER_IMAGE_USD = 0.04;
1354
1370
  function dataUrl(path) {
@@ -1387,6 +1403,7 @@ function createOpenRouterEngine(opts) {
1387
1403
  return key;
1388
1404
  }
1389
1405
  async function post(key, body, signal) {
1406
+ const bound = AbortSignal.timeout(PER_IMAGE_TIMEOUT_MS);
1390
1407
  const res = await fetchImpl(ENDPOINT, {
1391
1408
  method: "POST",
1392
1409
  headers: {
@@ -1394,7 +1411,7 @@ function createOpenRouterEngine(opts) {
1394
1411
  "Content-Type": "application/json"
1395
1412
  },
1396
1413
  body: JSON.stringify(body),
1397
- signal
1414
+ signal: signal ? AbortSignal.any([signal, bound]) : bound
1398
1415
  });
1399
1416
  const text = await res.text();
1400
1417
  if (!res.ok) {
@@ -1437,7 +1454,11 @@ function createOpenRouterEngine(opts) {
1437
1454
  localOnly: false,
1438
1455
  supportsEdit: true,
1439
1456
  supportsMask: false,
1440
- 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
1441
1462
  };
1442
1463
  },
1443
1464
  async isAvailable() {
@@ -1458,12 +1479,14 @@ function createOpenRouterEngine(opts) {
1458
1479
  const role = roles[i] ?? "reference";
1459
1480
  return refs.length > 1 ? `Attached image ${i + 1} is ${roleDirective[role]}.` : `The attached image is ${roleDirective[role]}.`;
1460
1481
  }).join(" ");
1482
+ const identityCoda = roles.includes("character") && roles.some((r) => r === "scene" || r === "reference") ? "Identity check: the person in this image comes only from the character reference image(s); the scene and reference images lend world, composition, lighting and treatment \u2014 take no face or likeness from them." : null;
1461
1483
  const content = [
1462
1484
  { type: "text", text: refDirectives ? `${req.prompt} ${refDirectives}` : req.prompt },
1463
1485
  ...refs.map((p) => ({
1464
1486
  type: "image_url",
1465
1487
  image_url: { url: dataUrl(p) }
1466
- }))
1488
+ })),
1489
+ ...identityCoda ? [{ type: "text", text: identityCoda }] : []
1467
1490
  ];
1468
1491
  const body = {
1469
1492
  model: opts.model ?? DEFAULT_MODEL,
@@ -1476,7 +1499,12 @@ function createOpenRouterEngine(opts) {
1476
1499
  let reportedCost = 0;
1477
1500
  let sawReportedCost = false;
1478
1501
  for (let i = 0; i < req.count; i++) {
1479
- 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
+ );
1480
1508
  raws.push(json);
1481
1509
  for (const buf of extractImages(json)) hashes.push(opts.saveImage(buf));
1482
1510
  if (typeof json?.usage?.cost === "number") {
@@ -2511,20 +2539,12 @@ function createCodexSetup(opts = {}) {
2511
2539
 
2512
2540
  // ../engines/codex/src/index.ts
2513
2541
  var CODEX_POOL = 2;
2514
- function codexNodeBudgetMs(count) {
2515
- return Math.ceil(Math.max(1, count) / CODEX_POOL) * DEFAULT_TIMEOUT_MS2 + 6e4;
2516
- }
2517
2542
  function orientationOf(width, height) {
2518
2543
  return width === height ? "square" : width > height ? "landscape" : "portrait";
2519
2544
  }
2520
2545
  var CODEX_PIXEL_BUDGET = 1572864;
2521
2546
  function codexNativeSize(width, height) {
2522
- const ratio = width / height;
2523
- if (!(ratio > 0) || !Number.isFinite(ratio)) return { width, height };
2524
- return {
2525
- width: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET * ratio)),
2526
- height: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET / ratio))
2527
- };
2547
+ return budgetSize(width, height, CODEX_PIXEL_BUDGET);
2528
2548
  }
2529
2549
  function refFileNames(roles, count) {
2530
2550
  const perRole = /* @__PURE__ */ new Map();
@@ -2550,7 +2570,7 @@ function createCodexEngine(opts) {
2550
2570
  return /* @__PURE__ */ new Set();
2551
2571
  }
2552
2572
  }
2553
- async function collectImages(dir, before = null) {
2573
+ async function collectImages(dir, before = null, claimed) {
2554
2574
  const entries = await readdir(dir);
2555
2575
  const outFiles = entries.filter((name) => /^out-.*\.png$/.test(name)).sort((a, b) => {
2556
2576
  const na = Number(/^out-(\d+)\.png$/.exec(a)?.[1] ?? NaN);
@@ -2560,7 +2580,7 @@ function createCodexEngine(opts) {
2560
2580
  });
2561
2581
  if (outFiles.length === 0) {
2562
2582
  if (before) {
2563
- const recovered = await recoverFromGenerated(before);
2583
+ const recovered = await recoverFromGenerated(before, claimed);
2564
2584
  if (recovered) return [recovered];
2565
2585
  }
2566
2586
  throw new Error("Codex finished but produced no images");
@@ -2573,11 +2593,11 @@ function createCodexEngine(opts) {
2573
2593
  }
2574
2594
  return hashes;
2575
2595
  }
2576
- async function recoverFromGenerated(before) {
2596
+ async function recoverFromGenerated(before, claimed) {
2577
2597
  const home = generatedImagesDir();
2578
2598
  let names;
2579
2599
  try {
2580
- names = (await readdir(home)).filter((n) => !before.has(n));
2600
+ names = (await readdir(home)).filter((n) => !before.has(n) && !claimed?.has(n));
2581
2601
  } catch {
2582
2602
  return null;
2583
2603
  }
@@ -2585,6 +2605,7 @@ function createCodexEngine(opts) {
2585
2605
  const stamped = await Promise.all(names.map(async (n) => ({ n, mtime: (await stat(join(home, n))).mtimeMs })));
2586
2606
  stamped.sort((a, b) => b.mtime - a.mtime);
2587
2607
  const pick2 = stamped[0].n;
2608
+ claimed?.add(pick2);
2588
2609
  console.warn(`codex: workdir empty, recovered ${pick2} from ${home}`);
2589
2610
  return saveImage(await readFile(join(home, pick2)));
2590
2611
  }
@@ -2597,6 +2618,10 @@ function createCodexEngine(opts) {
2597
2618
  // OSS-local only: the user's own session, on the user's own machine
2598
2619
  supportsEdit: true,
2599
2620
  supportsMask: false,
2621
+ // The image tool draws at this fixed pixel count (measured, the
2622
+ // native-size probe): an edit of a larger source steps down honestly
2623
+ // instead of being upscaled back into pixels the tool never drew.
2624
+ editPixelBudget: CODEX_PIXEL_BUDGET,
2600
2625
  /*
2601
2626
  * Five, and it is a hard constraint of the image tool, not a product
2602
2627
  * choice.
@@ -2630,7 +2655,12 @@ function createCodexEngine(opts) {
2630
2655
  * budget — a full-resolution phone-photo PNG is tens of megabytes that
2631
2656
  * buy nothing. Same cap as brand marks (MARK_MAX_EDGE).
2632
2657
  */
2633
- 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
2634
2664
  };
2635
2665
  },
2636
2666
  isAvailable() {
@@ -2644,8 +2674,9 @@ function createCodexEngine(opts) {
2644
2674
  const refs = req.referenceImages ?? [];
2645
2675
  const roles = req.referenceRoles ?? refs.map(() => "reference");
2646
2676
  const inner = new AbortController();
2647
- const onOuterAbort = () => inner.abort();
2648
- 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);
2649
2680
  else signal?.addEventListener("abort", onOuterAbort, { once: true });
2650
2681
  const jobs = Array.from(
2651
2682
  { length: count },
@@ -2664,7 +2695,7 @@ function createCodexEngine(opts) {
2664
2695
  stdin: buildPrompt2(req, i, roles),
2665
2696
  label: `gen v${i + 1}/${count} refs=${refs.length} refKB=${Math.round(refBytes / 1024)}`
2666
2697
  });
2667
- return collectImages(dir, before);
2698
+ return collectImages(dir, before, claimed);
2668
2699
  })
2669
2700
  );
2670
2701
  const results = new Array(count);
@@ -2678,7 +2709,7 @@ function createCodexEngine(opts) {
2678
2709
  try {
2679
2710
  results[i] = await jobs[i]();
2680
2711
  } catch (err) {
2681
- if (signal?.aborted) throw err;
2712
+ if (signal?.aborted && signal.reason !== BUDGET_EXHAUSTED) throw err;
2682
2713
  results[i] = [];
2683
2714
  failures.push(err);
2684
2715
  if (fatal == null && isFatalSetupError(err)) {
@@ -2761,10 +2792,10 @@ function createCodexEngine(opts) {
2761
2792
  );
2762
2793
  }
2763
2794
  function buildPrompt2(req, index, roles) {
2795
+ const variation = req.variations?.[index] ?? "";
2764
2796
  const roleDirective = REFERENCE_ROLE_DIRECTIVE;
2765
2797
  const names = refFileNames(roles, roles.length);
2766
2798
  const refDirectives = roles.map((role, i) => `${names[i]} shows ${roleDirective[role]}.`).join(" ");
2767
- const count = Math.max(1, req.count);
2768
2799
  const native = codexNativeSize(req.width, req.height);
2769
2800
  return (
2770
2801
  // "professional-grade", not "flawless": the audit of the waxy-presenter
@@ -2778,13 +2809,19 @@ function createCodexEngine(opts) {
2778
2809
  // to the requested one, and the aspect check passed BECAUSE of the shear
2779
2810
  // - the reported crushed faces. Copy/move stays licensed because the
2780
2811
  // win32 recovery path moves files out of generated_images.
2781
- ` 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
2782
- // nothing - so the first output was literally asked for the most
2783
- // reference-faithful decode - and later takes were licensed to a
2784
- // "different composition", which read as permission to drift from the
2785
- // directives. Reported as: output #1 copies the scene reference, output
2786
- // #2 mixes identities. A single generation stays byte-stable.
2787
- (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}` : "")
2788
2825
  );
2789
2826
  }
2790
2827
  }
@@ -2831,7 +2868,7 @@ function createDemoEngine(saveImage) {
2831
2868
  <text x="24" y="${h - 48}" font-family="Helvetica, Arial" font-size="${Math.max(14, Math.round(w / 42))}" fill="#ffffff" opacity="0.92">${esc(label)}</text>
2832
2869
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
2833
2870
  </svg>`;
2834
- return sharp19(Buffer.from(svg)).png().toBuffer();
2871
+ return sharp20(Buffer.from(svg)).png().toBuffer();
2835
2872
  }
2836
2873
  return {
2837
2874
  capabilities() {
@@ -3000,14 +3037,22 @@ function presenterRefPath(templatesRoot, id, slot) {
3000
3037
  function presenterAvatarPath(templatesRoot, id) {
3001
3038
  return contentFile(templatesRoot, "previews", "presenters", id, "avatar.jpg");
3002
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
+ }
3003
3048
  async function resolvePresenterImages(core, templatesRoot, presenter) {
3004
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 });
3005
3052
  for (const [slot, angle] of PRESENTER_ANGLES) {
3006
3053
  const path = presenterRefPath(templatesRoot, presenter.id, slot);
3007
3054
  if (!existsSync(path)) continue;
3008
- const png = await sharp19(readFileSync(path)).png().toBuffer();
3009
- const hash = core.images.save(png);
3010
- shots.push({ file: `asset:${hash}`, angle, locked: true });
3055
+ shots.push({ file: `asset:${await refHash(core, path)}`, angle, locked: true });
3011
3056
  }
3012
3057
  if (!shots.length) return null;
3013
3058
  return {
@@ -3016,6 +3061,8 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
3016
3061
  ...presenter.identityNotes ? { identityNotes: presenter.identityNotes } : {},
3017
3062
  ...presenter.negativeConstraints?.length ? { negativeConstraints: presenter.negativeConstraints } : {},
3018
3063
  ...presenter.skin ? { skin: presenter.skin } : {},
3064
+ ...presenter.facial ? { facial: presenter.facial } : {},
3065
+ ...presenter.build ? { build: presenter.build } : {},
3019
3066
  shots
3020
3067
  };
3021
3068
  }
@@ -3100,7 +3147,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
3100
3147
  for (const angle of angles) {
3101
3148
  const path = demoProductRefPath(templatesRoot, product.id, angle);
3102
3149
  if (!existsSync(path)) continue;
3103
- const png = await sharp19(readFileSync(path)).png().toBuffer();
3150
+ const png = await sharp20(readFileSync(path)).png().toBuffer();
3104
3151
  const hash = core.images.save(png);
3105
3152
  shots.push({ file: `asset:${hash}`, angle, locked: true });
3106
3153
  }
@@ -3239,7 +3286,7 @@ var assetHash = (ref) => {
3239
3286
  };
3240
3287
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
3241
3288
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
3242
- var toPng = (buf) => sharp19(buf).rotate().png().toBuffer();
3289
+ var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
3243
3290
  var COST_PROBE = {
3244
3291
  prompt: "",
3245
3292
  brand: { brand: {}, assetPaths: {} },
@@ -3252,11 +3299,11 @@ var MARK_MIN_EDGE = 1024;
3252
3299
  var MARK_TINY_EDGE = 256;
3253
3300
  var MARK_WARN_EDGE = 512;
3254
3301
  var toMarkPng = async (buf) => {
3255
- const out = await sharp19(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3256
- const meta = await sharp19(out).metadata();
3302
+ const out = await sharp20(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3303
+ const meta = await sharp20(out).metadata();
3257
3304
  const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
3258
3305
  if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
3259
- return sharp19(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
3306
+ return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
3260
3307
  }
3261
3308
  return out;
3262
3309
  };
@@ -3267,12 +3314,13 @@ async function capReferenceEdge(core, path, maxEdge) {
3267
3314
  if (hit) return hit;
3268
3315
  let out = path;
3269
3316
  try {
3270
- const meta = await sharp19(path).metadata();
3317
+ const meta = await sharp20(path).metadata();
3271
3318
  if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
3272
- const buf = await sharp19(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3319
+ const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3273
3320
  out = core.images.pathFor(core.images.save(buf));
3274
3321
  }
3275
3322
  } catch {
3323
+ return path;
3276
3324
  }
3277
3325
  cappedRefs.set(key, out);
3278
3326
  return out;
@@ -3306,12 +3354,21 @@ function productFidelityDirective(attached) {
3306
3354
  function editPreservationDirective(scope, opts) {
3307
3355
  if (scope === "local") {
3308
3356
  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." : "";
3309
- return "This is a change to a photograph that already exists, not a new photograph. Return the same image with one change made. Everything the instruction does not name comes back exactly as it is now: the same framing, the same crop, the same camera position, the same subject placement and pose, the same lighting, the same colours, the same background and the same dimensions. Do not re-render, re-stage, re-light or re-compose the picture. Change only what was asked for, together with the shadows, reflections and contact points that move with it." + removal;
3357
+ return "This is a change to a photograph that already exists, not a new photograph. Return the same image with one change made. Everything the instruction does not name comes back exactly as it is now: the same framing, the same crop, the same camera position, the same subject placement and pose, the same lighting, the same colours, the same background and the same dimensions. Do not re-render, re-stage, re-light or re-compose the picture. Change only what was asked for, together with the shadows, reflections and contact points that move with it. Every surface keeps the texture it already has: no invented pattern, grain, weave or embossing on fabric, skin or walls that the photograph does not carry now." + removal;
3310
3358
  }
3311
- return "This is a change to a photograph that already exists, not a new photograph. Apply the instruction to the image you were given and keep what it does not name: the same subject and the same face, the same product with the same label, geometry and colour, and the same dimensions. Do not replace the subject and do not redesign the product.";
3359
+ return "This is a change to a photograph that already exists, not a new photograph. Apply the instruction to the image you were given and keep what it does not name: the same subject and the same face, the same product with the same label, geometry and colour, and the same dimensions. Do not replace the subject and do not redesign the product. Every surface keeps the texture it already has: no invented pattern, grain, weave or embossing on fabric, skin or walls that the photograph does not carry now.";
3360
+ }
3361
+ function inheritedIdentityDirective(kinds) {
3362
+ const product = kinds?.product ?? true;
3363
+ const person = kinds?.person ?? true;
3364
+ if (product && person)
3365
+ return "The attached product and person references are the same product and the same person that are already in this picture. Use them to hold that identity exact while you make the change, not as a reason to re-stage the shot.";
3366
+ if (product)
3367
+ return "The attached product references show the same product that is already in this picture. Use them to hold its identity exact while you make the change, not as a reason to re-stage the shot.";
3368
+ return "The attached person reference shows the same person who is already in this picture. Use it to hold their identity exact while you make the change, not as a reason to re-stage the shot.";
3312
3369
  }
3313
- function inheritedIdentityDirective() {
3314
- return "The extra attached references are the same product and the same person that are already in this picture. Use them to hold that identity exact while you make the change, not as a reason to re-stage the shot.";
3370
+ function inheritedRefDirective() {
3371
+ return "The carried reference is attached for composition, lighting and treatment only. Any person or product visible in it lends mood, never identity \u2014 nobody and nothing in this photograph takes a face, a body or a design from it.";
3315
3372
  }
3316
3373
  function productFactDirectives(p) {
3317
3374
  const out = [];
@@ -3340,6 +3397,9 @@ function productEditFidelityDirective(name) {
3340
3397
  function characterEditIdentityDirective(name) {
3341
3398
  return `${name} is the person in this photograph: keep them present and clearly visible. Match their face, facial structure, skin, hair and build to the attached person reference exactly. The reference's plain outfit and studio backdrop are capture conditions, not direction: keep the styling this photograph already has unless the instruction itself changes it, and never return them to the plain base layers they were photographed in.`;
3342
3399
  }
3400
+ function referenceIdentityGuard() {
3401
+ return "A reference shot lends its composition, lighting and treatment, never its cast: the attached presenter is the only source of person identity in this shot, and any person visible in a reference shot is a stand-in whose place the presenter takes. This holds even where the direction asks to use someone from a reference \u2014 the attached presenter is that someone.";
3402
+ }
3343
3403
  function markEditDirective() {
3344
3404
  return "The attached brand mark is this brand's own mark: wherever the logo appears or the instruction asks for it, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions \u2014 never redrawn or re-lettered. Every character it carries stays intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled.";
3345
3405
  }
@@ -3440,7 +3500,7 @@ function markLabel(brand, logo) {
3440
3500
 
3441
3501
  // src/brief.ts
3442
3502
  var PRODUCT_REF_MAX = 3;
3443
- var CHARACTER_REF_MAX = 2;
3503
+ var CHARACTER_REF_MAX = 3;
3444
3504
  var SCENE_REF_MAX = 1;
3445
3505
  var FORMATS = [
3446
3506
  { id: "square", label: "Square 1:1", w: 1024, h: 1024 },
@@ -3497,6 +3557,7 @@ function validateBrief(brief) {
3497
3557
  function compileBrief(brief, ctx) {
3498
3558
  const warnings = [];
3499
3559
  const attachments = [];
3560
+ const rawSceneFallback = [];
3500
3561
  const productDirectives = [];
3501
3562
  const personDirectives = [];
3502
3563
  const otherDirectives = [];
@@ -3529,17 +3590,25 @@ function compileBrief(brief, ctx) {
3529
3590
  append(p.promptName ?? p.name);
3530
3591
  const primary = tok.angle && p.shots?.find((s) => s.angle === tok.angle) || p.shots?.[0];
3531
3592
  const orderedShots = [primary, ...(p.shots ?? []).filter((s) => s && s !== primary)];
3532
- const phashes = [];
3593
+ const pshots = [];
3533
3594
  for (const s of orderedShots) {
3534
- if (phashes.length >= PRODUCT_REF_MAX) break;
3595
+ if (pshots.length >= PRODUCT_REF_MAX) break;
3535
3596
  const h = assetHash2(s?.file);
3536
- 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) } : {} });
3537
3599
  }
3538
- if (phashes.length) {
3539
- phashes.forEach((h, i) => {
3540
- 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
+ });
3541
3610
  });
3542
- productDirectives.push(productFidelityDirective(phashes.length));
3611
+ productDirectives.push(productFidelityDirective(pshots.length));
3543
3612
  productDirectives.push(...productFactDirectives(p));
3544
3613
  if (p.description && !p.dimensions)
3545
3614
  productDirectives.push(
@@ -3558,10 +3627,17 @@ function compileBrief(brief, ctx) {
3558
3627
  }
3559
3628
  hasPerson = true;
3560
3629
  append(c.promptName ?? c.name);
3561
- const chashes = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) => assetHash2(s?.file)).filter((h) => !!h && ctx.images.has(h));
3562
- if (chashes.length) {
3563
- chashes.forEach((chash, i) => {
3564
- 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
+ });
3565
3641
  });
3566
3642
  personDirectives.push(
3567
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.`
@@ -3574,6 +3650,11 @@ function compileBrief(brief, ctx) {
3574
3650
  personDirectives.push(
3575
3651
  `${c.promptName ?? c.name}'s skin, exactly as the reference photographs show it: ${c.skin}.`
3576
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}.`);
3577
3658
  } else {
3578
3659
  warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
3579
3660
  }
@@ -3633,10 +3714,14 @@ function compileBrief(brief, ctx) {
3633
3714
  inlineTemplates.push(t);
3634
3715
  append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
3635
3716
  if (ctx.mode !== "edit" && t.figure) {
3636
- for (const r of (t.refs ?? []).slice(0, SCENE_REF_MAX)) {
3637
- const h = assetHash2(r?.file);
3717
+ const plate = assetHash2(t.preview);
3718
+ const hasPlate = !!plate && ctx.images.has(plate);
3719
+ const candidates = hasPlate ? [plate] : (t.refs ?? []).slice(0, SCENE_REF_MAX).map((r) => assetHash2(r?.file));
3720
+ for (const h of candidates) {
3638
3721
  if (h && ctx.images.has(h)) {
3639
- attachments.push({ role: "scene", id: t.id, label: t.name, hash: h, essential: false });
3722
+ const a = { role: "scene", id: t.id, label: t.name, hash: h, essential: false };
3723
+ attachments.push(a);
3724
+ if (!hasPlate) rawSceneFallback.push(a);
3640
3725
  }
3641
3726
  }
3642
3727
  }
@@ -3687,6 +3772,12 @@ function compileBrief(brief, ctx) {
3687
3772
  }
3688
3773
  const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
3689
3774
  const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
3775
+ if (hasPerson && rawSceneFallback.length) {
3776
+ for (const a of rawSceneFallback) {
3777
+ const i = attachments.indexOf(a);
3778
+ if (i !== -1) attachments.splice(i, 1);
3779
+ }
3780
+ }
3690
3781
  const max = ctx.engineCaps.maxReferenceImages;
3691
3782
  const { kept, dropped } = allocateAttachments(attachments, max);
3692
3783
  const guard = scene ? sceneGuardDirectives({
@@ -3720,7 +3811,7 @@ function compileBrief(brief, ctx) {
3720
3811
  const brandLines = brandRuleDirectives(ctx.brand);
3721
3812
  const preservation = ctx.mode === "edit" ? [
3722
3813
  ...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
3723
- ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : [],
3814
+ ...ctx.inheritedIdentity ? [inheritedIdentityDirective(ctx.inheritedIdentity === true ? void 0 : ctx.inheritedIdentity)] : [],
3724
3815
  ...ctx.inheritedDirectives ?? []
3725
3816
  ] : [];
3726
3817
  const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
@@ -3728,6 +3819,7 @@ function compileBrief(brief, ctx) {
3728
3819
  const rec = (ctx.brand?.products ?? []).find((x) => x?.id === a.id);
3729
3820
  return String(rec?.category ?? "").toLowerCase() === "apparel";
3730
3821
  }) ? [garmentDisplayDirective()] : [];
3822
+ const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard()] : [];
3731
3823
  const allDirectives = [
3732
3824
  ...productDirectives,
3733
3825
  ...personDirectives,
@@ -3739,15 +3831,21 @@ function compileBrief(brief, ctx) {
3739
3831
  ...apparelUnworn,
3740
3832
  ...brandLines,
3741
3833
  ...guard,
3834
+ ...refGuard,
3742
3835
  ...preservation
3743
3836
  ];
3744
3837
  if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
3745
3838
  if (dropped.some((d) => d.role !== "scene")) {
3746
- const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))];
3747
- const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
3748
- warnings.push(
3749
- `${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
3839
+ const keptLabels = new Set(kept.map((a) => a.label));
3840
+ const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))].filter(
3841
+ (l) => !keptLabels.has(l)
3750
3842
  );
3843
+ if (names.length) {
3844
+ const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
3845
+ warnings.push(
3846
+ `${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
3847
+ );
3848
+ }
3751
3849
  }
3752
3850
  return {
3753
3851
  prompt: prompt.trim(),
@@ -5756,8 +5854,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
5756
5854
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
5757
5855
  return;
5758
5856
  }
5759
- const png = await sharp19(buf).rotate().png().toBuffer();
5760
- const meta = await sharp19(png).metadata();
5857
+ const png = await sharp20(buf).rotate().png().toBuffer();
5858
+ const meta = await sharp20(png).metadata();
5761
5859
  const hash = core.images.save(png);
5762
5860
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
5763
5861
  width: meta.width,
@@ -5939,7 +6037,8 @@ function presenterRecordFrom(input, base) {
5939
6037
  const has = (k) => input[k] !== void 0;
5940
6038
  const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
5941
6039
  if (!name) return { ok: false, error: "a presenter needs a name" };
5942
- 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 ?? [];
5943
6042
  if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
5944
6043
  const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
5945
6044
  const presenter = {
@@ -6077,6 +6176,26 @@ async function runBuild(deps, job, hashes, instruction, signal) {
6077
6176
  }
6078
6177
  }
6079
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
+ },
6080
6199
  {
6081
6200
  angle: "front",
6082
6201
  from: "sources",
@@ -6129,22 +6248,28 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
6129
6248
  }
6130
6249
  if (signal.aborted) throw new Error("cancelled");
6131
6250
  let shotHashes = hashes;
6251
+ let shotAngles = [];
6132
6252
  const warnings = [];
6133
6253
  if (deps.engine) {
6134
6254
  patch(job, { stage: "building", steps: STUDIO_FRAMES.length, message: "Building the studio views" });
6135
6255
  const built2 = await generateStudioSet(deps, job, whoIs(job.name, draft), sourcePaths, signal);
6136
- if (built2.length) shotHashes = built2;
6137
- 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.");
6138
6260
  } else {
6139
6261
  warnings.push("No engine could draw the studio views, so the photos are being used directly.");
6140
6262
  }
6141
6263
  if (signal.aborted) throw new Error("cancelled");
6142
6264
  patch(job, { stage: "saving", message: null });
6143
6265
  const generated = shotHashes !== hashes;
6144
- 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");
6145
6269
  const built = presenterRecordFrom({
6146
6270
  name: job.name,
6147
6271
  shotHashes,
6272
+ shotAngles,
6148
6273
  sourceHashes: hashes,
6149
6274
  previewHash,
6150
6275
  avatarHash,
@@ -6167,16 +6292,16 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
6167
6292
  stage: "done",
6168
6293
  step: job.steps,
6169
6294
  assetId: built.presenter.id,
6170
- previewHash: previewHash ?? shotHashes[0] ?? null,
6295
+ previewHash: previewHash ?? cardSource ?? null,
6171
6296
  warnings: [...job.warnings, ...warnings],
6172
6297
  finished: true
6173
6298
  });
6174
6299
  }
6175
6300
  async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6176
6301
  const engine = deps.engine;
6177
- if (!engine) return [];
6302
+ if (!engine) return { hashes: [], angles: [] };
6178
6303
  const caps = engine.capabilities();
6179
- if (!caps.maxReferenceImages) return [];
6304
+ if (!caps.maxReferenceImages) return { hashes: [], angles: [] };
6180
6305
  const byAngle = /* @__PURE__ */ new Map();
6181
6306
  for (const frame of STUDIO_FRAMES) {
6182
6307
  if (signal.aborted) throw new Error("cancelled");
@@ -6203,10 +6328,11 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6203
6328
  patch(job, { warnings: [...job.warnings, `The ${frame.angle} view could not be drawn.`] });
6204
6329
  }
6205
6330
  }
6206
- 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) };
6207
6333
  }
6208
6334
  async function edgeBarGeometry(buf) {
6209
- const { data, info } = await sharp19(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
6335
+ const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
6210
6336
  const W = info.width;
6211
6337
  const H = info.height;
6212
6338
  const scan = (len, cross, at) => {
@@ -6260,7 +6386,7 @@ async function trimEdgeBars(core, hash) {
6260
6386
  const width = g.right - g.left + 1;
6261
6387
  const height = g.bottom - g.top + 1;
6262
6388
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
6263
- const png = await sharp19(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
6389
+ const png = await sharp20(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
6264
6390
  return core.images.save(png);
6265
6391
  } catch {
6266
6392
  return hash;
@@ -6301,13 +6427,71 @@ async function avatarCrop(core, hash) {
6301
6427
  AVATAR_MAX_PX
6302
6428
  );
6303
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
+ }
6304
6488
  async function figureBox(buf) {
6305
- const meta = await sharp19(buf).metadata();
6489
+ const meta = await sharp20(buf).metadata();
6306
6490
  const W = meta.width ?? 0;
6307
6491
  const H = meta.height ?? 0;
6308
6492
  if (!W || !H) return null;
6309
6493
  for (const threshold of FIGURE_TRIM_THRESHOLDS) {
6310
- const { info } = await sharp19(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6494
+ const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6311
6495
  const left = Math.abs(info.trimOffsetLeft ?? 0);
6312
6496
  const top = Math.abs(info.trimOffsetTop ?? 0);
6313
6497
  const width = info.width ?? 0;
@@ -6340,13 +6524,13 @@ async function smartCover(core, hash, box) {
6340
6524
  if (!hash || !core.images.has(hash)) return void 0;
6341
6525
  try {
6342
6526
  const buf = core.images.read(hash);
6343
- const meta = await sharp19(buf).metadata();
6527
+ const meta = await sharp20(buf).metadata();
6344
6528
  const w = meta.width ?? 0;
6345
6529
  const h = meta.height ?? 0;
6346
6530
  if (!w || !h) return void 0;
6347
6531
  const raw = box(w, h);
6348
6532
  const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
6349
- const png = await sharp19(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
6533
+ const png = await sharp20(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
6350
6534
  return core.images.save(png);
6351
6535
  } catch {
6352
6536
  return void 0;
@@ -6355,11 +6539,11 @@ async function smartCover(core, hash, box) {
6355
6539
  async function crop(core, hash, region, cap2) {
6356
6540
  if (!hash || !core.images.has(hash)) return void 0;
6357
6541
  try {
6358
- const meta = await sharp19(core.images.read(hash)).metadata();
6542
+ const meta = await sharp20(core.images.read(hash)).metadata();
6359
6543
  const w = meta.width ?? 0;
6360
6544
  const h = meta.height ?? 0;
6361
6545
  if (!w || !h) return void 0;
6362
- let pipeline = sharp19(core.images.read(hash)).extract(region(w, h));
6546
+ let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
6363
6547
  if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
6364
6548
  const png = await pipeline.png().toBuffer();
6365
6549
  return core.images.save(png);
@@ -6450,7 +6634,7 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
6450
6634
  });
6451
6635
  }
6452
6636
  function scenePreviewPrompt(scene) {
6453
- const body = scene.figure ? `A figure is in this photograph: ${scene.figure.replace(/[.\s]+$/, "")}. ` + (scene.figureTreatment ? `The art direction is what has been done to them: ${scene.figureTreatment.replace(/[.\s]+$/, "")}, rendered as a real physical treatment that follows the shape it sits on. ` : "") + "They are nobody in particular: do not reproduce any person from the attached reference images, and give them no recognisable identity. No product, no logos, no watermarks, and no readable words anywhere in the frame." : "The set is empty: no product, no person, no hands, no text, no logos, no watermarks anywhere in the frame.";
6637
+ const body = scene.figure ? `A figure is in this photograph: ${scene.figure.replace(/[.\s]+$/, "")}. ` + (scene.figureTreatment ? `The art direction is what has been done to them: ${scene.figureTreatment.replace(/[.\s]+$/, "")}, rendered as a real physical treatment that follows the shape it sits on. ` : "") + "They are nobody in particular: do not reproduce any person from the attached reference images, and give them no recognisable identity. " + (scene.figureTreatment ? "No product and no watermarks. Where the treatment itself carries printing, render it as genuinely designed print - real letterforms, readable words, numerals and label-quality artwork - belonging to companies that are plausible but fictional, resembling no existing brand, and borrowing, extending or re-spelling no name that appears in any attached reference. Everywhere outside the treatment, no logos and no readable words." : "No product, no logos, no watermarks, and no readable words anywhere in the frame.") : "The set is empty: no product, no person, no hands, no text, no logos, no watermarks anywhere in the frame.";
6454
6638
  return `Full-bleed photograph filling the entire frame edge to edge with no border, frame, letterbox band or matte of any kind. ${scene.prompt} ${scene.lighting ? `${scene.lighting}. ` : ""}` + body;
6455
6639
  }
6456
6640
  async function draw(deps, req) {
@@ -6571,6 +6755,49 @@ function inheritedIdentityTokens(parentId, getNode) {
6571
6755
  return { tokens: [], truncated: id !== null };
6572
6756
  }
6573
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
+
6574
6801
  // src/editScopeRules.ts
6575
6802
  var GLOBAL_CUES = [
6576
6803
  ["light", /\b(light|lighting|lit|relight|exposure|white ?balance|backlit|shadows everywhere)\b/i],
@@ -6621,6 +6848,119 @@ function scopeOfInstruction(text) {
6621
6848
  if (!matched.length) return { scope: "global", matched: ["no local cue"] };
6622
6849
  return { scope: "local", matched, removal: REMOVAL_VERB.test(s) };
6623
6850
  }
6851
+ var FIT_EDGE = 160;
6852
+ var GATE_EDGE = 32;
6853
+ var GRADE_GATE_MEAN_DELTA = 40;
6854
+ var SLOPE_CLAMP = 1.8;
6855
+ var TONAL_WORD = /^(warm(er)?|cool(er)?|bright(er)?|dark(er)?|deep(er)?|soft(er)?|hard(er)?|punch(ier|y)?|rich(er)?|flat(ter)?|mood(y|ier)?|golden|overcast|sun(ny|lit)?|cloudy|dusk|dawn|evening|morning|night|daylight|light|lighting|lit|glow(ing)?|exposure|contrast|saturation|desaturat(e|ed)|muted|vivid|tone[sd]?|tint(ed)?|grade[d]?|grading|temperature|white|balance|shadows?|highlights?|blacks?|whites?|filmic|cinematic|dramatic|airy|hazy|misty|crisp)$/i;
6856
+ var NEUTRAL_WORD = /^(a|an|the|it|its|this|that|make|makes|keep|slightly|slight|touch|bit|little|more|less|much|very|again|overall|whole|image|frame|shot|picture|photo|look|feel|and|of|in|with|to|too|just|please|now|even)$/i;
6857
+ function isGradeOnlyInstruction(text) {
6858
+ const words = String(text ?? "").toLowerCase().replace(/[^a-z\s-]/g, " ").split(/\s+/).filter(Boolean);
6859
+ if (!words.length) return false;
6860
+ let tonal = 0;
6861
+ for (const w of words) {
6862
+ if (TONAL_WORD.test(w)) tonal++;
6863
+ else if (!NEUTRAL_WORD.test(w)) return false;
6864
+ }
6865
+ return tonal > 0;
6866
+ }
6867
+ var srgbToLinear = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
6868
+ var linearToSrgb = (c) => c <= 31308e-7 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
6869
+ var EPS = 1e-6;
6870
+ var toLab = (r, g, b) => {
6871
+ const R = srgbToLinear(r / 255);
6872
+ const G = srgbToLinear(g / 255);
6873
+ const B = srgbToLinear(b / 255);
6874
+ const L = Math.log10(Math.max(EPS, 0.3811 * R + 0.5783 * G + 0.0402 * B));
6875
+ const M = Math.log10(Math.max(EPS, 0.1967 * R + 0.7244 * G + 0.0782 * B));
6876
+ const S = Math.log10(Math.max(EPS, 0.0241 * R + 0.1288 * G + 0.8444 * B));
6877
+ return [(L + M + S) / Math.sqrt(3), (L + M - 2 * S) / Math.sqrt(6), (L - M) / Math.sqrt(2)];
6878
+ };
6879
+ var fromLab = (l, a, bb) => {
6880
+ const L = l / Math.sqrt(3) + a / Math.sqrt(6) + bb / Math.sqrt(2);
6881
+ const M = l / Math.sqrt(3) + a / Math.sqrt(6) - bb / Math.sqrt(2);
6882
+ const S = l / Math.sqrt(3) - 2 * a / Math.sqrt(6);
6883
+ const Rl = 10 ** L;
6884
+ const Ml = 10 ** M;
6885
+ const Sl = 10 ** S;
6886
+ const R = 4.4679 * Rl - 3.5873 * Ml + 0.1193 * Sl;
6887
+ const G = -1.2186 * Rl + 2.3809 * Ml - 0.1624 * Sl;
6888
+ const B = 0.0497 * Rl - 0.2439 * Ml + 1.2045 * Sl;
6889
+ const clamp = (x) => Math.max(0, Math.min(255, Math.round(linearToSrgb(Math.max(0, Math.min(1, x))) * 255)));
6890
+ return [clamp(R), clamp(G), clamp(B)];
6891
+ };
6892
+ var rawAt = async (png, edge) => {
6893
+ let img = sharp20(png);
6894
+ if (edge) img = img.resize(edge, edge, { fit: "fill" });
6895
+ const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
6896
+ return { data, width: info.width, height: info.height };
6897
+ };
6898
+ var labStats = (raw) => {
6899
+ const n = raw.data.length / 3;
6900
+ const mu = [0, 0, 0];
6901
+ const sq = [0, 0, 0];
6902
+ for (let i = 0; i < raw.data.length; i += 3) {
6903
+ const lab = toLab(raw.data[i], raw.data[i + 1], raw.data[i + 2]);
6904
+ for (let c = 0; c < 3; c++) {
6905
+ mu[c] += lab[c];
6906
+ sq[c] += lab[c] * lab[c];
6907
+ }
6908
+ }
6909
+ for (let c = 0; c < 3; c++) {
6910
+ mu[c] /= n;
6911
+ sq[c] = Math.sqrt(Math.max(1e-9, sq[c] / n - mu[c] * mu[c]));
6912
+ }
6913
+ return { mu, sd: sq };
6914
+ };
6915
+ var fitAffine = (input, output) => {
6916
+ const a = labStats(input);
6917
+ const b = labStats(output);
6918
+ return [0, 1, 2].map((c) => ({
6919
+ k: Math.max(1 / SLOPE_CLAMP, Math.min(SLOPE_CLAMP, b.sd[c] / a.sd[c])),
6920
+ mi: a.mu[c],
6921
+ mo: b.mu[c]
6922
+ }));
6923
+ };
6924
+ var applyAffine = (raw, T) => {
6925
+ const d = raw.data;
6926
+ for (let i = 0; i < d.length; i += 3) {
6927
+ const peak = Math.max(d[i], d[i + 1], d[i + 2]);
6928
+ const w = peak <= 225 ? 1 : Math.max(0, (255 - peak) / 30);
6929
+ const lab = toLab(d[i], d[i + 1], d[i + 2]);
6930
+ const out = fromLab(
6931
+ (lab[0] - T[0].mi) * T[0].k + T[0].mo,
6932
+ (lab[1] - T[1].mi) * T[1].k + T[1].mo,
6933
+ (lab[2] - T[2].mi) * T[2].k + T[2].mo
6934
+ );
6935
+ d[i] = Math.round(d[i] * (1 - w) + out[0] * w);
6936
+ d[i + 1] = Math.round(d[i + 1] * (1 - w) + out[1] * w);
6937
+ d[i + 2] = Math.round(d[i + 2] * (1 - w) + out[2] * w);
6938
+ }
6939
+ };
6940
+ var meanDelta = (a, b) => {
6941
+ const n = Math.min(a.data.length, b.data.length);
6942
+ let sum = 0;
6943
+ for (let i = 0; i < n; i++) sum += Math.abs(a.data[i] - b.data[i]);
6944
+ return n ? sum / n : 255;
6945
+ };
6946
+ async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
6947
+ try {
6948
+ const T = fitAffine(await rawAt(modelInputPng, FIT_EDGE), await rawAt(modelOutputPng, FIT_EDGE));
6949
+ const gateIn = await rawAt(modelInputPng, GATE_EDGE);
6950
+ const gateOut = await rawAt(modelOutputPng, GATE_EDGE);
6951
+ applyAffine(gateIn, T);
6952
+ const residual = meanDelta(gateIn, gateOut);
6953
+ if (residual > GRADE_GATE_MEAN_DELTA) return null;
6954
+ const full = await rawAt(originalPng);
6955
+ applyAffine(full, T);
6956
+ const image = await sharp20(full.data, {
6957
+ raw: { width: full.width, height: full.height, channels: 3 }
6958
+ }).png().toBuffer();
6959
+ return { image, residual };
6960
+ } catch {
6961
+ return null;
6962
+ }
6963
+ }
6624
6964
 
6625
6965
  // src/expandRules.ts
6626
6966
  var round8 = (n) => Math.max(8, Math.round(n / 8) * 8);
@@ -6687,13 +7027,20 @@ Avoid: new objects, products, people, text or watermarks; do not recompose, rece
6687
7027
  // src/editSizeRules.ts
6688
7028
  var SAME_SHAPE_TOL = 0.01;
6689
7029
  var SHRINK_FLOOR = 0.8;
6690
- function judgeEditSize(src, got) {
7030
+ var BUDGET_TOL = 0.1;
7031
+ function judgeEditSize(src, got, opts) {
6691
7032
  if (!(src.width > 0 && src.height > 0 && got.width > 0 && got.height > 0)) return { action: "keep" };
6692
7033
  if (got.width === src.width && got.height === src.height) return { action: "keep" };
6693
7034
  const want = src.width / src.height;
6694
7035
  const have = got.width / got.height;
6695
7036
  if (Math.abs(have - want) / want > SAME_SHAPE_TOL) return { action: "keep" };
6696
7037
  const scale = Math.max(got.width, got.height) / Math.max(src.width, src.height);
7038
+ const budget = opts?.pixelBudget;
7039
+ if (budget && src.width * src.height > budget) {
7040
+ const gotPx = got.width * got.height;
7041
+ if (gotPx <= src.width * src.height && Math.abs(gotPx - budget) / budget <= BUDGET_TOL)
7042
+ return { action: "accept", scale };
7043
+ }
6697
7044
  if (scale < SHRINK_FLOOR) return { action: "reject", scale };
6698
7045
  return { action: "resize", scale };
6699
7046
  }
@@ -6712,7 +7059,7 @@ function planCrop(source, targetRatio) {
6712
7059
  }
6713
7060
  async function attentionCropOrigin(srcBuf, source, plan) {
6714
7061
  try {
6715
- const { info } = await sharp19(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7062
+ const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6716
7063
  const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
6717
7064
  const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
6718
7065
  const left = Math.round((attnLeft + plan.left) / 2);
@@ -6865,23 +7212,23 @@ function relax(grid, seam, fixedSweeps) {
6865
7212
 
6866
7213
  // src/expand.ts
6867
7214
  async function expandCanvas(source, plan) {
6868
- const bed = await sharp19(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6869
- return sharp19(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
7215
+ const bed = await sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
7216
+ return sharp20(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6870
7217
  }
6871
7218
  async function compositeExpand(engineImage, source, plan) {
6872
- const meta = await sharp19(engineImage).metadata();
7219
+ const meta = await sharp20(engineImage).metadata();
6873
7220
  const want = plan.width / plan.height;
6874
7221
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
6875
7222
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
6876
7223
  const aligned = sameOrientation;
6877
7224
  const exact = meta.width === plan.width && meta.height === plan.height;
6878
- const surround = aligned ? exact ? engineImage : await sharp19(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
7225
+ const surround = aligned ? exact ? engineImage : await sharp20(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
6879
7226
  const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
6880
- const image = await sharp19(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
7227
+ const image = await sharp20(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6881
7228
  return { image, aligned };
6882
7229
  }
6883
7230
  async function matchMarginsToSeam(surround, source, plan) {
6884
- const src = await sharp19(source).metadata();
7231
+ const src = await sharp20(source).metadata();
6885
7232
  if (!src.width || !src.height) return surround;
6886
7233
  const SW = src.width;
6887
7234
  const SH = src.height;
@@ -6928,8 +7275,8 @@ var MAX_CORRECTION = 60;
6928
7275
  async function reconcile(surround, source, side, axis) {
6929
7276
  const { margin } = side;
6930
7277
  if (margin.width < 1 || margin.height < 1) return surround;
6931
- const marginRaw = await sharp19(surround).extract(margin).removeAlpha().raw().toBuffer();
6932
- const edgeRaw = await sharp19(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
7278
+ const marginRaw = await sharp20(surround).extract(margin).removeAlpha().raw().toBuffer();
7279
+ const edgeRaw = await sharp20(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
6933
7280
  const W = margin.width;
6934
7281
  const H = margin.height;
6935
7282
  const along = axis === "width" ? H : W;
@@ -6983,11 +7330,11 @@ async function reconcile(surround, source, side, axis) {
6983
7330
  }
6984
7331
  }
6985
7332
  }
6986
- const patch2 = await sharp19(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
6987
- return sharp19(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
7333
+ const patch2 = await sharp20(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
7334
+ return sharp20(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
6988
7335
  }
6989
7336
  async function expandCanvasBedOnly(source, plan) {
6990
- return sharp19(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
7337
+ return sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6991
7338
  }
6992
7339
  function medianOf(rgb, channel, from, to) {
6993
7340
  const n = to - from;
@@ -6998,17 +7345,17 @@ function medianOf(rgb, channel, from, to) {
6998
7345
  return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
6999
7346
  }
7000
7347
  async function reframeExpand(engineImage, plan) {
7001
- const meta = await sharp19(engineImage).metadata();
7348
+ const meta = await sharp20(engineImage).metadata();
7002
7349
  if (!(meta.width && meta.height)) return null;
7003
7350
  const want = plan.width / plan.height;
7004
7351
  const got = meta.width / meta.height;
7005
7352
  if (got >= 1 !== want >= 1) return null;
7006
7353
  if (meta.width === plan.width && meta.height === plan.height) return engineImage;
7007
7354
  const straight = Math.abs(got - want) / want <= 0.02;
7008
- return sharp19(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
7355
+ return sharp20(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
7009
7356
  }
7010
7357
  async function seamScore(image, plan, source) {
7011
- const { data, info } = await sharp19(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
7358
+ const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
7012
7359
  const W = info.width;
7013
7360
  const H = info.height;
7014
7361
  const horizontal = plan.axis === "width";
@@ -7041,7 +7388,7 @@ var SEAM_VISIBLE = 2.2;
7041
7388
  var OFFSET = 4;
7042
7389
  var RESIDUAL_VISIBLE = 15;
7043
7390
  async function seamResidual(image, plan, source) {
7044
- const { data, info } = await sharp19(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
7391
+ const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
7045
7392
  const W = info.width;
7046
7393
  const H = info.height;
7047
7394
  const ch = info.channels;
@@ -7076,7 +7423,7 @@ var MAX_SHARE = 0.8;
7076
7423
  async function subjectFraction(src, source, axis) {
7077
7424
  try {
7078
7425
  const window = axis === "width" ? { width: Math.max(8, Math.round(source.width * 0.5)), height: source.height } : { width: source.width, height: Math.max(8, Math.round(source.height * 0.5)) };
7079
- const { info } = await sharp19(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7426
+ const { info } = await sharp20(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7080
7427
  const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
7081
7428
  const span = axis === "width" ? source.width : source.height;
7082
7429
  const extent = axis === "width" ? window.width : window.height;
@@ -7099,14 +7446,14 @@ function placeExpand(plan, source, fraction) {
7099
7446
  }
7100
7447
  var NEUTRAL = { r: 128, g: 128, b: 128 };
7101
7448
  async function conditioningCanvas(source, plan, fill = "edge") {
7102
- const meta = await sharp19(source).metadata();
7449
+ const meta = await sharp20(source).metadata();
7103
7450
  const sw = meta.width ?? 0;
7104
7451
  const sh = meta.height ?? 0;
7105
7452
  if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
7106
7453
  const layers = [];
7107
7454
  if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
7108
7455
  layers.push({ input: source, left: plan.left, top: plan.top });
7109
- const canvas = sharp19({
7456
+ const canvas = sharp20({
7110
7457
  create: {
7111
7458
  width: plan.width,
7112
7459
  height: plan.height,
@@ -7118,7 +7465,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
7118
7465
  }
7119
7466
  async function edgeMargins(source, plan, size) {
7120
7467
  const out = [];
7121
- const strip = async (extract, width, height) => sharp19(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
7468
+ const strip = async (extract, width, height) => sharp20(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
7122
7469
  if (plan.axis === "width") {
7123
7470
  const before = plan.left;
7124
7471
  const after = plan.width - plan.left - size.width;
@@ -7209,12 +7556,12 @@ async function resolveOutpaintRoute(all, shot) {
7209
7556
  return { engine: shot, method: "reframe", crossed: false };
7210
7557
  }
7211
7558
  async function driftDiff(a, b) {
7212
- const metaA = await sharp19(a).metadata();
7213
- const metaB = await sharp19(b).metadata();
7559
+ const metaA = await sharp20(a).metadata();
7560
+ const metaB = await sharp20(b).metadata();
7214
7561
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
7215
7562
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
7216
7563
  const [rawA, rawB] = await Promise.all(
7217
- [a, b].map((buf) => sharp19(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
7564
+ [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
7218
7565
  );
7219
7566
  const out = new PNG({ width, height });
7220
7567
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -7226,11 +7573,11 @@ async function driftDiff(a, b) {
7226
7573
  };
7227
7574
  }
7228
7575
  async function changeMask(a, b, cap2 = 1024) {
7229
- const metaA = await sharp19(a).metadata();
7576
+ const metaA = await sharp20(a).metadata();
7230
7577
  const width = Math.min(metaA.width ?? 1, cap2);
7231
7578
  const height = Math.min(metaA.height ?? 1, cap2);
7232
7579
  const [rawA, rawB] = await Promise.all(
7233
- [a, b].map((buf) => sharp19(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
7580
+ [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
7234
7581
  );
7235
7582
  const out = new PNG({ width, height });
7236
7583
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -7279,8 +7626,8 @@ function dilationFor(longEdge) {
7279
7626
  // src/localEdit.ts
7280
7627
  async function preserveOutsideChange(source, edited) {
7281
7628
  try {
7282
- const srcMeta = await sharp19(source).metadata();
7283
- const outMeta = await sharp19(edited).metadata();
7629
+ const srcMeta = await sharp20(source).metadata();
7630
+ const outMeta = await sharp20(edited).metadata();
7284
7631
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
7285
7632
  return { image: edited, outcome: "error", changed: 0 };
7286
7633
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -7290,15 +7637,15 @@ async function preserveOutsideChange(source, edited) {
7290
7637
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
7291
7638
  const r = dilationFor(Math.max(shape.width, shape.height));
7292
7639
  const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
7293
- const spread = await sharp19(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
7294
- const dilated = await sharp19(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
7295
- const feathered = await sharp19(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
7296
- const grown = await sharp19(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
7297
- const editedRgb = await sharp19(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
7298
- const masked = await sharp19(editedRgb, {
7640
+ const spread = await sharp20(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
7641
+ const dilated = await sharp20(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
7642
+ const feathered = await sharp20(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
7643
+ const grown = await sharp20(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
7644
+ const editedRgb = await sharp20(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
7645
+ const masked = await sharp20(editedRgb, {
7299
7646
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
7300
7647
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
7301
- const image = await sharp19(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
7648
+ const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
7302
7649
  return { image, outcome: "composited", changed: shape.changed };
7303
7650
  } catch {
7304
7651
  return { image: edited, outcome: "error", changed: 0 };
@@ -7336,7 +7683,7 @@ function registerLogoRoutes(app, deps) {
7336
7683
  const v = validateBrand(json);
7337
7684
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
7338
7685
  const row = core.store.updateBrand(brand.id, json);
7339
- const meta = await sharp19(core.images.read(part.hash)).metadata().catch(() => null);
7686
+ const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
7340
7687
  const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
7341
7688
  return { ...row, logoHash: part.hash, logoEdge };
7342
7689
  });
@@ -7463,7 +7810,7 @@ async function vibrantColor(input) {
7463
7810
  let data;
7464
7811
  let channels;
7465
7812
  try {
7466
- const out = await sharp19(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
7813
+ const out = await sharp20(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
7467
7814
  data = out.data;
7468
7815
  channels = out.info.channels;
7469
7816
  } catch {
@@ -7486,7 +7833,7 @@ async function vibrantColor(input) {
7486
7833
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
7487
7834
  if (best.score <= 0) {
7488
7835
  try {
7489
- const { dominant } = await sharp19(input).stats();
7836
+ const { dominant } = await sharp20(input).stats();
7490
7837
  return toHex(dominant.r, dominant.g, dominant.b);
7491
7838
  } catch {
7492
7839
  return null;
@@ -7870,10 +8217,11 @@ function registerAssetBuildRoutes(app, deps) {
7870
8217
  core.ledger.recordCost(engineId, null, result.costUsd);
7871
8218
  const hash = result.images[0];
7872
8219
  if (!hash) return reply.status(500).send({ error: "the engine returned no image" });
8220
+ const trimmed = await trimEdgeBars(core, hash);
7873
8221
  commit(core, brand.id, (json) => {
7874
- json.scenes = brandScenes(json).map((s) => s.id === id ? { ...s, preview: `asset:${hash}` } : s);
8222
+ json.scenes = brandScenes(json).map((s) => s.id === id ? { ...s, preview: `asset:${trimmed}` } : s);
7875
8223
  });
7876
- return { preview: `asset:${hash}`, brand: core.store.getBrand(brand.id) };
8224
+ return { preview: `asset:${trimmed}`, brand: core.store.getBrand(brand.id) };
7877
8225
  });
7878
8226
  }
7879
8227
  async function withDerivedCrops(core, body, base) {
@@ -8104,7 +8452,7 @@ async function buildExportZip(image, baseName, presetIds) {
8104
8452
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
8105
8453
  if (chosen.length === 0) throw new Error("No valid export presets selected");
8106
8454
  for (const p of chosen) {
8107
- const buf = p.width && p.height ? await sharp19(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
8455
+ const buf = p.width && p.height ? await sharp20(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
8108
8456
  zip.file(`${baseName}-${p.id}.png`, buf);
8109
8457
  }
8110
8458
  return zip.generateAsync({ type: "nodebuffer" });
@@ -8265,8 +8613,8 @@ function registerImageRoutes(app, deps) {
8265
8613
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
8266
8614
  const buf = await part.toBuffer();
8267
8615
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
8268
- const fmt = (await sharp19(buf).metadata().catch(() => null))?.format;
8269
- const png = fmt === "svg" ? await toMarkPng(buf) : await sharp19(buf).rotate().png().toBuffer();
8616
+ const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
8617
+ const png = fmt === "svg" ? await toMarkPng(buf) : await sharp20(buf).rotate().png().toBuffer();
8270
8618
  return { hash: core.images.save(png) };
8271
8619
  });
8272
8620
  app.post("/api/diff", async (req, reply) => {
@@ -8301,6 +8649,44 @@ function registerImageRoutes(app, deps) {
8301
8649
 
8302
8650
  // src/release/notes.data.ts
8303
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
+ },
8671
+ {
8672
+ version: "0.7.0",
8673
+ date: "2026-08-30",
8674
+ title: "Your presenter stays your presenter.",
8675
+ sections: [
8676
+ {
8677
+ heading: "Scenes",
8678
+ body: "A scene lends its world, its light and its treatment, never a face. Generation now conditions on the scene\u2019s own drawn card instead of your raw upload, so the person you selected is the person in the shot. Scenes made before this release render printed treatments best after a one-tap redraw from the scene\u2019s page."
8679
+ },
8680
+ {
8681
+ heading: "Refine",
8682
+ body: "Refining light and mood no longer wears a shot down: a tonal refinement keeps the photograph\u2019s own pixels and changes only the grade, so the tenth adjustment is as sharp as the first. High-resolution refinements state their working size honestly instead of inflating it, and refining an extended shot works again."
8683
+ },
8684
+ {
8685
+ heading: "Presenters",
8686
+ body: "A presenter\u2019s identity rides with three of their views. A reference you attach cannot lend anyone its face while a presenter is selected, and a mood image carried into a refinement is never mistaken for the person in the picture."
8687
+ }
8688
+ ]
8689
+ },
8304
8690
  {
8305
8691
  version: "0.6.13",
8306
8692
  date: "2026-08-30",
@@ -9099,7 +9485,7 @@ function buildServer(opts) {
9099
9485
  // Measured as stored (post-toMarkPng), so the scrape judges the same
9100
9486
  // pixels the compiler will one day attach.
9101
9487
  probeLongEdge: async (buf) => {
9102
- const m = await sharp19(await toMarkPng(buf)).metadata();
9488
+ const m = await sharp20(await toMarkPng(buf)).metadata();
9103
9489
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
9104
9490
  },
9105
9491
  createdWith: `${meta.name}/${meta.version}`
@@ -9188,7 +9574,7 @@ function buildServer(opts) {
9188
9574
  fetchImpl: opts.fetchImpl,
9189
9575
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
9190
9576
  probeLongEdge: async (buf) => {
9191
- const m = await sharp19(await toMarkPng(buf)).metadata();
9577
+ const m = await sharp20(await toMarkPng(buf)).metadata();
9192
9578
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
9193
9579
  },
9194
9580
  createdWith: `${meta.name}/${meta.version}`
@@ -9294,7 +9680,19 @@ function buildServer(opts) {
9294
9680
  registerShowcaseRoutes(app, { templatesRoot });
9295
9681
  app.get("/api/formats", async () => FORMATS);
9296
9682
  function briefInputsOnly(brief) {
9297
- const { inherited, rendered, croppedFrom, resizedFrom, resampledHops, expand, crop: crop2, sourceImage, ...inputs } = brief;
9683
+ const {
9684
+ inherited,
9685
+ rendered,
9686
+ croppedFrom,
9687
+ resizedFrom,
9688
+ resampledHops,
9689
+ steppedDown,
9690
+ gradeComposited,
9691
+ expand,
9692
+ crop: crop2,
9693
+ sourceImage,
9694
+ ...inputs
9695
+ } = brief;
9298
9696
  return inputs;
9299
9697
  }
9300
9698
  async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
@@ -9305,18 +9703,22 @@ function buildServer(opts) {
9305
9703
  );
9306
9704
  const inheritedTokens = borrowed.filter((t) => !already.has(identityTokenKey(t)));
9307
9705
  const combined = [...brief.tokens, ...inheritedTokens];
9308
- const brandJson = await brandJsonWithResolvedPresenters(
9706
+ const brandJson = await brandJsonWithIdentityCrops(
9309
9707
  core,
9310
- templatesRoot,
9311
- presenters,
9312
- await brandJsonWithResolvedDemoProducts(
9708
+ await brandJsonWithResolvedPresenters(
9313
9709
  core,
9314
9710
  templatesRoot,
9315
- demoProducts,
9316
- brandJsonWithCatalogProducts(core, brandId),
9711
+ presenters,
9712
+ await brandJsonWithResolvedDemoProducts(
9713
+ core,
9714
+ templatesRoot,
9715
+ demoProducts,
9716
+ brandJsonWithCatalogProducts(core, brandId),
9717
+ combined
9718
+ ),
9317
9719
  combined
9318
9720
  ),
9319
- combined
9721
+ combined.filter((t) => t.t === "character").map((t) => t.id)
9320
9722
  );
9321
9723
  const sceneById = sceneFor(brandJson);
9322
9724
  const uncapped = { ...engineCaps, maxReferenceImages: 32 };
@@ -9325,6 +9727,9 @@ function buildServer(opts) {
9325
9727
  );
9326
9728
  const inheritedDirectives = [];
9327
9729
  let inheritedMark = false;
9730
+ let inheritedRef = false;
9731
+ const inheritedProduct = inheritedTokens.some((t) => t.t === "product");
9732
+ const inheritedPerson = inheritedTokens.some((t) => t.t === "character");
9328
9733
  for (const tok of inheritedTokens) {
9329
9734
  if (tok.t === "product") {
9330
9735
  const rec = (brandJson?.products ?? []).find((x) => x?.id === tok.id);
@@ -9343,8 +9748,12 @@ function buildServer(opts) {
9343
9748
  } else if (tok.t === "mark" && !inheritedMark) {
9344
9749
  inheritedMark = true;
9345
9750
  inheritedDirectives.push(markEditDirective());
9751
+ } else if (tok.t === "ref" && !inheritedRef) {
9752
+ inheritedRef = true;
9753
+ inheritedDirectives.push(inheritedRefDirective());
9346
9754
  }
9347
9755
  }
9756
+ if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
9348
9757
  const compiled2 = compileBrief(brief, {
9349
9758
  brand: brandJson,
9350
9759
  images: core.images,
@@ -9354,7 +9763,11 @@ function buildServer(opts) {
9354
9763
  mode: "edit",
9355
9764
  editScope: verdict.scope,
9356
9765
  editRemoval: verdict.removal ?? false,
9357
- inheritedIdentity: inheritedTokens.length > 0,
9766
+ // Kinds, not a count: the identity claim speaks only about the kinds
9767
+ // that actually ride. A mark-only or ref-only inheritance emits no
9768
+ // generic claim - markEditDirective and inheritedRefDirective speak
9769
+ // for themselves.
9770
+ inheritedIdentity: inheritedProduct || inheritedPerson ? { product: inheritedProduct, person: inheritedPerson } : false,
9358
9771
  inheritedDirectives,
9359
9772
  // Only the explicit op drops the dimension promise: an implicit legacy
9360
9773
  // expansion keeps its historical prompt byte for byte.
@@ -9365,7 +9778,7 @@ function buildServer(opts) {
9365
9778
  if (inheritedTokens.length) {
9366
9779
  const identity = compileBrief(
9367
9780
  { tokens: inheritedTokens },
9368
- { brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
9781
+ { brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById, mode: "edit" }
9369
9782
  );
9370
9783
  identityWarnings = identity.warnings;
9371
9784
  const productAngles = /* @__PURE__ */ new Map();
@@ -9432,18 +9845,22 @@ function buildServer(opts) {
9432
9845
  referenceCount: edit.merged.kept.length
9433
9846
  };
9434
9847
  }
9435
- const brandJson = await brandJsonWithResolvedPresenters(
9848
+ const brandJson = await brandJsonWithIdentityCrops(
9436
9849
  core,
9437
- templatesRoot,
9438
- presenters,
9439
- await brandJsonWithResolvedDemoProducts(
9850
+ await brandJsonWithResolvedPresenters(
9440
9851
  core,
9441
9852
  templatesRoot,
9442
- demoProducts,
9443
- brandJsonWithCatalogProducts(core, brand.id),
9853
+ presenters,
9854
+ await brandJsonWithResolvedDemoProducts(
9855
+ core,
9856
+ templatesRoot,
9857
+ demoProducts,
9858
+ brandJsonWithCatalogProducts(core, brand.id),
9859
+ brief.tokens
9860
+ ),
9444
9861
  brief.tokens
9445
9862
  ),
9446
- brief.tokens
9863
+ (brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
9447
9864
  );
9448
9865
  const sceneById = sceneFor(brandJson);
9449
9866
  const compiled2 = compileBrief(brief, {
@@ -9520,11 +9937,11 @@ function buildServer(opts) {
9520
9937
  const out = [];
9521
9938
  for (const h of images) {
9522
9939
  const buf = core.images.read(h);
9523
- const meta2 = await sharp19(buf).metadata().catch(() => null);
9940
+ const meta2 = await sharp20(buf).metadata().catch(() => null);
9524
9941
  if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
9525
9942
  const oriented = (meta2.orientation ?? 1) !== 1;
9526
9943
  out.push(
9527
- buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp19(buf).rotate().png().toBuffer())
9944
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
9528
9945
  );
9529
9946
  }
9530
9947
  return out;
@@ -9536,7 +9953,7 @@ function buildServer(opts) {
9536
9953
  const out = [];
9537
9954
  for (const h of images) {
9538
9955
  const buf = core.images.read(h);
9539
- const meta2 = await sharp19(buf).metadata();
9956
+ const meta2 = await sharp20(buf).metadata();
9540
9957
  if (!meta2.width || !meta2.height) {
9541
9958
  out.push(h);
9542
9959
  continue;
@@ -9549,7 +9966,7 @@ function buildServer(opts) {
9549
9966
  }
9550
9967
  const w = got > target ? Math.round(meta2.height * target) : meta2.width;
9551
9968
  const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
9552
- const cropped = await sharp19(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9969
+ const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9553
9970
  app.log.info(
9554
9971
  { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
9555
9972
  "canvas: cropped a drifted frame to the asked ratio"
@@ -9568,7 +9985,7 @@ function buildServer(opts) {
9568
9985
  async function assertAspect(images, expect) {
9569
9986
  const want = expect.width / expect.height;
9570
9987
  for (const h of images) {
9571
- const meta2 = await sharp19(core.images.read(h)).metadata();
9988
+ const meta2 = await sharp20(core.images.read(h)).metadata();
9572
9989
  if (!meta2.width || !meta2.height) continue;
9573
9990
  const got = meta2.width / meta2.height;
9574
9991
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -9587,7 +10004,7 @@ function buildServer(opts) {
9587
10004
  let watchdogFired = false;
9588
10005
  const watchdog = setTimeout(() => {
9589
10006
  watchdogFired = true;
9590
- ctrl.abort();
10007
+ ctrl.abort(BUDGET_EXHAUSTED);
9591
10008
  }, bound);
9592
10009
  const startedAt = Date.now();
9593
10010
  try {
@@ -9600,7 +10017,7 @@ function buildServer(opts) {
9600
10017
  try {
9601
10018
  const sizes = [];
9602
10019
  for (const h of result.images) {
9603
- const meta2 = await sharp19(core.images.read(h)).metadata();
10020
+ const meta2 = await sharp20(core.images.read(h)).metadata();
9604
10021
  if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
9605
10022
  }
9606
10023
  const node = core.store.getNode(nodeId);
@@ -9667,7 +10084,7 @@ function buildServer(opts) {
9667
10084
  if (!srcHash || !core.images.has(String(srcHash)))
9668
10085
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
9669
10086
  const srcBuf = core.images.read(String(srcHash));
9670
- const srcMeta = await sharp19(srcBuf).metadata();
10087
+ const srcMeta = await sharp20(srcBuf).metadata();
9671
10088
  if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
9672
10089
  const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
9673
10090
  if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
@@ -9690,7 +10107,7 @@ function buildServer(opts) {
9690
10107
  crop: window
9691
10108
  });
9692
10109
  const work2 = async () => ({
9693
- images: [core.images.save(await sharp19(srcBuf).extract(window).png().toBuffer())],
10110
+ images: [core.images.save(await sharp20(srcBuf).extract(window).png().toBuffer())],
9694
10111
  costUsd: 0
9695
10112
  });
9696
10113
  void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
@@ -9712,6 +10129,9 @@ function buildServer(opts) {
9712
10129
  let inheritedTokens = [];
9713
10130
  let mergedEdit = null;
9714
10131
  let editScope = "global";
10132
+ let gradeOnlyAsk = false;
10133
+ let budgetSourceHash;
10134
+ let sentSize;
9715
10135
  const extraWarnings = [];
9716
10136
  let expandPlan = null;
9717
10137
  let expandSourceHash = null;
@@ -9726,22 +10146,29 @@ function buildServer(opts) {
9726
10146
  inheritedTokens = edit.inheritedTokens;
9727
10147
  mergedEdit = edit.merged;
9728
10148
  editScope = edit.editScope;
10149
+ gradeOnlyAsk = editScope === "global" && isGradeOnlyInstruction(
10150
+ (brief?.tokens ?? []).filter((t) => t.t === "text").map((t) => t.v).join(" ")
10151
+ );
9729
10152
  extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
9730
10153
  if (!compiled2.prompt.trim() && reshape !== "extend")
9731
10154
  return reply.status(400).send({ error: "the brief is empty" });
9732
10155
  } else {
9733
- const brandJson = await brandJsonWithResolvedPresenters(
10156
+ const brandJson = await brandJsonWithIdentityCrops(
9734
10157
  core,
9735
- templatesRoot,
9736
- presenters,
9737
- await brandJsonWithResolvedDemoProducts(
10158
+ await brandJsonWithResolvedPresenters(
9738
10159
  core,
9739
10160
  templatesRoot,
9740
- demoProducts,
9741
- brandJsonWithCatalogProducts(core, project.brandId),
10161
+ presenters,
10162
+ await brandJsonWithResolvedDemoProducts(
10163
+ core,
10164
+ templatesRoot,
10165
+ demoProducts,
10166
+ brandJsonWithCatalogProducts(core, project.brandId),
10167
+ brief.tokens
10168
+ ),
9742
10169
  brief.tokens
9743
10170
  ),
9744
- brief.tokens
10171
+ (brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
9745
10172
  );
9746
10173
  const sceneById = sceneFor(brandJson);
9747
10174
  compiled2 = compileBrief(brief, {
@@ -9807,6 +10234,7 @@ function buildServer(opts) {
9807
10234
  }
9808
10235
  if (kind === "generation") {
9809
10236
  const cap2 = engine.capabilities().maxReferenceImages;
10237
+ const wantedCount = Math.min(Math.max(1, Number(count)), 8);
9810
10238
  const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
9811
10239
  if (lostIdentity.length) {
9812
10240
  const names = joinNames(lostIdentity.map((d) => d.label));
@@ -9818,14 +10246,23 @@ function buildServer(opts) {
9818
10246
  const maxEdge = engine.capabilities().maxReferenceEdge;
9819
10247
  const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
9820
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
+ });
9821
10257
  const genReq = {
9822
10258
  prompt: finalPrompt,
9823
10259
  brand: ctx,
9824
10260
  width: Number(width),
9825
10261
  height: Number(height),
9826
- count: Math.min(Math.max(1, Number(count)), 8),
10262
+ count: wantedCount,
9827
10263
  ...sentRefs ? { referenceImages: sentRefs } : {},
9828
- ...referenceRoles && cap2 > 0 ? { referenceRoles: referenceRoles.slice(0, cap2) } : {}
10264
+ ...sentRoles.length && cap2 > 0 ? { referenceRoles: sentRoles } : {},
10265
+ ...variations.length ? { variations } : {}
9829
10266
  };
9830
10267
  estimate = await engine.costEstimate(genReq);
9831
10268
  work = (signal) => engine.generate(genReq, signal);
@@ -9842,11 +10279,12 @@ function buildServer(opts) {
9842
10279
  const editEdge = engine.capabilities().maxReferenceEdge;
9843
10280
  if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
9844
10281
  const srcBuf = core.images.read(String(srcHash));
9845
- const srcMeta = await sharp19(srcBuf).metadata();
10282
+ const srcMeta = await sharp20(srcBuf).metadata();
9846
10283
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
9847
10284
  const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
9848
10285
  const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
9849
- const reshapeIntended = reshape === "extend" || reshape === void 0 && !!srcMeta.width && !!srcMeta.height && !!compiled2?.width && !!compiled2?.height && wantsImplicitReshape({ width: compiled2.width, height: compiled2.height }, parentNominal, {
10286
+ const briefNamesShape = (brief?.tokens ?? []).some((t) => t?.t === "format");
10287
+ const reshapeIntended = reshape === "extend" || reshape === void 0 && briefNamesShape && !!srcMeta.width && !!srcMeta.height && !!compiled2?.width && !!compiled2?.height && wantsImplicitReshape({ width: compiled2.width, height: compiled2.height }, parentNominal, {
9850
10288
  width: srcMeta.width,
9851
10289
  height: srcMeta.height
9852
10290
  });
@@ -9873,9 +10311,20 @@ function buildServer(opts) {
9873
10311
  }
9874
10312
  expectShape = { width: expandPlan.width, height: expandPlan.height };
9875
10313
  }
10314
+ const editPixelBudget = runEngine.capabilities().editPixelBudget;
10315
+ if (!expandPlan && editPixelBudget && srcMeta.width && srcMeta.height && srcMeta.width * srcMeta.height > editPixelBudget) {
10316
+ sentSize = budgetSize(srcMeta.width, srcMeta.height, editPixelBudget);
10317
+ budgetSourceHash = core.images.save(
10318
+ await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
10319
+ );
10320
+ if (!gradeOnlyAsk)
10321
+ extraWarnings.push(
10322
+ `${runEngine.capabilities().displayName} refines at about ${(editPixelBudget / 1e6).toFixed(1)} megapixels, so this ${srcMeta.width}x${srcMeta.height} frame continues at ${sentSize.width}x${sentSize.height} from here on. The picture is unchanged; the stored size is now the size the engine truly drew.`
10323
+ );
10324
+ }
9876
10325
  const editReq = {
9877
10326
  instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
9878
- sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
10327
+ sourceImage: core.images.pathFor(String(expandSourceHash ?? budgetSourceHash ?? srcHash)),
9879
10328
  brand: ctx,
9880
10329
  ...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
9881
10330
  ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
@@ -9885,7 +10334,7 @@ function buildServer(opts) {
9885
10334
  // engines given no size answered at whatever size they liked, the
9886
10335
  // shrunken answer was stored, and the next refinement inherited it —
9887
10336
  // the chain that walked shots down to thumbnails.
9888
- ...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : {},
10337
+ ...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : sentSize ? sentSize : srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : {},
9889
10338
  // Only an engine that can genuinely paint a margin is told where the
9890
10339
  // picture sits; the rest would ignore it anyway.
9891
10340
  ...expandPlan && canOutpaint2 ? {
@@ -9987,13 +10436,29 @@ function buildServer(opts) {
9987
10436
  const original = editedFrom ? core.images.read(editedFrom) : null;
9988
10437
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
9989
10438
  const enforceEditCanvas = async (images) => {
9990
- const srcMeta = await sharp19(original).metadata();
10439
+ const srcMeta = await sharp20(original).metadata();
9991
10440
  if (!srcMeta.width || !srcMeta.height) return images;
9992
10441
  const out = [];
9993
10442
  for (const h of images) {
9994
- const meta2 = await sharp19(core.images.read(h)).metadata();
10443
+ const meta2 = await sharp20(core.images.read(h)).metadata();
9995
10444
  const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
9996
- const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got);
10445
+ const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
10446
+ pixelBudget: runEngine.capabilities().editPixelBudget
10447
+ });
10448
+ if (verdict.action === "accept") {
10449
+ app.log.info(
10450
+ { nodeId: node.id, got: `${got.width}x${got.height}`, src: `${srcMeta.width}x${srcMeta.height}` },
10451
+ "edit: kept the engine-native answer (pixel-budget step-down)"
10452
+ );
10453
+ try {
10454
+ const fresh = core.store.getNode(node.id);
10455
+ const b = fresh?.brief ?? {};
10456
+ core.store.setBrief(node.id, { ...b, steppedDown: [srcMeta.width, srcMeta.height] });
10457
+ } catch {
10458
+ }
10459
+ out.push(h);
10460
+ continue;
10461
+ }
9997
10462
  if (verdict.action === "reject")
9998
10463
  throw new Error(
9999
10464
  `engine returned ${got.width}x${got.height} for a ${srcMeta.width}x${srcMeta.height} frame: too little of the picture came back to keep at this resolution`
@@ -10005,7 +10470,7 @@ function buildServer(opts) {
10005
10470
  );
10006
10471
  out.push(
10007
10472
  core.images.save(
10008
- await sharp19(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
10473
+ await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
10009
10474
  )
10010
10475
  );
10011
10476
  try {
@@ -10027,7 +10492,7 @@ function buildServer(opts) {
10027
10492
  const out = [];
10028
10493
  for (const h of images) {
10029
10494
  const answer = core.images.read(h);
10030
- const got = await sharp19(answer).metadata();
10495
+ const got = await sharp20(answer).metadata();
10031
10496
  if (got.width !== plan.width || got.height !== plan.height)
10032
10497
  app.log.info(
10033
10498
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
@@ -10053,6 +10518,26 @@ function buildServer(opts) {
10053
10518
  return images;
10054
10519
  } : kind === "edit" && original ? async (images) => {
10055
10520
  let staged = images;
10521
+ if (gradeOnlyAsk && !expandPlan) {
10522
+ const sent = budgetSourceHash ? core.images.read(budgetSourceHash) : original;
10523
+ const out = [];
10524
+ for (const h of staged) {
10525
+ const g = await gradeComposite(original, sent, core.images.read(h));
10526
+ if (g) {
10527
+ app.log.info(
10528
+ { nodeId: node.id, residual: Number(g.residual.toFixed(2)) },
10529
+ "edit: shipped the original pixels wearing the grade"
10530
+ );
10531
+ out.push(core.images.save(g.image));
10532
+ try {
10533
+ const fresh = core.store.getNode(node.id);
10534
+ core.store.setBrief(node.id, { ...fresh?.brief ?? {}, gradeComposited: true });
10535
+ } catch {
10536
+ }
10537
+ } else out.push(h);
10538
+ }
10539
+ staged = out;
10540
+ }
10056
10541
  if (localScope) {
10057
10542
  const out = [];
10058
10543
  for (const h of staged) {
@@ -10066,7 +10551,8 @@ function buildServer(opts) {
10066
10551
  }
10067
10552
  return enforceEditCanvas(staged);
10068
10553
  } : kind === "generation" && compiled2?.width && compiled2?.height ? conformToCanvas(node.id, { width: compiled2.width, height: compiled2.height }) : void 0;
10069
- 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;
10070
10556
  void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
10071
10557
  (err) => app.log.error({ err }, "node run failed")
10072
10558
  );
@@ -10328,8 +10814,8 @@ async function verify() {
10328
10814
  const db = new Database2(":memory:");
10329
10815
  db.pragma("user_version");
10330
10816
  db.close();
10331
- const { default: sharp20 } = await import('sharp');
10332
- await sharp20({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
10817
+ const { default: sharp21 } = await import('sharp');
10818
+ await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
10333
10819
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
10334
10820
  } catch (err) {
10335
10821
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));