scenri 0.5.1 → 0.6.1

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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.1](https://github.com/tonygorb/Scenri/compare/v0.6.0...v0.6.1) (2026-08-26)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * refine aspect ratio belongs to the generation being refined ([1177f95](https://github.com/tonygorb/Scenri/commit/1177f955a926eca917096b328e8ec4fd86f913a2))
9
+ * refine aspect ratio belongs to the generation being refined ([2ef201c](https://github.com/tonygorb/Scenri/commit/2ef201c079a8a1c07fdbc18e43f4962d8689a41d))
10
+
11
+ ## [0.6.0](https://github.com/tonygorb/Scenri/compare/v0.5.1...v0.6.0) (2026-08-26)
12
+
13
+
14
+ ### Features
15
+
16
+ * an extend keeps the exact picture unless the join would show ([af6d8ba](https://github.com/tonygorb/Scenri/commit/af6d8baecbe6b524e7333ad0b63663f73c213642))
17
+ * an extend keeps the exact picture unless the join would show ([bb48b52](https://github.com/tonygorb/Scenri/commit/bb48b52c77dcc1fc62f0ffc35cd26a81934f9423))
18
+ * composite both draws and keep the better join ([8bbada7](https://github.com/tonygorb/Scenri/commit/8bbada70cf84268c347166bdeb768767b99386df))
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * codex reads at most five images, and the sixth was the shot itself ([91fe75e](https://github.com/tonygorb/Scenri/commit/91fe75e017c25313f64b79fb4d4b32cfc628838a))
24
+ * codex reads at most five images, and the sixth was the shot itself ([3e3c430](https://github.com/tonygorb/Scenri/commit/3e3c4307df355cf7091a905b5416f5eabbb88504))
25
+
3
26
  ## [0.5.1](https://github.com/tonygorb/Scenri/compare/v0.5.0...v0.5.1) (2026-08-26)
4
27
 
5
28
 
package/dist/serve.js CHANGED
@@ -2485,14 +2485,27 @@ function createCodexEngine(opts) {
2485
2485
  // OSS-local only: the user's own session, on the user's own machine
2486
2486
  supportsEdit: true,
2487
2487
  supportsMask: false,
2488
- // The underlying `codex` binary's --image flag is genuinely variadic
2489
- // ("-i, --image <FILE>...", re-confirmed via `codex exec --help`), so
2490
- // this number is a product decision, not a binary constraint. It is
2491
- // sized to hold a full identity payload without eviction:
2492
- // PRODUCT_REF_MAX (3 angles) + CHARACTER_REF_MAX (2 views) + one
2493
- // style reference = 6. Below this, compileBrief's role-priority clamp
2494
- // starts dropping real identity information.
2495
- maxReferenceImages: 6
2488
+ /*
2489
+ * Five, and it is a hard constraint of the image tool, not a product
2490
+ * choice.
2491
+ *
2492
+ * The `codex` binary's --image flag really is variadic, which is why
2493
+ * this used to be 6 one style reference on top of PRODUCT_REF_MAX (3)
2494
+ * plus CHARACTER_REF_MAX (2). But the flag only puts pictures into the
2495
+ * conversation; the thing that consumes them is codex's built-in
2496
+ * image_gen tool, and that caps at five either way it is called:
2497
+ * `referenced_image_paths` longer than five is a hard tool error, and
2498
+ * `num_last_images_to_include` is validated to 1..=5
2499
+ * (codex-rs/ext/image-generation/src/tool.rs, MAX_EDIT_IMAGES = 5).
2500
+ *
2501
+ * Six was therefore not a generous budget, it was an eviction. On the
2502
+ * context route `recent_images` walks the history BACKWARDS and keeps
2503
+ * the last five, so the sixth image to be dropped is the FIRST one
2504
+ * attached — and on an edit the first one attached is `input.png`, the
2505
+ * shot being edited. A refine carrying a full identity payload was
2506
+ * silently editing nothing at all.
2507
+ */
2508
+ maxReferenceImages: 5
2496
2509
  };
2497
2510
  },
2498
2511
  isAvailable() {
@@ -6149,6 +6162,44 @@ Continue: the same surface, the same light direction, the same colour temperatur
6149
6162
  Constraints: change only the blurred margin; keep the sharp photograph unchanged in position, scale and content.
6150
6163
  Avoid: new objects, products, people, text or watermarks.` + own;
6151
6164
  }
6165
+ var NAMED_RATIOS = [
6166
+ ["1:1", 1],
6167
+ ["4:5", 4 / 5],
6168
+ ["5:4", 5 / 4],
6169
+ ["2:3", 2 / 3],
6170
+ ["3:2", 3 / 2],
6171
+ ["3:4", 3 / 4],
6172
+ ["4:3", 4 / 3],
6173
+ ["9:16", 9 / 16],
6174
+ ["16:9", 16 / 9],
6175
+ ["2:1", 2],
6176
+ ["1:2", 0.5]
6177
+ ];
6178
+ function ratioLabel(width, height) {
6179
+ const ratio = width / height;
6180
+ for (const [label, value] of NAMED_RATIOS) {
6181
+ if (Math.abs(ratio - value) / value < 0.02) return label;
6182
+ }
6183
+ const gcd = (a, b) => b ? gcd(b, a % b) : a;
6184
+ const d = gcd(width, height) || 1;
6185
+ return `${Math.round(width / d)}:${Math.round(height / d)}`;
6186
+ }
6187
+ function reframeInstruction(plan, source, direction) {
6188
+ const wider = plan.axis === "width";
6189
+ const where = wider ? "to the left and to the right" : "above and below";
6190
+ const shape = ratioLabel(plan.width, plan.height);
6191
+ const share = wider ? source.width / plan.width : source.height / plan.height;
6192
+ const middle = `${Math.round(share * 100)}%`;
6193
+ const span = wider ? "width" : "height";
6194
+ const nearEdge = wider ? "" : "\nDepth: the bottom edge of the frame is the part of the surface nearest the camera; the top edge is the furthest away.";
6195
+ const extra = direction.trim().replace(/^[.\s]+/, "");
6196
+ const own = extra ? `
6197
+ Also: ${extra}` : "";
6198
+ return `Redraw this photograph as one ${shape} frame, ${wider ? "wider" : "taller"} than it is now, revealing more of the same scene ${where}.
6199
+ Frame: the photograph you were given is the middle ${middle} of the new frame's ${span}; everything outside that is scene that was just beyond the original edges.
6200
+ Keep: every object in the same place at the same size, the same camera position and height, the same light direction and colour temperature, the same depth of field, the same colours, and the same clothing.` + nearEdge + `
6201
+ Avoid: new objects, products, people, text or watermarks; do not recompose, recentre, crop or resize anything already visible.` + own;
6202
+ }
6152
6203
 
6153
6204
  // src/cropRules.ts
6154
6205
  function planCrop(source, targetRatio) {
@@ -6449,6 +6500,16 @@ function medianOf(rgb, channel, from, to) {
6449
6500
  values.sort();
6450
6501
  return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
6451
6502
  }
6503
+ async function reframeExpand(engineImage, plan) {
6504
+ const meta = await sharp7(engineImage).metadata();
6505
+ if (!(meta.width && meta.height)) return null;
6506
+ const want = plan.width / plan.height;
6507
+ const got = meta.width / meta.height;
6508
+ if (got >= 1 !== want >= 1) return null;
6509
+ if (meta.width === plan.width && meta.height === plan.height) return engineImage;
6510
+ const straight = Math.abs(got - want) / want <= 0.02;
6511
+ return sharp7(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
6512
+ }
6452
6513
  async function seamScore(image, plan, source) {
6453
6514
  const { data, info } = await sharp7(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
6454
6515
  const W = info.width;
@@ -6539,6 +6600,94 @@ function placeExpand(plan, source, fraction) {
6539
6600
  if (room <= 0) return plan;
6540
6601
  return { ...plan, top: Math.min(room, Math.max(0, Math.round(room * share))) };
6541
6602
  }
6603
+ var NEUTRAL = { r: 128, g: 128, b: 128 };
6604
+ async function conditioningCanvas(source, plan, fill = "edge") {
6605
+ const meta = await sharp7(source).metadata();
6606
+ const sw = meta.width ?? 0;
6607
+ const sh = meta.height ?? 0;
6608
+ if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
6609
+ const layers = [];
6610
+ if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
6611
+ layers.push({ input: source, left: plan.left, top: plan.top });
6612
+ const canvas = sharp7({
6613
+ create: {
6614
+ width: plan.width,
6615
+ height: plan.height,
6616
+ channels: 4,
6617
+ background: fill === "transparent" ? { ...NEUTRAL, alpha: 0 } : { ...NEUTRAL, alpha: 1 }
6618
+ }
6619
+ }).composite(layers);
6620
+ return (fill === "transparent" ? canvas : canvas.removeAlpha()).png().toBuffer();
6621
+ }
6622
+ async function edgeMargins(source, plan, size) {
6623
+ const out = [];
6624
+ const strip = async (extract, width, height) => sharp7(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
6625
+ if (plan.axis === "width") {
6626
+ const before = plan.left;
6627
+ const after = plan.width - plan.left - size.width;
6628
+ if (before > 0)
6629
+ out.push({
6630
+ input: await strip({ left: 0, top: 0, width: 1, height: size.height }, before, size.height),
6631
+ left: 0,
6632
+ top: plan.top
6633
+ });
6634
+ if (after > 0)
6635
+ out.push({
6636
+ input: await strip({ left: size.width - 1, top: 0, width: 1, height: size.height }, after, size.height),
6637
+ left: plan.left + size.width,
6638
+ top: plan.top
6639
+ });
6640
+ return out;
6641
+ }
6642
+ const above = plan.top;
6643
+ const below = plan.height - plan.top - size.height;
6644
+ if (above > 0)
6645
+ out.push({
6646
+ input: await strip({ left: 0, top: 0, width: size.width, height: 1 }, size.width, above),
6647
+ left: plan.left,
6648
+ top: 0
6649
+ });
6650
+ if (below > 0)
6651
+ out.push({
6652
+ input: await strip({ left: 0, top: size.height - 1, width: size.width, height: 1 }, size.width, below),
6653
+ left: plan.left,
6654
+ top: plan.top + size.height
6655
+ });
6656
+ return out;
6657
+ }
6658
+
6659
+ // src/outpaint/choose.ts
6660
+ function chooseExpand(candidates) {
6661
+ const { preserved, reframed } = candidates;
6662
+ const best = preserved.reduce(
6663
+ (winner, c) => winner === null || c.seam < winner.seam ? c : winner,
6664
+ null
6665
+ );
6666
+ if (!best && !reframed) return null;
6667
+ if (!best && reframed) {
6668
+ return { choice: "reframed", image: reframed.image, reason: "only-candidate", seam: null };
6669
+ }
6670
+ const won = best;
6671
+ if (!reframed) {
6672
+ return {
6673
+ choice: "preserved",
6674
+ image: won.image,
6675
+ reason: "only-candidate",
6676
+ seam: won.seam,
6677
+ from: won.from
6678
+ };
6679
+ }
6680
+ if (won.seam < SEAM_VISIBLE) {
6681
+ return {
6682
+ choice: "preserved",
6683
+ image: won.image,
6684
+ reason: "join-invisible",
6685
+ seam: won.seam,
6686
+ from: won.from
6687
+ };
6688
+ }
6689
+ return { choice: "reframed", image: reframed.image, reason: "join-visible", seam: won.seam };
6690
+ }
6542
6691
 
6543
6692
  // src/outpaint/route.ts
6544
6693
  var canOutpaint = (e) => {
@@ -7674,6 +7823,30 @@ function registerImageRoutes(app, deps) {
7674
7823
 
7675
7824
  // src/release/notes.data.ts
7676
7825
  var RELEASES = [
7826
+ {
7827
+ version: "0.6.1",
7828
+ date: "2026-08-26",
7829
+ sections: [
7830
+ {
7831
+ heading: "Shots",
7832
+ body: "Refining a shot now opens at that picture's own shape. The aspect ratio belongs to the shot in front of you rather than to the last one you touched, so refining a portrait shot no longer quietly reframes it to whatever shape a different shot was set to, and two shots open side by side can each hold their own."
7833
+ }
7834
+ ]
7835
+ },
7836
+ {
7837
+ version: "0.6.0",
7838
+ date: "2026-08-26",
7839
+ sections: [
7840
+ {
7841
+ heading: "Shots",
7842
+ body: "Changing a shot to a wider or taller shape now keeps the original photograph wherever that can be done without a visible join, and rebuilds the frame as one coherent picture where it cannot. Scenri draws the new frame two ways, looks at both joins, and keeps the one you cannot find."
7843
+ },
7844
+ {
7845
+ heading: "Fixes",
7846
+ body: "A refinement carrying a full set of product and presenter references now reaches the engine with the shot itself attached. Codex reads five pictures at most, and a sixth was quietly displacing the frame being refined."
7847
+ }
7848
+ ]
7849
+ },
7677
7850
  {
7678
7851
  version: "0.5.1",
7679
7852
  date: "2026-08-26",
@@ -8876,6 +9049,7 @@ function buildServer(opts) {
8876
9049
  let editedFrom = null;
8877
9050
  let runEngine = engine;
8878
9051
  let expandMethod = null;
9052
+ let expandDecision = null;
8879
9053
  if (compiled2) {
8880
9054
  finalPrompt = compiled2.prompt;
8881
9055
  referenceImages = compiled2.referenceImages;
@@ -8932,10 +9106,11 @@ function buildServer(opts) {
8932
9106
  expandMethod = route.method;
8933
9107
  }
8934
9108
  const canOutpaint2 = expandMethod === "outpaint";
9109
+ let reframeSourceHash;
8935
9110
  if (expandPlan) {
8936
9111
  if (!canOutpaint2) {
8937
- const canvas = await expandCanvas(srcBuf, expandPlan);
8938
- expandSourceHash = core.images.save(canvas);
9112
+ expandSourceHash = core.images.save(await expandCanvas(srcBuf, expandPlan));
9113
+ reframeSourceHash = core.images.save(await conditioningCanvas(srcBuf, expandPlan, "edge"));
8939
9114
  }
8940
9115
  expectShape = { width: expandPlan.width, height: expandPlan.height };
8941
9116
  }
@@ -8969,25 +9144,48 @@ function buildServer(opts) {
8969
9144
  const plan2 = expandPlan;
8970
9145
  const srcSize = { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
8971
9146
  const original2 = srcBuf;
8972
- work = plan2 && !canOutpaint2 ? async (signal) => {
8973
- const draws = await Promise.all([
9147
+ const reframeReq = plan2 && !canOutpaint2 && reframeSourceHash ? {
9148
+ ...editReq,
9149
+ instruction: reframeInstruction(plan2, srcSize, finalPrompt),
9150
+ sourceImage: core.images.pathFor(reframeSourceHash)
9151
+ } : null;
9152
+ work = plan2 && !canOutpaint2 && reframeReq ? async (signal) => {
9153
+ const [bedDraw, paddedDraw] = await Promise.allSettled([
8974
9154
  runEngine.edit(editReq, signal),
8975
- runEngine.edit(editReq, signal).catch(() => null)
9155
+ runEngine.edit(reframeReq, signal)
8976
9156
  ]);
8977
- const scored = await Promise.all(
8978
- draws.map(async (got) => {
8979
- const first = got?.images[0];
8980
- if (!got || !first) return null;
8981
- const { image } = await compositeExpand(core.images.read(first), original2, plan2);
8982
- const [score, residual] = await Promise.all([
8983
- seamScore(image, plan2, srcSize),
8984
- seamResidual(image, plan2, srcSize)
8985
- ]);
8986
- return { got, penalty: seamPenalty(score, residual) };
8987
- })
8988
- );
8989
- const best = scored.filter((x) => x !== null).sort((a, b) => a.penalty - b.penalty)[0];
8990
- return best?.got ?? draws[0];
9157
+ const bed = bedDraw.status === "fulfilled" ? bedDraw.value : null;
9158
+ const padded = paddedDraw.status === "fulfilled" ? paddedDraw.value : null;
9159
+ if (!bed && !padded) {
9160
+ throw bedDraw.status === "rejected" ? bedDraw.reason : paddedDraw.reason;
9161
+ }
9162
+ const preserved = [];
9163
+ for (const [from, draw2] of [
9164
+ ["bed", bed],
9165
+ ["padded", padded]
9166
+ ]) {
9167
+ const hash = draw2?.images[0];
9168
+ if (!hash) continue;
9169
+ const { image } = await compositeExpand(core.images.read(hash), original2, plan2);
9170
+ const [score, residual] = await Promise.all([
9171
+ seamScore(image, plan2, srcSize),
9172
+ seamResidual(image, plan2, srcSize)
9173
+ ]);
9174
+ preserved.push({ image, seam: seamPenalty(score, residual), from });
9175
+ }
9176
+ let reframed = null;
9177
+ const paddedImage = padded?.images[0];
9178
+ if (paddedImage) {
9179
+ const frame = await reframeExpand(core.images.read(paddedImage), plan2);
9180
+ if (frame) reframed = { image: frame };
9181
+ }
9182
+ const decision = chooseExpand({ preserved, reframed });
9183
+ if (!decision) return bed ?? padded;
9184
+ expandDecision = decision;
9185
+ return {
9186
+ images: [core.images.save(decision.image)],
9187
+ costUsd: (bed?.costUsd ?? 0) + (padded?.costUsd ?? 0)
9188
+ };
8991
9189
  } : (signal) => runEngine.edit(editReq, signal);
8992
9190
  }
8993
9191
  const billedId = runEngine.capabilities().id;
@@ -9025,7 +9223,7 @@ function buildServer(opts) {
9025
9223
  const plan = expandPlan;
9026
9224
  const original = editedFrom ? core.images.read(editedFrom) : null;
9027
9225
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
9028
- const post = plan ? async (images) => {
9226
+ const post = plan ? expandMethod === "outpaint" ? async (images) => {
9029
9227
  const out = [];
9030
9228
  for (const h of images) {
9031
9229
  const answer = core.images.read(h);
@@ -9040,6 +9238,19 @@ function buildServer(opts) {
9040
9238
  out.push(core.images.save(image));
9041
9239
  }
9042
9240
  return out;
9241
+ } : async (images) => {
9242
+ if (expandDecision)
9243
+ app.log.info(
9244
+ {
9245
+ nodeId: node.id,
9246
+ choice: expandDecision.choice,
9247
+ reason: expandDecision.reason,
9248
+ seam: expandDecision.seam,
9249
+ from: expandDecision.from
9250
+ },
9251
+ "expand: chose which frame to keep"
9252
+ );
9253
+ return images;
9043
9254
  } : localScope ? async (images) => {
9044
9255
  const out = [];
9045
9256
  for (const h of images) {
@@ -9310,8 +9521,8 @@ async function verify() {
9310
9521
  const db = new Database2(":memory:");
9311
9522
  db.pragma("user_version");
9312
9523
  db.close();
9313
- const { default: sharp18 } = await import('sharp');
9314
- await sharp18({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
9524
+ const { default: sharp19 } = await import('sharp');
9525
+ await sharp19({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
9315
9526
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
9316
9527
  } catch (err) {
9317
9528
  console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",