nodejs-audio-visualizer 4.5.0 → 5.0.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 (47) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +80 -80
  3. package/dist/audio.d.ts +12 -12
  4. package/dist/audio.js +120 -120
  5. package/dist/beats.d.ts +19 -19
  6. package/dist/beats.js +43 -43
  7. package/dist/bpmEncoder.d.ts +14 -14
  8. package/dist/bpmEncoder.js +97 -97
  9. package/dist/config.d.ts +77 -77
  10. package/dist/config.js +369 -369
  11. package/dist/dsp.d.ts +16 -16
  12. package/dist/dsp.js +76 -76
  13. package/dist/highlight.d.ts +32 -32
  14. package/dist/highlight.js +325 -325
  15. package/dist/image.d.ts +79 -79
  16. package/dist/image.js +458 -458
  17. package/dist/index.d.ts +79 -79
  18. package/dist/index.js +534 -531
  19. package/dist/index.js.map +1 -1
  20. package/dist/subtitleConvert.d.ts +3 -3
  21. package/dist/subtitleConvert.js +61 -61
  22. package/dist/video.d.ts +80 -80
  23. package/dist/video.js +434 -426
  24. package/dist/video.js.map +1 -1
  25. package/dist/waitForEventLoop.d.ts +1 -1
  26. package/dist/waitForEventLoop.js +5 -5
  27. package/package.json +61 -61
  28. package/src/audio.ts +120 -120
  29. package/src/beats.ts +68 -68
  30. package/src/bpmEncoder.ts +97 -97
  31. package/src/config.ts +476 -476
  32. package/src/dsp.ts +95 -95
  33. package/src/highlight.ts +284 -284
  34. package/src/image.ts +602 -602
  35. package/src/index.ts +718 -713
  36. package/src/subtitleConvert.ts +63 -63
  37. package/src/tests/audio.test.ts +96 -96
  38. package/src/tests/config.test.ts +496 -496
  39. package/src/tests/dsp.test.ts +24 -24
  40. package/src/tests/highlight.test.ts +178 -178
  41. package/src/tests/image.test.ts +222 -222
  42. package/src/tests/index.test.ts +180 -112
  43. package/src/tests/subtitleConvert.test.ts +23 -23
  44. package/src/tests/video.test.ts +285 -216
  45. package/src/typings.d.ts +6 -6
  46. package/src/video.ts +550 -542
  47. package/src/waitForEventLoop.ts +2 -2
