@litfamily/litopencode 1.0.29 → 1.0.30
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 +18 -0
- package/README-Ko-KR.md +7 -7
- package/README.md +7 -7
- package/dist/activation-primary-prompts.js +15 -6
- package/dist/activation-prompt-utils.d.ts +2 -2
- package/dist/activation-prompt-utils.js +9 -3
- package/dist/activation-reentry.d.ts +29 -0
- package/dist/activation-reentry.js +71 -0
- package/dist/activation-routing.d.ts +1 -1
- package/dist/activation-routing.js +40 -7
- package/dist/activation-workflow-prompts.d.ts +2 -0
- package/dist/activation-workflow-prompts.js +32 -7
- package/dist/activation.d.ts +6 -1
- package/dist/activation.js +34 -7
- package/dist/cli/args.js +14 -0
- package/dist/cli/auto-update.js +2 -4
- package/dist/cli/command-plan.d.ts +40 -0
- package/dist/cli/command-plan.js +62 -0
- package/dist/cli/doctor.d.ts +4 -1
- package/dist/cli/doctor.js +65 -4
- package/dist/cli/host-detect.d.ts +58 -0
- package/dist/cli/host-detect.js +199 -0
- package/dist/cli/install-report.js +3 -1
- package/dist/cli/install.d.ts +9 -1
- package/dist/cli/install.js +134 -31
- package/dist/cli/managed-skill-assets.d.ts +1 -1
- package/dist/cli/managed-skill-assets.js +5 -3
- package/dist/cli/office-dependencies.d.ts +2 -0
- package/dist/cli/office-dependencies.js +7 -3
- package/dist/cli/scientific-visualization-dependencies.js +1 -1
- package/dist/cli/tui-config.d.ts +27 -2
- package/dist/cli/tui-config.js +77 -2
- package/dist/cli/types.d.ts +17 -0
- package/dist/cli.js +9 -6
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +17 -3
- package/dist/features.d.ts +27 -1
- package/dist/features.js +40 -8
- package/dist/host/server.d.ts +7 -0
- package/dist/host/server.js +12 -0
- package/dist/host/tui.d.ts +7 -0
- package/dist/host/tui.js +12 -0
- package/dist/server.js +9 -0
- package/dist/skill-renames.d.ts +1 -1
- package/dist/skills.d.ts +9 -2
- package/dist/skills.js +22 -8
- package/dist/tui/mission-source-v2.d.ts +51 -0
- package/dist/tui/mission-source-v2.js +52 -0
- package/dist/v2/agents.d.ts +105 -0
- package/dist/v2/agents.js +185 -0
- package/dist/v2/commands.d.ts +44 -0
- package/dist/v2/commands.js +65 -0
- package/dist/v2/context.d.ts +67 -0
- package/dist/v2/context.js +17 -0
- package/dist/v2/events.d.ts +55 -0
- package/dist/v2/events.js +193 -0
- package/dist/v2/hooks.d.ts +39 -0
- package/dist/v2/hooks.js +61 -0
- package/dist/v2/server.d.ts +2 -0
- package/dist/v2/server.js +68 -0
- package/dist/v2/session-hooks.d.ts +64 -0
- package/dist/v2/session-hooks.js +230 -0
- package/dist/v2/tools.d.ts +26 -0
- package/dist/v2/tools.js +49 -0
- package/dist/v2/tui.d.ts +63 -0
- package/dist/v2/tui.js +259 -0
- package/docs/assets/cover-motion-still.webp +0 -0
- package/docs/assets/cover-motion.webp +0 -0
- package/docs/assets/cover.webp +0 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/assets/readme/litfamily-machines.png +0 -0
- package/docs/migration.md +4 -3
- package/docs/reference-Ko-KR.md +109 -7
- package/docs/reference.md +122 -9
- package/package.json +10 -5
- package/skills/deep-interview/SKILL.md +1 -1
- package/skills/lit-brainstorm/SKILL.md +299 -0
- package/skills/lit-brainstorm/references/handoff-briefs.md +72 -0
- package/skills/lit-brainstorm/references/lenses.md +106 -0
- package/skills/lit-brainstorm/references/scoring-and-convergence.md +93 -0
- package/skills/lit-brainstorm/references/session-notes-and-lanes.md +122 -0
- package/skills/lit-docx/SKILL.md +1 -1
- package/skills/lit-docx/run.py +42 -4
- package/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -0
- package/skills/lit-docx/templates/latex/acs/template.tex +2 -0
- package/skills/lit-docx/templates/latex/elsevier/template.tex +2 -0
- package/skills/lit-docx/templates/latex/ieee/template.tex +2 -0
- package/skills/lit-docx/templates/latex/korean-generic/template.tex +2 -0
- package/skills/lit-docx/templates/latex/nature/template.tex +2 -0
- package/skills/lit-humanizer/scripts/detect.mjs +1 -1
- package/skills/lit-pptx/SKILL.md +1 -1
- package/skills/lit-pptx/command.mjs +94 -0
- package/skills/lit-pptx/run.mjs +16 -6
- package/skills/lit-pptx/scripts/compile-deck.js +2 -2
- package/skills/lit-typographic-motion/SKILL.md +5 -2
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +3 -0
- package/skills/lit-typographic-motion/engine/browser.mjs +3 -9
- package/skills/lit-typographic-motion/engine/command.mjs +94 -0
- package/skills/lit-typographic-motion/engine/constants.mjs +29 -1
- package/skills/lit-typographic-motion/engine/cover/assets.mjs +91 -0
- package/skills/lit-typographic-motion/engine/cover/capture.mjs +173 -0
- package/skills/lit-typographic-motion/engine/cover/decode.mjs +173 -0
- package/skills/lit-typographic-motion/engine/cover/frame-worker.mjs +19 -0
- package/skills/lit-typographic-motion/engine/cover/gate.mjs +261 -0
- package/skills/lit-typographic-motion/engine/cover/image.mjs +138 -0
- package/skills/lit-typographic-motion/engine/cover/render.mjs +323 -0
- package/skills/lit-typographic-motion/engine/cover/sheet.mjs +65 -0
- package/skills/lit-typographic-motion/engine/cover/treatment.mjs +110 -0
- package/skills/lit-typographic-motion/engine/encode.mjs +1 -1
- package/skills/lit-typographic-motion/engine/look.mjs +113 -10
- package/skills/lit-typographic-motion/engine/sound.mjs +1 -1
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +26 -18
- package/skills/lit-typographic-motion/engine/stage/cover-init.js +38 -0
- package/skills/lit-typographic-motion/engine/stage/loop-kit.js +102 -0
- package/skills/lit-typographic-motion/engine/stage/render.mjs +4 -4
- package/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
- package/skills/lit-typographic-motion/fonts/JetBrainsMono-OFL.txt +93 -0
- package/skills/lit-typographic-motion/fonts/JetBrainsMono-wght-latin.woff2 +0 -0
- package/skills/lit-typographic-motion/fonts/pins.json +3 -1
- package/skills/lit-typographic-motion/gate.mjs +34 -1
- package/skills/lit-typographic-motion/probe.mjs +8 -3
- package/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +16 -0
- package/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +35 -0
- package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +16 -0
- package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +29 -0
- package/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +17 -0
- package/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
- package/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +17 -0
- package/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +35 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +62 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/index.html +100 -0
- package/skills/lit-typographic-motion/recipes/painted-light/expected.json +95 -0
- package/skills/lit-typographic-motion/recipes/painted-light/index.html +232 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +62 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
- package/skills/lit-typographic-motion/references/cover.md +423 -0
- package/skills/lit-typographic-motion/references/craft-loop.md +2 -0
- package/skills/lit-typographic-motion/references/runtime.md +17 -2
- package/skills/lit-typographic-motion/references/stage.md +3 -0
- package/skills/lit-typographic-motion/references/treatment.md +4 -0
- package/skills/lit-typographic-motion/render.mjs +30 -6
- package/skills/lit-typographic-motion/runtime.mjs +12 -13
- package/skills/{durable-litgoal → litgoal}/SKILL.md +31 -12
- package/skills/managed-skill-manifest.json +173 -29
- package/skills/review-work/SKILL.md +27 -18
- package/skills/skill-rename-aliases.json +2 -1
- package/tools/check-pack-payload.mjs +4 -1
- package/tools/payload-substance-allowlist.json +1 -1
- package/tools/payload-substance-parity.json +6 -4
- package/tools/qa-cover-recipes.mjs +167 -0
- package/tools/version-manifests.json +6 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// `render.mjs cover`: one cover round. Treatment (exit 16) -> static scan of cover/ (17, 19) -> the
|
|
2
|
+
// asset list (22 before any capture) -> pre-warm, Chrome and the encoder (10, 14, 15, 21) -> the
|
|
3
|
+
// master browser seeks t = i / N for every frame at 2x, then the title-mask pass -> a fresh browser
|
|
4
|
+
// repeats every frame (G0) -> another fresh browser draws a seeded shuffle of 16 frames, which must
|
|
5
|
+
// match the master within the rasterizer's one-level rounding (18 otherwise: the page keeps state)
|
|
6
|
+
// -> flash audit of the frames as a loop -> img2webp (frame 0 and the frames before the seam at a
|
|
7
|
+
// higher quality), or gifski when libwebp is missing -> the gate on the DECODED file, with G6 and the
|
|
8
|
+
// frame-0 art check -> stills from the decoded file (twelve frames, poster, seam pair, sheet) ->
|
|
9
|
+
// manifest and report. Exit 13 until every gate reaches TARGET; a flash failure withholds the cover.
|
|
10
|
+
// `--window t0,t1` captures and sheets only the frames around a logged defect and never counts as done.
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { performance } from "node:perf_hooks";
|
|
16
|
+
import { COVER, EXIT } from "../constants.mjs";
|
|
17
|
+
import { findChrome, loadPlaywright, which } from "../browser.mjs";
|
|
18
|
+
import { ExcursionDetector, auditCells, countFlashes, flashGeometryFor } from "../flash.mjs";
|
|
19
|
+
import { encodePng } from "../png.mjs";
|
|
20
|
+
import { scanStage, stageSha } from "../stage/scan.mjs";
|
|
21
|
+
import { inspectWarmState, pins, skillRoot } from "../../runtime.mjs";
|
|
22
|
+
import { artCheck, checkAssets } from "./assets.mjs";
|
|
23
|
+
import { CoverPool, capturePass, coverFonts, openCover } from "./capture.mjs";
|
|
24
|
+
import { coverGate, formatCoverReport, framesIn, readRgba, verdicts } from "./gate.mjs";
|
|
25
|
+
import { lumaGrid, meanLumaStep } from "./image.mjs";
|
|
26
|
+
import { composeSheet } from "./sheet.mjs";
|
|
27
|
+
import { loadCoverTreatment } from "./treatment.mjs";
|
|
28
|
+
|
|
29
|
+
export const SHEET_TILES = 12;
|
|
30
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
31
|
+
const stop = (code, name, detail) => Object.assign(new Error(`${name} (${code}): ${detail}`), { exitCode: code });
|
|
32
|
+
const pad4 = (n) => String(n).padStart(4, "0");
|
|
33
|
+
|
|
34
|
+
/** How long img2webp shows each frame: whole milliseconds. */
|
|
35
|
+
export const frameMs = (fps) => Math.round(1000 / fps);
|
|
36
|
+
/** Frame i of N shows loop time t = i / N; t = 1 is never drawn (it would repeat frame 0 at the seam). */
|
|
37
|
+
export const loopTimes = (frames) => Array.from({ length: frames }, (_, i) => [i, i / frames]);
|
|
38
|
+
|
|
39
|
+
/** The encoder this host offers a cover: img2webp (decoded with dwebp), else gifski (decoded with ffmpeg), else none. */
|
|
40
|
+
export function coverEncoder(env = process.env) {
|
|
41
|
+
if (which("img2webp", env) && which("dwebp", env)) return { name: "img2webp", decoder: "dwebp" };
|
|
42
|
+
if (which("gifski", env) && which("ffmpeg", env)) return { name: "gifski", decoder: "ffmpeg" };
|
|
43
|
+
return { name: "none", fix: "install libwebp (img2webp and dwebp), or gifski with ffmpeg; a cover is never an MP4" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** img2webp arguments: lossy, loop forever, every frame `-d` ms, frame 0 and the last `edgeFrames` at `edgeQ`. */
|
|
47
|
+
export function img2webpArgs({ files, fps, encode, out }) {
|
|
48
|
+
const args = ["-loop", "0", "-lossy", "-q", String(encode.q), "-m", String(encode.method)];
|
|
49
|
+
files.forEach((file, i) => args.push("-d", String(frameMs(fps)), "-q", String(i === 0 || i >= files.length - encode.edgeFrames ? encode.edgeQ : encode.q), file));
|
|
50
|
+
return [...args, "-o", out];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Encode the frames into `<out>/cover.webp` (or cover.gif); returns { file, encoder }. */
|
|
54
|
+
export function encodeCover({ files, fps, encode, out, env = process.env }) {
|
|
55
|
+
const encoder = coverEncoder(env);
|
|
56
|
+
if (encoder.name === "none") throw stop(EXIT.BLOCKED_NO_COVER_ENCODER, "BLOCKED_NO_COVER_ENCODER", `no cover encoder on PATH; fix: ${encoder.fix}`);
|
|
57
|
+
const file = path.join(out, encoder.name === "img2webp" ? "cover.webp" : "cover.gif");
|
|
58
|
+
const args = encoder.name === "img2webp" ? img2webpArgs({ files, fps, encode, out: file }) : ["--fps", String(fps), "--quality", "90", "--repeat", "0", "-o", file, ...files];
|
|
59
|
+
const run = spawnSync(which(encoder.name, env), args, { encoding: "utf8", timeout: 1_200_000, maxBuffer: 64 * 1024 * 1024 });
|
|
60
|
+
if (run.status !== 0) throw new Error(`${encoder.name} failed: ${(run.stderr || run.error?.message || "").trim().split(/\r?\n/u).at(-1)}`);
|
|
61
|
+
return { file, encoder: encoder.name };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A seeded shuffle (mulberry32, as the loop kit) of `count` distinct frame indices. */
|
|
65
|
+
export function seekOrder(frames, seed, count = COVER.seekFrames) {
|
|
66
|
+
let state = seed >>> 0;
|
|
67
|
+
const next = () => {
|
|
68
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
69
|
+
let z = state;
|
|
70
|
+
z = Math.imul(z ^ (z >>> 15), z | 1);
|
|
71
|
+
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
|
|
72
|
+
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
|
|
73
|
+
};
|
|
74
|
+
const order = Array.from({ length: frames }, (_, i) => i);
|
|
75
|
+
for (let i = frames - 1; i > 0; i--) {
|
|
76
|
+
const j = Math.floor(next() * (i + 1));
|
|
77
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
78
|
+
}
|
|
79
|
+
return order.slice(0, Math.min(count, frames));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The largest per-channel difference between two same-size frames on disk. */
|
|
83
|
+
export function maxLevelDiff(fileA, fileB) {
|
|
84
|
+
const a = readRgba(fileA), b = readRgba(fileB);
|
|
85
|
+
if (a.width !== b.width || a.height !== b.height) return 255;
|
|
86
|
+
let worst = 0;
|
|
87
|
+
for (let i = 0; i < a.rgba.length; i++) worst = Math.max(worst, Math.abs(a.rgba[i] - b.rgba[i]));
|
|
88
|
+
return worst;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The film gate's flash detector (WCAG 2.3.1) over the cover frames as a loop. */
|
|
92
|
+
export function flashAudit(files, fps) {
|
|
93
|
+
const first = readRgba(files[0]);
|
|
94
|
+
const geometry = flashGeometryFor(first.width, first.height);
|
|
95
|
+
const detector = new ExcursionDetector(geometry);
|
|
96
|
+
const records = [];
|
|
97
|
+
for (let i = 0; i < files.length + fps; i++) {
|
|
98
|
+
const frame = i === 0 ? first : readRgba(files[i % files.length]);
|
|
99
|
+
const record = detector.push(auditCells(frame.rgba, frame.width, frame.height, geometry));
|
|
100
|
+
if (i < files.length) records.push(record);
|
|
101
|
+
else for (const key of ["up", "down", "redUp", "redDown"]) records[i % files.length][key] ||= record[key];
|
|
102
|
+
}
|
|
103
|
+
return countFlashes(records, fps, true);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Everything a cover round checks before a browser starts.
|
|
107
|
+
function prepare(out) {
|
|
108
|
+
const cover = loadCoverTreatment(out);
|
|
109
|
+
const coverDir = path.join(out, "cover");
|
|
110
|
+
if (!existsSync(path.join(coverDir, "index.html"))) throw stop(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `no cover page at ${path.join(coverDir, "index.html")}; write it first (references/cover.md)`);
|
|
111
|
+
const scan = scanStage(coverDir);
|
|
112
|
+
const assets = checkAssets({ out, treatment: cover, rasters: scan.rasters });
|
|
113
|
+
const state = inspectWarmState();
|
|
114
|
+
if (state.blocked) throw stop(state.blocked.exit, state.blocked.name, `${state.blocked.detail}; fix: ${state.fix}`);
|
|
115
|
+
const chrome = findChrome();
|
|
116
|
+
if (!chrome.path) throw stop(EXIT.BLOCKED_NO_CHROME, "BLOCKED_NO_CHROME", `${chrome.missing}; fix: install Chrome or set CHROME_PATH`);
|
|
117
|
+
return { cover, coverDir, assets, state, chromePath: chrome.path, fonts: coverFonts(skillRoot, state, pins) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One browser for one pass; the profile lives under .run/cover/ and goes with the pass.
|
|
121
|
+
async function withBrowser(chromium, context, profile, body) {
|
|
122
|
+
rmSync(profile, { recursive: true, force: true });
|
|
123
|
+
mkdirSync(profile, { recursive: true });
|
|
124
|
+
const browser = await openCover(chromium, { chromePath: context.chromePath, profile, coverDir: context.coverDir, page: context.cover.page, scale: COVER.captureScale, fonts: context.fonts });
|
|
125
|
+
try { return await body(browser); } finally { await browser.close(); rmSync(profile, { recursive: true, force: true }); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The decoded frame on show `ms` into the loop. */
|
|
129
|
+
export function frameAt(durations, ms) {
|
|
130
|
+
let end = 0;
|
|
131
|
+
for (let i = 0; i < durations.length; i++) { end += durations[i]; if (ms < end) return i; }
|
|
132
|
+
return durations.length - 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The stills of a finished cover, taken from the decoded file, and their index stills/stills.json.
|
|
136
|
+
function writeCoverStills(out, { round, gate, cover }) {
|
|
137
|
+
const dir = path.join(out, "stills");
|
|
138
|
+
rmSync(dir, { recursive: true, force: true });
|
|
139
|
+
mkdirSync(dir, { recursive: true });
|
|
140
|
+
const { width, height, durations, kept } = gate;
|
|
141
|
+
const total = durations.reduce((a, b) => a + b, 0);
|
|
142
|
+
const files = [];
|
|
143
|
+
const save = (name, w, h, pixels, kind, extra) => {
|
|
144
|
+
const bytes = encodePng(w, h, pixels, 4, 6);
|
|
145
|
+
writeFileSync(path.join(dir, name), bytes);
|
|
146
|
+
files.push({ file: `stills/${name}`, kind, sha256: sha256(bytes), ...extra });
|
|
147
|
+
};
|
|
148
|
+
const tiles = [];
|
|
149
|
+
for (let k = 0; k < SHEET_TILES; k++) {
|
|
150
|
+
const at = k / SHEET_TILES, index = frameAt(durations, at * total);
|
|
151
|
+
save(`frame-${String(k).padStart(2, "0")}.png`, width, height, kept.get(index), "frame", { t: Number((at * total / 1000).toFixed(3)), decodedFrame: index });
|
|
152
|
+
tiles.push({ rgba: kept.get(index), width, height, at });
|
|
153
|
+
}
|
|
154
|
+
const last = durations.length - 1;
|
|
155
|
+
save("poster.png", width, height, kept.get(0), "poster", { decodedFrame: 0 });
|
|
156
|
+
const pair = Buffer.alloc(width * 2 * height * 4);
|
|
157
|
+
for (let y = 0; y < height; y++) {
|
|
158
|
+
kept.get(last).copy(pair, y * width * 8, y * width * 4, (y + 1) * width * 4);
|
|
159
|
+
kept.get(0).copy(pair, y * width * 8 + width * 4, y * width * 4, (y + 1) * width * 4);
|
|
160
|
+
}
|
|
161
|
+
save("seam.png", width * 2, height, pair, "seam", { decodedFrames: [last, 0] });
|
|
162
|
+
const sheet = composeSheet({ tiles, steps: gate.steps, medianD: gate.medianD });
|
|
163
|
+
save("sheet.png", sheet.width, sheet.height, sheet.pixels, "sheet", { tiles: tiles.map((tile) => Number(tile.at.toFixed(4))) });
|
|
164
|
+
const index = { schemaVersion: 1, path: "cover", mode: "cover", round, size: [width, height], fps: cover.fps, durationSec: total / 1000, files, gate: { pass: gate.pass, target: gate.target } };
|
|
165
|
+
writeFileSync(path.join(dir, "stills.json"), JSON.stringify(index, null, 2) + "\n");
|
|
166
|
+
return { index, sha256: sha256(readFileSync(path.join(dir, "stills.json"))) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Moves the previous round's cover files aside, so a new round never mixes with them.
|
|
170
|
+
function clearCoverExports(out, runDir) {
|
|
171
|
+
const previous = path.join(runDir, "previous-round");
|
|
172
|
+
rmSync(previous, { recursive: true, force: true });
|
|
173
|
+
for (const name of ["cover.webp", "cover.gif", "poster.png", "gate-report.txt", "manifest.json"]) {
|
|
174
|
+
for (const source of [path.join(out, name), path.join(out, "withheld", name)]) {
|
|
175
|
+
if (!existsSync(source)) continue;
|
|
176
|
+
mkdirSync(previous, { recursive: true });
|
|
177
|
+
renameSync(source, path.join(previous, `${path.basename(path.dirname(source))}-${name}`));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
rmSync(path.join(out, "withheld"), { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** One full cover round. Returns { code, report, manifest }: 0 at TARGET, 13 otherwise. */
|
|
184
|
+
export async function renderCover(out, { round = 1, log = () => {} } = {}) {
|
|
185
|
+
const context = prepare(out);
|
|
186
|
+
const { cover } = context;
|
|
187
|
+
const encoder = coverEncoder();
|
|
188
|
+
if (encoder.name === "none") throw stop(EXIT.BLOCKED_NO_COVER_ENCODER, "BLOCKED_NO_COVER_ENCODER", `no cover encoder on PATH; fix: ${encoder.fix}`);
|
|
189
|
+
const runDir = path.join(out, ".run");
|
|
190
|
+
const work = path.join(runDir, "cover");
|
|
191
|
+
rmSync(work, { recursive: true, force: true });
|
|
192
|
+
const dirs = { frames: path.join(work, "frames"), masks: path.join(work, "masks"), replay: path.join(work, "replay"), seek: path.join(work, "seek") };
|
|
193
|
+
for (const dir of Object.values(dirs)) mkdirSync(dir, { recursive: true });
|
|
194
|
+
const times = loopTimes(cover.frames);
|
|
195
|
+
const size = cover.size;
|
|
196
|
+
const expected = `${cover.page[0] * COVER.captureScale}x${cover.page[1] * COVER.captureScale}`;
|
|
197
|
+
const order = seekOrder(times.length, cover.seed);
|
|
198
|
+
const chromium = await loadPlaywright(context.state.nodeDir);
|
|
199
|
+
const pool = new CoverPool();
|
|
200
|
+
const began = performance.now();
|
|
201
|
+
let master, replay, sought;
|
|
202
|
+
try {
|
|
203
|
+
await withBrowser(chromium, context, path.join(work, "cm"), async (browser) => {
|
|
204
|
+
log(`cover: master pass, ${times.length} frames at ${expected}`);
|
|
205
|
+
master = await capturePass({ browser, pool, times, dir: dirs.frames, size, expected });
|
|
206
|
+
await capturePass({ browser, pool, times, dir: dirs.masks, prefix: "m", mask: true, size, expected });
|
|
207
|
+
});
|
|
208
|
+
log("cover: replay pass in a fresh browser");
|
|
209
|
+
replay = await withBrowser(chromium, context, path.join(work, "cr"), (browser) => capturePass({ browser, pool, times, dir: dirs.replay, size, expected }));
|
|
210
|
+
log(`cover: seek-order pass, ${order.length} frames`);
|
|
211
|
+
sought = await withBrowser(chromium, context, path.join(work, "cs"), (browser) => capturePass({ browser, pool, times: order.map((i) => times[i]), dir: dirs.seek, prefix: "s", size, expected }));
|
|
212
|
+
} finally {
|
|
213
|
+
await pool.close();
|
|
214
|
+
}
|
|
215
|
+
const captureSec = (performance.now() - began) / 1000;
|
|
216
|
+
const ms = frameMs(cover.fps);
|
|
217
|
+
const differing = times.filter(([i]) => master.get(i) !== replay.get(i));
|
|
218
|
+
if (differing.length) throw stop(EXIT.STAGE_NONDETERMINISTIC, "STAGE_NONDETERMINISTIC", `a fresh capture differs at ${differing.slice(0, 4).map(([i, t]) => `frame ${i} (t = ${t.toFixed(4)})`).join(", ")}${differing.length > 4 ? ` and ${differing.length - 4} more` : ""}; draw every pixel from t and LitLoop.rng(seed)`);
|
|
219
|
+
// An element whose opacity and transform change together can rasterize one level apart depending
|
|
220
|
+
// on what was painted before it; state kept between frames differs by far more.
|
|
221
|
+
let oneLevel = 0;
|
|
222
|
+
for (let k = 0; k < order.length; k++) {
|
|
223
|
+
const i = order[k];
|
|
224
|
+
if (sought.get(i) === master.get(i)) continue;
|
|
225
|
+
const worst = maxLevelDiff(path.join(dirs.frames, `f${pad4(i)}.png`), path.join(dirs.seek, `s${pad4(i)}.png`));
|
|
226
|
+
if (worst <= COVER.seekLevels) { oneLevel += 1; continue; }
|
|
227
|
+
throw stop(EXIT.STAGE_NONDETERMINISTIC, "STAGE_NONDETERMINISTIC", `frame ${i} (t = ${((i * ms) / 1000).toFixed(3)} s) differs by up to ${worst} levels when sought out of order (a fresh page drew it ${k ? `after frame ${order[k - 1]}` : "first"}); the page keeps state between frames: clear every canvas and draw each frame from t alone`);
|
|
228
|
+
}
|
|
229
|
+
const files = framesIn(dirs.frames, "f");
|
|
230
|
+
const flash = flashAudit(files, cover.fps);
|
|
231
|
+
const runOut = path.join(work, "encoded");
|
|
232
|
+
mkdirSync(runOut, { recursive: true });
|
|
233
|
+
const encoded = encodeCover({ files, fps: cover.fps, encode: cover.encode, out: runOut });
|
|
234
|
+
const planned = times.length * ms;
|
|
235
|
+
const wanted = Array.from({ length: SHEET_TILES }, (_, k) => (k / SHEET_TILES) * planned);
|
|
236
|
+
let elapsed = 0;
|
|
237
|
+
const keep = (i, duration) => {
|
|
238
|
+
const start = elapsed;
|
|
239
|
+
elapsed += duration;
|
|
240
|
+
return i === 0 || start + duration >= planned || wanted.some((x) => x >= start && x < start + duration);
|
|
241
|
+
};
|
|
242
|
+
const painted = cover.title.kind === "painted" ? context.assets.find((asset) => asset.role === "base-art" && asset.title) : null;
|
|
243
|
+
const gate = coverGate(encoded.file, { frames: dirs.frames, replay: dirs.replay, masks: dirs.masks, title: cover.title.box, glyph: { exempt: painted ? `painted into the base art ${painted.path}` : null }, fps: cover.fps, keep });
|
|
244
|
+
gate.gates.SEEK = { pass: true, sampled: order.length, identical: order.length - oneLevel, oneLevel };
|
|
245
|
+
for (const asset of context.assets.filter((entry) => entry.role === "base-art")) {
|
|
246
|
+
let art = null;
|
|
247
|
+
try { art = readRgba(path.join(out, asset.path)); } catch { art = null; }
|
|
248
|
+
const check = art ? artCheck({ frame0: gate.kept.get(0), width: gate.width, height: gate.height, art, box: asset.box, checkBox: asset.checkBox }) : null;
|
|
249
|
+
gate.gates.ART = check ? { pass: check.pass, ssim: check.ssim, checked: check.checked.join(",") } : { pass: false, reason: `${asset.path} is not a PNG the gate can compare with frame 0` };
|
|
250
|
+
}
|
|
251
|
+
gate.gates.FLASH = { pass: flash.pass, general: flash.general, red: flash.red };
|
|
252
|
+
Object.assign(gate, verdicts(gate.gates));
|
|
253
|
+
clearCoverExports(out, runDir);
|
|
254
|
+
const delivered = path.join(out, path.basename(encoded.file));
|
|
255
|
+
renameSync(encoded.file, delivered);
|
|
256
|
+
const stills = writeCoverStills(out, { round, gate, cover });
|
|
257
|
+
writeFileSync(path.join(out, "poster.png"), readFileSync(path.join(out, "stills", "poster.png")));
|
|
258
|
+
const report = formatCoverReport(gate, { strict: true });
|
|
259
|
+
writeFileSync(path.join(out, "gate-report.txt"), report);
|
|
260
|
+
const manifest = {
|
|
261
|
+
schemaVersion: 1, mode: "cover", path: "cover", craftRound: round, recipe: cover.recipe,
|
|
262
|
+
page: cover.page, size, scale: COVER.captureScale, fps: cover.fps, frames: times.length, frameMs: ms, seed: cover.seed,
|
|
263
|
+
encoder: encoded.encoder, encode: cover.encode, file: path.basename(delivered), bytes: statSync(delivered).size, captureSec: Number(captureSec.toFixed(1)),
|
|
264
|
+
treatmentSha256: sha256(readFileSync(path.join(out, "treatment.json"))), coverSha256: stageSha(context.coverDir), stillsSha256: stills.sha256,
|
|
265
|
+
gate: { pass: gate.pass, target: gate.target, medianD: gate.medianD, movingFrac: gate.movingFrac, longestStillSec: gate.longestStillSec, spike: gate.spike, seamLocal: gate.seamLocal, gates: gate.gates },
|
|
266
|
+
generatedAt: new Date().toISOString()
|
|
267
|
+
};
|
|
268
|
+
writeFileSync(path.join(out, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
269
|
+
if (!flash.pass) {
|
|
270
|
+
mkdirSync(path.join(out, "withheld"), { recursive: true });
|
|
271
|
+
renameSync(delivered, path.join(out, "withheld", path.basename(delivered)));
|
|
272
|
+
rmSync(path.join(out, "poster.png"), { force: true });
|
|
273
|
+
}
|
|
274
|
+
const failing = Object.entries(gate.gates).filter(([, g]) => g.pass === false).map(([name]) => name);
|
|
275
|
+
const short = Object.entries(gate.gates).filter(([, g]) => g.pass !== false && g.target === false).map(([name]) => name);
|
|
276
|
+
const summary = `cover: ${path.basename(delivered)} ${manifest.bytes} bytes, ${times.length} frames at ${cover.fps} fps, ${size[0]}x${size[1]} from ${expected} captures in ${captureSec.toFixed(0)} s; stills and sheet in ${path.join(out, "stills")}`;
|
|
277
|
+
if (gate.target) return { code: EXIT.OK, report: `${report}${summary}\n`, manifest };
|
|
278
|
+
const why = `GATE_FAIL_QA (13): ${[failing.length ? `${failing.join(", ")} fail` : "", short.length ? `${short.join(", ")} miss the cover TARGET` : ""].filter(Boolean).join("; ")}${flash.pass ? "" : "; the flash audit failed, so the cover is withheld"}`;
|
|
279
|
+
return { code: EXIT.GATE_FAIL_QA, report: `${report}${summary}\n${why}\n`, manifest };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* `--window t0,t1` (seconds into the loop): capture only the frames from t0 - 0.5 s to t1 + 0.5 s
|
|
284
|
+
* (wrapping at the seam) in a fresh page, before encoding, and sheet them with the span shaded. It
|
|
285
|
+
* writes only windows/round-N/<t0>-<t1>/; the stills, the cover, the report and the manifest stay
|
|
286
|
+
* as they were, so a window render never counts as done.
|
|
287
|
+
*/
|
|
288
|
+
export async function renderCoverWindow(out, { round = 1, window: [t0, t1], log = () => {} } = {}) {
|
|
289
|
+
const context = prepare(out);
|
|
290
|
+
const { cover } = context;
|
|
291
|
+
const ms = frameMs(cover.fps);
|
|
292
|
+
const loopSec = (cover.frames * ms) / 1000;
|
|
293
|
+
if (!(Number.isFinite(t0) && Number.isFinite(t1) && t0 >= 0 && t1 >= t0 && t1 <= loopSec)) throw stop(2, "USAGE", `--window takes t0,t1 in seconds with 0 <= t0 <= t1 <= ${loopSec.toFixed(3)}`);
|
|
294
|
+
const from = t0 - COVER.windowPadSec, to = t1 + COVER.windowPadSec;
|
|
295
|
+
const picks = [];
|
|
296
|
+
for (let i = 0; i < cover.frames; i++) for (const lap of [-1, 0, 1]) {
|
|
297
|
+
const t = (i * ms) / 1000 + lap * loopSec;
|
|
298
|
+
if (t >= from - 1e-9 && t <= to + 1e-9) picks.push({ index: i, t: Number(t.toFixed(3)) });
|
|
299
|
+
}
|
|
300
|
+
picks.sort((a, b) => a.t - b.t);
|
|
301
|
+
const dir = path.join(out, "windows", `round-${round}`, `${t0.toFixed(3)}-${t1.toFixed(3)}`);
|
|
302
|
+
rmSync(dir, { recursive: true, force: true });
|
|
303
|
+
mkdirSync(dir, { recursive: true });
|
|
304
|
+
const expected = `${cover.page[0] * COVER.captureScale}x${cover.page[1] * COVER.captureScale}`;
|
|
305
|
+
const chromium = await loadPlaywright(context.state.nodeDir);
|
|
306
|
+
const pool = new CoverPool();
|
|
307
|
+
try {
|
|
308
|
+
log(`cover window: ${picks.length} frames from ${from.toFixed(3)} to ${to.toFixed(3)} s`);
|
|
309
|
+
await withBrowser(chromium, context, path.join(out, ".run", "cover-window"), (browser) => capturePass({ browser, pool, times: [...new Set(picks.map((p) => p.index))].map((i) => [i, i / cover.frames]), dir, size: cover.size, expected }));
|
|
310
|
+
} finally {
|
|
311
|
+
await pool.close();
|
|
312
|
+
}
|
|
313
|
+
const frames = picks.map((p) => readRgba(path.join(dir, `f${pad4(p.index)}.png`)));
|
|
314
|
+
const lumas = frames.map((f) => lumaGrid(f.rgba, f.width, f.height).luma);
|
|
315
|
+
const steps = lumas.slice(1).map((l, k) => meanLumaStep(l, lumas[k]));
|
|
316
|
+
const medianD = steps.length ? [...steps].sort((a, b) => a - b)[steps.length >> 1] : 0;
|
|
317
|
+
const count = Math.min(SHEET_TILES, picks.length);
|
|
318
|
+
const tiles = Array.from({ length: count }, (_, k) => Math.round((k * (picks.length - 1)) / Math.max(1, count - 1))).map((j) => ({ ...frames[j], at: (picks[j].t - from) / (to - from) }));
|
|
319
|
+
const sheet = composeSheet({ tiles, steps, medianD, marks: [{ from: (t0 - from) / (to - from), to: (t1 - from) / (to - from) }] });
|
|
320
|
+
writeFileSync(path.join(dir, "sheet.png"), encodePng(sheet.width, sheet.height, sheet.pixels, 4, 6));
|
|
321
|
+
writeFileSync(path.join(dir, "window.json"), JSON.stringify({ schemaVersion: 1, round, t0, t1, padSec: COVER.windowPadSec, frames: picks, tiles: tiles.map((tile) => Number((from + tile.at * (to - from)).toFixed(3))), note: "frames before encoding; a window render never counts as done" }, null, 2) + "\n");
|
|
322
|
+
return { code: EXIT.OK, report: `WINDOW round ${round}: ${picks.length} frames from ${from.toFixed(3)} to ${to.toFixed(3)} s; sheet: ${path.join(dir, "sheet.png")}\na window render never counts as done: fix the cause, then render the full cover\n` };
|
|
323
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The cover's review picture: twelve decoded frames (4 x 3) above a plot of every grid step's luma
|
|
2
|
+
// change over the whole loop. The plot shows what single stills cannot: a hold is a flat run on the
|
|
3
|
+
// red dashed still line, an encoder pop is a regular spike, a reset is a spike at the right end, the
|
|
4
|
+
// green dotted line is the median. White ticks mark where each tile sits in the loop, and amber
|
|
5
|
+
// shading marks a span (a logged defect, a re-rendered window). It is drawn in plain RGBA like the
|
|
6
|
+
// film contact sheet, with no browser, so the same frames always give the same picture.
|
|
7
|
+
import { COVER } from "../constants.mjs";
|
|
8
|
+
import { lanczosResize } from "./image.mjs";
|
|
9
|
+
|
|
10
|
+
const GAP = 8, PLOT_H = 170, COLS = 4;
|
|
11
|
+
const INK = Object.freeze({ ground: [8, 11, 16], plot: [17, 22, 29], line: [255, 122, 70], still: [190, 60, 60], median: [90, 170, 130], tick: [220, 225, 232], mark: [255, 176, 46] });
|
|
12
|
+
|
|
13
|
+
function canvas(width, height, fill) {
|
|
14
|
+
const pixels = Buffer.alloc(width * height * 4);
|
|
15
|
+
for (let i = 0; i < pixels.length; i += 4) { pixels[i] = fill[0]; pixels[i + 1] = fill[1]; pixels[i + 2] = fill[2]; pixels[i + 3] = 255; }
|
|
16
|
+
return { width, height, pixels };
|
|
17
|
+
}
|
|
18
|
+
function dot(img, x, y, color, alpha = 1) {
|
|
19
|
+
x = Math.round(x); y = Math.round(y);
|
|
20
|
+
if (x < 0 || y < 0 || x >= img.width || y >= img.height) return;
|
|
21
|
+
const p = (y * img.width + x) * 4;
|
|
22
|
+
for (let c = 0; c < 3; c++) img.pixels[p + c] = Math.round(img.pixels[p + c] * (1 - alpha) + color[c] * alpha);
|
|
23
|
+
}
|
|
24
|
+
function rect(img, x, y, w, h, color, alpha = 1) {
|
|
25
|
+
for (let yy = Math.round(y); yy < Math.round(y + h); yy++) for (let xx = Math.round(x); xx < Math.round(x + w); xx++) dot(img, xx, yy, color, alpha);
|
|
26
|
+
}
|
|
27
|
+
function segment(img, x0, y0, x1, y1, color, width = 1.5) {
|
|
28
|
+
const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
|
|
29
|
+
for (let i = 0; i <= n; i++) {
|
|
30
|
+
const x = x0 + ((x1 - x0) * i) / n, y = y0 + ((y1 - y0) * i) / n;
|
|
31
|
+
for (let d = -Math.floor(width / 2); d <= Math.floor(width / 2); d++) dot(img, x, y + d, color);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function dashed(img, x0, x1, y, color, on, off) {
|
|
35
|
+
for (let x = x0; x < x1; x += on + off) rect(img, x, y, Math.min(on, x1 - x), 1, color);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compose the sheet. `tiles` are { rgba, width, height, at } (at = position in the loop, 0..1);
|
|
40
|
+
* `steps` are the grid steps of the loop; `marks` are { from, to } spans as shares of the loop.
|
|
41
|
+
* Returns { width, height, pixels } (RGBA).
|
|
42
|
+
*/
|
|
43
|
+
export function composeSheet({ tiles, steps, medianD, marks = [], tileWidth = 400 }) {
|
|
44
|
+
const tileHeight = Math.round((tileWidth * tiles[0].height) / tiles[0].width);
|
|
45
|
+
const rows = Math.ceil(tiles.length / COLS);
|
|
46
|
+
const width = COLS * tileWidth + (COLS + 1) * GAP;
|
|
47
|
+
const plotTop = rows * (tileHeight + GAP) + 2 * GAP;
|
|
48
|
+
const img = canvas(width, plotTop + PLOT_H + 2 * GAP, INK.ground);
|
|
49
|
+
tiles.forEach((tile, k) => {
|
|
50
|
+
const small = lanczosResize(tile.rgba, tile.width, tile.height, tileWidth, tileHeight);
|
|
51
|
+
const x0 = GAP + (k % COLS) * (tileWidth + GAP), y0 = GAP + Math.floor(k / COLS) * (tileHeight + GAP);
|
|
52
|
+
for (let y = 0; y < tileHeight; y++) small.copy(img.pixels, ((y0 + y) * width + x0) * 4, y * tileWidth * 4, (y + 1) * tileWidth * 4);
|
|
53
|
+
});
|
|
54
|
+
const plotW = width - 2 * GAP;
|
|
55
|
+
rect(img, GAP, plotTop, plotW, PLOT_H, INK.plot);
|
|
56
|
+
for (const mark of marks) rect(img, GAP + mark.from * plotW, plotTop, Math.max(2, (mark.to - mark.from) * plotW), PLOT_H, INK.mark, 0.22);
|
|
57
|
+
const top = Math.max(0.01, ...steps) * 1.05;
|
|
58
|
+
const yOf = (v) => plotTop + PLOT_H - 1 - (Math.min(v, top) / top) * (PLOT_H - 2);
|
|
59
|
+
const xOf = (i) => GAP + (steps.length > 1 ? (i / (steps.length - 1)) * (plotW - 1) : 0);
|
|
60
|
+
dashed(img, GAP, GAP + plotW, Math.round(yOf(COVER.stillEps)), INK.still, 5, 4);
|
|
61
|
+
dashed(img, GAP, GAP + plotW, Math.round(yOf(medianD)), INK.median, 2, 3);
|
|
62
|
+
for (const tile of tiles) rect(img, GAP + tile.at * (plotW - 1), plotTop + PLOT_H - 7, 1, 7, INK.tick);
|
|
63
|
+
for (let i = 1; i < steps.length; i++) segment(img, xOf(i - 1), yOf(steps[i - 1]), xOf(i), yOf(steps[i]), INK.line);
|
|
64
|
+
return img;
|
|
65
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The cover treatment: `treatment.json` with `"mode": "cover"`, validated by every cover render
|
|
2
|
+
// before anything starts (exit 16 names the first field that fails). A cover is not a film, so this
|
|
3
|
+
// is not the film treatment with fields removed: it has no beats, no arc, no copy lines, no ending
|
|
4
|
+
// and no sound. It names one recipe, the one rule copied many times, the three speed layers, the
|
|
5
|
+
// brand word set once (plus at most one motto that the user wrote or the project already uses), the
|
|
6
|
+
// aspect and loop, and the title box the contrast and size gates measure. Its text is inert data.
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { COVER } from "../constants.mjs";
|
|
10
|
+
import { normalize, restates, TREATMENT_EXIT } from "../treatment.mjs";
|
|
11
|
+
|
|
12
|
+
export const RECIPES = Object.freeze(["painted-light", "weight-tide", "orbit-loop", "own"]);
|
|
13
|
+
export const LAYERS = Object.freeze(["slow", "mid", "beat"]);
|
|
14
|
+
|
|
15
|
+
class Invalid extends Error {
|
|
16
|
+
constructor(field, detail) { super(detail); this.field = field; }
|
|
17
|
+
}
|
|
18
|
+
const need = (condition, field, detail) => { if (!condition) throw new Invalid(field, detail); };
|
|
19
|
+
const placeholder = /<[^<>\n]{1,80}>|^\s*(?:todo|tbd|xxx|\.\.\.|…)\s*$/iu;
|
|
20
|
+
const sentences = (text) => String(text).split(/(?<=[.!?。…])\s+/u).map((part) => part.trim()).filter(Boolean).length;
|
|
21
|
+
const words = (text) => String(text).trim().split(/\s+/u).filter(Boolean).length;
|
|
22
|
+
|
|
23
|
+
function text(t, key, { maxSentences } = {}) {
|
|
24
|
+
const value = key.split(".").reduce((node, part) => (node && typeof node === "object" ? node[part] : undefined), t);
|
|
25
|
+
need(typeof value === "string" && value.trim().length > 0, key, value === undefined ? "missing" : "must be a non-empty string");
|
|
26
|
+
need(!placeholder.test(value), key, `still holds a placeholder (${value})`);
|
|
27
|
+
if (maxSentences) need(sentences(value) <= maxSentences, key, `at most ${maxSentences} sentence(s)`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Validate a cover treatment. Returns { ok: true, cover } with the derived sizes, or { ok: false, field, detail }. */
|
|
32
|
+
export function validateCoverTreatment(t) {
|
|
33
|
+
try {
|
|
34
|
+
need(t && typeof t === "object" && !Array.isArray(t), "treatment", "treatment.json must hold a JSON object");
|
|
35
|
+
need(t.mode === "cover", "mode", "must be cover for a cover render");
|
|
36
|
+
text(t, "request");
|
|
37
|
+
text(t, "idea", { maxSentences: 1 });
|
|
38
|
+
need(!restates(t.idea, t.request), "idea", "the idea restates the request; say what the loop does");
|
|
39
|
+
need(RECIPES.includes(t.recipe), "recipe", `must be one of ${RECIPES.join(", ")} (references/cover.md)`);
|
|
40
|
+
text(t, "recipeReason", { maxSentences: 2 });
|
|
41
|
+
text(t, "brandWord");
|
|
42
|
+
need(words(t.brandWord) <= 3, "brandWord", "is the name, set once: at most three words");
|
|
43
|
+
if ("motto" in t) {
|
|
44
|
+
need(t.motto && typeof t.motto === "object", "motto", "must be { text, source: user | project, from? }");
|
|
45
|
+
text(t, "motto.text");
|
|
46
|
+
need(words(t.motto.text) <= 6, "motto.text", "at most six words");
|
|
47
|
+
need(["user", "project"].includes(t.motto.source), "motto.source", "must be user (in the request) or project (the project's own line, with from)");
|
|
48
|
+
if (t.motto.source === "user") need(normalize(t.request).includes(normalize(t.motto.text)), "motto.text", "a user motto appears in the request; a cover never invents a tagline");
|
|
49
|
+
else text(t, "motto.from");
|
|
50
|
+
}
|
|
51
|
+
for (const key of ["copy", "beats", "lines"]) need(!(key in t), key, "a cover has no copy lines and no beats: the brand word is set once and never swapped (references/cover.md)");
|
|
52
|
+
need(!("sound" in t) || t.sound === "none", "sound", "a cover is silent: leave sound out or set it to none");
|
|
53
|
+
need(Object.hasOwn(COVER.aspects, t.aspect), "aspect", `must be ${Object.keys(COVER.aspects).join(" or ")}`);
|
|
54
|
+
need(typeof t.loopSec === "number" && t.loopSec >= COVER.minSec && t.loopSec <= COVER.maxSec, "loopSec", `must be ${COVER.minSec}-${COVER.maxSec} seconds`);
|
|
55
|
+
need(COVER.fps.includes(t.fps), "fps", `must be one of ${COVER.fps.join(", ")}`);
|
|
56
|
+
need(t.layers && typeof t.layers === "object", "layers", "missing: name what moves on the slow, mid and beat layers");
|
|
57
|
+
for (const layer of LAYERS) text(t, `layers.${layer}`, { maxSentences: 2 });
|
|
58
|
+
need(t.system && typeof t.system === "object", "system", "missing: the one rule, what it is copied onto, and where the offsets come from");
|
|
59
|
+
for (const part of ["rule", "copies", "offset"]) text(t, `system.${part}`, { maxSentences: 2 });
|
|
60
|
+
const box = t.title?.box;
|
|
61
|
+
need(t.title && typeof t.title === "object", "title", "missing: { box: [x, y, w, h] as fractions of the cover, kind: live or painted }");
|
|
62
|
+
need(Array.isArray(box) && box.length === 4 && box.every((v) => typeof v === "number" && v >= 0 && v <= 1), "title.box", "must be [x, y, w, h], each a fraction of the cover");
|
|
63
|
+
need(box[2] > 0 && box[3] > 0 && box[0] + box[2] <= 1 + 1e-9 && box[1] + box[3] <= 1 + 1e-9, "title.box", "must lie inside the cover");
|
|
64
|
+
need(["live", "painted"].includes(t.title.kind), "title.kind", "must be live (set as text) or painted (part of the base art)");
|
|
65
|
+
if ("palette" in t) {
|
|
66
|
+
need(Array.isArray(t.palette) && t.palette.length >= 2 && t.palette.length <= 6, "palette", "2-6 { color, role } entries");
|
|
67
|
+
t.palette.forEach((entry, i) => {
|
|
68
|
+
need(/^#[0-9a-f]{6}$/iu.test(entry?.color ?? ""), `palette[${i}].color`, "must be #rrggbb");
|
|
69
|
+
need(typeof entry?.role === "string" && entry.role.trim(), `palette[${i}].role`, "must say the colour's job");
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if ("encode" in t) {
|
|
73
|
+
need(t.encode && typeof t.encode === "object", "encode", "must be { q, edgeQ, edgeFrames }");
|
|
74
|
+
for (const [key, lo, hi] of [["q", 50, 100], ["edgeQ", 50, 100], ["edgeFrames", 0, 60]]) {
|
|
75
|
+
if (key in t.encode) need(Number.isInteger(t.encode[key]) && t.encode[key] >= lo && t.encode[key] <= hi, `encode.${key}`, `an integer ${lo}-${hi}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if ("seed" in t) need(Number.isSafeInteger(t.seed), "seed", "must be an integer");
|
|
79
|
+
text(t, "ambition", { maxSentences: 2 });
|
|
80
|
+
const { page, size } = COVER.aspects[t.aspect];
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
cover: { ...t, page: [...page], size: [...size], frames: Math.round(t.loopSec * t.fps), encode: { ...COVER.encode, ...(t.encode ?? {}) }, seed: Number.isSafeInteger(t.seed) ? t.seed >>> 0 : 20261020 }
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof Invalid) return { ok: false, field: error.field, detail: error.message };
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True when `<out>/treatment.json` is readable JSON with mode cover (validity aside). */
|
|
92
|
+
export function isCoverRun(out) {
|
|
93
|
+
try { return JSON.parse(readFileSync(path.join(out, "treatment.json"), "utf8"))?.mode === "cover"; } catch { return false; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Read and validate `<out>/treatment.json` as a cover; throws exit 16 naming the field. Keeps the first valid one. */
|
|
97
|
+
export function loadCoverTreatment(out) {
|
|
98
|
+
const file = path.join(out, "treatment.json");
|
|
99
|
+
const fail = (field, detail) => Object.assign(new Error(`BLOCKED_TREATMENT_INVALID (16): field ${field}: ${detail}`), { exitCode: TREATMENT_EXIT, field });
|
|
100
|
+
if (!existsSync(file)) throw fail("treatment", `no treatment.json in ${out}; write the cover treatment first (references/cover.md)`);
|
|
101
|
+
let parsed;
|
|
102
|
+
try { parsed = JSON.parse(readFileSync(file, "utf8")); } catch (error) { throw fail("treatment", `treatment.json is not JSON (${error.message})`); }
|
|
103
|
+
if (parsed?.mode !== "cover") throw fail("mode", "this treatment is a film; render it with `render.mjs stage` or `render.mjs film` (references/treatment.md). A cover sets mode: cover");
|
|
104
|
+
const result = validateCoverTreatment(parsed);
|
|
105
|
+
if (!result.ok) throw fail(result.field, result.detail);
|
|
106
|
+
const first = path.join(out, ".run", "treatment-first.json");
|
|
107
|
+
mkdirSync(path.dirname(first), { recursive: true });
|
|
108
|
+
if (!existsSync(first)) writeFileSync(first, JSON.stringify(parsed, null, 2) + "\n");
|
|
109
|
+
return result.cover;
|
|
110
|
+
}
|
|
@@ -41,6 +41,6 @@ export async function encodePreview(runDir, frameDir, fps, count, encoder) {
|
|
|
41
41
|
const files = Array.from({ length: count }, (_, k) => path.join(frameDir, `p${String(k).padStart(5, "0")}.png`));
|
|
42
42
|
result = spawnSync("img2webp", ["-loop", "0", "-lossy", "-q", "78", "-m", "4", "-d", String(Math.round(1000 / fps)), ...files, "-o", target], { encoding: "utf8", timeout: 600000, maxBuffer: 16_000_000 });
|
|
43
43
|
} else result = spawnSync("ffmpeg", ["-y", "-loglevel", "error", "-framerate", String(fps), "-i", pattern, "-filter_complex", "[0:v]split[a][b];[a]palettegen=stats_mode=full[p];[b][p]paletteuse=dither=bayer:bayer_scale=4", "-loop", "0", target], { encoding: "utf8", timeout: 600000 });
|
|
44
|
-
if (result.status !== 0) throw new Error(`preview encode (${encoder}) failed: ${(result.stderr || result.error?.message || "").trim().split(
|
|
44
|
+
if (result.status !== 0) throw new Error(`preview encode (${encoder}) failed: ${(result.stderr || result.error?.message || "").trim().split(/\r?\n/u).slice(-2).join(" ")}`);
|
|
45
45
|
return target;
|
|
46
46
|
}
|