nodejs-audio-visualizer 5.1.0 → 5.3.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.
- package/LICENSE +21 -21
- package/README.md +81 -80
- package/dist/audio.d.ts +13 -13
- package/dist/audio.js +128 -128
- package/dist/beats.d.ts +39 -39
- package/dist/beats.js +220 -220
- package/dist/bpmEncoder.d.ts +14 -14
- package/dist/bpmEncoder.js +97 -97
- package/dist/config.d.ts +78 -77
- package/dist/config.js +377 -369
- package/dist/config.js.map +1 -1
- package/dist/dsp.d.ts +16 -16
- package/dist/dsp.js +76 -76
- package/dist/highlight.d.ts +66 -34
- package/dist/highlight.js +471 -334
- package/dist/highlight.js.map +1 -1
- package/dist/image.d.ts +97 -92
- package/dist/image.js +545 -538
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +87 -79
- package/dist/index.js +598 -566
- package/dist/index.js.map +1 -1
- package/dist/subtitleConvert.d.ts +3 -3
- package/dist/subtitleConvert.js +61 -61
- package/dist/video.d.ts +150 -120
- package/dist/video.js +677 -582
- package/dist/video.js.map +1 -1
- package/dist/waitForEventLoop.d.ts +1 -1
- package/dist/waitForEventLoop.js +5 -5
- package/package.json +62 -62
- package/src/audio.ts +129 -129
- package/src/beats.ts +279 -279
- package/src/bpmEncoder.ts +97 -97
- package/src/config.ts +487 -476
- package/src/dsp.ts +95 -95
- package/src/highlight.ts +451 -291
- package/src/image.ts +707 -700
- package/src/index.ts +813 -769
- package/src/subtitleConvert.ts +63 -63
- package/src/tests/audio.test.ts +102 -102
- package/src/tests/beats.test.ts +134 -134
- package/src/tests/config.test.ts +534 -496
- package/src/tests/dsp.test.ts +24 -24
- package/src/tests/highlight.test.ts +261 -178
- package/src/tests/image.test.ts +319 -308
- package/src/tests/index.test.ts +180 -180
- package/src/tests/subtitleConvert.test.ts +23 -23
- package/src/tests/video.test.ts +535 -447
- package/src/typings.d.ts +28 -28
- package/src/video.ts +856 -722
- 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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
export const
|
|
434
|
-
/**
|
|
435
|
-
export const
|
|
436
|
-
/**
|
|
437
|
-
export const
|
|
438
|
-
/**
|
|
439
|
-
export const
|
|
440
|
-
|
|
441
|
-
export const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
push(beat);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
fps: number,
|
|
574
|
-
options?: {
|
|
575
|
-
beatIntensities?: number[];
|
|
576
|
-
minCutIntervalSeconds?: number;
|
|
577
|
-
maxCutIntervalSeconds?: number;
|
|
578
|
-
tempo?: TempoEstimate;
|
|
579
|
-
},
|
|
580
|
-
):
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (
|
|
622
|
-
|
|
623
|
-
}
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
];
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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;
|