package/src/video.ts CHANGED
@@ -1,542 +1,550 @@
1
- import { Readable, Writable } from 'stream';
2
- import { spawn } from 'child_process';
3
- import { writeFileSync, unlinkSync } from 'fs';
4
- import { resolve as resolvePath, join as joinPath } from 'path';
5
- import { tmpdir } from 'os';
6
- import ffmpegPath from 'ffmpeg-static';
7
- import { defaults } from './config';
8
-
9
- export interface AudioMuxSegment {
10
- seekSeconds: number;
11
- durationSeconds: number;
12
- }
13
-
14
- interface FfmpegVideoWriterConfig {
15
- audioFilename: string;
16
- subtitleFilename?: string;
17
- /** ASS `Alignment` in FFmpeg `subtitles` `force_style` (e.g. 2 bottom, 5 middle, 8 top). Default 2. */
18
- subtitleAlignmentAss?: number;
19
- videoFileName: string;
20
- fps: number;
21
- crf?: string;
22
- preset?: string;
23
- onStderr?: (data: any) => any;
24
- /** When set with `audioDurationSeconds`, passed to ffmpeg before `-i` for muxed highlight. */
25
- audioSeekSeconds?: number;
26
- audioDurationSeconds?: number;
27
- /** One contiguous trim of the same audio file (muxed highlight). */
28
- audioSegment?: AudioMuxSegment;
29
- }
30
-
31
- const escapeSubtitleFilterPath = (subtitlePath: string) =>
32
- subtitlePath
33
- .replace(/\\/g, '\\\\')
34
- .replace(/:/g, '\\:')
35
- .replace(/'/g, "\\'")
36
- .replace(/,/g, '\\,')
37
- .replace(/\[/g, '\\[')
38
- .replace(/\]/g, '\\]');
39
-
40
- export const spawnFfmpegVideoWriter = (config: FfmpegVideoWriterConfig) => {
41
- if (!ffmpegPath) {
42
- throw new Error('ffmpeg path not found');
43
- }
44
- const crf = config.crf || '23';
45
- const preset = config.preset || 'medium';
46
- const args: string[] = ['-y'];
47
- const seek =
48
- config.audioSegment?.seekSeconds ?? config.audioSeekSeconds;
49
- const duration =
50
- config.audioSegment?.durationSeconds ?? config.audioDurationSeconds;
51
- if (seek !== undefined && duration !== undefined) {
52
- args.push('-ss', String(seek), '-t', String(duration));
53
- }
54
- args.push(
55
- '-i', config.audioFilename,
56
- '-f', 'image2pipe',
57
- '-vcodec', 'bmp',
58
- '-framerate', `${config.fps}`,
59
- '-i', '-',
60
- '-crf', crf,
61
- '-c:a', 'aac', '-b:a', '384k', '-profile:a', 'aac_low',
62
- '-c:v', 'libx264',
63
- '-r', `${config.fps}`,
64
- '-pix_fmt', 'yuv420p',
65
- '-preset', preset,
66
- '-shortest',
67
- );
68
- if (config.subtitleFilename) {
69
- const subPath = escapeSubtitleFilterPath(config.subtitleFilename);
70
- const alignment = config.subtitleAlignmentAss ?? 2;
71
- args.push(
72
- '-vf',
73
- `subtitles='${subPath}':force_style='Alignment=${alignment}'`,
74
- );
75
- }
76
- args.push(config.videoFileName);
77
- const ffmpeg = spawn(ffmpegPath, args);
78
- if (config.onStderr) {
79
- ffmpeg.stderr.on('data', config.onStderr);
80
- }
81
- return ffmpeg;
82
- };
83
-
84
- export interface VideoInfo {
85
- width: number;
86
- height: number;
87
- duration: number;
88
- }
89
-
90
- export interface SceneChange {
91
- frameNumber: number;
92
- pts: number;
93
- ptsTime: number;
94
- }
95
-
96
- export const detectSceneChanges = (videoPath: string, threshold = 0.4): Promise<SceneChange[]> =>
97
- new Promise((resolve, reject) => {
98
- if (!ffmpegPath) {
99
- reject(new Error('ffmpeg path not found'));
100
- return;
101
- }
102
- const ffmpeg = spawn(ffmpegPath, [
103
- '-i', videoPath,
104
- '-filter:v', `select='gt(scene,${threshold})',showinfo`,
105
- '-f', 'null',
106
- '-'
107
- ]);
108
- let stderr = '';
109
- ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
110
- ffmpeg.on('close', () => {
111
- const sceneChanges: SceneChange[] = [];
112
- for (const line of stderr.split('\n')) {
113
- if (!line.includes('Parsed_showinfo')) continue;
114
- const nMatch = line.match(/n:\s*(\d+)/);
115
- const ptsMatch = line.match(/pts:\s*(\d+)/);
116
- const ptsTimeMatch = line.match(/pts_time:\s*([\d.]+)/);
117
- if (nMatch && ptsTimeMatch) {
118
- sceneChanges.push({
119
- frameNumber: parseInt(nMatch[1]),
120
- pts: ptsMatch ? parseInt(ptsMatch[1]) : 0,
121
- ptsTime: parseFloat(ptsTimeMatch[1]),
122
- });
123
- }
124
- }
125
- resolve(sceneChanges);
126
- });
127
- ffmpeg.on('error', reject);
128
- });
129
-
130
- export const getVideoInfo = (videoPath: string): Promise<VideoInfo> =>
131
- new Promise((resolvePromise, reject) => {
132
- if (!ffmpegPath) {
133
- reject(new Error('ffmpeg path not found'));
134
- return;
135
- }
136
- const ffmpeg = spawn(ffmpegPath, ['-i', videoPath, '-hide_banner']);
137
- let stderr = '';
138
- ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
139
- ffmpeg.on('close', () => {
140
- const dimMatch = stderr.match(/Stream.*Video:.*?(\d{2,5})x(\d{2,5})/);
141
- if (!dimMatch) {
142
- reject(new Error(`Could not determine video dimensions for: ${videoPath}`));
143
- return;
144
- }
145
- const durMatch = stderr.match(/Duration:\s*(\d+):(\d+):([\d.]+)/);
146
- const duration = durMatch
147
- ? parseInt(durMatch[1]) * 3600 + parseInt(durMatch[2]) * 60 + parseFloat(durMatch[3])
148
- : 0;
149
- resolvePromise({
150
- width: parseInt(dimMatch[1]),
151
- height: parseInt(dimMatch[2]),
152
- duration,
153
- });
154
- });
155
- });
156
-
157
- const isOrientationMismatch = (sw: number, sh: number, tw: number, th: number): boolean =>
158
- (sw > sh && tw < th) || (sw < sh && tw > th);
159
-
160
- export const buildVideoFilter = (
161
- sourceWidth: number,
162
- sourceHeight: number,
163
- targetWidth: number,
164
- targetHeight: number,
165
- ): string => {
166
- if (!isOrientationMismatch(sourceWidth, sourceHeight, targetWidth, targetHeight)) {
167
- return `scale=${targetWidth}:${targetHeight}`;
168
- }
169
-
170
- const targetAspect = targetWidth / targetHeight;
171
- const makeEven = (n: number) => Math.floor(n / 2) * 2;
172
- let cropW: number, cropH: number, cropX: number, cropY: number;
173
-
174
- if (sourceWidth / sourceHeight > targetAspect) {
175
- cropW = makeEven(Math.round(sourceHeight * targetAspect));
176
- cropH = makeEven(sourceHeight);
177
- cropX = Math.floor((sourceWidth - cropW) / 2);
178
- cropY = Math.floor((sourceHeight - cropH) / 2);
179
- } else {
180
- cropW = makeEven(sourceWidth);
181
- cropH = makeEven(Math.round(sourceWidth / targetAspect));
182
- cropX = Math.floor((sourceWidth - cropW) / 2);
183
- cropY = Math.floor((sourceHeight - cropH) / 2);
184
- }
185
-
186
- return `crop=${cropW}:${cropH}:${cropX}:${cropY},scale=${targetWidth}:${targetHeight}`;
187
- };
188
-
189
- export const spawnVideoFrameReader = (config: {
190
- videoPath: string;
191
- fps: number;
192
- totalFrames: number;
193
- width?: number;
194
- height?: number;
195
- sourceWidth?: number;
196
- sourceHeight?: number;
197
- }) => {
198
- if (!ffmpegPath) {
199
- throw new Error('ffmpeg path not found');
200
- }
201
- const args = [
202
- '-stream_loop', '-1',
203
- '-i', config.videoPath,
204
- '-r', `${config.fps}`,
205
- '-frames:v', `${config.totalFrames}`,
206
- ];
207
- if (config.width && config.height) {
208
- const filter = config.sourceWidth && config.sourceHeight
209
- ? buildVideoFilter(config.sourceWidth, config.sourceHeight, config.width, config.height)
210
- : `scale=${config.width}:${config.height}`;
211
- args.push('-vf', filter);
212
- }
213
- args.push(
214
- '-f', 'rawvideo',
215
- '-pix_fmt', 'bgr24',
216
- '-v', 'quiet',
217
- 'pipe:1'
218
- );
219
- return spawn(ffmpegPath, args);
220
- };
221
-
222
- export const readVideoFrame = (
223
- stream: Readable,
224
- frameSize: number,
225
- timeoutMs = defaults.timeouts.readVideoFrame,
226
- ): Promise<Buffer | null> =>
227
- new Promise((resolve) => {
228
- let timer: NodeJS.Timeout | undefined;
229
- const cleanup = () => {
230
- if (timer) {
231
- clearTimeout(timer);
232
- timer = undefined;
233
- }
234
- stream.removeListener('readable', onReadable);
235
- stream.removeListener('end', onEnd);
236
- stream.removeListener('error', onErrorOrClose);
237
- stream.removeListener('close', onErrorOrClose);
238
- };
239
- const onReadable = () => {
240
- tryRead();
241
- };
242
- const onEnd = () => {
243
- cleanup();
244
- resolve(null);
245
- };
246
- const onErrorOrClose = () => {
247
- cleanup();
248
- resolve(null);
249
- };
250
- const tryRead = () => {
251
- const data = stream.read(frameSize) as Buffer | null;
252
- if (data !== null) {
253
- cleanup();
254
- resolve(data.length === frameSize ? data : null);
255
- return;
256
- }
257
- if (timeoutMs > 0) {
258
- timer = setTimeout(() => {
259
- console.error('readVideoFrame timeout:', timeoutMs);
260
- cleanup();
261
- resolve(null);
262
- }, timeoutMs);
263
- }
264
- stream.once('readable', onReadable);
265
- stream.once('end', onEnd);
266
- stream.once('error', onErrorOrClose);
267
- stream.once('close', onErrorOrClose);
268
- };
269
- tryRead();
270
- });
271
-
272
- export const getProgress = (onProgress: (currentFrame: number) => any) =>
273
- (stderrOutput: Buffer) => {
274
- const matchResult = stderrOutput.toString().match(/frame=[ ]+(\d+)/);
275
- if (!matchResult) {
276
- return;
277
- }
278
- const currentFrame = +matchResult[1];
279
- if (isNaN(currentFrame)) {
280
- return;
281
- }
282
- onProgress(currentFrame);
283
- };
284
-
285
- export const calculateProgress = (framesCount: number, callback: (progress: number) => any) =>
286
- (currentFrame: number) =>
287
- callback(
288
- +(currentFrame / framesCount * 100).toFixed(2)
289
- );
290
-
291
- export const waitDrain = (
292
- stream: Writable,
293
- processStream?: {
294
- once: (eventName: string, listener: (...args: any[]) => void) => any;
295
- removeListener: (eventName: string, listener: (...args: any[]) => void) => any;
296
- },
297
- timeoutMs = defaults.timeouts.waitDrain,
298
- ) =>
299
- new Promise<boolean>(resolve => {
300
- const cleanup = () => {
301
- if (timer) {
302
- clearTimeout(timer);
303
- timer = undefined;
304
- }
305
- stream.removeListener('drain', onDrain);
306
- stream.removeListener('error', onErrorOrClose);
307
- stream.removeListener('close', onErrorOrClose);
308
- processStream?.removeListener('error', onErrorOrClose);
309
- processStream?.removeListener('exit', onErrorOrClose);
310
- processStream?.removeListener('close', onErrorOrClose);
311
- };
312
- const onDrain = () => {
313
- cleanup();
314
- resolve(true);
315
- };
316
- const onErrorOrClose = () => {
317
- cleanup();
318
- resolve(false);
319
- };
320
- let timer: NodeJS.Timeout | undefined;
321
- if (timeoutMs > 0) {
322
- timer = setTimeout(() => {
323
- console.error('waitDrain timeout:', timeoutMs);
324
- cleanup();
325
- resolve(false);
326
- }, timeoutMs);
327
- }
328
- stream.once('drain', onDrain);
329
- stream.once('error', onErrorOrClose);
330
- stream.once('close', onErrorOrClose);
331
- processStream?.once('error', onErrorOrClose);
332
- processStream?.once('exit', onErrorOrClose);
333
- processStream?.once('close', onErrorOrClose);
334
- });
335
-
336
- export interface ProcessExitResult {
337
- exitCode: number;
338
- reason?: string;
339
- }
340
-
341
- const resolveProcessExit = (
342
- resolve: (result: ProcessExitResult) => void,
343
- exitCode: number,
344
- reason?: string,
345
- ) => {
346
- resolve({ exitCode, ...(reason && { reason }) });
347
- };
348
-
349
- export const waitForProcessExit = (
350
- processStream: {
351
- once: (eventName: string, listener: (...args: any[]) => void) => any;
352
- removeListener: (eventName: string, listener: (...args: any[]) => void) => any;
353
- },
354
- timeoutMs = defaults.timeouts.waitForProcessExit,
355
- ) =>
356
- new Promise<ProcessExitResult>(resolve => {
357
- const cleanup = () => {
358
- if (timer) {
359
- clearTimeout(timer);
360
- timer = undefined;
361
- }
362
- processStream.removeListener('exit', onExit);
363
- processStream.removeListener('close', onClose);
364
- processStream.removeListener('error', onError);
365
- };
366
- const onExit = (code: number | null) => {
367
- cleanup();
368
- const exitCode = code ?? 0;
369
- resolveProcessExit(
370
- resolve,
371
- exitCode,
372
- exitCode !== 0 ? `ffmpeg exited with code ${exitCode}` : undefined,
373
- );
374
- };
375
- const onClose = (code: number | null) => {
376
- cleanup();
377
- const exitCode = code ?? 0;
378
- resolveProcessExit(
379
- resolve,
380
- exitCode,
381
- exitCode !== 0 ? `ffmpeg exited with code ${exitCode}` : undefined,
382
- );
383
- };
384
- const onError = () => {
385
- cleanup();
386
- resolveProcessExit(resolve, 1, 'ffmpeg process error');
387
- };
388
- let timer: NodeJS.Timeout | undefined;
389
- if (timeoutMs > 0) {
390
- timer = setTimeout(() => {
391
- console.error('waitForProcessExit timeout:', timeoutMs);
392
- cleanup();
393
- resolveProcessExit(resolve, 1, `waitForProcessExit timeout (${timeoutMs}ms)`);
394
- }, timeoutMs);
395
- }
396
-
397
- processStream.once('exit', onExit);
398
- processStream.once('close', onClose);
399
- processStream.once('error', onError);
400
- });
401
-
402
- export interface VideoSegment {
403
- outputStartFrame: number;
404
- videoSeekSeconds: number;
405
- frameCount: number;
406
- }
407
-
408
- const mergeSmallScenes = (sceneChanges: SceneChange[], videoDuration: number, minDuration = 2): SceneChange[] => {
409
- if (sceneChanges.length === 0) return [];
410
- const merged: SceneChange[] = [sceneChanges[0]];
411
- for (let i = 1; i < sceneChanges.length; i++) {
412
- const lastKept = merged[merged.length - 1];
413
- const gap = sceneChanges[i].ptsTime - lastKept.ptsTime;
414
- if (gap >= minDuration) {
415
- merged.push(sceneChanges[i]);
416
- }
417
- }
418
- const lastScene = merged[merged.length - 1];
419
- if (videoDuration - lastScene.ptsTime < minDuration && merged.length > 1) {
420
- merged.pop();
421
- }
422
- return merged;
423
- };
424
-
425
- export const buildBeatSyncedSegments = (
426
- beatFrameIndices: number[],
427
- totalFrames: number,
428
- sceneChanges: SceneChange[],
429
- videoDuration: number,
430
- ): VideoSegment[] => {
431
- if (sceneChanges.length === 0) {
432
- return [
433
- {
434
- outputStartFrame: 0,
435
- videoSeekSeconds: 0,
436
- frameCount: totalFrames,
437
- },
438
- ];
439
- }
440
-
441
- const consolidated = mergeSmallScenes(sceneChanges, videoDuration);
442
- let seekPositions: number[];
443
- if (consolidated.length >= 2) {
444
- seekPositions = consolidated.map(sc => sc.ptsTime);
445
- } else {
446
- const count = Math.max(10, Math.ceil(videoDuration / 3));
447
- seekPositions = Array.from({ length: count }, (_, i) => (i * videoDuration) / count);
448
- }
449
-
450
- const boundaries = [0, ...beatFrameIndices];
451
- if (boundaries[boundaries.length - 1] !== totalFrames) {
452
- boundaries.push(totalFrames);
453
- }
454
- const unique = Array.from(new Set(boundaries)).sort((a, b) => a - b);
455
-
456
- const segments: VideoSegment[] = [];
457
- for (let i = 0; i < unique.length - 1; i++) {
458
- segments.push({
459
- outputStartFrame: unique[i],
460
- videoSeekSeconds: seekPositions[i % seekPositions.length],
461
- frameCount: unique[i + 1] - unique[i],
462
- });
463
- }
464
- return segments;
465
- };
466
-
467
- export const writeConcatFile = (
468
- segments: VideoSegment[],
469
- videoPath: string,
470
- videoDuration: number,
471
- fps: number,
472
- ): string => {
473
- const absVideoPath = resolvePath(videoPath).replace(/\\/g, '/');
474
- const escaped = absVideoPath.replace(/'/g, "'\\''");
475
- let content = '';
476
-
477
- for (const seg of segments) {
478
- const segDuration = seg.frameCount / fps;
479
- let remaining = segDuration;
480
- let pos = seg.videoSeekSeconds % videoDuration;
481
-
482
- while (remaining > 0.001) {
483
- const available = videoDuration - pos;
484
- const take = Math.min(remaining, available);
485
- content += `file '${escaped}'\n`;
486
- content += `inpoint ${pos.toFixed(6)}\n`;
487
- content += `outpoint ${(pos + take).toFixed(6)}\n\n`;
488
- remaining -= take;
489
- pos = 0;
490
- }
491
- }
492
-
493
- const concatPath = joinPath(tmpdir(), `av-concat-${Date.now()}.txt`);
494
- writeFileSync(concatPath, content, 'utf-8');
495
- return concatPath;
496
- };
497
-
498
- export const writeSubtitlesFile = (subtitles: string): string => {
499
- const subtitlePath = joinPath(tmpdir(), `av-subtitles-${Date.now()}.srt`);
500
- writeFileSync(subtitlePath, subtitles, 'utf-8');
501
- return subtitlePath;
502
- };
503
-
504
- export const spawnConcatVideoFrameReader = (config: {
505
- concatFilePath: string;
506
- fps: number;
507
- totalFrames: number;
508
- width?: number;
509
- height?: number;
510
- sourceWidth?: number;
511
- sourceHeight?: number;
512
- }) => {
513
- if (!ffmpegPath) {
514
- throw new Error('ffmpeg path not found');
515
- }
516
- const args = [
517
- '-f', 'concat',
518
- '-safe', '0',
519
- '-i', config.concatFilePath,
520
- '-r', `${config.fps}`,
521
- '-frames:v', `${config.totalFrames}`,
522
- ];
523
- if (config.width && config.height) {
524
- const filter = config.sourceWidth && config.sourceHeight
525
- ? buildVideoFilter(config.sourceWidth, config.sourceHeight, config.width, config.height)
526
- : `scale=${config.width}:${config.height}`;
527
- args.push('-vf', filter);
528
- }
529
- args.push(
530
- '-f', 'rawvideo',
531
- '-pix_fmt', 'bgr24',
532
- '-v', 'quiet',
533
- 'pipe:1'
534
- );
535
- return spawn(ffmpegPath, args);
536
- };
537
-
538
- export const cleanupTempFile = (filePath: string) => {
539
- try { unlinkSync(filePath); } catch {}
540
- };
541
-
542
- export const cleanupConcatFile = cleanupTempFile;
1
+ import { Readable, Writable } from 'stream';
2
+ import { spawn } from 'child_process';
3
+ import { writeFileSync, unlinkSync } from 'fs';
4
+ import { resolve as resolvePath, join as joinPath } from 'path';
5
+ import { tmpdir } from 'os';
6
+ import ffmpegPath from 'ffmpeg-static';
7
+ import { defaults } from './config';
8
+
9
+ export interface AudioMuxSegment {
10
+ seekSeconds: number;
11
+ durationSeconds: number;
12
+ }
13
+
14
+ interface FfmpegVideoWriterConfig {
15
+ audioFilename: string;
16
+ subtitleFilename?: string;
17
+ /** ASS `Alignment` in FFmpeg `subtitles` `force_style` (e.g. 2 bottom, 5 middle, 8 top). Default 2. */
18
+ subtitleAlignmentAss?: number;
19
+ videoFileName: string;
20
+ fps: number;
21
+ crf?: string;
22
+ preset?: string;
23
+ onStderr?: (data: any) => any;
24
+ /** When set with `audioDurationSeconds`, passed to ffmpeg before `-i` for muxed highlight. */
25
+ audioSeekSeconds?: number;
26
+ audioDurationSeconds?: number;
27
+ /** One contiguous trim of the same audio file (muxed highlight). */
28
+ audioSegment?: AudioMuxSegment;
29
+ }
30
+
31
+ const escapeSubtitleFilterPath = (subtitlePath: string) =>
32
+ subtitlePath
33
+ .replace(/\\/g, '\\\\')
34
+ .replace(/:/g, '\\:')
35
+ .replace(/'/g, "\\'")
36
+ .replace(/,/g, '\\,')
37
+ .replace(/\[/g, '\\[')
38
+ .replace(/\]/g, '\\]');
39
+
40
+ export const spawnFfmpegVideoWriter = (config: FfmpegVideoWriterConfig) => {
41
+ if (!ffmpegPath) {
42
+ throw new Error('ffmpeg path not found');
43
+ }
44
+ const crf = config.crf || '23';
45
+ const preset = config.preset || 'medium';
46
+ const args: string[] = ['-y'];
47
+ const seek =
48
+ config.audioSegment?.seekSeconds ?? config.audioSeekSeconds;
49
+ const duration =
50
+ config.audioSegment?.durationSeconds ?? config.audioDurationSeconds;
51
+ if (seek !== undefined && duration !== undefined) {
52
+ args.push('-ss', String(seek), '-t', String(duration));
53
+ }
54
+ args.push(
55
+ '-i', config.audioFilename,
56
+ '-f', 'image2pipe',
57
+ '-vcodec', 'bmp',
58
+ '-framerate', `${config.fps}`,
59
+ '-i', '-',
60
+ '-crf', crf,
61
+ '-c:a', 'aac', '-b:a', '384k', '-profile:a', 'aac_low',
62
+ '-c:v', 'libx264',
63
+ '-r', `${config.fps}`,
64
+ '-pix_fmt', 'yuv420p',
65
+ '-preset', preset,
66
+ '-shortest',
67
+ );
68
+ if (config.subtitleFilename) {
69
+ const subPath = escapeSubtitleFilterPath(config.subtitleFilename);
70
+ const alignment = config.subtitleAlignmentAss ?? 2;
71
+ args.push(
72
+ '-vf',
73
+ `subtitles='${subPath}':force_style='Alignment=${alignment}'`,
74
+ );
75
+ }
76
+ args.push(config.videoFileName);
77
+ const ffmpeg = spawn(ffmpegPath, args);
78
+ if (config.onStderr) {
79
+ ffmpeg.stderr.on('data', config.onStderr);
80
+ }
81
+ return ffmpeg;
82
+ };
83
+
84
+ export interface VideoInfo {
85
+ width: number;
86
+ height: number;
87
+ duration: number;
88
+ }
89
+
90
+ export interface SceneChange {
91
+ frameNumber: number;
92
+ pts: number;
93
+ ptsTime: number;
94
+ }
95
+
96
+ export const detectSceneChanges = (videoPath: string, threshold = 0.4): Promise<SceneChange[]> =>
97
+ new Promise((resolve, reject) => {
98
+ if (!ffmpegPath) {
99
+ reject(new Error('ffmpeg path not found'));
100
+ return;
101
+ }
102
+ const ffmpeg = spawn(ffmpegPath, [
103
+ '-i', videoPath,
104
+ '-filter:v', `select='gt(scene,${threshold})',showinfo`,
105
+ '-f', 'null',
106
+ '-'
107
+ ]);
108
+ let stderr = '';
109
+ ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
110
+ ffmpeg.on('close', () => {
111
+ const sceneChanges: SceneChange[] = [];
112
+ for (const line of stderr.split('\n')) {
113
+ if (!line.includes('Parsed_showinfo')) continue;
114
+ const nMatch = line.match(/n:\s*(\d+)/);
115
+ const ptsMatch = line.match(/pts:\s*(\d+)/);
116
+ const ptsTimeMatch = line.match(/pts_time:\s*([\d.]+)/);
117
+ if (nMatch && ptsTimeMatch) {
118
+ sceneChanges.push({
119
+ frameNumber: parseInt(nMatch[1]),
120
+ pts: ptsMatch ? parseInt(ptsMatch[1]) : 0,
121
+ ptsTime: parseFloat(ptsTimeMatch[1]),
122
+ });
123
+ }
124
+ }
125
+ resolve(sceneChanges);
126
+ });
127
+ ffmpeg.on('error', reject);
128
+ });
129
+
130
+ export const getVideoInfo = (videoPath: string): Promise<VideoInfo> =>
131
+ new Promise((resolvePromise, reject) => {
132
+ if (!ffmpegPath) {
133
+ reject(new Error('ffmpeg path not found'));
134
+ return;
135
+ }
136
+ const ffmpeg = spawn(ffmpegPath, ['-i', videoPath, '-hide_banner']);
137
+ let stderr = '';
138
+ ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
139
+ ffmpeg.on('close', () => {
140
+ const dimMatch = stderr.match(/Stream.*Video:.*?(\d{2,5})x(\d{2,5})/);
141
+ if (!dimMatch) {
142
+ reject(new Error(`Could not determine video dimensions for: ${videoPath}`));
143
+ return;
144
+ }
145
+ const durMatch = stderr.match(/Duration:\s*(\d+):(\d+):([\d.]+)/);
146
+ const duration = durMatch
147
+ ? parseInt(durMatch[1]) * 3600 + parseInt(durMatch[2]) * 60 + parseFloat(durMatch[3])
148
+ : 0;
149
+ resolvePromise({
150
+ width: parseInt(dimMatch[1]),
151
+ height: parseInt(dimMatch[2]),
152
+ duration,
153
+ });
154
+ });
155
+ });
156
+
157
+ const isOrientationMismatch = (sw: number, sh: number, tw: number, th: number): boolean =>
158
+ (sw > sh && tw < th) || (sw < sh && tw > th);
159
+
160
+ export const buildVideoFilter = (
161
+ sourceWidth: number,
162
+ sourceHeight: number,
163
+ targetWidth: number,
164
+ targetHeight: number,
165
+ ): string => {
166
+ if (!isOrientationMismatch(sourceWidth, sourceHeight, targetWidth, targetHeight)) {
167
+ return `scale=${targetWidth}:${targetHeight}`;
168
+ }
169
+
170
+ const targetAspect = targetWidth / targetHeight;
171
+ const makeEven = (n: number) => Math.floor(n / 2) * 2;
172
+ let cropW: number, cropH: number, cropX: number, cropY: number;
173
+
174
+ if (sourceWidth / sourceHeight > targetAspect) {
175
+ cropW = makeEven(Math.round(sourceHeight * targetAspect));
176
+ cropH = makeEven(sourceHeight);
177
+ cropX = Math.floor((sourceWidth - cropW) / 2);
178
+ cropY = Math.floor((sourceHeight - cropH) / 2);
179
+ } else {
180
+ cropW = makeEven(sourceWidth);
181
+ cropH = makeEven(Math.round(sourceWidth / targetAspect));
182
+ cropX = Math.floor((sourceWidth - cropW) / 2);
183
+ cropY = Math.floor((sourceHeight - cropH) / 2);
184
+ }
185
+
186
+ return `crop=${cropW}:${cropH}:${cropX}:${cropY},scale=${targetWidth}:${targetHeight}`;
187
+ };
188
+
189
+ export const spawnVideoFrameReader = (config: {
190
+ videoPath: string;
191
+ fps: number;
192
+ totalFrames: number;
193
+ width?: number;
194
+ height?: number;
195
+ sourceWidth?: number;
196
+ sourceHeight?: number;
197
+ }) => {
198
+ if (!ffmpegPath) {
199
+ throw new Error('ffmpeg path not found');
200
+ }
201
+ const args = [
202
+ '-stream_loop', '-1',
203
+ '-i', config.videoPath,
204
+ '-r', `${config.fps}`,
205
+ '-frames:v', `${config.totalFrames}`,
206
+ ];
207
+ if (config.width && config.height) {
208
+ const filter = config.sourceWidth && config.sourceHeight
209
+ ? buildVideoFilter(config.sourceWidth, config.sourceHeight, config.width, config.height)
210
+ : `scale=${config.width}:${config.height}`;
211
+ args.push('-vf', filter);
212
+ }
213
+ args.push(
214
+ '-f', 'rawvideo',
215
+ '-pix_fmt', 'bgr24',
216
+ '-v', 'quiet',
217
+ 'pipe:1'
218
+ );
219
+ return spawn(ffmpegPath, args);
220
+ };
221
+
222
+ export const readVideoFrame = (
223
+ stream: Readable,
224
+ frameSize: number,
225
+ timeoutMs = defaults.timeouts.readVideoFrame,
226
+ ): Promise<Buffer | null> =>
227
+ new Promise((resolve) => {
228
+ let timer: NodeJS.Timeout | undefined;
229
+ let settled = false;
230
+ const finish = (result: Buffer | null) => {
231
+ if (settled) {
232
+ return;
233
+ }
234
+ settled = true;
235
+ if (timer) {
236
+ clearTimeout(timer);
237
+ timer = undefined;
238
+ }
239
+ stream.removeListener('readable', onReadable);
240
+ stream.removeListener('end', onEnd);
241
+ stream.removeListener('error', onErrorOrClose);
242
+ stream.removeListener('close', onErrorOrClose);
243
+ resolve(result);
244
+ };
245
+ const onReadable = () => {
246
+ tryRead();
247
+ };
248
+ const onEnd = () => {
249
+ finish(null);
250
+ };
251
+ const onErrorOrClose = () => {
252
+ finish(null);
253
+ };
254
+ const tryRead = () => {
255
+ const data = stream.read(frameSize) as Buffer | null;
256
+ if (data !== null) {
257
+ finish(data.length === frameSize ? data : null);
258
+ }
259
+ };
260
+ tryRead();
261
+ if (settled) {
262
+ return;
263
+ }
264
+ if (timeoutMs > 0) {
265
+ timer = setTimeout(() => {
266
+ console.error('readVideoFrame timeout:', timeoutMs);
267
+ finish(null);
268
+ }, timeoutMs);
269
+ }
270
+ stream.on('readable', onReadable);
271
+ stream.once('end', onEnd);
272
+ stream.once('error', onErrorOrClose);
273
+ stream.once('close', onErrorOrClose);
274
+ });
275
+
276
+ export const getProgress = (onProgress: (currentFrame: number) => any) =>
277
+ (stderrOutput: Buffer) => {
278
+ const matchResult = stderrOutput.toString().match(/frame=[ ]+(\d+)/);
279
+ if (!matchResult) {
280
+ return;
281
+ }
282
+ const currentFrame = +matchResult[1];
283
+ if (isNaN(currentFrame)) {
284
+ return;
285
+ }
286
+ onProgress(currentFrame);
287
+ };
288
+
289
+ export const calculateProgress = (framesCount: number, callback: (progress: number) => any) =>
290
+ (currentFrame: number) =>
291
+ callback(
292
+ +(currentFrame / framesCount * 100).toFixed(2)
293
+ );
294
+
295
+ export const waitDrain = (
296
+ stream: Writable,
297
+ processStream?: {
298
+ once: (eventName: string, listener: (...args: any[]) => void) => any;
299
+ removeListener: (eventName: string, listener: (...args: any[]) => void) => any;
300
+ },
301
+ timeoutMs = defaults.timeouts.waitDrain,
302
+ ) =>
303
+ new Promise<boolean>(resolve => {
304
+ const cleanup = () => {
305
+ if (timer) {
306
+ clearTimeout(timer);
307
+ timer = undefined;
308
+ }
309
+ stream.removeListener('drain', onDrain);
310
+ stream.removeListener('error', onErrorOrClose);
311
+ stream.removeListener('close', onErrorOrClose);
312
+ processStream?.removeListener('error', onErrorOrClose);
313
+ processStream?.removeListener('exit', onErrorOrClose);
314
+ processStream?.removeListener('close', onErrorOrClose);
315
+ };
316
+ const onDrain = () => {
317
+ cleanup();
318
+ resolve(true);
319
+ };
320
+ const onErrorOrClose = () => {
321
+ cleanup();
322
+ resolve(false);
323
+ };
324
+ let timer: NodeJS.Timeout | undefined;
325
+ if (timeoutMs > 0) {
326
+ timer = setTimeout(() => {
327
+ console.error('waitDrain timeout:', timeoutMs);
328
+ cleanup();
329
+ resolve(false);
330
+ }, timeoutMs);
331
+ }
332
+ stream.once('drain', onDrain);
333
+ stream.once('error', onErrorOrClose);
334
+ stream.once('close', onErrorOrClose);
335
+ processStream?.once('error', onErrorOrClose);
336
+ processStream?.once('exit', onErrorOrClose);
337
+ processStream?.once('close', onErrorOrClose);
338
+ });
339
+
340
+ export interface ProcessExitResult {
341
+ exitCode: number;
342
+ reason?: string;
343
+ }
344
+
345
+ const resolveProcessExit = (
346
+ resolve: (result: ProcessExitResult) => void,
347
+ exitCode: number,
348
+ reason?: string,
349
+ ) => {
350
+ resolve({ exitCode, ...(reason && { reason }) });
351
+ };
352
+
353
+ export const waitForProcessExit = (
354
+ processStream: {
355
+ once: (eventName: string, listener: (...args: any[]) => void) => any;
356
+ removeListener: (eventName: string, listener: (...args: any[]) => void) => any;
357
+ },
358
+ timeoutMs = defaults.timeouts.waitForProcessExit,
359
+ ) =>
360
+ new Promise<ProcessExitResult>(resolve => {
361
+ const cleanup = () => {
362
+ if (timer) {
363
+ clearTimeout(timer);
364
+ timer = undefined;
365
+ }
366
+ processStream.removeListener('exit', onExit);
367
+ processStream.removeListener('close', onClose);
368
+ processStream.removeListener('error', onError);
369
+ };
370
+ const onExit = (code: number | null) => {
371
+ cleanup();
372
+ const exitCode = code ?? 0;
373
+ resolveProcessExit(
374
+ resolve,
375
+ exitCode,
376
+ exitCode !== 0 ? `ffmpeg exited with code ${exitCode}` : undefined,
377
+ );
378
+ };
379
+ const onClose = (code: number | null) => {
380
+ cleanup();
381
+ const exitCode = code ?? 0;
382
+ resolveProcessExit(
383
+ resolve,
384
+ exitCode,
385
+ exitCode !== 0 ? `ffmpeg exited with code ${exitCode}` : undefined,
386
+ );
387
+ };
388
+ const onError = () => {
389
+ cleanup();
390
+ resolveProcessExit(resolve, 1, 'ffmpeg process error');
391
+ };
392
+ let timer: NodeJS.Timeout | undefined;
393
+ if (timeoutMs > 0) {
394
+ timer = setTimeout(() => {
395
+ console.error('waitForProcessExit timeout:', timeoutMs);
396
+ cleanup();
397
+ resolveProcessExit(resolve, 1, `waitForProcessExit timeout (${timeoutMs}ms)`);
398
+ }, timeoutMs);
399
+ }
400
+
401
+ processStream.once('exit', onExit);
402
+ processStream.once('close', onClose);
403
+ processStream.once('error', onError);
404
+ });
405
+
406
+ export interface VideoSegment {
407
+ outputStartFrame: number;
408
+ videoSeekSeconds: number;
409
+ frameCount: number;
410
+ }
411
+
412
+ const mergeSmallScenes = (sceneChanges: SceneChange[], videoDuration: number, minDuration = 2): SceneChange[] => {
413
+ if (sceneChanges.length === 0) return [];
414
+ const merged: SceneChange[] = [sceneChanges[0]];
415
+ for (let i = 1; i < sceneChanges.length; i++) {
416
+ const lastKept = merged[merged.length - 1];
417
+ const gap = sceneChanges[i].ptsTime - lastKept.ptsTime;
418
+ if (gap >= minDuration) {
419
+ merged.push(sceneChanges[i]);
420
+ }
421
+ }
422
+ const lastScene = merged[merged.length - 1];
423
+ if (videoDuration - lastScene.ptsTime < minDuration && merged.length > 1) {
424
+ merged.pop();
425
+ }
426
+ return merged;
427
+ };
428
+
429
+ export const buildBeatSyncedSegments = (
430
+ beatFrameIndices: number[],
431
+ totalFrames: number,
432
+ sceneChanges: SceneChange[],
433
+ videoDuration: number,
434
+ ): VideoSegment[] => {
435
+ if (sceneChanges.length === 0) {
436
+ return [
437
+ {
438
+ outputStartFrame: 0,
439
+ videoSeekSeconds: 0,
440
+ frameCount: totalFrames,
441
+ },
442
+ ];
443
+ }
444
+
445
+ const consolidated = mergeSmallScenes(sceneChanges, videoDuration);
446
+ let seekPositions: number[];
447
+ if (consolidated.length >= 2) {
448
+ seekPositions = consolidated.map(sc => sc.ptsTime);
449
+ } else {
450
+ const count = Math.max(10, Math.ceil(videoDuration / 3));
451
+ seekPositions = Array.from({ length: count }, (_, i) => (i * videoDuration) / count);
452
+ }
453
+
454
+ const boundaries = [0, ...beatFrameIndices];
455
+ if (boundaries[boundaries.length - 1] !== totalFrames) {
456
+ boundaries.push(totalFrames);
457
+ }
458
+ const unique = Array.from(new Set(boundaries)).sort((a, b) => a - b);
459
+
460
+ const segments: VideoSegment[] = [];
461
+ for (let i = 0; i < unique.length - 1; i++) {
462
+ segments.push({
463
+ outputStartFrame: unique[i],
464
+ videoSeekSeconds: seekPositions[i % seekPositions.length],
465
+ frameCount: unique[i + 1] - unique[i],
466
+ });
467
+ }
468
+ return segments;
469
+ };
470
+
471
+ export const writeConcatFile = (
472
+ segments: VideoSegment[],
473
+ videoPath: string,
474
+ videoDuration: number,
475
+ fps: number,
476
+ ): string => {
477
+ const absVideoPath = resolvePath(videoPath).replace(/\\/g, '/');
478
+ const escaped = absVideoPath.replace(/'/g, "'\\''");
479
+ let content = '';
480
+
481
+ for (const seg of segments) {
482
+ const segDuration = seg.frameCount / fps;
483
+ let remaining = segDuration;
484
+ let pos = seg.videoSeekSeconds % videoDuration;
485
+
486
+ while (remaining > 0.001) {
487
+ const available = videoDuration - pos;
488
+ const take = Math.min(remaining, available);
489
+ content += `file '${escaped}'\n`;
490
+ content += `inpoint ${pos.toFixed(6)}\n`;
491
+ content += `outpoint ${(pos + take).toFixed(6)}\n`;
492
+ content += `duration ${take.toFixed(6)}\n\n`;
493
+ remaining -= take;
494
+ pos = 0;
495
+ }
496
+ }
497
+
498
+ const concatPath = joinPath(tmpdir(), `av-concat-${Date.now()}.txt`);
499
+ writeFileSync(concatPath, content, 'utf-8');
500
+ return concatPath;
501
+ };
502
+
503
+ export const writeSubtitlesFile = (subtitles: string): string => {
504
+ const subtitlePath = joinPath(tmpdir(), `av-subtitles-${Date.now()}.srt`);
505
+ writeFileSync(subtitlePath, subtitles, 'utf-8');
506
+ return subtitlePath;
507
+ };
508
+
509
+ export const spawnConcatVideoFrameReader = (config: {
510
+ concatFilePath: string;
511
+ fps: number;
512
+ totalFrames: number;
513
+ width?: number;
514
+ height?: number;
515
+ sourceWidth?: number;
516
+ sourceHeight?: number;
517
+ }) => {
518
+ if (!ffmpegPath) {
519
+ throw new Error('ffmpeg path not found');
520
+ }
521
+ const args = [
522
+ '-f', 'concat',
523
+ '-safe', '0',
524
+ '-fflags', '+genpts',
525
+ '-i', config.concatFilePath,
526
+ '-an',
527
+ '-avoid_negative_ts', 'make_zero',
528
+ '-r', `${config.fps}`,
529
+ '-frames:v', `${config.totalFrames}`,
530
+ ];
531
+ if (config.width && config.height) {
532
+ const filter = config.sourceWidth && config.sourceHeight
533
+ ? buildVideoFilter(config.sourceWidth, config.sourceHeight, config.width, config.height)
534
+ : `scale=${config.width}:${config.height}`;
535
+ args.push('-vf', filter);
536
+ }
537
+ args.push(
538
+ '-f', 'rawvideo',
539
+ '-pix_fmt', 'bgr24',
540
+ '-v', 'quiet',
541
+ 'pipe:1'
542
+ );
543
+ return spawn(ffmpegPath, args);
544
+ };
545
+
546
+ export const cleanupTempFile = (filePath: string) => {
547
+ try { unlinkSync(filePath); } catch {}
548
+ };
549
+
550
+ export const cleanupConcatFile = cleanupTempFile;