scenri 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serve.js CHANGED
@@ -10,7 +10,7 @@ import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, write
10
10
  import { fileURLToPath } from 'url';
11
11
  import { readFile, copyFile, readdir, mkdtemp, rm, writeFile } from 'fs/promises';
12
12
  import { spawn } from 'child_process';
13
- import sharp5 from 'sharp';
13
+ import sharp6 from 'sharp';
14
14
  import Fastify from 'fastify';
15
15
  import fastifyStatic from '@fastify/static';
16
16
  import fastifyMultipart from '@fastify/multipart';
@@ -702,8 +702,20 @@ function createStore(db) {
702
702
  setKept(id, kept) {
703
703
  db.prepare("UPDATE nodes SET kept=? WHERE id=?").run(kept ? 1 : 0, id);
704
704
  },
705
+ /**
706
+ * Archiving also clears the keeper mark.
707
+ *
708
+ * The two flags were independent, and the Keepers lens reads the live list,
709
+ * so archiving a keeper removed it from Keepers and from the Keepers count
710
+ * without saying anything: the star stayed lit on a shot that was no longer
711
+ * in the shortlist it claimed to be in. Keepers is a live shortlist and
712
+ * archive means put away, so one clears the other and the two can never
713
+ * disagree. Restoring does not re-star: the judgement was made once and
714
+ * putting the shot back is not the same as making it again.
715
+ */
705
716
  setArchived(id, archived) {
706
- db.prepare("UPDATE nodes SET archived=? WHERE id=?").run(archived ? 1 : 0, id);
717
+ if (archived) db.prepare("UPDATE nodes SET archived=1, kept=0 WHERE id=?").run(id);
718
+ else db.prepare("UPDATE nodes SET archived=0 WHERE id=?").run(id);
707
719
  },
708
720
  /** Permanent. Orphans any children rather than blocking or cascading —
709
721
  * same technique collapseProjects already uses for a surplus root. */
@@ -2252,15 +2264,29 @@ function createCodexEngine(opts) {
2252
2264
  })
2253
2265
  );
2254
2266
  const results = new Array(count);
2267
+ const failures = [];
2255
2268
  let next = 0;
2256
2269
  const workers = Array.from({ length: Math.min(3, count) }, async () => {
2257
2270
  while (next < count) {
2258
2271
  const i = next++;
2259
- results[i] = await jobs[i]();
2272
+ try {
2273
+ results[i] = await jobs[i]();
2274
+ } catch (err) {
2275
+ if (signal?.aborted) throw err;
2276
+ results[i] = [];
2277
+ failures.push(err);
2278
+ }
2260
2279
  }
2261
2280
  });
2262
2281
  await Promise.all(workers);
2263
- return { images: results.flat(), costUsd: 0 };
2282
+ const images = results.flat();
2283
+ if (!images.length && failures.length) throw failures[0];
2284
+ if (failures.length) {
2285
+ console.warn(
2286
+ `codex: ${failures.length} of ${count} variants failed, keeping ${images.length}: ${String(failures[0]?.message ?? failures[0])}`
2287
+ );
2288
+ }
2289
+ return { images, costUsd: 0 };
2264
2290
  },
