scenri 0.6.9 → 0.6.10

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 +14 -0
  2. package/dist/serve.js +152 -35
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.10](https://github.com/tonygorb/Scenri/compare/v0.6.9...v0.6.10) (2026-08-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **engine-codex:** ask for the tool's own grid point, not our nominal pixels ([a0cc518](https://github.com/tonygorb/Scenri/commit/a0cc518c2e79702f728bc7d00edddf874d3b2dc3))
9
+ * **engine-codex:** bind references by role-named files and give every take the same instruction ([2e645b2](https://github.com/tonygorb/Scenri/commit/2e645b2d34dc7006f23792cad04ad9170f3bae28))
10
+ * **engine-codex:** bind references by role-named files and give every take the same instruction ([416999d](https://github.com/tonygorb/Scenri/commit/416999d85ad984e8e5ee4df06e12a6edf09ed84a))
11
+ * **engine-codex:** never let the model resize; the frame rides as ratio language ([a5907a0](https://github.com/tonygorb/Scenri/commit/a5907a0a2a247eb5e369d3eae94884880cdb6cc3))
12
+ * **engine-codex:** never let the model resize; the frame rides as ratio language ([063316a](https://github.com/tonygorb/Scenri/commit/063316ad15fe01000d112cf240ad10a6e5707ccf))
13
+ * validate and orient engine images before storing; crop drifted frames to the asked ratio ([b88c039](https://github.com/tonygorb/Scenri/commit/b88c0392ff97a103301617ad23ddfcb500303e90))
14
+ * validate and orient engine images before storing; crop drifted frames to the asked ratio ([3cbd462](https://github.com/tonygorb/Scenri/commit/3cbd462df35b322cd55c1e3b6e8c47b767ee4880))
15
+ * widen the crop net to the drift real briefs actually produce ([491aa3c](https://github.com/tonygorb/Scenri/commit/491aa3c9f03e1511188a3fc71a2de817a9aff289))
16
+
3
17
  ## [0.6.9](https://github.com/tonygorb/Scenri/compare/v0.6.8...v0.6.9) (2026-08-29)
4
18
 
5
19
 
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() {
@@ -2479,6 +2501,27 @@ var CODEX_POOL = 2;
2479
2501
  function codexNodeBudgetMs(count) {
2480
2502
  return Math.ceil(Math.max(1, count) / CODEX_POOL) * DEFAULT_TIMEOUT_MS2 + 6e4;
2481
2503
  }
2504
+ function orientationOf(width, height) {
2505
+ return width === height ? "square" : width > height ? "landscape" : "portrait";
2506
+ }
2507
+ var CODEX_PIXEL_BUDGET = 1572864;
2508
+ function codexNativeSize(width, height) {
2509
+ const ratio = width / height;
2510
+ if (!(ratio > 0) || !Number.isFinite(ratio)) return { width, height };
2511
+ return {
2512
+ width: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET * ratio)),
2513
+ height: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET / ratio))
2514
+ };
2515
+ }
2516
+ function refFileNames(roles, count) {
2517
+ const perRole = /* @__PURE__ */ new Map();
2518
+ return Array.from({ length: count }, (_, i) => {
2519
+ const role = roles[i] ?? "reference";
2520
+ const n = (perRole.get(role) ?? 0) + 1;
2521
+ perRole.set(role, n);
2522
+ return `${role}-${n}.png`;
2523
+ });
2524
+ }
2482
2525
  function createCodexEngine(opts) {
2483
2526
  const { saveImage } = opts;
2484
2527
  const platform = opts.platform ?? process.platform;
@@ -2511,7 +2554,9 @@ function createCodexEngine(opts) {
2511
2554
  }
2512
2555
  const hashes = [];
2513
2556
  for (const name of outFiles) {
2514
- hashes.push(saveImage(await readFile(join(dir, name))));
2557
+ const buf = await readFile(join(dir, name));
2558
+ if (buf.length === 0) throw new Error(`codex: ${name} is empty`);
2559
+ hashes.push(saveImage(buf));
2515
2560
  }
2516
2561
  return hashes;
2517
2562
  }
@@ -2558,6 +2603,11 @@ function createCodexEngine(opts) {
2558
2603
  * attached — and on an edit the first one attached is `input.png`, the
2559
2604
  * shot being edited. A refine carrying a full identity payload was
2560
2605
  * silently editing nothing at all.
2606
+ *
2607
+ * That same backwards walk is why every reference is bound to its role
2608
+ * by FILENAME (refFileNames) on both paths: the tool decides which
2609
+ * pictures it surfaces and in what order, so an ordinal "Attached
2610
+ * image N" binding pointed identity claims at the wrong picture.
2561
2611
  */
2562
2612
  maxReferenceImages: 5,
2563
2613
  /*
@@ -2589,8 +2639,9 @@ function createCodexEngine(opts) {
2589
2639
  (_, i) => async () => withWorkDir(async (dir) => {
2590
2640
  const args = execArgs(dir);
2591
2641
  let refBytes = 0;
2642
+ const names = refFileNames(roles, refs.length);
2592
2643
  for (const [idx, ref] of refs.entries()) {
2593
- const dest = join(dir, `ref-${idx}.png`);
2644
+ const dest = join(dir, names[idx]);
2594
2645
  await copyFile(ref, dest);
2595
2646
  refBytes += (await stat(dest)).size;
2596
2647
  args.splice(args.length - 1, 0, `--image=${dest}`);
@@ -2666,9 +2717,15 @@ function createCodexEngine(opts) {
2666
2717
  await copyFile(editRefs[i], join(dir, name));
2667
2718
  refLines.push(`${name} shows ${EDIT_REFERENCE_ROLE_DIRECTIVE[role]}`);
2668
2719
  }
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.`;
2720
+ 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"
2721
+ // and then asked for exactly WxH pixels - which the model honoured
2722
+ // with sips -z, a force-fit of BOTH axes. Every refine hop was a
2723
+ // cheap-kernel shell resample of freshly generated pixels, and when
2724
+ // the tool had drifted the shape it was a shear: the reported
2725
+ // crushed faces and the deep-chain mush. The server's own canvas
2726
+ // pass (enforceEditCanvas) owns size now, with one uniform lanczos
2727
+ // only when actually needed.
2728
+ (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
2729
  const args = execArgs(dir);
2673
2730
  for (const name of ["input.png", ...refLines.map((_, i) => `${editRoles[i] ?? "reference"}-${i + 1}.png`)]) {
2674
2731
  args.splice(args.length - 1, 0, `--image=${join(dir, name)}`);
@@ -2692,16 +2749,29 @@ function createCodexEngine(opts) {
2692
2749
  }
2693
2750
  function buildPrompt2(req, index, roles) {
2694
2751
  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(" ");
2752
+ const names = refFileNames(roles, roles.length);
2753
+ const refDirectives = roles.map((role, i) => `${names[i]} shows ${roleDirective[role]}.`).join(" ");
2754
+ const count = Math.max(1, req.count);
2755
+ const native = codexNativeSize(req.width, req.height);
2698
2756
  return (
2699
2757
  // "professional-grade", not "flawless": the audit of the waxy-presenter
2700
2758
  // report traced part of the plastic, over-perfected rendering to that
2701
2759
  // one unconditional word. Still "image", never "photograph" - this
2702
2760
  // wrapper also generates graphic assets. Independently revertible on
2703
2761
  // 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)` : "")
2762
+ `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
2763
+ // commands needed to save and resize it" invited sips -z, which
2764
+ // force-fits BOTH axes: the model drew at one shape, sheared the pixels
2765
+ // to the requested one, and the aspect check passed BECAUSE of the shear
2766
+ // - the reported crushed faces. Copy/move stays licensed because the
2767
+ // win32 recovery path moves files out of generated_images.
2768
+ ` 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
2769
+ // nothing - so the first output was literally asked for the most
2770
+ // reference-faithful decode - and later takes were licensed to a
2771
+ // "different composition", which read as permission to drift from the
2772
+ // directives. Reported as: output #1 copies the scene reference, output
2773
+ // #2 mixes identities. A single generation stays byte-stable.
2774
+ (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
2775
  );
2706
2776
  }
2707
2777
  }
@@ -6442,28 +6512,6 @@ Continue: the same surface, the same light direction, the same colour temperatur
6442
6512
  Constraints: change only the blurred margin; keep the sharp photograph unchanged in position, scale and content.
6443
6513
  Avoid: new objects, products, people, text or watermarks.` + own;
6444
6514
  }
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
6515
  function reframeInstruction(plan, source, direction) {
6468
6516
  const wider = plan.axis === "width";
6469
6517
  const where = wider ? "to the left and to the right" : "above and below";
@@ -8164,6 +8212,21 @@ function registerImageRoutes(app, deps) {
8164
8212
 
8165
8213
  // src/release/notes.data.ts
8166
8214
  var RELEASES = [
8215
+ {
8216
+ version: "0.6.10",
8217
+ date: "2026-08-29",
8218
+ title: "Pictures keep their shape.",
8219
+ sections: [
8220
+ {
8221
+ heading: "Create",
8222
+ 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."
8223
+ },
8224
+ {
8225
+ heading: "Scenes",
8226
+ 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."
8227
+ }
8228
+ ]
8229
+ },
8167
8230
  {
8168
8231
  version: "0.6.9",
8169
8232
  date: "2026-08-29",
@@ -9316,10 +9379,51 @@ function buildServer(opts) {
9316
9379
  const out = [];
9317
9380
  for (const h of images) {
9318
9381
  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()));
9382
+ const meta2 = await sharp7(buf).metadata().catch(() => null);
9383
+ if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
9384
+ const oriented = (meta2.orientation ?? 1) !== 1;
9385
+ out.push(
9386
+ buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp7(buf).rotate().png().toBuffer())
9387
+ );
9320
9388
  }
9321
9389
  return out;
9322
9390
  }
9391
+ const CROPPABLE_DRIFT = 0.35;
9392
+ function conformToCanvas(nodeId, want) {
9393
+ return async (images) => {
9394
+ const target = want.width / want.height;
9395
+ const out = [];
9396
+ for (const h of images) {
9397
+ const buf = core.images.read(h);
9398
+ const meta2 = await sharp7(buf).metadata();
9399
+ if (!meta2.width || !meta2.height) {
9400
+ out.push(h);
9401
+ continue;
9402
+ }
9403
+ const got = meta2.width / meta2.height;
9404
+ const drift = Math.abs(got - target) / target;
9405
+ if (drift <= SAME_SHAPE_TOL || drift > CROPPABLE_DRIFT) {
9406
+ out.push(h);
9407
+ continue;
9408
+ }
9409
+ const w = got > target ? Math.round(meta2.height * target) : meta2.width;
9410
+ const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
9411
+ const cropped = await sharp7(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
9412
+ app.log.info(
9413
+ { nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
9414
+ "canvas: cropped a drifted frame to the asked ratio"
9415
+ );
9416
+ out.push(core.images.save(cropped));
9417
+ try {
9418
+ const fresh = core.store.getNode(nodeId);
9419
+ const b = fresh?.brief ?? {};
9420
+ core.store.setBrief(nodeId, { ...b, croppedFrom: [meta2.width, meta2.height] });
9421
+ } catch {
9422
+ }
9423
+ }
9424
+ return out;
9425
+ };
9426
+ }
9323
9427
  async function assertAspect(images, expect) {
9324
9428
  const want = expect.width / expect.height;
9325
9429
  for (const h of images) {
@@ -9363,7 +9467,13 @@ function buildServer(opts) {
9363
9467
  const brief = node.brief ?? {};
9364
9468
  const raw = result.raw;
9365
9469
  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 } });
9470
+ const asked = expect ? { requestedSize: [expect.width, expect.height] } : {};
9471
+ core.store.setBrief(nodeId, { ...brief, rendered: { sizes, ...survivors, ...asked } });
9472
+ if (node.kind === "generation" && engineId === "codex-cli" && expect && expect.width !== expect.height && sizes.some(([w, h]) => w === expect.width && h === expect.height))
9473
+ app.log.warn(
9474
+ { nodeId },
9475
+ "codex delivered exactly the requested pixels; its image tool cannot pin size - suggests a forbidden shell resize"
9476
+ );
9367
9477
  }
9368
9478
  } catch {
9369
9479
  }
@@ -9760,7 +9870,12 @@ function buildServer(opts) {
9760
9870
  try {
9761
9871
  const fresh = core.store.getNode(node.id);
9762
9872
  const b = fresh?.brief ?? {};
9763
- core.store.setBrief(node.id, { ...b, resizedFrom: [got.width, got.height] });
9873
+ const parentBrief = core.store.getNode(resolvedParentId)?.brief ?? {};
9874
+ core.store.setBrief(node.id, {
9875
+ ...b,
9876
+ resizedFrom: [got.width, got.height],
9877
+ resampledHops: (parentBrief.resampledHops ?? 0) + 1
9878
+ });
9764
9879
  } catch {
9765
9880
  }
9766
9881
  } else out.push(h);
@@ -9805,9 +9920,11 @@ function buildServer(opts) {
9805
9920
  out.push(outcome === "composited" ? core.images.save(image) : h);
9806
9921
  }
9807
9922
  staged = out;
9923
+ } else if (expectShape) {
9924
+ staged = await conformToCanvas(node.id, expectShape)(staged);
9808
9925
  }
9809
9926
  return enforceEditCanvas(staged);
9810
- } : void 0;
9927
+ } : kind === "generation" && compiled2?.width && compiled2?.height ? conformToCanvas(node.id, { width: compiled2.width, height: compiled2.height }) : void 0;
9811
9928
  const nodeBudgetMs = kind === "generation" && runEngine.capabilities().id === "codex-cli" ? codexNodeBudgetMs(Math.min(Math.max(1, Number(count)), 8)) : void 0;
9812
9929
  void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
9813
9930
  (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.10",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",