scenri 0.4.5 → 0.4.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.6](https://github.com/tonygorb/Scenri/compare/v0.4.5...v0.4.6) (2026-08-23)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * a removal leaves nothing behind, and a garment needs no invented wearer ([219c1c2](https://github.com/tonygorb/Scenri/commit/219c1c2e64636a6a836cf99ab23a75b3ad7e8a1d))
9
+ * evidence round two, and the timing the app never records ([b1a0cd1](https://github.com/tonygorb/Scenri/commit/b1a0cd1b88dfe1937ef712380ad6ae99f7401d48))
10
+ * record how long a shot took and what pixels it really delivered ([c9258e5](https://github.com/tonygorb/Scenri/commit/c9258e5281877beb7078ea5d87a48fa82efb9f04))
11
+
3
12
  ## [0.4.5](https://github.com/tonygorb/Scenri/compare/v0.4.4...v0.4.5) (2026-08-23)
4
13
 
5
14
 
package/dist/serve.js CHANGED
@@ -10,7 +10,7 @@ import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, write
10
10
  import { fileURLToPath } from 'url';
11
11
  import { readFile, copyFile, mkdtemp, rm, readdir, stat, writeFile } from 'fs/promises';
12
12
  import { spawn } from 'child_process';
13
- import sharp6 from 'sharp';
13
+ import sharp8 from 'sharp';
14
14
  import Fastify from 'fastify';
15
15
  import fastifyStatic from '@fastify/static';
16
16
  import fastifyMultipart from '@fastify/multipart';
@@ -211,11 +211,12 @@ function widenNodeStatusCheck(db) {
211
211
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
212
212
  overlays TEXT NOT NULL DEFAULT '{}',
213
213
  brief TEXT,
214
- archived INTEGER NOT NULL DEFAULT 0
214
+ archived INTEGER NOT NULL DEFAULT 0,
215
+ duration_ms INTEGER
215
216
  );
216
217
  INSERT INTO nodes_new
217
218
  SELECT id, project_id, parent_id, kind, prompt, engine_id, status, images, cost_usd, kept, error,
218
- created_at, overlays, brief, archived
219
+ created_at, overlays, brief, archived, duration_ms
219
220
  FROM nodes;
220
221
  DROP TABLE nodes;
221
222
  ALTER TABLE nodes_new RENAME TO nodes;
@@ -342,6 +343,9 @@ function openDb(homeDir) {
342
343
  if (!nodeCols.includes("archived")) {
343
344
  db.exec("ALTER TABLE nodes ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
344
345
  }
346
+ if (!nodeCols.includes("duration_ms")) {
347
+ db.exec("ALTER TABLE nodes ADD COLUMN duration_ms INTEGER");
348
+ }
345
349
  const projectCols = db.pragma("table_info(projects)").map((c) => c.name);
346
350
  if (!projectCols.includes("slug")) {
347
351
  db.exec("ALTER TABLE projects ADD COLUMN slug TEXT");
@@ -472,6 +476,7 @@ function rowToNode(r) {
472
476
  status: r.status,
473
477
  images: JSON.parse(r.images),
474
478
  costUsd: r.cost_usd,
479
+ durationMs: r.duration_ms ?? null,
475
480
  kept: !!r.kept,
476
481
  error: r.error,
477
482
  createdAt: r.created_at,
@@ -649,9 +654,10 @@ function createStore(db) {
649
654
  return this.getNode(id);
650
655
  },
651
656
  completeNode(id, result) {
652
- db.prepare("UPDATE nodes SET status='done', images=?, cost_usd=? WHERE id=?").run(
657
+ db.prepare("UPDATE nodes SET status='done', images=?, cost_usd=?, duration_ms=? WHERE id=?").run(
653
658
  JSON.stringify(result.images),
654
659
  result.costUsd,
660
+ result.durationMs ?? null,
655
661
  id
656
662
  );
657
663
  },
@@ -2601,7 +2607,7 @@ function createDemoEngine(saveImage) {
2601
2607
  <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>
2602
2608
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
2603
2609
  </svg>`;
2604
- return sharp6(Buffer.from(svg)).png().toBuffer();
2610
+ return sharp8(Buffer.from(svg)).png().toBuffer();
2605
2611
  }
2606
2612
  return {
2607
2613
  capabilities() {
@@ -2764,7 +2770,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
2764
2770
  for (const [slot, angle] of PRESENTER_ANGLES) {
2765
2771
  const path = presenterRefPath(templatesRoot, presenter.id, slot);
2766
2772
  if (!existsSync(path)) continue;
2767
- const png = await sharp6(readFileSync(path)).png().toBuffer();
2773
+ const png = await sharp8(readFileSync(path)).png().toBuffer();
2768
2774
  const hash = core.images.save(png);
2769
2775
  shots.push({ file: `asset:${hash}`, angle, locked: true });
2770
2776
  }
@@ -2858,7 +2864,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
2858
2864
  for (const angle of angles) {
2859
2865
  const path = demoProductRefPath(templatesRoot, product.id, angle);
2860
2866
  if (!existsSync(path)) continue;
2861
- const png = await sharp6(readFileSync(path)).png().toBuffer();
2867
+ const png = await sharp8(readFileSync(path)).png().toBuffer();
2862
2868
  const hash = core.images.save(png);
2863
2869
  shots.push({ file: `asset:${hash}`, angle, locked: true });
2864
2870
  }
@@ -2914,15 +2920,19 @@ function productFidelityDirective(attached) {
2914
2920
  }
2915
2921
  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.";
2916
2922
  }
2917
- function editPreservationDirective(scope) {
2923
+ function editPreservationDirective(scope, opts) {
2918
2924
  if (scope === "local") {
2919
- 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.";
2925
+ 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." : "";
2926
+ 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;
2920
2927
  }
2921
2928
  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.";
2922
2929
  }
2923
2930
  function inheritedIdentityDirective() {
2924
2931
  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.";
2925
2932
  }
2933
+ function garmentDisplayDirective() {
2934
+ return "No person is part of this brief. Present the garment as a product, laid, hung, folded or dressed on a plain form, never on a person, a partial figure or an invisible body, unless the direction above explicitly asks for it worn.";
2935
+ }
2926
2936
  function shotSpecifiesCamera(text) {
2927
2937
  return /\b\d{2,3}\s?mm\b|\bf\/\d|\blens\b|\bcamera\b|\bshot from\b|\beye[- ]level\b|\blow angle\b|\bhigh angle\b|\boverhead\b|\btop[- ]down\b|\bbird'?s[- ]eye\b|\bclose[- ]up\b|\bmacro\b|\bwide shot\b|\bcrop(?:ped)?\b|\bframing\b|\bdepth of field\b|\bbokeh\b|\bshallow (?:focus|depth)\b|\bdeep focus\b/i.test(
2928
2938
  text
@@ -3193,15 +3203,21 @@ function compileBrief(brief, ctx) {
3193
3203
  ] : [];
3194
3204
  const brandLines = brandRuleDirectives(ctx.brand);
3195
3205
  const preservation = ctx.mode === "edit" ? [
3196
- editPreservationDirective(ctx.editScope ?? "global"),
3206
+ editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval }),
3197
3207
  ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : []
3198
3208
  ] : [];
3209
+ const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
3210
+ if (a.role !== "product" || !a.id) return false;
3211
+ const rec = (ctx.brand?.products ?? []).find((x) => x?.id === a.id);
3212
+ return String(rec?.category ?? "").toLowerCase() === "apparel";
3213
+ }) ? [garmentDisplayDirective()] : [];
3199
3214
  const allDirectives = [
3200
3215
  ...productDirectives,
3201
3216
  ...personDirectives,
3202
3217
  ...pairDirectives,
3203
3218
  ...otherDirectives,
3204
3219
  ...cameraDirectives,
3220
+ ...apparelUnworn,
3205
3221
  ...brandLines,
3206
3222
  ...guard,
3207
3223
  ...preservation
@@ -5197,8 +5213,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
5197
5213
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
5198
5214
  return;
5199
5215
  }
5200
- const png = await sharp6(buf).rotate().png().toBuffer();
5201
- const meta = await sharp6(png).metadata();
5216
+ const png = await sharp8(buf).rotate().png().toBuffer();
5217
+ const meta = await sharp8(png).metadata();
5202
5218
  const hash = core.images.save(png);
5203
5219
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
5204
5220
  width: meta.width,
@@ -5608,7 +5624,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
5608
5624
  return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
5609
5625
  }
5610
5626
  async function edgeBarGeometry(buf) {
5611
- const { data, info } = await sharp6(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
5627
+ const { data, info } = await sharp8(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
5612
5628
  const W = info.width;
5613
5629
  const H = info.height;
5614
5630
  const scan = (len, cross, at) => {
@@ -5662,7 +5678,7 @@ async function trimEdgeBars(core, hash) {
5662
5678
  const width = g.right - g.left + 1;
5663
5679
  const height = g.bottom - g.top + 1;
5664
5680
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
5665
- const png = await sharp6(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
5681
+ const png = await sharp8(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
5666
5682
  return core.images.save(png);
5667
5683
  } catch {
5668
5684
  return hash;
@@ -5684,11 +5700,11 @@ async function avatarCrop(core, hash) {
5684
5700
  async function crop(core, hash, region) {
5685
5701
  if (!hash || !core.images.has(hash)) return void 0;
5686
5702
  try {
5687
- const meta = await sharp6(core.images.read(hash)).metadata();
5703
+ const meta = await sharp8(core.images.read(hash)).metadata();
5688
5704
  const w = meta.width ?? 0;
5689
5705
  const h = meta.height ?? 0;
5690
5706
  if (!w || !h) return void 0;
5691
- const png = await sharp6(core.images.read(hash)).extract(region(w, h)).png().toBuffer();
5707
+ const png = await sharp8(core.images.read(hash)).extract(region(w, h)).png().toBuffer();
5692
5708
  return core.images.save(png);
5693
5709
  } catch {
5694
5710
  return void 0;
@@ -5889,6 +5905,7 @@ var GLOBAL_CUES = [
5889
5905
  ["pose", /\b(pose|posture|expression|smile|smiling|looking)\b/i]
5890
5906
  ];
5891
5907
  var LOCAL_VERB = /\b(add|remove|delete|erase|take out|get rid of|replace|swap|clean up|fix|repair|straighten|hide|cover)\b/i;
5908
+ var REMOVAL_VERB = /\b(remove|delete|erase|take out|get rid of)\b/i;
5892
5909
  var DEFINITE_OBJECT = /\b(the|that|this|his|her|their|its|a|an|one)\b/i;
5893
5910
  var REGION_CUE = /\b(in the (top|bottom|upper|lower|left|right)|on the (left|right|label|cap|lid|sleeve|table|floor|wall|shelf)|behind|next to|beside|in front of|to the (left|right)|corner|foreground|background object)\b/i;
5894
5911
  var COORDINATION = /\b(and|then|also|plus)\b|[;]/i;
@@ -5908,7 +5925,7 @@ function scopeOfInstruction(text) {
5908
5925
  if (REGION_CUE.test(s)) matched.push("region");
5909
5926
  if (LOCAL_VERB.test(s) && DEFINITE_OBJECT.test(s)) matched.push("verb+object");
5910
5927
  if (!matched.length) return { scope: "global", matched: ["no local cue"] };
5911
- return { scope: "local", matched };
5928
+ return { scope: "local", matched, removal: REMOVAL_VERB.test(s) };
5912
5929
  }
5913
5930
 
5914
5931
  // src/expandRules.ts
@@ -5943,29 +5960,29 @@ function expandInstruction(plan, direction) {
5943
5960
  return `Extend this photograph ${where} to fill the empty margin, continuing the same scene, the same surface, the same lighting and the same perspective straight out to the new edges. Do not change, move, rescale or reinterpret anything already in the picture, and do not add a subject, a product or a person that is not already there.${direction.trim() ? ` ${direction.trim()}` : ""}`;
5944
5961
  }
5945
5962
  async function expandCanvas(source, plan) {
5946
- const bed = await sharp6(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
5947
- return sharp6(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
5963
+ const bed = await sharp8(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
5964
+ return sharp8(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
5948
5965
  }
5949
5966
  async function compositeExpand(engineImage, source, plan) {
5950
- const meta = await sharp6(engineImage).metadata();
5967
+ const meta = await sharp8(engineImage).metadata();
5951
5968
  const want = plan.width / plan.height;
5952
5969
  const got = meta.width && meta.height ? meta.width / meta.height : 0;
5953
5970
  const sameOrientation = got > 0 && got >= 1 === want >= 1;
5954
5971
  const aligned = sameOrientation;
5955
- const surround = aligned ? await sharp6(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
5956
- const image = await sharp6(surround).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
5972
+ const surround = aligned ? await sharp8(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
5973
+ const image = await sharp8(surround).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
5957
5974
  return { image, aligned };
5958
5975
  }
5959
5976
  async function expandCanvasBedOnly(source, plan) {
5960
- return sharp6(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
5977
+ return sharp8(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
5961
5978
  }
5962
5979
  async function driftDiff(a, b) {
5963
- const metaA = await sharp6(a).metadata();
5964
- const metaB = await sharp6(b).metadata();
5980
+ const metaA = await sharp8(a).metadata();
5981
+ const metaB = await sharp8(b).metadata();
5965
5982
  const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
5966
5983
  const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
5967
5984
  const [rawA, rawB] = await Promise.all(
5968
- [a, b].map((buf) => sharp6(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
5985
+ [a, b].map((buf) => sharp8(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
5969
5986
  );
5970
5987
  const out = new PNG({ width, height });
5971
5988
  const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
@@ -5977,11 +5994,11 @@ async function driftDiff(a, b) {
5977
5994
  };
5978
5995
  }
5979
5996
  async function changeMask(a, b, cap2 = 1024) {
5980
- const metaA = await sharp6(a).metadata();
5997
+ const metaA = await sharp8(a).metadata();
5981
5998
  const width = Math.min(metaA.width ?? 1, cap2);
5982
5999
  const height = Math.min(metaA.height ?? 1, cap2);
5983
6000
  const [rawA, rawB] = await Promise.all(
5984
- [a, b].map((buf) => sharp6(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
6001
+ [a, b].map((buf) => sharp8(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
5985
6002
  );
5986
6003
  const out = new PNG({ width, height });
5987
6004
  pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
@@ -6030,8 +6047,8 @@ function dilationFor(longEdge) {
6030
6047
  // src/localEdit.ts
6031
6048
  async function preserveOutsideChange(source, edited) {
6032
6049
  try {
6033
- const srcMeta = await sharp6(source).metadata();
6034
- const outMeta = await sharp6(edited).metadata();
6050
+ const srcMeta = await sharp8(source).metadata();
6051
+ const outMeta = await sharp8(edited).metadata();
6035
6052
  if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
6036
6053
  return { image: edited, outcome: "error", changed: 0 };
6037
6054
  const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
@@ -6040,12 +6057,16 @@ async function preserveOutsideChange(source, edited) {
6040
6057
  const outcome = judgeChange(shape);
6041
6058
  if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
6042
6059
  const r = dilationFor(Math.max(shape.width, shape.height));
6043
- const grown = await sharp6(shape.mask, { raw: { width: shape.width, height: shape.height, channels: 1 } }).blur(Math.max(1, r / 3)).threshold(1).blur(Math.max(2, r / 3)).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
6044
- const editedRgb = await sharp6(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
6045
- const masked = await sharp6(editedRgb, {
6060
+ const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
6061
+ const spread = await sharp8(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
6062
+ const dilated = await sharp8(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
6063
+ const feathered = await sharp8(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
6064
+ const grown = await sharp8(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
6065
+ const editedRgb = await sharp8(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
6066
+ const masked = await sharp8(editedRgb, {
6046
6067
  raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
6047
6068
  }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
6048
- const image = await sharp6(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
6069
+ const image = await sharp8(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
6049
6070
  return { image, outcome: "composited", changed: shape.changed };
6050
6071
  } catch {
6051
6072
  return { image: edited, outcome: "error", changed: 0 };
@@ -6074,7 +6095,7 @@ var assetHash2 = (ref) => {
6074
6095
  };
6075
6096
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
6076
6097
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
6077
- var toPng = (buf) => sharp6(buf).rotate().png().toBuffer();
6098
+ var toPng = (buf) => sharp8(buf).rotate().png().toBuffer();
6078
6099
  var COST_PROBE = {
6079
6100
  prompt: "",
6080
6101
  brand: { brand: {}, assetPaths: {} },
@@ -6083,7 +6104,7 @@ var COST_PROBE = {
6083
6104
  count: 1
6084
6105
  };
6085
6106
  var MARK_MAX_EDGE = 2048;
6086
- var toMarkPng = (buf) => sharp6(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
6107
+ var toMarkPng = (buf) => sharp8(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
6087
6108
  var readImagePart = async (core, req, normalize2) => {
6088
6109
  const part = await req.file();
6089
6110
  if (!part) return { error: "multipart file field required" };
@@ -6260,7 +6281,7 @@ async function vibrantColor(input) {
6260
6281
  let data;
6261
6282
  let channels;
6262
6283
  try {
6263
- const out = await sharp6(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
6284
+ const out = await sharp8(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
6264
6285
  data = out.data;
6265
6286
  channels = out.info.channels;
6266
6287
  } catch {
@@ -6283,7 +6304,7 @@ async function vibrantColor(input) {
6283
6304
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
6284
6305
  if (best.score <= 0) {
6285
6306
  try {
6286
- const { dominant } = await sharp6(input).stats();
6307
+ const { dominant } = await sharp8(input).stats();
6287
6308
  return toHex(dominant.r, dominant.g, dominant.b);
6288
6309
  } catch {
6289
6310
  return null;
@@ -6826,7 +6847,7 @@ async function buildExportZip(image, baseName, presetIds) {
6826
6847
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
6827
6848
  if (chosen.length === 0) throw new Error("No valid export presets selected");
6828
6849
  for (const p of chosen) {
6829
- const buf = p.width && p.height ? await sharp6(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
6850
+ const buf = p.width && p.height ? await sharp8(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
6830
6851
  zip.file(`${baseName}-${p.id}.png`, buf);
6831
6852
  }
6832
6853
  return zip.generateAsync({ type: "nodebuffer" });
@@ -6987,7 +7008,7 @@ function registerImageRoutes(app, deps) {
6987
7008
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
6988
7009
  const buf = await part.toBuffer();
6989
7010
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
6990
- const png = await sharp6(buf).rotate().png().toBuffer();
7011
+ const png = await sharp8(buf).rotate().png().toBuffer();
6991
7012
  return { hash: core.images.save(png) };
6992
7013
  });
6993
7014
  app.post("/api/diff", async (req, reply) => {
@@ -7022,6 +7043,20 @@ function registerImageRoutes(app, deps) {
7022
7043
 
7023
7044
  // src/release/notes.data.ts
7024
7045
  var RELEASES = [
7046
+ {
7047
+ version: "0.4.6",
7048
+ date: "2026-08-24",
7049
+ sections: [
7050
+ {
7051
+ heading: "Refining",
7052
+ body: "Removing something from a shot no longer leaves a faint outline of it behind. The removed object is gone and the surface continues as if it had never been there."
7053
+ },
7054
+ {
7055
+ heading: "Shots",
7056
+ body: "A finished shot now says how long it took to generate, next to what it cost. Feed tiles hold the exact shape of the picture they carry, so a landing image no longer shifts its column, and a garment shot with no presenter attached leans toward a proper product display rather than inventing someone to wear it."
7057
+ }
7058
+ ]
7059
+ },
7025
7060
  {
7026
7061
  version: "0.4.5",
7027
7062
  date: "2026-08-23",
@@ -7829,14 +7864,14 @@ function buildServer(opts) {
7829
7864
  const out = [];
7830
7865
  for (const h of images) {
7831
7866
  const buf = core.images.read(h);
7832
- out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp6(buf).png().toBuffer()));
7867
+ out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp8(buf).png().toBuffer()));
7833
7868
  }
7834
7869
  return out;
7835
7870
  }
7836
7871
  async function assertAspect(images, expect) {
7837
7872
  const want = expect.width / expect.height;
7838
7873
  for (const h of images) {
7839
- const meta2 = await sharp6(core.images.read(h)).metadata();
7874
+ const meta2 = await sharp8(core.images.read(h)).metadata();
7840
7875
  if (!meta2.width || !meta2.height) continue;
7841
7876
  const got = meta2.width / meta2.height;
7842
7877
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -7856,12 +7891,26 @@ function buildServer(opts) {
7856
7891
  watchdogFired = true;
7857
7892
  ctrl.abort();
7858
7893
  }, opts.nodeTimeoutMs ?? NODE_TIMEOUT_MS);
7894
+ const startedAt = Date.now();
7859
7895
  try {
7860
7896
  const result = await work(ctrl.signal);
7861
7897
  result.images = await normalizePngs(result.images);
7862
7898
  if (post) result.images = await post(result.images);
7863
7899
  if (expect) await assertAspect(result.images, expect);
7864
- core.store.completeNode(nodeId, result);
7900
+ core.store.completeNode(nodeId, { ...result, durationMs: Date.now() - startedAt });
7901
+ try {
7902
+ const sizes = [];
7903
+ for (const h of result.images) {
7904
+ const meta2 = await sharp8(core.images.read(h)).metadata();
7905
+ if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
7906
+ }
7907
+ const node = core.store.getNode(nodeId);
7908
+ if (node && sizes.length) {
7909
+ const brief = node.brief ?? {};
7910
+ core.store.setBrief(nodeId, { ...brief, rendered: { sizes } });
7911
+ }
7912
+ } catch {
7913
+ }
7865
7914
  core.ledger.recordCost(engineId, nodeId, result.costUsd);
7866
7915
  } catch (err) {
7867
7916
  if (watchdogFired) core.store.failNode(nodeId, "generation timed out after 10 minutes");
@@ -7905,6 +7954,7 @@ function buildServer(opts) {
7905
7954
  let inheritedTokens = [];
7906
7955
  let inheritedAttachments = [];
7907
7956
  let editScope = "global";
7957
+ let editRemoval = false;
7908
7958
  const extraWarnings = [];
7909
7959
  let expandPlan = null;
7910
7960
  let expandSourceHash = null;
@@ -7933,9 +7983,11 @@ function buildServer(opts) {
7933
7983
  );
7934
7984
  inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
7935
7985
  }
7936
- editScope = scopeOfInstruction(
7986
+ const verdict = scopeOfInstruction(
7937
7987
  brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
7938
- ).scope;
7988
+ );
7989
+ editScope = verdict.scope;
7990
+ editRemoval = verdict.removal ?? false;
7939
7991
  }
7940
7992
  compiled2 = compileBrief(brief, {
7941
7993
  brand: brandJson,
@@ -7943,7 +7995,7 @@ function buildServer(opts) {
7943
7995
  engineCaps: engine.capabilities(),
7944
7996
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
7945
7997
  templateById: sceneById,
7946
- ...kind === "edit" ? { mode: "edit", editScope, inheritedIdentity: inheritedTokens.length > 0 } : {}
7998
+ ...kind === "edit" ? { mode: "edit", editScope, editRemoval, inheritedIdentity: inheritedTokens.length > 0 } : {}
7947
7999
  });
7948
8000
  if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
7949
8001
  if (inheritedTokens.length) {
@@ -8041,7 +8093,7 @@ function buildServer(opts) {
8041
8093
  `${engine.capabilities().displayName} cannot carry reference images, so the identity rides on the source frame alone.`
8042
8094
  );
8043
8095
  const srcBuf = core.images.read(String(srcHash));
8044
- const srcMeta = await sharp6(srcBuf).metadata();
8096
+ const srcMeta = await sharp8(srcBuf).metadata();
8045
8097
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
8046
8098
  if (srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
8047
8099
  expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",