scenri 0.5.1 → 0.6.0

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 +15 -0
  2. package/dist/serve.js +231 -30
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0](https://github.com/tonygorb/Scenri/compare/v0.5.1...v0.6.0) (2026-08-26)
4
+
5
+
6
+ ### Features
7
+
8
+ * an extend keeps the exact picture unless the join would show ([af6d8ba](https://github.com/tonygorb/Scenri/commit/af6d8baecbe6b524e7333ad0b63663f73c213642))
9
+ * an extend keeps the exact picture unless the join would show ([bb48b52](https://github.com/tonygorb/Scenri/commit/bb48b52c77dcc1fc62f0ffc35cd26a81934f9423))
10
+ * composite both draws and keep the better join ([8bbada7](https://github.com/tonygorb/Scenri/commit/8bbada70cf84268c347166bdeb768767b99386df))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * codex reads at most five images, and the sixth was the shot itself ([91fe75e](https://github.com/tonygorb/Scenri/commit/91fe75e017c25313f64b79fb4d4b32cfc628838a))
16
+ * codex reads at most five images, and the sixth was the shot itself ([3e3c430](https://github.com/tonygorb/Scenri/commit/3e3c4307df355cf7091a905b5416f5eabbb88504))
17
+
3
18
  ## [0.5.1](https://github.com/tonygorb/Scenri/compare/v0.5.0...v0.5.1) (2026-08-26)
4
19
 
5
20
 
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,20 @@ function registerImageRoutes(app, deps) {
7674
7823
 
7675
7824
  // src/release/notes.data.ts
7676
7825
  var RELEASES = [
7826
+ {
7827
+ version: "0.6.0",
7828
+ date: "2026-08-26",
7829
+ sections: [
7830
+ {
7831
+ heading: "Shots",
7832
+ 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."
7833
+ },
7834
+ {
7835
+ heading: "Fixes",
7836
+ 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."
7837
+ }
7838
+ ]
7839
+ },
7677
7840
  {
7678
7841
  version: "0.5.1",
7679
7842
  date: "2026-08-26",
@@ -8876,6 +9039,7 @@ function buildServer(opts) {
8876
9039
  let editedFrom = null;
8877
9040
  let runEngine = engine;
8878
9041
  let expandMethod = null;
9042
+ let expandDecision = null;
8879
9043
  if (compiled2) {
8880
9044
  finalPrompt = compiled2.prompt;
8881
9045
  referenceImages = compiled2.referenceImages;
@@ -8932,10 +9096,11 @@ function buildServer(opts) {
8932
9096
  expandMethod = route.method;
8933
9097
  }
8934
9098
  const canOutpaint2 = expandMethod === "outpaint";
9099
+ let reframeSourceHash;
8935
9100
  if (expandPlan) {
8936
9101
  if (!canOutpaint2) {
8937
- const canvas = await expandCanvas(srcBuf, expandPlan);
8938
- expandSourceHash = core.images.save(canvas);
9102
+ expandSourceHash = core.images.save(await expandCanvas(srcBuf, expandPlan));
9103
+ reframeSourceHash = core.images.save(await conditioningCanvas(srcBuf, expandPlan, "edge"));
8939
9104
  }
8940
9105
  expectShape = { width: expandPlan.width, height: expandPlan.height };
8941
9106
  }
@@ -8969,25 +9134,48 @@ function buildServer(opts) {
8969
9134
  const plan2 = expandPlan;
8970
9135
  const srcSize = { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
8971
9136
  const original2 = srcBuf;
8972
- work = plan2 && !canOutpaint2 ? async (signal) => {
8973
- const draws = await Promise.all([
9137
+ const reframeReq = plan2 && !canOutpaint2 && reframeSourceHash ? {
9138
+ ...editReq,
9139
+ instruction: reframeInstruction(plan2, srcSize, finalPrompt),
9140
+ sourceImage: core.images.pathFor(reframeSourceHash)
9141
+ } : null;
9142
+ work = plan2 && !canOutpaint2 && reframeReq ? async (signal) => {
9143
+ const [bedDraw, paddedDraw] = await Promise.allSettled([
8974
9144
  runEngine.edit(editReq, signal),
8975
- runEngine.edit(editReq, signal).catch(() => null)
9145
+ runEngine.edit(reframeReq, signal)
8976
9146
  ]);
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];
9147
+ const bed = bedDraw.status === "fulfilled" ? bedDraw.value : null;
9148
+ const padded = paddedDraw.status === "fulfilled" ? paddedDraw.value : null;
9149
+ if (!bed && !padded) {
9150
+ throw bedDraw.status === "rejected" ? bedDraw.reason : paddedDraw.reason;
9151
+ }
9152
+ const preserved = [];
9153
+ for (const [from, draw2] of [
9154
+ ["bed", bed],
9155
+ ["padded", padded]
9156
+ ]) {
9157
+ const hash = draw2?.images[0];
9158
+ if (!hash) continue;
9159
+ const { image } = await compositeExpand(core.images.read(hash), original2, plan2);
9160
+ const [score, residual] = await Promise.all([
9161
+ seamScore(image, plan2, srcSize),
9162
+ seamResidual(image, plan2, srcSize)
9163
+ ]);
9164
+ preserved.push({ image, seam: seamPenalty(score, residual), from });
9165
+ }
9166
+ let reframed = null;
9167
+ const paddedImage = padded?.images[0];
9168
+ if (paddedImage) {
9169
+ const frame = await reframeExpand(core.images.read(paddedImage), plan2);
9170
+ if (frame) reframed = { image: frame };
9171
+ }
9172
+ const decision = chooseExpand({ preserved, reframed });
9173
+ if (!decision) return bed ?? padded;
9174
+ expandDecision = decision;
9175
+ return {
9176
+ images: [core.images.save(decision.image)],
9177
+ costUsd: (bed?.costUsd ?? 0) + (padded?.costUsd ?? 0)
9178
+ };
8991
9179
  } : (signal) => runEngine.edit(editReq, signal);
8992
9180
  }
8993
9181
  const billedId = runEngine.capabilities().id;
@@ -9025,7 +9213,7 @@ function buildServer(opts) {
9025
9213
  const plan = expandPlan;
9026
9214
  const original = editedFrom ? core.images.read(editedFrom) : null;
9027
9215
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
9028
- const post = plan ? async (images) => {
9216
+ const post = plan ? expandMethod === "outpaint" ? async (images) => {
9029
9217
  const out = [];
9030
9218
  for (const h of images) {
9031
9219
  const answer = core.images.read(h);
@@ -9040,6 +9228,19 @@ function buildServer(opts) {
9040
9228
  out.push(core.images.save(image));
9041
9229
  }
9042
9230
  return out;
9231
+ } : async (images) => {
9232
+ if (expandDecision)
9233
+ app.log.info(
9234
+ {
9235
+ nodeId: node.id,
9236
+ choice: expandDecision.choice,
9237
+ reason: expandDecision.reason,
9238
+ seam: expandDecision.seam,
9239
+ from: expandDecision.from
9240
+ },
9241
+ "expand: chose which frame to keep"
9242
+ );
9243
+ return images;
9043
9244
  } : localScope ? async (images) => {
9044
9245
  const out = [];
9045
9246
  for (const h of images) {
@@ -9310,8 +9511,8 @@ async function verify() {
9310
9511
  const db = new Database2(":memory:");
9311
9512
  db.pragma("user_version");
9312
9513
  db.close();
9313
- const { default: sharp18 } = await import('sharp');
9314
- await sharp18({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
9514
+ const { default: sharp19 } = await import('sharp');
9515
+ await sharp19({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
9315
9516
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
9316
9517
  } catch (err) {
9317
9518
  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.0",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",