scenri 0.6.12 → 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.
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 sharp7 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';
@@ -1285,7 +1285,7 @@ function createCatalogStore(db) {
1285
1285
  var REFERENCE_ROLE_DIRECTIVE = {
1286
1286
  product: "the exact product \u2014 preserve its label, shape, colors and design faithfully; do not redesign it",
1287
1287
  character: "the exact person \u2014 match their face, facial structure, skin, hair and build exactly; their clothing, pose and background are capture context, not styling to reproduce",
1288
- brand: "the brand's own mark \u2014 if the direction calls for the mark to appear, reproduce it exactly as drawn, same colours, letterforms and proportions; otherwise take only its colour and treatment, and never its subject, geometry or composition",
1288
+ brand: "the brand's own mark \u2014 if the direction calls for the mark to appear, reproduce it exactly as drawn, same colours, letterforms and proportions, every character down to the smallest secondary lettering, in its original script and reading direction, never translated, transliterated or re-spelled; otherwise take only its colour and treatment, and never its subject, geometry or composition",
1289
1289
  // Only a figure-led scene attaches one of these now, so this says what that
1290
1290
  // case actually needs. It used to read "environment and light only - take no
1291
1291
  // subject or person from it", which handed the model a photograph of a face
@@ -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"
@@ -1303,8 +1309,8 @@ var REFERENCE_ROLE_DIRECTIVE = {
1303
1309
  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
- brand: "the brand's own mark: reproduce it exactly as drawn wherever it appears, never redrawn or re-lettered",
1307
- scene: "a reference for environment and light only",
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",
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 sharp7(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 sharp7(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 sharp7(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
  }
@@ -3216,23 +3231,111 @@ function mergeEditAttachments(own, inherited, cap2) {
3216
3231
  const borrowed = inherited.filter((a) => !seen.has(a.hash)).map((a) => ({ ...a, inherited: true }));
3217
3232
  return allocateAttachments([...own, ...borrowed], cap2);
3218
3233
  }
3234
+ function joinNames(labels) {
3235
+ const uniq = [...new Set(labels)];
3236
+ if (uniq.length <= 1) return uniq[0] ?? "";
3237
+ return `${uniq.slice(0, -1).join(", ")} and ${uniq[uniq.length - 1]}`;
3238
+ }
3239
+ function brandContext(core, brandId) {
3240
+ const brand = core.store.getBrand(brandId);
3241
+ if (!brand) throw new Error("brand not found");
3242
+ const assetPaths = {};
3243
+ const json = brand.json;
3244
+ for (const logo of json.logos ?? []) {
3245
+ const ref = String(logo.file ?? "");
3246
+ if (ref.startsWith("asset:") && core.images.has(ref.slice(6))) assetPaths[ref] = core.images.pathFor(ref.slice(6));
3247
+ }
3248
+ return { brand: brand.json, assetPaths };
3249
+ }
3250
+ var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
3251
+ var assetHash = (ref) => {
3252
+ const s = String(ref ?? "");
3253
+ return s.startsWith("asset:") ? s.slice(6) : null;
3254
+ };
3255
+ var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
3256
+ var LOGO_BACKGROUNDS = ["light", "dark", "any"];
3257
+ var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
3258
+ var COST_PROBE = {
3259
+ prompt: "",
3260
+ brand: { brand: {}, assetPaths: {} },
3261
+ width: 1024,
3262
+ height: 1024,
3263
+ count: 1
3264
+ };
3265
+ var MARK_MAX_EDGE = 2048;
3266
+ var MARK_MIN_EDGE = 1024;
3267
+ var MARK_TINY_EDGE = 256;
3268
+ var MARK_WARN_EDGE = 512;
3269
+ var toMarkPng = async (buf) => {
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();
3272
+ const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
3273
+ if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
3274
+ return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
3275
+ }
3276
+ return out;
3277
+ };
3278
+ var cappedRefs = /* @__PURE__ */ new Map();
3279
+ async function capReferenceEdge(core, path, maxEdge) {
3280
+ const key = `${path}#${maxEdge}`;
3281
+ const hit = cappedRefs.get(key);
3282
+ if (hit) return hit;
3283
+ let out = path;
3284
+ try {
3285
+ const meta = await sharp20(path).metadata();
3286
+ if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
3287
+ const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3288
+ out = core.images.pathFor(core.images.save(buf));
3289
+ }
3290
+ } catch {
3291
+ }
3292
+ cappedRefs.set(key, out);
3293
+ return out;
3294
+ }
3295
+ var readImagePart = async (core, req, normalize2) => {
3296
+ const part = await req.file();
3297
+ if (!part) return { error: "multipart file field required" };
3298
+ const buf = await part.toBuffer();
3299
+ if (buf.length === 0) return { error: "empty file" };
3300
+ try {
3301
+ return { hash: core.images.save(await normalize2(buf)), fields: part.fields ?? {}, filename: part.filename };
3302
+ } catch {
3303
+ return { error: "that file is not an image we can read" };
3304
+ }
3305
+ };
3306
+ var mtimeQS = (path) => existsSync(path) ? `?v=${Math.round(statSync(path).mtimeMs)}` : "";
3307
+ var serveJpeg = (req, reply, path) => {
3308
+ const etag = `"${statSync(path).mtimeMs}"`;
3309
+ reply.header("cache-control", "public, max-age=31536000, immutable").header("etag", etag);
3310
+ if (req.headers["if-none-match"] === etag) return reply.status(304).send();
3311
+ return reply.header("content-type", "image/jpeg").send(readFileSync(path));
3312
+ };
3219
3313
 
3220
3314
  // src/briefDirectives.ts
3221
3315
  function productFidelityDirective(attached) {
3222
3316
  if (attached <= 1) {
3223
3317
  return "The attached product image is the exact product: preserve its label, shape, colors and proportions faithfully, and do not redesign it. It is also the only view of this product that exists. Any face, side or detail not visible in it is unknown \u2014 keep those plain and consistent with the visible materials and color, and do not invent hardware, text, seams, closures, ornament or branding on them. Prefer a composition that shows the product from the view the reference gives.";
3224
3318
  }
3225
- return "The attached product images all show the exact same product from different angles: preserve its label, shape and colors faithfully, do not redesign it, and do not treat the extra angles as additional products. Any face not visible in them is unknown \u2014 keep it plain and consistent with the visible materials, and do not invent detail on it.";
3319
+ return "The attached product images all show the exact product to feature: preserve its label, shape and proportions faithfully, do not redesign it, and never treat an extra image as an additional product. The first product image is the authority for its color, finish and material. Where another image differs in color or finish, it shows the same product in another colorway \u2014 never blend colorways, and render the one the first image shows. Any face not visible in them is unknown \u2014 keep it plain and consistent with the materials the first image shows, and do not invent detail on it. If the direction above explicitly asks for more than one colorway, that explicit request wins.";
3226
3320
  }
3227
3321
  function editPreservationDirective(scope, opts) {
3228
3322
  if (scope === "local") {
3229
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." : "";
3230
- 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;
3231
3325
  }
3232
- 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.";
3233
3336
  }
3234
- function inheritedIdentityDirective() {
3235
- 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.";
3236
3339
  }
3237
3340
  function productFactDirectives(p) {
3238
3341
  const out = [];
@@ -3241,6 +3344,10 @@ function productFactDirectives(p) {
3241
3344
  const materials = p.materials ?? p.material;
3242
3345
  if (materials) out.push(`Its materials and finish: ${materials}.`);
3243
3346
  if (p.primaryColors) out.push(`Its actual colors: ${p.primaryColors}.`);
3347
+ if (Array.isArray(p.colorways) && p.colorways.length)
3348
+ out.push(
3349
+ `It is sold in these colorways: ${p.colorways.join(", ")}. The one in this shot is the colorway the first product image shows.`
3350
+ );
3244
3351
  if (p.dimensions)
3245
3352
  out.push(`Its real-world size is ${p.dimensions} \u2014 keep it at true scale relative to everything else in frame.`);
3246
3353
  return out;
@@ -3257,8 +3364,11 @@ function productEditFidelityDirective(name) {
3257
3364
  function characterEditIdentityDirective(name) {
3258
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.`;
3259
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
+ }
3260
3370
  function markEditDirective() {
3261
- 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.";
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.";
3262
3372
  }
3263
3373
  function personSkinDirective() {
3264
3374
  return "Every person in this photograph has real photographed skin: fine natural texture at pore scale, faint lines and natural asymmetry left intact, true-to-life proportions, never airbrushed, waxy, plastic or synthetic-looking. Light behaves physically on it: a hard flash or a specular key may genuinely shine on skin, but the surface underneath stays living skin in a photograph, never gloss, never a render. Any retouch, clarity or sharpness language in the direction above is about finish and focus, never a licence to smooth skin beyond what a professional photograph holds; if the direction above explicitly asks for a stylised or non-photographic treatment of the person, that explicit request wins.";
@@ -3296,7 +3406,7 @@ function sceneFigureDirectives(opts) {
3296
3406
  // instruction to mutate the one real mark the user deliberately
3297
3407
  // attached. Same shape as pairDirectives' packshot override - name the
3298
3408
  // scope of the earlier rule, carry the exception in the same breath.
3299
- (opts.hasMark ? " The one exception is the attached brand mark: it is this brand's own real mark, deliberately attached, so the fictional-brands rule above does not apply to it. Where the direction asks for that mark to appear, it appears exactly as drawn - never redrawn, re-lettered or fictionalised." : "")
3409
+ (opts.hasMark ? " The one exception is the attached brand mark: it is this brand's own real mark, deliberately attached, so the fictional-brands rule above does not apply to it. Where the direction asks for that mark to appear, it appears exactly as drawn - every character, including the smallest lettering, in its original script - never redrawn, re-lettered, translated or fictionalised." : "")
3300
3410
  );
3301
3411
  }
3302
3412
  return out;
@@ -3357,7 +3467,7 @@ function markLabel(brand, logo) {
3357
3467
 
3358
3468
  // src/brief.ts
3359
3469
  var PRODUCT_REF_MAX = 3;
3360
- var CHARACTER_REF_MAX = 2;
3470
+ var CHARACTER_REF_MAX = 3;
3361
3471
  var SCENE_REF_MAX = 1;
3362
3472
  var FORMATS = [
3363
3473
  { id: "square", label: "Square 1:1", w: 1024, h: 1024 },
@@ -3365,7 +3475,7 @@ var FORMATS = [
3365
3475
  { id: "landscape", label: "Landscape 16:9", w: 1600, h: 900 },
3366
3476
  { id: "portrait", label: "Portrait 4:5", w: 1024, h: 1280 }
3367
3477
  ];
3368
- var assetHash = (ref) => {
3478
+ var assetHash2 = (ref) => {
3369
3479
  const s = String(ref ?? "");
3370
3480
  return s.startsWith("asset:") ? s.slice(6) : null;
3371
3481
  };
@@ -3414,6 +3524,7 @@ function validateBrief(brief) {
3414
3524
  function compileBrief(brief, ctx) {
3415
3525
  const warnings = [];
3416
3526
  const attachments = [];
3527
+ const rawSceneFallback = [];
3417
3528
  const productDirectives = [];
3418
3529
  const personDirectives = [];
3419
3530
  const otherDirectives = [];
@@ -3428,6 +3539,9 @@ function compileBrief(brief, ctx) {
3428
3539
  const append = (s) => {
3429
3540
  sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
3430
3541
  };
3542
+ const attachingMarkHashes = new Set(
3543
+ brief.tokens.filter((t) => t?.t === "mark").map((t) => t.imageHash).filter((h) => (ctx.brand?.logos ?? []).some((l) => assetHash2(l?.file) === h) && ctx.images.has(h))
3544
+ );
3431
3545
  for (const tok of brief.tokens) {
3432
3546
  switch (tok.t) {
3433
3547
  case "text":
@@ -3446,7 +3560,7 @@ function compileBrief(brief, ctx) {
3446
3560
  const phashes = [];
3447
3561
  for (const s of orderedShots) {
3448
3562
  if (phashes.length >= PRODUCT_REF_MAX) break;
3449
- const h = assetHash(s?.file);
3563
+ const h = assetHash2(s?.file);
3450
3564
  if (h && ctx.images.has(h) && !phashes.includes(h)) phashes.push(h);
3451
3565
  }
3452
3566
  if (phashes.length) {
@@ -3472,7 +3586,7 @@ function compileBrief(brief, ctx) {
3472
3586
  }
3473
3587
  hasPerson = true;
3474
3588
  append(c.promptName ?? c.name);
3475
- const chashes = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) => assetHash(s?.file)).filter((h) => !!h && ctx.images.has(h));
3589
+ const chashes = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) => assetHash2(s?.file)).filter((h) => !!h && ctx.images.has(h));
3476
3590
  if (chashes.length) {
3477
3591
  chashes.forEach((chash, i) => {
3478
3592
  attachments.push({ role: "character", id: c.id, label: c.name, hash: chash, essential: i === 0 });
@@ -3500,6 +3614,10 @@ function compileBrief(brief, ctx) {
3500
3614
  break;
3501
3615
  }
3502
3616
  case "ref": {
3617
+ if (attachingMarkHashes.has(tok.imageHash)) {
3618
+ warnings.push("That reference is the same image as your brand mark, so it rides once, as the mark.");
3619
+ break;
3620
+ }
3503
3621
  if (!ctx.images.has(tok.imageHash)) {
3504
3622
  warnings.push("A reference shot is missing and was skipped.");
3505
3623
  break;
@@ -3510,14 +3628,23 @@ function compileBrief(brief, ctx) {
3510
3628
  }
3511
3629
  case "mark": {
3512
3630
  const logos = ctx.brand?.logos ?? [];
3513
- const logo = logos.find((l) => assetHash(l?.file) === tok.imageHash);
3631
+ const logo = logos.find((l) => assetHash2(l?.file) === tok.imageHash);
3514
3632
  if (!logo || !ctx.images.has(tok.imageHash)) {
3515
3633
  warnings.push("A brand mark in this brief is no longer in the kit.");
3516
3634
  break;
3517
3635
  }
3518
3636
  attachments.push({ role: "brand", label: markLabel(ctx.brand, logo), hash: tok.imageHash });
3637
+ try {
3638
+ const head = ctx.images.read(tok.imageHash);
3639
+ const edge = Math.max(head.readUInt32BE(16), head.readUInt32BE(20));
3640
+ if (edge < MARK_WARN_EDGE)
3641
+ warnings.push(
3642
+ `${markLabel(ctx.brand, logo)} is only ${edge}px across, so fine lettering will not survive generation. Export it larger, or as SVG.`
3643
+ );
3644
+ } catch {
3645
+ }
3519
3646
  otherDirectives.push(
3520
- "The attached brand mark is this brand's own mark. If the direction asks for the logo to appear, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions, never redrawn or re-lettered. Otherwise take only its colour and treatment from it."
3647
+ "The attached brand mark is this brand's own mark. If the direction asks for the logo to appear, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions, never redrawn or re-lettered. Every character it carries appears intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled. Otherwise take only its colour and treatment from it."
3521
3648
  );
3522
3649
  break;
3523
3650
  }
@@ -3534,10 +3661,14 @@ function compileBrief(brief, ctx) {
3534
3661
  inlineTemplates.push(t);
3535
3662
  append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
3536
3663
  if (ctx.mode !== "edit" && t.figure) {
3537
- for (const r of (t.refs ?? []).slice(0, SCENE_REF_MAX)) {
3538
- const h = assetHash(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) {
3539
3668
  if (h && ctx.images.has(h)) {
3540
- 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);
3541
3672
  }
3542
3673
  }
3543
3674
  }
@@ -3588,6 +3719,12 @@ function compileBrief(brief, ctx) {
3588
3719
  }
3589
3720
  const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
3590
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
+ }
3591
3728
  const max = ctx.engineCaps.maxReferenceImages;
3592
3729
  const { kept, dropped } = allocateAttachments(attachments, max);
3593
3730
  const guard = scene ? sceneGuardDirectives({
@@ -3621,7 +3758,7 @@ function compileBrief(brief, ctx) {
3621
3758
  const brandLines = brandRuleDirectives(ctx.brand);
3622
3759
  const preservation = ctx.mode === "edit" ? [
3623
3760
  ...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
3624
- ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : [],
3761
+ ...ctx.inheritedIdentity ? [inheritedIdentityDirective(ctx.inheritedIdentity === true ? void 0 : ctx.inheritedIdentity)] : [],
3625
3762
  ...ctx.inheritedDirectives ?? []
3626
3763
  ] : [];
3627
3764
  const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
@@ -3629,6 +3766,7 @@ function compileBrief(brief, ctx) {
3629
3766
  const rec = (ctx.brand?.products ?? []).find((x) => x?.id === a.id);
3630
3767
  return String(rec?.category ?? "").toLowerCase() === "apparel";
3631
3768
  }) ? [garmentDisplayDirective()] : [];
3769
+ const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard()] : [];
3632
3770
  const allDirectives = [
3633
3771
  ...productDirectives,
3634
3772
  ...personDirectives,
@@ -3640,15 +3778,21 @@ function compileBrief(brief, ctx) {
3640
3778
  ...apparelUnworn,
3641
3779
  ...brandLines,
3642
3780
  ...guard,
3781
+ ...refGuard,
3643
3782
  ...preservation
3644
3783
  ];
3645
3784
  if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
3646
3785
  if (dropped.some((d) => d.role !== "scene")) {
3647
- const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))];
3648
- const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
3649
- warnings.push(
3650
- `${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)
3651
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
+ }
3652
3796
  }
3653
3797
  return {
3654
3798
  prompt: prompt.trim(),
@@ -4334,6 +4478,7 @@ async function buildFromUrl(url, opts = {}) {
4334
4478
  if (!palette.primary) warnings.push("No confident palette found. Set colors manually.");
4335
4479
  let logoRef;
4336
4480
  let logoFromOg = false;
4481
+ let logoTiny = false;
4337
4482
  let iconHref = $('link[rel="apple-touch-icon"]').attr("href") || $('link[rel~="icon"]').attr("href");
4338
4483
  if (!iconHref) {
4339
4484
  iconHref = $('meta[property="og:image"]').attr("content");
@@ -4344,7 +4489,18 @@ async function buildFromUrl(url, opts = {}) {
4344
4489
  const iconRes = await fetchImpl(new URL(iconHref, origin).toString(), { redirect: "follow" });
4345
4490
  if (iconRes.ok) {
4346
4491
  const buf = Buffer.from(await iconRes.arrayBuffer());
4347
- if (buf.length > 0) logoRef = await opts.saveAsset(buf, "logo");
4492
+ if (buf.length > 0) {
4493
+ logoRef = await opts.saveAsset(buf, "logo");
4494
+ if (!logoFromOg && opts.probeLongEdge) {
4495
+ const edge = await opts.probeLongEdge(buf).catch(() => null);
4496
+ if (edge !== null && edge < 256) {
4497
+ logoTiny = true;
4498
+ warnings.push(
4499
+ `The site icon is favicon-sized (${edge}px), so it was saved as an alternate mark, not the logo. Upload your real logo in Settings.`
4500
+ );
4501
+ }
4502
+ }
4503
+ }
4348
4504
  }
4349
4505
  } catch {
4350
4506
  warnings.push("Logo download failed.");
@@ -4373,7 +4529,7 @@ async function buildFromUrl(url, opts = {}) {
4373
4529
  ...palette.neutrals.length ? { neutrals: palette.neutrals.map((hex) => ({ hex })) } : {}
4374
4530
  }
4375
4531
  } : {},
4376
- ...logoRef ? { logos: [{ role: logoFromOg ? "alternate" : "primary", file: logoRef }] } : {}
4532
+ ...logoRef ? { logos: [{ role: logoFromOg || logoTiny ? "alternate" : "primary", file: logoRef }] } : {}
4377
4533
  };
4378
4534
  return { brand, warnings };
4379
4535
  }
@@ -5645,8 +5801,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
5645
5801
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
5646
5802
  return;
5647
5803
  }
5648
- const png = await sharp7(buf).rotate().png().toBuffer();
5649
- const meta = await sharp7(png).metadata();
5804
+ const png = await sharp20(buf).rotate().png().toBuffer();
5805
+ const meta = await sharp20(png).metadata();
5650
5806
  const hash = core.images.save(png);
5651
5807
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
5652
5808
  width: meta.width,
@@ -5703,24 +5859,54 @@ function resolveLibraryProduct(core, brandId, productId) {
5703
5859
  const library = core.catalog.listLibraryProducts(brandId, brand.json);
5704
5860
  return library.find((p) => p.id === productId) ?? null;
5705
5861
  }
5862
+ function stripHtml(html) {
5863
+ return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\s+/g, " ").trim();
5864
+ }
5865
+ function firstSentence(text, max) {
5866
+ if (text.length <= max) return text;
5867
+ const stop = text.slice(0, max + 1).search(/[.!?](\s|$)/);
5868
+ if (stop > 40) return text.slice(0, stop + 1);
5869
+ const cut = text.lastIndexOf(" ", max);
5870
+ return `${text.slice(0, cut > 40 ? cut : max).trimEnd()}\u2026`;
5871
+ }
5872
+ function colorwaysOf(variants) {
5873
+ const seen = [];
5874
+ for (const v of variants ?? []) {
5875
+ const key = Object.keys(v.options ?? {}).find((k) => /colou?r/i.test(k));
5876
+ const val = key ? String(v.options[key]).trim() : "";
5877
+ if (val && !seen.includes(val)) seen.push(val);
5878
+ if (seen.length >= 12) break;
5879
+ }
5880
+ return seen.length >= 2 ? seen : [];
5881
+ }
5706
5882
  function brandJsonWithCatalogProducts(core, brandId) {
5707
5883
  const brand = core.store.getBrand(brandId);
5708
5884
  if (!brand) return null;
5709
5885
  const json = { ...brand.json };
5710
5886
  const library = core.catalog.listLibraryProducts(brandId, brand.json);
5711
- json.products = library.map((p) => ({
5712
- id: p.id,
5713
- name: p.name,
5714
- shots: p.shots,
5715
- notes: p.url ?? void 0,
5716
- // Forwarded so the compiler can state real-world material and size. These
5717
- // were dropped here, which is part of why a watch could render plate-sized:
5718
- // nothing downstream ever knew how big the object actually is.
5719
- ...p.category ? { category: p.category } : {},
5720
- ...p.variant ? { variant: p.variant } : {},
5721
- ...p.material ? { material: p.material } : {},
5722
- ...p.dimensions ? { dimensions: p.dimensions } : {}
5723
- }));
5887
+ json.products = library.map((p) => {
5888
+ const colorways = colorwaysOf(p.variants);
5889
+ return {
5890
+ id: p.id,
5891
+ name: p.name,
5892
+ shots: p.shots,
5893
+ notes: p.url ?? void 0,
5894
+ // Forwarded so the compiler can state real-world material and size. These
5895
+ // were dropped here, which is part of why a watch could render plate-sized:
5896
+ // nothing downstream ever knew how big the object actually is.
5897
+ ...p.category ? { category: p.category } : {},
5898
+ ...p.variant ? { variant: p.variant } : {},
5899
+ ...p.material ? { material: p.material } : {},
5900
+ ...p.dimensions ? { dimensions: p.dimensions } : {},
5901
+ // The store's own words, the way the 0.6.9 fix gave demo products
5902
+ // theirs: the description is what anchors scale when dimensions are
5903
+ // absent, and imported products shipped with neither.
5904
+ ...p.descriptionHtml ? { description: firstSentence(stripHtml(String(p.descriptionHtml)), 300) } : {},
5905
+ // The declared colorways, so the compiler can say a colour difference
5906
+ // between references is a colorway rather than lighting.
5907
+ ...colorways.length ? { colorways } : {}
5908
+ };
5909
+ });
5724
5910
  return json;
5725
5911
  }
5726
5912
  function runningImportCount() {
@@ -6065,7 +6251,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
6065
6251
  return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
6066
6252
  }
6067
6253
  async function edgeBarGeometry(buf) {
6068
- const { data, info } = await sharp7(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
6254
+ const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
6069
6255
  const W = info.width;
6070
6256
  const H = info.height;
6071
6257
  const scan = (len, cross, at) => {
@@ -6119,7 +6305,7 @@ async function trimEdgeBars(core, hash) {
6119
6305
  const width = g.right - g.left + 1;
6120
6306
  const height = g.bottom - g.top + 1;
6121
6307
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
6122
- const png = await sharp7(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();
6123
6309
  return core.images.save(png);
6124
6310
  } catch {
6125
6311
  return hash;
@@ -6161,12 +6347,12 @@ async function avatarCrop(core, hash) {
6161
6347
  );
6162
6348
  }
6163
6349
  async function figureBox(buf) {
6164
- const meta = await sharp7(buf).metadata();
6350
+ const meta = await sharp20(buf).metadata();
6165
6351
  const W = meta.width ?? 0;
6166
6352
  const H = meta.height ?? 0;
6167
6353
  if (!W || !H) return null;
6168
6354
  for (const threshold of FIGURE_TRIM_THRESHOLDS) {
6169
- const { info } = await sharp7(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6355
+ const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6170
6356
  const left = Math.abs(info.trimOffsetLeft ?? 0);
6171
6357
  const top = Math.abs(info.trimOffsetTop ?? 0);
6172
6358
  const width = info.width ?? 0;
@@ -6199,13 +6385,13 @@ async function smartCover(core, hash, box) {
6199
6385
  if (!hash || !core.images.has(hash)) return void 0;
6200
6386
  try {
6201
6387
  const buf = core.images.read(hash);
6202
- const meta = await sharp7(buf).metadata();
6388
+ const meta = await sharp20(buf).metadata();
6203
6389
  const w = meta.width ?? 0;
6204
6390
  const h = meta.height ?? 0;
6205
6391
  if (!w || !h) return void 0;
6206
6392
  const raw = box(w, h);
6207
6393
  const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
6208
- const png = await sharp7(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();
6209
6395
  return core.images.save(png);
6210
6396
  } catch {
6211
6397
  return void 0;
@@ -6214,11 +6400,11 @@ async function smartCover(core, hash, box) {
6214
6400
  async function crop(core, hash, region, cap2) {
6215
6401
  if (!hash || !core.images.has(hash)) return void 0;
6216
6402
  try {
6217
- const meta = await sharp7(core.images.read(hash)).metadata();
6403
+ const meta = await sharp20(core.images.read(hash)).metadata();
6218
6404
  const w = meta.width ?? 0;
6219
6405
  const h = meta.height ?? 0;
6220
6406
  if (!w || !h) return void 0;
6221
- let pipeline = sharp7(core.images.read(hash)).extract(region(w, h));
6407
+ let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
6222
6408
  if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
6223
6409
  const png = await pipeline.png().toBuffer();
6224
6410
  return core.images.save(png);
@@ -6309,7 +6495,7 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
6309
6495
  });
6310
6496
  }
6311
6497
  function scenePreviewPrompt(scene) {
6312
- 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.";
6313
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;
6314
6500
  }
6315
6501
  async function draw(deps, req) {
@@ -6402,6 +6588,7 @@ function registerAccessGuard(app, opts = {}) {
6402
6588
  var MAX_HOPS = 64;
6403
6589
  var IDENTITY_KINDS = /* @__PURE__ */ new Set(["product", "character", "mark", "ref"]);
6404
6590
  var identityOf = (list2) => Array.isArray(list2) ? list2.filter((t) => IDENTITY_KINDS.has(t?.t)) : [];
6591
+ var identityTokenKey = (t) => t?.t === "product" ? `p:${t.id}` : JSON.stringify(t);
6405
6592
  function inheritedIdentityTokens(parentId, getNode) {
6406
6593
  let id = parentId;
6407
6594
  const visited = /* @__PURE__ */ new Set();
@@ -6417,7 +6604,7 @@ function inheritedIdentityTokens(parentId, getNode) {
6417
6604
  const seen = /* @__PURE__ */ new Set();
6418
6605
  const tokens = [];
6419
6606
  for (const t of [...own, ...carried]) {
6420
- const key = JSON.stringify(t);
6607
+ const key = identityTokenKey(t);
6421
6608
  if (seen.has(key)) continue;
6422
6609
  seen.add(key);
6423
6610
  tokens.push(t);
@@ -6479,6 +6666,119 @@ function scopeOfInstruction(text) {
6479
6666
  if (!matched.length) return { scope: "global", matched: ["no local cue"] };
6480
6667
  return { scope: "local", matched, removal: REMOVAL_VERB.test(s) };
6481
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
+ }
6482
6782
 
6483
6783
  // src/expandRules.ts
6484
6784
  var round8 = (n) => Math.max(8, Math.round(n / 8) * 8);
@@ -6545,13 +6845,20 @@ Avoid: new objects, products, people, text or watermarks; do not recompose, rece
6545
6845
  // src/editSizeRules.ts
6546
6846
  var SAME_SHAPE_TOL = 0.01;
6547
6847
  var SHRINK_FLOOR = 0.8;
6548
- function judgeEditSize(src, got) {
6848
+ var BUDGET_TOL = 0.1;
6849
+ function judgeEditSize(src, got, opts) {
6549
6850
  if (!(src.width > 0 && src.height > 0 && got.width > 0 && got.height > 0)) return { action: "keep" };
6550
6851
  if (got.width === src.width && got.height === src.height) return { action: "keep" };
6551
6852
  const want = src.width / src.height;
6552
6853
  const have = got.width / got.height;
6553
6854
  if (Math.abs(have - want) / want > SAME_SHAPE_TOL) return { action: "keep" };
6554
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
+ }
6555
6862
  if (scale < SHRINK_FLOOR) return { action: "reject", scale };
6556
6863
  return { action: "resize", scale };
6557
6864
  }
@@ -6570,7 +6877,7 @@ function planCrop(source, targetRatio) {
6570
6877
  }
6571
6878
  async function attentionCropOrigin(srcBuf, source, plan) {
6572
6879
  try {
6573
- const { info } = await sharp7(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 });
6574
6881
  const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
6575
6882
  const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
6576
6883
  const left = Math.round((attnLeft + plan.left) / 2);
@@ -6723,23 +7030,23 @@ function relax(grid, seam, fixedSweeps) {
6723
7030
 
6724
7031
  // src/expand.ts
6725
7032
  async function expandCanvas(source, plan) {
6726
- 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();
6727
- return sharp7(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();
6728
7035
  }
6729
7036
  async function compositeExpand(engineImage, source, plan) {
6730
- const meta = await sharp7(engineImage).metadata();
7037
+ const meta = await sharp20(engineImage).metadata();
6731
7038
  const want = plan.width / plan.height;
6732
7039
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
6733
7040
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
6734
7041
  const aligned = sameOrientation;
6735
7042
  const exact = meta.width === plan.width && meta.height === plan.height;
6736
- const surround = aligned ? exact ? engineImage : await sharp7(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);
6737
7044
  const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
6738
- const image = await sharp7(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();
6739
7046
  return { image, aligned };
6740
7047
  }
6741
7048
  async function matchMarginsToSeam(surround, source, plan) {
6742
- const src = await sharp7(source).metadata();
7049
+ const src = await sharp20(source).metadata();
6743
7050
  if (!src.width || !src.height) return surround;
6744
7051
  const SW = src.width;
6745
7052
  const SH = src.height;
@@ -6786,8 +7093,8 @@ var MAX_CORRECTION = 60;
6786
7093
  async function reconcile(surround, source, side, axis) {
6787
7094
  const { margin } = side;
6788
7095
  if (margin.width < 1 || margin.height < 1) return surround;
6789
- const marginRaw = await sharp7(surround).extract(margin).removeAlpha().raw().toBuffer();
6790
- const edgeRaw = await sharp7(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();
6791
7098
  const W = margin.width;
6792
7099
  const H = margin.height;
6793
7100
  const along = axis === "width" ? H : W;
@@ -6841,11 +7148,11 @@ async function reconcile(surround, source, side, axis) {
6841
7148
  }
6842
7149
  }
6843
7150
  }
6844
- const patch2 = await sharp7(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
6845
- return sharp7(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();
6846
7153
  }
6847
7154
  async function expandCanvasBedOnly(source, plan) {
6848
- 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();
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();
6849
7156
  }
6850
7157
  function medianOf(rgb, channel, from, to) {
6851
7158
  const n = to - from;
@@ -6856,17 +7163,17 @@ function medianOf(rgb, channel, from, to) {
6856
7163
  return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
6857
7164
  }
6858
7165
  async function reframeExpand(engineImage, plan) {
6859
- const meta = await sharp7(engineImage).metadata();
7166
+ const meta = await sharp20(engineImage).metadata();
6860
7167
  if (!(meta.width && meta.height)) return null;
6861
7168
  const want = plan.width / plan.height;
6862
7169
  const got = meta.width / meta.height;
6863
7170
  if (got >= 1 !== want >= 1) return null;
6864
7171
  if (meta.width === plan.width && meta.height === plan.height) return engineImage;
6865
7172
  const straight = Math.abs(got - want) / want <= 0.02;
6866
- return sharp7(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();
6867
7174
  }
6868
7175
  async function seamScore(image, plan, source) {
6869
- const { data, info } = await sharp7(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
7176
+ const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
6870
7177
  const W = info.width;
6871
7178
  const H = info.height;
6872
7179
  const horizontal = plan.axis === "width";
@@ -6899,7 +7206,7 @@ var SEAM_VISIBLE = 2.2;
6899
7206
  var OFFSET = 4;
6900
7207
  var RESIDUAL_VISIBLE = 15;
6901
7208
  async function seamResidual(image, plan, source) {
6902
- const { data, info } = await sharp7(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
7209
+ const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
6903
7210
  const W = info.width;
6904
7211
  const H = info.height;
6905
7212
  const ch = info.channels;
@@ -6934,7 +7241,7 @@ var MAX_SHARE = 0.8;
6934
7241
  async function subjectFraction(src, source, axis) {
6935
7242
  try {
6936
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)) };
6937
- const { info } = await sharp7(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 });
6938
7245
  const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
6939
7246
  const span = axis === "width" ? source.width : source.height;
6940
7247
  const extent = axis === "width" ? window.width : window.height;
@@ -6957,14 +7264,14 @@ function placeExpand(plan, source, fraction) {
6957
7264
  }
6958
7265
  var NEUTRAL = { r: 128, g: 128, b: 128 };
6959
7266
  async function conditioningCanvas(source, plan, fill = "edge") {
6960
- const meta = await sharp7(source).metadata();
7267
+ const meta = await sharp20(source).metadata();
6961
7268
  const sw = meta.width ?? 0;
6962
7269
  const sh = meta.height ?? 0;
6963
7270
  if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
6964
7271
  const layers = [];
6965
7272
  if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
6966
7273
  layers.push({ input: source, left: plan.left, top: plan.top });
6967
- const canvas = sharp7({
7274
+ const canvas = sharp20({
6968
7275
  create: {
6969
7276
  width: plan.width,
6970
7277
  height: plan.height,
@@ -6976,7 +7283,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
6976
7283
  }
6977
7284
  async function edgeMargins(source, plan, size) {
6978
7285
  const out = [];
6979
- const strip = async (extract, width, height) => sharp7(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();
6980
7287
  if (plan.axis === "width") {
6981
7288
  const before = plan.left;
6982
7289
  const after = plan.width - plan.left - size.width;
@@ -7067,12 +7374,12 @@ async function resolveOutpaintRoute(all, shot) {
7067
7374
  return { engine: shot, method: "reframe", crossed: false };
7068
7375
  }
7069
7376
  async function driftDiff(a, b) {
7070
- const metaA = await sharp7(a).metadata();
7071
- const metaB = await sharp7(b).metadata();
7377
+ const metaA = await sharp20(a).metadata();
7378
+ const metaB = await sharp20(b).metadata();
7072
7379
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
7073
7380
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
7074
7381
  const [rawA, rawB] = await Promise.all(
7075
- [a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
7382
+ [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
7076
7383
  );
7077
7384
  const out = new PNG({ width, height });
7078
7385
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -7084,11 +7391,11 @@ async function driftDiff(a, b) {
7084
7391
  };
7085
7392
  }
7086
7393
  async function changeMask(a, b, cap2 = 1024) {
7087
- const metaA = await sharp7(a).metadata();
7394
+ const metaA = await sharp20(a).metadata();
7088
7395
  const width = Math.min(metaA.width ?? 1, cap2);
7089
7396
  const height = Math.min(metaA.height ?? 1, cap2);
7090
7397
  const [rawA, rawB] = await Promise.all(
7091
- [a, b].map((buf) => sharp7(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
7398
+ [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
7092
7399
  );
7093
7400
  const out = new PNG({ width, height });
7094
7401
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -7137,8 +7444,8 @@ function dilationFor(longEdge) {
7137
7444
  // src/localEdit.ts
7138
7445
  async function preserveOutsideChange(source, edited) {
7139
7446
  try {
7140
- const srcMeta = await sharp7(source).metadata();
7141
- const outMeta = await sharp7(edited).metadata();
7447
+ const srcMeta = await sharp20(source).metadata();
7448
+ const outMeta = await sharp20(edited).metadata();
7142
7449
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
7143
7450
  return { image: edited, outcome: "error", changed: 0 };
7144
7451
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -7148,94 +7455,24 @@ async function preserveOutsideChange(source, edited) {
7148
7455
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
7149
7456
  const r = dilationFor(Math.max(shape.width, shape.height));
7150
7457
  const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
7151
- const spread = await sharp7(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
7152
- const dilated = await sharp7(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
7153
- const feathered = await sharp7(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
7154
- const grown = await sharp7(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
7155
- const editedRgb = await sharp7(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
7156
- const masked = await sharp7(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, {
7157
7464
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
7158
7465
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
7159
- const image = await sharp7(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
7466
+ const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
7160
7467
  return { image, outcome: "composited", changed: shape.changed };
7161
7468
  } catch {
7162
7469
  return { image: edited, outcome: "error", changed: 0 };
7163
7470
  }
7164
7471
  }
7165
- function joinNames(labels) {
7166
- const uniq = [...new Set(labels)];
7167
- if (uniq.length <= 1) return uniq[0] ?? "";
7168
- return `${uniq.slice(0, -1).join(", ")} and ${uniq[uniq.length - 1]}`;
7169
- }
7170
- function brandContext(core, brandId) {
7171
- const brand = core.store.getBrand(brandId);
7172
- if (!brand) throw new Error("brand not found");
7173
- const assetPaths = {};
7174
- const json = brand.json;
7175
- for (const logo of json.logos ?? []) {
7176
- const ref = String(logo.file ?? "");
7177
- if (ref.startsWith("asset:") && core.images.has(ref.slice(6))) assetPaths[ref] = core.images.pathFor(ref.slice(6));
7178
- }
7179
- return { brand: brand.json, assetPaths };
7180
- }
7181
- var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
7182
- var assetHash2 = (ref) => {
7183
- const s = String(ref ?? "");
7184
- return s.startsWith("asset:") ? s.slice(6) : null;
7185
- };
7186
- var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
7187
- var LOGO_BACKGROUNDS = ["light", "dark", "any"];
7188
- var toPng = (buf) => sharp7(buf).rotate().png().toBuffer();
7189
- var COST_PROBE = {
7190
- prompt: "",
7191
- brand: { brand: {}, assetPaths: {} },
7192
- width: 1024,
7193
- height: 1024,
7194
- count: 1
7195
- };
7196
- var MARK_MAX_EDGE = 2048;
7197
- var toMarkPng = (buf) => sharp7(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
7198
- var cappedRefs = /* @__PURE__ */ new Map();
7199
- async function capReferenceEdge(core, path, maxEdge) {
7200
- const key = `${path}#${maxEdge}`;
7201
- const hit = cappedRefs.get(key);
7202
- if (hit) return hit;
7203
- let out = path;
7204
- try {
7205
- const meta = await sharp7(path).metadata();
7206
- if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
7207
- const buf = await sharp7(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
7208
- out = core.images.pathFor(core.images.save(buf));
7209
- }
7210
- } catch {
7211
- }
7212
- cappedRefs.set(key, out);
7213
- return out;
7214
- }
7215
- var readImagePart = async (core, req, normalize2) => {
7216
- const part = await req.file();
7217
- if (!part) return { error: "multipart file field required" };
7218
- const buf = await part.toBuffer();
7219
- if (buf.length === 0) return { error: "empty file" };
7220
- try {
7221
- return { hash: core.images.save(await normalize2(buf)), fields: part.fields ?? {}, filename: part.filename };
7222
- } catch {
7223
- return { error: "that file is not an image we can read" };
7224
- }
7225
- };
7226
- var mtimeQS = (path) => existsSync(path) ? `?v=${Math.round(statSync(path).mtimeMs)}` : "";
7227
- var serveJpeg = (req, reply, path) => {
7228
- const etag = `"${statSync(path).mtimeMs}"`;
7229
- reply.header("cache-control", "public, max-age=31536000, immutable").header("etag", etag);
7230
- if (req.headers["if-none-match"] === etag) return reply.status(304).send();
7231
- return reply.header("content-type", "image/jpeg").send(readFileSync(path));
7232
- };
7233
-
7234
- // src/routes/logos.ts
7235
7472
  function registerLogoRoutes(app, deps) {
7236
7473
  const { core } = deps;
7237
7474
  const readLogos = (json) => Array.isArray(json.logos) ? json.logos : [];
7238
- const findLogo = (json, hash) => readLogos(json).findIndex((l) => assetHash2(l?.file) === hash);
7475
+ const findLogo = (json, hash) => readLogos(json).findIndex((l) => assetHash(l?.file) === hash);
7239
7476
  const enumField = (raw, allowed, fallback) => {
7240
7477
  const v = raw === void 0 || raw === null ? "" : String(raw);
7241
7478
  if (!v) return fallback ?? "";
@@ -7248,7 +7485,7 @@ function registerLogoRoutes(app, deps) {
7248
7485
  const logos = [...readLogos(json)];
7249
7486
  const part = await readImagePart(core, req, toMarkPng);
7250
7487
  if ("error" in part) return reply.status(400).send({ error: part.error });
7251
- const existing = logos.findIndex((l) => assetHash2(l?.file) === part.hash);
7488
+ const existing = logos.findIndex((l) => assetHash(l?.file) === part.hash);
7252
7489
  const role = enumField(
7253
7490
  part.fields?.role?.value,
7254
7491
  LOGO_ROLES,
@@ -7263,7 +7500,10 @@ function registerLogoRoutes(app, deps) {
7263
7500
  json.logos = role === "primary" ? logos.map((l) => l !== entry && l?.role === "primary" ? { ...l, role: "alternate" } : l) : logos;
7264
7501
  const v = validateBrand(json);
7265
7502
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
7266
- return core.store.updateBrand(brand.id, json);
7503
+ const row = core.store.updateBrand(brand.id, json);
7504
+ const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
7505
+ const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
7506
+ return { ...row, logoHash: part.hash, logoEdge };
7267
7507
  });
7268
7508
  app.patch("/api/brands/:id/logos/:hash", async (req, reply) => {
7269
7509
  const brand = core.store.getBrand(req.params.id);
@@ -7388,7 +7628,7 @@ async function vibrantColor(input) {
7388
7628
  let data;
7389
7629
  let channels;
7390
7630
  try {
7391
- const out = await sharp7(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 });
7392
7632
  data = out.data;
7393
7633
  channels = out.info.channels;
7394
7634
  } catch {
@@ -7411,7 +7651,7 @@ async function vibrantColor(input) {
7411
7651
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
7412
7652
  if (best.score <= 0) {
7413
7653
  try {
7414
- const { dominant } = await sharp7(input).stats();
7654
+ const { dominant } = await sharp20(input).stats();
7415
7655
  return toHex(dominant.r, dominant.g, dominant.b);
7416
7656
  } catch {
7417
7657
  return null;
@@ -7795,10 +8035,11 @@ function registerAssetBuildRoutes(app, deps) {
7795
8035
  core.ledger.recordCost(engineId, null, result.costUsd);
7796
8036
  const hash = result.images[0];
7797
8037
  if (!hash) return reply.status(500).send({ error: "the engine returned no image" });
8038
+ const trimmed = await trimEdgeBars(core, hash);
7798
8039
  commit(core, brand.id, (json) => {
7799
- 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);
7800
8041
  });
7801
- return { preview: `asset:${hash}`, brand: core.store.getBrand(brand.id) };
8042
+ return { preview: `asset:${trimmed}`, brand: core.store.getBrand(brand.id) };
7802
8043
  });
7803
8044
  }
7804
8045
  async function withDerivedCrops(core, body, base) {
@@ -8029,7 +8270,7 @@ async function buildExportZip(image, baseName, presetIds) {
8029
8270
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
8030
8271
  if (chosen.length === 0) throw new Error("No valid export presets selected");
8031
8272
  for (const p of chosen) {
8032
- const buf = p.width && p.height ? await sharp7(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;
8033
8274
  zip.file(`${baseName}-${p.id}.png`, buf);
8034
8275
  }
8035
8276
  return zip.generateAsync({ type: "nodebuffer" });
@@ -8190,7 +8431,8 @@ function registerImageRoutes(app, deps) {
8190
8431
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
8191
8432
  const buf = await part.toBuffer();
8192
8433
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
8193
- const png = await sharp7(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();
8194
8436
  return { hash: core.images.save(png) };
8195
8437
  });
8196
8438
  app.post("/api/diff", async (req, reply) => {
@@ -8225,6 +8467,43 @@ function registerImageRoutes(app, deps) {
8225
8467
 
8226
8468
  // src/release/notes.data.ts
8227
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
+ },
8489
+ {
8490
+ version: "0.6.13",
8491
+ date: "2026-08-30",
8492
+ sections: [
8493
+ {
8494
+ heading: "Products",
8495
+ body: "A product sold in several colours renders the colour its first photo shows, instead of blending them. Imported products now carry their own description and colour list into every shot, so scale and colourway stay true."
8496
+ },
8497
+ {
8498
+ heading: "Brand",
8499
+ body: "Attach your own logo straight from the Brand tab when composing; it joins the kit and rides the shot. Small logo files are raised to a workable resolution, the smallest lettering and its script are now part of the contract, and a logo too small to survive says so before you spend."
8500
+ },
8501
+ {
8502
+ heading: "Create",
8503
+ body: "The refine strip counts each product once and names the scene a thread keeps. A retried shot carries your recipe, never leftovers from the previous run."
8504
+ }
8505
+ ]
8506
+ },
8228
8507
  {
8229
8508
  version: "0.6.12",
8230
8509
  date: "2026-08-29",
@@ -9002,6 +9281,12 @@ function buildServer(opts) {
9002
9281
  // about its own format — broken in the marks grid, and mislabelled to any
9003
9282
  // engine it is later attached to.
9004
9283
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
9284
+ // Measured as stored (post-toMarkPng), so the scrape judges the same
9285
+ // pixels the compiler will one day attach.
9286
+ probeLongEdge: async (buf) => {
9287
+ const m = await sharp20(await toMarkPng(buf)).metadata();
9288
+ return Math.max(m.width ?? 0, m.height ?? 0) || null;
9289
+ },
9005
9290
  createdWith: `${meta.name}/${meta.version}`
9006
9291
  });
9007
9292
  const row = core.store.createBrand(brand);
@@ -9087,6 +9372,10 @@ function buildServer(opts) {
9087
9372
  const { brand: scraped, warnings } = await buildFromUrl(url, {
9088
9373
  fetchImpl: opts.fetchImpl,
9089
9374
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
9375
+ probeLongEdge: async (buf) => {
9376
+ const m = await sharp20(await toMarkPng(buf)).metadata();
9377
+ return Math.max(m.width ?? 0, m.height ?? 0) || null;
9378
+ },
9090
9379
  createdWith: `${meta.name}/${meta.version}`
9091
9380
  });
9092
9381
  const { brand: merged, suggestions } = mergeScrape(brand.json, scraped);
@@ -9189,13 +9478,29 @@ function buildServer(opts) {
9189
9478
  registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById });
9190
9479
  registerShowcaseRoutes(app, { templatesRoot });
9191
9480
  app.get("/api/formats", async () => FORMATS);
9481
+ function briefInputsOnly(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;
9495
+ return inputs;
9496
+ }
9192
9497
  async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
9193
9498
  const inherited = inheritedIdentityTokens(parentId, (id) => core.store.getNode(id));
9194
9499
  const borrowed = inherited.tokens;
9195
9500
  const already = new Set(
9196
- brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map((t) => JSON.stringify(t))
9501
+ brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark" || t.t === "ref").map(identityTokenKey)
9197
9502
  );
9198
- const inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
9503
+ const inheritedTokens = borrowed.filter((t) => !already.has(identityTokenKey(t)));
9199
9504
  const combined = [...brief.tokens, ...inheritedTokens];
9200
9505
  const brandJson = await brandJsonWithResolvedPresenters(
9201
9506
  core,
@@ -9217,6 +9522,9 @@ function buildServer(opts) {
9217
9522
  );
9218
9523
  const inheritedDirectives = [];
9219
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");
9220
9528
  for (const tok of inheritedTokens) {
9221
9529
  if (tok.t === "product") {
9222
9530
  const rec = (brandJson?.products ?? []).find((x) => x?.id === tok.id);
@@ -9235,8 +9543,12 @@ function buildServer(opts) {
9235
9543
  } else if (tok.t === "mark" && !inheritedMark) {
9236
9544
  inheritedMark = true;
9237
9545
  inheritedDirectives.push(markEditDirective());
9546
+ } else if (tok.t === "ref" && !inheritedRef) {
9547
+ inheritedRef = true;
9548
+ inheritedDirectives.push(inheritedRefDirective());
9238
9549
  }
9239
9550
  }
9551
+ if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
9240
9552
  const compiled2 = compileBrief(brief, {
9241
9553
  brand: brandJson,
9242
9554
  images: core.images,
@@ -9246,7 +9558,11 @@ function buildServer(opts) {
9246
9558
  mode: "edit",
9247
9559
  editScope: verdict.scope,
9248
9560
  editRemoval: verdict.removal ?? false,
9249
- 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,
9250
9566
  inheritedDirectives,
9251
9567
  // Only the explicit op drops the dimension promise: an implicit legacy
9252
9568
  // expansion keeps its historical prompt byte for byte.
@@ -9257,7 +9573,7 @@ function buildServer(opts) {
9257
9573
  if (inheritedTokens.length) {
9258
9574
  const identity = compileBrief(
9259
9575
  { tokens: inheritedTokens },
9260
- { brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
9576
+ { brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById, mode: "edit" }
9261
9577
  );
9262
9578
  identityWarnings = identity.warnings;
9263
9579
  const productAngles = /* @__PURE__ */ new Map();
@@ -9412,11 +9728,11 @@ function buildServer(opts) {
9412
9728
  const out = [];
9413
9729
  for (const h of images) {
9414
9730
  const buf = core.images.read(h);
9415
- const meta2 = await sharp7(buf).metadata().catch(() => null);
9731
+ const meta2 = await sharp20(buf).metadata().catch(() => null);
9416
9732
  if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
9417
9733
  const oriented = (meta2.orientation ?? 1) !== 1;
9418
9734
  out.push(
9419
- buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp7(buf).rotate().png().toBuffer())
9735
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
9420
9736
  );
9421
9737
  }
9422
9738
  return out;
@@ -9428,7 +9744,7 @@ function buildServer(opts) {
9428
9744
  const out = [];
9429
9745
  for (const h of images) {
9430
9746
  const buf = core.images.read(h);
9431
- const meta2 = await sharp7(buf).metadata();
9747
+ const meta2 = await sharp20(buf).metadata();
9432
9748
  if (!meta2.width || !meta2.height) {
9433
9749
  out.push(h);
9434
9750
  continue;
@@ -9441,7 +9757,7 @@ function buildServer(opts) {
9441
9757
  }
9442
9758
  const w = got > target ? Math.round(meta2.height * target) : meta2.width;
9443
9759
  const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
9444
- const cropped = await sharp7(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();
9445
9761
  app.log.info(
9446
9762
  { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
9447
9763
  "canvas: cropped a drifted frame to the asked ratio"
@@ -9460,7 +9776,7 @@ function buildServer(opts) {
9460
9776
  async function assertAspect(images, expect) {
9461
9777
  const want = expect.width / expect.height;
9462
9778
  for (const h of images) {
9463
- const meta2 = await sharp7(core.images.read(h)).metadata();
9779
+ const meta2 = await sharp20(core.images.read(h)).metadata();
9464
9780
  if (!meta2.width || !meta2.height) continue;
9465
9781
  const got = meta2.width / meta2.height;
9466
9782
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -9492,7 +9808,7 @@ function buildServer(opts) {
9492
9808
  try {
9493
9809
  const sizes = [];
9494
9810
  for (const h of result.images) {
9495
- const meta2 = await sharp7(core.images.read(h)).metadata();
9811
+ const meta2 = await sharp20(core.images.read(h)).metadata();
9496
9812
  if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
9497
9813
  }
9498
9814
  const node = core.store.getNode(nodeId);
@@ -9559,7 +9875,7 @@ function buildServer(opts) {
9559
9875
  if (!srcHash || !core.images.has(String(srcHash)))
9560
9876
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
9561
9877
  const srcBuf = core.images.read(String(srcHash));
9562
- const srcMeta = await sharp7(srcBuf).metadata();
9878
+ const srcMeta = await sharp20(srcBuf).metadata();
9563
9879
  if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
9564
9880
  const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
9565
9881
  if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
@@ -9576,13 +9892,13 @@ function buildServer(opts) {
9576
9892
  engineId: "local"
9577
9893
  });
9578
9894
  core.store.setBrief(node2.id, {
9579
- ...brief ?? {},
9895
+ ...briefInputsOnly(brief ?? {}),
9580
9896
  sourceImage: String(srcHash),
9581
9897
  reshape: "crop",
9582
9898
  crop: window
9583
9899
  });
9584
9900
  const work2 = async () => ({
9585
- images: [core.images.save(await sharp7(srcBuf).extract(window).png().toBuffer())],
9901
+ images: [core.images.save(await sharp20(srcBuf).extract(window).png().toBuffer())],
9586
9902
  costUsd: 0
9587
9903
  });
9588
9904
  void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
@@ -9604,6 +9920,9 @@ function buildServer(opts) {
9604
9920
  let inheritedTokens = [];
9605
9921
  let mergedEdit = null;
9606
9922
  let editScope = "global";
9923
+ let gradeOnlyAsk = false;
9924
+ let budgetSourceHash;
9925
+ let sentSize;
9607
9926
  const extraWarnings = [];
9608
9927
  let expandPlan = null;
9609
9928
  let expandSourceHash = null;
@@ -9618,6 +9937,9 @@ function buildServer(opts) {
9618
9937
  inheritedTokens = edit.inheritedTokens;
9619
9938
  mergedEdit = edit.merged;
9620
9939
  editScope = edit.editScope;
9940
+ gradeOnlyAsk = editScope === "global" && isGradeOnlyInstruction(
9941
+ (brief?.tokens ?? []).filter((t) => t.t === "text").map((t) => t.v).join(" ")
9942
+ );
9621
9943
  extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
9622
9944
  if (!compiled2.prompt.trim() && reshape !== "extend")
9623
9945
  return reply.status(400).send({ error: "the brief is empty" });
@@ -9734,11 +10056,12 @@ function buildServer(opts) {
9734
10056
  const editEdge = engine.capabilities().maxReferenceEdge;
9735
10057
  if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
9736
10058
  const srcBuf = core.images.read(String(srcHash));
9737
- const srcMeta = await sharp7(srcBuf).metadata();
10059
+ const srcMeta = await sharp20(srcBuf).metadata();
9738
10060
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
9739
10061
  const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
9740
10062
  const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
9741
- 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, {
9742
10065
  width: srcMeta.width,
9743
10066
  height: srcMeta.height
9744
10067
  });
@@ -9765,9 +10088,20 @@ function buildServer(opts) {
9765
10088
  }
9766
10089
  expectShape = { width: expandPlan.width, height: expandPlan.height };
9767
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
+ }
9768
10102
  const editReq = {
9769
10103
  instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
9770
- sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
10104
+ sourceImage: core.images.pathFor(String(expandSourceHash ?? budgetSourceHash ?? srcHash)),
9771
10105
  brand: ctx,
9772
10106
  ...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
9773
10107
  ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
@@ -9777,7 +10111,7 @@ function buildServer(opts) {
9777
10111
  // engines given no size answered at whatever size they liked, the
9778
10112
  // shrunken answer was stored, and the next refinement inherited it —
9779
10113
  // the chain that walked shots down to thumbnails.
9780
- ...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 } : {},
9781
10115
  // Only an engine that can genuinely paint a margin is told where the
9782
10116
  // picture sits; the rest would ignore it anyway.
9783
10117
  ...expandPlan && canOutpaint2 ? {
@@ -9854,7 +10188,7 @@ function buildServer(opts) {
9854
10188
  });
9855
10189
  if (brief)
9856
10190
  core.store.setBrief(node.id, {
9857
- ...brief,
10191
+ ...briefInputsOnly(brief),
9858
10192
  ...editedFrom ? { sourceImage: editedFrom } : {},
9859
10193
  ...kind === "edit" && reshape ? { reshape } : {},
9860
10194
  // How the margin was actually made, and by whom. An extend may be
@@ -9879,13 +10213,29 @@ function buildServer(opts) {
9879
10213
  const original = editedFrom ? core.images.read(editedFrom) : null;
9880
10214
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
9881
10215
  const enforceEditCanvas = async (images) => {
9882
- const srcMeta = await sharp7(original).metadata();
10216
+ const srcMeta = await sharp20(original).metadata();
9883
10217
  if (!srcMeta.width || !srcMeta.height) return images;
9884
10218
  const out = [];
9885
10219
  for (const h of images) {
9886
- const meta2 = await sharp7(core.images.read(h)).metadata();
10220
+ const meta2 = await sharp20(core.images.read(h)).metadata();
9887
10221
  const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
9888
- 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
+ }
9889
10239
  if (verdict.action === "reject")
9890
10240
  throw new Error(
9891
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`
@@ -9897,7 +10247,7 @@ function buildServer(opts) {
9897
10247
  );
9898
10248
  out.push(
9899
10249
  core.images.save(
9900
- await sharp7(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()
9901
10251
  )
9902
10252
  );
9903
10253
  try {
@@ -9919,7 +10269,7 @@ function buildServer(opts) {
9919
10269
  const out = [];
9920
10270
  for (const h of images) {
9921
10271
  const answer = core.images.read(h);
9922
- const got = await sharp7(answer).metadata();
10272
+ const got = await sharp20(answer).metadata();
9923
10273
  if (got.width !== plan.width || got.height !== plan.height)
9924
10274
  app.log.info(
9925
10275
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
@@ -9945,6 +10295,26 @@ function buildServer(opts) {
9945
10295
  return images;
9946
10296
  } : kind === "edit" && original ? async (images) => {
9947
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
+ }
9948
10318
  if (localScope) {
9949
10319
  const out = [];
9950
10320
  for (const h of staged) {
@@ -10220,8 +10590,8 @@ async function verify() {
10220
10590
  const db = new Database2(":memory:");
10221
10591
  db.pragma("user_version");
10222
10592
  db.close();
10223
- const { default: sharp19 } = await import('sharp');
10224
- await sharp19({ 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();
10225
10595
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
10226
10596
  } catch (err) {
10227
10597
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));