scenri 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serve.js CHANGED
@@ -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) => ({
@@ -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 };
5831
+ });
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)) };
5698
5857
  });
5699
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,192 @@ 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 own = direction.trim() ? `
6145
+ Also: ${direction.trim()}` : "";
6146
+ return `Fill only the soft blurred margin at the ${where} of this frame so the photograph continues into it.
6147
+ 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 + `
6148
+ Constraints: change only the blurred margin; keep the sharp photograph unchanged in position, scale and content.
6149
+ Avoid: new objects, products, people, text or watermarks.` + own;
6150
+ }
6151
+
6152
+ // src/cropRules.ts
6153
+ function planCrop(source, targetRatio) {
6154
+ if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
6155
+ const current = source.width / source.height;
6156
+ if (Math.abs(current - targetRatio) / targetRatio < 0.01) return null;
6157
+ if (targetRatio < current) {
6158
+ const width = Math.max(1, Math.min(source.width, Math.round(source.height * targetRatio)));
6159
+ return { left: Math.floor((source.width - width) / 2), top: 0, width, height: source.height, axis: "width" };
6160
+ }
6161
+ const height = Math.max(1, Math.min(source.height, Math.round(source.width / targetRatio)));
6162
+ return { left: 0, top: Math.floor((source.height - height) / 2), width: source.width, height, axis: "height" };
6163
+ }
6164
+ async function attentionCropOrigin(srcBuf, source, plan) {
6165
+ try {
6166
+ const { info } = await sharp7(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6167
+ const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
6168
+ const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
6169
+ const left = Math.round((attnLeft + plan.left) / 2);
6170
+ const top = Math.round((attnTop + plan.top) / 2);
6171
+ 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) };
6172
+ } catch {
6173
+ return { left: plan.left, top: plan.top };
6174
+ }
5961
6175
  }
5962
6176
  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();
6177
+ 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();
6178
+ return sharp7(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
5965
6179
  }
5966
6180
  async function compositeExpand(engineImage, source, plan) {
5967
- const meta = await sharp8(engineImage).metadata();
6181
+ const meta = await sharp7(engineImage).metadata();
5968
6182
  const want = plan.width / plan.height;
5969
6183
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
5970
6184
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
5971
6185
  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();
6186
+ const exact = meta.width === plan.width && meta.height === plan.height;
6187
+ const surround = aligned ? exact ? engineImage : await sharp7(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
6188
+ const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
6189
+ const image = await sharp7(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
5974
6190
  return { image, aligned };
5975
6191
  }
6192
+ async function matchMarginsToSeam(surround, source, plan) {
6193
+ const src = await sharp7(source).metadata();
6194
+ if (!src.width || !src.height) return surround;
6195
+ const SW = src.width;
6196
+ const SH = src.height;
6197
+ const sides = [];
6198
+ if (plan.axis === "width") {
6199
+ if (plan.left > 0)
6200
+ sides.push({
6201
+ margin: { left: 0, top: plan.top, width: plan.left, height: SH },
6202
+ srcEdge: { left: 0, top: 0, width: 1, height: SH },
6203
+ seamAt: "far"
6204
+ });
6205
+ const rightAt = plan.left + SW;
6206
+ if (rightAt < plan.width)
6207
+ sides.push({
6208
+ margin: { left: rightAt, top: plan.top, width: plan.width - rightAt, height: SH },
6209
+ srcEdge: { left: SW - 1, top: 0, width: 1, height: SH },
6210
+ seamAt: "near"
6211
+ });
6212
+ } else {
6213
+ if (plan.top > 0)
6214
+ sides.push({
6215
+ margin: { left: plan.left, top: 0, width: SW, height: plan.top },
6216
+ srcEdge: { left: 0, top: 0, width: SW, height: 1 },
6217
+ seamAt: "far"
6218
+ });
6219
+ const bottomAt = plan.top + SH;
6220
+ if (bottomAt < plan.height)
6221
+ sides.push({
6222
+ margin: { left: plan.left, top: bottomAt, width: SW, height: plan.height - bottomAt },
6223
+ srcEdge: { left: 0, top: SH - 1, width: SW, height: 1 },
6224
+ seamAt: "near"
6225
+ });
6226
+ }
6227
+ let out = surround;
6228
+ for (const side of sides) {
6229
+ try {
6230
+ out = await reconcile(out, source, side, plan.axis);
6231
+ } catch {
6232
+ }
6233
+ }
6234
+ return out;
6235
+ }
6236
+ var MAX_CORRECTION = 60;
6237
+ async function reconcile(surround, source, side, axis) {
6238
+ const { margin } = side;
6239
+ if (margin.width < 1 || margin.height < 1) return surround;
6240
+ const marginRaw = await sharp7(surround).extract(margin).removeAlpha().raw().toBuffer();
6241
+ const edgeRaw = await sharp7(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
6242
+ const W = margin.width;
6243
+ const H = margin.height;
6244
+ const along = axis === "width" ? H : W;
6245
+ const seamIndex = side.seamAt === "far" ? axis === "width" ? W - 1 : H - 1 : 0;
6246
+ const err = new Float32Array(along * 3);
6247
+ for (let i = 0; i < along; i++) {
6248
+ const mOff = axis === "width" ? (i * W + seamIndex) * 3 : (seamIndex * W + i) * 3;
6249
+ for (let c = 0; c < 3; c++) {
6250
+ const want = edgeRaw[i * 3 + c];
6251
+ const have = marginRaw[mOff + c];
6252
+ err[i * 3 + c] = Math.max(-MAX_CORRECTION, Math.min(MAX_CORRECTION, want - have));
6253
+ }
6254
+ }
6255
+ const radius = Math.max(2, Math.round(along / 64));
6256
+ const smooth = new Float32Array(err.length);
6257
+ for (let i = 0; i < along; i++) {
6258
+ for (let c = 0; c < 3; c++) {
6259
+ let sum = 0;
6260
+ let n = 0;
6261
+ for (let k = -radius; k <= radius; k++) {
6262
+ const j = i + k;
6263
+ if (j < 0 || j >= along) continue;
6264
+ sum += err[j * 3 + c];
6265
+ n++;
6266
+ }
6267
+ smooth[i * 3 + c] = sum / n;
6268
+ }
6269
+ }
6270
+ const depth = axis === "width" ? W : H;
6271
+ const corrected = Buffer.from(marginRaw);
6272
+ for (let y = 0; y < H; y++) {
6273
+ for (let x = 0; x < W; x++) {
6274
+ const d = axis === "width" ? side.seamAt === "far" ? W - 1 - x : x : side.seamAt === "far" ? H - 1 - y : y;
6275
+ const fall = 1 - d / Math.max(1, depth - 1);
6276
+ if (fall <= 0) continue;
6277
+ const i = axis === "width" ? y : x;
6278
+ const off = (y * W + x) * 3;
6279
+ for (let c = 0; c < 3; c++) {
6280
+ const v = corrected[off + c] + smooth[i * 3 + c] * fall;
6281
+ corrected[off + c] = v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
6282
+ }
6283
+ }
6284
+ }
6285
+ const patch2 = await sharp7(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
6286
+ return sharp7(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
6287
+ }
5976
6288
  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();
6289
+ 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();
6290
+ }
6291
+ async function seamScore(image, plan, source) {
6292
+ const { data, info } = await sharp7(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
6293
+ const W = info.width;
6294
+ const H = info.height;
6295
+ const horizontal = plan.axis === "width";
6296
+ const line = (i) => {
6297
+ let sum = 0;
6298
+ if (horizontal) {
6299
+ if (i < 1 || i >= W) return 0;
6300
+ for (let y = 0; y < H; y++) sum += Math.abs(data[y * W + i] - data[y * W + i - 1]);
6301
+ return sum / H;
6302
+ }
6303
+ if (i < 1 || i >= H) return 0;
6304
+ for (let x = 0; x < W; x++) sum += Math.abs(data[i * W + x] - data[(i - 1) * W + x]);
6305
+ return sum / W;
6306
+ };
6307
+ const at = (seam) => {
6308
+ const near = [];
6309
+ for (let d = 6; d <= 30; d++) {
6310
+ near.push(line(seam - d));
6311
+ near.push(line(seam + d));
6312
+ }
6313
+ const ordinary = near.reduce((a, b) => a + b, 0) / near.length;
6314
+ if (ordinary < 0.05) return 1;
6315
+ return line(seam) / ordinary;
6316
+ };
6317
+ const first = horizontal ? plan.left : plan.top;
6318
+ const second = first + (horizontal ? source.width : source.height);
6319
+ return Math.max(at(first), at(second));
5978
6320
  }
5979
6321
  async function driftDiff(a, b) {
5980
- const metaA = await sharp8(a).metadata();
5981
- const metaB = await sharp8(b).metadata();
6322
+ const metaA = await sharp7(a).metadata();
6323
+ const metaB = await sharp7(b).metadata();
5982
6324
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
5983
6325
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
5984
6326
  const [rawA, rawB] = await Promise.all(
5985
- [a, b].map((buf) => sharp8(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
6327
+ [a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
5986
6328
  );
5987
6329
  const out = new PNG({ width, height });
5988
6330
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -5994,11 +6336,11 @@ async function driftDiff(a, b) {
5994
6336
  };
5995
6337
  }
5996
6338
  async function changeMask(a, b, cap2 = 1024) {
5997
- const metaA = await sharp8(a).metadata();
6339
+ const metaA = await sharp7(a).metadata();
5998
6340
  const width = Math.min(metaA.width ?? 1, cap2);
5999
6341
  const height = Math.min(metaA.height ?? 1, cap2);
6000
6342
  const [rawA, rawB] = await Promise.all(
6001
- [a, b].map((buf) => sharp8(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
6343
+ [a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
6002
6344
  );
6003
6345
  const out = new PNG({ width, height });
6004
6346
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -6047,8 +6389,8 @@ function dilationFor(longEdge) {
6047
6389
  // src/localEdit.ts
6048
6390
  async function preserveOutsideChange(source, edited) {
6049
6391
  try {
6050
- const srcMeta = await sharp8(source).metadata();
6051
- const outMeta = await sharp8(edited).metadata();
6392
+ const srcMeta = await sharp7(source).metadata();
6393
+ const outMeta = await sharp7(edited).metadata();
6052
6394
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
6053
6395
  return { image: edited, outcome: "error", changed: 0 };
6054
6396
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -6058,15 +6400,15 @@ async function preserveOutsideChange(source, edited) {
6058
6400
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
6059
6401
  const r = dilationFor(Math.max(shape.width, shape.height));
6060
6402
  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, {
6403
+ const spread = await sharp7(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
6404
+ const dilated = await sharp7(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
6405
+ const feathered = await sharp7(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
6406
+ const grown = await sharp7(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
6407
+ const editedRgb = await sharp7(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
6408
+ const masked = await sharp7(editedRgb, {
6067
6409
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
6068
6410
  }).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();
6411
+ const image = await sharp7(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
6070
6412
  return { image, outcome: "composited", changed: shape.changed };
6071
6413
  } catch {
6072
6414
  return { image: edited, outcome: "error", changed: 0 };
@@ -6095,7 +6437,7 @@ var assetHash2 = (ref) => {
6095
6437
  };
6096
6438
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
6097
6439
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
6098
- var toPng = (buf) => sharp8(buf).rotate().png().toBuffer();
6440
+ var toPng = (buf) => sharp7(buf).rotate().png().toBuffer();
6099
6441
  var COST_PROBE = {
6100
6442
  prompt: "",
6101
6443
  brand: { brand: {}, assetPaths: {} },
@@ -6104,7 +6446,7 @@ var COST_PROBE = {
6104
6446
  count: 1
6105
6447
  };
6106
6448
  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();
6449
+ var toMarkPng = (buf) => sharp7(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
6108
6450
  var readImagePart = async (core, req, normalize2) => {
6109
6451
  const part = await req.file();
6110
6452
  if (!part) return { error: "multipart file field required" };
@@ -6281,7 +6623,7 @@ async function vibrantColor(input) {
6281
6623
  let data;
6282
6624
  let channels;
6283
6625
  try {
6284
- const out = await sharp8(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
6626
+ const out = await sharp7(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
6285
6627
  data = out.data;
6286
6628
  channels = out.info.channels;
6287
6629
  } catch {
@@ -6304,7 +6646,7 @@ async function vibrantColor(input) {
6304
6646
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
6305
6647
  if (best.score <= 0) {
6306
6648
  try {
6307
- const { dominant } = await sharp8(input).stats();
6649
+ const { dominant } = await sharp7(input).stats();
6308
6650
  return toHex(dominant.r, dominant.g, dominant.b);
6309
6651
  } catch {
6310
6652
  return null;
@@ -6412,6 +6754,39 @@ function registerPresenterRoutes(app, deps) {
6412
6754
  });
6413
6755
  }
6414
6756
 
6757
+ // src/presenterRepair.ts
6758
+ function presenterCropMode(firstShotFile, firstSourceFile) {
6759
+ return firstShotFile && firstShotFile === firstSourceFile ? "upload" : "generated";
6760
+ }
6761
+ async function repairPresenterCrops(core, log = () => {
6762
+ }) {
6763
+ let repaired = 0;
6764
+ for (const brand of core.store.listBrands()) {
6765
+ for (const c of brandCharacters(brand.json)) {
6766
+ if (!isCustomPresenter(c)) continue;
6767
+ try {
6768
+ const firstShot = c.shots?.[0]?.file;
6769
+ const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
6770
+ if (!hash) continue;
6771
+ const mode = presenterCropMode(firstShot, c.sourceRefs?.[0]?.file);
6772
+ const { previewHash, avatarHash } = await presenterCrops(core, hash, mode);
6773
+ const preview = previewHash ? `asset:${previewHash}` : void 0;
6774
+ const avatar = avatarHash ? `asset:${avatarHash}` : void 0;
6775
+ if ((!preview || preview === c.preview) && (!avatar || avatar === c.avatar)) continue;
6776
+ commit(core, brand.id, (json) => {
6777
+ json.characters = brandCharacters(json).map(
6778
+ (row) => row.id === c.id ? { ...row, ...preview ? { preview } : {}, ...avatar ? { avatar } : {} } : row
6779
+ );
6780
+ });
6781
+ repaired += 1;
6782
+ log(`Repaired presenter thumbnails: ${c.name ?? c.id}`);
6783
+ } catch {
6784
+ }
6785
+ }
6786
+ }
6787
+ return { repaired };
6788
+ }
6789
+
6415
6790
  // src/routes/assetBuilds.ts
6416
6791
  function registerAssetBuildRoutes(app, deps) {
6417
6792
  const { core, engines, scenes, presenters } = deps;
@@ -6514,7 +6889,7 @@ function registerAssetBuildRoutes(app, deps) {
6514
6889
  app.post("/api/brands/:id/presenters", async (req, reply) => {
6515
6890
  const brand = brandOr404(req, reply);
6516
6891
  if (!brand) return;
6517
- const built = presenterRecordFrom(req.body ?? {});
6892
+ const built = presenterRecordFrom(await withDerivedCrops(core, req.body ?? {}));
6518
6893
  if (!built.ok) return reply.status(400).send({ error: built.error });
6519
6894
  try {
6520
6895
  commit(core, brand.id, (json) => {
@@ -6532,7 +6907,7 @@ function registerAssetBuildRoutes(app, deps) {
6532
6907
  const base = brandCharacters(brand.json).find((c) => c.id === id);
6533
6908
  if (!base) return reply.status(404).send({ error: "presenter not found" });
6534
6909
  if (!isCustomPresenter(base)) return reply.status(400).send({ error: "this presenter is not editable" });
6535
- const built = presenterRecordFrom(req.body ?? {}, base);
6910
+ const built = presenterRecordFrom(await withDerivedCrops(core, req.body ?? {}, base), base);
6536
6911
  if (!built.ok) return reply.status(400).send({ error: built.error });
6537
6912
  try {
6538
6913
  commit(core, brand.id, (json) => {
@@ -6631,6 +7006,18 @@ function registerAssetBuildRoutes(app, deps) {
6631
7006
  return { preview: `asset:${hash}`, brand: core.store.getBrand(brand.id) };
6632
7007
  });
6633
7008
  }
7009
+ async function withDerivedCrops(core, body, base) {
7010
+ const shots = Array.isArray(body?.shotHashes) ? body.shotHashes : null;
7011
+ if (!shots?.length || body.previewHash !== void 0 && body.avatarHash !== void 0) return body;
7012
+ const firstShot = `asset:${String(shots[0])}`;
7013
+ const firstSource = Array.isArray(body.sourceHashes) ? `asset:${String(body.sourceHashes[0])}` : base?.sourceRefs?.[0]?.file;
7014
+ const derived = await presenterCrops(core, String(shots[0]), presenterCropMode(firstShot, firstSource));
7015
+ return {
7016
+ ...body,
7017
+ ...body.previewHash === void 0 && derived.previewHash ? { previewHash: derived.previewHash } : {},
7018
+ ...body.avatarHash === void 0 && derived.avatarHash ? { avatarHash: derived.avatarHash } : {}
7019
+ };
7020
+ }
6634
7021
  function registerDemoProductRoutes(app, deps) {
6635
7022
  const { templatesRoot, demoProducts, demoProductById } = deps;
6636
7023
  const demoProductThumbPath = (id) => {
@@ -6847,7 +7234,7 @@ async function buildExportZip(image, baseName, presetIds) {
6847
7234
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
6848
7235
  if (chosen.length === 0) throw new Error("No valid export presets selected");
6849
7236
  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;
7237
+ const buf = p.width && p.height ? await sharp7(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
6851
7238
  zip.file(`${baseName}-${p.id}.png`, buf);
6852
7239
  }
6853
7240
  return zip.generateAsync({ type: "nodebuffer" });
@@ -7008,7 +7395,7 @@ function registerImageRoutes(app, deps) {
7008
7395
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
7009
7396
  const buf = await part.toBuffer();
7010
7397
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
7011
- const png = await sharp8(buf).rotate().png().toBuffer();
7398
+ const png = await sharp7(buf).rotate().png().toBuffer();
7012
7399
  return { hash: core.images.save(png) };
7013
7400
  });
7014
7401
  app.post("/api/diff", async (req, reply) => {
@@ -7043,6 +7430,29 @@ function registerImageRoutes(app, deps) {
7043
7430
 
7044
7431
  // src/release/notes.data.ts
7045
7432
  var RELEASES = [
7433
+ {
7434
+ version: "0.5.0",
7435
+ date: "2026-08-26",
7436
+ title: "A finished shot can change shape, and a presenter you attach is a requirement rather than a suggestion.",
7437
+ sections: [
7438
+ {
7439
+ heading: "Create",
7440
+ 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."
7441
+ },
7442
+ {
7443
+ heading: "Shots",
7444
+ 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."
7445
+ },
7446
+ {
7447
+ heading: "Presenters",
7448
+ 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."
7449
+ },
7450
+ {
7451
+ heading: "Fixes",
7452
+ 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."
7453
+ }
7454
+ ]
7455
+ },
7046
7456
  {
7047
7457
  version: "0.4.7",
7048
7458
  date: "2026-08-24",
@@ -7570,6 +7980,14 @@ function registerSystemRoutes(app, deps) {
7570
7980
  }
7571
7981
 
7572
7982
  // src/server.ts
7983
+ function seedFor(sourceHash, width, height) {
7984
+ let h = 2166136261;
7985
+ for (const ch of `${sourceHash}:${width}x${height}`) {
7986
+ h ^= ch.charCodeAt(0);
7987
+ h = Math.imul(h, 16777619);
7988
+ }
7989
+ return Math.abs(h) % 2147483647;
7990
+ }
7573
7991
  var SECRET_KEYS = ["openrouter_api_key", "replicate_api_token", "fal_key"];
7574
7992
  function buildServer(opts) {
7575
7993
  const { core, engines } = opts;
@@ -7647,7 +8065,14 @@ function buildServer(opts) {
7647
8065
  const part = await readImagePart(core, req, toPng);
7648
8066
  if ("error" in part) return reply.status(400).send({ error: part.error });
7649
8067
  hashes = [part.hash];
7650
- name = String(part.fields?.name?.value ?? part.filename ?? spec.fallback).slice(0, 80);
8068
+ const explicit = part.fields?.name?.value;
8069
+ const fromFile = String(part.filename ?? "").replace(/\.[A-Za-z0-9]{1,5}$/, "");
8070
+ name = Array.from(
8071
+ String(explicit ?? (fromFile || spec.fallback)).replace(
8072
+ /[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g,
8073
+ ""
8074
+ )
8075
+ ).slice(0, 80).join("").trim() || spec.fallback;
7651
8076
  }
7652
8077
  const id = `${spec.prefix}-${randomUUID().slice(0, 8)}`;
7653
8078
  const json = { ...brand.json };
@@ -7785,8 +8210,81 @@ function buildServer(opts) {
7785
8210
  registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById });
7786
8211
  registerShowcaseRoutes(app, { templatesRoot });
7787
8212
  app.get("/api/formats", async () => FORMATS);
8213
+ async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
8214
+ const borrowed = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
8215
+ const already = new Set(
8216
+ brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map((t) => JSON.stringify(t))
8217
+ );
8218
+ const inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
8219
+ const combined = [...brief.tokens, ...inheritedTokens];
8220
+ const brandJson = await brandJsonWithResolvedPresenters(
8221
+ core,
8222
+ templatesRoot,
8223
+ presenters,
8224
+ await brandJsonWithResolvedDemoProducts(
8225
+ core,
8226
+ templatesRoot,
8227
+ demoProducts,
8228
+ brandJsonWithCatalogProducts(core, brandId),
8229
+ combined
8230
+ ),
8231
+ combined
8232
+ );
8233
+ const sceneById = sceneFor(brandJson);
8234
+ const uncapped = { ...engineCaps, maxReferenceImages: 32 };
8235
+ const verdict = scopeOfInstruction(
8236
+ brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
8237
+ );
8238
+ const compiled2 = compileBrief(brief, {
8239
+ brand: brandJson,
8240
+ images: core.images,
8241
+ engineCaps: uncapped,
8242
+ template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
8243
+ templateById: sceneById,
8244
+ mode: "edit",
8245
+ editScope: verdict.scope,
8246
+ editRemoval: verdict.removal ?? false,
8247
+ inheritedIdentity: inheritedTokens.length > 0,
8248
+ // Only the explicit op drops the dimension promise: an implicit legacy
8249
+ // expansion keeps its historical prompt byte for byte.
8250
+ ...opts2?.reshape === "extend" ? { editReshape: "extend" } : {}
8251
+ });
8252
+ let inheritedAttachments = [];
8253
+ if (inheritedTokens.length) {
8254
+ const identity = compileBrief(
8255
+ { tokens: inheritedTokens },
8256
+ { brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
8257
+ );
8258
+ inheritedAttachments = identity.attachments.filter((a) => a.essential || a.role === "brand" || a.role === "reference").map((a) => ({ ...a, inherited: true }));
8259
+ }
8260
+ const cap2 = Math.max(0, engineCaps.maxReferenceImages - 1);
8261
+ const merged = mergeEditAttachments(compiled2.attachments, inheritedAttachments, cap2);
8262
+ const warnings = [...compiled2.warnings];
8263
+ if (merged.dropped.length) {
8264
+ if (engineCaps.maxReferenceImages <= 1) {
8265
+ warnings.push(
8266
+ `${engineCaps.displayName} cannot carry reference images, so the identity rides on the source frame alone.`
8267
+ );
8268
+ } else {
8269
+ const names = [...new Set(merged.dropped.map((d) => d.label))];
8270
+ warnings.push(
8271
+ `${engineCaps.displayName} reads ${engineCaps.maxReferenceImages} reference images and the frame being refined keeps one, so ${names.join(
8272
+ " and "
8273
+ )} ${names.length === 1 ? "was" : "were"} left out.`
8274
+ );
8275
+ }
8276
+ }
8277
+ return {
8278
+ compiled: compiled2,
8279
+ inheritedTokens,
8280
+ merged,
8281
+ warnings,
8282
+ editScope: verdict.scope,
8283
+ editRemoval: verdict.removal ?? false
8284
+ };
8285
+ }
7788
8286
  app.post("/api/brief/preview", async (req, reply) => {
7789
- const { brief, engineId, brandId } = req.body;
8287
+ const { brief, engineId, brandId, parentId } = req.body;
7790
8288
  const brand = core.store.getBrand(String(brandId));
7791
8289
  if (!brand) return reply.status(404).send({ error: "brand not found" });
7792
8290
  const engine = engines.get(String(engineId));
@@ -7795,6 +8293,20 @@ function buildServer(opts) {
7795
8293
  return reply.status(400).send({ error: "brief.tokens must be an array" });
7796
8294
  const briefErrors = validateBrief(brief);
7797
8295
  if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
8296
+ if (parentId && core.store.getNode(String(parentId))) {
8297
+ const previewReshape = req.body.reshape;
8298
+ const edit = await compileEditBrief(brand.id, String(parentId), brief, engine.capabilities(), {
8299
+ reshape: previewReshape === "extend" ? "extend" : previewReshape === "crop" ? "crop" : void 0
8300
+ });
8301
+ const { referenceImages: referenceImages2, ...rest2 } = edit.compiled;
8302
+ return {
8303
+ ...rest2,
8304
+ attachments: edit.merged.kept,
8305
+ dropped: edit.merged.dropped,
8306
+ warnings: edit.warnings,
8307
+ referenceCount: edit.merged.kept.length
8308
+ };
8309
+ }
7798
8310
  const brandJson = await brandJsonWithResolvedPresenters(
7799
8311
  core,
7800
8312
  templatesRoot,
@@ -7883,14 +8395,14 @@ function buildServer(opts) {
7883
8395
  const out = [];
7884
8396
  for (const h of images) {
7885
8397
  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()));
8398
+ out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp7(buf).png().toBuffer()));
7887
8399
  }
7888
8400
  return out;
7889
8401
  }
7890
8402
  async function assertAspect(images, expect) {
7891
8403
  const want = expect.width / expect.height;
7892
8404
  for (const h of images) {
7893
- const meta2 = await sharp8(core.images.read(h)).metadata();
8405
+ const meta2 = await sharp7(core.images.read(h)).metadata();
7894
8406
  if (!meta2.width || !meta2.height) continue;
7895
8407
  const got = meta2.width / meta2.height;
7896
8408
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -7901,7 +8413,7 @@ function buildServer(opts) {
7901
8413
  }
7902
8414
  const NODE_TIMEOUT_MS = 6e5;
7903
8415
  async function runNode(nodeId, engine, estimate, work, expect, post) {
7904
- const engineId = engine.capabilities().id;
8416
+ const engineId = engine?.capabilities().id ?? "local";
7905
8417
  reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
7906
8418
  const ctrl = new AbortController();
7907
8419
  runningGenerations.set(nodeId, ctrl);
@@ -7920,13 +8432,15 @@ function buildServer(opts) {
7920
8432
  try {
7921
8433
  const sizes = [];
7922
8434
  for (const h of result.images) {
7923
- const meta2 = await sharp8(core.images.read(h)).metadata();
8435
+ const meta2 = await sharp7(core.images.read(h)).metadata();
7924
8436
  if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
7925
8437
  }
7926
8438
  const node = core.store.getNode(nodeId);
7927
8439
  if (node && sizes.length) {
7928
8440
  const brief = node.brief ?? {};
7929
- core.store.setBrief(nodeId, { ...brief, rendered: { sizes } });
8441
+ const raw = result.raw;
8442
+ const survivors = typeof raw?.requested === "number" && Array.isArray(raw.variantIndexes) ? { requested: raw.requested, variantIndexes: raw.variantIndexes } : {};
8443
+ core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors } });
7930
8444
  }
7931
8445
  } catch {
7932
8446
  }
@@ -7959,6 +8473,56 @@ function buildServer(opts) {
7959
8473
  let { width = 1024, height = 1024 } = req.body;
7960
8474
  const project = core.store.getProject(String(projectId));
7961
8475
  if (!project) return reply.status(404).send({ error: "project not found" });
8476
+ const rawReshape = req.body.reshape ?? req.body.brief?.reshape;
8477
+ const reshape = rawReshape === "crop" ? "crop" : rawReshape === "extend" ? "extend" : void 0;
8478
+ if (kind === "edit" && reshape === "crop") {
8479
+ const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
8480
+ if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
8481
+ const cropParentId = parentId ? String(parentId) : rootForCrop.id;
8482
+ if (brief && Array.isArray(brief.tokens)) {
8483
+ const briefErrors = validateBrief(brief);
8484
+ if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
8485
+ }
8486
+ const fmt = Array.isArray(brief?.tokens) ? brief.tokens.find(
8487
+ (t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
8488
+ ) : void 0;
8489
+ if (!fmt) return reply.status(400).send({ error: "a crop needs a target format" });
8490
+ const parent = core.store.getNode(cropParentId);
8491
+ const srcHash = req.body.sourceImage ?? parent?.images[0];
8492
+ if (!srcHash || !core.images.has(String(srcHash)))
8493
+ return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
8494
+ const srcBuf = core.images.read(String(srcHash));
8495
+ const srcMeta = await sharp7(srcBuf).metadata();
8496
+ if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
8497
+ const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
8498
+ if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
8499
+ const origin = await attentionCropOrigin(srcBuf, { width: srcMeta.width, height: srcMeta.height }, plan2);
8500
+ const window = { left: origin.left, top: origin.top, width: plan2.width, height: plan2.height };
8501
+ const label = FORMATS.find((f) => f.id === fmt.id)?.label ?? `${fmt.w}x${fmt.h}`;
8502
+ const node2 = core.store.addNode({
8503
+ projectId: project.id,
8504
+ parentId: cropParentId,
8505
+ kind: "edit",
8506
+ prompt: `Cropped to ${label}`,
8507
+ // No provider was asked; recording the engine the client HAPPENED to
8508
+ // have selected made the overlay display a name that did nothing.
8509
+ engineId: "local"
8510
+ });
8511
+ core.store.setBrief(node2.id, {
8512
+ ...brief ?? {},
8513
+ sourceImage: String(srcHash),
8514
+ reshape: "crop",
8515
+ crop: window
8516
+ });
8517
+ const work2 = async () => ({
8518
+ images: [core.images.save(await sharp7(srcBuf).extract(window).png().toBuffer())],
8519
+ costUsd: 0
8520
+ });
8521
+ void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
8522
+ (err) => app.log.error({ err }, "crop run failed")
8523
+ );
8524
+ return reply.status(202).send(node2);
8525
+ }
7962
8526
  const engine = engines.get(String(engineId));
7963
8527
  if (!engine) return reply.status(400).send({ error: `unknown engine ${engineId}` });
7964
8528
  const avail = await engine.isAvailable();
@@ -7971,58 +8535,48 @@ function buildServer(opts) {
7971
8535
  const ctx = brandContext(core, project.brandId);
7972
8536
  let compiled2 = null;
7973
8537
  let inheritedTokens = [];
7974
- let inheritedAttachments = [];
8538
+ let mergedEdit = null;
7975
8539
  let editScope = "global";
7976
- let editRemoval = false;
7977
8540
  const extraWarnings = [];
7978
8541
  let expandPlan = null;
7979
8542
  let expandSourceHash = null;
7980
8543
  if (brief && Array.isArray(brief.tokens)) {
7981
8544
  const briefErrors = validateBrief(brief);
7982
8545
  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(
8546
+ if (kind === "edit") {
8547
+ const edit = await compileEditBrief(project.brandId, resolvedParentId, brief, engine.capabilities(), {
8548
+ reshape
8549
+ });
8550
+ compiled2 = edit.compiled;
8551
+ inheritedTokens = edit.inheritedTokens;
8552
+ mergedEdit = edit.merged;
8553
+ editScope = edit.editScope;
8554
+ extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
8555
+ if (!compiled2.prompt.trim() && reshape !== "extend")
8556
+ return reply.status(400).send({ error: "the brief is empty" });
8557
+ } else {
8558
+ const brandJson = await brandJsonWithResolvedPresenters(
7988
8559
  core,
7989
8560
  templatesRoot,
7990
- demoProducts,
7991
- brandJsonWithCatalogProducts(core, project.brandId),
8561
+ presenters,
8562
+ await brandJsonWithResolvedDemoProducts(
8563
+ core,
8564
+ templatesRoot,
8565
+ demoProducts,
8566
+ brandJsonWithCatalogProducts(core, project.brandId),
8567
+ brief.tokens
8568
+ ),
7992
8569
  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
8570
  );
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);
8571
+ const sceneById = sceneFor(brandJson);
8572
+ compiled2 = compileBrief(brief, {
8573
+ brand: brandJson,
8574
+ images: core.images,
8575
+ engineCaps: engine.capabilities(),
8576
+ template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
8577
+ templateById: sceneById
8578
+ });
8579
+ if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
8026
8580
  }
8027
8581
  }
8028
8582
  let finalPrompt = String(prompt ?? "");
@@ -8103,23 +8657,21 @@ function buildServer(opts) {
8103
8657
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
8104
8658
  if (!engine.capabilities().supportsEdit)
8105
8659
  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
- );
8660
+ 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
8661
  const srcBuf = core.images.read(String(srcHash));
8115
- const srcMeta = await sharp8(srcBuf).metadata();
8662
+ const srcMeta = await sharp7(srcBuf).metadata();
8116
8663
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
8117
8664
  if (srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
8118
8665
  expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
8119
8666
  }
8667
+ if (reshape === "extend" && !expandPlan)
8668
+ return reply.status(400).send({ error: "the picture is already this shape" });
8669
+ const canOutpaint = expandPlan ? engine.capabilities().supportsOutpaint === true : false;
8120
8670
  if (expandPlan) {
8121
- const canvas = await expandCanvas(srcBuf, expandPlan);
8122
- expandSourceHash = core.images.save(canvas);
8671
+ if (!canOutpaint) {
8672
+ const canvas = await expandCanvas(srcBuf, expandPlan);
8673
+ expandSourceHash = core.images.save(canvas);
8674
+ }
8123
8675
  expectShape = { width: expandPlan.width, height: expandPlan.height };
8124
8676
  }
8125
8677
  const editReq = {
@@ -8127,10 +8679,47 @@ function buildServer(opts) {
8127
8679
  sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
8128
8680
  brand: ctx,
8129
8681
  ...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
8130
- ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {}
8682
+ ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
8683
+ // An answer at the planned size lets compositeExpand skip its rescale,
8684
+ // which is one whole class of seam misalignment gone when honored.
8685
+ ...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : {},
8686
+ // Only an engine that can genuinely paint a margin is told where the
8687
+ // picture sits; the rest would ignore it anyway.
8688
+ ...expandPlan && canOutpaint ? {
8689
+ expand: {
8690
+ left: expandPlan.left,
8691
+ top: expandPlan.top,
8692
+ width: srcMeta.width ?? 0,
8693
+ height: srcMeta.height ?? 0
8694
+ },
8695
+ // Derived from the picture and the shape asked for, so the same
8696
+ // extend of the same shot is the same picture every time. Without
8697
+ // it the margin is a fresh roll of the dice on every run, which
8698
+ // is not something a person can iterate against — or that a test
8699
+ // can measure.
8700
+ seed: seedFor(String(editedFrom ?? srcHash), expandPlan.width, expandPlan.height)
8701
+ } : {}
8131
8702
  };
8132
8703
  estimate = await engine.costEstimate(editReq);
8133
- work = (signal) => engine.edit(editReq, signal);
8704
+ const plan2 = expandPlan;
8705
+ const srcSize = { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
8706
+ const original2 = srcBuf;
8707
+ work = plan2 && !canOutpaint ? async (signal) => {
8708
+ const draws = await Promise.all([
8709
+ engine.edit(editReq, signal),
8710
+ engine.edit(editReq, signal).catch(() => null)
8711
+ ]);
8712
+ const scored = await Promise.all(
8713
+ draws.map(async (got) => {
8714
+ const first = got?.images[0];
8715
+ if (!got || !first) return null;
8716
+ const { image } = await compositeExpand(core.images.read(first), original2, plan2);
8717
+ return { got, score: await seamScore(image, plan2, srcSize) };
8718
+ })
8719
+ );
8720
+ const best = scored.filter((x) => x !== null).sort((a, b) => a.score - b.score)[0];
8721
+ return best?.got ?? draws[0];
8722
+ } : (signal) => engine.edit(editReq, signal);
8134
8723
  }
8135
8724
  core.ledger.assertUnderCap(engine.capabilities().id, estimate + (reserved.get(engine.capabilities().id) ?? 0));
8136
8725
  const node = core.store.addNode({
@@ -8140,14 +8729,29 @@ function buildServer(opts) {
8140
8729
  prompt: finalPrompt,
8141
8730
  engineId: String(engineId)
8142
8731
  });
8143
- if (brief) core.store.setBrief(node.id, editedFrom ? { ...brief, sourceImage: editedFrom } : brief);
8732
+ if (brief)
8733
+ core.store.setBrief(node.id, {
8734
+ ...brief,
8735
+ ...editedFrom ? { sourceImage: editedFrom } : {},
8736
+ ...kind === "edit" && reshape ? { reshape } : {},
8737
+ // What the refinement carried, recorded apart from what it asked for:
8738
+ // the detail view shows both, and remix reads tokens alone.
8739
+ ...kind === "edit" && inheritedTokens.length ? { inherited: inheritedTokens } : {}
8740
+ });
8144
8741
  const plan = expandPlan;
8145
8742
  const original = editedFrom ? core.images.read(editedFrom) : null;
8146
8743
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
8147
8744
  const post = plan ? async (images) => {
8148
8745
  const out = [];
8149
8746
  for (const h of images) {
8150
- const { image, aligned } = await compositeExpand(core.images.read(h), original, plan);
8747
+ const answer = core.images.read(h);
8748
+ const got = await sharp7(answer).metadata();
8749
+ if (got.width !== plan.width || got.height !== plan.height)
8750
+ app.log.info(
8751
+ { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
8752
+ "expand: engine size differs from plan"
8753
+ );
8754
+ const { image, aligned } = await compositeExpand(answer, original, plan);
8151
8755
  if (!aligned) app.log.warn({ nodeId: node.id }, "expand: engine frame did not align, kept the bed");
8152
8756
  out.push(core.images.save(image));
8153
8757
  }
@@ -8317,6 +8921,7 @@ async function run() {
8317
8921
  const onlyThisMachine = LOOPBACK2.includes(HOST);
8318
8922
  const token = onlyThisMachine ? void 0 : randomBytes(24).toString("base64url");
8319
8923
  const reachableAt = onlyThisMachine ? [] : HOST === "0.0.0.0" || HOST === "::" ? lanAddresses() : [HOST];
8924
+ await repairPresenterCrops(core, (line) => console.log(line));
8320
8925
  const app = buildServer({
8321
8926
  core,
8322
8927
  engines,
@@ -8421,8 +9026,8 @@ async function verify() {
8421
9026
  const db = new Database2(":memory:");
8422
9027
  db.pragma("user_version");
8423
9028
  db.close();
8424
- const { default: sharp14 } = await import('sharp');
8425
- await sharp14({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
9029
+ const { default: sharp16 } = await import('sharp');
9030
+ await sharp16({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
8426
9031
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
8427
9032
  } catch (err) {
8428
9033
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));