@anvpx/motion-build 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.
@@ -9,6 +9,11 @@ export interface MixClip {
9
9
  readonly duration: number;
10
10
  readonly wav: string;
11
11
  }
12
+ /** Final-timeline placement for one scene id (start/duration after overlaps). */
13
+ export interface MixPlacement {
14
+ readonly start: number;
15
+ readonly duration: number;
16
+ }
12
17
  /** Opt-in build input: which manifest to mix and where its wavs live. */
13
18
  export interface AudioManifestRef {
14
19
  readonly manifest: TimingManifest;
@@ -25,8 +30,8 @@ export type AudioManifestResult = {
25
30
  };
26
31
  /** Wav path for one scene id inside the manifest's audio directory. */
27
32
  export declare const wavPathFor: (audioDir: string, id: string) => string;
28
- /** Manifest scenes in order, each resolved to its wav path. */
29
- export declare const clipsFromManifest: (manifest: TimingManifest, audioDir: string) => MixClip[];
33
+ /** Manifest scenes in order, each resolved to its wav path and, when given, the final timeline placement. */
34
+ export declare const clipsFromManifest: (manifest: TimingManifest, audioDir: string, placements?: ReadonlyMap<string, MixPlacement>) => MixClip[];
30
35
  /** Clips whose wav is absent; non-empty means refuse to mix (fail closed). */
31
36
  export declare const missingClips: (clips: readonly MixClip[], exists: (path: string) => boolean) => MixClip[];
32
37
  /** Whole seconds to integer ffmpeg milliseconds; refuses nonsense. */
@@ -45,8 +50,9 @@ export declare const isContiguous: (scenes: readonly {
45
50
  export declare const buildFilterGraph: (clips: readonly MixClip[]) => string;
46
51
  /**
47
52
  * Deterministic ffmpeg argv: silent video plus one `-i` per wav, video passed
48
- * through (`-c:v copy`), narration mixed to aac, `-shortest` trimming any
49
- * sub-frame tail so the output never outruns the picture.
53
+ * through (`-c:v copy`), narration mixed to aac. No `-shortest`: the picture is
54
+ * the timing authority, and a scene may hold silent after its narration ends.
55
+ * Trimming to the last wav would cut the authored tail (and the video with it).
50
56
  */
51
57
  export declare const buildMixArgs: (videoIn: string, clips: readonly MixClip[], out: string) => string[];
52
58
  /** Validates an unknown document as a mixable TimingManifest (fail closed). */
@@ -136,7 +142,8 @@ export declare const musicLoopFor: (bedDuration: number, clips: readonly MixClip
136
142
  /**
137
143
  * Deterministic ffmpeg argv with a ducked bed: silent video plus one `-i`
138
144
  * per narration wav plus the bed wav last, video passed through
139
- * (`-c:v copy`), bed ducked under narration and mixed to aac, `-shortest`.
145
+ * (`-c:v copy`), bed ducked under narration and mixed to aac. No `-shortest`;
146
+ * the picture keeps its authored tail after the last word.
140
147
  */
141
148
  export declare const buildMusicMixArgs: (videoIn: string, clips: readonly MixClip[], music: MusicBed, out: string) => string[];
142
149
  /**
package/dist/audio/mix.js CHANGED
@@ -20,13 +20,19 @@ import { MIX_TRUE_PEAK_DBTP, SIDECHAIN_ATTACK_MS, SIDECHAIN_RATIO, SIDECHAIN_REL
20
20
  export const AUDIO_DIR_NAME = "audio";
21
21
  /** Wav path for one scene id inside the manifest's audio directory. */
22
22
  export const wavPathFor = (audioDir, id) => join(audioDir, `${id}.wav`);
23
- /** Manifest scenes in order, each resolved to its wav path. */
24
- export const clipsFromManifest = (manifest, audioDir) => manifest.scenes.map((scene) => ({
25
- id: scene.id,
26
- start: scene.start,
27
- duration: scene.duration,
28
- wav: wavPathFor(audioDir, scene.id),
29
- }));
23
+ /** Manifest scenes in order, each resolved to its wav path and, when given, the final timeline placement. */
24
+ export const clipsFromManifest = (manifest, audioDir, placements) => manifest.scenes.map((scene) => {
25
+ const placed = placements?.get(scene.id);
26
+ return {
27
+ id: scene.id,
28
+ // The voice manifest is a non-overlapping audio sequence; the film
29
+ // timeline may start scenes 0.5 s early for a transition. The mix must
30
+ // follow the picture, not the voice sequence.
31
+ start: placed?.start ?? scene.start,
32
+ duration: placed?.duration ?? scene.duration,
33
+ wav: wavPathFor(audioDir, scene.id),
34
+ };
35
+ });
30
36
  /** Clips whose wav is absent; non-empty means refuse to mix (fail closed). */
31
37
  export const missingClips = (clips, exists) => clips.filter((clip) => !exists(clip.wav));
32
38
  /** Whole seconds to integer ffmpeg milliseconds; refuses nonsense. */
@@ -78,8 +84,9 @@ const narrationGraph = (clips, outLabel) => {
78
84
  };
79
85
  /**
80
86
  * Deterministic ffmpeg argv: silent video plus one `-i` per wav, video passed
81
- * through (`-c:v copy`), narration mixed to aac, `-shortest` trimming any
82
- * sub-frame tail so the output never outruns the picture.
87
+ * through (`-c:v copy`), narration mixed to aac. No `-shortest`: the picture is
88
+ * the timing authority, and a scene may hold silent after its narration ends.
89
+ * Trimming to the last wav would cut the authored tail (and the video with it).
83
90
  */
84
91
  export const buildMixArgs = (videoIn, clips, out) => [
85
92
  "-y",
@@ -98,7 +105,6 @@ export const buildMixArgs = (videoIn, clips, out) => [
98
105
  "aac",
99
106
  "-movflags",
100
107
  "+faststart",
101
- "-shortest",
102
108
  out,
103
109
  ];
104
110
  /** Validates an unknown document as a mixable TimingManifest (fail closed). */
@@ -167,7 +173,7 @@ export const buildMusicFilterGraph = (clips, music) => {
167
173
  const sidechain = `sidechaincompress=threshold=${SIDECHAIN_THRESHOLD}:ratio=${SIDECHAIN_RATIO}` +
168
174
  `:attack=${SIDECHAIN_ATTACK_MS}:release=${SIDECHAIN_RELEASE_MS}`;
169
175
  return (`[${musicIndex}:a]volume=${volume},aloop=loop=-1:size=2e9,atrim=duration=${total}[mbed];` +
170
- `${narr};[narr]asplit[nkey][nmix];` +
176
+ `${narr};[narr]apad=whole_dur=${total},asplit[nkey][nmix];` +
171
177
  `[mbed][nkey]${sidechain}[ducked];` +
172
178
  `[nmix][ducked]amix=inputs=2:normalize=0[aout]`);
173
179
  };
@@ -264,7 +270,8 @@ export const musicLoopFor = (bedDuration, clips) => {
264
270
  /**
265
271
  * Deterministic ffmpeg argv with a ducked bed: silent video plus one `-i`
266
272
  * per narration wav plus the bed wav last, video passed through
267
- * (`-c:v copy`), bed ducked under narration and mixed to aac, `-shortest`.
273
+ * (`-c:v copy`), bed ducked under narration and mixed to aac. No `-shortest`;
274
+ * the picture keeps its authored tail after the last word.
268
275
  */
269
276
  export const buildMusicMixArgs = (videoIn, clips, music, out) => [
270
277
  "-y",
@@ -285,7 +292,6 @@ export const buildMusicMixArgs = (videoIn, clips, music, out) => [
285
292
  "aac",
286
293
  "-movflags",
287
294
  "+faststart",
288
- "-shortest",
289
295
  out,
290
296
  ];
291
297
  /**
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { CUSTOM_SCENE_TYPE, type CustomSceneFile, type CustomSceneSource, collec
2
2
  export { bundledThemeDocs } from "@anvpx/motion-themes";
3
3
  export { type AssetCollector, type CopiedAsset, copyAssets, createAssetCollector, normaliseRef, sha256Of, } from "./assets.ts";
4
4
  export { type FrozenCopyCheck, frozenCopyProblems, } from "./audio/approval.ts";
5
- export { AT_TARGET_TOLERANCE_LU, AUDIO_DIR_NAME, type AudioManifestRef, type AudioManifestResult, buildFilterGraph, buildLoudnessMeasureArgs, buildLoudnessNormalizeArgs, buildLoudnessNormalizeFilter, buildMixArgs, buildMusicFilterGraph, buildMusicMixArgs, clipsFromManifest, DEFAULT_MUSIC_VOLUME, frozenClips, frozenMusic, frozenWavFor, isContiguous, LOUDNORM_MEASURE_FILTER, type LoudnessMeasurement, type MixClip, type MixDelivery, type MixDeliveryPlan, type MixRunner, type MixRunResult, type MusicBed, missingClips, missingMusic, mixAtDeliveryLevel, needsNormalization, parseAudioManifest, parseLoudnessMeasurement, resolveMusicVolume, TARGET_LRA, TARGET_LUFS, TARGET_TRUE_PEAK_DB, toDelayMs, wavPathFor, } from "./audio/mix.ts";
5
+ export { AT_TARGET_TOLERANCE_LU, AUDIO_DIR_NAME, type AudioManifestRef, type AudioManifestResult, buildFilterGraph, buildLoudnessMeasureArgs, buildLoudnessNormalizeArgs, buildLoudnessNormalizeFilter, buildMixArgs, buildMusicFilterGraph, buildMusicMixArgs, clipsFromManifest, DEFAULT_MUSIC_VOLUME, frozenClips, frozenMusic, frozenWavFor, isContiguous, LOUDNORM_MEASURE_FILTER, type LoudnessMeasurement, type MixClip, type MixDelivery, type MixDeliveryPlan, type MixPlacement, type MixRunner, type MixRunResult, type MusicBed, missingClips, missingMusic, mixAtDeliveryLevel, needsNormalization, parseAudioManifest, parseLoudnessMeasurement, resolveMusicVolume, TARGET_LRA, TARGET_LUFS, TARGET_TRUE_PEAK_DB, toDelayMs, wavPathFor, } from "./audio/mix.ts";
6
6
  export { type BuildOptions, type BuildResult, buildProject, tryResolveTheme, } from "./build.ts";
7
7
  export { type CacheStats, clearCache } from "./cache.ts";
8
8
  export { CSP } from "./emit/index-html.ts";
@@ -14,6 +14,6 @@ export { WORDS_PER_SECOND } from "./lint/reading-time.ts";
14
14
  export { type BuildManifest, type CompiledAssetView, compiledAssetMismatches, } from "./manifest.ts";
15
15
  export { createRenderManifest, type RenderManifest, type RenderManifestInput, renderRenderManifest, } from "./render-manifest.ts";
16
16
  export * from "./templates/index.ts";
17
- export { autoDuration, resolveTimeline, type TimedScene, type Timeline, } from "./timing.ts";
17
+ export { autoDuration, placementsFromTimeline, resolveTimeline, type ScenePlacement, type TimedScene, type Timeline, } from "./timing.ts";
18
18
  export { validateStoryboardFull } from "./validate-full.ts";
19
19
  export { type FindingLevel, type MediaExpectation, type VerifyFinding, type VerifyInput, type VerifyReport, type VerifyStatus, verifyMedia, } from "./verify.ts";
package/dist/index.js CHANGED
@@ -14,6 +14,6 @@ export { WORDS_PER_SECOND } from "./lint/reading-time.js";
14
14
  export { compiledAssetMismatches, } from "./manifest.js";
15
15
  export { createRenderManifest, renderRenderManifest, } from "./render-manifest.js";
16
16
  export * from "./templates/index.js";
17
- export { autoDuration, resolveTimeline, } from "./timing.js";
17
+ export { autoDuration, placementsFromTimeline, resolveTimeline, } from "./timing.js";
18
18
  export { validateStoryboardFull } from "./validate-full.js";
19
19
  export { verifyMedia, } from "./verify.js";
package/dist/timing.d.ts CHANGED
@@ -32,6 +32,13 @@ export interface Timeline {
32
32
  readonly overlays: readonly TimedOverlay[];
33
33
  readonly total: number;
34
34
  }
35
+ /** One scene's placement on the final (overlap-aware) timeline. */
36
+ export interface ScenePlacement {
37
+ readonly start: number;
38
+ readonly duration: number;
39
+ }
40
+ /** Map a resolved timeline to the placement a narration clip must use. */
41
+ export declare const placementsFromTimeline: (timeline: Timeline) => Map<string, ScenePlacement>;
35
42
  export declare const autoDuration: (words: number) => number;
36
43
  export declare const isOverlay: (entry: SceneEntry) => boolean;
37
44
  /** Authored durations, optionally overridden per base scene id by a voice TimingManifest. */
package/dist/timing.js CHANGED
@@ -8,10 +8,20 @@ export const IMPLEMENTED_TRANSITIONS = [
8
8
  "wipe",
9
9
  "zoom-through",
10
10
  ];
11
+ /** Map a resolved timeline to the placement a narration clip must use. */
12
+ export const placementsFromTimeline = (timeline) => new Map(timeline.scenes.map((scene) => [scene.id, { start: scene.start, duration: scene.duration }]));
11
13
  const round3 = (value) => Math.round(value * 1000) / 1000;
12
14
  export const autoDuration = (words) => Math.max(MIN_AUTO_SECONDS, Math.ceil(words / 3 + 1.5));
13
15
  export const isOverlay = (entry) => entry.layer === "overlay";
14
- const durationOf = (entry, voice) => round3(voice.get(entry.id) ?? entry.duration ?? autoDuration(sceneWords(entry.props, entry.type)));
16
+ const durationOf = (entry, voice) => {
17
+ const authored = entry.duration ?? autoDuration(sceneWords(entry.props, entry.type));
18
+ const voiced = voice.get(entry.id);
19
+ // Voice is a floor on the spoken scene, never a reason to cut the authored
20
+ // picture shorter. `mk voice` preflights narration against the authored
21
+ // duration, so this normally keeps the authored value and leaves the tail of
22
+ // the scene silent; a longer manifest wins so narration can never clip.
23
+ return round3(voiced === undefined ? authored : Math.max(authored, voiced));
24
+ };
15
25
  const overlapOf = (entry, prev, own) => entry.transition === "crossfade" ||
16
26
  entry.transition === "push" ||
17
27
  entry.transition === "wipe" ||
package/dist/verify.js CHANGED
@@ -27,6 +27,7 @@ const videoOf = (stream) => ({
27
27
  ? { fps: parseRate(stream.r_frame_rate) }
28
28
  : {}),
29
29
  ...(count(stream.nb_frames) !== undefined ? { frames: count(stream.nb_frames) } : {}),
30
+ ...(count(stream.duration) !== undefined ? { duration: count(stream.duration) } : {}),
30
31
  });
31
32
  const readProbe = (probe) => {
32
33
  const root = asRecord(probe);
@@ -67,13 +68,16 @@ function checkFrame(video, expected, add) {
67
68
  function checkTiming(probed, expected, add) {
68
69
  const { video, duration } = probed;
69
70
  const fps = video?.fps;
70
- if (expected.duration !== undefined && duration !== undefined) {
71
+ // Measure the picture. The container can be ~0.17 s longer than the video
72
+ // because AAC adds encoder priming/padding; that is not a timing failure.
73
+ const measured = video?.duration ?? duration;
74
+ if (expected.duration !== undefined && measured !== undefined) {
71
75
  const tolerance = Math.max(DURATION_TOLERANCE_SECONDS, fps ? 1 / fps : 0);
72
- if (Math.abs(duration - expected.duration) > tolerance) {
73
- add("duration-mismatch", "fatal", `Duration is ${duration.toFixed(2)}s, expected ${expected.duration.toFixed(2)}s.`);
76
+ if (Math.abs(measured - expected.duration) > tolerance) {
77
+ add("duration-mismatch", "fatal", `Duration is ${measured.toFixed(2)}s, expected ${expected.duration.toFixed(2)}s.`);
74
78
  }
75
79
  }
76
- const reference = expected.duration ?? duration;
80
+ const reference = expected.duration ?? video?.duration ?? duration;
77
81
  if (video?.frames === undefined || fps === undefined || reference === undefined)
78
82
  return;
79
83
  const wanted = Math.round(reference * fps);
@@ -136,8 +140,9 @@ function checkPlatform(probed, template, add) {
136
140
  if (video)
137
141
  checkPlatformFrame(video, template, add);
138
142
  const cap = template.maxDurationSec;
139
- if (cap !== undefined && duration !== undefined && duration > cap) {
140
- add("platform-duration", "fatal", `Duration ${duration.toFixed(1)}s exceeds the ${id} cap ${cap}s.`);
143
+ const measured = video?.duration ?? duration;
144
+ if (cap !== undefined && measured !== undefined && measured > cap) {
145
+ add("platform-duration", "fatal", `Duration ${measured.toFixed(1)}s exceeds the ${id} cap ${cap}s.`);
141
146
  }
142
147
  if (!template.verified) {
143
148
  add("platform-unverified", "info", `Template ${id} has verified: false; its limits are approximations. Check the platform's current specs.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anvpx/motion-build",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Storyboard compiler for @anvpx/motion: validation, scene emission and HyperFrames project output.",
5
5
  "license": "MIT",
6
6
  "author": "anvpx",
@@ -31,9 +31,9 @@
31
31
  "@fontsource/jetbrains-mono": "5.3.0",
32
32
  "@fontsource/source-serif-4": "5.3.1",
33
33
  "@fontsource/space-grotesk": "5.3.0",
34
- "@anvpx/motion-scenes": "0.1.0",
35
- "@anvpx/motion-schema": "0.1.0",
36
- "@anvpx/motion-themes": "0.1.0",
34
+ "@anvpx/motion-scenes": "0.1.1",
35
+ "@anvpx/motion-schema": "0.1.1",
36
+ "@anvpx/motion-themes": "0.1.1",
37
37
  "postprocessing": "6.39.5",
38
38
  "three": "0.186.0"
39
39
  },