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 +23 -0
- package/dist/serve.js +241 -30
- package/package.json +1 -1
- package/studio-dist/assets/{index-BCsZlYNL.js → index-CYdC4kte.js} +23 -23
- package/studio-dist/index.html +1 -1
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
|
-
|
|
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,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
|
-
|
|
8938
|
-
|
|
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
|
-
|
|
8973
|
-
|
|
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(
|
|
9155
|
+
runEngine.edit(reframeReq, signal)
|
|
8976
9156
|
]);
|
|
8977
|
-
const
|
|
8978
|
-
|
|
8979
|
-
|
|
8980
|
-
|
|
8981
|
-
|
|
8982
|
-
|
|
8983
|
-
|
|
8984
|
-
|
|
8985
|
-
|
|
8986
|
-
|
|
8987
|
-
|
|
8988
|
-
|
|
8989
|
-
|
|
8990
|
-
|
|
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:
|
|
9314
|
-
await
|
|
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) }));
|