Files
hyperframes/packages/engine/src/services/audioMixer.ts
T
Miguel Ángel 0cf03016b2 fix(engine): resolve external asset paths from compiled dir (#231)
## Summary
- Parent-relative paths (e.g. `src="../file.wav"`) silently drop media from rendered MP4
- The compiler rewrites external paths to `hf-ext/` and copies files to the compiled directory, but both the audio mixer and video frame extractor only resolved against `projectDir` — never finding them
- Now checks `compiledDir` first (matching the file server's resolution order), then falls back to `projectDir`
- Fixes both `<audio>` and `<video>` elements with external paths

## Real-world context
Reported in Slack by Abhai — a TTS comparison video using `<audio src="../tts-voxcpm2.wav">` (audio file in parent directory, composition in subdirectory) rendered successfully but the output MP4 had no audio stream. The render completed without any error, silently dropping the audio.

## Testing

### Environment
- Linux (Ubuntu 20.04), ffmpeg 4.2
- Test composition: `subdir/index.html` with `<audio id="bg-audio" src="../test-audio.wav">`, WAV file at parent directory

### Before fix (main)
```
[AUDIO-DEBUG] element.src=hf-ext/tmp/hf-test-231/test-audio.wav
              baseDir=/tmp/hf-test-231/subdir
[AUDIO-DEBUG] resolved srcPath=/tmp/hf-test-231/subdir/hf-ext/tmp/hf-test-231/test-audio.wav
              exists=false
```

- Audio mixer tries `join(projectDir, "hf-ext/...")` → file doesn't exist at that path
- Output: **9.8 KB, video stream only** (confirmed via ffprobe)
- No error logged — audio silently dropped

### After fix (this branch)
```
[AUDIO-DEBUG] element.src=hf-ext/tmp/hf-test-231/test-audio.wav
              baseDir=/tmp/hf-test-231/subdir
              compiledDir=/tmp/.../compiled
[AUDIO-DEBUG] fromCompiled=/tmp/.../compiled/hf-ext/tmp/hf-test-231/test-audio.wav
              exists=true
[AUDIO-DEBUG] resolved srcPath=/tmp/.../compiled/hf-ext/tmp/hf-test-231/test-audio.wav
              exists=true
[AUDIO-RESULT] success=true, hasAudio=true
```

- Audio mixer checks `join(compiledDir, "hf-ext/...")` first → file found
- Output: **44.6 KB, video + audio streams** (confirmed via ffprobe)

### ffprobe comparison

| Branch | File size | Streams |
|--------|-----------|---------|
| `main` | 9.8 KB | `video (h264)` only |
| `fix` | 44.6 KB | `video (h264)` + `audio (aac)` |

### Path resolution flow
1. Compiler sees `<audio src="../test-audio.wav">`
2. Compiler resolves to absolute path, maps it to `hf-ext/tmp/.../test-audio.wav`
3. Compiler copies file to `compiled/hf-ext/tmp/.../test-audio.wav`
4. Audio mixer gets `element.src = "hf-ext/tmp/.../test-audio.wav"`
5. **main**: tries `join(projectDir, src)` → not found → silent drop
6. **fix**: tries `join(compiledDir, src)` first → found → audio mixed in

### Repro
```bash
mkdir -p /tmp/test/subdir
ffmpeg -f lavfi -i "sine=frequency=440:duration=2" /tmp/test/test-audio.wav -y
# Create subdir/index.html with <audio src="../test-audio.wav" ...>
cd /tmp/test/subdir && npx hyperframes render
ffprobe -v error -show_streams output.mp4  # video only on main, video+audio on fix
```
2026-04-09 19:05:28 +02:00

421 lines
12 KiB
TypeScript

/**
* Audio Mixer Service
*
* Processes and mixes audio tracks using FFmpeg.
*/
import { existsSync, mkdirSync, rmSync } from "fs";
import { join, dirname } from "path";
import { parseHTML } from "linkedom";
import { extractAudioMetadata } from "../utils/ffprobe.js";
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
export type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
interface ExtractResult {
success: boolean;
outputPath: string;
durationMs: number;
error?: string;
}
export function parseAudioElements(html: string): AudioElement[] {
const elements: AudioElement[] = [];
const { document } = parseHTML(html);
// Parse <audio> elements
const audioEls = document.querySelectorAll("audio[id][src]");
for (const el of audioEls) {
const id = el.getAttribute("id");
const src = el.getAttribute("src");
if (!id || !src) continue;
const startAttr = el.getAttribute("data-start");
const endAttr = el.getAttribute("data-end");
const mediaStartAttr = el.getAttribute("data-media-start");
const layerAttr = el.getAttribute("data-layer");
const volumeAttr = el.getAttribute("data-volume");
elements.push({
id,
src,
start: startAttr ? parseFloat(startAttr) : 0,
end: endAttr ? parseFloat(endAttr) : 0,
mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
layer: layerAttr ? parseInt(layerAttr) : 0,
volume: volumeAttr ? parseFloat(volumeAttr) : 1.0,
type: "audio",
});
}
// Parse <video> elements with data-has-audio="true"
const videoEls = document.querySelectorAll('video[id][src][data-has-audio="true"]');
for (const el of videoEls) {
const id = el.getAttribute("id");
const src = el.getAttribute("src");
if (!id || !src) continue;
const startAttr = el.getAttribute("data-start");
const endAttr = el.getAttribute("data-end");
const mediaStartAttr = el.getAttribute("data-media-start");
const layerAttr = el.getAttribute("data-layer");
const volumeAttr = el.getAttribute("data-volume");
elements.push({
id: `${id}-audio`,
src,
start: startAttr ? parseFloat(startAttr) : 0,
end: endAttr ? parseFloat(endAttr) : 0,
mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
layer: layerAttr ? parseInt(layerAttr) : 0,
volume: volumeAttr ? parseFloat(volumeAttr) : 1.0,
type: "video",
});
}
return elements;
}
async function extractAudioFromVideo(
videoPath: string,
outputPath: string,
options?: { startTime?: number; duration?: number },
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
): Promise<ExtractResult> {
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const args: string[] = ["-i", videoPath];
if (options?.startTime !== undefined) args.push("-ss", String(options.startTime));
if (options?.duration !== undefined) args.push("-t", String(options.duration));
args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", "-ac", "2", "-y", outputPath);
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
if (signal?.aborted) {
return {
success: false,
outputPath,
durationMs: result.durationMs,
error: "Audio extract cancelled",
};
}
if (!result.success) {
return {
success: false,
outputPath,
durationMs: result.durationMs,
error:
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
};
}
return { success: true, outputPath, durationMs: result.durationMs };
}
async function prepareAudioTrack(
srcPath: string,
outputPath: string,
mediaStart: number,
duration: number,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
): Promise<ExtractResult> {
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const args = [
"-ss",
String(mediaStart),
"-t",
String(duration),
"-i",
srcPath,
"-acodec",
"pcm_s16le",
"-ar",
"48000",
"-ac",
"2",
"-y",
outputPath,
];
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
if (signal?.aborted) {
return {
success: false,
outputPath,
durationMs: result.durationMs,
error: "Audio prepare cancelled",
};
}
return {
success: result.success,
outputPath,
durationMs: result.durationMs,
error: !result.success
? result.exitCode !== null
? `FFmpeg exited with code ${result.exitCode}: ${result.stderr.slice(-200)}`
: result.stderr
: undefined,
};
}
async function generateSilence(
outputPath: string,
duration: number,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
): Promise<ExtractResult> {
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const args = [
"-f",
"lavfi",
"-i",
"anullsrc=r=48000:cl=stereo",
"-t",
String(duration),
"-acodec",
"pcm_s16le",
"-y",
outputPath,
];
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
if (signal?.aborted) {
return {
success: false,
outputPath,
durationMs: result.durationMs,
error: "Silence generation cancelled",
};
}
return {
success: result.success,
outputPath,
durationMs: result.durationMs,
error: !result.success
? result.exitCode !== null
? `FFmpeg exited with code ${result.exitCode}`
: result.stderr
: undefined,
};
}
async function mixAudioTracks(
tracks: AudioTrack[],
outputPath: string,
totalDuration: number,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">>,
): Promise<MixResult> {
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const masterOutputGain = config?.audioGain ?? DEFAULT_CONFIG.audioGain;
if (tracks.length === 0) {
const result = await generateSilence(outputPath, totalDuration, signal, config);
return {
success: result.success,
outputPath,
durationMs: result.durationMs,
tracksProcessed: 0,
error: result.error,
};
}
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const inputs: string[] = [];
const filterParts: string[] = [];
tracks.forEach((track, i) => {
inputs.push("-i", track.srcPath);
const delayMs = Math.round(track.start * 1000);
const trimDuration = track.end - track.start;
filterParts.push(
`[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`,
);
});
const mixInputs = tracks.map((_, i) => `[a${i}]`).join("");
const weights = tracks.map(() => "1").join(" ");
const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest:dropout_transition=0:normalize=0:weights='${weights}'[mixed]`;
const postMixGainFilter = `[mixed]volume=${masterOutputGain}[out]`;
const fullFilter = [...filterParts, mixFilter, postMixGainFilter].join(";");
const args = [
...inputs,
"-filter_complex",
fullFilter,
"-map",
"[out]",
"-acodec",
"aac",
"-b:a",
"192k",
"-t",
String(totalDuration),
"-y",
outputPath,
];
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
if (signal?.aborted) {
return {
success: false,
outputPath,
durationMs: result.durationMs,
tracksProcessed: 0,
error: "Audio mix cancelled",
};
}
if (!result.success) {
return {
success: false,
outputPath,
durationMs: result.durationMs,
tracksProcessed: 0,
error:
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
};
}
return {
success: true,
outputPath,
durationMs: result.durationMs,
tracksProcessed: tracks.length,
};
}
export async function processCompositionAudio(
elements: AudioElement[],
baseDir: string,
workDir: string,
outputPath: string,
totalDuration: number,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">>,
compiledDir?: string,
): Promise<MixResult> {
const startMs = Date.now();
const tracks: AudioTrack[] = [];
const errors: string[] = [];
if (!existsSync(workDir)) mkdirSync(workDir, { recursive: true });
await Promise.all(
elements.map(async (element) => {
if (signal?.aborted) {
errors.push(`Cancelled: ${element.id}`);
return;
}
try {
let srcPath = element.src;
if (!srcPath.startsWith("/") && !isHttpUrl(srcPath)) {
const fromCompiled = compiledDir ? join(compiledDir, srcPath) : null;
srcPath =
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, srcPath);
}
if (isHttpUrl(srcPath)) {
try {
srcPath = await downloadToTemp(srcPath, workDir);
} catch (err: unknown) {
errors.push(
`Download failed: ${element.id}${err instanceof Error ? err.message : String(err)}`,
);
return;
}
}
if (!existsSync(srcPath)) {
errors.push(`Source not found: ${element.id} (${element.src})`);
return;
}
// Fallback: if no duration was specified, probe the actual file
if (element.end - element.start <= 0) {
const metadata = await extractAudioMetadata(srcPath);
const effectiveDuration = metadata.durationSeconds - element.mediaStart;
element.end =
element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
}
let audioSrcPath = srcPath;
if (element.type === "video") {
const extractedPath = join(workDir, `${element.id}-extracted.wav`);
const extractResult = await extractAudioFromVideo(
srcPath,
extractedPath,
{
startTime: element.mediaStart,
duration: element.end - element.start,
},
signal,
config,
);
if (!extractResult.success) {
errors.push(`Extract failed: ${element.id}`);
return;
}
audioSrcPath = extractedPath;
} else {
const trimmedPath = join(workDir, `${element.id}-trimmed.wav`);
const prepResult = await prepareAudioTrack(
srcPath,
trimmedPath,
element.mediaStart,
element.end - element.start,
signal,
config,
);
if (!prepResult.success) {
errors.push(`Prepare failed: ${element.id}`);
return;
}
audioSrcPath = trimmedPath;
}
tracks.push({
id: element.id,
srcPath: audioSrcPath,
start: element.start,
end: element.end,
mediaStart: element.mediaStart,
duration: element.end - element.start,
volume: element.volume || 1.0,
});
} catch (err: unknown) {
errors.push(`Error: ${element.id}${err instanceof Error ? err.message : String(err)}`);
}
}),
);
const mixResult = await mixAudioTracks(tracks, outputPath, totalDuration, signal, config);
try {
rmSync(workDir, { recursive: true, force: true });
} catch {
/* ignore */
}
return {
...mixResult,
durationMs: Date.now() - startMs,
error: errors.length > 0 ? `Warnings: ${errors.join(", ")}` : mixResult.error,
};
}