@litfamily/litmuse 0.1.1 → 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 +11 -0
- package/README.md +20 -0
- package/README_ko-KR.md +20 -0
- package/bin/litmuse.mjs +4 -0
- package/lib/stage-card.mjs +180 -5
- package/package.json +1 -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/start-work/SKILL.md +6 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The offline Blender rung. A 3D film with `render3d: "blender"` writes its world as a Blender
|
|
2
|
+
// Python scene (<out>/stage/scene.py); `motion.mjs blender` renders it headless with Cycles on the
|
|
3
|
+
// GPU through the bundled runner: first preview stills at the beat midpoints (half size, few
|
|
4
|
+
// samples) to look at, then with --final the full plate sequence in <out>/stage/plate/ with
|
|
5
|
+
// plate.json, the receipt the stage trusts. The stage page shows the plate through
|
|
6
|
+
// LitStage.plate() and draws its type and labels over it, so the page itself stays on the
|
|
7
|
+
// software rung. Without Blender the command renders nothing and names the fallback: the
|
|
8
|
+
// three.js world on the GPU rung.
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { EXIT } from "../core/constants.mjs";
|
|
14
|
+
import { BlockedError } from "./chrome.mjs";
|
|
15
|
+
import { FORMATS, TreatmentError, loadTreatment } from "./treatment.mjs";
|
|
16
|
+
|
|
17
|
+
export const BLENDER = Object.freeze({
|
|
18
|
+
macApp: "/Applications/Blender.app/Contents/MacOS/Blender",
|
|
19
|
+
runner: fileURLToPath(new URL("../blender/runner.py", import.meta.url)),
|
|
20
|
+
plateFps: 30,
|
|
21
|
+
device: "METAL",
|
|
22
|
+
preview: Object.freeze({ percent: 50, samples: 40 }),
|
|
23
|
+
final: Object.freeze({ percent: 100, samples: 160 }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where Blender is: an explicit LITMUSE_BLENDER path (used only when it exists; never
|
|
28
|
+
* second-guessed), then the macOS app, then `blender` on PATH. Returns { path, source } or null.
|
|
29
|
+
*/
|
|
30
|
+
export function findBlender({ env = process.env, exists = existsSync, platform = process.platform } = {}) {
|
|
31
|
+
if (env.LITMUSE_BLENDER) return exists(env.LITMUSE_BLENDER) ? { path: env.LITMUSE_BLENDER, source: "LITMUSE_BLENDER" } : null;
|
|
32
|
+
if (platform === "darwin" && exists(BLENDER.macApp)) return { path: BLENDER.macApp, source: "app" };
|
|
33
|
+
for (const dir of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
34
|
+
const file = path.join(dir, "blender");
|
|
35
|
+
if (exists(file)) return { path: file, source: "PATH" };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const plateFrames = (treatment) => Math.round(treatment.durationSec * BLENDER.plateFps);
|
|
41
|
+
|
|
42
|
+
/** The headless command line: factory settings, the runner, and the runner's own arguments after `--`. */
|
|
43
|
+
export function blenderArgs({ treatment, scene, out, quality }) {
|
|
44
|
+
const q = BLENDER[quality];
|
|
45
|
+
if (!q) throw new Error(`blenderArgs: quality is preview or final (got ${quality})`);
|
|
46
|
+
const [width, height] = FORMATS[treatment.format];
|
|
47
|
+
const args = ["-b", "--factory-startup", "-P", BLENDER.runner, "--",
|
|
48
|
+
"--scene", scene, "--out", out, "--width", String(width), "--height", String(height),
|
|
49
|
+
"--percent", String(q.percent), "--samples", String(q.samples), "--device", BLENDER.device,
|
|
50
|
+
"--fps", String(BLENDER.plateFps), "--seed", String(treatment.seed % 2147483648)];
|
|
51
|
+
if (quality === "preview") args.push("--stills", treatment.beats.map((b) => +((b.t0 + b.t1) / 2).toFixed(3)).join(","));
|
|
52
|
+
else args.push("--frames", `1-${plateFrames(treatment)}`);
|
|
53
|
+
return args;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const FALLBACK = (out) => [
|
|
57
|
+
"FALLBACK: no Blender found (LITMUSE_BLENDER, /Applications/Blender.app, blender on PATH), so nothing was rendered.",
|
|
58
|
+
`Set render3d to "three" in ${path.join(out, "treatment.json")} and build the world in three.js on the GPU rung (references/depth.md),`,
|
|
59
|
+
"and say in the reply that the film used the GPU rung because Blender is not installed.",
|
|
60
|
+
].join("\n");
|
|
61
|
+
|
|
62
|
+
const listPngs = (dir) => (existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(".png")).sort() : []);
|
|
63
|
+
|
|
64
|
+
function runBlender({ bin, args, dir, expected, progress }) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
67
|
+
let tail = "";
|
|
68
|
+
const keep = (chunk) => {
|
|
69
|
+
tail = (tail + chunk).slice(-4000);
|
|
70
|
+
};
|
|
71
|
+
child.stdout.on("data", keep);
|
|
72
|
+
child.stderr.on("data", keep);
|
|
73
|
+
const timer = progress ? setInterval(() => progress(listPngs(dir).length, expected), 2000) : null;
|
|
74
|
+
child.on("close", (code) => {
|
|
75
|
+
if (timer) clearInterval(timer);
|
|
76
|
+
resolve({ code, tail });
|
|
77
|
+
});
|
|
78
|
+
child.on("error", (error) => {
|
|
79
|
+
if (timer) clearInterval(timer);
|
|
80
|
+
resolve({ code: -1, tail: error.message });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `motion.mjs blender --out <dir> [--final]`. Preview: stills in stage/plate-preview/. Final: the
|
|
87
|
+
* plate sequence in stage/plate/ plus plate.json naming every frame with its size, carrying the
|
|
88
|
+
* device, samples and Blender version from the runner's receipt.
|
|
89
|
+
*/
|
|
90
|
+
export async function blenderCommand({ out, final = false, env = process.env, log = () => {}, progress = null }) {
|
|
91
|
+
let treatment;
|
|
92
|
+
try {
|
|
93
|
+
treatment = loadTreatment(out, { pathName: "stage" });
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof TreatmentError) throw new BlockedError(EXIT.BLOCKED_TREATMENT_INVALID, error.message);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
if (treatment.render3d !== "blender") throw new BlockedError(EXIT.USAGE, `blender renders a treatment with render3d "blender" (this one is ${treatment.render3d ?? "three"})`);
|
|
99
|
+
const stageDir = path.join(out, "stage");
|
|
100
|
+
const scene = path.join(stageDir, "scene.py");
|
|
101
|
+
if (!existsSync(scene)) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${scene} is missing; write the Blender scene first (references/depth.md, the Blender rung)`);
|
|
102
|
+
const found = findBlender({ env });
|
|
103
|
+
if (!found) return { code: EXIT.OK, message: FALLBACK(out) };
|
|
104
|
+
const quality = final ? "final" : "preview";
|
|
105
|
+
const dir = path.join(stageDir, final ? "plate" : "plate-preview");
|
|
106
|
+
rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
mkdirSync(dir, { recursive: true });
|
|
108
|
+
const args = blenderArgs({ treatment, scene, out: dir, quality });
|
|
109
|
+
const expected = final ? plateFrames(treatment) : treatment.beats.length;
|
|
110
|
+
log(`blender ${quality}: ${found.path} (${found.source}), ${expected} ${final ? "frames" : "stills"}`);
|
|
111
|
+
const run = await runBlender({ bin: found.path, args, dir, expected, progress });
|
|
112
|
+
const receiptFile = path.join(dir, "receipt.json");
|
|
113
|
+
if (run.code !== 0 || !existsSync(receiptFile)) {
|
|
114
|
+
throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: Blender ${quality} failed (exit ${run.code}); fix stage/scene.py and run it again. Last output: ${run.tail.trim().split("\n").slice(-8).join(" | ")}`);
|
|
115
|
+
}
|
|
116
|
+
const receipt = JSON.parse(readFileSync(receiptFile, "utf8"));
|
|
117
|
+
const frames = receipt.files.map((name) => ({ name, bytes: statSync(path.join(dir, name)).size }));
|
|
118
|
+
if (frames.length !== expected) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: Blender wrote ${frames.length} of ${expected} ${final ? "frames" : "stills"}`);
|
|
119
|
+
const plate = { kind: quality, blender: receipt.blender, engine: receipt.engine, device: receipt.device, samples: receipt.samples, percent: receipt.percent, fps: BLENDER.plateFps, frames };
|
|
120
|
+
writeFileSync(path.join(dir, "plate.json"), `${JSON.stringify(plate, null, 1)}\n`);
|
|
121
|
+
const label = `Blender ${receipt.blender}, ${receipt.engine} ${receipt.device}, ${receipt.samples} spp at ${receipt.percent} %`;
|
|
122
|
+
if (!final) {
|
|
123
|
+
return { code: EXIT.OK, message: `BLENDER preview: ${frames.length} stills at the beat midpoints in ${dir} (${label}). Open them, compare them with any reference frames the user gave, fix scene.py, then render the plate: blender --out ${out} --final` };
|
|
124
|
+
}
|
|
125
|
+
return { code: EXIT.OK, message: `BLENDER plate: ${frames.length} frames at ${BLENDER.plateFps} fps in ${dir} (${label}). Show it with LitStage.plate() under the type, then render the stage: stage --out ${out} --round N` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The plate a stage dir carries: { dir, frames, device, ... } when plate.json lists frames all present as listed, else null. */
|
|
129
|
+
export function readPlate(stageDir, sub = "plate") {
|
|
130
|
+
const file = path.join(stageDir, sub, "plate.json");
|
|
131
|
+
if (!existsSync(file)) return null;
|
|
132
|
+
let plate;
|
|
133
|
+
try {
|
|
134
|
+
plate = JSON.parse(readFileSync(file, "utf8"));
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (!Array.isArray(plate.frames)) return null;
|
|
139
|
+
const ok = plate.frames.every((f) => {
|
|
140
|
+
const p = path.join(stageDir, sub, String(f.name));
|
|
141
|
+
return path.basename(String(f.name)) === f.name && existsSync(p) && statSync(p).size === f.bytes;
|
|
142
|
+
});
|
|
143
|
+
return ok ? { ...plate, dir: sub } : null;
|
|
144
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The storyboard: `stills/board.png`, one page a person reads before the full render. One
|
|
2
|
+
// keyframe per shot (the beat still) with number, time, layout, hand-off and hero move under
|
|
3
|
+
// it, the shot's type zone outlined on the frame; a header with the house style (name,
|
|
4
|
+
// palette, type) plus format, length and sound; a footer with the layout check. It is drawn
|
|
5
|
+
// as a page in the same headless Chrome the stage uses, so its type is the product's faces.
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { LAYOUTS, readStyle } from "./styles.mjs";
|
|
9
|
+
import { StageBrowser } from "./stage-capture.mjs";
|
|
10
|
+
import { stageFonts } from "./stage-serve.mjs";
|
|
11
|
+
|
|
12
|
+
export const BOARD = Object.freeze({ width: 1920, height: 1080, margin: 48, gap: 20, header: 150, footer: 56, caption: 104 });
|
|
13
|
+
|
|
14
|
+
const esc = (text) => String(text ?? "").replace(/[&<>"]/gu, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
15
|
+
const secs = (v) => `${Number(v).toFixed(1)}`;
|
|
16
|
+
|
|
17
|
+
/** Tile geometry: columns and thumbnail size for n shots of a width x height film. */
|
|
18
|
+
export function boardGrid(n, width, height) {
|
|
19
|
+
const usableW = BOARD.width - 2 * BOARD.margin;
|
|
20
|
+
const usableH = BOARD.height - BOARD.header - BOARD.footer - BOARD.margin;
|
|
21
|
+
const aspect = width / height;
|
|
22
|
+
const options = [];
|
|
23
|
+
for (let cols = 1; cols <= Math.max(1, n); cols++) {
|
|
24
|
+
const rows = Math.ceil(n / cols);
|
|
25
|
+
const tw = Math.floor((usableW - (cols - 1) * BOARD.gap) / cols);
|
|
26
|
+
const byHeight = Math.floor((usableH - (rows - 1) * BOARD.gap) / rows) - BOARD.caption;
|
|
27
|
+
const th = Math.min(Math.round(tw / aspect), byHeight);
|
|
28
|
+
if (th <= 0) continue;
|
|
29
|
+
options.push({ cols, rows, tw: Math.round(th * aspect), th, empty: rows * cols - n });
|
|
30
|
+
}
|
|
31
|
+
// The largest tiles win, unless a grid with fewer empty slots keeps 85 % of their area.
|
|
32
|
+
const largest = Math.max(...options.map((o) => o.tw * o.th));
|
|
33
|
+
return options.filter((o) => o.tw * o.th >= 0.85 * largest).sort((a, b) => a.empty - b.empty || b.tw * b.th - a.tw * a.th)[0];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The board page HTML for a treatment and its beat stills (names relative to the page). */
|
|
37
|
+
export function boardHtml({ treatment, stills, check = null }) {
|
|
38
|
+
const style = treatment.style ? readStyle(treatment.style) : null;
|
|
39
|
+
const [width, height] = treatment.format === "9:16" ? [1080, 1920] : [1920, 1080];
|
|
40
|
+
const grid = boardGrid(stills.length, width, height);
|
|
41
|
+
const zones = LAYOUTS[treatment.format === "9:16" ? "9:16" : "16:9"];
|
|
42
|
+
const pal = style?.palette ?? {};
|
|
43
|
+
const chips = Object.entries(pal).filter(([k]) => ["ground", "ink", "muted", "signal", "accent"].includes(k))
|
|
44
|
+
.map(([k, v]) => `<span class="chip"><i style="background:${esc(v)}"></i>${esc(k)} ${esc(v)}</span>`).join("");
|
|
45
|
+
const tiles = stills.map((file, i) => {
|
|
46
|
+
const b = treatment.beats[i] ?? {};
|
|
47
|
+
const z = b.layout && zones[b.layout]?.type;
|
|
48
|
+
const guide = z ? `<b class="zone" style="left:${(z[0] * 100).toFixed(2)}%;top:${(z[1] * 100).toFixed(2)}%;width:${(z[2] * 100).toFixed(2)}%;height:${(z[3] * 100).toFixed(2)}%"></b>` : "";
|
|
49
|
+
const meta = [String(i + 1).padStart(2, "0"), `${secs(b.t0)}–${secs(b.t1)} s`, b.layout, b.transition && b.transition !== "end" ? `→ ${b.transition}` : b.transition].filter(Boolean).map(esc).join(" · ");
|
|
50
|
+
return `<figure style="width:${grid.tw}px"><div class="frame" style="width:${grid.tw}px;height:${grid.th}px"><img src="${esc(file)}">${guide}</div>`
|
|
51
|
+
+ `<figcaption><span class="meta">${meta}</span><span class="hero">${esc(b.hero ?? b.onScreen ?? "")}</span></figcaption></figure>`;
|
|
52
|
+
}).join("");
|
|
53
|
+
const failed = (check?.rules ?? []).filter((x) => x.status === "FAIL");
|
|
54
|
+
const verdict = !check ? "layout check: not run on this render"
|
|
55
|
+
: failed.length ? `layout check: FAIL — ${failed.map((x) => x.id).join(", ")}` : "layout check: PASS";
|
|
56
|
+
const sound = treatment.sound?.mode === "generated" && treatment.sound.palette
|
|
57
|
+
? `${treatment.sound.palette.timbre}, ${treatment.sound.palette.key} ${treatment.sound.palette.mode}, ${treatment.sound.palette.tempo} BPM`
|
|
58
|
+
: treatment.sound?.mode === "score" ? `score (${treatment.sound.genre ?? readStyle(treatment.style).score.genre})` : treatment.sound?.mode ?? "none";
|
|
59
|
+
const ground = pal.ground ?? "#101216";
|
|
60
|
+
return `<!doctype html>
|
|
61
|
+
<meta charset="utf-8">
|
|
62
|
+
<link rel="stylesheet" href="/lit/fonts.css">
|
|
63
|
+
<script src="/lit/stage-kit.js"></script>
|
|
64
|
+
<style>
|
|
65
|
+
html, body { margin: 0; width: ${BOARD.width}px; height: ${BOARD.height}px; overflow: hidden; background: #15171b; color: #e8e9e6; font-family: "Pretendard"; }
|
|
66
|
+
header { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; top: 36px; height: ${BOARD.header - 48}px; display: flex; justify-content: space-between; align-items: flex-start; }
|
|
67
|
+
h1 { margin: 0; font: 700 40px/1.1 "Pretendard"; }
|
|
68
|
+
.sub { margin-top: 10px; font: 400 20px/1.3 "MesloLGS NF"; color: #a8aba6; }
|
|
69
|
+
.style { text-align: right; }
|
|
70
|
+
.specimen { display: inline-block; padding: 6px 14px; margin-bottom: 10px; background: ${esc(ground)}; color: ${esc(pal.ink ?? "#fff")}; font: ${style?.faces.display.weight ?? 700} 34px/1.1 "${esc(style?.faces.display.family ?? "Pretendard")}", "Pretendard"; }
|
|
71
|
+
.chips { display: flex; gap: 14px; justify-content: flex-end; font: 400 16px/1 "MesloLGS NF"; color: #a8aba6; }
|
|
72
|
+
.chip i { display: inline-block; width: 16px; height: 16px; margin-right: 6px; vertical-align: -3px; border: 1px solid #555; }
|
|
73
|
+
main { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; top: ${BOARD.header}px; display: flex; flex-wrap: wrap; gap: ${BOARD.gap}px; }
|
|
74
|
+
figure { margin: 0; }
|
|
75
|
+
.frame { position: relative; overflow: hidden; background: #000; }
|
|
76
|
+
.frame img { display: block; width: 100%; height: 100%; }
|
|
77
|
+
.zone { position: absolute; box-sizing: border-box; border: 2px dashed rgba(255, 196, 0, 0.75); }
|
|
78
|
+
figcaption { display: block; height: ${BOARD.caption - 8}px; padding-top: 8px; overflow: hidden; }
|
|
79
|
+
.meta { display: block; font: 400 16px/1.3 "MesloLGS NF"; color: #c9cbc6; }
|
|
80
|
+
.hero { display: block; margin-top: 4px; font: 400 18px/1.35 "Pretendard"; color: #e8e9e6; }
|
|
81
|
+
footer { position: absolute; left: ${BOARD.margin}px; right: ${BOARD.margin}px; bottom: 18px; font: 400 18px/1.2 "MesloLGS NF"; color: ${failed.length ? "#ff8a7a" : "#9fd3a4"}; }
|
|
82
|
+
</style>
|
|
83
|
+
<body>
|
|
84
|
+
<header>
|
|
85
|
+
<div><h1>Storyboard · ${esc(treatment.subject?.name ?? "")}</h1>
|
|
86
|
+
<div class="sub">${esc(treatment.format)} · ${esc(treatment.durationSec)} s · ${treatment.beats.length} shots · ${esc(treatment.path)} path · sound: ${esc(sound)}</div></div>
|
|
87
|
+
<div class="style"><div class="specimen">${esc(style?.name ?? treatment.style ?? "")} Aa 가</div><div class="chips">${chips}</div></div>
|
|
88
|
+
</header>
|
|
89
|
+
<main>${tiles}</main>
|
|
90
|
+
<footer>${esc(verdict)} · dashed box: the shot's type zone</footer>
|
|
91
|
+
<script>LitStage.define({ width: ${BOARD.width}, height: ${BOARD.height}, fps: 30, duration: 1, render() {} });</script>
|
|
92
|
+
</body>
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Render `<out>/stills/board.png` from the beat stills already written. Returns the file path.
|
|
98
|
+
* `check` is the layout check ({ rules }) when one ran.
|
|
99
|
+
*/
|
|
100
|
+
export async function writeBoard({ out, treatment, beatFiles, check = null, env = process.env, log = () => {} }) {
|
|
101
|
+
const dir = path.join(out, ".run", "board");
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
mkdirSync(dir, { recursive: true });
|
|
104
|
+
const names = beatFiles.map((f, i) => {
|
|
105
|
+
const name = `s${String(i + 1).padStart(2, "0")}.png`;
|
|
106
|
+
copyFileSync(f, path.join(dir, name));
|
|
107
|
+
return name;
|
|
108
|
+
});
|
|
109
|
+
writeFileSync(path.join(dir, "index.html"), boardHtml({ treatment, stills: names, check }));
|
|
110
|
+
const browser = await StageBrowser.open({
|
|
111
|
+
stageDir: dir, faces: stageFonts(env), rasters: names.map((rel) => ({ rel })), width: BOARD.width, height: BOARD.height, seed: 1,
|
|
112
|
+
profileDir: path.join(out, ".run", `cb${process.pid % 100000}`), env, log,
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
await browser.step(0, 30, { paint: true });
|
|
116
|
+
const png = await browser.capture();
|
|
117
|
+
const file = path.join(out, "stills", "board.png");
|
|
118
|
+
writeFileSync(file, png);
|
|
119
|
+
return file;
|
|
120
|
+
} finally {
|
|
121
|
+
await browser.close();
|
|
122
|
+
rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Add a file the renderer wrote after the stills set to `stills/index.json`; returns the new index SHA-256. */
|
|
127
|
+
export function addToStills(out, entry, { sha256 }) {
|
|
128
|
+
const indexFile = path.join(out, "stills", "index.json");
|
|
129
|
+
const index = JSON.parse(readFileSync(indexFile, "utf8"));
|
|
130
|
+
const file = path.join(out, entry.file);
|
|
131
|
+
if (!existsSync(file)) throw new Error(`addToStills: ${entry.file} was not written`);
|
|
132
|
+
index.files = [...index.files.filter((f) => f.file !== entry.file), { ...entry, sha256: sha256(readFileSync(file)) }];
|
|
133
|
+
const bytes = Buffer.from(`${JSON.stringify(index, null, 2)}\n`);
|
|
134
|
+
writeFileSync(indexFile, bytes);
|
|
135
|
+
return sha256(bytes);
|
|
136
|
+
}
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
// treatment with the first valid one and records any downgrade. A cover (treatment mode: cover) is
|
|
7
7
|
// done on its own terms: the decoded cover reached every TARGET gate, and the last look round viewed
|
|
8
8
|
// the contact sheet, the poster and the seam pair of that render. A generated asset in assets.json
|
|
9
|
-
// becomes a label the reply has to carry.
|
|
9
|
+
// becomes a label the reply has to carry. A film whose treatment asks for a preview (`preview: true`)
|
|
10
|
+
// is done at its board: a stills-only render whose layout check passed, looked at, storyboard included.
|
|
10
11
|
import { createHash } from "node:crypto";
|
|
11
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
import { generatedLabels } from "./cover-assets.mjs";
|
|
14
15
|
import { validateCoverTreatment } from "./cover-treatment.mjs";
|
|
16
|
+
import { readHouse } from "./house.mjs";
|
|
15
17
|
import { readLook } from "./look.mjs";
|
|
16
18
|
import { readStills } from "./stills.mjs";
|
|
17
19
|
import { validateTreatment } from "./treatment.mjs";
|
|
@@ -49,10 +51,11 @@ export function evaluateDone({ out, viewedFiles = null }) {
|
|
|
49
51
|
try {
|
|
50
52
|
const raw = JSON.parse(readFileSync(tFile, "utf8"));
|
|
51
53
|
if (raw?.mode === "cover") return evaluateCoverDone({ out, raw, viewedFiles });
|
|
52
|
-
treatment = validateTreatment(raw);
|
|
54
|
+
treatment = validateTreatment(raw, { house: readHouse(out) });
|
|
53
55
|
} catch (error) {
|
|
54
56
|
return notDone(`treatment.json is not valid: ${error.message}`);
|
|
55
57
|
}
|
|
58
|
+
if (treatment.preview === true) return evaluatePreviewDone({ out, viewedFiles });
|
|
56
59
|
const manifestFile = path.join(out, "manifest.json");
|
|
57
60
|
const reportFile = path.join(out, "gate-report.txt");
|
|
58
61
|
if (!existsSync(manifestFile) || !existsSync(reportFile)) return notDone("no full render has finished (manifest.json or gate-report.txt is missing)");
|
|
@@ -85,7 +88,7 @@ export function evaluateDone({ out, viewedFiles = null }) {
|
|
|
85
88
|
if (stills && last.stillsSha256 !== stills.sha256) reasons.push("the last look round was recorded before the final render (its stills manifest differs); look again and record it");
|
|
86
89
|
if (last.blocked === "no-vision-tool") unviewed = true;
|
|
87
90
|
else if (stills) {
|
|
88
|
-
const required = stills.index.files.filter((f) => ["poster", "sheet", "beat", "strip"].includes(f.kind)).map((f) => f.file);
|
|
91
|
+
const required = stills.index.files.filter((f) => ["poster", "sheet", "beat", "strip", "board"].includes(f.kind)).map((f) => f.file);
|
|
89
92
|
const missing = required.filter((f) => !last.viewed?.includes(f));
|
|
90
93
|
if (missing.length) reasons.push(`the last look round did not view ${missing.slice(0, 4).join(", ")}${missing.length > 4 ? ` (+${missing.length - 4})` : ""}`);
|
|
91
94
|
if (viewedFiles) {
|
|
@@ -110,6 +113,42 @@ export function evaluateDone({ out, viewedFiles = null }) {
|
|
|
110
113
|
return { status: unviewed ? "DONE_UNVIEWED" : "DONE", reasons: [], downgraded, openItems };
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
/** A preview: the board round of a film is the deliverable; the full render waits for the user. */
|
|
117
|
+
export function evaluatePreviewDone({ out, viewedFiles = null }) {
|
|
118
|
+
const reasons = [];
|
|
119
|
+
const stills = readStills(out);
|
|
120
|
+
if (!stills) return { status: "NOT_DONE", reasons: ["no board yet: render with --stills-only"], downgraded: [], openItems: [], preview: true };
|
|
121
|
+
const board = stills.index.files.find((f) => f.kind === "board");
|
|
122
|
+
if (!board) reasons.push("the stills set has no storyboard (stills/board.png); render with --stills-only again");
|
|
123
|
+
try {
|
|
124
|
+
const check = JSON.parse(readFileSync(path.join(out, "stills", "check.json"), "utf8"));
|
|
125
|
+
const failed = (check.rules ?? []).filter((x) => x.status === "FAIL").map((x) => x.id);
|
|
126
|
+
if (failed.length) reasons.push(`the board check failed (${failed.join(", ")}); fix the page and render the board again`);
|
|
127
|
+
} catch {
|
|
128
|
+
if (stills.index.path === "stage") reasons.push("the board check result (stills/check.json) is missing");
|
|
129
|
+
}
|
|
130
|
+
let look;
|
|
131
|
+
try {
|
|
132
|
+
look = readLook(out);
|
|
133
|
+
} catch {
|
|
134
|
+
return { status: "NOT_DONE", reasons: ["look.json is unreadable"], downgraded: [], openItems: [], preview: true };
|
|
135
|
+
}
|
|
136
|
+
const last = look.rounds[look.rounds.length - 1];
|
|
137
|
+
if (!last) reasons.push("no look round on the board: open the storyboard and the stills with read_file and record round 1");
|
|
138
|
+
else {
|
|
139
|
+
if (last.stillsSha256 !== stills.sha256) reasons.push("the look round was recorded before the latest board; look again and record it");
|
|
140
|
+
const required = stills.index.files.filter((f) => ["board", "sheet", "beat"].includes(f.kind)).map((f) => f.file);
|
|
141
|
+
const missing = required.filter((f) => !last.viewed?.includes(f));
|
|
142
|
+
if (missing.length) reasons.push(`the look round did not view ${missing.slice(0, 4).join(", ")}`);
|
|
143
|
+
if (viewedFiles) {
|
|
144
|
+
const unopened = (last.viewed ?? []).filter((f) => !viewedFiles.has(path.resolve(out, f)));
|
|
145
|
+
if (unopened.length) reasons.push(`these files are listed as viewed but were never opened with read_file: ${unopened.slice(0, 4).join(", ")}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (reasons.length) return { status: "NOT_DONE", reasons, downgraded: [], openItems: [], preview: true };
|
|
149
|
+
return { status: last.blocked === "no-vision-tool" ? "DONE_UNVIEWED" : "DONE", reasons: [], downgraded: [], openItems: [], preview: true, labels: [`this is the storyboard preview (${path.join(out, "stills", "board.png")}); the film is not rendered yet, and the user can say go to render it`] };
|
|
150
|
+
}
|
|
151
|
+
|
|
113
152
|
/** The cover branch of the done-check (references/cover.md, Done). */
|
|
114
153
|
export function evaluateCoverDone({ out, raw, viewedFiles = null }) {
|
|
115
154
|
const notDone = (reason) => ({ status: "NOT_DONE", reasons: [reason], downgraded: [], openItems: [], mode: "cover" });
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// `motion.mjs styles --out <dir> [--format 16:9|9:16]`: the style gallery. One short demo
|
|
2
|
+
// film, the same treatment and the same drawings, rendered as a board round in every house
|
|
3
|
+
// style, so a person sees what each style looks like before asking for a film. Each style's
|
|
4
|
+
// board lands in `<dir>/<style>/stills/board.png`, and `<dir>/styles.png` sets them side by
|
|
5
|
+
// side. The demo also proves the styles and the scaffold pass the board check as shipped.
|
|
6
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { EXIT } from "../core/constants.mjs";
|
|
9
|
+
import { decodePng } from "./png.mjs";
|
|
10
|
+
import { scaffoldHtml } from "./scaffold.mjs";
|
|
11
|
+
import { renderStage } from "./stage.mjs";
|
|
12
|
+
import { composeGrid } from "./stills.mjs";
|
|
13
|
+
import { STYLE_IDS, readStyle } from "./styles.mjs";
|
|
14
|
+
|
|
15
|
+
const COPY = ["작게 시작해서 끝까지", "One clock for every frame", "한 화면에 한 가지 생각", "Same house, every film"];
|
|
16
|
+
|
|
17
|
+
/** The demo treatment for one style: four shots, one per text layout, 12 s. */
|
|
18
|
+
export function demoTreatment(styleId, format = "16:9") {
|
|
19
|
+
const style = readStyle(styleId);
|
|
20
|
+
const hand = (i) => style.transitions[i % style.transitions.length];
|
|
21
|
+
const vertical = format === "9:16" ? " vertical" : "";
|
|
22
|
+
const beats = [
|
|
23
|
+
{ t0: 0, t1: 3, layout: "title", hero: "The opening line lands while a ring draws itself behind it.", purpose: "Open on the promise.", onScreen: "A ring drawing on behind the opening line.", motion: "The ring draws on; the line rises in.", sound: "Pad opens with a hit on the first word." },
|
|
24
|
+
{ t0: 3, t1: 6, layout: "split", hero: "Five bars rise in turn beside the line.", purpose: "Show the system.", onScreen: "Five bars rising beside one line.", motion: "Bars rise in stagger.", sound: "Hit on the cut." },
|
|
25
|
+
{ t0: 6, t1: 9, layout: "split-alt", hero: "A dot travels a path from left to right.", purpose: "Show one idea per screen.", onScreen: "A dot crossing a drawn path.", motion: "The dot travels the path.", sound: "A rise into the close." },
|
|
26
|
+
{ t0: 9, t1: 12, layout: "stage", hero: "A grid of squares settles above the closing line.", purpose: "Close on the house.", onScreen: "A settling grid of squares over a closing caption.", motion: "Squares settle in stagger.", sound: "Closing cadence." },
|
|
27
|
+
].map((b, i, all) => ({ ...b, lines: [i], transition: i === all.length - 1 ? "end" : hand(i) }));
|
|
28
|
+
return {
|
|
29
|
+
// A calibration sheet, not a film: the same plain demo in every style, so styles compare.
|
|
30
|
+
request: `render a 12 s${vertical} simple demo film in the ${styleId} style`,
|
|
31
|
+
register: "minimal",
|
|
32
|
+
genre: "other", path: "stage", pathReason: "The demo draws shapes beside its type, which the type engine cannot draw.",
|
|
33
|
+
style: styleId,
|
|
34
|
+
idea: "Four screens, one per layout, so the house style can be judged before a film is made.",
|
|
35
|
+
audience: "Someone choosing a house style for their films.", channel: "A gallery sheet viewed before a film is made, played with sound.",
|
|
36
|
+
format, formatReason: format === "9:16" ? "The gallery shows the vertical layouts." : "The gallery shows the landscape layouts.",
|
|
37
|
+
durationSec: 12, beats,
|
|
38
|
+
story: { expect: "Four screens that each show a new shape.", turn: "The bars of the second screen become the path the dot travels on the third.", beat: 2, payoff: "Everything settles into one grid above the closing line." },
|
|
39
|
+
protagonist: { name: "the signal mark", kind: "object", look: "The style's signal colour as a ring, bars, a dot and one accent square.", acts: [{ beat: 0, action: "Draws itself as a ring." }, { beat: 1, action: "Rises as five bars." }, { beat: 2, action: "Travels the path as a dot." }, { beat: 3, action: "Lands as the accent square of the grid." }] },
|
|
40
|
+
subject: { name: "the style demo", source: "invented", specifics: ["Four screens with a ring, bars, a travelling dot and a grid.", "Each screen uses one layout of the house grid."] },
|
|
41
|
+
visualDevices: [{ kind: "shape", role: "subject", beats: [0, 1, 2, 3] }, { kind: "path", role: "support", beats: [2] }],
|
|
42
|
+
typePlan: { faces: [style.faces.headline.family], hierarchy: "One line per screen in the style's headline or display size.", maxWordsOnScreen: 6 },
|
|
43
|
+
sound: { mode: "generated", plan: "The style's own bed with a hit on the cuts and a closing cadence.", palette: { ...style.sound }, events: [{ t: 1.2, on: "The ring closes.", kind: "tap" }, { t: 4.4, on: "The last bar reaches its height.", kind: "tap" }, { t: 10.6, on: "The accent square lands in the grid.", kind: "chime" }] },
|
|
44
|
+
copy: { source: "invented", lines: COPY },
|
|
45
|
+
inventions: ["the style demo (its four screens)", "all four copy lines"],
|
|
46
|
+
ambition: "Each screen shows one layout cleanly so the style's type, colour and motion read at a glance.",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The demo's drawings: generic shapes in the style's own colours, one per shot.
|
|
51
|
+
const DEMO_DRAW = [
|
|
52
|
+
`(s, svg) => {
|
|
53
|
+
const z = LitStage.zone(s.layout, "subject");
|
|
54
|
+
const r = Math.min(z.w, z.h) * 0.34;
|
|
55
|
+
const len = 2 * Math.PI * r;
|
|
56
|
+
svg.innerHTML = \`<circle cx="\${z.w * 0.5}" cy="\${z.h * 0.5}" r="\${r}" fill="none" stroke="var(--lit-line)" stroke-width="6"/><circle cx="\${z.w * 0.5}" cy="\${z.h * 0.5}" r="\${r}" fill="none" stroke="var(--lit-signal)" stroke-width="6" stroke-dasharray="\${len}" stroke-dashoffset="\${len * (1 - s.enter(0, 1.6))}"/>\`;
|
|
57
|
+
},`,
|
|
58
|
+
`(s, svg) => {
|
|
59
|
+
const z = LitStage.zone(s.layout, "subject");
|
|
60
|
+
const w = z.w / 9;
|
|
61
|
+
svg.innerHTML = [0, 1, 2, 3, 4].map((k) => { const h = z.h * (0.3 + 0.12 * k) * s.enter(0.1 + k * T.stagger.item * 2, T.dur.hero); return \`<rect x="\${w * (1 + k * 1.6)}" y="\${z.h * 0.9 - h}" width="\${w}" height="\${h}" fill="\${k === 4 ? "var(--lit-accent, var(--lit-signal))" : "var(--lit-signal)"}"/>\`; }).join("");
|
|
62
|
+
},`,
|
|
63
|
+
`(s, svg) => {
|
|
64
|
+
const z = LitStage.zone(s.layout, "subject");
|
|
65
|
+
const x = z.w * (0.1 + 0.8 * T.ease.move(LitStage.clamp(s.local / (s.length * 0.8))));
|
|
66
|
+
svg.innerHTML = \`<path d="M\${z.w * 0.1} \${z.h * 0.6} C \${z.w * 0.35} \${z.h * 0.2}, \${z.w * 0.65} \${z.h * 0.9}, \${z.w * 0.9} \${z.h * 0.45}" fill="none" stroke="var(--lit-line)" stroke-width="5"/><circle cx="\${x}" cy="\${z.h * 0.55}" r="\${z.h * 0.06}" fill="var(--lit-signal)"/>\`;
|
|
67
|
+
},`,
|
|
68
|
+
`(s, svg) => {
|
|
69
|
+
const z = LitStage.zone(s.layout, "subject");
|
|
70
|
+
const n = 8;
|
|
71
|
+
const c = Math.min(z.w / (n + 2), z.h / 4);
|
|
72
|
+
const cells = [];
|
|
73
|
+
for (let k = 0; k < n * 2; k++) { const e = s.enter(0.05 * k, T.dur.move); cells.push(\`<rect x="\${c * (1 + (k % n)) + c * 0.1}" y="\${z.h * 0.3 + c * Math.floor(k / n) + (1 - e) * 30}" width="\${c * 0.8}" height="\${c * 0.8}" opacity="\${e}" fill="\${k === 11 ? "var(--lit-accent, var(--lit-signal))" : "var(--lit-muted)"}"/>\`); }
|
|
74
|
+
svg.innerHTML = cells.join("");
|
|
75
|
+
},`,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** The demo page: the scaffold with the demo drawings in its DRAW slots. */
|
|
79
|
+
export function demoPage(treatment) {
|
|
80
|
+
let k = 0;
|
|
81
|
+
return scaffoldHtml(treatment).replace(/ {4}\(s, svg, t\) => \{\},/gu, () => ` ${DEMO_DRAW[k++ % DEMO_DRAW.length]}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Render the gallery: a board round per style, then one sheet of the four boards. */
|
|
85
|
+
export async function stylesCommand({ out, format = "16:9", env = process.env, log = () => {} }) {
|
|
86
|
+
if (!["16:9", "9:16"].includes(format)) return { code: EXIT.USAGE, message: "--format is 16:9 or 9:16" };
|
|
87
|
+
mkdirSync(out, { recursive: true });
|
|
88
|
+
const boards = [];
|
|
89
|
+
const verdicts = [];
|
|
90
|
+
for (const id of STYLE_IDS) {
|
|
91
|
+
const dir = path.join(out, id);
|
|
92
|
+
rmSync(dir, { recursive: true, force: true });
|
|
93
|
+
mkdirSync(path.join(dir, "stage"), { recursive: true });
|
|
94
|
+
const treatment = demoTreatment(id, format);
|
|
95
|
+
writeFileSync(path.join(dir, "treatment.json"), `${JSON.stringify(treatment, null, 2)}\n`);
|
|
96
|
+
writeFileSync(path.join(dir, "stage", "index.html"), demoPage(treatment));
|
|
97
|
+
log(`styles: rendering the ${id} board`);
|
|
98
|
+
const r = await renderStage({ out: dir, round: 1, stillsOnly: true, env, log });
|
|
99
|
+
verdicts.push(`${id}: ${r.code === EXIT.OK ? "board check PASS" : r.message.split(";")[0]}`);
|
|
100
|
+
boards.push(decodePng(readFileSync(path.join(dir, "stills", "board.png"))));
|
|
101
|
+
}
|
|
102
|
+
const rgba = boards.map((b) => (b.channels === 4 ? b.pixels : toRgba(b.pixels, b.channels)));
|
|
103
|
+
const sheet = composeGrid(rgba, boards[0].width, boards[0].height, { cols: 2, tw: 960, th: 540 });
|
|
104
|
+
const file = path.join(out, "styles.png");
|
|
105
|
+
writeFileSync(file, sheet);
|
|
106
|
+
const failed = verdicts.filter((v) => !/PASS$/u.test(v));
|
|
107
|
+
return { code: failed.length ? EXIT.GATE_FAIL_QA : EXIT.OK, message: `STYLES: ${file}\n${verdicts.join("\n")}` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toRgba(pixels, channels) {
|
|
111
|
+
const n = pixels.length / channels;
|
|
112
|
+
const out = Buffer.alloc(n * 4);
|
|
113
|
+
for (let i = 0; i < n; i++) {
|
|
114
|
+
out[i * 4] = pixels[i * channels];
|
|
115
|
+
out[i * 4 + 1] = pixels[i * channels + (channels >= 3 ? 1 : 0)];
|
|
116
|
+
out[i * 4 + 2] = pixels[i * channels + (channels >= 3 ? 2 : 0)];
|
|
117
|
+
out[i * 4 + 3] = 255;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The house file: `motion-house.json` beside a film's output folder, in the user's directory.
|
|
2
|
+
// A full render that passes its gate writes it, or appends the film to it. While it exists,
|
|
3
|
+
// the treatment validator holds every new film in that folder to its style unless the request
|
|
4
|
+
// names another. It is validator input, so the second film inherits the first one's style
|
|
5
|
+
// without anyone having to carry it over.
|
|
6
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { STYLE_IDS } from "./styles.mjs";
|
|
9
|
+
|
|
10
|
+
export const HOUSE_FILE = "motion-house.json";
|
|
11
|
+
export const HOUSE_SCHEMA = "litmuse.motion-house/v1";
|
|
12
|
+
const MAX_FILMS = 50;
|
|
13
|
+
|
|
14
|
+
export const houseFileFor = (out) => path.join(path.dirname(path.resolve(out)), HOUSE_FILE);
|
|
15
|
+
|
|
16
|
+
/** The house record for an output folder, or null when missing or unusable. */
|
|
17
|
+
export function readHouse(out) {
|
|
18
|
+
const file = houseFileFor(out);
|
|
19
|
+
if (!existsSync(file)) return null;
|
|
20
|
+
try {
|
|
21
|
+
const house = JSON.parse(readFileSync(file, "utf8"));
|
|
22
|
+
if (house?.schema !== HOUSE_SCHEMA || !STYLE_IDS.includes(house.style)) return null;
|
|
23
|
+
return house;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Record a passed film. The first film fixes the house style; later films append. A film
|
|
31
|
+
* whose style the request named outright appends without moving the house style.
|
|
32
|
+
*/
|
|
33
|
+
export function recordHouse(out, { style, path: renderPath, format, durationSec, now = new Date() }) {
|
|
34
|
+
const file = houseFileFor(out);
|
|
35
|
+
const house = readHouse(out) ?? { schema: HOUSE_SCHEMA, style, createdAt: now.toISOString(), films: [] };
|
|
36
|
+
const dir = path.basename(path.resolve(out));
|
|
37
|
+
const films = (Array.isArray(house.films) ? house.films : []).filter((f) => f?.dir !== dir);
|
|
38
|
+
films.push({ dir, style, path: renderPath, format, durationSec, at: now.toISOString() });
|
|
39
|
+
const next = { ...house, films: films.slice(-MAX_FILMS) };
|
|
40
|
+
const tmp = `${file}.tmp`;
|
|
41
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
|
|
42
|
+
renameSync(tmp, file);
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// brief): timeline -> pre-flight gate -> stills -> contact sheet -> [--stills-only stops] -> master,
|
|
3
3
|
// preview, poster, reduced-motion still into .run/stage -> determinism re-render and perf in a second
|
|
4
4
|
// Chrome process -> full gate -> promote, or withhold on a flash-audit FAIL.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, createWriteStream } from "node:fs";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { spawnSync } from "node:child_process";
|
|
@@ -23,6 +24,8 @@ import { formatReport, formatPreflightReport } from "./report.mjs";
|
|
|
23
24
|
import { loadTreatment, TreatmentError } from "./treatment.mjs";
|
|
24
25
|
import { viewedCount } from "./look.mjs";
|
|
25
26
|
import { stillsPlan, writeStills } from "./stills.mjs";
|
|
27
|
+
import { addToStills, writeBoard } from "./board.mjs";
|
|
28
|
+
import { recordHouse } from "./house.mjs";
|
|
26
29
|
import { treatmentSha } from "./done.mjs";
|
|
27
30
|
import { auditMuxed } from "./sound.mjs";
|
|
28
31
|
import { soundTrackFor } from "./sound-cli.mjs";
|
|
@@ -183,7 +186,7 @@ export function cutFrames(plan) {
|
|
|
183
186
|
* midpoints, a -6/0/+6 strip at each of the engine's cuts, and a 12-frame sheet. Returns the frames
|
|
184
187
|
* so a full render can rewrite the index with the delivered poster.
|
|
185
188
|
*/
|
|
186
|
-
async function renderStillsSet({ session, plan, treatment, out, round, stillsOnly }) {
|
|
189
|
+
async function renderStillsSet({ session, plan, treatment, out, round, stillsOnly, env = process.env, log = () => {} }) {
|
|
187
190
|
const beats = treatment.beats.map((b) => ({ t0: Math.min(b.t0, plan.durationSec), t1: Math.min(b.t1, plan.durationSec) }));
|
|
188
191
|
const cuts = plan.shots.slice(1).map((sh) => Math.round(sh.start * plan.fps)).filter((c) => c > 0 && c < plan.frameCount);
|
|
189
192
|
const layout = stillsPlan({ beats, fps: plan.fps, frameCount: plan.frameCount, cuts, posterBeat: treatment.posterBeat });
|
|
@@ -194,8 +197,15 @@ async function renderStillsSet({ session, plan, treatment, out, round, stillsOnl
|
|
|
194
197
|
frames.set(n, Buffer.from(bytes));
|
|
195
198
|
});
|
|
196
199
|
}
|
|
197
|
-
|
|
198
|
-
|
|
200
|
+
// Every stills set carries the storyboard (references/studio.md), drawn from its beat stills.
|
|
201
|
+
const write = async (posterFile = null) => {
|
|
202
|
+
const stills = writeStills({ out, plan: layout, frames, width: 1920, height: 1080, fps: plan.fps, frameCount: plan.frameCount, round, stillsOnly, path: "type", posterFile });
|
|
203
|
+
const beatFiles = stills.index.files.filter((f) => f.kind === "beat").sort((a, b) => a.beat - b.beat).map((f) => path.join(out, f.file));
|
|
204
|
+
await writeBoard({ out, treatment, beatFiles, env, log });
|
|
205
|
+
stills.sha256 = addToStills(out, { file: "stills/board.png", kind: "board", frames: layout.beats }, { sha256: (b) => createHash("sha256").update(b).digest("hex") });
|
|
206
|
+
return stills;
|
|
207
|
+
};
|
|
208
|
+
return { layout, frames, stills: await write(), write };
|
|
199
209
|
}
|
|
200
210
|
|
|
201
211
|
function contrastFrames(plan) {
|
|
@@ -444,10 +454,10 @@ export async function make({ briefPath, out, round = 1, stillsOnly = false, view
|
|
|
444
454
|
let poster;
|
|
445
455
|
let still;
|
|
446
456
|
try {
|
|
447
|
-
stillsSet = await renderStillsSet({ session, plan, treatment: prep.treatment, out, round, stillsOnly: stillsOnly || !ffmpeg });
|
|
457
|
+
stillsSet = await renderStillsSet({ session, plan, treatment: prep.treatment, out, round, stillsOnly: stillsOnly || !ffmpeg, env, log });
|
|
448
458
|
log(`stills: ${stillsSet.stills.files.length} files in ${path.join(out, "stills")}`);
|
|
449
459
|
if (stillsOnly) {
|
|
450
|
-
return { code: EXIT.OK, message: `STILLS_ONLY:
|
|
460
|
+
return { code: EXIT.OK, message: `STILLS_ONLY: open the storyboard ${path.join(out, "stills", "board.png")} 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)`, plan, stills: stillsSet.stills, egress: session.egress, renderer: session.renderer };
|
|
451
461
|
}
|
|
452
462
|
if (!ffmpeg) {
|
|
453
463
|
return { code: EXIT.BLOCKED_NO_FFMPEG_FOR_VIDEO, message: `BLOCKED_NO_FFMPEG_FOR_VIDEO: ffmpeg is not on PATH; the stills and contact sheet are in ${path.join(out, "stills")}; install ffmpeg for the MP4, preview and poster`, plan };
|
|
@@ -496,7 +506,8 @@ export async function make({ briefPath, out, round = 1, stillsOnly = false, view
|
|
|
496
506
|
const { gate, manifest } = collectAndEvaluate({ out, previewRecords: preview.records, previewFps: preview.rung?.fps, determinism, perf, coverage: pre.coverage, still, extraRules: soundAudit.rules });
|
|
497
507
|
const promoted = promote({ out, gate });
|
|
498
508
|
const loc = (name) => (promoted.withheld ? path.join(out, "withheld", name) : path.join(out, name));
|
|
499
|
-
if (existsSync(loc("poster.png"))) stillsSet.write(loc("poster.png"));
|
|
509
|
+
if (existsSync(loc("poster.png"))) await stillsSet.write(loc("poster.png"));
|
|
510
|
+
if (gate.pass && !promoted.withheld) recordHouse(out, { style: prep.treatment.style, path: "type", format: prep.treatment.format, durationSec: prep.treatment.durationSec });
|
|
500
511
|
const report = formatReport({
|
|
501
512
|
manifest, reason: plan.reason, gate, round, framesViewed: resolveViewed(out, viewed), software: manifest.softwareRenderer, withheld: promoted.withheld,
|
|
502
513
|
outputs: { film: loc("film.mp4"), preview: preview.file ? loc(path.basename(preview.file)) : "(no preview under 3 MB)", poster: loc("poster.png"), still: loc("reduced-motion.png") },
|
|
@@ -568,7 +579,7 @@ export async function partial({ mode, briefPath, out, env = process.env, log = (
|
|
|
568
579
|
const session = await openSession({ plan: prep.plan, fontKeys: prep.fontKeys, runDir: prep.runDir, env, log });
|
|
569
580
|
try {
|
|
570
581
|
if (mode === "stills" || mode === "sheet") {
|
|
571
|
-
const set = await renderStillsSet({ session, plan: prep.plan, treatment: prep.treatment, out, round: 1, stillsOnly: true });
|
|
582
|
+
const set = await renderStillsSet({ session, plan: prep.plan, treatment: prep.treatment, out, round: 1, stillsOnly: true, env, log });
|
|
572
583
|
return { code: EXIT.OK, message: `STILLS: ${set.stills.files.length} files in ${path.join(out, "stills")} (the sheet is stills/sheet.png)` };
|
|
573
584
|
}
|
|
574
585
|
const perf = await perfCheck({ session, plan: prep.plan });
|