Spaces:
Running
Running
File size: 4,569 Bytes
f2f99a3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | /**
* FFmpeg-based video I/O for server-side watermark processing
*
* Spawns FFmpeg subprocesses to decode/encode raw YUV420p frames.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { Readable, Writable } from 'node:stream';
/** Video metadata */
export interface VideoInfo {
width: number;
height: number;
fps: number;
duration: number;
totalFrames: number;
}
/**
* Probe video file for metadata using ffprobe
*/
export async function probeVideo(inputPath: string): Promise<VideoInfo> {
return new Promise((resolve, reject) => {
const proc = spawn('ffprobe', [
'-v', 'quiet',
'-print_format', 'json',
'-show_streams',
'-show_format',
inputPath,
]);
let stdout = '';
let stderr = '';
proc.stdout.on('data', (d: Buffer) => (stdout += d.toString()));
proc.stderr.on('data', (d: Buffer) => (stderr += d.toString()));
proc.on('close', (code) => {
if (code !== 0) {
reject(new Error(`ffprobe failed (${code}): ${stderr}`));
return;
}
try {
const info = JSON.parse(stdout);
const videoStream = info.streams?.find((s: { codec_type: string }) => s.codec_type === 'video');
if (!videoStream) throw new Error('No video stream found');
const [num, den] = (videoStream.r_frame_rate || '30/1').split('/').map(Number);
const fps = den ? num / den : 30;
const duration = parseFloat(info.format?.duration || videoStream.duration || '0');
const totalFrames = Math.ceil(fps * duration);
resolve({
width: videoStream.width,
height: videoStream.height,
fps,
duration,
totalFrames,
});
} catch (e) {
reject(new Error(`Failed to parse ffprobe output: ${e}`));
}
});
});
}
/**
* Frame reader: decodes a video file to raw Y planes
* Yields one Y plane (Uint8Array of width*height) per frame
*/
export async function* readYPlanes(
inputPath: string,
width: number,
height: number
): AsyncGenerator<Uint8Array> {
const frameSize = width * height; // Y plane only
const yuvFrameSize = frameSize * 3 / 2; // YUV420p: Y + U/4 + V/4
const proc = spawn('ffmpeg', [
'-i', inputPath,
'-f', 'rawvideo',
'-pix_fmt', 'yuv420p',
'-v', 'error',
'pipe:1',
], { stdio: ['ignore', 'pipe', 'pipe'] });
let buffer = Buffer.alloc(0);
for await (const chunk of proc.stdout as AsyncIterable<Buffer>) {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= yuvFrameSize) {
// Extract Y plane (first width*height bytes of YUV420p frame)
const yPlane = new Uint8Array(buffer.subarray(0, frameSize));
yield yPlane;
buffer = buffer.subarray(yuvFrameSize);
}
}
}
/**
* Create a write pipe to FFmpeg for encoding watermarked frames
* Returns a writable stream that accepts YUV420p frame data
*/
export function createEncoder(
outputPath: string,
width: number,
height: number,
fps: number,
crf: number = 18
): { stdin: Writable; process: ChildProcess } {
const proc = spawn('ffmpeg', [
'-y',
'-f', 'rawvideo',
'-pix_fmt', 'yuv420p',
'-s', `${width}x${height}`,
'-r', String(fps),
'-i', 'pipe:0',
'-c:v', 'libx264',
'-crf', String(crf),
'-preset', 'medium',
'-pix_fmt', 'yuv420p',
'-v', 'error',
outputPath,
], { stdio: ['pipe', 'ignore', 'pipe'] });
return { stdin: proc.stdin, process: proc };
}
/**
* Read all YUV420p frames from video and provide full frame buffers
* (Y, U, V planes) for pass-through of chroma channels
*/
export async function* readYuvFrames(
inputPath: string,
width: number,
height: number
): AsyncGenerator<{ y: Uint8Array; u: Uint8Array; v: Uint8Array }> {
const ySize = width * height;
const uvSize = (width / 2) * (height / 2);
const frameSize = ySize + 2 * uvSize;
const proc = spawn('ffmpeg', [
'-i', inputPath,
'-f', 'rawvideo',
'-pix_fmt', 'yuv420p',
'-v', 'error',
'pipe:1',
], { stdio: ['ignore', 'pipe', 'pipe'] });
let buffer = Buffer.alloc(0);
for await (const chunk of proc.stdout as AsyncIterable<Buffer>) {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= frameSize) {
const y = new Uint8Array(buffer.subarray(0, ySize));
const u = new Uint8Array(buffer.subarray(ySize, ySize + uvSize));
const v = new Uint8Array(buffer.subarray(ySize + uvSize, frameSize));
yield { y, u, v };
buffer = buffer.subarray(frameSize);
}
}
}
|