scenri 0.3.4 → 0.4.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 +36 -0
- package/dist/{chunk-GA7P7K2M.js → chunk-3W2RHBZC.js} +23 -5
- package/dist/{cli-GQLRYYGU.js → cli-HF5KPURA.js} +5 -4
- package/dist/index.js +1 -1
- package/dist/serve.js +401 -59
- package/package.json +1 -1
- package/studio-dist/assets/{index-B1iLAbLh.css → index-CC0sP0Ws.css} +1 -1
- package/studio-dist/assets/{index-DBz4hsVB.js → index-CwJxyJAh.js} +25 -25
- package/studio-dist/index.html +2 -2
package/dist/serve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { portBusyLines } from './chunk-WGIZJXNE.js';
|
|
2
|
-
import { createUpdateChecker, classify, stageVersion, findNpm } from './chunk-
|
|
2
|
+
import { createUpdateChecker, classify, stageVersion, findNpm } from './chunk-3W2RHBZC.js';
|
|
3
3
|
import { readMeta, repoSlug } from './chunk-Y3ZPBPLP.js';
|
|
4
4
|
import { newestStaged, compareSemver } 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, readdir, mkdtemp, rm, writeFile } from 'fs/promises';
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
|
-
import
|
|
13
|
+
import sharp6 from 'sharp';
|
|
14
14
|
import Fastify from 'fastify';
|
|
15
15
|
import fastifyStatic from '@fastify/static';
|
|
16
16
|
import fastifyMultipart from '@fastify/multipart';
|
|
@@ -702,8 +702,20 @@ function createStore(db) {
|
|
|
702
702
|
setKept(id, kept) {
|
|
703
703
|
db.prepare("UPDATE nodes SET kept=? WHERE id=?").run(kept ? 1 : 0, id);
|
|
704
704
|
},
|
|
705
|
+
/**
|
|
706
|
+
* Archiving also clears the keeper mark.
|
|
707
|
+
*
|
|
708
|
+
* The two flags were independent, and the Keepers lens reads the live list,
|
|
709
|
+
* so archiving a keeper removed it from Keepers and from the Keepers count
|
|
710
|
+
* without saying anything: the star stayed lit on a shot that was no longer
|
|
711
|
+
* in the shortlist it claimed to be in. Keepers is a live shortlist and
|
|
712
|
+
* archive means put away, so one clears the other and the two can never
|
|
713
|
+
* disagree. Restoring does not re-star: the judgement was made once and
|
|
714
|
+
* putting the shot back is not the same as making it again.
|
|
715
|
+
*/
|
|
705
716
|
setArchived(id, archived) {
|
|
706
|
-
db.prepare("UPDATE nodes SET archived
|
|
717
|
+
if (archived) db.prepare("UPDATE nodes SET archived=1, kept=0 WHERE id=?").run(id);
|
|
718
|
+
else db.prepare("UPDATE nodes SET archived=0 WHERE id=?").run(id);
|
|
707
719
|
},
|
|
708
720
|
/** Permanent. Orphans any children rather than blocking or cascading —
|
|
709
721
|
* same technique collapseProjects already uses for a surplus root. */
|
|
@@ -1857,11 +1869,17 @@ function execArgs(dir, promptText, effort = "low") {
|
|
|
1857
1869
|
function createRunner(opts = {}) {
|
|
1858
1870
|
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
1859
1871
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
1872
|
+
const platform = opts.platform ?? process.platform;
|
|
1873
|
+
const winArg = (a) => `"${a.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/%/g, " percent ")}"`;
|
|
1874
|
+
const spawnCodex = (args) => platform === "win32" ? spawnImpl(["codex", ...args].map(winArg).join(" "), [], {
|
|
1875
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1876
|
+
shell: true
|
|
1877
|
+
}) : spawnImpl("codex", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1860
1878
|
function run2(args, signal) {
|
|
1861
1879
|
return new Promise((resolve, reject) => {
|
|
1862
1880
|
let child;
|
|
1863
1881
|
try {
|
|
1864
|
-
child =
|
|
1882
|
+
child = spawnCodex(args);
|
|
1865
1883
|
} catch (err) {
|
|
1866
1884
|
reject(new Error(`Failed to spawn codex: ${err.message}`));
|
|
1867
1885
|
return;
|
|
@@ -1930,7 +1948,7 @@ function createRunner(opts = {}) {
|
|
|
1930
1948
|
};
|
|
1931
1949
|
let child;
|
|
1932
1950
|
try {
|
|
1933
|
-
child =
|
|
1951
|
+
child = spawnCodex(args);
|
|
1934
1952
|
} catch {
|
|
1935
1953
|
done(false);
|
|
1936
1954
|
return;
|
|
@@ -2108,6 +2126,7 @@ function stateFrom(avail) {
|
|
|
2108
2126
|
}
|
|
2109
2127
|
function createCodexSetup(opts = {}) {
|
|
2110
2128
|
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
2129
|
+
const platform = opts.platform ?? process.platform;
|
|
2111
2130
|
const runner = createRunner(opts);
|
|
2112
2131
|
const installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
|
|
2113
2132
|
function run2(cmd, args, timeoutMs) {
|
|
@@ -2126,7 +2145,7 @@ function createCodexSetup(opts = {}) {
|
|
|
2126
2145
|
done({ code: null, stderr, spawnError: `${cmd} timed out after ${timeoutMs}ms` });
|
|
2127
2146
|
}, timeoutMs);
|
|
2128
2147
|
try {
|
|
2129
|
-
child = spawnImpl(cmd, args, { stdio: ["ignore", "pipe", "pipe"], shell:
|
|
2148
|
+
child = spawnImpl(cmd, args, { stdio: ["ignore", "pipe", "pipe"], shell: platform === "win32" });
|
|
2130
2149
|
} catch (err) {
|
|
2131
2150
|
done({ code: null, stderr, spawnError: err.message });
|
|
2132
2151
|
return;
|
|
@@ -2159,7 +2178,7 @@ function createCodexSetup(opts = {}) {
|
|
|
2159
2178
|
}
|
|
2160
2179
|
return { ok: true };
|
|
2161
2180
|
}
|
|
2162
|
-
if (
|
|
2181
|
+
if (platform !== "win32" && /EACCES|permission denied/i.test(res.stderr)) {
|
|
2163
2182
|
return {
|
|
2164
2183
|
ok: false,
|
|
2165
2184
|
fallbackCommand: INSTALL_COMMAND_SUDO,
|
|
@@ -2245,15 +2264,29 @@ function createCodexEngine(opts) {
|
|
|
2245
2264
|
})
|
|
2246
2265
|
);
|
|
2247
2266
|
const results = new Array(count);
|
|
2267
|
+
const failures = [];
|
|
2248
2268
|
let next = 0;
|
|
2249
2269
|
const workers = Array.from({ length: Math.min(3, count) }, async () => {
|
|
2250
2270
|
while (next < count) {
|
|
2251
2271
|
const i = next++;
|
|
2252
|
-
|
|
2272
|
+
try {
|
|
2273
|
+
results[i] = await jobs[i]();
|
|
2274
|
+
} catch (err) {
|
|
2275
|
+
if (signal?.aborted) throw err;
|
|
2276
|
+
results[i] = [];
|
|
2277
|
+
failures.push(err);
|
|
2278
|
+
}
|
|
2253
2279
|
}
|
|
2254
2280
|
});
|
|
2255
2281
|
await Promise.all(workers);
|
|
2256
|
-
|
|
2282
|
+
const images = results.flat();
|
|
2283
|
+
if (!images.length && failures.length) throw failures[0];
|
|
2284
|
+
if (failures.length) {
|
|
2285
|
+
console.warn(
|
|
2286
|
+
`codex: ${failures.length} of ${count} variants failed, keeping ${images.length}: ${String(failures[0]?.message ?? failures[0])}`
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
return { images, costUsd: 0 };
|
|
2257
2290
|
},
|
|
2258
2291
|
async edit(req, signal) {
|
|
2259
2292
|
return withWorkDir(async (dir) => {
|
|
@@ -2262,13 +2295,17 @@ function createCodexEngine(opts) {
|
|
|
2262
2295
|
const editRoles = req.referenceRoles ?? [];
|
|
2263
2296
|
const refLines = [];
|
|
2264
2297
|
for (let i = 0; i < editRefs.length; i++) {
|
|
2265
|
-
const role = editRoles[i] ?? "
|
|
2298
|
+
const role = editRoles[i] ?? "reference";
|
|
2266
2299
|
const name = `${role}-${i + 1}.png`;
|
|
2267
2300
|
await copyFile(editRefs[i], join(dir, name));
|
|
2268
2301
|
refLines.push(`${name} shows ${EDIT_REFERENCE_ROLE_DIRECTIVE[role]}`);
|
|
2269
2302
|
}
|
|
2270
2303
|
const promptText = `Edit input.png using your image generation/editing tool: ${req.instruction}.` + (refLines.length ? ` ${refLines.join(". ")}.` : "") + ` Do not browse the web or explore files. Save the result in the current directory as out-1.png (you may run the commands needed to save and resize it). Nothing else.`;
|
|
2271
|
-
|
|
2304
|
+
const args = execArgs(dir, promptText);
|
|
2305
|
+
for (const name of ["input.png", ...refLines.map((_, i) => `${editRoles[i] ?? "reference"}-${i + 1}.png`)]) {
|
|
2306
|
+
args.splice(args.length - 1, 0, `--image=${join(dir, name)}`);
|
|
2307
|
+
}
|
|
2308
|
+
await runCodex(args, signal);
|
|
2272
2309
|
const images = await collectImages(dir);
|
|
2273
2310
|
return { images, costUsd: 0 };
|
|
2274
2311
|
});
|
|
@@ -2323,7 +2360,7 @@ function createDemoEngine(saveImage) {
|
|
|
2323
2360
|
<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>
|
|
2324
2361
|
<text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
|
|
2325
2362
|
</svg>`;
|
|
2326
|
-
return
|
|
2363
|
+
return sharp6(Buffer.from(svg)).png().toBuffer();
|
|
2327
2364
|
}
|
|
2328
2365
|
return {
|
|
2329
2366
|
capabilities() {
|
|
@@ -2486,7 +2523,7 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
|
|
|
2486
2523
|
for (const [slot, angle] of PRESENTER_ANGLES) {
|
|
2487
2524
|
const path = presenterRefPath(templatesRoot, presenter.id, slot);
|
|
2488
2525
|
if (!existsSync(path)) continue;
|
|
2489
|
-
const png = await
|
|
2526
|
+
const png = await sharp6(readFileSync(path)).png().toBuffer();
|
|
2490
2527
|
const hash = core.images.save(png);
|
|
2491
2528
|
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
2492
2529
|
}
|
|
@@ -2580,7 +2617,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
|
|
|
2580
2617
|
for (const angle of angles) {
|
|
2581
2618
|
const path = demoProductRefPath(templatesRoot, product.id, angle);
|
|
2582
2619
|
if (!existsSync(path)) continue;
|
|
2583
|
-
const png = await
|
|
2620
|
+
const png = await sharp6(readFileSync(path)).png().toBuffer();
|
|
2584
2621
|
const hash = core.images.save(png);
|
|
2585
2622
|
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
2586
2623
|
}
|
|
@@ -2636,6 +2673,15 @@ function productFidelityDirective(attached) {
|
|
|
2636
2673
|
}
|
|
2637
2674
|
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.";
|
|
2638
2675
|
}
|
|
2676
|
+
function editPreservationDirective(scope) {
|
|
2677
|
+
if (scope === "local") {
|
|
2678
|
+
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.";
|
|
2679
|
+
}
|
|
2680
|
+
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.";
|
|
2681
|
+
}
|
|
2682
|
+
function inheritedIdentityDirective() {
|
|
2683
|
+
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.";
|
|
2684
|
+
}
|
|
2639
2685
|
function shotSpecifiesCamera(text) {
|
|
2640
2686
|
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(
|
|
2641
2687
|
text
|
|
@@ -2803,7 +2849,7 @@ function compileBrief(brief, ctx) {
|
|
|
2803
2849
|
attachments.push({ role: "character", id: c.id, label: c.name, hash: chash, essential: i === 0 });
|
|
2804
2850
|
});
|
|
2805
2851
|
personDirectives.push(
|
|
2806
|
-
"The attached person reference is the same person every time: match their face, facial structure, skin, hair and build exactly. Their outfit, pose, background and lighting are neutral studio capture conditions, not styling direction: dress and style them for this shot, to a commercial standard, following any wardrobe the direction itself specifies."
|
|
2852
|
+
"The attached person reference is the same person every time: match their face, facial structure, skin, hair and build exactly. Their outfit, pose, background and lighting are neutral studio capture conditions, not styling direction: dress and style them for this shot, to a commercial standard, following any wardrobe the direction itself specifies. Where the direction specifies none, dress them for the place and the occasion the frame shows, and never return them to the plain base layers they were photographed in."
|
|
2807
2853
|
);
|
|
2808
2854
|
if (c.identityNotes) personDirectives.push(String(c.identityNotes));
|
|
2809
2855
|
if (c.negativeConstraints?.length)
|
|
@@ -2905,6 +2951,10 @@ function compileBrief(brief, ctx) {
|
|
|
2905
2951
|
"If the attached product is something a person wears, the presenter wears that exact product, with the rest of the outfit styled around it; otherwise the presenter presents or uses the product naturally."
|
|
2906
2952
|
] : [];
|
|
2907
2953
|
const brandLines = brandRuleDirectives(ctx.brand);
|
|
2954
|
+
const preservation = ctx.mode === "edit" ? [
|
|
2955
|
+
editPreservationDirective(ctx.editScope ?? "global"),
|
|
2956
|
+
...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : []
|
|
2957
|
+
] : [];
|
|
2908
2958
|
const allDirectives = [
|
|
2909
2959
|
...productDirectives,
|
|
2910
2960
|
...personDirectives,
|
|
@@ -2912,7 +2962,8 @@ function compileBrief(brief, ctx) {
|
|
|
2912
2962
|
...otherDirectives,
|
|
2913
2963
|
...cameraDirectives,
|
|
2914
2964
|
...brandLines,
|
|
2915
|
-
...guard
|
|
2965
|
+
...guard,
|
|
2966
|
+
...preservation
|
|
2916
2967
|
];
|
|
2917
2968
|
if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
|
|
2918
2969
|
const ROLE_PRIORITY = {
|
|
@@ -4905,8 +4956,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
|
|
|
4905
4956
|
errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
|
|
4906
4957
|
return;
|
|
4907
4958
|
}
|
|
4908
|
-
const png = await
|
|
4909
|
-
const meta = await
|
|
4959
|
+
const png = await sharp6(buf).rotate().png().toBuffer();
|
|
4960
|
+
const meta = await sharp6(png).metadata();
|
|
4910
4961
|
const hash = core.images.save(png);
|
|
4911
4962
|
core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
|
|
4912
4963
|
width: meta.width,
|
|
@@ -5316,7 +5367,7 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
|
5316
5367
|
return STUDIO_FRAMES.map((f) => byAngle.get(f.angle)).filter((h) => !!h);
|
|
5317
5368
|
}
|
|
5318
5369
|
async function edgeBarGeometry(buf) {
|
|
5319
|
-
const { data, info } = await
|
|
5370
|
+
const { data, info } = await sharp6(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
5320
5371
|
const W = info.width;
|
|
5321
5372
|
const H = info.height;
|
|
5322
5373
|
const scan = (len, cross, at) => {
|
|
@@ -5370,7 +5421,7 @@ async function trimEdgeBars(core, hash) {
|
|
|
5370
5421
|
const width = g.right - g.left + 1;
|
|
5371
5422
|
const height = g.bottom - g.top + 1;
|
|
5372
5423
|
if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
|
|
5373
|
-
const png = await
|
|
5424
|
+
const png = await sharp6(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
|
|
5374
5425
|
return core.images.save(png);
|
|
5375
5426
|
} catch {
|
|
5376
5427
|
return hash;
|
|
@@ -5392,11 +5443,11 @@ async function avatarCrop(core, hash) {
|
|
|
5392
5443
|
async function crop(core, hash, region) {
|
|
5393
5444
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
5394
5445
|
try {
|
|
5395
|
-
const meta = await
|
|
5446
|
+
const meta = await sharp6(core.images.read(hash)).metadata();
|
|
5396
5447
|
const w = meta.width ?? 0;
|
|
5397
5448
|
const h = meta.height ?? 0;
|
|
5398
5449
|
if (!w || !h) return void 0;
|
|
5399
|
-
const png = await
|
|
5450
|
+
const png = await sharp6(core.images.read(hash)).extract(region(w, h)).png().toBuffer();
|
|
5400
5451
|
return core.images.save(png);
|
|
5401
5452
|
} catch {
|
|
5402
5453
|
return void 0;
|
|
@@ -5550,6 +5601,215 @@ function registerAccessGuard(app, opts = {}) {
|
|
|
5550
5601
|
}
|
|
5551
5602
|
});
|
|
5552
5603
|
}
|
|
5604
|
+
|
|
5605
|
+
// src/editIdentity.ts
|
|
5606
|
+
var MAX_HOPS = 8;
|
|
5607
|
+
var tokensOf = (node) => {
|
|
5608
|
+
const t = node?.brief?.tokens;
|
|
5609
|
+
return Array.isArray(t) ? t : [];
|
|
5610
|
+
};
|
|
5611
|
+
function inheritedIdentityTokens(parentId, getNode) {
|
|
5612
|
+
let id = parentId;
|
|
5613
|
+
for (let hop = 0; hop < MAX_HOPS && id; hop++) {
|
|
5614
|
+
const node = getNode(id);
|
|
5615
|
+
if (!node || node.kind === "root") return [];
|
|
5616
|
+
const identity = tokensOf(node).filter((t) => t.t === "product" || t.t === "character" || t.t === "mark");
|
|
5617
|
+
if (identity.length) return identity;
|
|
5618
|
+
id = node.parentId;
|
|
5619
|
+
}
|
|
5620
|
+
return [];
|
|
5621
|
+
}
|
|
5622
|
+
|
|
5623
|
+
// src/editScopeRules.ts
|
|
5624
|
+
var GLOBAL_CUES = [
|
|
5625
|
+
["light", /\b(light|lighting|lit|relight|exposure|white ?balance|backlit|shadows everywhere)\b/i],
|
|
5626
|
+
[
|
|
5627
|
+
"grade",
|
|
5628
|
+
/\b(grade|grading|colou?r ?grade|tone|tint|saturation|contrast|filmic|film stock|grain|black and white|monochrome|sepia)\b/i
|
|
5629
|
+
],
|
|
5630
|
+
["time", /\b(night|nighttime|daytime|dusk|dawn|sunset|sunrise|golden hour|midday|morning|evening)\b/i],
|
|
5631
|
+
["weather", /\b(rain|rainy|snow|snowy|fog|foggy|misty|storm|overcast|sunny)\b/i],
|
|
5632
|
+
["scene", /\b(scene|background|backdrop|environment|location|setting|studio|indoors|outdoors)\b/i],
|
|
5633
|
+
[
|
|
5634
|
+
"camera",
|
|
5635
|
+
/\b(angle|zoom|closer|wider|crop|reframe|recompose|framing|perspective|lens|\d{2,3} ?mm|shot from|low angle|high angle|overhead|top ?down|eye ?level)\b/i
|
|
5636
|
+
],
|
|
5637
|
+
["mood", /\b(mood|vibe|feel|editorial|cinematic|dramatic|moody|minimal|luxurious|playful|clinical)\b/i],
|
|
5638
|
+
[
|
|
5639
|
+
"comparative",
|
|
5640
|
+
/\b(warmer|cooler|brighter|darker|softer|harder|punchier|richer|flatter|sharper|moodier|more|less)\b/i
|
|
5641
|
+
],
|
|
5642
|
+
[
|
|
5643
|
+
"restage",
|
|
5644
|
+
/\b(regenerate|redo|re-?do|start over|another take|different (take|composition|version)|try again|new (version|take))\b/i
|
|
5645
|
+
],
|
|
5646
|
+
["whole", /\b(overall|whole (image|frame|shot|thing)|entire|everything|all of it|throughout)\b/i],
|
|
5647
|
+
["wardrobe", /\b(outfit|wardrobe|clothes|clothing|dress(ed)?|styling)\b/i],
|
|
5648
|
+
["pose", /\b(pose|posture|expression|smile|smiling|looking)\b/i]
|
|
5649
|
+
];
|
|
5650
|
+
var LOCAL_VERB = /\b(add|remove|delete|erase|take out|get rid of|replace|swap|clean up|fix|repair|straighten|hide|cover)\b/i;
|
|
5651
|
+
var DEFINITE_OBJECT = /\b(the|that|this|his|her|their|its|a|an|one)\b/i;
|
|
5652
|
+
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;
|
|
5653
|
+
var COORDINATION = /\b(and|then|also|plus)\b|[;]/i;
|
|
5654
|
+
var MAX_LOCAL_WORDS = 16;
|
|
5655
|
+
function scopeOfInstruction(text) {
|
|
5656
|
+
const s = String(text ?? "").trim();
|
|
5657
|
+
if (!s) return { scope: "global", matched: ["empty"] };
|
|
5658
|
+
const globals = GLOBAL_CUES.filter(([, re]) => re.test(s)).map(([name]) => name);
|
|
5659
|
+
if (globals.length) return { scope: "global", matched: globals };
|
|
5660
|
+
const words = s.split(/\s+/).filter(Boolean);
|
|
5661
|
+
if (words.length > MAX_LOCAL_WORDS) return { scope: "global", matched: ["long"] };
|
|
5662
|
+
const clauses = s.split(COORDINATION).filter((c) => c.trim().length > 0);
|
|
5663
|
+
if (clauses.length > 1 && clauses.filter((c) => LOCAL_VERB.test(c)).length > 1) {
|
|
5664
|
+
return { scope: "global", matched: ["multiple"] };
|
|
5665
|
+
}
|
|
5666
|
+
const matched = [];
|
|
5667
|
+
if (REGION_CUE.test(s)) matched.push("region");
|
|
5668
|
+
if (LOCAL_VERB.test(s) && DEFINITE_OBJECT.test(s)) matched.push("verb+object");
|
|
5669
|
+
if (!matched.length) return { scope: "global", matched: ["no local cue"] };
|
|
5670
|
+
return { scope: "local", matched };
|
|
5671
|
+
}
|
|
5672
|
+
|
|
5673
|
+
// src/expandRules.ts
|
|
5674
|
+
var round8 = (n) => Math.max(8, Math.round(n / 8) * 8);
|
|
5675
|
+
function planExpand(source, targetRatio) {
|
|
5676
|
+
if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
|
|
5677
|
+
const current = source.width / source.height;
|
|
5678
|
+
if (Math.abs(current - targetRatio) / targetRatio < 0.01) return null;
|
|
5679
|
+
if (targetRatio > current) {
|
|
5680
|
+
const width = round8(source.height * targetRatio);
|
|
5681
|
+
if (width <= source.width) return null;
|
|
5682
|
+
return {
|
|
5683
|
+
width,
|
|
5684
|
+
height: source.height,
|
|
5685
|
+
left: Math.round((width - source.width) / 2),
|
|
5686
|
+
top: 0,
|
|
5687
|
+
axis: "width"
|
|
5688
|
+
};
|
|
5689
|
+
}
|
|
5690
|
+
const height = round8(source.width / targetRatio);
|
|
5691
|
+
if (height <= source.height) return null;
|
|
5692
|
+
return {
|
|
5693
|
+
width: source.width,
|
|
5694
|
+
height,
|
|
5695
|
+
left: 0,
|
|
5696
|
+
top: Math.round((height - source.height) / 2),
|
|
5697
|
+
axis: "height"
|
|
5698
|
+
};
|
|
5699
|
+
}
|
|
5700
|
+
function expandInstruction(plan, direction) {
|
|
5701
|
+
const where = plan.axis === "width" ? "to the left and right" : "above and below";
|
|
5702
|
+
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()}` : ""}`;
|
|
5703
|
+
}
|
|
5704
|
+
async function expandCanvas(source, plan) {
|
|
5705
|
+
const bed = await sharp6(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
5706
|
+
return sharp6(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
5707
|
+
}
|
|
5708
|
+
async function compositeExpand(engineImage, source, plan) {
|
|
5709
|
+
const meta = await sharp6(engineImage).metadata();
|
|
5710
|
+
const want = plan.width / plan.height;
|
|
5711
|
+
const got = meta.width && meta.height ? meta.width / meta.height : 0;
|
|
5712
|
+
const sameOrientation = got > 0 && got >= 1 === want >= 1;
|
|
5713
|
+
const aligned = sameOrientation;
|
|
5714
|
+
const surround = aligned ? await sharp6(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
|
|
5715
|
+
const image = await sharp6(surround).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
5716
|
+
return { image, aligned };
|
|
5717
|
+
}
|
|
5718
|
+
async function expandCanvasBedOnly(source, plan) {
|
|
5719
|
+
return sharp6(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
5720
|
+
}
|
|
5721
|
+
async function driftDiff(a, b) {
|
|
5722
|
+
const metaA = await sharp6(a).metadata();
|
|
5723
|
+
const metaB = await sharp6(b).metadata();
|
|
5724
|
+
const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
|
|
5725
|
+
const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
|
|
5726
|
+
const [rawA, rawB] = await Promise.all(
|
|
5727
|
+
[a, b].map((buf) => sharp6(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
|
|
5728
|
+
);
|
|
5729
|
+
const out = new PNG({ width, height });
|
|
5730
|
+
const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
|
|
5731
|
+
return {
|
|
5732
|
+
score: changed / (width * height),
|
|
5733
|
+
heatmap: PNG.sync.write(out),
|
|
5734
|
+
width,
|
|
5735
|
+
height
|
|
5736
|
+
};
|
|
5737
|
+
}
|
|
5738
|
+
async function changeMask(a, b, cap2 = 1024) {
|
|
5739
|
+
const metaA = await sharp6(a).metadata();
|
|
5740
|
+
const width = Math.min(metaA.width ?? 1, cap2);
|
|
5741
|
+
const height = Math.min(metaA.height ?? 1, cap2);
|
|
5742
|
+
const [rawA, rawB] = await Promise.all(
|
|
5743
|
+
[a, b].map((buf) => sharp6(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
|
|
5744
|
+
);
|
|
5745
|
+
const out = new PNG({ width, height });
|
|
5746
|
+
pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
|
|
5747
|
+
const mask = Buffer.alloc(width * height);
|
|
5748
|
+
let changed = 0;
|
|
5749
|
+
let minX = width;
|
|
5750
|
+
let minY = height;
|
|
5751
|
+
let maxX = -1;
|
|
5752
|
+
let maxY = -1;
|
|
5753
|
+
for (let i = 0; i < width * height; i++) {
|
|
5754
|
+
if (out.data[i * 4 + 3] > 0) {
|
|
5755
|
+
mask[i] = 255;
|
|
5756
|
+
changed++;
|
|
5757
|
+
const x = i % width;
|
|
5758
|
+
const y = i / width | 0;
|
|
5759
|
+
if (x < minX) minX = x;
|
|
5760
|
+
if (x > maxX) maxX = x;
|
|
5761
|
+
if (y < minY) minY = y;
|
|
5762
|
+
if (y > maxY) maxY = y;
|
|
5763
|
+
}
|
|
5764
|
+
}
|
|
5765
|
+
const boxArea = maxX < 0 ? 0 : (maxX - minX + 1) * (maxY - minY + 1);
|
|
5766
|
+
return {
|
|
5767
|
+
mask,
|
|
5768
|
+
width,
|
|
5769
|
+
height,
|
|
5770
|
+
changed: changed / (width * height),
|
|
5771
|
+
spread: boxArea / (width * height)
|
|
5772
|
+
};
|
|
5773
|
+
}
|
|
5774
|
+
|
|
5775
|
+
// src/localEditRules.ts
|
|
5776
|
+
var MIN_CHANGED = 5e-4;
|
|
5777
|
+
var MAX_CHANGED = 0.25;
|
|
5778
|
+
var MAX_SPREAD = 0.85;
|
|
5779
|
+
function judgeChange(shape) {
|
|
5780
|
+
if (!(shape.changed > 0) || shape.changed < MIN_CHANGED) return "no-change";
|
|
5781
|
+
if (shape.changed > MAX_CHANGED) return "too-much-changed";
|
|
5782
|
+
if (shape.spread > MAX_SPREAD && shape.changed < 0.2) return "scattered";
|
|
5783
|
+
return "composited";
|
|
5784
|
+
}
|
|
5785
|
+
function dilationFor(longEdge) {
|
|
5786
|
+
return Math.max(6, Math.round(longEdge * 0.02));
|
|
5787
|
+
}
|
|
5788
|
+
|
|
5789
|
+
// src/localEdit.ts
|
|
5790
|
+
async function preserveOutsideChange(source, edited) {
|
|
5791
|
+
try {
|
|
5792
|
+
const srcMeta = await sharp6(source).metadata();
|
|
5793
|
+
const outMeta = await sharp6(edited).metadata();
|
|
5794
|
+
if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
|
|
5795
|
+
return { image: edited, outcome: "error", changed: 0 };
|
|
5796
|
+
const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
|
|
5797
|
+
if (!sameShape) return { image: edited, outcome: "shape-changed", changed: 0 };
|
|
5798
|
+
const shape = await changeMask(source, edited);
|
|
5799
|
+
const outcome = judgeChange(shape);
|
|
5800
|
+
if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
|
|
5801
|
+
const r = dilationFor(Math.max(shape.width, shape.height));
|
|
5802
|
+
const grown = await sharp6(shape.mask, { raw: { width: shape.width, height: shape.height, channels: 1 } }).blur(Math.max(1, r / 3)).threshold(1).blur(Math.max(2, r / 3)).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
|
|
5803
|
+
const editedRgb = await sharp6(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
|
|
5804
|
+
const masked = await sharp6(editedRgb, {
|
|
5805
|
+
raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
|
|
5806
|
+
}).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
|
|
5807
|
+
const image = await sharp6(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
|
|
5808
|
+
return { image, outcome: "composited", changed: shape.changed };
|
|
5809
|
+
} catch {
|
|
5810
|
+
return { image: edited, outcome: "error", changed: 0 };
|
|
5811
|
+
}
|
|
5812
|
+
}
|
|
5553
5813
|
function joinNames(labels) {
|
|
5554
5814
|
const uniq = [...new Set(labels)];
|
|
5555
5815
|
if (uniq.length <= 1) return uniq[0] ?? "";
|
|
@@ -5573,7 +5833,7 @@ var assetHash2 = (ref) => {
|
|
|
5573
5833
|
};
|
|
5574
5834
|
var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
|
|
5575
5835
|
var LOGO_BACKGROUNDS = ["light", "dark", "any"];
|
|
5576
|
-
var toPng = (buf) =>
|
|
5836
|
+
var toPng = (buf) => sharp6(buf).rotate().png().toBuffer();
|
|
5577
5837
|
var COST_PROBE = {
|
|
5578
5838
|
prompt: "",
|
|
5579
5839
|
brand: { brand: {}, assetPaths: {} },
|
|
@@ -5582,7 +5842,7 @@ var COST_PROBE = {
|
|
|
5582
5842
|
count: 1
|
|
5583
5843
|
};
|
|
5584
5844
|
var MARK_MAX_EDGE = 2048;
|
|
5585
|
-
var toMarkPng = (buf) =>
|
|
5845
|
+
var toMarkPng = (buf) => sharp6(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
5586
5846
|
var readImagePart = async (core, req, normalize2) => {
|
|
5587
5847
|
const part = await req.file();
|
|
5588
5848
|
if (!part) return { error: "multipart file field required" };
|
|
@@ -5759,7 +6019,7 @@ async function vibrantColor(input) {
|
|
|
5759
6019
|
let data;
|
|
5760
6020
|
let channels;
|
|
5761
6021
|
try {
|
|
5762
|
-
const out = await
|
|
6022
|
+
const out = await sharp6(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
|
|
5763
6023
|
data = out.data;
|
|
5764
6024
|
channels = out.info.channels;
|
|
5765
6025
|
} catch {
|
|
@@ -5782,7 +6042,7 @@ async function vibrantColor(input) {
|
|
|
5782
6042
|
const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
|
|
5783
6043
|
if (best.score <= 0) {
|
|
5784
6044
|
try {
|
|
5785
|
-
const { dominant } = await
|
|
6045
|
+
const { dominant } = await sharp6(input).stats();
|
|
5786
6046
|
return toHex(dominant.r, dominant.g, dominant.b);
|
|
5787
6047
|
} catch {
|
|
5788
6048
|
return null;
|
|
@@ -6314,23 +6574,6 @@ function registerCodexSetupRoutes(app, deps) {
|
|
|
6314
6574
|
}
|
|
6315
6575
|
});
|
|
6316
6576
|
}
|
|
6317
|
-
async function driftDiff(a, b) {
|
|
6318
|
-
const metaA = await sharp5(a).metadata();
|
|
6319
|
-
const metaB = await sharp5(b).metadata();
|
|
6320
|
-
const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
|
|
6321
|
-
const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
|
|
6322
|
-
const [rawA, rawB] = await Promise.all(
|
|
6323
|
-
[a, b].map((buf) => sharp5(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
|
|
6324
|
-
);
|
|
6325
|
-
const out = new PNG({ width, height });
|
|
6326
|
-
const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
|
|
6327
|
-
return {
|
|
6328
|
-
score: changed / (width * height),
|
|
6329
|
-
heatmap: PNG.sync.write(out),
|
|
6330
|
-
width,
|
|
6331
|
-
height
|
|
6332
|
-
};
|
|
6333
|
-
}
|
|
6334
6577
|
var EXPORT_PRESETS = [
|
|
6335
6578
|
{ id: "original", label: "Original", width: null, height: null },
|
|
6336
6579
|
{ id: "ig-post", label: "Instagram post 1080\xD71080", width: 1080, height: 1080 },
|
|
@@ -6342,7 +6585,7 @@ async function buildExportZip(image, baseName, presetIds) {
|
|
|
6342
6585
|
const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
|
|
6343
6586
|
if (chosen.length === 0) throw new Error("No valid export presets selected");
|
|
6344
6587
|
for (const p of chosen) {
|
|
6345
|
-
const buf = p.width && p.height ? await
|
|
6588
|
+
const buf = p.width && p.height ? await sharp6(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
|
|
6346
6589
|
zip.file(`${baseName}-${p.id}.png`, buf);
|
|
6347
6590
|
}
|
|
6348
6591
|
return zip.generateAsync({ type: "nodebuffer" });
|
|
@@ -6503,7 +6746,7 @@ function registerImageRoutes(app, deps) {
|
|
|
6503
6746
|
if (!part) return reply.status(400).send({ error: "multipart file field required" });
|
|
6504
6747
|
const buf = await part.toBuffer();
|
|
6505
6748
|
if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
|
|
6506
|
-
const png = await
|
|
6749
|
+
const png = await sharp6(buf).rotate().png().toBuffer();
|
|
6507
6750
|
return { hash: core.images.save(png) };
|
|
6508
6751
|
});
|
|
6509
6752
|
app.post("/api/diff", async (req, reply) => {
|
|
@@ -6538,6 +6781,39 @@ function registerImageRoutes(app, deps) {
|
|
|
6538
6781
|
|
|
6539
6782
|
// src/release/notes.data.ts
|
|
6540
6783
|
var RELEASES = [
|
|
6784
|
+
{
|
|
6785
|
+
version: "0.4.0",
|
|
6786
|
+
date: "2026-08-23",
|
|
6787
|
+
title: "Refining a shot keeps the shot.",
|
|
6788
|
+
sections: [
|
|
6789
|
+
{
|
|
6790
|
+
heading: "Refining",
|
|
6791
|
+
body: "Asking for one change now makes one change. Adding a prop or removing an object keeps the rest of the photograph exactly as it was, down to the pixel, instead of returning a fresh interpretation of the same idea. A refinement also carries the product and the presenter it started from, so identity holds through a thread of edits, and a request that genuinely affects the whole frame, like new lighting or a different time of day, is still free to change it."
|
|
6792
|
+
},
|
|
6793
|
+
{
|
|
6794
|
+
heading: "Expand",
|
|
6795
|
+
body: "A finished shot can be grown into another shape. Choosing a new aspect ratio while refining extends the picture you have and generates only the new margin, so the original is kept at its own resolution rather than being replaced by a different take. Nothing is ever cropped to fit."
|
|
6796
|
+
},
|
|
6797
|
+
{
|
|
6798
|
+
heading: "Presenters",
|
|
6799
|
+
body: "The plain studio layers a presenter is photographed in no longer turn up as the outfit in a finished shot. Where the direction names no wardrobe, they are dressed for the place and the occasion in the frame."
|
|
6800
|
+
},
|
|
6801
|
+
{
|
|
6802
|
+
heading: "Fixes",
|
|
6803
|
+
body: "The row of takes under a shot no longer stretches portrait and landscape images into squares. A shot card states what it is in one row, so set names no longer print over the version count and the Refine button, and the keeper star can now be used to keep a shot rather than only to un-keep one. Photos uploaded from a phone are stored the right way up. A run that loses one variant keeps the others instead of throwing all of them away, and the resolution setting no longer promises pixel counts on an engine that renders at its own size."
|
|
6804
|
+
}
|
|
6805
|
+
]
|
|
6806
|
+
},
|
|
6807
|
+
{
|
|
6808
|
+
version: "0.3.5",
|
|
6809
|
+
date: "2026-08-21",
|
|
6810
|
+
sections: [
|
|
6811
|
+
{
|
|
6812
|
+
heading: "Fixes",
|
|
6813
|
+
body: "Setting up the Codex engine on Windows no longer reports a successful install as missing, and updates can now find npm there. When npm truly is unreachable, the update command points to the one-line recovery instead of a dead end."
|
|
6814
|
+
}
|
|
6815
|
+
]
|
|
6816
|
+
},
|
|
6541
6817
|
{
|
|
6542
6818
|
version: "0.3.4",
|
|
6543
6819
|
date: "2026-08-21",
|
|
@@ -6895,7 +7171,7 @@ function registerSystemRoutes(app, deps) {
|
|
|
6895
7171
|
}
|
|
6896
7172
|
core.close();
|
|
6897
7173
|
for (const name of ["scenri.db", "scenri.db-wal", "scenri.db-shm", "images", "backups"]) {
|
|
6898
|
-
rmSync(join(core.home, name), { recursive: true, force: true });
|
|
7174
|
+
rmSync(join(core.home, name), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
6899
7175
|
}
|
|
6900
7176
|
return { ok: true, scope };
|
|
6901
7177
|
});
|
|
@@ -7215,14 +7491,14 @@ function buildServer(opts) {
|
|
|
7215
7491
|
const out = [];
|
|
7216
7492
|
for (const h of images) {
|
|
7217
7493
|
const buf = core.images.read(h);
|
|
7218
|
-
out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await
|
|
7494
|
+
out.push(buf.subarray(0, 8).equals(PNG_SIG) ? h : core.images.save(await sharp6(buf).png().toBuffer()));
|
|
7219
7495
|
}
|
|
7220
7496
|
return out;
|
|
7221
7497
|
}
|
|
7222
7498
|
async function assertAspect(images, expect) {
|
|
7223
7499
|
const want = expect.width / expect.height;
|
|
7224
7500
|
for (const h of images) {
|
|
7225
|
-
const meta2 = await
|
|
7501
|
+
const meta2 = await sharp6(core.images.read(h)).metadata();
|
|
7226
7502
|
if (!meta2.width || !meta2.height) continue;
|
|
7227
7503
|
const got = meta2.width / meta2.height;
|
|
7228
7504
|
if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
|
|
@@ -7231,7 +7507,7 @@ function buildServer(opts) {
|
|
|
7231
7507
|
);
|
|
7232
7508
|
}
|
|
7233
7509
|
}
|
|
7234
|
-
async function runNode(nodeId, engine, estimate, work, expect) {
|
|
7510
|
+
async function runNode(nodeId, engine, estimate, work, expect, post) {
|
|
7235
7511
|
const engineId = engine.capabilities().id;
|
|
7236
7512
|
reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
|
|
7237
7513
|
const ctrl = new AbortController();
|
|
@@ -7239,6 +7515,7 @@ function buildServer(opts) {
|
|
|
7239
7515
|
try {
|
|
7240
7516
|
const result = await work(ctrl.signal);
|
|
7241
7517
|
result.images = await normalizePngs(result.images);
|
|
7518
|
+
if (post) result.images = await post(result.images);
|
|
7242
7519
|
if (expect) await assertAspect(result.images, expect);
|
|
7243
7520
|
core.store.completeNode(nodeId, result);
|
|
7244
7521
|
core.ledger.recordCost(engineId, nodeId, result.costUsd);
|
|
@@ -7279,6 +7556,12 @@ function buildServer(opts) {
|
|
|
7279
7556
|
const resolvedParentId = parentId ? String(parentId) : rootNode.id;
|
|
7280
7557
|
const ctx = brandContext(core, project.brandId);
|
|
7281
7558
|
let compiled2 = null;
|
|
7559
|
+
let inheritedTokens = [];
|
|
7560
|
+
let inheritedAttachments = [];
|
|
7561
|
+
let editScope = "global";
|
|
7562
|
+
const extraWarnings = [];
|
|
7563
|
+
let expandPlan = null;
|
|
7564
|
+
let expandSourceHash = null;
|
|
7282
7565
|
if (brief && Array.isArray(brief.tokens)) {
|
|
7283
7566
|
const briefErrors = validateBrief(brief);
|
|
7284
7567
|
if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
|
|
@@ -7296,14 +7579,34 @@ function buildServer(opts) {
|
|
|
7296
7579
|
brief.tokens
|
|
7297
7580
|
);
|
|
7298
7581
|
const sceneById = sceneFor(brandJson);
|
|
7582
|
+
if (kind === "edit") {
|
|
7583
|
+
const borrowed = inheritedIdentityTokens(resolvedParentId, (id) => core.store.getNode(id));
|
|
7584
|
+
if (borrowed.length) {
|
|
7585
|
+
const already = new Set(
|
|
7586
|
+
brief.tokens.filter((t) => t.t === "product" || t.t === "character" || t.t === "mark").map((t) => JSON.stringify(t))
|
|
7587
|
+
);
|
|
7588
|
+
inheritedTokens = borrowed.filter((t) => !already.has(JSON.stringify(t)));
|
|
7589
|
+
}
|
|
7590
|
+
editScope = scopeOfInstruction(
|
|
7591
|
+
brief.tokens.filter((t) => t.t === "text").map((t) => t.v).join(" ")
|
|
7592
|
+
).scope;
|
|
7593
|
+
}
|
|
7299
7594
|
compiled2 = compileBrief(brief, {
|
|
7300
7595
|
brand: brandJson,
|
|
7301
7596
|
images: core.images,
|
|
7302
7597
|
engineCaps: engine.capabilities(),
|
|
7303
7598
|
template: brief.templateId ? sceneById(String(brief.templateId)) : void 0,
|
|
7304
|
-
templateById: sceneById
|
|
7599
|
+
templateById: sceneById,
|
|
7600
|
+
...kind === "edit" ? { mode: "edit", editScope, inheritedIdentity: inheritedTokens.length > 0 } : {}
|
|
7305
7601
|
});
|
|
7306
7602
|
if (!compiled2.prompt.trim()) return reply.status(400).send({ error: "the brief is empty" });
|
|
7603
|
+
if (inheritedTokens.length) {
|
|
7604
|
+
const identity = compileBrief(
|
|
7605
|
+
{ tokens: inheritedTokens },
|
|
7606
|
+
{ brand: brandJson, images: core.images, engineCaps: engine.capabilities(), templateById: sceneById }
|
|
7607
|
+
);
|
|
7608
|
+
inheritedAttachments = identity.attachments.filter((a) => a.essential);
|
|
7609
|
+
}
|
|
7307
7610
|
}
|
|
7308
7611
|
let finalPrompt = String(prompt ?? "");
|
|
7309
7612
|
let referenceImages;
|
|
@@ -7383,13 +7686,31 @@ function buildServer(opts) {
|
|
|
7383
7686
|
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
7384
7687
|
if (!engine.capabilities().supportsEdit)
|
|
7385
7688
|
return reply.status(400).send({ error: "engine does not support edits" });
|
|
7386
|
-
const cap2 = engine.capabilities().maxReferenceImages;
|
|
7689
|
+
const cap2 = Math.max(0, engine.capabilities().maxReferenceImages - 1);
|
|
7690
|
+
const own = (referenceImages ?? []).map((path, i) => ({ path, role: referenceRoles?.[i] }));
|
|
7691
|
+
const borrowedRefs = inheritedAttachments.map((a) => ({ path: core.images.pathFor(a.hash), role: a.role })).filter((r) => !own.some((o) => o.path === r.path));
|
|
7692
|
+
const editRefs = [...own, ...borrowedRefs].slice(0, cap2);
|
|
7693
|
+
if (cap2 === 0 && borrowedRefs.length)
|
|
7694
|
+
extraWarnings.push(
|
|
7695
|
+
`${engine.capabilities().displayName} cannot carry reference images, so the identity rides on the source frame alone.`
|
|
7696
|
+
);
|
|
7697
|
+
const srcBuf = core.images.read(String(srcHash));
|
|
7698
|
+
const srcMeta = await sharp6(srcBuf).metadata();
|
|
7699
|
+
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
7700
|
+
if (srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
|
|
7701
|
+
expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
|
|
7702
|
+
}
|
|
7703
|
+
if (expandPlan) {
|
|
7704
|
+
const canvas = await expandCanvas(srcBuf, expandPlan);
|
|
7705
|
+
expandSourceHash = core.images.save(canvas);
|
|
7706
|
+
expectShape = { width: expandPlan.width, height: expandPlan.height };
|
|
7707
|
+
}
|
|
7387
7708
|
const editReq = {
|
|
7388
|
-
instruction: finalPrompt,
|
|
7389
|
-
sourceImage: core.images.pathFor(String(srcHash)),
|
|
7709
|
+
instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
|
|
7710
|
+
sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
|
|
7390
7711
|
brand: ctx,
|
|
7391
|
-
...
|
|
7392
|
-
...
|
|
7712
|
+
...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
|
|
7713
|
+
...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {}
|
|
7393
7714
|
};
|
|
7394
7715
|
estimate = await engine.costEstimate(editReq);
|
|
7395
7716
|
work = (signal) => engine.edit(editReq, signal);
|
|
@@ -7403,10 +7724,31 @@ function buildServer(opts) {
|
|
|
7403
7724
|
engineId: String(engineId)
|
|
7404
7725
|
});
|
|
7405
7726
|
if (brief) core.store.setBrief(node.id, editedFrom ? { ...brief, sourceImage: editedFrom } : brief);
|
|
7406
|
-
|
|
7727
|
+
const plan = expandPlan;
|
|
7728
|
+
const original = editedFrom ? core.images.read(editedFrom) : null;
|
|
7729
|
+
const localScope = kind === "edit" && !plan && editScope === "local" && original;
|
|
7730
|
+
const post = plan ? async (images) => {
|
|
7731
|
+
const out = [];
|
|
7732
|
+
for (const h of images) {
|
|
7733
|
+
const { image, aligned } = await compositeExpand(core.images.read(h), original, plan);
|
|
7734
|
+
if (!aligned) app.log.warn({ nodeId: node.id }, "expand: engine frame did not align, kept the bed");
|
|
7735
|
+
out.push(core.images.save(image));
|
|
7736
|
+
}
|
|
7737
|
+
return out;
|
|
7738
|
+
} : localScope ? async (images) => {
|
|
7739
|
+
const out = [];
|
|
7740
|
+
for (const h of images) {
|
|
7741
|
+
const { image, outcome, changed } = await preserveOutsideChange(original, core.images.read(h));
|
|
7742
|
+
app.log.info({ nodeId: node.id, outcome, changed }, "local edit");
|
|
7743
|
+
out.push(outcome === "composited" ? core.images.save(image) : h);
|
|
7744
|
+
}
|
|
7745
|
+
return out;
|
|
7746
|
+
} : void 0;
|
|
7747
|
+
void runNode(node.id, engine, estimate, work, expectShape, post).catch(
|
|
7407
7748
|
(err) => app.log.error({ err }, "node run failed")
|
|
7408
7749
|
);
|
|
7409
|
-
|
|
7750
|
+
const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
|
|
7751
|
+
return reply.status(202).send(allWarnings.length ? { ...node, warnings: allWarnings } : node);
|
|
7410
7752
|
});
|
|
7411
7753
|
app.post("/api/nodes/:id/cancel", async (req, reply) => {
|
|
7412
7754
|
const id = req.params.id;
|
|
@@ -7662,8 +8004,8 @@ async function verify() {
|
|
|
7662
8004
|
const db = new Database2(":memory:");
|
|
7663
8005
|
db.pragma("user_version");
|
|
7664
8006
|
db.close();
|
|
7665
|
-
const { default:
|
|
7666
|
-
await
|
|
8007
|
+
const { default: sharp14 } = await import('sharp');
|
|
8008
|
+
await sharp14({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
|
|
7667
8009
|
console.log(JSON.stringify({ ok: true, version: readMeta().version }));
|
|
7668
8010
|
} catch (err) {
|
|
7669
8011
|
console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
|