@anvpx/motion-cli 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +121 -2
- package/dist/analyze/command.d.ts +2 -0
- package/dist/analyze/command.js +190 -0
- package/dist/approval/approval.d.ts +32 -0
- package/dist/approval/approval.js +141 -0
- package/dist/approval/command.d.ts +2 -0
- package/dist/approval/command.js +255 -0
- package/dist/approval/gate.d.ts +7 -0
- package/dist/approval/gate.js +23 -0
- package/dist/approval/hash.d.ts +71 -0
- package/dist/approval/hash.js +451 -0
- package/dist/args.d.ts +30 -0
- package/dist/args.js +53 -0
- package/dist/assets/command.d.ts +2 -0
- package/dist/assets/command.js +165 -0
- package/dist/captions/command.d.ts +2 -0
- package/dist/captions/command.js +341 -0
- package/dist/captions/cues.d.ts +10 -0
- package/dist/captions/cues.js +61 -0
- package/dist/catalog/command.d.ts +2 -0
- package/dist/catalog/command.js +60 -0
- package/dist/catalog/format.d.ts +13 -0
- package/dist/catalog/format.js +54 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +35 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +59 -0
- package/dist/compare/command.d.ts +4 -0
- package/dist/compare/command.js +144 -0
- package/dist/contact/argv.d.ts +24 -0
- package/dist/contact/argv.js +42 -0
- package/dist/contact/command.d.ts +2 -0
- package/dist/contact/command.js +79 -0
- package/dist/doctor/checks.d.ts +25 -0
- package/dist/doctor/checks.js +571 -0
- package/dist/doctor/command.d.ts +4 -0
- package/dist/doctor/command.js +88 -0
- package/dist/doctor/format.d.ts +7 -0
- package/dist/doctor/format.js +41 -0
- package/dist/doctor/heal.d.ts +14 -0
- package/dist/doctor/heal.js +71 -0
- package/dist/doctor/pins.d.ts +8 -0
- package/dist/doctor/pins.js +28 -0
- package/dist/doctor/skills.d.ts +15 -0
- package/dist/doctor/skills.js +58 -0
- package/dist/doctor/types.d.ts +36 -0
- package/dist/doctor/types.js +1 -0
- package/dist/doctor/vendored.d.ts +15 -0
- package/dist/doctor/vendored.js +90 -0
- package/dist/doctor/version.d.ts +8 -0
- package/dist/doctor/version.js +11 -0
- package/dist/exit-codes.d.ts +16 -0
- package/dist/exit-codes.js +15 -0
- package/dist/fit/command.d.ts +5 -0
- package/dist/fit/command.js +154 -0
- package/dist/fit/solve.d.ts +21 -0
- package/dist/fit/solve.js +80 -0
- package/dist/fit/target.d.ts +18 -0
- package/dist/fit/target.js +47 -0
- package/dist/generate3d/command.d.ts +3 -0
- package/dist/generate3d/command.js +38 -0
- package/dist/generate3d/run.d.ts +39 -0
- package/dist/generate3d/run.js +77 -0
- package/dist/icon/command.d.ts +2 -0
- package/dist/icon/command.js +113 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/install-mk.d.ts +31 -0
- package/dist/install-mk.js +168 -0
- package/dist/integration/preview.integration.d.ts +1 -0
- package/dist/integration/preview.integration.js +53 -0
- package/dist/integration/smoke.integration.d.ts +1 -0
- package/dist/integration/smoke.integration.js +91 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +4 -0
- package/dist/node-io.d.ts +14 -0
- package/dist/node-io.js +152 -0
- package/dist/optimize/command.d.ts +12 -0
- package/dist/optimize/command.js +110 -0
- package/dist/packs/command.d.ts +2 -0
- package/dist/packs/command.js +109 -0
- package/dist/patch/apply.d.ts +36 -0
- package/dist/patch/apply.js +186 -0
- package/dist/patch/command.d.ts +2 -0
- package/dist/patch/command.js +92 -0
- package/dist/plugin/install.d.ts +40 -0
- package/dist/plugin/install.js +170 -0
- package/dist/preview/command.d.ts +10 -0
- package/dist/preview/command.js +758 -0
- package/dist/preview/manifest.d.ts +94 -0
- package/dist/preview/manifest.js +19 -0
- package/dist/preview/pack.d.ts +24 -0
- package/dist/preview/pack.js +53 -0
- package/dist/preview/plan.d.ts +58 -0
- package/dist/preview/plan.js +160 -0
- package/dist/preview/provenance.d.ts +23 -0
- package/dist/preview/provenance.js +38 -0
- package/dist/publish/command.d.ts +2 -0
- package/dist/publish/command.js +70 -0
- package/dist/publish/player.d.ts +25 -0
- package/dist/publish/player.js +108 -0
- package/dist/publish/static.d.ts +16 -0
- package/dist/publish/static.js +78 -0
- package/dist/qc.d.ts +2 -0
- package/dist/qc.js +192 -0
- package/dist/scene/author.d.ts +16 -0
- package/dist/scene/author.js +260 -0
- package/dist/scene/command.d.ts +2 -0
- package/dist/scene/command.js +110 -0
- package/dist/scene/ops.d.ts +23 -0
- package/dist/scene/ops.js +151 -0
- package/dist/state/command.d.ts +2 -0
- package/dist/state/command.js +128 -0
- package/dist/state/evidence.d.ts +71 -0
- package/dist/state/evidence.js +243 -0
- package/dist/storyboard/check.d.ts +21 -0
- package/dist/storyboard/check.js +39 -0
- package/dist/storyboard/containment.d.ts +24 -0
- package/dist/storyboard/containment.js +43 -0
- package/dist/storyboard/custom.d.ts +15 -0
- package/dist/storyboard/custom.js +133 -0
- package/dist/storyboard/edit.d.ts +19 -0
- package/dist/storyboard/edit.js +39 -0
- package/dist/storyboard/load.d.ts +11 -0
- package/dist/storyboard/load.js +52 -0
- package/dist/storyboard/report.d.ts +5 -0
- package/dist/storyboard/report.js +8 -0
- package/dist/template/coerce.d.ts +6 -0
- package/dist/template/coerce.js +44 -0
- package/dist/template/command.d.ts +5 -0
- package/dist/template/command.js +163 -0
- package/dist/template/env.d.ts +34 -0
- package/dist/template/env.js +38 -0
- package/dist/template/fork.d.ts +8 -0
- package/dist/template/fork.js +156 -0
- package/dist/template/format.d.ts +21 -0
- package/dist/template/format.js +161 -0
- package/dist/template/list.d.ts +15 -0
- package/dist/template/list.js +116 -0
- package/dist/template/new.d.ts +6 -0
- package/dist/template/new.js +302 -0
- package/dist/testing/env.d.ts +8 -0
- package/dist/testing/env.js +19 -0
- package/dist/testing/fakes.d.ts +33 -0
- package/dist/testing/fakes.js +95 -0
- package/dist/testing/fixtures.d.ts +8 -0
- package/dist/testing/fixtures.js +6 -0
- package/dist/testing/probe.d.ts +15 -0
- package/dist/testing/probe.js +20 -0
- package/dist/testing/project.d.ts +22 -0
- package/dist/testing/project.js +26 -0
- package/dist/tooling/session.d.ts +40 -0
- package/dist/tooling/session.js +106 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/dist/verify/audio-loudness.d.ts +46 -0
- package/dist/verify/audio-loudness.js +186 -0
- package/dist/verify/command.d.ts +2 -0
- package/dist/verify/command.js +161 -0
- package/dist/verify/determinism.d.ts +30 -0
- package/dist/verify/determinism.js +59 -0
- package/dist/verify/expectation.d.ts +15 -0
- package/dist/verify/expectation.js +19 -0
- package/dist/verify/media-events.d.ts +61 -0
- package/dist/verify/media-events.js +174 -0
- package/dist/verify/platform.d.ts +33 -0
- package/dist/verify/platform.js +59 -0
- package/dist/verify/probe.d.ts +14 -0
- package/dist/verify/probe.js +23 -0
- package/dist/verify/report.d.ts +15 -0
- package/dist/verify/report.js +45 -0
- package/dist/verify/run.d.ts +27 -0
- package/dist/verify/run.js +53 -0
- package/dist/voice/command.d.ts +3 -0
- package/dist/voice/command.js +49 -0
- package/dist/voice/manifest.d.ts +33 -0
- package/dist/voice/manifest.js +69 -0
- package/dist/voice/run.d.ts +21 -0
- package/dist/voice/run.js +181 -0
- package/dist/workflow/build.d.ts +2 -0
- package/dist/workflow/build.js +25 -0
- package/dist/workflow/draft.d.ts +9 -0
- package/dist/workflow/draft.js +201 -0
- package/dist/workflow/finish.d.ts +23 -0
- package/dist/workflow/finish.js +28 -0
- package/dist/workflow/project.d.ts +73 -0
- package/dist/workflow/project.js +198 -0
- package/dist/workflow/provenance.d.ts +18 -0
- package/dist/workflow/provenance.js +36 -0
- package/dist/workflow/render.d.ts +31 -0
- package/dist/workflow/render.js +705 -0
- package/dist/workflow/still.d.ts +5 -0
- package/dist/workflow/still.js +62 -0
- package/dist/workflow/validate.d.ts +2 -0
- package/dist/workflow/validate.js +49 -0
- package/package.json +38 -3
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { validateMusicBed } from "@anvpx/motion-audio";
|
|
4
|
+
import { AUDIO_DIR_NAME, CUSTOM_SCENE_TYPE, clipsFromManifest, frozenClips, frozenCopyProblems, frozenMusic, isContiguous, mixAtDeliveryLevel, parseAudioManifest, readRelativeClosure, resolveSceneDir, resolveTimeline, validateStoryboardFull, } from "@anvpx/motion-build";
|
|
5
|
+
import { buildApprovalInputManifest, hashApprovalInput, storyboardHash, } from "../approval/hash.js";
|
|
6
|
+
import { parseFlags, requireStoryboard, textFlag, usageError } from "../args.js";
|
|
7
|
+
import { EXIT } from "../exit-codes.js";
|
|
8
|
+
import { checkStoryboard, rootError } from "../storyboard/check.js";
|
|
9
|
+
import { sceneContainment } from "../storyboard/containment.js";
|
|
10
|
+
import { readJsonFile } from "../storyboard/load.js";
|
|
11
|
+
import { formatFindings } from "../storyboard/report.js";
|
|
12
|
+
import { defaultEnv } from "../template/env.js";
|
|
13
|
+
import { DRAFT_FPS } from "../verify/expectation.js";
|
|
14
|
+
import { platformLookup } from "../verify/platform.js";
|
|
15
|
+
import { makeDraftCommand } from "../workflow/draft.js";
|
|
16
|
+
import { stillCommand } from "../workflow/still.js";
|
|
17
|
+
import { CHECKLIST, defaultOut, PREVIEW_JSON, packDir, } from "./manifest.js";
|
|
18
|
+
import { clearPngs, contactSheet, listPngs, nameStills, } from "./pack.js";
|
|
19
|
+
import { CLIP_MAX_SEC, CLIP_SCENE_SEC, capClip, DARK_COUNT, distinctPicks, sceneInfos, shouldRunClip, stillCandidates, stillPlan, } from "./plan.js";
|
|
20
|
+
import { readProvenance, resolvePair } from "./provenance.js";
|
|
21
|
+
const USAGE = `mk preview <storyboard.json> [--stills 6 | --every] [--budget-sec 90] [--out <dir>] [--assets <dir>] [--ledger <assets.json>] [--audio <manifest>] [--music <bed.wav>] [--music-volume <n>] [--music-license <file>] [--json]
|
|
22
|
+
|
|
23
|
+
Build the proposal pack the user approves, into <out>/<name>.preview, <name> = the storyboard file name without .json (default <out> = <storyboard dir>/out):
|
|
24
|
+
preview.json hash, template, frame, scenes, stills, lint, estimate, approval checklist
|
|
25
|
+
contact.png one image tiling the stills (look at this, show it to the user)
|
|
26
|
+
stills/NN-<scene>-<theme>.png
|
|
27
|
+
clip.mp4 each scene capped at ${CLIP_SCENE_SEC} s, whole clip <= ${CLIP_MAX_SEC} s, 960 px, ${DRAFT_FPS} fps
|
|
28
|
+
Stills: first scene, last scene, each scene type once (an overlay off screen at its scene's still counts as a scene), then an even spread; ${DARK_COUNT} extra stills in the paired theme when
|
|
29
|
+
the template has one. Order of work: stills, contact sheet, clip. If --budget-sec runs out the clip is skipped
|
|
30
|
+
(preview.json says so, exit 0); stills are never skipped. Validation or lint errors exit 2.
|
|
31
|
+
--stills n base stills (1-24, default 6)
|
|
32
|
+
--every one still per base scene at 70% of it (instead of --stills); overlays show where they cover that moment
|
|
33
|
+
--budget-sec n time budget (default 90)
|
|
34
|
+
--audio <manifest>
|
|
35
|
+
voice TimingManifest: mix each scene's wav (the manifest's sibling
|
|
36
|
+
audio/ dir, <id>.wav) under clip.mp4 with the same mix args as
|
|
37
|
+
the final render; absent means a silent clip (audible:false).
|
|
38
|
+
The audiovisual binding (manifest + wavs + mix) enters
|
|
39
|
+
inputManifestSha256.
|
|
40
|
+
--music <bed.wav> opt-in CC0 bed ducked under narration (needs --audio);
|
|
41
|
+
same buildMusicMixArgs as final; volume via --music-volume,
|
|
42
|
+
license receipt via --music-license. Bed + volume + licenseEvidenceSha256
|
|
43
|
+
enter inputManifestSha256.
|
|
44
|
+
--music-volume <n> bed level within (0, 1] (default 0.5)
|
|
45
|
+
--music-license <file> license-evidence file hashed into the receipt
|
|
46
|
+
--json print preview.json instead of the summary line
|
|
47
|
+
Scratch builds live in <out>/<name>.preview-work. Then show the pack and, after an explicit yes: mk approve <storyboard.json>
|
|
48
|
+
`;
|
|
49
|
+
const FLAGS = {
|
|
50
|
+
stills: { type: "string" },
|
|
51
|
+
every: { type: "boolean" },
|
|
52
|
+
"budget-sec": { type: "string" },
|
|
53
|
+
out: { type: "string" },
|
|
54
|
+
assets: { type: "string" },
|
|
55
|
+
ledger: { type: "string" },
|
|
56
|
+
audio: { type: "string" },
|
|
57
|
+
music: { type: "string" },
|
|
58
|
+
"music-volume": { type: "string" },
|
|
59
|
+
"music-license": { type: "string" },
|
|
60
|
+
json: { type: "boolean" },
|
|
61
|
+
};
|
|
62
|
+
const DEFAULT_STILLS = 6;
|
|
63
|
+
const MAX_STILLS = 24;
|
|
64
|
+
const DEFAULT_BUDGET_SEC = 90;
|
|
65
|
+
const MAX_LINT_ITEMS = 10;
|
|
66
|
+
/** The clip is a derived trailer; authored-work lint (reading time, hold) does not apply to it. */
|
|
67
|
+
const clipDraft = makeDraftCommand({ lint: false });
|
|
68
|
+
const ESTIMATE_NOTE = "estimate: measured draft ms per frame x total frames at the storyboard fps; delivery quality is slower";
|
|
69
|
+
const MS = 1000;
|
|
70
|
+
/** A copy of `io` whose stdout/stderr are captured, so wrapped commands stay quiet. */
|
|
71
|
+
const captured = (io) => {
|
|
72
|
+
const out = [];
|
|
73
|
+
const err = [];
|
|
74
|
+
return { io: { ...io, stdout: (t) => out.push(t), stderr: (t) => err.push(t) }, out, err };
|
|
75
|
+
};
|
|
76
|
+
/** `--stills` as a count (1-24); an exit code on a bad value or a clash with `--every`. */
|
|
77
|
+
function stillCount(flags, every, io) {
|
|
78
|
+
const given = textFlag(flags, "stills");
|
|
79
|
+
if (every && given !== undefined) {
|
|
80
|
+
return { code: usageError("preview", USAGE, "--every and --stills conflict", io).code };
|
|
81
|
+
}
|
|
82
|
+
const count = Number(given ?? DEFAULT_STILLS);
|
|
83
|
+
if (Number.isInteger(count) && count >= 1 && count <= MAX_STILLS)
|
|
84
|
+
return { count };
|
|
85
|
+
return {
|
|
86
|
+
code: usageError("preview", USAGE, `--stills must be an integer 1-${MAX_STILLS}`, io).code,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function readOptions(flags, storyboard, io) {
|
|
90
|
+
const every = flags.every === true;
|
|
91
|
+
const counted = stillCount(flags, every, io);
|
|
92
|
+
if ("code" in counted)
|
|
93
|
+
return counted.code;
|
|
94
|
+
const stills = counted.count;
|
|
95
|
+
const budgetSec = Number(textFlag(flags, "budget-sec") ?? DEFAULT_BUDGET_SEC);
|
|
96
|
+
if (!Number.isFinite(budgetSec) || budgetSec <= 0) {
|
|
97
|
+
return usageError("preview", USAGE, "--budget-sec must be a positive number", io).code;
|
|
98
|
+
}
|
|
99
|
+
const assets = textFlag(flags, "assets");
|
|
100
|
+
const ledger = textFlag(flags, "ledger");
|
|
101
|
+
const audio = textFlag(flags, "audio");
|
|
102
|
+
const music = textFlag(flags, "music");
|
|
103
|
+
const musicVolumeRaw = textFlag(flags, "music-volume");
|
|
104
|
+
const musicLicense = textFlag(flags, "music-license");
|
|
105
|
+
if (musicVolumeRaw !== undefined) {
|
|
106
|
+
const n = Number(musicVolumeRaw);
|
|
107
|
+
if (!Number.isFinite(n) || n <= 0 || n > 1) {
|
|
108
|
+
return usageError("preview", USAGE, "--music-volume must be within (0, 1]", io).code;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (music !== undefined && audio === undefined) {
|
|
112
|
+
return usageError("preview", USAGE, "--music needs --audio", io).code;
|
|
113
|
+
}
|
|
114
|
+
if ((musicVolumeRaw !== undefined || musicLicense !== undefined) && music === undefined) {
|
|
115
|
+
return usageError("preview", USAGE, "--music-volume/--music-license need --music", io).code;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
stills,
|
|
119
|
+
every,
|
|
120
|
+
budgetSec,
|
|
121
|
+
out: resolve(textFlag(flags, "out") ?? defaultOut(storyboard)),
|
|
122
|
+
extra: [
|
|
123
|
+
...(assets === undefined ? [] : ["--assets", assets]),
|
|
124
|
+
...(ledger === undefined ? [] : ["--ledger", ledger]),
|
|
125
|
+
],
|
|
126
|
+
...(assets === undefined ? {} : { assetsDir: resolve(assets) }),
|
|
127
|
+
...(ledger === undefined ? {} : { ledgerPath: ledger }),
|
|
128
|
+
...(audio === undefined ? {} : { audioManifest: audio }),
|
|
129
|
+
...(music === undefined ? {} : { musicPath: music }),
|
|
130
|
+
...(musicVolumeRaw === undefined ? {} : { musicVolume: Number(musicVolumeRaw) }),
|
|
131
|
+
...(musicLicense === undefined ? {} : { musicLicensePath: musicLicense }),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Copies every `<dir>/themes/*.json` of the project into a scratch project. */
|
|
135
|
+
async function copyThemes(host, from, to) {
|
|
136
|
+
const dir = join(from, "themes");
|
|
137
|
+
const names = (await host.listDir(dir).catch(() => [])).filter((n) => n.endsWith(".json"));
|
|
138
|
+
for (const name of names)
|
|
139
|
+
await host.copyFile(join(dir, name), join(to, "themes", name));
|
|
140
|
+
}
|
|
141
|
+
const CUSTOM_FILES = ["scene.js", "fragment.html", "style.css"];
|
|
142
|
+
/**
|
|
143
|
+
* Copies authored scene directories beside a derived storyboard, so a relative
|
|
144
|
+
* `props.dir` still resolves after the storyboard is rewritten into a scratch dir.
|
|
145
|
+
*/
|
|
146
|
+
export async function copyCustomScenes(host, board, origin, targetDir) {
|
|
147
|
+
const base = dirname(resolve(origin));
|
|
148
|
+
for (const scene of board.scenes) {
|
|
149
|
+
if (scene.type !== CUSTOM_SCENE_TYPE)
|
|
150
|
+
continue;
|
|
151
|
+
const dir = scene.props.dir;
|
|
152
|
+
if (typeof dir !== "string")
|
|
153
|
+
continue;
|
|
154
|
+
const resolved = resolveSceneDir(base, dir);
|
|
155
|
+
if (!resolved.ok)
|
|
156
|
+
continue;
|
|
157
|
+
// Never copy bytes that resolve outside the project through a link.
|
|
158
|
+
const containment = sceneContainment(base, resolved.dir, CUSTOM_FILES);
|
|
159
|
+
if (!containment.dirInside || containment.escapedFiles.length > 0) {
|
|
160
|
+
throw new Error(`custom scene "${dir}" resolves outside the storyboard directory (symlink)`);
|
|
161
|
+
}
|
|
162
|
+
for (const file of CUSTOM_FILES) {
|
|
163
|
+
const from = join(resolved.dir, file);
|
|
164
|
+
if (await host.exists(from))
|
|
165
|
+
await host.copyFile(from, join(targetDir, dir, file));
|
|
166
|
+
}
|
|
167
|
+
// Relative helpers emitted beside scene.js must resolve in the scratch copy too.
|
|
168
|
+
const jsPath = join(resolved.dir, "scene.js");
|
|
169
|
+
if (await host.exists(jsPath)) {
|
|
170
|
+
const closure = await readRelativeClosure(await host.readText(jsPath), async (helper) => {
|
|
171
|
+
const check = sceneContainment(base, resolved.dir, [helper]);
|
|
172
|
+
if (!check.dirInside || check.escapedFiles.length > 0) {
|
|
173
|
+
throw new Error(`custom scene helper "${helper}" resolves outside the scene (symlink)`);
|
|
174
|
+
}
|
|
175
|
+
const full = join(resolved.dir, helper);
|
|
176
|
+
return (await host.exists(full)) ? host.readText(full) : undefined;
|
|
177
|
+
});
|
|
178
|
+
for (const helper of closure.files) {
|
|
179
|
+
await host.copyFile(join(resolved.dir, helper.path), join(targetDir, dir, helper.path));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Writes a derived storyboard (+ the project's themes, + extra theme files) into its own scratch dir. */
|
|
185
|
+
async function scratch(io, work, name, board, projectDir, extraThemes = [], origin) {
|
|
186
|
+
const dir = join(work, name);
|
|
187
|
+
await io.host.writeTextAtomic(join(dir, "storyboard.json"), `${JSON.stringify(board, null, 2)}\n`);
|
|
188
|
+
if (origin !== undefined)
|
|
189
|
+
await copyCustomScenes(io.host, board, origin, dir);
|
|
190
|
+
await copyThemes(io.host, projectDir, dir);
|
|
191
|
+
for (const theme of extraThemes) {
|
|
192
|
+
await io.host.writeTextAtomic(join(dir, "themes", `${theme.id}.json`), theme.text);
|
|
193
|
+
}
|
|
194
|
+
return { storyboard: join(dir, "storyboard.json"), project: join(dir, "project") };
|
|
195
|
+
}
|
|
196
|
+
/** Runs `mk still` quietly; returns the PNGs it wrote (ordered like the `--at` list) or an exit code. */
|
|
197
|
+
async function capture(io, scratchDir, picks, extra, clock, spent) {
|
|
198
|
+
const stillsDir = join(scratchDir.project, "stills");
|
|
199
|
+
await clearPngs(io.host, stillsDir);
|
|
200
|
+
const quiet = captured(io);
|
|
201
|
+
const began = clock();
|
|
202
|
+
const at = picks.map((p) => p.t).join(",");
|
|
203
|
+
const code = await stillCommand.run([scratchDir.storyboard, "--at", at, "--out", scratchDir.project, ...extra], quiet.io);
|
|
204
|
+
spent.ms += clock() - began;
|
|
205
|
+
if (code !== 0) {
|
|
206
|
+
io.stderr(quiet.err.join(""));
|
|
207
|
+
return code;
|
|
208
|
+
}
|
|
209
|
+
const files = await listPngs(io.host, stillsDir);
|
|
210
|
+
if (files.length !== picks.length) {
|
|
211
|
+
io.stderr(`mk preview: expected ${picks.length} stills, hyperframes wrote ${files.length}\n`);
|
|
212
|
+
return EXIT.ERROR;
|
|
213
|
+
}
|
|
214
|
+
return files;
|
|
215
|
+
}
|
|
216
|
+
const stripNarration = (board) => ({
|
|
217
|
+
...board,
|
|
218
|
+
scenes: board.scenes.map((entry) => {
|
|
219
|
+
const { narration: _narration, ...scene } = entry;
|
|
220
|
+
return scene;
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
/** Voice total for the full board, or undefined when the manifest cannot resolve yet. */
|
|
224
|
+
async function voiceTotal(io, board, audioManifest) {
|
|
225
|
+
try {
|
|
226
|
+
const parsed = parseAudioManifest(JSON.parse(await io.host.readText(audioManifest)));
|
|
227
|
+
if (!parsed.ok)
|
|
228
|
+
return undefined;
|
|
229
|
+
const timeline = resolveTimeline(board, parsed.manifest);
|
|
230
|
+
return timeline.ok ? timeline.value.total : undefined;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Unreadable/invalid manifests fail closed in clipDraft below.
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Full-length audible clip: voice-timed build so clip video total equals narration total. */
|
|
238
|
+
async function makeAudibleClip(io, board, projectDir, work, pack, extra, clock, audioManifest, origin) {
|
|
239
|
+
const clip = await scratch(io, work, "clip", stripNarration(board), projectDir, [], origin);
|
|
240
|
+
const quiet = captured(io);
|
|
241
|
+
const began = clock();
|
|
242
|
+
const code = await clipDraft.run([clip.storyboard, "--out", clip.project, ...extra, "--audio", audioManifest], quiet.io);
|
|
243
|
+
const ms = clock() - began;
|
|
244
|
+
if (code !== 0) {
|
|
245
|
+
const why = quiet.err.join("").trim().split("\n")[0] ?? "";
|
|
246
|
+
return {
|
|
247
|
+
result: {
|
|
248
|
+
status: "skipped",
|
|
249
|
+
reason: `draft failed (exit ${code}${why === "" ? "" : `: ${why}`})`,
|
|
250
|
+
},
|
|
251
|
+
msPerFrame: undefined,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
await io.host.copyFile(join(clip.project, "draft.mp4"), join(pack, "clip.mp4"));
|
|
255
|
+
const voiceSec = await voiceTotal(io, board, audioManifest);
|
|
256
|
+
const fallback = resolveTimeline(board);
|
|
257
|
+
const sec = voiceSec ?? (fallback.ok ? fallback.value.total : 0);
|
|
258
|
+
return {
|
|
259
|
+
result: { status: "ok", file: "clip.mp4", sec },
|
|
260
|
+
msPerFrame: ms / Math.max(1, Math.round(sec * DRAFT_FPS)),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
async function makeClip(io, board, projectDir, work, pack, extra, clock, audioManifest, origin) {
|
|
264
|
+
// With --audio the clip is the full audible review (not a capped trailer).
|
|
265
|
+
// Without --audio keep the capped trailer: byte-identical to today.
|
|
266
|
+
if (audioManifest !== undefined) {
|
|
267
|
+
return makeAudibleClip(io, board, projectDir, work, pack, extra, clock, audioManifest, origin);
|
|
268
|
+
}
|
|
269
|
+
const plan = capClip(board);
|
|
270
|
+
const clip = await scratch(io, work, "clip", plan.storyboard, projectDir, [], origin);
|
|
271
|
+
const quiet = captured(io);
|
|
272
|
+
const began = clock();
|
|
273
|
+
const code = await clipDraft.run([clip.storyboard, "--out", clip.project, ...extra], quiet.io);
|
|
274
|
+
const ms = clock() - began;
|
|
275
|
+
if (code !== 0) {
|
|
276
|
+
const why = quiet.err.join("").trim().split("\n")[0] ?? "";
|
|
277
|
+
const reason = `draft failed (exit ${code}${why === "" ? "" : `: ${why}`})`;
|
|
278
|
+
return { result: { status: "skipped", reason }, msPerFrame: undefined };
|
|
279
|
+
}
|
|
280
|
+
await io.host.copyFile(join(clip.project, "draft.mp4"), join(pack, "clip.mp4"));
|
|
281
|
+
const frames = Math.max(1, Math.round(plan.totalSec * DRAFT_FPS));
|
|
282
|
+
return {
|
|
283
|
+
result: { status: "ok", file: "clip.mp4", sec: plan.totalSec },
|
|
284
|
+
msPerFrame: ms / frames,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const summary = (m, pack, storyboard, overlays) => {
|
|
288
|
+
const paired = m.stills.filter((s) => s.theme !== m.theme).length;
|
|
289
|
+
const from = m.template === null
|
|
290
|
+
? "free storyboard"
|
|
291
|
+
: `template ${m.template} ${m.blueprint} style ${m.style}`;
|
|
292
|
+
const clip = m.clip.status === "ok" ? `clip ${m.clip.sec}s` : "clip skipped";
|
|
293
|
+
const est = m.estFullRenderSec === null
|
|
294
|
+
? "est render n/a"
|
|
295
|
+
: `est render ~${Math.max(1, Math.round(m.estFullRenderSec / 60))}m`;
|
|
296
|
+
return (`preview ok | ${from} | ${m.scenes.length} scenes + ${overlays} overlays ${m.totalSec}s ${m.frame.width}x${m.frame.height} | pack ${pack} | ` +
|
|
297
|
+
`contact.png ${m.stills.length - paired} stills (+${paired} paired) ${clip} | ${est} (estimate) | ` +
|
|
298
|
+
`lint ${m.lint.errors} err ${m.lint.warnings} warn | approve: mk approve ${storyboard}\n`);
|
|
299
|
+
};
|
|
300
|
+
const round = (value) => Math.round(value * 100) / 100;
|
|
301
|
+
/** Reads, validates and lints the storyboard; findings go to stderr with exit 2. */
|
|
302
|
+
async function loadBoard(storyboard, io) {
|
|
303
|
+
const loaded = await readJsonFile(storyboard, io.host);
|
|
304
|
+
if (!loaded.ok && loaded.kind === "io") {
|
|
305
|
+
io.stderr(`mk preview: ${loaded.message}\n`);
|
|
306
|
+
return EXIT.USAGE;
|
|
307
|
+
}
|
|
308
|
+
const report = loaded.ok
|
|
309
|
+
? checkStoryboard(loaded.value, { platforms: await platformLookup(io.host) })
|
|
310
|
+
: { errors: [rootError(loaded.message)], warnings: [] };
|
|
311
|
+
const valid = loaded.ok ? validateStoryboardFull(loaded.value) : undefined;
|
|
312
|
+
if (report.errors.length > 0 || valid === undefined || !valid.ok) {
|
|
313
|
+
io.stderr(formatFindings([...report.errors, ...report.warnings]));
|
|
314
|
+
return EXIT.USAGE;
|
|
315
|
+
}
|
|
316
|
+
const infos = sceneInfos(valid.value);
|
|
317
|
+
if (infos === undefined)
|
|
318
|
+
return EXIT.USAGE;
|
|
319
|
+
const overlays = valid.value.scenes.filter((s) => s.layer === "overlay").length;
|
|
320
|
+
return {
|
|
321
|
+
board: valid.value,
|
|
322
|
+
report,
|
|
323
|
+
infos,
|
|
324
|
+
candidates: stillCandidates(valid.value),
|
|
325
|
+
overlays,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/** Stills in the storyboard's theme, plus the paired theme for the most distinct scenes. */
|
|
329
|
+
async function shootStills(ctx, pair, spent) {
|
|
330
|
+
const { io, board, projectDir, options, work, clock } = ctx;
|
|
331
|
+
const theme = board.meta.theme;
|
|
332
|
+
const picks = options.every
|
|
333
|
+
? stillPlan(ctx.infos, ctx.infos.length)
|
|
334
|
+
: stillPlan(ctx.candidates, options.stills);
|
|
335
|
+
const light = await scratch(io, work, "light", board, projectDir, [], ctx.storyboard);
|
|
336
|
+
const files = await capture(io, light, picks, options.extra, clock, spent);
|
|
337
|
+
if (typeof files === "number")
|
|
338
|
+
return files;
|
|
339
|
+
const taken = picks.map((pick, i) => ({ pick, theme, source: files[i] ?? "" }));
|
|
340
|
+
if (pair !== undefined) {
|
|
341
|
+
const extra = distinctPicks(picks, DARK_COUNT);
|
|
342
|
+
const paired = { ...board, meta: { ...board.meta, theme: pair.id } };
|
|
343
|
+
const scratchDir = await scratch(io, work, "paired", paired, projectDir, [pair], ctx.storyboard);
|
|
344
|
+
const darkFiles = await capture(io, scratchDir, extra, options.extra, clock, spent);
|
|
345
|
+
if (typeof darkFiles === "number")
|
|
346
|
+
return darkFiles;
|
|
347
|
+
for (const [i, pick] of extra.entries()) {
|
|
348
|
+
taken.push({ pick, theme: pair.id, source: darkFiles[i] ?? "" });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return nameStills(taken, theme);
|
|
352
|
+
}
|
|
353
|
+
/** Copies the named stills into the pack and tiles them into contact.png. */
|
|
354
|
+
async function packStills(ctx, named) {
|
|
355
|
+
const { io, pack, work } = ctx;
|
|
356
|
+
const dir = join(pack, "stills");
|
|
357
|
+
await clearPngs(io.host, dir);
|
|
358
|
+
for (const still of named)
|
|
359
|
+
await io.host.copyFile(still.source, join(dir, still.name));
|
|
360
|
+
const files = named.map((s) => join(dir, s.name));
|
|
361
|
+
return contactSheet(io, files, join(work, "contact.txt"), join(pack, "contact.png"));
|
|
362
|
+
}
|
|
363
|
+
/** The clip, unless the stills already used the budget (then a labelled skip). */
|
|
364
|
+
async function clipOrSkip(ctx, stillsMs) {
|
|
365
|
+
const { io, pack, options, clock, began } = ctx;
|
|
366
|
+
await io.host.remove(join(pack, "clip.mp4"));
|
|
367
|
+
const elapsed = clock() - began;
|
|
368
|
+
if (shouldRunClip(elapsed, stillsMs, options.budgetSec * MS)) {
|
|
369
|
+
return makeClip(io, ctx.board, ctx.projectDir, ctx.work, pack, options.extra, clock, options.audioManifest, ctx.storyboard);
|
|
370
|
+
}
|
|
371
|
+
const used = `${Math.round(elapsed / MS)} s used of ${options.budgetSec} s`;
|
|
372
|
+
return {
|
|
373
|
+
result: { status: "skipped", reason: `budget: ${used}; raise --budget-sec` },
|
|
374
|
+
msPerFrame: undefined,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
const stillRow = (s) => ({
|
|
378
|
+
file: `stills/${s.name}`,
|
|
379
|
+
scene: s.pick.id,
|
|
380
|
+
t: s.pick.t,
|
|
381
|
+
theme: s.theme,
|
|
382
|
+
});
|
|
383
|
+
/** The invocation inputs recorded in the pack; absolute so a later `mk approve` matches exactly. */
|
|
384
|
+
const inputsOf = (options) => ({
|
|
385
|
+
assetsDir: options.assetsDir ?? null,
|
|
386
|
+
ledger: options.ledgerPath === undefined ? null : resolve(options.ledgerPath),
|
|
387
|
+
audioManifest: options.audioManifest === undefined ? null : resolve(options.audioManifest),
|
|
388
|
+
music: options.musicPath === undefined ? null : resolve(options.musicPath),
|
|
389
|
+
musicVolume: options.musicVolume ?? null,
|
|
390
|
+
musicLicense: options.musicLicensePath === undefined ? null : resolve(options.musicLicensePath),
|
|
391
|
+
});
|
|
392
|
+
function manifestOf(ctx, facts) {
|
|
393
|
+
const { board, infos, report, options } = ctx;
|
|
394
|
+
const { provenance, clip } = facts;
|
|
395
|
+
const totalSec = round(Math.max(...infos.map((i) => i.start + i.duration)));
|
|
396
|
+
const estimate = clip.msPerFrame === undefined
|
|
397
|
+
? null
|
|
398
|
+
: Math.round((clip.msPerFrame * totalSec * board.meta.fps) / MS);
|
|
399
|
+
const { width, height, fps } = board.meta;
|
|
400
|
+
const checklist = CHECKLIST.map((item) => item.id === "not-shown" && facts.audioMix.audible
|
|
401
|
+
? {
|
|
402
|
+
...item,
|
|
403
|
+
text: facts.audioMix.musicSha === null
|
|
404
|
+
? "the user knows what is NOT previewed: motion between stills and any scene without a still"
|
|
405
|
+
: "the user knows what is NOT previewed: motion between stills and any scene without a still (music bed included in clip)",
|
|
406
|
+
}
|
|
407
|
+
: { ...item });
|
|
408
|
+
return {
|
|
409
|
+
hash: facts.hash,
|
|
410
|
+
inputManifest: facts.inputManifest,
|
|
411
|
+
inputManifestSha256: facts.inputManifestSha256,
|
|
412
|
+
storyboard: basename(ctx.storyboard),
|
|
413
|
+
inputs: inputsOf(options),
|
|
414
|
+
template: provenance?.template ?? null,
|
|
415
|
+
blueprint: provenance?.blueprint ?? null,
|
|
416
|
+
style: provenance?.style ?? null,
|
|
417
|
+
theme: board.meta.theme,
|
|
418
|
+
pair: facts.pair?.id ?? null,
|
|
419
|
+
frame: { width, height, fps },
|
|
420
|
+
totalSec,
|
|
421
|
+
scenes: infos.map((i) => ({ id: i.id, type: i.type, start: i.start, duration: i.duration })),
|
|
422
|
+
stills: facts.named.map(stillRow),
|
|
423
|
+
contact: "contact.png",
|
|
424
|
+
clip: clip.result,
|
|
425
|
+
estFullRenderSec: estimate,
|
|
426
|
+
estimateNote: ESTIMATE_NOTE,
|
|
427
|
+
lint: {
|
|
428
|
+
errors: report.errors.length,
|
|
429
|
+
warnings: report.warnings.length,
|
|
430
|
+
items: report.warnings.slice(0, MAX_LINT_ITEMS).map((w) => `${w.path || "/"}: ${w.message}`),
|
|
431
|
+
},
|
|
432
|
+
budgetSec: options.budgetSec,
|
|
433
|
+
elapsedSec: round((ctx.clock() - ctx.began) / MS),
|
|
434
|
+
audioMix: facts.audioMix,
|
|
435
|
+
checklist: checklist.map((item) => ({ ...item, done: false })),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/** Parses flags, loads and validates the storyboard; an exit code on any problem (or `--help`). */
|
|
439
|
+
async function start(args, io, clock) {
|
|
440
|
+
const parsed = parseFlags("preview", USAGE, args, FLAGS, io);
|
|
441
|
+
if (parsed.kind === "exit")
|
|
442
|
+
return parsed.code;
|
|
443
|
+
const storyboard = requireStoryboard("preview", USAGE, parsed.positionals, io);
|
|
444
|
+
if (typeof storyboard !== "string")
|
|
445
|
+
return storyboard.code;
|
|
446
|
+
const options = readOptions(parsed.flags, storyboard, io);
|
|
447
|
+
if (typeof options === "number")
|
|
448
|
+
return options;
|
|
449
|
+
const began = clock();
|
|
450
|
+
const loaded = await loadBoard(storyboard, io);
|
|
451
|
+
if (typeof loaded === "number")
|
|
452
|
+
return loaded;
|
|
453
|
+
const ctx = {
|
|
454
|
+
...loaded,
|
|
455
|
+
io,
|
|
456
|
+
storyboard,
|
|
457
|
+
options,
|
|
458
|
+
projectDir: dirname(resolve(storyboard)),
|
|
459
|
+
work: `${packDir(options.out, storyboard)}-work`,
|
|
460
|
+
pack: packDir(options.out, storyboard),
|
|
461
|
+
clock,
|
|
462
|
+
began,
|
|
463
|
+
};
|
|
464
|
+
return { ctx, json: parsed.flags.json === true };
|
|
465
|
+
}
|
|
466
|
+
/** `mk preview`; `env` and `clock` are injectable for tests. */
|
|
467
|
+
const resolvePreview = async (io, ctx) => {
|
|
468
|
+
try {
|
|
469
|
+
const manifest = await buildApprovalInputManifest(io.host, ctx.storyboard, {
|
|
470
|
+
...(ctx.options.assetsDir === undefined ? {} : { assetsDir: ctx.options.assetsDir }),
|
|
471
|
+
...(ctx.options.ledgerPath === undefined ? {} : { ledgerPath: ctx.options.ledgerPath }),
|
|
472
|
+
...(ctx.options.audioManifest === undefined
|
|
473
|
+
? {}
|
|
474
|
+
: { audioManifest: ctx.options.audioManifest }),
|
|
475
|
+
...(ctx.options.musicPath === undefined ? {} : { music: ctx.options.musicPath }),
|
|
476
|
+
...(ctx.options.musicVolume === undefined ? {} : { musicVolume: ctx.options.musicVolume }),
|
|
477
|
+
...(ctx.options.musicLicensePath === undefined
|
|
478
|
+
? {}
|
|
479
|
+
: { musicLicense: ctx.options.musicLicensePath }),
|
|
480
|
+
});
|
|
481
|
+
return { manifest, sha: hashApprovalInput(manifest) };
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
const hint = ctx.options.audioManifest === undefined
|
|
485
|
+
? ""
|
|
486
|
+
: `; rerun \`mk preview ${ctx.storyboard} --audio ${ctx.options.audioManifest}\` after fixing the audio inputs`;
|
|
487
|
+
io.stderr(`mk preview: ${error.message}${hint}\n`);
|
|
488
|
+
return EXIT.USAGE;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const SILENT_AUDIO_MIX = {
|
|
492
|
+
manifestSha: null,
|
|
493
|
+
wavSha: null,
|
|
494
|
+
audible: false,
|
|
495
|
+
musicSha: null,
|
|
496
|
+
musicVolume: null,
|
|
497
|
+
musicLicenseSha: null,
|
|
498
|
+
};
|
|
499
|
+
/** Mixes the exact final wavs under the preview clip with the same args as the final render. */
|
|
500
|
+
const mixPreviewClip = async (io, pack, work, audioManifest, storyboard, expected, musicOpts = {}) => {
|
|
501
|
+
const rerun = `rerun \`mk preview ${storyboard} --audio ${audioManifest}\` after fixing the audio inputs`;
|
|
502
|
+
let text;
|
|
503
|
+
try {
|
|
504
|
+
text = await io.host.readText(audioManifest);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
io.stderr(`mk preview: cannot read --audio manifest: ${error.message}; ${rerun}\n`);
|
|
508
|
+
return EXIT.USAGE;
|
|
509
|
+
}
|
|
510
|
+
let raw;
|
|
511
|
+
try {
|
|
512
|
+
raw = JSON.parse(text);
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
io.stderr(`mk preview: invalid --audio manifest: ${error.message}; ${rerun}\n`);
|
|
516
|
+
return EXIT.USAGE;
|
|
517
|
+
}
|
|
518
|
+
const parsed = parseAudioManifest(raw);
|
|
519
|
+
if (!parsed.ok) {
|
|
520
|
+
io.stderr(`mk preview: invalid --audio manifest: ${parsed.problems.join("; ")}; ${rerun}\n`);
|
|
521
|
+
return EXIT.USAGE;
|
|
522
|
+
}
|
|
523
|
+
const audioDir = join(dirname(resolve(audioManifest)), AUDIO_DIR_NAME);
|
|
524
|
+
const clips = clipsFromManifest(parsed.manifest, audioDir);
|
|
525
|
+
if (clips.length === 0) {
|
|
526
|
+
io.stderr(`mk preview: --audio manifest has no scenes; nothing to mix; ${rerun}\n`);
|
|
527
|
+
return EXIT.USAGE;
|
|
528
|
+
}
|
|
529
|
+
for (const clip of clips) {
|
|
530
|
+
const digest = await io.host.sha256File(clip.wav);
|
|
531
|
+
if (digest === undefined) {
|
|
532
|
+
io.stderr(`mk preview: missing narration wav for scene "${clip.id}": ${clip.wav}; ${rerun}\n`);
|
|
533
|
+
return EXIT.USAGE;
|
|
534
|
+
}
|
|
535
|
+
// AU-01 MEDIUM: ungated --audio must reject silent wavs like the manifest
|
|
536
|
+
// path does (EMPTY_SHA256); never mix silence as narration.
|
|
537
|
+
if (digest === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") {
|
|
538
|
+
io.stderr(`mk preview: narration wav is silent/empty for scene "${clip.id}": ${clip.wav}; ${rerun}\n`);
|
|
539
|
+
return EXIT.USAGE;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// RA-02: never certify audible from hashes alone. The clip must exist and
|
|
543
|
+
// the mix must succeed before audible:true is returned.
|
|
544
|
+
const clipFile = join(pack, "clip.mp4");
|
|
545
|
+
if (!(await io.host.exists(clipFile))) {
|
|
546
|
+
io.stderr(`mk preview: --audio requires an audible clip but ${clipFile} is missing (clip skipped or failed); raise --budget-sec and ${rerun}\n`);
|
|
547
|
+
return EXIT.USAGE;
|
|
548
|
+
}
|
|
549
|
+
const manifestSha = createHash("sha256").update(text).digest("hex");
|
|
550
|
+
const mixedFile = join(pack, "clip-audible.mp4");
|
|
551
|
+
// AU-01: freeze the exact bytes ffmpeg consumes (ABA-proof) and mix with the
|
|
552
|
+
// same build authority as the final render (no second pipeline).
|
|
553
|
+
const frozenDir = join(work, ".frozen-audio");
|
|
554
|
+
let mixBed;
|
|
555
|
+
let musicSha = null;
|
|
556
|
+
let musicVolume = null;
|
|
557
|
+
let musicLicenseSha = null;
|
|
558
|
+
const frozenNarration = frozenClips(clips, frozenDir);
|
|
559
|
+
const checks = [];
|
|
560
|
+
const frozenDigests = [];
|
|
561
|
+
try {
|
|
562
|
+
for (const [index, clip] of frozenNarration.entries()) {
|
|
563
|
+
const source = clips[index];
|
|
564
|
+
if (source === undefined)
|
|
565
|
+
continue;
|
|
566
|
+
await io.host.copyFile(source.wav, clip.wav);
|
|
567
|
+
const frozenSha = await io.host.sha256File(clip.wav);
|
|
568
|
+
frozenDigests.push(frozenSha ?? "");
|
|
569
|
+
checks.push({
|
|
570
|
+
label: `narration "${clip.id}"`,
|
|
571
|
+
actualSha256: frozenSha,
|
|
572
|
+
expectedSha256: expected.mix?.clips[index]?.wavSha,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
io.stderr(`mk preview: cannot freeze narration wavs: ${error.message}; ${rerun}\n`);
|
|
578
|
+
return EXIT.ERROR;
|
|
579
|
+
}
|
|
580
|
+
const wavSha = createHash("sha256").update(frozenDigests.join("\n")).digest("hex");
|
|
581
|
+
if (musicOpts.musicPath !== undefined) {
|
|
582
|
+
const validated = validateMusicBed(musicOpts.musicVolume === undefined
|
|
583
|
+
? { wav: musicOpts.musicPath }
|
|
584
|
+
: { wav: musicOpts.musicPath, volume: musicOpts.musicVolume });
|
|
585
|
+
if (!validated.ok) {
|
|
586
|
+
io.stderr(`mk preview: invalid --music bed: ${validated.hint}; ${rerun}\n`);
|
|
587
|
+
return EXIT.USAGE;
|
|
588
|
+
}
|
|
589
|
+
const bedDigest = await io.host.sha256File(musicOpts.musicPath);
|
|
590
|
+
if (bedDigest === undefined) {
|
|
591
|
+
io.stderr(`mk preview: music bed wav not found: ${musicOpts.musicPath}; ${rerun}\n`);
|
|
592
|
+
return EXIT.USAGE;
|
|
593
|
+
}
|
|
594
|
+
if (bedDigest === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") {
|
|
595
|
+
io.stderr(`mk preview: music bed wav is silent/empty: ${musicOpts.musicPath}; ${rerun}\n`);
|
|
596
|
+
return EXIT.USAGE;
|
|
597
|
+
}
|
|
598
|
+
if (musicOpts.musicLicensePath !== undefined) {
|
|
599
|
+
const licDigest = await io.host.sha256File(musicOpts.musicLicensePath);
|
|
600
|
+
if (licDigest === undefined) {
|
|
601
|
+
io.stderr(`mk preview: cannot read --music-license ${musicOpts.musicLicensePath}: not found; ${rerun}\n`);
|
|
602
|
+
return EXIT.USAGE;
|
|
603
|
+
}
|
|
604
|
+
musicLicenseSha = licDigest;
|
|
605
|
+
}
|
|
606
|
+
const frozenBed = frozenMusic({ wav: musicOpts.musicPath, volume: validated.bed.volume }, frozenDir);
|
|
607
|
+
try {
|
|
608
|
+
await io.host.copyFile(musicOpts.musicPath, frozenBed.wav);
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
io.stderr(`mk preview: cannot freeze music bed: ${error.message}; ${rerun}\n`);
|
|
612
|
+
return EXIT.ERROR;
|
|
613
|
+
}
|
|
614
|
+
const frozenBedSha = await io.host.sha256File(frozenBed.wav);
|
|
615
|
+
checks.push({
|
|
616
|
+
label: "music bed",
|
|
617
|
+
actualSha256: frozenBedSha,
|
|
618
|
+
expectedSha256: expected.music?.sha256,
|
|
619
|
+
});
|
|
620
|
+
if (musicLicenseSha !== null) {
|
|
621
|
+
checks.push({
|
|
622
|
+
label: "music license evidence",
|
|
623
|
+
actualSha256: musicLicenseSha,
|
|
624
|
+
expectedSha256: expected.music?.licenseEvidenceSha256 ?? undefined,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
musicSha = frozenBedSha ?? null;
|
|
628
|
+
musicVolume = validated.bed.volume;
|
|
629
|
+
mixBed = frozenBed;
|
|
630
|
+
}
|
|
631
|
+
// ADR-0008 D2: the audible clip must mix the bytes the preview manifest
|
|
632
|
+
// describes. An edit during the copy that is restored before the post-media
|
|
633
|
+
// re-resolve is invisible to that re-resolve; the frozen copy is not. The
|
|
634
|
+
// clip list must also still line up with the manifest's mix (count, id,
|
|
635
|
+
// start, mode): the manifest is re-read for the mix, so an extra or reordered
|
|
636
|
+
// clip must not ride along on matching file digests.
|
|
637
|
+
const frozenProblems = frozenCopyProblems(checks);
|
|
638
|
+
if (expected.mix !== undefined) {
|
|
639
|
+
if (expected.mix.clips.length !== clips.length) {
|
|
640
|
+
frozenProblems.push(`the previewed mix has ${expected.mix.clips.length} clips but ${clips.length} were prepared`);
|
|
641
|
+
}
|
|
642
|
+
for (const [index, clip] of clips.entries()) {
|
|
643
|
+
const want = expected.mix.clips[index];
|
|
644
|
+
if (want !== undefined && (want.id !== clip.id || want.start !== clip.start)) {
|
|
645
|
+
frozenProblems.push(`narration ${index + 1} does not match the previewed clip "${want.id}" at ${want.start}s`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const mode = clips.length === 1 ? "single" : isContiguous(clips) ? "concat" : "adelay";
|
|
649
|
+
if (expected.mix.mode !== mode) {
|
|
650
|
+
frozenProblems.push(`the previewed mix mode "${expected.mix.mode}" does not match "${mode}"`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (frozenProblems.length > 0) {
|
|
654
|
+
io.stderr(`mk preview: inputs changed while the pack was produced; the pack was not written (${frozenProblems.join("; ")}); ${rerun}\n`);
|
|
655
|
+
return EXIT.USAGE;
|
|
656
|
+
}
|
|
657
|
+
const delivery = await mixAtDeliveryLevel((argv) => io.run("ffmpeg", argv), {
|
|
658
|
+
videoIn: clipFile,
|
|
659
|
+
clips: frozenNarration,
|
|
660
|
+
...(mixBed === undefined ? {} : { music: mixBed }),
|
|
661
|
+
out: mixedFile,
|
|
662
|
+
swap: async (from, to) => {
|
|
663
|
+
await io.host.copyFile(from, to);
|
|
664
|
+
await io.host.remove(from);
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
if (!delivery.ok) {
|
|
668
|
+
const why = delivery.problems[0] ?? "";
|
|
669
|
+
io.stderr(`mk preview: audible clip mix failed${why === "" ? "" : `: ${why}`}; ${rerun}\n`);
|
|
670
|
+
return EXIT.ERROR;
|
|
671
|
+
}
|
|
672
|
+
if (!(await io.host.exists(mixedFile))) {
|
|
673
|
+
io.stderr(`mk preview: audible clip mix produced no file; ${rerun}\n`);
|
|
674
|
+
return EXIT.ERROR;
|
|
675
|
+
}
|
|
676
|
+
await io.host.copyFile(mixedFile, clipFile);
|
|
677
|
+
await io.host.remove(mixedFile);
|
|
678
|
+
return { manifestSha, wavSha, audible: true, musicSha, musicVolume, musicLicenseSha };
|
|
679
|
+
};
|
|
680
|
+
export const makePreviewCommand = (env = defaultEnv, clock = () => performance.now()) => ({
|
|
681
|
+
name: "preview",
|
|
682
|
+
summary: "build the short proposal pack (contact sheet, stills, clip) for approval",
|
|
683
|
+
async run(args, io) {
|
|
684
|
+
const started = await start(args, io, clock);
|
|
685
|
+
if (typeof started === "number")
|
|
686
|
+
return started;
|
|
687
|
+
const { ctx, json } = started;
|
|
688
|
+
const { storyboard } = ctx;
|
|
689
|
+
const provenance = await readProvenance(io.host, storyboard);
|
|
690
|
+
const pair = resolvePair(env(), io, provenance, ctx.board.meta.theme);
|
|
691
|
+
const spent = { ms: 0 };
|
|
692
|
+
// RA-01: freeze the approval identity before any media is produced. The
|
|
693
|
+
// post-media re-resolve below must match, or the pack is unusable: the
|
|
694
|
+
// identity must describe the bytes the reviewer actually saw.
|
|
695
|
+
const snapshot = await resolvePreview(io, ctx);
|
|
696
|
+
if (typeof snapshot === "number")
|
|
697
|
+
return snapshot;
|
|
698
|
+
const frozenHash = await storyboardHash(io.host, storyboard);
|
|
699
|
+
const named = await shootStills(ctx, pair, spent);
|
|
700
|
+
if (typeof named === "number")
|
|
701
|
+
return named;
|
|
702
|
+
const packed = await packStills(ctx, named);
|
|
703
|
+
if (packed !== EXIT.OK)
|
|
704
|
+
return packed;
|
|
705
|
+
const clip = await clipOrSkip(ctx, spent.ms);
|
|
706
|
+
let audioMix;
|
|
707
|
+
if (ctx.options.audioManifest === undefined) {
|
|
708
|
+
audioMix = SILENT_AUDIO_MIX;
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
// RA-02: an audiovisual pack is only audible after a playable mixed
|
|
712
|
+
// clip exists. A budget-skipped clip never certifies from hashes.
|
|
713
|
+
if (clip.result.status !== "ok") {
|
|
714
|
+
const why = clip.result.status === "skipped" ? ` (${clip.result.reason})` : " (missing)";
|
|
715
|
+
io.stderr(`mk preview: --audio requires an audible clip but the clip was skipped${why}; raise --budget-sec and rerun \`mk preview ${storyboard} --audio ${ctx.options.audioManifest}\`\n`);
|
|
716
|
+
return EXIT.USAGE;
|
|
717
|
+
}
|
|
718
|
+
const mixed = await mixPreviewClip(io, ctx.pack, ctx.work, ctx.options.audioManifest, storyboard, snapshot.manifest, {
|
|
719
|
+
...(ctx.options.musicPath === undefined ? {} : { musicPath: ctx.options.musicPath }),
|
|
720
|
+
...(ctx.options.musicVolume === undefined
|
|
721
|
+
? {}
|
|
722
|
+
: { musicVolume: ctx.options.musicVolume }),
|
|
723
|
+
...(ctx.options.musicLicensePath === undefined
|
|
724
|
+
? {}
|
|
725
|
+
: { musicLicensePath: ctx.options.musicLicensePath }),
|
|
726
|
+
});
|
|
727
|
+
if (typeof mixed === "number")
|
|
728
|
+
return mixed;
|
|
729
|
+
audioMix = mixed;
|
|
730
|
+
}
|
|
731
|
+
// RA-01: an edit that landed while stills/clip/mix ran must not silently
|
|
732
|
+
// rebind the pack to bytes the reviewer never saw. Fail closed without
|
|
733
|
+
// writing preview.json; `mk approve` also rechecks the binding.
|
|
734
|
+
const reverified = await resolvePreview(io, ctx);
|
|
735
|
+
if (typeof reverified === "number")
|
|
736
|
+
return reverified;
|
|
737
|
+
if (reverified.sha !== snapshot.sha) {
|
|
738
|
+
io.stderr(`mk preview: inputs changed while the pack was produced; the pack was not written. Rerun \`mk preview ${storyboard}\`.\n`);
|
|
739
|
+
return EXIT.USAGE;
|
|
740
|
+
}
|
|
741
|
+
const manifest = manifestOf(ctx, {
|
|
742
|
+
hash: frozenHash,
|
|
743
|
+
inputManifest: snapshot.manifest,
|
|
744
|
+
inputManifestSha256: snapshot.sha,
|
|
745
|
+
provenance,
|
|
746
|
+
pair,
|
|
747
|
+
named,
|
|
748
|
+
clip,
|
|
749
|
+
audioMix,
|
|
750
|
+
});
|
|
751
|
+
const text = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
752
|
+
await io.host.writeTextAtomic(join(ctx.pack, PREVIEW_JSON), text);
|
|
753
|
+
io.stdout(json ? text : summary(manifest, ctx.pack, storyboard, ctx.overlays));
|
|
754
|
+
io.stderr(formatFindings(ctx.report.warnings.slice(0, MAX_LINT_ITEMS)));
|
|
755
|
+
return EXIT.OK;
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
export const previewCommand = makePreviewCommand();
|