scenri 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/serve.js +489 -111
- package/package.json +1 -1
- package/studio-dist/assets/{index-pvL9RRJ6.js → index-C-khfwCq.js} +21 -21
- package/studio-dist/index.html +1 -1
package/dist/serve.js
CHANGED
|
@@ -1315,6 +1315,7 @@ var EDIT_REFERENCE_ROLE_DIRECTIVE = {
|
|
|
1315
1315
|
style: "a reference for treatment and mood only",
|
|
1316
1316
|
reference: "a reference for composition, lighting and treatment only"
|
|
1317
1317
|
};
|
|
1318
|
+
var BUDGET_EXHAUSTED = "scenri:budget-exhausted";
|
|
1318
1319
|
var ASPECT_TOLERANCE = 0.15;
|
|
1319
1320
|
var NAMED_RATIOS = [
|
|
1320
1321
|
["1:1", 1],
|
|
@@ -1363,6 +1364,7 @@ function createCore(homeDir = defaultHome()) {
|
|
|
1363
1364
|
};
|
|
1364
1365
|
}
|
|
1365
1366
|
var ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
|
|
1367
|
+
var PER_IMAGE_TIMEOUT_MS = 3e5;
|
|
1366
1368
|
var DEFAULT_MODEL = "google/gemini-2.5-flash-image";
|
|
1367
1369
|
var DEFAULT_COST_PER_IMAGE_USD = 0.04;
|
|
1368
1370
|
function dataUrl(path) {
|
|
@@ -1401,6 +1403,7 @@ function createOpenRouterEngine(opts) {
|
|
|
1401
1403
|
return key;
|
|
1402
1404
|
}
|
|
1403
1405
|
async function post(key, body, signal) {
|
|
1406
|
+
const bound = AbortSignal.timeout(PER_IMAGE_TIMEOUT_MS);
|
|
1404
1407
|
const res = await fetchImpl(ENDPOINT, {
|
|
1405
1408
|
method: "POST",
|
|
1406
1409
|
headers: {
|
|
@@ -1408,7 +1411,7 @@ function createOpenRouterEngine(opts) {
|
|
|
1408
1411
|
"Content-Type": "application/json"
|
|
1409
1412
|
},
|
|
1410
1413
|
body: JSON.stringify(body),
|
|
1411
|
-
signal
|
|
1414
|
+
signal: signal ? AbortSignal.any([signal, bound]) : bound
|
|
1412
1415
|
});
|
|
1413
1416
|
const text = await res.text();
|
|
1414
1417
|
if (!res.ok) {
|
|
@@ -1451,7 +1454,11 @@ function createOpenRouterEngine(opts) {
|
|
|
1451
1454
|
localOnly: false,
|
|
1452
1455
|
supportsEdit: true,
|
|
1453
1456
|
supportsMask: false,
|
|
1454
|
-
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
|
|
1455
1462
|
};
|
|
1456
1463
|
},
|
|
1457
1464
|
async isAvailable() {
|
|
@@ -1492,7 +1499,12 @@ function createOpenRouterEngine(opts) {
|
|
|
1492
1499
|
let reportedCost = 0;
|
|
1493
1500
|
let sawReportedCost = false;
|
|
1494
1501
|
for (let i = 0; i < req.count; i++) {
|
|
1495
|
-
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
|
+
);
|
|
1496
1508
|
raws.push(json);
|
|
1497
1509
|
for (const buf of extractImages(json)) hashes.push(opts.saveImage(buf));
|
|
1498
1510
|
if (typeof json?.usage?.cost === "number") {
|
|
@@ -2527,9 +2539,6 @@ function createCodexSetup(opts = {}) {
|
|
|
2527
2539
|
|
|
2528
2540
|
// ../engines/codex/src/index.ts
|
|
2529
2541
|
var CODEX_POOL = 2;
|
|
2530
|
-
function codexNodeBudgetMs(count) {
|
|
2531
|
-
return Math.ceil(Math.max(1, count) / CODEX_POOL) * DEFAULT_TIMEOUT_MS2 + 6e4;
|
|
2532
|
-
}
|
|
2533
2542
|
function orientationOf(width, height) {
|
|
2534
2543
|
return width === height ? "square" : width > height ? "landscape" : "portrait";
|
|
2535
2544
|
}
|
|
@@ -2561,7 +2570,7 @@ function createCodexEngine(opts) {
|
|
|
2561
2570
|
return /* @__PURE__ */ new Set();
|
|
2562
2571
|
}
|
|
2563
2572
|
}
|
|
2564
|
-
async function collectImages(dir, before = null) {
|
|
2573
|
+
async function collectImages(dir, before = null, claimed) {
|
|
2565
2574
|
const entries = await readdir(dir);
|
|
2566
2575
|
const outFiles = entries.filter((name) => /^out-.*\.png$/.test(name)).sort((a, b) => {
|
|
2567
2576
|
const na = Number(/^out-(\d+)\.png$/.exec(a)?.[1] ?? NaN);
|
|
@@ -2571,7 +2580,7 @@ function createCodexEngine(opts) {
|
|
|
2571
2580
|
});
|
|
2572
2581
|
if (outFiles.length === 0) {
|
|
2573
2582
|
if (before) {
|
|
2574
|
-
const recovered = await recoverFromGenerated(before);
|
|
2583
|
+
const recovered = await recoverFromGenerated(before, claimed);
|
|
2575
2584
|
if (recovered) return [recovered];
|
|
2576
2585
|
}
|
|
2577
2586
|
throw new Error("Codex finished but produced no images");
|
|
@@ -2584,11 +2593,11 @@ function createCodexEngine(opts) {
|
|
|
2584
2593
|
}
|
|
2585
2594
|
return hashes;
|
|
2586
2595
|
}
|
|
2587
|
-
async function recoverFromGenerated(before) {
|
|
2596
|
+
async function recoverFromGenerated(before, claimed) {
|
|
2588
2597
|
const home = generatedImagesDir();
|
|
2589
2598
|
let names;
|
|
2590
2599
|
try {
|
|
2591
|
-
names = (await readdir(home)).filter((n) => !before.has(n));
|
|
2600
|
+
names = (await readdir(home)).filter((n) => !before.has(n) && !claimed?.has(n));
|
|
2592
2601
|
} catch {
|
|
2593
2602
|
return null;
|
|
2594
2603
|
}
|
|
@@ -2596,6 +2605,7 @@ function createCodexEngine(opts) {
|
|
|
2596
2605
|
const stamped = await Promise.all(names.map(async (n) => ({ n, mtime: (await stat(join(home, n))).mtimeMs })));
|
|
2597
2606
|
stamped.sort((a, b) => b.mtime - a.mtime);
|
|
2598
2607
|
const pick2 = stamped[0].n;
|
|
2608
|
+
claimed?.add(pick2);
|
|
2599
2609
|
console.warn(`codex: workdir empty, recovered ${pick2} from ${home}`);
|
|
2600
2610
|
return saveImage(await readFile(join(home, pick2)));
|
|
2601
2611
|
}
|
|
@@ -2645,7 +2655,12 @@ function createCodexEngine(opts) {
|
|
|
2645
2655
|
* budget — a full-resolution phone-photo PNG is tens of megabytes that
|
|
2646
2656
|
* buy nothing. Same cap as brand marks (MARK_MAX_EDGE).
|
|
2647
2657
|
*/
|
|
2648
|
-
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
|
|
2649
2664
|
};
|
|
2650
2665
|
},
|
|
2651
2666
|
isAvailable() {
|
|
@@ -2659,8 +2674,9 @@ function createCodexEngine(opts) {
|
|
|
2659
2674
|
const refs = req.referenceImages ?? [];
|
|
2660
2675
|
const roles = req.referenceRoles ?? refs.map(() => "reference");
|
|
2661
2676
|
const inner = new AbortController();
|
|
2662
|
-
const
|
|
2663
|
-
|
|
2677
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
2678
|
+
const onOuterAbort = () => inner.abort(signal?.reason);
|
|
2679
|
+
if (signal?.aborted) inner.abort(signal.reason);
|
|
2664
2680
|
else signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
2665
2681
|
const jobs = Array.from(
|
|
2666
2682
|
{ length: count },
|
|
@@ -2679,7 +2695,7 @@ function createCodexEngine(opts) {
|
|
|
2679
2695
|
stdin: buildPrompt2(req, i, roles),
|
|
2680
2696
|
label: `gen v${i + 1}/${count} refs=${refs.length} refKB=${Math.round(refBytes / 1024)}`
|
|
2681
2697
|
});
|
|
2682
|
-
return collectImages(dir, before);
|
|
2698
|
+
return collectImages(dir, before, claimed);
|
|
2683
2699
|
})
|
|
2684
2700
|
);
|
|
2685
2701
|
const results = new Array(count);
|
|
@@ -2693,7 +2709,7 @@ function createCodexEngine(opts) {
|
|
|
2693
2709
|
try {
|
|
2694
2710
|
results[i] = await jobs[i]();
|
|
2695
2711
|
} catch (err) {
|
|
2696
|
-
if (signal?.aborted) throw err;
|
|
2712
|
+
if (signal?.aborted && signal.reason !== BUDGET_EXHAUSTED) throw err;
|
|
2697
2713
|
results[i] = [];
|
|
2698
2714
|
failures.push(err);
|
|
2699
2715
|
if (fatal == null && isFatalSetupError(err)) {
|
|
@@ -2776,10 +2792,10 @@ function createCodexEngine(opts) {
|
|
|
2776
2792
|
);
|
|
2777
2793
|
}
|
|
2778
2794
|
function buildPrompt2(req, index, roles) {
|
|
2795
|
+
const variation = req.variations?.[index] ?? "";
|
|
2779
2796
|
const roleDirective = REFERENCE_ROLE_DIRECTIVE;
|
|
2780
2797
|
const names = refFileNames(roles, roles.length);
|
|
2781
2798
|
const refDirectives = roles.map((role, i) => `${names[i]} shows ${roleDirective[role]}.`).join(" ");
|
|
2782
|
-
const count = Math.max(1, req.count);
|
|
2783
2799
|
const native = codexNativeSize(req.width, req.height);
|
|
2784
2800
|
return (
|
|
2785
2801
|
// "professional-grade", not "flawless": the audit of the waxy-presenter
|
|
@@ -2793,13 +2809,19 @@ function createCodexEngine(opts) {
|
|
|
2793
2809
|
// to the requested one, and the aspect check passed BECAUSE of the shear
|
|
2794
2810
|
// - the reported crushed faces. Copy/move stays licensed because the
|
|
2795
2811
|
// win32 recovery path moves files out of generated_images.
|
|
2796
|
-
` 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.` + //
|
|
2797
|
-
//
|
|
2798
|
-
//
|
|
2799
|
-
//
|
|
2800
|
-
//
|
|
2801
|
-
//
|
|
2802
|
-
|
|
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}` : "")
|
|
2803
2825
|
);
|
|
2804
2826
|
}
|
|
2805
2827
|
}
|
|
@@ -3015,14 +3037,22 @@ function presenterRefPath(templatesRoot, id, slot) {
|
|
|
3015
3037
|
function presenterAvatarPath(templatesRoot, id) {
|
|
3016
3038
|
return contentFile(templatesRoot, "previews", "presenters", id, "avatar.jpg");
|
|
3017
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
|
+
}
|
|
3018
3048
|
async function resolvePresenterImages(core, templatesRoot, presenter) {
|
|
3019
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 });
|
|
3020
3052
|
for (const [slot, angle] of PRESENTER_ANGLES) {
|
|
3021
3053
|
const path = presenterRefPath(templatesRoot, presenter.id, slot);
|
|
3022
3054
|
if (!existsSync(path)) continue;
|
|
3023
|
-
|
|
3024
|
-
const hash = core.images.save(png);
|
|
3025
|
-
shots.push({ file: `asset:${hash}`, angle, locked: true });
|
|
3055
|
+
shots.push({ file: `asset:${await refHash(core, path)}`, angle, locked: true });
|
|
3026
3056
|
}
|
|
3027
3057
|
if (!shots.length) return null;
|
|
3028
3058
|
return {
|
|
@@ -3031,6 +3061,8 @@ async function resolvePresenterImages(core, templatesRoot, presenter) {
|
|
|
3031
3061
|
...presenter.identityNotes ? { identityNotes: presenter.identityNotes } : {},
|
|
3032
3062
|
...presenter.negativeConstraints?.length ? { negativeConstraints: presenter.negativeConstraints } : {},
|
|
3033
3063
|
...presenter.skin ? { skin: presenter.skin } : {},
|
|
3064
|
+
...presenter.facial ? { facial: presenter.facial } : {},
|
|
3065
|
+
...presenter.build ? { build: presenter.build } : {},
|
|
3034
3066
|
shots
|
|
3035
3067
|
};
|
|
3036
3068
|
}
|
|
@@ -3288,6 +3320,7 @@ async function capReferenceEdge(core, path, maxEdge) {
|
|
|
3288
3320
|
out = core.images.pathFor(core.images.save(buf));
|
|
3289
3321
|
}
|
|
3290
3322
|
} catch {
|
|
3323
|
+
return path;
|
|
3291
3324
|
}
|
|
3292
3325
|
cappedRefs.set(key, out);
|
|
3293
3326
|
return out;
|
|
@@ -3318,6 +3351,9 @@ function productFidelityDirective(attached) {
|
|
|
3318
3351
|
}
|
|
3319
3352
|
return "The attached product images all show the exact product to feature: preserve its label, shape and proportions faithfully, do not redesign it, and never treat an extra image as an additional product. The first product image is the authority for its color, finish and material. Where another image differs in color or finish, it shows the same product in another colorway \u2014 never blend colorways, and render the one the first image shows. Any face not visible in them is unknown \u2014 keep it plain and consistent with the materials the first image shows, and do not invent detail on it. If the direction above explicitly asks for more than one colorway, that explicit request wins.";
|
|
3320
3353
|
}
|
|
3354
|
+
function extendPreservationDirective() {
|
|
3355
|
+
return "This grows the frame of a photograph that already exists; it does not restage it. The photograph in hand is the shot: the same person with the same face and the same clothing, the same product with the same label, geometry and colour, each at the same size, in the same place, under the same light. New area only continues the same scene past the original edges. Do not redesign the product, replace the person, change what anyone wears, or move the camera nearer or further away.";
|
|
3356
|
+
}
|
|
3321
3357
|
function editPreservationDirective(scope, opts) {
|
|
3322
3358
|
if (scope === "local") {
|
|
3323
3359
|
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." : "";
|
|
@@ -3557,17 +3593,25 @@ function compileBrief(brief, ctx) {
|
|
|
3557
3593
|
append(p.promptName ?? p.name);
|
|
3558
3594
|
const primary = tok.angle && p.shots?.find((s) => s.angle === tok.angle) || p.shots?.[0];
|
|
3559
3595
|
const orderedShots = [primary, ...(p.shots ?? []).filter((s) => s && s !== primary)];
|
|
3560
|
-
const
|
|
3596
|
+
const pshots = [];
|
|
3561
3597
|
for (const s of orderedShots) {
|
|
3562
|
-
if (
|
|
3598
|
+
if (pshots.length >= PRODUCT_REF_MAX) break;
|
|
3563
3599
|
const h = assetHash2(s?.file);
|
|
3564
|
-
if (h && ctx.images.has(h) && !
|
|
3600
|
+
if (h && ctx.images.has(h) && !pshots.some((x) => x.h === h))
|
|
3601
|
+
pshots.push({ h, ...s?.angle ? { angle: String(s.angle) } : {} });
|
|
3565
3602
|
}
|
|
3566
|
-
if (
|
|
3567
|
-
|
|
3568
|
-
attachments.push({
|
|
3603
|
+
if (pshots.length) {
|
|
3604
|
+
pshots.forEach(({ h, angle }, i) => {
|
|
3605
|
+
attachments.push({
|
|
3606
|
+
role: "product",
|
|
3607
|
+
id: p.id,
|
|
3608
|
+
label: p.name,
|
|
3609
|
+
hash: h,
|
|
3610
|
+
essential: i === 0,
|
|
3611
|
+
...angle ? { angle } : {}
|
|
3612
|
+
});
|
|
3569
3613
|
});
|
|
3570
|
-
productDirectives.push(productFidelityDirective(
|
|
3614
|
+
productDirectives.push(productFidelityDirective(pshots.length));
|
|
3571
3615
|
productDirectives.push(...productFactDirectives(p));
|
|
3572
3616
|
if (p.description && !p.dimensions)
|
|
3573
3617
|
productDirectives.push(
|
|
@@ -3586,10 +3630,17 @@ function compileBrief(brief, ctx) {
|
|
|
3586
3630
|
}
|
|
3587
3631
|
hasPerson = true;
|
|
3588
3632
|
append(c.promptName ?? c.name);
|
|
3589
|
-
const
|
|
3590
|
-
if (
|
|
3591
|
-
|
|
3592
|
-
attachments.push({
|
|
3633
|
+
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));
|
|
3634
|
+
if (cshots.length) {
|
|
3635
|
+
cshots.forEach(({ h, angle }, i) => {
|
|
3636
|
+
attachments.push({
|
|
3637
|
+
role: "character",
|
|
3638
|
+
id: c.id,
|
|
3639
|
+
label: c.name,
|
|
3640
|
+
hash: h,
|
|
3641
|
+
essential: i === 0,
|
|
3642
|
+
...angle ? { angle } : {}
|
|
3643
|
+
});
|
|
3593
3644
|
});
|
|
3594
3645
|
personDirectives.push(
|
|
3595
3646
|
`${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.`
|
|
@@ -3602,6 +3653,11 @@ function compileBrief(brief, ctx) {
|
|
|
3602
3653
|
personDirectives.push(
|
|
3603
3654
|
`${c.promptName ?? c.name}'s skin, exactly as the reference photographs show it: ${c.skin}.`
|
|
3604
3655
|
);
|
|
3656
|
+
if (c.facial)
|
|
3657
|
+
personDirectives.push(
|
|
3658
|
+
`${c.promptName ?? c.name}'s face, which must survive every generation unchanged: ${c.facial}.`
|
|
3659
|
+
);
|
|
3660
|
+
if (c.build) personDirectives.push(`${c.promptName ?? c.name}'s build: ${c.build}.`);
|
|
3605
3661
|
} else {
|
|
3606
3662
|
warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
|
|
3607
3663
|
}
|
|
@@ -3757,7 +3813,13 @@ function compileBrief(brief, ctx) {
|
|
|
3757
3813
|
] : [];
|
|
3758
3814
|
const brandLines = brandRuleDirectives(ctx.brand);
|
|
3759
3815
|
const preservation = ctx.mode === "edit" ? [
|
|
3760
|
-
|
|
3816
|
+
// An extend gets its own preservation language: the global
|
|
3817
|
+
// directive's "same framing, same dimensions" lines contradict a
|
|
3818
|
+
// frame that is deliberately growing, but dropping preservation
|
|
3819
|
+
// altogether left the redrawn-frame arm with nothing protecting
|
|
3820
|
+
// the person, the product or the wardrobe. See
|
|
3821
|
+
// extendPreservationDirective.
|
|
3822
|
+
...ctx.editReshape === "extend" ? [extendPreservationDirective()] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
|
|
3761
3823
|
...ctx.inheritedIdentity ? [inheritedIdentityDirective(ctx.inheritedIdentity === true ? void 0 : ctx.inheritedIdentity)] : [],
|
|
3762
3824
|
...ctx.inheritedDirectives ?? []
|
|
3763
3825
|
] : [];
|
|
@@ -5984,7 +6046,8 @@ function presenterRecordFrom(input, base) {
|
|
|
5984
6046
|
const has = (k) => input[k] !== void 0;
|
|
5985
6047
|
const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
|
|
5986
6048
|
if (!name) return { ok: false, error: "a presenter needs a name" };
|
|
5987
|
-
const
|
|
6049
|
+
const angles = has("shotAngles") ? strList(input.shotAngles, 8, 32) : [];
|
|
6050
|
+
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 ?? [];
|
|
5988
6051
|
if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
|
|
5989
6052
|
const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
|
|
5990
6053
|
const presenter = {
|
|
@@ -6122,6 +6185,26 @@ async function runBuild(deps, job, hashes, instruction, signal) {
|
|
|
6122
6185
|
}
|
|
6123
6186
|
}
|
|
6124
6187
|
var STUDIO_FRAMES = [
|
|
6188
|
+
/*
|
|
6189
|
+
* The identity frame, and it comes first because that is the order a brief
|
|
6190
|
+
* attaches: `shots[0]` is the essential character reference.
|
|
6191
|
+
*
|
|
6192
|
+
* Every other frame here is full-length head-to-toe, which is right for
|
|
6193
|
+
* build, proportion and wardrobe and useless for a face — in a 1024x1280
|
|
6194
|
+
* full-length frame the face is about 105px brow to chin, while a portrait
|
|
6195
|
+
* output renders it at four times that. Measured 2026-08-30 against the
|
|
6196
|
+
* reported failure: four outputs of one brief, four different jaws, and
|
|
6197
|
+
* drift that tracked nothing but how big the face was in the output.
|
|
6198
|
+
*
|
|
6199
|
+
* Drawn `from: 'sources'` rather than chained off the front view, because
|
|
6200
|
+
* the user's own photographs are the only real face evidence in the system
|
|
6201
|
+
* and a chain would just enlarge the same 105px.
|
|
6202
|
+
*/
|
|
6203
|
+
{
|
|
6204
|
+
angle: "portrait",
|
|
6205
|
+
from: "sources",
|
|
6206
|
+
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`
|
|
6207
|
+
},
|
|
6125
6208
|
{
|
|
6126
6209
|
angle: "front",
|
|
6127
6210
|
from: "sources",
|
|
@@ -6174,22 +6257,28 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
|
|
|
6174
6257
|
}
|
|
6175
6258
|
if (signal.aborted) throw new Error("cancelled");
|
|
6176
6259
|
let shotHashes = hashes;
|
|
6260
|
+
let shotAngles = [];
|
|
6177
6261
|
const warnings = [];
|
|
6178
6262
|
if (deps.engine) {
|
|
6179
6263
|
patch(job, { stage: "building", steps: STUDIO_FRAMES.length, message: "Building the studio views" });
|
|
6180
6264
|
const built2 = await generateStudioSet(deps, job, whoIs(job.name, draft), sourcePaths, signal);
|
|
6181
|
-
if (built2.length)
|
|
6182
|
-
|
|
6265
|
+
if (built2.hashes.length) {
|
|
6266
|
+
shotHashes = built2.hashes;
|
|
6267
|
+
shotAngles = built2.angles;
|
|
6268
|
+
} else warnings.push("The studio views could not be drawn, so the photos are being used directly.");
|
|
6183
6269
|
} else {
|
|
6184
6270
|
warnings.push("No engine could draw the studio views, so the photos are being used directly.");
|
|
6185
6271
|
}
|
|
6186
6272
|
if (signal.aborted) throw new Error("cancelled");
|
|
6187
6273
|
patch(job, { stage: "saving", message: null });
|
|
6188
6274
|
const generated = shotHashes !== hashes;
|
|
6189
|
-
const
|
|
6275
|
+
const frontIndex = shotAngles.indexOf("front");
|
|
6276
|
+
const cardSource = frontIndex === -1 ? shotHashes[0] : shotHashes[frontIndex];
|
|
6277
|
+
const { previewHash, avatarHash } = await presenterCrops(core, cardSource, generated ? "generated" : "upload");
|
|
6190
6278
|
const built = presenterRecordFrom({
|
|
6191
6279
|
name: job.name,
|
|
6192
6280
|
shotHashes,
|
|
6281
|
+
shotAngles,
|
|
6193
6282
|
sourceHashes: hashes,
|
|
6194
6283
|
previewHash,
|
|
6195
6284
|
avatarHash,
|
|
@@ -6212,16 +6301,16 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
|
|
|
6212
6301
|
stage: "done",
|
|
6213
6302
|
step: job.steps,
|
|
6214
6303
|
assetId: built.presenter.id,
|
|
6215
|
-
previewHash: previewHash ??
|
|
6304
|
+
previewHash: previewHash ?? cardSource ?? null,
|
|
6216
6305
|
warnings: [...job.warnings, ...warnings],
|
|
6217
6306
|
finished: true
|
|
6218
6307
|
});
|
|
6219
6308
|
}
|
|
6220
6309
|
async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
6221
6310
|
const engine = deps.engine;
|
|
6222
|
-
if (!engine) return [];
|
|
6311
|
+
if (!engine) return { hashes: [], angles: [] };
|
|
6223
6312
|
const caps = engine.capabilities();
|
|
6224
|
-
if (!caps.maxReferenceImages) return [];
|
|
6313
|
+
if (!caps.maxReferenceImages) return { hashes: [], angles: [] };
|
|
6225
6314
|
const byAngle = /* @__PURE__ */ new Map();
|
|
6226
6315
|
for (const frame of STUDIO_FRAMES) {
|
|
6227
6316
|
if (signal.aborted) throw new Error("cancelled");
|
|
@@ -6248,7 +6337,8 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
|
6248
6337
|
patch(job, { warnings: [...job.warnings, `The ${frame.angle} view could not be drawn.`] });
|
|
6249
6338
|
}
|
|
6250
6339
|
}
|
|
6251
|
-
|
|
6340
|
+
const kept = STUDIO_FRAMES.filter((f) => byAngle.get(f.angle));
|
|
6341
|
+
return { hashes: kept.map((f) => byAngle.get(f.angle)), angles: kept.map((f) => f.angle) };
|
|
6252
6342
|
}
|
|
6253
6343
|
async function edgeBarGeometry(buf) {
|
|
6254
6344
|
const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
@@ -6346,6 +6436,64 @@ async function avatarCrop(core, hash) {
|
|
|
6346
6436
|
AVATAR_MAX_PX
|
|
6347
6437
|
);
|
|
6348
6438
|
}
|
|
6439
|
+
var IDENTITY_FIGURE_FRACTION = 0.26;
|
|
6440
|
+
var IDENTITY_HEADROOM = 0.08;
|
|
6441
|
+
var IDENTITY_ASPECT = 0.66;
|
|
6442
|
+
var IDENTITY_TARGET_HEIGHT = 1280;
|
|
6443
|
+
var IDENTITY_MAX_UPSCALE = 3;
|
|
6444
|
+
var STANDING_FIGURE_RATIO = 2.2;
|
|
6445
|
+
async function identityCrop(core, hash) {
|
|
6446
|
+
if (!hash || !core.images.has(hash)) return void 0;
|
|
6447
|
+
const hit = identityCrops.get(hash);
|
|
6448
|
+
if (hit && core.images.has(hit)) return hit;
|
|
6449
|
+
let box = null;
|
|
6450
|
+
try {
|
|
6451
|
+
box = await figureBox(core.images.read(hash));
|
|
6452
|
+
} catch {
|
|
6453
|
+
box = null;
|
|
6454
|
+
}
|
|
6455
|
+
if (!box) return void 0;
|
|
6456
|
+
if (box.height / Math.max(1, box.width) < STANDING_FIGURE_RATIO) return void 0;
|
|
6457
|
+
let nativeHeight = 0;
|
|
6458
|
+
const out = await crop(core, hash, (w, h) => {
|
|
6459
|
+
const height = Math.min(h, Math.max(16, Math.round(box.height * IDENTITY_FIGURE_FRACTION)));
|
|
6460
|
+
const width = Math.min(w, Math.max(16, Math.round(height * IDENTITY_ASPECT)));
|
|
6461
|
+
nativeHeight = height;
|
|
6462
|
+
const top = Math.min(Math.max(0, Math.round(box.top - height * IDENTITY_HEADROOM)), h - height);
|
|
6463
|
+
const left = Math.min(Math.max(0, Math.round(box.left + box.width / 2 - width / 2)), w - width);
|
|
6464
|
+
return { left, top, width, height };
|
|
6465
|
+
});
|
|
6466
|
+
if (!out) return void 0;
|
|
6467
|
+
try {
|
|
6468
|
+
const height = Math.min(IDENTITY_TARGET_HEIGHT, Math.round(nativeHeight * IDENTITY_MAX_UPSCALE)) || IDENTITY_TARGET_HEIGHT;
|
|
6469
|
+
const png = await sharp20(core.images.read(out)).resize({ height, fit: "inside", kernel: "lanczos3", withoutEnlargement: false }).png().toBuffer();
|
|
6470
|
+
const scaled = core.images.save(png);
|
|
6471
|
+
identityCrops.set(hash, scaled);
|
|
6472
|
+
return scaled;
|
|
6473
|
+
} catch {
|
|
6474
|
+
identityCrops.set(hash, out);
|
|
6475
|
+
return out;
|
|
6476
|
+
}
|
|
6477
|
+
}
|
|
6478
|
+
var identityCrops = /* @__PURE__ */ new Map();
|
|
6479
|
+
async function brandJsonWithIdentityCrops(core, json, characterIds) {
|
|
6480
|
+
const wanted = new Set(characterIds);
|
|
6481
|
+
const roster = json?.characters ?? [];
|
|
6482
|
+
if (!wanted.size || !roster.length) return json;
|
|
6483
|
+
let changed = false;
|
|
6484
|
+
const characters = await Promise.all(
|
|
6485
|
+
roster.map(async (c) => {
|
|
6486
|
+
if (!wanted.has(c?.id) || !c?.shots?.length) return c;
|
|
6487
|
+
if (c.shots[0]?.angle === "portrait") return c;
|
|
6488
|
+
const front = String(c.shots[0]?.file ?? "").replace(/^asset:/, "") || null;
|
|
6489
|
+
const cropped = await identityCrop(core, front ?? void 0);
|
|
6490
|
+
if (!cropped) return c;
|
|
6491
|
+
changed = true;
|
|
6492
|
+
return { ...c, shots: [{ file: `asset:${cropped}`, angle: "identity", locked: true }, ...c.shots] };
|
|
6493
|
+
})
|
|
6494
|
+
);
|
|
6495
|
+
return changed ? { ...json, characters } : json;
|
|
6496
|
+
}
|
|
6349
6497
|
async function figureBox(buf) {
|
|
6350
6498
|
const meta = await sharp20(buf).metadata();
|
|
6351
6499
|
const W = meta.width ?? 0;
|
|
@@ -6616,6 +6764,49 @@ function inheritedIdentityTokens(parentId, getNode) {
|
|
|
6616
6764
|
return { tokens: [], truncated: id !== null };
|
|
6617
6765
|
}
|
|
6618
6766
|
|
|
6767
|
+
// src/variationPlan.ts
|
|
6768
|
+
var OPEN_LADDER = [
|
|
6769
|
+
"Frame this one as the direction describes it, the straight read of the brief.",
|
|
6770
|
+
"Step the camera to one side of where the direction places it, and let the pose settle with the move.",
|
|
6771
|
+
"Frame tighter on the subject than the straight read, same lens character.",
|
|
6772
|
+
"Drop the eye line a little and leave more air in the frame.",
|
|
6773
|
+
"Step back for a wider read of the same setup.",
|
|
6774
|
+
"Come round to a three-quarter view of the same arrangement.",
|
|
6775
|
+
"Take it from slightly above, the same distance.",
|
|
6776
|
+
"Hold the same framing and let the subject carry a different beat of the same moment."
|
|
6777
|
+
];
|
|
6778
|
+
var FIXED_LADDER = [
|
|
6779
|
+
"Frame this one as the direction describes it, the straight read of the brief.",
|
|
6780
|
+
"Keep the camera the direction asks for and shift it a little laterally.",
|
|
6781
|
+
"Keep the camera the direction asks for and let the weight and hands settle differently.",
|
|
6782
|
+
"Keep the camera the direction asks for and change the head angle slightly.",
|
|
6783
|
+
"Keep the camera the direction asks for and let the light fall a touch differently across the same setup.",
|
|
6784
|
+
"Keep the camera the direction asks for and give the expression a different beat of the same moment.",
|
|
6785
|
+
"Keep the camera the direction asks for and rearrange the near foreground slightly.",
|
|
6786
|
+
"Keep the camera the direction asks for and let the pose breathe a little wider."
|
|
6787
|
+
];
|
|
6788
|
+
function locks(ctx) {
|
|
6789
|
+
const parts = [
|
|
6790
|
+
"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."
|
|
6791
|
+
];
|
|
6792
|
+
if (ctx.hasPresenter)
|
|
6793
|
+
parts.push(
|
|
6794
|
+
"The person is the one in the character references and nobody else, unchanged in face, hair, build and skin."
|
|
6795
|
+
);
|
|
6796
|
+
if (ctx.hasProduct)
|
|
6797
|
+
parts.push(
|
|
6798
|
+
"The product is the one in the product references and no other, unchanged in geometry, packaging, label and colour."
|
|
6799
|
+
);
|
|
6800
|
+
if (ctx.hasMark) parts.push("The brand mark stays exactly as drawn.");
|
|
6801
|
+
return parts.join(" ");
|
|
6802
|
+
}
|
|
6803
|
+
function variationPlan(count, ctx) {
|
|
6804
|
+
if (!Number.isFinite(count) || count <= 1) return [];
|
|
6805
|
+
const ladder = ctx.cameraFixed ? FIXED_LADDER : OPEN_LADDER;
|
|
6806
|
+
const shared = locks(ctx);
|
|
6807
|
+
return Array.from({ length: Math.floor(count) }, (_, i) => `${ladder[i % ladder.length]} ${shared}`);
|
|
6808
|
+
}
|
|
6809
|
+
|
|
6619
6810
|
// src/editScopeRules.ts
|
|
6620
6811
|
var GLOBAL_CUES = [
|
|
6621
6812
|
["light", /\b(light|lighting|lit|relight|exposure|white ?balance|backlit|shadows everywhere)\b/i],
|
|
@@ -6875,6 +7066,74 @@ function planCrop(source, targetRatio) {
|
|
|
6875
7066
|
const height = Math.max(1, Math.min(source.height, Math.round(source.width / targetRatio)));
|
|
6876
7067
|
return { left: 0, top: Math.floor((source.height - height) / 2), width: source.width, height, axis: "height" };
|
|
6877
7068
|
}
|
|
7069
|
+
function defaultReshapeOp(sourceRatio, targetRatio) {
|
|
7070
|
+
if (!(sourceRatio > 0 && targetRatio > 0)) return "extend";
|
|
7071
|
+
return Math.abs(Math.log(targetRatio)) < Math.abs(Math.log(sourceRatio)) - 0.01 ? "crop" : "extend";
|
|
7072
|
+
}
|
|
7073
|
+
|
|
7074
|
+
// src/outpaint/growth.ts
|
|
7075
|
+
var SINGLE_PASS_MAX = 1.5;
|
|
7076
|
+
var CROP_ASSIST_ABOVE = 2;
|
|
7077
|
+
var CROP_ASSIST_MAX = 0.15;
|
|
7078
|
+
var STAGE_MAX = 1.4;
|
|
7079
|
+
function planGrowth(source, targetRatio) {
|
|
7080
|
+
if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
|
|
7081
|
+
const current = source.width / source.height;
|
|
7082
|
+
if (Math.abs(current - targetRatio) / targetRatio < 0.01) return null;
|
|
7083
|
+
const axis = targetRatio > current ? "width" : "height";
|
|
7084
|
+
const growth = axis === "width" ? targetRatio / current : current / targetRatio;
|
|
7085
|
+
let cropAssist = 0;
|
|
7086
|
+
if (growth > CROP_ASSIST_ABOVE) {
|
|
7087
|
+
const wanted = 1 - CROP_ASSIST_ABOVE / growth;
|
|
7088
|
+
cropAssist = Math.min(CROP_ASSIST_MAX, wanted);
|
|
7089
|
+
}
|
|
7090
|
+
const effective = growth * (1 - cropAssist);
|
|
7091
|
+
const stages = effective <= SINGLE_PASS_MAX ? 1 : Math.ceil(Math.log(effective) / Math.log(STAGE_MAX));
|
|
7092
|
+
return { growth, axis, stages, cropAssist, effective };
|
|
7093
|
+
}
|
|
7094
|
+
function cropAssistWindow(source, plan) {
|
|
7095
|
+
if (plan.cropAssist <= 0) return null;
|
|
7096
|
+
if (plan.axis === "width") {
|
|
7097
|
+
const height = Math.max(1, Math.round(source.height * (1 - plan.cropAssist)));
|
|
7098
|
+
return { left: 0, top: Math.floor((source.height - height) / 2), width: source.width, height };
|
|
7099
|
+
}
|
|
7100
|
+
const width = Math.max(1, Math.round(source.width * (1 - plan.cropAssist)));
|
|
7101
|
+
return { left: Math.floor((source.width - width) / 2), top: 0, width, height: source.height };
|
|
7102
|
+
}
|
|
7103
|
+
|
|
7104
|
+
// src/reshapeRules.ts
|
|
7105
|
+
var EXTEND_MAX = CROP_ASSIST_ABOVE;
|
|
7106
|
+
function classifyReshape(source, targetRatio, requested) {
|
|
7107
|
+
if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return { op: "none" };
|
|
7108
|
+
const growth = planGrowth(source, targetRatio);
|
|
7109
|
+
if (!growth) return { op: "none" };
|
|
7110
|
+
const op = requested ?? defaultReshapeOp(source.width / source.height, targetRatio);
|
|
7111
|
+
if (op === "crop") return { op: "crop", forced: false };
|
|
7112
|
+
if (growth.effective > EXTEND_MAX + 1e-9) return { op: "crop", forced: true, growth };
|
|
7113
|
+
return { op: "extend", growth, assist: cropAssistWindow(source, growth) };
|
|
7114
|
+
}
|
|
7115
|
+
function fitExpandToBudget(plan, source, pixelBudget) {
|
|
7116
|
+
if (!pixelBudget || plan.width * plan.height <= pixelBudget) return { plan, source, scale: 1 };
|
|
7117
|
+
const frame = budgetSize(plan.width, plan.height, pixelBudget);
|
|
7118
|
+
if (plan.axis === "width") {
|
|
7119
|
+
const scale2 = frame.height / plan.height;
|
|
7120
|
+
const width = Math.min(frame.width, Math.max(1, Math.round(source.width * scale2)));
|
|
7121
|
+
const left = Math.min(Math.max(0, Math.round(plan.left * scale2)), frame.width - width);
|
|
7122
|
+
return {
|
|
7123
|
+
plan: { width: frame.width, height: frame.height, left, top: 0, axis: "width" },
|
|
7124
|
+
source: { width, height: frame.height },
|
|
7125
|
+
scale: scale2
|
|
7126
|
+
};
|
|
7127
|
+
}
|
|
7128
|
+
const scale = frame.width / plan.width;
|
|
7129
|
+
const height = Math.min(frame.height, Math.max(1, Math.round(source.height * scale)));
|
|
7130
|
+
const top = Math.min(Math.max(0, Math.round(plan.top * scale)), frame.height - height);
|
|
7131
|
+
return {
|
|
7132
|
+
plan: { width: frame.width, height: frame.height, left: 0, top, axis: "height" },
|
|
7133
|
+
source: { width: frame.width, height },
|
|
7134
|
+
scale
|
|
7135
|
+
};
|
|
7136
|
+
}
|
|
6878
7137
|
async function attentionCropOrigin(srcBuf, source, plan) {
|
|
6879
7138
|
try {
|
|
6880
7139
|
const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
|
|
@@ -8467,6 +8726,40 @@ function registerImageRoutes(app, deps) {
|
|
|
8467
8726
|
|
|
8468
8727
|
// src/release/notes.data.ts
|
|
8469
8728
|
var RELEASES = [
|
|
8729
|
+
{
|
|
8730
|
+
version: "0.7.2",
|
|
8731
|
+
date: "2026-08-30",
|
|
8732
|
+
title: "Changing a shot to a new shape keeps the photograph.",
|
|
8733
|
+
sections: [
|
|
8734
|
+
{
|
|
8735
|
+
heading: "Create",
|
|
8736
|
+
body: "Refining a shot into a different aspect ratio no longer costs it quality. The frame is planned at the size the engine can genuinely draw, so nothing is enlarged afterwards to fill a canvas its pixels could not reach. The presenter, the product, the wardrobe, the light and the subject scale carry across the new shape, and the stored size is now the size that was really drawn."
|
|
8737
|
+
},
|
|
8738
|
+
{
|
|
8739
|
+
heading: "Fixes",
|
|
8740
|
+
body: "A target shape that is tighter than the shot now crops it, instantly and without a generation, instead of building out around it. A shape too far from the current one to reach in a single step crops as well, and says so, rather than attempting a stretch that could not work. The composer tells you which of the two will happen before you run it."
|
|
8741
|
+
}
|
|
8742
|
+
]
|
|
8743
|
+
},
|
|
8744
|
+
{
|
|
8745
|
+
version: "0.7.1",
|
|
8746
|
+
date: "2026-08-30",
|
|
8747
|
+
title: "Four images from one brief are one set.",
|
|
8748
|
+
sections: [
|
|
8749
|
+
{
|
|
8750
|
+
heading: "Create",
|
|
8751
|
+
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."
|
|
8752
|
+
},
|
|
8753
|
+
{
|
|
8754
|
+
heading: "Presenters",
|
|
8755
|
+
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."
|
|
8756
|
+
},
|
|
8757
|
+
{
|
|
8758
|
+
heading: "Fixes",
|
|
8759
|
+
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."
|
|
8760
|
+
}
|
|
8761
|
+
]
|
|
8762
|
+
},
|
|
8470
8763
|
{
|
|
8471
8764
|
version: "0.7.0",
|
|
8472
8765
|
date: "2026-08-30",
|
|
@@ -9502,18 +9795,22 @@ function buildServer(opts) {
|
|
|
9502
9795
|
);
|
|
9503
9796
|
const inheritedTokens = borrowed.filter((t) => !already.has(identityTokenKey(t)));
|
|
9504
9797
|
const combined = [...brief.tokens, ...inheritedTokens];
|
|
9505
|
-
const brandJson = await
|
|
9798
|
+
const brandJson = await brandJsonWithIdentityCrops(
|
|
9506
9799
|
core,
|
|
9507
|
-
|
|
9508
|
-
presenters,
|
|
9509
|
-
await brandJsonWithResolvedDemoProducts(
|
|
9800
|
+
await brandJsonWithResolvedPresenters(
|
|
9510
9801
|
core,
|
|
9511
9802
|
templatesRoot,
|
|
9512
|
-
|
|
9513
|
-
|
|
9803
|
+
presenters,
|
|
9804
|
+
await brandJsonWithResolvedDemoProducts(
|
|
9805
|
+
core,
|
|
9806
|
+
templatesRoot,
|
|
9807
|
+
demoProducts,
|
|
9808
|
+
brandJsonWithCatalogProducts(core, brandId),
|
|
9809
|
+
combined
|
|
9810
|
+
),
|
|
9514
9811
|
combined
|
|
9515
9812
|
),
|
|
9516
|
-
combined
|
|
9813
|
+
combined.filter((t) => t.t === "character").map((t) => t.id)
|
|
9517
9814
|
);
|
|
9518
9815
|
const sceneById = sceneFor(brandJson);
|
|
9519
9816
|
const uncapped = { ...engineCaps, maxReferenceImages: 32 };
|
|
@@ -9640,18 +9937,22 @@ function buildServer(opts) {
|
|
|
9640
9937
|
referenceCount: edit.merged.kept.length
|
|
9641
9938
|
};
|
|
9642
9939
|
}
|
|
9643
|
-
const brandJson = await
|
|
9940
|
+
const brandJson = await brandJsonWithIdentityCrops(
|
|
9644
9941
|
core,
|
|
9645
|
-
|
|
9646
|
-
presenters,
|
|
9647
|
-
await brandJsonWithResolvedDemoProducts(
|
|
9942
|
+
await brandJsonWithResolvedPresenters(
|
|
9648
9943
|
core,
|
|
9649
9944
|
templatesRoot,
|
|
9650
|
-
|
|
9651
|
-
|
|
9945
|
+
presenters,
|
|
9946
|
+
await brandJsonWithResolvedDemoProducts(
|
|
9947
|
+
core,
|
|
9948
|
+
templatesRoot,
|
|
9949
|
+
demoProducts,
|
|
9950
|
+
brandJsonWithCatalogProducts(core, brand.id),
|
|
9951
|
+
brief.tokens
|
|
9952
|
+
),
|
|
9652
9953
|
brief.tokens
|
|
9653
9954
|
),
|
|
9654
|
-
brief.tokens
|
|
9955
|
+
(brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
|
|
9655
9956
|
);
|
|
9656
9957
|
const sceneById = sceneFor(brandJson);
|
|
9657
9958
|
const compiled2 = compileBrief(brief, {
|
|
@@ -9795,7 +10096,7 @@ function buildServer(opts) {
|
|
|
9795
10096
|
let watchdogFired = false;
|
|
9796
10097
|
const watchdog = setTimeout(() => {
|
|
9797
10098
|
watchdogFired = true;
|
|
9798
|
-
ctrl.abort();
|
|
10099
|
+
ctrl.abort(BUDGET_EXHAUSTED);
|
|
9799
10100
|
}, bound);
|
|
9800
10101
|
const startedAt = Date.now();
|
|
9801
10102
|
try {
|
|
@@ -9858,33 +10159,15 @@ function buildServer(opts) {
|
|
|
9858
10159
|
if (!project) return reply.status(404).send({ error: "project not found" });
|
|
9859
10160
|
const rawReshape = req.body.reshape ?? req.body.brief?.reshape;
|
|
9860
10161
|
const reshape = rawReshape === "crop" ? "crop" : rawReshape === "extend" ? "extend" : void 0;
|
|
9861
|
-
|
|
9862
|
-
const
|
|
9863
|
-
if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
|
|
9864
|
-
const cropParentId = parentId ? String(parentId) : rootForCrop.id;
|
|
9865
|
-
if (brief && Array.isArray(brief.tokens)) {
|
|
9866
|
-
const briefErrors = validateBrief(brief);
|
|
9867
|
-
if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
|
|
9868
|
-
}
|
|
9869
|
-
const fmt = Array.isArray(brief?.tokens) ? brief.tokens.find(
|
|
9870
|
-
(t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
|
|
9871
|
-
) : void 0;
|
|
9872
|
-
if (!fmt) return reply.status(400).send({ error: "a crop needs a target format" });
|
|
9873
|
-
const parent = core.store.getNode(cropParentId);
|
|
9874
|
-
const srcHash = req.body.sourceImage ?? parent?.images[0];
|
|
9875
|
-
if (!srcHash || !core.images.has(String(srcHash)))
|
|
9876
|
-
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
9877
|
-
const srcBuf = core.images.read(String(srcHash));
|
|
9878
|
-
const srcMeta = await sharp20(srcBuf).metadata();
|
|
9879
|
-
if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
|
|
9880
|
-
const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
|
|
10162
|
+
const runCropNode = async (args) => {
|
|
10163
|
+
const plan2 = planCrop(args.srcSize, Number(args.fmt.w) / Number(args.fmt.h));
|
|
9881
10164
|
if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
|
|
9882
|
-
const origin = await attentionCropOrigin(srcBuf,
|
|
10165
|
+
const origin = await attentionCropOrigin(args.srcBuf, args.srcSize, plan2);
|
|
9883
10166
|
const window = { left: origin.left, top: origin.top, width: plan2.width, height: plan2.height };
|
|
9884
|
-
const label = FORMATS.find((f) => f.id === fmt.id)?.label ?? `${fmt.w}x${fmt.h}`;
|
|
10167
|
+
const label = FORMATS.find((f) => f.id === args.fmt.id)?.label ?? `${args.fmt.w}x${args.fmt.h}`;
|
|
9885
10168
|
const node2 = core.store.addNode({
|
|
9886
10169
|
projectId: project.id,
|
|
9887
|
-
parentId:
|
|
10170
|
+
parentId: args.parentId,
|
|
9888
10171
|
kind: "edit",
|
|
9889
10172
|
prompt: `Cropped to ${label}`,
|
|
9890
10173
|
// No provider was asked; recording the engine the client HAPPENED to
|
|
@@ -9893,18 +10176,45 @@ function buildServer(opts) {
|
|
|
9893
10176
|
});
|
|
9894
10177
|
core.store.setBrief(node2.id, {
|
|
9895
10178
|
...briefInputsOnly(brief ?? {}),
|
|
9896
|
-
sourceImage:
|
|
10179
|
+
sourceImage: args.srcHash,
|
|
9897
10180
|
reshape: "crop",
|
|
9898
10181
|
crop: window
|
|
9899
10182
|
});
|
|
9900
10183
|
const work2 = async () => ({
|
|
9901
|
-
images: [core.images.save(await sharp20(srcBuf).extract(window).png().toBuffer())],
|
|
10184
|
+
images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
|
|
9902
10185
|
costUsd: 0
|
|
9903
10186
|
});
|
|
9904
10187
|
void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
|
|
9905
10188
|
(err) => app.log.error({ err }, "crop run failed")
|
|
9906
10189
|
);
|
|
9907
|
-
return reply.status(202).send(node2);
|
|
10190
|
+
return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
|
|
10191
|
+
};
|
|
10192
|
+
if (kind === "edit" && reshape === "crop") {
|
|
10193
|
+
const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
|
|
10194
|
+
if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
|
|
10195
|
+
const cropParentId = parentId ? String(parentId) : rootForCrop.id;
|
|
10196
|
+
if (brief && Array.isArray(brief.tokens)) {
|
|
10197
|
+
const briefErrors = validateBrief(brief);
|
|
10198
|
+
if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
|
|
10199
|
+
}
|
|
10200
|
+
const fmt = Array.isArray(brief?.tokens) ? brief.tokens.find(
|
|
10201
|
+
(t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
|
|
10202
|
+
) : void 0;
|
|
10203
|
+
if (!fmt) return reply.status(400).send({ error: "a crop needs a target format" });
|
|
10204
|
+
const parent = core.store.getNode(cropParentId);
|
|
10205
|
+
const srcHash = req.body.sourceImage ?? parent?.images[0];
|
|
10206
|
+
if (!srcHash || !core.images.has(String(srcHash)))
|
|
10207
|
+
return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
|
|
10208
|
+
const srcBuf = core.images.read(String(srcHash));
|
|
10209
|
+
const srcMeta = await sharp20(srcBuf).metadata();
|
|
10210
|
+
if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
|
|
10211
|
+
return runCropNode({
|
|
10212
|
+
parentId: cropParentId,
|
|
10213
|
+
fmt,
|
|
10214
|
+
srcHash: String(srcHash),
|
|
10215
|
+
srcBuf,
|
|
10216
|
+
srcSize: { width: srcMeta.width, height: srcMeta.height }
|
|
10217
|
+
});
|
|
9908
10218
|
}
|
|
9909
10219
|
const engine = engines.get(String(engineId));
|
|
9910
10220
|
if (!engine) return reply.status(400).send({ error: `unknown engine ${engineId}` });
|
|
@@ -9925,6 +10235,9 @@ function buildServer(opts) {
|
|
|
9925
10235
|
let sentSize;
|
|
9926
10236
|
const extraWarnings = [];
|
|
9927
10237
|
let expandPlan = null;
|
|
10238
|
+
let expandSent = null;
|
|
10239
|
+
let expandAssist = null;
|
|
10240
|
+
let expandWorkHash = null;
|
|
9928
10241
|
let expandSourceHash = null;
|
|
9929
10242
|
if (brief && Array.isArray(brief.tokens)) {
|
|
9930
10243
|
const briefErrors = validateBrief(brief);
|
|
@@ -9944,18 +10257,22 @@ function buildServer(opts) {
|
|
|
9944
10257
|
if (!compiled2.prompt.trim() && reshape !== "extend")
|
|
9945
10258
|
return reply.status(400).send({ error: "the brief is empty" });
|
|
9946
10259
|
} else {
|
|
9947
|
-
const brandJson = await
|
|
10260
|
+
const brandJson = await brandJsonWithIdentityCrops(
|
|
9948
10261
|
core,
|
|
9949
|
-
|
|
9950
|
-
presenters,
|
|
9951
|
-
await brandJsonWithResolvedDemoProducts(
|
|
10262
|
+
await brandJsonWithResolvedPresenters(
|
|
9952
10263
|
core,
|
|
9953
10264
|
templatesRoot,
|
|
9954
|
-
|
|
9955
|
-
|
|
10265
|
+
presenters,
|
|
10266
|
+
await brandJsonWithResolvedDemoProducts(
|
|
10267
|
+
core,
|
|
10268
|
+
templatesRoot,
|
|
10269
|
+
demoProducts,
|
|
10270
|
+
brandJsonWithCatalogProducts(core, project.brandId),
|
|
10271
|
+
brief.tokens
|
|
10272
|
+
),
|
|
9956
10273
|
brief.tokens
|
|
9957
10274
|
),
|
|
9958
|
-
brief.tokens
|
|
10275
|
+
(brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
|
|
9959
10276
|
);
|
|
9960
10277
|
const sceneById = sceneFor(brandJson);
|
|
9961
10278
|
compiled2 = compileBrief(brief, {
|
|
@@ -10021,6 +10338,7 @@ function buildServer(opts) {
|
|
|
10021
10338
|
}
|
|
10022
10339
|
if (kind === "generation") {
|
|
10023
10340
|
const cap2 = engine.capabilities().maxReferenceImages;
|
|
10341
|
+
const wantedCount = Math.min(Math.max(1, Number(count)), 8);
|
|
10024
10342
|
const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
|
|
10025
10343
|
if (lostIdentity.length) {
|
|
10026
10344
|
const names = joinNames(lostIdentity.map((d) => d.label));
|
|
@@ -10032,14 +10350,23 @@ function buildServer(opts) {
|
|
|
10032
10350
|
const maxEdge = engine.capabilities().maxReferenceEdge;
|
|
10033
10351
|
const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
|
|
10034
10352
|
const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
|
|
10353
|
+
const sentRoles = referenceRoles && cap2 > 0 ? referenceRoles.slice(0, cap2) : referenceRoles ?? [];
|
|
10354
|
+
const briefText = Array.isArray(brief?.tokens) ? brief.tokens.filter((t) => t?.t === "text").map((t) => String(t?.v ?? "")).join(" ") : String(prompt ?? "");
|
|
10355
|
+
const variations = variationPlan(wantedCount, {
|
|
10356
|
+
hasPresenter: sentRoles.includes("character"),
|
|
10357
|
+
hasProduct: sentRoles.includes("product"),
|
|
10358
|
+
hasMark: sentRoles.includes("brand"),
|
|
10359
|
+
cameraFixed: shotSpecifiesCamera(briefText)
|
|
10360
|
+
});
|
|
10035
10361
|
const genReq = {
|
|
10036
10362
|
prompt: finalPrompt,
|
|
10037
10363
|
brand: ctx,
|
|
10038
10364
|
width: Number(width),
|
|
10039
10365
|
height: Number(height),
|
|
10040
|
-
count:
|
|
10366
|
+
count: wantedCount,
|
|
10041
10367
|
...sentRefs ? { referenceImages: sentRefs } : {},
|
|
10042
|
-
...
|
|
10368
|
+
...sentRoles.length && cap2 > 0 ? { referenceRoles: sentRoles } : {},
|
|
10369
|
+
...variations.length ? { variations } : {}
|
|
10043
10370
|
};
|
|
10044
10371
|
estimate = await engine.costEstimate(genReq);
|
|
10045
10372
|
work = (signal) => engine.generate(genReq, signal);
|
|
@@ -10065,14 +10392,41 @@ function buildServer(opts) {
|
|
|
10065
10392
|
width: srcMeta.width,
|
|
10066
10393
|
height: srcMeta.height
|
|
10067
10394
|
});
|
|
10068
|
-
|
|
10069
|
-
|
|
10395
|
+
let workBuf = srcBuf;
|
|
10396
|
+
let workSize = srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : null;
|
|
10397
|
+
if (reshapeIntended && workSize && compiled2?.width && compiled2?.height) {
|
|
10398
|
+
const targetRatio = compiled2.width / compiled2.height;
|
|
10399
|
+
const decision = classifyReshape(workSize, targetRatio, reshape);
|
|
10400
|
+
if (decision.op === "crop") {
|
|
10401
|
+
if (reshape === "extend")
|
|
10402
|
+
return reply.status(400).send({
|
|
10403
|
+
error: `growing a ${workSize.width}x${workSize.height} frame to this shape would invent more of the photograph than it keeps; crop instead`
|
|
10404
|
+
});
|
|
10405
|
+
const fmt = (brief?.tokens ?? []).find(
|
|
10406
|
+
(t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
|
|
10407
|
+
);
|
|
10408
|
+
if (fmt)
|
|
10409
|
+
return runCropNode({
|
|
10410
|
+
parentId: resolvedParentId,
|
|
10411
|
+
fmt,
|
|
10412
|
+
srcHash: String(srcHash),
|
|
10413
|
+
srcBuf,
|
|
10414
|
+
srcSize: workSize,
|
|
10415
|
+
note: decision.forced ? "That shape is further than one extend can reach, so the picture was cropped to it instead." : void 0
|
|
10416
|
+
});
|
|
10417
|
+
} else if (decision.op === "extend") {
|
|
10418
|
+
if (decision.assist) {
|
|
10419
|
+
expandAssist = { width: decision.assist.width, height: decision.assist.height };
|
|
10420
|
+
workBuf = await sharp20(srcBuf).extract(decision.assist).png().toBuffer();
|
|
10421
|
+
workSize = { width: decision.assist.width, height: decision.assist.height };
|
|
10422
|
+
}
|
|
10423
|
+
expandPlan = planExpand(workSize, targetRatio);
|
|
10424
|
+
}
|
|
10070
10425
|
}
|
|
10071
10426
|
if (reshape === "extend" && !expandPlan)
|
|
10072
10427
|
return reply.status(400).send({ error: "the picture is already this shape" });
|
|
10073
|
-
if (expandPlan &&
|
|
10074
|
-
|
|
10075
|
-
expandPlan = placeExpand(expandPlan, size, await subjectFraction(srcBuf, size, expandPlan.axis));
|
|
10428
|
+
if (expandPlan && workSize) {
|
|
10429
|
+
expandPlan = placeExpand(expandPlan, workSize, await subjectFraction(workBuf, workSize, expandPlan.axis));
|
|
10076
10430
|
}
|
|
10077
10431
|
if (expandPlan) {
|
|
10078
10432
|
const route = await resolveOutpaintRoute(engines.all(), engine);
|
|
@@ -10080,11 +10434,25 @@ function buildServer(opts) {
|
|
|
10080
10434
|
expandMethod = route.method;
|
|
10081
10435
|
}
|
|
10082
10436
|
const canOutpaint2 = expandMethod === "outpaint";
|
|
10437
|
+
if (expandPlan && workSize) {
|
|
10438
|
+
const fit = fitExpandToBudget(expandPlan, workSize, runEngine.capabilities().editPixelBudget);
|
|
10439
|
+
if (fit.scale < 1) {
|
|
10440
|
+
expandPlan = fit.plan;
|
|
10441
|
+
workBuf = await sharp20(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
|
|
10442
|
+
workSize = fit.source;
|
|
10443
|
+
extraWarnings.push(
|
|
10444
|
+
`${runEngine.capabilities().displayName} draws about ${((runEngine.capabilities().editPixelBudget ?? 0) / 1e6).toFixed(1)} megapixels, so this shape continues as a ${fit.plan.width}x${fit.plan.height} frame with the photograph riding inside it at ${fit.source.width}x${fit.source.height}. Nothing is upscaled; the stored size is the size the engine truly drew.`
|
|
10445
|
+
);
|
|
10446
|
+
}
|
|
10447
|
+
expandSent = workSize;
|
|
10448
|
+
}
|
|
10083
10449
|
let reframeSourceHash;
|
|
10084
10450
|
if (expandPlan) {
|
|
10085
10451
|
if (!canOutpaint2) {
|
|
10086
|
-
expandSourceHash = core.images.save(await expandCanvas(
|
|
10087
|
-
reframeSourceHash = core.images.save(await conditioningCanvas(
|
|
10452
|
+
expandSourceHash = core.images.save(await expandCanvas(workBuf, expandPlan));
|
|
10453
|
+
reframeSourceHash = core.images.save(await conditioningCanvas(workBuf, expandPlan, "edge"));
|
|
10454
|
+
} else {
|
|
10455
|
+
expandWorkHash = core.images.save(workBuf);
|
|
10088
10456
|
}
|
|
10089
10457
|
expectShape = { width: expandPlan.width, height: expandPlan.height };
|
|
10090
10458
|
}
|
|
@@ -10101,7 +10469,7 @@ function buildServer(opts) {
|
|
|
10101
10469
|
}
|
|
10102
10470
|
const editReq = {
|
|
10103
10471
|
instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
|
|
10104
|
-
sourceImage: core.images.pathFor(String(expandSourceHash ?? budgetSourceHash ?? srcHash)),
|
|
10472
|
+
sourceImage: core.images.pathFor(String(expandSourceHash ?? expandWorkHash ?? budgetSourceHash ?? srcHash)),
|
|
10105
10473
|
brand: ctx,
|
|
10106
10474
|
...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
|
|
10107
10475
|
...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
|
|
@@ -10118,8 +10486,10 @@ function buildServer(opts) {
|
|
|
10118
10486
|
expand: {
|
|
10119
10487
|
left: expandPlan.left,
|
|
10120
10488
|
top: expandPlan.top,
|
|
10121
|
-
|
|
10122
|
-
|
|
10489
|
+
// The sent copy's own size: after crop assist and the budget
|
|
10490
|
+
// fit these are the pixels the offsets actually refer to.
|
|
10491
|
+
width: workSize?.width ?? srcMeta.width ?? 0,
|
|
10492
|
+
height: workSize?.height ?? srcMeta.height ?? 0
|
|
10123
10493
|
},
|
|
10124
10494
|
// Derived from the picture and the shape asked for, so the same
|
|
10125
10495
|
// extend of the same shot is the same picture every time. Without
|
|
@@ -10131,8 +10501,8 @@ function buildServer(opts) {
|
|
|
10131
10501
|
};
|
|
10132
10502
|
estimate = await runEngine.costEstimate(editReq);
|
|
10133
10503
|
const plan2 = expandPlan;
|
|
10134
|
-
const srcSize = { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
|
|
10135
|
-
const original2 =
|
|
10504
|
+
const srcSize = workSize ?? { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
|
|
10505
|
+
const original2 = workBuf;
|
|
10136
10506
|
const reframeReq = plan2 && !canOutpaint2 && reframeSourceHash ? {
|
|
10137
10507
|
...editReq,
|
|
10138
10508
|
instruction: reframeInstruction(plan2, srcSize, finalPrompt),
|
|
@@ -10202,7 +10572,13 @@ function buildServer(opts) {
|
|
|
10202
10572
|
// Placement is no longer always centred, so a reader that
|
|
10203
10573
|
// assumes it is would be looking in the wrong place.
|
|
10204
10574
|
left: expandPlan.left,
|
|
10205
|
-
top: expandPlan.top
|
|
10575
|
+
top: expandPlan.top,
|
|
10576
|
+
// The planned frame and the size the photograph was sent at,
|
|
10577
|
+
// so requested-versus-drawn is a readable fact — and the
|
|
10578
|
+
// assist window when a slice of the other axis was given up.
|
|
10579
|
+
frame: [expandPlan.width, expandPlan.height],
|
|
10580
|
+
...expandSent ? { source: [expandSent.width, expandSent.height] } : {},
|
|
10581
|
+
...expandAssist ? { assist: [expandAssist.width, expandAssist.height] } : {}
|
|
10206
10582
|
}
|
|
10207
10583
|
} : {},
|
|
10208
10584
|
// What the refinement carried, recorded apart from what it asked for:
|
|
@@ -10275,7 +10651,8 @@ function buildServer(opts) {
|
|
|
10275
10651
|
{ nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
|
|
10276
10652
|
"expand: engine size differs from plan"
|
|
10277
10653
|
);
|
|
10278
|
-
const
|
|
10654
|
+
const pasted = expandWorkHash ? core.images.read(expandWorkHash) : original;
|
|
10655
|
+
const { image, aligned } = await compositeExpand(answer, pasted, plan);
|
|
10279
10656
|
if (!aligned) app.log.warn({ nodeId: node.id }, "expand: engine frame did not align, kept the bed");
|
|
10280
10657
|
out.push(core.images.save(image));
|
|
10281
10658
|
}
|
|
@@ -10328,7 +10705,8 @@ function buildServer(opts) {
|
|
|
10328
10705
|
}
|
|
10329
10706
|
return enforceEditCanvas(staged);
|
|
10330
10707
|
} : kind === "generation" && compiled2?.width && compiled2?.height ? conformToCanvas(node.id, { width: compiled2.width, height: compiled2.height }) : void 0;
|
|
10331
|
-
const
|
|
10708
|
+
const runCaps = runEngine.capabilities();
|
|
10709
|
+
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;
|
|
10332
10710
|
void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
|
|
10333
10711
|
(err) => app.log.error({ err }, "node run failed")
|
|
10334
10712
|
);
|