@anvpx/motion-cli 0.1.0 → 0.1.1
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/README.md +1 -1
- package/dist/approval/hash.js +18 -5
- package/dist/plugin/install.d.ts +2 -2
- package/dist/plugin/install.js +1 -1
- package/dist/preview/command.js +8 -2
- package/dist/scene/author.js +37 -18
- package/dist/voice/run.d.ts +6 -0
- package/dist/voice/run.js +14 -5
- package/dist/workflow/draft.js +18 -4
- package/dist/workflow/render.js +20 -4
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -105,7 +105,7 @@ Add to the repo-root `package.json`:
|
|
|
105
105
|
|
|
106
106
|
## Install qualification (DL-01)
|
|
107
107
|
|
|
108
|
-
Install is the npm package install plus `mk doctor` hints; `mk plugin install --out <dir>` stays a separate explicit step, never part of npm install. Qualify with `node scripts/qualify-pack.mjs --json`: fresh pack records full inventory/SHA (`tarball.members`, `sha256`, bytes, `node`/`pnpm`/`npm`/`tar` versions, packed `hyperframes` pin vs root pin, `unpublishedInternals`), installs in an empty external consumer with no workspace fallback (`pnpm-workspace.yaml`/`packages/` absent), then runs native `doctor`/`catalog`/`template list`/`template validate explain` plus `new`/`validate`/`build`/`preview --stills 1 --budget-sec 20`/`approve` smoke with missing-target negatives; final `gated-render`/`verify` stay `NOT_RUN` without an explicit yes (do not render). Registry/marketplace stay `NOT_RUN`; isolated install is currently `BLOCKED` (`404` on unpublished `0.0.0` internals, `base: BLOCKED`; see DISTRIBUTION MK-080 choices A/B). Missing guide/index/font/icon/bed, source fallback reachable, wrong pin, broken install, missing venv/WAV all fail closed with actionable nonzero diagnostics (`reinstall @anvpx/motion`, `
|
|
108
|
+
Install is the npm package install plus `mk doctor` hints; `mk plugin install --out <dir>` stays a separate explicit step, never part of npm install. Qualify with `node scripts/qualify-pack.mjs --json`: fresh pack records full inventory/SHA (`tarball.members`, `sha256`, bytes, `node`/`pnpm`/`npm`/`tar` versions, packed `hyperframes` pin vs root pin, `unpublishedInternals`), installs in an empty external consumer with no workspace fallback (`pnpm-workspace.yaml`/`packages/` absent), then runs native `doctor`/`catalog`/`template list`/`template validate explain` plus `new`/`validate`/`build`/`preview --stills 1 --budget-sec 20`/`approve` smoke with missing-target negatives; final `gated-render`/`verify` stay `NOT_RUN` without an explicit yes (do not render). Registry/marketplace stay `NOT_RUN`; isolated install is currently `BLOCKED` (`404` on unpublished `0.0.0` internals, `base: BLOCKED`; see DISTRIBUTION MK-080 choices A/B). Missing guide/index/font/icon/bed, source fallback reachable, wrong pin, broken install, missing venv/WAV all fail closed with actionable nonzero diagnostics (`reinstall @anvpx/motion`, `scaffold`, `mk doctor`, `py -3.12 -m venv .venv-voice`).
|
|
109
109
|
|
|
110
110
|
## Voice and music delivery (DL-01)
|
|
111
111
|
|
package/dist/approval/hash.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { AUDIO_DIR_NAME, normaliseRef as buildNormaliseRef, clipsFromManifest, isContiguous, parseAudioManifest, readRelativeClosure, resolveSceneDir, } from "@anvpx/motion-build";
|
|
4
|
+
import { AUDIO_DIR_NAME, normaliseRef as buildNormaliseRef, clipsFromManifest, isContiguous, parseAudioManifest, placementsFromTimeline, readRelativeClosure, resolveSceneDir, resolveTimeline, validateStoryboardFull, } from "@anvpx/motion-build";
|
|
5
5
|
import { parseMusicBed } from "@anvpx/motion-schema";
|
|
6
6
|
import { sceneContainment } from "../storyboard/containment.js";
|
|
7
7
|
export const MK_DIR = ".mk";
|
|
@@ -267,7 +267,7 @@ const readLedgerSha = async (host, ledgerPath) => {
|
|
|
267
267
|
}
|
|
268
268
|
};
|
|
269
269
|
/** Resolves --audio TimingManifest plus every sibling wav into refs + mix. Fails closed. */
|
|
270
|
-
const readAudioRefs = async (host, projectDir, audioManifestPath) => {
|
|
270
|
+
const readAudioRefs = async (host, projectDir, audioManifestPath, storyboardJson) => {
|
|
271
271
|
let text;
|
|
272
272
|
try {
|
|
273
273
|
text = await host.readText(audioManifestPath);
|
|
@@ -287,7 +287,20 @@ const readAudioRefs = async (host, projectDir, audioManifestPath) => {
|
|
|
287
287
|
throw new Error(`invalid --audio manifest ${audioManifestPath}: ${parsed.problems.join("; ")}`);
|
|
288
288
|
}
|
|
289
289
|
const audioDir = join(dirname(resolve(audioManifestPath)), AUDIO_DIR_NAME);
|
|
290
|
-
|
|
290
|
+
// The manifest is a non-overlapping voice sequence; the film may start a
|
|
291
|
+
// scene 0.5 s early for a transition. Bind the placement the renderer will
|
|
292
|
+
// actually use, not the voice sequence's cumulative start.
|
|
293
|
+
const valid = validateStoryboardFull(storyboardJson);
|
|
294
|
+
if (!valid.ok) {
|
|
295
|
+
throw new Error(`--audio manifest cannot be placed: the storyboard is invalid`);
|
|
296
|
+
}
|
|
297
|
+
const timeline = resolveTimeline(valid.value, parsed.manifest);
|
|
298
|
+
if (!timeline.ok) {
|
|
299
|
+
throw new Error(`--audio manifest ${audioManifestPath} does not fit the storyboard: ` +
|
|
300
|
+
timeline.errors.map((error) => error.message).join("; "));
|
|
301
|
+
}
|
|
302
|
+
const placements = placementsFromTimeline(timeline.value);
|
|
303
|
+
const clips = clipsFromManifest(parsed.manifest, audioDir, placements);
|
|
291
304
|
if (clips.length === 0) {
|
|
292
305
|
throw new Error(`--audio manifest ${audioManifestPath} has no scenes; nothing to mix`);
|
|
293
306
|
}
|
|
@@ -314,7 +327,7 @@ const readAudioRefs = async (host, projectDir, audioManifestPath) => {
|
|
|
314
327
|
throw new Error(`--audio manifest ${audioManifestPath} escapes the project (escape)`);
|
|
315
328
|
}
|
|
316
329
|
const audioManifest = { path: manifestRel, sha256: sha256(text) };
|
|
317
|
-
const mode = clips.length === 1 ? "single" : isContiguous(
|
|
330
|
+
const mode = clips.length === 1 ? "single" : isContiguous(clips) ? "concat" : "adelay";
|
|
318
331
|
return { audio, audioManifest, mix: { mode, clips: mixClips } };
|
|
319
332
|
};
|
|
320
333
|
/** Resolves --music bed wav plus volume and license-evidence receipt. Fails closed. */
|
|
@@ -392,7 +405,7 @@ export async function buildApprovalInputManifest(host, storyboard, opts = {}) {
|
|
|
392
405
|
audio: [],
|
|
393
406
|
};
|
|
394
407
|
}
|
|
395
|
-
const { audio, audioManifest, mix } = await readAudioRefs(host, projectDir, opts.audioManifest);
|
|
408
|
+
const { audio, audioManifest, mix } = await readAudioRefs(host, projectDir, opts.audioManifest, parsed);
|
|
396
409
|
const music = opts.music === undefined
|
|
397
410
|
? undefined
|
|
398
411
|
: await readMusicRefs(host, projectDir, opts.music, opts.musicVolume, opts.musicLicense);
|
package/dist/plugin/install.d.ts
CHANGED
|
@@ -12,8 +12,8 @@ import type { Command } from "../types.ts";
|
|
|
12
12
|
* at real versions) or choiceB (bundle into one tarball); either lands with
|
|
13
13
|
* a version bump of core manifest + this pin + plugin.json together.
|
|
14
14
|
*/
|
|
15
|
-
export declare const PINNED_CORE_VERSION = "0.1.
|
|
16
|
-
export declare const PINNED_NPX = "npx -y @anvpx/motion@0.1.
|
|
15
|
+
export declare const PINNED_CORE_VERSION = "0.1.1";
|
|
16
|
+
export declare const PINNED_NPX = "npx -y @anvpx/motion@0.1.1";
|
|
17
17
|
/** Skill file whose sha256 must survive `plugin install` byte for byte. */
|
|
18
18
|
export declare const PLUGIN_SKILL: string;
|
|
19
19
|
export interface PluginEnv {
|
package/dist/plugin/install.js
CHANGED
|
@@ -17,7 +17,7 @@ import { EXIT } from "../exit-codes.js";
|
|
|
17
17
|
* at real versions) or choiceB (bundle into one tarball); either lands with
|
|
18
18
|
* a version bump of core manifest + this pin + plugin.json together.
|
|
19
19
|
*/
|
|
20
|
-
export const PINNED_CORE_VERSION = "0.1.
|
|
20
|
+
export const PINNED_CORE_VERSION = "0.1.1";
|
|
21
21
|
export const PINNED_NPX = `npx -y @anvpx/motion@${PINNED_CORE_VERSION}`;
|
|
22
22
|
/** Skill file whose sha256 must survive `plugin install` byte for byte. */
|
|
23
23
|
export const PLUGIN_SKILL = join("skills", "motion-kit", "SKILL.md");
|
package/dist/preview/command.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
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";
|
|
4
|
+
import { AUDIO_DIR_NAME, CUSTOM_SCENE_TYPE, clipsFromManifest, frozenClips, frozenCopyProblems, frozenMusic, isContiguous, mixAtDeliveryLevel, parseAudioManifest, placementsFromTimeline, readRelativeClosure, resolveSceneDir, resolveTimeline, validateStoryboardFull, } from "@anvpx/motion-build";
|
|
5
5
|
import { buildApprovalInputManifest, hashApprovalInput, storyboardHash, } from "../approval/hash.js";
|
|
6
6
|
import { parseFlags, requireStoryboard, textFlag, usageError } from "../args.js";
|
|
7
7
|
import { EXIT } from "../exit-codes.js";
|
|
@@ -521,7 +521,13 @@ const mixPreviewClip = async (io, pack, work, audioManifest, storyboard, expecte
|
|
|
521
521
|
return EXIT.USAGE;
|
|
522
522
|
}
|
|
523
523
|
const audioDir = join(dirname(resolve(audioManifest)), AUDIO_DIR_NAME);
|
|
524
|
-
|
|
524
|
+
// Place the voice sequence on the film timeline (transitions start scenes
|
|
525
|
+
// early), not on the manifest's cumulative starts.
|
|
526
|
+
const boardLoaded = await readJsonFile(storyboard, io.host);
|
|
527
|
+
const validBoard = boardLoaded.ok ? validateStoryboardFull(boardLoaded.value) : undefined;
|
|
528
|
+
const timeline = validBoard?.ok === true ? resolveTimeline(validBoard.value, parsed.manifest) : undefined;
|
|
529
|
+
const placements = timeline?.ok === true ? placementsFromTimeline(timeline.value) : undefined;
|
|
530
|
+
const clips = clipsFromManifest(parsed.manifest, audioDir, placements);
|
|
525
531
|
if (clips.length === 0) {
|
|
526
532
|
io.stderr(`mk preview: --audio manifest has no scenes; nothing to mix; ${rerun}\n`);
|
|
527
533
|
return EXIT.USAGE;
|
package/dist/scene/author.js
CHANGED
|
@@ -20,7 +20,7 @@ const NEW_FLAGS = {
|
|
|
20
20
|
force: { type: "boolean" },
|
|
21
21
|
};
|
|
22
22
|
const SCENE_JS = `// @ts-check
|
|
23
|
-
import {
|
|
23
|
+
import { ease, interpolate, progress } from "../../runtime/compose.js";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Authored scene: seek(t) must be a pure function of t.
|
|
@@ -29,33 +29,52 @@ import { mountTimeline } from "../../runtime/mount.js";
|
|
|
29
29
|
* @type {import("../../runtime/mount.js").CreateScene}
|
|
30
30
|
*/
|
|
31
31
|
export function createScene(root, config, ctx) {
|
|
32
|
-
|
|
32
|
+
const element = /** @type {HTMLElement | null} */ (root.querySelector(".authored-element"));
|
|
33
|
+
if (!element) throw new Error("authored scene: missing .authored-element");
|
|
34
|
+
// Replace the markup, layout and these values with the direction's composition.
|
|
35
|
+
// Derive every animated value on EVERY seek, including backward seeks.
|
|
36
|
+
const seek = (t) => {
|
|
37
|
+
const amount = ease("out", progress(t, 0, 0.8));
|
|
38
|
+
element.style.opacity = String(amount);
|
|
39
|
+
element.style.transform = "translateY(" + interpolate(24, 0, amount) + "px)";
|
|
40
|
+
};
|
|
41
|
+
seek(0);
|
|
42
|
+
return {
|
|
43
|
+
seek,
|
|
44
|
+
dispose() {
|
|
45
|
+
element.style.removeProperty("opacity");
|
|
46
|
+
element.style.removeProperty("transform");
|
|
47
|
+
},
|
|
48
|
+
ready: Promise.resolve(),
|
|
49
|
+
};
|
|
33
50
|
}
|
|
34
51
|
`;
|
|
35
|
-
const FRAGMENT_HTML = `<div class="authored-
|
|
36
|
-
<
|
|
37
|
-
<h1 class="authored-title" data-anim="rise" data-step="1" data-focus-key="title">Your scene</h1>
|
|
52
|
+
const FRAGMENT_HTML = `<div class="authored-composition">
|
|
53
|
+
<h1 class="authored-element" data-focus-key="subject">Your composition</h1>
|
|
38
54
|
</div>
|
|
39
55
|
`;
|
|
40
|
-
const STYLE_CSS =
|
|
41
|
-
#scene-root .authored-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
#scene-root .authored-title {
|
|
48
|
-
font-size: 96px;
|
|
49
|
-
line-height: 1.05;
|
|
50
|
-
font-weight: 800;
|
|
51
|
-
letter-spacing: -0.02em;
|
|
56
|
+
const STYLE_CSS = `/* A blank starting surface: design its grid, layers and type from the brief. */
|
|
57
|
+
#scene-root .authored-composition { display: grid; place-items: center; width: 100%; height: 100%; }
|
|
58
|
+
#scene-root .authored-element {
|
|
59
|
+
margin: 0;
|
|
60
|
+
font: inherit;
|
|
61
|
+
font-size: clamp(32px, 6vw, 96px);
|
|
62
|
+
color: var(--mk-color-fg);
|
|
52
63
|
}
|
|
53
64
|
`;
|
|
54
65
|
const notes = (id) => `# ${id}
|
|
55
66
|
|
|
56
67
|
Why this scene exists: <one line>.
|
|
57
68
|
|
|
58
|
-
|
|
69
|
+
Direction: <concept, composition, type, motion and sound decisions>.
|
|
70
|
+
|
|
71
|
+
The markup and motion are yours to replace. Shared \`runtime/compose.js\` offers
|
|
72
|
+
\`progress(t, start, duration)\`, \`interpolate(from, to, amount)\`,
|
|
73
|
+
\`stagger(index, step, start)\` and the existing \`ease(name, amount)\` functions.
|
|
74
|
+
All times are seconds. Progress clamps to 0..1; a zero duration is a cut.
|
|
75
|
+
Assign every animated property from t so seeking A -> B -> A restores A.
|
|
76
|
+
|
|
77
|
+
Promotion to the catalog (\`mk scene promote\`) is optional when reuse justifies it.
|
|
59
78
|
`;
|
|
60
79
|
const FILES = (id) => [
|
|
61
80
|
["scene.js", SCENE_JS],
|
package/dist/voice/run.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface VoiceRunOptions {
|
|
|
13
13
|
export declare const VOICE_WAV_FALLBACK_NOTE = "pre-rendered WAV fallback in pack";
|
|
14
14
|
export declare const VOICE_MEASURED_QC_NOTE = "human QC via `mk qc` (pronunciation/intelligibility/pacing/alignment/no-silence)";
|
|
15
15
|
export declare const VOICE_ENV_NOTE = "BLOCKED_ENV stays NOT_RUN, never PASS";
|
|
16
|
+
/**
|
|
17
|
+
* Tokens the aligner cannot match: a number with no letters (`01`, `$480`,
|
|
18
|
+
* `1,200`). `mk voice` must fail before synthesis with a fix, not after the
|
|
19
|
+
* aligner reports `clean_word empty`.
|
|
20
|
+
*/
|
|
21
|
+
export declare const unspokenNarrationTokens: (narration: string) => string[];
|
|
16
22
|
/**
|
|
17
23
|
* Synthesize every narrated base scene, align words, gate silence, and write
|
|
18
24
|
* `.mk/<stem>/voice-manifest.json` + licences-style provenance. Resolves to
|
package/dist/voice/run.js
CHANGED
|
@@ -74,16 +74,24 @@ const gateSilence = async (io, sceneId, audioPath, words) => {
|
|
|
74
74
|
return { silences: silence.intervals };
|
|
75
75
|
};
|
|
76
76
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
77
|
+
* Tokens the aligner cannot match: a number with no letters (`01`, `$480`,
|
|
78
|
+
* `1,200`). `mk voice` must fail before synthesis with a fix, not after the
|
|
79
|
+
* aligner reports `clean_word empty`.
|
|
80
|
+
*/
|
|
81
|
+
export const unspokenNarrationTokens = (narration) => splitTokens(narration).filter((token) => /[0-9]/.test(token) && !/\p{L}/u.test(token));
|
|
82
|
+
/**
|
|
83
|
+
* A6: narration must fit the scene at 180 wpm before anything is synthesized,
|
|
84
|
+
* and numbers must be written as spoken. The render-time reading/narration
|
|
85
|
+
* checks then cannot fail for a length the voice command could have refused.
|
|
80
86
|
*/
|
|
81
87
|
function narrationFitProblems(board) {
|
|
82
88
|
const timeline = resolveTimeline(board.board);
|
|
83
89
|
if (!timeline.ok)
|
|
84
90
|
return timeline.errors.map((error) => `${error.path}: ${error.message}`);
|
|
85
91
|
const available = new Map(timeline.value.scenes.map((scene) => [scene.id, scene.duration]));
|
|
86
|
-
|
|
92
|
+
const unspoken = board.narrated.flatMap((scene) => unspokenNarrationTokens(scene.narration ?? "").map((token) => `"${scene.id}": token "${token}" has no letters; write numbers as spoken ` +
|
|
93
|
+
`(for example "zero one" or "four hundred eighty")`));
|
|
94
|
+
const lengths = board.narrated.flatMap((scene) => {
|
|
87
95
|
const count = splitTokens(scene.narration ?? "").length;
|
|
88
96
|
const needed = Math.ceil(count / WORDS_PER_SECOND);
|
|
89
97
|
const seconds = available.get(scene.id) ?? scene.duration ?? 0;
|
|
@@ -95,6 +103,7 @@ function narrationFitProblems(board) {
|
|
|
95
103
|
`scene is ${seconds}s; set duration >= ${needed} or cut ${excess} word(s)`,
|
|
96
104
|
];
|
|
97
105
|
});
|
|
106
|
+
return [...unspoken, ...lengths];
|
|
98
107
|
}
|
|
99
108
|
/** Synth + align + silence gate for one narrated scene; fails closed, never fakes.
|
|
100
109
|
* `j*` (Japanese-pipeline) voices are unaligned-only: the EN wav2vec2 aligner
|
|
@@ -160,7 +169,7 @@ export async function runVoice(storyboard, options, io) {
|
|
|
160
169
|
return board;
|
|
161
170
|
const misfit = narrationFitProblems(board);
|
|
162
171
|
if (misfit.length > 0) {
|
|
163
|
-
io.stderr(`mk voice: narration
|
|
172
|
+
io.stderr(`mk voice: narration cannot be synthesized yet:\n${misfit.map((line) => ` - ${line}`).join("\n")}\n`);
|
|
164
173
|
return EXIT.USAGE;
|
|
165
174
|
}
|
|
166
175
|
const picked = await pickPython(io, options.python);
|
package/dist/workflow/draft.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dirname, join, resolve } from "node:path";
|
|
2
2
|
import { validateMusicBed } from "@anvpx/motion-audio";
|
|
3
|
-
import { AUDIO_DIR_NAME, clipsFromManifest, missingMusic, mixAtDeliveryLevel, parseAudioManifest, } from "@anvpx/motion-build";
|
|
3
|
+
import { AUDIO_DIR_NAME, clipsFromManifest, missingMusic, mixAtDeliveryLevel, parseAudioManifest, placementsFromTimeline, resolveTimeline, validateStoryboardFull, } from "@anvpx/motion-build";
|
|
4
4
|
import { textFlag, usageError } from "../args.js";
|
|
5
5
|
import { EXIT } from "../exit-codes.js";
|
|
6
6
|
import { readJsonFile } from "../storyboard/load.js";
|
|
@@ -46,8 +46,19 @@ const downscale = (input, output) => [
|
|
|
46
46
|
"copy",
|
|
47
47
|
output,
|
|
48
48
|
];
|
|
49
|
+
/** Final-timeline start/duration per narrated scene; undefined when the board cannot resolve. */
|
|
50
|
+
async function voicePlacements(storyboard, manifest, io) {
|
|
51
|
+
const loaded = await readJsonFile(storyboard, io.host);
|
|
52
|
+
if (!loaded.ok)
|
|
53
|
+
return undefined;
|
|
54
|
+
const valid = validateStoryboardFull(loaded.value);
|
|
55
|
+
if (!valid.ok)
|
|
56
|
+
return undefined;
|
|
57
|
+
const timeline = resolveTimeline(valid.value, manifest);
|
|
58
|
+
return timeline.ok ? placementsFromTimeline(timeline.value) : undefined;
|
|
59
|
+
}
|
|
49
60
|
/** Loads a voice TimingManifest plus its sibling wavs; fails closed, never silent. */
|
|
50
|
-
async function loadNarration(audioPath, io) {
|
|
61
|
+
async function loadNarration(storyboard, audioPath, io) {
|
|
51
62
|
const loaded = await readJsonFile(audioPath, io.host);
|
|
52
63
|
if (!loaded.ok) {
|
|
53
64
|
io.stderr(`mk draft: cannot read --audio manifest: ${loaded.message}\n`);
|
|
@@ -59,7 +70,8 @@ async function loadNarration(audioPath, io) {
|
|
|
59
70
|
return { ok: false, code: EXIT.USAGE };
|
|
60
71
|
}
|
|
61
72
|
const audioDir = join(dirname(resolve(audioPath)), AUDIO_DIR_NAME);
|
|
62
|
-
const
|
|
73
|
+
const placements = await voicePlacements(storyboard, parsed.manifest, io);
|
|
74
|
+
const clips = clipsFromManifest(parsed.manifest, audioDir, placements);
|
|
63
75
|
if (clips.length === 0) {
|
|
64
76
|
io.stderr("mk draft: --audio manifest has no scenes; nothing to mix\n");
|
|
65
77
|
return { ok: false, code: EXIT.USAGE };
|
|
@@ -101,7 +113,9 @@ async function resolveDraftPlan(args, io) {
|
|
|
101
113
|
code: usageError("draft", USAGE, "--music needs --audio: the bed loops to the narration length", io).code,
|
|
102
114
|
};
|
|
103
115
|
}
|
|
104
|
-
const narration = audioPath === undefined
|
|
116
|
+
const narration = audioPath === undefined
|
|
117
|
+
? undefined
|
|
118
|
+
: await loadNarration(parsed.request.storyboard, audioPath, io);
|
|
105
119
|
if (narration !== undefined && !narration.ok)
|
|
106
120
|
return { code: narration.code };
|
|
107
121
|
const bedded = musicPath === undefined ? undefined : await loadMusic(musicPath, io);
|
package/dist/workflow/render.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dirname, join, resolve } from "node:path";
|
|
2
2
|
import { validateMusicBed } from "@anvpx/motion-audio";
|
|
3
|
-
import { AUDIO_DIR_NAME, clipsFromManifest, compiledAssetMismatches, frozenClips, frozenCopyProblems, frozenMusic, isContiguous, missingMusic, mixAtDeliveryLevel, parseAudioManifest, } from "@anvpx/motion-build";
|
|
3
|
+
import { AUDIO_DIR_NAME, clipsFromManifest, compiledAssetMismatches, frozenClips, frozenCopyProblems, frozenMusic, isContiguous, missingMusic, mixAtDeliveryLevel, parseAudioManifest, placementsFromTimeline, resolveTimeline, validateStoryboardFull, } from "@anvpx/motion-build";
|
|
4
4
|
import { approvalState, readApproval } from "../approval/approval.js";
|
|
5
5
|
import { buildApprovalInputManifest, hashApprovalInput, } from "../approval/hash.js";
|
|
6
6
|
import { textFlag, usageError } from "../args.js";
|
|
@@ -133,8 +133,19 @@ export async function exportArtifact(io, outDir, format) {
|
|
|
133
133
|
/** sha256 of zero bytes: a silent/empty wav can never qualify as audible proof. */
|
|
134
134
|
const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
135
135
|
const isFps = (text) => /^\d+$/.test(text) && Number(text) >= 1 && Number(text) <= MAX_FPS;
|
|
136
|
+
/** Final-timeline start/duration per narrated scene; undefined when the board cannot resolve. */
|
|
137
|
+
async function voicePlacements(storyboard, manifest, io) {
|
|
138
|
+
const loaded = await readJsonFile(storyboard, io.host);
|
|
139
|
+
if (!loaded.ok)
|
|
140
|
+
return undefined;
|
|
141
|
+
const valid = validateStoryboardFull(loaded.value);
|
|
142
|
+
if (!valid.ok)
|
|
143
|
+
return undefined;
|
|
144
|
+
const timeline = resolveTimeline(valid.value, manifest);
|
|
145
|
+
return timeline.ok ? placementsFromTimeline(timeline.value) : undefined;
|
|
146
|
+
}
|
|
136
147
|
/** Loads a voice TimingManifest plus its sibling wavs; fails closed, never silent. */
|
|
137
|
-
async function loadNarration(name, audioPath, io) {
|
|
148
|
+
async function loadNarration(name, storyboard, audioPath, io) {
|
|
138
149
|
const loaded = await readJsonFile(audioPath, io.host);
|
|
139
150
|
if (!loaded.ok) {
|
|
140
151
|
io.stderr(`mk ${name}: cannot read --audio manifest: ${loaded.message}\n`);
|
|
@@ -146,7 +157,10 @@ async function loadNarration(name, audioPath, io) {
|
|
|
146
157
|
return { ok: false, code: EXIT.USAGE };
|
|
147
158
|
}
|
|
148
159
|
const audioDir = join(dirname(resolve(audioPath)), AUDIO_DIR_NAME);
|
|
149
|
-
|
|
160
|
+
// The voice manifest is a non-overlapping audio sequence; the picture starts
|
|
161
|
+
// scenes early for transitions. Place each wav on the resolved film timeline.
|
|
162
|
+
const placements = await voicePlacements(storyboard, parsed.manifest, io);
|
|
163
|
+
const clips = clipsFromManifest(parsed.manifest, audioDir, placements);
|
|
150
164
|
if (clips.length === 0) {
|
|
151
165
|
io.stderr(`mk ${name}: --audio manifest has no scenes; nothing to mix\n`);
|
|
152
166
|
return { ok: false, code: EXIT.USAGE };
|
|
@@ -249,7 +263,9 @@ async function resolvePlan(args, io) {
|
|
|
249
263
|
code: usageError("render", USAGE, "--music needs --audio: the bed loops to the narration length", io).code,
|
|
250
264
|
};
|
|
251
265
|
}
|
|
252
|
-
const narration = audioPath === undefined
|
|
266
|
+
const narration = audioPath === undefined
|
|
267
|
+
? undefined
|
|
268
|
+
: await loadNarration("render", parsed.request.storyboard, audioPath, io);
|
|
253
269
|
if (narration !== undefined && !narration.ok)
|
|
254
270
|
return { code: narration.code };
|
|
255
271
|
const bedded = musicPath === undefined ? undefined : await loadMusic("render", musicPath, io, musicVolume);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anvpx/motion-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "The mk CLI for @anvpx/motion: validate, preview, approve and render storyboards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "anvpx",
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
"dist/**/*.d.ts"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@anvpx/motion-audio": "0.1.
|
|
34
|
-
"@anvpx/motion-build": "0.1.
|
|
35
|
-
"@anvpx/motion-schema": "0.1.
|
|
36
|
-
"@anvpx/motion-templates": "0.1.
|
|
33
|
+
"@anvpx/motion-audio": "0.1.1",
|
|
34
|
+
"@anvpx/motion-build": "0.1.1",
|
|
35
|
+
"@anvpx/motion-schema": "0.1.1",
|
|
36
|
+
"@anvpx/motion-templates": "0.1.1"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"test:integration": "vitest run --config vitest.integration.config.ts"
|