mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
## 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
```
434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
/**
|
|
* Video Frame Extractor Service
|
|
*
|
|
* Pre-extracts video frames using FFmpeg for frame-accurate rendering.
|
|
* Videos are replaced with <img> elements during capture.
|
|
*/
|
|
|
|
import { spawn } from "child_process";
|
|
import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
|
|
import { join } from "path";
|
|
import { parseHTML } from "linkedom";
|
|
import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
|
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
|
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
|
|
|
export interface VideoElement {
|
|
id: string;
|
|
src: string;
|
|
start: number;
|
|
end: number;
|
|
mediaStart: number;
|
|
hasAudio: boolean;
|
|
}
|
|
|
|
export interface ExtractedFrames {
|
|
videoId: string;
|
|
srcPath: string;
|
|
outputDir: string;
|
|
framePattern: string;
|
|
fps: number;
|
|
totalFrames: number;
|
|
metadata: VideoMetadata;
|
|
framePaths: Map<number, string>;
|
|
}
|
|
|
|
export interface ExtractionOptions {
|
|
fps: number;
|
|
outputDir: string;
|
|
quality?: number;
|
|
format?: "jpg" | "png";
|
|
}
|
|
|
|
export interface ExtractionResult {
|
|
success: boolean;
|
|
extracted: ExtractedFrames[];
|
|
errors: Array<{ videoId: string; error: string }>;
|
|
totalFramesExtracted: number;
|
|
durationMs: number;
|
|
}
|
|
|
|
export function parseVideoElements(html: string): VideoElement[] {
|
|
const videos: VideoElement[] = [];
|
|
const { document } = parseHTML(html);
|
|
|
|
const videoEls = document.querySelectorAll("video[src]");
|
|
let autoIdCounter = 0;
|
|
for (const el of videoEls) {
|
|
const src = el.getAttribute("src");
|
|
if (!src) continue;
|
|
// Generate a stable ID for videos without one — the producer needs IDs
|
|
// to track extracted frames and composite them during encoding.
|
|
const id = el.getAttribute("id") || `hf-video-${autoIdCounter++}`;
|
|
if (!el.getAttribute("id")) {
|
|
el.setAttribute("id", id);
|
|
}
|
|
|
|
const startAttr = el.getAttribute("data-start");
|
|
const endAttr = el.getAttribute("data-end");
|
|
const durationAttr = el.getAttribute("data-duration");
|
|
const mediaStartAttr = el.getAttribute("data-media-start");
|
|
const hasAudioAttr = el.getAttribute("data-has-audio");
|
|
|
|
const start = startAttr ? parseFloat(startAttr) : 0;
|
|
// Derive end from data-end → data-start+data-duration → Infinity (natural duration).
|
|
// The caller (htmlCompiler) clamps Infinity to the composition's absoluteEnd.
|
|
let end = 0;
|
|
if (endAttr) {
|
|
end = parseFloat(endAttr);
|
|
} else if (durationAttr) {
|
|
end = start + parseFloat(durationAttr);
|
|
} else {
|
|
end = Infinity; // no explicit bounds — play for the full natural video duration
|
|
}
|
|
|
|
videos.push({
|
|
id,
|
|
src,
|
|
start,
|
|
end,
|
|
mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
|
|
hasAudio: hasAudioAttr === "true",
|
|
});
|
|
}
|
|
|
|
return videos;
|
|
}
|
|
|
|
export async function extractVideoFramesRange(
|
|
videoPath: string,
|
|
videoId: string,
|
|
startTime: number,
|
|
duration: number,
|
|
options: ExtractionOptions,
|
|
signal?: AbortSignal,
|
|
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
|
): Promise<ExtractedFrames> {
|
|
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
const { fps, outputDir, quality = 95, format = "jpg" } = options;
|
|
|
|
const videoOutputDir = join(outputDir, videoId);
|
|
if (!existsSync(videoOutputDir)) mkdirSync(videoOutputDir, { recursive: true });
|
|
|
|
const metadata = await extractVideoMetadata(videoPath);
|
|
const framePattern = `frame_%05d.${format}`;
|
|
const outputPattern = join(videoOutputDir, framePattern);
|
|
|
|
const args: string[] = [
|
|
"-ss",
|
|
String(startTime),
|
|
"-i",
|
|
videoPath,
|
|
"-t",
|
|
String(duration),
|
|
"-vf",
|
|
`fps=${fps}`,
|
|
"-q:v",
|
|
format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0",
|
|
];
|
|
if (format === "png") args.push("-compression_level", "6");
|
|
args.push("-y", outputPattern);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const ffmpeg = spawn("ffmpeg", args);
|
|
let stderr = "";
|
|
const onAbort = () => {
|
|
ffmpeg.kill("SIGTERM");
|
|
};
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
ffmpeg.kill("SIGTERM");
|
|
} else {
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
}
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
ffmpeg.kill("SIGTERM");
|
|
}, ffmpegProcessTimeout);
|
|
|
|
ffmpeg.stderr.on("data", (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
ffmpeg.on("close", (code) => {
|
|
clearTimeout(timer);
|
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
if (signal?.aborted) {
|
|
reject(new Error("Video frame extraction cancelled"));
|
|
return;
|
|
}
|
|
if (code !== 0) {
|
|
reject(new Error(`FFmpeg exited with code ${code}: ${stderr.slice(-500)}`));
|
|
return;
|
|
}
|
|
|
|
const framePaths = new Map<number, string>();
|
|
const files = readdirSync(videoOutputDir)
|
|
.filter((f) => f.startsWith("frame_") && f.endsWith(`.${format}`))
|
|
.sort();
|
|
files.forEach((file, index) => {
|
|
framePaths.set(index, join(videoOutputDir, file));
|
|
});
|
|
|
|
resolve({
|
|
videoId,
|
|
srcPath: videoPath,
|
|
outputDir: videoOutputDir,
|
|
framePattern,
|
|
fps,
|
|
totalFrames: framePaths.size,
|
|
metadata,
|
|
framePaths,
|
|
});
|
|
});
|
|
|
|
ffmpeg.on("error", (err) => {
|
|
clearTimeout(timer);
|
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
reject(new Error("[FFmpeg] ffmpeg not found"));
|
|
} else {
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function extractAllVideoFrames(
|
|
videos: VideoElement[],
|
|
baseDir: string,
|
|
options: ExtractionOptions,
|
|
signal?: AbortSignal,
|
|
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
|
compiledDir?: string,
|
|
): Promise<ExtractionResult> {
|
|
const startTime = Date.now();
|
|
const extracted: ExtractedFrames[] = [];
|
|
const errors: Array<{ videoId: string; error: string }> = [];
|
|
let totalFramesExtracted = 0;
|
|
|
|
// Process videos in parallel for better performance
|
|
const results = await Promise.all(
|
|
videos.map(async (video) => {
|
|
if (signal?.aborted) {
|
|
throw new Error("Video frame extraction cancelled");
|
|
}
|
|
try {
|
|
let videoPath = video.src;
|
|
if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) {
|
|
const fromCompiled = compiledDir ? join(compiledDir, videoPath) : null;
|
|
videoPath =
|
|
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, videoPath);
|
|
}
|
|
|
|
if (isHttpUrl(videoPath)) {
|
|
const downloadDir = join(options.outputDir, "_downloads");
|
|
mkdirSync(downloadDir, { recursive: true });
|
|
videoPath = await downloadToTemp(videoPath, downloadDir);
|
|
}
|
|
|
|
if (!existsSync(videoPath)) {
|
|
return { error: { videoId: video.id, error: `Video file not found: ${videoPath}` } };
|
|
}
|
|
|
|
let videoDuration = video.end - video.start;
|
|
|
|
// Fallback: if no data-duration/data-end was specified (end is Infinity or 0),
|
|
// probe the actual video file to get its natural duration.
|
|
if (!Number.isFinite(videoDuration) || videoDuration <= 0) {
|
|
const metadata = await extractVideoMetadata(videoPath);
|
|
const sourceDuration = metadata.durationSeconds - video.mediaStart;
|
|
videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
|
|
video.end = video.start + videoDuration;
|
|
}
|
|
|
|
const result = await extractVideoFramesRange(
|
|
videoPath,
|
|
video.id,
|
|
video.mediaStart,
|
|
videoDuration,
|
|
options,
|
|
signal,
|
|
config,
|
|
);
|
|
|
|
return { result };
|
|
} catch (err) {
|
|
return {
|
|
error: {
|
|
videoId: video.id,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
},
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Collect results and errors
|
|
for (const item of results) {
|
|
if ("error" in item && item.error) {
|
|
errors.push(item.error);
|
|
} else if ("result" in item) {
|
|
extracted.push(item.result);
|
|
totalFramesExtracted += item.result.totalFrames;
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: errors.length === 0,
|
|
extracted,
|
|
errors,
|
|
totalFramesExtracted,
|
|
durationMs: Date.now() - startTime,
|
|
};
|
|
}
|
|
|
|
export function getFrameAtTime(
|
|
extracted: ExtractedFrames,
|
|
globalTime: number,
|
|
videoStart: number,
|
|
): string | null {
|
|
const localTime = globalTime - videoStart;
|
|
if (localTime < 0) return null;
|
|
const frameIndex = Math.floor(localTime * extracted.fps);
|
|
if (frameIndex < 0 || frameIndex >= extracted.totalFrames) return null;
|
|
return extracted.framePaths.get(frameIndex) || null;
|
|
}
|
|
|
|
export class FrameLookupTable {
|
|
private videos: Map<
|
|
string,
|
|
{
|
|
extracted: ExtractedFrames;
|
|
start: number;
|
|
end: number;
|
|
mediaStart: number;
|
|
}
|
|
> = new Map();
|
|
private orderedVideos: Array<{
|
|
videoId: string;
|
|
extracted: ExtractedFrames;
|
|
start: number;
|
|
end: number;
|
|
mediaStart: number;
|
|
}> = [];
|
|
private activeVideoIds: Set<string> = new Set();
|
|
private startCursor = 0;
|
|
private lastTime: number | null = null;
|
|
|
|
addVideo(extracted: ExtractedFrames, start: number, end: number, mediaStart: number): void {
|
|
this.videos.set(extracted.videoId, { extracted, start, end, mediaStart });
|
|
this.orderedVideos = Array.from(this.videos.entries())
|
|
.map(([videoId, video]) => ({ videoId, ...video }))
|
|
.sort((a, b) => a.start - b.start);
|
|
this.resetActiveState();
|
|
}
|
|
|
|
getFrame(videoId: string, globalTime: number): string | null {
|
|
const video = this.videos.get(videoId);
|
|
if (!video) return null;
|
|
if (globalTime < video.start || globalTime >= video.end) return null;
|
|
return getFrameAtTime(video.extracted, globalTime, video.start);
|
|
}
|
|
|
|
private resetActiveState(): void {
|
|
this.activeVideoIds.clear();
|
|
this.startCursor = 0;
|
|
this.lastTime = null;
|
|
}
|
|
|
|
private refreshActiveSet(globalTime: number): void {
|
|
if (this.lastTime == null || globalTime < this.lastTime) {
|
|
this.activeVideoIds.clear();
|
|
this.startCursor = 0;
|
|
for (const entry of this.orderedVideos) {
|
|
if (entry.start <= globalTime && globalTime < entry.end) {
|
|
this.activeVideoIds.add(entry.videoId);
|
|
}
|
|
if (entry.start <= globalTime) {
|
|
this.startCursor += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
this.lastTime = globalTime;
|
|
return;
|
|
}
|
|
|
|
while (this.startCursor < this.orderedVideos.length) {
|
|
const candidate = this.orderedVideos[this.startCursor];
|
|
if (!candidate) break;
|
|
if (candidate.start > globalTime) {
|
|
break;
|
|
}
|
|
if (globalTime < candidate.end) {
|
|
this.activeVideoIds.add(candidate.videoId);
|
|
}
|
|
this.startCursor += 1;
|
|
}
|
|
|
|
for (const videoId of Array.from(this.activeVideoIds)) {
|
|
const video = this.videos.get(videoId);
|
|
if (!video || globalTime < video.start || globalTime >= video.end) {
|
|
this.activeVideoIds.delete(videoId);
|
|
}
|
|
}
|
|
this.lastTime = globalTime;
|
|
}
|
|
|
|
getActiveFramePayloads(
|
|
globalTime: number,
|
|
): Map<string, { framePath: string; frameIndex: number }> {
|
|
const frames = new Map<string, { framePath: string; frameIndex: number }>();
|
|
this.refreshActiveSet(globalTime);
|
|
for (const videoId of this.activeVideoIds) {
|
|
const video = this.videos.get(videoId);
|
|
if (!video) continue;
|
|
const localTime = globalTime - video.start;
|
|
const frameIndex = Math.floor(localTime * video.extracted.fps);
|
|
if (frameIndex < 0 || frameIndex >= video.extracted.totalFrames) continue;
|
|
const framePath = video.extracted.framePaths.get(frameIndex);
|
|
if (!framePath) continue;
|
|
frames.set(videoId, { framePath, frameIndex });
|
|
}
|
|
return frames;
|
|
}
|
|
|
|
getActiveFrames(globalTime: number): Map<string, string> {
|
|
const payloads = this.getActiveFramePayloads(globalTime);
|
|
const frames = new Map<string, string>();
|
|
for (const [videoId, payload] of payloads) {
|
|
frames.set(videoId, payload.framePath);
|
|
}
|
|
return frames;
|
|
}
|
|
|
|
cleanup(): void {
|
|
for (const video of this.videos.values()) {
|
|
if (existsSync(video.extracted.outputDir)) {
|
|
rmSync(video.extracted.outputDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
this.videos.clear();
|
|
this.orderedVideos = [];
|
|
this.resetActiveState();
|
|
}
|
|
}
|
|
|
|
export function createFrameLookupTable(
|
|
videos: VideoElement[],
|
|
extracted: ExtractedFrames[],
|
|
): FrameLookupTable {
|
|
const table = new FrameLookupTable();
|
|
const extractedMap = new Map<string, ExtractedFrames>();
|
|
for (const ext of extracted) extractedMap.set(ext.videoId, ext);
|
|
|
|
for (const video of videos) {
|
|
const ext = extractedMap.get(video.id);
|
|
if (ext) table.addVideo(ext, video.start, video.end, video.mediaStart);
|
|
}
|
|
|
|
return table;
|
|
}
|