@hyperframes/engine 0.1.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.
Files changed (119) hide show
  1. package/dist/config.d.ts +62 -0
  2. package/dist/config.d.ts.map +1 -0
  3. package/dist/config.js +92 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/config.test.d.ts +2 -0
  6. package/dist/config.test.d.ts.map +1 -0
  7. package/dist/config.test.js +77 -0
  8. package/dist/config.test.js.map +1 -0
  9. package/dist/index.d.ts +49 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +55 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/services/audioMixer.d.ts +11 -0
  14. package/dist/services/audioMixer.d.ts.map +1 -0
  15. package/dist/services/audioMixer.js +306 -0
  16. package/dist/services/audioMixer.js.map +1 -0
  17. package/dist/services/audioMixer.types.d.ts +27 -0
  18. package/dist/services/audioMixer.types.d.ts.map +1 -0
  19. package/dist/services/audioMixer.types.js +2 -0
  20. package/dist/services/audioMixer.types.js.map +1 -0
  21. package/dist/services/browserManager.d.ts +29 -0
  22. package/dist/services/browserManager.d.ts.map +1 -0
  23. package/dist/services/browserManager.js +175 -0
  24. package/dist/services/browserManager.js.map +1 -0
  25. package/dist/services/chunkEncoder.d.ts +32 -0
  26. package/dist/services/chunkEncoder.d.ts.map +1 -0
  27. package/dist/services/chunkEncoder.js +347 -0
  28. package/dist/services/chunkEncoder.js.map +1 -0
  29. package/dist/services/chunkEncoder.test.d.ts +2 -0
  30. package/dist/services/chunkEncoder.test.d.ts.map +1 -0
  31. package/dist/services/chunkEncoder.test.js +24 -0
  32. package/dist/services/chunkEncoder.test.js.map +1 -0
  33. package/dist/services/chunkEncoder.types.d.ts +26 -0
  34. package/dist/services/chunkEncoder.types.d.ts.map +1 -0
  35. package/dist/services/chunkEncoder.types.js +2 -0
  36. package/dist/services/chunkEncoder.types.js.map +1 -0
  37. package/dist/services/fileServer.d.ts +25 -0
  38. package/dist/services/fileServer.d.ts.map +1 -0
  39. package/dist/services/fileServer.js +138 -0
  40. package/dist/services/fileServer.js.map +1 -0
  41. package/dist/services/frameCapture.d.ts +52 -0
  42. package/dist/services/frameCapture.d.ts.map +1 -0
  43. package/dist/services/frameCapture.js +317 -0
  44. package/dist/services/frameCapture.js.map +1 -0
  45. package/dist/services/parallelCoordinator.d.ts +40 -0
  46. package/dist/services/parallelCoordinator.d.ts.map +1 -0
  47. package/dist/services/parallelCoordinator.js +192 -0
  48. package/dist/services/parallelCoordinator.js.map +1 -0
  49. package/dist/services/parallelCoordinator.test.d.ts +2 -0
  50. package/dist/services/parallelCoordinator.test.d.ts.map +1 -0
  51. package/dist/services/parallelCoordinator.test.js +38 -0
  52. package/dist/services/parallelCoordinator.test.js.map +1 -0
  53. package/dist/services/screenshotService.d.ts +28 -0
  54. package/dist/services/screenshotService.d.ts.map +1 -0
  55. package/dist/services/screenshotService.js +190 -0
  56. package/dist/services/screenshotService.js.map +1 -0
  57. package/dist/services/streamingEncoder.d.ts +49 -0
  58. package/dist/services/streamingEncoder.d.ts.map +1 -0
  59. package/dist/services/streamingEncoder.js +227 -0
  60. package/dist/services/streamingEncoder.js.map +1 -0
  61. package/dist/services/videoFrameExtractor.d.ts +65 -0
  62. package/dist/services/videoFrameExtractor.d.ts.map +1 -0
  63. package/dist/services/videoFrameExtractor.js +302 -0
  64. package/dist/services/videoFrameExtractor.js.map +1 -0
  65. package/dist/services/videoFrameInjector.d.ts +17 -0
  66. package/dist/services/videoFrameInjector.d.ts.map +1 -0
  67. package/dist/services/videoFrameInjector.js +92 -0
  68. package/dist/services/videoFrameInjector.js.map +1 -0
  69. package/dist/types.d.ts +79 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +8 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/utils/ffprobe.d.ts +18 -0
  74. package/dist/utils/ffprobe.d.ts.map +1 -0
  75. package/dist/utils/ffprobe.js +137 -0
  76. package/dist/utils/ffprobe.js.map +1 -0
  77. package/dist/utils/gpuEncoder.d.ts +11 -0
  78. package/dist/utils/gpuEncoder.d.ts.map +1 -0
  79. package/dist/utils/gpuEncoder.js +55 -0
  80. package/dist/utils/gpuEncoder.js.map +1 -0
  81. package/dist/utils/parityContract.d.ts +3 -0
  82. package/dist/utils/parityContract.d.ts.map +1 -0
  83. package/dist/utils/parityContract.js +35 -0
  84. package/dist/utils/parityContract.js.map +1 -0
  85. package/dist/utils/runFfmpeg.d.ts +19 -0
  86. package/dist/utils/runFfmpeg.d.ts.map +1 -0
  87. package/dist/utils/runFfmpeg.js +62 -0
  88. package/dist/utils/runFfmpeg.js.map +1 -0
  89. package/dist/utils/urlDownloader.d.ts +3 -0
  90. package/dist/utils/urlDownloader.d.ts.map +1 -0
  91. package/dist/utils/urlDownloader.js +68 -0
  92. package/dist/utils/urlDownloader.js.map +1 -0
  93. package/package.json +30 -0
  94. package/src/cdp-headless-experimental.d.ts +54 -0
  95. package/src/config.test.ts +95 -0
  96. package/src/config.ts +176 -0
  97. package/src/index.ts +154 -0
  98. package/src/services/audioMixer.ts +392 -0
  99. package/src/services/audioMixer.types.ts +28 -0
  100. package/src/services/browserManager.ts +219 -0
  101. package/src/services/chunkEncoder.test.ts +27 -0
  102. package/src/services/chunkEncoder.ts +399 -0
  103. package/src/services/chunkEncoder.types.ts +27 -0
  104. package/src/services/fileServer.ts +175 -0
  105. package/src/services/frameCapture.ts +452 -0
  106. package/src/services/parallelCoordinator.test.ts +44 -0
  107. package/src/services/parallelCoordinator.ts +293 -0
  108. package/src/services/screenshotService.ts +222 -0
  109. package/src/services/streamingEncoder.ts +308 -0
  110. package/src/services/videoFrameExtractor.ts +395 -0
  111. package/src/services/videoFrameInjector.ts +113 -0
  112. package/src/types.ts +91 -0
  113. package/src/utils/ffprobe.ts +192 -0
  114. package/src/utils/gpuEncoder.ts +58 -0
  115. package/src/utils/parityContract.ts +35 -0
  116. package/src/utils/runFfmpeg.ts +81 -0
  117. package/src/utils/urlDownloader.ts +78 -0
  118. package/tsconfig.json +18 -0
  119. package/vitest.config.ts +7 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Video Frame Injector
