scenri 0.7.0 → 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 +15 -0
- package/dist/serve.js +294 -70
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.1](https://github.com/tonygorb/Scenri/compare/v0.7.0...v0.7.1) (2026-08-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* condition a presenter on their face, not only on their figure ([b556b91](https://github.com/tonygorb/Scenri/commit/b556b91403e53ee424ceb0c937d5d0f968cc77fe))
|
|
9
|
+
* condition a presenter on their face, not only on their figure ([4f1e02b](https://github.com/tonygorb/Scenri/commit/4f1e02b9a0512bf588c7f8b46f7d469f22ecae67))
|
|
10
|
+
* decode a presenter's references once, and let one e2e test finish ([e862212](https://github.com/tonygorb/Scenri/commit/e862212898d96a46148f5bf3cb6106a624bfcf67))
|
|
11
|
+
* draw presenters a portrait frame, and stop the crop copying the sweep ([a41eb27](https://github.com/tonygorb/Scenri/commit/a41eb271c03a3130085637ac88d030034d552d3e))
|
|
12
|
+
* make a multi-image run one coherent set ([a463074](https://github.com/tonygorb/Scenri/commit/a463074c6fe28e048b95d5848e660146f5c6b9f9))
|
|
13
|
+
* make a multi-image run one coherent set ([d4ae447](https://github.com/tonygorb/Scenri/commit/d4ae4479965f779b18c41d4d518b84c5a69428a9))
|
|
14
|
+
* preview the same identity payload a generation sends ([15a8d30](https://github.com/tonygorb/Scenri/commit/15a8d304807e271c21f28929976551b8bee914f4))
|
|
15
|
+
* record which view every attachment is, and settle the portrait untouched ([9f1a1a4](https://github.com/tonygorb/Scenri/commit/9f1a1a4bff8b5237f7e322ab6fcffd62a6d836b9))
|
|
16
|
+
* send the presenter portrait that was already on disk ([bbd3def](https://github.com/tonygorb/Scenri/commit/bbd3defbfd936cd0bc672e0e66b5399637ec2d89))
|
|
17
|
+
|
|
3
18
|
## [0.7.0](https://github.com/tonygorb/Scenri/compare/v0.6.13...v0.7.0) (2026-08-30)
|
|
4
19
|
|
|
5
20
|
|
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;
|
|
@@ -3557,17 +3590,25 @@ function compileBrief(brief, ctx) {
|
|
|
3557
3590
|
append(p.promptName ?? p.name);
|
|
3558
3591
|
const primary = tok.angle && p.shots?.find((s) => s.angle === tok.angle) || p.shots?.[0];
|
|
3559
3592
|
const orderedShots = [primary, ...(p.shots ?? []).filter((s) => s && s !== primary)];
|
|
3560
|
-
const
|
|
3593
|
+
const pshots = [];
|
|
3561
3594
|
for (const s of orderedShots) {
|
|
3562
|
-
if (
|
|
3595
|
+
if (pshots.length >= PRODUCT_REF_MAX) break;
|
|
3563
3596
|
const h = assetHash2(s?.file);
|
|
3564
|
-
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) } : {} });
|
|
3565
3599
|
}
|
|
3566
|
-
if (
|
|
3567
|
-
|
|
3568
|
-
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
|
+
});
|
|
3569
3610
|
});
|
|
3570
|
-
productDirectives.push(productFidelityDirective(
|
|
3611
|
+
productDirectives.push(productFidelityDirective(pshots.length));
|
|
3571
3612
|
productDirectives.push(...productFactDirectives(p));
|
|
3572
3613
|
if (p.description && !p.dimensions)
|
|
3573
3614
|
productDirectives.push(
|
|
@@ -3586,10 +3627,17 @@ function compileBrief(brief, ctx) {
|
|
|
3586
3627
|
}
|
|
3587
3628
|
hasPerson = true;
|
|
3588
3629
|
append(c.promptName ?? c.name);
|
|
3589
|
-
const
|
|
3590
|
-
if (
|
|
3591
|
-
|
|
3592
|
-
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
|
+
});
|
|
3593
3641
|
});
|
|
3594
3642
|
personDirectives.push(
|
|
3595
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.`
|
|
@@ -3602,6 +3650,11 @@ function compileBrief(brief, ctx) {
|
|
|
3602
3650
|
personDirectives.push(
|
|
3603
3651
|
`${c.promptName ?? c.name}'s skin, exactly as the reference photographs show it: ${c.skin}.`
|
|
3604
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}.`);
|
|
3605
3658
|
} else {
|
|
3606
3659
|
warnings.push(`${c.name} has no usable photo, so they are named but not attached.`);
|
|
3607
3660
|
}
|
|
@@ -5984,7 +6037,8 @@ function presenterRecordFrom(input, base) {
|
|
|
5984
6037
|
const has = (k) => input[k] !== void 0;
|
|
5985
6038
|
const name = has("name") ? str3(input.name, 60) : base?.name ?? "";
|
|
5986
6039
|
if (!name) return { ok: false, error: "a presenter needs a name" };
|
|
5987
|
-
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 ?? [];
|
|
5988
6042
|
if (!shots.length) return { ok: false, error: "a presenter needs at least one photo" };
|
|
5989
6043
|
const sources = has("sourceHashes") ? strList(input.sourceHashes, 8, 64).map((h) => assetRef(h)).filter((f) => !!f).map((file) => ({ file })) : base?.sourceRefs;
|
|
5990
6044
|
const presenter = {
|
|
@@ -6122,6 +6176,26 @@ async function runBuild(deps, job, hashes, instruction, signal) {
|
|
|
6122
6176
|
}
|
|
6123
6177
|
}
|
|
6124
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
|
+
},
|
|
6125
6199
|
{
|
|
6126
6200
|
angle: "front",
|
|
6127
6201
|
from: "sources",
|
|
@@ -6174,22 +6248,28 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
|
|
|
6174
6248
|
}
|
|
6175
6249
|
if (signal.aborted) throw new Error("cancelled");
|
|
6176
6250
|
let shotHashes = hashes;
|
|
6251
|
+
let shotAngles = [];
|
|
6177
6252
|
const warnings = [];
|
|
6178
6253
|
if (deps.engine) {
|
|
6179
6254
|
patch(job, { stage: "building", steps: STUDIO_FRAMES.length, message: "Building the studio views" });
|
|
6180
6255
|
const built2 = await generateStudioSet(deps, job, whoIs(job.name, draft), sourcePaths, signal);
|
|
6181
|
-
if (built2.length)
|
|
6182
|
-
|
|
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.");
|
|
6183
6260
|
} else {
|
|
6184
6261
|
warnings.push("No engine could draw the studio views, so the photos are being used directly.");
|
|
6185
6262
|
}
|
|
6186
6263
|
if (signal.aborted) throw new Error("cancelled");
|
|
6187
6264
|
patch(job, { stage: "saving", message: null });
|
|
6188
6265
|
const generated = shotHashes !== hashes;
|
|
6189
|
-
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");
|
|
6190
6269
|
const built = presenterRecordFrom({
|
|
6191
6270
|
name: job.name,
|
|
6192
6271
|
shotHashes,
|
|
6272
|
+
shotAngles,
|
|
6193
6273
|
sourceHashes: hashes,
|
|
6194
6274
|
previewHash,
|
|
6195
6275
|
avatarHash,
|
|
@@ -6212,16 +6292,16 @@ async function runPresenterBuild(deps, job, hashes, instruction, signal) {
|
|
|
6212
6292
|
stage: "done",
|
|
6213
6293
|
step: job.steps,
|
|
6214
6294
|
assetId: built.presenter.id,
|
|
6215
|
-
previewHash: previewHash ??
|
|
6295
|
+
previewHash: previewHash ?? cardSource ?? null,
|
|
6216
6296
|
warnings: [...job.warnings, ...warnings],
|
|
6217
6297
|
finished: true
|
|
6218
6298
|
});
|
|
6219
6299
|
}
|
|
6220
6300
|
async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
6221
6301
|
const engine = deps.engine;
|
|
6222
|
-
if (!engine) return [];
|
|
6302
|
+
if (!engine) return { hashes: [], angles: [] };
|
|
6223
6303
|
const caps = engine.capabilities();
|
|
6224
|
-
if (!caps.maxReferenceImages) return [];
|
|
6304
|
+
if (!caps.maxReferenceImages) return { hashes: [], angles: [] };
|
|
6225
6305
|
const byAngle = /* @__PURE__ */ new Map();
|
|
6226
6306
|
for (const frame of STUDIO_FRAMES) {
|
|
6227
6307
|
if (signal.aborted) throw new Error("cancelled");
|
|
@@ -6248,7 +6328,8 @@ async function generateStudioSet(deps, job, who, sourcePaths, signal) {
|
|
|
6248
6328
|
patch(job, { warnings: [...job.warnings, `The ${frame.angle} view could not be drawn.`] });
|
|
6249
6329
|
}
|
|
6250
6330
|
}
|
|
6251
|
-
|
|
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) };
|
|
6252
6333
|
}
|
|
6253
6334
|
async function edgeBarGeometry(buf) {
|
|
6254
6335
|
const { data, info } = await sharp20(buf).greyscale().raw().toBuffer({ resolveWithObject: true });
|
|
@@ -6346,6 +6427,64 @@ async function avatarCrop(core, hash) {
|
|
|
6346
6427
|
AVATAR_MAX_PX
|
|
6347
6428
|
);
|
|
6348
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
|
+
}
|
|
6349
6488
|
async function figureBox(buf) {
|
|
6350
6489
|
const meta = await sharp20(buf).metadata();
|
|
6351
6490
|
const W = meta.width ?? 0;
|
|
@@ -6616,6 +6755,49 @@ function inheritedIdentityTokens(parentId, getNode) {
|
|
|
6616
6755
|
return { tokens: [], truncated: id !== null };
|
|
6617
6756
|
}
|
|
6618
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
|
+
|
|
6619
6801
|
// src/editScopeRules.ts
|
|
6620
6802
|
var GLOBAL_CUES = [
|
|
6621
6803
|
["light", /\b(light|lighting|lit|relight|exposure|white ?balance|backlit|shadows everywhere)\b/i],
|
|
@@ -8467,6 +8649,25 @@ function registerImageRoutes(app, deps) {
|
|
|
8467
8649
|
|
|
8468
8650
|
// src/release/notes.data.ts
|
|
8469
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
|
+
},
|
|
8470
8671
|
{
|
|
8471
8672
|
version: "0.7.0",
|
|
8472
8673
|
date: "2026-08-30",
|
|
@@ -9502,18 +9703,22 @@ function buildServer(opts) {
|
|
|
9502
9703
|
);
|
|
9503
9704
|
const inheritedTokens = borrowed.filter((t) => !already.has(identityTokenKey(t)));
|
|
9504
9705
|
const combined = [...brief.tokens, ...inheritedTokens];
|
|
9505
|
-
const brandJson = await
|
|
9706
|
+
const brandJson = await brandJsonWithIdentityCrops(
|
|
9506
9707
|
core,
|
|
9507
|
-
|
|
9508
|
-
presenters,
|
|
9509
|
-
await brandJsonWithResolvedDemoProducts(
|
|
9708
|
+
await brandJsonWithResolvedPresenters(
|
|
9510
9709
|
core,
|
|
9511
9710
|
templatesRoot,
|
|
9512
|
-
|
|
9513
|
-
|
|
9711
|
+
presenters,
|
|
9712
|
+
await brandJsonWithResolvedDemoProducts(
|
|
9713
|
+
core,
|
|
9714
|
+
templatesRoot,
|
|
9715
|
+
demoProducts,
|
|
9716
|
+
brandJsonWithCatalogProducts(core, brandId),
|
|
9717
|
+
combined
|
|
9718
|
+
),
|
|
9514
9719
|
combined
|
|
9515
9720
|
),
|
|
9516
|
-
combined
|
|
9721
|
+
combined.filter((t) => t.t === "character").map((t) => t.id)
|
|
9517
9722
|
);
|
|
9518
9723
|
const sceneById = sceneFor(brandJson);
|
|
9519
9724
|
const uncapped = { ...engineCaps, maxReferenceImages: 32 };
|
|
@@ -9640,18 +9845,22 @@ function buildServer(opts) {
|
|
|
9640
9845
|
referenceCount: edit.merged.kept.length
|
|
9641
9846
|
};
|
|
9642
9847
|
}
|
|
9643
|
-
const brandJson = await
|
|
9848
|
+
const brandJson = await brandJsonWithIdentityCrops(
|
|
9644
9849
|
core,
|
|
9645
|
-
|
|
9646
|
-
presenters,
|
|
9647
|
-
await brandJsonWithResolvedDemoProducts(
|
|
9850
|
+
await brandJsonWithResolvedPresenters(
|
|
9648
9851
|
core,
|
|
9649
9852
|
templatesRoot,
|
|
9650
|
-
|
|
9651
|
-
|
|
9853
|
+
presenters,
|
|
9854
|
+
await brandJsonWithResolvedDemoProducts(
|
|
9855
|
+
core,
|
|
9856
|
+
templatesRoot,
|
|
9857
|
+
demoProducts,
|
|
9858
|
+
brandJsonWithCatalogProducts(core, brand.id),
|
|
9859
|
+
brief.tokens
|
|
9860
|
+
),
|
|
9652
9861
|
brief.tokens
|
|
9653
9862
|
),
|
|
9654
|
-
brief.tokens
|
|
9863
|
+
(brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
|
|
9655
9864
|
);
|
|
9656
9865
|
const sceneById = sceneFor(brandJson);
|
|
9657
9866
|
const compiled2 = compileBrief(brief, {
|
|
@@ -9795,7 +10004,7 @@ function buildServer(opts) {
|
|
|
9795
10004
|
let watchdogFired = false;
|
|
9796
10005
|
const watchdog = setTimeout(() => {
|
|
9797
10006
|
watchdogFired = true;
|
|
9798
|
-
ctrl.abort();
|
|
10007
|
+
ctrl.abort(BUDGET_EXHAUSTED);
|
|
9799
10008
|
}, bound);
|
|
9800
10009
|
const startedAt = Date.now();
|
|
9801
10010
|
try {
|
|
@@ -9944,18 +10153,22 @@ function buildServer(opts) {
|
|
|
9944
10153
|
if (!compiled2.prompt.trim() && reshape !== "extend")
|
|
9945
10154
|
return reply.status(400).send({ error: "the brief is empty" });
|
|
9946
10155
|
} else {
|
|
9947
|
-
const brandJson = await
|
|
10156
|
+
const brandJson = await brandJsonWithIdentityCrops(
|
|
9948
10157
|
core,
|
|
9949
|
-
|
|
9950
|
-
presenters,
|
|
9951
|
-
await brandJsonWithResolvedDemoProducts(
|
|
10158
|
+
await brandJsonWithResolvedPresenters(
|
|
9952
10159
|
core,
|
|
9953
10160
|
templatesRoot,
|
|
9954
|
-
|
|
9955
|
-
|
|
10161
|
+
presenters,
|
|
10162
|
+
await brandJsonWithResolvedDemoProducts(
|
|
10163
|
+
core,
|
|
10164
|
+
templatesRoot,
|
|
10165
|
+
demoProducts,
|
|
10166
|
+
brandJsonWithCatalogProducts(core, project.brandId),
|
|
10167
|
+
brief.tokens
|
|
10168
|
+
),
|
|
9956
10169
|
brief.tokens
|
|
9957
10170
|
),
|
|
9958
|
-
brief.tokens
|
|
10171
|
+
(brief.tokens ?? []).filter((t) => t.t === "character").map((t) => t.id)
|
|
9959
10172
|
);
|
|
9960
10173
|
const sceneById = sceneFor(brandJson);
|
|
9961
10174
|
compiled2 = compileBrief(brief, {
|
|
@@ -10021,6 +10234,7 @@ function buildServer(opts) {
|
|
|
10021
10234
|
}
|
|
10022
10235
|
if (kind === "generation") {
|
|
10023
10236
|
const cap2 = engine.capabilities().maxReferenceImages;
|
|
10237
|
+
const wantedCount = Math.min(Math.max(1, Number(count)), 8);
|
|
10024
10238
|
const lostIdentity = engine.capabilities().placeholder ? [] : (compiled2?.dropped ?? []).filter((d) => d.essential);
|
|
10025
10239
|
if (lostIdentity.length) {
|
|
10026
10240
|
const names = joinNames(lostIdentity.map((d) => d.label));
|
|
@@ -10032,14 +10246,23 @@ function buildServer(opts) {
|
|
|
10032
10246
|
const maxEdge = engine.capabilities().maxReferenceEdge;
|
|
10033
10247
|
const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
|
|
10034
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
|
+
});
|
|
10035
10257
|
const genReq = {
|
|
10036
10258
|
prompt: finalPrompt,
|
|
10037
10259
|
brand: ctx,
|
|
10038
10260
|
width: Number(width),
|
|
10039
10261
|
height: Number(height),
|
|
10040
|
-
count:
|
|
10262
|
+
count: wantedCount,
|
|
10041
10263
|
...sentRefs ? { referenceImages: sentRefs } : {},
|
|
10042
|
-
...
|
|
10264
|
+
...sentRoles.length && cap2 > 0 ? { referenceRoles: sentRoles } : {},
|
|
10265
|
+
...variations.length ? { variations } : {}
|
|
10043
10266
|
};
|
|
10044
10267
|
estimate = await engine.costEstimate(genReq);
|
|
10045
10268
|
work = (signal) => engine.generate(genReq, signal);
|
|
@@ -10328,7 +10551,8 @@ function buildServer(opts) {
|
|
|
10328
10551
|
}
|
|
10329
10552
|
return enforceEditCanvas(staged);
|
|
10330
10553
|
} : kind === "generation" && compiled2?.width && compiled2?.height ? conformToCanvas(node.id, { width: compiled2.width, height: compiled2.height }) : void 0;
|
|
10331
|
-
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;
|
|
10332
10556
|
void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
|
|
10333
10557
|
(err) => app.log.error({ err }, "node run failed")
|
|
10334
10558
|
);
|