scenri 0.5.0 → 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 +23 -0
  2. package/dist/serve.js +530 -45
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
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
+
18
+ ## [0.5.1](https://github.com/tonygorb/Scenri/compare/v0.5.0...v0.5.1) (2026-08-26)
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * a grown frame keeps its subject's place and solves its own seam ([9d2108b](https://github.com/tonygorb/Scenri/commit/9d2108b2e5af29e1a6efdd531b82b647ec1c58ad))
24
+ * a grown frame keeps its subject's place and solves its own seam ([d45bf73](https://github.com/tonygorb/Scenri/commit/d45bf73d4760573af863b2f4533983382b16795c))
25
+
3
26
  ## [0.5.0](https://github.com/tonygorb/Scenri/compare/v0.4.7...v0.5.0) (2026-08-25)
4
27
 
5
28
 
package/dist/serve.js CHANGED
@@ -1238,9 +1238,9 @@ function libraryMethods(db) {
1238
1238
  local: String(i.sourceUrl).startsWith("local:")
1239
1239
  });
1240
1240
  const seen = /* @__PURE__ */ new Set();
1241
- const usable = images.filter((i) => i.assetRef && !seen.has(i.assetRef) && seen.add(i.assetRef));
1242
- const shots = usable.filter((i) => !i.excluded).map(shot);
1243
- const hiddenShots = usable.filter((i) => i.excluded).map(shot);
1241
+ const usable2 = images.filter((i) => i.assetRef && !seen.has(i.assetRef) && seen.add(i.assetRef));
1242
+ const shots = usable2.filter((i) => !i.excluded).map(shot);
1243
+ const hiddenShots = usable2.filter((i) => i.excluded).map(shot);
1244
1244
  return {
1245
1245
  id: `cat-${p.id}`,
1246
1246
  name: p.title,
@@ -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() {
@@ -6141,13 +6154,52 @@ function planExpand(source, targetRatio) {
6141
6154
  function expandInstruction(plan, direction) {
6142
6155
  const where = plan.axis === "width" ? "left and right" : "top and bottom";
6143
6156
  const nearEdge = plan.axis === "height" ? "\nDepth: the bottom edge of the frame is the part of the surface nearest the camera; the top edge is the furthest away." : "";
6144
- const own = direction.trim() ? `
6145
- Also: ${direction.trim()}` : "";
6157
+ const extra = direction.trim().replace(/^[.\s]+/, "");
6158
+ const own = extra ? `
6159
+ Also: ${extra}` : "";
6146
6160
  return `Fill only the soft blurred margin at the ${where} of this frame so the photograph continues into it.
6147
6161
  Continue: the same surface, the same light direction, the same colour temperature and the same depth of field that are already in the picture.` + nearEdge + `
6148
6162
  Constraints: change only the blurred margin; keep the sharp photograph unchanged in position, scale and content.
6149
6163
  Avoid: new objects, products, people, text or watermarks.` + own;
6150
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
+ }
6151
6203
 
6152
6204
  // src/cropRules.ts
6153
6205
  function planCrop(source, targetRatio) {
@@ -6173,6 +6225,148 @@ async function attentionCropOrigin(srcBuf, source, plan) {
6173
6225
  return { left: plan.left, top: plan.top };
6174
6226
  }
6175
6227
  }
6228
+
6229
+ // src/outpaint/membrane.ts
6230
+ var SOLVE_MAX_EDGE = 192;
6231
+ var SOLVE_MIN_EDGE = 8;
6232
+ var SWEEPS_PER_EDGE = 2;
6233
+ var MIN_SWEEPS = 40;
6234
+ var POLISH_SWEEPS = 32;
6235
+ var POLISH_MAX_CELLS = 4e6;
6236
+ function solveMembrane(req) {
6237
+ const { width, height, axis, seamAt } = req;
6238
+ const along = axis === "width" ? height : width;
6239
+ const depth = axis === "width" ? width : height;
6240
+ const out = new Float32Array(width * height * 3);
6241
+ if (width < 1 || height < 1 || along < 1 || depth < 1) return out;
6242
+ if (depth === 1) {
6243
+ for (let a = 0; a < along; a++) {
6244
+ const off = marginOffset(0, a, width, axis, seamAt, depth) * 3;
6245
+ for (let c = 0; c < 3; c++) out[off + c] = req.seam[a * 3 + c];
6246
+ }
6247
+ return out;
6248
+ }
6249
+ const coarse = cascade(req.seam, along, depth);
6250
+ if (depth * along <= POLISH_MAX_CELLS) {
6251
+ const full = { along, depth, data: new Float32Array(along * depth * 3) };
6252
+ prolong(coarse, full);
6253
+ relax(full, req.seam, POLISH_SWEEPS);
6254
+ for (let d = 0; d < depth; d++) {
6255
+ for (let a = 0; a < along; a++) {
6256
+ const off = marginOffset(d, a, width, axis, seamAt, depth) * 3;
6257
+ const src = (d * along + a) * 3;
6258
+ out[off] = full.data[src];
6259
+ out[off + 1] = full.data[src + 1];
6260
+ out[off + 2] = full.data[src + 2];
6261
+ }
6262
+ }
6263
+ return out;
6264
+ }
6265
+ for (let d = 0; d < depth; d++) {
6266
+ for (let a = 0; a < along; a++) {
6267
+ const off = marginOffset(d, a, width, axis, seamAt, depth) * 3;
6268
+ sampleGrid(coarse, d, a, depth, along, out, off);
6269
+ }
6270
+ }
6271
+ return out;
6272
+ }
6273
+ function marginOffset(d, a, width, axis, seamAt, depth) {
6274
+ if (axis === "width") {
6275
+ const x = seamAt === "far" ? depth - 1 - d : d;
6276
+ return a * width + x;
6277
+ }
6278
+ const y = seamAt === "far" ? depth - 1 - d : d;
6279
+ return y * width + a;
6280
+ }
6281
+ function cascade(seam, along, depth) {
6282
+ const scale = Math.max(1, Math.ceil(Math.max(along, depth) / SOLVE_MAX_EDGE));
6283
+ const targetAlong = Math.max(2, Math.round(along / scale));
6284
+ const targetDepth = Math.max(2, Math.round(depth / scale));
6285
+ const edges = [];
6286
+ for (let e = SOLVE_MIN_EDGE; e < Math.max(targetAlong, targetDepth); e *= 2) edges.push(e);
6287
+ edges.push(Math.max(targetAlong, targetDepth));
6288
+ let grid = null;
6289
+ for (const edge of edges) {
6290
+ const ratio = edge / Math.max(targetAlong, targetDepth);
6291
+ const la = Math.max(2, Math.round(targetAlong * ratio));
6292
+ const ld = Math.max(2, Math.round(targetDepth * ratio));
6293
+ const next = { along: la, depth: ld, data: new Float32Array(la * ld * 3) };
6294
+ if (grid) prolong(grid, next);
6295
+ relax(next, resampleSeam(seam, along, la));
6296
+ grid = next;
6297
+ }
6298
+ return grid;
6299
+ }
6300
+ function resampleSeam(seam, along, to) {
6301
+ const out = new Float32Array(to * 3);
6302
+ for (let i = 0; i < to; i++) {
6303
+ const src = to === 1 ? 0 : i * (along - 1) / (to - 1);
6304
+ const lo = Math.floor(src);
6305
+ const hi = Math.min(along - 1, lo + 1);
6306
+ const t = src - lo;
6307
+ for (let c = 0; c < 3; c++) out[i * 3 + c] = seam[lo * 3 + c] * (1 - t) + seam[hi * 3 + c] * t;
6308
+ }
6309
+ return out;
6310
+ }
6311
+ function prolong(from, to) {
6312
+ for (let d = 0; d < to.depth; d++) {
6313
+ const sd = to.depth === 1 ? 0 : d * (from.depth - 1) / (to.depth - 1);
6314
+ for (let a = 0; a < to.along; a++) {
6315
+ const sa = to.along === 1 ? 0 : a * (from.along - 1) / (to.along - 1);
6316
+ sampleGrid(from, sd, sa, from.depth, from.along, to.data, (d * to.along + a) * 3);
6317
+ }
6318
+ }
6319
+ }
6320
+ function sampleGrid(grid, d, a, fromDepth, fromAlong, into, off) {
6321
+ const sd = fromDepth === grid.depth ? d : grid.depth === 1 ? 0 : d * (grid.depth - 1) / (fromDepth - 1);
6322
+ const sa = fromAlong === grid.along ? a : grid.along === 1 ? 0 : a * (grid.along - 1) / (fromAlong - 1);
6323
+ const d0 = Math.floor(sd);
6324
+ const a0 = Math.floor(sa);
6325
+ const d1 = Math.min(grid.depth - 1, d0 + 1);
6326
+ const a1 = Math.min(grid.along - 1, a0 + 1);
6327
+ const td = sd - d0;
6328
+ const ta = sa - a0;
6329
+ const i00 = (d0 * grid.along + a0) * 3;
6330
+ const i01 = (d0 * grid.along + a1) * 3;
6331
+ const i10 = (d1 * grid.along + a0) * 3;
6332
+ const i11 = (d1 * grid.along + a1) * 3;
6333
+ for (let c = 0; c < 3; c++) {
6334
+ const top = grid.data[i00 + c] * (1 - ta) + grid.data[i01 + c] * ta;
6335
+ const bot = grid.data[i10 + c] * (1 - ta) + grid.data[i11 + c] * ta;
6336
+ into[off + c] = top * (1 - td) + bot * td;
6337
+ }
6338
+ }
6339
+ function relax(grid, seam, fixedSweeps) {
6340
+ const { depth, along, data } = grid;
6341
+ for (let a = 0; a < along; a++) {
6342
+ const off = a * 3;
6343
+ for (let c = 0; c < 3; c++) data[off + c] = seam[off + c];
6344
+ }
6345
+ if (depth < 2) return;
6346
+ const n = Math.max(depth, along);
6347
+ const omega = Math.min(1.99, 2 / (1 + Math.sin(Math.PI / Math.max(2, n))));
6348
+ const sweeps = fixedSweeps ?? Math.max(MIN_SWEEPS, SWEEPS_PER_EDGE * n);
6349
+ for (let sweep = 0; sweep < sweeps; sweep++) {
6350
+ for (let parity = 0; parity < 2; parity++) {
6351
+ for (let d = 1; d < depth; d++) {
6352
+ const row = d * along;
6353
+ const rowUp = (d - 1) * along;
6354
+ const rowDn = (d < depth - 1 ? d + 1 : d - 1) * along;
6355
+ for (let a = (d + parity) % 2; a < along; a += 2) {
6356
+ const iL = row + (a > 0 ? a - 1 : Math.min(1, along - 1));
6357
+ const iR = row + (a < along - 1 ? a + 1 : Math.max(0, along - 2));
6358
+ const i = row + a;
6359
+ for (let c = 0; c < 3; c++) {
6360
+ const avg = (data[(rowUp + a) * 3 + c] + data[(rowDn + a) * 3 + c] + data[iL * 3 + c] + data[iR * 3 + c]) * 0.25;
6361
+ data[i * 3 + c] += omega * (avg - data[i * 3 + c]);
6362
+ }
6363
+ }
6364
+ }
6365
+ }
6366
+ }
6367
+ }
6368
+
6369
+ // src/expand.ts
6176
6370
  async function expandCanvas(source, plan) {
6177
6371
  const bed = await sharp7(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6178
6372
  return sharp7(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
@@ -6267,17 +6461,27 @@ async function reconcile(surround, source, side, axis) {
6267
6461
  smooth[i * 3 + c] = sum / n;
6268
6462
  }
6269
6463
  }
6270
- const depth = axis === "width" ? W : H;
6464
+ const half = Math.max(1, Math.floor(along / 2));
6465
+ const centreLo = (half - 1) / 2;
6466
+ const centreHi = half + (along - half - 1) / 2;
6467
+ const span = Math.max(1, centreHi - centreLo);
6468
+ const base = [0, 0, 0];
6469
+ const slope = [0, 0, 0];
6470
+ for (let c = 0; c < 3; c++) {
6471
+ const lo = medianOf(smooth, c, 0, half);
6472
+ const hi = medianOf(smooth, c, half, along);
6473
+ slope[c] = (hi - lo) / span;
6474
+ base[c] = lo - slope[c] * centreLo;
6475
+ for (let i = 0; i < along; i++) smooth[i * 3 + c] -= base[c] + slope[c] * i;
6476
+ }
6477
+ const field = solveMembrane({ width: W, height: H, axis, seamAt: side.seamAt, seam: smooth });
6271
6478
  const corrected = Buffer.from(marginRaw);
6272
6479
  for (let y = 0; y < H; y++) {
6273
6480
  for (let x = 0; x < W; x++) {
6274
- const d = axis === "width" ? side.seamAt === "far" ? W - 1 - x : x : side.seamAt === "far" ? H - 1 - y : y;
6275
- const fall = 1 - d / Math.max(1, depth - 1);
6276
- if (fall <= 0) continue;
6277
6481
  const i = axis === "width" ? y : x;
6278
6482
  const off = (y * W + x) * 3;
6279
6483
  for (let c = 0; c < 3; c++) {
6280
- const v = corrected[off + c] + smooth[i * 3 + c] * fall;
6484
+ const v = corrected[off + c] + base[c] + slope[c] * i + field[off + c];
6281
6485
  corrected[off + c] = v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
6282
6486
  }
6283
6487
  }
@@ -6288,6 +6492,24 @@ async function reconcile(surround, source, side, axis) {
6288
6492
  async function expandCanvasBedOnly(source, plan) {
6289
6493
  return sharp7(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
6290
6494
  }
6495
+ function medianOf(rgb, channel, from, to) {
6496
+ const n = to - from;
6497
+ if (n < 1) return 0;
6498
+ const values = new Float64Array(n);
6499
+ for (let i = 0; i < n; i++) values[i] = rgb[(from + i) * 3 + channel];
6500
+ values.sort();
6501
+ return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
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
+ }
6291
6513
  async function seamScore(image, plan, source) {
6292
6514
  const { data, info } = await sharp7(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
6293
6515
  const W = info.width;
@@ -6318,6 +6540,177 @@ async function seamScore(image, plan, source) {
6318
6540
  const second = first + (horizontal ? source.width : source.height);
6319
6541
  return Math.max(at(first), at(second));
6320
6542
  }
6543
+ var SEAM_VISIBLE = 2.2;
6544
+ var OFFSET = 4;
6545
+ var RESIDUAL_VISIBLE = 15;
6546
+ async function seamResidual(image, plan, source) {
6547
+ const { data, info } = await sharp7(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
6548
+ const W = info.width;
6549
+ const H = info.height;
6550
+ const ch = info.channels;
6551
+ const horizontal = plan.axis === "width";
6552
+ const between = (a, b) => {
6553
+ const limit = horizontal ? W : H;
6554
+ if (a < 0 || b < 0 || a >= limit || b >= limit) return null;
6555
+ const run2 = horizontal ? H : W;
6556
+ let sum = 0;
6557
+ for (let i = 0; i < run2; i++) {
6558
+ const ia = (horizontal ? i * W + a : a * W + i) * ch;
6559
+ const ib = (horizontal ? i * W + b : b * W + i) * ch;
6560
+ sum += Math.abs(data[ia] - data[ib]) + Math.abs(data[ia + 1] - data[ib + 1]) + Math.abs(data[ia + 2] - data[ib + 2]);
6561
+ }
6562
+ return sum / (run2 * 3);
6563
+ };
6564
+ const near = horizontal ? plan.left : plan.top;
6565
+ const far = near + (horizontal ? source.width : source.height);
6566
+ const both = [
6567
+ // The margin before the picture starts, against the picture just inside it.
6568
+ between(near - OFFSET, near + OFFSET - 1),
6569
+ // The picture just before it ends, against the margin just after.
6570
+ between(far - OFFSET, far + OFFSET - 1)
6571
+ ].filter((v) => v !== null);
6572
+ return both.length ? Math.max(...both) : 0;
6573
+ }
6574
+ function seamPenalty(score, residual) {
6575
+ return Math.max(score / SEAM_VISIBLE, residual / RESIDUAL_VISIBLE);
6576
+ }
6577
+ var MIN_SHARE = 0.2;
6578
+ var MAX_SHARE = 0.8;
6579
+ async function subjectFraction(src, source, axis) {
6580
+ try {
6581
+ const window = axis === "width" ? { width: Math.max(8, Math.round(source.width * 0.5)), height: source.height } : { width: source.width, height: Math.max(8, Math.round(source.height * 0.5)) };
6582
+ const { info } = await sharp7(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
6583
+ const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
6584
+ const span = axis === "width" ? source.width : source.height;
6585
+ const extent = axis === "width" ? window.width : window.height;
6586
+ const centre = (offset + extent / 2) / span;
6587
+ return Math.min(1, Math.max(0, (centre + 0.5) / 2));
6588
+ } catch {
6589
+ return 0.5;
6590
+ }
6591
+ }
6592
+ function placeExpand(plan, source, fraction) {
6593
+ const share = Math.min(MAX_SHARE, Math.max(MIN_SHARE, fraction));
6594
+ if (plan.axis === "width") {
6595
+ const room2 = plan.width - source.width;
6596
+ if (room2 <= 0) return plan;
6597
+ return { ...plan, left: Math.min(room2, Math.max(0, Math.round(room2 * share))) };
6598
+ }
6599
+ const room = plan.height - source.height;
6600
+ if (room <= 0) return plan;
6601
+ return { ...plan, top: Math.min(room, Math.max(0, Math.round(room * share))) };
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
+ }
6691
+
6692
+ // src/outpaint/route.ts
6693
+ var canOutpaint = (e) => {
6694
+ const caps = e.capabilities();
6695
+ return caps.supportsOutpaint === true;
6696
+ };
6697
+ var usable = async (e) => {
6698
+ try {
6699
+ return (await e.isAvailable()).ok;
6700
+ } catch {
6701
+ return false;
6702
+ }
6703
+ };
6704
+ async function resolveOutpaintRoute(all, shot) {
6705
+ if (canOutpaint(shot)) return { engine: shot, method: "outpaint", crossed: false };
6706
+ const shotId = shot.capabilities().id;
6707
+ for (const candidate of all) {
6708
+ const caps = candidate.capabilities();
6709
+ if (caps.id === shotId || caps.placeholder || !canOutpaint(candidate)) continue;
6710
+ if (await usable(candidate)) return { engine: candidate, method: "outpaint", crossed: true };
6711
+ }
6712
+ return { engine: shot, method: "reframe", crossed: false };
6713
+ }
6321
6714
  async function driftDiff(a, b) {
6322
6715
  const metaA = await sharp7(a).metadata();
6323
6716
  const metaB = await sharp7(b).metadata();
@@ -7430,6 +7823,30 @@ function registerImageRoutes(app, deps) {
7430
7823
 
7431
7824
  // src/release/notes.data.ts
7432
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
+ },
7840
+ {
7841
+ version: "0.5.1",
7842
+ date: "2026-08-26",
7843
+ sections: [
7844
+ {
7845
+ heading: "Shots",
7846
+ body: "An extended shot keeps its subject where it was composed, so a product standing near one edge stays near that edge in the wider frame rather than drifting to the middle. The new margin now meets the picture across the whole join instead of only at the seam, so a difference in tone or light no longer survives out at the frame edge. Where a connected engine can paint a margin directly, an extension is handed to it."
7847
+ }
7848
+ ]
7849
+ },
7433
7850
  {
7434
7851
  version: "0.5.0",
7435
7852
  date: "2026-08-26",
@@ -8620,6 +9037,9 @@ function buildServer(opts) {
8620
9037
  let work;
8621
9038
  let expectShape;
8622
9039
  let editedFrom = null;
9040
+ let runEngine = engine;
9041
+ let expandMethod = null;
9042
+ let expandDecision = null;
8623
9043
  if (compiled2) {
8624
9044
  finalPrompt = compiled2.prompt;
8625
9045
  referenceImages = compiled2.referenceImages;
@@ -8666,11 +9086,21 @@ function buildServer(opts) {
8666
9086
  }
8667
9087
  if (reshape === "extend" && !expandPlan)
8668
9088
  return reply.status(400).send({ error: "the picture is already this shape" });
8669
- const canOutpaint = expandPlan ? engine.capabilities().supportsOutpaint === true : false;
9089
+ if (expandPlan && srcMeta.width && srcMeta.height) {
9090
+ const size = { width: srcMeta.width, height: srcMeta.height };
9091
+ expandPlan = placeExpand(expandPlan, size, await subjectFraction(srcBuf, size, expandPlan.axis));
9092
+ }
8670
9093
  if (expandPlan) {
8671
- if (!canOutpaint) {
8672
- const canvas = await expandCanvas(srcBuf, expandPlan);
8673
- expandSourceHash = core.images.save(canvas);
9094
+ const route = await resolveOutpaintRoute(engines.all(), engine);
9095
+ runEngine = route.engine;
9096
+ expandMethod = route.method;
9097
+ }
9098
+ const canOutpaint2 = expandMethod === "outpaint";
9099
+ let reframeSourceHash;
9100
+ if (expandPlan) {
9101
+ if (!canOutpaint2) {
9102
+ expandSourceHash = core.images.save(await expandCanvas(srcBuf, expandPlan));
9103
+ reframeSourceHash = core.images.save(await conditioningCanvas(srcBuf, expandPlan, "edge"));
8674
9104
  }
8675
9105
  expectShape = { width: expandPlan.width, height: expandPlan.height };
8676
9106
  }
@@ -8685,7 +9115,7 @@ function buildServer(opts) {
8685
9115
  ...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : {},
8686
9116
  // Only an engine that can genuinely paint a margin is told where the
8687
9117
  // picture sits; the rest would ignore it anyway.
8688
- ...expandPlan && canOutpaint ? {
9118
+ ...expandPlan && canOutpaint2 ? {
8689
9119
  expand: {
8690
9120
  left: expandPlan.left,
8691
9121
  top: expandPlan.top,
@@ -8700,40 +9130,82 @@ function buildServer(opts) {
8700
9130
  seed: seedFor(String(editedFrom ?? srcHash), expandPlan.width, expandPlan.height)
8701
9131
  } : {}
8702
9132
  };
8703
- estimate = await engine.costEstimate(editReq);
9133
+ estimate = await runEngine.costEstimate(editReq);
8704
9134
  const plan2 = expandPlan;
8705
9135
  const srcSize = { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
8706
9136
  const original2 = srcBuf;
8707
- work = plan2 && !canOutpaint ? async (signal) => {
8708
- const draws = await Promise.all([
8709
- engine.edit(editReq, signal),
8710
- engine.edit(editReq, signal).catch(() => null)
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([
9144
+ runEngine.edit(editReq, signal),
9145
+ runEngine.edit(reframeReq, signal)
8711
9146
  ]);
8712
- const scored = await Promise.all(
8713
- draws.map(async (got) => {
8714
- const first = got?.images[0];
8715
- if (!got || !first) return null;
8716
- const { image } = await compositeExpand(core.images.read(first), original2, plan2);
8717
- return { got, score: await seamScore(image, plan2, srcSize) };
8718
- })
8719
- );
8720
- const best = scored.filter((x) => x !== null).sort((a, b) => a.score - b.score)[0];
8721
- return best?.got ?? draws[0];
8722
- } : (signal) => engine.edit(editReq, signal);
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
+ };
9179
+ } : (signal) => runEngine.edit(editReq, signal);
8723
9180
  }
8724
- core.ledger.assertUnderCap(engine.capabilities().id, estimate + (reserved.get(engine.capabilities().id) ?? 0));
9181
+ const billedId = runEngine.capabilities().id;
9182
+ core.ledger.assertUnderCap(billedId, estimate + (reserved.get(billedId) ?? 0));
8725
9183
  const node = core.store.addNode({
8726
9184
  projectId: project.id,
8727
9185
  parentId: resolvedParentId,
8728
9186
  kind,
8729
9187
  prompt: finalPrompt,
8730
- engineId: String(engineId)
9188
+ engineId: billedId
8731
9189
  });
8732
9190
  if (brief)
8733
9191
  core.store.setBrief(node.id, {
8734
9192
  ...brief,
8735
9193
  ...editedFrom ? { sourceImage: editedFrom } : {},
8736
9194
  ...kind === "edit" && reshape ? { reshape } : {},
9195
+ // How the margin was actually made, and by whom. An extend may be
9196
+ // handed to a different engine than the shot used, and a record that
9197
+ // does not say so cannot be read back later.
9198
+ ...expandMethod && expandPlan ? {
9199
+ expand: {
9200
+ method: expandMethod,
9201
+ engineId: billedId,
9202
+ // Where the protected picture sits in the frame it grew into.
9203
+ // Placement is no longer always centred, so a reader that
9204
+ // assumes it is would be looking in the wrong place.
9205
+ left: expandPlan.left,
9206
+ top: expandPlan.top
9207
+ }
9208
+ } : {},
8737
9209
  // What the refinement carried, recorded apart from what it asked for:
8738
9210
  // the detail view shows both, and remix reads tokens alone.
8739
9211
  ...kind === "edit" && inheritedTokens.length ? { inherited: inheritedTokens } : {}
@@ -8741,7 +9213,7 @@ function buildServer(opts) {
8741
9213
  const plan = expandPlan;
8742
9214
  const original = editedFrom ? core.images.read(editedFrom) : null;
8743
9215
  const localScope = kind === "edit" && !plan && editScope === "local" && original;
8744
- const post = plan ? async (images) => {
9216
+ const post = plan ? expandMethod === "outpaint" ? async (images) => {
8745
9217
  const out = [];
8746
9218
  for (const h of images) {
8747
9219
  const answer = core.images.read(h);
@@ -8756,6 +9228,19 @@ function buildServer(opts) {
8756
9228
  out.push(core.images.save(image));
8757
9229
  }
8758
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;
8759
9244
  } : localScope ? async (images) => {
8760
9245
  const out = [];
8761
9246
  for (const h of images) {
@@ -8765,7 +9250,7 @@ function buildServer(opts) {
8765
9250
  }
8766
9251
  return out;
8767
9252
  } : void 0;
8768
- void runNode(node.id, engine, estimate, work, expectShape, post).catch(
9253
+ void runNode(node.id, runEngine, estimate, work, expectShape, post).catch(
8769
9254
  (err) => app.log.error({ err }, "node run failed")
8770
9255
  );
8771
9256
  const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
@@ -9026,8 +9511,8 @@ async function verify() {
9026
9511
  const db = new Database2(":memory:");
9027
9512
  db.pragma("user_version");
9028
9513
  db.close();
9029
- const { default: sharp16 } = await import('sharp');
9030
- await sharp16({ 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();
9031
9516
  console.log(JSON.stringify({ ok: true, version: readMeta().version }));
9032
9517
  } catch (err) {
9033
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.0",
3
+ "version": "0.6.0",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",