@vibeframe/cli 0.53.0 → 0.55.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/dist/commands/_shared/scene-audio-mux.d.ts +75 -0
- package/dist/commands/_shared/scene-audio-mux.d.ts.map +1 -0
- package/dist/commands/_shared/scene-audio-mux.js +159 -0
- package/dist/commands/_shared/scene-audio-mux.js.map +1 -0
- package/dist/commands/_shared/scene-audio-mux.test.d.ts +7 -0
- package/dist/commands/_shared/scene-audio-mux.test.d.ts.map +1 -0
- package/dist/commands/_shared/scene-audio-mux.test.js +94 -0
- package/dist/commands/_shared/scene-audio-mux.test.js.map +1 -0
- package/dist/commands/_shared/scene-audio-scan.d.ts +94 -0
- package/dist/commands/_shared/scene-audio-scan.d.ts.map +1 -0
- package/dist/commands/_shared/scene-audio-scan.js +144 -0
- package/dist/commands/_shared/scene-audio-scan.js.map +1 -0
- package/dist/commands/_shared/scene-audio-scan.test.d.ts +6 -0
- package/dist/commands/_shared/scene-audio-scan.test.d.ts.map +1 -0
- package/dist/commands/_shared/scene-audio-scan.test.js +190 -0
- package/dist/commands/_shared/scene-audio-scan.test.js.map +1 -0
- package/dist/commands/_shared/scene-html-emit.d.ts +35 -0
- package/dist/commands/_shared/scene-html-emit.d.ts.map +1 -1
- package/dist/commands/_shared/scene-html-emit.js +85 -16
- package/dist/commands/_shared/scene-html-emit.js.map +1 -1
- package/dist/commands/_shared/scene-html-emit.test.js +194 -1
- package/dist/commands/_shared/scene-html-emit.test.js.map +1 -1
- package/dist/commands/_shared/scene-project.d.ts +1 -1
- package/dist/commands/_shared/scene-project.d.ts.map +1 -1
- package/dist/commands/_shared/scene-render.d.ts +6 -0
- package/dist/commands/_shared/scene-render.d.ts.map +1 -1
- package/dist/commands/_shared/scene-render.js +41 -1
- package/dist/commands/_shared/scene-render.js.map +1 -1
- package/dist/commands/_shared/tts-resolve.d.ts +75 -0
- package/dist/commands/_shared/tts-resolve.d.ts.map +1 -0
- package/dist/commands/_shared/tts-resolve.js +89 -0
- package/dist/commands/_shared/tts-resolve.js.map +1 -0
- package/dist/commands/_shared/tts-resolve.test.d.ts +6 -0
- package/dist/commands/_shared/tts-resolve.test.d.ts.map +1 -0
- package/dist/commands/_shared/tts-resolve.test.js +147 -0
- package/dist/commands/_shared/tts-resolve.test.js.map +1 -0
- package/dist/commands/scene.d.ts +27 -1
- package/dist/commands/scene.d.ts.map +1 -1
- package/dist/commands/scene.js +120 -13
- package/dist/commands/scene.js.map +1 -1
- package/dist/commands/scene.test.js +87 -1
- package/dist/commands/scene.test.js.map +1 -1
- package/dist/utils/provider-resolver.d.ts.map +1 -1
- package/dist/utils/provider-resolver.js +6 -2
- package/dist/utils/provider-resolver.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module _shared/scene-audio-mux
|
|
3
|
+
*
|
|
4
|
+
* Lay every `<audio>` element discovered by `scene-audio-scan.ts` onto a
|
|
5
|
+
* single audio track and mux it into the producer's silent video output.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: `@hyperframes/producer` renders frames + assembles the
|
|
8
|
+
* MP4 but does not embed audio from sub-composition `<audio>` elements
|
|
9
|
+
* (verified against the upstream Hyperframes reference example). We do the
|
|
10
|
+
* mux ourselves with one ffmpeg pass, copying the producer's video stream
|
|
11
|
+
* untouched so we don't pay a re-encode tax.
|
|
12
|
+
*
|
|
13
|
+
* The filter graph for N audio inputs is:
|
|
14
|
+
*
|
|
15
|
+
* [1:a]adelay=Sms:all=1,volume=V[a1];
|
|
16
|
+
* [2:a]adelay=Sms:all=1,volume=V[a2];
|
|
17
|
+
* ...
|
|
18
|
+
* [a1][a2]...amix=inputs=N:dropout_transition=0:normalize=0[mixed]
|
|
19
|
+
*
|
|
20
|
+
* Single-input case skips the amix (cheaper and avoids amix's auto-gain).
|
|
21
|
+
*
|
|
22
|
+
* Pure construction lives in `buildAudioMuxFilter`. The async `muxAudioIntoVideo`
|
|
23
|
+
* shells out to ffmpeg via the project's existing `execSafe` wrapper.
|
|
24
|
+
*/
|
|
25
|
+
import type { SceneAudioElement } from "./scene-audio-scan.js";
|
|
26
|
+
export interface AudioMuxFilter {
|
|
27
|
+
/** ffmpeg `-filter_complex` string. */
|
|
28
|
+
filterComplex: string;
|
|
29
|
+
/** Output stream label inside the graph (e.g. `[mixed]` or `[a0]`). */
|
|
30
|
+
outLabel: string;
|
|
31
|
+
/** Number of audio inputs the caller must `-i` before this filter. */
|
|
32
|
+
inputCount: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build a filter_complex for the given audio elements. Pure — no I/O.
|
|
36
|
+
*
|
|
37
|
+
* Each element becomes one ffmpeg input (input index 1, 2, ... — input 0 is
|
|
38
|
+
* the silent video). `adelay` shifts the audio to its absolute timeline
|
|
39
|
+
* start, `volume` applies per-element gain, and `amix` blends them. When
|
|
40
|
+
* there's only one element we skip `amix` entirely.
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildAudioMuxFilter(audios: SceneAudioElement[]): AudioMuxFilter | null;
|
|
43
|
+
export interface AudioMuxOptions {
|
|
44
|
+
/** Producer's silent video output (will be replaced with the mux'd file). */
|
|
45
|
+
videoPath: string;
|
|
46
|
+
/** Audio elements with absolute timing, from `scanSceneAudio`. */
|
|
47
|
+
audios: SceneAudioElement[];
|
|
48
|
+
/** Final output container — drives the audio codec choice. */
|
|
49
|
+
format: "mp4" | "webm" | "mov";
|
|
50
|
+
/** Cap output to this many seconds via ffmpeg `-t`. Falls back to `videoDuration`. */
|
|
51
|
+
totalDuration?: number;
|
|
52
|
+
/** Producer's video duration (probed by ffprobe). Used as default cap. */
|
|
53
|
+
videoDuration?: number;
|
|
54
|
+
/** Callback for ffmpeg stderr lines (progress / errors). */
|
|
55
|
+
onProgress?: (line: string) => void;
|
|
56
|
+
}
|
|
57
|
+
export interface AudioMuxResult {
|
|
58
|
+
/** True when ffmpeg succeeded. */
|
|
59
|
+
success: boolean;
|
|
60
|
+
/** Absolute path of the produced file (overwrites the input on success). */
|
|
61
|
+
outputPath: string;
|
|
62
|
+
/** ffmpeg stderr tail when success === false. */
|
|
63
|
+
error?: string;
|
|
64
|
+
/** Number of audio inputs muxed. */
|
|
65
|
+
audioCount: number;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Run ffmpeg to overlay every `<audio>` element onto the producer's silent
|
|
69
|
+
* video. Replaces the input file on success. Idempotent — re-running on the
|
|
70
|
+
* same project repeats the work but produces an equivalent result.
|
|
71
|
+
*
|
|
72
|
+
* No-op (`success: true, audioCount: 0`) when `audios` is empty.
|
|
73
|
+
*/
|
|
74
|
+
export declare function muxAudioIntoVideo(opts: AudioMuxOptions): Promise<AudioMuxResult>;
|
|
75
|
+
//# sourceMappingURL=scene-audio-mux.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scene-audio-mux.d.ts","sourceRoot":"","sources":["../../../src/commands/_shared/scene-audio-mux.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAKH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE/D,MAAM,WAAW,cAAc;IAC7B,uCAAuC;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,uEAAuE;IACvE,QAAQ,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,iBAAiB,EAAE,GAAG,cAAc,GAAG,IAAI,CAwCtF;AAED,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,SAAS,EAAE,MAAM,CAAC;IAClB,kEAAkE;IAClE,MAAM,EAAE,iBAAiB,EAAE,CAAC;IAC5B,8DAA8D;IAC9D,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,CAAC;IAC/B,sFAAsF;IACtF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4DAA4D;IAC5D,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,cAAc;IAC7B,kCAAkC;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,4EAA4E;IAC5E,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,UAAU,EAAE,MAAM,CAAC;CACpB;AAYD;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAmFtF"}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module _shared/scene-audio-mux
|
|
3
|
+
*
|
|
4
|
+
* Lay every `<audio>` element discovered by `scene-audio-scan.ts` onto a
|
|
5
|
+
* single audio track and mux it into the producer's silent video output.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: `@hyperframes/producer` renders frames + assembles the
|
|
8
|
+
* MP4 but does not embed audio from sub-composition `<audio>` elements
|
|
9
|
+
* (verified against the upstream Hyperframes reference example). We do the
|
|
10
|
+
* mux ourselves with one ffmpeg pass, copying the producer's video stream
|
|
11
|
+
* untouched so we don't pay a re-encode tax.
|
|
12
|
+
*
|
|
13
|
+
* The filter graph for N audio inputs is:
|
|
14
|
+
*
|
|
15
|
+
* [1:a]adelay=Sms:all=1,volume=V[a1];
|
|
16
|
+
* [2:a]adelay=Sms:all=1,volume=V[a2];
|
|
17
|
+
* ...
|
|
18
|
+
* [a1][a2]...amix=inputs=N:dropout_transition=0:normalize=0[mixed]
|
|
19
|
+
*
|
|
20
|
+
* Single-input case skips the amix (cheaper and avoids amix's auto-gain).
|
|
21
|
+
*
|
|
22
|
+
* Pure construction lives in `buildAudioMuxFilter`. The async `muxAudioIntoVideo`
|
|
23
|
+
* shells out to ffmpeg via the project's existing `execSafe` wrapper.
|
|
24
|
+
*/
|
|
25
|
+
import { rename, unlink } from "node:fs/promises";
|
|
26
|
+
import { resolve, dirname, extname, basename } from "node:path";
|
|
27
|
+
import { execSafe, commandExists } from "../../utils/exec-safe.js";
|
|
28
|
+
/**
|
|
29
|
+
* Build a filter_complex for the given audio elements. Pure — no I/O.
|
|
30
|
+
*
|
|
31
|
+
* Each element becomes one ffmpeg input (input index 1, 2, ... — input 0 is
|
|
32
|
+
* the silent video). `adelay` shifts the audio to its absolute timeline
|
|
33
|
+
* start, `volume` applies per-element gain, and `amix` blends them. When
|
|
34
|
+
* there's only one element we skip `amix` entirely.
|
|
35
|
+
*/
|
|
36
|
+
export function buildAudioMuxFilter(audios) {
|
|
37
|
+
if (audios.length === 0)
|
|
38
|
+
return null;
|
|
39
|
+
const labels = [];
|
|
40
|
+
const stages = [];
|
|
41
|
+
audios.forEach((a, i) => {
|
|
42
|
+
const inputIdx = i + 1; // input 0 is the video
|
|
43
|
+
const delayMs = Math.max(0, Math.round(a.absoluteStart * 1000));
|
|
44
|
+
const volume = Number.isFinite(a.volume) ? a.volume : 1;
|
|
45
|
+
// Hard-cap audio length at the parent clip's duration so a long wav
|
|
46
|
+
// doesn't bleed past its scene. Use atrim+asetpts for a clean cut.
|
|
47
|
+
const trimSec = Math.max(0, a.clipDurationCap);
|
|
48
|
+
const label = `a${i}`;
|
|
49
|
+
const stage = [
|
|
50
|
+
`[${inputIdx}:a]`,
|
|
51
|
+
`atrim=duration=${trimSec.toFixed(3)},`,
|
|
52
|
+
`asetpts=PTS-STARTPTS,`,
|
|
53
|
+
`adelay=${delayMs}:all=1,`,
|
|
54
|
+
`volume=${volume}`,
|
|
55
|
+
`[${label}]`,
|
|
56
|
+
].join("");
|
|
57
|
+
stages.push(stage);
|
|
58
|
+
labels.push(`[${label}]`);
|
|
59
|
+
});
|
|
60
|
+
if (audios.length === 1) {
|
|
61
|
+
return {
|
|
62
|
+
filterComplex: stages.join(";"),
|
|
63
|
+
outLabel: labels[0],
|
|
64
|
+
inputCount: 1,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const mix = `${labels.join("")}amix=inputs=${audios.length}:dropout_transition=0:normalize=0[mixed]`;
|
|
68
|
+
return {
|
|
69
|
+
filterComplex: `${stages.join(";")};${mix}`,
|
|
70
|
+
outLabel: "[mixed]",
|
|
71
|
+
inputCount: audios.length,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Audio codec for each container. Stays in sync with what the upstream
|
|
76
|
+
* producer chose for video so the file stays playable.
|
|
77
|
+
*/
|
|
78
|
+
function audioCodecForFormat(format) {
|
|
79
|
+
if (format === "webm")
|
|
80
|
+
return "libopus";
|
|
81
|
+
if (format === "mov")
|
|
82
|
+
return "pcm_s16le"; // ProRes-friendly
|
|
83
|
+
return "aac";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Run ffmpeg to overlay every `<audio>` element onto the producer's silent
|
|
87
|
+
* video. Replaces the input file on success. Idempotent — re-running on the
|
|
88
|
+
* same project repeats the work but produces an equivalent result.
|
|
89
|
+
*
|
|
90
|
+
* No-op (`success: true, audioCount: 0`) when `audios` is empty.
|
|
91
|
+
*/
|
|
92
|
+
export async function muxAudioIntoVideo(opts) {
|
|
93
|
+
if (opts.audios.length === 0) {
|
|
94
|
+
return { success: true, outputPath: opts.videoPath, audioCount: 0 };
|
|
95
|
+
}
|
|
96
|
+
if (!commandExists("ffmpeg")) {
|
|
97
|
+
return {
|
|
98
|
+
success: false,
|
|
99
|
+
outputPath: opts.videoPath,
|
|
100
|
+
audioCount: opts.audios.length,
|
|
101
|
+
error: "ffmpeg not found in PATH — install via `brew install ffmpeg` (mac) or your package manager",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const filter = buildAudioMuxFilter(opts.audios);
|
|
105
|
+
if (!filter) {
|
|
106
|
+
return { success: true, outputPath: opts.videoPath, audioCount: 0 };
|
|
107
|
+
}
|
|
108
|
+
// Write to a sibling temp file then atomic-rename over the original. Avoids
|
|
109
|
+
// corrupting the producer's MP4 if ffmpeg crashes mid-write.
|
|
110
|
+
const ext = extname(opts.videoPath) || `.${opts.format}`;
|
|
111
|
+
const tmpPath = resolve(dirname(opts.videoPath), `.${basename(opts.videoPath, ext)}.muxing${ext}`);
|
|
112
|
+
const args = ["-y", "-loglevel", "error", "-i", opts.videoPath];
|
|
113
|
+
for (const a of opts.audios) {
|
|
114
|
+
args.push("-i", a.srcAbs);
|
|
115
|
+
}
|
|
116
|
+
args.push("-filter_complex", filter.filterComplex, "-map", "0:v", "-map", filter.outLabel, "-c:v", "copy", "-c:a", audioCodecForFormat(opts.format),
|
|
117
|
+
// Cap on the video duration so audio that overruns the producer's render
|
|
118
|
+
// (e.g. a long Kokoro wav on a short scene) doesn't extend the output.
|
|
119
|
+
// Video drives the timeline because the producer already counted frames.
|
|
120
|
+
"-t", (opts.totalDuration && opts.totalDuration > 0
|
|
121
|
+
? opts.totalDuration.toFixed(3)
|
|
122
|
+
: opts.videoDuration?.toFixed(3) ?? ""));
|
|
123
|
+
// Drop trailing empty -t value if neither override nor probe-supplied
|
|
124
|
+
// duration was provided. ffmpeg without -t falls back to its default
|
|
125
|
+
// (longest input) which is fine when -c:v copy keeps the video stream.
|
|
126
|
+
if (args[args.length - 1] === "") {
|
|
127
|
+
args.pop();
|
|
128
|
+
args.pop(); // also drop the "-t" flag
|
|
129
|
+
}
|
|
130
|
+
args.push("-movflags", "+faststart", tmpPath);
|
|
131
|
+
try {
|
|
132
|
+
const { stderr } = await execSafe("ffmpeg", args);
|
|
133
|
+
if (stderr && opts.onProgress) {
|
|
134
|
+
stderr.split(/\r?\n/).forEach((line) => opts.onProgress?.(line));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
// execSafe throws on non-zero exit. Surface ffmpeg's stderr if present.
|
|
139
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
140
|
+
// Best-effort cleanup of the half-written temp.
|
|
141
|
+
try {
|
|
142
|
+
await unlink(tmpPath);
|
|
143
|
+
}
|
|
144
|
+
catch { /* ignore */ }
|
|
145
|
+
return {
|
|
146
|
+
success: false,
|
|
147
|
+
outputPath: opts.videoPath,
|
|
148
|
+
audioCount: opts.audios.length,
|
|
149
|
+
error: `ffmpeg mux failed: ${msg}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
await rename(tmpPath, opts.videoPath);
|
|
153
|
+
return {
|
|
154
|
+
success: true,
|
|
155
|
+
outputPath: opts.videoPath,
|
|
156
|
+
audioCount: opts.audios.length,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=scene-audio-mux.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scene-audio-mux.js","sourceRoot":"","sources":["../../../src/commands/_shared/scene-audio-mux.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scene-audio-mux.test.d.ts","sourceRoot":"","sources":["../../../src/commands/_shared/scene-audio-mux.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for scene-audio-mux. Pure construction only — `muxAudioIntoVideo`
|
|
3
|
+
* is exercised by the C3 smoke test, not here, so CI doesn't depend on
|
|
4
|
+
* ffmpeg installation.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { buildAudioMuxFilter } from "./scene-audio-mux.js";
|
|
8
|
+
function makeAudio(overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
srcRel: "assets/narration-1.wav",
|
|
11
|
+
srcAbs: "/proj/assets/narration-1.wav",
|
|
12
|
+
absoluteStart: 0,
|
|
13
|
+
durationHint: "auto",
|
|
14
|
+
clipDurationCap: 5,
|
|
15
|
+
volume: 1,
|
|
16
|
+
trackIndex: 2,
|
|
17
|
+
compositionSrc: "compositions/scene-1.html",
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe("buildAudioMuxFilter", () => {
|
|
22
|
+
it("returns null for an empty list", () => {
|
|
23
|
+
expect(buildAudioMuxFilter([])).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
it("single input: trim + delay + volume → labelled stream, no amix", () => {
|
|
26
|
+
const filter = buildAudioMuxFilter([makeAudio()]);
|
|
27
|
+
expect(filter).not.toBeNull();
|
|
28
|
+
expect(filter.inputCount).toBe(1);
|
|
29
|
+
expect(filter.outLabel).toBe("[a0]");
|
|
30
|
+
// Per-stage construction
|
|
31
|
+
expect(filter.filterComplex).toBe("[1:a]atrim=duration=5.000,asetpts=PTS-STARTPTS,adelay=0:all=1,volume=1[a0]");
|
|
32
|
+
// No amix
|
|
33
|
+
expect(filter.filterComplex).not.toContain("amix");
|
|
34
|
+
});
|
|
35
|
+
it("converts absoluteStart to milliseconds for adelay", () => {
|
|
36
|
+
const filter = buildAudioMuxFilter([
|
|
37
|
+
makeAudio({ absoluteStart: 1.234, clipDurationCap: 3 }),
|
|
38
|
+
]);
|
|
39
|
+
expect(filter.filterComplex).toContain("adelay=1234:all=1");
|
|
40
|
+
});
|
|
41
|
+
it("preserves volume in the filter", () => {
|
|
42
|
+
const filter = buildAudioMuxFilter([
|
|
43
|
+
makeAudio({ volume: 0.6 }),
|
|
44
|
+
]);
|
|
45
|
+
expect(filter.filterComplex).toContain("volume=0.6");
|
|
46
|
+
});
|
|
47
|
+
it("multi-input: builds per-stream stages plus an amix", () => {
|
|
48
|
+
const filter = buildAudioMuxFilter([
|
|
49
|
+
makeAudio({ absoluteStart: 0, clipDurationCap: 3 }),
|
|
50
|
+
makeAudio({
|
|
51
|
+
absoluteStart: 3,
|
|
52
|
+
clipDurationCap: 4,
|
|
53
|
+
volume: 0.8,
|
|
54
|
+
srcRel: "assets/narration-2.wav",
|
|
55
|
+
srcAbs: "/proj/assets/narration-2.wav",
|
|
56
|
+
}),
|
|
57
|
+
]);
|
|
58
|
+
expect(filter.inputCount).toBe(2);
|
|
59
|
+
expect(filter.outLabel).toBe("[mixed]");
|
|
60
|
+
expect(filter.filterComplex).toContain("[1:a]atrim=duration=3.000,asetpts=PTS-STARTPTS,adelay=0:all=1,volume=1[a0]");
|
|
61
|
+
expect(filter.filterComplex).toContain("[2:a]atrim=duration=4.000,asetpts=PTS-STARTPTS,adelay=3000:all=1,volume=0.8[a1]");
|
|
62
|
+
expect(filter.filterComplex).toContain("[a0][a1]amix=inputs=2:dropout_transition=0:normalize=0[mixed]");
|
|
63
|
+
});
|
|
64
|
+
it("clamps negative clipDurationCap to 0", () => {
|
|
65
|
+
const filter = buildAudioMuxFilter([
|
|
66
|
+
makeAudio({ clipDurationCap: -1 }),
|
|
67
|
+
]);
|
|
68
|
+
expect(filter.filterComplex).toContain("atrim=duration=0.000");
|
|
69
|
+
});
|
|
70
|
+
it("clamps negative absoluteStart to 0 in adelay", () => {
|
|
71
|
+
const filter = buildAudioMuxFilter([
|
|
72
|
+
makeAudio({ absoluteStart: -0.05 }),
|
|
73
|
+
]);
|
|
74
|
+
expect(filter.filterComplex).toContain("adelay=0:all=1");
|
|
75
|
+
});
|
|
76
|
+
it("falls back to volume=1 when value is non-finite", () => {
|
|
77
|
+
const filter = buildAudioMuxFilter([
|
|
78
|
+
makeAudio({ volume: NaN }),
|
|
79
|
+
]);
|
|
80
|
+
expect(filter.filterComplex).toContain("volume=1");
|
|
81
|
+
});
|
|
82
|
+
it("emits a deterministic label sequence (a0, a1, a2, ...)", () => {
|
|
83
|
+
const filter = buildAudioMuxFilter([
|
|
84
|
+
makeAudio(),
|
|
85
|
+
makeAudio({ absoluteStart: 1 }),
|
|
86
|
+
makeAudio({ absoluteStart: 2 }),
|
|
87
|
+
]);
|
|
88
|
+
expect(filter.filterComplex).toContain("[a0]");
|
|
89
|
+
expect(filter.filterComplex).toContain("[a1]");
|
|
90
|
+
expect(filter.filterComplex).toContain("[a2]");
|
|
91
|
+
expect(filter.outLabel).toBe("[mixed]");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
//# sourceMappingURL=scene-audio-mux.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scene-audio-mux.test.js","sourceRoot":"","sources":["../../../src/commands/_shared/scene-audio-mux.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAG3D,SAAS,SAAS,CAAC,YAAwC,EAAE;IAC3D,OAAO;QACL,MAAM,EAAE,wBAAwB;QAChC,MAAM,EAAE,8BAA8B;QACtC,aAAa,EAAE,CAAC;QAChB,YAAY,EAAE,MAAM;QACpB,eAAe,EAAE,CAAC;QAClB,MAAM,EAAE,CAAC;QACT,UAAU,EAAE,CAAC;QACb,cAAc,EAAE,2BAA2B;QAC3C,GAAG,SAAS;KACb,CAAC;AACJ,CAAC;AAED,QAAQ,CAAC,qBAAqB,EAAE,GAAG,EAAE;IACnC,EAAE,CAAC,gCAAgC,EAAE,GAAG,EAAE;QACxC,MAAM,CAAC,mBAAmB,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gEAAgE,EAAE,GAAG,EAAE;QACxE,MAAM,MAAM,GAAG,mBAAmB,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;QAClD,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;QAC9B,MAAM,CAAC,MAAO,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACnC,MAAM,CAAC,MAAO,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACtC,yBAAyB;QACzB,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,IAAI,CAChC,4EAA4E,CAC7E,CAAC;QACF,UAAU;QACV,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IACtD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mDAAmD,EAAE,GAAG,EAAE;QAC3D,MAAM,MAAM,GAAG,mBAAmB,CAAC;YACjC,SAAS,CAAC,EAAE,aAAa,EAAE,KAAK,EAAE,eAAe,EAAE,CAAC,EAAE,CAAC;SACxD,CAAC,CAAC;QACH,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CAAC,mBAAmB,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gCAAgC,EAAE,GAAG,EAAE;QACxC,MAAM,MAAM,GAAG,mBAAmB,CAAC;YACjC,SAAS,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;SAC3B,CAAC,CAAC;QACH,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CAAC,YAAY,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,MAAM,MAAM,GAAG,mBAAmB,CAAC;YACjC,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,eAAe,EAAE,CAAC,EAAE,CAAC;YACnD,SAAS,CAAC;gBACR,aAAa,EAAE,CAAC;gBAChB,eAAe,EAAE,CAAC;gBAClB,MAAM,EAAE,GAAG;gBACX,MAAM,EAAE,wBAAwB;gBAChC,MAAM,EAAE,8BAA8B;aACvC,CAAC;SACH,CAAC,CAAC;QACH,MAAM,CAAC,MAAO,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACnC,MAAM,CAAC,MAAO,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACzC,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CACrC,4EAA4E,CAC7E,CAAC;QACF,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CACrC,iFAAiF,CAClF,CAAC;QACF,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CACrC,+DAA+D,CAChE,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C,MAAM,MAAM,GAAG,mBAAmB,CAAC;YACjC,SAAS,CAAC,EAAE,eAAe,EAAE,CAAC,CAAC,EAAE,CAAC;SACnC,CAAC,CAAC;QACH,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CAAC,sBAAsB,CAAC,CAAC;IAClE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8CAA8C,EAAE,GAAG,EAAE;QACtD,MAAM,MAAM,GAAG,mBAAmB,CAAC;YACjC,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC,IAAI,EAAE,CAAC;SACpC,CAAC,CAAC;QACH,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC;IAC5D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,MAAM,GAAG,mBAAmB,CAAC;YACjC,SAAS,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;SAC3B,CAAC,CAAC;QACH,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;IACtD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAChE,MAAM,MAAM,GAAG,mBAAmB,CAAC;YACjC,SAAS,EAAE;YACX,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,CAAC;YAC/B,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,CAAC;SAChC,CAAC,CAAC;QACH,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QAChD,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QAChD,MAAM,CAAC,MAAO,CAAC,aAAa,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QAChD,MAAM,CAAC,MAAO,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module _shared/scene-audio-scan
|
|
3
|
+
*
|
|
4
|
+
* Reads a scene project's root + sub-composition HTML and returns a flat list
|
|
5
|
+
* of audio elements with **absolute** timeline positions. Used by `executeSceneRender`
|
|
6
|
+
* to mux narration / background-audio tracks into the producer's silent video
|
|
7
|
+
* output via ffmpeg.
|
|
8
|
+
*
|
|
9
|
+
* Why a post-render scanner: at time of writing, `@hyperframes/producer`
|
|
10
|
+
* renders frames + assembles the MP4 but does not embed audio from
|
|
11
|
+
* `<audio>` elements that live inside sub-compositions. We pick up the slack
|
|
12
|
+
* here without touching the producer.
|
|
13
|
+
*
|
|
14
|
+
* Pure module — no I/O beyond the explicit `loadCompositionHtml` reader the
|
|
15
|
+
* orchestrator passes in. Easy to unit-test with fixture strings.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* One `<audio>` element resolved into an absolute-timeline entry.
|
|
19
|
+
*/
|
|
20
|
+
export interface SceneAudioElement {
|
|
21
|
+
/** Project-relative `src` from the audio tag (e.g. `assets/narration-intro.wav`). */
|
|
22
|
+
srcRel: string;
|
|
23
|
+
/** Absolute path on disk — `resolve(projectDir, srcRel)`. */
|
|
24
|
+
srcAbs: string;
|
|
25
|
+
/** Seconds from the start of the root timeline at which playback begins. */
|
|
26
|
+
absoluteStart: number;
|
|
27
|
+
/**
|
|
28
|
+
* Composition-supplied duration. `"auto"` means "use the file's natural
|
|
29
|
+
* duration"; the mux pass probes the file. Numeric means a hard cap.
|
|
30
|
+
*/
|
|
31
|
+
durationHint: "auto" | number;
|
|
32
|
+
/** Cap from the parent scene clip — audio is trimmed to fit this many seconds. */
|
|
33
|
+
clipDurationCap: number;
|
|
34
|
+
/** `data-volume`. Defaults to `1`. */
|
|
35
|
+
volume: number;
|
|
36
|
+
/** `data-track-index` — used so callers can group narration vs music vs etc. */
|
|
37
|
+
trackIndex: number;
|
|
38
|
+
/** Composition that owns this audio element (debug aid). */
|
|
39
|
+
compositionSrc: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Parsed view of a single `<div class="clip" data-composition-src="...">`
|
|
43
|
+
* reference inside the root composition.
|
|
44
|
+
*/
|
|
45
|
+
export interface SceneClipRef {
|
|
46
|
+
compositionId: string;
|
|
47
|
+
compositionSrc: string;
|
|
48
|
+
start: number;
|
|
49
|
+
duration: number;
|
|
50
|
+
trackIndex: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Walk the root HTML for clip references. Returns one entry per
|
|
54
|
+
* `<div class="clip">` with the four attributes we care about. Pure: a
|
|
55
|
+
* regex-based scan, not a full DOM parser. The root file is generated by
|
|
56
|
+
* VibeFrame so the markup is predictable; if a user hand-edits it into
|
|
57
|
+
* something unparseable, the scanner simply finds fewer clips.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseRootClips(rootHtml: string): SceneClipRef[];
|
|
60
|
+
/**
|
|
61
|
+
* Walk a single composition's HTML for `<audio>` elements. Returns one entry
|
|
62
|
+
* per element with attributes resolved into our shape. Composition-local —
|
|
63
|
+
* caller adds the parent clip's start to compute absolute timing.
|
|
64
|
+
*/
|
|
65
|
+
export declare function parseSceneAudios(compositionHtml: string): Array<{
|
|
66
|
+
srcRel: string;
|
|
67
|
+
localStart: number;
|
|
68
|
+
durationHint: "auto" | number;
|
|
69
|
+
volume: number;
|
|
70
|
+
trackIndex: number;
|
|
71
|
+
}>;
|
|
72
|
+
/** Reader hook so the scanner stays unit-testable without touching the FS. */
|
|
73
|
+
export type CompositionReader = (compositionSrcRel: string) => Promise<string | null>;
|
|
74
|
+
/**
|
|
75
|
+
* Default reader: read the composition file from disk relative to the
|
|
76
|
+
* project directory. Returns `null` for missing files so the scanner can
|
|
77
|
+
* skip them and continue.
|
|
78
|
+
*/
|
|
79
|
+
export declare function makeFsCompositionReader(projectDir: string): CompositionReader;
|
|
80
|
+
/**
|
|
81
|
+
* Top-level scan. Parses every clip in the root, loads each composition,
|
|
82
|
+
* pulls every audio element, and resolves absolute timeline positions.
|
|
83
|
+
*
|
|
84
|
+
* Audio absolute start = clip.start + audio.localStart. The `clipDurationCap`
|
|
85
|
+
* is filled in from the clip's own duration so the mux pass can trim audio
|
|
86
|
+
* that runs past the visual end of its parent scene.
|
|
87
|
+
*/
|
|
88
|
+
export declare function scanSceneAudio(opts: {
|
|
89
|
+
projectDir: string;
|
|
90
|
+
rootHtml: string;
|
|
91
|
+
/** Override the default FS reader (testing). */
|
|
92
|
+
readComposition?: CompositionReader;
|
|
93
|
+
}): Promise<SceneAudioElement[]>;
|
|
94
|
+
//# sourceMappingURL=scene-audio-scan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scene-audio-scan.d.ts","sourceRoot":"","sources":["../../../src/commands/_shared/scene-audio-scan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,qFAAqF;IACrF,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,aAAa,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,kFAAkF;IAClF,eAAe,EAAE,MAAM,CAAC;IACxB,sCAAsC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,EAAE,CAmB/D;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,eAAe,EAAE,MAAM,GACtB,KAAK,CAAC;IACP,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC,CA4BD;AAED,8EAA8E;AAC9E,MAAM,MAAM,iBAAiB,GAAG,CAAC,iBAAiB,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAEtF;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,MAAM,GAAG,iBAAiB,CAS7E;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,gDAAgD;IAChD,eAAe,CAAC,EAAE,iBAAiB,CAAC;CACrC,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAyB/B"}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module _shared/scene-audio-scan
|
|
3
|
+
*
|
|
4
|
+
* Reads a scene project's root + sub-composition HTML and returns a flat list
|
|
5
|
+
* of audio elements with **absolute** timeline positions. Used by `executeSceneRender`
|
|
6
|
+
* to mux narration / background-audio tracks into the producer's silent video
|
|
7
|
+
* output via ffmpeg.
|
|
8
|
+
*
|
|
9
|
+
* Why a post-render scanner: at time of writing, `@hyperframes/producer`
|
|
10
|
+
* renders frames + assembles the MP4 but does not embed audio from
|
|
11
|
+
* `<audio>` elements that live inside sub-compositions. We pick up the slack
|
|
12
|
+
* here without touching the producer.
|
|
13
|
+
*
|
|
14
|
+
* Pure module — no I/O beyond the explicit `loadCompositionHtml` reader the
|
|
15
|
+
* orchestrator passes in. Easy to unit-test with fixture strings.
|
|
16
|
+
*/
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
/**
|
|
20
|
+
* Walk the root HTML for clip references. Returns one entry per
|
|
21
|
+
* `<div class="clip">` with the four attributes we care about. Pure: a
|
|
22
|
+
* regex-based scan, not a full DOM parser. The root file is generated by
|
|
23
|
+
* VibeFrame so the markup is predictable; if a user hand-edits it into
|
|
24
|
+
* something unparseable, the scanner simply finds fewer clips.
|
|
25
|
+
*/
|
|
26
|
+
export function parseRootClips(rootHtml) {
|
|
27
|
+
const clips = [];
|
|
28
|
+
// Anchor on `class="clip"` and pull every relevant data attribute that
|
|
29
|
+
// appears in the same opening tag. Attribute order is not assumed.
|
|
30
|
+
const clipRegex = /<div\b[^>]*class="clip"[^>]*>/gi;
|
|
31
|
+
let match;
|
|
32
|
+
while ((match = clipRegex.exec(rootHtml)) !== null) {
|
|
33
|
+
const tag = match[0];
|
|
34
|
+
const compositionId = pickAttr(tag, "data-composition-id");
|
|
35
|
+
const compositionSrc = pickAttr(tag, "data-composition-src");
|
|
36
|
+
const start = pickNumberAttr(tag, "data-start");
|
|
37
|
+
const duration = pickNumberAttr(tag, "data-duration");
|
|
38
|
+
const trackIndex = pickNumberAttr(tag, "data-track-index") ?? 1;
|
|
39
|
+
if (!compositionId || !compositionSrc || start === null || duration === null) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
clips.push({ compositionId, compositionSrc, start, duration, trackIndex });
|
|
43
|
+
}
|
|
44
|
+
return clips;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Walk a single composition's HTML for `<audio>` elements. Returns one entry
|
|
48
|
+
* per element with attributes resolved into our shape. Composition-local —
|
|
49
|
+
* caller adds the parent clip's start to compute absolute timing.
|
|
50
|
+
*/
|
|
51
|
+
export function parseSceneAudios(compositionHtml) {
|
|
52
|
+
const out = [];
|
|
53
|
+
// `<audio>` opens may span multiple lines in our emitted HTML; capture the
|
|
54
|
+
// full open tag (everything up to `>`).
|
|
55
|
+
const audioRegex = /<audio\b([^>]*)>/gi;
|
|
56
|
+
let match;
|
|
57
|
+
while ((match = audioRegex.exec(compositionHtml)) !== null) {
|
|
58
|
+
const attrs = match[1];
|
|
59
|
+
const src = pickAttr(attrs, "src");
|
|
60
|
+
if (!src)
|
|
61
|
+
continue;
|
|
62
|
+
const localStart = pickNumberAttr(attrs, "data-start") ?? 0;
|
|
63
|
+
const durationRaw = pickAttr(attrs, "data-duration");
|
|
64
|
+
const durationHint = !durationRaw || durationRaw === "auto"
|
|
65
|
+
? "auto"
|
|
66
|
+
: Number(durationRaw);
|
|
67
|
+
const volume = pickNumberAttr(attrs, "data-volume") ?? 1;
|
|
68
|
+
const trackIndex = pickNumberAttr(attrs, "data-track-index") ?? 2;
|
|
69
|
+
out.push({ srcRel: src, localStart, durationHint, volume, trackIndex });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Default reader: read the composition file from disk relative to the
|
|
75
|
+
* project directory. Returns `null` for missing files so the scanner can
|
|
76
|
+
* skip them and continue.
|
|
77
|
+
*/
|
|
78
|
+
export function makeFsCompositionReader(projectDir) {
|
|
79
|
+
return async (compositionSrcRel) => {
|
|
80
|
+
const abs = resolve(projectDir, compositionSrcRel);
|
|
81
|
+
try {
|
|
82
|
+
return await readFile(abs, "utf-8");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Top-level scan. Parses every clip in the root, loads each composition,
|
|
91
|
+
* pulls every audio element, and resolves absolute timeline positions.
|
|
92
|
+
*
|
|
93
|
+
* Audio absolute start = clip.start + audio.localStart. The `clipDurationCap`
|
|
94
|
+
* is filled in from the clip's own duration so the mux pass can trim audio
|
|
95
|
+
* that runs past the visual end of its parent scene.
|
|
96
|
+
*/
|
|
97
|
+
export async function scanSceneAudio(opts) {
|
|
98
|
+
const reader = opts.readComposition ?? makeFsCompositionReader(opts.projectDir);
|
|
99
|
+
const clips = parseRootClips(opts.rootHtml);
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const clip of clips) {
|
|
102
|
+
const html = await reader(clip.compositionSrc);
|
|
103
|
+
if (!html)
|
|
104
|
+
continue;
|
|
105
|
+
const audios = parseSceneAudios(html);
|
|
106
|
+
for (const audio of audios) {
|
|
107
|
+
out.push({
|
|
108
|
+
srcRel: audio.srcRel,
|
|
109
|
+
srcAbs: resolve(opts.projectDir, audio.srcRel),
|
|
110
|
+
absoluteStart: clip.start + audio.localStart,
|
|
111
|
+
durationHint: audio.durationHint,
|
|
112
|
+
clipDurationCap: clip.duration - audio.localStart,
|
|
113
|
+
volume: audio.volume,
|
|
114
|
+
trackIndex: audio.trackIndex,
|
|
115
|
+
compositionSrc: clip.compositionSrc,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Stable sort by absolute start so downstream filter graphs are deterministic.
|
|
120
|
+
out.sort((a, b) => a.absoluteStart - b.absoluteStart);
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Helpers
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
function pickAttr(tag, name) {
|
|
127
|
+
// Allow either single or double quotes; tolerate whitespace around `=`.
|
|
128
|
+
const re = new RegExp(`\\b${escapeRegex(name)}\\s*=\\s*("([^"]*)"|'([^']*)')`);
|
|
129
|
+
const m = tag.match(re);
|
|
130
|
+
if (!m)
|
|
131
|
+
return null;
|
|
132
|
+
return m[2] ?? m[3] ?? null;
|
|
133
|
+
}
|
|
134
|
+
function pickNumberAttr(tag, name) {
|
|
135
|
+
const raw = pickAttr(tag, name);
|
|
136
|
+
if (raw === null)
|
|
137
|
+
return null;
|
|
138
|
+
const n = Number(raw);
|
|
139
|
+
return Number.isFinite(n) ? n : null;
|
|
140
|
+
}
|
|
141
|
+
function escapeRegex(s) {
|
|
142
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=scene-audio-scan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scene-audio-scan.js","sourceRoot":"","sources":["../../../src/commands/_shared/scene-audio-scan.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scene-audio-scan.test.d.ts","sourceRoot":"","sources":["../../../src/commands/_shared/scene-audio-scan.test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|