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.
- package/dist/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/three/client/index.d.ts +3 -0
- package/dist/three/client/index.d.ts.map +1 -0
- package/dist/three/client/index.js +5 -0
- package/dist/three/client/index.js.map +1 -0
- package/dist/three/server/VideoEncoder.d.ts +132 -0
- package/dist/three/server/VideoEncoder.d.ts.map +1 -0
- package/dist/three/server/VideoEncoder.js +512 -0
- package/dist/three/server/VideoEncoder.js.map +1 -0
- package/dist/three/server/index.d.ts +5 -0
- package/dist/three/server/index.d.ts.map +1 -0
- package/dist/three/server/index.js +6 -0
- package/dist/three/server/index.js.map +1 -0
- package/dist/three/shared/CameraController.d.ts +89 -0
- package/dist/three/shared/CameraController.d.ts.map +1 -0
- package/dist/three/shared/CameraController.js +340 -0
- package/dist/three/shared/CameraController.js.map +1 -0
- package/dist/three/shared/HeadlessEnvironment.d.ts +87 -0
- package/dist/three/shared/HeadlessEnvironment.d.ts.map +1 -0
- package/dist/three/shared/HeadlessEnvironment.js +646 -0
- package/dist/three/shared/HeadlessEnvironment.js.map +1 -0
- package/dist/three/shared/PostProcessing.d.ts +65 -0
- package/dist/three/shared/PostProcessing.d.ts.map +1 -0
- package/dist/three/shared/PostProcessing.js +490 -0
- package/dist/three/shared/PostProcessing.js.map +1 -0
- package/package.json +3 -1
- package/src/cli/version.ts +1 -1
- package/src/three/client/index.ts +10 -0
- package/src/three/server/VideoEncoder.ts +719 -0
- package/src/three/server/index.ts +23 -0
- package/src/three/shared/CameraController.ts +477 -0
- package/src/three/shared/HeadlessEnvironment.ts +808 -0
- package/src/three/shared/PostProcessing.ts +561 -0
package/dist/cli/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.1.
|
|
1
|
+
export declare const CLI_VERSION = "0.1.242";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/cli/version.js
CHANGED
|
@@ -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
|