scenri 0.6.9 → 0.6.11

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 (3) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/serve.js +178 -38
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.11](https://github.com/tonygorb/Scenri/compare/v0.6.10...v0.6.11) (2026-08-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **engine-codex:** say what a usage limit means instead of quoting the banner ([f769d81](https://github.com/tonygorb/Scenri/commit/f769d812675e8671c46959c6b2007808cb3fa97e))
9
+ * **engine-codex:** say what a usage limit means instead of quoting the banner ([edd2155](https://github.com/tonygorb/Scenri/commit/edd2155fe1bf6592376feef9ab9871e1c51ca3b1))
10
+
11
+ ## [0.6.10](https://github.com/tonygorb/Scenri/compare/v0.6.9...v0.6.10) (2026-08-29)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **engine-codex:** ask for the tool's own grid point, not our nominal pixels ([a0cc518](https://github.com/tonygorb/Scenri/commit/a0cc518c2e79702f728bc7d00edddf874d3b2dc3))
17
+ * **engine-codex:** bind references by role-named files and give every take the same instruction ([2e645b2](https://github.com/tonygorb/Scenri/commit/2e645b2d34dc7006f23792cad04ad9170f3bae28))
18
+ * **engine-codex:** bind references by role-named files and give every take the same instruction ([416999d](https://github.com/tonygorb/Scenri/commit/416999d85ad984e8e5ee4df06e12a6edf09ed84a))
19
+ * **engine-codex:** never let the model resize; the frame rides as ratio language ([a5907a0](https://github.com/tonygorb/Scenri/commit/a5907a0a2a247eb5e369d3eae94884880cdb6cc3))
20
+ * **engine-codex:** never let the model resize; the frame rides as ratio language ([063316a](https://github.com/tonygorb/Scenri/commit/063316ad15fe01000d112cf240ad10a6e5707ccf))
21
+ * validate and orient engine images before storing; crop drifted frames to the asked ratio ([b88c039](https://github.com/tonygorb/Scenri/commit/b88c0392ff97a103301617ad23ddfcb500303e90))
22
+ * validate and orient engine images before storing; crop drifted frames to the asked ratio ([3cbd462](https://github.com/tonygorb/Scenri/commit/3cbd462df35b322cd55c1e3b6e8c47b767ee4880))
23
+ * widen the crop net to the drift real briefs actually produce ([491aa3c](https://github.com/tonygorb/Scenri/commit/491aa3c9f03e1511188a3fc71a2de817a9aff289))
24
+
3
25
  ## [0.6.9](https://github.com/tonygorb/Scenri/compare/v0.6.8...v0.6.9) (2026-08-29)
4
26
 
5
27
 
package/dist/serve.js CHANGED
@@ -1310,6 +1310,28 @@ var EDIT_REFERENCE_ROLE_DIRECTIVE = {
1310
1310
  reference: "a reference for composition, lighting and treatment only"
1311
1311
  };
1312
1312
  var ASPECT_TOLERANCE = 0.15;
1313
+ var NAMED_RATIOS = [
1314
+ ["1:1", 1],
1315
+ ["4:5", 4 / 5],
1316
+ ["5:4", 5 / 4],
1317
+ ["2:3", 2 / 3],
1318
+ ["3:2", 3 / 2],
1319
+ ["3:4", 3 / 4],
1320
+ ["4:3", 4 / 3],
1321
+ ["9:16", 9 / 16],
1322
+ ["16:9", 16 / 9],
1323
+ ["2:1", 2],
1324
+ ["1:2", 0.5]
1325
+ ];
1326
+ function ratioLabel(width, height) {
1327
+ const ratio = width / height;
1328
+ for (const [label, value] of NAMED_RATIOS) {
1329
+ if (Math.abs(ratio - value) / value < 0.02) return label;
1330
+ }
1331
+ const gcd = (a, b) => b ? gcd(b, a % b) : a;
1332
+ const d = gcd(width, height) || 1;
1333
+ return `${Math.round(width / d)}:${Math.round(height / d)}`;
1334
+ }
1313
1335
 
1314
1336
  // ../core/src/index.ts
1315
1337
  function defaultHome() {
@@ -2118,13 +2140,26 @@ function createRunner(opts = {}) {
2118
2140
  child.on("exit", (code) => {
2119
2141
  if (code === 0) {
2120
2142
  finish("ok", resolve);
2121
- } else {
2122
- const snippet2 = stderr.trim().slice(0, 200);
2143
+ return;
2144
+ }
2145
+ const limit = /You've hit your usage limit\.(?:[^\n]*?\btry again at ([^.\n]+)\.)?/.exec(stderr);
2146
+ if (limit) {
2147
+ const when = limit[1]?.trim();
2123
2148
  finish(
2124
2149
  `exit-${code ?? "unknown"}`,
2125
- () => reject(new Error(`codex exited with code ${code ?? "unknown"}${snippet2 ? `: ${snippet2}` : ""}`))
2150
+ () => reject(
2151
+ new Error(
2152
+ `Your Codex plan's usage limit is used up${when ? ` until ${when}` : ""}. Generation resumes on its own then, or add credits from your Codex account.`
2153
+ )
2154
+ )
2126
2155
  );
2156
+ return;
2127
2157
  }
2158
+ const snippet2 = stderr.trim().slice(0, 200);
2159
+ finish(
2160
+ `exit-${code ?? "unknown"}`,
2161
+ () => reject(new Error(`codex exited with code ${code ?? "unknown"}${snippet2 ? `: ${snippet2}` : ""}`))
2162
+ );
2128
2163
  });
2129
2164
  });
2130
2165
  }
@@ -2479,6 +2514,27 @@ var CODEX_POOL = 2;
2479
2514
  function codexNodeBudgetMs(count) {
2480
2515
  return Math.ceil(Math.max(1, count) / CODEX_POOL) * DEFAULT_TIMEOUT_MS2 + 6e4;
2481
2516
  }
2517
+ function orientationOf(width, height) {
2518
+ return width === height ? "square" : width > height ? "landscape" : "portrait";
2519
+ }
2520
+ var CODEX_PIXEL_BUDGET = 1572864;
2521
+ function codexNativeSize(width, height) {
2522
+ const ratio = width / height;
2523
+ if (!(ratio > 0) || !Number.isFinite(ratio)) return { width, height };
2524
+ return {
2525
+ width: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET * ratio)),
2526
+ height: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET / ratio))
2527
+ };
2528
+ }
2529
+ function refFileNames(roles, count) {
2530
+ const perRole = /* @__PURE__ */ new Map();
2531
+ return Array.from({ length: count }, (_, i) => {
2532
+ const role = roles[i] ?? "reference";
2533
+ const n = (perRole.get(role) ?? 0) + 1;
2534
+ perRole.set(role, n);
2535
+ return `${role}-${n}.png`;
2536
+ });
2537
+ }
2482
2538
  function createCodexEngine(opts) {
2483
2539
  const { saveImage } = opts;
2484
2540
  const platform = opts.platform ?? process.platform;
@@ -2511,7 +2567,9 @@ function createCodexEngine(opts) {
2511
2567
  }
2512
2568
  const hashes = [];
2513
2569
  for (const name of outFiles) {
2514
- hashes.push(saveImage(await readFile(join(dir, name))));
2570
+ const buf = await readFile(join(dir, name));
2571
+ if (buf.length === 0) throw new Error(`codex: ${name} is empty`);
2572
+ hashes.push(saveImage(buf));
2515
2573
  }
2516
2574
  return hashes;
2517
2575
  }
@@ -2558,6 +2616,11 @@ function createCodexEngine(opts) {
2558
2616
  * attached — and on an edit the first one attached is `input.png`, the
2559
2617
  * shot being edited. A refine carrying a full identity payload was
2560
2618
  * silently editing nothing at all.
2619
+ *
2620
+ * That same backwards walk is why every reference is bound to its role
2621
+ * by FILENAME (refFileNames) on both paths: the tool decides which
2622
+ * pictures it surfaces and in what order, so an ordinal "Attached
2623
+ * image N" binding pointed identity claims at the wrong picture.
2561
2624
  */
2562
2625
  maxReferenceImages: 5,
2563
2626
  /*
@@ -2589,8 +2652,9 @@ function createCodexEngine(opts) {
2589
2652
  (_, i) => async () => withWorkDir(async (dir) => {
2590
2653
  const args = execArgs(dir);
2591
2654
  let refBytes = 0;
2655
+ const names = refFileNames(roles, refs.length);
2592
2656
  for (const [idx, ref] of refs.entries()) {
2593
- const dest = join(dir, `ref-${idx}.png`);
2657
+ const dest = join(dir, names[idx]);
2594
2658
  await copyFile(ref, dest);
2595
2659
  refBytes += (await stat(dest)).size;
2596
2660
  args.splice(args.length - 1, 0, `--image=${dest}`);
@@ -2666,9 +2730,15 @@ function createCodexEngine(opts) {
2666
2730
  await copyFile(editRefs[i], join(dir, name));
2667
2731
  refLines.push(`${name} shows ${EDIT_REFERENCE_ROLE_DIRECTIVE[role]}`);
2668
2732
  }
2669
- 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).` + // sips is already licensed by the parenthesis above; an exact-size
2670
- // answer lets the caller's compositing pass skip its rescale.
2671
- (req.width && req.height ? ` Save the result at exactly ${req.width}x${req.height} pixels.` : "") + ` Nothing else.`;
2733
+ const promptText = `Edit input.png using your image generation/editing tool: ${req.instruction}.` + (refLines.length ? ` ${refLines.join(". ")}.` : "") + // The old tail licensed "the commands needed to save and resize it"
2734
+ // and then asked for exactly WxH pixels - which the model honoured
2735
+ // with sips -z, a force-fit of BOTH axes. Every refine hop was a
2736
+ // cheap-kernel shell resample of freshly generated pixels, and when
2737
+ // the tool had drifted the shape it was a shear: the reported
2738
+ // crushed faces and the deep-chain mush. The server's own canvas
2739
+ // pass (enforceEditCanvas) owns size now, with one uniform lanczos
2740
+ // only when actually needed.
2741
+ (req.width && req.height ? ` Keep the edited frame at input.png's own ${ratioLabel(req.width, req.height)} shape, ${codexNativeSize(req.width, req.height).width}x${codexNativeSize(req.width, req.height).height}.` : "") + ` Do not browse the web or explore files. Save the tool's output in the current directory as out-1.png, byte-for-byte unchanged: you may run the commands needed to copy or move the file, but never resize, scale, stretch, pad, crop or re-encode it \u2014 deliver the tool's own pixels at the tool's own size. Nothing else.`;
2672
2742
  const args = execArgs(dir);
2673
2743
  for (const name of ["input.png", ...refLines.map((_, i) => `${editRoles[i] ?? "reference"}-${i + 1}.png`)]) {
2674
2744
  args.splice(args.length - 1, 0, `--image=${join(dir, name)}`);
@@ -2692,16 +2762,29 @@ function createCodexEngine(opts) {
2692
2762
  }
2693
2763
  function buildPrompt2(req, index, roles) {
2694
2764
  const roleDirective = REFERENCE_ROLE_DIRECTIVE;
2695
- const refDirectives = roles.map(
2696
- (role, i) => roles.length > 1 ? `Attached image ${i + 1} is ${roleDirective[role]}.` : `The attached image is ${roleDirective[role]}.`
2697
- ).join(" ");
2765
+ const names = refFileNames(roles, roles.length);
2766
+ const refDirectives = roles.map((role, i) => `${names[i]} shows ${roleDirective[role]}.`).join(" ");
2767
+ const count = Math.max(1, req.count);
2768
+ const native = codexNativeSize(req.width, req.height);
2698
2769
  return (
2699
2770
  // "professional-grade", not "flawless": the audit of the waxy-presenter
2700
2771
  // report traced part of the plastic, over-perfected rendering to that
2701
2772
  // one unconditional word. Still "image", never "photograph" - this
2702
2773
  // wrapper also generates graphic assets. Independently revertible on
2703
2774
  // render evidence.
2704
- `Generate one professional-grade image immediately using your image generation tool, ${req.width}x${req.height}: ${req.prompt}.` + (refDirectives ? ` ${refDirectives}` : "") + ` Do not browse the web or explore files. Save the image in the current directory as out-1.png (you may run the commands needed to save and resize it). Nothing else.` + (index > 0 ? ` (variant ${index + 1} \u2014 same brief, different composition)` : "")
2775
+ `Generate one professional-grade image immediately using your image generation tool, composed as a ${native.width}x${native.height} frame (${ratioLabel(req.width, req.height)} ${orientationOf(req.width, req.height)}): ${req.prompt}.` + (refDirectives ? ` ${refDirectives}` : "") + // The save instruction bans what the old one licensed. "you may run the
2776
+ // commands needed to save and resize it" invited sips -z, which
2777
+ // force-fits BOTH axes: the model drew at one shape, sheared the pixels
2778
+ // to the requested one, and the aspect check passed BECAUSE of the shear
2779
+ // - the reported crushed faces. Copy/move stays licensed because the
2780
+ // win32 recovery path moves files out of generated_images.
2781
+ ` Do not browse the web or explore files. Save the tool's output in the current directory as out-1.png, byte-for-byte unchanged: you may run the commands needed to copy or move the file, but never resize, scale, stretch, pad, crop or re-encode it \u2014 deliver the tool's own pixels at the tool's own size. Nothing else.` + // Every take in a batch gets the SAME-shaped clause. Take 1 used to get
2782
+ // nothing - so the first output was literally asked for the most
2783
+ // reference-faithful decode - and later takes were licensed to a
2784
+ // "different composition", which read as permission to drift from the
2785
+ // directives. Reported as: output #1 copies the scene reference, output
2786
+ // #2 mixes identities. A single generation stays byte-stable.
2787
+ (count > 1 ? ` (take ${index + 1} of ${count} \u2014 same brief, same identities and constraints, a naturally different moment and framing of the same shoot)` : "")
2705
2788
  );
2706
2789
  }
2707
2790
  }
@@ -6442,28 +6525,6 @@ Continue: the same surface, the same light direction, the same colour temperatur
6442
6525
  Constraints: change only the blurred margin; keep the sharp photograph unchanged in position, scale and content.
6443
6526
  Avoid: new objects, products, people, text or watermarks.` + own;
6444
6527
  }
6445
- var NAMED_RATIOS = [
6446
- ["1:1", 1],
6447
- ["4:5", 4 / 5],
6448
- ["5:4", 5 / 4],
6449
- ["2:3", 2 / 3],
6450
- ["3:2", 3 / 2],
6451
- ["3:4", 3 / 4],
6452
- ["4:3", 4 / 3],
6453
- ["9:16", 9 / 16],
6454
- ["16:9", 16 / 9],
6455
- ["2:1", 2],
6456
- ["1:2", 0.5]
6457
- ];
6458
- function ratioLabel(width, height) {
6459
- const ratio = width / height;
6460
- for (const [label, value] of NAMED_RATIOS) {
6461
- if (Math.abs(ratio - value) / value < 0.02) return label;
6462
- }
6463
- const gcd = (a, b) => b ? gcd(b, a % b) : a;
6464
- const d = gcd(width, height) || 1;
6465
- return `${Math.round(width / d)}:${Math.round(height / d)}`;
6466
- }
6467
6528
  function reframeInstruction(plan, source, direction) {
6468
6529
  const wider = plan.axis === "width";
6469
6530
  const where = wider ? "to the left and to the right" : "above and below";
@@ -8164,6 +8225,31 @@ function registerImageRoutes(app, deps) {
8164
8225
 
8165
8226
  // src/release/notes.data.ts
8166
8227
  var RELEASES = [
8228
+ {
8229
+ version: "0.6.11",
8230
+ date: "2026-08-29",
8231
+ sections: [
8232
+ {
8233
+ heading: "Create",
8234
+ body: "Hitting your Codex plan's usage limit now says so in plain words, with the time it comes back, instead of a technical session listing."
8235
+ }
8236
+ ]
8237
+ },
8238
+ {
8239
+ version: "0.6.10",
8240
+ date: "2026-08-29",
8241
+ title: "Pictures keep their shape.",
8242
+ sections: [
8243
+ {
8244
+ heading: "Create",
8245
+ body: "Images can no longer come back crushed or stretched: Scenri now works with the exact frame sizes the Codex image tool actually produces, and an answer that drifts off the requested shape is trimmed to it rather than distorted or refused. The wave of failed shots saying the engine could not produce the requested aspect ratio is gone with it, and refining an image over and over keeps its full sharpness at every step."
8246
+ },
8247
+ {
8248
+ heading: "Scenes",
8249
+ body: "When a scene built from photographs of one person is used with a chosen presenter, every output now shows the chosen presenter. Each reference image travels with a name that says what it is, so a scene photograph can lend its world and its styling without lending anyone a face, and asking for several variations gives every variation the same instructions rather than letting the first one copy the reference."
8250
+ }
8251
+ ]
8252
+ },
8167
8253
  {
8168
8254
  version: "0.6.9",
8169
8255
  date: "2026-08-29",
@@ -9316,10 +9402,51 @@ function buildServer(opts) {
9316
9402
  const out = [];
9317
9403
  for (const h of images) {
9318
9404
  const buf = core.images.read(h);
9319
- out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp7(buf).png().toBuffer()));
9405
+ const meta2 = await sharp7(buf).metadata().catch(() => null);
9406
+ if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
9407
+ const oriented = (meta2.orientation ?? 1) !== 1;
9408
+ out.push(
9409
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp7(buf).rotate().png().toBuffer())
9410
+ );
9320
9411
  }
9321
9412
  return out;
9322
9413
  }
9414
+ const CROPPABLE_DRIFT = 0.35;
9415
+ function conformToCanvas(nodeId, want) {
9416
+ return async (images) => {
9417
+ const target = want.width / want.height;
9418
+ const out = [];
9419
+ for (const h of images) {
9420
+ const buf = core.images.read(h);
9421
+ const meta2 = await sharp7(buf).metadata();
9422
+ if (!meta2.width || !meta2.height) {
9423
+ out.push(h);
9424
+ continue;
9425
+ }
9426
+ const got = meta2.width / meta2.height;
9427
+ const drift = Math.abs(got - target) / target;
9428
+ if (drift <= SAME_SHAPE_TOL || drift > CROPPABLE_DRIFT) {
9429
+ out.push(h);
9430
+ continue;
9431
+ }
9432
+ const w = got > target ? Math.round(meta2.height * target) : meta2.width;
9433
+ const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
9434
+ const cropped = await sharp7(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9435
+ app.log.info(
9436
+ { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
9437
+ "canvas: cropped a drifted frame to the asked ratio"
9438
+ );
9439
+ out.push(core.images.save(cropped));
9440
+ try {
9441
+ const fresh = core.store.getNode(nodeId);
9442
+ const b = fresh?.brief ?? {};
9443
+ core.store.setBrief(nodeId, { ...b, croppedFrom: [meta2.width, meta2.height] });
9444
+ } catch {
9445
+ }
9446
+ }
9447
+ return out;
9448
+ };
9449
+ }
9323
9450
  async function assertAspect(images, expect) {
9324
9451
  const want = expect.width / expect.height;
9325
9452
  for (const h of images) {
@@ -9363,7 +9490,13 @@ function buildServer(opts) {
9363
9490
  const brief = node.brief ?? {};
9364
9491
  const raw = result.raw;
9365
9492
  const survivors = typeof raw?.requested === "number" && Array.isArray(raw.variantIndexes) ? { requested: raw.requested, variantIndexes: raw.variantIndexes } : {};
9366
- core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors } });
9493
+ const asked = expect ? { requestedSize: [expect.width, expect.height] } : {};
9494
+ core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors, ...asked } });
9495
+ if (node.kind === "generation" && engineId === "codex-cli" && expect && expect.width !== expect.height && sizes.some(([w, h]) => w === expect.width && h === expect.height))
9496
+ app.log.warn(
9497
+ { nodeId },
9498
+ "codex delivered exactly the requested pixels; its image tool cannot pin size - suggests a forbidden shell resize"
9499
+ );
9367
9500
  }
9368
9501
  } catch {
9369
9502
  }
@@ -9760,7 +9893,12 @@ function buildServer(opts) {
9760
9893
  try {
9761
9894
  const fresh = core.store.getNode(node.id);
9762
9895
  const b = fresh?.brief ?? {};
9763
- core.store.setBrief(node.id, { ...b, resizedFrom: [got.width, got.height] });
9896
+ const parentBrief = core.store.getNode(resolvedParentId)?.brief ?? {};
9897
+ core.store.setBrief(node.id, {
9898
+ ...b,
9899
+ resizedFrom: [got.width, got.height],
9900
+ resampledHops: (parentBrief.resampledHops ?? 0) + 1
9901
+ });
9764
9902
  } catch {
9765
9903
  }
9766
9904
  } else out.push(h);
@@ -9805,9 +9943,11 @@ function buildServer(opts) {
9805
9943
  out.push(outcome === "composited" ? core.images.save(image) : h);
9806
9944
  }
9807
9945
  staged = out;
9946
+ } else if (expectShape) {
9947
+ staged = await conformToCanvas(node.id, expectShape)(staged);
9808
9948
  }
9809
9949
  return enforceEditCanvas(staged);
9810
- } : void 0;
9950
+ } : kind === "generation" && compiled2?.width && compiled2?.height ? conformToCanvas(node.id, { width: compiled2.width, height: compiled2.height }) : void 0;
9811
9951
  const nodeBudgetMs = kind === "generation" && runEngine.capabilities().id === "codex-cli" ? codexNodeBudgetMs(Math.min(Math.max(1, Number(count)), 8)) : void 0;
9812
9952
  void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
9813
9953
  (err) => app.log.error({ err }, "node run failed")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",