scenri 0.6.4 → 0.6.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/serve.js +192 -57
  3. package/package.json +1 -1
  4. package/studio-dist/assets/{index-DWWdEXLo.css → index-BG-cxlng.css} +1 -1
  5. package/studio-dist/assets/{index-D__G1gHp.js → index-BxwxIvyc.js} +20 -20
  6. package/studio-dist/index.html +2 -2
  7. package/templates/chrome-bubble-warp.json +1 -1
  8. package/templates/cold-table-clinic.json +1 -1
  9. package/templates/confetti-burst-freeze.json +1 -1
  10. package/templates/editorial-fisheye-flash-studio.json +1 -1
  11. package/templates/heliconia-bloom-field.json +2 -2
  12. package/templates/indigo-fold.json +1 -1
  13. package/templates/interiors-bathroom-vanity.json +2 -2
  14. package/templates/interiors-marble-kitchen-counter.json +1 -1
  15. package/templates/interiors-wood-table-flatlay.json +2 -2
  16. package/templates/liquid-marble-pour.json +1 -1
  17. package/templates/plain-light-portrait.json +2 -2
  18. package/templates/precision-bench-anodised.json +1 -1
  19. package/templates/screen-print-duotone.json +1 -1
  20. package/templates/showcase/holloway-rings-loupe-surface-plate.json +1 -1
  21. package/templates/showcase/tanner-fielding-cocoa-crown.json +1 -1
  22. package/templates/showcase/voss-rowe-dune-slip-face.json +1 -1
  23. package/templates/social-amber-flare-motion.json +1 -1
  24. package/templates/social-fashion-campaign-billboard.json +1 -1
  25. package/templates/social-vertical-story-motion.json +2 -2
  26. package/templates/stitched-construction-detail.json +2 -2
  27. package/templates/studio-aloe-flesh-macro.json +1 -1
  28. package/templates/studio-amber-resin-suspension.json +1 -1
  29. package/templates/studio-basalt-crust-glow.json +1 -1
  30. package/templates/studio-blue-ice-melt-channel.json +1 -1
  31. package/templates/studio-boulder-gap.json +1 -1
  32. package/templates/studio-dune-slip-face.json +1 -1
  33. package/templates/studio-molten-sugar-pull.json +1 -1
  34. package/templates/studio-rainforest-canopy.json +1 -1
  35. package/templates/studio-reef-shelf-caustics.json +1 -1
  36. package/templates/studio-sea-stack-horizon.json +1 -1
  37. package/templates/studio-volcanic-ash-field.json +2 -2
  38. package/templates/succulent-dew-bed.json +1 -1
  39. package/templates/suspended-color-field.json +1 -1
  40. package/templates/terracotta-ledge-glow.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.6](https://github.com/tonygorb/Scenri/compare/v0.6.5...v0.6.6) (2026-08-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * composer chip removal is deterministic ([11246fc](https://github.com/tonygorb/Scenri/commit/11246fc64e26be522631f0fd6927210d6b5759b5))
9
+ * composer chip removal is deterministic ([29b9a4b](https://github.com/tonygorb/Scenri/commit/29b9a4bffaee289d5066bf15b52254d030547d97))
10
+ * presenter avatars frame head and shoulders like a real portrait ([3edb693](https://github.com/tonygorb/Scenri/commit/3edb69360755280af822380bd7cf3f65297aa258))
11
+ * presenter avatars frame head and shoulders like a real portrait ([1e4040c](https://github.com/tonygorb/Scenri/commit/1e4040c6c07858994ccde2a862927a6047b02062))
12
+ * stop killing healthy codex runs and classify what actually failed ([0d7888b](https://github.com/tonygorb/Scenri/commit/0d7888bcb3da875775ab125797cdf0b3fc7dec0c))
13
+ * stop killing healthy codex runs and classify what actually failed ([87e4df3](https://github.com/tonygorb/Scenri/commit/87e4df3ee680ad7673fad73351b1f36c07dacba6))
14
+
15
+ ## [0.6.5](https://github.com/tonygorb/Scenri/compare/v0.6.4...v0.6.5) (2026-08-29)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * a scene stages a subject slot, never its own demo object ([471981a](https://github.com/tonygorb/Scenri/commit/471981a89f7b3bb62cd4e5f7aec795b468e0aa15))
21
+ * a scene stages a subject slot, never its own demo object ([04e2afe](https://github.com/tonygorb/Scenri/commit/04e2afe7da67af13df32d854d2fd861597e6025e))
22
+
3
23
  ## [0.6.4](https://github.com/tonygorb/Scenri/compare/v0.6.3...v0.6.4) (2026-08-28)
4
24
 
5
25
 
package/dist/serve.js CHANGED
@@ -8,7 +8,7 @@ import Database from 'better-sqlite3';
8
8
  import { randomBytes, createHash, randomUUID, timingSafeEqual } from 'crypto';
9
9
  import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, writeFileSync, readdirSync, statSync, rmSync, renameSync } from 'fs';
10
10
  import { fileURLToPath } from 'url';
11
- import { readFile, copyFile, mkdtemp, rm, readdir, stat, writeFile } from 'fs/promises';
11
+ import { readFile, copyFile, stat, mkdtemp, rm, readdir, writeFile } from 'fs/promises';
12
12
  import { spawn } from 'child_process';
13
13
  import sharp7 from 'sharp';
14
14
  import Fastify from 'fastify';
@@ -1291,8 +1291,11 @@ var REFERENCE_ROLE_DIRECTIVE = {
1291
1291
  // subject or person from it", which handed the model a photograph of a face
1292
1292
  // treatment and told it to ignore the treatment. A hand-attached reference
1293
1293
  // says "match ... treatment" and works; this now says the same thing, with the
1294
- // identity carve-out a scene needs and a lone reference does not.
1295
- scene: "a reference for this world and for the treatment applied to the figure in it \u2014 match the environment, the light, and the material, density, scale, finish and spread of that treatment, including which parts of the form it covers and how far it reaches; take no identity from the person in it, and no product or brand",
1294
+ // identity carve-out a scene needs and a lone reference does not. The tail
1295
+ // names staged objects as stand-ins rather than just "no product": a bare
1296
+ // prohibition still left the demo object in the frame, because the model had
1297
+ // nowhere to put what the photograph so vividly showed.
1298
+ scene: "a reference for this world and for the treatment applied to the figure in it \u2014 match the environment, the light, and the material, density, scale, finish and spread of that treatment, including which parts of the form it covers and how far it reaches; take no identity from the person in it, and treat any product, garment or prop staged in it as a stand-in whose place the attached subject takes \u2014 never an object to reproduce",
1296
1299
  composition: "a reference for framing, camera angle and pose only \u2014 take no subject, color, material or branding from it",
1297
1300
  style: "a reference for overall treatment and mood only \u2014 take no composition, subject or product detail from it",
1298
1301
  reference: "a reference to match in composition, lighting and treatment"
@@ -1964,7 +1967,7 @@ var NOT_AUTHENTICATED_REASON = "Codex CLI is installed but not signed in";
1964
1967
  var UNVERIFIED_REASON = "Could not verify Codex on this computer";
1965
1968
  var DEFAULT_TIMEOUT_MS2 = 3e5;
1966
1969
  var PROBE_TIMEOUT_MS = 1e4;
1967
- var NO_ACTIVITY_TIMEOUT_MS = 12e4;
1970
+ var FIRST_OUTPUT_TIMEOUT_MS = 6e4;
1968
1971
  var PROBE_TTL_MS = 3e4;
1969
1972
  function execArgs(dir, effort = "low") {
1970
1973
  return [
@@ -1999,6 +2002,13 @@ function killTree(child, platform, spawnImpl) {
1999
2002
  } catch {
2000
2003
  }
2001
2004
  }
2005
+ if (platform !== "win32" && child.pid && spawnImpl === spawn) {
2006
+ try {
2007
+ process.kill(-child.pid, "SIGTERM");
2008
+ return;
2009
+ } catch {
2010
+ }
2011
+ }
2002
2012
  try {
2003
2013
  child.kill();
2004
2014
  } catch {
@@ -2006,10 +2016,10 @@ function killTree(child, platform, spawnImpl) {
2006
2016
  }
2007
2017
  function createRunner(opts = {}) {
2008
2018
  const spawnImpl = opts.spawnImpl ?? spawn;
2009
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
2019
+ const defaultTimeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
2010
2020
  const probeTimeoutMs = opts.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
2011
2021
  const probeTtlMs = opts.probeTtlMs ?? PROBE_TTL_MS;
2012
- const noActivityMs = opts.noActivityMs ?? NO_ACTIVITY_TIMEOUT_MS;
2022
+ const firstOutputMs = opts.firstOutputMs ?? FIRST_OUTPUT_TIMEOUT_MS;
2013
2023
  const platform = opts.platform ?? process.platform;
2014
2024
  const killCodex = (child) => killTree(child, platform, spawnImpl);
2015
2025
  let resolved = null;
@@ -2020,10 +2030,11 @@ function createRunner(opts = {}) {
2020
2030
  const winArg = (a) => `"${a.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/%/g, " percent ")}"`;
2021
2031
  const spawnCodex = (exe, args, stdinOpen) => {
2022
2032
  const stdio = [stdinOpen ? "pipe" : "ignore", "pipe", "pipe"];
2023
- return exe.direct ? spawnImpl(exe.command, args, { stdio }) : spawnImpl([exe.command, ...args.map(winArg)].join(" "), [], { stdio, shell: true });
2033
+ return exe.direct ? spawnImpl(exe.command, args, { stdio, ...platform !== "win32" ? { detached: true } : {} }) : spawnImpl([exe.command, ...args.map(winArg)].join(" "), [], { stdio, shell: true });
2024
2034
  };
2025
2035
  async function run2(args, signal, io) {
2026
2036
  const exe = await resolution();
2037
+ const timeoutMs = io?.timeoutMs ?? defaultTimeoutMs;
2027
2038
  return new Promise((resolve, reject) => {
2028
2039
  let child;
2029
2040
  try {
@@ -2034,19 +2045,31 @@ function createRunner(opts = {}) {
2034
2045
  }
2035
2046
  let settled = false;
2036
2047
  let stderr = "";
2037
- let activityTimer = setTimeout(onSilence, noActivityMs);
2038
- function sawActivity() {
2039
- if (settled) return;
2040
- clearTimeout(activityTimer);
2041
- activityTimer = setTimeout(onSilence, noActivityMs);
2042
- }
2043
- function onSilence() {
2048
+ const spawnedAt = Date.now();
2049
+ let firstByteAt = 0;
2050
+ let lastByteAt = 0;
2051
+ let maxGapMs = 0;
2052
+ const firstOutputTimer = setTimeout(() => {
2044
2053
  finish(
2054
+ "first-output-timeout",
2045
2055
  () => reject(
2046
- new Error(`Codex CLI produced no output for ${Math.round(noActivityMs / 1e3)}s, treating it as stuck`)
2056
+ new Error(
2057
+ `Codex CLI produced no output for ${Math.round(firstOutputMs / 1e3)}s after launch, treating it as stuck`
2058
+ )
2047
2059
  )
2048
2060
  );
2049
2061
  killCodex(child);
2062
+ }, firstOutputMs);
2063
+ function sawActivity() {
2064
+ if (settled) return;
2065
+ const now = Date.now();
2066
+ if (firstByteAt === 0) {
2067
+ firstByteAt = now;
2068
+ clearTimeout(firstOutputTimer);
2069
+ } else {
2070
+ maxGapMs = Math.max(maxGapMs, now - lastByteAt);
2071
+ }
2072
+ lastByteAt = now;
2050
2073
  }
2051
2074
  child.stdout?.on("data", sawActivity);
2052
2075
  child.stderr?.on("data", (d) => {
@@ -2060,19 +2083,23 @@ function createRunner(opts = {}) {
2060
2083
  child.stdin?.end();
2061
2084
  }
2062
2085
  const timer = setTimeout(() => {
2063
- finish(() => reject(new Error(`Codex CLI timed out after ${timeoutMs}ms`)));
2086
+ finish("hard-timeout", () => reject(new Error(`Codex CLI timed out after ${timeoutMs}ms`)));
2064
2087
  killCodex(child);
2065
2088
  }, timeoutMs);
2066
2089
  const onAbort = () => {
2067
- finish(() => reject(new Error("Codex CLI run aborted")));
2090
+ finish("abort", () => reject(new Error("Codex CLI run aborted")));
2068
2091
  killCodex(child);
2069
2092
  };
2070
- function finish(fn) {
2093
+ function finish(outcome, fn) {
2071
2094
  if (settled) return;
2072
2095
  settled = true;
2073
2096
  clearTimeout(timer);
2074
- clearTimeout(activityTimer);
2097
+ clearTimeout(firstOutputTimer);
2075
2098
  signal?.removeEventListener("abort", onAbort);
2099
+ const ttfb = firstByteAt ? firstByteAt - spawnedAt : -1;
2100
+ const line = `codex exec${io?.label ? ` [${io.label}]` : ""}: outcome=${outcome} ttfb=${ttfb}ms maxGap=${maxGapMs}ms total=${Date.now() - spawnedAt}ms`;
2101
+ if (outcome !== "ok" && outcome !== "abort") console.warn(line);
2102
+ else if (process.env.SCENRI_DEBUG === "1") console.log(line);
2076
2103
  fn();
2077
2104
  }
2078
2105
  if (signal) {
@@ -2083,14 +2110,15 @@ function createRunner(opts = {}) {
2083
2110
  signal.addEventListener("abort", onAbort, { once: true });
2084
2111
  }
2085
2112
  child.on("error", (err) => {
2086
- finish(() => reject(new Error(`Failed to spawn codex: ${err.message}`)));
2113
+ finish("spawn-error", () => reject(new Error(`Failed to spawn codex: ${err.message}`)));
2087
2114
  });
2088
2115
  child.on("exit", (code) => {
2089
2116
  if (code === 0) {
2090
- finish(resolve);
2117
+ finish("ok", resolve);
2091
2118
  } else {
2092
2119
  const snippet2 = stderr.trim().slice(0, 200);
2093
2120
  finish(
2121
+ `exit-${code ?? "unknown"}`,
2094
2122
  () => reject(new Error(`codex exited with code ${code ?? "unknown"}${snippet2 ? `: ${snippet2}` : ""}`))
2095
2123
  );
2096
2124
  }
@@ -2215,7 +2243,10 @@ function createCodexAnalyzer(opts = {}) {
2215
2243
  for (const ref of refs) {
2216
2244
  args.splice(args.length - 1, 0, `--image=${ref}`);
2217
2245
  }
2218
- await runner.run(args, signal, { stdin: buildPrompt(req, refs.length, problems) });
2246
+ await runner.run(args, signal, {
2247
+ stdin: buildPrompt(req, refs.length, problems),
2248
+ label: `analyze refs=${refs.length} attempt=${attempt + 1}`
2249
+ });
2219
2250
  let raw;
2220
2251
  try {
2221
2252
  raw = await readFile(join(dir, OUT_FILE), "utf8");
@@ -2441,6 +2472,10 @@ function createCodexSetup(opts = {}) {
2441
2472
  }
2442
2473
 
2443
2474
  // ../engines/codex/src/index.ts
2475
+ var CODEX_POOL = 2;
2476
+ function codexNodeBudgetMs(count) {
2477
+ return Math.ceil(Math.max(1, count) / CODEX_POOL) * DEFAULT_TIMEOUT_MS2 + 6e4;
2478
+ }
2444
2479
  function createCodexEngine(opts) {
2445
2480
  const { saveImage } = opts;
2446
2481
  const platform = opts.platform ?? process.platform;
@@ -2521,7 +2556,15 @@ function createCodexEngine(opts) {
2521
2556
  * shot being edited. A refine carrying a full identity payload was
2522
2557
  * silently editing nothing at all.
2523
2558
  */
2524
- maxReferenceImages: 5
2559
+ maxReferenceImages: 5,
2560
+ /*
2561
+ * The longest edge a reference is worth sending at. codex's image_gen
2562
+ * reads references at reduced resolution server-side either way, but
2563
+ * the bytes still ride the user's uplink inside the exec's own time
2564
+ * budget — a full-resolution phone-photo PNG is tens of megabytes that
2565
+ * buy nothing. Same cap as brand marks (MARK_MAX_EDGE).
2566
+ */
2567
+ maxReferenceEdge: 2048
2525
2568
  };
2526
2569
  },
2527
2570
  isAvailable() {
@@ -2542,13 +2585,18 @@ function createCodexEngine(opts) {
2542
2585
  { length: count },
2543
2586
  (_, i) => async () => withWorkDir(async (dir) => {
2544
2587
  const args = execArgs(dir);
2588
+ let refBytes = 0;
2545
2589
  for (const [idx, ref] of refs.entries()) {
2546
2590
  const dest = join(dir, `ref-${idx}.png`);
2547
2591
  await copyFile(ref, dest);
2592
+ refBytes += (await stat(dest)).size;
2548
2593
  args.splice(args.length - 1, 0, `--image=${dest}`);
2549
2594
  }
2550
2595
  const before = await snapshotGenerated();
2551
- await runCodex(args, inner.signal, { stdin: buildPrompt2(req, i, roles) });
2596
+ await runCodex(args, inner.signal, {
2597
+ stdin: buildPrompt2(req, i, roles),
2598
+ label: `gen v${i + 1}/${count} refs=${refs.length} refKB=${Math.round(refBytes / 1024)}`
2599
+ });
2552
2600
  return collectImages(dir, before);
2553
2601
  })
2554
2602
  );
@@ -2557,7 +2605,7 @@ function createCodexEngine(opts) {
2557
2605
  let fatal = null;
2558
2606
  let next = 0;
2559
2607
  try {
2560
- const workers = Array.from({ length: Math.min(3, count) }, async () => {
2608
+ const workers = Array.from({ length: Math.min(CODEX_POOL, count) }, async () => {
2561
2609
  while (next < count && !inner.signal.aborted) {
2562
2610
  const i = next++;
2563
2611
  try {
@@ -2624,7 +2672,7 @@ function createCodexEngine(opts) {
2624
2672
  }
2625
2673
  const before = await snapshotGenerated();
2626
2674
  try {
2627
- await runCodex(args, signal, { stdin: promptText });
2675
+ await runCodex(args, signal, { stdin: promptText, label: `edit refs=${editRefs.length}` });
2628
2676
  } catch (err) {
2629
2677
  if (isFatalSetupError(err)) runner.invalidateProbe();
2630
2678
  throw err;
@@ -3129,6 +3177,21 @@ function sceneGuardDirectives(opts) {
3129
3177
  "The scene direction above describes the set, not the cast. If it says the space is empty or that no people appear, disregard that: the attached person stands in this set, clearly visible. A ban on props or extra objects in the scene direction is about set dressing only \u2014 it never applies to the presenter or to the product in their hands."
3130
3178
  );
3131
3179
  }
3180
+ if (opts.hasScenePhoto && (opts.hasProduct || opts.hasPerson)) {
3181
+ out.push(
3182
+ "One attached reference is the scene's own photograph. It shows this world \u2014 the set, the light, and any treatment this world applies \u2014 never a cast: any product, garment, prop or person visible in it is a stand-in, demonstrating where the subject sits, how large it stands in the frame, and what has been done to it."
3183
+ );
3184
+ if (opts.hasProduct) {
3185
+ out.push(
3186
+ "The product in the scene photograph is not in this shot. The attached product photo is the only source of product identity: that exact product takes the stand-in's position, at the placement and scale the scene photograph demonstrates, keeping its own shape, label and colours."
3187
+ );
3188
+ }
3189
+ if (opts.hasPerson) {
3190
+ out.push(
3191
+ "Any person in the scene photograph lends their role, never their face: the attached presenter takes their place, wearing whatever treatment this world applies, with their identity drawn from their own photographs alone."
3192
+ );
3193
+ }
3194
+ }
3132
3195
  return out;
3133
3196
  }
3134
3197
  function brandRuleDirectives(brand) {
@@ -3388,7 +3451,13 @@ function compileBrief(brief, ctx) {
3388
3451
  }
3389
3452
  const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
3390
3453
  const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
3391
- const guard = scene ? sceneGuardDirectives({ hasProduct: !!productId, hasPerson }) : [];
3454
+ const max = ctx.engineCaps.maxReferenceImages;
3455
+ const { kept, dropped } = allocateAttachments(attachments, max);
3456
+ const guard = scene ? sceneGuardDirectives({
3457
+ hasProduct: !!productId,
3458
+ hasPerson,
3459
+ hasScenePhoto: kept.some((a) => a.role === "scene")
3460
+ }) : [];
3392
3461
  const pairDirectives = productId && hasPerson ? [
3393
3462
  "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.",
3394
3463
  // A product's own notes are written for solo packshots, and several
@@ -3434,8 +3503,6 @@ function compileBrief(brief, ctx) {
3434
3503
  ...preservation
3435
3504
  ];
3436
3505
  if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
3437
- const max = ctx.engineCaps.maxReferenceImages;
3438
- const { kept, dropped } = allocateAttachments(attachments, max);
3439
3506
  if (dropped.some((d) => d.role !== "scene")) {
3440
3507
  const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))];
3441
3508
  const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
@@ -5925,6 +5992,10 @@ async function cardCrop(core, hash) {
5925
5992
  return { left: Math.max(0, Math.round((w - width) / 2)), top: 0, width, height };
5926
5993
  });
5927
5994
  }
5995
+ var AVATAR_FIGURE_FRACTION = 0.22;
5996
+ var AVATAR_HEADROOM = 0.1;
5997
+ var AVATAR_MAX_PX = 512;
5998
+ var FIGURE_TRIM_THRESHOLDS = [12, 25];
5928
5999
  async function avatarCrop(core, hash) {
5929
6000
  if (!hash || !core.images.has(hash)) return void 0;
5930
6001
  let box = null;
@@ -5933,31 +6004,39 @@ async function avatarCrop(core, hash) {
5933
6004
  } catch {
5934
6005
  box = null;
5935
6006
  }
5936
- return crop(core, hash, (w, h) => {
5937
- if (!box) {
5938
- const size2 = Math.min(w, h, Math.round(h * 0.16));
5939
- return { left: Math.max(0, Math.round((w - size2) / 2)), top: 0, width: size2, height: size2 };
5940
- }
5941
- const size = Math.min(w, h, Math.max(16, Math.round(box.height * 0.27)));
5942
- const top = Math.min(Math.max(0, Math.round(box.top - size * 0.08)), h - size);
5943
- const left = Math.min(Math.max(0, Math.round(box.left + box.width / 2 - size / 2)), w - size);
5944
- return { left, top, width: size, height: size };
5945
- });
6007
+ return crop(
6008
+ core,
6009
+ hash,
6010
+ (w, h) => {
6011
+ if (!box) {
6012
+ const size2 = Math.min(w, h, Math.round(h * 0.16));
6013
+ return { left: Math.max(0, Math.round((w - size2) / 2)), top: 0, width: size2, height: size2 };
6014
+ }
6015
+ const size = Math.min(w, h, Math.max(16, Math.round(box.height * AVATAR_FIGURE_FRACTION)));
6016
+ const top = Math.min(Math.max(0, Math.round(box.top - size * AVATAR_HEADROOM)), h - size);
6017
+ const left = Math.min(Math.max(0, Math.round(box.left + box.width / 2 - size / 2)), w - size);
6018
+ return { left, top, width: size, height: size };
6019
+ },
6020
+ AVATAR_MAX_PX
6021
+ );
5946
6022
  }
5947
6023
  async function figureBox(buf) {
5948
6024
  const meta = await sharp7(buf).metadata();
5949
6025
  const W = meta.width ?? 0;
5950
6026
  const H = meta.height ?? 0;
5951
6027
  if (!W || !H) return null;
5952
- const { info } = await sharp7(buf).trim({ threshold: 12 }).toBuffer({ resolveWithObject: true });
5953
- const left = Math.abs(info.trimOffsetLeft ?? 0);
5954
- const top = Math.abs(info.trimOffsetTop ?? 0);
5955
- const width = info.width ?? 0;
5956
- const height = info.height ?? 0;
5957
- if (!width || !height) return null;
5958
- if (width >= W && height >= H) return null;
5959
- if (height < H * 0.3 || width < W * 0.05) return null;
5960
- return { left, top, width, height };
6028
+ for (const threshold of FIGURE_TRIM_THRESHOLDS) {
6029
+ const { info } = await sharp7(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
6030
+ const left = Math.abs(info.trimOffsetLeft ?? 0);
6031
+ const top = Math.abs(info.trimOffsetTop ?? 0);
6032
+ const width = info.width ?? 0;
6033
+ const height = info.height ?? 0;
6034
+ if (!width || !height) continue;
6035
+ if (width >= W || height >= H) continue;
6036
+ if (height < H * 0.3 || width < W * 0.05) continue;
6037
+ return { left, top, width, height };
6038
+ }
6039
+ return null;
5961
6040
  }
5962
6041
  async function presenterCrops(core, hash, mode) {
5963
6042
  const previewHash = mode === "generated" ? await cardCrop(core, hash) ?? await cardCropSmart(core, hash) : await cardCropSmart(core, hash) ?? await cardCrop(core, hash);
@@ -5992,14 +6071,16 @@ async function smartCover(core, hash, box) {
5992
6071
  return void 0;
5993
6072
  }
5994
6073
  }
5995
- async function crop(core, hash, region) {
6074
+ async function crop(core, hash, region, cap2) {
5996
6075
  if (!hash || !core.images.has(hash)) return void 0;
5997
6076
  try {
5998
6077
  const meta = await sharp7(core.images.read(hash)).metadata();
5999
6078
  const w = meta.width ?? 0;
6000
6079
  const h = meta.height ?? 0;
6001
6080
  if (!w || !h) return void 0;
6002
- const png = await sharp7(core.images.read(hash)).extract(region(w, h)).png().toBuffer();
6081
+ let pipeline = sharp7(core.images.read(hash)).extract(region(w, h));
6082
+ if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
6083
+ const png = await pipeline.png().toBuffer();
6003
6084
  return core.images.save(png);
6004
6085
  } catch {
6005
6086
  return void 0;
@@ -6964,6 +7045,23 @@ var COST_PROBE = {
6964
7045
  };
6965
7046
  var MARK_MAX_EDGE = 2048;
6966
7047
  var toMarkPng = (buf) => sharp7(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
7048
+ var cappedRefs = /* @__PURE__ */ new Map();
7049
+ async function capReferenceEdge(core, path, maxEdge) {
7050
+ const key = `${path}#${maxEdge}`;
7051
+ const hit = cappedRefs.get(key);
7052
+ if (hit) return hit;
7053
+ let out = path;
7054
+ try {
7055
+ const meta = await sharp7(path).metadata();
7056
+ if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
7057
+ const buf = await sharp7(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
7058
+ out = core.images.pathFor(core.images.save(buf));
7059
+ }
7060
+ } catch {
7061
+ }
7062
+ cappedRefs.set(key, out);
7063
+ return out;
7064
+ }
6967
7065
  var readImagePart = async (core, req, normalize2) => {
6968
7066
  const part = await req.file();
6969
7067
  if (!part) return { error: "multipart file field required" };
@@ -7280,7 +7378,7 @@ async function repairPresenterCrops(core, log = () => {
7280
7378
  let repaired = 0;
7281
7379
  for (const brand of core.store.listBrands()) {
7282
7380
  for (const c of brandCharacters(brand.json)) {
7283
- if (!isCustomPresenter(c)) continue;
7381
+ if (!c?.id) continue;
7284
7382
  try {
7285
7383
  const firstShot = c.shots?.[0]?.file;
7286
7384
  const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
@@ -7513,7 +7611,11 @@ function registerAssetBuildRoutes(app, deps) {
7513
7611
  kind: "scene",
7514
7612
  sceneId: id,
7515
7613
  name: scene.name,
7516
- instruction: body.correction == null ? void 0 : String(body.correction),
7614
+ // A plain re-read used to drop the Direction on the floor: body.correction
7615
+ // was the only source, so the analyzer's deciding-word preamble never
7616
+ // fired and whatever the Direction excluded came straight back. No new
7617
+ // word means the stored word.
7618
+ instruction: String(body.correction ?? "").trim() || scene.instruction || void 0,
7517
7619
  imageHashes: []
7518
7620
  });
7519
7621
  } catch (err) {
@@ -7973,6 +8075,30 @@ function registerImageRoutes(app, deps) {
7973
8075
 
7974
8076
  // src/release/notes.data.ts
7975
8077
  var RELEASES = [
8078
+ {
8079
+ version: "0.6.6",
8080
+ date: "2026-08-29",
8081
+ sections: [
8082
+ {
8083
+ heading: "Create",
8084
+ body: "Removing a chip from the brief now works on the first click, every time, including right after dragging chips around. Codex generations no longer fail as timeouts while they are still working, and when a run does fail, the message says what actually happened instead of suggesting a second try."
8085
+ },
8086
+ {
8087
+ heading: "Presenters",
8088
+ body: "Custom presenter avatars are cropped like real profile photos, head and shoulders from the front reference. Presenters you made earlier are reframed automatically, including ones that never got an avatar at all."
8089
+ }
8090
+ ]
8091
+ },
8092
+ {
8093
+ version: "0.6.5",
8094
+ date: "2026-08-29",
8095
+ sections: [
8096
+ {
8097
+ heading: "Scenes",
8098
+ body: "A Scene no longer brings its own demo object into your shots. The product and presenter you select take the place of whatever its reference or preview happened to stage, while the environment, the light and any figure treatment carry over as before. Editing the Direction and pressing Read again now applies the Direction you wrote instead of quietly ignoring it."
8099
+ }
8100
+ ]
8101
+ },
7976
8102
  {
7977
8103
  version: "0.6.4",
7978
8104
  date: "2026-08-29",
@@ -9039,19 +9165,21 @@ function buildServer(opts) {
9039
9165
  }
9040
9166
  }
9041
9167
  const NODE_TIMEOUT_MS = 6e5;
9042
- async function runNode(nodeId, engine, estimate, work, expect, post) {
9168
+ async function runNode(nodeId, engine, estimate, work, expect, post, timeoutMs) {
9043
9169
  const engineId = engine?.capabilities().id ?? "local";
9044
9170
  reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
9045
9171
  const ctrl = new AbortController();
9046
9172
  runningGenerations.set(nodeId, ctrl);
9173
+ const bound = opts.nodeTimeoutMs ?? timeoutMs ?? NODE_TIMEOUT_MS;
9047
9174
  let watchdogFired = false;
9048
9175
  const watchdog = setTimeout(() => {
9049
9176
  watchdogFired = true;
9050
9177
  ctrl.abort();
9051
- }, opts.nodeTimeoutMs ?? NODE_TIMEOUT_MS);
9178
+ }, bound);
9052
9179
  const startedAt = Date.now();
9053
9180
  try {
9054
9181
  const result = await work(ctrl.signal);
9182
+ clearTimeout(watchdog);
9055
9183
  result.images = await normalizePngs(result.images);
9056
9184
  if (post) result.images = await post(result.images);
9057
9185
  if (expect) await assertAspect(result.images, expect);
@@ -9073,7 +9201,8 @@ function buildServer(opts) {
9073
9201
  }
9074
9202
  core.ledger.recordCost(engineId, nodeId, result.costUsd);
9075
9203
  } catch (err) {
9076
- if (watchdogFired) core.store.failNode(nodeId, "generation timed out after 10 minutes");
9204
+ if (watchdogFired)
9205
+ core.store.failNode(nodeId, `generation timed out after ${Math.round(bound / 6e4)} minutes`);
9077
9206
  else if (ctrl.signal.aborted) core.store.cancelNode(nodeId);
9078
9207
  else core.store.failNode(nodeId, String(err?.message ?? err));
9079
9208
  } finally {
@@ -9267,13 +9396,16 @@ function buildServer(opts) {
9267
9396
  error: `${engine.capabilities().displayName} cannot carry enough reference images, so ${names} would be named in the prompt but never shown. The result would not be your ${kindWord}. Choose an engine that supports reference images, or remove ${names} from the brief.`
9268
9397
  });
9269
9398
  }
9399
+ const maxEdge = engine.capabilities().maxReferenceEdge;
9400
+ const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
9401
+ const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
9270
9402
  const genReq = {
9271
9403
  prompt: finalPrompt,
9272
9404
  brand: ctx,
9273
9405
  width: Number(width),
9274
9406
  height: Number(height),
9275
9407
  count: Math.min(Math.max(1, Number(count)), 8),
9276
- ...referenceImages && cap2 > 0 ? { referenceImages: referenceImages.slice(0, cap2) } : {},
9408
+ ...sentRefs ? { referenceImages: sentRefs } : {},
9277
9409
  ...referenceRoles && cap2 > 0 ? { referenceRoles: referenceRoles.slice(0, cap2) } : {}
9278
9410
  };
9279
9411
  estimate = await engine.costEstimate(genReq);
@@ -9288,6 +9420,8 @@ function buildServer(opts) {
9288
9420
  if (!engine.capabilities().supportsEdit)
9289
9421
  return reply.status(400).send({ error: "engine does not support edits" });
9290
9422
  const editRefs = mergedEdit ? mergedEdit.kept.map((a) => ({ path: core.images.pathFor(a.hash), role: a.role })) : (referenceImages ?? []).map((path, i) => ({ path, role: referenceRoles?.[i] })).slice(0, Math.max(0, engine.capabilities().maxReferenceImages - 1));
9423
+ const editEdge = engine.capabilities().maxReferenceEdge;
9424
+ if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
9291
9425
  const srcBuf = core.images.read(String(srcHash));
9292
9426
  const srcMeta = await sharp7(srcBuf).metadata();
9293
9427
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
@@ -9460,7 +9594,8 @@ function buildServer(opts) {
9460
9594
  }
9461
9595
  return out;
9462
9596
  } : void 0;
9463
- void runNode(node.id, runEngine, estimate, work, expectShape, post).catch(
9597
+ const nodeBudgetMs = kind === "generation" && runEngine.capabilities().id === "codex-cli" ? codexNodeBudgetMs(Math.min(Math.max(1, Number(count)), 8)) : void 0;
9598
+ void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
9464
9599
  (err) => app.log.error({ err }, "node run failed")
9465
9600
  );
9466
9601
  const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",