2265
2291
  async edit(req, signal) {
2266
2292
  return withWorkDir(async (dir) => {
@@ -2269,13 +2295,17 @@ function createCodexEngine(opts) {
2269
2295
  const editRoles = req.referenceRoles ?? [];
2270
2296
  const refLines = [];
2271
2297
  for (let i = 0; i < editRefs.length; i++) {
2272
- const role = editRoles[i] ?? "product";
2298
+ const role = editRoles[i] ?? "reference";
2273
2299
  const name = `${role}-${i + 1}.png`;
2274
2300
  await copyFile(editRefs[i], join(dir, name));
2275
2301
  refLines.push(`${name} shows ${EDIT_REFERENCE_ROLE_DIRECTIVE[role]}`);
2276
2302
  }
2277
2303
  const promptText = `Edit input.png using your image generation/editing tool: ${req.instruction}.` + (refLines.length ? ` ${refLines.join(". ")}.` : "") + ` Do not browse the web or explore files. Save the result in the current directory as out-1.png (you may run the commands needed to save and resize it). Nothing else.`;
2278
- await runCodex(execArgs(dir, promptText), signal);
2304
+ const args = execArgs(dir, promptText);
2305
+ for (const name of ["input.png", ...refLines.map((_, i) => `${editRoles[i] ?? "reference"}-${i + 1}.png`)]) {
2306
+ args.splice(args.length - 1, 0, `--image=${join(dir, name)}`);
2307
+ }
2308
+ await runCodex(args, signal);
2279
2309
  const images = await collectImages(dir);
2280
2310
  return { images, costUsd: 0 };
2281
2311
  });
@@ -2330,7 +2360,7 @@ function createDemoEngine(saveImage) {
2330
2360
  <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>
2331
2361
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
2332
2362
  </svg>`;
2333
- return sharp5(Buffer.from(svg)).png().toBuffer();
2363
+ return sharp6(Buffer.from(svg)).png().toBuffer();
2334
2364
  }
2335
2365
  return {
2336
2366
  capabilities() {
@@ -2493,7 +2523,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
2493
2523
  for (const [slot, angle] of PRESENTER_ANGLES) {
2494
2524
  const path = presenterRefPath(templatesRoot, presenter.id, slot);
2495
2525
  if (!existsSync(path)) continue;
2496
- const png = await sharp5(readFileSync(path)).png().toBuffer();
2526
+ const png = await sharp6(readFileSync(path)).png().toBuffer();
2497
2527
  const hash = core.images.save(png);
2498
2528
  shots.push({ file: `asset:${hash}`, angle, locked: true });
2499
2529
  }
@@ -2587,7 +2617,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
2587
2617
  for (const angle of angles) {
2588
2618
  const path = demoProductRefPath(templatesRoot, product.id, angle);
2589
2619
  if (!existsSync(path)) continue;
2590
- const png = await sharp5(readFileSync(path)).png().toBuffer();
2620
+ const png = await sharp6(readFileSync(path)).png().toBuffer();
2591
2621
  const hash = core.images.save(png);
2592
2622
  shots.push({ file: `asset:${hash}`, angle, locked: true });
2593
2623
  }
@@ -2643,6 +2673,15 @@ function productFidelityDirective(attached) {
2643
2673
  }
2644
2674
  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.";
2645
2675
  }
2676
+ function editPreservationDirective(scope) {
2677
+ if (scope === "local") {
2678
+ 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.";
2679
+ }
2680
+ 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.";
2681
+ }
2682
+ function inheritedIdentityDirective() {
2683
+ 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.";
2684
+ }
2646
2685
  function shotSpecifiesCamera(text) {
2647
2686
  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(
2648
2687
  text
@@ -2810,7 +2849,7 @@ function compileBrief(brief, ctx) {
2810
2849
  attachments.push({ role: "character", id: c.id, label: c.name, hash: chash, essential: i === 0 });
2811
2850
  });
2812
2851
  personDirectives.push(
2813
- "The attached person reference is the same person every time: match their face, facial structure, skin, hair and build exactly. Their outfit, pose, background and lighting are neutral studio capture conditions, not styling direction: dress and style them for this shot, to a commercial standard, following any wardrobe the direction itself specifies."
2852
+ "The attached person reference is the same person every time: match their face, facial structure, skin, hair and build exactly. Their outfit, pose, background and lighting are neutral studio capture conditions, not styling direction: dress and style them for this shot, to a commercial standard, following any wardrobe the direction itself specifies. Where the direction specifies none, dress them for the place and the occasion the frame shows, and never return them to the plain base layers they were photographed in."
2814
2853
  );
2815
2854
  if (c.identityNotes) personDirectives.push(String(c.identityNotes));
2816
2855
  if (c.negativeConstraints?.length)
@@ -2912,6 +2951,10 @@ function compileBrief(brief, ctx) {
2912
2951
  "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."
2913
2952
  ] : [];
2914
2953
  const brandLines = brandRuleDirectives(ctx.brand);
2954
+ const preservation = ctx.mode === "edit" ? [
2955
+ editPreservationDirective(ctx.editScope ?? "global"),
2956
+ ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : []
2957
+ ] : [];
2915
2958
  const allDirectives = [
2916
2959
  ...productDirectives,
2917
2960
  ...personDirectives,
@@ -2919,7 +2962,8 @@ function compileBrief(brief, ctx) {
2919
2962
  ...otherDirectives,
2920
2963
  ...cameraDirectives,
2921
2964
  ...brandLines,
2922
- ...guard
2965
+ ...guard,
2966
+ ...preservation
2923
2967
  ];
2924
2968
  if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
2925
2969
  const ROLE_PRIORITY = {
@@ -4912,8 +4956,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
4912
4956
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
4913
4957
  return;
4914
4958
  }
4915
- const png = await sharp5(buf).rotate().png().toBuffer();
4916
- const meta = await sharp5(png).metadata();
4959
+ const png = await sharp6(buf).rotate().png().toBuffer();
4960
+ const meta = await sharp6(png).metadata();
4917
4961
  const hash = core.images.save(png);
4918
4962
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
4919
4963
  width: meta.width,
@@ -5323,7 +5367,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
5323
5367
  return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
5324
5368
  }
5325
5369
  async function edgeBarGeometry(buf) {
5326
- const { data, info } = await sharp5(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
5370
+ const { data, info } = await sharp6(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
5327
5371
  const W = info.width;
5328
5372
  const H = info.height;
5329
5373
  const scan = (len, cross, at) => {
@@ -5377,7 +5421,7 @@ async function trimEdgeBars(core, hash) {
5377
5421
  const width = g.right - g.left + 1;
5378
5422
  const height = g.bottom - g.top + 1;
5379
5423
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
5380
- const png = await sharp5(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
5424
+ const png = await sharp6(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
5381
5425
  return core.images.save(png);
5382
5426
  } catch {
5383
5427
  return hash;
@@ -5399,11 +5443,11 @@ async function avatarCrop(core, hash) {
5399
5443
  async function crop(core, hash, region) {
5400
5444
  if (!hash || !core.images.has(hash)) return void 0;
5401
5445
  try {
5402
- const meta = await sharp5(core.images.read(hash)).metadata();
5446
+ const meta = await sharp6(core.images.read(hash)).metadata();
5403
5447
  const w = meta.width ?? 0;
5404
5448
  const h = meta.height ?? 0;
5405
5449
  if (!w || !h) return void 0;
5406
- const png = await sharp5(core.images.read(hash)).extract(region(w, h)).png().toBuffer();
5450
+ const png = await sharp6(core.images.read(hash)).extract(region(w, h)).png().toBuffer();
5407
5451
  return core.images.save(png);
5408
5452
  } catch {
5409
5453
  return void 0;
@@ -5557,6 +5601,215 @@ function registerAccessGuard(app, opts = {}) {
5557
5601
  }
5558
5602
  });
5559
5603
  }
5604
+
5605
+ // src/editIdentity.ts
5606
+ var MAX_HOPS = 8;
5607
+ var tokensOf = (node) => {
5608
+ const t = node?.brief?.tokens;
5609
+ return Array.isArray(t) ? t : [];
5610
+ };
5611
+ function inheritedIdentityTokens(parentId, getNode) {
5612
+ let id = parentId;
5613
+ for (let hop = 0; hop < MAX_HOPS && id; hop++) {
5614
+ const node = getNode(id);
5615
+ if (!node || node.kind === "root") return [];
5616
+ const identity = tokensOf(node).filter((t) => t.t === "product" || t.t === "character" || t.t === "mark");
5617
+ if (identity.length) return identity;
5618
+ id = node.parentId;
5619
+ }
5620
+ return [];
5621
+ }
5622
+
5623
+ // src/editScopeRules.ts
5624
+ var GLOBAL_CUES = [
5625
+ ["light", /\b(light|lighting|lit|relight|exposure|white ?balance|backlit|shadows everywhere)\b/i],
5626
+ [
5627
+ "grade",
5628
+ /\b(grade|grading|colou?r ?grade|tone|tint|saturation|contrast|filmic|film stock|grain|black and white|monochrome|sepia)\b/i
5629
+ ],
5630
+ ["time", /\b(night|nighttime|daytime|dusk|dawn|sunset|sunrise|golden hour|midday|morning|evening)\b/i],
5631
+ ["weather", /\b(rain|rainy|snow|snowy|fog|foggy|misty|storm|overcast|sunny)\b/i],
5632
+ ["scene", /\b(scene|background|backdrop|environment|location|setting|studio|indoors|outdoors)\b/i],
5633
+ [
5634
+ "camera",
5635
+ /\b(angle|zoom|closer|wider|crop|reframe|recompose|framing|perspective|lens|\d{2,3} ?mm|shot from|low angle|high angle|overhead|top ?down|eye ?level)\b/i
5636
+ ],
5637
+ ["mood", /\b(mood|vibe|feel|editorial|cinematic|dramatic|moody|minimal|luxurious|playful|clinical)\b/i],
5638
+ [
5639
+ "comparative",
5640
+ /\b(warmer|cooler|brighter|darker|softer|harder|punchier|richer|flatter|sharper|moodier|more|less)\b/i
5641
+ ],
5642
+ [
5643
+ "restage",
5644
+ /\b(regenerate|redo|re-?do|start over|another take|different (take|composition|version)|try again|new (version|take))\b/i
5645
+ ],
5646
+ ["whole", /\b(overall|whole (image|frame|shot|thing)|entire|everything|all of it|throughout)\b/i],
5647
+ ["wardrobe", /\b(outfit|wardrobe|clothes|clothing|dress(ed)?|styling)\b/i],
5648
+ ["pose", /\b(pose|posture|expression|smile|smiling|looking)\b/i]
5649
+ ];
5650
+ var LOCAL_VERB = /\b(add|remove|delete|erase|take out|get rid of|replace|swap|clean up|fix|repair|straighten|hide|cover)\b/i;
5651
+ var DEFINITE_OBJECT = /\b(the|that|this|his|her|their|its|a|an|one)\b/i;
5652
+ 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;
5653
+ var COORDINATION = /\b(and|then|also|plus)\b|[;]/i;
5654
+ var MAX_LOCAL_WORDS = 16;
5655
+ function scopeOfInstruction(text) {
5656
+ const s = String(text ?? "").trim();
5657
+ if (!s) return { scope: "global", matched: ["empty"] };
5658
+ const globals = GLOBAL_CUES.filter(([, re]) => re.test(s)).map(([name]) => name);
5659
+ if (globals.length) return { scope: "global", matched: globals };
5660
+ const words = s.split(/\s+/).filter(Boolean);
5661
+ if (words.length > MAX_LOCAL_WORDS) return { scope: "global", matched: ["long"] };
5662
+ const clauses = s.split(COORDINATION).filter((c) => c.trim().length > 0);
5663
+ if (clauses.length > 1 && clauses.filter((c) => LOCAL_VERB.test(c)).length > 1) {
5664
+ return { scope: "global", matched: ["multiple"] };
5665
+ }
5666
+ const matched = [];
5667
+ if (REGION_CUE.test(s)) matched.push("region");
5668
+ if (LOCAL_VERB.test(s) && DEFINITE_OBJECT.test(s)) matched.push("verb+object");
5669
+ if (!matched.length) return { scope: "global", matched: ["no local cue"] };
5670
+ return { scope: "local", matched };
5671
+ }
5672
+
5673
+ // src/expandRules.ts
5674
+ var round8 = (n) => Math.max(8, Math.round(n / 8) * 8);
5675
+ function planExpand(source, targetRatio) {
5676
+ if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
5677
+ const current = source.width / source.height;
5678
+ if (Math.abs(current - targetRatio) / targetRatio < 0.01) return null;
5679
+ if (targetRatio > current) {
5680
+ const width = round8(source.height * targetRatio);
5681
+ if (width <= source.width) return null;
5682
+ return {
5683
+ width,
5684
+ height: source.height,
5685
+ left: Math.round((width - source.width) / 2),
5686
+ top: 0,
5687
+ axis: "width"
5688
+ };
5689
+ }
5690
+ const height = round8(source.width / targetRatio);
5691
+ if (height <= source.height) return null;
5692
+ return {
5693
+ width: source.width,
5694
+ height,
5695
+ left: 0,
5696
+ top: Math.round((height - source.height) / 2),
5697
+ axis: "height"
5698
+ };
5699
+ }
5700
+ function expandInstruction(plan, direction) {
5701
+ const where = plan.axis === "width" ? "to the left and right" : "above and below";
5702
+ 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()}` : ""}`;
5703
+ }
5704
+ async function expandCanvas(source, plan) {
5705
+ 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();
5706
+ return sharp6(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
5707
+ }
5708
+ async function compositeExpand(engineImage, source, plan) {
5709
+ const meta = await sharp6(engineImage).metadata();
5710
+ const want = plan.width / plan.height;
5711
+ const got = meta.width && meta.height ? meta.width / meta.height : 0;
5712
+ const sameOrientation = got > 0 && got >= 1 === want >= 1;
5713
+ const aligned = sameOrientation;
5714
+ const surround = aligned ? await sharp6(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
5715
+ const image = await sharp6(surround).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
5716
+ return { image, aligned };
5717
+ }
5718
+ async function expandCanvasBedOnly(source, plan) {
5719
+ 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();
5720
+ }
5721
+ async function driftDiff(a, b) {
5722
+ const metaA = await sharp6(a).metadata();
5723
+ const metaB = await sharp6(b).metadata();
5724
+ const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
5725
+ const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
5726
+ const [rawA, rawB] = await Promise.all(
5727
+ [a, b].map((buf) => sharp6(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
5728
+ );
5729
+ const out = new PNG({ width, height });
5730
+ const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
5731
+ return {
5732
+ score: changed / (width * height),
5733
+ heatmap: PNG.sync.write(out),
5734
+ width,
5735
+ height
5736
+ };
5737
+ }
5738
+ async function changeMask(a, b, cap2 = 1024) {
5739
+ const metaA = await sharp6(a).metadata();
5740
+ const width = Math.min(metaA.width ?? 1, cap2);
5741
+ const height = Math.min(metaA.height ?? 1, cap2);
5742
+ const [rawA, rawB] = await Promise.all(
5743
+ [a, b].map((buf) => sharp6(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
5744
+ );
5745
+ const out = new PNG({ width, height });
5746
+ pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
5747
+ const mask = Buffer.alloc(width * height);
5748
+ let changed = 0;
5749
+ let minX = width;
5750
+ let minY = height;
5751
+ let maxX = -1;
5752
+ let maxY = -1;
5753
+ for (let i = 0; i < width * height; i++) {
5754
+ if (out.data[i * 4 + 3] > 0) {
5755
+ mask[i] = 255;
5756
+ changed++;
5757
+ const x = i % width;
5758
+ const y = i / width | 0;
5759
+ if (x < minX) minX = x;
5760
+ if (x > maxX) maxX = x;
5761
+ if (y < minY) minY = y;
5762
+ if (y > maxY) maxY = y;
5763
+ }
5764
+ }
5765
+ const boxArea = maxX < 0 ? 0 : (maxX - minX + 1) * (maxY - minY + 1);
5766
+ return {
5767
+ mask,
5768
+ width,
5769
+ height,
5770
+ changed: changed / (width * height),
5771
+ spread: boxArea / (width * height)
5772
+ };
5773
+ }
5774
+
5775
+ // src/localEditRules.ts
5776
+ var MIN_CHANGED = 5e-4;
5777
+ var MAX_CHANGED = 0.25;
5778
+ var MAX_SPREAD = 0.85;
5779
+ function judgeChange(shape) {
5780
+ if (!(shape.changed > 0) || shape.changed < MIN_CHANGED) return "no-change";
5781
+ if (shape.changed > MAX_CHANGED) return "too-much-changed";
5782
+ if (shape.spread > MAX_SPREAD && shape.changed < 0.2) return "scattered";
5783
+ return "composited";
5784
+ }
5785
+ function dilationFor(longEdge) {
5786
+ return Math.max(6, Math.round(longEdge * 0.02));
5787
+ }
5788
+
5789
+ // src/localEdit.ts
5790
+ async function preserveOutsideChange(source, edited) {
5791
+ try {
5792
+ const srcMeta = await sharp6(source).metadata();
5793
+ const outMeta = await sharp6(edited).metadata();
5794
+ if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
5795
+ return { image: edited, outcome: "error", changed: 0 };
5796
+ const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
5797
+ if (!sameShape) return { image: edited, outcome: "shape-changed", changed: 0 };
5798
+ const shape = await changeMask(source, edited);
5799
+ const outcome = judgeChange(shape);
5800
+ if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
5801
+ const r = dilationFor(Math.max(shape.width, shape.height));
5802
+ 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();
5803
+ const editedRgb = await sharp6(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
5804
+ const masked = await sharp6(editedRgb, {
5805
+ raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
5806
+ }).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
5807
+ const image = await sharp6(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
5808
+ return { image, outcome: "composited", changed: shape.changed };
5809
+ } catch {
5810
+ return { image: edited, outcome: "error", changed: 0 };
5811
+ }
5812
+ }
5560
5813
  function joinNames(labels) {
5561
5814
  const uniq = [...new Set(labels)];
5562
5815
  if (uniq.length <= 1) return uniq[0] ?? "";
@@ -5580,7 +5833,7 @@ var assetHash2 = (ref) => {
5580
5833
  };
5581
5834
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
5582
5835
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
5583
- var toPng = (buf) => sharp5(buf).png().toBuffer();
5836
+ var toPng = (buf) => sharp6(buf).rotate().png().toBuffer();
5584
5837
  var COST_PROBE = {
5585
5838
  prompt: "",
5586
5839
  brand: { brand: {}, assetPaths: {} },
@@ -5589,7 +5842,7 @@ var COST_PROBE = {
5589
5842
  count: 1
5590
5843
  };
5591
5844
  var MARK_MAX_EDGE = 2048;
5592
- var toMarkPng = (buf) => sharp5(buf, { density: 384 }).resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
5845
+ var toMarkPng = (buf) => sharp6(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
5593
5846
  var readImagePart = async (core, req, normalize2) => {
5594
5847
  const part = await req.file();
5595
5848
  if (!part) return { error: "multipart file field required" };
@@ -5766,7 +6019,7 @@ async function vibrantColor(input) {
5766
6019
  let data;
5767
6020
  let channels;
5768
6021
  try {
5769
- const out = await sharp5(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
6022
+ const out = await sharp6(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
5770
6023
  data = out.data;
5771
6024
  channels = out.info.channels;
5772
6025
  } catch {
@@ -5789,7 +6042,7 @@ async function vibrantColor(input) {
5789
6042
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
5790
6043
  if (best.score <= 0) {
5791
6044
  try {
5792
- const { dominant } = await sharp5(input).stats();
6045
+ const { dominant } = await sharp6(input).stats();
5793
6046
  return toHex(dominant.r, dominant.g, dominant.b);
5794
6047
  } catch {
5795
6048
  return null;
@@ -6321,23 +6574,6 @@ function registerCodexSetupRoutes(app, deps) {
6321
6574
  }
6322
6575
  });
6323
6576
  }
6324
- async function driftDiff(a, b) {
6325
- const metaA = await sharp5(a).metadata();
6326
- const metaB = await sharp5(b).metadata();
6327
- const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
6328
- const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
6329
- const [rawA, rawB] = await Promise.all(
6330
- [a, b].map((buf) => sharp5(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
6331
- );
6332
- const out = new PNG({ width, height });
6333
- const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
6334
- return {
6335
- score: changed / (width * height),
6336
- heatmap: PNG.sync.write(out),
6337
- width,
6338
- height
6339
- };
6340
- }
6341
6577
  var EXPORT_PRESETS = [
6342
6578
  { id: "original", label: "Original", width: null, height: null },
6343
6579
  { id: "ig-post", label: "Instagram post 1080\xD71080", width: 1080, height: 1080 },
@@ -6349,7 +6585,7 @@ async function buildExportZip(image, baseName, presetIds) {
6349
6585
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
6350
6586
  if (chosen.length === 0) throw new Error("No valid export presets selected");
6351
6587
  for (const p of chosen) {
6352
- const buf = p.width && p.height ? await sharp5(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
6588
+ const buf = p.width && p.height ? await sharp6(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
6353
6589
  zip.file(`${baseName}-${p.id}.png`, buf);
6354
6590
  }
6355
6591
  return zip.generateAsync({ type: "nodebuffer" });
@@ -6510,7 +6746,7 @@ function registerImageRoutes(app, deps) {
6510
6746
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
6511
6747
  const buf = await part.toBuffer();
6512
6748
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
6513
- const png = await sharp5(buf).png().toBuffer();
6749
+ const png = await sharp6(buf).rotate().png().toBuffer();
6514
6750
  return { hash: core.images.save(png) };
6515
6751
  });
6516
6752
  app.post("/api/diff", async (req, reply) => {
@@ -6545,6 +6781,29 @@ function registerImageRoutes(app, deps) {
6545
6781
 
6546
6782
  // src/release/notes.data.ts
6547
6783
  var RELEASES = [
6784
+ {
6785
+ version: "0.4.0",
6786
+ date: "2026-08-23",
6787
+ title: "Refining a shot keeps the shot.",
6788
+ sections: [
6789
+ {
6790
+ heading: "Refining",
6791
+ body: "Asking for one change now makes one change. Adding a prop or removing an object keeps the rest of the photograph exactly as it was, down to the pixel, instead of returning a fresh interpretation of the same idea. A refinement also carries the product and the presenter it started from, so identity holds through a thread of edits, and a request that genuinely affects the whole frame, like new lighting or a different time of day, is still free to change it."
6792
+ },
6793
+ {
6794
+ heading: "Expand",
6795
+ body: "A finished shot can be grown into another shape. Choosing a new aspect ratio while refining extends the picture you have and generates only the new margin, so the original is kept at its own resolution rather than being replaced by a different take. Nothing is ever cropped to fit."
6796
+ },
6797
+ {
6798
+ heading: "Presenters",
6799
+ body: "The plain studio layers a presenter is photographed in no longer turn up as the outfit in a finished shot. Where the direction names no wardrobe, they are dressed for the place and the occasion in the frame."
6800
+ },
6801
+ {
6802
+ heading: "Fixes",
6803
+ body: "The row of takes under a shot no longer stretches portrait and landscape images into squares. A shot card states what it is in one row, so set names no longer print over the version count and the Refine button, and the keeper star can now be used to keep a shot rather than only to un-keep one. Photos uploaded from a phone are stored the right way up. A run that loses one variant keeps the others instead of throwing all of them away, and the resolution setting no longer promises pixel counts on an engine that renders at its own size."
6804
+ }
6805
+ ]
6806
+ },
6548
6807
  {
6549
6808
  version: "0.3.5",
6550
6809
  date: "2026-08-21",
@@ -7232,14 +7491,14 @@ function buildServer(opts) {
7232
7491
  const out = [];
7233
7492
  for (const h of images) {
7234
7493
  const buf = core.images.read(h);
7235
- out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp5(buf).png().toBuffer()));
7494
+ out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp6(buf).png().toBuffer()));
7236
7495
  }
7237
7496
  return out;
7238
7497
  }
7239
7498
  async function assertAspect(images, expect) {
7240
7499
  const want = expect.width / expect.height;
7241
7500
  for (const h of images) {
7242
- const meta2 = await sharp5(core.images.read(h)).metadata();
7501
+ const meta2 = await sharp6(core.images.read(h)).metadata();
7243
7502
  if (!meta2.width || !meta2.height) continue;
7244
7503
  const got = meta2.width / meta2.height;
7245
7504
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -7248,7 +7507,7 @@ function buildServer(opts) {
7248
7507
  );
7249
7508
  }
7250
7509
  }
7251
- async function runNode(nodeId, engine, estimate, work, expect) {
7510
+ async function runNode(nodeId, engine, estimate, work, expect, post) {
7252
7511
  const engineId = engine.capabilities().id;
7253
7512
  reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
7254
7513
  const ctrl = new AbortController();
@@ -7256,6 +7515,7 @@ function buildServer(opts) {
7256
7515
  try {
7257
7516
  const result = await work(ctrl.signal);
7258
7517
  result.images = await normalizePngs(result.images);
7518
+ if (post) result.images = await post(result.images);
7259
7519
  if (expect) await assertAspect(result.images, expect);
7260
7520
  core.store.completeNode(nodeId, result);
7261
7521
  core.ledger.recordCost(engineId, nodeId, result.costUsd);
@@ -7296,6 +7556,12 @@ function buildServer(opts) {
7296
7556
  const resolvedParentId = parentId ? String(parentId) : rootNode.id;
7297
7557
  const ctx = brandContext(core, project.brandId);
7298
7558
  let compiled2 = null;
7559
+ let inheritedTokens = [];
7560
+ let inheritedAttachments = [];
7561
+ let editScope = "global";
7562
+ const extraWarnings = [];
7563
+ let expandPlan = null;
7564
+ let expandSourceHash = null;
7299
7565
  if (brief && Array.isArray(brief.tokens)) {
7300
7566
  const briefErrors = validateBrief(brief);
7301
7567
  if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
@@ -7313,14 +7579,34 @@ function buildServer(opts) {
7313
7579
  brief.tokens
7314
7580
  );
7315
7581
  const sceneById = sceneFor(brandJson);
7582
+ if (kind === "edit") {
7583
+ const borrowed = inheritedIdentityTokens(resolvedParentId, (id) => core.store.getNode(id));
7584
+ if (borrowed.length) {
7585
+ const already = new Set(
7586
+ brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark").map((t) => JSON.stringify(t))
7587
+ );
7588
+ inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
7589
+ }
7590
+ editScope = scopeOfInstruction(
7591
+ brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
7592
+ ).scope;
7593
+ }
7316
7594
  compiled2 = compileBrief(brief, {
7317
7595
  brand: brandJson,
7318
7596
  images: core.images,
7319
7597
  engineCaps: engine.capabilities(),
7320
7598
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
7321
- templateById: sceneById
7599
+ templateById: sceneById,
7600
+ ...kind === "edit" ? { mode: "edit", editScope, inheritedIdentity: inheritedTokens.length > 0 } : {}
7322
7601
  });
7323
7602
  if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
7603
+ if (inheritedTokens.length) {
7604
+ const identity = compileBrief(
7605
+ { tokens: inheritedTokens },
7606
+ { brand: brandJson, images: core.images, engineCaps: engine.capabilities(), templateById: sceneById }
7607
+ );
7608
+ inheritedAttachments = identity.attachments.filter((a) => a.essential);
7609
+ }
7324
7610
  }
7325
7611
  let finalPrompt = String(prompt ?? "");
7326
7612
  let referenceImages;
@@ -7400,13 +7686,31 @@ function buildServer(opts) {
7400
7686
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
7401
7687
  if (!engine.capabilities().supportsEdit)
7402
7688
  return reply.status(400).send({ error: "engine does not support edits" });
7403
- const cap2 = engine.capabilities().maxReferenceImages;
7689
+ const cap2 = Math.max(0, engine.capabilities().maxReferenceImages - 1);
7690
+ const own = (referenceImages ?? []).map((path, i) => ({ path, role: referenceRoles?.[i] }));
7691
+ const borrowedRefs = inheritedAttachments.map((a) => ({ path: core.images.pathFor(a.hash), role: a.role })).filter((r) => !own.some((o) => o.path === r.path));
7692
+ const editRefs = [...own, ...borrowedRefs].slice(0, cap2);
7693
+ if (cap2 === 0 && borrowedRefs.length)
7694
+ extraWarnings.push(
7695
+ `${engine.capabilities().displayName} cannot carry reference images, so the identity rides on the source frame alone.`
7696
+ );
7697
+ const srcBuf = core.images.read(String(srcHash));
7698
+ const srcMeta = await sharp6(srcBuf).metadata();
7699
+ if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
7700
+ if (srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
7701
+ expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
7702
+ }
7703
+ if (expandPlan) {
7704
+ const canvas = await expandCanvas(srcBuf, expandPlan);
7705
+ expandSourceHash = core.images.save(canvas);
7706
+ expectShape = { width: expandPlan.width, height: expandPlan.height };
7707
+ }
7404
7708
  const editReq = {
7405
- instruction: finalPrompt,
7406
- sourceImage: core.images.pathFor(String(srcHash)),
7709
+ instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
7710
+ sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
7407
7711
  brand: ctx,
7408
- ...referenceImages && cap2 > 0 ? { referenceImages: referenceImages.slice(0, cap2) } : {},
7409
- ...referenceRoles && cap2 > 0 ? { referenceRoles: referenceRoles.slice(0, cap2) } : {}
7712
+ ...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
7713
+ ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {}
7410
7714
  };
7411
7715
  estimate = await engine.costEstimate(editReq);
7412
7716
  work = (signal) => engine.edit(editReq, signal);
@@ -7420,10 +7724,31 @@ function buildServer(opts) {
7420
7724
  engineId: String(engineId)
7421
7725
  });
7422
7726
  if (brief) core.store.setBrief(node.id, editedFrom ? { ...brief, sourceImage: editedFrom } : brief);
7423
- void runNode(node.id, engine, estimate, work, expectShape).catch(
7727
+ const plan = expandPlan;
7728
+ const original = editedFrom ? core.images.read(editedFrom) : null;
7729
+ const localScope = kind === "edit" && !plan && editScope === "local" && original;
7730
+ const post = plan ? async (images) => {
7731
+ const out = [];
7732
+ for (const h of images) {
7733
+ const { image, aligned } = await compositeExpand(core.images.read(h), original, plan);
7734
+ if (!aligned) app.log.warn({ nodeId: node.id }, "expand: engine frame did not align, kept the bed");
7735
+ out.push(core.images.save(image));
7736
+ }
7737
+ return out;
7738
+ } : localScope ? async (images) => {
7739
+ const out = [];
7740
+ for (const h of images) {
7741
+ const { image, outcome, changed } = await preserveOutsideChange(original, core.images.read(h));
7742
+ app.log.info({ nodeId: node.id, outcome, changed }, "local edit");
7743
+ out.push(outcome === "composited" ? core.images.save(image) : h);
7744
+ }
7745
+ return out;
7746
+ } : void 0;
7747
+ void runNode(node.id, engine, estimate, work, expectShape, post).catch(
7424
7748
  (err) => app.log.error({ err }, "node run failed")
7425
7749
  );
7426
- return reply.status(202).send(compiled2?.warnings?.length ? { ...node, warnings: compiled2.warnings } : node);
7750
+ const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
7751
+ return reply.status(202).send(allWarnings.length ? { ...node, warnings: allWarnings } : node);
7427
7752
  });
7428
7753
  app.post("/api/nodes/:id/cancel", async (req, reply) => {
7429
7754
  const id = req.params.id;
@@ -7679,8 +8004,8 @@ async function verify() {
7679
8004
  const db = new Database2(":memory:");
7680
8005
  db.pragma("user_version");
7681
8006
  db.close();
7682
- const { default: sharp12 } = await import('sharp');
7683
- await sharp12({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
8007
+ const { default: sharp14 } = await import('sharp');
8008
+ await sharp14({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
7684
8009
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
7685
8010
  } catch (err) {
7686
8011
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));