ugly-app 0.1.241 → 0.1.242

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 (34) hide show
  1. package/dist/cli/version.d.ts +1 -1
  2. package/dist/cli/version.js +1 -1
  3. package/dist/three/client/index.d.ts +3 -0
  4. package/dist/three/client/index.d.ts.map +1 -0
  5. package/dist/three/client/index.js +5 -0
  6. package/dist/three/client/index.js.map +1 -0
  7. package/dist/three/server/VideoEncoder.d.ts +132 -0
  8. package/dist/three/server/VideoEncoder.d.ts.map +1 -0
  9. package/dist/three/server/VideoEncoder.js +512 -0
  10. package/dist/three/server/VideoEncoder.js.map +1 -0
  11. package/dist/three/server/index.d.ts +5 -0
  12. package/dist/three/server/index.d.ts.map +1 -0
  13. package/dist/three/server/index.js +6 -0
  14. package/dist/three/server/index.js.map +1 -0
  15. package/dist/three/shared/CameraController.d.ts +89 -0
  16. package/dist/three/shared/CameraController.d.ts.map +1 -0
  17. package/dist/three/shared/CameraController.js +340 -0
  18. package/dist/three/shared/CameraController.js.map +1 -0
  19. package/dist/three/shared/HeadlessEnvironment.d.ts +87 -0
  20. package/dist/three/shared/HeadlessEnvironment.d.ts.map +1 -0
  21. package/dist/three/shared/HeadlessEnvironment.js +646 -0
  22. package/dist/three/shared/HeadlessEnvironment.js.map +1 -0
  23. package/dist/three/shared/PostProcessing.d.ts +65 -0
  24. package/dist/three/shared/PostProcessing.d.ts.map +1 -0
  25. package/dist/three/shared/PostProcessing.js +490 -0
  26. package/dist/three/shared/PostProcessing.js.map +1 -0
  27. package/package.json +3 -1
  28. package/src/cli/version.ts +1 -1
  29. package/src/three/client/index.ts +10 -0
  30. package/src/three/server/VideoEncoder.ts +719 -0
  31. package/src/three/server/index.ts +23 -0
  32. package/src/three/shared/CameraController.ts +477 -0
  33. package/src/three/shared/HeadlessEnvironment.ts +808 -0
  34. package/src/three/shared/PostProcessing.ts +561 -0
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.1.241";
1
+ export declare const CLI_VERSION = "0.1.242";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.241";
2
+ export const CLI_VERSION = "0.1.242";
3
3
  //# sourceMappingURL=version.js.map
