scenri 0.3.4 → 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
@@ -1,5 +1,5 @@
1
1
  import { portBusyLines } from './chunk-WGIZJXNE.js';
2
- import { createUpdateChecker, classify, stageVersion, findNpm } from './chunk-GA7P7K2M.js';
2
+ import { createUpdateChecker, classify, stageVersion, findNpm } from './chunk-3W2RHBZC.js';
3
3
  import { readMeta, repoSlug } from './chunk-Y3ZPBPLP.js';
4
4
  import { newestStaged, compareSemver } from './chunk-4MAFHYAD.js';
5
5
  import { networkInterfaces, homedir, tmpdir } from 'os';
@@ -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. */
@@ -1857,11 +1869,17 @@ function execArgs(dir, promptText, effort = "low") {
1857
1869
  function createRunner(opts = {}) {
1858
1870
  const spawnImpl = opts.spawnImpl ?? spawn;
1859
1871
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
1872
+ const platform = opts.platform ?? process.platform;
1873
+ const winArg = (a) => `"${a.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/%/g, " percent ")}"`;
1874
+ const spawnCodex = (args) => platform === "win32" ? spawnImpl(["codex", ...args].map(winArg).join(" "), [], {
1875
+ stdio: ["ignore", "pipe", "pipe"],
1876
+ shell: true
1877
+ }) : spawnImpl("codex", args, { stdio: ["ignore", "pipe", "pipe"] });
1860
1878
  function run2(args, signal) {
1861
1879
  return new Promise((resolve, reject) => {
1862
1880
  let child;
1863
1881
  try {
1864
- child = spawnImpl("codex", args, { stdio: ["ignore", "pipe", "pipe"] });
1882
+ child = spawnCodex(args);
1865
1883
  } catch (err) {
1866
1884
  reject(new Error(`Failed to spawn codex: ${err.message}`));
1867
1885
  return;
@@ -1930,7 +1948,7 @@ function createRunner(opts = {}) {
1930
1948
  };
1931
1949
  let child;
1932
1950
  try {
1933
- child = spawnImpl("codex", args, { stdio: ["ignore", "pipe", "pipe"] });
1951
+ child = spawnCodex(args);
1934
1952
  } catch {
1935
1953
  done(false);
1936
1954
  return;
@@ -2108,6 +2126,7 @@ function stateFrom(avail) {
2108
2126
  }
2109
2127
  function createCodexSetup(opts = {}) {
2110
2128
  const spawnImpl = opts.spawnImpl ?? spawn;
2129
+ const platform = opts.platform ?? process.platform;
2111
2130
  const runner = createRunner(opts);
2112
2131
  const installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
2113
2132
  function run2(cmd, args, timeoutMs) {
@@ -2126,7 +2145,7 @@ function createCodexSetup(opts = {}) {
2126
2145
  done({ code: null, stderr, spawnError: `${cmd} timed out after ${timeoutMs}ms` });
2127
2146
  }, timeoutMs);
2128
2147
  try {
2129
- child = spawnImpl(cmd, args, { stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
2148
+ child = spawnImpl(cmd, args, { stdio: ["ignore", "pipe", "pipe"], shell: platform === "win32" });
2130
2149
  } catch (err) {
2131
2150
  done({ code: null, stderr, spawnError: err.message });
2132
2151
  return;
@@ -2159,7 +2178,7 @@ function createCodexSetup(opts = {}) {
2159
2178
  }
2160
2179
  return { ok: true };
2161
2180
  }
2162
- if (process.platform !== "win32" && /EACCES|permission denied/i.test(res.stderr)) {
2181
+ if (platform !== "win32" && /EACCES|permission denied/i.test(res.stderr)) {
2163
2182
  return {
2164
2183
  ok: false,
2165
2184
  fallbackCommand: INSTALL_COMMAND_SUDO,
@@ -2245,15 +2264,29 @@ function createCodexEngine(opts) {
2245
2264
  })
2246
2265
  );
2247
2266
  const results = new Array(count);
2267
+ const failures = [];
2248
2268
  let next = 0;
2249
2269
  const workers = Array.from({ length: Math.min(3, count) }, async () => {
2250
2270
  while (next < count) {
2251
2271
  const i = next++;
2252
- 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
+ }
2253
2279
  }
2254
2280
  });
2255
2281
  await Promise.all(workers);
2256
- 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 };
2257
2290
  },
2258
2291
  async edit(req, signal) {
2259
2292
  return withWorkDir(async (dir) => {
@@ -2262,13 +2295,17 @@ function createCodexEngine(opts) {
2262
2295
  const editRoles = req.referenceRoles ?? [];
2263
2296
  const refLines = [];
2264
2297
  for (let i = 0; i < editRefs.length; i++) {
2265
- const role = editRoles[i] ?? "product";
2298
+ const role = editRoles[i] ?? "reference";
2266
2299
  const name = `${role}-${i + 1}.png`;
2267
2300
  await copyFile(editRefs[i], join(dir, name));
2268
2301
  refLines.push(`${name} shows ${EDIT_REFERENCE_ROLE_DIRECTIVE[role]}`);
2269
2302
  }
2270
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.`;
2271
- 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);
2272
2309
  const images = await collectImages(dir);
2273
2310
  return { images, costUsd: 0 };
2274
2311
  });
@@ -2323,7 +2360,7 @@ function createDemoEngine(saveImage) {
2323
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>
2324
2361
  <text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
2325
2362
  </svg>`;
2326
- return sharp5(Buffer.from(svg)).png().toBuffer();
2363
+ return sharp6(Buffer.from(svg)).png().toBuffer();
2327
2364
  }
2328
2365
  return {
2329
2366
  capabilities() {
@@ -2486,7 +2523,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
2486
2523
  for (const [slot, angle] of PRESENTER_ANGLES) {
2487
2524
  const path = presenterRefPath(templatesRoot, presenter.id, slot);
2488
2525
  if (!existsSync(path)) continue;
2489
- const png = await sharp5(readFileSync(path)).png().toBuffer();
2526
+ const png = await sharp6(readFileSync(path)).png().toBuffer();
2490
2527
  const hash = core.images.save(png);
2491
2528
  shots.push({ file: `asset:${hash}`, angle, locked: true });
2492
2529
  }
@@ -2580,7 +2617,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
2580
2617
  for (const angle of angles) {
2581
2618
  const path = demoProductRefPath(templatesRoot, product.id, angle);
2582
2619
  if (!existsSync(path)) continue;
2583
- const png = await sharp5(readFileSync(path)).png().toBuffer();
2620
+ const png = await sharp6(readFileSync(path)).png().toBuffer();
2584
2621
  const hash = core.images.save(png);
2585
2622
  shots.push({ file: `asset:${hash}`, angle, locked: true });
2586
2623
  }
@@ -2636,6 +2673,15 @@ function productFidelityDirective(attached) {
2636
2673
  }
2637
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.";
2638
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
+ }
2639
2685
  function shotSpecifiesCamera(text) {
2640
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(
2641
2687
  text
@@ -2803,7 +2849,7 @@ function compileBrief(brief, ctx) {
2803
2849
  attachments.push({ role: "character", id: c.id, label: c.name, hash: chash, essential: i === 0 });
2804
2850
  });
2805
2851
  personDirectives.push(
2806
- "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."
2807
2853
  );
2808
2854
  if (c.identityNotes) personDirectives.push(String(c.identityNotes));
2809
2855
  if (c.negativeConstraints?.length)
@@ -2905,6 +2951,10 @@ function compileBrief(brief, ctx) {
2905
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."
2906
2952
  ] : [];
2907
2953
  const brandLines = brandRuleDirectives(ctx.brand);
2954
+ const preservation = ctx.mode === "edit" ? [
2955
+ editPreservationDirective(ctx.editScope ?? "global"),
2956
+ ...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : []
2957
+ ] : [];
2908
2958
  const allDirectives = [
2909
2959
  ...productDirectives,
2910
2960
  ...personDirectives,
@@ -2912,7 +2962,8 @@ function compileBrief(brief, ctx) {
2912
2962
  ...otherDirectives,
2913
2963
  ...cameraDirectives,
2914
2964
  ...brandLines,
2915
- ...guard
2965
+ ...guard,
2966
+ ...preservation
2916
2967
  ];
2917
2968
  if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
2918
2969
  const ROLE_PRIORITY = {
@@ -4905,8 +4956,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
4905
4956
  errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
4906
4957
  return;
4907
4958
  }
4908
- const png = await sharp5(buf).rotate().png().toBuffer();
4909
- const meta = await sharp5(png).metadata();
4959
+ const png = await sharp6(buf).rotate().png().toBuffer();
4960
+ const meta = await sharp6(png).metadata();
4910
4961
  const hash = core.images.save(png);
4911
4962
  core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
4912
4963
  width: meta.width,
@@ -5316,7 +5367,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
5316
5367
  return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
5317
5368
  }
5318
5369
  async function edgeBarGeometry(buf) {
5319
- const { data, info } = await sharp5(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
5370
+ const { data, info } = await sharp6(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
5320
5371
  const W = info.width;
5321
5372
  const H = info.height;
5322
5373
  const scan = (len, cross, at) => {
@@ -5370,7 +5421,7 @@ async function trimEdgeBars(core, hash) {
5370
5421
  const width = g.right - g.left + 1;
5371
5422
  const height = g.bottom - g.top + 1;
5372
5423
  if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
5373
- 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();
5374
5425
  return core.images.save(png);
5375
5426
  } catch {
5376
5427
  return hash;
@@ -5392,11 +5443,11 @@ async function avatarCrop(core, hash) {
5392
5443
  async function crop(core, hash, region) {
5393
5444
  if (!hash || !core.images.has(hash)) return void 0;
5394
5445
  try {
5395
- const meta = await sharp5(core.images.read(hash)).metadata();
5446
+ const meta = await sharp6(core.images.read(hash)).metadata();
5396
5447
  const w = meta.width ?? 0;
5397
5448
  const h = meta.height ?? 0;
5398
5449
  if (!w || !h) return void 0;
5399
- 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();
5400
5451
  return core.images.save(png);
5401
5452
  } catch {
5402
5453
  return void 0;
@@ -5550,6 +5601,215 @@ function registerAccessGuard(app, opts = {}) {
5550
5601
  }
5551
5602
  });
5552
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
+ }
5553
5813
  function joinNames(labels) {
5554
5814
  const uniq = [...new Set(labels)];
5555
5815
  if (uniq.length <= 1) return uniq[0] ?? "";
@@ -5573,7 +5833,7 @@ var assetHash2 = (ref) => {
5573
5833
  };
5574
5834
  var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
5575
5835
  var LOGO_BACKGROUNDS = ["light", "dark", "any"];
5576
- var toPng = (buf) => sharp5(buf).png().toBuffer();
5836
+ var toPng = (buf) => sharp6(buf).rotate().png().toBuffer();
5577
5837
  var COST_PROBE = {
5578
5838
  prompt: "",
5579
5839
  brand: { brand: {}, assetPaths: {} },
@@ -5582,7 +5842,7 @@ var COST_PROBE = {
5582
5842
  count: 1
5583
5843
  };
5584
5844
  var MARK_MAX_EDGE = 2048;
5585
- 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();
5586
5846
  var readImagePart = async (core, req, normalize2) => {
5587
5847
  const part = await req.file();
5588
5848
  if (!part) return { error: "multipart file field required" };
@@ -5759,7 +6019,7 @@ async function vibrantColor(input) {
5759
6019
  let data;
5760
6020
  let channels;
5761
6021
  try {
5762
- 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 });
5763
6023
  data = out.data;
5764
6024
  channels = out.info.channels;
5765
6025
  } catch {
@@ -5782,7 +6042,7 @@ async function vibrantColor(input) {
5782
6042
  const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
5783
6043
  if (best.score <= 0) {
5784
6044
  try {
5785
- const { dominant } = await sharp5(input).stats();
6045
+ const { dominant } = await sharp6(input).stats();
5786
6046
  return toHex(dominant.r, dominant.g, dominant.b);
5787
6047
  } catch {
5788
6048
  return null;
@@ -6314,23 +6574,6 @@ function registerCodexSetupRoutes(app, deps) {
6314
6574
  }
6315
6575
  });
6316
6576
  }
6317
- async function driftDiff(a, b) {
6318
- const metaA = await sharp5(a).metadata();
6319
- const metaB = await sharp5(b).metadata();
6320
- const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
6321
- const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
6322
- const [rawA, rawB] = await Promise.all(
6323
- [a, b].map((buf) => sharp5(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
6324
- );
6325
- const out = new PNG({ width, height });
6326
- const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
6327
- return {
6328
- score: changed / (width * height),
6329
- heatmap: PNG.sync.write(out),
6330
- width,
6331
- height
6332
- };
6333
- }
6334
6577
  var EXPORT_PRESETS = [
6335
6578
  { id: "original", label: "Original", width: null, height: null },
6336
6579
  { id: "ig-post", label: "Instagram post 1080\xD71080", width: 1080, height: 1080 },
@@ -6342,7 +6585,7 @@ async function buildExportZip(image, baseName, presetIds) {
6342
6585
  const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
6343
6586
  if (chosen.length === 0) throw new Error("No valid export presets selected");
6344
6587
  for (const p of chosen) {
6345
- 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;
6346
6589
  zip.file(`${baseName}-${p.id}.png`, buf);
6347
6590
  }
6348
6591
  return zip.generateAsync({ type: "nodebuffer" });
@@ -6503,7 +6746,7 @@ function registerImageRoutes(app, deps) {
6503
6746
  if (!part) return reply.status(400).send({ error: "multipart file field required" });
6504
6747
  const buf = await part.toBuffer();
6505
6748
  if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
6506
- const png = await sharp5(buf).png().toBuffer();
6749
+ const png = await sharp6(buf).rotate().png().toBuffer();
6507
6750
  return { hash: core.images.save(png) };
6508
6751
  });
6509
6752
  app.post("/api/diff", async (req, reply) => {
@@ -6538,6 +6781,39 @@ function registerImageRoutes(app, deps) {
6538
6781
 
6539
6782
  // src/release/notes.data.ts
6540
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
+ },
6807
+ {
6808
+ version: "0.3.5",
6809
+ date: "2026-08-21",
6810
+ sections: [
6811
+ {
6812
+ heading: "Fixes",
6813
+ body: "Setting up the Codex engine on Windows no longer reports a successful install as missing, and updates can now find npm there. When npm truly is unreachable, the update command points to the one-line recovery instead of a dead end."
6814
+ }
6815
+ ]
6816
+ },
6541
6817
  {
6542
6818
  version: "0.3.4",
6543
6819
  date: "2026-08-21",
@@ -6895,7 +7171,7 @@ function registerSystemRoutes(app, deps) {
6895
7171
  }
6896
7172
  core.close();
6897
7173
  for (const name of ["scenri.db", "scenri.db-wal", "scenri.db-shm", "images", "backups"]) {
6898
- rmSync(join(core.home, name), { recursive: true, force: true });
7174
+ rmSync(join(core.home, name), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
6899
7175
  }
6900
7176
  return { ok: true, scope };
6901
7177
  });
@@ -7215,14 +7491,14 @@ function buildServer(opts) {
7215
7491
  const out = [];
7216
7492
  for (const h of images) {
7217
7493
  const buf = core.images.read(h);
7218
- 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()));
7219
7495
  }
7220
7496
  return out;
7221
7497
  }
7222
7498
  async function assertAspect(images, expect) {
7223
7499
  const want = expect.width / expect.height;
7224
7500
  for (const h of images) {
7225
- const meta2 = await sharp5(core.images.read(h)).metadata();
7501
+ const meta2 = await sharp6(core.images.read(h)).metadata();
7226
7502
  if (!meta2.width || !meta2.height) continue;
7227
7503
  const got = meta2.width / meta2.height;
7228
7504
  if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
@@ -7231,7 +7507,7 @@ function buildServer(opts) {
7231
7507
  );
7232
7508
  }
7233
7509
  }
7234
- async function runNode(nodeId, engine, estimate, work, expect) {
7510
+ async function runNode(nodeId, engine, estimate, work, expect, post) {
7235
7511
  const engineId = engine.capabilities().id;
7236
7512
  reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
7237
7513
  const ctrl = new AbortController();
@@ -7239,6 +7515,7 @@ function buildServer(opts) {
7239
7515
  try {
7240
7516
  const result = await work(ctrl.signal);
7241
7517
  result.images = await normalizePngs(result.images);
7518
+ if (post) result.images = await post(result.images);
7242
7519
  if (expect) await assertAspect(result.images, expect);
7243
7520
  core.store.completeNode(nodeId, result);
7244
7521
  core.ledger.recordCost(engineId, nodeId, result.costUsd);
@@ -7279,6 +7556,12 @@ function buildServer(opts) {
7279
7556
  const resolvedParentId = parentId ? String(parentId) : rootNode.id;
7280
7557
  const ctx = brandContext(core, project.brandId);
7281
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;
7282
7565
  if (brief && Array.isArray(brief.tokens)) {
7283
7566
  const briefErrors = validateBrief(brief);
7284
7567
  if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
@@ -7296,14 +7579,34 @@ function buildServer(opts) {
7296
7579
  brief.tokens
7297
7580
  );
7298
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
+ }
7299
7594
  compiled2 = compileBrief(brief, {
7300
7595
  brand: brandJson,
7301
7596
  images: core.images,
7302
7597
  engineCaps: engine.capabilities(),
7303
7598
  template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
7304
- templateById: sceneById
7599
+ templateById: sceneById,
7600
+ ...kind === "edit" ? { mode: "edit", editScope, inheritedIdentity: inheritedTokens.length > 0 } : {}
7305
7601
  });
7306
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
+ }
7307
7610
  }
7308
7611
  let finalPrompt = String(prompt ?? "");
7309
7612
  let referenceImages;
@@ -7383,13 +7686,31 @@ function buildServer(opts) {
7383
7686
  return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
7384
7687
  if (!engine.capabilities().supportsEdit)
7385
7688
  return reply.status(400).send({ error: "engine does not support edits" });
7386
- 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
+ }
7387
7708
  const editReq = {
7388
- instruction: finalPrompt,
7389
- sourceImage: core.images.pathFor(String(srcHash)),
7709
+ instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
7710
+ sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
7390
7711
  brand: ctx,
7391
- ...referenceImages && cap2 > 0 ? { referenceImages: referenceImages.slice(0, cap2) } : {},
7392
- ...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") } : {}
7393
7714
  };
7394
7715
  estimate = await engine.costEstimate(editReq);
7395
7716
  work = (signal) => engine.edit(editReq, signal);
@@ -7403,10 +7724,31 @@ function buildServer(opts) {
7403
7724
  engineId: String(engineId)
7404
7725
  });
7405
7726
  if (brief) core.store.setBrief(node.id, editedFrom ? { ...brief, sourceImage: editedFrom } : brief);
7406
- 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(
7407
7748
  (err) => app.log.error({ err }, "node run failed")
7408
7749
  );
7409
- 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);
7410
7752
  });
7411
7753
  app.post("/api/nodes/:id/cancel", async (req, reply) => {
7412
7754
  const id = req.params.id;
@@ -7662,8 +8004,8 @@ async function verify() {
7662
8004
  const db = new Database2(":memory:");
7663
8005
  db.pragma("user_version");
7664
8006
  db.close();
7665
- const { default: sharp12 } = await import('sharp');
7666
- 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();
7667
8009
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
7668
8010
  } catch (err) {
7669
8011
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));