@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 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`, `copy-dist-assets`, `mk doctor`, `py -3.12 -m venv .venv-voice`).
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
 
@@ -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
- const clips = clipsFromManifest(parsed.manifest, audioDir);
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(parsed.manifest.scenes) ? "concat" : "adelay";
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);
@@ -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.0";
16
- export declare const PINNED_NPX = "npx -y @anvpx/motion@0.1.0";
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 {
@@ -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.0";
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");
@@ -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
- const clips = clipsFromManifest(parsed.manifest, audioDir);
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;
@@ -20,7 +20,7 @@ const NEW_FLAGS = {
20
20
  force: { type: "boolean" },
21
21
  };
22
22
  const SCENE_JS = `// @ts-check
23
- import { mountTimeline } from "../../runtime/mount.js";
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
- return mountTimeline(root, config, ctx, { baseDelay: 0.3, staggerFrames: 6, rise: 48 });
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-stack">
36
- <p class="authored-kicker" data-anim="rise" data-step="0" data-focus-key="kicker">Kicker</p>
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 = `#scene-root .authored-stack { display: flex; flex-direction: column; gap: 24px; }
41
- #scene-root .authored-kicker {
42
- font-size: 22px;
43
- letter-spacing: 0.12em;
44
- text-transform: uppercase;
45
- color: var(--mk-color-accent);
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
- Promote it to the catalog (\`mk scene promote\`) once two projects use it.
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],
@@ -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
- * 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.
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
- return board.narrated.flatMap((scene) => {
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 does not fit before synthesis:\n${misfit.map((line) => ` - ${line}`).join("\n")}\n`);
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);
@@ -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 clips = clipsFromManifest(parsed.manifest, audioDir);
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 ? undefined : await loadNarration(audioPath, io);
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);
@@ -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
- const clips = clipsFromManifest(parsed.manifest, audioDir);
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 ? undefined : await loadNarration("render", audioPath, io);
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.0",
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.0",
34
- "@anvpx/motion-build": "0.1.0",
35
- "@anvpx/motion-schema": "0.1.0",
36
- "@anvpx/motion-templates": "0.1.0"
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"