mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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
```
This commit is contained in:
@@ -308,6 +308,7 @@ export async function processCompositionAudio(
|
|||||||
totalDuration: number,
|
totalDuration: number,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">>,
|
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">>,
|
||||||
|
compiledDir?: string,
|
||||||
): Promise<MixResult> {
|
): Promise<MixResult> {
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
const tracks: AudioTrack[] = [];
|
const tracks: AudioTrack[] = [];
|
||||||
@@ -324,7 +325,9 @@ export async function processCompositionAudio(
|
|||||||
try {
|
try {
|
||||||
let srcPath = element.src;
|
let srcPath = element.src;
|
||||||
if (!srcPath.startsWith("/") && !isHttpUrl(srcPath)) {
|
if (!srcPath.startsWith("/") && !isHttpUrl(srcPath)) {
|
||||||
srcPath = join(baseDir, srcPath);
|
const fromCompiled = compiledDir ? join(compiledDir, srcPath) : null;
|
||||||
|
srcPath =
|
||||||
|
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, srcPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHttpUrl(srcPath)) {
|
if (isHttpUrl(srcPath)) {
|
||||||
@@ -339,7 +342,7 @@ export async function processCompositionAudio(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!existsSync(srcPath)) {
|
if (!existsSync(srcPath)) {
|
||||||
errors.push(`Source not found: ${element.id}`);
|
errors.push(`Source not found: ${element.id} (${element.src})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ export async function extractAllVideoFrames(
|
|||||||
options: ExtractionOptions,
|
options: ExtractionOptions,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
||||||
|
compiledDir?: string,
|
||||||
): Promise<ExtractionResult> {
|
): Promise<ExtractionResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const extracted: ExtractedFrames[] = [];
|
const extracted: ExtractedFrames[] = [];
|
||||||
@@ -216,7 +217,9 @@ export async function extractAllVideoFrames(
|
|||||||
try {
|
try {
|
||||||
let videoPath = video.src;
|
let videoPath = video.src;
|
||||||
if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) {
|
if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) {
|
||||||
videoPath = join(baseDir, videoPath);
|
const fromCompiled = compiledDir ? join(compiledDir, videoPath) : null;
|
||||||
|
videoPath =
|
||||||
|
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, videoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHttpUrl(videoPath)) {
|
if (isHttpUrl(videoPath)) {
|
||||||
|
|||||||
@@ -692,6 +692,7 @@ export async function executeRenderJob(
|
|||||||
updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress);
|
updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress);
|
||||||
|
|
||||||
let frameLookup: FrameLookupTable | null = null;
|
let frameLookup: FrameLookupTable | null = null;
|
||||||
|
const compiledDir = join(workDir, "compiled");
|
||||||
|
|
||||||
if (composition.videos.length > 0) {
|
if (composition.videos.length > 0) {
|
||||||
const extractionResult = await extractAllVideoFrames(
|
const extractionResult = await extractAllVideoFrames(
|
||||||
@@ -699,6 +700,8 @@ export async function executeRenderJob(
|
|||||||
projectDir,
|
projectDir,
|
||||||
{ fps: job.config.fps, outputDir: join(workDir, "video-frames") },
|
{ fps: job.config.fps, outputDir: join(workDir, "video-frames") },
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
undefined,
|
||||||
|
compiledDir,
|
||||||
);
|
);
|
||||||
assertNotAborted();
|
assertNotAborted();
|
||||||
|
|
||||||
@@ -747,6 +750,8 @@ export async function executeRenderJob(
|
|||||||
audioOutputPath,
|
audioOutputPath,
|
||||||
job.duration,
|
job.duration,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
undefined,
|
||||||
|
compiledDir,
|
||||||
);
|
);
|
||||||
assertNotAborted();
|
assertNotAborted();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user