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.
- package/CHANGELOG.md +15 -0
- package/dist/serve.js +231 -30
- 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
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
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
|
-
|
|
8938
|
-
|
|
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
|
-
|
|
8973
|
-
|
|
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(
|
|
9145
|
+
runEngine.edit(reframeReq, signal)
|
|
8976
9146
|
]);
|
|
8977
|
-
const
|
|
8978
|
-
|
|
8979
|
-
|
|
8980
|
-
|
|
8981
|
-
|
|
8982
|
-
|
|
8983
|
-
|
|
8984
|
-
|
|
8985
|
-
|
|
8986
|
-
|
|
8987
|
-
|
|
8988
|
-
|
|
8989
|
-
|
|
8990
|
-
|
|
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:
|
|
9314
|
-
await
|
|
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) }));
|