nodejs-audio-visualizer 5.1.0 → 5.2.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 (50) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -2
  3. package/dist/audio.d.ts +13 -13
  4. package/dist/audio.js +128 -128
  5. package/dist/beats.d.ts +39 -39
  6. package/dist/beats.js +220 -220
  7. package/dist/bpmEncoder.d.ts +14 -14
  8. package/dist/bpmEncoder.js +97 -97
  9. package/dist/config.d.ts +78 -77
  10. package/dist/config.js +377 -369
  11. package/dist/config.js.map +1 -1
  12. package/dist/dsp.d.ts +16 -16
  13. package/dist/dsp.js +76 -76
  14. package/dist/highlight.d.ts +34 -34
  15. package/dist/highlight.js +334 -334
  16. package/dist/image.d.ts +97 -92
  17. package/dist/image.js +545 -538
  18. package/dist/image.js.map +1 -1
  19. package/dist/index.d.ts +87 -79
  20. package/dist/index.js +597 -566
  21. package/dist/index.js.map +1 -1
  22. package/dist/subtitleConvert.d.ts +3 -3
  23. package/dist/subtitleConvert.js +61 -61
  24. package/dist/video.d.ts +150 -120
  25. package/dist/video.js +677 -582
  26. package/dist/video.js.map +1 -1
  27. package/dist/waitForEventLoop.d.ts +1 -1
  28. package/dist/waitForEventLoop.js +5 -5
  29. package/package.json +62 -62
  30. package/src/audio.ts +129 -129
  31. package/src/beats.ts +279 -279
  32. package/src/bpmEncoder.ts +97 -97
  33. package/src/config.ts +487 -476
  34. package/src/dsp.ts +95 -95
  35. package/src/highlight.ts +291 -291
  36. package/src/image.ts +707 -700
  37. package/src/index.ts +79 -37
  38. package/src/subtitleConvert.ts +63 -63
  39. package/src/tests/audio.test.ts +102 -102
  40. package/src/tests/beats.test.ts +134 -134
  41. package/src/tests/config.test.ts +534 -496
  42. package/src/tests/dsp.test.ts +24 -24
  43. package/src/tests/highlight.test.ts +178 -178
  44. package/src/tests/image.test.ts +319 -308
  45. package/src/tests/index.test.ts +180 -180
  46. package/src/tests/subtitleConvert.test.ts +23 -23
  47. package/src/tests/video.test.ts +535 -447
  48. package/src/typings.d.ts +28 -28
  49. package/src/video.ts +856 -722
  50. package/src/waitForEventLoop.ts +2 -2
