scenri 0.6.13 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/serve.js +665 -179
- package/package.json +1 -1
package/dist/serve.js
CHANGED
|
@@ -10,7 +10,7 @@ import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, write
|
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
11
|
import { readFile, copyFile, stat, mkdtemp, rm, readdir, writeFile } from 'fs/promises';
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
|
-
import
|
|
13
|
+
import sharp20 from 'sharp';
|
|
14
14
|
import Fastify from 'fastify';
|
|
15
15
|
import fastifyStatic from '@fastify/static';
|
|
16
16
|
import fastifyMultipart from '@fastify/multipart';
|
|
@@ -1295,7 +1295,13 @@ var REFERENCE_ROLE_DIRECTIVE = {
|
|
|
1295
1295
|
// names staged objects as stand-ins rather than just "no product": a bare
|
|
1296
1296
|
// prohibition still left the demo object in the frame, because the model had
|
|
1297
1297
|
// nowhere to put what the photograph so vividly showed.
|
|
1298
|
-
|
|
1298
|
+
//
|
|
1299
|
+
// The carve-out leads. It used to sit forty words in, one subordinate
|
|
1300
|
+
// clause after a paragraph of "match this" - and the tester case (a close
|
|
1301
|
+
// portrait as the scene image beside a selected presenter) showed which
|
|
1302
|
+
// half the model heard. Every treatment clause is retained word for word;
|
|
1303
|
+
// only the order and the register of the identity refusal changed.
|
|
1304
|
+
scene: "a reference for this world, never for a person: take no identity from the person in it \u2014 not their face, not their likeness \u2014 they are an anonymous stand-in whose place the attached subject takes. Match the environment, the light, and the material, density, scale, finish and spread of the treatment applied to the figure, including which parts of the form it covers and how far it reaches; treat any product, garment or prop staged in it as the same kind of stand-in, demonstrating placement and scale \u2014 never an object to reproduce",
|
|
1299
1305
|
composition: "a reference for framing, camera angle and pose only \u2014 take no subject, color, material or branding from it",
|
|
1300
1306
|
style: "a reference for overall treatment and mood only \u2014 take no composition, subject or product detail from it",
|
|
1301
1307
|
reference: "a reference to match in composition, lighting and treatment"
|
|
@@ -1304,11 +1310,12 @@ var EDIT_REFERENCE_ROLE_DIRECTIVE = {
|
|
|
1304
1310
|
product: "the exact product: keep or restore its label, shape and design faithfully",
|
|
1305
1311
|
character: "the exact person: keep their face, facial structure, skin, hair and build faithfully; take no clothing, pose or background from this reference, and keep the source image's existing outfit unless the instruction changes it",
|
|
1306
1312
|
brand: "the brand's own mark: reproduce it exactly as drawn wherever it appears \u2014 every character down to the smallest secondary lettering, in its original script and reading direction \u2014 never redrawn, re-lettered, translated or transliterated",
|
|
1307
|
-
scene: "a reference for environment and
|
|
1313
|
+
scene: "a reference for environment, light and treatment only \u2014 take no identity from any person in it",
|
|
1308
1314
|
composition: "a reference for framing and pose only",
|
|
1309
1315
|
style: "a reference for treatment and mood only",
|
|
1310
1316
|
reference: "a reference for composition, lighting and treatment only"
|
|
1311
1317
|
};
|
|
1318
|
+
var BUDGET_EXHAUSTED = "scenri:budget-exhausted";
|
|
1312
1319
|
var ASPECT_TOLERANCE = 0.15;
|
|
1313
1320
|
var NAMED_RATIOS = [
|
|
1314
1321
|
["1:1", 1],
|
|
@@ -1332,6 +1339,14 @@ function ratioLabel(width, height) {
|
|
|
1332
1339
|
const d = gcd(width, height) || 1;
|
|
1333
1340
|
return `${Math.round(width / d)}:${Math.round(height / d)}`;
|
|
1334
1341
|
}
|
|
1342
|
+
function budgetSize(width, height, pixelBudget) {
|
|
1343
|
+
const ratio = width / height;
|
|
1344
|
+
if (!(ratio > 0) || !Number.isFinite(ratio) || !(pixelBudget > 0)) return { width, height };
|
|
1345
|
+
return {
|
|
1346
|
+
width: Math.round(Math.sqrt(pixelBudget * ratio)),
|
|
1347
|
+
height: Math.round(Math.sqrt(pixelBudget / ratio))
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1335
1350
|
|
|
1336
1351
|
// ../core/src/index.ts
|
|
1337
1352
|
function defaultHome() {
|
|
@@ -1349,6 +1364,7 @@ function createCore(homeDir = defaultHome()) {
|
|
|
1349
1364
|
};
|
|
1350
1365
|
}
|
|
1351
1366
|
var ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
|
|
1367
|
+
var PER_IMAGE_TIMEOUT_MS = 3e5;
|
|
1352
1368
|
var DEFAULT_MODEL = "google/gemini-2.5-flash-image";
|
|
1353
1369
|
var DEFAULT_COST_PER_IMAGE_USD = 0.04;
|
|
1354
1370
|
function dataUrl(path) {
|
|
@@ -1387,6 +1403,7 @@ function createOpenRouterEngine(opts) {
|
|
|
1387
1403
|
return key;
|
|
1388
1404
|
}
|
|
1389
1405
|
async function post(key, body, signal) {
|
|
1406
|
+
const bound = AbortSignal.timeout(PER_IMAGE_TIMEOUT_MS);
|
|
1390
1407
|
const res = await fetchImpl(ENDPOINT, {
|
|
1391
1408
|
method: "POST",
|
|
1392
1409
|
headers: {
|
|
@@ -1394,7 +1411,7 @@ function createOpenRouterEngine(opts) {
|
|
|
1394
1411
|
"Content-Type": "application/json"
|
|
1395
1412
|
},
|
|
1396
1413
|
body: JSON.stringify(body),
|
|
1397
|
-
signal
|
|
1414
|
+
signal: signal ? AbortSignal.any([signal, bound]) : bound
|
|
1398
1415
|
});
|
|
1399
1416
|
const text = await res.text();
|
|
1400
1417
|
if (!res.ok) {
|
|
@@ -1437,7 +1454,11 @@ function createOpenRouterEngine(opts) {
|
|
|
1437
1454
|
localOnly: false,
|
|
1438
1455
|
supportsEdit: true,
|
|
1439
1456
|
supportsMask: false,
|
|
1440
|
-
maxReferenceImages: 4
|
|
1457
|
+
maxReferenceImages: 4,
|
|
1458
|
+
// N sequential calls, one image each: the server budgets the node by
|
|
1459
|
+
// that shape instead of handing the whole run one flat ten minutes.
|
|
1460
|
+
perImageTimeoutMs: PER_IMAGE_TIMEOUT_MS,
|
|
1461
|
+
imageConcurrency: 1
|
|
1441
1462
|
};
|
|
1442
1463
|
},
|
|
1443
1464
|
async isAvailable() {
|
|
@@ -1458,12 +1479,14 @@ function createOpenRouterEngine(opts) {
|
|
|
1458
1479
|
const role = roles[i] ?? "reference";
|
|
1459
1480
|
return refs.length > 1 ? `Attached image ${i + 1} is ${roleDirective[role]}.` : `The attached image is ${roleDirective[role]}.`;
|
|
1460
1481
|
}).join(" ");
|
|
1482
|
+
const identityCoda = roles.includes("character") && roles.some((r) => r === "scene" || r === "reference") ? "Identity check: the person in this image comes only from the character reference image(s); the scene and reference images lend world, composition, lighting and treatment \u2014 take no face or likeness from them." : null;
|
|
1461
1483
|
const content = [
|
|
1462
1484
|
{ type: "text", text: refDirectives ? `${req.prompt} ${refDirectives}` : req.prompt },
|
|
1463
1485
|
...refs.map((p) => ({
|
|
1464
1486
|
type: "image_url",
|
|
1465
1487
|
image_url: { url: dataUrl(p) }
|
|
1466
|
-
}))
|
|
1488
|
+
})),
|
|
1489
|
+
...identityCoda ? [{ type: "text", text: identityCoda }] : []
|
|
1467
1490
|
];
|
|
1468
1491
|
const body = {
|
|
1469
1492
|
model: opts.model ?? DEFAULT_MODEL,
|
|
@@ -1476,7 +1499,12 @@ function createOpenRouterEngine(opts) {
|
|
|
1476
1499
|
let reportedCost = 0;
|
|
1477
1500
|
let sawReportedCost = false;
|
|
1478
1501
|
for (let i = 0; i < req.count; i++) {
|
|
1479
|
-
const
|
|
1502
|
+
const variation = req.variations?.[i];
|
|
1503
|
+
const json = await post(
|
|
1504
|
+
key,
|
|
1505
|
+
variation ? { ...body, messages: [{ role: "user", content: [...content, { type: "text", text: variation }] }] } : body,
|
|
1506
|
+
signal
|
|
1507
|
+
);
|
|
1480
1508
|
raws.push(json);
|
|
1481
1509
|
for (const buf of extractImages(json)) hashes.push(opts.saveImage(buf));
|
|
1482
1510
|
if (typeof json?.usage?.cost === "number") {
|
|
@@ -2511,20 +2539,12 @@ function createCodexSetup(opts = {}) {
|
|
|
2511
2539
|
|
|
2512
2540
|
// ../engines/codex/src/index.ts
|
|
2513
2541
|
var CODEX_POOL = 2;
|
|
2514
|
-
function codexNodeBudgetMs(count) {
|
|
2515
|
-
return Math.ceil(Math.max(1, count) / CODEX_POOL) * DEFAULT_TIMEOUT_MS2 + 6e4;
|
|
2516
|
-
}
|
|
2517
2542
|
function orientationOf(width, height) {
|
|
2518
2543
|
return width === height ? "square" : width > height ? "landscape" : "portrait";
|
|
2519
2544
|
}
|
|
2520
2545
|
var CODEX_PIXEL_BUDGET = 1572864;
|
|
2521
2546
|
function codexNativeSize(width, height) {
|
|
2522
|
-
|
|
2523
|
-
if (!(ratio > 0) || !Number.isFinite(ratio)) return { width, height };
|
|
2524
|
-
return {
|
|
2525
|
-
width: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET * ratio)),
|
|
2526
|
-
height: Math.round(Math.sqrt(CODEX_PIXEL_BUDGET / ratio))
|
|
2527
|
-
};
|
|
2547
|
+
return budgetSize(width, height, CODEX_PIXEL_BUDGET);
|
|
2528
2548
|
}
|
|
2529
2549
|
function refFileNames(roles, count) {
|
|
2530
2550
|
const perRole = /* @__PURE__ */ new Map();
|
|
@@ -2550,7 +2570,7 @@ function createCodexEngine(opts) {
|
|
|
2550
2570
|
return /* @__PURE__ */ new Set();
|
|
2551
2571
|
}
|
|
2552
2572
|
}
|
|
2553
|
-
async function collectImages(dir, before = null) {
|
|
2573
|
+
async function collectImages(dir, before = null, claimed) {
|
|
2554
2574
|
const entries = await readdir(dir);
|
|
2555
2575
|
const outFiles = entries.filter((name) => /^out-.*\.png$/.test(name)).sort((a, b) => {
|
|
2556
2576
|
const na = Number(/^out-(\d+)\.png$/.exec(a)?.[1] ?? NaN);
|
|
@@ -2560,7 +2580,7 @@ function createCodexEngine(opts) {
|
|
|
2560
2580
|
});
|
|
2561
2581
|
if (outFiles.length === 0) {
|
|
2562
2582
|
if (before) {
|
|
2563
|
-
const recovered = await recoverFromGenerated(before);
|
|
2583
|
+
const recovered = await recoverFromGenerated(before, claimed);
|
|
2564
2584
|
if (recovered) return [recovered];
|
|
2565
2585
|
}
|
|
2566
2586
|
throw new Error("Codex finished but produced no images");
|
|
@@ -2573,11 +2593,11 @@ function createCodexEngine(opts) {
|
|
|
2573
2593
|
}
|
|
2574
2594
|
return hashes;
|
|
2575
2595
|
}
|
|
2576
|
-
async function recoverFromGenerated(before) {
|
|
2596
|
+
async function recoverFromGenerated(before, claimed) {
|
|
2577
2597
|
const home = generatedImagesDir();
|
|
2578
2598
|
let names;
|
|
2579
2599
|
try {
|
|
2580
|
-
names = (await readdir(home)).filter((n) => !before.has(n));
|
|
2600
|
+
names = (await readdir(home)).filter((n) => !before.has(n) && !claimed?.has(n));
|
|
2581
2601
|
} catch {
|
|
2582
2602
|
return null;
|
|
2583
2603
|
}
|
|
@@ -2585,6 +2605,7 @@ function createCodexEngine(opts) {
|
|
|
2585
2605
|
const stamped = await Promise.all(names.map(async (n) => ({ n, mtime: (await stat(join(home, n))).mtimeMs })));
|
|
2586
2606
|
stamped.sort((a, b) => b.mtime - a.mtime);
|
|
2587
2607
|
const pick2 = stamped[0].n;
|
|
2608
|
+
claimed?.add(pick2);
|
|
2588
2609
|
console.warn(`codex: workdir empty, recovered ${pick2} from ${home}`);
|
|
2589
2610
|
return saveImage(await readFile(join(home, pick2)));
|
|
2590
2611
|
}
|
|
@@ -2597,6 +2618,10 @@ function createCodexEngine(opts) {
|
|
|
2597
2618
|
// OSS-local only: the user's own session, on the user's own machine
|
|
2598
2619
|
supportsEdit: true,
|
|
2599
2620
|
supportsMask: false,
|
|
2621
|
+
// The image tool draws at this fixed pixel count (measured, the
|
|
2622
|
+
// native-size probe): an edit of a larger source steps down honestly
|
|
2623
|
+
// instead of being upscaled back into pixels the tool never drew.
|
|
2624
|
+
editPixelBudget: CODEX_PIXEL_BUDGET,
|
|
2600
2625
|
/*
|
|
2601
2626
|
* Five, and it is a hard constraint of the image tool, not a product
|
|
2602
2627
|
* choice.
|
|
@@ -2630,7 +2655,12 @@ function createCodexEngine(opts) {
|
|
|
2630
2655
|
* budget — a full-resolution phone-photo PNG is tens of megabytes that
|
|
2631
2656
|
* buy nothing. Same cap as brand marks (MARK_MAX_EDGE).
|
|
2632
2657
|
*/
|
|
2633
|
-
maxReferenceEdge: 2048
|
|
2658
|
+
maxReferenceEdge: 2048,
|
|
2659
|
+
// One exec per image at CODEX_POOL at a time, each carrying its own
|
|
2660
|
+
// full timer. The server turns these two numbers into the node bound,
|
|
2661
|
+
// which is the same arithmetic codexNodeBudgetMs states above.
|
|
2662
|
+
perImageTimeoutMs: DEFAULT_TIMEOUT_MS2,
|
|
2663
|
+
imageConcurrency: CODEX_POOL
|
|
2634
2664
|
};
|
|
2635
2665
|
},
|
|
2636
2666
|
isAvailable() {
|
|
@@ -2644,8 +2674,9 @@ function createCodexEngine(opts) {
|
|
|
2644
2674
|
const refs = req.referenceImages ?? [];
|
|
2645
2675
|
const roles = req.referenceRoles ?? refs.map(() => "reference");
|
|
2646
2676
|
const inner = new AbortController();
|
|
2647
|
-
const
|
|
2648
|
-
|
|
2677
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
2678
|
+
const onOuterAbort = () => inner.abort(signal?.reason);
|
|
2679
|
+
if (signal?.aborted) inner.abort(signal.reason);
|
|
2649
2680
|
else signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
2650
2681
|
const jobs = Array.from(
|
|
2651
2682
|
{ length: count },
|
|
@@ -2664,7 +2695,7 @@ function createCodexEngine(opts) {
|
|
|
2664
2695
|
stdin: buildPrompt2(req, i, roles),
|
|
2665
2696
|
label: `gen v${i + 1}/${count} refs=${refs.length} refKB=${Math.round(refBytes / 1024)}`
|
|
2666
2697
|
});
|
|
2667
|
-
return collectImages(dir, before);
|
|
2698
|
+
return collectImages(dir, before, claimed);
|
|
2668
2699
|
})
|
|
2669
2700
|
);
|
|
2670
2701
|
const results = new Array(count);
|
|
@@ -2678,7 +2709,7 @@ function createCodexEngine(opts) {
|
|
|
2678
2709
|
try {
|
|
2679
2710
|
results[i] = await jobs[i]();
|
|
2680
2711
|
} catch (err) {
|
|
2681
|
-
if (signal?.aborted) throw err;
|
|
2712
|
+
if (signal?.aborted && signal.reason !== BUDGET_EXHAUSTED) throw err;
|
|
2682
2713
|
results[i] = [];
|
|
2683
2714
|
failures.push(err);
|
|
2684
2715
|
if (fatal == null && isFatalSetupError(err)) {
|
|
@@ -2761,10 +2792,10 @@ function createCodexEngine(opts) {
|
|
|
2761
2792
|
);
|
|
2762
2793
|
}
|
|
2763
2794
|
function buildPrompt2(req, index, roles) {
|
|
2795
|
+
const variation = req.variations?.[index] ?? "";
|
|
2764
2796
|
const roleDirective = REFERENCE_ROLE_DIRECTIVE;
|
|
2765
2797
|
const names = refFileNames(roles, roles.length);
|
|
2766
2798
|
const refDirectives = roles.map((role, i) => `${names[i]} shows ${roleDirective[role]}.`).join(" ");
|
|
2767
|
-
const count = Math.max(1, req.count);
|
|
2768
2799
|
const native = codexNativeSize(req.width, req.height);
|
|
2769
2800
|
return (
|
|
2770
2801
|
// "professional-grade", not "flawless": the audit of the waxy-presenter
|
|
@@ -2778,13 +2809,19 @@ function createCodexEngine(opts) {
|
|
|
2778
2809
|
// to the requested one, and the aspect check passed BECAUSE of the shear
|
|
2779
2810
|
// - the reported crushed faces. Copy/move stays licensed because the
|
|
2780
2811
|
// win32 recovery path moves files out of generated_images.
|
|
2781
|
-
` Do not browse the web or explore files. Save the tool's output in the current directory as out-1.png, byte-for-byte unchanged: you may run the commands needed to copy or move the file, but never resize, scale, stretch, pad, crop or re-encode it \u2014 deliver the tool's own pixels at the tool's own size. Nothing else.` + //
|
|
2782
|
-
//
|
|
2783
|
-
//
|
|
2784
|
-
//
|
|
2785
|
-
//
|
|
2786
|
-
//
|
|
2787
|
-
|
|
2812
|
+
` Do not browse the web or explore files. Save the tool's output in the current directory as out-1.png, byte-for-byte unchanged: you may run the commands needed to copy or move the file, but never resize, scale, stretch, pad, crop or re-encode it \u2014 deliver the tool's own pixels at the tool's own size. Nothing else.` + // The set clause, built once by the server for the whole run and handed
|
|
2813
|
+
// over index-aligned with the output slots. It carries the photographic
|
|
2814
|
+
// move this frame explores and the locks every frame shares.
|
|
2815
|
+
//
|
|
2816
|
+
// What used to be here was a COUNTER — "take 3 of 4". Every take got the
|
|
2817
|
+
// same-shaped sentence, so this was already the hardened version, and the
|
|
2818
|
+
// drift survived it: a rising take number is not neutral text. In shoot
|
|
2819
|
+
// language it reads as "we have already done that, go further", a licence
|
|
2820
|
+
// to deviate that grows with the output index, which is precisely the
|
|
2821
|
+
// reported shape (output 1 holds the presenter, 2 and 3 and 4 drift). No
|
|
2822
|
+
// frame is described in terms of any other frame now, and no number
|
|
2823
|
+
// reaches the model at all.
|
|
2824
|
+
(variation ? ` ${variation}` : "")
|
|
2788
2825
|
);
|
|
2789
2826
|
}
|
|
2790
2827
|
}
|
|
@@ -2831,7 +2868,7 @@ function createDemoEngine(saveImage) {
|
|
|
2831
2868
|
<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>
|
|
2832
2869
|
<text x="24" y="${h - 22}" font-family="Helvetica, Arial" font-size="12" fill="#ffffff" opacity="0.6">Scenri demo engine</text>
|
|
2833
2870
|
</svg>`;
|
|
2834
|
-
return
|
|
2871
|
+
return sharp20(Buffer.from(svg)).png().toBuffer();
|
|
2835
2872
|
}
|
|
2836
2873
|
return {
|
|
2837
2874
|
capabilities() {
|
|
@@ -3000,14 +3037,22 @@ function presenterRefPath(templatesRoot, id, slot) {
|
|
|
3000
3037
|
function presenterAvatarPath(templatesRoot, id) {
|
|
3001
3038
|
return contentFile(templatesRoot, "previews", "presenters", id, "avatar.jpg");
|
|
3002
3039
|
}
|
|
3040
|
+
var resolvedRefs = /* @__PURE__ */ new Map();
|
|
3041
|
+
async function refHash(core, path) {
|
|
3042
|
+
const hit = resolvedRefs.get(path);
|
|
3043
|
+
if (hit && core.images.has(hit)) return hit;
|
|
3044
|
+
const hash = core.images.save(await sharp20(readFileSync(path)).png().toBuffer());
|
|
3045
|
+
resolvedRefs.set(path, hash);
|
|
3046
|
+
return hash;
|
|
3047
|
+
}
|
|
3003
3048
|
async function resolvePresenterImages(core, templatesRoot, presenter) {
|
|
3004
3049
|
const shots = [];
|
|
3050
|
+
const avatar = presenterAvatarPath(templatesRoot, presenter.id);
|
|
3051
|
+
if (existsSync(avatar)) shots.push({ file: `asset:${await refHash(core, avatar)}`, angle: "portrait", locked: true });
|
|
3005
3052
|
for (const [slot, angle] of PRESENTER_ANGLES) {
|
|
3006
3053
|
const path = presenterRefPath(templatesRoot, presenter.id, slot);
|
|
3007
3054
|
if (!existsSync(path)) continue;
|
|
3008
|
-
|
|
3009
|
-
const hash = core.images.save(png);
|
|
3010
|
-
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
3055
|
+
shots.push({ file: `asset:${await refHash(core, path)}`, angle, locked: true });
|
|
3011
3056
|
}
|
|
3012
3057
|
if (!shots.length) return null;
|
|
3013
3058
|
return {
|
|
@@ -3016,6 +3061,8 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
|
|
|
3016
3061
|
...presenter.identityNotes ? { identityNotes: presenter.identityNotes } : {},
|
|
3017
3062
|
...presenter.negativeConstraints?.length ? { negativeConstraints: presenter.negativeConstraints } : {},
|
|
3018
3063
|
...presenter.skin ? { skin: presenter.skin } : {},
|
|
3064
|
+
...presenter.facial ? { facial: presenter.facial } : {},
|
|
3065
|
+
...presenter.build ? { build: presenter.build } : {},
|
|
3019
3066
|
shots
|
|
3020
3067
|
};
|
|
3021
3068
|
}
|
|
@@ -3100,7 +3147,7 @@ async function resolveDemoProductImages(core, templatesRoot, product) {
|
|
|
3100
3147
|
for (const angle of angles) {
|
|
3101
3148
|
const path = demoProductRefPath(templatesRoot, product.id, angle);
|
|
3102
3149
|
if (!existsSync(path)) continue;
|
|
3103
|
-
const png = await
|
|
3150
|
+
const png = await sharp20(readFileSync(path)).png().toBuffer();
|
|
3104
3151
|
const hash = core.images.save(png);
|
|
3105
3152
|
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
3106
3153
|
}
|
|
@@ -3239,7 +3286,7 @@ var assetHash = (ref) => {
|
|
|
3239
3286
|
};
|
|
3240
3287
|
var LOGO_ROLES = ["primary", "mark", "wordmark", "monochrome", "alternate"];
|
|
3241
3288
|
var LOGO_BACKGROUNDS = ["light", "dark", "any"];
|
|
3242
|
-
var toPng = (buf) =>
|
|
3289
|
+
var toPng = (buf) => sharp20(buf).rotate().png().toBuffer();
|
|
3243
3290
|
var COST_PROBE = {
|
|
3244
3291
|
prompt: "",
|
|
3245
3292
|
brand: { brand: {}, assetPaths: {} },
|
|
@@ -3252,11 +3299,11 @@ var MARK_MIN_EDGE = 1024;
|
|
|
3252
3299
|
var MARK_TINY_EDGE = 256;
|
|
3253
3300
|
var MARK_WARN_EDGE = 512;
|
|
3254
3301
|
var toMarkPng = async (buf) => {
|
|
3255
|
-
const out = await
|
|
3256
|
-
const meta = await
|
|
3302
|
+
const out = await sharp20(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
3303
|
+
const meta = await sharp20(out).metadata();
|
|
3257
3304
|
const edge = Math.max(meta.width ?? 0, meta.height ?? 0);
|
|
3258
3305
|
if (edge >= MARK_TINY_EDGE && edge < MARK_MIN_EDGE) {
|
|
3259
|
-
return
|
|
3306
|
+
return sharp20(out).resize({ width: MARK_MIN_EDGE, height: MARK_MIN_EDGE, fit: "inside", kernel: "lanczos3" }).png().toBuffer();
|
|
3260
3307
|
}
|
|
3261
3308
|
return out;
|
|
3262
3309
|
};
|
|
@@ -3267,12 +3314,13 @@ async function capReferenceEdge(core, path, maxEdge) {
|
|
|
3267
3314
|
if (hit) return hit;
|
|
3268
3315
|
let out = path;
|
|
3269
3316
|
try {
|
|
3270
|
-
const meta = await
|
|
3317
|
+
const meta = await sharp20(path).metadata();
|
|
3271
3318
|
if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
|
|
3272
|
-
const buf = await
|
|
3319
|
+
const buf = await sharp20(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
3273
3320
|
out = core.images.pathFor(core.images.save(buf));
|
|
3274
3321
|
}
|
|
3275
3322
|
} catch {
|
|
3323
|
+
return path;
|
|
3276
3324
|
}
|
|
3277
3325
|
cappedRefs.set(key, out);
|
|
3278
3326
|
return out;
|
|
@@ -3306,12 +3354,21 @@ function productFidelityDirective(attached) {
|
|
|
3306
3354
|
function editPreservationDirective(scope, opts) {
|
|
3307
3355
|
if (scope === "local") {
|
|
3308
3356
|
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." : "";
|
|
3309
|
-
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;
|
|
3357
|
+
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. Every surface keeps the texture it already has: no invented pattern, grain, weave or embossing on fabric, skin or walls that the photograph does not carry now." + removal;
|
|
3310
3358
|
}
|
|
3311
|
-
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.";
|
|
3359
|
+
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. Every surface keeps the texture it already has: no invented pattern, grain, weave or embossing on fabric, skin or walls that the photograph does not carry now.";
|
|
3360
|
+
}
|
|
3361
|
+
function inheritedIdentityDirective(kinds) {
|
|
3362
|
+
const product = kinds?.product ?? true;
|
|
3363
|
+
const person = kinds?.person ?? true;
|
|
3364
|
+
if (product && person)
|
|
3365
|
+
return "The attached product and person 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.";
|
|
3366
|
+
if (product)
|
|
3367
|
+
return "The attached product references show the same product that is already in this picture. Use them to hold its identity exact while you make the change, not as a reason to re-stage the shot.";
|
|
3368
|
+
return "The attached person reference shows the same person who is already in this picture. Use it to hold their identity exact while you make the change, not as a reason to re-stage the shot.";
|
|
3312
3369
|
}
|
|
3313
|
-
function
|
|
3314
|
-
return "The
|
|
3370
|
+
function inheritedRefDirective() {
|
|
3371
|
+
return "The carried reference is attached for composition, lighting and treatment only. Any person or product visible in it lends mood, never identity \u2014 nobody and nothing in this photograph takes a face, a body or a design from it.";
|
|
3315
3372
|
}
|
|
3316
3373
|
function productFactDirectives(p) {
|
|
3317
3374
|
const out = [];
|
|
@@ -3340,6 +3397,9 @@ function productEditFidelityDirective(name) {
|
|
|
3340
3397
|
function characterEditIdentityDirective(name) {
|
|
3341
3398
|
return `${name} is the person in this photograph: keep them present and clearly visible. Match their face, facial structure, skin, hair and build to the attached person reference exactly. The reference's plain outfit and studio backdrop are capture conditions, not direction: keep the styling this photograph already has unless the instruction itself changes it, and never return them to the plain base layers they were photographed in.`;
|
|
3342
3399
|
}
|
|
3400
|
+
function referenceIdentityGuard() {
|
|
3401
|
+
return "A reference shot lends its composition, lighting and treatment, never its cast: the attached presenter is the only source of person identity in this shot, and any person visible in a reference shot is a stand-in whose place the presenter takes. This holds even where the direction asks to use someone from a reference \u2014 the attached presenter is that someone.";
|
|
3402
|
+
}
|
|
3343
3403
|
function markEditDirective() {
|
|
3344
3404
|
return "The attached brand mark is this brand's own mark: wherever the logo appears or the instruction asks for it, reproduce it exactly as drawn \u2014 same colours, letterforms and proportions \u2014 never redrawn or re-lettered. Every character it carries stays intact, including the smallest secondary lettering, in its original script and reading direction \u2014 never translated, transliterated or re-spelled.";
|
|
3345
3405
|
}
|
|
@@ -3440,7 +3500,7 @@ function markLabel(brand, logo) {
|
|
|
3440
3500
|
|
|
3441
3501
|
// src/brief.ts
|
|
3442
3502
|
var PRODUCT_REF_MAX = 3;
|
|
3443
|
-
var CHARACTER_REF_MAX =
|
|
3503
|
+
var CHARACTER_REF_MAX = 3;
|
|
3444
3504
|
var SCENE_REF_MAX = 1;
|
|
3445
3505
|
var FORMATS = [
|
|
3446
3506
|
{ id: "square", label: "Square 1:1", w: 1024, h: 1024 },
|
|
@@ -3497,6 +3557,7 @@ function validateBrief(brief) {
|
|
|
3497
3557
|
function compileBrief(brief, ctx) {
|
|
3498
3558
|
const warnings = [];
|
|
3499
3559
|
const attachments = [];
|
|
3560
|
+
const rawSceneFallback = [];
|
|
3500
3561
|
const productDirectives = [];
|
|
3501
3562
|
const personDirectives = [];
|
|
3502
3563
|
const otherDirectives = [];
|
|
@@ -3529,17 +3590,25 @@ function compileBrief(brief, ctx) {
|
|
|
3529
3590
|
append(p.promptName ?? p.name);
|
|
3530
3591
|
const primary = tok.angle && p.shots?.find((s) => s.angle === tok.angle) || p.shots?.[0];
|
|
3531
3592
|
const orderedShots = [primary, ...(p.shots ?? []).filter((s) => s && s !== primary)];
|
|
3532
|
-
const
|
|
3593
|
+
const pshots = [];
|
|
3533
3594
|
for (const s of orderedShots) {
|
|
3534
|
-
if (
|
|
3595
|
+
if (pshots.length >= PRODUCT_REF_MAX) break;
|
|
3535
3596
|
const h = assetHash2(s?.file);
|
|
3536
|
-
if (h && ctx.images.has(h) && !
|
|
3597
|
+
if (h && ctx.images.has(h) && !pshots.some((x) => x.h === h))
|
|
3598
|
+
pshots.push({ h, ...s?.angle ? { angle: String(s.angle) } : {} });
|
|
3537
3599
|
}
|
|
3538
|
-
if (
|
|
3539
|
-
|
|
3540
|
-
attachments.push({
|
|
3600
|
+
if (pshots.length) {
|
|
3601
|
+
pshots.forEach(({ h, angle }, i) => {
|
|
3602
|
+
attachments.push({
|
|
3603
|
+
role: "product",
|
|
3604
|
+
id: p.id,
|
|
3605
|
+
label: p.name,
|
|
3606
|
+
hash: h,
|
|
3607
|
+
essential: i === 0,
|
|
3608
|
+
...angle ? { angle } : {}
|
|
3609
|
+
});
|
|
3541
3610
|
});
|
|
3542
|
-
productDirectives.push(productFidelityDirective(
|
|
3611
|
+
productDirectives.push(productFidelityDirective(pshots.length));
|
|
3543
3612
|
productDirectives.push(...productFactDirectives(p));
|
|
3544
3613
|
if (p.description && !p.dimensions)
|
|
3545
3614
|
productDirectives.push(
|
|
@@ -3558,10 +3627,17 @@ function compileBrief(brief, ctx) {
|
|
|
3558
3627
|
}
|
|
3559
3628
|
hasPerson = true;
|
|
3560
3629
|
append(c.promptName ?? c.name);
|
|
3561
|
-
const
|
|
3562
|
-
if (
|
|
3563
|
-
|
|
3564
|
-
attachments.push({
|
|
3630
|
+
const cshots = (c.shots ?? []).slice(0, CHARACTER_REF_MAX).map((s) => ({ h: assetHash2(s?.file), angle: s?.angle ? String(s.angle) : void 0 })).filter((x) => !!x.h && ctx.images.has(x.h));
|
|
3631
|
+
if (cshots.length) {
|
|
3632
|
+
cshots.forEach(({ h, angle }, i) => {
|
|
3633
|
+
attachments.push({
|
|
3634
|
+
role: "character",
|
|
3635
|
+
id: c.id,
|
|
3636
|
+
label: c.name,
|
|
3637
|
+
hash: h,
|
|
3638
|
+
essential: i === 0,
|
|
3639
|
+
...angle ? { angle } : {}
|
|
3640
|
+
});
|
|
3565
3641
|
});
|
|
3566
3642
|
personDirectives.push(
|
|
3567
3643
|
`${c.promptName ?? c.name} is in this photograph: a real person, clearly visible in the frame. Do not leave them out, crop them out, or reduce them to a reflection or a shadow.`
|
|
@@ -3574,6 +3650,11 @@ function compileBrief(brief, ctx) {
|
|
|
3574
3650
|
personDirectives.push(
|
|
3575
3651
|
`${c.promptName ?? c.name}'s skin, exactly as the reference photographs show it: ${c.skin}.`
|
|
3576
3652
|
);
|
|
3653
|
+
if (c.facial)
|
|
3654
|
+
personDirectives.push(
|
|
3655
|
+
`${c.promptName ?? c.name}'s face, which must survive every generation unchanged: ${c.facial}.`
|
|
3656
|
+
);
|
|
3657
|
+
if (c.build) personDirectives.push(`${c.promptName ?? c.name}'s build: ${c.build}.`);
|
|
3577
3658
|
} else {
|
|
3578
3659
|
warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
|
|
3579
3660
|
}
|
|
@@ -3633,10 +3714,14 @@ function compileBrief(brief, ctx) {
|
|
|
3633
3714
|
inlineTemplates.push(t);
|
|
3634
3715
|
append(composePrompt(t, { fields: brief.templateFields ?? {}, notes: "" }));
|
|
3635
3716
|
if (ctx.mode !== "edit" && t.figure) {
|
|
3636
|
-
|
|
3637
|
-
|
|
3717
|
+
const plate = assetHash2(t.preview);
|
|
3718
|
+
const hasPlate = !!plate && ctx.images.has(plate);
|
|
3719
|
+
const candidates = hasPlate ? [plate] : (t.refs ?? []).slice(0, SCENE_REF_MAX).map((r) => assetHash2(r?.file));
|
|
3720
|
+
for (const h of candidates) {
|
|
3638
3721
|
if (h && ctx.images.has(h)) {
|
|
3639
|
-
|
|
3722
|
+
const a = { role: "scene", id: t.id, label: t.name, hash: h, essential: false };
|
|
3723
|
+
attachments.push(a);
|
|
3724
|
+
if (!hasPlate) rawSceneFallback.push(a);
|
|
3640
3725
|
}
|
|
3641
3726
|
}
|
|
3642
3727
|
}
|
|
@@ -3687,6 +3772,12 @@ function compileBrief(brief, ctx) {
|
|
|
3687
3772
|
}
|
|
3688
3773
|
const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
|
|
3689
3774
|
const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
|
|
3775
|
+
if (hasPerson && rawSceneFallback.length) {
|
|
3776
|
+
for (const a of rawSceneFallback) {
|
|
3777
|
+
const i = attachments.indexOf(a);
|
|
3778
|
+
if (i !== -1) attachments.splice(i, 1);
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3690
3781
|
const max = ctx.engineCaps.maxReferenceImages;
|
|
3691
3782
|
const { kept, dropped } = allocateAttachments(attachments, max);
|
|
3692
3783
|
const guard = scene ? sceneGuardDirectives({
|
|
@@ -3720,7 +3811,7 @@ function compileBrief(brief, ctx) {
|
|
|
3720
3811
|
const brandLines = brandRuleDirectives(ctx.brand);
|
|
3721
3812
|
const preservation = ctx.mode === "edit" ? [
|
|
3722
3813
|
...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
|
|
3723
|
-
...ctx.inheritedIdentity ? [inheritedIdentityDirective()] : [],
|
|
3814
|
+
...ctx.inheritedIdentity ? [inheritedIdentityDirective(ctx.inheritedIdentity === true ? void 0 : ctx.inheritedIdentity)] : [],
|
|
3724
3815
|
...ctx.inheritedDirectives ?? []
|
|
3725
3816
|
] : [];
|
|
3726
3817
|
const apparelUnworn = ctx.mode !== "edit" && !hasPerson && attachments.some((a) => {
|
|
@@ -3728,6 +3819,7 @@ function compileBrief(brief, ctx) {
|
|
|
3728
3819
|
const rec = (ctx.brand?.products ?? []).find((x) => x?.id === a.id);
|
|
3729
3820
|
return String(rec?.category ?? "").toLowerCase() === "apparel";
|
|
3730
3821
|
}) ? [garmentDisplayDirective()] : [];
|
|
3822
|
+
const refGuard = ctx.mode !== "edit" && hasPerson && kept.some((a) => a.role === "reference") ? [referenceIdentityGuard()] : [];
|
|
3731
3823
|
const allDirectives = [
|
|
3732
3824
|
...productDirectives,
|
|
3733
3825
|
...personDirectives,
|
|
@@ -3739,15 +3831,21 @@ function compileBrief(brief, ctx) {
|
|
|
3739
3831
|
...apparelUnworn,
|
|
3740
3832
|
...brandLines,
|
|
3741
3833
|
...guard,
|
|
3834
|
+
...refGuard,
|
|
3742
3835
|
...preservation
|
|
3743
3836
|
];
|
|
3744
3837
|
if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
|
|
3745
3838
|
if (dropped.some((d) => d.role !== "scene")) {
|
|
3746
|
-
const
|
|
3747
|
-
const
|
|
3748
|
-
|
|
3749
|
-
`${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
|
|
3839
|
+
const keptLabels = new Set(kept.map((a) => a.label));
|
|
3840
|
+
const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))].filter(
|
|
3841
|
+
(l) => !keptLabels.has(l)
|
|
3750
3842
|
);
|
|
3843
|
+
if (names.length) {
|
|
3844
|
+
const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
|
|
3845
|
+
warnings.push(
|
|
3846
|
+
`${ctx.engineCaps.displayName} ${reads}, so ${names.join(" and ")} ${names.length === 1 ? "was" : "were"} left out.`
|
|
3847
|
+
);
|
|
3848
|
+
}
|
|
3751
3849
|
}
|
|
3752
3850
|
return {
|
|
3753
3851
|
prompt: prompt.trim(),
|
|
@@ -5756,8 +5854,8 @@ async function runJob(deps, jobId, brandId, url, signal) {
|
|
|
5756
5854
|
errors.push({ code: "image_empty", message: "Empty image", url: img.sourceUrl });
|
|
5757
5855
|
return;
|
|
5758
5856
|
}
|
|
5759
|
-
const png = await
|
|
5760
|
-
const meta = await
|
|
5857
|
+
const png = await sharp20(buf).rotate().png().toBuffer();
|
|
5858
|
+
const meta = await sharp20(png).metadata();
|
|
5761
5859
|
const hash = core.images.save(png);
|
|
5762
5860
|
core.catalog.setImageAsset(img.productId, img.sourceUrl, `asset:${hash}`, {
|
|
5763
5861
|
width: meta.width,
|
|
@@ -5939,7 +6037,8 @@ function presenterRecordFrom(input, base) {
|
|
|
5939
6037
|
const has = (k) => input[k] !== void 0;
|
|
5940
6038
|
const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
|
|
5941
6039
|
if (!name) return { ok: false, error: "a presenter needs a name" };
|
|
5942
|
-
const
|
|
6040
|
+
const angles = has("shotAngles") ? strList(input.shotAngles, 8, 32) : [];
|
|
6041
|
+
const shots = has("shotHashes") ? strList(input.shotHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file, i) => angles[i] ? { file, angle: angles[i], locked: true } : { file, locked: true }) : base?.shots ?? [];
|
|
5943
6042
|
if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
|
|
5944
6043
|
const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
|
|
5945
6044
|
const presenter = {
|
|
@@ -6077,6 +6176,26 @@ async function runBuild(deps, job, hashes, instruction, signal) {
|
|
|
6077
6176
|
}
|
|
6078
6177
|
}
|
|
6079
6178
|
var STUDIO_FRAMES = [
|
|
6179
|
+
/*
|
|
6180
|
+
* The identity frame, and it comes first because that is the order a brief
|
|
6181
|
+
* attaches: `shots[0]` is the essential character reference.
|
|
6182
|
+
*
|
|
6183
|
+
* Every other frame here is full-length head-to-toe, which is right for
|
|
6184
|
+
* build, proportion and wardrobe and useless for a face — in a 1024x1280
|
|
6185
|
+
* full-length frame the face is about 105px brow to chin, while a portrait
|
|
6186
|
+
* output renders it at four times that. Measured 2026-08-30 against the
|
|
6187
|
+
* reported failure: four outputs of one brief, four different jaws, and
|
|
6188
|
+
* drift that tracked nothing but how big the face was in the output.
|
|
6189
|
+
*
|
|
6190
|
+
* Drawn `from: 'sources'` rather than chained off the front view, because
|
|
6191
|
+
* the user's own photographs are the only real face evidence in the system
|
|
6192
|
+
* and a chain would just enlarge the same 105px.
|
|
6193
|
+
*/
|
|
6194
|
+
{
|
|
6195
|
+
angle: "portrait",
|
|
6196
|
+
from: "sources",
|
|
6197
|
+
subject: (who) => `${who}, head-and-shoulders portrait framing from just above the top of the head down to the collarbone, facing the camera straight-on, relaxed neutral expression, eyes to the lens, their own hair exactly as the references show it, the same plain studio backdrop and even frontal light`
|
|
6198
|
+
},
|
|
6080
6199
|
{
|
|
6081
6200
|
angle: "front",
|
|
6082
6201
|
from: "sources",
|
|
@@ -6129,22 +6248,28 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
|
|
|
6129
6248
|
}
|
|
6130
6249
|
if (signal.aborted) throw new Error("cancelled");
|
|
6131
6250
|
let shotHashes = hashes;
|
|
6251
|
+
let shotAngles = [];
|
|
6132
6252
|
const warnings = [];
|
|
6133
6253
|
if (deps.engine) {
|
|
6134
6254
|
patch(job, { stage: "building", steps: STUDIO_FRAMES.length, message: "Building the studio views" });
|
|
6135
6255
|
const built2 = await generateStudioSet(deps, job, whoIs(job.name, draft), sourcePaths, signal);
|
|
6136
|
-
if (built2.length)
|
|
6137
|
-
|
|
6256
|
+
if (built2.hashes.length) {
|
|
6257
|
+
shotHashes = built2.hashes;
|
|
6258
|
+
shotAngles = built2.angles;
|
|
6259
|
+
} else warnings.push("The studio views could not be drawn, so the photos are being used directly.");
|
|
6138
6260
|
} else {
|
|
6139
6261
|
warnings.push("No engine could draw the studio views, so the photos are being used directly.");
|
|
6140
6262
|
}
|
|
6141
6263
|
if (signal.aborted) throw new Error("cancelled");
|
|
6142
6264
|
patch(job, { stage: "saving", message: null });
|
|
6143
6265
|
const generated = shotHashes !== hashes;
|
|
6144
|
-
const
|
|
6266
|
+
const frontIndex = shotAngles.indexOf("front");
|
|
6267
|
+
const cardSource = frontIndex === -1 ? shotHashes[0] : shotHashes[frontIndex];
|
|
6268
|
+
const { previewHash, avatarHash } = await presenterCrops(core, cardSource, generated ? "generated" : "upload");
|
|
6145
6269
|
const built = presenterRecordFrom({
|
|
6146
6270
|
name: job.name,
|
|
6147
6271
|
shotHashes,
|
|
6272
|
+
shotAngles,
|
|
6148
6273
|
sourceHashes: hashes,
|
|
6149
6274
|
previewHash,
|
|
6150
6275
|
avatarHash,
|
|
@@ -6167,16 +6292,16 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
|
|
|
6167
6292
|
stage: "done",
|
|
6168
6293
|
step: job.steps,
|
|
6169
6294
|
assetId: built.presenter.id,
|
|
6170
|
-
previewHash: previewHash ??
|
|
6295
|
+
previewHash: previewHash ?? cardSource ?? null,
|
|
6171
6296
|
warnings: [...job.warnings, ...warnings],
|
|
6172
6297
|
finished: true
|
|
6173
6298
|
});
|
|
6174
6299
|
}
|
|
6175
6300
|
async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
6176
6301
|
const engine = deps.engine;
|
|
6177
|
-
if (!engine) return [];
|
|
6302
|
+
if (!engine) return { hashes: [], angles: [] };
|
|
6178
6303
|
const caps = engine.capabilities();
|
|
6179
|
-
if (!caps.maxReferenceImages) return [];
|
|
6304
|
+
if (!caps.maxReferenceImages) return { hashes: [], angles: [] };
|
|
6180
6305
|
const byAngle = /* @__PURE__ */ new Map();
|
|
6181
6306
|
for (const frame of STUDIO_FRAMES) {
|
|
6182
6307
|
if (signal.aborted) throw new Error("cancelled");
|
|
@@ -6203,10 +6328,11 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
|
6203
6328
|
patch(job, { warnings: [...job.warnings, `The ${frame.angle} view could not be drawn.`] });
|
|
6204
6329
|
}
|
|
6205
6330
|
}
|
|
6206
|
-
|
|
6331
|
+
const kept = STUDIO_FRAMES.filter((f) => byAngle.get(f.angle));
|
|
6332
|
+
return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
|
|
6207
6333
|
}
|
|
6208
6334
|
async function edgeBarGeometry(buf) {
|
|
6209
|
-
const { data, info } = await
|
|
6335
|
+
const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
6210
6336
|
const W = info.width;
|
|
6211
6337
|
const H = info.height;
|
|
6212
6338
|
const scan = (len, cross, at) => {
|
|
@@ -6260,7 +6386,7 @@ async function trimEdgeBars(core, hash) {
|
|
|
6260
6386
|
const width = g.right - g.left + 1;
|
|
6261
6387
|
const height = g.bottom - g.top + 1;
|
|
6262
6388
|
if (width < g.W * 0.6 || height < g.H * 0.6) return hash;
|
|
6263
|
-
const png = await
|
|
6389
|
+
const png = await sharp20(buf).extract({ left: g.left, top: g.top, width, height }).png().toBuffer();
|
|
6264
6390
|
return core.images.save(png);
|
|
6265
6391
|
} catch {
|
|
6266
6392
|
return hash;
|
|
@@ -6301,13 +6427,71 @@ async function avatarCrop(core, hash) {
|
|
|
6301
6427
|
AVATAR_MAX_PX
|
|
6302
6428
|
);
|
|
6303
6429
|
}
|
|
6430
|
+
var IDENTITY_FIGURE_FRACTION = 0.26;
|
|
6431
|
+
var IDENTITY_HEADROOM = 0.08;
|
|
6432
|
+
var IDENTITY_ASPECT = 0.66;
|
|
6433
|
+
var IDENTITY_TARGET_HEIGHT = 1280;
|
|
6434
|
+
var IDENTITY_MAX_UPSCALE = 3;
|
|
6435
|
+
var STANDING_FIGURE_RATIO = 2.2;
|
|
6436
|
+
async function identityCrop(core, hash) {
|
|
6437
|
+
if (!hash || !core.images.has(hash)) return void 0;
|
|
6438
|
+
const hit = identityCrops.get(hash);
|
|
6439
|
+
if (hit && core.images.has(hit)) return hit;
|
|
6440
|
+
let box = null;
|
|
6441
|
+
try {
|
|
6442
|
+
box = await figureBox(core.images.read(hash));
|
|
6443
|
+
} catch {
|
|
6444
|
+
box = null;
|
|
6445
|
+
}
|
|
6446
|
+
if (!box) return void 0;
|
|
6447
|
+
if (box.height / Math.max(1, box.width) < STANDING_FIGURE_RATIO) return void 0;
|
|
6448
|
+
let nativeHeight = 0;
|
|
6449
|
+
const out = await crop(core, hash, (w, h) => {
|
|
6450
|
+
const height = Math.min(h, Math.max(16, Math.round(box.height * IDENTITY_FIGURE_FRACTION)));
|
|
6451
|
+
const width = Math.min(w, Math.max(16, Math.round(height * IDENTITY_ASPECT)));
|
|
6452
|
+
nativeHeight = height;
|
|
6453
|
+
const top = Math.min(Math.max(0, Math.round(box.top - height * IDENTITY_HEADROOM)), h - height);
|
|
6454
|
+
const left = Math.min(Math.max(0, Math.round(box.left + box.width / 2 - width / 2)), w - width);
|
|
6455
|
+
return { left, top, width, height };
|
|
6456
|
+
});
|
|
6457
|
+
if (!out) return void 0;
|
|
6458
|
+
try {
|
|
6459
|
+
const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
|
|
6460
|
+
const png = await sharp20(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
|
|
6461
|
+
const scaled = core.images.save(png);
|
|
6462
|
+
identityCrops.set(hash, scaled);
|
|
6463
|
+
return scaled;
|
|
6464
|
+
} catch {
|
|
6465
|
+
identityCrops.set(hash, out);
|
|
6466
|
+
return out;
|
|
6467
|
+
}
|
|
6468
|
+
}
|
|
6469
|
+
var identityCrops = /* @__PURE__ */ new Map();
|
|
6470
|
+
async function brandJsonWithIdentityCrops(core, json, characterIds) {
|
|
6471
|
+
const wanted = new Set(characterIds);
|
|
6472
|
+
const roster = json?.characters ?? [];
|
|
6473
|
+
if (!wanted.size || !roster.length) return json;
|
|
6474
|
+
let changed = false;
|
|
6475
|
+
const characters = await Promise.all(
|
|
6476
|
+
roster.map(async (c) => {
|
|
6477
|
+
if (!wanted.has(c?.id) || !c?.shots?.length) return c;
|
|
6478
|
+
if (c.shots[0]?.angle === "portrait") return c;
|
|
6479
|
+
const front = String(c.shots[0]?.file ?? "").replace(/^asset:/, "") || null;
|
|
6480
|
+
const cropped = await identityCrop(core, front ?? void 0);
|
|
6481
|
+
if (!cropped) return c;
|
|
6482
|
+
changed = true;
|
|
6483
|
+
return { ...c, shots: [{ file: `asset:${cropped}`, angle: "identity", locked: true }, ...c.shots] };
|
|
6484
|
+
})
|
|
6485
|
+
);
|
|
6486
|
+
return changed ? { ...json, characters } : json;
|
|
6487
|
+
}
|
|
6304
6488
|
async function figureBox(buf) {
|
|
6305
|
-
const meta = await
|
|
6489
|
+
const meta = await sharp20(buf).metadata();
|
|
6306
6490
|
const W = meta.width ?? 0;
|
|
6307
6491
|
const H = meta.height ?? 0;
|
|
6308
6492
|
if (!W || !H) return null;
|
|
6309
6493
|
for (const threshold of FIGURE_TRIM_THRESHOLDS) {
|
|
6310
|
-
const { info } = await
|
|
6494
|
+
const { info } = await sharp20(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
|
|
6311
6495
|
const left = Math.abs(info.trimOffsetLeft ?? 0);
|
|
6312
6496
|
const top = Math.abs(info.trimOffsetTop ?? 0);
|
|
6313
6497
|
const width = info.width ?? 0;
|
|
@@ -6340,13 +6524,13 @@ async function smartCover(core, hash, box) {
|
|
|
6340
6524
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
6341
6525
|
try {
|
|
6342
6526
|
const buf = core.images.read(hash);
|
|
6343
|
-
const meta = await
|
|
6527
|
+
const meta = await sharp20(buf).metadata();
|
|
6344
6528
|
const w = meta.width ?? 0;
|
|
6345
6529
|
const h = meta.height ?? 0;
|
|
6346
6530
|
if (!w || !h) return void 0;
|
|
6347
6531
|
const raw = box(w, h);
|
|
6348
6532
|
const target = { width: Math.max(1, raw.width), height: Math.max(1, raw.height) };
|
|
6349
|
-
const png = await
|
|
6533
|
+
const png = await sharp20(buf).resize(target.width, target.height, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
6350
6534
|
return core.images.save(png);
|
|
6351
6535
|
} catch {
|
|
6352
6536
|
return void 0;
|
|
@@ -6355,11 +6539,11 @@ async function smartCover(core, hash, box) {
|
|
|
6355
6539
|
async function crop(core, hash, region, cap2) {
|
|
6356
6540
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
6357
6541
|
try {
|
|
6358
|
-
const meta = await
|
|
6542
|
+
const meta = await sharp20(core.images.read(hash)).metadata();
|
|
6359
6543
|
const w = meta.width ?? 0;
|
|
6360
6544
|
const h = meta.height ?? 0;
|
|
6361
6545
|
if (!w || !h) return void 0;
|
|
6362
|
-
let pipeline =
|
|
6546
|
+
let pipeline = sharp20(core.images.read(hash)).extract(region(w, h));
|
|
6363
6547
|
if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
|
|
6364
6548
|
const png = await pipeline.png().toBuffer();
|
|
6365
6549
|
return core.images.save(png);
|
|
@@ -6450,7 +6634,7 @@ async function runSceneBuild(deps, job, hashes, instruction, signal) {
|
|
|
6450
6634
|
});
|
|
6451
6635
|
}
|
|
6452
6636
|
function scenePreviewPrompt(scene) {
|
|
6453
|
-
const body = scene.figure ? `A figure is in this photograph: ${scene.figure.replace(/[.\s]+$/, "")}. ` + (scene.figureTreatment ? `The art direction is what has been done to them: ${scene.figureTreatment.replace(/[.\s]+$/, "")}, rendered as a real physical treatment that follows the shape it sits on. ` : "") + "They are nobody in particular: do not reproduce any person from the attached reference images, and give them no recognisable identity. No product, no logos, no watermarks, and no readable words anywhere in the frame." : "The set is empty: no product, no person, no hands, no text, no logos, no watermarks anywhere in the frame.";
|
|
6637
|
+
const body = scene.figure ? `A figure is in this photograph: ${scene.figure.replace(/[.\s]+$/, "")}. ` + (scene.figureTreatment ? `The art direction is what has been done to them: ${scene.figureTreatment.replace(/[.\s]+$/, "")}, rendered as a real physical treatment that follows the shape it sits on. ` : "") + "They are nobody in particular: do not reproduce any person from the attached reference images, and give them no recognisable identity. " + (scene.figureTreatment ? "No product and no watermarks. Where the treatment itself carries printing, render it as genuinely designed print - real letterforms, readable words, numerals and label-quality artwork - belonging to companies that are plausible but fictional, resembling no existing brand, and borrowing, extending or re-spelling no name that appears in any attached reference. Everywhere outside the treatment, no logos and no readable words." : "No product, no logos, no watermarks, and no readable words anywhere in the frame.") : "The set is empty: no product, no person, no hands, no text, no logos, no watermarks anywhere in the frame.";
|
|
6454
6638
|
return `Full-bleed photograph filling the entire frame edge to edge with no border, frame, letterbox band or matte of any kind. ${scene.prompt} ${scene.lighting ? `${scene.lighting}. ` : ""}` + body;
|
|
6455
6639
|
}
|
|
6456
6640
|
async function draw(deps, req) {
|
|
@@ -6571,6 +6755,49 @@ function inheritedIdentityTokens(parentId, getNode) {
|
|
|
6571
6755
|
return { tokens: [], truncated: id !== null };
|
|
6572
6756
|
}
|
|
6573
6757
|
|
|
6758
|
+
// src/variationPlan.ts
|
|
6759
|
+
var OPEN_LADDER = [
|
|
6760
|
+
"Frame this one as the direction describes it, the straight read of the brief.",
|
|
6761
|
+
"Step the camera to one side of where the direction places it, and let the pose settle with the move.",
|
|
6762
|
+
"Frame tighter on the subject than the straight read, same lens character.",
|
|
6763
|
+
"Drop the eye line a little and leave more air in the frame.",
|
|
6764
|
+
"Step back for a wider read of the same setup.",
|
|
6765
|
+
"Come round to a three-quarter view of the same arrangement.",
|
|
6766
|
+
"Take it from slightly above, the same distance.",
|
|
6767
|
+
"Hold the same framing and let the subject carry a different beat of the same moment."
|
|
6768
|
+
];
|
|
6769
|
+
var FIXED_LADDER = [
|
|
6770
|
+
"Frame this one as the direction describes it, the straight read of the brief.",
|
|
6771
|
+
"Keep the camera the direction asks for and shift it a little laterally.",
|
|
6772
|
+
"Keep the camera the direction asks for and let the weight and hands settle differently.",
|
|
6773
|
+
"Keep the camera the direction asks for and change the head angle slightly.",
|
|
6774
|
+
"Keep the camera the direction asks for and let the light fall a touch differently across the same setup.",
|
|
6775
|
+
"Keep the camera the direction asks for and give the expression a different beat of the same moment.",
|
|
6776
|
+
"Keep the camera the direction asks for and rearrange the near foreground slightly.",
|
|
6777
|
+
"Keep the camera the direction asks for and let the pose breathe a little wider."
|
|
6778
|
+
];
|
|
6779
|
+
function locks(ctx) {
|
|
6780
|
+
const parts = [
|
|
6781
|
+
"Every frame in this run belongs to one continuous shoot: the same location, the same light, and the same wardrobe garment for garment, changing only as the pose moves the cloth."
|
|
6782
|
+
];
|
|
6783
|
+
if (ctx.hasPresenter)
|
|
6784
|
+
parts.push(
|
|
6785
|
+
"The person is the one in the character references and nobody else, unchanged in face, hair, build and skin."
|
|
6786
|
+
);
|
|
6787
|
+
if (ctx.hasProduct)
|
|
6788
|
+
parts.push(
|
|
6789
|
+
"The product is the one in the product references and no other, unchanged in geometry, packaging, label and colour."
|
|
6790
|
+
);
|
|
6791
|
+
if (ctx.hasMark) parts.push("The brand mark stays exactly as drawn.");
|
|
6792
|
+
return parts.join(" ");
|
|
6793
|
+
}
|
|
6794
|
+
function variationPlan(count, ctx) {
|
|
6795
|
+
if (!Number.isFinite(count) || count <= 1) return [];
|
|
6796
|
+
const ladder = ctx.cameraFixed ? FIXED_LADDER : OPEN_LADDER;
|
|
6797
|
+
const shared = locks(ctx);
|
|
6798
|
+
return Array.from({ length: Math.floor(count) }, (_, i) => `${ladder[i % ladder.length]} ${shared}`);
|
|
6799
|
+
}
|
|
6800
|
+
|
|
6574
6801
|
// src/editScopeRules.ts
|
|
6575
6802
|
var GLOBAL_CUES = [
|
|
6576
6803
|
["light", /\b(light|lighting|lit|relight|exposure|white ?balance|backlit|shadows everywhere)\b/i],
|
|
@@ -6621,6 +6848,119 @@ function scopeOfInstruction(text) {
|
|
|
6621
6848
|
if (!matched.length) return { scope: "global", matched: ["no local cue"] };
|
|
6622
6849
|
return { scope: "local", matched, removal: REMOVAL_VERB.test(s) };
|
|
6623
6850
|
}
|
|
6851
|
+
var FIT_EDGE = 160;
|
|
6852
|
+
var GATE_EDGE = 32;
|
|
6853
|
+
var GRADE_GATE_MEAN_DELTA = 40;
|
|
6854
|
+
var SLOPE_CLAMP = 1.8;
|
|
6855
|
+
var TONAL_WORD = /^(warm(er)?|cool(er)?|bright(er)?|dark(er)?|deep(er)?|soft(er)?|hard(er)?|punch(ier|y)?|rich(er)?|flat(ter)?|mood(y|ier)?|golden|overcast|sun(ny|lit)?|cloudy|dusk|dawn|evening|morning|night|daylight|light|lighting|lit|glow(ing)?|exposure|contrast|saturation|desaturat(e|ed)|muted|vivid|tone[sd]?|tint(ed)?|grade[d]?|grading|temperature|white|balance|shadows?|highlights?|blacks?|whites?|filmic|cinematic|dramatic|airy|hazy|misty|crisp)$/i;
|
|
6856
|
+
var NEUTRAL_WORD = /^(a|an|the|it|its|this|that|make|makes|keep|slightly|slight|touch|bit|little|more|less|much|very|again|overall|whole|image|frame|shot|picture|photo|look|feel|and|of|in|with|to|too|just|please|now|even)$/i;
|
|
6857
|
+
function isGradeOnlyInstruction(text) {
|
|
6858
|
+
const words = String(text ?? "").toLowerCase().replace(/[^a-z\s-]/g, " ").split(/\s+/).filter(Boolean);
|
|
6859
|
+
if (!words.length) return false;
|
|
6860
|
+
let tonal = 0;
|
|
6861
|
+
for (const w of words) {
|
|
6862
|
+
if (TONAL_WORD.test(w)) tonal++;
|
|
6863
|
+
else if (!NEUTRAL_WORD.test(w)) return false;
|
|
6864
|
+
}
|
|
6865
|
+
return tonal > 0;
|
|
6866
|
+
}
|
|
6867
|
+
var srgbToLinear = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
6868
|
+
var linearToSrgb = (c) => c <= 31308e-7 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
|
|
6869
|
+
var EPS = 1e-6;
|
|
6870
|
+
var toLab = (r, g, b) => {
|
|
6871
|
+
const R = srgbToLinear(r / 255);
|
|
6872
|
+
const G = srgbToLinear(g / 255);
|
|
6873
|
+
const B = srgbToLinear(b / 255);
|
|
6874
|
+
const L = Math.log10(Math.max(EPS, 0.3811 * R + 0.5783 * G + 0.0402 * B));
|
|
6875
|
+
const M = Math.log10(Math.max(EPS, 0.1967 * R + 0.7244 * G + 0.0782 * B));
|
|
6876
|
+
const S = Math.log10(Math.max(EPS, 0.0241 * R + 0.1288 * G + 0.8444 * B));
|
|
6877
|
+
return [(L + M + S) / Math.sqrt(3), (L + M - 2 * S) / Math.sqrt(6), (L - M) / Math.sqrt(2)];
|
|
6878
|
+
};
|
|
6879
|
+
var fromLab = (l, a, bb) => {
|
|
6880
|
+
const L = l / Math.sqrt(3) + a / Math.sqrt(6) + bb / Math.sqrt(2);
|
|
6881
|
+
const M = l / Math.sqrt(3) + a / Math.sqrt(6) - bb / Math.sqrt(2);
|
|
6882
|
+
const S = l / Math.sqrt(3) - 2 * a / Math.sqrt(6);
|
|
6883
|
+
const Rl = 10 ** L;
|
|
6884
|
+
const Ml = 10 ** M;
|
|
6885
|
+
const Sl = 10 ** S;
|
|
6886
|
+
const R = 4.4679 * Rl - 3.5873 * Ml + 0.1193 * Sl;
|
|
6887
|
+
const G = -1.2186 * Rl + 2.3809 * Ml - 0.1624 * Sl;
|
|
6888
|
+
const B = 0.0497 * Rl - 0.2439 * Ml + 1.2045 * Sl;
|
|
6889
|
+
const clamp = (x) => Math.max(0, Math.min(255, Math.round(linearToSrgb(Math.max(0, Math.min(1, x))) * 255)));
|
|
6890
|
+
return [clamp(R), clamp(G), clamp(B)];
|
|
6891
|
+
};
|
|
6892
|
+
var rawAt = async (png, edge) => {
|
|
6893
|
+
let img = sharp20(png);
|
|
6894
|
+
if (edge) img = img.resize(edge, edge, { fit: "fill" });
|
|
6895
|
+
const { data, info } = await img.removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
6896
|
+
return { data, width: info.width, height: info.height };
|
|
6897
|
+
};
|
|
6898
|
+
var labStats = (raw) => {
|
|
6899
|
+
const n = raw.data.length / 3;
|
|
6900
|
+
const mu = [0, 0, 0];
|
|
6901
|
+
const sq = [0, 0, 0];
|
|
6902
|
+
for (let i = 0; i < raw.data.length; i += 3) {
|
|
6903
|
+
const lab = toLab(raw.data[i], raw.data[i + 1], raw.data[i + 2]);
|
|
6904
|
+
for (let c = 0; c < 3; c++) {
|
|
6905
|
+
mu[c] += lab[c];
|
|
6906
|
+
sq[c] += lab[c] * lab[c];
|
|
6907
|
+
}
|
|
6908
|
+
}
|
|
6909
|
+
for (let c = 0; c < 3; c++) {
|
|
6910
|
+
mu[c] /= n;
|
|
6911
|
+
sq[c] = Math.sqrt(Math.max(1e-9, sq[c] / n - mu[c] * mu[c]));
|
|
6912
|
+
}
|
|
6913
|
+
return { mu, sd: sq };
|
|
6914
|
+
};
|
|
6915
|
+
var fitAffine = (input, output) => {
|
|
6916
|
+
const a = labStats(input);
|
|
6917
|
+
const b = labStats(output);
|
|
6918
|
+
return [0, 1, 2].map((c) => ({
|
|
6919
|
+
k: Math.max(1 / SLOPE_CLAMP, Math.min(SLOPE_CLAMP, b.sd[c] / a.sd[c])),
|
|
6920
|
+
mi: a.mu[c],
|
|
6921
|
+
mo: b.mu[c]
|
|
6922
|
+
}));
|
|
6923
|
+
};
|
|
6924
|
+
var applyAffine = (raw, T) => {
|
|
6925
|
+
const d = raw.data;
|
|
6926
|
+
for (let i = 0; i < d.length; i += 3) {
|
|
6927
|
+
const peak = Math.max(d[i], d[i + 1], d[i + 2]);
|
|
6928
|
+
const w = peak <= 225 ? 1 : Math.max(0, (255 - peak) / 30);
|
|
6929
|
+
const lab = toLab(d[i], d[i + 1], d[i + 2]);
|
|
6930
|
+
const out = fromLab(
|
|
6931
|
+
(lab[0] - T[0].mi) * T[0].k + T[0].mo,
|
|
6932
|
+
(lab[1] - T[1].mi) * T[1].k + T[1].mo,
|
|
6933
|
+
(lab[2] - T[2].mi) * T[2].k + T[2].mo
|
|
6934
|
+
);
|
|
6935
|
+
d[i] = Math.round(d[i] * (1 - w) + out[0] * w);
|
|
6936
|
+
d[i + 1] = Math.round(d[i + 1] * (1 - w) + out[1] * w);
|
|
6937
|
+
d[i + 2] = Math.round(d[i + 2] * (1 - w) + out[2] * w);
|
|
6938
|
+
}
|
|
6939
|
+
};
|
|
6940
|
+
var meanDelta = (a, b) => {
|
|
6941
|
+
const n = Math.min(a.data.length, b.data.length);
|
|
6942
|
+
let sum = 0;
|
|
6943
|
+
for (let i = 0; i < n; i++) sum += Math.abs(a.data[i] - b.data[i]);
|
|
6944
|
+
return n ? sum / n : 255;
|
|
6945
|
+
};
|
|
6946
|
+
async function gradeComposite(originalPng, modelInputPng, modelOutputPng) {
|
|
6947
|
+
try {
|
|
6948
|
+
const T = fitAffine(await rawAt(modelInputPng, FIT_EDGE), await rawAt(modelOutputPng, FIT_EDGE));
|
|
6949
|
+
const gateIn = await rawAt(modelInputPng, GATE_EDGE);
|
|
6950
|
+
const gateOut = await rawAt(modelOutputPng, GATE_EDGE);
|
|
6951
|
+
applyAffine(gateIn, T);
|
|
6952
|
+
const residual = meanDelta(gateIn, gateOut);
|
|
6953
|
+
if (residual > GRADE_GATE_MEAN_DELTA) return null;
|
|
6954
|
+
const full = await rawAt(originalPng);
|
|
6955
|
+
applyAffine(full, T);
|
|
6956
|
+
const image = await sharp20(full.data, {
|
|
6957
|
+
raw: { width: full.width, height: full.height, channels: 3 }
|
|
6958
|
+
}).png().toBuffer();
|
|
6959
|
+
return { image, residual };
|
|
6960
|
+
} catch {
|
|
6961
|
+
return null;
|
|
6962
|
+
}
|
|
6963
|
+
}
|
|
6624
6964
|
|
|
6625
6965
|
// src/expandRules.ts
|
|
6626
6966
|
var round8 = (n) => Math.max(8, Math.round(n / 8) * 8);
|
|
@@ -6687,13 +7027,20 @@ Avoid: new objects, products, people, text or watermarks; do not recompose, rece
|
|
|
6687
7027
|
// src/editSizeRules.ts
|
|
6688
7028
|
var SAME_SHAPE_TOL = 0.01;
|
|
6689
7029
|
var SHRINK_FLOOR = 0.8;
|
|
6690
|
-
|
|
7030
|
+
var BUDGET_TOL = 0.1;
|
|
7031
|
+
function judgeEditSize(src, got, opts) {
|
|
6691
7032
|
if (!(src.width > 0 && src.height > 0 && got.width > 0 && got.height > 0)) return { action: "keep" };
|
|
6692
7033
|
if (got.width === src.width && got.height === src.height) return { action: "keep" };
|
|
6693
7034
|
const want = src.width / src.height;
|
|
6694
7035
|
const have = got.width / got.height;
|
|
6695
7036
|
if (Math.abs(have - want) / want > SAME_SHAPE_TOL) return { action: "keep" };
|
|
6696
7037
|
const scale = Math.max(got.width, got.height) / Math.max(src.width, src.height);
|
|
7038
|
+
const budget = opts?.pixelBudget;
|
|
7039
|
+
if (budget && src.width * src.height > budget) {
|
|
7040
|
+
const gotPx = got.width * got.height;
|
|
7041
|
+
if (gotPx <= src.width * src.height && Math.abs(gotPx - budget) / budget <= BUDGET_TOL)
|
|
7042
|
+
return { action: "accept", scale };
|
|
7043
|
+
}
|
|
6697
7044
|
if (scale < SHRINK_FLOOR) return { action: "reject", scale };
|
|
6698
7045
|
return { action: "resize", scale };
|
|
6699
7046
|
}
|
|
@@ -6712,7 +7059,7 @@ function planCrop(source, targetRatio) {
|
|
|
6712
7059
|
}
|
|
6713
7060
|
async function attentionCropOrigin(srcBuf, source, plan) {
|
|
6714
7061
|
try {
|
|
6715
|
-
const { info } = await
|
|
7062
|
+
const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
6716
7063
|
const attnLeft = typeof info.cropOffsetLeft === "number" ? Math.abs(info.cropOffsetLeft) : plan.left;
|
|
6717
7064
|
const attnTop = typeof info.cropOffsetTop === "number" ? Math.abs(info.cropOffsetTop) : plan.top;
|
|
6718
7065
|
const left = Math.round((attnLeft + plan.left) / 2);
|
|
@@ -6865,23 +7212,23 @@ function relax(grid, seam, fixedSweeps) {
|
|
|
6865
7212
|
|
|
6866
7213
|
// src/expand.ts
|
|
6867
7214
|
async function expandCanvas(source, plan) {
|
|
6868
|
-
const bed = await
|
|
6869
|
-
return
|
|
7215
|
+
const bed = await sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
7216
|
+
return sharp20(bed).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
6870
7217
|
}
|
|
6871
7218
|
async function compositeExpand(engineImage, source, plan) {
|
|
6872
|
-
const meta = await
|
|
7219
|
+
const meta = await sharp20(engineImage).metadata();
|
|
6873
7220
|
const want = plan.width / plan.height;
|
|
6874
7221
|
const got = meta.width && meta.height ? meta.width / meta.height : 0;
|
|
6875
7222
|
const sameOrientation = got > 0 && got >= 1 === want >= 1;
|
|
6876
7223
|
const aligned = sameOrientation;
|
|
6877
7224
|
const exact = meta.width === plan.width && meta.height === plan.height;
|
|
6878
|
-
const surround = aligned ? exact ? engineImage : await
|
|
7225
|
+
const surround = aligned ? exact ? engineImage : await sharp20(engineImage).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).toBuffer() : await expandCanvasBedOnly(source, plan);
|
|
6879
7226
|
const matched = aligned ? await matchMarginsToSeam(surround, source, plan) : surround;
|
|
6880
|
-
const image = await
|
|
7227
|
+
const image = await sharp20(matched).composite([{ input: source, left: plan.left, top: plan.top }]).png().toBuffer();
|
|
6881
7228
|
return { image, aligned };
|
|
6882
7229
|
}
|
|
6883
7230
|
async function matchMarginsToSeam(surround, source, plan) {
|
|
6884
|
-
const src = await
|
|
7231
|
+
const src = await sharp20(source).metadata();
|
|
6885
7232
|
if (!src.width || !src.height) return surround;
|
|
6886
7233
|
const SW = src.width;
|
|
6887
7234
|
const SH = src.height;
|
|
@@ -6928,8 +7275,8 @@ var MAX_CORRECTION = 60;
|
|
|
6928
7275
|
async function reconcile(surround, source, side, axis) {
|
|
6929
7276
|
const { margin } = side;
|
|
6930
7277
|
if (margin.width < 1 || margin.height < 1) return surround;
|
|
6931
|
-
const marginRaw = await
|
|
6932
|
-
const edgeRaw = await
|
|
7278
|
+
const marginRaw = await sharp20(surround).extract(margin).removeAlpha().raw().toBuffer();
|
|
7279
|
+
const edgeRaw = await sharp20(source).extract(side.srcEdge).removeAlpha().raw().toBuffer();
|
|
6933
7280
|
const W = margin.width;
|
|
6934
7281
|
const H = margin.height;
|
|
6935
7282
|
const along = axis === "width" ? H : W;
|
|
@@ -6983,11 +7330,11 @@ async function reconcile(surround, source, side, axis) {
|
|
|
6983
7330
|
}
|
|
6984
7331
|
}
|
|
6985
7332
|
}
|
|
6986
|
-
const patch2 = await
|
|
6987
|
-
return
|
|
7333
|
+
const patch2 = await sharp20(corrected, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
|
|
7334
|
+
return sharp20(surround).composite([{ input: patch2, left: margin.left, top: margin.top }]).toBuffer();
|
|
6988
7335
|
}
|
|
6989
7336
|
async function expandCanvasBedOnly(source, plan) {
|
|
6990
|
-
return
|
|
7337
|
+
return sharp20(source).resize(plan.width, plan.height, { fit: "cover", position: "centre" }).blur(Math.max(8, Math.round(Math.max(plan.width, plan.height) / 40))).toBuffer();
|
|
6991
7338
|
}
|
|
6992
7339
|
function medianOf(rgb, channel, from, to) {
|
|
6993
7340
|
const n = to - from;
|
|
@@ -6998,17 +7345,17 @@ function medianOf(rgb, channel, from, to) {
|
|
|
6998
7345
|
return n % 2 ? values[(n - 1) / 2] : (values[n / 2 - 1] + values[n / 2]) / 2;
|
|
6999
7346
|
}
|
|
7000
7347
|
async function reframeExpand(engineImage, plan) {
|
|
7001
|
-
const meta = await
|
|
7348
|
+
const meta = await sharp20(engineImage).metadata();
|
|
7002
7349
|
if (!(meta.width && meta.height)) return null;
|
|
7003
7350
|
const want = plan.width / plan.height;
|
|
7004
7351
|
const got = meta.width / meta.height;
|
|
7005
7352
|
if (got >= 1 !== want >= 1) return null;
|
|
7006
7353
|
if (meta.width === plan.width && meta.height === plan.height) return engineImage;
|
|
7007
7354
|
const straight = Math.abs(got - want) / want <= 0.02;
|
|
7008
|
-
return
|
|
7355
|
+
return sharp20(engineImage).resize(plan.width, plan.height, { fit: straight ? "fill" : "cover", position: "centre" }).png().toBuffer();
|
|
7009
7356
|
}
|
|
7010
7357
|
async function seamScore(image, plan, source) {
|
|
7011
|
-
const { data, info } = await
|
|
7358
|
+
const { data, info } = await sharp20(image).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
7012
7359
|
const W = info.width;
|
|
7013
7360
|
const H = info.height;
|
|
7014
7361
|
const horizontal = plan.axis === "width";
|
|
@@ -7041,7 +7388,7 @@ var SEAM_VISIBLE = 2.2;
|
|
|
7041
7388
|
var OFFSET = 4;
|
|
7042
7389
|
var RESIDUAL_VISIBLE = 15;
|
|
7043
7390
|
async function seamResidual(image, plan, source) {
|
|
7044
|
-
const { data, info } = await
|
|
7391
|
+
const { data, info } = await sharp20(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
7045
7392
|
const W = info.width;
|
|
7046
7393
|
const H = info.height;
|
|
7047
7394
|
const ch = info.channels;
|
|
@@ -7076,7 +7423,7 @@ var MAX_SHARE = 0.8;
|
|
|
7076
7423
|
async function subjectFraction(src, source, axis) {
|
|
7077
7424
|
try {
|
|
7078
7425
|
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)) };
|
|
7079
|
-
const { info } = await
|
|
7426
|
+
const { info } = await sharp20(src).resize(window.width, window.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
7080
7427
|
const offset = axis === "width" ? Math.abs(typeof info.cropOffsetLeft === "number" ? info.cropOffsetLeft : 0) : Math.abs(typeof info.cropOffsetTop === "number" ? info.cropOffsetTop : 0);
|
|
7081
7428
|
const span = axis === "width" ? source.width : source.height;
|
|
7082
7429
|
const extent = axis === "width" ? window.width : window.height;
|
|
@@ -7099,14 +7446,14 @@ function placeExpand(plan, source, fraction) {
|
|
|
7099
7446
|
}
|
|
7100
7447
|
var NEUTRAL = { r: 128, g: 128, b: 128 };
|
|
7101
7448
|
async function conditioningCanvas(source, plan, fill = "edge") {
|
|
7102
|
-
const meta = await
|
|
7449
|
+
const meta = await sharp20(source).metadata();
|
|
7103
7450
|
const sw = meta.width ?? 0;
|
|
7104
7451
|
const sh = meta.height ?? 0;
|
|
7105
7452
|
if (!(sw > 0 && sh > 0)) throw new Error("conditioningCanvas: source has no dimensions");
|
|
7106
7453
|
const layers = [];
|
|
7107
7454
|
if (fill === "edge") layers.push(...await edgeMargins(source, plan, { width: sw, height: sh }));
|
|
7108
7455
|
layers.push({ input: source, left: plan.left, top: plan.top });
|
|
7109
|
-
const canvas =
|
|
7456
|
+
const canvas = sharp20({
|
|
7110
7457
|
create: {
|
|
7111
7458
|
width: plan.width,
|
|
7112
7459
|
height: plan.height,
|
|
@@ -7118,7 +7465,7 @@ async function conditioningCanvas(source, plan, fill = "edge") {
|
|
|
7118
7465
|
}
|
|
7119
7466
|
async function edgeMargins(source, plan, size) {
|
|
7120
7467
|
const out = [];
|
|
7121
|
-
const strip = async (extract, width, height) =>
|
|
7468
|
+
const strip = async (extract, width, height) => sharp20(source).extract(extract).resize(width, height, { fit: "fill" }).png().toBuffer();
|
|
7122
7469
|
if (plan.axis === "width") {
|
|
7123
7470
|
const before = plan.left;
|
|
7124
7471
|
const after = plan.width - plan.left - size.width;
|
|
@@ -7209,12 +7556,12 @@ async function resolveOutpaintRoute(all, shot) {
|
|
|
7209
7556
|
return { engine: shot, method: "reframe", crossed: false };
|
|
7210
7557
|
}
|
|
7211
7558
|
async function driftDiff(a, b) {
|
|
7212
|
-
const metaA = await
|
|
7213
|
-
const metaB = await
|
|
7559
|
+
const metaA = await sharp20(a).metadata();
|
|
7560
|
+
const metaB = await sharp20(b).metadata();
|
|
7214
7561
|
const width = Math.min(metaA.width ?? 1, metaB.width ?? 1, 1024);
|
|
7215
7562
|
const height = Math.min(metaA.height ?? 1, metaB.height ?? 1, 1024);
|
|
7216
7563
|
const [rawA, rawB] = await Promise.all(
|
|
7217
|
-
[a, b].map((buf) =>
|
|
7564
|
+
[a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "cover" }).ensureAlpha().raw().toBuffer())
|
|
7218
7565
|
);
|
|
7219
7566
|
const out = new PNG({ width, height });
|
|
7220
7567
|
const changed = pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, diffColor: [255, 64, 64] });
|
|
@@ -7226,11 +7573,11 @@ async function driftDiff(a, b) {
|
|
|
7226
7573
|
};
|
|
7227
7574
|
}
|
|
7228
7575
|
async function changeMask(a, b, cap2 = 1024) {
|
|
7229
|
-
const metaA = await
|
|
7576
|
+
const metaA = await sharp20(a).metadata();
|
|
7230
7577
|
const width = Math.min(metaA.width ?? 1, cap2);
|
|
7231
7578
|
const height = Math.min(metaA.height ?? 1, cap2);
|
|
7232
7579
|
const [rawA, rawB] = await Promise.all(
|
|
7233
|
-
[a, b].map((buf) =>
|
|
7580
|
+
[a, b].map((buf) => sharp20(buf).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer())
|
|
7234
7581
|
);
|
|
7235
7582
|
const out = new PNG({ width, height });
|
|
7236
7583
|
pixelmatch(rawA, rawB, out.data, width, height, { threshold: 0.1, includeAA: true, diffMask: true });
|
|
@@ -7279,8 +7626,8 @@ function dilationFor(longEdge) {
|
|
|
7279
7626
|
// src/localEdit.ts
|
|
7280
7627
|
async function preserveOutsideChange(source, edited) {
|
|
7281
7628
|
try {
|
|
7282
|
-
const srcMeta = await
|
|
7283
|
-
const outMeta = await
|
|
7629
|
+
const srcMeta = await sharp20(source).metadata();
|
|
7630
|
+
const outMeta = await sharp20(edited).metadata();
|
|
7284
7631
|
if (!srcMeta.width || !srcMeta.height || !outMeta.width || !outMeta.height)
|
|
7285
7632
|
return { image: edited, outcome: "error", changed: 0 };
|
|
7286
7633
|
const sameShape = Math.abs(outMeta.width / outMeta.height - srcMeta.width / srcMeta.height) / (srcMeta.width / srcMeta.height) <= 0.01;
|
|
@@ -7290,15 +7637,15 @@ async function preserveOutsideChange(source, edited) {
|
|
|
7290
7637
|
if (outcome !== "composited") return { image: edited, outcome, changed: shape.changed };
|
|
7291
7638
|
const r = dilationFor(Math.max(shape.width, shape.height));
|
|
7292
7639
|
const rawShape = { raw: { width: shape.width, height: shape.height, channels: 1 } };
|
|
7293
|
-
const spread = await
|
|
7294
|
-
const dilated = await
|
|
7295
|
-
const feathered = await
|
|
7296
|
-
const grown = await
|
|
7297
|
-
const editedRgb = await
|
|
7298
|
-
const masked = await
|
|
7640
|
+
const spread = await sharp20(shape.mask, rawShape).blur(r).toColourspace("b-w").raw().toBuffer();
|
|
7641
|
+
const dilated = await sharp20(spread, rawShape).threshold(8).toColourspace("b-w").raw().toBuffer();
|
|
7642
|
+
const feathered = await sharp20(dilated, rawShape).blur(Math.max(2, r / 3)).toColourspace("b-w").raw().toBuffer();
|
|
7643
|
+
const grown = await sharp20(feathered, rawShape).resize(srcMeta.width, srcMeta.height, { fit: "fill", kernel: "cubic" }).toColourspace("b-w").raw().toBuffer();
|
|
7644
|
+
const editedRgb = await sharp20(edited).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).removeAlpha().raw().toBuffer();
|
|
7645
|
+
const masked = await sharp20(editedRgb, {
|
|
7299
7646
|
raw: { width: srcMeta.width, height: srcMeta.height, channels: 3 }
|
|
7300
7647
|
}).joinChannel(grown, { raw: { width: srcMeta.width, height: srcMeta.height, channels: 1 } }).png().toBuffer();
|
|
7301
|
-
const image = await
|
|
7648
|
+
const image = await sharp20(source).removeAlpha().composite([{ input: masked }]).png().toBuffer();
|
|
7302
7649
|
return { image, outcome: "composited", changed: shape.changed };
|
|
7303
7650
|
} catch {
|
|
7304
7651
|
return { image: edited, outcome: "error", changed: 0 };
|
|
@@ -7336,7 +7683,7 @@ function registerLogoRoutes(app, deps) {
|
|
|
7336
7683
|
const v = validateBrand(json);
|
|
7337
7684
|
if (!v.valid) return reply.status(400).send({ error: "brand became invalid", details: v.errors });
|
|
7338
7685
|
const row = core.store.updateBrand(brand.id, json);
|
|
7339
|
-
const meta = await
|
|
7686
|
+
const meta = await sharp20(core.images.read(part.hash)).metadata().catch(() => null);
|
|
7340
7687
|
const logoEdge = meta ? Math.max(meta.width ?? 0, meta.height ?? 0) || null : null;
|
|
7341
7688
|
return { ...row, logoHash: part.hash, logoEdge };
|
|
7342
7689
|
});
|
|
@@ -7463,7 +7810,7 @@ async function vibrantColor(input) {
|
|
|
7463
7810
|
let data;
|
|
7464
7811
|
let channels;
|
|
7465
7812
|
try {
|
|
7466
|
-
const out = await
|
|
7813
|
+
const out = await sharp20(input).resize(48, 48, { fit: "inside" }).raw().toBuffer({ resolveWithObject: true });
|
|
7467
7814
|
data = out.data;
|
|
7468
7815
|
channels = out.info.channels;
|
|
7469
7816
|
} catch {
|
|
@@ -7486,7 +7833,7 @@ async function vibrantColor(input) {
|
|
|
7486
7833
|
const best = buckets.reduce((a, b) => b.score > a.score ? b : a, buckets[0]);
|
|
7487
7834
|
if (best.score <= 0) {
|
|
7488
7835
|
try {
|
|
7489
|
-
const { dominant } = await
|
|
7836
|
+
const { dominant } = await sharp20(input).stats();
|
|
7490
7837
|
return toHex(dominant.r, dominant.g, dominant.b);
|
|
7491
7838
|
} catch {
|
|
7492
7839
|
return null;
|
|
@@ -7870,10 +8217,11 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
7870
8217
|
core.ledger.recordCost(engineId, null, result.costUsd);
|
|
7871
8218
|
const hash = result.images[0];
|
|
7872
8219
|
if (!hash) return reply.status(500).send({ error: "the engine returned no image" });
|
|
8220
|
+
const trimmed = await trimEdgeBars(core, hash);
|
|
7873
8221
|
commit(core, brand.id, (json) => {
|
|
7874
|
-
json.scenes = brandScenes(json).map((s) => s.id === id ? { ...s, preview: `asset:${
|
|
8222
|
+
json.scenes = brandScenes(json).map((s) => s.id === id ? { ...s, preview: `asset:${trimmed}` } : s);
|
|
7875
8223
|
});
|
|
7876
|
-
return { preview: `asset:${
|
|
8224
|
+
return { preview: `asset:${trimmed}`, brand: core.store.getBrand(brand.id) };
|
|
7877
8225
|
});
|
|
7878
8226
|
}
|
|
7879
8227
|
async function withDerivedCrops(core, body, base) {
|
|
@@ -8104,7 +8452,7 @@ async function buildExportZip(image, baseName, presetIds) {
|
|
|
8104
8452
|
const chosen = EXPORT_PRESETS.filter((p) => presetIds.includes(p.id));
|
|
8105
8453
|
if (chosen.length === 0) throw new Error("No valid export presets selected");
|
|
8106
8454
|
for (const p of chosen) {
|
|
8107
|
-
const buf = p.width && p.height ? await
|
|
8455
|
+
const buf = p.width && p.height ? await sharp20(image).resize(p.width, p.height, { fit: "cover", position: "attention" }).png().toBuffer() : image;
|
|
8108
8456
|
zip.file(`${baseName}-${p.id}.png`, buf);
|
|
8109
8457
|
}
|
|
8110
8458
|
return zip.generateAsync({ type: "nodebuffer" });
|
|
@@ -8265,8 +8613,8 @@ function registerImageRoutes(app, deps) {
|
|
|
8265
8613
|
if (!part) return reply.status(400).send({ error: "multipart file field required" });
|
|
8266
8614
|
const buf = await part.toBuffer();
|
|
8267
8615
|
if (buf.length === 0) return reply.status(400).send({ error: "empty file" });
|
|
8268
|
-
const fmt = (await
|
|
8269
|
-
const png = fmt === "svg" ? await toMarkPng(buf) : await
|
|
8616
|
+
const fmt = (await sharp20(buf).metadata().catch(() => null))?.format;
|
|
8617
|
+
const png = fmt === "svg" ? await toMarkPng(buf) : await sharp20(buf).rotate().png().toBuffer();
|
|
8270
8618
|
return { hash: core.images.save(png) };
|
|
8271
8619
|
});
|
|
8272
8620
|
app.post("/api/diff", async (req, reply) => {
|
|
@@ -8301,6 +8649,44 @@ function registerImageRoutes(app, deps) {
|
|
|
8301
8649
|
|
|
8302
8650
|
// src/release/notes.data.ts
|
|
8303
8651
|
var RELEASES = [
|
|
8652
|
+
{
|
|
8653
|
+
version: "0.7.1",
|
|
8654
|
+
date: "2026-08-30",
|
|
8655
|
+
title: "Four images from one brief are one set.",
|
|
8656
|
+
sections: [
|
|
8657
|
+
{
|
|
8658
|
+
heading: "Create",
|
|
8659
|
+
body: "Asking for two, three or four images returns variations of one shot rather than four readings of it. The presenter, the product, the scene and the brand hold across the set, and so does the wardrobe. What changes is the photography: each frame explores a different camera position, crop or pose within the brief you wrote."
|
|
8660
|
+
},
|
|
8661
|
+
{
|
|
8662
|
+
heading: "Presenters",
|
|
8663
|
+
body: "A selected presenter now reaches generation as a portrait, not only as full-length views, so their face carries into every image of a run instead of being rebuilt each time. Presenters built in Scenri gain a head-and-shoulders reference of their own, and their casting notes reach the shot."
|
|
8664
|
+
},
|
|
8665
|
+
{
|
|
8666
|
+
heading: "Fixes",
|
|
8667
|
+
body: "A run that takes too long keeps the images that already finished instead of throwing them away with the rest. Refining a shot conditions on the same presenter portrait the generation used."
|
|
8668
|
+
}
|
|
8669
|
+
]
|
|
8670
|
+
},
|
|
8671
|
+
{
|
|
8672
|
+
version: "0.7.0",
|
|
8673
|
+
date: "2026-08-30",
|
|
8674
|
+
title: "Your presenter stays your presenter.",
|
|
8675
|
+
sections: [
|
|
8676
|
+
{
|
|
8677
|
+
heading: "Scenes",
|
|
8678
|
+
body: "A scene lends its world, its light and its treatment, never a face. Generation now conditions on the scene\u2019s own drawn card instead of your raw upload, so the person you selected is the person in the shot. Scenes made before this release render printed treatments best after a one-tap redraw from the scene\u2019s page."
|
|
8679
|
+
},
|
|
8680
|
+
{
|
|
8681
|
+
heading: "Refine",
|
|
8682
|
+
body: "Refining light and mood no longer wears a shot down: a tonal refinement keeps the photograph\u2019s own pixels and changes only the grade, so the tenth adjustment is as sharp as the first. High-resolution refinements state their working size honestly instead of inflating it, and refining an extended shot works again."
|
|
8683
|
+
},
|
|
8684
|
+
{
|
|
8685
|
+
heading: "Presenters",
|
|
8686
|
+
body: "A presenter\u2019s identity rides with three of their views. A reference you attach cannot lend anyone its face while a presenter is selected, and a mood image carried into a refinement is never mistaken for the person in the picture."
|
|
8687
|
+
}
|
|
8688
|
+
]
|
|
8689
|
+
},
|
|
8304
8690
|
{
|
|
8305
8691
|
version: "0.6.13",
|
|
8306
8692
|
date: "2026-08-30",
|
|
@@ -9099,7 +9485,7 @@ function buildServer(opts) {
|
|
|
9099
9485
|
// Measured as stored (post-toMarkPng), so the scrape judges the same
|
|
9100
9486
|
// pixels the compiler will one day attach.
|
|
9101
9487
|
probeLongEdge: async (buf) => {
|
|
9102
|
-
const m = await
|
|
9488
|
+
const m = await sharp20(await toMarkPng(buf)).metadata();
|
|
9103
9489
|
return Math.max(m.width ?? 0, m.height ?? 0) || null;
|
|
9104
9490
|
},
|
|
9105
9491
|
createdWith: `${meta.name}/${meta.version}`
|
|
@@ -9188,7 +9574,7 @@ function buildServer(opts) {
|
|
|
9188
9574
|
fetchImpl: opts.fetchImpl,
|
|
9189
9575
|
saveAsset: async (buf) => `asset:${core.images.save(await toMarkPng(buf))}`,
|
|
9190
9576
|
probeLongEdge: async (buf) => {
|
|
9191
|
-
const m = await
|
|
9577
|
+
const m = await sharp20(await toMarkPng(buf)).metadata();
|
|
9192
9578
|
return Math.max(m.width ?? 0, m.height ?? 0) || null;
|
|
9193
9579
|
},
|
|
9194
9580
|
createdWith: `${meta.name}/${meta.version}`
|
|
@@ -9294,7 +9680,19 @@ function buildServer(opts) {
|
|
|
9294
9680
|
registerShowcaseRoutes(app, { templatesRoot });
|
|
9295
9681
|
app.get("/api/formats", async () => FORMATS);
|
|
9296
9682
|
function briefInputsOnly(brief) {
|
|
9297
|
-
const {
|
|
9683
|
+
const {
|
|
9684
|
+
inherited,
|
|
9685
|
+
rendered,
|
|
9686
|
+
croppedFrom,
|
|
9687
|
+
resizedFrom,
|
|
9688
|
+
resampledHops,
|
|
9689
|
+
steppedDown,
|
|
9690
|
+
gradeComposited,
|
|
9691
|
+
expand,
|
|
9692
|
+
crop: crop2,
|
|
9693
|
+
sourceImage,
|
|
9694
|
+
...inputs
|
|
9695
|
+
} = brief;
|
|
9298
9696
|
return inputs;
|
|
9299
9697
|
}
|
|
9300
9698
|
async function compileEditBrief(brandId, parentId, brief, engineCaps, opts2) {
|
|
@@ -9305,18 +9703,22 @@ function buildServer(opts) {
|
|
|
9305
9703
|
);
|
|
9306
9704
|
const inheritedTokens = borrowed.filter((t) => !already.has(identityTokenKey(t)));
|
|
9307
9705
|
const combined = [...brief.tokens, ...inheritedTokens];
|
|
9308
|
-
const brandJson = await
|
|
9706
|
+
const brandJson = await brandJsonWithIdentityCrops(
|
|
9309
9707
|
core,
|
|
9310
|
-
|
|
9311
|
-
presenters,
|
|
9312
|
-
await brandJsonWithResolvedDemoProducts(
|
|
9708
|
+
await brandJsonWithResolvedPresenters(
|
|
9313
9709
|
core,
|
|
9314
9710
|
templatesRoot,
|
|
9315
|
-
|
|
9316
|
-
|
|
9711
|
+
presenters,
|
|
9712
|
+
await brandJsonWithResolvedDemoProducts(
|
|
9713
|
+
core,
|
|
9714
|
+
templatesRoot,
|
|
9715
|
+
demoProducts,
|
|
9716
|
+
brandJsonWithCatalogProducts(core, brandId),
|
|
9717
|
+
combined
|
|
9718
|
+
),
|
|
9317
9719
|
combined
|
|
9318
9720
|
),
|
|
9319
|
-
combined
|
|
9721
|
+
combined.filter((t) => t.t === "character").map((t) => t.id)
|
|
9320
9722
|
);
|
|
9321
9723
|
const sceneById = sceneFor(brandJson);
|
|
9322
9724
|
const uncapped = { ...engineCaps, maxReferenceImages: 32 };
|
|
@@ -9325,6 +9727,9 @@ function buildServer(opts) {
|
|
|
9325
9727
|
);
|
|
9326
9728
|
const inheritedDirectives = [];
|
|
9327
9729
|
let inheritedMark = false;
|
|
9730
|
+
let inheritedRef = false;
|
|
9731
|
+
const inheritedProduct = inheritedTokens.some((t) => t.t === "product");
|
|
9732
|
+
const inheritedPerson = inheritedTokens.some((t) => t.t === "character");
|
|
9328
9733
|
for (const tok of inheritedTokens) {
|
|
9329
9734
|
if (tok.t === "product") {
|
|
9330
9735
|
const rec = (brandJson?.products ?? []).find((x) => x?.id === tok.id);
|
|
@@ -9343,8 +9748,12 @@ function buildServer(opts) {
|
|
|
9343
9748
|
} else if (tok.t === "mark" && !inheritedMark) {
|
|
9344
9749
|
inheritedMark = true;
|
|
9345
9750
|
inheritedDirectives.push(markEditDirective());
|
|
9751
|
+
} else if (tok.t === "ref" && !inheritedRef) {
|
|
9752
|
+
inheritedRef = true;
|
|
9753
|
+
inheritedDirectives.push(inheritedRefDirective());
|
|
9346
9754
|
}
|
|
9347
9755
|
}
|
|
9756
|
+
if (inheritedPerson) inheritedDirectives.push(personSkinDirective());
|
|
9348
9757
|
const compiled2 = compileBrief(brief, {
|
|
9349
9758
|
brand: brandJson,
|
|
9350
9759
|
images: core.images,
|
|
@@ -9354,7 +9763,11 @@ function buildServer(opts) {
|
|
|
9354
9763
|
mode: "edit",
|
|
9355
9764
|
editScope: verdict.scope,
|
|
9356
9765
|
editRemoval: verdict.removal ?? false,
|
|
9357
|
-
|
|
9766
|
+
// Kinds, not a count: the identity claim speaks only about the kinds
|
|
9767
|
+
// that actually ride. A mark-only or ref-only inheritance emits no
|
|
9768
|
+
// generic claim - markEditDirective and inheritedRefDirective speak
|
|
9769
|
+
// for themselves.
|
|
9770
|
+
inheritedIdentity: inheritedProduct || inheritedPerson ? { product: inheritedProduct, person: inheritedPerson } : false,
|
|
9358
9771
|
inheritedDirectives,
|
|
9359
9772
|
// Only the explicit op drops the dimension promise: an implicit legacy
|
|
9360
9773
|
// expansion keeps its historical prompt byte for byte.
|
|
@@ -9365,7 +9778,7 @@ function buildServer(opts) {
|
|
|
9365
9778
|
if (inheritedTokens.length) {
|
|
9366
9779
|
const identity = compileBrief(
|
|
9367
9780
|
{ tokens: inheritedTokens },
|
|
9368
|
-
{ brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById }
|
|
9781
|
+
{ brand: brandJson, images: core.images, engineCaps: uncapped, templateById: sceneById, mode: "edit" }
|
|
9369
9782
|
);
|
|
9370
9783
|
identityWarnings = identity.warnings;
|
|
9371
9784
|
const productAngles = /* @__PURE__ */ new Map();
|
|
@@ -9432,18 +9845,22 @@ function buildServer(opts) {
|
|
|
9432
9845
|
referenceCount: edit.merged.kept.length
|
|
9433
9846
|
};
|
|
9434
9847
|
}
|
|
9435
|
-
const brandJson = await
|
|
9848
|
+
const brandJson = await brandJsonWithIdentityCrops(
|
|
9436
9849
|
core,
|
|
9437
|
-
|
|
9438
|
-
presenters,
|
|
9439
|
-
await brandJsonWithResolvedDemoProducts(
|
|
9850
|
+
await brandJsonWithResolvedPresenters(
|
|
9440
9851
|
core,
|
|
9441
9852
|
templatesRoot,
|
|
9442
|
-
|
|
9443
|
-
|
|
9853
|
+
presenters,
|
|
9854
|
+
await brandJsonWithResolvedDemoProducts(
|
|
9855
|
+
core,
|
|
9856
|
+
templatesRoot,
|
|
9857
|
+
demoProducts,
|
|
9858
|
+
brandJsonWithCatalogProducts(core, brand.id),
|
|
9859
|
+
brief.tokens
|
|
9860
|
+
),
|
|
9444
9861
|
brief.tokens
|
|
9445
9862
|
),
|
|
9446
|
-
brief.tokens
|
|
9863
|
+
(brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
|
|
9447
9864
|
);
|
|
9448
9865
|
const sceneById = sceneFor(brandJson);
|
|
9449
9866
|
const compiled2 = compileBrief(brief, {
|
|
@@ -9520,11 +9937,11 @@ function buildServer(opts) {
|
|
|
9520
9937
|
const out = [];
|
|
9521
9938
|
for (const h of images) {
|
|
9522
9939
|
const buf = core.images.read(h);
|
|
9523
|
-
const meta2 = await
|
|
9940
|
+
const meta2 = await sharp20(buf).metadata().catch(() => null);
|
|
9524
9941
|
if (!meta2?.width || !meta2.height) throw new Error("engine returned an undecodable image");
|
|
9525
9942
|
const oriented = (meta2.orientation ?? 1) !== 1;
|
|
9526
9943
|
out.push(
|
|
9527
|
-
buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await
|
|
9944
|
+
buf.subarray(0, 8).equals(PNG_SIG) && !oriented ? h : core.images.save(await sharp20(buf).rotate().png().toBuffer())
|
|
9528
9945
|
);
|
|
9529
9946
|
}
|
|
9530
9947
|
return out;
|
|
@@ -9536,7 +9953,7 @@ function buildServer(opts) {
|
|
|
9536
9953
|
const out = [];
|
|
9537
9954
|
for (const h of images) {
|
|
9538
9955
|
const buf = core.images.read(h);
|
|
9539
|
-
const meta2 = await
|
|
9956
|
+
const meta2 = await sharp20(buf).metadata();
|
|
9540
9957
|
if (!meta2.width || !meta2.height) {
|
|
9541
9958
|
out.push(h);
|
|
9542
9959
|
continue;
|
|
@@ -9549,7 +9966,7 @@ function buildServer(opts) {
|
|
|
9549
9966
|
}
|
|
9550
9967
|
const w = got > target ? Math.round(meta2.height * target) : meta2.width;
|
|
9551
9968
|
const hpx = got > target ? meta2.height : Math.round(meta2.width / target);
|
|
9552
|
-
const cropped = await
|
|
9969
|
+
const cropped = await sharp20(buf).resize(w, hpx, { fit: "cover", position: "attention" }).png().toBuffer();
|
|
9553
9970
|
app.log.info(
|
|
9554
9971
|
{ nodeId, got: `${meta2.width}x${meta2.height}`, want: `${w}x${hpx}` },
|
|
9555
9972
|
"canvas: cropped a drifted frame to the asked ratio"
|
|
@@ -9568,7 +9985,7 @@ function buildServer(opts) {
|
|
|
9568
9985
|
async function assertAspect(images, expect) {
|
|
9569
9986
|
const want = expect.width / expect.height;
|
|
9570
9987
|
for (const h of images) {
|
|
9571
|
-
const meta2 = await
|
|
9988
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
9572
9989
|
if (!meta2.width || !meta2.height) continue;
|
|
9573
9990
|
const got = meta2.width / meta2.height;
|
|
9574
9991
|
if (Math.abs(got - want) / want > ASPECT_TOLERANCE)
|
|
@@ -9587,7 +10004,7 @@ function buildServer(opts) {
|
|
|
9587
10004
|
let watchdogFired = false;
|
|
9588
10005
|
const watchdog = setTimeout(() => {
|
|
9589
10006
|
watchdogFired = true;
|
|
9590
|
-
ctrl.abort();
|
|
10007
|
+
ctrl.abort(BUDGET_EXHAUSTED);
|
|
9591
10008
|
}, bound);
|
|
9592
10009
|
const startedAt = Date.now();
|
|
9593
10010
|
try {
|
|
@@ -9600,7 +10017,7 @@ function buildServer(opts) {
|
|
|
9600
10017
|
try {
|
|
9601
10018
|
const sizes = [];
|
|
9602
10019
|
for (const h of result.images) {
|
|
9603
|
-
const meta2 = await
|
|
10020
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
9604
10021
|
if (meta2.width && meta2.height) sizes.push([meta2.width, meta2.height]);
|
|
9605
10022
|
}
|
|
9606
10023
|
const node = core.store.getNode(nodeId);
|
|
@@ -9667,7 +10084,7 @@ function buildServer(opts) {
|
|
|
9667
10084
|
if (!srcHash || !core.images.has(String(srcHash)))
|
|
9668
10085
|
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
9669
10086
|
const srcBuf = core.images.read(String(srcHash));
|
|
9670
|
-
const srcMeta = await
|
|
10087
|
+
const srcMeta = await sharp20(srcBuf).metadata();
|
|
9671
10088
|
if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
|
|
9672
10089
|
const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
|
|
9673
10090
|
if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
|
|
@@ -9690,7 +10107,7 @@ function buildServer(opts) {
|
|
|
9690
10107
|
crop: window
|
|
9691
10108
|
});
|
|
9692
10109
|
const work2 = async () => ({
|
|
9693
|
-
images: [core.images.save(await
|
|
10110
|
+
images: [core.images.save(await sharp20(srcBuf).extract(window).png().toBuffer())],
|
|
9694
10111
|
costUsd: 0
|
|
9695
10112
|
});
|
|
9696
10113
|
void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
|
|
@@ -9712,6 +10129,9 @@ function buildServer(opts) {
|
|
|
9712
10129
|
let inheritedTokens = [];
|
|
9713
10130
|
let mergedEdit = null;
|
|
9714
10131
|
let editScope = "global";
|
|
10132
|
+
let gradeOnlyAsk = false;
|
|
10133
|
+
let budgetSourceHash;
|
|
10134
|
+
let sentSize;
|
|
9715
10135
|
const extraWarnings = [];
|
|
9716
10136
|
let expandPlan = null;
|
|
9717
10137
|
let expandSourceHash = null;
|
|
@@ -9726,22 +10146,29 @@ function buildServer(opts) {
|
|
|
9726
10146
|
inheritedTokens = edit.inheritedTokens;
|
|
9727
10147
|
mergedEdit = edit.merged;
|
|
9728
10148
|
editScope = edit.editScope;
|
|
10149
|
+
gradeOnlyAsk = editScope === "global" && isGradeOnlyInstruction(
|
|
10150
|
+
(brief?.tokens ?? []).filter((t) => t.t === "text").map((t) => t.v).join(" ")
|
|
10151
|
+
);
|
|
9729
10152
|
extraWarnings.push(...edit.warnings.filter((w) => !compiled2?.warnings.includes(w)));
|
|
9730
10153
|
if (!compiled2.prompt.trim() && reshape !== "extend")
|
|
9731
10154
|
return reply.status(400).send({ error: "the brief is empty" });
|
|
9732
10155
|
} else {
|
|
9733
|
-
const brandJson = await
|
|
10156
|
+
const brandJson = await brandJsonWithIdentityCrops(
|
|
9734
10157
|
core,
|
|
9735
|
-
|
|
9736
|
-
presenters,
|
|
9737
|
-
await brandJsonWithResolvedDemoProducts(
|
|
10158
|
+
await brandJsonWithResolvedPresenters(
|
|
9738
10159
|
core,
|
|
9739
10160
|
templatesRoot,
|
|
9740
|
-
|
|
9741
|
-
|
|
10161
|
+
presenters,
|
|
10162
|
+
await brandJsonWithResolvedDemoProducts(
|
|
10163
|
+
core,
|
|
10164
|
+
templatesRoot,
|
|
10165
|
+
demoProducts,
|
|
10166
|
+
brandJsonWithCatalogProducts(core, project.brandId),
|
|
10167
|
+
brief.tokens
|
|
10168
|
+
),
|
|
9742
10169
|
brief.tokens
|
|
9743
10170
|
),
|
|
9744
|
-
brief.tokens
|
|
10171
|
+
(brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
|
|
9745
10172
|
);
|
|
9746
10173
|
const sceneById = sceneFor(brandJson);
|
|
9747
10174
|
compiled2 = compileBrief(brief, {
|
|
@@ -9807,6 +10234,7 @@ function buildServer(opts) {
|
|
|
9807
10234
|
}
|
|
9808
10235
|
if (kind === "generation") {
|
|
9809
10236
|
const cap2 = engine.capabilities().maxReferenceImages;
|
|
10237
|
+
const wantedCount = Math.min(Math.max(1, Number(count)), 8);
|
|
9810
10238
|
const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
|
|
9811
10239
|
if (lostIdentity.length) {
|
|
9812
10240
|
const names = joinNames(lostIdentity.map((d) => d.label));
|
|
@@ -9818,14 +10246,23 @@ function buildServer(opts) {
|
|
|
9818
10246
|
const maxEdge = engine.capabilities().maxReferenceEdge;
|
|
9819
10247
|
const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
|
|
9820
10248
|
const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
|
|
10249
|
+
const sentRoles = referenceRoles && cap2 > 0 ? referenceRoles.slice(0, cap2) : referenceRoles ?? [];
|
|
10250
|
+
const briefText = Array.isArray(brief?.tokens) ? brief.tokens.filter((t) => t?.t === "text").map((t) => String(t?.v ?? "")).join(" ") : String(prompt ?? "");
|
|
10251
|
+
const variations = variationPlan(wantedCount, {
|
|
10252
|
+
hasPresenter: sentRoles.includes("character"),
|
|
10253
|
+
hasProduct: sentRoles.includes("product"),
|
|
10254
|
+
hasMark: sentRoles.includes("brand"),
|
|
10255
|
+
cameraFixed: shotSpecifiesCamera(briefText)
|
|
10256
|
+
});
|
|
9821
10257
|
const genReq = {
|
|
9822
10258
|
prompt: finalPrompt,
|
|
9823
10259
|
brand: ctx,
|
|
9824
10260
|
width: Number(width),
|
|
9825
10261
|
height: Number(height),
|
|
9826
|
-
count:
|
|
10262
|
+
count: wantedCount,
|
|
9827
10263
|
...sentRefs ? { referenceImages: sentRefs } : {},
|
|
9828
|
-
...
|
|
10264
|
+
...sentRoles.length && cap2 > 0 ? { referenceRoles: sentRoles } : {},
|
|
10265
|
+
...variations.length ? { variations } : {}
|
|
9829
10266
|
};
|
|
9830
10267
|
estimate = await engine.costEstimate(genReq);
|
|
9831
10268
|
work = (signal) => engine.generate(genReq, signal);
|
|
@@ -9842,11 +10279,12 @@ function buildServer(opts) {
|
|
|
9842
10279
|
const editEdge = engine.capabilities().maxReferenceEdge;
|
|
9843
10280
|
if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
|
|
9844
10281
|
const srcBuf = core.images.read(String(srcHash));
|
|
9845
|
-
const srcMeta = await
|
|
10282
|
+
const srcMeta = await sharp20(srcBuf).metadata();
|
|
9846
10283
|
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
9847
10284
|
const parentFormat = parent?.brief?.tokens?.find((t) => t?.t === "format");
|
|
9848
10285
|
const parentNominal = parentFormat && Number(parentFormat.w) > 0 && Number(parentFormat.h) > 0 ? { width: Number(parentFormat.w), height: Number(parentFormat.h) } : null;
|
|
9849
|
-
const
|
|
10286
|
+
const briefNamesShape = (brief?.tokens ?? []).some((t) => t?.t === "format");
|
|
10287
|
+
const reshapeIntended = reshape === "extend" || reshape === void 0 && briefNamesShape && !!srcMeta.width && !!srcMeta.height && !!compiled2?.width && !!compiled2?.height && wantsImplicitReshape({ width: compiled2.width, height: compiled2.height }, parentNominal, {
|
|
9850
10288
|
width: srcMeta.width,
|
|
9851
10289
|
height: srcMeta.height
|
|
9852
10290
|
});
|
|
@@ -9873,9 +10311,20 @@ function buildServer(opts) {
|
|
|
9873
10311
|
}
|
|
9874
10312
|
expectShape = { width: expandPlan.width, height: expandPlan.height };
|
|
9875
10313
|
}
|
|
10314
|
+
const editPixelBudget = runEngine.capabilities().editPixelBudget;
|
|
10315
|
+
if (!expandPlan && editPixelBudget && srcMeta.width && srcMeta.height && srcMeta.width * srcMeta.height > editPixelBudget) {
|
|
10316
|
+
sentSize = budgetSize(srcMeta.width, srcMeta.height, editPixelBudget);
|
|
10317
|
+
budgetSourceHash = core.images.save(
|
|
10318
|
+
await sharp20(srcBuf).resize(sentSize.width, sentSize.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer()
|
|
10319
|
+
);
|
|
10320
|
+
if (!gradeOnlyAsk)
|
|
10321
|
+
extraWarnings.push(
|
|
10322
|
+
`${runEngine.capabilities().displayName} refines at about ${(editPixelBudget / 1e6).toFixed(1)} megapixels, so this ${srcMeta.width}x${srcMeta.height} frame continues at ${sentSize.width}x${sentSize.height} from here on. The picture is unchanged; the stored size is now the size the engine truly drew.`
|
|
10323
|
+
);
|
|
10324
|
+
}
|
|
9876
10325
|
const editReq = {
|
|
9877
10326
|
instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
|
|
9878
|
-
sourceImage: core.images.pathFor(String(expandSourceHash ?? srcHash)),
|
|
10327
|
+
sourceImage: core.images.pathFor(String(expandSourceHash ?? budgetSourceHash ?? srcHash)),
|
|
9879
10328
|
brand: ctx,
|
|
9880
10329
|
...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
|
|
9881
10330
|
...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
|
|
@@ -9885,7 +10334,7 @@ function buildServer(opts) {
|
|
|
9885
10334
|
// engines given no size answered at whatever size they liked, the
|
|
9886
10335
|
// shrunken answer was stored, and the next refinement inherited it —
|
|
9887
10336
|
// the chain that walked shots down to thumbnails.
|
|
9888
|
-
...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : {},
|
|
10337
|
+
...expandPlan ? { width: expandPlan.width, height: expandPlan.height } : sentSize ? sentSize : srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : {},
|
|
9889
10338
|
// Only an engine that can genuinely paint a margin is told where the
|
|
9890
10339
|
// picture sits; the rest would ignore it anyway.
|
|
9891
10340
|
...expandPlan && canOutpaint2 ? {
|
|
@@ -9987,13 +10436,29 @@ function buildServer(opts) {
|
|
|
9987
10436
|
const original = editedFrom ? core.images.read(editedFrom) : null;
|
|
9988
10437
|
const localScope = kind === "edit" && !plan && editScope === "local" && original;
|
|
9989
10438
|
const enforceEditCanvas = async (images) => {
|
|
9990
|
-
const srcMeta = await
|
|
10439
|
+
const srcMeta = await sharp20(original).metadata();
|
|
9991
10440
|
if (!srcMeta.width || !srcMeta.height) return images;
|
|
9992
10441
|
const out = [];
|
|
9993
10442
|
for (const h of images) {
|
|
9994
|
-
const meta2 = await
|
|
10443
|
+
const meta2 = await sharp20(core.images.read(h)).metadata();
|
|
9995
10444
|
const got = { width: meta2.width ?? 0, height: meta2.height ?? 0 };
|
|
9996
|
-
const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got
|
|
10445
|
+
const verdict = judgeEditSize({ width: srcMeta.width, height: srcMeta.height }, got, {
|
|
10446
|
+
pixelBudget: runEngine.capabilities().editPixelBudget
|
|
10447
|
+
});
|
|
10448
|
+
if (verdict.action === "accept") {
|
|
10449
|
+
app.log.info(
|
|
10450
|
+
{ nodeId: node.id, got: `${got.width}x${got.height}`, src: `${srcMeta.width}x${srcMeta.height}` },
|
|
10451
|
+
"edit: kept the engine-native answer (pixel-budget step-down)"
|
|
10452
|
+
);
|
|
10453
|
+
try {
|
|
10454
|
+
const fresh = core.store.getNode(node.id);
|
|
10455
|
+
const b = fresh?.brief ?? {};
|
|
10456
|
+
core.store.setBrief(node.id, { ...b, steppedDown: [srcMeta.width, srcMeta.height] });
|
|
10457
|
+
} catch {
|
|
10458
|
+
}
|
|
10459
|
+
out.push(h);
|
|
10460
|
+
continue;
|
|
10461
|
+
}
|
|
9997
10462
|
if (verdict.action === "reject")
|
|
9998
10463
|
throw new Error(
|
|
9999
10464
|
`engine returned ${got.width}x${got.height} for a ${srcMeta.width}x${srcMeta.height} frame: too little of the picture came back to keep at this resolution`
|
|
@@ -10005,7 +10470,7 @@ function buildServer(opts) {
|
|
|
10005
10470
|
);
|
|
10006
10471
|
out.push(
|
|
10007
10472
|
core.images.save(
|
|
10008
|
-
await
|
|
10473
|
+
await sharp20(core.images.read(h)).resize(srcMeta.width, srcMeta.height, { fit: "fill" }).png().toBuffer()
|
|
10009
10474
|
)
|
|
10010
10475
|
);
|
|
10011
10476
|
try {
|
|
@@ -10027,7 +10492,7 @@ function buildServer(opts) {
|
|
|
10027
10492
|
const out = [];
|
|
10028
10493
|
for (const h of images) {
|
|
10029
10494
|
const answer = core.images.read(h);
|
|
10030
|
-
const got = await
|
|
10495
|
+
const got = await sharp20(answer).metadata();
|
|
10031
10496
|
if (got.width !== plan.width || got.height !== plan.height)
|
|
10032
10497
|
app.log.info(
|
|
10033
10498
|
{ nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
|
|
@@ -10053,6 +10518,26 @@ function buildServer(opts) {
|
|
|
10053
10518
|
return images;
|
|
10054
10519
|
} : kind === "edit" && original ? async (images) => {
|
|
10055
10520
|
let staged = images;
|
|
10521
|
+
if (gradeOnlyAsk && !expandPlan) {
|
|
10522
|
+
const sent = budgetSourceHash ? core.images.read(budgetSourceHash) : original;
|
|
10523
|
+
const out = [];
|
|
10524
|
+
for (const h of staged) {
|
|
10525
|
+
const g = await gradeComposite(original, sent, core.images.read(h));
|
|
10526
|
+
if (g) {
|
|
10527
|
+
app.log.info(
|
|
10528
|
+
{ nodeId: node.id, residual: Number(g.residual.toFixed(2)) },
|
|
10529
|
+
"edit: shipped the original pixels wearing the grade"
|
|
10530
|
+
);
|
|
10531
|
+
out.push(core.images.save(g.image));
|
|
10532
|
+
try {
|
|
10533
|
+
const fresh = core.store.getNode(node.id);
|
|
10534
|
+
core.store.setBrief(node.id, { ...fresh?.brief ?? {}, gradeComposited: true });
|
|
10535
|
+
} catch {
|
|
10536
|
+
}
|
|
10537
|
+
} else out.push(h);
|
|
10538
|
+
}
|
|
10539
|
+
staged = out;
|
|
10540
|
+
}
|
|
10056
10541
|
if (localScope) {
|
|
10057
10542
|
const out = [];
|
|
10058
10543
|
for (const h of staged) {
|
|
@@ -10066,7 +10551,8 @@ function buildServer(opts) {
|
|
|
10066
10551
|
}
|
|
10067
10552
|
return enforceEditCanvas(staged);
|
|
10068
10553
|
} : kind === "generation" && compiled2?.width && compiled2?.height ? conformToCanvas(node.id, { width: compiled2.width, height: compiled2.height }) : void 0;
|
|
10069
|
-
const
|
|
10554
|
+
const runCaps = runEngine.capabilities();
|
|
10555
|
+
const nodeBudgetMs = kind === "generation" && runCaps.perImageTimeoutMs ? Math.ceil(Math.min(Math.max(1, Number(count)), 8) / Math.max(1, runCaps.imageConcurrency ?? 1)) * runCaps.perImageTimeoutMs + 6e4 : void 0;
|
|
10070
10556
|
void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
|
|
10071
10557
|
(err) => app.log.error({ err }, "node run failed")
|
|
10072
10558
|
);
|
|
@@ -10328,8 +10814,8 @@ async function verify() {
|
|
|
10328
10814
|
const db = new Database2(":memory:");
|
|
10329
10815
|
db.pragma("user_version");
|
|
10330
10816
|
db.close();
|
|
10331
|
-
const { default:
|
|
10332
|
-
await
|
|
10817
|
+
const { default: sharp21 } = await import('sharp');
|
|
10818
|
+
await sharp21({ create: { width: 1, height: 1, channels: 3, background: "#000" } }).png().toBuffer();
|
|
10333
10819
|
console.log(JSON.stringify({ ok: true, version: readMeta().version }));
|
|
10334
10820
|
} catch (err) {
|
|
10335
10821
|
console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
|