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);
    }
  }
}