package/src/video.ts CHANGED
@@ -1,722 +1,856 @@
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
- import { TempoEstimate, beatGridFrameIndices } from './beats';
9
-
10
- export interface AudioMuxSegment {
11
- seekSeconds: number;
12
- durationSeconds: number;
13
- }
14
-
15
- interface FfmpegVideoWriterConfig {
16
- audioFilename: string;
17
- subtitleFilename?: string;
18
- /** ASS `Alignment` in FFmpeg `subtitles` `force_style` (e.g. 2 bottom, 5 middle, 8 top). Default 2. */
19
- subtitleAlignmentAss?: number;
20
- videoFileName: string;
21
- fps: number;
22
- crf?: string;
23
- preset?: string;
24
- onStderr?: (data: any) => any;
25
- /** When set with `audioDurationSeconds`, passed to ffmpeg before `-i` for muxed highlight. */
26
- audioSeekSeconds?: number;
27
- audioDurationSeconds?: number;
28
- /** One contiguous trim of the same audio file (muxed highlight). */
29
- audioSegment?: AudioMuxSegment;
30
- }
31
-
32
- const escapeSubtitleFilterPath = (subtitlePath: string) =>
33
- subtitlePath
34
- .replace(/\\/g, '\\\\')
35
- .replace(/:/g, '\\:')
36
- .replace(/'/g, "\\'")
37
- .replace(/,/g, '\\,')
38
- .replace(/\[/g, '\\[')
39
- .replace(/\]/g, '\\]');
40
-
41
- export const spawnFfmpegVideoWriter = (config: FfmpegVideoWriterConfig) => {
42
- if (!ffmpegPath) {
43
- throw new Error('ffmpeg path not found');
44
- }
45
- const crf = config.crf || '23';
46
- const preset = config.preset || 'medium';
47
- const args: string[] = ['-y'];
48
- const seek =
49
- config.audioSegment?.seekSeconds ?? config.audioSeekSeconds;
50
- const duration =
51
- config.audioSegment?.durationSeconds ?? config.audioDurationSeconds;
52
- if (seek !== undefined && duration !== undefined) {
53
- args.push('-ss', String(seek), '-t', String(duration));
54
- }
55
- args.push(
56
- '-i', config.audioFilename,
57
- '-f', 'image2pipe',
58
- '-vcodec', 'bmp',
59
- '-framerate', `${config.fps}`,
60
- '-i', '-',
61
- '-crf', crf,
62
- '-c:a', 'aac', '-b:a', '384k', '-profile:a', 'aac_low',
63
- '-c:v', 'libx264',
64
- '-r', `${config.fps}`,
65
- '-pix_fmt', 'yuv420p',
66
- '-preset', preset,
67
- '-shortest',
68
- );
69
- if (config.subtitleFilename) {
70
- const subPath = escapeSubtitleFilterPath(config.subtitleFilename);
71
- const alignment = config.subtitleAlignmentAss ?? 2;
72
- args.push(
73
- '-vf',
74
- `subtitles='${subPath}':force_style='Alignment=${alignment}'`,
75
- );
76
- }
77
- args.push(config.videoFileName);
78
- const ffmpeg = spawn(ffmpegPath, args);
79
- if (config.onStderr) {
80
- ffmpeg.stderr.on('data', config.onStderr);
81
- }
82
- return ffmpeg;
83
- };
84
-
85
- export interface VideoInfo {
86
- width: number;
87
- height: number;
88
- duration: number;
89
- }
90
-
91
- export interface SceneChange {
92
- frameNumber: number;
93
- pts: number;
94
- ptsTime: number;
95
- }
96
-
97
- export const detectSceneChanges = (videoPath: string, threshold = 0.4): Promise<SceneChange[]> =>
98
- new Promise((resolve, reject) => {
99
- if (!ffmpegPath) {
100
- reject(new Error('ffmpeg path not found'));
101
- return;
102
- }
103
- const ffmpeg = spawn(ffmpegPath, [
104
- '-i', videoPath,
105
- '-filter:v', `select='gt(scene,${threshold})',showinfo`,
106
- '-f', 'null',
107
- '-'
108
- ]);
109
- let stderr = '';
110
- ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
111
- ffmpeg.on('close', () => {
112
- const sceneChanges: SceneChange[] = [];
113
- for (const line of stderr.split('\n')) {
114
- if (!line.includes('Parsed_showinfo')) continue;
115
- const nMatch = line.match(/n:\s*(\d+)/);
116
- const ptsMatch = line.match(/pts:\s*(\d+)/);
117
- const ptsTimeMatch = line.match(/pts_time:\s*([\d.]+)/);
118
- if (nMatch && ptsTimeMatch) {
119
- sceneChanges.push({
120
- frameNumber: parseInt(nMatch[1]),
121
- pts: ptsMatch ? parseInt(ptsMatch[1]) : 0,
122
- ptsTime: parseFloat(ptsTimeMatch[1]),
123
- });
124
- }
125
- }
126
- resolve(sceneChanges);
127
- });
128
- ffmpeg.on('error', reject);
129
- });
130
-
131
- export const getVideoInfo = (videoPath: string): Promise<VideoInfo> =>
132
- new Promise((resolvePromise, reject) => {
133
- if (!ffmpegPath) {
134
- reject(new Error('ffmpeg path not found'));
135
- return;
136
- }
137
- const ffmpeg = spawn(ffmpegPath, ['-i', videoPath, '-hide_banner']);
138
- let stderr = '';
139
- ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
140
- ffmpeg.on('close', () => {
141
- const dimMatch = stderr.match(/Stream.*Video:.*?(\d{2,5})x(\d{2,5})/);
142
- if (!dimMatch) {
143
- reject(new Error(`Could not determine video dimensions for: ${videoPath}`));
144
- return;
145
- }
146
- const durMatch = stderr.match(/Duration:\s*(\d+):(\d+):([\d.]+)/);
147
- const duration = durMatch
148
- ? parseInt(durMatch[1]) * 3600 + parseInt(durMatch[2]) * 60 + parseFloat(durMatch[3])
149
- : 0;
150
- resolvePromise({
151
- width: parseInt(dimMatch[1]),
152
- height: parseInt(dimMatch[2]),
153
- duration,
154
- });
155
- });
156
- });
157
-
158
- const isOrientationMismatch = (sw: number, sh: number, tw: number, th: number): boolean =>
159
- (sw > sh && tw < th) || (sw < sh && tw > th);
160
-
161
- export const buildVideoFilter = (
162
- sourceWidth: number,
163
- sourceHeight: number,
164
- targetWidth: number,
165
- targetHeight: number,
166
- ): string => {
167
- if (!isOrientationMismatch(sourceWidth, sourceHeight, targetWidth, targetHeight)) {
168
- return `scale=${targetWidth}:${targetHeight}`;
169
- }
170
-
171
- const targetAspect = targetWidth / targetHeight;
172
- const makeEven = (n: number) => Math.floor(n / 2) * 2;
173
- let cropW: number, cropH: number, cropX: number, cropY: number;
174
-
175
- if (sourceWidth / sourceHeight > targetAspect) {
176
- cropW = makeEven(Math.round(sourceHeight * targetAspect));
177
- cropH = makeEven(sourceHeight);
178
- cropX = Math.floor((sourceWidth - cropW) / 2);
179
- cropY = Math.floor((sourceHeight - cropH) / 2);
180
- } else {
181
- cropW = makeEven(sourceWidth);
182
- cropH = makeEven(Math.round(sourceWidth / targetAspect));
183
- cropX = Math.floor((sourceWidth - cropW) / 2);
184
- cropY = Math.floor((sourceHeight - cropH) / 2);
185
- }
186
-
187
- return `crop=${cropW}:${cropH}:${cropX}:${cropY},scale=${targetWidth}:${targetHeight}`;
188
- };
189
-
190
- export const spawnVideoFrameReader = (config: {
191
- videoPath: string;
192
- fps: number;
193
- totalFrames: number;
194
- width?: number;
195
- height?: number;
196
- sourceWidth?: number;
197
- sourceHeight?: number;
198
- }) => {
199
- if (!ffmpegPath) {
200
- throw new Error('ffmpeg path not found');
201
- }
202
- const args = [
203
- '-stream_loop', '-1',
204
- '-i', config.videoPath,
205
- '-r', `${config.fps}`,
206
- '-frames:v', `${config.totalFrames}`,
207
- ];
208
- if (config.width && config.height) {
209
- const filter = config.sourceWidth && config.sourceHeight
210
- ? buildVideoFilter(config.sourceWidth, config.sourceHeight, config.width, config.height)
211
- : `scale=${config.width}:${config.height}`;
212
- args.push('-vf', filter);
213
- }
214
- args.push(
215
- '-f', 'rawvideo',
216
- '-pix_fmt', 'bgr24',
217
- '-v', 'quiet',
218
- 'pipe:1'
219
- );
220
- return spawn(ffmpegPath, args);
221
- };
222
-
223
- export const readVideoFrame = (
224
- stream: Readable,
225
- frameSize: number,
226
- timeoutMs = defaults.timeouts.readVideoFrame,
227
- ): Promise<Buffer | null> =>
228
- new Promise((resolve) => {
229
- let timer: NodeJS.Timeout | undefined;
230
- let settled = false;
231
- const finish = (result: Buffer | null) => {
232
- if (settled) {
233
- return;
234
- }
235
- settled = true;
236
- if (timer) {
237
- clearTimeout(timer);
238
- timer = undefined;
239
- }
240
- stream.removeListener('readable', onReadable);
241
- stream.removeListener('end', onEnd);
242
- stream.removeListener('error', onErrorOrClose);
243
- stream.removeListener('close', onErrorOrClose);
244
- resolve(result);
245
- };
246
- const onReadable = () => {
247
- tryRead();
248
- };
249
- const onEnd = () => {
250
- finish(null);
251
- };
252
- const onErrorOrClose = () => {
253
- finish(null);
254
- };
255
- const tryRead = () => {
256
- const data = stream.read(frameSize) as Buffer | null;
257
- if (data !== null) {
258
- finish(data.length === frameSize ? data : null);
259
- }
260
- };
261
- tryRead();
262
- if (settled) {
263
- return;
264
- }
265
- if (timeoutMs > 0) {
266
- timer = setTimeout(() => {
267
- console.error('readVideoFrame timeout:', timeoutMs);
268
- finish(null);
269
- }, timeoutMs);
270
- }
271
- stream.on('readable', onReadable);
272
- stream.once('end', onEnd);
273
- stream.once('error', onErrorOrClose);
274
- stream.once('close', onErrorOrClose);
275
- });
276
-
277
- export const getProgress = (onProgress: (currentFrame: number) => any) =>
278
- (stderrOutput: Buffer) => {
279
- const matchResult = stderrOutput.toString().match(/frame=[ ]+(\d+)/);
280
- if (!matchResult) {
281
- return;
282
- }
283
- const currentFrame = +matchResult[1];
284
- if (isNaN(currentFrame)) {
285
- return;
286
- }
287
- onProgress(currentFrame);
288
- };
289
-
290
- export const calculateProgress = (framesCount: number, callback: (progress: number) => any) =>
291
- (currentFrame: number) =>
292
- callback(
293
- +(currentFrame / framesCount * 100).toFixed(2)
294
- );
295
-
296
- export const waitDrain = (
297
- stream: Writable,
298
- processStream?: {
299
- once: (eventName: string, listener: (...args: any[]) => void) => any;
300
- removeListener: (eventName: string, listener: (...args: any[]) => void) => any;
301
- },
302
- timeoutMs = defaults.timeouts.waitDrain,
303
- ) =>
304
- new Promise<boolean>(resolve => {
305
- const cleanup = () => {
306
- if (timer) {
307
- clearTimeout(timer);
308
- timer = undefined;
309
- }
310
- stream.removeListener('drain', onDrain);
311
- stream.removeListener('error', onErrorOrClose);
312
- stream.removeListener('close', onErrorOrClose);
313
- processStream?.removeListener('error', onErrorOrClose);
314
- processStream?.removeListener('exit', onErrorOrClose);
315
- processStream?.removeListener('close', onErrorOrClose);
316
- };
317
- const onDrain = () => {
318
- cleanup();
319
- resolve(true);
320
- };
321
- const onErrorOrClose = () => {
322
- cleanup();
323
- resolve(false);
324
- };
325
- let timer: NodeJS.Timeout | undefined;
326
- if (timeoutMs > 0) {
327
- timer = setTimeout(() => {
328
- console.error('waitDrain timeout:', timeoutMs);
329
- cleanup();
330
- resolve(false);
331
- }, timeoutMs);
332
- }
333
- stream.once('drain', onDrain);
334
- stream.once('error', onErrorOrClose);
335
- stream.once('close', onErrorOrClose);
336
- processStream?.once('error', onErrorOrClose);
337
- processStream?.once('exit', onErrorOrClose);
338
- processStream?.once('close', onErrorOrClose);
339
- });
340
-
341
- export interface ProcessExitResult {
342
- exitCode: number;
343
- reason?: string;
344
- }
345
-
346
- const resolveProcessExit = (
347
- resolve: (result: ProcessExitResult) => void,
348
- exitCode: number,
349
- reason?: string,
350
- ) => {
351
- resolve({ exitCode, ...(reason && { reason }) });
352
- };
353
-
354
- export const waitForProcessExit = (
355
- processStream: {
356
- once: (eventName: string, listener: (...args: any[]) => void) => any;
357
- removeListener: (eventName: string, listener: (...args: any[]) => void) => any;
358
- },
359
- timeoutMs = defaults.timeouts.waitForProcessExit,
360
- ) =>
361
- new Promise<ProcessExitResult>(resolve => {
362
- const cleanup = () => {
363
- if (timer) {
364
- clearTimeout(timer);
365
- timer = undefined;
366
- }
367
- processStream.removeListener('exit', onExit);
368
- processStream.removeListener('close', onClose);
369
- processStream.removeListener('error', onError);
370
- };
371
- const onExit = (code: number | null) => {
372
- cleanup();
373
- const exitCode = code ?? 0;
374
- resolveProcessExit(
375
- resolve,
376
- exitCode,
377
- exitCode !== 0 ? `ffmpeg exited with code ${exitCode}` : undefined,
378
- );
379
- };
380
- const onClose = (code: number | null) => {
381
- cleanup();
382
- const exitCode = code ?? 0;
383
- resolveProcessExit(
384
- resolve,
385
- exitCode,
386
- exitCode !== 0 ? `ffmpeg exited with code ${exitCode}` : undefined,
387
- );
388
- };
389
- const onError = () => {
390
- cleanup();
391
- resolveProcessExit(resolve, 1, 'ffmpeg process error');
392
- };
393
- let timer: NodeJS.Timeout | undefined;
394
- if (timeoutMs > 0) {
395
- timer = setTimeout(() => {
396
- console.error('waitForProcessExit timeout:', timeoutMs);
397
- cleanup();
398
- resolveProcessExit(resolve, 1, `waitForProcessExit timeout (${timeoutMs}ms)`);
399
- }, timeoutMs);
400
- }
401
-
402
- processStream.once('exit', onExit);
403
- processStream.once('close', onClose);
404
- processStream.once('error', onError);
405
- });
406
-
407
- export interface VideoSegment {
408
- outputStartFrame: number;
409
- videoSeekSeconds: number;
410
- frameCount: number;
411
- }
412
-
413
- const mergeSmallScenes = (sceneChanges: SceneChange[], videoDuration: number, minDuration = 2): SceneChange[] => {
414
- if (sceneChanges.length === 0) return [];
415
- const merged: SceneChange[] = [sceneChanges[0]];
416
- for (let i = 1; i < sceneChanges.length; i++) {
417
- const lastKept = merged[merged.length - 1];
418
- const gap = sceneChanges[i].ptsTime - lastKept.ptsTime;
419
- if (gap >= minDuration) {
420
- merged.push(sceneChanges[i]);
421
- }
422
- }
423
- const lastScene = merged[merged.length - 1];
424
- if (videoDuration - lastScene.ptsTime < minDuration && merged.length > 1) {
425
- merged.pop();
426
- }
427
- return merged;
428
- };
429
-
430
- /** Minimum time between auto-edit cuts; cuts still land on beats. */
431
- export const DEFAULT_AUTO_EDIT_MIN_CUT_INTERVAL_SECONDS = 2;
432
- /** Window after the minimum gap in which the strongest beat is chosen. */
433
- export const DEFAULT_AUTO_EDIT_MAX_CUT_INTERVAL_SECONDS = 4;
434
- /** Half a bar of 4/4, used as the minimum gap when tempo is known. */
435
- export const AUTO_EDIT_MIN_CUT_BEATS = 2;
436
- /** One bar of 4/4, used as the maximum gap when tempo is known. */
437
- export const AUTO_EDIT_MAX_CUT_BEATS = 4;
438
- /** Snap an onset to the tempo grid when it is within this fraction of a beat. */
439
- export const BEAT_SNAP_TOLERANCE = 0.25;
440
-
441
- export const cutIntervalSecondsFromTempo = (bpm: number) => ({
442
- minCutIntervalSeconds: AUTO_EDIT_MIN_CUT_BEATS * (60 / bpm),
443
- maxCutIntervalSeconds: AUTO_EDIT_MAX_CUT_BEATS * (60 / bpm),
444
- });
445
-
446
- export const snapBeatsToTempoGrid = (
447
- beats: Array<{ frameIndex: number; intensity?: number }>,
448
- tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
449
- totalFrames: number,
450
- ): Array<{ frameIndex: number; intensity?: number }> => {
451
- const period = tempo.periodFrames;
452
- if (!(period > 0)) {
453
- return beats.slice();
454
- }
455
- const maxSnap = Math.max(1, period * BEAT_SNAP_TOLERANCE);
456
- const merged: Array<{ frameIndex: number; intensity?: number }> = [];
457
- const push = (beat: { frameIndex: number; intensity?: number }) => {
458
- for (let i = 0; i < merged.length; i++) {
459
- if (merged[i].frameIndex === beat.frameIndex) {
460
- if ((beat.intensity ?? 0) > (merged[i].intensity ?? 0)) {
461
- merged[i] = beat;
462
- }
463
- return;
464
- }
465
- }
466
- merged.push(beat);
467
- };
468
- for (const beat of beats) {
469
- const n = Math.round((beat.frameIndex - tempo.phaseFrame) / period);
470
- const grid = Math.round(tempo.phaseFrame + n * period);
471
- const dist = Math.abs(beat.frameIndex - grid);
472
- if (dist <= maxSnap && grid > 0 && grid < totalFrames) {
473
- push({ frameIndex: grid, intensity: beat.intensity });
474
- } else {
475
- push(beat);
476
- }
477
- }
478
- return merged.sort((a, b) => a.frameIndex - b.frameIndex);
479
- };
480
-
481
- /** Every BPM-grid beat, with onset intensity copied onto the nearest grid frame. */
482
- export const projectBeatsOntoTempoGrid = (
483
- beats: Array<{ frameIndex: number; intensity?: number }>,
484
- tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
485
- totalFrames: number,
486
- ): Array<{ frameIndex: number; intensity?: number }> => {
487
- const grid = beatGridFrameIndices(tempo, totalFrames);
488
- if (grid.length === 0) {
489
- return [];
490
- }
491
- const period = tempo.periodFrames;
492
- const maxSnap = Math.max(1, period * BEAT_SNAP_TOLERANCE);
493
- const intensityByFrame = new Map<number, number>();
494
- for (const beat of beats) {
495
- const n = Math.round((beat.frameIndex - tempo.phaseFrame) / period);
496
- const gridFrame = Math.round(tempo.phaseFrame + n * period);
497
- if (Math.abs(beat.frameIndex - gridFrame) > maxSnap) {
498
- continue;
499
- }
500
- if (gridFrame <= 0 || gridFrame >= totalFrames) {
501
- continue;
502
- }
503
- const intensity = beat.intensity ?? 0;
504
- if (intensity >= (intensityByFrame.get(gridFrame) ?? 0)) {
505
- intensityByFrame.set(gridFrame, intensity);
506
- }
507
- }
508
- return grid.map(frameIndex => ({
509
- frameIndex,
510
- intensity: intensityByFrame.get(frameIndex) ?? 0,
511
- }));
512
- };
513
-
514
- export const selectAutoEditCutFrames = (
515
- beats: Array<{ frameIndex: number; intensity?: number }>,
516
- fps: number,
517
- totalFrames: number,
518
- minIntervalSeconds = DEFAULT_AUTO_EDIT_MIN_CUT_INTERVAL_SECONDS,
519
- maxIntervalSeconds = DEFAULT_AUTO_EDIT_MAX_CUT_INTERVAL_SECONDS,
520
- ): number[] => {
521
- const sorted = beats
522
- .filter(beat => beat.frameIndex > 0 && beat.frameIndex < totalFrames)
523
- .sort((a, b) => a.frameIndex - b.frameIndex);
524
- if (sorted.length === 0 || fps <= 0) {
525
- return [];
526
- }
527
- if (minIntervalSeconds <= 0) {
528
- return sorted.map(beat => beat.frameIndex);
529
- }
530
-
531
- const minGap = Math.max(1, Math.round(fps * minIntervalSeconds));
532
- const maxGap = Math.max(minGap, Math.round(fps * maxIntervalSeconds));
533
- const selected: number[] = [];
534
- let lastCut = 0;
535
-
536
- while (lastCut + minGap < totalFrames) {
537
- const windowStart = lastCut + minGap;
538
- const windowEnd = Math.min(lastCut + maxGap, totalFrames);
539
-
540
- let best: { frameIndex: number; intensity?: number } | undefined;
541
- let nextAfterWindow: { frameIndex: number; intensity?: number } | undefined;
542
-
543
- for (const beat of sorted) {
544
- if (beat.frameIndex < windowStart) {
545
- continue;
546
- }
547
- if (beat.frameIndex >= windowEnd) {
548
- nextAfterWindow = beat;
549
- break;
550
- }
551
- const intensity = beat.intensity ?? 0;
552
- if (!best || intensity > (best.intensity ?? 0)) {
553
- best = beat;
554
- }
555
- }
556
-
557
- const chosen = best ?? nextAfterWindow;
558
- if (!chosen) {
559
- break;
560
- }
561
- selected.push(chosen.frameIndex);
562
- lastCut = chosen.frameIndex;
563
- }
564
-
565
- return selected;
566
- };
567
-
568
- export const buildBeatSyncedSegments = (
569
- beatFrameIndices: number[],
570
- totalFrames: number,
571
- sceneChanges: SceneChange[],
572
- videoDuration: number,
573
- fps: number,
574
- options?: {
575
- beatIntensities?: number[];
576
- minCutIntervalSeconds?: number;
577
- maxCutIntervalSeconds?: number;
578
- tempo?: TempoEstimate;
579
- },
580
- ): VideoSegment[] => {
581
- if (sceneChanges.length === 0) {
582
- return [
583
- {
584
- outputStartFrame: 0,
585
- videoSeekSeconds: 0,
586
- frameCount: totalFrames,
587
- },
588
- ];
589
- }
590
-
591
- const consolidated = mergeSmallScenes(sceneChanges, videoDuration);
592
- let seekPositions: number[];
593
- if (consolidated.length >= 2) {
594
- seekPositions = consolidated.map(sc => sc.ptsTime);
595
- } else {
596
- const count = Math.max(10, Math.ceil(videoDuration / 3));
597
- seekPositions = Array.from({ length: count }, (_, i) => (i * videoDuration) / count);
598
- }
599
-
600
- const tempo = options?.tempo;
601
- const tempoIntervals = tempo && tempo.bpm > 0
602
- ? cutIntervalSecondsFromTempo(tempo.bpm)
603
- : undefined;
604
- let beats: Array<{ frameIndex: number; intensity?: number }> = beatFrameIndices.map((frameIndex, i) => ({
605
- frameIndex,
606
- intensity: options?.beatIntensities?.[i],
607
- }));
608
- if (tempo) {
609
- beats = projectBeatsOntoTempoGrid(beats, tempo, totalFrames);
610
- }
611
-
612
- const cutBeats = selectAutoEditCutFrames(
613
- beats,
614
- fps,
615
- totalFrames,
616
- options?.minCutIntervalSeconds ?? tempoIntervals?.minCutIntervalSeconds,
617
- options?.maxCutIntervalSeconds ?? tempoIntervals?.maxCutIntervalSeconds,
618
- );
619
-
620
- const boundaries = [0, ...cutBeats];
621
- if (boundaries[boundaries.length - 1] !== totalFrames) {
622
- boundaries.push(totalFrames);
623
- }
624
- const unique = Array.from(new Set(boundaries)).sort((a, b) => a - b);
625
-
626
- const segments: VideoSegment[] = [];
627
- for (let i = 0; i < unique.length - 1; i++) {
628
- segments.push({
629
- outputStartFrame: unique[i],
630
- videoSeekSeconds: seekPositions[i % seekPositions.length],
631
- frameCount: unique[i + 1] - unique[i],
632
- });
633
- }
634
- return segments;
635
- };
636
-
637
- export const getCutFrameIndices = (segments: VideoSegment[]): number[] =>
638
- segments
639
- .slice(1)
640
- .map(segment => segment.outputStartFrame)
641
- .filter(frameIndex => frameIndex > 0);
642
-
643
- export const writeConcatFile = (
644
- segments: VideoSegment[],
645
- videoPath: string,
646
- videoDuration: number,
647
- fps: number,
648
- ): string => {
649
- const absVideoPath = resolvePath(videoPath).replace(/\\/g, '/');
650
- const escaped = absVideoPath.replace(/'/g, "'\\''");
651
- let content = '';
652
-
653
- for (const seg of segments) {
654
- const segDuration = seg.frameCount / fps;
655
- let remaining = segDuration;
656
- let pos = seg.videoSeekSeconds % videoDuration;
657
-
658
- while (remaining > 0.001) {
659
- const available = videoDuration - pos;
660
- const take = Math.min(remaining, available);
661
- content += `file '${escaped}'\n`;
662
- content += `inpoint ${pos.toFixed(6)}\n`;
663
- content += `outpoint ${(pos + take).toFixed(6)}\n`;
664
- content += `duration ${take.toFixed(6)}\n\n`;
665
- remaining -= take;
666
- pos = 0;
667
- }
668
- }
669
-
670
- const concatPath = joinPath(tmpdir(), `av-concat-${Date.now()}.txt`);
671
- writeFileSync(concatPath, content, 'utf-8');
672
- return concatPath;
673
- };
674
-
675
- export const writeSubtitlesFile = (subtitles: string): string => {
676
- const subtitlePath = joinPath(tmpdir(), `av-subtitles-${Date.now()}.srt`);
677
- writeFileSync(subtitlePath, subtitles, 'utf-8');
678
- return subtitlePath;
679
- };
680
-
681
- export const spawnConcatVideoFrameReader = (config: {
682
- concatFilePath: string;
683
- fps: number;
684
- totalFrames: number;
685
- width?: number;
686
- height?: number;
687
- sourceWidth?: number;
688
- sourceHeight?: number;
689
- }) => {
690
- if (!ffmpegPath) {
691
- throw new Error('ffmpeg path not found');
692
- }
693
- const args = [
694
- '-f', 'concat',
695
- '-safe', '0',
696
- '-fflags', '+genpts',
697
- '-i', config.concatFilePath,
698
- '-an',
699
- '-avoid_negative_ts', 'make_zero',
700
- '-r', `${config.fps}`,
701
- '-frames:v', `${config.totalFrames}`,
702
- ];
703
- if (config.width && config.height) {
704
- const filter = config.sourceWidth && config.sourceHeight
705
- ? buildVideoFilter(config.sourceWidth, config.sourceHeight, config.width, config.height)
706
- : `scale=${config.width}:${config.height}`;
707
- args.push('-vf', filter);
708
- }
709
- args.push(
710
- '-f', 'rawvideo',
711
- '-pix_fmt', 'bgr24',
712
- '-v', 'quiet',
713
- 'pipe:1'
714
- );
715
- return spawn(ffmpegPath, args);
716
- };
717
-
718
- export const cleanupTempFile = (filePath: string) => {
719
- try { unlinkSync(filePath); } catch {}
720
- };
721
-
722
- 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
+ import { TempoEstimate, beatGridFrameIndices } from './beats';
9
+
10
+ export interface AudioMuxSegment {
11
+ seekSeconds: number;
12
+ durationSeconds: number;
13
+ }
14
+
15
+ interface FfmpegVideoWriterConfig {
16
+ audioFilename: string;
17
+ subtitleFilename?: string;
18
+ /** ASS `Alignment` in FFmpeg `subtitles` `force_style` (e.g. 2 bottom, 5 middle, 8 top). Default 2. */
19
+ subtitleAlignmentAss?: number;
20
+ videoFileName: string;
21
+ fps: number;
22
+ crf?: string;
23
+ preset?: string;
24
+ onStderr?: (data: any) => any;
25
+ /** When set with `audioDurationSeconds`, passed to ffmpeg before `-i` for muxed highlight. */
26
+ audioSeekSeconds?: number;
27
+ audioDurationSeconds?: number;
28
+ /** One contiguous trim of the same audio file (muxed highlight). */
29
+ audioSegment?: AudioMuxSegment;
30
+ }
31
+
32
+ const escapeSubtitleFilterPath = (subtitlePath: string) =>
33
+ subtitlePath
34
+ .replace(/\\/g, '\\\\')
35
+ .replace(/:/g, '\\:')
36
+ .replace(/'/g, "\\'")
37
+ .replace(/,/g, '\\,')
38
+ .replace(/\[/g, '\\[')
39
+ .replace(/\]/g, '\\]');
40
+
41
+ export const spawnFfmpegVideoWriter = (config: FfmpegVideoWriterConfig) => {
42
+ if (!ffmpegPath) {
43
+ throw new Error('ffmpeg path not found');
44
+ }
45
+ const crf = config.crf || '23';
46
+ const preset = config.preset || 'medium';
47
+ const args: string[] = ['-y'];
48
+ const seek =
49
+ config.audioSegment?.seekSeconds ?? config.audioSeekSeconds;
50
+ const duration =
51
+ config.audioSegment?.durationSeconds ?? config.audioDurationSeconds;
52
+ if (seek !== undefined && duration !== undefined) {
53
+ args.push('-ss', String(seek), '-t', String(duration));
54
+ }
55
+ args.push(
56
+ '-i', config.audioFilename,
57
+ '-f', 'image2pipe',
58
+ '-vcodec', 'bmp',
59
+ '-framerate', `${config.fps}`,
60
+ '-i', '-',
61
+ '-crf', crf,
62
+ '-c:a', 'aac', '-b:a', '384k', '-profile:a', 'aac_low',
63
+ '-c:v', 'libx264',
64
+ '-r', `${config.fps}`,
65
+ '-pix_fmt', 'yuv420p',
66
+ '-preset', preset,
67
+ '-shortest',
68
+ );
69
+ if (config.subtitleFilename) {
70
+ const subPath = escapeSubtitleFilterPath(config.subtitleFilename);
71
+ const alignment = config.subtitleAlignmentAss ?? 2;
72
+ args.push(
73
+ '-vf',
74
+ `subtitles='${subPath}':force_style='Alignment=${alignment}'`,
75
+ );
76
+ }
77
+ args.push(config.videoFileName);
78
+ const ffmpeg = spawn(ffmpegPath, args);
79
+ if (config.onStderr) {
80
+ ffmpeg.stderr.on('data', config.onStderr);
81
+ }
82
+ return ffmpeg;
83
+ };
84
+
85
+ export interface VideoInfo {
86
+ width: number;
87
+ height: number;
88
+ duration: number;
89
+ }
90
+
91
+ export interface SceneChange {
92
+ frameNumber: number;
93
+ pts: number;
94
+ ptsTime: number;
95
+ }
96
+
97
+ export const detectSceneChanges = (videoPath: string, threshold = 0.4): Promise<SceneChange[]> =>
98
+ new Promise((resolve, reject) => {
99
+ if (!ffmpegPath) {
100
+ reject(new Error('ffmpeg path not found'));
101
+ return;
102
+ }
103
+ const ffmpeg = spawn(ffmpegPath, [
104
+ '-i', videoPath,
105
+ '-filter:v', `select='gt(scene,${threshold})',showinfo`,
106
+ '-f', 'null',
107
+ '-'
108
+ ]);
109
+ let stderr = '';
110
+ ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
111
+ ffmpeg.on('close', () => {
112
+ const sceneChanges: SceneChange[] = [];
113
+ for (const line of stderr.split('\n')) {
114
+ if (!line.includes('Parsed_showinfo')) continue;
115
+ const nMatch = line.match(/n:\s*(\d+)/);
116
+ const ptsMatch = line.match(/pts:\s*(\d+)/);
117
+ const ptsTimeMatch = line.match(/pts_time:\s*([\d.]+)/);
118
+ if (nMatch && ptsTimeMatch) {
119
+ sceneChanges.push({
120
+ frameNumber: parseInt(nMatch[1]),
121
+ pts: ptsMatch ? parseInt(ptsMatch[1]) : 0,
122
+ ptsTime: parseFloat(ptsTimeMatch[1]),
123
+ });
124
+ }
125
+ }
126
+ resolve(sceneChanges);
127
+ });
128
+ ffmpeg.on('error', reject);
129
+ });
130
+
131
+ export const getVideoInfo = (videoPath: string): Promise<VideoInfo> =>
132
+ new Promise((resolvePromise, reject) => {
133
+ if (!ffmpegPath) {
134
+ reject(new Error('ffmpeg path not found'));
135
+ return;
136
+ }
137
+ const ffmpeg = spawn(ffmpegPath, ['-i', videoPath, '-hide_banner']);
138
+ let stderr = '';
139
+ ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
140
+ ffmpeg.on('close', () => {
141
+ const dimMatch = stderr.match(/Stream.*Video:.*?(\d{2,5})x(\d{2,5})/);
142
+ if (!dimMatch) {
143
+ reject(new Error(`Could not determine video dimensions for: ${videoPath}`));
144
+ return;
145
+ }
146
+ const durMatch = stderr.match(/Duration:\s*(\d+):(\d+):([\d.]+)/);
147
+ const duration = durMatch
148
+ ? parseInt(durMatch[1]) * 3600 + parseInt(durMatch[2]) * 60 + parseFloat(durMatch[3])
149
+ : 0;
150
+ resolvePromise({
151
+ width: parseInt(dimMatch[1]),
152
+ height: parseInt(dimMatch[2]),
153
+ duration,
154
+ });
155
+ });
156
+ });
157
+
158
+ const isOrientationMismatch = (sw: number, sh: number, tw: number, th: number): boolean =>
159
+ (sw > sh && tw < th) || (sw < sh && tw > th);
160
+
161
+ export const buildVideoFilter = (
162
+ sourceWidth: number,
163
+ sourceHeight: number,
164
+ targetWidth: number,
165
+ targetHeight: number,
166
+ ): string => {
167
+ if (!isOrientationMismatch(sourceWidth, sourceHeight, targetWidth, targetHeight)) {
168
+ return `scale=${targetWidth}:${targetHeight}`;
169
+ }
170
+
171
+ const targetAspect = targetWidth / targetHeight;
172
+ const makeEven = (n: number) => Math.floor(n / 2) * 2;
173
+ let cropW: number, cropH: number, cropX: number, cropY: number;
174
+
175
+ if (sourceWidth / sourceHeight > targetAspect) {
176
+ cropW = makeEven(Math.round(sourceHeight * targetAspect));
177
+ cropH = makeEven(sourceHeight);
178
+ cropX = Math.floor((sourceWidth - cropW) / 2);
179
+ cropY = Math.floor((sourceHeight - cropH) / 2);
180
+ } else {
181
+ cropW = makeEven(sourceWidth);
182
+ cropH = makeEven(Math.round(sourceWidth / targetAspect));
183
+ cropX = Math.floor((sourceWidth - cropW) / 2);
184
+ cropY = Math.floor((sourceHeight - cropH) / 2);
185
+ }
186
+
187
+ return `crop=${cropW}:${cropH}:${cropX}:${cropY},scale=${targetWidth}:${targetHeight}`;
188
+ };
189
+
190
+ export const spawnVideoFrameReader = (config: {
191
+ videoPath: string;
192
+ fps: number;
193
+ totalFrames: number;
194
+ width?: number;
195
+ height?: number;
196
+ sourceWidth?: number;
197
+ sourceHeight?: number;
198
+ }) => {
199
+ if (!ffmpegPath) {
200
+ throw new Error('ffmpeg path not found');
201
+ }
202
+ const args = [
203
+ '-stream_loop', '-1',
204
+ '-i', config.videoPath,
205
+ '-r', `${config.fps}`,
206
+ '-frames:v', `${config.totalFrames}`,
207
+ ];
208
+ if (config.width && config.height) {
209
+ const filter = config.sourceWidth && config.sourceHeight
210
+ ? buildVideoFilter(config.sourceWidth, config.sourceHeight, config.width, config.height)
211
+ : `scale=${config.width}:${config.height}`;
212
+ args.push('-vf', filter);
213
+ }
214
+ args.push(
215
+ '-f', 'rawvideo',
216
+ '-pix_fmt', 'bgr24',
217
+ '-v', 'quiet',
218
+ 'pipe:1'
219
+ );
220
+ return spawn(ffmpegPath, args);
221
+ };
222
+
223
+ export const readVideoFrame = (
224
+ stream: Readable,
225
+ frameSize: number,
226
+ timeoutMs = defaults.timeouts.readVideoFrame,
227
+ ): Promise<Buffer | null> =>
228
+ new Promise((resolve) => {
229
+ let timer: NodeJS.Timeout | undefined;
230
+ let settled = false;
231
+ const finish = (result: Buffer | null) => {
232
+ if (settled) {
233
+ return;
234
+ }
235
+ settled = true;
236
+ if (timer) {
237
+ clearTimeout(timer);
238
+ timer = undefined;
239
+ }
240
+ stream.removeListener('readable', onReadable);
241
+ stream.removeListener('end', onEnd);
242
+ stream.removeListener('error', onErrorOrClose);
243
+ stream.removeListener('close', onErrorOrClose);
244
+ resolve(result);
245
+ };
246
+ const onReadable = () => {
247
+ tryRead();
248
+ };
249
+ const onEnd = () => {
250
+ finish(null);
251
+ };
252
+ const onErrorOrClose = () => {
253
+ finish(null);
254
+ };
255
+ const tryRead = () => {
256
+ const data = stream.read(frameSize) as Buffer | null;
257
+ if (data !== null) {
258
+ finish(data.length === frameSize ? data : null);
259
+ }
260
+ };
261
+ tryRead();
262
+ if (settled) {
263
+ return;
264
+ }
265
+ if (timeoutMs > 0) {
266
+ timer = setTimeout(() => {
267
+ console.error('readVideoFrame timeout:', timeoutMs);
268
+ finish(null);
269
+ }, timeoutMs);
270
+ }
271
+ stream.on('readable', onReadable);
272
+ stream.once('end', onEnd);
273
+ stream.once('error', onErrorOrClose);
274
+ stream.once('close', onErrorOrClose);
275
+ });
276
+
277
+ export const getProgress = (onProgress: (currentFrame: number) => any) =>
278
+ (stderrOutput: Buffer) => {
279
+ const matchResult = stderrOutput.toString().match(/frame=[ ]+(\d+)/);
280
+ if (!matchResult) {
281
+ return;
282
+ }
283
+ const currentFrame = +matchResult[1];
284
+ if (isNaN(currentFrame)) {
285
+ return;
286
+ }
287
+ onProgress(currentFrame);
288
+ };
289
+
290
+ export const calculateProgress = (framesCount: number, callback: (progress: number) => any) =>
291
+ (currentFrame: number) =>
292
+ callback(
293
+ +(currentFrame / framesCount * 100).toFixed(2)
294
+ );
295
+
296
+ export const waitDrain = (
297
+ stream: Writable,
298
+ processStream?: {
299
+ once: (eventName: string, listener: (...args: any[]) => void) => any;
300
+ removeListener: (eventName: string, listener: (...args: any[]) => void) => any;
301
+ },
302
+ timeoutMs = defaults.timeouts.waitDrain,
303
+ ) =>
304
+ new Promise<boolean>(resolve => {
305
+ const cleanup = () => {
306
+ if (timer) {
307
+ clearTimeout(timer);
308
+ timer = undefined;
309
+ }
310
+ stream.removeListener('drain', onDrain);
311
+ stream.removeListener('error', onErrorOrClose);
312
+ stream.removeListener('close', onErrorOrClose);
313
+ processStream?.removeListener('error', onErrorOrClose);
314
+ processStream?.removeListener('exit', onErrorOrClose);
315
+ processStream?.removeListener('close', onErrorOrClose);
316
+ };
317
+ const onDrain = () => {
318
+ cleanup();
319
+ resolve(true);
320
+ };
321
+ const onErrorOrClose = () => {
322
+ cleanup();
323
+ resolve(false);
324
+ };
325
+ let timer: NodeJS.Timeout | undefined;
326
+ if (timeoutMs > 0) {
327
+ timer = setTimeout(() => {
328
+ console.error('waitDrain timeout:', timeoutMs);
329
+ cleanup();
330
+ resolve(false);
331
+ }, timeoutMs);
332
+ }
333
+ stream.once('drain', onDrain);
334
+ stream.once('error', onErrorOrClose);
335
+ stream.once('close', onErrorOrClose);
336
+ processStream?.once('error', onErrorOrClose);
337
+ processStream?.once('exit', onErrorOrClose);
338
+ processStream?.once('close', onErrorOrClose);
339
+ });
340
+
341
+ export interface ProcessExitResult {
342
+ exitCode: number;
343
+ reason?: string;
344
+ }
345
+
346
+ const resolveProcessExit = (
347
+ resolve: (result: ProcessExitResult) => void,
348
+ exitCode: number,
349
+ reason?: string,
350
+ ) => {
351
+ resolve({ exitCode, ...(reason && { reason }) });
352
+ };
353
+
354
+ export const waitForProcessExit = (
355
+ processStream: {
356
+ once: (eventName: string, listener: (...args: any[]) => void) => any;
357
+ removeListener: (eventName: string, listener: (...args: any[]) => void) => any;
358
+ },
359
+ timeoutMs = defaults.timeouts.waitForProcessExit,
360
+ ) =>
361
+ new Promise<ProcessExitResult>(resolve => {
362
+ const cleanup = () => {
363
+ if (timer) {
364
+ clearTimeout(timer);
365
+ timer = undefined;
366
+ }
367
+ processStream.removeListener('exit', onExit);
368
+ processStream.removeListener('close', onClose);
369
+ processStream.removeListener('error', onError);
370
+ };
371
+ const onExit = (code: number | null) => {
372
+ cleanup();
373
+ const exitCode = code ?? 0;
374
+ resolveProcessExit(
375
+ resolve,
376
+ exitCode,
377
+ exitCode !== 0 ? `ffmpeg exited with code ${exitCode}` : undefined,
378
+ );
379
+ };
380
+ const onClose = (code: number | null) => {
381
+ cleanup();
382
+ const exitCode = code ?? 0;
383
+ resolveProcessExit(
384
+ resolve,
385
+ exitCode,
386
+ exitCode !== 0 ? `ffmpeg exited with code ${exitCode}` : undefined,
387
+ );
388
+ };
389
+ const onError = () => {
390
+ cleanup();
391
+ resolveProcessExit(resolve, 1, 'ffmpeg process error');
392
+ };
393
+ let timer: NodeJS.Timeout | undefined;
394
+ if (timeoutMs > 0) {
395
+ timer = setTimeout(() => {
396
+ console.error('waitForProcessExit timeout:', timeoutMs);
397
+ cleanup();
398
+ resolveProcessExit(resolve, 1, `waitForProcessExit timeout (${timeoutMs}ms)`);
399
+ }, timeoutMs);
400
+ }
401
+
402
+ processStream.once('exit', onExit);
403
+ processStream.once('close', onClose);
404
+ processStream.once('error', onError);
405
+ });
406
+
407
+ export interface VideoSegment {
408
+ outputStartFrame: number;
409
+ videoSeekSeconds: number;
410
+ frameCount: number;
411
+ /** Index into the background video sources array; defaults to 0 when omitted. */
412
+ videoIndex?: number;
413
+ }
414
+
415
+ const mergeSmallScenes = (sceneChanges: SceneChange[], videoDuration: number, minDuration = 2): SceneChange[] => {
416
+ if (sceneChanges.length === 0) return [];
417
+ const merged: SceneChange[] = [sceneChanges[0]];
418
+ for (let i = 1; i < sceneChanges.length; i++) {
419
+ const lastKept = merged[merged.length - 1];
420
+ const gap = sceneChanges[i].ptsTime - lastKept.ptsTime;
421
+ if (gap >= minDuration) {
422
+ merged.push(sceneChanges[i]);
423
+ }
424
+ }
425
+ const lastScene = merged[merged.length - 1];
426
+ if (videoDuration - lastScene.ptsTime < minDuration && merged.length > 1) {
427
+ merged.pop();
428
+ }
429
+ return merged;
430
+ };
431
+
432
+ /** Minimum time between auto-edit cuts; cuts still land on beats. */
433
+ export const DEFAULT_AUTO_EDIT_MIN_CUT_INTERVAL_SECONDS = 2;
434
+ /** Window after the minimum gap in which the strongest beat is chosen. */
435
+ export const DEFAULT_AUTO_EDIT_MAX_CUT_INTERVAL_SECONDS = 4;
436
+ /** Half a bar of 4/4, used as the minimum gap when tempo is known. */
437
+ export const AUTO_EDIT_MIN_CUT_BEATS = 2;
438
+ /** One bar of 4/4, used as the maximum gap when tempo is known. */
439
+ export const AUTO_EDIT_MAX_CUT_BEATS = 4;
440
+ /** Snap an onset to the tempo grid when it is within this fraction of a beat. */
441
+ export const BEAT_SNAP_TOLERANCE = 0.25;
442
+
443
+ export const cutIntervalSecondsFromTempo = (bpm: number) => ({
444
+ minCutIntervalSeconds: AUTO_EDIT_MIN_CUT_BEATS * (60 / bpm),
445
+ maxCutIntervalSeconds: AUTO_EDIT_MAX_CUT_BEATS * (60 / bpm),
446
+ });
447
+
448
+ export const snapBeatsToTempoGrid = (
449
+ beats: Array<{ frameIndex: number; intensity?: number }>,
450
+ tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
451
+ totalFrames: number,
452
+ ): Array<{ frameIndex: number; intensity?: number }> => {
453
+ const period = tempo.periodFrames;
454
+ if (!(period > 0)) {
455
+ return beats.slice();
456
+ }
457
+ const maxSnap = Math.max(1, period * BEAT_SNAP_TOLERANCE);
458
+ const merged: Array<{ frameIndex: number; intensity?: number }> = [];
459
+ const push = (beat: { frameIndex: number; intensity?: number }) => {
460
+ for (let i = 0; i < merged.length; i++) {
461
+ if (merged[i].frameIndex === beat.frameIndex) {
462
+ if ((beat.intensity ?? 0) > (merged[i].intensity ?? 0)) {
463
+ merged[i] = beat;
464
+ }
465
+ return;
466
+ }
467
+ }
468
+ merged.push(beat);
469
+ };
470
+ for (const beat of beats) {
471
+ const n = Math.round((beat.frameIndex - tempo.phaseFrame) / period);
472
+ const grid = Math.round(tempo.phaseFrame + n * period);
473
+ const dist = Math.abs(beat.frameIndex - grid);
474
+ if (dist <= maxSnap && grid > 0 && grid < totalFrames) {
475
+ push({ frameIndex: grid, intensity: beat.intensity });
476
+ } else {
477
+ push(beat);
478
+ }
479
+ }
480
+ return merged.sort((a, b) => a.frameIndex - b.frameIndex);
481
+ };
482
+
483
+ /** Every BPM-grid beat, with onset intensity copied onto the nearest grid frame. */
484
+ export const projectBeatsOntoTempoGrid = (
485
+ beats: Array<{ frameIndex: number; intensity?: number }>,
486
+ tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
487
+ totalFrames: number,
488
+ ): Array<{ frameIndex: number; intensity?: number }> => {
489
+ const grid = beatGridFrameIndices(tempo, totalFrames);
490
+ if (grid.length === 0) {
491
+ return [];
492
+ }
493
+ const period = tempo.periodFrames;
494
+ const maxSnap = Math.max(1, period * BEAT_SNAP_TOLERANCE);
495
+ const intensityByFrame = new Map<number, number>();
496
+ for (const beat of beats) {
497
+ const n = Math.round((beat.frameIndex - tempo.phaseFrame) / period);
498
+ const gridFrame = Math.round(tempo.phaseFrame + n * period);
499
+ if (Math.abs(beat.frameIndex - gridFrame) > maxSnap) {
500
+ continue;
501
+ }
502
+ if (gridFrame <= 0 || gridFrame >= totalFrames) {
503
+ continue;
504
+ }
505
+ const intensity = beat.intensity ?? 0;
506
+ if (intensity >= (intensityByFrame.get(gridFrame) ?? 0)) {
507
+ intensityByFrame.set(gridFrame, intensity);
508
+ }
509
+ }
510
+ return grid.map(frameIndex => ({
511
+ frameIndex,
512
+ intensity: intensityByFrame.get(frameIndex) ?? 0,
513
+ }));
514
+ };
515
+
516
+ export const selectAutoEditCutFrames = (
517
+ beats: Array<{ frameIndex: number; intensity?: number }>,
518
+ fps: number,
519
+ totalFrames: number,
520
+ minIntervalSeconds = DEFAULT_AUTO_EDIT_MIN_CUT_INTERVAL_SECONDS,
521
+ maxIntervalSeconds = DEFAULT_AUTO_EDIT_MAX_CUT_INTERVAL_SECONDS,
522
+ ): number[] => {
523
+ const sorted = beats
524
+ .filter(beat => beat.frameIndex > 0 && beat.frameIndex < totalFrames)
525
+ .sort((a, b) => a.frameIndex - b.frameIndex);
526
+ if (sorted.length === 0 || fps <= 0) {
527
+ return [];
528
+ }
529
+ if (minIntervalSeconds <= 0) {
530
+ return sorted.map(beat => beat.frameIndex);
531
+ }
532
+
533
+ const minGap = Math.max(1, Math.round(fps * minIntervalSeconds));
534
+ const maxGap = Math.max(minGap, Math.round(fps * maxIntervalSeconds));
535
+ const selected: number[] = [];
536
+ let lastCut = 0;
537
+
538
+ while (lastCut + minGap < totalFrames) {
539
+ const windowStart = lastCut + minGap;
540
+ const windowEnd = Math.min(lastCut + maxGap, totalFrames);
541
+
542
+ let best: { frameIndex: number; intensity?: number } | undefined;
543
+ let nextAfterWindow: { frameIndex: number; intensity?: number } | undefined;
544
+
545
+ for (const beat of sorted) {
546
+ if (beat.frameIndex < windowStart) {
547
+ continue;
548
+ }
549
+ if (beat.frameIndex >= windowEnd) {
550
+ nextAfterWindow = beat;
551
+ break;
552
+ }
553
+ const intensity = beat.intensity ?? 0;
554
+ if (!best || intensity > (best.intensity ?? 0)) {
555
+ best = beat;
556
+ }
557
+ }
558
+
559
+ const chosen = best ?? nextAfterWindow;
560
+ if (!chosen) {
561
+ break;
562
+ }
563
+ selected.push(chosen.frameIndex);
564
+ lastCut = chosen.frameIndex;
565
+ }
566
+
567
+ return selected;
568
+ };
569
+
570
+ const computeAutoEditCutBoundaries = (
571
+ beatFrameIndices: number[],
572
+ totalFrames: number,
573
+ fps: number,
574
+ options?: {
575
+ beatIntensities?: number[];
576
+ minCutIntervalSeconds?: number;
577
+ maxCutIntervalSeconds?: number;
578
+ tempo?: TempoEstimate;
579
+ },
580
+ ): number[] => {
581
+ const tempo = options?.tempo;
582
+ const tempoIntervals = tempo && tempo.bpm > 0
583
+ ? cutIntervalSecondsFromTempo(tempo.bpm)
584
+ : undefined;
585
+ let beats: Array<{ frameIndex: number; intensity?: number }> = beatFrameIndices.map((frameIndex, i) => ({
586
+ frameIndex,
587
+ intensity: options?.beatIntensities?.[i],
588
+ }));
589
+ if (tempo) {
590
+ beats = projectBeatsOntoTempoGrid(beats, tempo, totalFrames);
591
+ }
592
+
593
+ const cutBeats = selectAutoEditCutFrames(
594
+ beats,
595
+ fps,
596
+ totalFrames,
597
+ options?.minCutIntervalSeconds ?? tempoIntervals?.minCutIntervalSeconds,
598
+ options?.maxCutIntervalSeconds ?? tempoIntervals?.maxCutIntervalSeconds,
599
+ );
600
+
601
+ const boundaries = [0, ...cutBeats];
602
+ if (boundaries[boundaries.length - 1] !== totalFrames) {
603
+ boundaries.push(totalFrames);
604
+ }
605
+ return Array.from(new Set(boundaries)).sort((a, b) => a - b);
606
+ };
607
+
608
+ /**
609
+ * Splits `totalFrames` into segments that cycle through `videoDurations` in order, each
610
+ * segment playing one source video from the start. Used for `video.path` arrays with
611
+ * `autoEdit` disabled, so the given videos play one after another (looping the sequence).
612
+ * `startVideoIndex` rotates which video plays first (e.g. so separate auto-highlight outputs
613
+ * each open with a different video).
614
+ */
615
+ export const buildSequentialVideoSegments = (
616
+ totalFrames: number,
617
+ videoDurations: number[],
618
+ fps: number,
619
+ startVideoIndex = 0,
620
+ ): VideoSegment[] => {
621
+ if (videoDurations.length === 0 || totalFrames <= 0 || fps <= 0) {
622
+ return [];
623
+ }
624
+ const segments: VideoSegment[] = [];
625
+ let framesRemaining = totalFrames;
626
+ let outputStartFrame = 0;
627
+ let cycleIndex = 0;
628
+ const maxIterations = videoDurations.length * 100000;
629
+ let iterations = 0;
630
+ while (framesRemaining > 0 && iterations < maxIterations) {
631
+ iterations += 1;
632
+ const videoIndex = (startVideoIndex + cycleIndex) % videoDurations.length;
633
+ const videoFrames = Math.max(1, Math.round(videoDurations[videoIndex] * fps));
634
+ const frameCount = Math.min(videoFrames, framesRemaining);
635
+ segments.push({
636
+ outputStartFrame,
637
+ videoSeekSeconds: 0,
638
+ frameCount,
639
+ videoIndex,
640
+ });
641
+ outputStartFrame += frameCount;
642
+ framesRemaining -= frameCount;
643
+ cycleIndex += 1;
644
+ }
645
+ return segments;
646
+ };
647
+
648
+ /**
649
+ * Fisher-Yates shuffle of `[0, videoCount)`, optionally reshuffling the first slot so it
650
+ * doesn't repeat `avoidFirst` (used to avoid a repeat across bag boundaries).
651
+ */
652
+ const shuffleVideoIndices = (
653
+ videoCount: number,
654
+ random: () => number,
655
+ avoidFirst?: number,
656
+ ): number[] => {
657
+ const order = Array.from({ length: videoCount }, (_, i) => i);
658
+ for (let i = order.length - 1; i > 0; i--) {
659
+ const j = Math.floor(random() * (i + 1));
660
+ [order[i], order[j]] = [order[j], order[i]];
661
+ }
662
+ if (avoidFirst !== undefined && order.length > 1 && order[0] === avoidFirst) {
663
+ const swapWith = 1 + Math.floor(random() * (order.length - 1));
664
+ [order[0], order[swapWith]] = [order[swapWith], order[0]];
665
+ }
666
+ return order;
667
+ };
668
+
669
+ /**
670
+ * Builds auto-edit cut segments like `buildBeatSyncedSegments`, but instead of switching
671
+ * between detected scenes within a single video, each segment is assigned a video from
672
+ * `videoCount` sources. Videos are picked by shuffling the full set into a "bag" and cycling
673
+ * through it (reshuffling once exhausted), rather than picking uniformly at random each time,
674
+ * so every video plays about as often as the others instead of some clumping together while
675
+ * others go unpicked for long stretches. The same video never plays twice in a row, including
676
+ * across a reshuffle.
677
+ */
678
+ export const buildShuffledVideoSegments = (
679
+ beatFrameIndices: number[],
680
+ totalFrames: number,
681
+ videoCount: number,
682
+ fps: number,
683
+ options?: {
684
+ beatIntensities?: number[];
685
+ minCutIntervalSeconds?: number;
686
+ maxCutIntervalSeconds?: number;
687
+ tempo?: TempoEstimate;
688
+ randomFn?: () => number;
689
+ },
690
+ ): VideoSegment[] => {
691
+ if (videoCount <= 0 || totalFrames <= 0) {
692
+ return [];
693
+ }
694
+ if (videoCount === 1) {
695
+ return [{ outputStartFrame: 0, videoSeekSeconds: 0, frameCount: totalFrames, videoIndex: 0 }];
696
+ }
697
+
698
+ const boundaries = computeAutoEditCutBoundaries(beatFrameIndices, totalFrames, fps, options);
699
+ const random = options?.randomFn ?? Math.random;
700
+
701
+ const segments: VideoSegment[] = [];
702
+ let bag: number[] = [];
703
+ let previousVideoIndex: number | undefined;
704
+ for (let i = 0; i < boundaries.length - 1; i++) {
705
+ if (bag.length === 0) {
706
+ bag = shuffleVideoIndices(videoCount, random, previousVideoIndex);
707
+ }
708
+ const videoIndex = bag.shift() as number;
709
+ previousVideoIndex = videoIndex;
710
+ segments.push({
711
+ outputStartFrame: boundaries[i],
712
+ videoSeekSeconds: 0,
713
+ frameCount: boundaries[i + 1] - boundaries[i],
714
+ videoIndex,
715
+ });
716
+ }
717
+ return segments;
718
+ };
719
+
720
+ export const buildBeatSyncedSegments = (
721
+ beatFrameIndices: number[],
722
+ totalFrames: number,
723
+ sceneChanges: SceneChange[],
724
+ videoDuration: number,
725
+ fps: number,
726
+ options?: {
727
+ beatIntensities?: number[];
728
+ minCutIntervalSeconds?: number;
729
+ maxCutIntervalSeconds?: number;
730
+ tempo?: TempoEstimate;
731
+ },
732
+ ): VideoSegment[] => {
733
+ if (sceneChanges.length === 0) {
734
+ return [
735
+ {
736
+ outputStartFrame: 0,
737
+ videoSeekSeconds: 0,
738
+ frameCount: totalFrames,
739
+ },
740
+ ];
741
+ }
742
+
743
+ const consolidated = mergeSmallScenes(sceneChanges, videoDuration);
744
+ let seekPositions: number[];
745
+ if (consolidated.length >= 2) {
746
+ seekPositions = consolidated.map(sc => sc.ptsTime);
747
+ } else {
748
+ const count = Math.max(10, Math.ceil(videoDuration / 3));
749
+ seekPositions = Array.from({ length: count }, (_, i) => (i * videoDuration) / count);
750
+ }
751
+
752
+ const unique = computeAutoEditCutBoundaries(beatFrameIndices, totalFrames, fps, options);
753
+
754
+ const segments: VideoSegment[] = [];
755
+ for (let i = 0; i < unique.length - 1; i++) {
756
+ segments.push({
757
+ outputStartFrame: unique[i],
758
+ videoSeekSeconds: seekPositions[i % seekPositions.length],
759
+ frameCount: unique[i + 1] - unique[i],
760
+ });
761
+ }
762
+ return segments;
763
+ };
764
+
765
+ export const getCutFrameIndices = (segments: VideoSegment[]): number[] =>
766
+ segments
767
+ .slice(1)
768
+ .map(segment => segment.outputStartFrame)
769
+ .filter(frameIndex => frameIndex > 0);
770
+
771
+ export interface VideoSourceInfo {
772
+ path: string;
773
+ duration: number;
774
+ }
775
+
776
+ export const writeConcatFile = (
777
+ segments: VideoSegment[],
778
+ videoSources: VideoSourceInfo[],
779
+ fps: number,
780
+ ): string => {
781
+ let content = '';
782
+
783
+ for (const seg of segments) {
784
+ const source = videoSources[seg.videoIndex ?? 0];
785
+ const absVideoPath = resolvePath(source.path).replace(/\\/g, '/');
786
+ const escaped = absVideoPath.replace(/'/g, "'\\''");
787
+ const videoDuration = source.duration;
788
+ const segDuration = seg.frameCount / fps;
789
+ let remaining = segDuration;
790
+ let pos = videoDuration > 0 ? seg.videoSeekSeconds % videoDuration : 0;
791
+
792
+ while (remaining > 0.001) {
793
+ const available = videoDuration > 0 ? videoDuration - pos : remaining;
794
+ const take = Math.min(remaining, available);
795
+ content += `file '${escaped}'\n`;
796
+ content += `inpoint ${pos.toFixed(6)}\n`;
797
+ content += `outpoint ${(pos + take).toFixed(6)}\n`;
798
+ content += `duration ${take.toFixed(6)}\n\n`;
799
+ remaining -= take;
800
+ pos = 0;
801
+ }
802
+ }
803
+
804
+ const concatPath = joinPath(tmpdir(), `av-concat-${Date.now()}.txt`);
805
+ writeFileSync(concatPath, content, 'utf-8');
806
+ return concatPath;
807
+ };
808
+
809
+ export const writeSubtitlesFile = (subtitles: string): string => {
810
+ const subtitlePath = joinPath(tmpdir(), `av-subtitles-${Date.now()}.srt`);
811
+ writeFileSync(subtitlePath, subtitles, 'utf-8');
812
+ return subtitlePath;
813
+ };
814
+
815
+ export const spawnConcatVideoFrameReader = (config: {
816
+ concatFilePath: string;
817
+ fps: number;
818
+ totalFrames: number;
819
+ width?: number;
820
+ height?: number;
821
+ sourceWidth?: number;
822
+ sourceHeight?: number;
823
+ }) => {
824
+ if (!ffmpegPath) {
825
+ throw new Error('ffmpeg path not found');
826
+ }
827
+ const args = [
828
+ '-f', 'concat',
829
+ '-safe', '0',
830
+ '-fflags', '+genpts',
831
+ '-i', config.concatFilePath,
832
+ '-an',
833
+ '-avoid_negative_ts', 'make_zero',
834
+ '-r', `${config.fps}`,
835
+ '-frames:v', `${config.totalFrames}`,
836
+ ];
837
+ if (config.width && config.height) {
838
+ const filter = config.sourceWidth && config.sourceHeight
839
+ ? buildVideoFilter(config.sourceWidth, config.sourceHeight, config.width, config.height)
840
+ : `scale=${config.width}:${config.height}`;
841
+ args.push('-vf', filter);
842
+ }
843
+ args.push(
844
+ '-f', 'rawvideo',
845
+ '-pix_fmt', 'bgr24',
846
+ '-v', 'quiet',
847
+ 'pipe:1'
848
+ );
849
+ return spawn(ffmpegPath, args);
850
+ };
851
+
852
+ export const cleanupTempFile = (filePath: string) => {
853
+ try { unlinkSync(filePath); } catch {}
854
+ };
855
+
856
+ export const cleanupConcatFile = cleanupTempFile;