scenri 0.6.5 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## [0.6.5](https://github.com/tonygorb/Scenri/compare/v0.6.4...v0.6.5) (2026-08-29)
4
16
 
5
17
 
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';
@@ -1967,7 +1967,7 @@ var NOT_AUTHENTICATED_REASON = "Codex CLI is installed but not signed in";
1967
1967
  var UNVERIFIED_REASON = "Could not verify Codex on this computer";
1968
1968
  var DEFAULT_TIMEOUT_MS2 = 3e5;
1969
1969
  var PROBE_TIMEOUT_MS = 1e4;
1970
- var NO_ACTIVITY_TIMEOUT_MS = 12e4;
1970
+ var FIRST_OUTPUT_TIMEOUT_MS = 6e4;
1971
1971
  var PROBE_TTL_MS = 3e4;
1972
1972
  function execArgs(dir, effort = "low") {
1973
1973
  return [
@@ -2002,6 +2002,13 @@ function killTree(child, platform, spawnImpl) {
2002
2002
  } catch {
2003
2003
  }
2004
2004
  }
2005
+ if (platform !== "win32" && child.pid && spawnImpl === spawn) {
2006
+ try {
2007
+ process.kill(-child.pid, "SIGTERM");
2008
+ return;
2009
+ } catch {
2010
+ }
2011
+ }
2005
2012
  try {
2006
2013
  child.kill();
2007
2014
  } catch {
@@ -2009,10 +2016,10 @@ function killTree(child, platform, spawnImpl) {
2009
2016
  }
2010
2017
  function createRunner(opts = {}) {
2011
2018
  const spawnImpl = opts.spawnImpl ?? spawn;
2012
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
2019
+ const defaultTimeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
2013
2020
  const probeTimeoutMs = opts.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
2014
2021
  const probeTtlMs = opts.probeTtlMs ?? PROBE_TTL_MS;
2015
- const noActivityMs = opts.noActivityMs ?? NO_ACTIVITY_TIMEOUT_MS;
2022
+ const firstOutputMs = opts.firstOutputMs ?? FIRST_OUTPUT_TIMEOUT_MS;
2016
2023
  const platform = opts.platform ?? process.platform;
2017
2024
  const killCodex = (child) => killTree(child, platform, spawnImpl);
2018
2025
  let resolved = null;
@@ -2023,10 +2030,11 @@ function createRunner(opts = {}) {
2023
2030
  const winArg = (a) => `"${a.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/%/g, " percent ")}"`;
2024
2031
  const spawnCodex = (exe, args, stdinOpen) => {
2025
2032
  const stdio = [stdinOpen ? "pipe" : "ignore", "pipe", "pipe"];
2026
- 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 });
2027
2034
  };
2028
2035
  async function run2(args, signal, io) {
2029
2036
  const exe = await resolution();
2037
+ const timeoutMs = io?.timeoutMs ?? defaultTimeoutMs;
2030
2038
  return new Promise((resolve, reject) => {
2031
2039
  let child;
2032
2040
  try {
@@ -2037,19 +2045,31 @@ function createRunner(opts = {}) {
2037
2045
  }
2038
2046
  let settled = false;
2039
2047
  let stderr = "";
2040
- let activityTimer = setTimeout(onSilence, noActivityMs);
2041
- function sawActivity() {
2042
- if (settled) return;
2043
- clearTimeout(activityTimer);
2044
- activityTimer = setTimeout(onSilence, noActivityMs);
2045
- }
2046
- function onSilence() {
2048
+ const spawnedAt = Date.now();
2049
+ let firstByteAt = 0;
2050
+ let lastByteAt = 0;
2051
+ let maxGapMs = 0;
2052
+ const firstOutputTimer = setTimeout(() => {
2047
2053
  finish(
2054
+ "first-output-timeout",
2048
2055
  () => reject(
2049
- 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
+ )
2050
2059
  )
2051
2060
  );
2052
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;
2053
2073
  }
2054
2074
  child.stdout?.on("data", sawActivity);
2055
2075
  child.stderr?.on("data", (d) => {
@@ -2063,19 +2083,23 @@ function createRunner(opts = {}) {
2063
2083
  child.stdin?.end();
2064
2084
  }
2065
2085
  const timer = setTimeout(() => {
2066
- 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`)));
2067
2087
  killCodex(child);
2068
2088
  }, timeoutMs);
2069
2089
  const onAbort = () => {
2070
- finish(() => reject(new Error("Codex CLI run aborted")));
2090
+ finish("abort", () => reject(new Error("Codex CLI run aborted")));
2071
2091
  killCodex(child);
2072
2092
  };
2073
- function finish(fn) {
2093
+ function finish(outcome, fn) {
2074
2094
  if (settled) return;
2075
2095
  settled = true;
2076
2096
  clearTimeout(timer);
2077
- clearTimeout(activityTimer);
2097
+ clearTimeout(firstOutputTimer);
2078
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);
2079
2103
  fn();
2080
2104
  }
2081
2105
  if (signal) {
@@ -2086,14 +2110,15 @@ function createRunner(opts = {}) {
2086
2110
  signal.addEventListener("abort", onAbort, { once: true });
2087
2111
  }
2088
2112
  child.on("error", (err) => {
2089
- finish(() => reject(new Error(`Failed to spawn codex: ${err.message}`)));
2113
+ finish("spawn-error", () => reject(new Error(`Failed to spawn codex: ${err.message}`)));
2090
2114
  });
2091
2115
  child.on("exit", (code) => {
2092
2116
  if (code === 0) {
2093
- finish(resolve);
2117
+ finish("ok", resolve);
2094
2118
  } else {
2095
2119
  const snippet2 = stderr.trim().slice(0, 200);
2096
2120
  finish(
2121
+ `exit-${code ?? "unknown"}`,
2097
2122
  () => reject(new Error(`codex exited with code ${code ?? "unknown"}${snippet2 ? `: ${snippet2}` : ""}`))
2098
2123
  );
2099
2124
  }
@@ -2218,7 +2243,10 @@ function createCodexAnalyzer(opts = {}) {
2218
2243
  for (const ref of refs) {
2219
2244
  args.splice(args.length - 1, 0, `--image=${ref}`);
2220
2245
  }
2221
- 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
+ });
2222
2250
  let raw;
2223
2251
  try {
2224
2252
  raw = await readFile(join(dir, OUT_FILE), "utf8");
@@ -2444,6 +2472,10 @@ function createCodexSetup(opts = {}) {
2444
2472
  }
2445
2473
 
2446
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
+ }
2447
2479
  function createCodexEngine(opts) {
2448
2480
  const { saveImage } = opts;
2449
2481
  const platform = opts.platform ?? process.platform;
@@ -2524,7 +2556,15 @@ function createCodexEngine(opts) {
2524
2556
  * shot being edited. A refine carrying a full identity payload was
2525
2557
  * silently editing nothing at all.
2526
2558
  */
2527
- 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
2528
2568
  };
2529
2569
  },
2530
2570
  isAvailable() {
@@ -2545,13 +2585,18 @@ function createCodexEngine(opts) {
2545
2585
  { length: count },
2546
2586
  (_, i) => async () => withWorkDir(async (dir) => {
2547
2587
  const args = execArgs(dir);
2588
+ let refBytes = 0;
2548
2589
  for (const [idx, ref] of refs.entries()) {
2549
2590
  const dest = join(dir, `ref-${idx}.png`);
2550
2591
  await copyFile(ref, dest);
2592
+ refBytes += (await stat(dest)).size;
2551
2593
  args.splice(args.length - 1, 0, `--image=${dest}`);
2552
2594
  }
2553
2595
  const before = await snapshotGenerated();
2554
- 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
+ });
2555
2600
  return collectImages(dir, before);
2556
2601
  })
2557
2602
  );
@@ -2560,7 +2605,7 @@ function createCodexEngine(opts) {
2560
2605
  let fatal = null;
2561
2606
  let next = 0;
2562
2607
  try {
2563
- const workers = Array.from({ length: Math.min(3, count) }, async () => {
2608
+ const workers = Array.from({ length: Math.min(CODEX_POOL, count) }, async () => {
2564
2609
  while (next < count && !inner.signal.aborted) {
2565
2610
  const i = next++;
2566
2611
  try {
@@ -2627,7 +2672,7 @@ function createCodexEngine(opts) {
2627
2672
  }
2628
2673
  const before = await snapshotGenerated();
2629
2674
  try {
2630
- await runCodex(args, signal, { stdin: promptText });
2675
+ await runCodex(args, signal, { stdin: promptText, label: `edit refs=${editRefs.length}` });
2631
2676
  } catch (err) {
2632
2677
  if (isFatalSetupError(err)) runner.invalidateProbe();
2633
2678
  throw err;
@@ -5947,6 +5992,10 @@ async function cardCrop(core, hash) {
5947
5992
  return { left: Math.max(0, Math.round((w - width) / 2)), top: 0, width, height };
5948
5993
  });
5949
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];
5950
5999
  async function avatarCrop(core, hash) {
5951
6000
  if (!hash || !core.images.has(hash)) return void 0;
5952
6001
  let box = null;
@@ -5955,31 +6004,39 @@ async function avatarCrop(core, hash) {
5955
6004
  } catch {
5956
6005
  box = null;
5957
6006
  }
5958
- return crop(core, hash, (w, h) => {
5959
- if (!box) {
5960
- const size2 = Math.min(w, h, Math.round(h * 0.16));
5961
- return { left: Math.max(0, Math.round((w - size2) / 2)), top: 0, width: size2, height: size2 };
5962
- }
5963
- const size = Math.min(w, h, Math.max(16, Math.round(box.height * 0.27)));
5964
- const top = Math.min(Math.max(0, Math.round(box.top - size * 0.08)), h - size);
5965
- const left = Math.min(Math.max(0, Math.round(box.left + box.width / 2 - size / 2)), w - size);
5966
- return { left, top, width: size, height: size };
5967
- });
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
+ );
5968
6022
  }
5969
6023
  async function figureBox(buf) {
5970
6024
  const meta = await sharp7(buf).metadata();
5971
6025
  const W = meta.width ?? 0;
5972
6026
  const H = meta.height ?? 0;
5973
6027
  if (!W || !H) return null;
5974
- const { info } = await sharp7(buf).trim({ threshold: 12 }).toBuffer({ resolveWithObject: true });
5975
- const left = Math.abs(info.trimOffsetLeft ?? 0);
5976
- const top = Math.abs(info.trimOffsetTop ?? 0);
5977
- const width = info.width ?? 0;
5978
- const height = info.height ?? 0;
5979
- if (!width || !height) return null;
5980
- if (width >= W && height >= H) return null;
5981
- if (height < H * 0.3 || width < W * 0.05) return null;
5982
- 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;
5983
6040
  }
5984
6041
  async function presenterCrops(core, hash, mode) {
5985
6042
  const previewHash = mode === "generated" ? await cardCrop(core, hash) ?? await cardCropSmart(core, hash) : await cardCropSmart(core, hash) ?? await cardCrop(core, hash);
@@ -6014,14 +6071,16 @@ async function smartCover(core, hash, box) {
6014
6071
  return void 0;
6015
6072
  }
6016
6073
  }
6017
- async function crop(core, hash, region) {
6074
+ async function crop(core, hash, region, cap2) {
6018
6075
  if (!hash || !core.images.has(hash)) return void 0;
6019
6076
  try {
6020
6077
  const meta = await sharp7(core.images.read(hash)).metadata();
6021
6078
  const w = meta.width ?? 0;
6022
6079
  const h = meta.height ?? 0;
6023
6080
  if (!w || !h) return void 0;
6024
- 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();
6025
6084
  return core.images.save(png);
6026
6085
  } catch {
6027
6086
  return void 0;
@@ -6986,6 +7045,23 @@ var COST_PROBE = {
6986
7045
  };
6987
7046
  var MARK_MAX_EDGE = 2048;
6988
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
+ }
6989
7065
  var readImagePart = async (core, req, normalize2) => {
6990
7066
  const part = await req.file();
6991
7067
  if (!part) return { error: "multipart file field required" };
@@ -7302,7 +7378,7 @@ async function repairPresenterCrops(core, log = () => {
7302
7378
  let repaired = 0;
7303
7379
  for (const brand of core.store.listBrands()) {
7304
7380
  for (const c of brandCharacters(brand.json)) {
7305
- if (!isCustomPresenter(c)) continue;
7381
+ if (!c?.id) continue;
7306
7382
  try {
7307
7383
  const firstShot = c.shots?.[0]?.file;
7308
7384
  const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
@@ -7999,6 +8075,20 @@ function registerImageRoutes(app, deps) {
7999
8075
 
8000
8076
  // src/release/notes.data.ts
8001
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
+ },
8002
8092
  {
8003
8093
  version: "0.6.5",
8004
8094
  date: "2026-08-29",
@@ -9075,19 +9165,21 @@ function buildServer(opts) {
9075
9165
  }
9076
9166
  }
9077
9167
  const NODE_TIMEOUT_MS = 6e5;
9078
- async function runNode(nodeId, engine, estimate, work, expect, post) {
9168
+ async function runNode(nodeId, engine, estimate, work, expect, post, timeoutMs) {
9079
9169
  const engineId = engine?.capabilities().id ?? "local";
9080
9170
  reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
9081
9171
  const ctrl = new AbortController();
9082
9172
  runningGenerations.set(nodeId, ctrl);
9173
+ const bound = opts.nodeTimeoutMs ?? timeoutMs ?? NODE_TIMEOUT_MS;
9083
9174
  let watchdogFired = false;
9084
9175
  const watchdog = setTimeout(() => {
9085
9176
  watchdogFired = true;
9086
9177
  ctrl.abort();
9087
- }, opts.nodeTimeoutMs ?? NODE_TIMEOUT_MS);
9178
+ }, bound);
9088
9179
  const startedAt = Date.now();
9089
9180
  try {
9090
9181
  const result = await work(ctrl.signal);
9182
+ clearTimeout(watchdog);
9091
9183
  result.images = await normalizePngs(result.images);
9092
9184
  if (post) result.images = await post(result.images);
9093
9185
  if (expect) await assertAspect(result.images, expect);
@@ -9109,7 +9201,8 @@ function buildServer(opts) {
9109
9201
  }
9110
9202
  core.ledger.recordCost(engineId, nodeId, result.costUsd);
9111
9203
  } catch (err) {
9112
- 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`);
9113
9206
  else if (ctrl.signal.aborted) core.store.cancelNode(nodeId);
9114
9207
  else core.store.failNode(nodeId, String(err?.message ?? err));
9115
9208
  } finally {
@@ -9303,13 +9396,16 @@ function buildServer(opts) {
9303
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.`
9304
9397
  });
9305
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;
9306
9402
  const genReq = {
9307
9403
  prompt: finalPrompt,
9308
9404
  brand: ctx,
9309
9405
  width: Number(width),
9310
9406
  height: Number(height),
9311
9407
  count: Math.min(Math.max(1, Number(count)), 8),
9312
- ...referenceImages && cap2 > 0 ? { referenceImages: referenceImages.slice(0, cap2) } : {},
9408
+ ...sentRefs ? { referenceImages: sentRefs } : {},
9313
9409
  ...referenceRoles && cap2 > 0 ? { referenceRoles: referenceRoles.slice(0, cap2) } : {}
9314
9410
  };
9315
9411
  estimate = await engine.costEstimate(genReq);
@@ -9324,6 +9420,8 @@ function buildServer(opts) {
9324
9420
  if (!engine.capabilities().supportsEdit)
9325
9421
  return reply.status(400).send({ error: "engine does not support edits" });
9326
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);
9327
9425
  const srcBuf = core.images.read(String(srcHash));
9328
9426
  const srcMeta = await sharp7(srcBuf).metadata();
9329
9427
  if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
@@ -9496,7 +9594,8 @@ function buildServer(opts) {
9496
9594
  }
9497
9595
  return out;
9498
9596
  } : void 0;
9499
- 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(
9500
9599
  (err) => app.log.error({ err }, "node run failed")
9501
9600
  );
9502
9601
  const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",