scenri 0.6.13 → 0.7.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/serve.js +372 -110
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/tonygorb/Scenri/compare/v0.6.13...v0.7.0) (2026-08-30)
4
+
5
+
6
+ ### Features
7
+
8
+ * a pure-grade refinement keeps the photograph's own pixels ([7daf442](https://github.com/tonygorb/Scenri/commit/7daf442a7949a3dec0acbcb510ed9100863937e0))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **brief:** a shed corroboration angle no longer reads as a lost identity ([d4830f7](https://github.com/tonygorb/Scenri/commit/d4830f7940f8cc596f013000e4159da4459d6a60))
14
+ * **brief:** identity has one source - the carve-out leads the scene directive, and the presenter outranks a reference ([9c97fad](https://github.com/tonygorb/Scenri/commit/9c97fada04934e27f54e439d78511f12d7d775d0))
15
+ * **brief:** the drawn plate conditions a figure-led scene, and the raw upload never rides beside a presenter ([c0766da](https://github.com/tonygorb/Scenri/commit/c0766da479a36677bc4ad66762b55d1daa4a8eb1))
16
+ * carried mood references stop claiming to be the subject on refines ([a0d3c40](https://github.com/tonygorb/Scenri/commit/a0d3c408156ae2da9579e97a220848c5591fd68d))
17
+ * **openrouter:** repeat the identity carve-out after the image list ([dc2cf93](https://github.com/tonygorb/Scenri/commit/dc2cf93f3fa36af76a67f4a85c0f9676a1e4bf4c))
18
+ * refines on pixel-budget engines step down honestly instead of upscaling ([1fa419d](https://github.com/tonygorb/Scenri/commit/1fa419d191fa5201b2a78fe29fa6f4123955411c))
19
+ * **scenes:** a treatment plate may carry fictional print, and a redraw trims its bars ([8f5c0a2](https://github.com/tonygorb/Scenri/commit/8f5c0a2bc77433b8a46dca6f985768fd7e13d940))
20
+ * **server:** a formatless edit brief never reshapes ([253804f](https://github.com/tonygorb/Scenri/commit/253804ffedc6164c5dbc46e5f19e942b424a6ad9))
21
+ * the edit identity compile declares edit mode at the scene seam ([40cb845](https://github.com/tonygorb/Scenri/commit/40cb84596ca77b74f1626c3cfeed68cf1b8f44d2))
22
+
3
23
  ## [0.6.13](https://github.com/tonygorb/Scenri/compare/v0.6.12...v0.6.13) (2026-08-30)
4
24
 
5
25
 
package/dist/serve.js CHANGED
@@ -10,7 +10,7 @@ import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, write
10
10
  import { fileURLToPath } from 'url';
11
11
  import { readFile, copyFile, stat, mkdtemp, rm, readdir, writeFile } from 'fs/promises';
12
12
  import { spawn } from 'child_process';
13
- import sharp19 from 'sharp';
13
+ import sharp20 from 'sharp';
14
14
  import Fastify from 'fastify';
15
15
  import fastifyStatic from '@fastify/static';
16
16
  import fastifyMultipart from '@fastify/multipart';
@@ -1295,7 +1295,13 @@ var REFERENCE_ROLE_DIRECTIVE = {
1295
1295
  // names staged objects as stand-ins rather than just "no product": a bare
1296
1296
  // prohibition still left the demo object in the frame, because the model had
1297
1297
  // nowhere to put what the photograph so vividly showed.
1298
- scene: "a reference for this world and for the treatment applied to the figure in it \u2014 match the environment, the light, and the material, density, scale, finish and spread of that treatment, including which parts of the form it covers and how far it reaches; take no identity from the person in it, and treat any product, garment or prop staged in it as a stand-in whose place the attached subject takes \u2014 never an object to reproduce",
1298
+ //
1299
+ // The carve-out leads. It used to sit forty words in, one subordinate
1300
+ // clause after a paragraph of "match this" - and the tester case (a close
1301
+ // portrait as the scene image beside a selected presenter) showed which
1302
+ // half the model heard. Every treatment clause is retained word for word;
1303
+ // only the order and the register of the identity refusal changed.
1304
+ scene: "a reference for this world, never for a person: take no identity from the person in it \u2014 not their face, not their likeness \u2014 they are an anonymous stand-in whose place the attached subject takes. Match the environment, the light, and the material, density, scale, finish and spread of the treatment applied to the figure, including which parts of the form it covers and how far it reaches; treat any product, garment or prop staged in it as the same kind of stand-in, demonstrating placement and scale \u2014 never an object to reproduce",
1299
1305
  composition: "a reference for framing, camera angle and pose only \u2014 take no subject, color, material or branding from it",
1300
1306
  style: "a reference for overall treatment and mood only \u2014 take no composition, subject or product detail from it",
1301
1307
  reference: "a reference to match in composition, lighting and treatment"
@@ -1304,7 +1310,7 @@ var EDIT_REFERENCE_ROLE_DIRECTIVE = {
1304
1310
  product: "the exact product: keep or restore its label, shape and design faithfully",
1305
1311
  character: "the exact person: keep their face, facial structure, skin, hair and build faithfully; take no clothing, pose or background from this reference, and keep the source image's existing outfit unless the instruction changes it",
1306
1312
  brand: "the brand's own mark: reproduce it exactly as drawn wherever it appears \u2014 every character down to the smallest secondary lettering, in its original script and reading direction \u2014 never redrawn, re-lettered, translated or transliterated",
1307
- scene: "a reference for environment and light only",
1313
+ scene: "a reference for environment, light and treatment only \u2014 take no identity from any person in it",
1308
1314
  composition: "a reference for framing and pose only",
1309
1315
  style: "a reference for treatment and mood only",
1310
1316
  reference: "a reference for composition, lighting and treatment only"
@@ -1332,6 +1338,14 @@ function ratioLabel(width, height) {
1332
1338
  const d = gcd(width, height) || 1;
1333
1339
  return `${Math.round(width / d)}:${Math.round(height / d)}`;
1334
1340
  }
1341
+ function budgetSize(width, height, pixelBudget) {
1342
+ const ratio = width / height;
1343
+ if (!(ratio > 0) || !Number.isFinite(ratio) || !(pixelBudget > 0)) return { width, height };
1344
+ return {
1345
+ width: Math.round(Math.sqrt(pixelBudget * ratio)),
1346
+ height: Math.round(Math.sqrt(pixelBudget / ratio))
1347
+ };
1348
+ }
1335
1349
 
1336
1350
  // ../core/src/index.ts
1337
1351
  function defaultHome() {
@@ -1458,12 +1472,14 @@ function createOpenRouterEngine(opts) {
1458
1472
  const role = roles[i] ?? "reference";
1459
1473
  return refs.length > 1 ? `Attached image ${i + 1} is ${roleDirective[role]}.` : `The attached image is ${roleDirective[role]}.`;
1460
1474
  }).join(" ");
1475
+ const identityCoda = roles.includes("character") && roles.some((r) => r === "scene" || r === "reference") ? "Identity check: the person in this image comes only from the character reference image(s); the scene and reference images lend world, composition, lighting and treatment \u2014 take no face or likeness from them." : null;
1461
1476
  const content = [
1462
1477
  { type: "text", text: refDirectives ? `${req.prompt} ${refDirectives}` : req.prompt },
1463
1478
  ...refs.map((p) => ({
1464
1479
  type: "image_url",
1465
1480
  image_url: { url: dataUrl(p) }
1466
- }))
1481
+ })),
1482
+ ...identityCoda ? [{ type: "text", text: identityCoda }] : []
1467
1483
  ];
1468
1484
  const body = {
1469
1485
  model: opts.model ?? DEFAULT_MODEL,
@@ -2519,12 +2535,7 @@ function orientationOf(width, height) {
2519
2535
  }
2520
2536
  var CODEX_PIXEL_BUDGET = 1572864;
2521
2537
  function codexNativeSize(width, height) {
2522
- const ratio = width / height;
2523
- if (!(ratio > 0) || !Number.isFinite(ratio)) return { width, height };
2524
- return {
2525
- width: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET * ratio)),
2526
- height: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET / ratio))
2527
- };
2538
+ return budgetSize(width, height, CODEX_PIXEL_BUDGET);
2528
2539
  }
2529
2540
  function refFileNames(roles, count) {
2530
2541
  const perRole = /* @__PURE__ */ new Map();
@@ -2597,6 +2608,10 @@ function createCodexEngine(opts) {
2597
2608
  // OSS-local only: the user's own session, on the user's own machine
2598
2609
  supportsEdit: true,
2599
2610
  supportsMask: false,
2611
+ // The image tool draws at this fixed pixel count (measured, the
2612
+ // native-size probe): an edit of a larger source steps down honestly
2613
+ // instead of being upscaled back into pixels the tool never drew.
2614
+ editPixelBudget: CODEX_PIXEL_BUDGET,
2600
2615
  /*
2601
2616
  * Five, and it is a hard constraint of the image tool, not a product
2602
2617
  * choice.
@@ -2831,7 +2846,7 @@ function createDemoEngine(saveImage) {
2831
2846
  <text x="24" y="${h - 48}" font-family="Helvetica, Arial" font-size="${Math.max(14, Math.round(w / 42))}" fill="#ffffff" opacity="0.92">${esc(label)}</text>
2832
2847
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
2833
2848
  </svg>`;
2834
- return sharp19(Buffer.from(svg)).png().toBuffer();
2849
+ return sharp20(Buffer.from(svg)).png().toBuffer();
2835
2850
  }
2836
2851
  return {
2837
2852
  capabilities() {
@@ -3005,7 +3020,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
3005
3020
  for (const [slot, angle] of PRESENTER_ANGLES) {
3006
3021
  const path = presenterRefPath(templatesRoot, presenter.id, slot);
3007
3022
  if (!existsSync(path)) continue;
3008
- const png = await sharp19(readFileSync(path)).png().toBuffer();
3023
+ const png = await sharp20(readFileSync(path)).png().toBuffer();
3009
3024
  const hash = core.images.save(png);
3010
3025
  shots.push({ file: `asset:${hash}`, angle, locked: true });
3011
3026
  }
@@ -3100,7 +3115,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
3100
3115
  for (const angle of angles) {
3101
3116
  const path = demoProductRefPath(templatesRoot, product.id, angle);
3102
3117
  if (!existsSync(path)) continue;
3103
- const png = await sharp19(readFileSync(path)).png().toBuffer();
3118
+ const png = await sharp20(readFileSync(path)).png().toBuffer();
3104
3119
  const hash = core.images.save(png);
3105
3120
  shots.push({ file: `asset:${hash}`, angle, locked: true });
3106
3121
  }
@@ -3239,7 +3254,7 @@ var assetHash = (ref) => {
3239
3254
  };
3240
3255
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
3241
3256
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
3242
- var toPng = (buf) => sharp19(buf).rotate().png().toBuffer();
3257
+ var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
3243
3258
  var COST_PROBE = {
3244
3259
  prompt: "",
3245
3260
  brand: { brand: {}, assetPaths: {} },
@@ -3252,11 +3267,11 @@ var MARK_MIN_EDGE = 1024;
3252
3267
  var MARK_TINY_EDGE = 256;
3253
3268
  var MARK_WARN_EDGE = 512;
3254
3269
  var toMarkPng = async (buf) => {
3255
- const out = await sharp19(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3256
- const meta = await sharp19(out).metadata();
3270
+ const out = await sharp20(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3271
+ const meta = await sharp20(out).metadata();
3257
3272
  const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
3258
3273
  if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
3259
- return sharp19(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
3274
+ return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
3260
3275
  }
3261
3276
  return out;
3262
3277
  };
@@ -3267,9 +3282,9 @@ async function capReferenceEdge(core, path, maxEdge) {
3267
3282
  if (hit) return hit;
3268
3283
  let out = path;
3269
3284
  try {
3270
- const meta = await sharp19(path).metadata();
3285
+ const meta = await sharp20(path).metadata();
3271
3286
  if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
3272
- const buf = await sharp19(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3287
+ const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3273
3288
  out = core.images.pathFor(core.images.save(buf));
3274
3289
  }
3275
3290
  } catch {
@@ -3306,12 +3321,21 @@ function productFidelityDirective(attached) {
3306
3321
  function editPreservationDirective(scope, opts) {
3307
3322
  if (scope === "local") {
3308
3323
  const removal = opts?.removal ? " What is removed leaves nothing behind: the surface and the scene continue as if it had never been there, with no outline, silhouette, residue or ghost of it." : "";
3309
- return "This is a change to a photograph that already exists, not a new photograph. Return the same image with one change made. Everything the instruction does not name comes back exactly as it is now: the same framing, the same crop, the same camera position, the same subject placement and pose, the same lighting, the same colours, the same background and the same dimensions. Do not re-render, re-stage, re-light or re-compose the picture. Change only what was asked for, together with the shadows, reflections and contact points that move with it." + removal;
3324
+ return "This is a change to a photograph that already exists, not a new photograph. Return the same image with one change made. Everything the instruction does not name comes back exactly as it is now: the same framing, the same crop, the same camera position, the same subject placement and pose, the same lighting, the same colours, the same background and the same dimensions. Do not re-render, re-stage, re-light or re-compose the picture. Change only what was asked for, together with the shadows, reflections and contact points that move with it. Every surface keeps the texture it already has: no invented pattern, grain, weave or embossing on fabric, skin or walls that the photograph does not carry now." + removal;
3310
3325
  }
3311
- return "This is a change to a photograph that already exists, not a new photograph. Apply the instruction to the image you were given and keep what it does not name: the same subject and the same face, the same product with the same label, geometry and colour, and the same dimensions. Do not replace the subject and do not redesign the product.";
3326
+ return "This is a change to a photograph that already exists, not a new photograph. Apply the instruction to the image you were given and keep what it does not name: the same subject and the same face, the same product with the same label, geometry and colour, and the same dimensions. Do not replace the subject and do not redesign the product. Every surface keeps the texture it already has: no invented pattern, grain, weave or embossing on fabric, skin or walls that the photograph does not carry now.";
3327
+ }
3328
+ function inheritedIdentityDirective(kinds) {
3329
+ const product = kinds?.product ?? true;
3330
+ const person = kinds?.person ?? true;
3331
+ if (product && person)
3332
+ return "The attached product and person references are the same product and the same person that are already in this picture. Use them to hold that identity exact while you make the change, not as a reason to re-stage the shot.";
3333
+ if (product)
3334
+ return "The attached product references show the same product that is already in this picture. Use them to hold its identity exact while you make the change, not as a reason to re-stage the shot.";
3335
+ return "The attached person reference shows the same person who is already in this picture. Use it to hold their identity exact while you make the change, not as a reason to re-stage the shot.";
3312
3336
  }
3313
- function inheritedIdentityDirective() {
3314
- return "The extra attached references are the same product and the same person that are already in this picture. Use them to hold that identity exact while you make the change, not as a reason to re-stage the shot.";
3337
+ function inheritedRefDirective() {
3338
+ return "The carried reference is attached for composition, lighting and treatment only. Any person or product visible in it lends mood, never identity \u2014 nobody and nothing in this photograph takes a face, a body or a design from it.";
3315
3339
  }
3316
3340
  function productFactDirectives(p) {
3317
3341
  const out = [];
@@ -3340,6 +3364,9 @@ function productEditFidelityDirective(name) {
3340
3364
  function characterEditIdentityDirective(name) {
3341
3365
  return `${name} is the person in this photograph: keep them present and clearly visible. Match their face, facial structure, skin, hair and build to the attached person reference exactly. The reference's plain outfit and studio backdrop are capture conditions, not direction: keep the styling this photograph already has unless the instruction itself changes it, and never return them to the plain base layers they were photographed in.`;
3342
3366
  }
3367
+ function referenceIdentityGuard() {
3368
+ return "A reference shot lends its composition, lighting and treatment, never its cast: the attached presenter is the only source of person identity in this shot, and any person visible in a reference shot is a stand-in whose place the presenter takes. This holds even where the direction asks to use someone from a reference \u2014 the attached presenter is that someone.";
3369
+ }
3343
3370
  function markEditDirective() {
3344
3371
  return "The attached brand mark is this brand's own mark: wherever the logo appears or the instruction asks for it, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions \u2014 never redrawn or re-lettered. Every character it carries stays intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled.";
3345
3372
  }
@@ -3440,7 +3467,7 @@ function markLabel(brand, logo) {
3440
3467
 
3441
3468
  // src/brief.ts
3442
3469
  var PRODUCT_REF_MAX = 3;
3443
- var CHARACTER_REF_MAX = 2;
3470
+ var CHARACTER_REF_MAX = 3;
3444
3471
  var SCENE_REF_MAX = 1;
3445
3472
  var FORMATS = [
3446
3473
  { id: "square", label: "Square 1:1", w: 1024, h: 1024 },
@@ -3497,6 +3524,7 @@ function validateBrief(brief) {
3497
3524
  function compileBrief(brief, ctx) {
3498
3525
  const warnings = [];
3499
3526
  const attachments = [];
3527
+ const rawSceneFallback = [];
3500
3528
  const productDirectives = [];
3501
3529
  const personDirectives = [];
3502
3530
  const otherDirectives = [];
@@ -3633,10 +3661,14 @@ function compileBrief(brief, ctx) {
3633
3661
  inlineTemplates.push(t);
3634
3662
  append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
3635
3663
  if (ctx.mode !== "edit" && t.figure) {
3636
- for (const r of (t.refs ?? []).slice(0, SCENE_REF_MAX)) {
3637
- const h = assetHash2(r?.file);
3664
+ const plate = assetHash2(t.preview);
3665
+ const hasPlate = !!plate && ctx.images.has(plate);
3666
+ const candidates = hasPlate ? [plate] : (t.refs ?? []).slice(0, SCENE_REF_MAX).map((r) => assetHash2(r?.file));
3667
+ for (const h of candidates) {
3638
3668
  if (h && ctx.images.has(h)) {
3639
- attachments.push({ role: "scene", id: t.id, label: t.name, hash: h, essential: false });
3669
+ const a = { role: "scene", id: t.id, label: t.name, hash: h, essential: false };
3670
+ attachments.push(a);
3671
+ if (!hasPlate) rawSceneFallback.push(a);
3640
3672
  }
3641
3673
  }
3642
3674
  }
@@ -3687,6 +3719,12 @@ function compileBrief(brief, ctx) {
3687
3719
  }
3688
3720
  const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
3689
3721
  const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
3722
+ if (hasPerson && rawSceneFallback.length) {
3723
+ for (const a of rawSceneFallback) {
3724
+ const i = attachments.indexOf(a);
3725
+ if (i !== -1) attachments.splice(i, 1);
3726
+ }
3727
+ }
3690
3728
  const max = ctx.engineCaps.maxReferenceImages;
3691
3729
  const { kept, dropped } = allocateAttachments(attachments, max);
3692
3730
  const guard = scene ? sceneGuardDirectives({
@@ -3720,7 +3758,7 @@ function compileBrief(brief, ctx) {
3720
3758
  const brandLines = brandRuleDirectives(ctx.brand);
3721
3759
  const preservation = ctx.mode === "edit" ? [
3722
3760
  ...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
3723
- ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : [],
3761
+ ...ctx.inheritedIdentity ? [inheritedIdentityDirective(ctx.inheritedIdentity === true ? void 0 : ctx.inheritedIdentity)] : [],
3724
3762
  ...ctx.inheritedDirectives ?? []
3725
3763
  ] : [];
3726
3764
  const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
@@ -3728,6 +3766,7 @@ function compileBrief(brief, ctx) {
3728
3766
  const rec = (ctx.brand?.products ?? []).find((x) => x?.id === a.id);
3729
3767
  return String(rec?.category ?? "").toLowerCase() === "apparel";
3730
3768
  }) ? [garmentDisplayDirective()] : [];
3769
+ const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard()] : [];
3731
3770
  const allDirectives = [
3732
3771
  ...productDirectives,
3733
3772
  ...personDirectives,
@@ -3739,15 +3778,21 @@ function compileBrief(brief, ctx) {
3739
3778
  ...apparelUnworn,
3740
3779
  ...brandLines,
3741
3780
  ...guard,
3781
+ ...refGuard,
3742
3782
  ...preservation
3743
3783
  ];
3744
3784
  if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
3745
3785
  if (dropped.some((d) => d.role !== "scene")) {
3746
- const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))];
3747
- const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
3748
- warnings.push(
3749
- `${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
3786
+ const keptLabels = new Set(kept.map((a) => a.label));
3787
+ const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))].filter(
3788
+ (l) => !keptLabels.has(l)
3750
3789
  );
3790
+ if (names.length) {
3791
+ const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
3792
+ warnings.push(
3793
+ `${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
3794
+ );
3795
+ }
3751
3796
  }
3752
3797
  return {
3753
3798
  prompt: prompt.trim(),
@@ -5756,8 +5801,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
5756
5801
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
5757
5802
  return;
5758
5803
  }
5759
- const png = await sharp19(buf).rotate().png().toBuffer();
5760
- const meta = await sharp19(png).metadata();
5804
+ const png = await sharp20(buf).rotate().png().toBuffer();
5805
+ const meta = await sharp20(png).metadata();
5761
5806
  const hash = core.images.save(png);
5762
5807
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
5763
5808
  width: meta.width,
@@ -6206,7 +6251,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6206
6251
  return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
6207
6252
  }
6208
6253
  async function edgeBarGeometry(buf) {
6209
- const { data, info } = await sharp19(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
6254
+ const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
6210
6255
  const W = info.width;
6211
6256
  const H = info.height;
6212
6257
  const scan = (len, cross, at) => {
@@ -6260,7 +6305,7 @@ async function trimEdgeBars(core, hash) {
6260
6305
  const width = g.right - g.left + 1;
6261
6306
  const height = g.bottom - g.top + 1;
6262
6307
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
6263
- const png = await sharp19(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
6308
+ const png = await sharp20(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
6264
6309
  return core.images.save(png);
6265
6310
  } catch {
6266
6311
  return hash;
@@ -6302,12 +6347,12 @@ async function avatarCrop(core, hash) {
6302
6347
  );
6303
6348
  }
6304
6349
  async function figureBox(buf) {
6305
- const meta = await sharp19(buf).metadata();
6350
+ const meta = await sharp20(buf).metadata();
6306
6351
  const W = meta.width ?? 0;
6307
6352
  const H = meta.height ?? 0;
6308
6353
  if (!W || !H) return null;
6309
6354
  for (const threshold of FIGURE_TRIM_THRESHOLDS) {
6310
- const { info } = await sharp19(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6355
+ const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6311
6356
  const left = Math.abs(info.trimOffsetLeft ?? 0);
6312
6357
  const top = Math.abs(info.trimOffsetTop ?? 0);
6313
6358
  const width = info.width ?? 0;
@@ -6340,13 +6385,13 @@ async function smartCover(core, hash, box) {
6340
6385
  if (!hash || !core.images.has(hash)) return void 0;
6341
6386
  try {
6342
6387
  const buf = core.images.read(hash);
6343
- const meta = await sharp19(buf).metadata();
6388
+ const meta = await sharp20(buf).metadata();
6344
6389
  const w = meta.width ?? 0;
6345
6390
  const h = meta.height ?? 0;
6346
6391
  if (!w || !h) return void 0;
6347
6392
  const raw = box(w, h);
6348
6393
  const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
6349
- const png = await sharp19(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
6394
+ const png = await sharp20(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
6350
6395
  return core.images.save(png);
6351
6396
  } catch {
6352
6397
  return void 0;
@@ -6355,11 +6400,11 @@ async function smartCover(core, hash, box) {
6355
6400
  async function crop(core, hash, region, cap2) {
6356
6401
  if (!hash || !core.images.has(hash)) return void 0;
6357
6402
  try {
6358
- const meta = await sharp19(core.images.read(hash)).metadata();
6403
+ const meta = await sharp20(core.images.read(hash)).metadata();
6359
6404
  const w = meta.width ?? 0;
6360
6405
  const h = meta.height ?? 0;
6361
6406
  if (!w || !h) return void 0;
6362
- let pipeline = sharp19(core.images.read(hash)).extract(region(w, h));
6407
+ let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
6363
6408
  if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
6364
6409
  const png = await pipeline.png().toBuffer();
6365
6410
  return core.images.save(png);
@@ -6450,7 +6495,7 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
6450
6495
  });
6451
6496
  }
6452
6497
  function scenePreviewPrompt(scene) {
6453
- const body = scene.figure ? `A figure is in this photograph: ${scene.figure.replace(/[.\s]+$/, "")}. ` + (scene.figureTreatment ? `The art direction is what has been done to them: ${scene.figureTreatment.replace(/[.\s]+$/, "")}, rendered as a real physical treatment that follows the shape it sits on. ` : "") + "They are nobody in particular: do not reproduce any person from the attached reference images, and give them no recognisable identity. No product, no logos, no watermarks, and no readable words anywhere in the frame." : "The set is empty: no product, no person, no hands, no text, no logos, no watermarks anywhere in the frame.";
6498
+ const body = scene.figure ? `A figure is in this photograph: ${scene.figure.replace(/[.\s]+$/, "")}. ` + (scene.figureTreatment ? `The art direction is what has been done to them: ${scene.figureTreatment.replace(/[.\s]+$/, "")}, rendered as a real physical treatment that follows the shape it sits on. ` : "") + "They are nobody in particular: do not reproduce any person from the attached reference images, and give them no recognisable identity. " + (scene.figureTreatment ? "No product and no watermarks. Where the treatment itself carries printing, render it as genuinely designed print - real letterforms, readable words, numerals and label-quality artwork - belonging to companies that are plausible but fictional, resembling no existing brand, and borrowing, extending or re-spelling no name that appears in any attached reference. Everywhere outside the treatment, no logos and no readable words." : "No product, no logos, no watermarks, and no readable words anywhere in the frame.") : "The set is empty: no product, no person, no hands, no text, no logos, no watermarks anywhere in the frame.";
6454
6499
  return `Full-bleed photograph filling the entire frame edge to edge with no border, frame, letterbox band or matte of any kind. ${scene.prompt} ${scene.lighting ? `${scene.lighting}. ` : ""}` + body;
6455
6500
  }
6456
6501
  async function draw(deps, req) {
@@ -6621,6 +6666,119 @@ function scopeOfInstruction(text) {
6621
6666
  if (!matched.length) return { scope: "global", matched: ["no local cue"] };
6622
6667
  return { scope: "local", matched, removal: REMOVAL_VERB.test(s) };
6623
6668
  }
6669
+ var FIT_EDGE = 160;
6670
+ var GATE_EDGE = 32;
6671
+ var GRADE_GATE_MEAN_DELTA = 40;
6672
+ var SLOPE_CLAMP = 1.8;
6673
+ var TONAL_WORD = /^(warm(er)?|cool(er)?|bright(er)?|dark(er)?|deep(er)?|soft(er)?|hard(er)?|punch(ier|y)?|rich(er)?|flat(ter)?|mood(y|ier)?|golden|overcast|sun(ny|lit)?|cloudy|dusk|dawn|evening|morning|night|daylight|light|lighting|lit|glow(ing)?|exposure|contrast|saturation|desaturat(e|ed)|muted|vivid|tone[sd]?|tint(ed)?|grade[d]?|grading|temperature|white|balance|shadows?|highlights?|blacks?|whites?|filmic|cinematic|dramatic|airy|hazy|misty|crisp)$/i;
6674
+ var NEUTRAL_WORD = /^(a|an|the|it|its|this|that|make|makes|keep|slightly|slight|touch|bit|little|more|less|much|very|again|overall|whole|image|frame|shot|picture|photo|look|feel|and|of|in|with|to|too|just|please|now|even)$/i;
6675
+ function isGradeOnlyInstruction(text) {
6676
+ const words = String(text ?? "").toLowerCase().replace(/[^a-z\s-]/g, " ").split(/\s+/).filter(Boolean);
6677
+ if (!words.length) return false;
6678
+ let tonal = 0;
6679
+ for (const w of words) {
6680
+ if (TONAL_WORD.test(w)) tonal++;
6681
+ else if (!NEUTRAL_WORD.test(w)) return false;
6682
+ }
6683
+ return tonal > 0;
6684
+ }
6685
+ var srgbToLinear = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
6686
+ var linearToSrgb = (c) => c <= 31308e-7 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
6687
+ var EPS = 1e-6;
6688
+ var toLab = (r, g, b) => {
6689
+ const R = srgbToLinear(r / 255);
6690
+ const G = srgbToLinear(g / 255);
6691
+ const B = srgbToLinear(b / 255);
6692
+ const L = Math.log10(Math.max(EPS, 0.3811 * R + 0.5783 * G + 0.0402 * B));
6693
+ const M = Math.log10(Math.max(EPS, 0.1967 * R + 0.7244 * G + 0.0782 * B));
6694
+ const S = Math.log10(Math.max(EPS, 0.0241 * R + 0.1288 * G + 0.8444 * B));
6695
+ return [(L + M + S) / Math.sqrt(3), (L + M - 2 * S) / Math.sqrt(6), (L - M) / Math.sqrt(2)];
6696
+ };
6697
+ var fromLab = (l, a, bb) => {
6698
+ const L = l / Math.sqrt(3) + a / Math.sqrt(6) + bb / Math.sqrt(2);
6699
+ const M = l / Math.sqrt(3) + a / Math.sqrt(6) - bb / Math.sqrt(2);
6700
+ const S = l / Math.sqrt(3) - 2 * a / Math.sqrt(6);
6701
+ const Rl = 10 ** L;
6702
+ const Ml = 10 ** M;
6703
+ const Sl = 10 ** S;
6704
+ const R = 4.4679 * Rl - 3.5873 * Ml + 0.1193 * Sl;
6705
+ const G = -1.2186 * Rl + 2.3809 * Ml - 0.1624 * Sl;
6706
+ const B = 0.0497 * Rl - 0.2439 * Ml + 1.2045 * Sl;
6707
+ const clamp = (x) => Math.max(0, Math.min(255, Math.round(linearToSrgb(Math.max(0, Math.min(1, x))) * 255)));
6708
+ return [clamp(R), clamp(G), clamp(B)];
6709
+ };
6710
+ var rawAt = async (png, edge) => {
6711
+ let img = sharp20(png);
6712
+ if (edge) img = img.resize(edge, edge, { fit: "fill" });
6713
+ const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
6714
+ return { data, width: info.width, height: info.height };
6715
+ };
6716
+ var labStats = (raw) => {
6717
+ const n = raw.data.length / 3;
6718
+ const mu = [0, 0, 0];
6719
+ const sq = [0, 0, 0];
6720
+ for (let i = 0; i < raw.data.length; i += 3) {
6721
+ const lab = toLab(raw.data[i], raw.data[i + 1], raw.data[i + 2]);
6722
+ for (let c = 0; c < 3; c++) {
6723
+ mu[c] += lab[c];
6724
+ sq[c] += lab[c] * lab[c];
6725
+ }
6726
+ }
6727
+ for (let c = 0; c < 3; c++) {
6728
+ mu[c] /= n;
6729
+ sq[c] = Math.sqrt(Math.max(1e-9, sq[c] / n - mu[c] * mu[c]));
6730
+ }
6731
+ return { mu, sd: sq };
6732
+ };
6733
+ var fitAffine = (input, output) => {
6734
+ const a = labStats(input);
6735
+ const b = labStats(output);
6736
+ return [0, 1, 2].map((c) => ({
6737
+ k: Math.max(1 / SLOPE_CLAMP, Math.min(SLOPE_CLAMP, b.sd[c] / a.sd[c])),
6738
+ mi: a.mu[c],
6739
+ mo: b.mu[c]
6740
+ }));
6741
+ };
6742
+ var applyAffine = (raw, T) => {
6743
+ const d = raw.data;
6744
+ for (let i = 0; i < d.length; i += 3) {
6745
+ const peak = Math.max(d[i], d[i + 1], d[i + 2]);
6746
+ const w = peak <= 225 ? 1 : Math.max(0, (255 - peak) / 30);
6747
+ const lab = toLab(d[i], d[i + 1], d[i + 2]);
6748
+ const out = fromLab(
6749
+ (lab[0] - T[0].mi) * T[0].k + T[0].mo,
6750
+ (lab[1] - T[1].mi) * T[1].k + T[1].mo,
6751
+ (lab[2] - T[2].mi) * T[2].k + T[2].mo
6752
+ );
6753
+ d[i] = Math.round(d[i] * (1 - w) + out[0] * w);
6754
+ d[i + 1] = Math.round(d[i + 1] * (1 - w) + out[1] * w);
6755
+ d[i + 2] = Math.round(d[i + 2] * (1 - w) + out[2] * w);
6756
+ }
6757
+ };
6758
+ var meanDelta = (a, b) => {
6759
+ const n = Math.min(a.data.length, b.data.length);
6760
+ let sum = 0;
6761
+ for (let i = 0; i < n; i++) sum += Math.abs(a.data[i] - b.data[i]);
6762
+ return n ? sum / n : 255;
6763
+ };
6764
+ async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
6765
+ try {
6766
+ const T = fitAffine(await rawAt(modelInputPng, FIT_EDGE), await rawAt(modelOutputPng, FIT_EDGE));
6767
+ const gateIn = await rawAt(modelInputPng, GATE_EDGE);
6768
+ const gateOut = await rawAt(modelOutputPng, GATE_EDGE);
6769
+ applyAffine(gateIn, T);
6770
+ const residual = meanDelta(gateIn, gateOut);
6771
+ if (residual > GRADE_GATE_MEAN_DELTA) return null;
6772
+ const full = await rawAt(originalPng);
6773
+ applyAffine(full, T);
6774
+ const image = await sharp20(full.data, {
6775
+ raw: { width: full.width, height: full.height, channels: 3 }
6776
+ }).png().toBuffer();
6777
+ return { image, residual };
6778
+ } catch {
6779
+ return null;
6780
+ }
6781
+ }
6624
6782
 
6625
6783
  // src/expandRules.ts
6626
6784
  var round8 = (n) => Math.max(8, Math.round(n / 8) * 8);
@@ -6687,13 +6845,20 @@ Avoid: new objects, products, people, text or watermarks; do not recompose, rece
6687
6845
  // src/editSizeRules.ts
6688
6846
  var SAME_SHAPE_TOL = 0.01;
6689
6847
  var SHRINK_FLOOR = 0.8;
6690
- function judgeEditSize(src, got) {
6848
+ var BUDGET_TOL = 0.1;
6849
+ function judgeEditSize(src, got, opts) {
6691
6850
  if (!(src.width > 0 && src.height > 0 && got.width > 0 && got.height > 0)) return { action: "keep" };
6692
6851
  if (got.width === src.width && got.height === src.height) return { action: "keep" };
6693
6852
  const want = src.width / src.height;
6694
6853
  const have = got.width / got.height;
6695
6854
  if (Math.abs(have - want) / want > SAME_SHAPE_TOL) return { action: "keep" };
6696
6855
  const scale = Math.max(got.width, got.height) / Math.max(src.width, src.height);
6856
+ const budget = opts?.pixelBudget;
6857
+ if (budget && src.width * src.height > budget) {
6858
+ const gotPx = got.width * got.height;
6859
+ if (gotPx <= src.width * src.height && Math.abs(gotPx - budget) / budget <= BUDGET_TOL)
6860
+ return { action: "accept", scale };
6861
+ }
6697
6862
  if (scale < SHRINK_FLOOR) return { action: "reject", scale };
6698
6863
  return { action: "resize", scale };
6699
6864
  }
@@ -6712,7 +6877,7 @@ function planCrop(source, targetRatio) {
6712
6877
  }
6713
6878
  async function attentionCropOrigin(srcBuf, source, plan) {
6714
6879
  try {
6715
- const { info } = await sharp19(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6880
+ const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6716
6881
  const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
6717
6882
  const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
6718
6883
  const left = Math.round((attnLeft + plan.left) / 2);
@@ -6865,23 +7030,23 @@ function relax(grid, seam, fixedSweeps) {
6865
7030
 
6866
7031
  // src/expand.ts
6867
7032
  async function expandCanvas(source, plan) {
6868
- const bed = await sharp19(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6869
- return sharp19(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
7033
+ const bed = await sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
7034
+ return sharp20(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6870
7035
  }
6871
7036
  async function compositeExpand(engineImage, source, plan) {
6872
- const meta = await sharp19(engineImage).metadata();
7037
+ const meta = await sharp20(engineImage).metadata();
6873
7038
  const want = plan.width / plan.height;
6874
7039
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
6875
7040
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
6876
7041
  const aligned = sameOrientation;
6877
7042
  const exact = meta.width === plan.width && meta.height === plan.height;
6878
- const surround = aligned ? exact ? engineImage : await sharp19(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
7043
+ const surround = aligned ? exact ? engineImage : await sharp20(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
6879
7044
  const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
6880
- const image = await sharp19(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
7045
+ const image = await sharp20(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
6881
7046
  return { image, aligned };
6882
7047
  }
6883
7048
  async function matchMarginsToSeam(surround, source, plan) {
6884
- const src = await sharp19(source).metadata();
7049
+ const src = await sharp20(source).metadata();
6885
7050
  if (!src.width || !src.height) return surround;
6886
7051
  const SW = src.width;
6887
7052
  const SH = src.height;
@@ -6928,8 +7093,8 @@ var MAX_CORRECTION = 60;
6928
7093
  async function reconcile(surround, source, side, axis) {
6929
7094
  const { margin } = side;
6930
7095
  if (margin.width < 1 || margin.height < 1) return surround;
6931
- const marginRaw = await sharp19(surround).extract(margin).removeAlpha().raw().toBuffer();
6932
- const edgeRaw = await sharp19(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
7096
+ const marginRaw = await sharp20(surround).extract(margin).removeAlpha().raw().toBuffer();
7097
+ const edgeRaw = await sharp20(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
6933
7098
  const W = margin.width;
6934
7099
  const H = margin.height;
6935
7100
  const along = axis === "width" ? H : W;
@@ -6983,11 +7148,11 @@ async function reconcile(surround, source, side, axis) {
6983
7148
  }
6984
7149
  }
6985
7150
  }
6986
- const patch2 = await sharp19(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
6987
- return sharp19(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
7151
+ const patch2 = await sharp20(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
7152
+ return sharp20(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
6988
7153
  }
6989
7154
  async function expandCanvasBedOnly(source, plan) {
6990
- return sharp19(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
7155
+ return sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6991
7156
  }
6992
7157
  function medianOf(rgb, channel, from, to) {
6993
7158
  const n = to - from;
@@ -6998,17 +7163,17 @@ function medianOf(rgb, channel, from, to) {
6998
7163
  return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
6999
7164
  }
7000
7165
  async function reframeExpand(engineImage, plan) {
7001
- const meta = await sharp19(engineImage).metadata();
7166
+ const meta = await sharp20(engineImage).metadata();
7002
7167
  if (!(meta.width && meta.height)) return null;
7003
7168
  const want = plan.width / plan.height;
7004
7169
  const got = meta.width / meta.height;
7005
7170
  if (got >= 1 !== want >= 1) return null;
7006
7171
  if (meta.width === plan.width && meta.height === plan.height) return engineImage;
7007
7172
  const straight = Math.abs(got - want) / want <= 0.02;
7008
- return sharp19(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
7173
+ return sharp20(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
7009
7174
  }
7010
7175
  async function seamScore(image, plan, source) {
7011
- const { data, info } = await sharp19(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
7176
+ const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
7012
7177
  const W = info.width;
7013
7178
  const H = info.height;
7014
7179
  const horizontal = plan.axis === "width";
@@ -7041,7 +7206,7 @@ var SEAM_VISIBLE = 2.2;
7041
7206
  var OFFSET = 4;
7042
7207
  var RESIDUAL_VISIBLE = 15;
7043
7208
  async function seamResidual(image, plan, source) {
7044
- const { data, info } = await sharp19(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
7209
+ const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
7045
7210
  const W = info.width;
7046
7211
  const H = info.height;
7047
7212
  const ch = info.channels;
@@ -7076,7 +7241,7 @@ var MAX_SHARE = 0.8;
7076
7241
  async function subjectFraction(src, source, axis) {
7077
7242
  try {
7078
7243
  const window = axis === "width" ? { width: Math.max(8, Math.round(source.width * 0.5)), height: source.height } : { width: source.width, height: Math.max(8, Math.round(source.height * 0.5)) };
7079
- const { info } = await sharp19(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7244
+ const { info } = await sharp20(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7080
7245
  const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
7081
7246
  const span = axis === "width" ? source.width : source.height;
7082
7247
  const extent = axis === "width" ? window.width : window.height;
@@ -7099,14 +7264,14 @@ function placeExpand(plan, source, fraction) {
7099
7264
  }
7100
7265
  var NEUTRAL = { r: 128, g: 128, b: 128 };
7101
7266
  async function conditioningCanvas(source, plan, fill = "edge") {
7102
- const meta = await sharp19(source).metadata();
7267
+ const meta = await sharp20(source).metadata();
7103
7268
  const sw = meta.width ?? 0;
7104
7269
  const sh = meta.height ?? 0;
7105
7270
  if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
7106
7271
  const layers = [];
7107
7272
  if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
7108
7273
  layers.push({ input: source, left: plan.left, top: plan.top });
7109
- const canvas = sharp19({
7274
+ const canvas = sharp20({
7110
7275
  create: {
7111
7276
  width: plan.width,
7112
7277
  height: plan.height,
@@ -7118,7 +7283,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
7118
7283
  }
7119
7284
  async function edgeMargins(source, plan, size) {
7120
7285
  const out = [];
7121
- const strip = async (extract, width, height) => sharp19(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
7286
+ const strip = async (extract, width, height) => sharp20(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
7122
7287
  if (plan.axis === "width") {
7123
7288
  const before = plan.left;
7124
7289
  const after = plan.width - plan.left - size.width;
@@ -7209,12 +7374,12 @@ async function resolveOutpaintRoute(all, shot) {
7209
7374
  return { engine: shot, method: "reframe", crossed: false };
7210
7375
  }
7211
7376
  async function driftDiff(a, b) {
7212
- const metaA = await sharp19(a).metadata();
7213
- const metaB = await sharp19(b).metadata();
7377
+ const metaA = await sharp20(a).metadata();
7378
+ const metaB = await sharp20(b).metadata();
7214
7379
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
7215
7380
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
7216
7381
  const [rawA, rawB] = await Promise.all(
7217
- [a, b].map((buf) => sharp19(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
7382
+ [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
7218
7383
  );
7219
7384
  const out = new PNG({ width, height });
7220
7385
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -7226,11 +7391,11 @@ async function driftDiff(a, b) {
7226
7391
  };
7227
7392
  }
7228
7393
  async function changeMask(a, b, cap2 = 1024) {
7229
- const metaA = await sharp19(a).metadata();
7394
+ const metaA = await sharp20(a).metadata();
7230
7395
  const width = Math.min(metaA.width ?? 1, cap2);
7231
7396
  const height = Math.min(metaA.height ?? 1, cap2);
7232
7397
  const [rawA, rawB] = await Promise.all(
7233
- [a, b].map((buf) => sharp19(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
7398
+ [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
7234
7399
  );
7235
7400
  const out = new PNG({ width, height });
7236
7401
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -7279,8 +7444,8 @@ function dilationFor(longEdge) {
7279
7444
  // src/localEdit.ts
7280
7445
  async function preserveOutsideChange(source, edited) {
7281
7446
  try {
7282
- const srcMeta = await sharp19(source).metadata();
7283
- const outMeta = await sharp19(edited).metadata();
7447
+ const srcMeta = await sharp20(source).metadata();
7448
+ const outMeta = await sharp20(edited).metadata();
7284
7449
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
7285
7450
  return { image: edited, outcome: "error", changed: 0 };
7286
7451
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -7290,15 +7455,15 @@ async function preserveOutsideChange(source, edited) {
7290
7455
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
7291
7456
  const r = dilationFor(Math.max(shape.width, shape.height));
7292
7457
  const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
7293
- const spread = await sharp19(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
7294
- const dilated = await sharp19(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
7295
- const feathered = await sharp19(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
7296
- const grown = await sharp19(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
7297
- const editedRgb = await sharp19(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
7298
- const masked = await sharp19(editedRgb, {
7458
+ const spread = await sharp20(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
7459
+ const dilated = await sharp20(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
7460
+ const feathered = await sharp20(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
7461
+ const grown = await sharp20(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
7462
+ const editedRgb = await sharp20(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
7463
+ const masked = await sharp20(editedRgb, {
7299
7464
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
7300
7465
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
7301
- const image = await sharp19(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
7466
+ const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
7302
7467
  return { image, outcome: "composited", changed: shape.changed };
7303
7468
  } catch {
7304
7469
  return { image: edited, outcome: "error", changed: 0 };
@@ -7336,7 +7501,7 @@ function registerLogoRoutes(app, deps) {
7336
7501
  const v = validateBrand(json);
7337
7502
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
7338
7503
  const row = core.store.updateBrand(brand.id, json);
7339
- const meta = await sharp19(core.images.read(part.hash)).metadata().catch(() => null);
7504
+ const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
7340
7505
  const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
7341
7506
  return { ...row, logoHash: part.hash, logoEdge };
7342
7507
  });
@@ -7463,7 +7628,7 @@ async function vibrantColor(input) {
7463
7628
  let data;
7464
7629
  let channels;
7465
7630
  try {
7466
- const out = await sharp19(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
7631
+ const out = await sharp20(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
7467
7632
  data = out.data;
7468
7633
  channels = out.info.channels;
7469
7634
  } catch {
@@ -7486,7 +7651,7 @@ async function vibrantColor(input) {
7486
7651
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
7487
7652
  if (best.score <= 0) {
7488
7653
  try {
7489
- const { dominant } = await sharp19(input).stats();
7654
+ const { dominant } = await sharp20(input).stats();
7490
7655
  return toHex(dominant.r, dominant.g, dominant.b);
7491
7656
  } catch {
7492
7657
  return null;
@@ -7870,10 +8035,11 @@ function registerAssetBuildRoutes(app, deps) {
7870
8035
  core.ledger.recordCost(engineId, null, result.costUsd);
7871
8036
  const hash = result.images[0];
7872
8037
  if (!hash) return reply.status(500).send({ error: "the engine returned no image" });
8038
+ const trimmed = await trimEdgeBars(core, hash);
7873
8039
  commit(core, brand.id, (json) => {
7874
- json.scenes = brandScenes(json).map((s) => s.id === id ? { ...s, preview: `asset:${hash}` } : s);
8040
+ json.scenes = brandScenes(json).map((s) => s.id === id ? { ...s, preview: `asset:${trimmed}` } : s);
7875
8041
  });
7876
- return { preview: `asset:${hash}`, brand: core.store.getBrand(brand.id) };
8042
+ return { preview: `asset:${trimmed}`, brand: core.store.getBrand(brand.id) };
7877
8043
  });
7878
8044
  }
7879
8045
  async function withDerivedCrops(core, body, base) {
@@ -8104,7 +8270,7 @@ async function buildExportZip(image, baseName, presetIds) {
8104
8270
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
8105
8271
  if (chosen.length === 0) throw new Error("No valid export presets selected");
8106
8272
  for (const p of chosen) {
8107
- const buf = p.width && p.height ? await sharp19(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
8273
+ const buf = p.width && p.height ? await sharp20(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
8108
8274
  zip.file(`${baseName}-${p.id}.png`, buf);
8109
8275
  }
8110
8276
  return zip.generateAsync({ type: "nodebuffer" });
@@ -8265,8 +8431,8 @@ function registerImageRoutes(app, deps) {
8265
8431
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
8266
8432
  const buf = await part.toBuffer();
8267
8433
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
8268
- const fmt = (await sharp19(buf).metadata().catch(() => null))?.format;
8269
- const png = fmt === "svg" ? await toMarkPng(buf) : await sharp19(buf).rotate().png().toBuffer();
8434
+ const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
8435
+ const png = fmt === "svg" ? await toMarkPng(buf) : await sharp20(buf).rotate().png().toBuffer();
8270
8436
  return { hash: core.images.save(png) };
8271
8437
  });
8272
8438
  app.post("/api/diff", async (req, reply) => {
@@ -8301,6 +8467,25 @@ function registerImageRoutes(app, deps) {
8301
8467
 
8302
8468
  // src/release/notes.data.ts
8303
8469
  var RELEASES = [
8470
+ {
8471
+ version: "0.7.0",
8472
+ date: "2026-08-30",
8473
+ title: "Your presenter stays your presenter.",
8474
+ sections: [
8475
+ {
8476
+ heading: "Scenes",
8477
+ body: "A scene lends its world, its light and its treatment, never a face. Generation now conditions on the scene\u2019s own drawn card instead of your raw upload, so the person you selected is the person in the shot. Scenes made before this release render printed treatments best after a one-tap redraw from the scene\u2019s page."
8478
+ },
8479
+ {
8480
+ heading: "Refine",
8481
+ body: "Refining light and mood no longer wears a shot down: a tonal refinement keeps the photograph\u2019s own pixels and changes only the grade, so the tenth adjustment is as sharp as the first. High-resolution refinements state their working size honestly instead of inflating it, and refining an extended shot works again."
8482
+ },
8483
+ {
8484
+ heading: "Presenters",
8485
+ body: "A presenter\u2019s identity rides with three of their views. A reference you attach cannot lend anyone its face while a presenter is selected, and a mood image carried into a refinement is never mistaken for the person in the picture."
8486
+ }
8487
+ ]
8488
+ },
8304
8489
  {
8305
8490
  version: "0.6.13",
8306
8491
  date: "2026-08-30",
@@ -9099,7 +9284,7 @@ function buildServer(opts) {
9099
9284
  // Measured as stored (post-toMarkPng), so the scrape judges the same
9100
9285
  // pixels the compiler will one day attach.
9101
9286
  probeLongEdge: async (buf) => {
9102
- const m = await sharp19(await toMarkPng(buf)).metadata();
9287
+ const m = await sharp20(await toMarkPng(buf)).metadata();
9103
9288
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
9104
9289
  },
9105
9290
  createdWith: `${meta.name}/${meta.version}`
@@ -9188,7 +9373,7 @@ function buildServer(opts) {
9188
9373
  fetchImpl: opts.fetchImpl,
9189
9374
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
9190
9375
  probeLongEdge: async (buf) => {
9191
- const m = await sharp19(await toMarkPng(buf)).metadata();
9376
+ const m = await sharp20(await toMarkPng(buf)).metadata();
9192
9377
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
9193
9378
  },
9194
9379
  createdWith: `${meta.name}/${meta.version}`
@@ -9294,7 +9479,19 @@ function buildServer(opts) {
9294
9479
  registerShowcaseRoutes(app, { templatesRoot });
9295
9480
  app.get("/api/formats", async () => FORMATS);
9296
9481
  function briefInputsOnly(brief) {
9297
- const { inherited, rendered, croppedFrom, resizedFrom, resampledHops, expand, crop: crop2, sourceImage, ...inputs } = brief;
9482
+ const {
9483
+ inherited,
9484
+ rendered,
9485
+ croppedFrom,
9486
+ resizedFrom,
9487
+ resampledHops,
9488
+ steppedDown,
9489
+ gradeComposited,
9490
+ expand,
9491
+ crop: crop2,
9492
+ sourceImage,
9493
+ ...inputs
9494
+ } = brief;
9298
9495
  return inputs;
9299
9496
  }
9300
9497
  async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
@@ -9325,6 +9522,9 @@ function buildServer(opts) {
9325
9522
  );
9326
9523
  const inheritedDirectives = [];
9327
9524
  let inheritedMark = false;
9525
+ let inheritedRef = false;
9526
+ const inheritedProduct = inheritedTokens.some((t) => t.t === "product");
9527
+ const inheritedPerson = inheritedTokens.some((t) => t.t === "character");
9328
9528
  for (const tok of inheritedTokens) {
9329
9529
  if (tok.t === "product") {
9330
9530
  const rec = (brandJson?.products ?? []).find((x) => x?.id === tok.id);
@@ -9343,8 +9543,12 @@ function buildServer(opts) {
9343
9543
  } else if (tok.t === "mark" && !inheritedMark) {
9344
9544
  inheritedMark = true;
9345
9545
  inheritedDirectives.push(markEditDirective());
9546
+ } else if (tok.t === "ref" && !inheritedRef) {
9547
+ inheritedRef = true;
9548
+ inheritedDirectives.push(inheritedRefDirective());
9346
9549
  }
9347
9550
  }
9551
+ if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
9348
9552
  const compiled2 = compileBrief(brief, {
9349
9553
  brand: brandJson,
9350
9554
  images: core.images,
@@ -9354,7 +9558,11 @@ function buildServer(opts) {
9354
9558
  mode: "edit",
9355
9559
  editScope: verdict.scope,
9356
9560
  editRemoval: verdict.removal ?? false,
9357
- inheritedIdentity: inheritedTokens.length > 0,
9561
+ // Kinds, not a count: the identity claim speaks only about the kinds
9562
+ // that actually ride. A mark-only or ref-only inheritance emits no
9563
+ // generic claim - markEditDirective and inheritedRefDirective speak
9564
+ // for themselves.
9565
+ inheritedIdentity: inheritedProduct || inheritedPerson ? { product: inheritedProduct, person: inheritedPerson } : false,
9358
9566
  inheritedDirectives,
9359
9567
  // Only the explicit op drops the dimension promise: an implicit legacy
9360
9568
  // expansion keeps its historical prompt byte for byte.
@@ -9365,7 +9573,7 @@ function buildServer(opts) {
9365
9573
  if (inheritedTokens.length) {
9366
9574
  const identity = compileBrief(
9367
9575
  { tokens: inheritedTokens },
9368
- { brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
9576
+ { brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById, mode: "edit" }
9369
9577
  );
9370
9578
  identityWarnings = identity.warnings;
9371
9579
  const productAngles = /* @__PURE__ */ new Map();
@@ -9520,11 +9728,11 @@ function buildServer(opts) {
9520
9728
  const out = [];
9521
9729
  for (const h of images) {
9522
9730
  const buf = core.images.read(h);
9523
- const meta2 = await sharp19(buf).metadata().catch(() => null);
9731
+ const meta2 = await sharp20(buf).metadata().catch(() => null);
9524
9732
  if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
9525
9733
  const oriented = (meta2.orientation ?? 1) !== 1;
9526
9734
  out.push(
9527
- buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp19(buf).rotate().png().toBuffer())
9735
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
9528
9736
  );
9529
9737
  }
9530
9738
  return out;
@@ -9536,7 +9744,7 @@ function buildServer(opts) {
9536
9744
  const out = [];
9537
9745
  for (const h of images) {
9538
9746
  const buf = core.images.read(h);
9539
- const meta2 = await sharp19(buf).metadata();
9747
+ const meta2 = await sharp20(buf).metadata();
9540
9748
  if (!meta2.width || !meta2.height) {
9541
9749
  out.push(h);
9542
9750
  continue;
@@ -9549,7 +9757,7 @@ function buildServer(opts) {
9549
9757
  }
9550
9758
  const w = got > target ? Math.round(meta2.height * target) : meta2.width;
9551
9759
  const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
9552
- const cropped = await sharp19(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9760
+ const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9553
9761
  app.log.info(
9554
9762
  { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
9555
9763
  "canvas: cropped a drifted frame to the asked ratio"
@@ -9568,7 +9776,7 @@ function buildServer(opts) {
9568
9776
  async function assertAspect(images, expect) {
9569
9777
  const want = expect.width / expect.height;
9570
9778
  for (const h of images) {
9571
- const meta2 = await sharp19(core.images.read(h)).metadata();
9779
+ const meta2 = await sharp20(core.images.read(h)).metadata();
9572
9780
  if (!meta2.width || !meta2.height) continue;
9573
9781
  const got = meta2.width / meta2.height;
9574
9782
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -9600,7 +9808,7 @@ function buildServer(opts) {
9600
9808
  try {
9601
9809
  const sizes = [];
9602
9810
  for (const h of result.images) {
9603
- const meta2 = await sharp19(core.images.read(h)).metadata();
9811
+ const meta2 = await sharp20(core.images.read(h)).metadata();
9604
9812
  if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
9605
9813
  }
9606
9814
  const node = core.store.getNode(nodeId);
@@ -9667,7 +9875,7 @@ function buildServer(opts) {
9667
9875
  if (!srcHash || !core.images.has(String(srcHash)))
9668
9876
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
9669
9877
  const srcBuf = core.images.read(String(srcHash));
9670
- const srcMeta = await sharp19(srcBuf).metadata();
9878
+ const srcMeta = await sharp20(srcBuf).metadata();
9671
9879
  if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
9672
9880
  const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
9673
9881
  if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
@@ -9690,7 +9898,7 @@ function buildServer(opts) {
9690
9898
  crop: window
9691
9899
  });
9692
9900
  const work2 = async () => ({
9693
- images: [core.images.save(await sharp19(srcBuf).extract(window).png().toBuffer())],
9901
+ images: [core.images.save(await sharp20(srcBuf).extract(window).png().toBuffer())],
9694
9902
  costUsd: 0
9695
9903
  });
9696
9904
  void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
@@ -9712,6 +9920,9 @@ function buildServer(opts) {
9712
9920
  let inheritedTokens = [];
9713
9921
  let mergedEdit = null;
9714
9922
  let editScope = "global";
9923
+ let gradeOnlyAsk = false;
9924
+ let budgetSourceHash;
9925
+ let sentSize;
9715
9926
  const extraWarnings = [];
9716
9927
  let expandPlan = null;
9717
9928
  let expandSourceHash = null;
@@ -9726,6 +9937,9 @@ function buildServer(opts) {
9726
9937
  inheritedTokens = edit.inheritedTokens;
9727
9938
  mergedEdit = edit.merged;
9728
9939
  editScope = edit.editScope;
9940
+ gradeOnlyAsk = editScope === "global" && isGradeOnlyInstruction(
9941
+ (brief?.tokens ?? []).filter((t) => t.t === "text").map((t) => t.v).join(" ")
9942
+ );
9729
9943
  extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
9730
9944
  if (!compiled2.prompt.trim() && reshape !== "extend")
9731
9945
  return reply.status(400).send({ error: "the brief is empty" });
@@ -9842,11 +10056,12 @@ function buildServer(opts) {
9842
10056
  const editEdge = engine.capabilities().maxReferenceEdge;
9843
10057
  if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
9844
10058
  const srcBuf = core.images.read(String(srcHash));
9845
- const srcMeta = await sharp19(srcBuf).metadata();
10059
+ const srcMeta = await sharp20(srcBuf).metadata();
9846
10060
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
9847
10061
  const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
9848
10062
  const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
9849
- const reshapeIntended = reshape === "extend" || reshape === void 0 && !!srcMeta.width && !!srcMeta.height && !!compiled2?.width && !!compiled2?.height && wantsImplicitReshape({ width: compiled2.width, height: compiled2.height }, parentNominal, {
10063
+ const briefNamesShape = (brief?.tokens ?? []).some((t) => t?.t === "format");
10064
+ const reshapeIntended = reshape === "extend" || reshape === void 0 && briefNamesShape && !!srcMeta.width && !!srcMeta.height && !!compiled2?.width && !!compiled2?.height && wantsImplicitReshape({ width: compiled2.width, height: compiled2.height }, parentNominal, {
9850
10065
  width: srcMeta.width,
9851
10066
  height: srcMeta.height
9852
10067
  });
@@ -9873,9 +10088,20 @@ function buildServer(opts) {
9873
10088
  }
9874
10089
  expectShape = { width: expandPlan.width, height: expandPlan.height };
9875
10090
  }
10091
+ const editPixelBudget = runEngine.capabilities().editPixelBudget;
10092
+ if (!expandPlan && editPixelBudget && srcMeta.width && srcMeta.height && srcMeta.width * srcMeta.height > editPixelBudget) {
10093
+ sentSize = budgetSize(srcMeta.width, srcMeta.height, editPixelBudget);
10094
+ budgetSourceHash = core.images.save(
10095
+ await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
10096
+ );
10097
+ if (!gradeOnlyAsk)
10098
+ extraWarnings.push(
10099
+ `${runEngine.capabilities().displayName} refines at about ${(editPixelBudget / 1e6).toFixed(1)} megapixels, so this ${srcMeta.width}x${srcMeta.height} frame continues at ${sentSize.width}x${sentSize.height} from here on. The picture is unchanged; the stored size is now the size the engine truly drew.`
10100
+ );
10101
+ }
9876
10102
  const editReq = {
9877
10103
  instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
9878
- sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
10104
+ sourceImage: core.images.pathFor(String(expandSourceHash ?? budgetSourceHash ?? srcHash)),
9879
10105
  brand: ctx,
9880
10106
  ...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
9881
10107
  ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
@@ -9885,7 +10111,7 @@ function buildServer(opts) {
9885
10111
  // engines given no size answered at whatever size they liked, the
9886
10112
  // shrunken answer was stored, and the next refinement inherited it —
9887
10113
  // the chain that walked shots down to thumbnails.
9888
- ...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : {},
10114
+ ...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : sentSize ? sentSize : srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : {},
9889
10115
  // Only an engine that can genuinely paint a margin is told where the
9890
10116
  // picture sits; the rest would ignore it anyway.
9891
10117
  ...expandPlan && canOutpaint2 ? {
@@ -9987,13 +10213,29 @@ function buildServer(opts) {
9987
10213
  const original = editedFrom ? core.images.read(editedFrom) : null;
9988
10214
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
9989
10215
  const enforceEditCanvas = async (images) => {
9990
- const srcMeta = await sharp19(original).metadata();
10216
+ const srcMeta = await sharp20(original).metadata();
9991
10217
  if (!srcMeta.width || !srcMeta.height) return images;
9992
10218
  const out = [];
9993
10219
  for (const h of images) {
9994
- const meta2 = await sharp19(core.images.read(h)).metadata();
10220
+ const meta2 = await sharp20(core.images.read(h)).metadata();
9995
10221
  const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
9996
- const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got);
10222
+ const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
10223
+ pixelBudget: runEngine.capabilities().editPixelBudget
10224
+ });
10225
+ if (verdict.action === "accept") {
10226
+ app.log.info(
10227
+ { nodeId: node.id, got: `${got.width}x${got.height}`, src: `${srcMeta.width}x${srcMeta.height}` },
10228
+ "edit: kept the engine-native answer (pixel-budget step-down)"
10229
+ );
10230
+ try {
10231
+ const fresh = core.store.getNode(node.id);
10232
+ const b = fresh?.brief ?? {};
10233
+ core.store.setBrief(node.id, { ...b, steppedDown: [srcMeta.width, srcMeta.height] });
10234
+ } catch {
10235
+ }
10236
+ out.push(h);
10237
+ continue;
10238
+ }
9997
10239
  if (verdict.action === "reject")
9998
10240
  throw new Error(
9999
10241
  `engine returned ${got.width}x${got.height} for a ${srcMeta.width}x${srcMeta.height} frame: too little of the picture came back to keep at this resolution`
@@ -10005,7 +10247,7 @@ function buildServer(opts) {
10005
10247
  );
10006
10248
  out.push(
10007
10249
  core.images.save(
10008
- await sharp19(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
10250
+ await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
10009
10251
  )
10010
10252
  );
10011
10253
  try {
@@ -10027,7 +10269,7 @@ function buildServer(opts) {
10027
10269
  const out = [];
10028
10270
  for (const h of images) {
10029
10271
  const answer = core.images.read(h);
10030
- const got = await sharp19(answer).metadata();
10272
+ const got = await sharp20(answer).metadata();
10031
10273
  if (got.width !== plan.width || got.height !== plan.height)
10032
10274
  app.log.info(
10033
10275
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
@@ -10053,6 +10295,26 @@ function buildServer(opts) {
10053
10295
  return images;
10054
10296
  } : kind === "edit" && original ? async (images) => {
10055
10297
  let staged = images;
10298
+ if (gradeOnlyAsk && !expandPlan) {
10299
+ const sent = budgetSourceHash ? core.images.read(budgetSourceHash) : original;
10300
+ const out = [];
10301
+ for (const h of staged) {
10302
+ const g = await gradeComposite(original, sent, core.images.read(h));
10303
+ if (g) {
10304
+ app.log.info(
10305
+ { nodeId: node.id, residual: Number(g.residual.toFixed(2)) },
10306
+ "edit: shipped the original pixels wearing the grade"
10307
+ );
10308
+ out.push(core.images.save(g.image));
10309
+ try {
10310
+ const fresh = core.store.getNode(node.id);
10311
+ core.store.setBrief(node.id, { ...fresh?.brief ?? {}, gradeComposited: true });
10312
+ } catch {
10313
+ }
10314
+ } else out.push(h);
10315
+ }
10316
+ staged = out;
10317
+ }
10056
10318
  if (localScope) {
10057
10319
  const out = [];
10058
10320
  for (const h of staged) {
@@ -10328,8 +10590,8 @@ async function verify() {
10328
10590
  const db = new Database2(":memory:");
10329
10591
  db.pragma("user_version");
10330
10592
  db.close();
10331
- const { default: sharp20 } = await import('sharp');
10332
- await sharp20({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
10593
+ const { default: sharp21 } = await import('sharp');
10594
+ await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
10333
10595
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
10334
10596
  } catch (err) {
10335
10597
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.6.13",
3
+ "version": "0.7.0",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",