@@ -0,0 +1,3 @@
1
+ export { PostProcessingManager, } from '../shared/PostProcessing.js';
2
+ export { DynamicCameraController, } from '../shared/CameraController.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/three/client/index.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,qBAAqB,GACtB,MAAM,6BAA6B,CAAC;AAErC,OAAO,EACL,uBAAuB,GACxB,MAAM,+BAA+B,CAAC"}
@@ -0,0 +1,5 @@
1
+ // Client-side Three.js utilities
2
+ // Currently re-exports shared modules that work in browser context
3
+ export { PostProcessingManager, } from '../shared/PostProcessing.js';
4
+ export { DynamicCameraController, } from '../shared/CameraController.js';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/three/client/index.ts"],"names":[],"mappings":"AAAA,iCAAiC;AACjC,mEAAmE;AAEnE,OAAO,EACL,qBAAqB,GACtB,MAAM,6BAA6B,CAAC;AAErC,OAAO,EACL,uBAAuB,GACxB,MAAM,+BAA+B,CAAC"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * VideoEncoder - FFmpeg-based video encoding for server-side rendering
3
+ *
4
+ * Handles:
5
+ * - Receiving raw RGBA frames from AvatarScene.captureFrameResult()
6
+ * - Encoding to H.264/MP4 via FFmpeg
7
+ * - Muxing with audio (MP3/PCM)
8
+ * - Output to file or buffer
9
+ */
10
+ declare let captureServerError: (msg: string, err: unknown, ctx?: Record<string, unknown>) => void;
11
+ export declare function setVideoEncoderErrorHandler(handler: typeof captureServerError): void;
12
+ /**
13
+ * Check if the FFmpeg `ass` filter (libass) is available.
14
+ * Result is cached for the lifetime of the process.
15
+ */
16
+ export declare function checkLibassAvailable(): Promise<boolean>;
17
+ /**
18
+ * Check if NVIDIA NVENC hardware encoder is available.
19
+ * Result is cached for the lifetime of the process.
20
+ */
21
+ export declare function checkNvencAvailable(): Promise<boolean>;
22
+ export interface VideoEncoderOptions {
23
+ /** Output width in pixels */
24
+ width: number;
25
+ /** Output height in pixels */
26
+ height: number;
27
+ /** Frames per second (default: 30) */
28
+ fps?: number;
29
+ /** Video bitrate (default: '4M') */
30
+ videoBitrate?: string;
31
+ /** Audio bitrate (default: '192k') */
32
+ audioBitrate?: string;
33
+ /** Output format (default: 'mp4') */
34
+ outputFormat?: 'mp4' | 'webm';
35
+ /** Output file path (if not provided, uses temp file) */
36
+ outputPath?: string;
37
+ /** Audio input file path (optional - will be muxed with video) */
38
+ audioPath?: string;
39
+ /** H.264 encoding preset (default: 'fast') */
40
+ preset?: 'ultrafast' | 'superfast' | 'veryfast' | 'faster' | 'fast' | 'medium';
41
+ /** CRF quality (0-51, default: 23, lower = better quality) */
42
+ crf?: number;
43
+ /** Path to ASS subtitle file to burn into the video */
44
+ subtitlePath?: string;
45
+ }
46
+ export interface VideoEncoderStats {
47
+ framesWritten: number;
48
+ bytesWritten: number;
49
+ durationMs: number;
50
+ }
51
+ /**
52
+ * FFmpeg-based video encoder for server-side avatar rendering.
53
+ *
54
+ * Usage:
55
+ * ```typescript
56
+ * const encoder = new VideoEncoder({
57
+ * width: 1280,
58
+ * height: 720,
59
+ * fps: 30,
60
+ * audioPath: '/path/to/audio.mp3',
61
+ * });
62
+ *
63
+ * await encoder.start();
64
+ *
65
+ * // For each frame:
66
+ * const result = scene.captureFrameResult();
67
+ * if (result.type === 'raw') {
68
+ * encoder.writeFrame(result.raw!);
69
+ * }
70
+ *
71
+ * const output = await encoder.finalize();
72
+ * // output.buffer contains the MP4 data
73
+ * // output.path contains the temp file path
74
+ * ```
75
+ */
76
+ export declare class VideoEncoder {
77
+ private options;
78
+ private ffmpeg;
79
+ private outputPath;
80
+ private framesWritten;
81
+ private startTime;
82
+ private isFinalized;
83
+ private stderrOutput;
84
+ private useNvenc;
85
+ private ffmpegExitPromise;
86
+ constructor(options: VideoEncoderOptions);
87
+ /**
88
+ * Start the FFmpeg encoding process.
89
+ */
90
+ start(): Promise<void>;
91
+ /**
92
+ * Build FFmpeg command arguments.
93
+ */
94
+ private buildFFmpegArgs;
95
+ /**
96
+ * Write a frame to the encoder.
97
+ *
98
+ * @param rgba - Raw RGBA pixel data (Uint8Array from captureFrameResult)
99
+ * @returns Promise that resolves when frame is written (handles backpressure)
100
+ */
101
+ writeFrame(rgba: Uint8Array): Promise<void>;
102
+ /**
103
+ * Finalize encoding and return the output.
104
+ */
105
+ finalize(): Promise<{
106
+ buffer: Buffer;
107
+ path: string;
108
+ stats: VideoEncoderStats;
109
+ }>;
110
+ /**
111
+ * Abort encoding and clean up.
112
+ */
113
+ abort(): void;
114
+ /**
115
+ * Get encoding statistics.
116
+ */
117
+ getStats(): VideoEncoderStats;
118
+ /**
119
+ * Clean up the output file (call after uploading to storage).
120
+ */
121
+ cleanup(): void;
122
+ }
123
+ /**
124
+ * Check if FFmpeg is available on the system.
125
+ */
126
+ export declare function checkFFmpegAvailable(): Promise<boolean>;
127
+ /**
128
+ * Get FFmpeg version string.
129
+ */
130
+ export declare function getFFmpegVersion(): Promise<string | null>;
131
+ export {};
132
+ //# sourceMappingURL=VideoEncoder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VideoEncoder.d.ts","sourceRoot":"","sources":["../../../src/three/server/VideoEncoder.ts"],"names":[],"mappings":"AACA;;;;;;;;GAQG;AAMH,QAAA,IAAI,kBAAkB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IACrC,CAAC;AAElD,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,OAAO,kBAAkB,GAAG,IAAI,CAEpF;AAgBD;;;GAGG;AACH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CA4C7D;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,OAAO,CAAC,CAmD5D;AAMD,MAAM,WAAW,mBAAmB;IAClC,6BAA6B;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,8BAA8B;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,oCAAoC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qCAAqC;IACrC,YAAY,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC9B,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8CAA8C;IAC9C,MAAM,CAAC,EACH,WAAW,GACX,WAAW,GACX,UAAU,GACV,QAAQ,GACR,MAAM,GACN,QAAQ,CAAC;IACb,8DAA8D;IAC9D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;CACpB;AAMD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,OAAO,CAMb;IAEF,OAAO,CAAC,MAAM,CAA+C;IAC7D,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,YAAY,CAAM;IAC1B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,iBAAiB,CAGR;gBAEL,OAAO,EAAE,mBAAmB;IA2BxC;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAiF5B;;OAEG;IACH,OAAO,CAAC,eAAe;IA2IvB;;;;;OAKG;IACG,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAwEjD;;OAEG;IACG,QAAQ,IAAI,OAAO,CAAC;QACxB,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,iBAAiB,CAAC;KAC1B,CAAC;IA8DF;;OAEG;IACH,KAAK,IAAI,IAAI;IAcb;;OAEG;IACH,QAAQ,IAAI,iBAAiB;IAQ7B;;OAEG;IACH,OAAO,IAAI,IAAI;CAKhB;AAMD;;GAEG;AACH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAU7D;AAED;;GAEG;AACH,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAwB/D"}
@@ -0,0 +1,512 @@
1
+ // @ts-nocheck — Lifted from main app; generalized for ugly-app/three
2
+ /**
3
+ * VideoEncoder - FFmpeg-based video encoding for server-side rendering
4
+ *
5
+ * Handles:
6
+ * - Receiving raw RGBA frames from AvatarScene.captureFrameResult()
7
+ * - Encoding to H.264/MP4 via FFmpeg
8
+ * - Muxing with audio (MP3/PCM)
9
+ * - Output to file or buffer
10
+ */
11
+ import { spawn } from 'child_process';
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ // Error handler — defaults to console.error, can be overridden
15
+ let captureServerError = (msg, err, ctx) => console.error(msg, err, ctx);
16
+ export function setVideoEncoderErrorHandler(handler) {
17
+ captureServerError = handler;
18
+ }
19
+ // ============================================================================
20
+ // NVENC Detection (cached)
21
+ // ============================================================================
22
+ let nvencAvailable = null;
23
+ let nvencCheckPromise = null;
24
+ // ============================================================================
25
+ // libass Detection (cached)
26
+ // ============================================================================
27
+ let libassAvailable = null;
28
+ let libassCheckPromise = null;
29
+ /**
30
+ * Check if the FFmpeg `ass` filter (libass) is available.
31
+ * Result is cached for the lifetime of the process.
32
+ */
33
+ export async function checkLibassAvailable() {
34
+ if (libassAvailable !== null) {
35
+ return libassAvailable;
36
+ }
37
+ if (libassCheckPromise) {
38
+ return libassCheckPromise;
39
+ }
40
+ libassCheckPromise = new Promise((resolve) => {
41
+ const ffmpeg = spawn('ffmpeg', ['-filters']);
42
+ let output = '';
43
+ ffmpeg.stdout.on('data', (data) => {
44
+ output += data.toString();
45
+ });
46
+ ffmpeg.on('close', () => {
47
+ libassAvailable = output.includes('ass');
48
+ if (libassAvailable) {
49
+ console.log('[VideoEncoder] libass subtitle filter available');
50
+ }
51
+ else {
52
+ console.warn('[VideoEncoder] libass not available — ASS subtitle burn-in disabled. Install ffmpeg-full: brew install ffmpeg-full');
53
+ }
54
+ resolve(libassAvailable);
55
+ });
56
+ ffmpeg.on('error', () => {
57
+ libassAvailable = false;
58
+ resolve(false);
59
+ });
60
+ setTimeout(() => {
61
+ if (libassAvailable === null) {
62
+ ffmpeg.kill();
63
+ libassAvailable = false;
64
+ resolve(false);
65
+ }
66
+ }, 5000);
67
+ });
68
+ return libassCheckPromise;
69
+ }
70
+ /**
71
+ * Check if NVIDIA NVENC hardware encoder is available.
72
+ * Result is cached for the lifetime of the process.
73
+ */
74
+ export async function checkNvencAvailable() {
75
+ if (nvencAvailable !== null) {
76
+ return nvencAvailable;
77
+ }
78
+ if (nvencCheckPromise) {
79
+ return nvencCheckPromise;
80
+ }
81
+ nvencCheckPromise = new Promise((resolve) => {
82
+ // Try to run ffmpeg with h264_nvenc to see if it's available
83
+ const ffmpeg = spawn('ffmpeg', [
84
+ '-f',
85
+ 'lavfi',
86
+ '-i',
87
+ 'nullsrc=s=256x256:d=0.1',
88
+ '-c:v',
89
+ 'h264_nvenc',
90
+ '-f',
91
+ 'null',
92
+ '-',
93
+ ]);
94
+ ffmpeg.on('close', (code) => {
95
+ nvencAvailable = code === 0;
96
+ if (nvencAvailable) {
97
+ console.log('[VideoEncoder] NVENC hardware encoder available');
98
+ }
99
+ else {
100
+ console.log('[VideoEncoder] NVENC not available, using CPU encoding (libx264)');
101
+ }
102
+ resolve(nvencAvailable);
103
+ });
104
+ ffmpeg.on('error', () => {
105
+ nvencAvailable = false;
106
+ resolve(false);
107
+ });
108
+ // Timeout after 5 seconds
109
+ setTimeout(() => {
110
+ if (nvencAvailable === null) {
111
+ ffmpeg.kill();
112
+ nvencAvailable = false;
113
+ resolve(false);
114
+ }
115
+ }, 5000);
116
+ });
117
+ return nvencCheckPromise;
118
+ }
119
+ // ============================================================================
120
+ // VideoEncoder Class
121
+ // ============================================================================
122
+ /**
123
+ * FFmpeg-based video encoder for server-side avatar rendering.
124
+ *
125
+ * Usage:
126
+ * ```typescript
127
+ * const encoder = new VideoEncoder({
128
+ * width: 1280,
129
+ * height: 720,
130
+ * fps: 30,
131
+ * audioPath: '/path/to/audio.mp3',
132
+ * });
133
+ *
134
+ * await encoder.start();
135
+ *
136
+ * // For each frame:
137
+ * const result = scene.captureFrameResult();
138
+ * if (result.type === 'raw') {
139
+ * encoder.writeFrame(result.raw!);
140
+ * }
141
+ *
142
+ * const output = await encoder.finalize();
143
+ * // output.buffer contains the MP4 data
144
+ * // output.path contains the temp file path
145
+ * ```
146
+ */
147
+ export class VideoEncoder {
148
+ options;
149
+ ffmpeg = null;
150
+ outputPath;
151
+ framesWritten = 0;
152
+ startTime = 0;
153
+ isFinalized = false;
154
+ stderrOutput = '';
155
+ useNvenc = false;
156
+ ffmpegExitPromise = null;
157
+ constructor(options) {
158
+ // Generate temp output path if not provided
159
+ const tempDir = process.env.TEMP_DIR || '/tmp';
160
+ const outputPath = options.outputPath ||
161
+ path.join(tempDir, `video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
162
+ this.options = {
163
+ width: options.width,
164
+ height: options.height,
165
+ fps: options.fps ?? 30,
166
+ videoBitrate: options.videoBitrate ?? '4M',
167
+ audioBitrate: options.audioBitrate ?? '192k',
168
+ outputFormat: options.outputFormat ?? 'mp4',
169
+ outputPath,
170
+ audioPath: options.audioPath ?? null,
171
+ preset: options.preset ?? 'fast',
172
+ crf: options.crf ?? 23,
173
+ subtitlePath: options.subtitlePath ?? null,
174
+ };
175
+ this.outputPath = outputPath;
176
+ }
177
+ /**
178
+ * Start the FFmpeg encoding process.
179
+ */
180
+ async start() {
181
+ if (this.ffmpeg) {
182
+ throw new Error('VideoEncoder: Already started');
183
+ }
184
+ this.startTime = Date.now();
185
+ // Check if NVENC is available for GPU-accelerated encoding
186
+ this.useNvenc = await checkNvencAvailable();
187
+ // Check if libass is available for subtitle burn-in
188
+ if (this.options.subtitlePath) {
189
+ const hasLibass = await checkLibassAvailable();
190
+ if (!hasLibass) {
191
+ console.warn('[VideoEncoder] Skipping ASS subtitle burn-in (libass not available)');
192
+ this.options.subtitlePath = null;
193
+ }
194
+ }
195
+ // Build FFmpeg arguments
196
+ const args = this.buildFFmpegArgs();
197
+ const encoder = this.useNvenc ? 'NVENC (GPU)' : 'libx264 (CPU)';
198
+ console.log(`[VideoEncoder] Starting FFmpeg with ${encoder}:`, args.join(' '));
199
+ this.ffmpeg = spawn('ffmpeg', args);
200
+ // Collect stderr for debugging
201
+ this.ffmpeg.stderr.on('data', (data) => {
202
+ this.stderrOutput += data.toString();
203
+ });
204
+ // Handle process errors
205
+ this.ffmpeg.on('error', (error) => {
206
+ captureServerError('[VideoEncoder] FFmpeg process error', error);
207
+ });
208
+ // Create a promise that resolves when FFmpeg exits (for EPIPE handling)
209
+ this.ffmpegExitPromise = new Promise((resolve) => {
210
+ this.ffmpeg.on('close', (code, signal) => {
211
+ if (code !== 0 && !this.isFinalized) {
212
+ console.error(`[VideoEncoder] FFmpeg exited unexpectedly: code=${code}, signal=${signal}`);
213
+ console.error(`[VideoEncoder] FFmpeg stderr:\n${this.stderrOutput}`);
214
+ }
215
+ resolve({ code, signal });
216
+ });
217
+ });
218
+ // Wait for FFmpeg to be ready (it starts accepting input immediately)
219
+ await new Promise((resolve, reject) => {
220
+ const timeout = setTimeout(() => {
221
+ this.ffmpeg?.off('error', onError);
222
+ reject(new Error('VideoEncoder: FFmpeg startup timeout'));
223
+ }, 5000);
224
+ // FFmpeg is ready when it outputs to stderr (version info, etc.)
225
+ const onData = () => {
226
+ clearTimeout(timeout);
227
+ this.ffmpeg?.stderr.off('data', onData);
228
+ this.ffmpeg?.off('error', onError);
229
+ resolve();
230
+ };
231
+ const onError = (err) => {
232
+ clearTimeout(timeout);
233
+ this.ffmpeg?.stderr.off('data', onData);
234
+ reject(err);
235
+ };
236
+ this.ffmpeg.stderr.once('data', onData);
237
+ this.ffmpeg.once('error', onError);
238
+ });
239
+ }
240
+ /**
241
+ * Build FFmpeg command arguments.
242
+ */
243
+ buildFFmpegArgs() {
244
+ const { width, height, fps, videoBitrate, audioBitrate, preset, crf, audioPath, subtitlePath, } = this.options;
245
+ const args = [
246
+ // Overwrite output
247
+ '-y',
248
+ // Input: raw RGBA video from stdin
249
+ '-f',
250
+ 'rawvideo',
251
+ '-pix_fmt',
252
+ 'rgba',
253
+ '-s',
254
+ `${width}x${height}`,
255
+ '-r',
256
+ fps.toString(),
257
+ '-i',
258
+ 'pipe:0',
259
+ ];
260
+ // Add audio input if provided
261
+ if (audioPath) {
262
+ args.push('-i', audioPath);
263
+ }
264
+ // Add subtitle filter if provided (burns ASS subtitles into the video)
265
+ if (subtitlePath) {
266
+ // Escape special characters in the path for FFmpeg filter syntax
267
+ // Do NOT wrap in single quotes — FFmpeg 8.x treats them as filter graph delimiters
268
+ const escapedPath = subtitlePath
269
+ .replace(/\\/g, '\\\\')
270
+ .replace(/:/g, '\\:')
271
+ .replace(/'/g, "\\'")
272
+ .replace(/\[/g, '\\[')
273
+ .replace(/]/g, '\\]')
274
+ .replace(/;/g, '\\;');
275
+ args.push('-vf', `ass=${escapedPath}`);
276
+ }
277
+ // Video encoding settings
278
+ args.push(
279
+ // Convert RGBA to YUV for H.264
280
+ '-pix_fmt', 'yuv420p');
281
+ if (this.useNvenc) {
282
+ // NVIDIA GPU hardware encoding (h264_nvenc)
283
+ // Map libx264 presets to NVENC presets: p1 (fastest) to p7 (slowest)
284
+ const nvencPresetMap = {
285
+ ultrafast: 'p1',
286
+ superfast: 'p2',
287
+ veryfast: 'p3',
288
+ faster: 'p4',
289
+ fast: 'p5',
290
+ medium: 'p6',
291
+ };
292
+ args.push('-c:v', 'h264_nvenc',
293
+ // NVENC preset (p1-p7)
294
+ '-preset', nvencPresetMap[preset] ?? 'p5',
295
+ // Constant quality mode (similar to CRF)
296
+ '-rc', 'constqp', '-qp', crf.toString(),
297
+ // Bitrate (as max bitrate for constqp)
298
+ '-maxrate', videoBitrate, '-bufsize', videoBitrate,
299
+ // Keyframe interval
300
+ '-g', fps.toString(),
301
+ // Disable B-frames
302
+ '-bf', '0');
303
+ }
304
+ else {
305
+ // CPU software encoding (libx264)
306
+ args.push('-c:v', 'libx264',
307
+ // H.264 High profile for broad compatibility (TikTok, Instagram, etc.)
308
+ '-profile:v', 'high', '-level', '4.2',
309
+ // Encoding preset
310
+ '-preset', preset,
311
+ // Constant Rate Factor (quality-based encoding, no bitrate target)
312
+ '-crf', crf.toString(),
313
+ // Keyframe interval (1 second for better seeking)
314
+ '-g', fps.toString(),
315
+ // Disable B-frames for better seeking accuracy
316
+ '-bf', '0');
317
+ }
318
+ // Audio encoding settings (if audio provided)
319
+ if (audioPath) {
320
+ args.push('-c:a', 'aac', '-b:a', audioBitrate);
321
+ }
322
+ // Output format settings for MP4
323
+ args.push(
324
+ // Enable fast start (moov atom at beginning for streaming)
325
+ '-movflags', '+faststart',
326
+ // Output file
327
+ this.outputPath);
328
+ return args;
329
+ }
330
+ /**
331
+ * Write a frame to the encoder.
332
+ *
333
+ * @param rgba - Raw RGBA pixel data (Uint8Array from captureFrameResult)
334
+ * @returns Promise that resolves when frame is written (handles backpressure)
335
+ */
336
+ async writeFrame(rgba) {
337
+ if (!this.ffmpeg) {
338
+ throw new Error('VideoEncoder: Not started. Call start() first.');
339
+ }
340
+ if (this.isFinalized) {
341
+ throw new Error('VideoEncoder: Already finalized');
342
+ }
343
+ // Verify frame size
344
+ const expectedSize = this.options.width * this.options.height * 4;
345
+ if (rgba.length !== expectedSize) {
346
+ throw new Error(`VideoEncoder: Frame size mismatch. Expected ${expectedSize} bytes, got ${rgba.length}`);
347
+ }
348
+ // Write to FFmpeg stdin with EPIPE handling
349
+ // Use Buffer.copyBytesFrom to create a Node Buffer backed by the same memory
350
+ // when possible, avoiding a full copy of ~8MB per frame at 1080p
351
+ try {
352
+ const buf = rgba.buffer instanceof SharedArrayBuffer
353
+ ? Buffer.from(rgba)
354
+ : Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength);
355
+ const canContinue = this.ffmpeg.stdin.write(buf);
356
+ // Handle backpressure - wait for drain if buffer is full
357
+ if (!canContinue) {
358
+ await new Promise((resolve, reject) => {
359
+ const onDrain = () => {
360
+ this.ffmpeg.stdin.off('error', onError);
361
+ resolve();
362
+ };
363
+ const onError = (err) => {
364
+ this.ffmpeg.stdin.off('drain', onDrain);
365
+ reject(err);
366
+ };
367
+ this.ffmpeg.stdin.once('drain', onDrain);
368
+ this.ffmpeg.stdin.once('error', onError);
369
+ });
370
+ }
371
+ }
372
+ catch (error) {
373
+ // On EPIPE, wait for FFmpeg to exit to capture the real error
374
+ if (error instanceof Error &&
375
+ error.code === 'EPIPE') {
376
+ // Wait for FFmpeg to exit (with timeout)
377
+ if (this.ffmpegExitPromise) {
378
+ const timeout = new Promise((resolve) => setTimeout(() => resolve({ code: null, signal: 'timeout' }), 2000));
379
+ const exitInfo = await Promise.race([
380
+ this.ffmpegExitPromise,
381
+ timeout,
382
+ ]);
383
+ throw new Error(`FFmpeg crashed at frame ${this.framesWritten}: code=${exitInfo.code}, signal=${exitInfo.signal}\n` +
384
+ `FFmpeg stderr:\n${this.stderrOutput}`);
385
+ }
386
+ }
387
+ throw error;
388
+ }
389
+ this.framesWritten++;
390
+ }
391
+ /**
392
+ * Finalize encoding and return the output.
393
+ */
394
+ async finalize() {
395
+ if (!this.ffmpeg) {
396
+ throw new Error('VideoEncoder: Not started');
397
+ }
398
+ if (this.isFinalized) {
399
+ throw new Error('VideoEncoder: Already finalized');
400
+ }
401
+ this.isFinalized = true;
402
+ // Close stdin to signal end of input
403
+ this.ffmpeg.stdin.end();
404
+ // Wait for FFmpeg to finish
405
+ await new Promise((resolve, reject) => {
406
+ const timeout = setTimeout(() => {
407
+ this.ffmpeg?.kill('SIGKILL');
408
+ reject(new Error('VideoEncoder: FFmpeg finalization timeout'));
409
+ }, 60000); // 1 minute timeout
410
+ this.ffmpeg.on('close', (code) => {
411
+ clearTimeout(timeout);
412
+ if (code === 0) {
413
+ resolve();
414
+ }
415
+ else {
416
+ reject(new Error(`VideoEncoder: FFmpeg exited with code ${code}\n${this.stderrOutput}`));
417
+ }
418
+ });
419
+ });
420
+ // Read the output file
421
+ const buffer = await fs.promises.readFile(this.outputPath);
422
+ const durationMs = Date.now() - this.startTime;
423
+ const stats = {
424
+ framesWritten: this.framesWritten,
425
+ bytesWritten: buffer.length,
426
+ durationMs,
427
+ };
428
+ const encoderUsed = this.useNvenc ? 'NVENC' : 'libx264';
429
+ console.log(`[VideoEncoder] Finalized (${encoderUsed}): ${this.framesWritten} frames, ${(buffer.length / 1024 / 1024).toFixed(2)} MB, ${(durationMs / 1000).toFixed(1)}s`);
430
+ return {
431
+ buffer,
432
+ path: this.outputPath,
433
+ stats,
434
+ };
435
+ }
436
+ /**
437
+ * Abort encoding and clean up.
438
+ */
439
+ abort() {
440
+ if (this.ffmpeg) {
441
+ this.ffmpeg.kill('SIGKILL');
442
+ this.ffmpeg = null;
443
+ }
444
+ // Clean up temp file
445
+ if (this.outputPath && fs.existsSync(this.outputPath)) {
446
+ fs.unlinkSync(this.outputPath);
447
+ }
448
+ this.isFinalized = true;
449
+ }
450
+ /**
451
+ * Get encoding statistics.
452
+ */
453
+ getStats() {
454
+ return {
455
+ framesWritten: this.framesWritten,
456
+ bytesWritten: 0, // Unknown until finalized
457
+ durationMs: Date.now() - this.startTime,
458
+ };
459
+ }
460
+ /**
461
+ * Clean up the output file (call after uploading to storage).
462
+ */
463
+ cleanup() {
464
+ if (this.outputPath && fs.existsSync(this.outputPath)) {
465
+ fs.unlinkSync(this.outputPath);
466
+ }
467
+ }
468
+ }
469
+ // ============================================================================
470
+ // Helper Functions
471
+ // ============================================================================
472
+ /**
473
+ * Check if FFmpeg is available on the system.
474
+ */
475
+ export async function checkFFmpegAvailable() {
476
+ return new Promise((resolve) => {
477
+ const ffmpeg = spawn('ffmpeg', ['-version']);
478
+ ffmpeg.on('close', (code) => {
479
+ resolve(code === 0);
480
+ });
481
+ ffmpeg.on('error', () => {
482
+ resolve(false);
483
+ });
484
+ });
485
+ }
486
+ /**
487
+ * Get FFmpeg version string.
488
+ */
489
+ export async function getFFmpegVersion() {
490
+ return new Promise((resolve) => {
491
+ const ffmpeg = spawn('ffmpeg', ['-version']);
492
+ let output = '';
493
+ ffmpeg.stdout.on('data', (data) => {
494
+ output += data.toString();
495
+ });
496
+ ffmpeg.on('close', (code) => {
497
+ if (code === 0) {
498
+ // Extract version from first line
499
+ const match = /ffmpeg version (\S+)/.exec(output);
500
+ const firstLine = output.split('\n')[0];
501
+ resolve(match?.[1] ?? firstLine ?? null);
502
+ }
503
+ else {
504
+ resolve(null);
505
+ }
506
+ });
507
+ ffmpeg.on('error', () => {
508
+ resolve(null);
509
+ });
510
+ });
511
+ }
512
+ //# sourceMappingURL=VideoEncoder.js.map