scenri 0.6.4 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/dist/serve.js +192 -57
- package/package.json +1 -1
- package/studio-dist/assets/{index-DWWdEXLo.css → index-BG-cxlng.css} +1 -1
- package/studio-dist/assets/{index-D__G1gHp.js → index-BxwxIvyc.js} +20 -20
- package/studio-dist/index.html +2 -2
- package/templates/chrome-bubble-warp.json +1 -1
- package/templates/cold-table-clinic.json +1 -1
- package/templates/confetti-burst-freeze.json +1 -1
- package/templates/editorial-fisheye-flash-studio.json +1 -1
- package/templates/heliconia-bloom-field.json +2 -2
- package/templates/indigo-fold.json +1 -1
- package/templates/interiors-bathroom-vanity.json +2 -2
- package/templates/interiors-marble-kitchen-counter.json +1 -1
- package/templates/interiors-wood-table-flatlay.json +2 -2
- package/templates/liquid-marble-pour.json +1 -1
- package/templates/plain-light-portrait.json +2 -2
- package/templates/precision-bench-anodised.json +1 -1
- package/templates/screen-print-duotone.json +1 -1
- package/templates/showcase/holloway-rings-loupe-surface-plate.json +1 -1
- package/templates/showcase/tanner-fielding-cocoa-crown.json +1 -1
- package/templates/showcase/voss-rowe-dune-slip-face.json +1 -1
- package/templates/social-amber-flare-motion.json +1 -1
- package/templates/social-fashion-campaign-billboard.json +1 -1
- package/templates/social-vertical-story-motion.json +2 -2
- package/templates/stitched-construction-detail.json +2 -2
- package/templates/studio-aloe-flesh-macro.json +1 -1
- package/templates/studio-amber-resin-suspension.json +1 -1
- package/templates/studio-basalt-crust-glow.json +1 -1
- package/templates/studio-blue-ice-melt-channel.json +1 -1
- package/templates/studio-boulder-gap.json +1 -1
- package/templates/studio-dune-slip-face.json +1 -1
- package/templates/studio-molten-sugar-pull.json +1 -1
- package/templates/studio-rainforest-canopy.json +1 -1
- package/templates/studio-reef-shelf-caustics.json +1 -1
- package/templates/studio-sea-stack-horizon.json +1 -1
- package/templates/studio-volcanic-ash-field.json +2 -2
- package/templates/succulent-dew-bed.json +1 -1
- package/templates/suspended-color-field.json +1 -1
- package/templates/terracotta-ledge-glow.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.6](https://github.com/tonygorb/Scenri/compare/v0.6.5...v0.6.6) (2026-08-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* composer chip removal is deterministic ([11246fc](https://github.com/tonygorb/Scenri/commit/11246fc64e26be522631f0fd6927210d6b5759b5))
|
|
9
|
+
* composer chip removal is deterministic ([29b9a4b](https://github.com/tonygorb/Scenri/commit/29b9a4bffaee289d5066bf15b52254d030547d97))
|
|
10
|
+
* presenter avatars frame head and shoulders like a real portrait ([3edb693](https://github.com/tonygorb/Scenri/commit/3edb69360755280af822380bd7cf3f65297aa258))
|
|
11
|
+
* presenter avatars frame head and shoulders like a real portrait ([1e4040c](https://github.com/tonygorb/Scenri/commit/1e4040c6c07858994ccde2a862927a6047b02062))
|
|
12
|
+
* stop killing healthy codex runs and classify what actually failed ([0d7888b](https://github.com/tonygorb/Scenri/commit/0d7888bcb3da875775ab125797cdf0b3fc7dec0c))
|
|
13
|
+
* stop killing healthy codex runs and classify what actually failed ([87e4df3](https://github.com/tonygorb/Scenri/commit/87e4df3ee680ad7673fad73351b1f36c07dacba6))
|
|
14
|
+
|
|
15
|
+
## [0.6.5](https://github.com/tonygorb/Scenri/compare/v0.6.4...v0.6.5) (2026-08-29)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* a scene stages a subject slot, never its own demo object ([471981a](https://github.com/tonygorb/Scenri/commit/471981a89f7b3bb62cd4e5f7aec795b468e0aa15))
|
|
21
|
+
* a scene stages a subject slot, never its own demo object ([04e2afe](https://github.com/tonygorb/Scenri/commit/04e2afe7da67af13df32d854d2fd861597e6025e))
|
|
22
|
+
|
|
3
23
|
## [0.6.4](https://github.com/tonygorb/Scenri/compare/v0.6.3...v0.6.4) (2026-08-28)
|
|
4
24
|
|
|
5
25
|
|
package/dist/serve.js
CHANGED
|
@@ -8,7 +8,7 @@ import Database from 'better-sqlite3';
|
|
|
8
8
|
import { randomBytes, createHash, randomUUID, timingSafeEqual } from 'crypto';
|
|
9
9
|
import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, writeFileSync, readdirSync, statSync, rmSync, renameSync } from 'fs';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
|
-
import { readFile, copyFile, mkdtemp, rm, readdir,
|
|
11
|
+
import { readFile, copyFile, stat, mkdtemp, rm, readdir, writeFile } from 'fs/promises';
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
13
|
import sharp7 from 'sharp';
|
|
14
14
|
import Fastify from 'fastify';
|
|
@@ -1291,8 +1291,11 @@ var REFERENCE_ROLE_DIRECTIVE = {
|
|
|
1291
1291
|
// subject or person from it", which handed the model a photograph of a face
|
|
1292
1292
|
// treatment and told it to ignore the treatment. A hand-attached reference
|
|
1293
1293
|
// says "match ... treatment" and works; this now says the same thing, with the
|
|
1294
|
-
// identity carve-out a scene needs and a lone reference does not.
|
|
1295
|
-
|
|
1294
|
+
// identity carve-out a scene needs and a lone reference does not. The tail
|
|
1295
|
+
// names staged objects as stand-ins rather than just "no product": a bare
|
|
1296
|
+
// prohibition still left the demo object in the frame, because the model had
|
|
1297
|
+
// nowhere to put what the photograph so vividly showed.
|
|
1298
|
+
scene: "a reference for this world and for the treatment applied to the figure in it \u2014 match the environment, the light, and the material, density, scale, finish and spread of that treatment, including which parts of the form it covers and how far it reaches; take no identity from the person in it, and treat any product, garment or prop staged in it as a stand-in whose place the attached subject takes \u2014 never an object to reproduce",
|
|
1296
1299
|
composition: "a reference for framing, camera angle and pose only \u2014 take no subject, color, material or branding from it",
|
|
1297
1300
|
style: "a reference for overall treatment and mood only \u2014 take no composition, subject or product detail from it",
|
|
1298
1301
|
reference: "a reference to match in composition, lighting and treatment"
|
|
@@ -1964,7 +1967,7 @@ var NOT_AUTHENTICATED_REASON = "Codex CLI is installed but not signed in";
|
|
|
1964
1967
|
var UNVERIFIED_REASON = "Could not verify Codex on this computer";
|
|
1965
1968
|
var DEFAULT_TIMEOUT_MS2 = 3e5;
|
|
1966
1969
|
var PROBE_TIMEOUT_MS = 1e4;
|
|
1967
|
-
var
|
|
1970
|
+
var FIRST_OUTPUT_TIMEOUT_MS = 6e4;
|
|
1968
1971
|
var PROBE_TTL_MS = 3e4;
|
|
1969
1972
|
function execArgs(dir, effort = "low") {
|
|
1970
1973
|
return [
|
|
@@ -1999,6 +2002,13 @@ function killTree(child, platform, spawnImpl) {
|
|
|
1999
2002
|
} catch {
|
|
2000
2003
|
}
|
|
2001
2004
|
}
|
|
2005
|
+
if (platform !== "win32" && child.pid && spawnImpl === spawn) {
|
|
2006
|
+
try {
|
|
2007
|
+
process.kill(-child.pid, "SIGTERM");
|
|
2008
|
+
return;
|
|
2009
|
+
} catch {
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2002
2012
|
try {
|
|
2003
2013
|
child.kill();
|
|
2004
2014
|
} catch {
|
|
@@ -2006,10 +2016,10 @@ function killTree(child, platform, spawnImpl) {
|
|
|
2006
2016
|
}
|
|
2007
2017
|
function createRunner(opts = {}) {
|
|
2008
2018
|
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
2009
|
-
const
|
|
2019
|
+
const defaultTimeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
2010
2020
|
const probeTimeoutMs = opts.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
|
|
2011
2021
|
const probeTtlMs = opts.probeTtlMs ?? PROBE_TTL_MS;
|
|
2012
|
-
const
|
|
2022
|
+
const firstOutputMs = opts.firstOutputMs ?? FIRST_OUTPUT_TIMEOUT_MS;
|
|
2013
2023
|
const platform = opts.platform ?? process.platform;
|
|
2014
2024
|
const killCodex = (child) => killTree(child, platform, spawnImpl);
|
|
2015
2025
|
let resolved = null;
|
|
@@ -2020,10 +2030,11 @@ function createRunner(opts = {}) {
|
|
|
2020
2030
|
const winArg = (a) => `"${a.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/%/g, " percent ")}"`;
|
|
2021
2031
|
const spawnCodex = (exe, args, stdinOpen) => {
|
|
2022
2032
|
const stdio = [stdinOpen ? "pipe" : "ignore", "pipe", "pipe"];
|
|
2023
|
-
return exe.direct ? spawnImpl(exe.command, args, { stdio }) : spawnImpl([exe.command, ...args.map(winArg)].join(" "), [], { stdio, shell: true });
|
|
2033
|
+
return exe.direct ? spawnImpl(exe.command, args, { stdio, ...platform !== "win32" ? { detached: true } : {} }) : spawnImpl([exe.command, ...args.map(winArg)].join(" "), [], { stdio, shell: true });
|
|
2024
2034
|
};
|
|
2025
2035
|
async function run2(args, signal, io) {
|
|
2026
2036
|
const exe = await resolution();
|
|
2037
|
+
const timeoutMs = io?.timeoutMs ?? defaultTimeoutMs;
|
|
2027
2038
|
return new Promise((resolve, reject) => {
|
|
2028
2039
|
let child;
|
|
2029
2040
|
try {
|
|
@@ -2034,19 +2045,31 @@ function createRunner(opts = {}) {
|
|
|
2034
2045
|
}
|
|
2035
2046
|
let settled = false;
|
|
2036
2047
|
let stderr = "";
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
}
|
|
2043
|
-
function onSilence() {
|
|
2048
|
+
const spawnedAt = Date.now();
|
|
2049
|
+
let firstByteAt = 0;
|
|
2050
|
+
let lastByteAt = 0;
|
|
2051
|
+
let maxGapMs = 0;
|
|
2052
|
+
const firstOutputTimer = setTimeout(() => {
|
|
2044
2053
|
finish(
|
|
2054
|
+
"first-output-timeout",
|
|
2045
2055
|
() => reject(
|
|
2046
|
-
new Error(
|
|
2056
|
+
new Error(
|
|
2057
|
+
`Codex CLI produced no output for ${Math.round(firstOutputMs / 1e3)}s after launch, treating it as stuck`
|
|
2058
|
+
)
|
|
2047
2059
|
)
|
|
2048
2060
|
);
|
|
2049
2061
|
killCodex(child);
|
|
2062
|
+
}, firstOutputMs);
|
|
2063
|
+
function sawActivity() {
|
|
2064
|
+
if (settled) return;
|
|
2065
|
+
const now = Date.now();
|
|
2066
|
+
if (firstByteAt === 0) {
|
|
2067
|
+
firstByteAt = now;
|
|
2068
|
+
clearTimeout(firstOutputTimer);
|
|
2069
|
+
} else {
|
|
2070
|
+
maxGapMs = Math.max(maxGapMs, now - lastByteAt);
|
|
2071
|
+
}
|
|
2072
|
+
lastByteAt = now;
|
|
2050
2073
|
}
|
|
2051
2074
|
child.stdout?.on("data", sawActivity);
|
|
2052
2075
|
child.stderr?.on("data", (d) => {
|
|
@@ -2060,19 +2083,23 @@ function createRunner(opts = {}) {
|
|
|
2060
2083
|
child.stdin?.end();
|
|
2061
2084
|
}
|
|
2062
2085
|
const timer = setTimeout(() => {
|
|
2063
|
-
finish(() => reject(new Error(`Codex CLI timed out after ${timeoutMs}ms`)));
|
|
2086
|
+
finish("hard-timeout", () => reject(new Error(`Codex CLI timed out after ${timeoutMs}ms`)));
|
|
2064
2087
|
killCodex(child);
|
|
2065
2088
|
}, timeoutMs);
|
|
2066
2089
|
const onAbort = () => {
|
|
2067
|
-
finish(() => reject(new Error("Codex CLI run aborted")));
|
|
2090
|
+
finish("abort", () => reject(new Error("Codex CLI run aborted")));
|
|
2068
2091
|
killCodex(child);
|
|
2069
2092
|
};
|
|
2070
|
-
function finish(fn) {
|
|
2093
|
+
function finish(outcome, fn) {
|
|
2071
2094
|
if (settled) return;
|
|
2072
2095
|
settled = true;
|
|
2073
2096
|
clearTimeout(timer);
|
|
2074
|
-
clearTimeout(
|
|
2097
|
+
clearTimeout(firstOutputTimer);
|
|
2075
2098
|
signal?.removeEventListener("abort", onAbort);
|
|
2099
|
+
const ttfb = firstByteAt ? firstByteAt - spawnedAt : -1;
|
|
2100
|
+
const line = `codex exec${io?.label ? ` [${io.label}]` : ""}: outcome=${outcome} ttfb=${ttfb}ms maxGap=${maxGapMs}ms total=${Date.now() - spawnedAt}ms`;
|
|
2101
|
+
if (outcome !== "ok" && outcome !== "abort") console.warn(line);
|
|
2102
|
+
else if (process.env.SCENRI_DEBUG === "1") console.log(line);
|
|
2076
2103
|
fn();
|
|
2077
2104
|
}
|
|
2078
2105
|
if (signal) {
|
|
@@ -2083,14 +2110,15 @@ function createRunner(opts = {}) {
|
|
|
2083
2110
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
2084
2111
|
}
|
|
2085
2112
|
child.on("error", (err) => {
|
|
2086
|
-
finish(() => reject(new Error(`Failed to spawn codex: ${err.message}`)));
|
|
2113
|
+
finish("spawn-error", () => reject(new Error(`Failed to spawn codex: ${err.message}`)));
|
|
2087
2114
|
});
|
|
2088
2115
|
child.on("exit", (code) => {
|
|
2089
2116
|
if (code === 0) {
|
|
2090
|
-
finish(resolve);
|
|
2117
|
+
finish("ok", resolve);
|
|
2091
2118
|
} else {
|
|
2092
2119
|
const snippet2 = stderr.trim().slice(0, 200);
|
|
2093
2120
|
finish(
|
|
2121
|
+
`exit-${code ?? "unknown"}`,
|
|
2094
2122
|
() => reject(new Error(`codex exited with code ${code ?? "unknown"}${snippet2 ? `: ${snippet2}` : ""}`))
|
|
2095
2123
|
);
|
|
2096
2124
|
}
|
|
@@ -2215,7 +2243,10 @@ function createCodexAnalyzer(opts = {}) {
|
|
|
2215
2243
|
for (const ref of refs) {
|
|
2216
2244
|
args.splice(args.length - 1, 0, `--image=${ref}`);
|
|
2217
2245
|
}
|
|
2218
|
-
await runner.run(args, signal, {
|
|
2246
|
+
await runner.run(args, signal, {
|
|
2247
|
+
stdin: buildPrompt(req, refs.length, problems),
|
|
2248
|
+
label: `analyze refs=${refs.length} attempt=${attempt + 1}`
|
|
2249
|
+
});
|
|
2219
2250
|
let raw;
|
|
2220
2251
|
try {
|
|
2221
2252
|
raw = await readFile(join(dir, OUT_FILE), "utf8");
|
|
@@ -2441,6 +2472,10 @@ function createCodexSetup(opts = {}) {
|
|
|
2441
2472
|
}
|
|
2442
2473
|
|
|
2443
2474
|
// ../engines/codex/src/index.ts
|
|
2475
|
+
var CODEX_POOL = 2;
|
|
2476
|
+
function codexNodeBudgetMs(count) {
|
|
2477
|
+
return Math.ceil(Math.max(1, count) / CODEX_POOL) * DEFAULT_TIMEOUT_MS2 + 6e4;
|
|
2478
|
+
}
|
|
2444
2479
|
function createCodexEngine(opts) {
|
|
2445
2480
|
const { saveImage } = opts;
|
|
2446
2481
|
const platform = opts.platform ?? process.platform;
|
|
@@ -2521,7 +2556,15 @@ function createCodexEngine(opts) {
|
|
|
2521
2556
|
* shot being edited. A refine carrying a full identity payload was
|
|
2522
2557
|
* silently editing nothing at all.
|
|
2523
2558
|
*/
|
|
2524
|
-
maxReferenceImages: 5
|
|
2559
|
+
maxReferenceImages: 5,
|
|
2560
|
+
/*
|
|
2561
|
+
* The longest edge a reference is worth sending at. codex's image_gen
|
|
2562
|
+
* reads references at reduced resolution server-side either way, but
|
|
2563
|
+
* the bytes still ride the user's uplink inside the exec's own time
|
|
2564
|
+
* budget — a full-resolution phone-photo PNG is tens of megabytes that
|
|
2565
|
+
* buy nothing. Same cap as brand marks (MARK_MAX_EDGE).
|
|
2566
|
+
*/
|
|
2567
|
+
maxReferenceEdge: 2048
|
|
2525
2568
|
};
|
|
2526
2569
|
},
|
|
2527
2570
|
isAvailable() {
|
|
@@ -2542,13 +2585,18 @@ function createCodexEngine(opts) {
|
|
|
2542
2585
|
{ length: count },
|
|
2543
2586
|
(_, i) => async () => withWorkDir(async (dir) => {
|
|
2544
2587
|
const args = execArgs(dir);
|
|
2588
|
+
let refBytes = 0;
|
|
2545
2589
|
for (const [idx, ref] of refs.entries()) {
|
|
2546
2590
|
const dest = join(dir, `ref-${idx}.png`);
|
|
2547
2591
|
await copyFile(ref, dest);
|
|
2592
|
+
refBytes += (await stat(dest)).size;
|
|
2548
2593
|
args.splice(args.length - 1, 0, `--image=${dest}`);
|
|
2549
2594
|
}
|
|
2550
2595
|
const before = await snapshotGenerated();
|
|
2551
|
-
await runCodex(args, inner.signal, {
|
|
2596
|
+
await runCodex(args, inner.signal, {
|
|
2597
|
+
stdin: buildPrompt2(req, i, roles),
|
|
2598
|
+
label: `gen v${i + 1}/${count} refs=${refs.length} refKB=${Math.round(refBytes / 1024)}`
|
|
2599
|
+
});
|
|
2552
2600
|
return collectImages(dir, before);
|
|
2553
2601
|
})
|
|
2554
2602
|
);
|
|
@@ -2557,7 +2605,7 @@ function createCodexEngine(opts) {
|
|
|
2557
2605
|
let fatal = null;
|
|
2558
2606
|
let next = 0;
|
|
2559
2607
|
try {
|
|
2560
|
-
const workers = Array.from({ length: Math.min(
|
|
2608
|
+
const workers = Array.from({ length: Math.min(CODEX_POOL, count) }, async () => {
|
|
2561
2609
|
while (next < count && !inner.signal.aborted) {
|
|
2562
2610
|
const i = next++;
|
|
2563
2611
|
try {
|
|
@@ -2624,7 +2672,7 @@ function createCodexEngine(opts) {
|
|
|
2624
2672
|
}
|
|
2625
2673
|
const before = await snapshotGenerated();
|
|
2626
2674
|
try {
|
|
2627
|
-
await runCodex(args, signal, { stdin: promptText });
|
|
2675
|
+
await runCodex(args, signal, { stdin: promptText, label: `edit refs=${editRefs.length}` });
|
|
2628
2676
|
} catch (err) {
|
|
2629
2677
|
if (isFatalSetupError(err)) runner.invalidateProbe();
|
|
2630
2678
|
throw err;
|
|
@@ -3129,6 +3177,21 @@ function sceneGuardDirectives(opts) {
|
|
|
3129
3177
|
"The scene direction above describes the set, not the cast. If it says the space is empty or that no people appear, disregard that: the attached person stands in this set, clearly visible. A ban on props or extra objects in the scene direction is about set dressing only \u2014 it never applies to the presenter or to the product in their hands."
|
|
3130
3178
|
);
|
|
3131
3179
|
}
|
|
3180
|
+
if (opts.hasScenePhoto && (opts.hasProduct || opts.hasPerson)) {
|
|
3181
|
+
out.push(
|
|
3182
|
+
"One attached reference is the scene's own photograph. It shows this world \u2014 the set, the light, and any treatment this world applies \u2014 never a cast: any product, garment, prop or person visible in it is a stand-in, demonstrating where the subject sits, how large it stands in the frame, and what has been done to it."
|
|
3183
|
+
);
|
|
3184
|
+
if (opts.hasProduct) {
|
|
3185
|
+
out.push(
|
|
3186
|
+
"The product in the scene photograph is not in this shot. The attached product photo is the only source of product identity: that exact product takes the stand-in's position, at the placement and scale the scene photograph demonstrates, keeping its own shape, label and colours."
|
|
3187
|
+
);
|
|
3188
|
+
}
|
|
3189
|
+
if (opts.hasPerson) {
|
|
3190
|
+
out.push(
|
|
3191
|
+
"Any person in the scene photograph lends their role, never their face: the attached presenter takes their place, wearing whatever treatment this world applies, with their identity drawn from their own photographs alone."
|
|
3192
|
+
);
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3132
3195
|
return out;
|
|
3133
3196
|
}
|
|
3134
3197
|
function brandRuleDirectives(brand) {
|
|
@@ -3388,7 +3451,13 @@ function compileBrief(brief, ctx) {
|
|
|
3388
3451
|
}
|
|
3389
3452
|
const sceneCamera = inlineTemplates[0]?.camera?.trim() || ctx.template?.camera?.trim() || "";
|
|
3390
3453
|
const cameraDirectives = sceneCamera && !shotSpecifiesCamera(sentence) ? [`Camera for this shot: ${sceneCamera}`] : [];
|
|
3391
|
-
const
|
|
3454
|
+
const max = ctx.engineCaps.maxReferenceImages;
|
|
3455
|
+
const { kept, dropped } = allocateAttachments(attachments, max);
|
|
3456
|
+
const guard = scene ? sceneGuardDirectives({
|
|
3457
|
+
hasProduct: !!productId,
|
|
3458
|
+
hasPerson,
|
|
3459
|
+
hasScenePhoto: kept.some((a) => a.role === "scene")
|
|
3460
|
+
}) : [];
|
|
3392
3461
|
const pairDirectives = productId && hasPerson ? [
|
|
3393
3462
|
"If the attached product is something a person wears, the presenter wears that exact product, with the rest of the outfit styled around it; otherwise the presenter presents or uses the product naturally.",
|
|
3394
3463
|
// A product's own notes are written for solo packshots, and several
|
|
@@ -3434,8 +3503,6 @@ function compileBrief(brief, ctx) {
|
|
|
3434
3503
|
...preservation
|
|
3435
3504
|
];
|
|
3436
3505
|
if (allDirectives.length) prompt = `${prompt}${prompt.endsWith(".") ? "" : "."} ${dedupe(allDirectives).join(" ")}`;
|
|
3437
|
-
const max = ctx.engineCaps.maxReferenceImages;
|
|
3438
|
-
const { kept, dropped } = allocateAttachments(attachments, max);
|
|
3439
3506
|
if (dropped.some((d) => d.role !== "scene")) {
|
|
3440
3507
|
const names = [...new Set(dropped.filter((d) => d.role !== "scene").map((d) => d.label))];
|
|
3441
3508
|
const reads = max === 0 ? "reads no reference images" : `reads ${max} reference image${max === 1 ? "" : "s"}`;
|
|
@@ -5925,6 +5992,10 @@ async function cardCrop(core, hash) {
|
|
|
5925
5992
|
return { left: Math.max(0, Math.round((w - width) / 2)), top: 0, width, height };
|
|
5926
5993
|
});
|
|
5927
5994
|
}
|
|
5995
|
+
var AVATAR_FIGURE_FRACTION = 0.22;
|
|
5996
|
+
var AVATAR_HEADROOM = 0.1;
|
|
5997
|
+
var AVATAR_MAX_PX = 512;
|
|
5998
|
+
var FIGURE_TRIM_THRESHOLDS = [12, 25];
|
|
5928
5999
|
async function avatarCrop(core, hash) {
|
|
5929
6000
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
5930
6001
|
let box = null;
|
|
@@ -5933,31 +6004,39 @@ async function avatarCrop(core, hash) {
|
|
|
5933
6004
|
} catch {
|
|
5934
6005
|
box = null;
|
|
5935
6006
|
}
|
|
5936
|
-
return crop(
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
6007
|
+
return crop(
|
|
6008
|
+
core,
|
|
6009
|
+
hash,
|
|
6010
|
+
(w, h) => {
|
|
6011
|
+
if (!box) {
|
|
6012
|
+
const size2 = Math.min(w, h, Math.round(h * 0.16));
|
|
6013
|
+
return { left: Math.max(0, Math.round((w - size2) / 2)), top: 0, width: size2, height: size2 };
|
|
6014
|
+
}
|
|
6015
|
+
const size = Math.min(w, h, Math.max(16, Math.round(box.height * AVATAR_FIGURE_FRACTION)));
|
|
6016
|
+
const top = Math.min(Math.max(0, Math.round(box.top - size * AVATAR_HEADROOM)), h - size);
|
|
6017
|
+
const left = Math.min(Math.max(0, Math.round(box.left + box.width / 2 - size / 2)), w - size);
|
|
6018
|
+
return { left, top, width: size, height: size };
|
|
6019
|
+
},
|
|
6020
|
+
AVATAR_MAX_PX
|
|
6021
|
+
);
|
|
5946
6022
|
}
|
|
5947
6023
|
async function figureBox(buf) {
|
|
5948
6024
|
const meta = await sharp7(buf).metadata();
|
|
5949
6025
|
const W = meta.width ?? 0;
|
|
5950
6026
|
const H = meta.height ?? 0;
|
|
5951
6027
|
if (!W || !H) return null;
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
6028
|
+
for (const threshold of FIGURE_TRIM_THRESHOLDS) {
|
|
6029
|
+
const { info } = await sharp7(buf).trim({ threshold }).toBuffer({ resolveWithObject: true });
|
|
6030
|
+
const left = Math.abs(info.trimOffsetLeft ?? 0);
|
|
6031
|
+
const top = Math.abs(info.trimOffsetTop ?? 0);
|
|
6032
|
+
const width = info.width ?? 0;
|
|
6033
|
+
const height = info.height ?? 0;
|
|
6034
|
+
if (!width || !height) continue;
|
|
6035
|
+
if (width >= W || height >= H) continue;
|
|
6036
|
+
if (height < H * 0.3 || width < W * 0.05) continue;
|
|
6037
|
+
return { left, top, width, height };
|
|
6038
|
+
}
|
|
6039
|
+
return null;
|
|
5961
6040
|
}
|
|
5962
6041
|
async function presenterCrops(core, hash, mode) {
|
|
5963
6042
|
const previewHash = mode === "generated" ? await cardCrop(core, hash) ?? await cardCropSmart(core, hash) : await cardCropSmart(core, hash) ?? await cardCrop(core, hash);
|
|
@@ -5992,14 +6071,16 @@ async function smartCover(core, hash, box) {
|
|
|
5992
6071
|
return void 0;
|
|
5993
6072
|
}
|
|
5994
6073
|
}
|
|
5995
|
-
async function crop(core, hash, region) {
|
|
6074
|
+
async function crop(core, hash, region, cap2) {
|
|
5996
6075
|
if (!hash || !core.images.has(hash)) return void 0;
|
|
5997
6076
|
try {
|
|
5998
6077
|
const meta = await sharp7(core.images.read(hash)).metadata();
|
|
5999
6078
|
const w = meta.width ?? 0;
|
|
6000
6079
|
const h = meta.height ?? 0;
|
|
6001
6080
|
if (!w || !h) return void 0;
|
|
6002
|
-
|
|
6081
|
+
let pipeline = sharp7(core.images.read(hash)).extract(region(w, h));
|
|
6082
|
+
if (cap2) pipeline = pipeline.resize(cap2, cap2, { fit: "inside", withoutEnlargement: true });
|
|
6083
|
+
const png = await pipeline.png().toBuffer();
|
|
6003
6084
|
return core.images.save(png);
|
|
6004
6085
|
} catch {
|
|
6005
6086
|
return void 0;
|
|
@@ -6964,6 +7045,23 @@ var COST_PROBE = {
|
|
|
6964
7045
|
};
|
|
6965
7046
|
var MARK_MAX_EDGE = 2048;
|
|
6966
7047
|
var toMarkPng = (buf) => sharp7(buf, { density: 384 }).rotate().resize({ width: MARK_MAX_EDGE, height: MARK_MAX_EDGE, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
7048
|
+
var cappedRefs = /* @__PURE__ */ new Map();
|
|
7049
|
+
async function capReferenceEdge(core, path, maxEdge) {
|
|
7050
|
+
const key = `${path}#${maxEdge}`;
|
|
7051
|
+
const hit = cappedRefs.get(key);
|
|
7052
|
+
if (hit) return hit;
|
|
7053
|
+
let out = path;
|
|
7054
|
+
try {
|
|
7055
|
+
const meta = await sharp7(path).metadata();
|
|
7056
|
+
if ((meta.width ?? 0) > maxEdge || (meta.height ?? 0) > maxEdge) {
|
|
7057
|
+
const buf = await sharp7(path).resize({ width: maxEdge, height: maxEdge, fit: "inside", withoutEnlargement: true }).png().toBuffer();
|
|
7058
|
+
out = core.images.pathFor(core.images.save(buf));
|
|
7059
|
+
}
|
|
7060
|
+
} catch {
|
|
7061
|
+
}
|
|
7062
|
+
cappedRefs.set(key, out);
|
|
7063
|
+
return out;
|
|
7064
|
+
}
|
|
6967
7065
|
var readImagePart = async (core, req, normalize2) => {
|
|
6968
7066
|
const part = await req.file();
|
|
6969
7067
|
if (!part) return { error: "multipart file field required" };
|
|
@@ -7280,7 +7378,7 @@ async function repairPresenterCrops(core, log = () => {
|
|
|
7280
7378
|
let repaired = 0;
|
|
7281
7379
|
for (const brand of core.store.listBrands()) {
|
|
7282
7380
|
for (const c of brandCharacters(brand.json)) {
|
|
7283
|
-
if (!
|
|
7381
|
+
if (!c?.id) continue;
|
|
7284
7382
|
try {
|
|
7285
7383
|
const firstShot = c.shots?.[0]?.file;
|
|
7286
7384
|
const hash = typeof firstShot === "string" && firstShot.startsWith("asset:") ? firstShot.slice(6) : null;
|
|
@@ -7513,7 +7611,11 @@ function registerAssetBuildRoutes(app, deps) {
|
|
|
7513
7611
|
kind: "scene",
|
|
7514
7612
|
sceneId: id,
|
|
7515
7613
|
name: scene.name,
|
|
7516
|
-
|
|
7614
|
+
// A plain re-read used to drop the Direction on the floor: body.correction
|
|
7615
|
+
// was the only source, so the analyzer's deciding-word preamble never
|
|
7616
|
+
// fired and whatever the Direction excluded came straight back. No new
|
|
7617
|
+
// word means the stored word.
|
|
7618
|
+
instruction: String(body.correction ?? "").trim() || scene.instruction || void 0,
|
|
7517
7619
|
imageHashes: []
|
|
7518
7620
|
});
|
|
7519
7621
|
} catch (err) {
|
|
@@ -7973,6 +8075,30 @@ function registerImageRoutes(app, deps) {
|
|
|
7973
8075
|
|
|
7974
8076
|
// src/release/notes.data.ts
|
|
7975
8077
|
var RELEASES = [
|
|
8078
|
+
{
|
|
8079
|
+
version: "0.6.6",
|
|
8080
|
+
date: "2026-08-29",
|
|
8081
|
+
sections: [
|
|
8082
|
+
{
|
|
8083
|
+
heading: "Create",
|
|
8084
|
+
body: "Removing a chip from the brief now works on the first click, every time, including right after dragging chips around. Codex generations no longer fail as timeouts while they are still working, and when a run does fail, the message says what actually happened instead of suggesting a second try."
|
|
8085
|
+
},
|
|
8086
|
+
{
|
|
8087
|
+
heading: "Presenters",
|
|
8088
|
+
body: "Custom presenter avatars are cropped like real profile photos, head and shoulders from the front reference. Presenters you made earlier are reframed automatically, including ones that never got an avatar at all."
|
|
8089
|
+
}
|
|
8090
|
+
]
|
|
8091
|
+
},
|
|
8092
|
+
{
|
|
8093
|
+
version: "0.6.5",
|
|
8094
|
+
date: "2026-08-29",
|
|
8095
|
+
sections: [
|
|
8096
|
+
{
|
|
8097
|
+
heading: "Scenes",
|
|
8098
|
+
body: "A Scene no longer brings its own demo object into your shots. The product and presenter you select take the place of whatever its reference or preview happened to stage, while the environment, the light and any figure treatment carry over as before. Editing the Direction and pressing Read again now applies the Direction you wrote instead of quietly ignoring it."
|
|
8099
|
+
}
|
|
8100
|
+
]
|
|
8101
|
+
},
|
|
7976
8102
|
{
|
|
7977
8103
|
version: "0.6.4",
|
|
7978
8104
|
date: "2026-08-29",
|
|
@@ -9039,19 +9165,21 @@ function buildServer(opts) {
|
|
|
9039
9165
|
}
|
|
9040
9166
|
}
|
|
9041
9167
|
const NODE_TIMEOUT_MS = 6e5;
|
|
9042
|
-
async function runNode(nodeId, engine, estimate, work, expect, post) {
|
|
9168
|
+
async function runNode(nodeId, engine, estimate, work, expect, post, timeoutMs) {
|
|
9043
9169
|
const engineId = engine?.capabilities().id ?? "local";
|
|
9044
9170
|
reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
|
|
9045
9171
|
const ctrl = new AbortController();
|
|
9046
9172
|
runningGenerations.set(nodeId, ctrl);
|
|
9173
|
+
const bound = opts.nodeTimeoutMs ?? timeoutMs ?? NODE_TIMEOUT_MS;
|
|
9047
9174
|
let watchdogFired = false;
|
|
9048
9175
|
const watchdog = setTimeout(() => {
|
|
9049
9176
|
watchdogFired = true;
|
|
9050
9177
|
ctrl.abort();
|
|
9051
|
-
},
|
|
9178
|
+
}, bound);
|
|
9052
9179
|
const startedAt = Date.now();
|
|
9053
9180
|
try {
|
|
9054
9181
|
const result = await work(ctrl.signal);
|
|
9182
|
+
clearTimeout(watchdog);
|
|
9055
9183
|
result.images = await normalizePngs(result.images);
|
|
9056
9184
|
if (post) result.images = await post(result.images);
|
|
9057
9185
|
if (expect) await assertAspect(result.images, expect);
|
|
@@ -9073,7 +9201,8 @@ function buildServer(opts) {
|
|
|
9073
9201
|
}
|
|
9074
9202
|
core.ledger.recordCost(engineId, nodeId, result.costUsd);
|
|
9075
9203
|
} catch (err) {
|
|
9076
|
-
if (watchdogFired)
|
|
9204
|
+
if (watchdogFired)
|
|
9205
|
+
core.store.failNode(nodeId, `generation timed out after ${Math.round(bound / 6e4)} minutes`);
|
|
9077
9206
|
else if (ctrl.signal.aborted) core.store.cancelNode(nodeId);
|
|
9078
9207
|
else core.store.failNode(nodeId, String(err?.message ?? err));
|
|
9079
9208
|
} finally {
|
|
@@ -9267,13 +9396,16 @@ function buildServer(opts) {
|
|
|
9267
9396
|
error: `${engine.capabilities().displayName} cannot carry enough reference images, so ${names} would be named in the prompt but never shown. The result would not be your ${kindWord}. Choose an engine that supports reference images, or remove ${names} from the brief.`
|
|
9268
9397
|
});
|
|
9269
9398
|
}
|
|
9399
|
+
const maxEdge = engine.capabilities().maxReferenceEdge;
|
|
9400
|
+
const keptRefs = referenceImages && cap2 > 0 ? referenceImages.slice(0, cap2) : void 0;
|
|
9401
|
+
const sentRefs = keptRefs && maxEdge ? await Promise.all(keptRefs.map((p) => capReferenceEdge(core, p, maxEdge))) : keptRefs;
|
|
9270
9402
|
const genReq = {
|
|
9271
9403
|
prompt: finalPrompt,
|
|
9272
9404
|
brand: ctx,
|
|
9273
9405
|
width: Number(width),
|
|
9274
9406
|
height: Number(height),
|
|
9275
9407
|
count: Math.min(Math.max(1, Number(count)), 8),
|
|
9276
|
-
...
|
|
9408
|
+
...sentRefs ? { referenceImages: sentRefs } : {},
|
|
9277
9409
|
...referenceRoles && cap2 > 0 ? { referenceRoles: referenceRoles.slice(0, cap2) } : {}
|
|
9278
9410
|
};
|
|
9279
9411
|
estimate = await engine.costEstimate(genReq);
|
|
@@ -9288,6 +9420,8 @@ function buildServer(opts) {
|
|
|
9288
9420
|
if (!engine.capabilities().supportsEdit)
|
|
9289
9421
|
return reply.status(400).send({ error: "engine does not support edits" });
|
|
9290
9422
|
const editRefs = mergedEdit ? mergedEdit.kept.map((a) => ({ path: core.images.pathFor(a.hash), role: a.role })) : (referenceImages ?? []).map((path, i) => ({ path, role: referenceRoles?.[i] })).slice(0, Math.max(0, engine.capabilities().maxReferenceImages - 1));
|
|
9423
|
+
const editEdge = engine.capabilities().maxReferenceEdge;
|
|
9424
|
+
if (editEdge) for (const r of editRefs) r.path = await capReferenceEdge(core, r.path, editEdge);
|
|
9291
9425
|
const srcBuf = core.images.read(String(srcHash));
|
|
9292
9426
|
const srcMeta = await sharp7(srcBuf).metadata();
|
|
9293
9427
|
if (srcMeta.width && srcMeta.height) expectShape = { width: srcMeta.width, height: srcMeta.height };
|
|
@@ -9460,7 +9594,8 @@ function buildServer(opts) {
|
|
|
9460
9594
|
}
|
|
9461
9595
|
return out;
|
|
9462
9596
|
} : void 0;
|
|
9463
|
-
|
|
9597
|
+
const nodeBudgetMs = kind === "generation" && runEngine.capabilities().id === "codex-cli" ? codexNodeBudgetMs(Math.min(Math.max(1, Number(count)), 8)) : void 0;
|
|
9598
|
+
void runNode(node.id, runEngine, estimate, work, expectShape, post, nodeBudgetMs).catch(
|
|
9464
9599
|
(err) => app.log.error({ err }, "node run failed")
|
|
9465
9600
|
);
|
|
9466
9601
|
const allWarnings = [...compiled2?.warnings ?? [], ...extraWarnings];
|