scenri 0.4.7 → 0.5.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.
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, mkdtemp, rm, readdir, stat, writeFile } from 'fs/promises';
12
12
  import { spawn } from 'child_process';
13
- import sharp8 from 'sharp';
13
+ import sharp7 from 'sharp';
14
14
  import Fastify from 'fastify';
15
15
  import fastifyStatic from '@fastify/static';
16
16
  import fastifyMultipart from '@fastify/multipart';
@@ -643,14 +643,9 @@ function createStore(db) {
643
643
  if (!parent || parent.projectId !== input.projectId) throw new Error("parent node not found in project");
644
644
  }
645
645
  const id = randomUUID();
646
- db.prepare("INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id) VALUES (?,?,?,?,?,?)").run(
647
- id,
648
- input.projectId,
649
- input.parentId,
650
- input.kind,
651
- input.prompt,
652
- input.engineId
653
- );
646
+ db.prepare(
647
+ "INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, created_at) VALUES (?,?,?,?,?,?, strftime('%Y-%m-%d %H:%M:%f','now'))"
648
+ ).run(id, input.projectId, input.parentId, input.kind, input.prompt, input.engineId);
654
649
  return this.getNode(id);
655
650
  },
656
651
  completeNode(id, result) {
@@ -672,7 +667,7 @@ function createStore(db) {
672
667
  return r ? rowToNode(r) : null;
673
668
  },
674
669
  treeFor(projectId) {
675
- return db.prepare("SELECT * FROM nodes WHERE project_id=? ORDER BY created_at").all(projectId).map(
670
+ return db.prepare("SELECT * FROM nodes WHERE project_id=? ORDER BY created_at, id").all(projectId).map(
676
671
  rowToNode
677
672
  );
678
673
  },
@@ -697,7 +692,7 @@ function createStore(db) {
697
692
  WHERE p.brand_id = ?
698
693
  AND n.kind != 'root'
699
694
  AND (n.status = 'running' OR n.created_at >= datetime('now', '-2 days'))
700
- ORDER BY n.created_at DESC
695
+ ORDER BY n.created_at DESC, n.id DESC
701
696
  LIMIT ?`
702
697
  ).all(brandId, limit);
703
698
  return rows.map((r) => ({
@@ -1243,9 +1238,9 @@ function libraryMethods(db) {
1243
1238
  local: String(i.sourceUrl).startsWith("local:")
1244
1239
  });
1245
1240
  const seen = /* @__PURE__ */ new Set();
1246
- const usable = images.filter((i) => i.assetRef && !seen.has(i.assetRef) && seen.add(i.assetRef));
1247
- const shots = usable.filter((i) => !i.excluded).map(shot);
1248
- const hiddenShots = usable.filter((i) => i.excluded).map(shot);
1241
+ const usable2 = images.filter((i) => i.assetRef && !seen.has(i.assetRef) && seen.add(i.assetRef));
1242
+ const shots = usable2.filter((i) => !i.excluded).map(shot);
1243
+ const hiddenShots = usable2.filter((i) => i.excluded).map(shot);
1249
1244
  return {
1250
1245
  id: `cat-${p.id}`,
1251
1246
  name: p.title,
@@ -1491,6 +1486,7 @@ function createOpenRouterEngine(opts) {
1491
1486
  var API_BASE = "https://api.replicate.com/v1";
1492
1487
  var DEFAULT_MODEL2 = "black-forest-labs/flux-schnell";
1493
1488
  var DEFAULT_EDIT_MODEL = "black-forest-labs/flux-kontext-pro";
1489
+ var DEFAULT_EXPAND_MODEL = "bria/expand-image";
1494
1490
  var DEFAULT_POLL_INTERVAL_MS = 1500;
1495
1491
  var DEFAULT_TIMEOUT_MS = 12e4;
1496
1492
  var GENERATE_COST_PER_IMAGE_USD = 3e-3;
@@ -1552,6 +1548,7 @@ function createReplicateEngine(opts) {
1552
1548
  fetchImpl = globalThis.fetch,
1553
1549
  model = DEFAULT_MODEL2,
1554
1550
  editModel = DEFAULT_EDIT_MODEL,
1551
+ expandModel = DEFAULT_EXPAND_MODEL,
1555
1552
  pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
1556
1553
  timeoutMs = DEFAULT_TIMEOUT_MS
1557
1554
  } = opts;
@@ -1646,6 +1643,8 @@ function createReplicateEngine(opts) {
1646
1643
  localOnly: false,
1647
1644
  supportsEdit: true,
1648
1645
  supportsMask: false,
1646
+ // bria/expand-image: the picture plus the canvas it belongs in.
1647
+ supportsOutpaint: true,
1649
1648
  // 0, deliberately — see the same note in the fal adapter. generate()
1650
1649
  // sends only prompt/num_outputs/aspect_ratio, so a declared capacity
1651
1650
  // of 1 was a promise this adapter never kept.
@@ -1684,12 +1683,30 @@ function createReplicateEngine(opts) {
1684
1683
  async edit(req, signal) {
1685
1684
  const key = requireKey();
1686
1685
  const file = await readFile(req.sourceImage);
1686
+ const dataUri = `data:image/png;base64,${file.toString("base64")}`;
1687
+ if (req.expand && req.width && req.height) {
1688
+ const started = await createPrediction(
1689
+ key,
1690
+ expandModel,
1691
+ {
1692
+ image_url: dataUri,
1693
+ canvas_size: [req.width, req.height],
1694
+ original_image_size: [req.expand.width, req.expand.height],
1695
+ original_image_location: [req.expand.left, req.expand.top],
1696
+ ...req.instruction.trim() ? { prompt: req.instruction } : {},
1697
+ ...typeof req.seed === "number" ? { seed: req.seed } : {}
1698
+ },
1699
+ signal
1700
+ );
1701
+ const done = await waitForCompletion(key, started, signal);
1702
+ return { images: await downloadOutputs(done, signal), costUsd: EDIT_COST_USD, raw: done };
1703
+ }
1687
1704
  const created = await createPrediction(
1688
1705
  key,
1689
1706
  editModel,
1690
1707
  {
1691
1708
  prompt: req.instruction,
1692
- input_image: `data:image/png;base64,${file.toString("base64")}`
1709
+ input_image: dataUri
1693
1710
  },
1694
1711
  signal
1695
1712
  );
@@ -1705,6 +1722,7 @@ function createReplicateEngine(opts) {
1705
1722
  }
1706
1723
  var DEFAULT_MODEL3 = "fal-ai/flux/schnell";
1707
1724
  var DEFAULT_EDIT_MODEL2 = "fal-ai/flux-kontext/dev";
1725
+ var DEFAULT_EXPAND_MODEL2 = "fal-ai/bria/expand";
1708
1726
  var GENERATE_COST_PER_IMAGE_USD2 = 3e-3;
1709
1727
  var EDIT_COST_USD2 = 0.025;
1710
1728
  function snippetOf(text) {
@@ -1754,6 +1772,7 @@ function createFalEngine(opts) {
1754
1772
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
1755
1773
  const model = opts.model ?? DEFAULT_MODEL3;
1756
1774
  const editModel = opts.editModel ?? DEFAULT_EDIT_MODEL2;
1775
+ const expandModel = opts.expandModel ?? DEFAULT_EXPAND_MODEL2;
1757
1776
  function requireKey() {
1758
1777
  const key = opts.getKey();
1759
1778
  if (!key) throw new Error("fal.ai API key not set. Set a fal.ai key in Settings.");
@@ -1811,6 +1830,8 @@ function createFalEngine(opts) {
1811
1830
  localOnly: false,
1812
1831
  supportsEdit: true,
1813
1832
  supportsMask: false,
1833
+ // bria/expand: the picture plus where it sits, margin only.
1834
+ supportsOutpaint: true,
1814
1835
  // 0, deliberately. This adapter's generate() sends only prompt/size/
1815
1836
  // count — it has never forwarded a reference image to the model. It
1816
1837
  // previously advertised 1, so compileBrief would resolve a product
@@ -1849,6 +1870,26 @@ function createFalEngine(opts) {
1849
1870
  async edit(req, signal) {
1850
1871
  const key = requireKey();
1851
1872
  const imageUrl = await sourceImageToDataUri(req.sourceImage);
1873
+ if (req.expand && req.width && req.height) {
1874
+ const json2 = await postJson(
1875
+ expandModel,
1876
+ key,
1877
+ {
1878
+ image_url: imageUrl,
1879
+ canvas_size: [req.width, req.height],
1880
+ original_image_size: [req.expand.width, req.expand.height],
1881
+ original_image_location: [req.expand.left, req.expand.top],
1882
+ ...req.instruction.trim() ? { prompt: req.instruction } : {},
1883
+ // Same picture, same shape, same margin: an extend a user runs
1884
+ // twice should not be a different picture twice.
1885
+ ...typeof req.seed === "number" ? { seed: req.seed } : {}
1886
+ },
1887
+ signal
1888
+ );
1889
+ const urls2 = extractImageUrls(json2);
1890
+ const images2 = await saveAll(urls2, signal);
1891
+ return { images: images2, costUsd: EDIT_COST_USD2, raw: json2 };
1892
+ }
1852
1893
  const json = await postJson(editModel, key, { prompt: req.instruction, image_url: imageUrl }, signal);
1853
1894
  const urls = extractImageUrls(json);
1854
1895
  const images = await saveAll(urls, signal);
@@ -2508,7 +2549,14 @@ function createCodexEngine(opts) {
2508
2549
  } finally {
2509
2550
  signal?.removeEventListener("abort", onOuterAbort);
2510
2551
  }
2511
- const images = results.filter(Boolean).flat();
2552
+ const images = [];
2553
+ const variantIndexes = [];
2554
+ for (const [i, slot] of results.entries()) {
2555
+ for (const hash of slot ?? []) {
2556
+ images.push(hash);
2557
+ variantIndexes.push(i);
2558
+ }
2559
+ }
2512
2560
  if (!images.length && failures.length) throw fatal ?? failures[0];
2513
2561
  if (failures.length) {
2514
2562
  console.warn(
@@ -2517,7 +2565,11 @@ function createCodexEngine(opts) {
2517
2565
  return {
2518
2566
  images,
2519
2567
  costUsd: 0,
2520
- raw: { requested: count, partialFailures: failures.map((f) => String(f?.message ?? f)) }
2568
+ raw: {
2569
+ requested: count,
2570
+ variantIndexes,
2571
+ partialFailures: failures.map((f) => String(f?.message ?? f))
2572
+ }
2521
2573
  };
2522
2574
  }
2523
2575
  return { images, costUsd: 0 };
@@ -2534,7 +2586,9 @@ function createCodexEngine(opts) {
2534
2586
  await copyFile(editRefs[i], join(dir, name));
2535
2587
  refLines.push(`${name} shows ${EDIT_REFERENCE_ROLE_DIRECTIVE[role]}`);
2536
2588
  }
2537
- const promptText = `Edit input.png using your image generation/editing tool: ${req.instruction}.` + (refLines.length ? ` ${refLines.join(". ")}.` : "") + ` Do not browse the web or explore files. Save the result in the current directory as out-1.png (you may run the commands needed to save and resize it). Nothing else.`;
2589
+ const promptText = `Edit input.png using your image generation/editing tool: ${req.instruction}.` + (refLines.length ? ` ${refLines.join(". ")}.` : "") + ` Do not browse the web or explore files. Save the result in the current directory as out-1.png (you may run the commands needed to save and resize it).` + // sips is already licensed by the parenthesis above; an exact-size
2590
+ // answer lets the caller's compositing pass skip its rescale.
2591
+ (req.width && req.height ? ` Save the result at exactly ${req.width}x${req.height} pixels.` : "") + ` Nothing else.`;
2538
2592
  const args = execArgs(dir);
2539
2593
  for (const name of ["input.png", ...refLines.map((_, i) => `${editRoles[i] ?? "reference"}-${i + 1}.png`)]) {
2540
2594
  args.splice(args.length - 1, 0, `--image=${join(dir, name)}`);
@@ -2607,7 +2661,7 @@ function createDemoEngine(saveImage) {
2607
2661
  <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>
2608
2662
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
2609
2663
  </svg>`;
2610
- return sharp8(Buffer.from(svg)).png().toBuffer();
2664
+ return sharp7(Buffer.from(svg)).png().toBuffer();
2611
2665
  }
2612
2666
  return {
2613
2667
  capabilities() {
@@ -2617,6 +2671,11 @@ function createDemoEngine(saveImage) {
2617
2671
  localOnly: false,
2618
2672
  supportsEdit: true,
2619
2673
  supportsMask: false,
2674
+ // It draws a placeholder for everything, expansions included, so it
2675
+ // stands in for a capable engine rather than blocking the path in
2676
+ // development and in the end-to-end suite. `placeholder` below is what
2677
+ // says none of this is a real picture.
2678
+ supportsOutpaint: true,
2620
2679
  maxReferenceImages: 0,
2621
2680
  placeholder: true
2622
2681
  };
@@ -2770,7 +2829,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
2770
2829
  for (const [slot, angle] of PRESENTER_ANGLES) {
2771
2830
  const path = presenterRefPath(templatesRoot, presenter.id, slot);
2772
2831
  if (!existsSync(path)) continue;
2773
- const png = await sharp8(readFileSync(path)).png().toBuffer();
2832
+ const png = await sharp7(readFileSync(path)).png().toBuffer();
2774
2833
  const hash = core.images.save(png);
2775
2834
  shots.push({ file: `asset:${hash}`, angle, locked: true });
2776
2835
  }
@@ -2864,7 +2923,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
2864
2923
  for (const angle of angles) {
2865
2924
  const path = demoProductRefPath(templatesRoot, product.id, angle);
2866
2925
  if (!existsSync(path)) continue;
2867
- const png = await sharp8(readFileSync(path)).png().toBuffer();
2926
+ const png = await sharp7(readFileSync(path)).png().toBuffer();
2868
2927
  const hash = core.images.save(png);
2869
2928
  shots.push({ file: `asset:${hash}`, angle, locked: true });
2870
2929
  }
@@ -2913,6 +2972,67 @@ function defaultDemoProductsDir() {
2913
2972
  return join(here, "..", "..", "..", "templates", "demo-products");
2914
2973
  }
2915
2974
 
2975
+ // src/attachmentBudget.ts
2976
+ var ROLE_PRIORITY = {
2977
+ product: 0,
2978
+ character: 1,
2979
+ brand: 2,
2980
+ scene: 3,
2981
+ composition: 4,
2982
+ reference: 5,
2983
+ style: 6
2984
+ };
2985
+ function allocateAttachments(attachments, cap2) {
2986
+ const max = Math.max(0, cap2);
2987
+ const indexed = attachments.map((a, i) => ({ a, i }));
2988
+ const legacyOrder = [...indexed].sort(
2989
+ (x, y) => Number(!!y.a.essential) - Number(!!x.a.essential) || ROLE_PRIORITY[x.a.role] - ROLE_PRIORITY[y.a.role] || x.i - y.i
2990
+ );
2991
+ const groupOf = (a) => `${a.role}:${a.id ?? a.hash}`;
2992
+ const kept = /* @__PURE__ */ new Set();
2993
+ const keptGroups = /* @__PURE__ */ new Set();
2994
+ const admit = (x) => {
2995
+ kept.add(x.i);
2996
+ keptGroups.add(groupOf(x.a));
2997
+ };
2998
+ for (const x of legacyOrder) {
2999
+ if (kept.size >= max) break;
3000
+ if (x.a.essential) admit(x);
3001
+ }
3002
+ for (const x of legacyOrder) {
3003
+ if (kept.size >= max) break;
3004
+ if (!kept.has(x.i) && !keptGroups.has(groupOf(x.a))) admit(x);
3005
+ }
3006
+ const queues = /* @__PURE__ */ new Map();
3007
+ for (const x of legacyOrder) {
3008
+ if (kept.has(x.i)) continue;
3009
+ const g = groupOf(x.a);
3010
+ if (!queues.has(g)) queues.set(g, []);
3011
+ queues.get(g).push(x);
3012
+ }
3013
+ let admitted = true;
3014
+ while (kept.size < max && admitted) {
3015
+ admitted = false;
3016
+ for (const queue of queues.values()) {
3017
+ if (kept.size >= max) break;
3018
+ const x = queue.shift();
3019
+ if (x) {
3020
+ admit(x);
3021
+ admitted = true;
3022
+ }
3023
+ }
3024
+ }
3025
+ return {
3026
+ kept: legacyOrder.filter((x) => kept.has(x.i)).map((x) => x.a),
3027
+ dropped: legacyOrder.filter((x) => !kept.has(x.i)).map((x) => x.a)
3028
+ };
3029
+ }
3030
+ function mergeEditAttachments(own, inherited, cap2) {
3031
+ const seen = new Set(own.map((a) => a.hash));
3032
+ const borrowed = inherited.filter((a) => !seen.has(a.hash)).map((a) => ({ ...a, inherited: true }));
3033
+ return allocateAttachments([...own, ...borrowed], cap2);
3034
+ }
3035
+
2916
3036
  // src/briefDirectives.ts
2917
3037
  function productFidelityDirective(attached) {
2918
3038
  if (attached <= 1) {
@@ -2934,7 +3054,7 @@ function garmentDisplayDirective() {
2934
3054
  return "No person is part of this brief. Present the garment as a product, laid, hung, folded or dressed on a plain form, never on a person, a partial figure or an invisible body, unless the direction above explicitly asks for it worn.";
2935
3055
  }
2936
3056
  function shotSpecifiesCamera(text) {
2937
- return /\b\d{2,3}\s?mm\b|\bf\/\d|\blens\b|\bcamera\b|\bshot from\b|\beye[- ]level\b|\blow angle\b|\bhigh angle\b|\boverhead\b|\btop[- ]down\b|\bbird'?s[- ]eye\b|\bclose[- ]up\b|\bmacro\b|\bwide shot\b|\bcrop(?:ped)?\b|\bframing\b|\bdepth of field\b|\bbokeh\b|\bshallow (?:focus|depth)\b|\bdeep focus\b/i.test(
3057
+ return /\b\d{2,3}\s?mm\b|\bf\/\d|\blens\b|\bcamera\b|\bshot from\b|\beye[- ]level\b|\blow angle\b|\bhigh angle\b|\boverhead\b|\btop[- ]down\b|\bbird'?s[- ]eye\b|\bclose[- ]?up\b|\bmacro\b|\bwide shot\b|\bcrop(?:ped)?\b|\bframing\b|\bDOF\b|\bdepth of field\b|\bbokeh\b|\bshallow (?:focus|depth)\b|\bdeep focus\b/i.test(
2938
3058
  text
2939
3059
  );
2940
3060
  }
@@ -2949,6 +3069,9 @@ function sceneGuardDirectives(opts) {
2949
3069
  out.push(
2950
3070
  "Disregard any wardrobe, accessory, or garment brand named in the scene direction above \u2014 dress the attached person reference only in the generic material and color terms described; do not print, stitch, or render any brand name or wordmark from the scene text onto them."
2951
3071
  );
3072
+ out.push(
3073
+ "The scene direction above describes the set, not the cast. If it says the space is empty or that no people appear, disregard that: the attached person stands in this set, clearly visible. A ban on props or extra objects in the scene direction is about set dressing only \u2014 it never applies to the presenter or to the product in their hands."
3074
+ );
2952
3075
  }
2953
3076
  return out;
2954
3077
  }
@@ -3099,6 +3222,9 @@ function compileBrief(brief, ctx) {
3099
3222
  chashes.forEach((chash, i) => {
3100
3223
  attachments.push({ role: "character", id: c.id, label: c.name, hash: chash, essential: i === 0 });
3101
3224
  });
3225
+ personDirectives.push(
3226
+ `${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.`
3227
+ );
3102
3228
  personDirectives.push(
3103
3229
  "The attached person reference is the same person every time: match their face, facial structure, skin, hair and build exactly. Their outfit, pose, background and lighting are neutral studio capture conditions, not styling direction: dress and style them for this shot, to a commercial standard, following any wardrobe the direction itself specifies. Where the direction specifies none, dress them for the place and the occasion the frame shows, and never return them to the plain base layers they were photographed in."
3104
3230
  );
@@ -3199,11 +3325,21 @@ function compileBrief(brief, ctx) {
3199
3325
  const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
3200
3326
  const guard = scene ? sceneGuardDirectives({ hasProduct: !!productId, hasPerson }) : [];
3201
3327
  const pairDirectives = productId && hasPerson ? [
3202
- "If the attached product is something a person wears, the presenter wears that exact product, with the rest of the outfit styled around it; otherwise the presenter presents or uses the product naturally."
3328
+ "If the attached product is something a person wears, the presenter wears that exact product, with the rest of the outfit styled around it; otherwise the presenter presents or uses the product naturally.",
3329
+ // A product's own notes are written for solo packshots, and several
3330
+ // catalog records literally say "no props, hands, or presenter in
3331
+ // frame". That data already shipped, so the override lives here in
3332
+ // the compiler: fired only when a person is attached, worded like
3333
+ // the mark exception — the ban stays for invented people, the
3334
+ // attached one is deliberate.
3335
+ "Any earlier instruction that bans props, hands, people, or a presenter from the frame is a solo-packshot rule for this product and does not apply to this shot: the attached presenter is deliberate and must appear as directed."
3336
+ ] : [];
3337
+ const closeUpDirectives = hasPerson && /\bclose[- ]?up\b|\bmacro\b|\bzoom(?:ed)?\b|\bDOF\b|\bdepth of field\b/i.test(sentence) ? [
3338
+ "The tight framing includes the presenter: keep at least their hand in genuine contact with the product, and as much more of them as the crop allows. A close-up is never a reason to leave the person out of the photograph."
3203
3339
  ] : [];
3204
3340
  const brandLines = brandRuleDirectives(ctx.brand);
3205
3341
  const preservation = ctx.mode === "edit" ? [
3206
- editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval }),
3342
+ ...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
3207
3343
  ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : []
3208
3344
  ] : [];
3209
3345
  const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
@@ -3215,6 +3351,7 @@ function compileBrief(brief, ctx) {
3215
3351
  ...productDirectives,
3216
3352
  ...personDirectives,
3217
3353
  ...pairDirectives,
3354
+ ...closeUpDirectives,
3218
3355
  ...otherDirectives,
3219
3356
  ...cameraDirectives,
3220
3357
  ...apparelUnworn,
@@ -3223,25 +3360,8 @@ function compileBrief(brief, ctx) {
3223
3360
  ...preservation
3224
3361
  ];
3225
3362
  if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
3226
- const ROLE_PRIORITY = {
3227
- product: 0,
3228
- character: 1,
3229
- brand: 2,
3230
- scene: 3,
3231
- composition: 4,
3232
- reference: 5,
3233
- style: 6
3234
- };
3235
- const ordered = attachments.map((a, i) => ({ a, i })).sort(
3236
- (x, y) => (
3237
- // Essential identity first, so a tight cap sheds extra product angles
3238
- // and style references before it sheds a subject entirely.
3239
- Number(!!y.a.essential) - Number(!!x.a.essential) || ROLE_PRIORITY[x.a.role] - ROLE_PRIORITY[y.a.role] || x.i - y.i
3240
- )
3241
- ).map((x) => x.a);
3242
3363
  const max = ctx.engineCaps.maxReferenceImages;
3243
- const kept = ordered.slice(0, Math.max(0, max));
3244
- const dropped = ordered.slice(kept.length);
3364
+ const { kept, dropped } = allocateAttachments(attachments, max);
3245
3365
  if (dropped.length) {
3246
3366
  const names = [...new Set(dropped.map((d) => d.label))];
3247
3367
  warnings.push(
@@ -5213,8 +5333,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
5213
5333
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
5214
5334
  return;
5215
5335
  }
5216
- const png = await sharp8(buf).rotate().png().toBuffer();
5217
- const meta = await sharp8(png).metadata();
5336
+ const png = await sharp7(buf).rotate().png().toBuffer();
5337
+ const meta = await sharp7(png).metadata();
5218
5338
  const hash = core.images.save(png);
5219
5339
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
5220
5340
  width: meta.width,
@@ -5558,8 +5678,8 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
5558
5678
  }
5559
5679
  if (signal.aborted) throw new Error("cancelled");
5560
5680
  patch(job, { stage: "saving", message: null });
5561
- const previewHash = await cardCrop(core, shotHashes[0]);
5562
- const avatarHash = await avatarCrop(core, shotHashes[0]);
5681
+ const generated = shotHashes !== hashes;
5682
+ const { previewHash, avatarHash } = await presenterCrops(core, shotHashes[0], generated ? "generated" : "upload");
5563
5683
  const built = presenterRecordFrom({
5564
5684
  name: job.name,
5565
5685
  shotHashes,
@@ -5624,7 +5744,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
5624
5744
  return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
5625
5745
  }
5626
5746
  async function edgeBarGeometry(buf) {
5627
- const { data, info } = await sharp8(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
5747
+ const { data, info } = await sharp7(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
5628
5748
  const W = info.width;
5629
5749
  const H = info.height;
5630
5750
  const scan = (len, cross, at) => {
@@ -5678,7 +5798,7 @@ async function trimEdgeBars(core, hash) {
5678
5798
  const width = g.right - g.left + 1;
5679
5799
  const height = g.bottom - g.top + 1;
5680
5800
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
5681
- const png = await sharp8(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
5801
+ const png = await sharp7(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
5682
5802
  return core.images.save(png);
5683
5803
  } catch {
5684
5804
  return hash;
@@ -5692,19 +5812,80 @@ async function cardCrop(core, hash) {
5692
5812
  });
5693
5813
  }
5694
5814
  async function avatarCrop(core, hash) {
5815
+ if (!hash || !core.images.has(hash)) return void 0;
5816
+ let box = null;
5817
+ try {
5818
+ box = await figureBox(core.images.read(hash));
5819
+ } catch {
5820
+ box = null;
5821
+ }
5695
5822
  return crop(core, hash, (w, h) => {
5696
- const size = Math.min(w, h, Math.round(h * 0.16));
5697
- return { left: Math.max(0, Math.round((w - size) / 2)), top: 0, width: size, height: size };
5823
+ if (!box) {
5824
+ const size2 = Math.min(w, h, Math.round(h * 0.16));
5825
+ return { left: Math.max(0, Math.round((w - size2) / 2)), top: 0, width: size2, height: size2 };
5826
+ }
5827
+ const size = Math.min(w, h, Math.max(16, Math.round(box.height * 0.27)));
5828
+ const top = Math.min(Math.max(0, Math.round(box.top - size * 0.08)), h - size);
5829
+ const left = Math.min(Math.max(0, Math.round(box.left + box.width / 2 - size / 2)), w - size);
5830
+ return { left, top, width: size, height: size };
5698
5831
  });
5699
5832
  }
5833
+ async function figureBox(buf) {
5834
+ const meta = await sharp7(buf).metadata();
5835
+ const W = meta.width ?? 0;
5836
+ const H = meta.height ?? 0;
5837
+ if (!W || !H) return null;
5838
+ const { info } = await sharp7(buf).trim({ threshold: 12 }).toBuffer({ resolveWithObject: true });
5839
+ const left = Math.abs(info.trimOffsetLeft ?? 0);
5840
+ const top = Math.abs(info.trimOffsetTop ?? 0);
5841
+ const width = info.width ?? 0;
5842
+ const height = info.height ?? 0;
5843
+ if (!width || !height) return null;
5844
+ if (width >= W && height >= H) return null;
5845
+ if (height < H * 0.3 || width < W * 0.05) return null;
5846
+ return { left, top, width, height };
5847
+ }
5848
+ async function presenterCrops(core, hash, mode) {
5849
+ const previewHash = mode === "generated" ? await cardCrop(core, hash) ?? await cardCropSmart(core, hash) : await cardCropSmart(core, hash) ?? await cardCrop(core, hash);
5850
+ const avatarHash = mode === "generated" ? await avatarCrop(core, hash) ?? await avatarCropSmart(core, hash) : await avatarCropSmart(core, hash) ?? await avatarCrop(core, hash);
5851
+ return { previewHash, avatarHash };
5852
+ }
5853
+ async function cardCropSmart(core, hash) {
5854
+ return smartCover(core, hash, (w, h) => {
5855
+ if (w / h > 0.8) return { width: Math.round(h * 0.8), height: h };
5856
+ return { width: w, height: Math.min(h, Math.round(w / 0.8)) };
5857
+ });
5858
+ }
5859
+ async function avatarCropSmart(core, hash) {
5860
+ return smartCover(core, hash, (w, h) => {
5861
+ const size = Math.min(w, h, 512);
5862
+ return { width: size, height: size };
5863
+ });
5864
+ }
5865
+ async function smartCover(core, hash, box) {
5866
+ if (!hash || !core.images.has(hash)) return void 0;
5867
+ try {
5868
+ const buf = core.images.read(hash);
5869
+ const meta = await sharp7(buf).metadata();
5870
+ const w = meta.width ?? 0;
5871
+ const h = meta.height ?? 0;
5872
+ if (!w || !h) return void 0;
5873
+ const raw = box(w, h);
5874
+ const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
5875
+ const png = await sharp7(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
5876
+ return core.images.save(png);
5877
+ } catch {
5878
+ return void 0;
5879
+ }
5880
+ }
5700
5881
  async function crop(core, hash, region) {
5701
5882
  if (!hash || !core.images.has(hash)) return void 0;
5702
5883
  try {
5703
- const meta = await sharp8(core.images.read(hash)).metadata();
5884
+ const meta = await sharp7(core.images.read(hash)).metadata();
5704
5885
  const w = meta.width ?? 0;
5705
5886
  const h = meta.height ?? 0;
5706
5887
  if (!w || !h) return void 0;
5707
- const png = await sharp8(core.images.read(hash)).extract(region(w, h)).png().toBuffer();
5888
+ const png = await sharp7(core.images.read(hash)).extract(region(w, h)).png().toBuffer();
5708
5889
  return core.images.save(png);
5709
5890
  } catch {
5710
5891
  return void 0;
@@ -5870,7 +6051,9 @@ function inheritedIdentityTokens(parentId, getNode) {
5870
6051
  for (let hop = 0; hop < MAX_HOPS && id; hop++) {
5871
6052
  const node = getNode(id);
5872
6053
  if (!node || node.kind === "root") return [];
5873
- const identity = tokensOf(node).filter((t) => t.t === "product" || t.t === "character" || t.t === "mark");
6054
+ const identity = tokensOf(node).filter(
6055
+ (t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref"
6056
+ );
5874
6057
  if (identity.length) return identity;
5875
6058
  id = node.parentId;
5876
6059
  }
@@ -5956,33 +6139,436 @@ function planExpand(source, targetRatio) {
5956
6139
  };
5957
6140
  }
5958
6141
  function expandInstruction(plan, direction) {
5959
- const where = plan.axis === "width" ? "to the left and right" : "above and below";
5960
- return `Extend this photograph ${where} to fill the empty margin, continuing the same scene, the same surface, the same lighting and the same perspective straight out to the new edges. Do not change, move, rescale or reinterpret anything already in the picture, and do not add a subject, a product or a person that is not already there.${direction.trim() ? ` ${direction.trim()}` : ""}`;
6142
+ const where = plan.axis === "width" ? "left and right" : "top and bottom";
6143
+ const nearEdge = plan.axis === "height" ? "\nDepth: the bottom edge of the frame is the part of the surface nearest the camera; the top edge is the furthest away." : "";
6144
+ const extra = direction.trim().replace(/^[.\s]+/, "");
6145
+ const own = extra ? `
6146
+ Also: ${extra}` : "";
6147
+ return `Fill only the soft blurred margin at the ${where} of this frame so the photograph continues into it.
6148
+ Continue: the same surface, the same light direction, the same colour temperature and the same depth of field that are already in the picture.` + nearEdge + `
6149
+ Constraints: change only the blurred margin; keep the sharp photograph unchanged in position, scale and content.
6150
+ Avoid: new objects, products, people, text or watermarks.` + own;
6151
+ }
6152
+
6153
+ // src/cropRules.ts
6154
+ function planCrop(source, targetRatio) {
6155
+ if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
6156
+ const current = source.width / source.height;
6157
+ if (Math.abs(current - targetRatio) / targetRatio < 0.01) return null;
6158
+ if (targetRatio < current) {
6159
+ const width = Math.max(1, Math.min(source.width, Math.round(source.height * targetRatio)));
6160
+ return { left: Math.floor((source.width - width) / 2), top: 0, width, height: source.height, axis: "width" };
6161
+ }
6162
+ const height = Math.max(1, Math.min(source.height, Math.round(source.width / targetRatio)));
6163
+ return { left: 0, top: Math.floor((source.height - height) / 2), width: source.width, height, axis: "height" };
6164
+ }
6165
+ async function attentionCropOrigin(srcBuf, source, plan) {
6166
+ try {
6167
+ const { info } = await sharp7(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6168
+ const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
6169
+ const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
6170
+ const left = Math.round((attnLeft + plan.left) / 2);
6171
+ const top = Math.round((attnTop + plan.top) / 2);
6172
+ return plan.axis === "width" ? { left: Math.min(Math.max(0, left), source.width - plan.width), top: 0 } : { left: 0, top: Math.min(Math.max(0, top), source.height - plan.height) };
6173
+ } catch {
6174
+ return { left: plan.left, top: plan.top };
6175
+ }
6176
+ }
6177
+
6178
+ // src/outpaint/membrane.ts
6179
+ var SOLVE_MAX_EDGE = 192;
6180
+ var SOLVE_MIN_EDGE = 8;
6181
+ var SWEEPS_PER_EDGE = 2;
6182
+ var MIN_SWEEPS = 40;
6183
+ var POLISH_SWEEPS = 32;
6184
+ var POLISH_MAX_CELLS = 4e6;
6185
+ function solveMembrane(req) {
6186
+ const { width, height, axis, seamAt } = req;
6187
+ const along = axis === "width" ? height : width;
6188
+ const depth = axis === "width" ? width : height;
6189
+ const out = new Float32Array(width * height * 3);
6190
+ if (width < 1 || height < 1 || along < 1 || depth < 1) return out;
6191
+ if (depth === 1) {
6192
+ for (let a = 0; a < along; a++) {
6193
+ const off = marginOffset(0, a, width, axis, seamAt, depth) * 3;
6194
+ for (let c = 0; c < 3; c++) out[off + c] = req.seam[a * 3 + c];
6195
+ }
6196
+ return out;
6197
+ }
6198
+ const coarse = cascade(req.seam, along, depth);
6199
+ if (depth * along <= POLISH_MAX_CELLS) {
6200
+ const full = { along, depth, data: new Float32Array(along * depth * 3) };
6201
+ prolong(coarse, full);
6202
+ relax(full, req.seam, POLISH_SWEEPS);
6203
+ for (let d = 0; d < depth; d++) {
6204
+ for (let a = 0; a < along; a++) {
6205
+ const off = marginOffset(d, a, width, axis, seamAt, depth) * 3;
6206
+ const src = (d * along + a) * 3;
6207
+ out[off] = full.data[src];
6208
+ out[off + 1] = full.data[src + 1];
6209
+ out[off + 2] = full.data[src + 2];
6210
+ }
6211
+ }
6212
+ return out;
6213
+ }
6214
+ for (let d = 0; d < depth; d++) {
6215
+ for (let a = 0; a < along; a++) {
6216
+ const off = marginOffset(d, a, width, axis, seamAt, depth) * 3;
6217
+ sampleGrid(coarse, d, a, depth, along, out, off);
6218
+ }
6219
+ }
6220
+ return out;
5961
6221
  }
6222
+ function marginOffset(d, a, width, axis, seamAt, depth) {
6223
+ if (axis === "width") {
6224
+ const x = seamAt === "far" ? depth - 1 - d : d;
6225
+ return a * width + x;
6226
+ }
6227
+ const y = seamAt === "far" ? depth - 1 - d : d;
6228
+ return y * width + a;
6229
+ }
6230
+ function cascade(seam, along, depth) {
6231
+ const scale = Math.max(1, Math.ceil(Math.max(along, depth) / SOLVE_MAX_EDGE));
6232
+ const targetAlong = Math.max(2, Math.round(along / scale));
6233
+ const targetDepth = Math.max(2, Math.round(depth / scale));
6234
+ const edges = [];
6235
+ for (let e = SOLVE_MIN_EDGE; e < Math.max(targetAlong, targetDepth); e *= 2) edges.push(e);
6236
+ edges.push(Math.max(targetAlong, targetDepth));
6237
+ let grid = null;
6238
+ for (const edge of edges) {
6239
+ const ratio = edge / Math.max(targetAlong, targetDepth);
6240
+ const la = Math.max(2, Math.round(targetAlong * ratio));
6241
+ const ld = Math.max(2, Math.round(targetDepth * ratio));
6242
+ const next = { along: la, depth: ld, data: new Float32Array(la * ld * 3) };
6243
+ if (grid) prolong(grid, next);
6244
+ relax(next, resampleSeam(seam, along, la));
6245
+ grid = next;
6246
+ }
6247
+ return grid;
6248
+ }
6249
+ function resampleSeam(seam, along, to) {
6250
+ const out = new Float32Array(to * 3);
6251
+ for (let i = 0; i < to; i++) {
6252
+ const src = to === 1 ? 0 : i * (along - 1) / (to - 1);
6253
+ const lo = Math.floor(src);
6254
+ const hi = Math.min(along - 1, lo + 1);
6255
+ const t = src - lo;
6256
+ for (let c = 0; c < 3; c++) out[i * 3 + c] = seam[lo * 3 + c] * (1 - t) + seam[hi * 3 + c] * t;
6257
+ }
6258
+ return out;
6259
+ }
6260
+ function prolong(from, to) {
6261
+ for (let d = 0; d < to.depth; d++) {
6262
+ const sd = to.depth === 1 ? 0 : d * (from.depth - 1) / (to.depth - 1);
6263
+ for (let a = 0; a < to.along; a++) {
6264
+ const sa = to.along === 1 ? 0 : a * (from.along - 1) / (to.along - 1);
6265
+ sampleGrid(from, sd, sa, from.depth, from.along, to.data, (d * to.along + a) * 3);
6266
+ }
6267
+ }
6268
+ }
6269
+ function sampleGrid(grid, d, a, fromDepth, fromAlong, into, off) {
6270
+ const sd = fromDepth === grid.depth ? d : grid.depth === 1 ? 0 : d * (grid.depth - 1) / (fromDepth - 1);
6271
+ const sa = fromAlong === grid.along ? a : grid.along === 1 ? 0 : a * (grid.along - 1) / (fromAlong - 1);
6272
+ const d0 = Math.floor(sd);
6273
+ const a0 = Math.floor(sa);
6274
+ const d1 = Math.min(grid.depth - 1, d0 + 1);
6275
+ const a1 = Math.min(grid.along - 1, a0 + 1);
6276
+ const td = sd - d0;
6277
+ const ta = sa - a0;
6278
+ const i00 = (d0 * grid.along + a0) * 3;
6279
+ const i01 = (d0 * grid.along + a1) * 3;
6280
+ const i10 = (d1 * grid.along + a0) * 3;
6281
+ const i11 = (d1 * grid.along + a1) * 3;
6282
+ for (let c = 0; c < 3; c++) {
6283
+ const top = grid.data[i00 + c] * (1 - ta) + grid.data[i01 + c] * ta;
6284
+ const bot = grid.data[i10 + c] * (1 - ta) + grid.data[i11 + c] * ta;
6285
+ into[off + c] = top * (1 - td) + bot * td;
6286
+ }
6287
+ }
6288
+ function relax(grid, seam, fixedSweeps) {
6289
+ const { depth, along, data } = grid;
6290
+ for (let a = 0; a < along; a++) {
6291
+ const off = a * 3;
6292
+ for (let c = 0; c < 3; c++) data[off + c] = seam[off + c];
6293
+ }
6294
+ if (depth < 2) return;
6295
+ const n = Math.max(depth, along);
6296
+ const omega = Math.min(1.99, 2 / (1 + Math.sin(Math.PI / Math.max(2, n))));
6297
+ const sweeps = fixedSweeps ?? Math.max(MIN_SWEEPS, SWEEPS_PER_EDGE * n);
6298
+ for (let sweep = 0; sweep < sweeps; sweep++) {
6299
+ for (let parity = 0; parity < 2; parity++) {
6300
+ for (let d = 1; d < depth; d++) {
6301
+ const row = d * along;
6302
+ const rowUp = (d - 1) * along;
6303
+ const rowDn = (d < depth - 1 ? d + 1 : d - 1) * along;
6304
+ for (let a = (d + parity) % 2; a < along; a += 2) {
6305
+ const iL = row + (a > 0 ? a - 1 : Math.min(1, along - 1));
6306
+ const iR = row + (a < along - 1 ? a + 1 : Math.max(0, along - 2));
6307
+ const i = row + a;
6308
+ for (let c = 0; c < 3; c++) {
6309
+ const avg = (data[(rowUp + a) * 3 + c] + data[(rowDn + a) * 3 + c] + data[iL * 3 + c] + data[iR * 3 + c]) * 0.25;
6310
+ data[i * 3 + c] += omega * (avg - data[i * 3 + c]);
6311
+ }
6312
+ }
6313
+ }
6314
+ }
6315
+ }
6316
+ }
6317
+
6318
+ // src/expand.ts
5962
6319
  async function expandCanvas(source, plan) {
5963
- const bed = await sharp8(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
5964
- return sharp8(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6320
+ const bed = await sharp7(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6321
+ return sharp7(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
5965
6322
  }
5966
6323
  async function compositeExpand(engineImage, source, plan) {
5967
- const meta = await sharp8(engineImage).metadata();
6324
+ const meta = await sharp7(engineImage).metadata();
5968
6325
  const want = plan.width / plan.height;
5969
6326
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
5970
6327
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
5971
6328
  const aligned = sameOrientation;
5972
- const surround = aligned ? await sharp8(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
5973
- const image = await sharp8(surround).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6329
+ const exact = meta.width === plan.width && meta.height === plan.height;
6330
+ const surround = aligned ? exact ? engineImage : await sharp7(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
6331
+ const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
6332
+ const image = await sharp7(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
5974
6333
  return { image, aligned };
5975
6334
  }
6335
+ async function matchMarginsToSeam(surround, source, plan) {
6336
+ const src = await sharp7(source).metadata();
6337
+ if (!src.width || !src.height) return surround;
6338
+ const SW = src.width;
6339
+ const SH = src.height;
6340
+ const sides = [];
6341
+ if (plan.axis === "width") {
6342
+ if (plan.left > 0)
6343
+ sides.push({
6344
+ margin: { left: 0, top: plan.top, width: plan.left, height: SH },
6345
+ srcEdge: { left: 0, top: 0, width: 1, height: SH },
6346
+ seamAt: "far"
6347
+ });
6348
+ const rightAt = plan.left + SW;
6349
+ if (rightAt < plan.width)
6350
+ sides.push({
6351
+ margin: { left: rightAt, top: plan.top, width: plan.width - rightAt, height: SH },
6352
+ srcEdge: { left: SW - 1, top: 0, width: 1, height: SH },
6353
+ seamAt: "near"
6354
+ });
6355
+ } else {
6356
+ if (plan.top > 0)
6357
+ sides.push({
6358
+ margin: { left: plan.left, top: 0, width: SW, height: plan.top },
6359
+ srcEdge: { left: 0, top: 0, width: SW, height: 1 },
6360
+ seamAt: "far"
6361
+ });
6362
+ const bottomAt = plan.top + SH;
6363
+ if (bottomAt < plan.height)
6364
+ sides.push({
6365
+ margin: { left: plan.left, top: bottomAt, width: SW, height: plan.height - bottomAt },
6366
+ srcEdge: { left: 0, top: SH - 1, width: SW, height: 1 },
6367
+ seamAt: "near"
6368
+ });
6369
+ }
6370
+ let out = surround;
6371
+ for (const side of sides) {
6372
+ try {
6373
+ out = await reconcile(out, source, side, plan.axis);
6374
+ } catch {
6375
+ }
6376
+ }
6377
+ return out;
6378
+ }
6379
+ var MAX_CORRECTION = 60;
6380
+ async function reconcile(surround, source, side, axis) {
6381
+ const { margin } = side;
6382
+ if (margin.width < 1 || margin.height < 1) return surround;
6383
+ const marginRaw = await sharp7(surround).extract(margin).removeAlpha().raw().toBuffer();
6384
+ const edgeRaw = await sharp7(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
6385
+ const W = margin.width;
6386
+ const H = margin.height;
6387
+ const along = axis === "width" ? H : W;
6388
+ const seamIndex = side.seamAt === "far" ? axis === "width" ? W - 1 : H - 1 : 0;
6389
+ const err = new Float32Array(along * 3);
6390
+ for (let i = 0; i < along; i++) {
6391
+ const mOff = axis === "width" ? (i * W + seamIndex) * 3 : (seamIndex * W + i) * 3;
6392
+ for (let c = 0; c < 3; c++) {
6393
+ const want = edgeRaw[i * 3 + c];
6394
+ const have = marginRaw[mOff + c];
6395
+ err[i * 3 + c] = Math.max(-MAX_CORRECTION, Math.min(MAX_CORRECTION, want - have));
6396
+ }
6397
+ }
6398
+ const radius = Math.max(2, Math.round(along / 64));
6399
+ const smooth = new Float32Array(err.length);
6400
+ for (let i = 0; i < along; i++) {
6401
+ for (let c = 0; c < 3; c++) {
6402
+ let sum = 0;
6403
+ let n = 0;
6404
+ for (let k = -radius; k <= radius; k++) {
6405
+ const j = i + k;
6406
+ if (j < 0 || j >= along) continue;
6407
+ sum += err[j * 3 + c];
6408
+ n++;
6409
+ }
6410
+ smooth[i * 3 + c] = sum / n;
6411
+ }
6412
+ }
6413
+ const half = Math.max(1, Math.floor(along / 2));
6414
+ const centreLo = (half - 1) / 2;
6415
+ const centreHi = half + (along - half - 1) / 2;
6416
+ const span = Math.max(1, centreHi - centreLo);
6417
+ const base = [0, 0, 0];
6418
+ const slope = [0, 0, 0];
6419
+ for (let c = 0; c < 3; c++) {
6420
+ const lo = medianOf(smooth, c, 0, half);
6421
+ const hi = medianOf(smooth, c, half, along);
6422
+ slope[c] = (hi - lo) / span;
6423
+ base[c] = lo - slope[c] * centreLo;
6424
+ for (let i = 0; i < along; i++) smooth[i * 3 + c] -= base[c] + slope[c] * i;
6425
+ }
6426
+ const field = solveMembrane({ width: W, height: H, axis, seamAt: side.seamAt, seam: smooth });
6427
+ const corrected = Buffer.from(marginRaw);
6428
+ for (let y = 0; y < H; y++) {
6429
+ for (let x = 0; x < W; x++) {
6430
+ const i = axis === "width" ? y : x;
6431
+ const off = (y * W + x) * 3;
6432
+ for (let c = 0; c < 3; c++) {
6433
+ const v = corrected[off + c] + base[c] + slope[c] * i + field[off + c];
6434
+ corrected[off + c] = v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
6435
+ }
6436
+ }
6437
+ }
6438
+ const patch2 = await sharp7(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
6439
+ return sharp7(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
6440
+ }
5976
6441
  async function expandCanvasBedOnly(source, plan) {
5977
- return sharp8(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6442
+ return sharp7(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6443
+ }
6444
+ function medianOf(rgb, channel, from, to) {
6445
+ const n = to - from;
6446
+ if (n < 1) return 0;
6447
+ const values = new Float64Array(n);
6448
+ for (let i = 0; i < n; i++) values[i] = rgb[(from + i) * 3 + channel];
6449
+ values.sort();
6450
+ return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
6451
+ }
6452
+ async function seamScore(image, plan, source) {
6453
+ const { data, info } = await sharp7(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
6454
+ const W = info.width;
6455
+ const H = info.height;
6456
+ const horizontal = plan.axis === "width";
6457
+ const line = (i) => {
6458
+ let sum = 0;
6459
+ if (horizontal) {
6460
+ if (i < 1 || i >= W) return 0;
6461
+ for (let y = 0; y < H; y++) sum += Math.abs(data[y * W + i] - data[y * W + i - 1]);
6462
+ return sum / H;
6463
+ }
6464
+ if (i < 1 || i >= H) return 0;
6465
+ for (let x = 0; x < W; x++) sum += Math.abs(data[i * W + x] - data[(i - 1) * W + x]);
6466
+ return sum / W;
6467
+ };
6468
+ const at = (seam) => {
6469
+ const near = [];
6470
+ for (let d = 6; d <= 30; d++) {
6471
+ near.push(line(seam - d));
6472
+ near.push(line(seam + d));
6473
+ }
6474
+ const ordinary = near.reduce((a, b) => a + b, 0) / near.length;
6475
+ if (ordinary < 0.05) return 1;
6476
+ return line(seam) / ordinary;
6477
+ };
6478
+ const first = horizontal ? plan.left : plan.top;
6479
+ const second = first + (horizontal ? source.width : source.height);
6480
+ return Math.max(at(first), at(second));
6481
+ }
6482
+ var SEAM_VISIBLE = 2.2;
6483
+ var OFFSET = 4;
6484
+ var RESIDUAL_VISIBLE = 15;
6485
+ async function seamResidual(image, plan, source) {
6486
+ const { data, info } = await sharp7(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
6487
+ const W = info.width;
6488
+ const H = info.height;
6489
+ const ch = info.channels;
6490
+ const horizontal = plan.axis === "width";
6491
+ const between = (a, b) => {
6492
+ const limit = horizontal ? W : H;
6493
+ if (a < 0 || b < 0 || a >= limit || b >= limit) return null;
6494
+ const run2 = horizontal ? H : W;
6495
+ let sum = 0;
6496
+ for (let i = 0; i < run2; i++) {
6497
+ const ia = (horizontal ? i * W + a : a * W + i) * ch;
6498
+ const ib = (horizontal ? i * W + b : b * W + i) * ch;
6499
+ sum += Math.abs(data[ia] - data[ib]) + Math.abs(data[ia + 1] - data[ib + 1]) + Math.abs(data[ia + 2] - data[ib + 2]);
6500
+ }
6501
+ return sum / (run2 * 3);
6502
+ };
6503
+ const near = horizontal ? plan.left : plan.top;
6504
+ const far = near + (horizontal ? source.width : source.height);
6505
+ const both = [
6506
+ // The margin before the picture starts, against the picture just inside it.
6507
+ between(near - OFFSET, near + OFFSET - 1),
6508
+ // The picture just before it ends, against the margin just after.
6509
+ between(far - OFFSET, far + OFFSET - 1)
6510
+ ].filter((v) => v !== null);
6511
+ return both.length ? Math.max(...both) : 0;
6512
+ }
6513
+ function seamPenalty(score, residual) {
6514
+ return Math.max(score / SEAM_VISIBLE, residual / RESIDUAL_VISIBLE);
6515
+ }
6516
+ var MIN_SHARE = 0.2;
6517
+ var MAX_SHARE = 0.8;
6518
+ async function subjectFraction(src, source, axis) {
6519
+ try {
6520
+ 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)) };
6521
+ const { info } = await sharp7(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6522
+ const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
6523
+ const span = axis === "width" ? source.width : source.height;
6524
+ const extent = axis === "width" ? window.width : window.height;
6525
+ const centre = (offset + extent / 2) / span;
6526
+ return Math.min(1, Math.max(0, (centre + 0.5) / 2));
6527
+ } catch {
6528
+ return 0.5;
6529
+ }
6530
+ }
6531
+ function placeExpand(plan, source, fraction) {
6532
+ const share = Math.min(MAX_SHARE, Math.max(MIN_SHARE, fraction));
6533
+ if (plan.axis === "width") {
6534
+ const room2 = plan.width - source.width;
6535
+ if (room2 <= 0) return plan;
6536
+ return { ...plan, left: Math.min(room2, Math.max(0, Math.round(room2 * share))) };
6537
+ }
6538
+ const room = plan.height - source.height;
6539
+ if (room <= 0) return plan;
6540
+ return { ...plan, top: Math.min(room, Math.max(0, Math.round(room * share))) };
6541
+ }
6542
+
6543
+ // src/outpaint/route.ts
6544
+ var canOutpaint = (e) => {
6545
+ const caps = e.capabilities();
6546
+ return caps.supportsOutpaint === true;
6547
+ };
6548
+ var usable = async (e) => {
6549
+ try {
6550
+ return (await e.isAvailable()).ok;
6551
+ } catch {
6552
+ return false;
6553
+ }
6554
+ };
6555
+ async function resolveOutpaintRoute(all, shot) {
6556
+ if (canOutpaint(shot)) return { engine: shot, method: "outpaint", crossed: false };
6557
+ const shotId = shot.capabilities().id;
6558
+ for (const candidate of all) {
6559
+ const caps = candidate.capabilities();
6560
+ if (caps.id === shotId || caps.placeholder || !canOutpaint(candidate)) continue;
6561
+ if (await usable(candidate)) return { engine: candidate, method: "outpaint", crossed: true };
6562
+ }
6563
+ return { engine: shot, method: "reframe", crossed: false };
5978
6564
  }
5979
6565
  async function driftDiff(a, b) {
5980
- const metaA = await sharp8(a).metadata();
5981
- const metaB = await sharp8(b).metadata();
6566
+ const metaA = await sharp7(a).metadata();
6567
+ const metaB = await sharp7(b).metadata();
5982
6568
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
5983
6569
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
5984
6570
  const [rawA, rawB] = await Promise.all(
5985
- [a, b].map((buf) => sharp8(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
6571
+ [a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
5986
6572
  );
5987
6573
  const out = new PNG({ width, height });
5988
6574
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -5994,11 +6580,11 @@ async function driftDiff(a, b) {
5994
6580
  };
5995
6581
  }
5996
6582
  async function changeMask(a, b, cap2 = 1024) {
5997
- const metaA = await sharp8(a).metadata();
6583
+ const metaA = await sharp7(a).metadata();
5998
6584
  const width = Math.min(metaA.width ?? 1, cap2);
5999
6585
  const height = Math.min(metaA.height ?? 1, cap2);
6000
6586
  const [rawA, rawB] = await Promise.all(
6001
- [a, b].map((buf) => sharp8(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
6587
+ [a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
6002
6588
  );
6003
6589
  const out = new PNG({ width, height });
6004
6590
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -6047,8 +6633,8 @@ function dilationFor(longEdge) {
6047
6633
  // src/localEdit.ts
6048
6634
  async function preserveOutsideChange(source, edited) {
6049
6635
  try {
6050
- const srcMeta = await sharp8(source).metadata();
6051
- const outMeta = await sharp8(edited).metadata();
6636
+ const srcMeta = await sharp7(source).metadata();
6637
+ const outMeta = await sharp7(edited).metadata();
6052
6638
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
6053
6639
  return { image: edited, outcome: "error", changed: 0 };
6054
6640
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -6058,15 +6644,15 @@ async function preserveOutsideChange(source, edited) {
6058
6644
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
6059
6645
  const r = dilationFor(Math.max(shape.width, shape.height));
6060
6646
  const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
6061
- const spread = await sharp8(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
6062
- const dilated = await sharp8(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
6063
- const feathered = await sharp8(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
6064
- const grown = await sharp8(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
6065
- const editedRgb = await sharp8(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
6066
- const masked = await sharp8(editedRgb, {
6647
+ const spread = await sharp7(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
6648
+ const dilated = await sharp7(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
6649
+ const feathered = await sharp7(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
6650
+ const grown = await sharp7(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
6651
+ const editedRgb = await sharp7(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
6652
+ const masked = await sharp7(editedRgb, {
6067
6653
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
6068
6654
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
6069
- const image = await sharp8(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
6655
+ const image = await sharp7(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
6070
6656
  return { image, outcome: "composited", changed: shape.changed };
6071
6657
  } catch {
6072
6658
  return { image: edited, outcome: "error", changed: 0 };
@@ -6095,7 +6681,7 @@ var assetHash2 = (ref) => {
6095
6681
  };
6096
6682
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
6097
6683
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
6098
- var toPng = (buf) => sharp8(buf).rotate().png().toBuffer();
6684
+ var toPng = (buf) => sharp7(buf).rotate().png().toBuffer();
6099
6685
  var COST_PROBE = {
6100
6686
  prompt: "",
6101
6687
  brand: { brand: {}, assetPaths: {} },
@@ -6104,7 +6690,7 @@ var COST_PROBE = {
6104
6690
  count: 1
6105
6691
  };
6106
6692
  var MARK_MAX_EDGE = 2048;
6107
- var toMarkPng = (buf) => sharp8(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
6693
+ var toMarkPng = (buf) => sharp7(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
6108
6694
  var readImagePart = async (core, req, normalize2) => {
6109
6695
  const part = await req.file();
6110
6696
  if (!part) return { error: "multipart file field required" };
@@ -6281,7 +6867,7 @@ async function vibrantColor(input) {
6281
6867
  let data;
6282
6868
  let channels;
6283
6869
  try {
6284
- const out = await sharp8(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
6870
+ const out = await sharp7(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
6285
6871
  data = out.data;
6286
6872
  channels = out.info.channels;
6287
6873
  } catch {
@@ -6304,7 +6890,7 @@ async function vibrantColor(input) {
6304
6890
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
6305
6891
  if (best.score <= 0) {
6306
6892
  try {
6307
- const { dominant } = await sharp8(input).stats();
6893
+ const { dominant } = await sharp7(input).stats();
6308
6894
  return toHex(dominant.r, dominant.g, dominant.b);
6309
6895
  } catch {
6310
6896
  return null;
@@ -6412,6 +6998,39 @@ function registerPresenterRoutes(app, deps) {
6412
6998
  });
6413
6999
  }
6414
7000
 
7001
+ // src/presenterRepair.ts
7002
+ function presenterCropMode(firstShotFile, firstSourceFile) {
7003
+ return firstShotFile && firstShotFile === firstSourceFile ? "upload" : "generated";
7004
+ }
7005
+ async function repairPresenterCrops(core, log = () => {
7006
+ }) {
7007
+ let repaired = 0;
7008
+ for (const brand of core.store.listBrands()) {
7009
+ for (const c of brandCharacters(brand.json)) {
7010
+ if (!isCustomPresenter(c)) continue;
7011
+ try {
7012
+ const firstShot = c.shots?.[0]?.file;
7013
+ const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
7014
+ if (!hash) continue;
7015
+ const mode = presenterCropMode(firstShot, c.sourceRefs?.[0]?.file);
7016
+ const { previewHash, avatarHash } = await presenterCrops(core, hash, mode);
7017
+ const preview = previewHash ? `asset:${previewHash}` : void 0;
7018
+ const avatar = avatarHash ? `asset:${avatarHash}` : void 0;
7019
+ if ((!preview || preview === c.preview) && (!avatar || avatar === c.avatar)) continue;
7020
+ commit(core, brand.id, (json) => {
7021
+ json.characters = brandCharacters(json).map(
7022
+ (row) => row.id === c.id ? { ...row, ...preview ? { preview } : {}, ...avatar ? { avatar } : {} } : row
7023
+ );
7024
+ });
7025
+ repaired += 1;
7026
+ log(`Repaired presenter thumbnails: ${c.name ?? c.id}`);
7027
+ } catch {
7028
+ }
7029
+ }
7030
+ }
7031
+ return { repaired };
7032
+ }
7033
+
6415
7034
  // src/routes/assetBuilds.ts
6416
7035
  function registerAssetBuildRoutes(app, deps) {
6417
7036
  const { core, engines, scenes, presenters } = deps;
@@ -6514,7 +7133,7 @@ function registerAssetBuildRoutes(app, deps) {
6514
7133
  app.post("/api/brands/:id/presenters", async (req, reply) => {
6515
7134
  const brand = brandOr404(req, reply);
6516
7135
  if (!brand) return;
6517
- const built = presenterRecordFrom(req.body ?? {});
7136
+ const built = presenterRecordFrom(await withDerivedCrops(core, req.body ?? {}));
6518
7137
  if (!built.ok) return reply.status(400).send({ error: built.error });
6519
7138
  try {
6520
7139
  commit(core, brand.id, (json) => {
@@ -6532,7 +7151,7 @@ function registerAssetBuildRoutes(app, deps) {
6532
7151
  const base = brandCharacters(brand.json).find((c) => c.id === id);
6533
7152
  if (!base) return reply.status(404).send({ error: "presenter not found" });
6534
7153
  if (!isCustomPresenter(base)) return reply.status(400).send({ error: "this presenter is not editable" });
6535
- const built = presenterRecordFrom(req.body ?? {}, base);
7154
+ const built = presenterRecordFrom(await withDerivedCrops(core, req.body ?? {}, base), base);
6536
7155
  if (!built.ok) return reply.status(400).send({ error: built.error });
6537
7156
  try {
6538
7157
  commit(core, brand.id, (json) => {
@@ -6631,6 +7250,18 @@ function registerAssetBuildRoutes(app, deps) {
6631
7250
  return { preview: `asset:${hash}`, brand: core.store.getBrand(brand.id) };
6632
7251
  });
6633
7252
  }
7253
+ async function withDerivedCrops(core, body, base) {
7254
+ const shots = Array.isArray(body?.shotHashes) ? body.shotHashes : null;
7255
+ if (!shots?.length || body.previewHash !== void 0 && body.avatarHash !== void 0) return body;
7256
+ const firstShot = `asset:${String(shots[0])}`;
7257
+ const firstSource = Array.isArray(body.sourceHashes) ? `asset:${String(body.sourceHashes[0])}` : base?.sourceRefs?.[0]?.file;
7258
+ const derived = await presenterCrops(core, String(shots[0]), presenterCropMode(firstShot, firstSource));
7259
+ return {
7260
+ ...body,
7261
+ ...body.previewHash === void 0 && derived.previewHash ? { previewHash: derived.previewHash } : {},
7262
+ ...body.avatarHash === void 0 && derived.avatarHash ? { avatarHash: derived.avatarHash } : {}
7263
+ };
7264
+ }
6634
7265
  function registerDemoProductRoutes(app, deps) {
6635
7266
  const { templatesRoot, demoProducts, demoProductById } = deps;
6636
7267
  const demoProductThumbPath = (id) => {
@@ -6847,7 +7478,7 @@ async function buildExportZip(image, baseName, presetIds) {
6847
7478
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
6848
7479
  if (chosen.length === 0) throw new Error("No valid export presets selected");
6849
7480
  for (const p of chosen) {
6850
- const buf = p.width && p.height ? await sharp8(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
7481
+ const buf = p.width && p.height ? await sharp7(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
6851
7482
  zip.file(`${baseName}-${p.id}.png`, buf);
6852
7483
  }
6853
7484
  return zip.generateAsync({ type: "nodebuffer" });
@@ -7008,7 +7639,7 @@ function registerImageRoutes(app, deps) {
7008
7639
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
7009
7640
  const buf = await part.toBuffer();
7010
7641
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
7011
- const png = await sharp8(buf).rotate().png().toBuffer();
7642
+ const png = await sharp7(buf).rotate().png().toBuffer();
7012
7643
  return { hash: core.images.save(png) };
7013
7644
  });
7014
7645
  app.post("/api/diff", async (req, reply) => {
@@ -7043,6 +7674,39 @@ function registerImageRoutes(app, deps) {
7043
7674
 
7044
7675
  // src/release/notes.data.ts
7045
7676
  var RELEASES = [
7677
+ {
7678
+ version: "0.5.1",
7679
+ date: "2026-08-26",
7680
+ sections: [
7681
+ {
7682
+ heading: "Shots",
7683
+ body: "An extended shot keeps its subject where it was composed, so a product standing near one edge stays near that edge in the wider frame rather than drifting to the middle. The new margin now meets the picture across the whole join instead of only at the seam, so a difference in tone or light no longer survives out at the frame edge. Where a connected engine can paint a margin directly, an extension is handed to it."
7684
+ }
7685
+ ]
7686
+ },
7687
+ {
7688
+ version: "0.5.0",
7689
+ date: "2026-08-26",
7690
+ title: "A finished shot can change shape, and a presenter you attach is a requirement rather than a suggestion.",
7691
+ sections: [
7692
+ {
7693
+ heading: "Create",
7694
+ body: "A brief can be rearranged by hand: drag its chips into a new order, or move them with the keyboard and on touch. Chips now sit on the sentence's own baseline, so a brief reads as a line of writing rather than a row of boxes."
7695
+ },
7696
+ {
7697
+ heading: "Shots",
7698
+ body: "Pick a new shape for a finished shot and Scenri works out from the geometry whether that means cropping or extending. A crop keeps every original pixel, follows the subject, costs nothing and calls no engine. An extension leaves the original untouched to the byte and paints only the new margin, and it is offered by the engines that can genuinely paint one."
7699
+ },
7700
+ {
7701
+ heading: "Presenters",
7702
+ body: "A presenter you attach now survives what used to drop them from the picture: product notes written for a solo packshot, close-up framing, and scene direction that bans props. A custom presenter is cropped for their avatar the way the built-in ones are, measured from the person rather than from the frame."
7703
+ },
7704
+ {
7705
+ heading: "Fixes",
7706
+ body: "What a shot says it used is what the engine received, including references and brand marks carried into a refine. Two shots sent in the same second keep their order, the newest shot is always top left, and text in right-to-left languages renders and travels correctly through the app."
7707
+ }
7708
+ ]
7709
+ },
7046
7710
  {
7047
7711
  version: "0.4.7",
7048
7712
  date: "2026-08-24",
@@ -7570,6 +8234,14 @@ function registerSystemRoutes(app, deps) {
7570
8234
  }
7571
8235
 
7572
8236
  // src/server.ts
8237
+ function seedFor(sourceHash, width, height) {
8238
+ let h = 2166136261;
8239
+ for (const ch of `${sourceHash}:${width}x${height}`) {
8240
+ h ^= ch.charCodeAt(0);
8241
+ h = Math.imul(h, 16777619);
8242
+ }
8243
+ return Math.abs(h) % 2147483647;
8244
+ }
7573
8245
  var SECRET_KEYS = ["openrouter_api_key", "replicate_api_token", "fal_key"];
7574
8246
  function buildServer(opts) {
7575
8247
  const { core, engines } = opts;
@@ -7647,7 +8319,14 @@ function buildServer(opts) {
7647
8319
  const part = await readImagePart(core, req, toPng);
7648
8320
  if ("error" in part) return reply.status(400).send({ error: part.error });
7649
8321
  hashes = [part.hash];
7650
- name = String(part.fields?.name?.value ?? part.filename ?? spec.fallback).slice(0, 80);
8322
+ const explicit = part.fields?.name?.value;
8323
+ const fromFile = String(part.filename ?? "").replace(/\.[A-Za-z0-9]{1,5}$/, "");
8324
+ name = Array.from(
8325
+ String(explicit ?? (fromFile || spec.fallback)).replace(
8326
+ /[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g,
8327
+ ""
8328
+ )
8329
+ ).slice(0, 80).join("").trim() || spec.fallback;
7651
8330
  }
7652
8331
  const id = `${spec.prefix}-${randomUUID().slice(0, 8)}`;
7653
8332
  const json = { ...brand.json };
@@ -7785,8 +8464,81 @@ function buildServer(opts) {
7785
8464
  registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById });
7786
8465
  registerShowcaseRoutes(app, { templatesRoot });
7787
8466
  app.get("/api/formats", async () => FORMATS);
8467
+ async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
8468
+ const borrowed = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
8469
+ const already = new Set(
8470
+ brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map((t) => JSON.stringify(t))
8471
+ );
8472
+ const inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
8473
+ const combined = [...brief.tokens, ...inheritedTokens];
8474
+ const brandJson = await brandJsonWithResolvedPresenters(
8475
+ core,
8476
+ templatesRoot,
8477
+ presenters,
8478
+ await brandJsonWithResolvedDemoProducts(
8479
+ core,
8480
+ templatesRoot,
8481
+ demoProducts,
8482
+ brandJsonWithCatalogProducts(core, brandId),
8483
+ combined
8484
+ ),
8485
+ combined
8486
+ );
8487
+ const sceneById = sceneFor(brandJson);
8488
+ const uncapped = { ...engineCaps, maxReferenceImages: 32 };
8489
+ const verdict = scopeOfInstruction(
8490
+ brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
8491
+ );
8492
+ const compiled2 = compileBrief(brief, {
8493
+ brand: brandJson,
8494
+ images: core.images,
8495
+ engineCaps: uncapped,
8496
+ template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
8497
+ templateById: sceneById,
8498
+ mode: "edit",
8499
+ editScope: verdict.scope,
8500
+ editRemoval: verdict.removal ?? false,
8501
+ inheritedIdentity: inheritedTokens.length > 0,
8502
+ // Only the explicit op drops the dimension promise: an implicit legacy
8503
+ // expansion keeps its historical prompt byte for byte.
8504
+ ...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
8505
+ });
8506
+ let inheritedAttachments = [];
8507
+ if (inheritedTokens.length) {
8508
+ const identity = compileBrief(
8509
+ { tokens: inheritedTokens },
8510
+ { brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
8511
+ );
8512
+ inheritedAttachments = identity.attachments.filter((a) => a.essential || a.role === "brand" || a.role === "reference").map((a) => ({ ...a, inherited: true }));
8513
+ }
8514
+ const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
8515
+ const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
8516
+ const warnings = [...compiled2.warnings];
8517
+ if (merged.dropped.length) {
8518
+ if (engineCaps.maxReferenceImages <= 1) {
8519
+ warnings.push(
8520
+ `${engineCaps.displayName} cannot carry reference images, so the identity rides on the source frame alone.`
8521
+ );
8522
+ } else {
8523
+ const names = [...new Set(merged.dropped.map((d) => d.label))];
8524
+ warnings.push(
8525
+ `${engineCaps.displayName} reads ${engineCaps.maxReferenceImages} reference images and the frame being refined keeps one, so ${names.join(
8526
+ " and "
8527
+ )} ${names.length === 1 ? "was" : "were"} left out.`
8528
+ );
8529
+ }
8530
+ }
8531
+ return {
8532
+ compiled: compiled2,
8533
+ inheritedTokens,
8534
+ merged,
8535
+ warnings,
8536
+ editScope: verdict.scope,
8537
+ editRemoval: verdict.removal ?? false
8538
+ };
8539
+ }
7788
8540
  app.post("/api/brief/preview", async (req, reply) => {
7789
- const { brief, engineId, brandId } = req.body;
8541
+ const { brief, engineId, brandId, parentId } = req.body;
7790
8542
  const brand = core.store.getBrand(String(brandId));
7791
8543
  if (!brand) return reply.status(404).send({ error: "brand not found" });
7792
8544
  const engine = engines.get(String(engineId));
@@ -7795,6 +8547,20 @@ function buildServer(opts) {
7795
8547
  return reply.status(400).send({ error: "brief.tokens must be an array" });
7796
8548
  const briefErrors = validateBrief(brief);
7797
8549
  if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
8550
+ if (parentId && core.store.getNode(String(parentId))) {
8551
+ const previewReshape = req.body.reshape;
8552
+ const edit = await compileEditBrief(brand.id, String(parentId), brief, engine.capabilities(), {
8553
+ reshape: previewReshape === "extend" ? "extend" : previewReshape === "crop" ? "crop" : void 0
8554
+ });
8555
+ const { referenceImages: referenceImages2, ...rest2 } = edit.compiled;
8556
+ return {
8557
+ ...rest2,
8558
+ attachments: edit.merged.kept,
8559
+ dropped: edit.merged.dropped,
8560
+ warnings: edit.warnings,
8561
+ referenceCount: edit.merged.kept.length
8562
+ };
8563
+ }
7798
8564
  const brandJson = await brandJsonWithResolvedPresenters(
7799
8565
  core,
7800
8566
  templatesRoot,
@@ -7883,14 +8649,14 @@ function buildServer(opts) {
7883
8649
  const out = [];
7884
8650
  for (const h of images) {
7885
8651
  const buf = core.images.read(h);
7886
- out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp8(buf).png().toBuffer()));
8652
+ out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp7(buf).png().toBuffer()));
7887
8653
  }
7888
8654
  return out;
7889
8655
  }
7890
8656
  async function assertAspect(images, expect) {
7891
8657
  const want = expect.width / expect.height;
7892
8658
  for (const h of images) {
7893
- const meta2 = await sharp8(core.images.read(h)).metadata();
8659
+ const meta2 = await sharp7(core.images.read(h)).metadata();
7894
8660
  if (!meta2.width || !meta2.height) continue;
7895
8661
  const got = meta2.width / meta2.height;
7896
8662
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -7901,7 +8667,7 @@ function buildServer(opts) {
7901
8667
  }
7902
8668
  const NODE_TIMEOUT_MS = 6e5;
7903
8669
  async function runNode(nodeId, engine, estimate, work, expect, post) {
7904
- const engineId = engine.capabilities().id;
8670
+ const engineId = engine?.capabilities().id ?? "local";
7905
8671
  reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
7906
8672
  const ctrl = new AbortController();
7907
8673
  runningGenerations.set(nodeId, ctrl);
@@ -7920,13 +8686,15 @@ function buildServer(opts) {
7920
8686
  try {
7921
8687
  const sizes = [];
7922
8688
  for (const h of result.images) {
7923
- const meta2 = await sharp8(core.images.read(h)).metadata();
8689
+ const meta2 = await sharp7(core.images.read(h)).metadata();
7924
8690
  if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
7925
8691
  }
7926
8692
  const node = core.store.getNode(nodeId);
7927
8693
  if (node && sizes.length) {
7928
8694
  const brief = node.brief ?? {};
7929
- core.store.setBrief(nodeId, { ...brief, rendered: { sizes } });
8695
+ const raw = result.raw;
8696
+ const survivors = typeof raw?.requested === "number" && Array.isArray(raw.variantIndexes) ? { requested: raw.requested, variantIndexes: raw.variantIndexes } : {};
8697
+ core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors } });
7930
8698
  }
7931
8699
  } catch {
7932
8700
  }
@@ -7959,6 +8727,56 @@ function buildServer(opts) {
7959
8727
  let { width = 1024, height = 1024 } = req.body;
7960
8728
  const project = core.store.getProject(String(projectId));
7961
8729
  if (!project) return reply.status(404).send({ error: "project not found" });
8730
+ const rawReshape = req.body.reshape ?? req.body.brief?.reshape;
8731
+ const reshape = rawReshape === "crop" ? "crop" : rawReshape === "extend" ? "extend" : void 0;
8732
+ if (kind === "edit" && reshape === "crop") {
8733
+ const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
8734
+ if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
8735
+ const cropParentId = parentId ? String(parentId) : rootForCrop.id;
8736
+ if (brief && Array.isArray(brief.tokens)) {
8737
+ const briefErrors = validateBrief(brief);
8738
+ if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
8739
+ }
8740
+ const fmt = Array.isArray(brief?.tokens) ? brief.tokens.find(
8741
+ (t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
8742
+ ) : void 0;
8743
+ if (!fmt) return reply.status(400).send({ error: "a crop needs a target format" });
8744
+ const parent = core.store.getNode(cropParentId);
8745
+ const srcHash = req.body.sourceImage ?? parent?.images[0];
8746
+ if (!srcHash || !core.images.has(String(srcHash)))
8747
+ return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
8748
+ const srcBuf = core.images.read(String(srcHash));
8749
+ const srcMeta = await sharp7(srcBuf).metadata();
8750
+ if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
8751
+ const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
8752
+ if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
8753
+ const origin = await attentionCropOrigin(srcBuf, { width: srcMeta.width, height: srcMeta.height }, plan2);
8754
+ const window = { left: origin.left, top: origin.top, width: plan2.width, height: plan2.height };
8755
+ const label = FORMATS.find((f) => f.id === fmt.id)?.label ?? `${fmt.w}x${fmt.h}`;
8756
+ const node2 = core.store.addNode({
8757
+ projectId: project.id,
8758
+ parentId: cropParentId,
8759
+ kind: "edit",
8760
+ prompt: `Cropped to ${label}`,
8761
+ // No provider was asked; recording the engine the client HAPPENED to
8762
+ // have selected made the overlay display a name that did nothing.
8763
+ engineId: "local"
8764
+ });
8765
+ core.store.setBrief(node2.id, {
8766
+ ...brief ?? {},
8767
+ sourceImage: String(srcHash),
8768
+ reshape: "crop",
8769
+ crop: window
8770
+ });
8771
+ const work2 = async () => ({
8772
+ images: [core.images.save(await sharp7(srcBuf).extract(window).png().toBuffer())],
8773
+ costUsd: 0
8774
+ });
8775
+ void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
8776
+ (err) => app.log.error({ err }, "crop run failed")
8777
+ );
8778
+ return reply.status(202).send(node2);
8779
+ }
7962
8780
  const engine = engines.get(String(engineId));
7963
8781
  if (!engine) return reply.status(400).send({ error: `unknown engine ${engineId}` });
7964
8782
  const avail = await engine.isAvailable();
@@ -7971,58 +8789,48 @@ function buildServer(opts) {
7971
8789
  const ctx = brandContext(core, project.brandId);
7972
8790
  let compiled2 = null;
7973
8791
  let inheritedTokens = [];
7974
- let inheritedAttachments = [];
8792
+ let mergedEdit = null;
7975
8793
  let editScope = "global";
7976
- let editRemoval = false;
7977
8794
  const extraWarnings = [];
7978
8795
  let expandPlan = null;
7979
8796
  let expandSourceHash = null;
7980
8797
  if (brief && Array.isArray(brief.tokens)) {
7981
8798
  const briefErrors = validateBrief(brief);
7982
8799
  if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
7983
- const brandJson = await brandJsonWithResolvedPresenters(
7984
- core,
7985
- templatesRoot,
7986
- presenters,
7987
- await brandJsonWithResolvedDemoProducts(
8800
+ if (kind === "edit") {
8801
+ const edit = await compileEditBrief(project.brandId, resolvedParentId, brief, engine.capabilities(), {
8802
+ reshape
8803
+ });
8804
+ compiled2 = edit.compiled;
8805
+ inheritedTokens = edit.inheritedTokens;
8806
+ mergedEdit = edit.merged;
8807
+ editScope = edit.editScope;
8808
+ extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
8809
+ if (!compiled2.prompt.trim() && reshape !== "extend")
8810
+ return reply.status(400).send({ error: "the brief is empty" });
8811
+ } else {
8812
+ const brandJson = await brandJsonWithResolvedPresenters(
7988
8813
  core,
7989
8814
  templatesRoot,
7990
- demoProducts,
7991
- brandJsonWithCatalogProducts(core, project.brandId),
8815
+ presenters,
8816
+ await brandJsonWithResolvedDemoProducts(
8817
+ core,
8818
+ templatesRoot,
8819
+ demoProducts,
8820
+ brandJsonWithCatalogProducts(core, project.brandId),
8821
+ brief.tokens
8822
+ ),
7992
8823
  brief.tokens
7993
- ),
7994
- brief.tokens
7995
- );
7996
- const sceneById = sceneFor(brandJson);
7997
- if (kind === "edit") {
7998
- const borrowed = inheritedIdentityTokens(resolvedParentId, (id) => core.store.getNode(id));
7999
- if (borrowed.length) {
8000
- const already = new Set(
8001
- brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark").map((t) => JSON.stringify(t))
8002
- );
8003
- inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
8004
- }
8005
- const verdict = scopeOfInstruction(
8006
- brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
8007
8824
  );
8008
- editScope = verdict.scope;
8009
- editRemoval = verdict.removal ?? false;
8010
- }
8011
- compiled2 = compileBrief(brief, {
8012
- brand: brandJson,
8013
- images: core.images,
8014
- engineCaps: engine.capabilities(),
8015
- template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
8016
- templateById: sceneById,
8017
- ...kind === "edit" ? { mode: "edit", editScope, editRemoval, inheritedIdentity: inheritedTokens.length > 0 } : {}
8018
- });
8019
- if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
8020
- if (inheritedTokens.length) {
8021
- const identity = compileBrief(
8022
- { tokens: inheritedTokens },
8023
- { brand: brandJson, images: core.images, engineCaps: engine.capabilities(), templateById: sceneById }
8024
- );
8025
- inheritedAttachments = identity.attachments.filter((a) => a.essential);
8825
+ const sceneById = sceneFor(brandJson);
8826
+ compiled2 = compileBrief(brief, {
8827
+ brand: brandJson,
8828
+ images: core.images,
8829
+ engineCaps: engine.capabilities(),
8830
+ template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
8831
+ templateById: sceneById
8832
+ });
8833
+ if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
8026
8834
  }
8027
8835
  }
8028
8836
  let finalPrompt = String(prompt ?? "");
@@ -8066,6 +8874,8 @@ function buildServer(opts) {
8066
8874
  let work;
8067
8875
  let expectShape;
8068
8876
  let editedFrom = null;
8877
+ let runEngine = engine;
8878
+ let expandMethod = null;
8069
8879
  if (compiled2) {
8070
8880
  finalPrompt = compiled2.prompt;
8071
8881
  referenceImages = compiled2.referenceImages;
@@ -8103,23 +8913,30 @@ function buildServer(opts) {
8103
8913
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
8104
8914
  if (!engine.capabilities().supportsEdit)
8105
8915
  return reply.status(400).send({ error: "engine does not support edits" });
8106
- const cap2 = Math.max(0, engine.capabilities().maxReferenceImages - 1);
8107
- const own = (referenceImages ?? []).map((path, i) => ({ path, role: referenceRoles?.[i] }));
8108
- const borrowedRefs = inheritedAttachments.map((a) => ({ path: core.images.pathFor(a.hash), role: a.role })).filter((r) => !own.some((o) => o.path === r.path));
8109
- const editRefs = [...own, ...borrowedRefs].slice(0, cap2);
8110
- if (cap2 === 0 && borrowedRefs.length)
8111
- extraWarnings.push(
8112
- `${engine.capabilities().displayName} cannot carry reference images, so the identity rides on the source frame alone.`
8113
- );
8916
+ const editRefs = mergedEdit ? mergedEdit.kept.map((a) => ({ path: core.images.pathFor(a.hash), role: a.role })) : (referenceImages ?? []).map((path, i) => ({ path, role: referenceRoles?.[i] })).slice(0, Math.max(0, engine.capabilities().maxReferenceImages - 1));
8114
8917
  const srcBuf = core.images.read(String(srcHash));
8115
- const srcMeta = await sharp8(srcBuf).metadata();
8918
+ const srcMeta = await sharp7(srcBuf).metadata();
8116
8919
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
8117
8920
  if (srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
8118
8921
  expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
8119
8922
  }
8923
+ if (reshape === "extend" && !expandPlan)
8924
+ return reply.status(400).send({ error: "the picture is already this shape" });
8925
+ if (expandPlan && srcMeta.width && srcMeta.height) {
8926
+ const size = { width: srcMeta.width, height: srcMeta.height };
8927
+ expandPlan = placeExpand(expandPlan, size, await subjectFraction(srcBuf, size, expandPlan.axis));
8928
+ }
8929
+ if (expandPlan) {
8930
+ const route = await resolveOutpaintRoute(engines.all(), engine);
8931
+ runEngine = route.engine;
8932
+ expandMethod = route.method;
8933
+ }
8934
+ const canOutpaint2 = expandMethod === "outpaint";
8120
8935
  if (expandPlan) {
8121
- const canvas = await expandCanvas(srcBuf, expandPlan);
8122
- expandSourceHash = core.images.save(canvas);
8936
+ if (!canOutpaint2) {
8937
+ const canvas = await expandCanvas(srcBuf, expandPlan);
8938
+ expandSourceHash = core.images.save(canvas);
8939
+ }
8123
8940
  expectShape = { width: expandPlan.width, height: expandPlan.height };
8124
8941
  }
8125
8942
  const editReq = {
@@ -8127,27 +8944,98 @@ function buildServer(opts) {
8127
8944
  sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
8128
8945
  brand: ctx,
8129
8946
  ...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
8130
- ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {}
8947
+ ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
8948
+ // An answer at the planned size lets compositeExpand skip its rescale,
8949
+ // which is one whole class of seam misalignment gone when honored.
8950
+ ...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : {},
8951
+ // Only an engine that can genuinely paint a margin is told where the
8952
+ // picture sits; the rest would ignore it anyway.
8953
+ ...expandPlan && canOutpaint2 ? {
8954
+ expand: {
8955
+ left: expandPlan.left,
8956
+ top: expandPlan.top,
8957
+ width: srcMeta.width ?? 0,
8958
+ height: srcMeta.height ?? 0
8959
+ },
8960
+ // Derived from the picture and the shape asked for, so the same
8961
+ // extend of the same shot is the same picture every time. Without
8962
+ // it the margin is a fresh roll of the dice on every run, which
8963
+ // is not something a person can iterate against — or that a test
8964
+ // can measure.
8965
+ seed: seedFor(String(editedFrom ?? srcHash), expandPlan.width, expandPlan.height)
8966
+ } : {}
8131
8967
  };
8132
- estimate = await engine.costEstimate(editReq);
8133
- work = (signal) => engine.edit(editReq, signal);
8968
+ estimate = await runEngine.costEstimate(editReq);
8969
+ const plan2 = expandPlan;
8970
+ const srcSize = { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
8971
+ const original2 = srcBuf;
8972
+ work = plan2 && !canOutpaint2 ? async (signal) => {
8973
+ const draws = await Promise.all([
8974
+ runEngine.edit(editReq, signal),
8975
+ runEngine.edit(editReq, signal).catch(() => null)
8976
+ ]);
8977
+ const scored = await Promise.all(
8978
+ draws.map(async (got) => {
8979
+ const first = got?.images[0];
8980
+ if (!got || !first) return null;
8981
+ const { image } = await compositeExpand(core.images.read(first), original2, plan2);
8982
+ const [score, residual] = await Promise.all([
8983
+ seamScore(image, plan2, srcSize),
8984
+ seamResidual(image, plan2, srcSize)
8985
+ ]);
8986
+ return { got, penalty: seamPenalty(score, residual) };
8987
+ })
8988
+ );
8989
+ const best = scored.filter((x) => x !== null).sort((a, b) => a.penalty - b.penalty)[0];
8990
+ return best?.got ?? draws[0];
8991
+ } : (signal) => runEngine.edit(editReq, signal);
8134
8992
  }
8135
- core.ledger.assertUnderCap(engine.capabilities().id, estimate + (reserved.get(engine.capabilities().id) ?? 0));
8993
+ const billedId = runEngine.capabilities().id;
8994
+ core.ledger.assertUnderCap(billedId, estimate + (reserved.get(billedId) ?? 0));
8136
8995
  const node = core.store.addNode({
8137
8996
  projectId: project.id,
8138
8997
  parentId: resolvedParentId,
8139
8998
  kind,
8140
8999
  prompt: finalPrompt,
8141
- engineId: String(engineId)
9000
+ engineId: billedId
8142
9001
  });
8143
- if (brief) core.store.setBrief(node.id, editedFrom ? { ...brief, sourceImage: editedFrom } : brief);
9002
+ if (brief)
9003
+ core.store.setBrief(node.id, {
9004
+ ...brief,
9005
+ ...editedFrom ? { sourceImage: editedFrom } : {},
9006
+ ...kind === "edit" && reshape ? { reshape } : {},
9007
+ // How the margin was actually made, and by whom. An extend may be
9008
+ // handed to a different engine than the shot used, and a record that
9009
+ // does not say so cannot be read back later.
9010
+ ...expandMethod && expandPlan ? {
9011
+ expand: {
9012
+ method: expandMethod,
9013
+ engineId: billedId,
9014
+ // Where the protected picture sits in the frame it grew into.
9015
+ // Placement is no longer always centred, so a reader that
9016
+ // assumes it is would be looking in the wrong place.
9017
+ left: expandPlan.left,
9018
+ top: expandPlan.top
9019
+ }
9020
+ } : {},
9021
+ // What the refinement carried, recorded apart from what it asked for:
9022
+ // the detail view shows both, and remix reads tokens alone.
9023
+ ...kind === "edit" && inheritedTokens.length ? { inherited: inheritedTokens } : {}
9024
+ });
8144
9025
  const plan = expandPlan;
8145
9026
  const original = editedFrom ? core.images.read(editedFrom) : null;
8146
9027
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
8147
9028
  const post = plan ? async (images) => {
8148
9029
  const out = [];
8149
9030
  for (const h of images) {
8150
- const { image, aligned } = await compositeExpand(core.images.read(h), original, plan);
9031
+ const answer = core.images.read(h);
9032
+ const got = await sharp7(answer).metadata();
9033
+ if (got.width !== plan.width || got.height !== plan.height)
9034
+ app.log.info(
9035
+ { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
9036
+ "expand: engine size differs from plan"
9037
+ );
9038
+ const { image, aligned } = await compositeExpand(answer, original, plan);
8151
9039
  if (!aligned) app.log.warn({ nodeId: node.id }, "expand: engine frame did not align, kept the bed");
8152
9040
  out.push(core.images.save(image));
8153
9041
  }
@@ -8161,7 +9049,7 @@ function buildServer(opts) {
8161
9049
  }
8162
9050
  return out;
8163
9051
  } : void 0;
8164
- void runNode(node.id, engine, estimate, work, expectShape, post).catch(
9052
+ void runNode(node.id, runEngine, estimate, work, expectShape, post).catch(
8165
9053
  (err) => app.log.error({ err }, "node run failed")
8166
9054
  );
8167
9055
  const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
@@ -8317,6 +9205,7 @@ async function run() {
8317
9205
  const onlyThisMachine = LOOPBACK2.includes(HOST);
8318
9206
  const token = onlyThisMachine ? void 0 : randomBytes(24).toString("base64url");
8319
9207
  const reachableAt = onlyThisMachine ? [] : HOST === "0.0.0.0" || HOST === "::" ? lanAddresses() : [HOST];
9208
+ await repairPresenterCrops(core, (line) => console.log(line));
8320
9209
  const app = buildServer({
8321
9210
  core,
8322
9211
  engines,
@@ -8421,8 +9310,8 @@ async function verify() {
8421
9310
  const db = new Database2(":memory:");
8422
9311
  db.pragma("user_version");
8423
9312
  db.close();
8424
- const { default: sharp14 } = await import('sharp');
8425
- await sharp14({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
9313
+ const { default: sharp18 } = await import('sharp');
9314
+ await sharp18({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
8426
9315
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
8427
9316
  } catch (err) {
8428
9317
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));