scenri 0.4.4 → 0.4.6
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 +20 -0
- package/README.md +1 -1
- package/dist/{chunk-WIIQZN3K.js → chunk-FYQ5BAFA.js} +23 -6
- package/dist/{cli-PBDPZQ4G.js → cli-KSKENGIR.js} +3 -3
- package/dist/index.js +1 -1
- package/dist/serve.js +118 -49
- package/package.json +1 -1
- package/studio-dist/assets/{index-CEDNCtju.css → index-DbI3NHBi.css} +1 -1
- package/studio-dist/assets/{index-5fvERzuB.js → index-Dz7u070F.js} +21 -21
- package/studio-dist/index.html +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.6](https://github.com/tonygorb/Scenri/compare/v0.4.5...v0.4.6) (2026-08-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* a removal leaves nothing behind, and a garment needs no invented wearer ([219c1c2](https://github.com/tonygorb/Scenri/commit/219c1c2e64636a6a836cf99ab23a75b3ad7e8a1d))
|
|
9
|
+
* evidence round two, and the timing the app never records ([b1a0cd1](https://github.com/tonygorb/Scenri/commit/b1a0cd1b88dfe1937ef712380ad6ae99f7401d48))
|
|
10
|
+
* record how long a shot took and what pixels it really delivered ([c9258e5](https://github.com/tonygorb/Scenri/commit/c9258e5281877beb7078ea5d87a48fa82efb9f04))
|
|
11
|
+
|
|
12
|
+
## [0.4.5](https://github.com/tonygorb/Scenri/compare/v0.4.4...v0.4.5) (2026-08-23)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* check npm every six hours for real, and catch up after sleep ([9efb633](https://github.com/tonygorb/Scenri/commit/9efb6338b8a3ffbcb0c0a0f167492510f33d8bca))
|
|
18
|
+
* keep the update float off a source checkout ([35d5269](https://github.com/tonygorb/Scenri/commit/35d5269f46d20044921878c2068fdc7495e6c27d))
|
|
19
|
+
* keep the update float off a source checkout ([d2c129e](https://github.com/tonygorb/Scenri/commit/d2c129e4a567ea4220f503118872087f9e2516cc))
|
|
20
|
+
* let an idle tab hear about a ready update within the half hour ([ea0088d](https://github.com/tonygorb/Scenri/commit/ea0088d363d8d709ddb01549717c4fed1aa06aab))
|
|
21
|
+
* say every six hours everywhere the update check is disclosed ([78204c8](https://github.com/tonygorb/Scenri/commit/78204c8cfc26c3a9d856d399808f25396fe6084c))
|
|
22
|
+
|
|
3
23
|
## [0.4.4](https://github.com/tonygorb/Scenri/compare/v0.4.3...v0.4.4) (2026-08-23)
|
|
4
24
|
|
|
5
25
|
|
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ pnpm dev # starts the server on 127.0.0.1:4747
|
|
|
95
95
|
- **Iteration is the product.** A version tree, not a prompt box. Branch, compare, keep the winners.
|
|
96
96
|
- **Your brands are files, not hostages.** `.brand` is an open, documented format under a permissive license. Email one to a client. Any tool can adopt it.
|
|
97
97
|
- **Your AI, your cost.** Bring your own Codex CLI session or an API key. Experiments cost raw API price, or nothing at all on a local session. No credits that burn on a miss.
|
|
98
|
-
- **Local first, and it means it.** No account, no telemetry, no upload. The server binds to your machine only. Scenri makes exactly two requests on its own behalf: a
|
|
98
|
+
- **Local first, and it means it.** No account, no telemetry, no upload. The server binds to your machine only. Scenri makes exactly two requests on its own behalf: a version-number check against npm every six hours so updates can announce themselves (and, when one is found, the download of that release from npm, staged locally until you choose to restart), and a one-time download of the library imagery archive, cached locally forever after. Nothing about you or your work is ever sent, and both turn off: in Settings or `SCENRI_NO_UPDATE_CHECK=1` for the first, `SCENRI_NO_CONTENT_FETCH=1` for the second ([how updates work](https://github.com/tonygorb/scenri/blob/main/docs/updates.md)).
|
|
99
99
|
|
|
100
100
|
## Engines
|
|
101
101
|
|
|
@@ -4,9 +4,13 @@ import { existsSync, rmSync, mkdirSync, readFileSync, renameSync } from 'fs';
|
|
|
4
4
|
import { join, dirname } from 'path';
|
|
5
5
|
|
|
6
6
|
// src/update/check.ts
|
|
7
|
-
var
|
|
7
|
+
var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
8
|
+
var TICK_MS = 15 * 60 * 1e3;
|
|
9
|
+
var JITTER_RATIO = 0.2;
|
|
8
10
|
var FORCE_COOLDOWN_MS = 60 * 1e3;
|
|
9
11
|
var TIMEOUT_MS = 5e3;
|
|
12
|
+
var MIN_INTERVAL_MS = 60 * 60 * 1e3;
|
|
13
|
+
var MIN_TICK_MS = 60 * 1e3;
|
|
10
14
|
function resolveRegistry(env = process.env, override) {
|
|
11
15
|
return (override ?? env.SCENRI_REGISTRY ?? "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
12
16
|
}
|
|
@@ -32,6 +36,7 @@ async function fetchDistTagLatest(name, opts = {}) {
|
|
|
32
36
|
return { latest: null, error: String(err?.message ?? err) };
|
|
33
37
|
}
|
|
34
38
|
}
|
|
39
|
+
var isReleaseTriplet = (v) => /^\d+\.\d+\.\d+$/.test(v);
|
|
35
40
|
function triplet(v) {
|
|
36
41
|
const m = v.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
37
42
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
@@ -50,7 +55,18 @@ function createUpdateChecker(deps) {
|
|
|
50
55
|
const env = deps.env ?? process.env;
|
|
51
56
|
const now = deps.now ?? Date.now;
|
|
52
57
|
const log = deps.log ?? console.log;
|
|
58
|
+
const random = deps.random ?? Math.random;
|
|
53
59
|
const registry = resolveRegistry(env, deps.registry);
|
|
60
|
+
const onNpm = registry.startsWith("https://registry.npmjs.org");
|
|
61
|
+
const envMs = (key) => {
|
|
62
|
+
const n = Number(env[key]);
|
|
63
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
|
64
|
+
};
|
|
65
|
+
const rawInterval = envMs("SCENRI_UPDATE_INTERVAL_MS") ?? CHECK_INTERVAL_MS;
|
|
66
|
+
const intervalMs = onNpm ? Math.max(rawInterval, MIN_INTERVAL_MS) : rawInterval;
|
|
67
|
+
const rawTick = envMs("SCENRI_UPDATE_TICK_MS") ?? TICK_MS;
|
|
68
|
+
const tickMs = onNpm ? Math.max(rawTick, MIN_TICK_MS) : rawTick;
|
|
69
|
+
let jitterMs = random() * JITTER_RATIO * intervalMs;
|
|
54
70
|
const cached = () => {
|
|
55
71
|
const at = deps.store.getSetting("update.checkedAt");
|
|
56
72
|
return {
|
|
@@ -72,6 +88,7 @@ function createUpdateChecker(deps) {
|
|
|
72
88
|
if (res.error) return { ...cached(), error: res.error };
|
|
73
89
|
deps.store.setSetting("update.latest", res.latest ?? "");
|
|
74
90
|
deps.store.setSetting("update.checkedAt", String(now()));
|
|
91
|
+
jitterMs = random() * JITTER_RATIO * intervalMs;
|
|
75
92
|
const result = cached();
|
|
76
93
|
onResultFn?.(result);
|
|
77
94
|
return result;
|
|
@@ -80,7 +97,7 @@ function createUpdateChecker(deps) {
|
|
|
80
97
|
if (!enabled() && !force) return cached();
|
|
81
98
|
if (inflight) return inflight;
|
|
82
99
|
const prior = cached();
|
|
83
|
-
const fresh = prior.checkedAt !== null && now() - prior.checkedAt <
|
|
100
|
+
const fresh = prior.checkedAt !== null && now() - prior.checkedAt < intervalMs + jitterMs;
|
|
84
101
|
if (force) {
|
|
85
102
|
if (now() - lastForceAt < FORCE_COOLDOWN_MS) return prior;
|
|
86
103
|
lastForceAt = now();
|
|
@@ -94,7 +111,7 @@ function createUpdateChecker(deps) {
|
|
|
94
111
|
}
|
|
95
112
|
function schedule() {
|
|
96
113
|
setTimeout(() => void check(), 1e4).unref();
|
|
97
|
-
setInterval(() => void check(),
|
|
114
|
+
setInterval(() => void check(), tickMs).unref();
|
|
98
115
|
}
|
|
99
116
|
return {
|
|
100
117
|
enabled,
|
|
@@ -229,6 +246,6 @@ async function stageVersion(deps) {
|
|
|
229
246
|
return { ok: true, version, entry: entryOf(deps.home, deps.pkg, version) };
|
|
230
247
|
}
|
|
231
248
|
|
|
232
|
-
export { classify, createUpdateChecker, fetchDistTagLatest, findNpm, stageVersion };
|
|
233
|
-
//# sourceMappingURL=chunk-
|
|
234
|
-
//# sourceMappingURL=chunk-
|
|
249
|
+
export { classify, createUpdateChecker, fetchDistTagLatest, findNpm, isReleaseTriplet, stageVersion };
|
|
250
|
+
//# sourceMappingURL=chunk-FYQ5BAFA.js.map
|
|
251
|
+
//# sourceMappingURL=chunk-FYQ5BAFA.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { stageVersion, fetchDistTagLatest, findNpm } from './chunk-
|
|
1
|
+
import { stageVersion, fetchDistTagLatest, findNpm } from './chunk-FYQ5BAFA.js';
|
|
2
2
|
import { readMeta } from './chunk-Y3ZPBPLP.js';
|
|
3
3
|
import { defaultHome, newestStaged, compareSemver } from './chunk-4MAFHYAD.js';
|
|
4
4
|
|
|
@@ -63,5 +63,5 @@ async function runUpdateCommand(opts) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export { runUpdateCommand };
|
|
66
|
-
//# sourceMappingURL=cli-
|
|
67
|
-
//# sourceMappingURL=cli-
|
|
66
|
+
//# sourceMappingURL=cli-KSKENGIR.js.map
|
|
67
|
+
//# sourceMappingURL=cli-KSKENGIR.js.map
|
package/dist/index.js
CHANGED
|
@@ -70,7 +70,7 @@ try {
|
|
|
70
70
|
await (await import('./serve.js')).serve();
|
|
71
71
|
break;
|
|
72
72
|
case "update": {
|
|
73
|
-
const { runUpdateCommand } = await import('./cli-
|
|
73
|
+
const { runUpdateCommand } = await import('./cli-KSKENGIR.js');
|
|
74
74
|
process.exit(await runUpdateCommand(command));
|
|
75
75
|
break;
|
|
76
76
|
}
|
package/dist/serve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { portBusyLines } from './chunk-WGIZJXNE.js';
|
|
2
|
-
import { createUpdateChecker, classify, findNpm, stageVersion } from './chunk-
|
|
2
|
+
import { createUpdateChecker, classify, isReleaseTriplet, findNpm, stageVersion } from './chunk-FYQ5BAFA.js';
|
|
3
3
|
import { readMeta, repoSlug } from './chunk-Y3ZPBPLP.js';
|
|
4
4
|
import { compareSemver, newestStaged } from './chunk-4MAFHYAD.js';
|
|
5
5
|
import { networkInterfaces, homedir, tmpdir } from 'os';
|
|
@@ -10,7 +10,7 @@ import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, write
|
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
11
|
import { readFile, copyFile, mkdtemp, rm, readdir, stat, writeFile } from 'fs/promises';
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
|
-
import
|
|
13
|
+
import sharp8 from 'sharp';
|
|
14
14
|
import Fastify from 'fastify';
|
|
15
15
|
import fastifyStatic from '@fastify/static';
|
|
16
16
|
import fastifyMultipart from '@fastify/multipart';
|
|
@@ -211,11 +211,12 @@ function widenNodeStatusCheck(db) {
|
|
|
211
211
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
212
212
|
overlays TEXT NOT NULL DEFAULT '{}',
|
|
213
213
|
brief TEXT,
|
|
214
|
-
archived INTEGER NOT NULL DEFAULT 0
|
|
214
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
215
|
+
duration_ms INTEGER
|
|
215
216
|
);
|
|
216
217
|
INSERT INTO nodes_new
|
|
217
218
|
SELECT id, project_id, parent_id, kind, prompt, engine_id, status, images, cost_usd, kept, error,
|
|
218
|
-
created_at, overlays, brief, archived
|
|
219
|
+
created_at, overlays, brief, archived, duration_ms
|
|
219
220
|
FROM nodes;
|
|
220
221
|
DROP TABLE nodes;
|
|
221
222
|
ALTER TABLE nodes_new RENAME TO nodes;
|
|
@@ -342,6 +343,9 @@ function openDb(homeDir) {
|
|
|
342
343
|
if (!nodeCols.includes("archived")) {
|
|
343
344
|
db.exec("ALTER TABLE nodes ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
|
|
344
345
|
}
|
|
346
|
+
if (!nodeCols.includes("duration_ms")) {
|
|
347
|
+
db.exec("ALTER TABLE nodes ADD COLUMN duration_ms INTEGER");
|
|
348
|
+
}
|
|
345
349
|
const projectCols = db.pragma("table_info(projects)").map((c) => c.name);
|
|
346
350
|
if (!projectCols.includes("slug")) {
|
|
347
351
|
db.exec("ALTER TABLE projects ADD COLUMN slug TEXT");
|
|
@@ -472,6 +476,7 @@ function rowToNode(r) {
|
|
|
472
476
|
status: r.status,
|
|
473
477
|
images: JSON.parse(r.images),
|
|
474
478
|
costUsd: r.cost_usd,
|
|
479
|
+
durationMs: r.duration_ms ?? null,
|
|
475
480
|
kept: !!r.kept,
|
|
476
481
|
error: r.error,
|
|
477
482
|
createdAt: r.created_at,
|
|
@@ -649,9 +654,10 @@ function createStore(db) {
|
|
|
649
654
|
return this.getNode(id);
|
|
650
655
|
},
|
|
651
656
|
completeNode(id, result) {
|
|
652
|
-
db.prepare("UPDATE nodes SET status='done', images=?, cost_usd=? WHERE id=?").run(
|
|
657
|
+
db.prepare("UPDATE nodes SET status='done', images=?, cost_usd=?, duration_ms=? WHERE id=?").run(
|
|
653
658
|
JSON.stringify(result.images),
|
|
654
659
|
result.costUsd,
|
|
660
|
+
result.durationMs ?? null,
|
|
655
661
|
id
|
|
656
662
|
);
|
|
657
663
|
},
|
|
@@ -2601,7 +2607,7 @@ function createDemoEngine(saveImage) {
|
|
|
2601
2607
|
<text x="24" y="${h - 48}" font-family="Helvetica, Arial" font-size="${Math.max(14, Math.round(w / 42))}" fill="#ffffff" opacity="0.92">${esc(label)}</text>
|
|
2602
2608
|
<text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
|
|
2603
2609
|
</svg>`;
|
|
2604
|
-
return
|
|
2610
|
+
return sharp8(Buffer.from(svg)).png().toBuffer();
|
|
2605
2611
|
}
|
|
2606
2612
|
return {
|
|
2607
2613
|
capabilities() {
|
|
@@ -2764,7 +2770,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
|
|
|
2764
2770
|
for (const [slot, angle] of PRESENTER_ANGLES) {
|
|
2765
2771
|
const path = presenterRefPath(templatesRoot, presenter.id, slot);
|
|
2766
2772
|
if (!existsSync(path)) continue;
|
|
2767
|
-
const png = await
|
|
2773
|
+
const png = await sharp8(readFileSync(path)).png().toBuffer();
|
|
2768
2774
|
const hash = core.images.save(png);
|
|
2769
2775
|
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
2770
2776
|
}
|
|
@@ -2858,7 +2864,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
|
|
|
2858
2864
|
for (const angle of angles) {
|
|
2859
2865
|
const path = demoProductRefPath(templatesRoot, product.id, angle);
|
|
2860
2866
|
if (!existsSync(path)) continue;
|
|
2861
|
-
const png = await
|
|
2867
|
+
const png = await sharp8(readFileSync(path)).png().toBuffer();
|
|
2862
2868
|
const hash = core.images.save(png);
|
|
2863
2869
|
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
2864
2870
|
}
|
|
@@ -2914,15 +2920,19 @@ function productFidelityDirective(attached) {
|
|
|
2914
2920
|
}
|
|
2915
2921
|
return "The attached product images all show the exact same product from different angles: preserve its label, shape and colors faithfully, do not redesign it, and do not treat the extra angles as additional products. Any face not visible in them is unknown \u2014 keep it plain and consistent with the visible materials, and do not invent detail on it.";
|
|
2916
2922
|
}
|
|
2917
|
-
function editPreservationDirective(scope) {
|
|
2923
|
+
function editPreservationDirective(scope, opts) {
|
|
2918
2924
|
if (scope === "local") {
|
|
2919
|
-
|
|
2925
|
+
const removal = opts?.removal ? " What is removed leaves nothing behind: the surface and the scene continue as if it had never been there, with no outline, silhouette, residue or ghost of it." : "";
|
|
2926
|
+
return "This is a change to a photograph that already exists, not a new photograph. Return the same image with one change made. Everything the instruction does not name comes back exactly as it is now: the same framing, the same crop, the same camera position, the same subject placement and pose, the same lighting, the same colours, the same background and the same dimensions. Do not re-render, re-stage, re-light or re-compose the picture. Change only what was asked for, together with the shadows, reflections and contact points that move with it." + removal;
|
|
2920
2927
|
}
|
|
2921
2928
|
return "This is a change to a photograph that already exists, not a new photograph. Apply the instruction to the image you were given and keep what it does not name: the same subject and the same face, the same product with the same label, geometry and colour, and the same dimensions. Do not replace the subject and do not redesign the product.";
|
|
2922
2929
|
}
|
|
2923
2930
|
function inheritedIdentityDirective() {
|
|
2924
2931
|
return "The extra attached references are the same product and the same person that are already in this picture. Use them to hold that identity exact while you make the change, not as a reason to re-stage the shot.";
|
|
2925
2932
|
}
|
|
2933
|
+
function garmentDisplayDirective() {
|
|
2934
|
+
return "No person is part of this brief. Present the garment as a product, laid, hung, folded or dressed on a plain form, never on a person, a partial figure or an invisible body, unless the direction above explicitly asks for it worn.";
|
|
2935
|
+
}
|
|
2926
2936
|
function shotSpecifiesCamera(text) {
|
|
2927
2937
|
return /\b\d{2,3}\s?mm\b|\bf\/\d|\blens\b|\bcamera\b|\bshot from\b|\beye[- ]level\b|\blow angle\b|\bhigh angle\b|\boverhead\b|\btop[- ]down\b|\bbird'?s[- ]eye\b|\bclose[- ]up\b|\bmacro\b|\bwide shot\b|\bcrop(?:ped)?\b|\bframing\b|\bdepth of field\b|\bbokeh\b|\bshallow (?:focus|depth)\b|\bdeep focus\b/i.test(
|
|
2928
2938
|
text
|
|
@@ -3193,15 +3203,21 @@ function compileBrief(brief, ctx) {
|
|
|
3193
3203
|
] : [];
|
|
3194
3204
|
const brandLines = brandRuleDirectives(ctx.brand);
|
|
3195
3205
|
const preservation = ctx.mode === "edit" ? [
|
|
3196
|
-
editPreservationDirective(ctx.editScope ?? "global"),
|
|
3206
|
+
editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval }),
|
|
3197
3207
|
...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : []
|
|
3198
3208
|
] : [];
|
|
3209
|
+
const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
|
|
3210
|
+
if (a.role !== "product" || !a.id) return false;
|
|
3211
|
+
const rec = (ctx.brand?.products ?? []).find((x) => x?.id === a.id);
|
|
3212
|
+
return String(rec?.category ?? "").toLowerCase() === "apparel";
|
|
3213
|
+
}) ? [garmentDisplayDirective()] : [];
|
|
3199
3214
|
const allDirectives = [
|
|
3200
3215
|
...productDirectives,
|
|
3201
3216
|
...personDirectives,
|
|
3202
3217
|
...pairDirectives,
|
|
3203
3218
|
...otherDirectives,
|
|
3204
3219
|
...cameraDirectives,
|
|
3220
|
+
...apparelUnworn,
|
|
3205
3221
|
...brandLines,
|
|
3206
3222
|
...guard,
|
|
3207
3223
|
...preservation
|
|
@@ -5197,8 +5213,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
|
|
|
5197
5213
|
errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
|
|
5198
5214
|
return;
|
|
5199
5215
|
}
|
|
5200
|
-
const png = await
|
|
5201
|
-
const meta = await
|
|
5216
|
+
const png = await sharp8(buf).rotate().png().toBuffer();
|
|
5217
|
+
const meta = await sharp8(png).metadata();
|
|
5202
5218
|
const hash = core.images.save(png);
|
|
5203
5219
|
core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
|
|
5204
5220
|
width: meta.width,
|
|
@@ -5608,7 +5624,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
|
5608
5624
|
return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
|
|
5609
5625
|
}
|
|
5610
5626
|
async function edgeBarGeometry(buf) {
|
|
5611
|
-
const { data, info } = await
|
|
5627
|
+
const { data, info } = await sharp8(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
5612
5628
|
const W = info.width;
|
|
5613
5629
|
const H = info.height;
|
|
5614
5630
|
const scan = (len, cross, at) => {
|
|
@@ -5662,7 +5678,7 @@ async function trimEdgeBars(core, hash) {
|
|
|
5662
5678
|
const width = g.right - g.left + 1;
|
|
5663
5679
|
const height = g.bottom - g.top + 1;
|
|
5664
5680
|
if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
|
|
5665
|
-
const png = await
|
|
5681
|
+
const png = await sharp8(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
|
|
5666
5682
|
return core.images.save(png);
|
|
5667
5683
|
} catch {
|
|
5668
5684
|
return hash;
|
|
@@ -5684,11 +5700,11 @@ async function avatarCrop(core, hash) {
|
|
|
5684
5700
|
async function crop(core, hash, region) {
|
|
5685
5701
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
5686
5702
|
try {
|
|
5687
|
-
const meta = await
|
|
5703
|
+
const meta = await sharp8(core.images.read(hash)).metadata();
|
|
5688
5704
|
const w = meta.width ?? 0;
|
|
5689
5705
|
const h = meta.height ?? 0;
|
|
5690
5706
|
if (!w || !h) return void 0;
|
|
5691
|
-
const png = await
|
|
5707
|
+
const png = await sharp8(core.images.read(hash)).extract(region(w, h)).png().toBuffer();
|
|
5692
5708
|
return core.images.save(png);
|
|
5693
5709
|
} catch {
|
|
5694
5710
|
return void 0;
|
|
@@ -5889,6 +5905,7 @@ var GLOBAL_CUES = [
|
|
|
5889
5905
|
["pose", /\b(pose|posture|expression|smile|smiling|looking)\b/i]
|
|
5890
5906
|
];
|
|
5891
5907
|
var LOCAL_VERB = /\b(add|remove|delete|erase|take out|get rid of|replace|swap|clean up|fix|repair|straighten|hide|cover)\b/i;
|
|
5908
|
+
var REMOVAL_VERB = /\b(remove|delete|erase|take out|get rid of)\b/i;
|
|
5892
5909
|
var DEFINITE_OBJECT = /\b(the|that|this|his|her|their|its|a|an|one)\b/i;
|
|
5893
5910
|
var REGION_CUE = /\b(in the (top|bottom|upper|lower|left|right)|on the (left|right|label|cap|lid|sleeve|table|floor|wall|shelf)|behind|next to|beside|in front of|to the (left|right)|corner|foreground|background object)\b/i;
|
|
5894
5911
|
var COORDINATION = /\b(and|then|also|plus)\b|[;]/i;
|
|
@@ -5908,7 +5925,7 @@ function scopeOfInstruction(text) {
|
|
|
5908
5925
|
if (REGION_CUE.test(s)) matched.push("region");
|
|
5909
5926
|
if (LOCAL_VERB.test(s) && DEFINITE_OBJECT.test(s)) matched.push("verb+object");
|
|
5910
5927
|
if (!matched.length) return { scope: "global", matched: ["no local cue"] };
|
|
5911
|
-
return { scope: "local", matched };
|
|
5928
|
+
return { scope: "local", matched, removal: REMOVAL_VERB.test(s) };
|
|
5912
5929
|
}
|
|
5913
5930
|
|
|
5914
5931
|
// src/expandRules.ts
|
|
@@ -5943,29 +5960,29 @@ function expandInstruction(plan, direction) {
|
|
|
5943
5960
|
return `Extend this photograph ${where} to fill the empty margin, continuing the same scene, the same surface, the same lighting and the same perspective straight out to the new edges. Do not change, move, rescale or reinterpret anything already in the picture, and do not add a subject, a product or a person that is not already there.${direction.trim() ? ` ${direction.trim()}` : ""}`;
|
|
5944
5961
|
}
|
|
5945
5962
|
async function expandCanvas(source, plan) {
|
|
5946
|
-
const bed = await
|
|
5947
|
-
return
|
|
5963
|
+
const bed = await sharp8(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
5964
|
+
return sharp8(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
5948
5965
|
}
|
|
5949
5966
|
async function compositeExpand(engineImage, source, plan) {
|
|
5950
|
-
const meta = await
|
|
5967
|
+
const meta = await sharp8(engineImage).metadata();
|
|
5951
5968
|
const want = plan.width / plan.height;
|
|
5952
5969
|
const got = meta.width && meta.height ? meta.width / meta.height : 0;
|
|
5953
5970
|
const sameOrientation = got > 0 && got >= 1 === want >= 1;
|
|
5954
5971
|
const aligned = sameOrientation;
|
|
5955
|
-
const surround = aligned ? await
|
|
5956
|
-
const image = await
|
|
5972
|
+
const surround = aligned ? await sharp8(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
|
|
5973
|
+
const image = await sharp8(surround).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
5957
5974
|
return { image, aligned };
|
|
5958
5975
|
}
|
|
5959
5976
|
async function expandCanvasBedOnly(source, plan) {
|
|
5960
|
-
return
|
|
5977
|
+
return sharp8(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
5961
5978
|
}
|
|
5962
5979
|
async function driftDiff(a, b) {
|
|
5963
|
-
const metaA = await
|
|
5964
|
-
const metaB = await
|
|
5980
|
+
const metaA = await sharp8(a).metadata();
|
|
5981
|
+
const metaB = await sharp8(b).metadata();
|
|
5965
5982
|
const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
|
|
5966
5983
|
const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
|
|
5967
5984
|
const [rawA, rawB] = await Promise.all(
|
|
5968
|
-
[a, b].map((buf) =>
|
|
5985
|
+
[a, b].map((buf) => sharp8(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
|
|
5969
5986
|
);
|
|
5970
5987
|
const out = new PNG({ width, height });
|
|
5971
5988
|
const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
|
|
@@ -5977,11 +5994,11 @@ async function driftDiff(a, b) {
|
|
|
5977
5994
|
};
|
|
5978
5995
|
}
|
|
5979
5996
|
async function changeMask(a, b, cap2 = 1024) {
|
|
5980
|
-
const metaA = await
|
|
5997
|
+
const metaA = await sharp8(a).metadata();
|
|
5981
5998
|
const width = Math.min(metaA.width ?? 1, cap2);
|
|
5982
5999
|
const height = Math.min(metaA.height ?? 1, cap2);
|
|
5983
6000
|
const [rawA, rawB] = await Promise.all(
|
|
5984
|
-
[a, b].map((buf) =>
|
|
6001
|
+
[a, b].map((buf) => sharp8(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
|
|
5985
6002
|
);
|
|
5986
6003
|
const out = new PNG({ width, height });
|
|
5987
6004
|
pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
|
|
@@ -6030,8 +6047,8 @@ function dilationFor(longEdge) {
|
|
|
6030
6047
|
// src/localEdit.ts
|
|
6031
6048
|
async function preserveOutsideChange(source, edited) {
|
|
6032
6049
|
try {
|
|
6033
|
-
const srcMeta = await
|
|
6034
|
-
const outMeta = await
|
|
6050
|
+
const srcMeta = await sharp8(source).metadata();
|
|
6051
|
+
const outMeta = await sharp8(edited).metadata();
|
|
6035
6052
|
if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
|
|
6036
6053
|
return { image: edited, outcome: "error", changed: 0 };
|
|
6037
6054
|
const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
|
|
@@ -6040,12 +6057,16 @@ async function preserveOutsideChange(source, edited) {
|
|
|
6040
6057
|
const outcome = judgeChange(shape);
|
|
6041
6058
|
if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
|
|
6042
6059
|
const r = dilationFor(Math.max(shape.width, shape.height));
|
|
6043
|
-
const
|
|
6044
|
-
const
|
|
6045
|
-
const
|
|
6060
|
+
const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
|
|
6061
|
+
const spread = await sharp8(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
|
|
6062
|
+
const dilated = await sharp8(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
|
|
6063
|
+
const feathered = await sharp8(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
|
|
6064
|
+
const grown = await sharp8(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
|
|
6065
|
+
const editedRgb = await sharp8(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
|
|
6066
|
+
const masked = await sharp8(editedRgb, {
|
|
6046
6067
|
raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
|
|
6047
6068
|
}).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
|
|
6048
|
-
const image = await
|
|
6069
|
+
const image = await sharp8(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
|
|
6049
6070
|
return { image, outcome: "composited", changed: shape.changed };
|
|
6050
6071
|
} catch {
|
|
6051
6072
|
return { image: edited, outcome: "error", changed: 0 };
|
|
@@ -6074,7 +6095,7 @@ var assetHash2 = (ref) => {
|
|
|
6074
6095
|
};
|
|
6075
6096
|
var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
|
|
6076
6097
|
var LOGO_BACKGROUNDS = ["light", "dark", "any"];
|
|
6077
|
-
var toPng = (buf) =>
|
|
6098
|
+
var toPng = (buf) => sharp8(buf).rotate().png().toBuffer();
|
|
6078
6099
|
var COST_PROBE = {
|
|
6079
6100
|
prompt: "",
|
|
6080
6101
|
brand: { brand: {}, assetPaths: {} },
|
|
@@ -6083,7 +6104,7 @@ var COST_PROBE = {
|
|
|
6083
6104
|
count: 1
|
|
6084
6105
|
};
|
|
6085
6106
|
var MARK_MAX_EDGE = 2048;
|
|
6086
|
-
var toMarkPng = (buf) =>
|
|
6107
|
+
var toMarkPng = (buf) => sharp8(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
6087
6108
|
var readImagePart = async (core, req, normalize2) => {
|
|
6088
6109
|
const part = await req.file();
|
|
6089
6110
|
if (!part) return { error: "multipart file field required" };
|
|
@@ -6260,7 +6281,7 @@ async function vibrantColor(input) {
|
|
|
6260
6281
|
let data;
|
|
6261
6282
|
let channels;
|
|
6262
6283
|
try {
|
|
6263
|
-
const out = await
|
|
6284
|
+
const out = await sharp8(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
|
|
6264
6285
|
data = out.data;
|
|
6265
6286
|
channels = out.info.channels;
|
|
6266
6287
|
} catch {
|
|
@@ -6283,7 +6304,7 @@ async function vibrantColor(input) {
|
|
|
6283
6304
|
const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
|
|
6284
6305
|
if (best.score <= 0) {
|
|
6285
6306
|
try {
|
|
6286
|
-
const { dominant } = await
|
|
6307
|
+
const { dominant } = await sharp8(input).stats();
|
|
6287
6308
|
return toHex(dominant.r, dominant.g, dominant.b);
|
|
6288
6309
|
} catch {
|
|
6289
6310
|
return null;
|
|
@@ -6826,7 +6847,7 @@ async function buildExportZip(image, baseName, presetIds) {
|
|
|
6826
6847
|
const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
|
|
6827
6848
|
if (chosen.length === 0) throw new Error("No valid export presets selected");
|
|
6828
6849
|
for (const p of chosen) {
|
|
6829
|
-
const buf = p.width && p.height ? await
|
|
6850
|
+
const buf = p.width && p.height ? await sharp8(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
|
|
6830
6851
|
zip.file(`${baseName}-${p.id}.png`, buf);
|
|
6831
6852
|
}
|
|
6832
6853
|
return zip.generateAsync({ type: "nodebuffer" });
|
|
@@ -6987,7 +7008,7 @@ function registerImageRoutes(app, deps) {
|
|
|
6987
7008
|
if (!part) return reply.status(400).send({ error: "multipart file field required" });
|
|
6988
7009
|
const buf = await part.toBuffer();
|
|
6989
7010
|
if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
|
|
6990
|
-
const png = await
|
|
7011
|
+
const png = await sharp8(buf).rotate().png().toBuffer();
|
|
6991
7012
|
return { hash: core.images.save(png) };
|
|
6992
7013
|
});
|
|
6993
7014
|
app.post("/api/diff", async (req, reply) => {
|
|
@@ -7022,6 +7043,35 @@ function registerImageRoutes(app, deps) {
|
|
|
7022
7043
|
|
|
7023
7044
|
// src/release/notes.data.ts
|
|
7024
7045
|
var RELEASES = [
|
|
7046
|
+
{
|
|
7047
|
+
version: "0.4.6",
|
|
7048
|
+
date: "2026-08-24",
|
|
7049
|
+
sections: [
|
|
7050
|
+
{
|
|
7051
|
+
heading: "Refining",
|
|
7052
|
+
body: "Removing something from a shot no longer leaves a faint outline of it behind. The removed object is gone and the surface continues as if it had never been there."
|
|
7053
|
+
},
|
|
7054
|
+
{
|
|
7055
|
+
heading: "Shots",
|
|
7056
|
+
body: "A finished shot now says how long it took to generate, next to what it cost. Feed tiles hold the exact shape of the picture they carry, so a landing image no longer shifts its column, and a garment shot with no presenter attached leans toward a proper product display rather than inventing someone to wear it."
|
|
7057
|
+
}
|
|
7058
|
+
]
|
|
7059
|
+
},
|
|
7060
|
+
{
|
|
7061
|
+
version: "0.4.5",
|
|
7062
|
+
date: "2026-08-23",
|
|
7063
|
+
title: "New versions find you while Scenri runs.",
|
|
7064
|
+
sections: [
|
|
7065
|
+
{
|
|
7066
|
+
heading: "Updates",
|
|
7067
|
+
body: "A running Scenri now checks for new versions every six hours instead of relying on the next launch, and a laptop that slept through a check catches up within minutes of waking. Open tabs hear about a downloaded update within the half hour, or the moment you return to them."
|
|
7068
|
+
},
|
|
7069
|
+
{
|
|
7070
|
+
heading: "Fixes",
|
|
7071
|
+
body: "Running from a source checkout no longer shows a floating update notice whose button had nothing to do. The checkout keeps its quiet badge and the pull-and-rebuild note in Settings."
|
|
7072
|
+
}
|
|
7073
|
+
]
|
|
7074
|
+
},
|
|
7025
7075
|
{
|
|
7026
7076
|
version: "0.4.4",
|
|
7027
7077
|
date: "2026-08-23",
|
|
@@ -7339,7 +7389,9 @@ function registerUpdateRoutes(app, deps) {
|
|
|
7339
7389
|
// (bump-minor-pre-major), so only a real major asks for attention.
|
|
7340
7390
|
attention: kind === "major",
|
|
7341
7391
|
checkedAt: r.checkedAt,
|
|
7342
|
-
|
|
7392
|
+
// Only a clean release triple becomes a URL: a stray prerelease on the
|
|
7393
|
+
// latest tag must not leak into link surfaces any more than into staging.
|
|
7394
|
+
notesUrl: r.latest && ghSlug && isReleaseTriplet(r.latest) ? `https://github.com/${ghSlug}/releases/tag/v${r.latest}` : null,
|
|
7343
7395
|
error: apply.phase === "error" ? apply.error : r.error,
|
|
7344
7396
|
canApply: blockReason() === null,
|
|
7345
7397
|
blockReason: blockReason(),
|
|
@@ -7812,14 +7864,14 @@ function buildServer(opts) {
|
|
|
7812
7864
|
const out = [];
|
|
7813
7865
|
for (const h of images) {
|
|
7814
7866
|
const buf = core.images.read(h);
|
|
7815
|
-
out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await
|
|
7867
|
+
out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp8(buf).png().toBuffer()));
|
|
7816
7868
|
}
|
|
7817
7869
|
return out;
|
|
7818
7870
|
}
|
|
7819
7871
|
async function assertAspect(images, expect) {
|
|
7820
7872
|
const want = expect.width / expect.height;
|
|
7821
7873
|
for (const h of images) {
|
|
7822
|
-
const meta2 = await
|
|
7874
|
+
const meta2 = await sharp8(core.images.read(h)).metadata();
|
|
7823
7875
|
if (!meta2.width || !meta2.height) continue;
|
|
7824
7876
|
const got = meta2.width / meta2.height;
|
|
7825
7877
|
if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
|
|
@@ -7839,12 +7891,26 @@ function buildServer(opts) {
|
|
|
7839
7891
|
watchdogFired = true;
|
|
7840
7892
|
ctrl.abort();
|
|
7841
7893
|
}, opts.nodeTimeoutMs ?? NODE_TIMEOUT_MS);
|
|
7894
|
+
const startedAt = Date.now();
|
|
7842
7895
|
try {
|
|
7843
7896
|
const result = await work(ctrl.signal);
|
|
7844
7897
|
result.images = await normalizePngs(result.images);
|
|
7845
7898
|
if (post) result.images = await post(result.images);
|
|
7846
7899
|
if (expect) await assertAspect(result.images, expect);
|
|
7847
|
-
core.store.completeNode(nodeId, result);
|
|
7900
|
+
core.store.completeNode(nodeId, { ...result, durationMs: Date.now() - startedAt });
|
|
7901
|
+
try {
|
|
7902
|
+
const sizes = [];
|
|
7903
|
+
for (const h of result.images) {
|
|
7904
|
+
const meta2 = await sharp8(core.images.read(h)).metadata();
|
|
7905
|
+
if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
|
|
7906
|
+
}
|
|
7907
|
+
const node = core.store.getNode(nodeId);
|
|
7908
|
+
if (node && sizes.length) {
|
|
7909
|
+
const brief = node.brief ?? {};
|
|
7910
|
+
core.store.setBrief(nodeId, { ...brief, rendered: { sizes } });
|
|
7911
|
+
}
|
|
7912
|
+
} catch {
|
|
7913
|
+
}
|
|
7848
7914
|
core.ledger.recordCost(engineId, nodeId, result.costUsd);
|
|
7849
7915
|
} catch (err) {
|
|
7850
7916
|
if (watchdogFired) core.store.failNode(nodeId, "generation timed out after 10 minutes");
|
|
@@ -7888,6 +7954,7 @@ function buildServer(opts) {
|
|
|
7888
7954
|
let inheritedTokens = [];
|
|
7889
7955
|
let inheritedAttachments = [];
|
|
7890
7956
|
let editScope = "global";
|
|
7957
|
+
let editRemoval = false;
|
|
7891
7958
|
const extraWarnings = [];
|
|
7892
7959
|
let expandPlan = null;
|
|
7893
7960
|
let expandSourceHash = null;
|
|
@@ -7916,9 +7983,11 @@ function buildServer(opts) {
|
|
|
7916
7983
|
);
|
|
7917
7984
|
inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
|
|
7918
7985
|
}
|
|
7919
|
-
|
|
7986
|
+
const verdict = scopeOfInstruction(
|
|
7920
7987
|
brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
|
|
7921
|
-
)
|
|
7988
|
+
);
|
|
7989
|
+
editScope = verdict.scope;
|
|
7990
|
+
editRemoval = verdict.removal ?? false;
|
|
7922
7991
|
}
|
|
7923
7992
|
compiled2 = compileBrief(brief, {
|
|
7924
7993
|
brand: brandJson,
|
|
@@ -7926,7 +7995,7 @@ function buildServer(opts) {
|
|
|
7926
7995
|
engineCaps: engine.capabilities(),
|
|
7927
7996
|
template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
|
|
7928
7997
|
templateById: sceneById,
|
|
7929
|
-
...kind === "edit" ? { mode: "edit", editScope, inheritedIdentity: inheritedTokens.length > 0 } : {}
|
|
7998
|
+
...kind === "edit" ? { mode: "edit", editScope, editRemoval, inheritedIdentity: inheritedTokens.length > 0 } : {}
|
|
7930
7999
|
});
|
|
7931
8000
|
if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
|
|
7932
8001
|
if (inheritedTokens.length) {
|
|
@@ -8024,7 +8093,7 @@ function buildServer(opts) {
|
|
|
8024
8093
|
`${engine.capabilities().displayName} cannot carry reference images, so the identity rides on the source frame alone.`
|
|
8025
8094
|
);
|
|
8026
8095
|
const srcBuf = core.images.read(String(srcHash));
|
|
8027
|
-
const srcMeta = await
|
|
8096
|
+
const srcMeta = await sharp8(srcBuf).metadata();
|
|
8028
8097
|
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
8029
8098
|
if (srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
|
|
8030
8099
|
expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
|