@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,33 @@
|
|
|
1
|
+
import { type TimingManifest } from "@anvpx/motion-schema";
|
|
2
|
+
export interface VoiceSceneTimings {
|
|
3
|
+
id: string;
|
|
4
|
+
/** Measured wav length, seconds. */
|
|
5
|
+
durationS: number;
|
|
6
|
+
/** Aligner words on the clip clock (local to this scene). */
|
|
7
|
+
words: Array<{
|
|
8
|
+
w: string;
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
}>;
|
|
12
|
+
/** Silence intervals on the clip clock (local to this scene). */
|
|
13
|
+
silences?: ReadonlyArray<readonly [number, number]>;
|
|
14
|
+
}
|
|
15
|
+
export type VoiceManifestResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
manifest: TimingManifest;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
problems: string[];
|
|
21
|
+
};
|
|
22
|
+
/** Float tolerance for aligner-vs-measured boundary comparison (rounding, not drift). */
|
|
23
|
+
export declare const WORD_BOUND_EPSILON_S = 0.01;
|
|
24
|
+
/**
|
|
25
|
+
* Voice TimingManifest with per-scene words on the absolute clock plus the
|
|
26
|
+
* full AudioAnalysis. Durations stay measured (never inferred from spans);
|
|
27
|
+
* starts are the exact cumulative sums from `buildVoiceManifest`.
|
|
28
|
+
*
|
|
29
|
+
* Fail-closed: every local word end must sit within its scene duration
|
|
30
|
+
* (+ epsilon). Overflow words pass the schema's overlap check but lie past
|
|
31
|
+
* the clip, so they are rejected here before offsetting to the absolute clock.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildVoiceManifestWithAnalysis(rows: readonly VoiceSceneTimings[]): VoiceManifestResult;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ALIGN_MODEL_ID, buildVoiceManifest, VOICE_PROVENANCE } from "@anvpx/motion-audio";
|
|
2
|
+
import { TimingManifestSchema, } from "@anvpx/motion-schema";
|
|
3
|
+
/** Float tolerance for aligner-vs-measured boundary comparison (rounding, not drift). */
|
|
4
|
+
export const WORD_BOUND_EPSILON_S = 0.01;
|
|
5
|
+
const checkWordBounds = (rows) => {
|
|
6
|
+
for (const row of rows) {
|
|
7
|
+
for (const w of row.words) {
|
|
8
|
+
if (!(w.end <= row.durationS + WORD_BOUND_EPSILON_S)) {
|
|
9
|
+
return `scene "${row.id}": word "${w.w}" ends at ${w.end}s beyond duration ${row.durationS}s`;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
};
|
|
15
|
+
const offsetScenes = (base, rows, starts) => base.scenes.map((scene) => {
|
|
16
|
+
const row = rows.find((r) => r.id === scene.id);
|
|
17
|
+
const start = starts.get(scene.id) ?? scene.start;
|
|
18
|
+
const words = (row?.words ?? []).map((w) => ({
|
|
19
|
+
w: w.w,
|
|
20
|
+
start: start + w.start,
|
|
21
|
+
end: start + w.end,
|
|
22
|
+
}));
|
|
23
|
+
return words.length === 0 ? scene : { ...scene, words };
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Voice TimingManifest with per-scene words on the absolute clock plus the
|
|
27
|
+
* full AudioAnalysis. Durations stay measured (never inferred from spans);
|
|
28
|
+
* starts are the exact cumulative sums from `buildVoiceManifest`.
|
|
29
|
+
*
|
|
30
|
+
* Fail-closed: every local word end must sit within its scene duration
|
|
31
|
+
* (+ epsilon). Overflow words pass the schema's overlap check but lie past
|
|
32
|
+
* the clip, so they are rejected here before offsetting to the absolute clock.
|
|
33
|
+
*/
|
|
34
|
+
export function buildVoiceManifestWithAnalysis(rows) {
|
|
35
|
+
const overflow = checkWordBounds(rows);
|
|
36
|
+
if (overflow !== undefined)
|
|
37
|
+
return { ok: false, problems: [overflow] };
|
|
38
|
+
const base = buildVoiceManifest(rows.map((row) => ({ id: row.id, durationS: row.durationS })));
|
|
39
|
+
if (!base.ok)
|
|
40
|
+
return base;
|
|
41
|
+
const starts = new Map(base.manifest.scenes.map((scene) => [scene.id, scene.start]));
|
|
42
|
+
const scenes = offsetScenes(base.manifest, rows, starts);
|
|
43
|
+
const flatWords = [];
|
|
44
|
+
for (const scene of scenes) {
|
|
45
|
+
flatWords.push(...(scene.words ?? []));
|
|
46
|
+
}
|
|
47
|
+
const flatSilences = rows.flatMap((row) => {
|
|
48
|
+
const start = starts.get(row.id) ?? 0;
|
|
49
|
+
return (row.silences ?? []).map(([s0, s1]) => ({ start: start + s0, end: start + s1 }));
|
|
50
|
+
});
|
|
51
|
+
const total = scenes.reduce((sum, scene) => sum + scene.duration, 0);
|
|
52
|
+
const audio = flatWords.length === 0
|
|
53
|
+
? undefined
|
|
54
|
+
: {
|
|
55
|
+
words: flatWords,
|
|
56
|
+
...(flatSilences.length === 0 ? {} : { silences: [...flatSilences] }),
|
|
57
|
+
model: ALIGN_MODEL_ID,
|
|
58
|
+
modelRevision: VOICE_PROVENANCE.alignModelRev,
|
|
59
|
+
duration: total,
|
|
60
|
+
};
|
|
61
|
+
const parsed = TimingManifestSchema.safeParse(audio === undefined ? { source: "voice", scenes } : { source: "voice", scenes, audio });
|
|
62
|
+
if (!parsed.success) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
problems: parsed.error.issues.map((issue) => `${issue.path.join("/")}: ${issue.message}`),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, manifest: parsed.data };
|
|
69
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Io } from "../types.ts";
|
|
2
|
+
export interface VoiceRunOptions {
|
|
3
|
+
/** Voice id from the storyboard/adapters (never a hardcoded default). */
|
|
4
|
+
voice: string;
|
|
5
|
+
/** Manifest path; defaults to `<dir>/.mk/<stem>/voice-manifest.json`. */
|
|
6
|
+
outPath?: string;
|
|
7
|
+
/** Directory for per-scene wavs; defaults to `<dir>/.mk/<stem>/audio`. */
|
|
8
|
+
audioDir?: string;
|
|
9
|
+
/** Explicit python binary; skips the venv probe when set (tests, custom venvs). */
|
|
10
|
+
python?: string;
|
|
11
|
+
}
|
|
12
|
+
/** DL-01b voice bar notes (constants, shared with the CLI usage text). */
|
|
13
|
+
export declare const VOICE_WAV_FALLBACK_NOTE = "pre-rendered WAV fallback in pack";
|
|
14
|
+
export declare const VOICE_MEASURED_QC_NOTE = "human QC via `mk qc` (pronunciation/intelligibility/pacing/alignment/no-silence)";
|
|
15
|
+
export declare const VOICE_ENV_NOTE = "BLOCKED_ENV stays NOT_RUN, never PASS";
|
|
16
|
+
/**
|
|
17
|
+
* Synthesize every narrated base scene, align words, gate silence, and write
|
|
18
|
+
* `.mk/<stem>/voice-manifest.json` + licences-style provenance. Resolves to
|
|
19
|
+
* the process exit code; never falls back to silent durations.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runVoice(storyboard: string, options: VoiceRunOptions, io: Io): Promise<number>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { alignWords, detectSilence, isJapaneseVoice, resolveVenvPython, synthesize, VOICE_PROVENANCE, wordsInSilence, } from "@anvpx/motion-audio";
|
|
3
|
+
import { resolveTimeline, validateStoryboardFull, WORDS_PER_SECOND } from "@anvpx/motion-build";
|
|
4
|
+
import { mkDir } from "../approval/hash.js";
|
|
5
|
+
import { EXIT } from "../exit-codes.js";
|
|
6
|
+
import { readJsonFile } from "../storyboard/load.js";
|
|
7
|
+
import { buildVoiceManifestWithAnalysis } from "./manifest.js";
|
|
8
|
+
const splitTokens = (narration) => narration.split(/\s+/).filter((w) => w.length > 0);
|
|
9
|
+
/** DL-01b voice bar notes (constants, shared with the CLI usage text). */
|
|
10
|
+
export const VOICE_WAV_FALLBACK_NOTE = "pre-rendered WAV fallback in pack";
|
|
11
|
+
export const VOICE_MEASURED_QC_NOTE = "human QC via `mk qc` (pronunciation/intelligibility/pacing/alignment/no-silence)";
|
|
12
|
+
export const VOICE_ENV_NOTE = "BLOCKED_ENV stays NOT_RUN, never PASS";
|
|
13
|
+
const doctorHint = `Run \`mk doctor\` for the voice setup steps. ${VOICE_WAV_FALLBACK_NOTE} when env blocked; ` +
|
|
14
|
+
`${VOICE_MEASURED_QC_NOTE}; ${VOICE_ENV_NOTE}.`;
|
|
15
|
+
/** Validated storyboard plus its narrated base scenes, or an exit code. */
|
|
16
|
+
async function loadNarrated(storyboard, io) {
|
|
17
|
+
const loaded = await readJsonFile(storyboard, io.host);
|
|
18
|
+
if (!loaded.ok) {
|
|
19
|
+
io.stderr(`mk voice: ${loaded.message}\n`);
|
|
20
|
+
return EXIT.USAGE;
|
|
21
|
+
}
|
|
22
|
+
const valid = validateStoryboardFull(loaded.value);
|
|
23
|
+
if (!valid.ok) {
|
|
24
|
+
io.stderr("mk voice: storyboard is invalid; run `mk validate`.\n");
|
|
25
|
+
return EXIT.USAGE;
|
|
26
|
+
}
|
|
27
|
+
const narrated = valid.value.scenes.filter((scene) => scene.layer !== "overlay" && (scene.narration?.trim() ?? "") !== "");
|
|
28
|
+
if (narrated.length === 0) {
|
|
29
|
+
io.stderr("mk voice: no narrated scenes; nothing to synthesize\n");
|
|
30
|
+
return EXIT.USAGE;
|
|
31
|
+
}
|
|
32
|
+
return { board: valid.value, narrated };
|
|
33
|
+
}
|
|
34
|
+
/** Probe the voice venv (or honor an explicit `--python`); fails closed on absence. */
|
|
35
|
+
async function pickPython(io, explicit) {
|
|
36
|
+
if (explicit !== undefined)
|
|
37
|
+
return { python: explicit };
|
|
38
|
+
const probe = await resolveVenvPython(io.run);
|
|
39
|
+
if (probe.ok)
|
|
40
|
+
return { python: probe.python };
|
|
41
|
+
io.stderr(`mk voice: ${probe.hint}\n${doctorHint}\n`);
|
|
42
|
+
return { code: EXIT.MISSING_TOOL };
|
|
43
|
+
}
|
|
44
|
+
const synthDuration = async (io, sceneId, narration, voice, audioPath, python) => {
|
|
45
|
+
const synth = await synthesize(io.run, { text: narration, voice, outPath: audioPath, python });
|
|
46
|
+
if (synth.ok)
|
|
47
|
+
return { durationS: synth.durationS };
|
|
48
|
+
io.stderr(`mk voice: synth for "${sceneId}" failed: ${synth.hint}\n${doctorHint}\n`);
|
|
49
|
+
return { code: synth.status === "missing" ? EXIT.MISSING_TOOL : EXIT.ERROR };
|
|
50
|
+
};
|
|
51
|
+
const alignSceneWords = async (io, sceneId, audioPath, tokens, python) => {
|
|
52
|
+
const aligned = await alignWords(io.run, { audioPath, tokens, python });
|
|
53
|
+
if (aligned.ok)
|
|
54
|
+
return { words: aligned.words };
|
|
55
|
+
io.stderr(`mk voice: align for "${sceneId}" failed: ${aligned.hint}\n${doctorHint}\n`);
|
|
56
|
+
return { code: aligned.status === "missing" ? EXIT.MISSING_TOOL : EXIT.ERROR };
|
|
57
|
+
};
|
|
58
|
+
const gateSilence = async (io, sceneId, audioPath, words) => {
|
|
59
|
+
const silence = await detectSilence(io.run, { audioPath });
|
|
60
|
+
if (!silence.ok) {
|
|
61
|
+
io.stderr(`mk voice: silence gate for "${sceneId}" failed: ${silence.hint}\n`);
|
|
62
|
+
return { code: silence.status === "missing" ? EXIT.MISSING_TOOL : EXIT.ERROR };
|
|
63
|
+
}
|
|
64
|
+
const swallowed = wordsInSilence(words, silence.intervals);
|
|
65
|
+
if (swallowed.length > 0) {
|
|
66
|
+
const shown = swallowed
|
|
67
|
+
.slice(0, 3)
|
|
68
|
+
.map((word) => `"${word.w}" at ${word.start.toFixed(2)}s`)
|
|
69
|
+
.join(", ");
|
|
70
|
+
const more = swallowed.length > 3 ? ", ..." : "";
|
|
71
|
+
io.stderr(`mk voice: "${sceneId}" has ${swallowed.length} word(s) inside silence (${shown}${more}); refusing fake timing\n`);
|
|
72
|
+
return { code: EXIT.ERROR };
|
|
73
|
+
}
|
|
74
|
+
return { silences: silence.intervals };
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* A6: narration must fit the scene at 180 wpm before anything is synthesized.
|
|
78
|
+
* The render-time reading/narration checks then cannot fail for a length the
|
|
79
|
+
* voice command could have refused; the fix names the scene and the exact edit.
|
|
80
|
+
*/
|
|
81
|
+
function narrationFitProblems(board) {
|
|
82
|
+
const timeline = resolveTimeline(board.board);
|
|
83
|
+
if (!timeline.ok)
|
|
84
|
+
return timeline.errors.map((error) => `${error.path}: ${error.message}`);
|
|
85
|
+
const available = new Map(timeline.value.scenes.map((scene) => [scene.id, scene.duration]));
|
|
86
|
+
return board.narrated.flatMap((scene) => {
|
|
87
|
+
const count = splitTokens(scene.narration ?? "").length;
|
|
88
|
+
const needed = Math.ceil(count / WORDS_PER_SECOND);
|
|
89
|
+
const seconds = available.get(scene.id) ?? scene.duration ?? 0;
|
|
90
|
+
if (needed <= seconds)
|
|
91
|
+
return [];
|
|
92
|
+
const excess = count - Math.floor(seconds * WORDS_PER_SECOND);
|
|
93
|
+
return [
|
|
94
|
+
`"${scene.id}": narration ${count} words needs >= ${needed}s at ${WORDS_PER_SECOND} words/s, ` +
|
|
95
|
+
`scene is ${seconds}s; set duration >= ${needed} or cut ${excess} word(s)`,
|
|
96
|
+
];
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Synth + align + silence gate for one narrated scene; fails closed, never fakes.
|
|
100
|
+
* `j*` (Japanese-pipeline) voices are unaligned-only: the EN wav2vec2 aligner
|
|
101
|
+
* cannot honestly caption non-ASCII narration, so alignment is skipped and
|
|
102
|
+
* the scene keeps measured duration with no words (QC stays UNKNOWN, never FAIL).
|
|
103
|
+
*/
|
|
104
|
+
async function voiceScene(io, scene, voice, audioDir, python) {
|
|
105
|
+
const narration = scene.narration;
|
|
106
|
+
const audioPath = join(audioDir, `${scene.id}.wav`);
|
|
107
|
+
const synth = await synthDuration(io, scene.id, narration, voice, audioPath, python);
|
|
108
|
+
if ("code" in synth)
|
|
109
|
+
return synth;
|
|
110
|
+
if (isJapaneseVoice(voice)) {
|
|
111
|
+
const gated = await gateSilence(io, scene.id, audioPath, []);
|
|
112
|
+
if ("code" in gated)
|
|
113
|
+
return gated;
|
|
114
|
+
return { id: scene.id, durationS: synth.durationS, words: [], silences: gated.silences };
|
|
115
|
+
}
|
|
116
|
+
const aligned = await alignSceneWords(io, scene.id, audioPath, splitTokens(narration), python);
|
|
117
|
+
if ("code" in aligned)
|
|
118
|
+
return aligned;
|
|
119
|
+
const gated = await gateSilence(io, scene.id, audioPath, aligned.words);
|
|
120
|
+
if ("code" in gated)
|
|
121
|
+
return gated;
|
|
122
|
+
return {
|
|
123
|
+
id: scene.id,
|
|
124
|
+
durationS: synth.durationS,
|
|
125
|
+
words: aligned.words,
|
|
126
|
+
silences: gated.silences,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Build the voice manifest, verify the timing override, and write artifacts. */
|
|
130
|
+
async function finishVoice(args) {
|
|
131
|
+
const built = buildVoiceManifestWithAnalysis(args.rows);
|
|
132
|
+
if (!built.ok) {
|
|
133
|
+
args.io.stderr(`mk voice: refusing to write invalid manifest: ${built.problems.join("; ")}\n`);
|
|
134
|
+
return EXIT.ERROR;
|
|
135
|
+
}
|
|
136
|
+
const timeline = resolveTimeline(args.board, built.manifest);
|
|
137
|
+
if (!timeline.ok) {
|
|
138
|
+
args.io.stderr(`mk voice: voice timing does not apply: ${timeline.errors.map((e) => e.message).join("; ")}\n`);
|
|
139
|
+
return EXIT.ERROR;
|
|
140
|
+
}
|
|
141
|
+
const provenance = {
|
|
142
|
+
voice: args.voice,
|
|
143
|
+
...VOICE_PROVENANCE,
|
|
144
|
+
scenes: args.rows.map((row) => row.id),
|
|
145
|
+
total: timeline.value.total,
|
|
146
|
+
};
|
|
147
|
+
await args.io.host.writeTextAtomic(args.outPath, `${JSON.stringify(built.manifest, null, 2)}\n`);
|
|
148
|
+
await args.io.host.writeTextAtomic(join(mkDir(args.storyboard), "voice-provenance.json"), `${JSON.stringify(provenance, null, 2)}\n`);
|
|
149
|
+
args.io.stdout(`voice ok | ${args.rows.length} scenes ${timeline.value.total}s source voice -> ${args.outPath}\n`);
|
|
150
|
+
return EXIT.OK;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Synthesize every narrated base scene, align words, gate silence, and write
|
|
154
|
+
* `.mk/<stem>/voice-manifest.json` + licences-style provenance. Resolves to
|
|
155
|
+
* the process exit code; never falls back to silent durations.
|
|
156
|
+
*/
|
|
157
|
+
export async function runVoice(storyboard, options, io) {
|
|
158
|
+
const board = await loadNarrated(storyboard, io);
|
|
159
|
+
if (typeof board === "number")
|
|
160
|
+
return board;
|
|
161
|
+
const misfit = narrationFitProblems(board);
|
|
162
|
+
if (misfit.length > 0) {
|
|
163
|
+
io.stderr(`mk voice: narration does not fit before synthesis:\n${misfit.map((line) => ` - ${line}`).join("\n")}\n`);
|
|
164
|
+
return EXIT.USAGE;
|
|
165
|
+
}
|
|
166
|
+
const picked = await pickPython(io, options.python);
|
|
167
|
+
if (!("python" in picked))
|
|
168
|
+
return picked.code;
|
|
169
|
+
const audioDir = options.audioDir ?? join(mkDir(storyboard), "audio");
|
|
170
|
+
const outPath = options.outPath ?? join(mkDir(storyboard), "voice-manifest.json");
|
|
171
|
+
// A6: first runs used to fail here because the synthesizer cannot create its own directory.
|
|
172
|
+
await io.host.mkdir?.(audioDir);
|
|
173
|
+
const rows = [];
|
|
174
|
+
for (const scene of board.narrated) {
|
|
175
|
+
const timed = await voiceScene(io, scene, options.voice, audioDir, picked.python);
|
|
176
|
+
if ("code" in timed)
|
|
177
|
+
return timed.code;
|
|
178
|
+
rows.push(timed);
|
|
179
|
+
}
|
|
180
|
+
return finishVoice({ board: board.board, storyboard, rows, voice: options.voice, outPath, io });
|
|
181
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { EXIT } from "../exit-codes.js";
|
|
2
|
+
import { parseBuildArgs, prepareProject } from "./project.js";
|
|
3
|
+
const USAGE = `mk build <storyboard.json> [--out <dir>] [--assets <dir>] [--ledger <assets.json>] [--incremental] [--clean-cache] [--shared-cache-dir <dir>]
|
|
4
|
+
|
|
5
|
+
Validate the storyboard and compile it into a HyperFrames project.
|
|
6
|
+
--out <dir> project directory (default: <storyboard dir>/out/project)
|
|
7
|
+
--assets <dir> directory that asset refs are relative to
|
|
8
|
+
--ledger <file> asset licence ledger (assets.json); every copied asset needs a row
|
|
9
|
+
--incremental reuse unchanged scenes from <storyboard dir>/.mk-cache (output is byte-identical);
|
|
10
|
+
prints "cache: N hit / M miss"; draft, render and still accept it too
|
|
11
|
+
--no-incremental build without the cache (the default)
|
|
12
|
+
--clean-cache delete the cache first (cache files only)
|
|
13
|
+
--shared-cache-dir <dir> opt-in shared cache across storyboards (requires --incremental); absent means per-storyboard only
|
|
14
|
+
`;
|
|
15
|
+
export const buildCommand = {
|
|
16
|
+
name: "build",
|
|
17
|
+
summary: "validate a storyboard and compile it into a HyperFrames project",
|
|
18
|
+
async run(args, io) {
|
|
19
|
+
const parsed = parseBuildArgs("build", USAGE, args, {}, io);
|
|
20
|
+
if (parsed.kind === "exit")
|
|
21
|
+
return parsed.code;
|
|
22
|
+
const prepared = await prepareProject(parsed.request, io);
|
|
23
|
+
return prepared.ok ? EXIT.OK : prepared.code;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Command } from "../types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* `mk draft`. `lint: false` is for `mk preview`'s clip only: its scenes are cut to a couple of
|
|
4
|
+
* seconds on purpose, which the authored-work rules (reading time, minimum hold) would reject.
|
|
5
|
+
*/
|
|
6
|
+
export declare const makeDraftCommand: (options?: {
|
|
7
|
+
lint?: boolean;
|
|
8
|
+
}) => Command;
|
|
9
|
+
export declare const draftCommand: Command;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { validateMusicBed } from "@anvpx/motion-audio";
|
|
3
|
+
import { AUDIO_DIR_NAME, clipsFromManifest, missingMusic, mixAtDeliveryLevel, parseAudioManifest, } from "@anvpx/motion-build";
|
|
4
|
+
import { textFlag, usageError } from "../args.js";
|
|
5
|
+
import { EXIT } from "../exit-codes.js";
|
|
6
|
+
import { readJsonFile } from "../storyboard/load.js";
|
|
7
|
+
import { runInProject } from "../tooling/session.js";
|
|
8
|
+
import { DRAFT_FPS, DRAFT_WIDTH, draftExpectation, draftHeight } from "../verify/expectation.js";
|
|
9
|
+
import { finishRender } from "./finish.js";
|
|
10
|
+
import { parseBuildArgs, prepareProject } from "./project.js";
|
|
11
|
+
const USAGE = `mk draft <storyboard.json> [--out <dir>] [--assets <dir>] [--ledger <assets.json>] [--verify] [--audio <manifest>] [--music <wav>]
|
|
12
|
+
|
|
13
|
+
Build, then render a fast 960 px wide draft at 15 fps to <out>/draft.mp4.
|
|
14
|
+
hyperframes only renders at the composition size or larger (--resolution presets start at
|
|
15
|
+
1080p), so it renders 1080p with --quality draft and ffmpeg downscales with lanczos.
|
|
16
|
+
Output of the tools: <out>/render-log.txt; provenance: <out>/render-manifest.json.
|
|
17
|
+
--audio <manifest> voice TimingManifest: mix per-scene wavs (the manifest's
|
|
18
|
+
sibling audio/ dir, <id>.wav) under the render at absolute
|
|
19
|
+
starts. Absent means silent video; missing wavs fail instead.
|
|
20
|
+
--music <wav> user CC0 music bed: loops to the narration length, ducks under
|
|
21
|
+
narration via sidechaincompress, then joins it. Requires
|
|
22
|
+
--audio; a missing bed wav fails instead of silence.
|
|
23
|
+
--verify re-probe draft.mp4 (see \`mk verify\`); exit 5 on FAIL
|
|
24
|
+
`;
|
|
25
|
+
const FULL_SIZE = "draft-full.mp4";
|
|
26
|
+
/** Narrated intermediate when --audio mixes before the downscale. */
|
|
27
|
+
const MIXED_SIZE = "draft-mixed.mp4";
|
|
28
|
+
const DRAFT = "draft.mp4";
|
|
29
|
+
const downscale = (input, output) => [
|
|
30
|
+
"-y",
|
|
31
|
+
"-i",
|
|
32
|
+
input,
|
|
33
|
+
"-vf",
|
|
34
|
+
`scale=${DRAFT_WIDTH}:-2:flags=lanczos`,
|
|
35
|
+
"-c:v",
|
|
36
|
+
"libx264",
|
|
37
|
+
"-preset",
|
|
38
|
+
"veryfast",
|
|
39
|
+
"-crf",
|
|
40
|
+
"23",
|
|
41
|
+
"-pix_fmt",
|
|
42
|
+
"yuv420p",
|
|
43
|
+
"-movflags",
|
|
44
|
+
"+faststart",
|
|
45
|
+
"-c:a",
|
|
46
|
+
"copy",
|
|
47
|
+
output,
|
|
48
|
+
];
|
|
49
|
+
/** Loads a voice TimingManifest plus its sibling wavs; fails closed, never silent. */
|
|
50
|
+
async function loadNarration(audioPath, io) {
|
|
51
|
+
const loaded = await readJsonFile(audioPath, io.host);
|
|
52
|
+
if (!loaded.ok) {
|
|
53
|
+
io.stderr(`mk draft: cannot read --audio manifest: ${loaded.message}\n`);
|
|
54
|
+
return { ok: false, code: EXIT.USAGE };
|
|
55
|
+
}
|
|
56
|
+
const parsed = parseAudioManifest(loaded.value);
|
|
57
|
+
if (!parsed.ok) {
|
|
58
|
+
io.stderr(`mk draft: invalid --audio manifest: ${parsed.problems.join("; ")}\n`);
|
|
59
|
+
return { ok: false, code: EXIT.USAGE };
|
|
60
|
+
}
|
|
61
|
+
const audioDir = join(dirname(resolve(audioPath)), AUDIO_DIR_NAME);
|
|
62
|
+
const clips = clipsFromManifest(parsed.manifest, audioDir);
|
|
63
|
+
if (clips.length === 0) {
|
|
64
|
+
io.stderr("mk draft: --audio manifest has no scenes; nothing to mix\n");
|
|
65
|
+
return { ok: false, code: EXIT.USAGE };
|
|
66
|
+
}
|
|
67
|
+
// NOTE (AU-01 scope): missing-only fails closed here; draft is explicitly
|
|
68
|
+
// non-approved, so audible/silent enforcement lives in the preview gate
|
|
69
|
+
// (mixPreviewClip silent reject → approve refuses unheard audio).
|
|
70
|
+
for (const clip of clips) {
|
|
71
|
+
if (!(await io.host.exists(clip.wav))) {
|
|
72
|
+
io.stderr(`mk draft: missing narration wav for scene "${clip.id}": ${clip.wav}\n`);
|
|
73
|
+
return { ok: false, code: EXIT.ERROR };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, clips, manifest: parsed.manifest };
|
|
77
|
+
}
|
|
78
|
+
/** Validates the explicit user bed through the audio guard; fails closed, never silent. */
|
|
79
|
+
async function loadMusic(musicPath, io) {
|
|
80
|
+
const validated = validateMusicBed({ wav: musicPath });
|
|
81
|
+
if (!validated.ok) {
|
|
82
|
+
io.stderr(`mk draft: invalid --music bed: ${validated.hint}\n`);
|
|
83
|
+
return { ok: false, code: EXIT.USAGE };
|
|
84
|
+
}
|
|
85
|
+
const present = await io.host.exists(validated.bed.wav);
|
|
86
|
+
if (missingMusic(validated.bed, () => present).length > 0) {
|
|
87
|
+
io.stderr(`mk draft: missing music bed wav: ${validated.bed.wav}\n`);
|
|
88
|
+
return { ok: false, code: EXIT.ERROR };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, music: validated.bed };
|
|
91
|
+
}
|
|
92
|
+
/** Parses flags and fails fast on bad input or a missing voice wav. */
|
|
93
|
+
async function resolveDraftPlan(args, io) {
|
|
94
|
+
const parsed = parseBuildArgs("draft", USAGE, args, { verify: { type: "boolean" }, audio: { type: "string" }, music: { type: "string" } }, io);
|
|
95
|
+
if (parsed.kind === "exit")
|
|
96
|
+
return { code: parsed.code };
|
|
97
|
+
const audioPath = textFlag(parsed.flags, "audio");
|
|
98
|
+
const musicPath = textFlag(parsed.flags, "music");
|
|
99
|
+
if (musicPath !== undefined && audioPath === undefined) {
|
|
100
|
+
return {
|
|
101
|
+
code: usageError("draft", USAGE, "--music needs --audio: the bed loops to the narration length", io).code,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const narration = audioPath === undefined ? undefined : await loadNarration(audioPath, io);
|
|
105
|
+
if (narration !== undefined && !narration.ok)
|
|
106
|
+
return { code: narration.code };
|
|
107
|
+
const bedded = musicPath === undefined ? undefined : await loadMusic(musicPath, io);
|
|
108
|
+
if (bedded !== undefined && !bedded.ok)
|
|
109
|
+
return { code: bedded.code };
|
|
110
|
+
return {
|
|
111
|
+
request: narration?.manifest === undefined
|
|
112
|
+
? parsed.request
|
|
113
|
+
: { ...parsed.request, timing: narration.manifest },
|
|
114
|
+
verify: parsed.flags.verify === true,
|
|
115
|
+
narration: narration?.clips,
|
|
116
|
+
music: bedded?.music,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Renders silent full-size video, mixes narration when requested, then downscales. */
|
|
120
|
+
async function runDraft(io, outDir, plan) {
|
|
121
|
+
const full = join(outDir, FULL_SIZE);
|
|
122
|
+
const mixed = join(outDir, MIXED_SIZE);
|
|
123
|
+
const draft = join(outDir, DRAFT);
|
|
124
|
+
let deliveryFailed = false;
|
|
125
|
+
const code = await runInProject(io, outDir, async (session) => {
|
|
126
|
+
const rendered = await session.hf([
|
|
127
|
+
"render",
|
|
128
|
+
".",
|
|
129
|
+
"-o",
|
|
130
|
+
FULL_SIZE,
|
|
131
|
+
"--fps",
|
|
132
|
+
String(DRAFT_FPS),
|
|
133
|
+
"--quality",
|
|
134
|
+
"draft",
|
|
135
|
+
"--quiet",
|
|
136
|
+
]);
|
|
137
|
+
if (plan.narration !== undefined) {
|
|
138
|
+
const delivery = await mixAtDeliveryLevel((argv) => session.probe("ffmpeg", argv), {
|
|
139
|
+
videoIn: full,
|
|
140
|
+
clips: plan.narration,
|
|
141
|
+
...(plan.music === undefined ? {} : { music: plan.music }),
|
|
142
|
+
out: mixed,
|
|
143
|
+
swap: async (from, to) => {
|
|
144
|
+
await io.host.copyFile(from, to);
|
|
145
|
+
await io.host.remove(from);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
// ADR-0008 D3 applies to the draft consumer too: a typed failed delivery
|
|
149
|
+
// is a failed command, not a logged problem, and never becomes draft.mp4.
|
|
150
|
+
if (!delivery.ok) {
|
|
151
|
+
const problems = delivery.problems.length > 0 ? delivery.problems : ["mix delivery failed"];
|
|
152
|
+
io.stderr(`mk draft: ${problems.join("; ")}\n`);
|
|
153
|
+
await io.host.remove(mixed).catch(() => undefined);
|
|
154
|
+
await io.host.remove(`${mixed}.loudnorm.mp4`).catch(() => undefined);
|
|
155
|
+
deliveryFailed = true;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (delivery.problems.length > 0) {
|
|
159
|
+
io.stderr(`mk draft: ${delivery.problems.join("; ")}\n`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (rendered)
|
|
163
|
+
await session.tool("ffmpeg", downscale(plan.narration === undefined ? full : mixed, draft));
|
|
164
|
+
});
|
|
165
|
+
await io.host.remove(full);
|
|
166
|
+
if (plan.narration !== undefined)
|
|
167
|
+
await io.host.remove(mixed);
|
|
168
|
+
return deliveryFailed ? EXIT.ERROR : code;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* `mk draft`. `lint: false` is for `mk preview`'s clip only: its scenes are cut to a couple of
|
|
172
|
+
* seconds on purpose, which the authored-work rules (reading time, minimum hold) would reject.
|
|
173
|
+
*/
|
|
174
|
+
export const makeDraftCommand = (options = {}) => ({
|
|
175
|
+
name: "draft",
|
|
176
|
+
summary: "build, then render a fast 960x540 15 fps draft.mp4",
|
|
177
|
+
async run(args, io) {
|
|
178
|
+
const plan = await resolveDraftPlan(args, io);
|
|
179
|
+
if ("code" in plan)
|
|
180
|
+
return plan.code;
|
|
181
|
+
const request = options.lint === false ? { ...plan.request, lint: false } : plan.request;
|
|
182
|
+
const prepared = await prepareProject(request, io);
|
|
183
|
+
if (!prepared.ok)
|
|
184
|
+
return prepared.code;
|
|
185
|
+
const code = await runDraft(io, prepared.outDir, plan);
|
|
186
|
+
if (code !== 0)
|
|
187
|
+
return code;
|
|
188
|
+
const { total, meta } = prepared;
|
|
189
|
+
const draft = join(prepared.outDir, DRAFT);
|
|
190
|
+
return finishRender(io, {
|
|
191
|
+
record: {
|
|
192
|
+
kind: "draft",
|
|
193
|
+
outDir: prepared.outDir,
|
|
194
|
+
file: draft,
|
|
195
|
+
params: { fps: DRAFT_FPS, quality: "draft", width: DRAFT_WIDTH, height: draftHeight(meta) },
|
|
196
|
+
},
|
|
197
|
+
...(plan.verify ? { verify: draftExpectation(meta, total) } : {}),
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
export const draftCommand = makeDraftCommand();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { MediaExpectation, VerifyReport } from "@anvpx/motion-build";
|
|
2
|
+
import type { Io } from "../types.ts";
|
|
3
|
+
import { type RenderRecord } from "./provenance.ts";
|
|
4
|
+
export interface Finish {
|
|
5
|
+
record: Omit<RenderRecord, "verify">;
|
|
6
|
+
/** Present when `--verify` was given: what the output must look like. */
|
|
7
|
+
verify?: MediaExpectation;
|
|
8
|
+
}
|
|
9
|
+
export type FinishStatus = VerifyReport["status"] | "not-run";
|
|
10
|
+
export interface FinishLines {
|
|
11
|
+
readonly stdout: string;
|
|
12
|
+
readonly stderr: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* What to report after the render manifest is written. A failed render is not a deliverable,
|
|
16
|
+
* so its path never reaches stdout: the one line a caller may relay is the refusal.
|
|
17
|
+
*/
|
|
18
|
+
export declare function finishLines(file: string, status: FinishStatus): FinishLines;
|
|
19
|
+
/**
|
|
20
|
+
* After a successful render: verify it (QA failure is EXIT.QA_FAIL), write
|
|
21
|
+
* `render-manifest.json` recording that status, then report only what was proven.
|
|
22
|
+
*/
|
|
23
|
+
export declare function finishRender(io: Io, finish: Finish): Promise<number>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { verifyStep } from "../verify/run.js";
|
|
2
|
+
import { writeRenderManifest } from "./provenance.js";
|
|
3
|
+
/**
|
|
4
|
+
* What to report after the render manifest is written. A failed render is not a deliverable,
|
|
5
|
+
* so its path never reaches stdout: the one line a caller may relay is the refusal.
|
|
6
|
+
*/
|
|
7
|
+
export function finishLines(file, status) {
|
|
8
|
+
if (status === "FAIL") {
|
|
9
|
+
return { stdout: "", stderr: `mk: QA FAILED, do not deliver: ${file}\n` };
|
|
10
|
+
}
|
|
11
|
+
return { stdout: `${file}\n`, stderr: "" };
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* After a successful render: verify it (QA failure is EXIT.QA_FAIL), write
|
|
15
|
+
* `render-manifest.json` recording that status, then report only what was proven.
|
|
16
|
+
*/
|
|
17
|
+
export async function finishRender(io, finish) {
|
|
18
|
+
const verified = finish.verify
|
|
19
|
+
? await verifyStep(io, { file: finish.record.file, expect: finish.verify })
|
|
20
|
+
: { code: 0, status: "not-run" };
|
|
21
|
+
await writeRenderManifest(io, { ...finish.record, verify: verified.status });
|
|
22
|
+
const lines = finishLines(finish.record.file, verified.status);
|
|
23
|
+
if (lines.stdout !== "")
|
|
24
|
+
io.stdout(lines.stdout);
|
|
25
|
+
if (lines.stderr !== "")
|
|
26
|
+
io.stderr(lines.stderr);
|
|
27
|
+
return verified.code;
|
|
28
|
+
}
|