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.
- package/CHANGELOG.md +23 -0
- package/dist/serve.js +530 -45
- 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
|
|
1242
|
-
const shots =
|
|
1243
|
-
const hiddenShots =
|
|
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
|
-
|
|
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() {
|
|
@@ -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
|
|
6145
|
-
|
|
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
|
|
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] +
|
|
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
|
-
|
|
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
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
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
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
9030
|
-
await
|
|
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) }));
|