3
+ *
4
+ * Creates a BeforeCaptureHook that replaces native <video> elements with
5
+ * pre-extracted frame images during rendering. This is the Hyperframes-specific
6
+ * video handling strategy — OSS users with different video pipelines can
7
+ * provide their own hook or skip video injection entirely.
8
+ */
9
+
10
+ import { type Page } from "puppeteer-core";
11
+ import { promises as fs } from "fs";
12
+ import { type FrameLookupTable } from "./videoFrameExtractor.js";
13
+ import { injectVideoFramesBatch, syncVideoFrameVisibility } from "./screenshotService.js";
14
+ import { type BeforeCaptureHook } from "./frameCapture.js";
15
+ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
16
+
17
+ function createFrameDataUriCache(cacheLimit: number) {
18
+ const cache = new Map<string, string>();
19
+ const inFlight = new Map<string, Promise<string>>();
20
+
21
+ function remember(framePath: string, dataUri: string): string {
22
+ if (cache.has(framePath)) {
23
+ cache.delete(framePath);
24
+ }
25
+ cache.set(framePath, dataUri);
26
+ if (cache.size > cacheLimit) {
27
+ const oldestKey = cache.keys().next().value;
28
+ if (oldestKey) {
29
+ cache.delete(oldestKey);
30
+ }
31
+ }
32
+ return dataUri;
33
+ }
34
+
35
+ async function get(framePath: string): Promise<string> {
36
+ const cached = cache.get(framePath);
37
+ if (cached) {
38
+ remember(framePath, cached);
39
+ return cached;
40
+ }
41
+
42
+ const existing = inFlight.get(framePath);
43
+ if (existing) {
44
+ return existing;
45
+ }
46
+
47
+ const pending = fs
48
+ .readFile(framePath)
49
+ .then((frameData) => {
50
+ const mimeType = framePath.endsWith(".png") ? "image/png" : "image/jpeg";
51
+ const dataUri = `data:${mimeType};base64,${frameData.toString("base64")}`;
52
+ return remember(framePath, dataUri);
53
+ })
54
+ .finally(() => {
55
+ inFlight.delete(framePath);
56
+ });
57
+ inFlight.set(framePath, pending);
58
+ return pending;
59
+ }
60
+
61
+ return { get };
62
+ }
63
+
64
+ /**
65
+ * Creates a BeforeCaptureHook that injects pre-extracted video frames
66
+ * into the page, replacing native <video> elements with frame images.
67
+ */
68
+ export function createVideoFrameInjector(
69
+ frameLookup: FrameLookupTable | null,
70
+ config?: Partial<Pick<EngineConfig, "frameDataUriCacheLimit">>,
71
+ ): BeforeCaptureHook | null {
72
+ if (!frameLookup) return null;
73
+
74
+ const cacheLimit = Math.max(32, config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit);
75
+ const frameCache = createFrameDataUriCache(cacheLimit);
76
+ const lastInjectedFrameByVideo = new Map<string, number>();
77
+
78
+ return async (page: Page, time: number) => {
79
+ const activePayloads = frameLookup.getActiveFramePayloads(time);
80
+
81
+ const updates: Array<{ videoId: string; dataUri: string; frameIndex: number }> = [];
82
+ const activeIds = new Set<string>();
83
+ if (activePayloads.size > 0) {
84
+ const pendingReads: Array<Promise<{ videoId: string; dataUri: string; frameIndex: number }>> = [];
85
+ for (const [videoId, payload] of activePayloads) {
86
+ activeIds.add(videoId);
87
+ const lastFrameIndex = lastInjectedFrameByVideo.get(videoId);
88
+ if (lastFrameIndex === payload.frameIndex) continue;
89
+ pendingReads.push(
90
+ frameCache.get(payload.framePath).then((dataUri) => ({ videoId, dataUri, frameIndex: payload.frameIndex })),
91
+ );
92
+ }
93
+ updates.push(...(await Promise.all(pendingReads)));
94
+ }
95
+
96
+ for (const videoId of Array.from(lastInjectedFrameByVideo.keys())) {
97
+ if (!activeIds.has(videoId)) {
98
+ lastInjectedFrameByVideo.delete(videoId);
99
+ }
100
+ }
101
+
102
+ await syncVideoFrameVisibility(page, Array.from(activeIds));
103
+ if (updates.length > 0) {
104
+ await injectVideoFramesBatch(
105
+ page,
106
+ updates.map((u) => ({ videoId: u.videoId, dataUri: u.dataUri })),
107
+ );
108
+ for (const update of updates) {
109
+ lastInjectedFrameByVideo.set(update.videoId, update.frameIndex);
110
+ }
111
+ }
112
+ };
113
+ }
package/src/types.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @hyperframes/engine — Protocol Types
3
+ *
4
+ * The engine's page contract. Any web page that wants to be rendered
5
+ * as video must expose `window.__hf` implementing the HfProtocol interface.
6
+ */
7
+
8
+ // ── Seek Protocol ──────────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Declares a media element the engine should handle.
12
+ *
13
+ * Headless Chrome in BeginFrame mode cannot play <video> or produce audio.
14
+ * The engine pre-extracts video frames and audio tracks from declared media
15
+ * elements and handles injection/mixing automatically.
16
+ */
17
+ export interface HfMediaElement {
18
+ /** DOM id of the <video> or <audio> element */
19
+ elementId: string;
20
+ /** Source file path or URL */
21
+ src: string;
22
+ /** When in the composition this element appears (seconds) */
23
+ startTime: number;
24
+ /** When in the composition this element disappears (seconds) */
25
+ endTime: number;
26
+ /** Offset into the source file (seconds, default: 0) */
27
+ mediaOffset?: number;
28
+ /** Audio volume 0-1 (default: 1) */
29
+ volume?: number;
30
+ /** Whether this element has audio that should be extracted */
31
+ hasAudio?: boolean;
32
+ }
33
+
34
+ /**
35
+ * The seek protocol. The only contract between the engine and a page.
36
+ *
37
+ * The engine reads `duration` to calculate total frames, calls `seek(time)`
38
+ * before each frame capture, and uses `media` (if provided) to handle
39
+ * video frame injection and audio mixing.
40
+ *
41
+ * The engine does NOT care what animation framework drives the page.
42
+ * GSAP, Framer Motion, CSS animations, Three.js — anything works as long
43
+ * as `seek()` produces deterministic visual output for a given time.
44
+ */
45
+ export interface HfProtocol {
46
+ /** Total duration of the composition in seconds */
47
+ duration: number;
48
+ /** Seek to a specific time. Must produce deterministic visual output. */
49
+ seek(time: number): void;
50
+ /** Optional: media elements the engine should handle */
51
+ media?: HfMediaElement[];
52
+ }
53
+
54
+ // ── Capture Types ──────────────────────────────────────────────────────────────
55
+
56
+ export interface CaptureOptions {
57
+ width: number;
58
+ height: number;
59
+ fps: number;
60
+ format?: "jpeg" | "png";
61
+ quality?: number;
62
+ deviceScaleFactor?: number;
63
+ }
64
+
65
+ export interface CaptureResult {
66
+ frameIndex: number;
67
+ time: number;
68
+ path: string;
69
+ captureTimeMs: number;
70
+ }
71
+
72
+ export interface CaptureBufferResult {
73
+ buffer: Buffer;
74
+ captureTimeMs: number;
75
+ }
76
+
77
+ export interface CapturePerfSummary {
78
+ frames: number;
79
+ avgTotalMs: number;
80
+ avgSeekMs: number;
81
+ avgBeforeCaptureMs: number;
82
+ avgScreenshotMs: number;
83
+ }
84
+
85
+ // ── Global Augmentation ────────────────────────────────────────────────────────
86
+
87
+ declare global {
88
+ interface Window {
89
+ __hf?: HfProtocol;
90
+ }
91
+ }
@@ -0,0 +1,192 @@
1
+ import { spawn } from "child_process";
2
+
3
+ const videoMetadataCache = new Map<string, Promise<VideoMetadata>>();
4
+ const audioMetadataCache = new Map<string, Promise<AudioMetadata>>();
5
+
6
+ export interface VideoMetadata {
7
+ durationSeconds: number;
8
+ width: number;
9
+ height: number;
10
+ fps: number;
11
+ videoCodec: string;
12
+ hasAudio: boolean;
13
+ }
14
+
15
+ export interface AudioMetadata {
16
+ durationSeconds: number;
17
+ sampleRate: number;
18
+ channels: number;
19
+ audioCodec: string;
20
+ bitrate?: number;
21
+ }
22
+
23
+ interface FFProbeStream {
24
+ codec_type: string;
25
+ codec_name?: string;
26
+ width?: number;
27
+ height?: number;
28
+ r_frame_rate?: string;
29
+ avg_frame_rate?: string;
30
+ sample_rate?: string;
31
+ channels?: number;
32
+ }
33
+
34
+ interface FFProbeFormat {
35
+ duration?: string;
36
+ bit_rate?: string;
37
+ }
38
+
39
+ interface FFProbeOutput {
40
+ streams: FFProbeStream[];
41
+ format: FFProbeFormat;
42
+ }
43
+
44
+ function parseFrameRate(frameRateStr: string | undefined): number {
45
+ if (!frameRateStr) return 0;
46
+ const parts = frameRateStr.split("/");
47
+ if (parts.length === 2) {
48
+ const num = parseFloat(parts[0] ?? "");
49
+ const den = parseFloat(parts[1] ?? "");
50
+ if (den !== 0) return Math.round((num / den) * 100) / 100;
51
+ }
52
+ return parseFloat(frameRateStr) || 0;
53
+ }
54
+
55
+ export async function extractVideoMetadata(filePath: string): Promise<VideoMetadata> {
56
+ const cached = videoMetadataCache.get(filePath);
57
+ if (cached) {
58
+ return cached;
59
+ }
60
+
61
+ const probePromise = new Promise<VideoMetadata>((resolve, reject) => {
62
+ const args = ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath];
63
+
64
+ const ffprobe = spawn("ffprobe", args);
65
+ let stdout = "";
66
+ let stderr = "";
67
+
68
+ ffprobe.stdout.on("data", (data) => {
69
+ stdout += data.toString();
70
+ });
71
+ ffprobe.stderr.on("data", (data) => {
72
+ stderr += data.toString();
73
+ });
74
+
75
+ ffprobe.on("close", (code) => {
76
+ if (code !== 0) {
77
+ reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
78
+ return;
79
+ }
80
+
81
+ try {
82
+ const output: FFProbeOutput = JSON.parse(stdout);
83
+ const videoStream = output.streams.find((s) => s.codec_type === "video");
84
+ if (!videoStream) {
85
+ reject(new Error("[FFmpeg] No video stream found"));
86
+ return;
87
+ }
88
+
89
+ const hasAudio = output.streams.some((s) => s.codec_type === "audio");
90
+ const fps = parseFrameRate(videoStream.avg_frame_rate) || parseFrameRate(videoStream.r_frame_rate);
91
+ const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
92
+
93
+ const metadata: VideoMetadata = {
94
+ durationSeconds,
95
+ width: videoStream.width || 0,
96
+ height: videoStream.height || 0,
97
+ fps,
98
+ videoCodec: videoStream.codec_name || "unknown",
99
+ hasAudio,
100
+ };
101
+ resolve(metadata);
102
+ } catch (parseError: unknown) {
103
+ reject(new Error(`[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`));
104
+ }
105
+ });
106
+
107
+ ffprobe.on("error", (err) => {
108
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
109
+ reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
110
+ } else {
111
+ reject(err);
112
+ }
113
+ });
114
+ });
115
+ videoMetadataCache.set(filePath, probePromise);
116
+ probePromise.catch(() => {
117
+ if (videoMetadataCache.get(filePath) === probePromise) {
118
+ videoMetadataCache.delete(filePath);
119
+ }
120
+ });
121
+ return probePromise;
122
+ }
123
+
124
+ export async function extractAudioMetadata(filePath: string): Promise<AudioMetadata> {
125
+ const cached = audioMetadataCache.get(filePath);
126
+ if (cached) {
127
+ return cached;
128
+ }
129
+
130
+ const probePromise = new Promise<AudioMetadata>((resolve, reject) => {
131
+ const args = ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath];
132
+
133
+ const ffprobe = spawn("ffprobe", args);
134
+ let stdout = "";
135
+ let stderr = "";
136
+
137
+ ffprobe.stdout.on("data", (data) => {
138
+ stdout += data.toString();
139
+ });
140
+ ffprobe.stderr.on("data", (data) => {
141
+ stderr += data.toString();
142
+ });
143
+
144
+ ffprobe.on("close", (code) => {
145
+ if (code !== 0) {
146
+ reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
147
+ return;
148
+ }
149
+
150
+ try {
151
+ const output: FFProbeOutput = JSON.parse(stdout);
152
+ const audioStream = output.streams.find((s) => s.codec_type === "audio");
153
+ if (!audioStream) {
154
+ reject(new Error("[FFmpeg] No audio stream found"));
155
+ return;
156
+ }
157
+
158
+ const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
159
+
160
+ const metadata: AudioMetadata = {
161
+ durationSeconds,
162
+ sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
163
+ channels: audioStream.channels || 2,
164
+ audioCodec: audioStream.codec_name || "unknown",
165
+ bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
166
+ };
167
+ resolve(metadata);
168
+ } catch (parseError: unknown) {
169
+ reject(
170
+ new Error(
171
+ `[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`,
172
+ ),
173
+ );
174
+ }
175
+ });
176
+
177
+ ffprobe.on("error", (err) => {
178
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
179
+ reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
180
+ } else {
181
+ reject(err);
182
+ }
183
+ });
184
+ });
185
+ audioMetadataCache.set(filePath, probePromise);
186
+ probePromise.catch(() => {
187
+ if (audioMetadataCache.get(filePath) === probePromise) {
188
+ audioMetadataCache.delete(filePath);
189
+ }
190
+ });
191
+ return probePromise;
192
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * GPU Encoder Detection
3
+ *
4
+ * Shared GPU encoder detection and naming utilities used by both
5
+ * chunkEncoder and streamingEncoder services.
6
+ */
7
+
8
+ import { spawn } from "child_process";
9
+
10
+ export type GpuEncoder = "nvenc" | "videotoolbox" | "vaapi" | "qsv" | null;
11
+
12
+ export async function detectGpuEncoder(): Promise<GpuEncoder> {
13
+ return new Promise((resolve) => {
14
+ const ffmpeg = spawn("ffmpeg", ["-encoders"], {
15
+ stdio: ["pipe", "pipe", "pipe"],
16
+ });
17
+ let stdout = "";
18
+
19
+ ffmpeg.stdout.on("data", (data) => {
20
+ stdout += data.toString();
21
+ });
22
+
23
+ ffmpeg.on("close", () => {
24
+ if (stdout.includes("h264_nvenc")) resolve("nvenc");
25
+ else if (stdout.includes("h264_videotoolbox")) resolve("videotoolbox");
26
+ else if (stdout.includes("h264_vaapi")) resolve("vaapi");
27
+ else if (stdout.includes("h264_qsv")) resolve("qsv");
28
+ else resolve(null);
29
+ });
30
+
31
+ ffmpeg.on("error", () => resolve(null));
32
+ });
33
+ }
34
+
35
+ let cachedGpuEncoder: GpuEncoder | undefined = undefined;
36
+
37
+ export async function getCachedGpuEncoder(): Promise<GpuEncoder> {
38
+ if (cachedGpuEncoder === undefined) {
39
+ cachedGpuEncoder = await detectGpuEncoder();
40
+ }
41
+ return cachedGpuEncoder;
42
+ }
43
+
44
+ export function getGpuEncoderName(encoder: GpuEncoder, codec: "h264" | "h265"): string {
45
+ if (!encoder) return codec === "h264" ? "libx264" : "libx265";
46
+ switch (encoder) {
47
+ case "nvenc":
48
+ return codec === "h264" ? "h264_nvenc" : "hevc_nvenc";
49
+ case "videotoolbox":
50
+ return codec === "h264" ? "h264_videotoolbox" : "hevc_videotoolbox";
51
+ case "vaapi":
52
+ return codec === "h264" ? "h264_vaapi" : "hevc_vaapi";
53
+ case "qsv":
54
+ return codec === "h264" ? "h264_qsv" : "hevc_qsv";
55
+ default:
56
+ return codec === "h264" ? "libx264" : "libx265";
57
+ }
58
+ }
@@ -0,0 +1,35 @@
1
+ export const MEDIA_VISUAL_STYLE_PROPERTIES = [
2
+ "width",
3
+ "height",
4
+ "top",
5
+ "left",
6
+ "right",
7
+ "bottom",
8
+ "inset",
9
+ "object-fit",
10
+ "object-position",
11
+ "z-index",
12
+ "opacity",
13
+ "visibility",
14
+ "filter",
15
+ "mix-blend-mode",
16
+ "backdrop-filter",
17
+ "border-radius",
18
+ "overflow",
19
+ "clip-path",
20
+ "mask",
21
+ "mask-image",
22
+ "mask-size",
23
+ "mask-position",
24
+ "mask-repeat",
25
+ "transform",
26
+ "transform-origin",
27
+ "box-sizing",
28
+ ] as const;
29
+
30
+ export function quantizeTimeToFrame(timeSeconds: number, fps: number): number {
31
+ const safeFps = Number.isFinite(fps) && fps > 0 ? fps : 30;
32
+ const safeTime = Number.isFinite(timeSeconds) && timeSeconds > 0 ? timeSeconds : 0;
33
+ const frameIndex = Math.floor(safeTime * safeFps + 1e-9);
34
+ return frameIndex / safeFps;
35
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Shared FFmpeg process runner.
3
+ *
4
+ * Extracts the repeated spawn-stderr-timeout-abort-close-error pattern
5
+ * that appears across audioMixer and chunkEncoder into a single helper.
6
+ */
7
+
8
+ import { spawn } from "child_process";
9
+
10
+ export interface RunFfmpegOptions {
11
+ signal?: AbortSignal;
12
+ timeout?: number;
13
+ onStderr?: (line: string) => void;
14
+ }
15
+
16
+ export interface RunFfmpegResult {
17
+ success: boolean;
18
+ exitCode: number | null;
19
+ stderr: string;
20
+ durationMs: number;
21
+ }
22
+
23
+ const DEFAULT_TIMEOUT = 300_000;
24
+
25
+ export async function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promise<RunFfmpegResult> {
26
+ const startMs = Date.now();
27
+ const signal = opts?.signal;
28
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
29
+ const onStderr = opts?.onStderr;
30
+
31
+ return new Promise<RunFfmpegResult>((resolve) => {
32
+ const ffmpeg = spawn("ffmpeg", args);
33
+ let stderr = "";
34
+
35
+ const onAbort = () => {
36
+ ffmpeg.kill("SIGTERM");
37
+ };
38
+
39
+ if (signal) {
40
+ if (signal.aborted) {
41
+ ffmpeg.kill("SIGTERM");
42
+ } else {
43
+ signal.addEventListener("abort", onAbort, { once: true });
44
+ }
45
+ }
46
+
47
+ const timer = setTimeout(() => {
48
+ ffmpeg.kill("SIGTERM");
49
+ }, timeout);
50
+
51
+ ffmpeg.stderr.on("data", (data: Buffer) => {
52
+ const chunk = data.toString();
53
+ stderr += chunk;
54
+ if (onStderr) {
55
+ onStderr(chunk);
56
+ }
57
+ });
58
+
59
+ ffmpeg.on("close", (code) => {
60
+ clearTimeout(timer);
61
+ if (signal) signal.removeEventListener("abort", onAbort);
62
+ resolve({
63
+ success: !signal?.aborted && code === 0,
64
+ exitCode: code,
65
+ stderr,
66
+ durationMs: Date.now() - startMs,
67
+ });
68
+ });
69
+
70
+ ffmpeg.on("error", (err) => {
71
+ clearTimeout(timer);
72
+ if (signal) signal.removeEventListener("abort", onAbort);
73
+ resolve({
74
+ success: false,
75
+ exitCode: null,
76
+ stderr: err.message,
77
+ durationMs: Date.now() - startMs,
78
+ });
79
+ });
80
+ });
81
+ }
@@ -0,0 +1,78 @@
1
+ import { createWriteStream, existsSync, mkdirSync } from "fs";
2
+ import { createHash } from "crypto";
3
+ import { join, extname } from "path";
4
+ import { Readable } from "stream";
5
+ import { finished } from "stream/promises";
6
+
7
+ const downloadPathCache = new Map<string, string>();
8
+ const inFlightDownloads = new Map<string, Promise<string>>();
9
+
10
+ function getFilenameFromUrl(url: string): string {
11
+ const hash = createHash("md5").update(url).digest("hex").slice(0, 12);
12
+ const urlObj = new URL(url);
13
+ const ext = extname(urlObj.pathname) || ".mp4";
14
+ return `download_${hash}${ext}`;
15
+ }
16
+
17
+ export async function downloadToTemp(url: string, destDir: string, timeoutMs: number = 300000): Promise<string> {
18
+ const cachedPath = downloadPathCache.get(url);
19
+ if (cachedPath && existsSync(cachedPath)) {
20
+ return cachedPath;
21
+ }
22
+ const inFlight = inFlightDownloads.get(url);
23
+ if (inFlight) {
24
+ return inFlight;
25
+ }
26
+
27
+ if (!existsSync(destDir)) {
28
+ mkdirSync(destDir, { recursive: true });
29
+ }
30
+
31
+ const filename = getFilenameFromUrl(url);
32
+ const localPath = join(destDir, filename);
33
+
34
+ if (existsSync(localPath)) {
35
+ downloadPathCache.set(url, localPath);
36
+ return localPath;
37
+ }
38
+
39
+ const downloadPromise = (async () => {
40
+ try {
41
+ const controller = new AbortController();
42
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
43
+
44
+ const response = await fetch(url, { signal: controller.signal });
45
+ clearTimeout(timeoutId);
46
+
47
+ if (!response.ok) {
48
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
49
+ }
50
+
51
+ if (!response.body) {
52
+ throw new Error("Response body is empty");
53
+ }
54
+
55
+ const fileStream = createWriteStream(localPath);
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ const readableStream = Readable.fromWeb(response.body as any);
58
+ await finished(readableStream.pipe(fileStream));
59
+
60
+ downloadPathCache.set(url, localPath);
61
+ return localPath;
62
+ } catch (err) {
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ if (message.includes("aborted")) {
65
+ throw new Error(`[URLDownloader] Download timeout after ${timeoutMs / 1000}s: ${url}`);
66
+ }
67
+ throw new Error(`[URLDownloader] Download failed: ${message}`);
68
+ } finally {
69
+ inFlightDownloads.delete(url);
70
+ }
71
+ })();
72
+ inFlightDownloads.set(url, downloadPromise);
73
+ return downloadPromise;
74
+ }
75
+
76
+ export function isHttpUrl(path: string): boolean {
77
+ return path.startsWith("http://") || path.startsWith("https://");
78
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "skipLibCheck": true,
10
+ "outDir": "./dist",
11
+ "rootDir": "./src",
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist", "src/**/*.test.ts"]
18
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["src/**/*.test.ts"],
6
+ },
7
+ });