scenri 0.8.2 → 0.8.3

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
@@ -8,9 +8,9 @@ import Database from 'better-sqlite3';
8
8
  import { randomBytes, createHash, randomUUID, timingSafeEqual } from 'crypto';
9
9
  import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, writeFileSync, createReadStream, rmSync, readdirSync, statSync, renameSync } from 'fs';
10
10
  import { fileURLToPath } from 'url';
11
- import { readFile, copyFile, stat, access, readdir, mkdtemp, rm, rename, unlink, writeFile } from 'fs/promises';
11
+ import { readFile, copyFile, stat, readdir, mkdtemp, rm, access, rename, unlink, writeFile } from 'fs/promises';
12
12
  import { spawn } from 'child_process';
13
- import sharp21 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';
@@ -690,6 +690,7 @@ var headOf = (prompt) => Array.from(String(prompt ?? "")).slice(0, PROMPT_HEAD_C
690
690
  var CHILD_COUNT_SQL = "(CASE WHEN n.kind = 'root' THEN 0 ELSE (SELECT count(*) FROM nodes c WHERE c.parent_id = n.id AND c.archived = 0) END)";
691
691
  var LINEAGE_SIBLINGS_RADIUS = 25;
692
692
  var LINEAGE_CHILDREN_MAX = 60;
693
+ var LINEAGE_HISTORY_MAX = 60;
693
694
  var FEED_COLS = `n.id, n.project_id, n.parent_id, n.kind, substr(n.prompt, 1, ${PROMPT_HEAD_CHARS}) AS prompt_head,
694
695
  n.engine_id, n.status, n.images, n.cost_usd, n.duration_ms, n.kept, n.error, n.created_at, n.brief, n.archived,
695
696
  n.batch_id, n.batch_index, ${CHILD_COUNT_SQL} AS child_count`;
@@ -1137,7 +1138,7 @@ function createStore(db) {
1137
1138
  lineageOf(id) {
1138
1139
  const node = this.getFeedNode(id);
1139
1140
  if (!node) return null;
1140
- if (node.kind === "root") return { ancestors: [], siblings: [], children: [] };
1141
+ if (node.kind === "root") return { ancestors: [], siblings: [], children: [], history: [] };
1141
1142
  const ancestors = [];
1142
1143
  let cur = node.parentId ? this.getFeedNode(node.parentId) : null;
1143
1144
  for (let hops = 0; cur && cur.kind !== "root" && hops < 64; hops++) {
@@ -1155,7 +1156,18 @@ function createStore(db) {
1155
1156
  ORDER BY n.created_at, n.id LIMIT ? OFFSET ?`
1156
1157
  ).all(node.parentId, take, skip).map(rowToFeedNode);
1157
1158
  const children = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE n.parent_id = ? ORDER BY n.created_at, n.id LIMIT ?`).all(node.id, LINEAGE_CHILDREN_MAX).map(rowToFeedNode);
1158
- return { ancestors, siblings, children };
1159
+ const rootShot = ancestors[0] ?? node;
1160
+ const history = db.prepare(
1161
+ `WITH RECURSIVE d(id) AS (SELECT @root UNION ALL SELECT c.id FROM nodes c JOIN d ON c.parent_id = d.id)
1162
+ SELECT ${FEED_COLS} FROM nodes n
1163
+ WHERE n.id IN (SELECT id FROM d) AND (n.archived = 0 OR n.id = @self)
1164
+ ORDER BY n.created_at, n.id LIMIT @limit`
1165
+ ).all({ root: rootShot.id, self: node.id, limit: LINEAGE_HISTORY_MAX }).map(rowToFeedNode);
1166
+ if (!history.some((n) => n.id === node.id)) {
1167
+ history.pop();
1168
+ history.push(node);
1169
+ }
1170
+ return { ancestors, siblings, children, history };
1159
1171
  },
1160
1172
  /** The newest finished shots, newest first, for the rail and the attach panel. */
1161
1173
  recentShots(projectId, limit = 48) {
@@ -3508,7 +3520,7 @@ function createDemoEngine(saveImage, opts = {}) {
3508
3520
  <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>
3509
3521
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
3510
3522
  </svg>`;
3511
- return sharp21(Buffer.from(svg)).png().toBuffer();
3523
+ return sharp20(Buffer.from(svg)).png().toBuffer();
3512
3524
  }
3513
3525
  return {
3514
3526
  capabilities() {
@@ -3699,7 +3711,7 @@ var resolvedRefs = /* @__PURE__ */ new Map();
3699
3711
  async function refHash(core, path) {
3700
3712
  const hit = resolvedRefs.get(path);
3701
3713
  if (hit && core.images.has(hit)) return hit;
3702
- const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
3714
+ const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
3703
3715
  resolvedRefs.set(path, hash);
3704
3716
  return hash;
3705
3717
  }
@@ -3803,7 +3815,7 @@ var resolvedRefs2 = /* @__PURE__ */ new Map();
3803
3815
  async function refHash2(core, path) {
3804
3816
  const hit = resolvedRefs2.get(path);
3805
3817
  if (hit && core.images.has(hit)) return hit;
3806
- const hash = core.images.save(await sharp21(readFileSync(path)).png().toBuffer());
3818
+ const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
3807
3819
  resolvedRefs2.set(path, hash);
3808
3820
  return hash;
3809
3821
  }
@@ -3940,6 +3952,109 @@ function mergeEditAttachments(own, inherited, cap2) {
3940
3952
  const borrowed = inherited.filter((a) => !seen.has(a.hash)).map((a) => ({ ...a, inherited: true }));
3941
3953
  return allocateAttachments([...own, ...borrowed], cap2);
3942
3954
  }
3955
+ var THUMB_WIDTHS = [640, 320, 160];
3956
+ var WARM_WIDTHS = [640, 160];
3957
+ var isThumbWidth = (w) => THUMB_WIDTHS.includes(w);
3958
+ var THUMB_WIDTH_LIST = THUMB_WIDTHS.join(", ");
3959
+ var QUALITY = { 640: 82, 320: 80, 160: 75 };
3960
+ var FILE_KEY = /^[a-z0-9-]{1,120}$/;
3961
+ function createThumbStore(core, opts = {}) {
3962
+ const dir = join(core.home, "thumbs");
3963
+ let enabled = true;
3964
+ try {
3965
+ mkdirSync(dir, { recursive: true, mode: 448 });
3966
+ } catch {
3967
+ enabled = false;
3968
+ }
3969
+ const pathFor = (key, w) => join(dir, `${key}-w${w}.webp`);
3970
+ const inflight = /* @__PURE__ */ new Map();
3971
+ const failed = /* @__PURE__ */ new Set();
3972
+ const concurrency = Math.max(1, opts.concurrency ?? 2);
3973
+ let active = 0;
3974
+ const waiting = [];
3975
+ const acquire = () => new Promise((resolve) => {
3976
+ if (active < concurrency) {
3977
+ active++;
3978
+ resolve();
3979
+ } else waiting.push(resolve);
3980
+ });
3981
+ const release = () => {
3982
+ const next = waiting.shift();
3983
+ if (next) next();
3984
+ else active--;
3985
+ };
3986
+ async function make(key, source, w) {
3987
+ const final = pathFor(key, w);
3988
+ const tmp = `${final}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
3989
+ await acquire();
3990
+ try {
3991
+ await sharp20(source).resize({ width: w, withoutEnlargement: true }).webp({ quality: QUALITY[w], effort: 4 }).toFile(tmp);
3992
+ await rename(tmp, final);
3993
+ return final;
3994
+ } catch {
3995
+ await unlink(tmp).catch(() => {
3996
+ });
3997
+ failed.add(`${key}-w${w}`);
3998
+ return null;
3999
+ } finally {
4000
+ release();
4001
+ }
4002
+ }
4003
+ async function ensureKey(key, source, w) {
4004
+ if (!enabled) return null;
4005
+ const memo = `${key}-w${w}`;
4006
+ if (failed.has(memo)) return null;
4007
+ const final = pathFor(key, w);
4008
+ try {
4009
+ await access(final);
4010
+ return final;
4011
+ } catch {
4012
+ }
4013
+ let job = inflight.get(memo);
4014
+ if (!job) {
4015
+ job = make(key, source, w).finally(() => inflight.delete(memo));
4016
+ inflight.set(memo, job);
4017
+ }
4018
+ return job;
4019
+ }
4020
+ return {
4021
+ dir,
4022
+ async ensure(hash, w) {
4023
+ if (!/^[a-f0-9]{32}$/.test(hash)) return null;
4024
+ return ensureKey(hash, core.images.pathFor(hash), w);
4025
+ },
4026
+ async ensureFile(key, sourcePath, w) {
4027
+ if (!FILE_KEY.test(key)) return null;
4028
+ return ensureKey(`f-${key}`, sourcePath, w);
4029
+ },
4030
+ warm(hash) {
4031
+ for (const w of WARM_WIDTHS) void this.ensure(hash, w);
4032
+ },
4033
+ async settle() {
4034
+ await Promise.allSettled([...inflight.values()]);
4035
+ },
4036
+ clear() {
4037
+ failed.clear();
4038
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
4039
+ try {
4040
+ mkdirSync(dir, { recursive: true, mode: 448 });
4041
+ } catch {
4042
+ enabled = false;
4043
+ }
4044
+ },
4045
+ stream: (path) => createReadStream(path)
4046
+ };
4047
+ }
4048
+ async function fileSize(path) {
4049
+ try {
4050
+ const s = await stat(path);
4051
+ return s.isFile() ? s.size : null;
4052
+ } catch {
4053
+ return null;
4054
+ }
4055
+ }
4056
+
4057
+ // src/routes/shared.ts
3943
4058
  function joinNames(labels) {
3944
4059
  const uniq = [...new Set(labels)];
3945
4060
  if (uniq.length <= 1) return uniq[0] ?? "";
@@ -3963,7 +4078,7 @@ var assetHash = (ref) => {
3963
4078
  };
3964
4079
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
3965
4080
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
3966
- var toPng = (buf) => sharp21(buf).rotate().png().toBuffer();
4081
+ var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
3967
4082
  var COST_PROBE = {
3968
4083
  prompt: "",
3969
4084
  brand: { brand: {}, assetPaths: {} },
@@ -3976,11 +4091,11 @@ var MARK_MIN_EDGE = 1024;
3976
4091
  var MARK_TINY_EDGE = 256;
3977
4092
  var MARK_WARN_EDGE = 512;
3978
4093
  var toMarkPng = async (buf) => {
3979
- const out = await sharp21(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3980
- const meta = await sharp21(out).metadata();
4094
+ const out = await sharp20(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
4095
+ const meta = await sharp20(out).metadata();
3981
4096
  const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
3982
4097
  if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
3983
- return sharp21(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
4098
+ return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
3984
4099
  }
3985
4100
  return out;
3986
4101
  };
@@ -3991,9 +4106,9 @@ async function capReferenceEdge(core, path, maxEdge) {
3991
4106
  if (hit) return hit;
3992
4107
  let out = path;
3993
4108
  try {
3994
- const meta = await sharp21(path).metadata();
4109
+ const meta = await sharp20(path).metadata();
3995
4110
  if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
3996
- const buf = await sharp21(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
4111
+ const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
3997
4112
  out = core.images.pathFor(core.images.save(buf));
3998
4113
  }
3999
4114
  } catch {
@@ -4020,6 +4135,25 @@ var serveJpeg = (req, reply, path) => {
4020
4135
  if (req.headers["if-none-match"] === etag) return reply.status(304).send();
4021
4136
  return reply.header("content-type", "image/jpeg").send(readFileSync(path));
4022
4137
  };
4138
+ var fileKey = (prefix, id, path) => `${prefix}-${id}-${Math.round(statSync(path).mtimeMs)}`;
4139
+ var serveJpegSized = async (req, reply, path, thumbs, key) => {
4140
+ const raw = req.query?.w;
4141
+ if (raw === void 0 || raw === "") return serveJpeg(req, reply, path);
4142
+ const w = Number(raw);
4143
+ if (!isThumbWidth(w)) return reply.status(400).send({ error: `w must be one of ${THUMB_WIDTH_LIST}` });
4144
+ const immutable = "public, max-age=31536000, immutable";
4145
+ const etag = `"${key}-w${w}"`;
4146
+ if (req.headers["if-none-match"] === etag) return reply.status(304).header("cache-control", immutable).send();
4147
+ const made = await thumbs.ensureFile(key, path, w);
4148
+ const size = made ? await fileSize(made) : null;
4149
+ if (!made || size === null) {
4150
+ const back = new URL(req.url, "http://scenri.local");
4151
+ back.searchParams.delete("w");
4152
+ return reply.header("cache-control", "no-store").redirect(`${back.pathname}${back.search}`, 307);
4153
+ }
4154
+ reply.header("content-type", "image/webp").header("cache-control", immutable).header("etag", etag).header("content-length", String(size));
4155
+ return reply.send(thumbs.stream(made));
4156
+ };
4023
4157
 
4024
4158
  // src/briefDirectives.ts
4025
4159
  function productFidelityDirective(attached) {
@@ -4100,6 +4234,12 @@ function shotSpecifiesCamera(text) {
4100
4234
  function namesAreNotLetteringDirective() {
4101
4235
  return "The names in this brief identify what to show and are never text to render: no caption, label, signage, engraving or lettering spells a product name or a person's name, in any language or script, anywhere in the picture. Printing that is part of a product's own packaging stays exactly as photographed, and nothing else spells a name unless the direction above explicitly asks for it to be written.";
4102
4236
  }
4237
+ var PERSON_SCENE_FIGURE = "the one person this set is shot around";
4238
+ function shotAsksForAPerson(text) {
4239
+ return /\b(?:person|people|man|men|woman|women|models?|figures?|someone|somebody|anyone|hands?(?!-)|arms?(?!-)|portrait|girls?|boys?|guys?|lady|ladies|couple|family|child|children|kids?|baby|crowd|presenter|character|athlete|dancer|customer|shopper|wearer|wearing|holding|holds)\b/i.test(
4240
+ text
4241
+ );
4242
+ }
4103
4243
  function sceneFigureDirectives(opts) {
4104
4244
  const figure = opts.figure.trim().replace(/[.\s]+$/, "");
4105
4245
  if (!figure) return [];
@@ -4113,15 +4253,23 @@ function sceneFigureDirectives(opts) {
4113
4253
  out.push(
4114
4254
  `This world is built around one figure: ${figure}. The attached presenter is that figure. Any person the scene direction describes IS the presenter and never a second person, and their identity comes from their own attached photograph alone, never from anything the scene direction says about a body.`
4115
4255
  );
4256
+ } else if (opts.asked) {
4257
+ out.push(
4258
+ `This world is built around one figure: ${figure}. The brief asks for a person and nobody is attached, so someone fills that role in the frame, and they are nobody in particular: an anonymous person invented for this photograph only, with no recognisable identity to preserve and nothing about them carried anywhere else.`
4259
+ );
4116
4260
  } else {
4117
4261
  out.push(
4118
- `This world is built around one figure: ${figure}. Someone fills that role in the frame, and they are nobody in particular: an anonymous person invented for this photograph only, with no recognisable identity to preserve and nothing about them carried anywhere else. Show them unless the direction above asks for no people.`
4262
+ `This world is built around one figure: ${figure}. Nobody is attached to take that role, so the role stays empty and nobody is in this image. The frame holds the set, the light and any treatment this world applies.`
4119
4263
  );
4120
4264
  }
4121
4265
  if (treatment) {
4122
4266
  const who = opts.hasPerson ? "The face and body underneath are still exactly theirs - same structure, same proportions, same build - and any earlier instruction that their features must survive unchanged is a rule about who they are, which this does not alter. " : "";
4123
4267
  out.push(
4124
- `The art direction of this world is what has been done to that figure: ${treatment}. Render it as a real physical treatment, following the shape of the face and body it sits on rather than floating in the frame. Spread it across the whole form the way the reference does, reaching every part it covers there - brow, forehead, nose, both cheeks, jaw - instead of massing it in one area and leaving the rest untouched. Reaching wide is not the same as covering more: keep the number of pieces and the bare surface between them exactly as the description says, so a sparse treatment stays sparse while still touching every part of the form. Each piece sits on the plane beneath it, curving and catching light with the surface it is stuck to. ${who}The figure is bodily present and in shot: where the treatment covers or hides them, that is the photograph working as intended and never a reason to leave them out, crop them away, or reduce them to a shadow. If no person appears in this shot, the treatment does not go with them: it is what this world looks like, so it applies to whatever the frame does hold - the product, the surfaces, the set - as real pieces resting on those things. Applied on top, never redesigning them: the product keeps the exact form, colour, material and its own printed label that its reference shows, with the treatment sitting over it. Where the treatment carries printing, render it as genuinely designed print: real letterforms, readable words, numerals, illustration and colour, at the quality of commercial label artwork. Invent the companies - every name, logotype and piece of packaging artwork must be plausible but fictional, resembling no existing brand. That includes near-misses: do not borrow, extend or re-spell a name that appears in any attached reference, and use ordinary words for the produce itself rather than any company that sells it.` + // The fictional-brands rule and an attached brand mark are in direct
4268
+ `The art direction of this world is what has been done to that figure: ${treatment}. Render it as a real physical treatment, following the shape of the face and body it sits on rather than floating in the frame. Spread it across the whole form the way the reference does, reaching every part it covers there - brow, forehead, nose, both cheeks, jaw - instead of massing it in one area and leaving the rest untouched. Reaching wide is not the same as covering more: keep the number of pieces and the bare surface between them exactly as the description says, so a sparse treatment stays sparse while still touching every part of the form. Each piece sits on the plane beneath it, curving and catching light with the surface it is stuck to. ${who}` + (opts.hasPerson ? "The figure is bodily present and in shot: where the treatment covers or hides them, that is the photograph working as intended and never a reason to leave them out, crop them away, or reduce them to a shadow. " : "") + // The treatment is the art direction, not a property of the person. Ask
4269
+ // for this world with no people in it and the stickers should still be
4270
+ // there, on whatever the frame does hold - that IS the scene. Suppressing
4271
+ // them left a plain product on a plinth with nothing of the scene in it.
4272
+ "If no person appears in this shot, the treatment does not go with them: it is what this world looks like, so it applies to whatever the frame does hold - the product, the surfaces, the set - as real pieces resting on those things. Applied on top, never redesigning them: the product keeps the exact form, colour, material and its own printed label that its reference shows, with the treatment sitting over it. Where the treatment carries printing, render it as genuinely designed print: real letterforms, readable words, numerals, illustration and colour, at the quality of commercial label artwork. Invent the companies - every name, logotype and piece of packaging artwork must be plausible but fictional, resembling no existing brand. That includes near-misses: do not borrow, extend or re-spell a name that appears in any attached reference, and use ordinary words for the produce itself rather than any company that sells it." + // The fictional-brands rule and an attached brand mark are in direct
4125
4273
  // conflict without this: "resembling no existing brand" reads as an
4126
4274
  // instruction to mutate the one real mark the user deliberately
4127
4275
  // attached. Same shape as pairDirectives' packshot override - name the
@@ -4161,6 +4309,12 @@ function sceneGuardDirectives(opts) {
4161
4309
  );
4162
4310
  }
4163
4311
  }
4312
+ const emptyRole = (opts.emptyRole ?? "").trim().replace(/[.\s]+$/, "");
4313
+ if (emptyRole) {
4314
+ out.push(
4315
+ `Disregard any person, figure, hand, face or silhouette described in the scene direction or the camera note above: that is the role this world is built around (${emptyRole}), nobody is attached to take it, and it stays empty. Nobody is in this image: no person, no hands, no reflection or shadow of anyone. The set, the light and any treatment this world applies fill the frame on their own.`
4316
+ );
4317
+ }
4164
4318
  return out;
4165
4319
  }
4166
4320
  function brandRuleDirectives(brand) {
@@ -4188,7 +4342,6 @@ function markLabel(brand, logo) {
4188
4342
  // src/brief.ts
4189
4343
  var PRODUCT_REF_MAX = 3;
4190
4344
  var CHARACTER_REF_MAX = 3;
4191
- var SCENE_REF_MAX = 1;
4192
4345
  var FORMATS = [
4193
4346
  { id: "square", label: "Square 1:1", w: 1024, h: 1024 },
4194
4347
  { id: "story", label: "Story 9:16", w: 1080, h: 1920 },
@@ -4247,7 +4400,6 @@ function compileBrief(brief, ctx) {
4247
4400
  const warnings = [];
4248
4401
  const attachments = [];
4249
4402
  const unattachable = [];
4250
- const rawSceneFallback = [];
4251
4403
  const productDirectives = [];
4252
4404
  const personDirectives = [];
4253
4405
  const otherDirectives = [];
@@ -4260,6 +4412,7 @@ function compileBrief(brief, ctx) {
4260
4412
  let hasPerson = false;
4261
4413
  let people = 0;
4262
4414
  let sentence = "";
4415
+ let userWords = "";
4263
4416
  const append = (s) => {
4264
4417
  sentence += (sentence && !sentence.endsWith(" ") ? " " : "") + s;
4265
4418
  };
@@ -4270,6 +4423,7 @@ function compileBrief(brief, ctx) {
4270
4423
  switch (tok.t) {
4271
4424
  case "text":
4272
4425
  append(tok.v);
4426
+ userWords += ` ${tok.v}`;
4273
4427
  break;
4274
4428
  case "product": {
4275
4429
  const p = products.find((x) => x.id === tok.id);
@@ -4426,14 +4580,8 @@ function compileBrief(brief, ctx) {
4426
4580
  append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
4427
4581
  if (ctx.mode !== "edit" && t.figure) {
4428
4582
  const plate = assetHash2(t.preview);
4429
- const hasPlate = !!plate && ctx.images.has(plate);
4430
- const candidates = hasPlate ? [plate] : (t.refs ?? []).slice(0, SCENE_REF_MAX).map((r) => assetHash2(r?.file));
4431
- for (const h of candidates) {
4432
- if (h && ctx.images.has(h)) {
4433
- const a = { role: "scene", id: t.id, label: t.name, hash: h, essential: false };
4434
- attachments.push(a);
4435
- if (!hasPlate) rawSceneFallback.push(a);
4436
- }
4583
+ if (plate && ctx.images.has(plate)) {
4584
+ attachments.push({ role: "scene", id: t.id, label: t.name, hash: plate, essential: false });
4437
4585
  }
4438
4586
  }
4439
4587
  break;
@@ -4479,15 +4627,12 @@ function compileBrief(brief, ctx) {
4479
4627
  if (scene?.subject === "product" && !productId) {
4480
4628
  warnings.push(`${scene.name} is built around a product. Add one to this brief.`);
4481
4629
  } else if (scene?.subject === "person" && !hasPerson) {
4482
- warnings.push(`${scene.name} is built around a person. Add a presenter.`);
4630
+ warnings.push(`${scene.name} is built around a person. With nobody attached, the set renders on its own.`);
4483
4631
  }
4484
4632
  const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
4485
4633
  const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
4486
- if (hasPerson && rawSceneFallback.length) {
4487
- for (const a of rawSceneFallback) {
4488
- const i = attachments.indexOf(a);
4489
- if (i !== -1) attachments.splice(i, 1);
4490
- }
4634
+ if (!hasPerson) {
4635
+ for (let i = attachments.length - 1; i >= 0; i--) if (attachments[i].role === "scene") attachments.splice(i, 1);
4491
4636
  }
4492
4637
  const identityHashes = /* @__PURE__ */ new Map();
4493
4638
  for (const a of attachments)
@@ -4533,10 +4678,14 @@ function compileBrief(brief, ctx) {
4533
4678
  );
4534
4679
  }
4535
4680
  }
4681
+ const figureRole = scene?.figure ?? (scene?.subject === "person" && !hasPerson ? PERSON_SCENE_FIGURE : void 0);
4682
+ const asked = shotAsksForAPerson(userWords);
4683
+ const emptyRole = figureRole && !hasPerson && !asked && ctx.mode !== "edit" ? figureRole : void 0;
4536
4684
  const guard = scene ? sceneGuardDirectives({
4537
4685
  hasProduct: !!productId,
4538
4686
  hasPerson,
4539
- hasScenePhoto: kept.some((a) => a.role === "scene")
4687
+ hasScenePhoto: kept.some((a) => a.role === "scene"),
4688
+ emptyRole
4540
4689
  }) : [];
4541
4690
  const pairDirectives = productId && hasPerson ? [
4542
4691
  "If the attached product is something a person wears, the presenter wears that exact product, with the rest of the outfit styled around it; otherwise the presenter presents or uses the product naturally.",
@@ -4550,10 +4699,11 @@ function compileBrief(brief, ctx) {
4550
4699
  productHandlingDirective()
4551
4700
  ] : [];
4552
4701
  const nameDirectives = productId || hasPerson ? [namesAreNotLetteringDirective()] : [];
4553
- const figureDirectives = scene?.figure ? sceneFigureDirectives({
4554
- figure: scene.figure,
4555
- treatment: scene.figureTreatment,
4702
+ const figureDirectives = figureRole && (hasPerson || ctx.mode !== "edit") ? sceneFigureDirectives({
4703
+ figure: figureRole,
4704
+ treatment: scene?.figureTreatment,
4556
4705
  hasPerson,
4706
+ asked,
4557
4707
  people,
4558
4708
  // The treatment's fictional-brands rule needs to know a real mark is
4559
4709
  // deliberately in play - and only one that actually rides counts,
@@ -6639,8 +6789,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
6639
6789
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
6640
6790
  return;
6641
6791
  }
6642
- const png = await sharp21(buf).rotate().png().toBuffer();
6643
- const meta = await sharp21(png).metadata();
6792
+ const png = await sharp20(buf).rotate().png().toBuffer();
6793
+ const meta = await sharp20(png).metadata();
6644
6794
  const hash = core.images.save(png);
6645
6795
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
6646
6796
  width: meta.width,
@@ -7117,7 +7267,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
7117
7267
  return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
7118
7268
  }
7119
7269
  async function edgeBarGeometry(buf) {
7120
- const { data, info } = await sharp21(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
7270
+ const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
7121
7271
  const W = info.width;
7122
7272
  const H = info.height;
7123
7273
  const scan = (len, cross, at) => {
@@ -7171,7 +7321,7 @@ async function trimEdgeBars(core, hash) {
7171
7321
  const width = g.right - g.left + 1;
7172
7322
  const height = g.bottom - g.top + 1;
7173
7323
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
7174
- const png = await sharp21(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
7324
+ const png = await sharp20(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
7175
7325
  return core.images.save(png);
7176
7326
  } catch {
7177
7327
  return hash;
@@ -7242,7 +7392,7 @@ async function identityCrop(core, hash) {
7242
7392
  if (!out) return void 0;
7243
7393
  try {
7244
7394
  const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
7245
- const png = await sharp21(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
7395
+ const png = await sharp20(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
7246
7396
  const scaled = core.images.save(png);
7247
7397
  identityCrops.set(hash, scaled);
7248
7398
  return scaled;
@@ -7271,12 +7421,12 @@ async function brandJsonWithIdentityCrops(core, json, characterIds) {
7271
7421
  return changed ? { ...json, characters } : json;
7272
7422
  }
7273
7423
  async function figureBox(buf) {
7274
- const meta = await sharp21(buf).metadata();
7424
+ const meta = await sharp20(buf).metadata();
7275
7425
  const W = meta.width ?? 0;
7276
7426
  const H = meta.height ?? 0;
7277
7427
  if (!W || !H) return null;
7278
7428
  for (const threshold of FIGURE_TRIM_THRESHOLDS) {
7279
- const { info } = await sharp21(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
7429
+ const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
7280
7430
  const left = Math.abs(info.trimOffsetLeft ?? 0);
7281
7431
  const top = Math.abs(info.trimOffsetTop ?? 0);
7282
7432
  const width = info.width ?? 0;
@@ -7309,13 +7459,13 @@ async function smartCover(core, hash, box) {
7309
7459
  if (!hash || !core.images.has(hash)) return void 0;
7310
7460
  try {
7311
7461
  const buf = core.images.read(hash);
7312
- const meta = await sharp21(buf).metadata();
7462
+ const meta = await sharp20(buf).metadata();
7313
7463
  const w = meta.width ?? 0;
7314
7464
  const h = meta.height ?? 0;
7315
7465
  if (!w || !h) return void 0;
7316
7466
  const raw = box(w, h);
7317
7467
  const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
7318
- const png = await sharp21(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
7468
+ const png = await sharp20(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
7319
7469
  return core.images.save(png);
7320
7470
  } catch {
7321
7471
  return void 0;
@@ -7324,11 +7474,11 @@ async function smartCover(core, hash, box) {
7324
7474
  async function crop(core, hash, region, cap2) {
7325
7475
  if (!hash || !core.images.has(hash)) return void 0;
7326
7476
  try {
7327
- const meta = await sharp21(core.images.read(hash)).metadata();
7477
+ const meta = await sharp20(core.images.read(hash)).metadata();
7328
7478
  const w = meta.width ?? 0;
7329
7479
  const h = meta.height ?? 0;
7330
7480
  if (!w || !h) return void 0;
7331
- let pipeline = sharp21(core.images.read(hash)).extract(region(w, h));
7481
+ let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
7332
7482
  if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
7333
7483
  const png = await pipeline.png().toBuffer();
7334
7484
  return core.images.save(png);
@@ -7675,7 +7825,7 @@ var fromLab = (l, a, bb) => {
7675
7825
  return [clamp(R), clamp(G), clamp(B)];
7676
7826
  };
7677
7827
  var rawAt = async (png, edge) => {
7678
- let img = sharp21(png);
7828
+ let img = sharp20(png);
7679
7829
  if (edge) img = img.resize(edge, edge, { fit: "fill" });
7680
7830
  const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
7681
7831
  return { data, width: info.width, height: info.height };
@@ -7738,7 +7888,7 @@ async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
7738
7888
  if (residual > GRADE_GATE_MEAN_DELTA) return null;
7739
7889
  const full = await rawAt(originalPng);
7740
7890
  applyAffine(full, T);
7741
- const image = await sharp21(full.data, {
7891
+ const image = await sharp20(full.data, {
7742
7892
  raw: { width: full.width, height: full.height, channels: 3 }
7743
7893
  }).png().toBuffer();
7744
7894
  return { image, residual };
@@ -7912,7 +8062,7 @@ function fitExpandToBudget(plan, source, pixelBudget) {
7912
8062
  }
7913
8063
  async function attentionCropOrigin(srcBuf, source, plan) {
7914
8064
  try {
7915
- const { info } = await sharp21(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
8065
+ const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
7916
8066
  const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
7917
8067
  const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
7918
8068
  const left = Math.round((attnLeft + plan.left) / 2);
@@ -8065,23 +8215,23 @@ function relax(grid, seam, fixedSweeps) {
8065
8215
 
8066
8216
  // src/expand.ts
8067
8217
  async function expandCanvas(source, plan) {
8068
- const bed = await sharp21(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
8069
- return sharp21(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
8218
+ 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();
8219
+ return sharp20(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
8070
8220
  }
8071
8221
  async function compositeExpand(engineImage, source, plan) {
8072
- const meta = await sharp21(engineImage).metadata();
8222
+ const meta = await sharp20(engineImage).metadata();
8073
8223
  const want = plan.width / plan.height;
8074
8224
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
8075
8225
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
8076
8226
  const aligned = sameOrientation;
8077
8227
  const exact = meta.width === plan.width && meta.height === plan.height;
8078
- const surround = aligned ? exact ? engineImage : await sharp21(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
8228
+ const surround = aligned ? exact ? engineImage : await sharp20(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
8079
8229
  const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
8080
- const image = await sharp21(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
8230
+ const image = await sharp20(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
8081
8231
  return { image, aligned };
8082
8232
  }
8083
8233
  async function matchMarginsToSeam(surround, source, plan) {
8084
- const src = await sharp21(source).metadata();
8234
+ const src = await sharp20(source).metadata();
8085
8235
  if (!src.width || !src.height) return surround;
8086
8236
  const SW = src.width;
8087
8237
  const SH = src.height;
@@ -8128,8 +8278,8 @@ var MAX_CORRECTION = 60;
8128
8278
  async function reconcile(surround, source, side, axis) {
8129
8279
  const { margin } = side;
8130
8280
  if (margin.width < 1 || margin.height < 1) return surround;
8131
- const marginRaw = await sharp21(surround).extract(margin).removeAlpha().raw().toBuffer();
8132
- const edgeRaw = await sharp21(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
8281
+ const marginRaw = await sharp20(surround).extract(margin).removeAlpha().raw().toBuffer();
8282
+ const edgeRaw = await sharp20(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
8133
8283
  const W = margin.width;
8134
8284
  const H = margin.height;
8135
8285
  const along = axis === "width" ? H : W;
@@ -8183,11 +8333,11 @@ async function reconcile(surround, source, side, axis) {
8183
8333
  }
8184
8334
  }
8185
8335
  }
8186
- const patch2 = await sharp21(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
8187
- return sharp21(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
8336
+ const patch2 = await sharp20(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
8337
+ return sharp20(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
8188
8338
  }
8189
8339
  async function expandCanvasBedOnly(source, plan) {
8190
- return sharp21(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
8340
+ 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();
8191
8341
  }
8192
8342
  function medianOf(rgb, channel, from, to) {
8193
8343
  const n = to - from;
@@ -8198,17 +8348,17 @@ function medianOf(rgb, channel, from, to) {
8198
8348
  return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
8199
8349
  }
8200
8350
  async function reframeExpand(engineImage, plan) {
8201
- const meta = await sharp21(engineImage).metadata();
8351
+ const meta = await sharp20(engineImage).metadata();
8202
8352
  if (!(meta.width && meta.height)) return null;
8203
8353
  const want = plan.width / plan.height;
8204
8354
  const got = meta.width / meta.height;
8205
8355
  if (got >= 1 !== want >= 1) return null;
8206
8356
  if (meta.width === plan.width && meta.height === plan.height) return engineImage;
8207
8357
  const straight = Math.abs(got - want) / want <= 0.02;
8208
- return sharp21(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
8358
+ return sharp20(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
8209
8359
  }
8210
8360
  async function seamScore(image, plan, source) {
8211
- const { data, info } = await sharp21(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
8361
+ const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
8212
8362
  const W = info.width;
8213
8363
  const H = info.height;
8214
8364
  const horizontal = plan.axis === "width";
@@ -8241,7 +8391,7 @@ var SEAM_VISIBLE = 2.2;
8241
8391
  var OFFSET = 4;
8242
8392
  var RESIDUAL_VISIBLE = 15;
8243
8393
  async function seamResidual(image, plan, source) {
8244
- const { data, info } = await sharp21(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
8394
+ const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
8245
8395
  const W = info.width;
8246
8396
  const H = info.height;
8247
8397
  const ch = info.channels;
@@ -8276,7 +8426,7 @@ var MAX_SHARE = 0.8;
8276
8426
  async function subjectFraction(src, source, axis) {
8277
8427
  try {
8278
8428
  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)) };
8279
- const { info } = await sharp21(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
8429
+ const { info } = await sharp20(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
8280
8430
  const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
8281
8431
  const span = axis === "width" ? source.width : source.height;
8282
8432
  const extent = axis === "width" ? window.width : window.height;
@@ -8299,14 +8449,14 @@ function placeExpand(plan, source, fraction) {
8299
8449
  }
8300
8450
  var NEUTRAL = { r: 128, g: 128, b: 128 };
8301
8451
  async function conditioningCanvas(source, plan, fill = "edge") {
8302
- const meta = await sharp21(source).metadata();
8452
+ const meta = await sharp20(source).metadata();
8303
8453
  const sw = meta.width ?? 0;
8304
8454
  const sh = meta.height ?? 0;
8305
8455
  if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
8306
8456
  const layers = [];
8307
8457
  if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
8308
8458
  layers.push({ input: source, left: plan.left, top: plan.top });
8309
- const canvas = sharp21({
8459
+ const canvas = sharp20({
8310
8460
  create: {
8311
8461
  width: plan.width,
8312
8462
  height: plan.height,
@@ -8318,7 +8468,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
8318
8468
  }
8319
8469
  async function edgeMargins(source, plan, size) {
8320
8470
  const out = [];
8321
- const strip = async (extract, width, height) => sharp21(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
8471
+ const strip = async (extract, width, height) => sharp20(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
8322
8472
  if (plan.axis === "width") {
8323
8473
  const before = plan.left;
8324
8474
  const after = plan.width - plan.left - size.width;
@@ -8409,12 +8559,12 @@ async function resolveOutpaintRoute(all, shot) {
8409
8559
  return { engine: shot, method: "reframe", crossed: false };
8410
8560
  }
8411
8561
  async function driftDiff(a, b) {
8412
- const metaA = await sharp21(a).metadata();
8413
- const metaB = await sharp21(b).metadata();
8562
+ const metaA = await sharp20(a).metadata();
8563
+ const metaB = await sharp20(b).metadata();
8414
8564
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
8415
8565
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
8416
8566
  const [rawA, rawB] = await Promise.all(
8417
- [a, b].map((buf) => sharp21(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
8567
+ [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
8418
8568
  );
8419
8569
  const out = new PNG({ width, height });
8420
8570
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -8426,11 +8576,11 @@ async function driftDiff(a, b) {
8426
8576
  };
8427
8577
  }
8428
8578
  async function changeMask(a, b, cap2 = 1024) {
8429
- const metaA = await sharp21(a).metadata();
8579
+ const metaA = await sharp20(a).metadata();
8430
8580
  const width = Math.min(metaA.width ?? 1, cap2);
8431
8581
  const height = Math.min(metaA.height ?? 1, cap2);
8432
8582
  const [rawA, rawB] = await Promise.all(
8433
- [a, b].map((buf) => sharp21(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
8583
+ [a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
8434
8584
  );
8435
8585
  const out = new PNG({ width, height });
8436
8586
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -8479,8 +8629,8 @@ function dilationFor(longEdge) {
8479
8629
  // src/localEdit.ts
8480
8630
  async function preserveOutsideChange(source, edited) {
8481
8631
  try {
8482
- const srcMeta = await sharp21(source).metadata();
8483
- const outMeta = await sharp21(edited).metadata();
8632
+ const srcMeta = await sharp20(source).metadata();
8633
+ const outMeta = await sharp20(edited).metadata();
8484
8634
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
8485
8635
  return { image: edited, outcome: "error", changed: 0 };
8486
8636
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -8490,15 +8640,15 @@ async function preserveOutsideChange(source, edited) {
8490
8640
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
8491
8641
  const r = dilationFor(Math.max(shape.width, shape.height));
8492
8642
  const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
8493
- const spread = await sharp21(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
8494
- const dilated = await sharp21(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
8495
- const feathered = await sharp21(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
8496
- const grown = await sharp21(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
8497
- const editedRgb = await sharp21(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
8498
- const masked = await sharp21(editedRgb, {
8643
+ const spread = await sharp20(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
8644
+ const dilated = await sharp20(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
8645
+ const feathered = await sharp20(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
8646
+ const grown = await sharp20(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
8647
+ const editedRgb = await sharp20(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
8648
+ const masked = await sharp20(editedRgb, {
8499
8649
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
8500
8650
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
8501
- const image = await sharp21(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
8651
+ const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
8502
8652
  return { image, outcome: "composited", changed: shape.changed };
8503
8653
  } catch {
8504
8654
  return { image: edited, outcome: "error", changed: 0 };
@@ -8536,7 +8686,7 @@ function registerLogoRoutes(app, deps) {
8536
8686
  const v = validateBrand(json);
8537
8687
  if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
8538
8688
  const row = core.store.updateBrand(brand.id, json);
8539
- const meta = await sharp21(core.images.read(part.hash)).metadata().catch(() => null);
8689
+ const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
8540
8690
  const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
8541
8691
  return { ...row, logoHash: part.hash, logoEdge };
8542
8692
  });
@@ -8658,7 +8808,7 @@ async function vibrantColor(input) {
8658
8808
  let data;
8659
8809
  let channels;
8660
8810
  try {
8661
- const out = await sharp21(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
8811
+ const out = await sharp20(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
8662
8812
  data = out.data;
8663
8813
  channels = out.info.channels;
8664
8814
  } catch {
@@ -8681,7 +8831,7 @@ async function vibrantColor(input) {
8681
8831
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
8682
8832
  if (best.score <= 0) {
8683
8833
  try {
8684
- const { dominant } = await sharp21(input).stats();
8834
+ const { dominant } = await sharp20(input).stats();
8685
8835
  return toHex(dominant.r, dominant.g, dominant.b);
8686
8836
  } catch {
8687
8837
  return null;
@@ -8708,7 +8858,7 @@ var toHex = (r, g, b) => "#" + [r, g, b].map(
8708
8858
 
8709
8859
  // src/routes/scenes.ts
8710
8860
  function registerSceneRoutes(app, deps) {
8711
- const { templatesRoot, scenes } = deps;
8861
+ const { templatesRoot, scenes, thumbs } = deps;
8712
8862
  const previewPath = (id) => contentFile(templatesRoot, "previews", `${id}.jpg`);
8713
8863
  const previewColors = /* @__PURE__ */ new Map();
8714
8864
  const previewColor = async (id) => {
@@ -8731,7 +8881,8 @@ function registerSceneRoutes(app, deps) {
8731
8881
  app.get("/api/scene-thumbnails/:file", async (req, reply) => {
8732
8882
  const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
8733
8883
  if (!m || !existsSync(previewPath(m[1]))) return reply.status(404).send({ error: "no preview" });
8734
- return serveJpeg(req, reply, previewPath(m[1]));
8884
+ const path = previewPath(m[1]);
8885
+ return serveJpegSized(req, reply, path, thumbs, fileKey("scene", m[1], path));
8735
8886
  });
8736
8887
  const refPath = (id, slot) => contentFile(templatesRoot, "previews", id, `${slot}.jpg`);
8737
8888
  app.get("/api/scene-previews/:id", async (req, reply) => {
@@ -8749,7 +8900,7 @@ function registerSceneRoutes(app, deps) {
8749
8900
  });
8750
8901
  }
8751
8902
  function registerPresenterRoutes(app, deps) {
8752
- const { templatesRoot, presenters } = deps;
8903
+ const { templatesRoot, presenters, thumbs } = deps;
8753
8904
  const presenterThumbPath = (id) => contentFile(templatesRoot, "previews", "presenters", `${id}.jpg`);
8754
8905
  const avatarPath = (id) => presenterAvatarPath(templatesRoot, id);
8755
8906
  const decoratePresenter = (p) => ({
@@ -8766,12 +8917,14 @@ function registerPresenterRoutes(app, deps) {
8766
8917
  app.get("/api/presenter-thumbnails/:file", async (req, reply) => {
8767
8918
  const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
8768
8919
  if (!m || !existsSync(presenterThumbPath(m[1]))) return reply.status(404).send({ error: "no preview" });
8769
- return serveJpeg(req, reply, presenterThumbPath(m[1]));
8920
+ const path = presenterThumbPath(m[1]);
8921
+ return serveJpegSized(req, reply, path, thumbs, fileKey("presenter", m[1], path));
8770
8922
  });
8771
8923
  app.get("/api/presenter-avatars/:file", async (req, reply) => {
8772
8924
  const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
8773
8925
  if (!m || !existsSync(avatarPath(m[1]))) return reply.status(404).send({ error: "no avatar" });
8774
- return serveJpeg(req, reply, avatarPath(m[1]));
8926
+ const path = avatarPath(m[1]);
8927
+ return serveJpegSized(req, reply, path, thumbs, fileKey("avatar", m[1], path));
8775
8928
  });
8776
8929
  app.get("/api/presenter-previews/:id", async (req, reply) => {
8777
8930
  const id = /^[a-z0-9-]+$/.exec(String(req.params.id))?.[0];
@@ -9085,7 +9238,7 @@ async function withDerivedCrops(core, body, base) {
9085
9238
  };
9086
9239
  }
9087
9240
  function registerDemoProductRoutes(app, deps) {
9088
- const { templatesRoot, demoProducts, demoProductById } = deps;
9241
+ const { templatesRoot, demoProducts, demoProductById, thumbs } = deps;
9089
9242
  const demoProductThumbPath = (id) => {
9090
9243
  const p = demoProductById(id);
9091
9244
  if (!p) return null;
@@ -9111,9 +9264,10 @@ function registerDemoProductRoutes(app, deps) {
9111
9264
  }));
9112
9265
  app.get("/api/demo-product-thumbnails/:file", async (req, reply) => {
9113
9266
  const m = /^([a-z0-9-]+)\.jpg$/.exec(String(req.params.file));
9114
- const path = m ? demoProductThumbPath(m[1]) : null;
9267
+ if (!m) return reply.status(404).send({ error: "no preview" });
9268
+ const path = demoProductThumbPath(m[1]);
9115
9269
  if (!path || !existsSync(path)) return reply.status(404).send({ error: "no preview" });
9116
- return serveJpeg(req, reply, path);
9270
+ return serveJpegSized(req, reply, path, thumbs, fileKey("demo", m[1], path));
9117
9271
  });
9118
9272
  app.get("/api/demo-product-previews/:id", async (req, reply) => {
9119
9273
  const id = /^[a-z0-9-]+$/.exec(String(req.params.id))?.[0];
@@ -9354,22 +9508,6 @@ function registerCodexSetupRoutes(app, deps) {
9354
9508
  }
9355
9509
  });
9356
9510
  }
9357
- var EXPORT_PRESETS = [
9358
- { id: "original", label: "Original", width: null, height: null },
9359
- { id: "ig-post", label: "Instagram post 1080\xD71080", width: 1080, height: 1080 },
9360
- { id: "ig-story", label: "Story 1080\xD71920", width: 1080, height: 1920 },
9361
- { id: "banner", label: "Banner 1200\xD7628", width: 1200, height: 628 }
9362
- ];
9363
- async function buildExportZip(image, baseName, presetIds) {
9364
- const zip = new JSZip();
9365
- const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
9366
- if (chosen.length === 0) throw new Error("No valid export presets selected");
9367
- for (const p of chosen) {
9368
- const buf = p.width && p.height ? await sharp21(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
9369
- zip.file(`${baseName}-${p.id}.png`, buf);
9370
- }
9371
- return zip.generateAsync({ type: "nodebuffer" });
9372
- }
9373
9511
  var slug = (v, fallback) => {
9374
9512
  const s = String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
9375
9513
  return s || fallback;
@@ -9511,96 +9649,6 @@ function readme(json, missing) {
9511
9649
  ${missing} referenced image${missing === 1 ? " was" : "s were"} missing and left out.` : ""
9512
9650
  ].join("\n");
9513
9651
  }
9514
- var THUMB_WIDTHS = [640, 160];
9515
- var isThumbWidth = (w) => THUMB_WIDTHS.includes(w);
9516
- var QUALITY = { 640: 82, 160: 75 };
9517
- function createThumbStore(core, opts = {}) {
9518
- const dir = join(core.home, "thumbs");
9519
- let enabled = true;
9520
- try {
9521
- mkdirSync(dir, { recursive: true, mode: 448 });
9522
- } catch {
9523
- enabled = false;
9524
- }
9525
- const pathFor = (hash, w) => join(dir, `${hash}-w${w}.webp`);
9526
- const inflight = /* @__PURE__ */ new Map();
9527
- const failed = /* @__PURE__ */ new Set();
9528
- const concurrency = Math.max(1, opts.concurrency ?? 2);
9529
- let active = 0;
9530
- const waiting = [];
9531
- const acquire = () => new Promise((resolve) => {
9532
- if (active < concurrency) {
9533
- active++;
9534
- resolve();
9535
- } else waiting.push(resolve);
9536
- });
9537
- const release = () => {
9538
- const next = waiting.shift();
9539
- if (next) next();
9540
- else active--;
9541
- };
9542
- async function make(hash, w) {
9543
- const final = pathFor(hash, w);
9544
- const tmp = `${final}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
9545
- await acquire();
9546
- try {
9547
- await sharp21(core.images.pathFor(hash)).resize({ width: w, withoutEnlargement: true }).webp({ quality: QUALITY[w], effort: 4 }).toFile(tmp);
9548
- await rename(tmp, final);
9549
- return final;
9550
- } catch {
9551
- await unlink(tmp).catch(() => {
9552
- });
9553
- failed.add(`${hash}-w${w}`);
9554
- return null;
9555
- } finally {
9556
- release();
9557
- }
9558
- }
9559
- return {
9560
- dir,
9561
- async ensure(hash, w) {
9562
- if (!enabled || !/^[a-f0-9]{32}$/.test(hash)) return null;
9563
- const key = `${hash}-w${w}`;
9564
- if (failed.has(key)) return null;
9565
- const final = pathFor(hash, w);
9566
- try {
9567
- await access(final);
9568
- return final;
9569
- } catch {
9570
- }
9571
- let job = inflight.get(key);
9572
- if (!job) {
9573
- job = make(hash, w).finally(() => inflight.delete(key));
9574
- inflight.set(key, job);
9575
- }
9576
- return job;
9577
- },
9578
- warm(hash) {
9579
- for (const w of THUMB_WIDTHS) void this.ensure(hash, w);
9580
- },
9581
- async settle() {
9582
- await Promise.allSettled([...inflight.values()]);
9583
- },
9584
- clear() {
9585
- failed.clear();
9586
- rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
9587
- try {
9588
- mkdirSync(dir, { recursive: true, mode: 448 });
9589
- } catch {
9590
- enabled = false;
9591
- }
9592
- },
9593
- stream: (path) => createReadStream(path)
9594
- };
9595
- }
9596
- async function fileSize(path) {
9597
- try {
9598
- const s = await stat(path);
9599
- return s.isFile() ? s.size : null;
9600
- } catch {
9601
- return null;
9602
- }
9603
- }
9604
9652
 
9605
9653
  // src/routes/images.ts
9606
9654
  var IMMUTABLE = "public, max-age=31536000, immutable";
@@ -9621,7 +9669,7 @@ function registerImageRoutes(app, deps) {
9621
9669
  app.get("/api/images/:hash/thumb", async (req, reply) => {
9622
9670
  const hash = String(req.params.hash);
9623
9671
  const w = Number(req.query?.w);
9624
- if (!isThumbWidth(w)) return reply.status(400).send({ error: "w must be 640 or 160" });
9672
+ if (!isThumbWidth(w)) return reply.status(400).send({ error: `w must be one of ${THUMB_WIDTH_LIST}` });
9625
9673
  if (!/^[a-f0-9]{32}$/.test(hash)) return reply.status(404).send({ error: "image not found" });
9626
9674
  const etag = `"${hash}-w${w}"`;
9627
9675
  if (req.headers["if-none-match"] === etag) return reply.status(304).header("cache-control", IMMUTABLE).send();
@@ -9639,8 +9687,8 @@ function registerImageRoutes(app, deps) {
9639
9687
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
9640
9688
  const buf = await part.toBuffer();
9641
9689
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
9642
- const fmt = (await sharp21(buf).metadata().catch(() => null))?.format;
9643
- const png = fmt === "svg" ? await toMarkPng(buf) : await sharp21(buf).rotate().png().toBuffer();
9690
+ const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
9691
+ const png = fmt === "svg" ? await toMarkPng(buf) : await sharp20(buf).rotate().png().toBuffer();
9644
9692
  return { hash: core.images.save(png) };
9645
9693
  });
9646
9694
  app.post("/api/diff", async (req, reply) => {
@@ -9651,7 +9699,6 @@ function registerImageRoutes(app, deps) {
9651
9699
  const heatmapHash = core.images.save(d.heatmap);
9652
9700
  return { score: d.score, heatmapHash, width: d.width, height: d.height };
9653
9701
  });
9654
- app.get("/api/export/presets", async () => EXPORT_PRESETS);
9655
9702
  app.get("/api/brands/:id/export", async (req, reply) => {
9656
9703
  const brandId = String(req.params.id);
9657
9704
  if (!core.store.getBrand(brandId)) return reply.status(404).send({ error: "brand not found" });
@@ -9659,22 +9706,32 @@ function registerImageRoutes(app, deps) {
9659
9706
  reply.header("content-type", "application/zip").header("content-disposition", `attachment; filename="${filename}"`);
9660
9707
  return reply.send(zip);
9661
9708
  });
9662
- app.post("/api/export", async (req, reply) => {
9663
- const { imageHash, presets, baseName = "scenri-export" } = req.body;
9664
- if (!core.images.has(String(imageHash))) return reply.status(404).send({ error: "image not found" });
9665
- const safeBase = String(baseName).replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 60) || "export";
9666
- const zip = await buildExportZip(
9667
- core.images.read(String(imageHash)),
9668
- safeBase,
9669
- Array.isArray(presets) ? presets.map(String) : []
9670
- );
9671
- reply.header("content-type", "application/zip").header("content-disposition", `attachment; filename="${safeBase}.zip"`);
9672
- return reply.send(zip);
9673
- });
9674
9709
  }
9675
9710
 
9676
9711
  // src/release/notes.data.ts
9677
9712
  var RELEASES = [
9713
+ {
9714
+ version: "0.8.3",
9715
+ date: "2026-09-06",
9716
+ sections: [
9717
+ {
9718
+ heading: "Create",
9719
+ body: "The + beside the prompt is a picker for adding to the shot: products, presenters, scenes, brand colours, your logo and finished shots in one grid, with search, Upload image and paste. A tile pressed again takes its chip out, and every tab can make a new one of its own."
9720
+ },
9721
+ {
9722
+ heading: "Shots",
9723
+ body: "An open shot has the rest of the feed beside it as a rail, and its own history under the picture as a trail of tiles, the original and each refinement. A right click on the picture holds its actions, Download is one click, and Compare is gone."
9724
+ },
9725
+ {
9726
+ heading: "Refine",
9727
+ body: "Refining is the ask alone. The field names the picture it is about and follows the stage as you step, and a refinement is recorded as what you asked, not the references that rode along."
9728
+ },
9729
+ {
9730
+ heading: "Scenes",
9731
+ body: "A scene with nobody attached renders its set alone. No stand-in person appears."
9732
+ }
9733
+ ]
9734
+ },
9678
9735
  {
9679
9736
  version: "0.8.2",
9680
9737
  date: "2026-09-03",
@@ -10618,7 +10675,7 @@ function buildServer(opts) {
10618
10675
  // Measured as stored (post-toMarkPng), so the scrape judges the same
10619
10676
  // pixels the compiler will one day attach.
10620
10677
  probeLongEdge: async (buf) => {
10621
- const m = await sharp21(await toMarkPng(buf)).metadata();
10678
+ const m = await sharp20(await toMarkPng(buf)).metadata();
10622
10679
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
10623
10680
  },
10624
10681
  createdWith: `${meta.name}/${meta.version}`
@@ -10707,7 +10764,7 @@ function buildServer(opts) {
10707
10764
  fetchImpl: opts.fetchImpl,
10708
10765
  saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
10709
10766
  probeLongEdge: async (buf) => {
10710
- const m = await sharp21(await toMarkPng(buf)).metadata();
10767
+ const m = await sharp20(await toMarkPng(buf)).metadata();
10711
10768
  return Math.max(m.width ?? 0, m.height ?? 0) || null;
10712
10769
  },
10713
10770
  createdWith: `${meta.name}/${meta.version}`
@@ -10802,14 +10859,14 @@ function buildServer(opts) {
10802
10859
  });
10803
10860
  registerCatalogImportRoutes(app, { core, fetchImpl: opts.fetchImpl });
10804
10861
  const templatesRoot = opts.templatesDir ?? defaultScenesDir();
10805
- registerSceneRoutes(app, { templatesRoot, scenes });
10862
+ registerSceneRoutes(app, { templatesRoot, scenes, thumbs });
10806
10863
  const presentersDir = join(templatesRoot, "presenters");
10807
10864
  const { presenters } = loadPresenters(presentersDir);
10808
- registerPresenterRoutes(app, { templatesRoot, presenters });
10865
+ registerPresenterRoutes(app, { templatesRoot, presenters, thumbs });
10809
10866
  registerAssetBuildRoutes(app, { core, engines, analyzer: opts.analyzer, scenes, presenters });
10810
10867
  const { demoProducts } = loadDemoProducts(join(templatesRoot, "demo-products"));
10811
10868
  const demoProductById = demoProductResolver(demoProducts);
10812
- registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById });
10869
+ registerDemoProductRoutes(app, { templatesRoot, demoProducts, demoProductById, thumbs });
10813
10870
  registerShowcaseRoutes(app, { templatesRoot });
10814
10871
  app.get("/api/formats", async () => FORMATS);
10815
10872
  function briefInputsOnly(brief) {
@@ -11080,11 +11137,11 @@ function buildServer(opts) {
11080
11137
  const out = [];
11081
11138
  for (const h of images) {
11082
11139
  const buf = core.images.read(h);
11083
- const meta2 = await sharp21(buf).metadata().catch(() => null);
11140
+ const meta2 = await sharp20(buf).metadata().catch(() => null);
11084
11141
  if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
11085
11142
  const oriented = (meta2.orientation ?? 1) !== 1;
11086
11143
  out.push(
11087
- buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp21(buf).rotate().png().toBuffer())
11144
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
11088
11145
  );
11089
11146
  }
11090
11147
  return out;
@@ -11096,7 +11153,7 @@ function buildServer(opts) {
11096
11153
  const out = [];
11097
11154
  for (const h of images) {
11098
11155
  const buf = core.images.read(h);
11099
- const meta2 = await sharp21(buf).metadata();
11156
+ const meta2 = await sharp20(buf).metadata();
11100
11157
  if (!meta2.width || !meta2.height) {
11101
11158
  out.push(h);
11102
11159
  continue;
@@ -11109,7 +11166,7 @@ function buildServer(opts) {
11109
11166
  }
11110
11167
  const w = got > target ? Math.round(meta2.height * target) : meta2.width;
11111
11168
  const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
11112
- const cropped = await sharp21(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
11169
+ const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
11113
11170
  app.log.info(
11114
11171
  { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
11115
11172
  "canvas: cropped a drifted frame to the asked ratio"
@@ -11128,7 +11185,7 @@ function buildServer(opts) {
11128
11185
  async function assertAspect(images, expect) {
11129
11186
  const want = expect.width / expect.height;
11130
11187
  for (const h of images) {
11131
- const meta2 = await sharp21(core.images.read(h)).metadata();
11188
+ const meta2 = await sharp20(core.images.read(h)).metadata();
11132
11189
  if (!meta2.width || !meta2.height) continue;
11133
11190
  const got = meta2.width / meta2.height;
11134
11191
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -11159,7 +11216,7 @@ function buildServer(opts) {
11159
11216
  if (post) own = await post(own);
11160
11217
  if (expect) await assertAspect(own, expect);
11161
11218
  try {
11162
- const meta2 = await sharp21(core.images.read(own[0])).metadata();
11219
+ const meta2 = await sharp20(core.images.read(own[0])).metadata();
11163
11220
  const node = core.store.getNode(id);
11164
11221
  if (node && meta2.width && meta2.height) {
11165
11222
  const brief = node.brief ?? {};
@@ -11268,7 +11325,7 @@ function buildServer(opts) {
11268
11325
  crop: window
11269
11326
  });
11270
11327
  const work2 = async () => ({
11271
- images: [core.images.save(await sharp21(args.srcBuf).extract(window).png().toBuffer())],
11328
+ images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
11272
11329
  costUsd: 0
11273
11330
  });
11274
11331
  void runNode([node2.id], null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
@@ -11293,7 +11350,7 @@ function buildServer(opts) {
11293
11350
  if (!srcHash || !core.images.has(String(srcHash)))
11294
11351
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
11295
11352
  const srcBuf = core.images.read(String(srcHash));
11296
- const srcMeta = await sharp21(srcBuf).metadata();
11353
+ const srcMeta = await sharp20(srcBuf).metadata();
11297
11354
  if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
11298
11355
  return runCropNode({
11299
11356
  parentId: cropParentId,
@@ -11342,7 +11399,7 @@ function buildServer(opts) {
11342
11399
  );
11343
11400
  extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
11344
11401
  if (!compiled2.prompt.trim() && reshape !== "extend")
11345
- return reply.status(400).send({ error: "the brief is empty" });
11402
+ return reply.status(400).send({ error: "the prompt is empty" });
11346
11403
  } else {
11347
11404
  const brandJson = await brandJsonWithIdentityCrops(
11348
11405
  core,
@@ -11370,7 +11427,7 @@ function buildServer(opts) {
11370
11427
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
11371
11428
  templateById: sceneById
11372
11429
  });
11373
- if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
11430
+ if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the prompt is empty" });
11374
11431
  }
11375
11432
  }
11376
11433
  let finalPrompt = String(prompt ?? "");
@@ -11409,7 +11466,7 @@ function buildServer(opts) {
11409
11466
  });
11410
11467
  if (productId && !compiled2.attachments.some((a) => a.role === "product"))
11411
11468
  return reply.status(400).send({ error: "product has no usable shots" });
11412
- if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
11469
+ if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the prompt is empty" });
11413
11470
  }
11414
11471
  let estimate;
11415
11472
  let work;
@@ -11508,7 +11565,7 @@ function buildServer(opts) {
11508
11565
  );
11509
11566
  }
11510
11567
  const srcBuf = core.images.read(String(srcHash));
11511
- const srcMeta = await sharp21(srcBuf).metadata();
11568
+ const srcMeta = await sharp20(srcBuf).metadata();
11512
11569
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
11513
11570
  const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
11514
11571
  const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
@@ -11542,7 +11599,7 @@ function buildServer(opts) {
11542
11599
  } else if (decision.op === "extend") {
11543
11600
  if (decision.assist) {
11544
11601
  expandAssist = { width: decision.assist.width, height: decision.assist.height };
11545
- workBuf = await sharp21(srcBuf).extract(decision.assist).png().toBuffer();
11602
+ workBuf = await sharp20(srcBuf).extract(decision.assist).png().toBuffer();
11546
11603
  workSize = { width: decision.assist.width, height: decision.assist.height };
11547
11604
  }
11548
11605
  expandPlan = planExpand(workSize, targetRatio);
@@ -11563,7 +11620,7 @@ function buildServer(opts) {
11563
11620
  const fit = fitExpandToBudget(expandPlan, workSize, runEngine.capabilities().editPixelBudget);
11564
11621
  if (fit.scale < 1) {
11565
11622
  expandPlan = fit.plan;
11566
- workBuf = await sharp21(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
11623
+ workBuf = await sharp20(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
11567
11624
  workSize = fit.source;
11568
11625
  extraWarnings.push(
11569
11626
  `${runEngine.capabilities().displayName} draws about ${((runEngine.capabilities().editPixelBudget ?? 0) / 1e6).toFixed(1)} megapixels, so this shape continues as a ${fit.plan.width}x${fit.plan.height} frame with the photograph riding inside it at ${fit.source.width}x${fit.source.height}. Nothing is upscaled; the stored size is the size the engine truly drew.`
@@ -11586,7 +11643,7 @@ function buildServer(opts) {
11586
11643
  if (editPixelBudget && stepped && (stepped.width !== srcMeta.width || stepped.height !== srcMeta.height)) {
11587
11644
  sentSize = stepped;
11588
11645
  budgetSourceHash = core.images.save(
11589
- await sharp21(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
11646
+ await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
11590
11647
  );
11591
11648
  if (!gradeOnlyAsk)
11592
11649
  extraWarnings.push(
@@ -11726,11 +11783,11 @@ function buildServer(opts) {
11726
11783
  const original = editedFrom ? core.images.read(editedFrom) : null;
11727
11784
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
11728
11785
  const enforceEditCanvas = async (images) => {
11729
- const srcMeta = await sharp21(original).metadata();
11786
+ const srcMeta = await sharp20(original).metadata();
11730
11787
  if (!srcMeta.width || !srcMeta.height) return images;
11731
11788
  const out = [];
11732
11789
  for (const h of images) {
11733
- const meta2 = await sharp21(core.images.read(h)).metadata();
11790
+ const meta2 = await sharp20(core.images.read(h)).metadata();
11734
11791
  const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
11735
11792
  const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
11736
11793
  pixelBudget: runEngine.capabilities().editPixelBudget
@@ -11760,7 +11817,7 @@ function buildServer(opts) {
11760
11817
  );
11761
11818
  out.push(
11762
11819
  core.images.save(
11763
- await sharp21(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
11820
+ await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
11764
11821
  )
11765
11822
  );
11766
11823
  try {
@@ -11782,7 +11839,7 @@ function buildServer(opts) {
11782
11839
  const out = [];
11783
11840
  for (const h of images) {
11784
11841
  const answer = core.images.read(h);
11785
- const got = await sharp21(answer).metadata();
11842
+ const got = await sharp20(answer).metadata();
11786
11843
  if (got.width !== plan.width || got.height !== plan.height)
11787
11844
  app.log.info(
11788
11845
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
@@ -12118,8 +12175,8 @@ async function verify() {
12118
12175
  const db = new Database2(":memory:");
12119
12176
  db.pragma("user_version");
12120
12177
  db.close();
12121
- const { default: sharp22 } = await import('sharp');
12122
- await sharp22({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
12178
+ const { default: sharp21 } = await import('sharp');
12179
+ await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
12123
12180
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
12124
12181
  } catch (err) {
12125
12182
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));