@litfamily/litmuse 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +115 -26
- package/README_ko-KR.md +112 -23
- package/bin/litmuse.mjs +4 -0
- package/docs/assets/card/after.webp +0 -0
- package/docs/assets/card/idle.webp +0 -0
- package/docs/assets/card/working.webp +0 -0
- package/docs/assets/handoff/latest.webp +0 -0
- package/docs/assets/handoff/saved.webp +0 -0
- package/docs/assets/readme/demo.webp +0 -0
- package/lib/stage-card.mjs +180 -5
- package/package.json +7 -1
- package/plugin/.muse-plugin/plugin.json +26 -11
- package/plugin/commands/lit-status.md +2 -1
- package/plugin/commands/lit.md +1 -1
- package/plugin/hooks/lib/digest.mjs +1 -1
- package/plugin/hooks/lib/next-step.mjs +68 -0
- package/plugin/hooks/lib/routes.mjs +59 -0
- package/plugin/hooks/lib/workflow-stack.mjs +318 -0
- package/plugin/hooks/session-start.mjs +29 -2
- package/plugin/hooks/user-prompt-submit.mjs +138 -7
- package/plugin/lib/capped-fetch.mjs +25 -0
- package/plugin/lib/pinned-fetch.mjs +79 -0
- package/plugin/lib/plan-task-rows.mjs +32 -0
- package/plugin/lib/render_pages.py +29 -1
- package/plugin/lib/rewind/collect.mjs +679 -0
- package/plugin/lib/rewind/gauge.mjs +72 -0
- package/plugin/lib/rewind/next-step.mjs +91 -0
- package/plugin/lib/rewind/redact.mjs +83 -0
- package/plugin/lib/rewind/transcripts.mjs +227 -0
- package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
- package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
- package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
- package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
- package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
- package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
- package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
- package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
- package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/plugin/skills/lit-adhd/SKILL.md +188 -0
- package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
- package/plugin/skills/lit-adhd/references/status-taxonomy.md +43 -0
- package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
- package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
- package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
- package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
- package/plugin/skills/lit-docx/SKILL.md +1 -1
- package/plugin/skills/lit-hijack/SKILL.md +589 -0
- package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
- package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
- package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
- package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
- package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
- package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
- package/plugin/skills/lit-pptx/SKILL.md +20 -9
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
- package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
- package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
- package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
- package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
- package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
- package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
- package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
- package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
- package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
- package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
- package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/plugin/skills/lit-recap/SKILL.md +3 -0
- package/plugin/skills/lit-schematic/SKILL.md +241 -0
- package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
- package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
- package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
- package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
- package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
- package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
- package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
- package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
- package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
- package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
- package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
- package/plugin/skills/lit-schematic/references/verification.md +24 -0
- package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
- package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
- package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
- package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
- package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
- package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
- package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
- package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
- package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
- package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
- package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
- package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
- package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
- package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
- package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
- package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
- package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
- package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
- package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
- package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
- package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
- package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
- package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
- package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
- package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
- package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
- package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
- package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
- package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
- package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
- package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
- package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
- package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
- package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
- package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
- package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
- package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
- package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
- package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
- package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
- package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
- package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
- package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
- package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
- package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
- package/plugin/skills/readme-studio/SKILL.md +1 -1
- package/plugin/skills/readme-studio/templates/hyperframes-cover/package-lock.json +3 -3
- package/plugin/skills/readme-studio/templates/remotion-cover/package-lock.json +3 -3
- package/plugin/skills/start-work/SKILL.md +6 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// `motion.mjs sound --out <dir>`: build the
|
|
2
|
-
// heard and its cues checked before the full render. The render
|
|
3
|
-
// exact frame count and cut times, so the muxed track always
|
|
1
|
+
// `motion.mjs sound --out <dir>`: build the film's score (or a minimal film's generated bed) from
|
|
2
|
+
// <dir>/treatment.json so it can be heard and its cues checked before the full render. The render
|
|
3
|
+
// rebuilds the same track at the film's exact frame count and cut times, so the muxed track always
|
|
4
|
+
// matches the picture.
|
|
4
5
|
import { existsSync } from "node:fs";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { EXIT } from "../core/constants.mjs";
|
|
7
8
|
import { loadBrief, planFromBrief } from "./brief.mjs";
|
|
8
9
|
import { BlockedError } from "./chrome.mjs";
|
|
9
10
|
import { asBlocked } from "./pipeline.mjs";
|
|
11
|
+
import { writeScore } from "./score.mjs";
|
|
10
12
|
import { prepareTrack, writeBed } from "./sound.mjs";
|
|
11
13
|
import { loadTreatment } from "./treatment.mjs";
|
|
12
14
|
|
|
@@ -21,8 +23,19 @@ export function soundTimeline(out, treatment) {
|
|
|
21
23
|
return { fps, frameCount: Math.round(treatment.durationSec * fps), cutTimes: treatment.beats.slice(1).map((b) => b.t0) };
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
const scoreLabel = (score) => `score, ${score.genre} in ${score.key}, ${score.tempo} BPM, ${score.stems.filter((x) => x.kind !== "return").length} stems`;
|
|
27
|
+
|
|
24
28
|
export async function soundCommand({ out }) {
|
|
25
29
|
const treatment = asBlocked(() => loadTreatment(out));
|
|
30
|
+
if (treatment.sound.mode === "score") {
|
|
31
|
+
const { fps, frameCount, cutTimes } = soundTimeline(out, treatment);
|
|
32
|
+
const score = writeScore({ treatment, frameCount, fps, cutTimes, dir: out });
|
|
33
|
+
const sections = score.sections.map((x) => x.kind).join(" → ");
|
|
34
|
+
return {
|
|
35
|
+
code: EXIT.OK,
|
|
36
|
+
message: `SOUND: ${scoreLabel(score)} (${score.progression}; ${sections}; sidechain ${score.sidechainDb.toFixed(1)} dB; ${score.lufs.toFixed(1)} LUFS, peak ${score.peakDbfs.toFixed(1)} dBFS) in ${score.file}; stems in ${path.join(out, "sound", "stems")}; ${score.cues.length} cues in ${path.join(out, "sound-cues.json")}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
26
39
|
if (treatment.sound.mode !== "generated") {
|
|
27
40
|
return { code: EXIT.OK, message: `SOUND: mode ${treatment.sound.mode}; nothing to generate (the render ${treatment.sound.mode === "none" ? "adds no track" : "prepares and muxes the track"})` };
|
|
28
41
|
}
|
|
@@ -35,13 +48,17 @@ export async function soundCommand({ out }) {
|
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
/**
|
|
38
|
-
* The film's track on either path: the generated bed at the film's exact frame count and cuts, a
|
|
51
|
+
* The film's track on either path: the score or the generated bed at the film's exact frame count and cuts, a
|
|
39
52
|
* supplied or authored track padded or trimmed to the film, or none. Always muxed whatever the
|
|
40
53
|
* audio-analysis tier's state (the Wave 1 renderer dropped a track when that tier was absent).
|
|
41
54
|
*/
|
|
42
55
|
export function soundTrackFor({ treatment, out, stageDir = null, frameCount, fps, cutTimes, ffmpeg }) {
|
|
43
56
|
const mode = treatment.sound.mode;
|
|
44
57
|
if (mode === "none") return { mode, file: null, label: "none (asked for)" };
|
|
58
|
+
if (mode === "score") {
|
|
59
|
+
const score = writeScore({ treatment, frameCount, fps, cutTimes, dir: out });
|
|
60
|
+
return { mode, file: score.file, label: scoreLabel(score), bed: score };
|
|
61
|
+
}
|
|
45
62
|
if (mode === "generated") {
|
|
46
63
|
const bed = writeBed({ treatment, frameCount, fps, cutTimes, dir: out });
|
|
47
64
|
return { mode, file: path.join(out, "sound", "bed.wav"), label: `generated bed, ${bed.timbre} in ${bed.key}, ${bed.tempo} BPM`, bed };
|
|
@@ -28,6 +28,7 @@ export const SOUND = Object.freeze({
|
|
|
28
28
|
fadeSec: 0.05,
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
export { TABLE as WAVETABLE_SIZE };
|
|
31
32
|
export const TEMPO_RANGE = Object.freeze([60, 160]);
|
|
32
33
|
const PITCH = { C: 0, "C#": 1, Db: 1, D: 2, "D#": 3, Eb: 3, E: 4, F: 5, "F#": 6, Gb: 6, G: 7, "G#": 8, Ab: 8, A: 9, "A#": 10, Bb: 10, B: 11 };
|
|
33
34
|
export const KEYS = Object.freeze({ pitchClasses: Object.freeze(Object.keys(PITCH)), modes: Object.freeze(["major", "minor"]) });
|
|
@@ -86,6 +87,13 @@ export function normalizePalette(palette) {
|
|
|
86
87
|
return { timbre, key, mode, tempo };
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Sounds an on-screen event can make (treatment `sound.events`): a tap (a small click with a pitched
|
|
92
|
+
* body: a key, a latch, a drop landing), a hit (the cut hit), a whoosh (air ending on the event) and a
|
|
93
|
+
* chime (two notes of the key: something completes).
|
|
94
|
+
*/
|
|
95
|
+
export const EVENT_SOUNDS = Object.freeze(["tap", "hit", "whoosh", "chime"]);
|
|
96
|
+
|
|
89
97
|
const CUE_WORDS = [
|
|
90
98
|
["hit", /\b(?:hits?|impacts?|cuts?|stabs?|accents?)\b|타격|히트|컷|강세/iu],
|
|
91
99
|
["rise", /\b(?:rises?|rising|risers?|swells?|swelling|builds?|building|build-?ups?|lifts?|lifting)\b|상승|고조|빌드업/iu],
|
|
@@ -104,8 +112,11 @@ const nearest = (times, t, within) => {
|
|
|
104
112
|
return best;
|
|
105
113
|
};
|
|
106
114
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Accent cues, each measured against the beat boundary or cut it serves, plus one `event` cue per
|
|
117
|
+
* on-screen event at its own time (it never snaps to a cut: it follows the picture).
|
|
118
|
+
*/
|
|
119
|
+
export function planCues({ beats = [], durationSec, cutTimes = [], events = [] }) {
|
|
109
120
|
const cues = [];
|
|
110
121
|
const clampT = (t) => Math.min(durationSec, Math.max(0, t));
|
|
111
122
|
beats.forEach((beat, beatIndex) => {
|
|
@@ -127,6 +138,12 @@ export function planCues({ beats = [], durationSec, cutTimes = [] }) {
|
|
|
127
138
|
}
|
|
128
139
|
}
|
|
129
140
|
});
|
|
141
|
+
for (const e of Array.isArray(events) ? events : []) {
|
|
142
|
+
if (!EVENT_SOUNDS.includes(e?.kind) || !(e.t >= 0 && e.t < durationSec)) continue;
|
|
143
|
+
const beatIndex = Math.max(0, beats.findIndex((b) => e.t >= Number(b.t0) && e.t < Number(b.t1)));
|
|
144
|
+
const cut = nearest(cutTimes, e.t, Infinity);
|
|
145
|
+
cues.push({ kind: "event", sound: e.kind, t: round4(e.t), beatIndex, target: round4(e.t), delta: 0, on: String(e.on ?? ""), fromCut: cut === null ? null : round4(Math.abs(e.t - cut)) });
|
|
146
|
+
}
|
|
130
147
|
cues.sort((a, b) => a.t - b.t || a.beatIndex - b.beatIndex);
|
|
131
148
|
return cues;
|
|
132
149
|
}
|
|
@@ -264,7 +281,8 @@ export function decodeWav(buffer) {
|
|
|
264
281
|
|
|
265
282
|
// ---- synthesis ----
|
|
266
283
|
const TABLE = 4096;
|
|
267
|
-
|
|
284
|
+
/** A normalized single-cycle table of additive partials (TABLE + 1 samples, wrapped). */
|
|
285
|
+
export function wavetable(harmonics) {
|
|
268
286
|
const t = new Float32Array(TABLE + 1);
|
|
269
287
|
let peak = 0;
|
|
270
288
|
for (let i = 0; i < TABLE; i++) {
|
|
@@ -316,7 +334,7 @@ export function buildBed({ treatment, frameCount, fps, cutTimes = [] }) {
|
|
|
316
334
|
const sampleCount = Math.round((frameCount * sr) / fps);
|
|
317
335
|
const durationSec = sampleCount / sr;
|
|
318
336
|
const beats = Array.isArray(treatment?.beats) ? treatment.beats : [];
|
|
319
|
-
const cues = planCues({ beats, durationSec, cutTimes });
|
|
337
|
+
const cues = planCues({ beats, durationSec, cutTimes, events: treatment?.sound?.events });
|
|
320
338
|
const rand = mulberry32(fnv1a32(`${treatment?.request ?? ""}|${palette.timbre}|${palette.key}|${palette.mode}|${palette.tempo}`));
|
|
321
339
|
const L = new Float64Array(sampleCount);
|
|
322
340
|
const R = new Float64Array(sampleCount);
|
|
@@ -445,22 +463,58 @@ export function buildBed({ treatment, frameCount, fps, cutTimes = [] }) {
|
|
|
445
463
|
}
|
|
446
464
|
}
|
|
447
465
|
};
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
466
|
+
// An on-screen event: a sound shaped like what the picture does at that frame.
|
|
467
|
+
const eventSound = (cue) => {
|
|
468
|
+
const s0 = Math.round(cue.t * sr);
|
|
469
|
+
if (cue.sound === "tap") {
|
|
470
|
+
const len = Math.min(sampleCount - s0, Math.round(0.3 * sr));
|
|
471
|
+
const hz = midiHz(tonic + 24 + (palette.mode === "major" ? 7 : 3));
|
|
452
472
|
let f = 0;
|
|
453
|
-
const c = onePole(
|
|
454
|
-
let ph = 0;
|
|
473
|
+
const c = onePole(4500, sr);
|
|
455
474
|
for (let i = 0; i < len; i++) {
|
|
456
475
|
const t = i / sr;
|
|
457
476
|
f += c * (rand() * 2 - 1 - f);
|
|
458
|
-
|
|
459
|
-
const v = 0.55 * Math.exp(-t / 0.12) * f + 0.5 * Math.exp(-t / 0.22) * Math.sin(ph);
|
|
477
|
+
const v = 1.2 * Math.exp(-t / 0.008) * f + 0.6 * Math.exp(-t / 0.07) * Math.sin(2 * Math.PI * hz * t);
|
|
460
478
|
L[s0 + i] += v;
|
|
461
|
-
R[s0 + i] += v;
|
|
479
|
+
R[s0 + i] += v * 0.9;
|
|
480
|
+
}
|
|
481
|
+
} else if (cue.sound === "whoosh") {
|
|
482
|
+
const start = Math.max(0, s0 - Math.round(0.4 * sr));
|
|
483
|
+
const tail = Math.min(sampleCount, s0 + Math.round(0.08 * sr));
|
|
484
|
+
let f = 0;
|
|
485
|
+
for (let s = start; s < tail; s++) {
|
|
486
|
+
const u = s < s0 ? (s - start) / Math.max(1, s0 - start) : 1 - (s - s0) / Math.max(1, tail - s0);
|
|
487
|
+
f += onePole(300 + 3200 * u, sr) * (rand() * 2 - 1 - f);
|
|
488
|
+
const v = 0.5 * u * u * f;
|
|
489
|
+
L[s] += v * (1 - 0.4 * u);
|
|
490
|
+
R[s] += v * (0.6 + 0.4 * u);
|
|
462
491
|
}
|
|
463
|
-
|
|
492
|
+
} else if (cue.sound === "chime") {
|
|
493
|
+
strike(cue.t, [tonic + 12], 1.2, 0.09);
|
|
494
|
+
strike(cue.t + 0.09, [tonic + 19], 1.2, 0.08);
|
|
495
|
+
} else {
|
|
496
|
+
hitAt(cue.t);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
const hitAt = (t0) => {
|
|
500
|
+
const s0 = Math.round(t0 * sr);
|
|
501
|
+
const len = Math.min(sampleCount - s0, Math.round(0.6 * sr));
|
|
502
|
+
let f = 0;
|
|
503
|
+
const c = onePole(voice.accent.kind === "bell" ? 6000 : 1800, sr);
|
|
504
|
+
let ph = 0;
|
|
505
|
+
for (let i = 0; i < len; i++) {
|
|
506
|
+
const t = i / sr;
|
|
507
|
+
f += c * (rand() * 2 - 1 - f);
|
|
508
|
+
ph += (2 * Math.PI * (50 + 90 * Math.exp(-t / 0.02))) / sr;
|
|
509
|
+
const v = 0.55 * Math.exp(-t / 0.12) * f + 0.5 * Math.exp(-t / 0.22) * Math.sin(ph);
|
|
510
|
+
L[s0 + i] += v;
|
|
511
|
+
R[s0 + i] += v;
|
|
512
|
+
}
|
|
513
|
+
if (voice.accent.kind === "bell") strike(t0, [tonic + 12], 0.6, 0.12);
|
|
514
|
+
};
|
|
515
|
+
for (const cue of cues) {
|
|
516
|
+
if (cue.kind === "hit") {
|
|
517
|
+
hitAt(cue.t);
|
|
464
518
|
} else if (cue.kind === "rise") {
|
|
465
519
|
const s0 = Math.round(cue.start * sr);
|
|
466
520
|
const s1 = Math.min(sampleCount, Math.round(cue.t * sr));
|
|
@@ -475,6 +529,8 @@ export function buildBed({ treatment, frameCount, fps, cutTimes = [] }) {
|
|
|
475
529
|
L[s] += v * (1 - 0.3 * u);
|
|
476
530
|
R[s] += v * (0.7 + 0.3 * u);
|
|
477
531
|
}
|
|
532
|
+
} else if (cue.kind === "event") {
|
|
533
|
+
eventSound(cue);
|
|
478
534
|
} else {
|
|
479
535
|
const third = palette.mode === "major" ? 4 : 3;
|
|
480
536
|
strike(cue.t, [tonic - 12, tonic, tonic + third, tonic + 7], voice.accent.decay, 0.1);
|
|
@@ -489,20 +545,61 @@ export function buildBed({ treatment, frameCount, fps, cutTimes = [] }) {
|
|
|
489
545
|
R[sampleCount - 1 - i] *= g;
|
|
490
546
|
}
|
|
491
547
|
|
|
492
|
-
|
|
548
|
+
const { left, right, lufs } = masterTo(L, R);
|
|
549
|
+
return { left, right, sampleCount, palette, cues, lufs: +lufs.toFixed(2), peakDbfs: +samplePeakDbfs([left, right]).toFixed(2), loudnessMethod: "BS.1770-4" };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The master stage every built track ends in: loudness to -16 LUFS through a lookahead limiter under
|
|
554
|
+
* -2 dBFS, quantized as the WAV will be. Returns the channels, the gain applied and the loudness.
|
|
555
|
+
*
|
|
556
|
+
* The limiter turns the gain down smoothly before a peak (5 ms lookahead, both channels linked, 80 ms
|
|
557
|
+
* release) instead of bending the waveform. A waveshaper adds harmonics the AAC encode turns into
|
|
558
|
+
* overshoot: one film's score sat at -2.1 dBFS in the WAV and decoded at +0.4 dBFS from the mux.
|
|
559
|
+
*/
|
|
560
|
+
export function masterTo(L, R) {
|
|
561
|
+
const sampleCount = L.length;
|
|
493
562
|
const ceiling = 10 ** (-2.1 / 20);
|
|
494
|
-
const
|
|
495
|
-
const
|
|
496
|
-
const a = Math.abs(x);
|
|
497
|
-
return a <= knee ? x : Math.sign(x) * (knee + (ceiling - knee) * Math.tanh((a - knee) / (ceiling - knee)));
|
|
498
|
-
};
|
|
563
|
+
const ahead = Math.round(0.005 * SOUND.sampleRate);
|
|
564
|
+
const release = 1 - Math.exp(-1 / (0.08 * SOUND.sampleRate));
|
|
499
565
|
const q = (v) => Math.round(Math.max(-1, Math.min(1, v)) * 32767) / 32767;
|
|
500
566
|
const render = (gain) => {
|
|
567
|
+
// need[s]: the gain that keeps sample s under the ceiling; held[s]: the lowest need in the next
|
|
568
|
+
// `ahead` samples (a monotonic deque); then the release lets it rise slowly; then a moving average
|
|
569
|
+
// over `ahead` samples reaches every held value before its peak, so the result never exceeds need.
|
|
570
|
+
const need = new Float32Array(sampleCount);
|
|
571
|
+
for (let s = 0; s < sampleCount; s++) {
|
|
572
|
+
const a = Math.max(Math.abs(L[s]), Math.abs(R[s])) * gain;
|
|
573
|
+
need[s] = a > ceiling ? ceiling / a : 1;
|
|
574
|
+
}
|
|
575
|
+
const held = new Float32Array(sampleCount);
|
|
576
|
+
const deque = new Int32Array(sampleCount + ahead + 1);
|
|
577
|
+
let head = 0;
|
|
578
|
+
let tail = 0;
|
|
579
|
+
let next = 0;
|
|
580
|
+
for (let s = 0; s < sampleCount; s++) {
|
|
581
|
+
for (; next < Math.min(sampleCount, s + ahead + 1); next++) {
|
|
582
|
+
while (tail > head && need[deque[tail - 1]] >= need[next]) tail--;
|
|
583
|
+
deque[tail++] = next;
|
|
584
|
+
}
|
|
585
|
+
while (deque[head] < s) head++;
|
|
586
|
+
held[s] = need[deque[head]];
|
|
587
|
+
}
|
|
588
|
+
let env = 1;
|
|
589
|
+
const smooth = new Float32Array(sampleCount);
|
|
590
|
+
for (let s = 0; s < sampleCount; s++) {
|
|
591
|
+
env = Math.min(held[s], env + (1 - env) * release);
|
|
592
|
+
smooth[s] = env;
|
|
593
|
+
}
|
|
501
594
|
const l = new Float32Array(sampleCount);
|
|
502
595
|
const r = new Float32Array(sampleCount);
|
|
596
|
+
// The averaging window starts full of unity gain; the hard ceiling below catches the first 5 ms.
|
|
597
|
+
let win = ahead;
|
|
503
598
|
for (let s = 0; s < sampleCount; s++) {
|
|
504
|
-
|
|
505
|
-
|
|
599
|
+
win += smooth[s] - (s >= ahead ? smooth[s - ahead] : 1);
|
|
600
|
+
const g = win / ahead;
|
|
601
|
+
l[s] = q(Math.max(-ceiling, Math.min(ceiling, L[s] * gain * g)));
|
|
602
|
+
r[s] = q(Math.max(-ceiling, Math.min(ceiling, R[s] * gain * g)));
|
|
506
603
|
}
|
|
507
604
|
return [l, r];
|
|
508
605
|
};
|
|
@@ -515,7 +612,7 @@ export function buildBed({ treatment, frameCount, fps, cutTimes = [] }) {
|
|
|
515
612
|
[left, right] = render(gain);
|
|
516
613
|
lufs = integratedLoudness([left, right]);
|
|
517
614
|
}
|
|
518
|
-
return { left, right,
|
|
615
|
+
return { left, right, gain, lufs };
|
|
519
616
|
}
|
|
520
617
|
|
|
521
618
|
export function writeBed({ treatment, frameCount, fps, cutTimes = [], dir }) {
|
|
@@ -621,6 +718,8 @@ export function auditMuxed({ ffmpeg, ffprobe = null, film, videoDurationSec, mod
|
|
|
621
718
|
const peak = samplePeakDbfs(channels);
|
|
622
719
|
const head = rmsRuns(channels);
|
|
623
720
|
const lufs = integratedLoudness(channels);
|
|
721
|
+
// A generated bed and a score are built here to the loudness target; a supplied track is only reported.
|
|
722
|
+
const built = mode === "generated" || mode === "score";
|
|
624
723
|
const rules = [{ id: "SOUND-STREAM", status: "PASS", detail: `audio stream present (${mode})` }];
|
|
625
724
|
const dd = Math.abs(duration - videoDurationSec);
|
|
626
725
|
rules.push({ id: "SOUND-DURATION", status: dd <= SOUND.durationToleranceSec ? "PASS" : "FAIL", detail: `audio ${duration.toFixed(3)} s vs video ${Number(videoDurationSec).toFixed(3)} s (limit ±${SOUND.durationToleranceSec} s)` });
|
|
@@ -628,14 +727,14 @@ export function auditMuxed({ ffmpeg, ffprobe = null, film, videoDurationSec, mod
|
|
|
628
727
|
const headBad = head > SOUND.silenceMaxSec;
|
|
629
728
|
rules.push({
|
|
630
729
|
id: "SOUND-HEAD",
|
|
631
|
-
status: headBad ? (
|
|
730
|
+
status: headBad ? (built ? "FAIL" : "WARN") : "PASS",
|
|
632
731
|
detail: `longest run below ${SOUND.silenceDbfs} dBFS RMS in the first ${SOUND.silenceHeadSec} s: ${head.toFixed(2)} s (limit ${SOUND.silenceMaxSec} s)`,
|
|
633
732
|
});
|
|
634
733
|
const inRange = Number.isFinite(lufs) && Math.abs(lufs - SOUND.targetLufs) <= SOUND.lufsTolerance;
|
|
635
734
|
rules.push({
|
|
636
735
|
id: "SOUND-LOUDNESS",
|
|
637
|
-
status:
|
|
638
|
-
detail: `integrated ${Number.isFinite(lufs) ? lufs.toFixed(2) : "-inf"} LUFS (BS.1770-4${
|
|
736
|
+
status: built ? (inRange ? "PASS" : "WARN") : "PASS",
|
|
737
|
+
detail: `integrated ${Number.isFinite(lufs) ? lufs.toFixed(2) : "-inf"} LUFS (BS.1770-4${built ? `, target ${SOUND.targetLufs} ± ${SOUND.lufsTolerance}` : ", reported"})`,
|
|
639
738
|
});
|
|
640
|
-
return { rules, exitCode: headBad &&
|
|
739
|
+
return { rules, exitCode: headBad && built ? 20 : null };
|
|
641
740
|
}
|
|
@@ -9,6 +9,11 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { EXIT } from "../core/constants.mjs";
|
|
10
10
|
import { BlockedError } from "./chrome.mjs";
|
|
11
11
|
import { checkFont, fontLocation, fontsDir, loadFontManifest } from "./runtime.mjs";
|
|
12
|
+
import { readPlate } from "./blender.mjs";
|
|
13
|
+
|
|
14
|
+
// The pinned three.js build and its few addons (engine/vendor/three, MIT), served at /lit/three/
|
|
15
|
+
// for the 3D route (references/depth.md). Pages import them through an import map; nothing is fetched.
|
|
16
|
+
export const THREE_DIR = fileURLToPath(new URL("../vendor/three/", import.meta.url));
|
|
12
17
|
|
|
13
18
|
export const STAGE_ORIGIN = "http://lit.stage";
|
|
14
19
|
export const KIT_FILE = fileURLToPath(new URL("../stage/stage-kit.js", import.meta.url));
|
|
@@ -179,17 +184,22 @@ const NETWORK_API = /\bnew\s+(WebSocket|WebTransport|RTCPeerConnection|EventSour
|
|
|
179
184
|
|
|
180
185
|
/**
|
|
181
186
|
* Scan the stage dir before launch. Throws exit 17 (contract) or 19 (network) naming the file and
|
|
182
|
-
* the thing found; returns { files, rasters, warnings } otherwise.
|
|
187
|
+
* the thing found; returns { files, rasters, warnings, plate } otherwise (plate: the Blender plate, or null).
|
|
183
188
|
*/
|
|
184
189
|
export function scanStage(stageDir) {
|
|
185
190
|
if (!existsSync(path.join(stageDir, "index.html"))) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${path.join(stageDir, "index.html")} is missing; write the stage page first (references/stage.md)`);
|
|
186
191
|
const files = listStage(stageDir);
|
|
187
192
|
const warnings = [];
|
|
188
193
|
const rasters = [];
|
|
194
|
+
// A Blender plate and its preview stills are frames the runner wrote and plate.json lists by name
|
|
195
|
+
// and size; they are not page rasters. Anything not listed is held to the raster limits.
|
|
196
|
+
const plate = readPlate(stageDir, "plate");
|
|
197
|
+
const preview = readPlate(stageDir, "plate-preview");
|
|
198
|
+
const listed = new Set([plate, preview].filter(Boolean).flatMap((p) => p.frames.map((f) => path.join(p.dir, f.name))));
|
|
189
199
|
for (const f of files) {
|
|
190
200
|
const ext = path.extname(f.rel).toLowerCase();
|
|
191
|
-
if (!CONTENT_TYPES[ext]) warnings.push(`${f.rel}: not a servable type; the page cannot load it`);
|
|
192
|
-
if (RASTER.includes(ext)) {
|
|
201
|
+
if (!CONTENT_TYPES[ext] && f.rel !== "scene.py") warnings.push(`${f.rel}: not a servable type; the page cannot load it`);
|
|
202
|
+
if (RASTER.includes(ext) && !listed.has(f.rel)) {
|
|
193
203
|
const info = rasterInfo(f.real);
|
|
194
204
|
if (!info) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${f.rel} is not a readable ${ext.slice(1)} image`);
|
|
195
205
|
if (info.animated) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${f.rel} is an animated image; rasters are textures and stills, never a frame sequence`);
|
|
@@ -225,14 +235,14 @@ export function scanStage(stageDir) {
|
|
|
225
235
|
for (const [size, names] of bySize) {
|
|
226
236
|
if (names.length >= RASTER_LIMITS.flipbook) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${names.length} rasters of ${size} look like a flipbook; animate the page, not a frame sequence`);
|
|
227
237
|
}
|
|
228
|
-
return { files, rasters, warnings };
|
|
238
|
+
return { files, rasters, warnings, plate };
|
|
229
239
|
}
|
|
230
240
|
|
|
231
241
|
/**
|
|
232
242
|
* The request router for one stage run. Returns { kind: "file", file, type } | { kind: "body", body,
|
|
233
243
|
* type } | { kind: "blocked", reason } | { kind: "missing" }, and records every refusal.
|
|
234
244
|
*/
|
|
235
|
-
export function stageRouter({ stageDir, faces }) {
|
|
245
|
+
export function stageRouter({ stageDir, faces, style = null }) {
|
|
236
246
|
const root = realpathSync(stageDir);
|
|
237
247
|
const css = fontsCss(faces);
|
|
238
248
|
const byRoute = new Map(faces.map((f) => [f.route, f.file]));
|
|
@@ -251,9 +261,23 @@ export function stageRouter({ stageDir, faces }) {
|
|
|
251
261
|
return { kind: "blocked" };
|
|
252
262
|
}
|
|
253
263
|
const pathname = decodeURIComponent(parsed.pathname);
|
|
264
|
+
// Chrome asks for a favicon on its own; the page did not, so it is not a missing file of the page.
|
|
265
|
+
if (pathname === "/favicon.ico") return { kind: "missing" };
|
|
254
266
|
if (pathname === "/lit/stage-kit.js") return { kind: "file", file: KIT_FILE, type: CONTENT_TYPES[".js"] };
|
|
255
267
|
if (pathname === "/lit/loop-kit.js") return { kind: "file", file: LOOP_KIT_FILE, type: CONTENT_TYPES[".js"] };
|
|
256
268
|
if (pathname === "/lit/fonts.css") return { kind: "body", body: css, type: CONTENT_TYPES[".css"] };
|
|
269
|
+
if (pathname.startsWith("/lit/three/")) {
|
|
270
|
+
const rel = pathname.slice("/lit/three/".length);
|
|
271
|
+
const file = path.join(THREE_DIR, rel);
|
|
272
|
+
if (rel.split("/").includes("..") || !rel.endsWith(".js") || !existsSync(file)) {
|
|
273
|
+
missing.push({ url, reason: "not a vendored three.js file" });
|
|
274
|
+
return { kind: "missing" };
|
|
275
|
+
}
|
|
276
|
+
return { kind: "file", file, type: CONTENT_TYPES[".js"] };
|
|
277
|
+
}
|
|
278
|
+
// The film's house style (references/studio.md): type, colour and zone tokens, and the shot timeline.
|
|
279
|
+
if (style && pathname === "/lit/style.css") return { kind: "body", body: style.css, type: CONTENT_TYPES[".css"] };
|
|
280
|
+
if (style && pathname === "/lit/style.js") return { kind: "body", body: style.js, type: CONTENT_TYPES[".js"] };
|
|
257
281
|
if (byRoute.has(pathname)) return { kind: "file", file: byRoute.get(pathname), type: CONTENT_TYPES[path.extname(pathname)] };
|
|
258
282
|
const rel = pathname === "/" ? "index.html" : pathname.replace(/^\/+/u, "");
|
|
259
283
|
if (rel.split("/").includes("..")) {
|
|
@@ -11,6 +11,9 @@ import path from "node:path";
|
|
|
11
11
|
import { EXIT, GATE, PREVIEW } from "../core/constants.mjs";
|
|
12
12
|
import { FlashDetector, cellsFromRgba, detectSequence, gridFor } from "../core/flash.mjs";
|
|
13
13
|
import { areaDownscale } from "./analysis.mjs";
|
|
14
|
+
import { assetUsed } from "./assets.mjs";
|
|
15
|
+
import { BLENDER } from "./blender.mjs";
|
|
16
|
+
import { addToStills, writeBoard } from "./board.mjs";
|
|
14
17
|
import { BlockedError } from "./chrome.mjs";
|
|
15
18
|
import { findFfmpeg, findFfprobe, findImg2webp, previewRungs, startMaster, encodePreview, probeVideo } from "./encode.mjs";
|
|
16
19
|
import { resolveViewed, asBlocked } from "./pipeline.mjs";
|
|
@@ -21,6 +24,8 @@ import { StageBrowser, FramePool, analyzeChecked } from "./stage-capture.mjs";
|
|
|
21
24
|
import { evaluateStageGate, formatStageReport } from "./stage-gate.mjs";
|
|
22
25
|
import { textQaReplay } from "./stage-qa.mjs";
|
|
23
26
|
import { treatmentSha } from "./done.mjs";
|
|
27
|
+
import { recordHouse } from "./house.mjs";
|
|
28
|
+
import { styleCss, styleScript, readStyle } from "./styles.mjs";
|
|
24
29
|
import { scanStage, stageFonts } from "./stage-serve.mjs";
|
|
25
30
|
import { beatCuts, stillsPlan, writeStills } from "./stills.mjs";
|
|
26
31
|
import { FORMATS, loadTreatment } from "./treatment.mjs";
|
|
@@ -33,11 +38,19 @@ const writeJson = (file, value) => {
|
|
|
33
38
|
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
34
39
|
};
|
|
35
40
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
41
|
+
/** The house style the page is served (/lit/style.css, /lit/style.js) for this treatment and format. */
|
|
42
|
+
export const styleAssets = (treatment) => {
|
|
43
|
+
const style = readStyle(treatment.style);
|
|
44
|
+
return { style, css: styleCss(style, treatment.format), js: styleScript(style, treatment.format, treatment) };
|
|
45
|
+
};
|
|
36
46
|
const percentile = (values, p) => {
|
|
37
47
|
const sorted = [...values].sort((a, b) => a - b);
|
|
38
48
|
return sorted.length ? +sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))].toFixed(1) : null;
|
|
39
49
|
};
|
|
40
50
|
|
|
51
|
+
/** The beat stills of a stills set, as absolute paths, in beat order. */
|
|
52
|
+
export const beatStillFiles = (out, stills) => stills.index.files.filter((f) => f.kind === "beat").sort((a, b) => a.beat - b.beat).map((f) => path.join(out, f.file));
|
|
53
|
+
|
|
41
54
|
/** Determinism samples: frame 0, the last frame, each beat's first frame, beat midpoints to 8, at most 16. */
|
|
42
55
|
export function determinismFrames({ beats, fps, frameCount }) {
|
|
43
56
|
const clamp = (f) => Math.max(0, Math.min(frameCount - 1, f));
|
|
@@ -83,10 +96,30 @@ function violationExit(state, router) {
|
|
|
83
96
|
return null;
|
|
84
97
|
}
|
|
85
98
|
|
|
86
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Open a stage browser on the round's rung and check the page's LitStage contract. A 3D film asks for
|
|
101
|
+
* the GPU rung; when that rung cannot launch or gives the page no WebGL, the round falls back to the
|
|
102
|
+
* software rung for every browser (master and replays must share one rung) and records a notice.
|
|
103
|
+
*/
|
|
87
104
|
async function openChecked({ label, ctx }) {
|
|
105
|
+
if (ctx.rung === "gpu") {
|
|
106
|
+
try {
|
|
107
|
+
const browser = await openOnRung({ label, ctx });
|
|
108
|
+
if (!browser.state.webglMissing) return browser;
|
|
109
|
+
await browser.close();
|
|
110
|
+
ctx.notices.push("the 3D route asked for the GPU rung and the page got no WebGL there; rendered on the software rung (SwiftShader), slower, same frames");
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof BlockedError) throw error;
|
|
113
|
+
ctx.notices.push(`the 3D route asked for the GPU rung and it did not start (${String(error.message).slice(0, 120)}); rendered on the software rung (SwiftShader), slower, same frames`);
|
|
114
|
+
}
|
|
115
|
+
ctx.rung = "software";
|
|
116
|
+
}
|
|
117
|
+
return openOnRung({ label, ctx });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function openOnRung({ label, ctx }) {
|
|
88
121
|
const { stageDir, faces, scan, width, height, treatment, runDir, env, log } = ctx;
|
|
89
|
-
const browser = await StageBrowser.open({ stageDir, faces, rasters: scan.rasters, width, height, seed: treatment.seed, profileDir: path.join(runDir, `c${process.pid % 100000}${label}`), env, log });
|
|
122
|
+
const browser = await StageBrowser.open({ stageDir, faces, style: ctx.style, rasters: scan.rasters, width, height, seed: treatment.seed, profileDir: path.join(runDir, `c${process.pid % 100000}${label}`), rung: ctx.rung, env, log });
|
|
90
123
|
const state = browser.state;
|
|
91
124
|
const fail = (message) => new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${message}`);
|
|
92
125
|
const early = violationExit(state, browser.router);
|
|
@@ -97,9 +130,9 @@ async function openChecked({ label, ctx }) {
|
|
|
97
130
|
else if (!error && spec.render !== "function") error = fail("LitStage.define needs render(t)");
|
|
98
131
|
else if (!error && spec.fps !== (treatment.fps ?? 60)) error = fail(`the page runs at ${spec.fps} fps; the treatment asks for ${treatment.fps ?? 60} (30 only when the treatment says fps: 30)`);
|
|
99
132
|
else if (!error && !(spec.duration > 0)) error = fail("the page's duration must be a positive number of seconds");
|
|
100
|
-
if (!error && state.webglMissing) error = new BlockedError(EXIT.BLOCKED_NO_WEBGL2,
|
|
133
|
+
if (!error && state.webglMissing) error = new BlockedError(EXIT.BLOCKED_NO_WEBGL2, `BLOCKED_NO_WEBGL2: the page asked for a WebGL context and the ${ctx.rung} rung gave none`);
|
|
101
134
|
if (!error && browser.prepared?.failedFaces?.length) log(`warning: faces that did not load: ${browser.prepared.failedFaces.join(", ")}`);
|
|
102
|
-
if (error) {
|
|
135
|
+
if (error && !(ctx.rung === "gpu" && error.code === EXIT.BLOCKED_NO_WEBGL2)) {
|
|
103
136
|
await browser.close();
|
|
104
137
|
throw error;
|
|
105
138
|
}
|
|
@@ -141,6 +174,18 @@ async function captureFrames({ browser, pool, fps, frames, width, height, asMast
|
|
|
141
174
|
return out;
|
|
142
175
|
}
|
|
143
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The browser rung: a three.js world asks for the GPU; a Blender film shows its path-traced plate
|
|
179
|
+
* under HTML type, so its page needs only the software rung, and it needs the plate, all of it.
|
|
180
|
+
*/
|
|
181
|
+
export function stageRung(treatment, scan, out) {
|
|
182
|
+
if (treatment.render3d !== "blender") return treatment.depth === "3d" ? "gpu" : "software";
|
|
183
|
+
const want = Math.round(treatment.durationSec * BLENDER.plateFps);
|
|
184
|
+
if (!scan.plate) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: render3d is blender but stage/plate/plate.json is missing or does not match its frames; render the plate first: blender --out ${out} --final (no Blender: set render3d to three and use the GPU rung)`);
|
|
185
|
+
if (scan.plate.frames.length !== want) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: the plate has ${scan.plate.frames.length} frames; a ${treatment.durationSec} s film needs ${want} at ${BLENDER.plateFps} fps (render it again: blender --out ${out} --final)`);
|
|
186
|
+
return "software";
|
|
187
|
+
}
|
|
188
|
+
|
|
144
189
|
function renderId(treatment, scan) {
|
|
145
190
|
const h = createHash("sha256").update(JSON.stringify(treatment));
|
|
146
191
|
for (const f of scan.files) h.update(f.rel).update(readFileSync(f.real));
|
|
@@ -213,7 +258,7 @@ export async function captureStageFrames({ out, frames, env = process.env, log =
|
|
|
213
258
|
const faces = stageFonts(env);
|
|
214
259
|
const runDir = path.join(out, ".run");
|
|
215
260
|
mkdirSync(runDir, { recursive: true });
|
|
216
|
-
const ctx = { stageDir, faces, scan, width, height, treatment, runDir, env, log };
|
|
261
|
+
const ctx = { stageDir, faces, scan, width, height, treatment, runDir, env, log, style: styleAssets(treatment), rung: stageRung(treatment, scan, out), notices: [] };
|
|
217
262
|
const browser = await openChecked({ label: "x", ctx });
|
|
218
263
|
const pool = new FramePool(2);
|
|
219
264
|
try {
|
|
@@ -285,7 +330,7 @@ export async function renderStage({ out, round = 1, stillsOnly = false, env = pr
|
|
|
285
330
|
const runDir = path.join(out, ".run");
|
|
286
331
|
mkdirSync(runDir, { recursive: true });
|
|
287
332
|
const ffmpeg = findFfmpeg(env);
|
|
288
|
-
const ctx = { stageDir, faces, scan, width, height, treatment, runDir, env, log };
|
|
333
|
+
const ctx = { stageDir, faces, scan, width, height, treatment, runDir, env, log, style: styleAssets(treatment), rung: stageRung(treatment, scan, out), notices: [] };
|
|
289
334
|
progress(out, { phase: "open", round, stillsOnly });
|
|
290
335
|
const master = await openChecked({ label: "m", ctx });
|
|
291
336
|
const spec = master.state.spec;
|
|
@@ -300,11 +345,30 @@ export async function renderStage({ out, round = 1, stillsOnly = false, env = pr
|
|
|
300
345
|
if (stillsOnly) {
|
|
301
346
|
const frames = await captureFrames({ browser: master, pool, fps, frames: plan.all, width, height });
|
|
302
347
|
const stills = writeStills({ out, plan, frames: new Map([...frames].map(([f, a]) => [f, a.rgba])), width, height, fps, frameCount, round, stillsOnly: true, path: "stage" });
|
|
348
|
+
// The board round: the text and style rules run on the board frames before any film is encoded,
|
|
349
|
+
// and the storyboard is drawn with the verdict on it.
|
|
350
|
+
progress(out, { phase: "board-check", round, stillsOnly });
|
|
351
|
+
const replayQa = qa ?? textQaReplay({ pool, stageFiles: scan.files.map((f) => path.basename(f.rel)) });
|
|
352
|
+
const check = await replayQa({ ctx, fps, frameCount, plan, openBrowser: (label) => openChecked({ label, ctx }), stepChecked });
|
|
353
|
+
const assetRule = assetUsed(treatment, scan.files);
|
|
354
|
+
if (assetRule) check.rules.push(assetRule);
|
|
355
|
+
writeJson(path.join(out, "stills", "check.json"), { rules: check.rules });
|
|
356
|
+
const boardFile = await writeBoard({ out, treatment, beatFiles: beatStillFiles(out, stills), check, env, log });
|
|
357
|
+
stills.sha256 = addToStills(out, { file: "stills/board.png", kind: "board", frames: plan.beats }, { sha256 });
|
|
303
358
|
progress(out, { phase: "done", round, stillsOnly });
|
|
359
|
+
const failed = check.rules.filter((x) => x.status === "FAIL");
|
|
360
|
+
if (check.exit) return { code: check.exit, message: `${check.message} (board check; the storyboard is ${boardFile})`, stills, renderId: id, check };
|
|
361
|
+
if (failed.length) {
|
|
362
|
+
return {
|
|
363
|
+
code: EXIT.GATE_FAIL_QA,
|
|
364
|
+
message: `BOARD_CHECK_FAIL: ${failed.map((x) => `${x.id} ${x.detail}`).join("; ")}. Fix the page, render --stills-only again, then look at the board ${boardFile} and every file in ${path.join(out, "stills")}`,
|
|
365
|
+
stills, renderId: id, check,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
304
368
|
return {
|
|
305
369
|
code: EXIT.OK,
|
|
306
|
-
message: `STILLS_ONLY:
|
|
307
|
-
stills, renderId: id,
|
|
370
|
+
message: `STILLS_ONLY: board check PASS; open the storyboard ${boardFile} and every file in ${path.join(out, "stills")} (index.json lists them), record the round with look --round ${round}, make the change, then render the film with --round ${round + 1} (no --stills-only)`,
|
|
371
|
+
stills, renderId: id, check,
|
|
308
372
|
};
|
|
309
373
|
}
|
|
310
374
|
if (!ffmpeg) {
|
|
@@ -350,7 +414,7 @@ export async function renderStage({ out, round = 1, stillsOnly = false, env = pr
|
|
|
350
414
|
const a = await entry.analysis;
|
|
351
415
|
await encoder.write(a.rgba);
|
|
352
416
|
const flash = detector.push(a.cells);
|
|
353
|
-
lines.push({ frame: entry.f, artifact: "master", rgbaSha256: a.rgbaSha256, luminanceP995: a.luminanceP995, flash });
|
|
417
|
+
lines.push({ frame: entry.f, artifact: "master", rgbaSha256: a.rgbaSha256, luminanceP995: a.luminanceP995, luminanceP005: a.luminanceP005, busy: a.busy, flash });
|
|
354
418
|
if (plan.all.has(entry.f)) kept.set(entry.f, a.rgba);
|
|
355
419
|
if (detFrames.includes(entry.f)) detMaster.set(entry.f, a);
|
|
356
420
|
if (entry.f % previewEvery === 0) {
|
|
@@ -401,23 +465,27 @@ export async function renderStage({ out, round = 1, stillsOnly = false, env = pr
|
|
|
401
465
|
};
|
|
402
466
|
const gate = evaluateStageGate({
|
|
403
467
|
masterFlash: lines.map((l) => l.flash), previewFlash: preview.records ? { records: preview.records, fps: preview.rung.fps } : null, fps,
|
|
404
|
-
probe, width, height, treatment, exports, reducedFrame: plan.reduced, frames: lines, usesWebGL: finalState.usesWebGL, renderer:
|
|
405
|
-
determinism, extraRules: [...soundAudit.rules, ...(qaResult?.rules ?? [])],
|
|
468
|
+
probe, width, height, treatment, exports, reducedFrame: plan.reduced, frames: lines, usesWebGL: finalState.usesWebGL, renderer: finalState.webglRenderer, rung: ctx.rung,
|
|
469
|
+
determinism, extraRules: [...soundAudit.rules, ...(qaResult?.rules ?? []), assetUsed(treatment, scan.files)].filter(Boolean),
|
|
406
470
|
});
|
|
407
471
|
const promoted = promote(out, gate.flashFail);
|
|
408
472
|
const loc = (name) => path.join(promoted.dir, name);
|
|
409
473
|
const stills = writeStills({ out, plan, frames: kept, width, height, fps, frameCount, round, stillsOnly: false, path: "stage", posterFile: existsSync(loc("poster.png")) ? loc("poster.png") : null });
|
|
474
|
+
await writeBoard({ out, treatment, beatFiles: beatStillFiles(out, stills), check: qaResult, env, log });
|
|
475
|
+
stills.sha256 = addToStills(out, { file: "stills/board.png", kind: "board", frames: plan.beats }, { sha256 });
|
|
476
|
+
if (gate.pass && !promoted.withheld) recordHouse(out, { style: treatment.style, path: "stage", format: treatment.format, durationSec: treatment.durationSec });
|
|
410
477
|
const totalSec = (Date.now() - t0) / 1000;
|
|
411
478
|
const manifest = {
|
|
412
479
|
schemaVersion: 1, path: "stage", renderId: id, round, treatmentSha256: treatmentSha(out), format: treatment.format, width, height, fps, durationSec: spec.duration, treatmentDurationSec: treatment.durationSec,
|
|
413
|
-
frameCount, seed: treatment.seed, renderer: "
|
|
480
|
+
frameCount, seed: treatment.seed, renderer: finalState.webglRenderer ?? "no WebGL", rung: ctx.rung, chromeFlags: master.flags, usesWebGL: finalState.usesWebGL,
|
|
414
481
|
cuts, stills: { sha256: stills.sha256, poster: plan.poster, reduced: plan.reduced }, posterEncoding: poster,
|
|
415
482
|
geometry: geometryFor(width, height),
|
|
483
|
+
plate: scan.plate ? { blender: scan.plate.blender, engine: scan.plate.engine, device: scan.plate.device, samples: scan.plate.samples, fps: scan.plate.fps, frames: scan.plate.frames.length } : null,
|
|
416
484
|
sound: { mode: sound.mode, label: sound.label, file: sound.file ? path.relative(out, sound.file) : null, sha256: sound.file ? sha256(readFileSync(sound.file)) : null, lufs: sound.bed?.lufs ?? null },
|
|
417
485
|
preview: { encoder: preview.encoder, rung: preview.rung, attempts: preview.attempts },
|
|
418
486
|
pageErrors: finalState.errors, missingAssets: master.router.missing,
|
|
419
487
|
timing: { frameP50Ms: percentile(frameMs, 0.5), frameP95Ms: percentile(frameMs, 0.95), masterSec: +masterSec.toFixed(1), totalSec: +totalSec.toFixed(1) },
|
|
420
|
-
warnings: [...scan.warnings, ...master.router.missing.map((m) => `the page asked for a missing file ${m.url} (${m.reason})`)],
|
|
488
|
+
warnings: [...ctx.notices, ...scan.warnings, ...master.router.missing.map((m) => `the page asked for a missing file ${m.url} (${m.reason})`)],
|
|
421
489
|
generatedAt: new Date().toISOString(),
|
|
422
490
|
};
|
|
423
491
|
writeJson(path.join(out, "manifest.json"), manifest);
|
|
@@ -492,7 +560,7 @@ export async function renderStageDetached({ out, round, cli, mode = "stage", env
|
|
|
492
560
|
}
|
|
493
561
|
try {
|
|
494
562
|
const p = JSON.parse(readFileSync(path.join(runDir, "progress.json"), "utf8"));
|
|
495
|
-
const phase = `${p.phase}${p.frame !== undefined && p.phase === "master" ? ` ${Math.floor((p.frame / p.frameCount) * 10) * 10}%` : ""}`;
|
|
563
|
+
const phase = `${p.phase}${p.frame !== undefined && (p.phase === "master" || p.phase === "plate") ? ` ${Math.floor((p.frame / p.frameCount) * 10) * 10}%` : ""}`;
|
|
496
564
|
if (phase !== lastPhase) log(`render: ${phase}`);
|
|
497
565
|
lastPhase = phase;
|
|
498
566
|
} catch {
|