nodejs-audio-visualizer 5.2.0 → 5.4.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/src/index.ts CHANGED
@@ -1,811 +1,882 @@
1
- import path from 'path';
2
- import { readFileSync } from 'fs';
3
- import {
4
- getAudioFilePath,
5
- getBackgroundImagePath,
6
- getBackgroundVideoPaths,
7
- getOutVideoPath,
8
- getSubtitleRenderSpec,
9
- subtitleAlignmentToAss,
10
- getFPS,
11
- getSpectrumBusMargin,
12
- getSpectrumWidthAbsolute,
13
- getSpectrumHeightAbsolute,
14
- getSpectrumXAbsolute,
15
- getSpectrumYAbsolute,
16
- getSpectrumColor,
17
- getSpectrumOpacityParsed,
18
- getFfmpeg_cfr,
19
- getFfmpeg_preset,
20
- getFrame_processing_delay,
21
- getVideoTimeouts,
22
- getOutputResolution,
23
- rotationAliasValues,
24
- getSpectrumRotation,
25
- getSpectrumEffect,
26
- SpectrumEffect,
27
- getPolarXAbsolute,
28
- getPolarYAbsolute,
29
- getPolarInnerRadius,
30
- getPolarMaxBarLength,
31
- getPolarBarWidth,
32
- getPolarEffect,
33
- getPolarColor,
34
- getPolarOpacityParsed,
35
- getAutoEditVideo,
36
- getCameraShakeEnabled,
37
- getAudioAutoHighlight,
38
- getAudioAutoHighlightCount,
39
- } from './config';
40
- import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor, pcmU8ToFloatSamples } from './audio';
41
- import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps, applyCameraShake, getCutShakeOffset, buildCutShakeAmplitudes, CAMERA_SHAKE_DELAY_SECONDS, CAMERA_SHAKE_EVERY_BEATS } from './image';
42
- import { normalizeInlineSubtitlesToSrt, lrcToSrt } from './subtitleConvert';
43
- import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, getCutFrameIndices, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile, VideoSegment } from './video';
44
- import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
45
- import { createBeatDetector, estimateTempo, TempoEstimate, beatGridFrameIndices, tempoForWindow, MAX_TEMPO_ANALYSIS_SECONDS } from './beats';
46
- export { BeatInfo, BeatDetectorOptions, TempoEstimate, estimateTempo, beatGridFrameIndices, shiftTempoPhase, tempoForWindow } from './beats';
47
- import { computeHighlightSlice, HIGHLIGHT_DURATION_SEC } from './highlight';
48
- import { waitForEventLoop } from './waitForEventLoop';
49
- export { computeHighlightSlice, HIGHLIGHT_DURATION_SEC, BeatFrameEvent, HighlightAudioSegment, HighlightRun } from './highlight';
50
-
51
- export const PCM_FORMAT = {
52
- bit: 8,
53
- sign: 'u',
54
- parseFunction: bufferToUInt8
55
- };
56
- const FFMPEG_FORMAT = `${PCM_FORMAT.sign}${PCM_FORMAT.bit}`;
57
- const PROCESSING_BUFFER_SIZE = Math.pow(2, 12);
58
-
59
- export interface Config {
60
- audio: {
61
- path: string;
62
- autoHighlight?: boolean;
63
- /** When `autoHighlight` is true, number of non-overlapping 15s windows to stitch (default 1). */
64
- autoHighlightCount?: number;
65
- };
66
- image?: {
67
- path: string;
68
- };
69
- video?: {
70
- /**
71
- * A single video path, or an array of video paths.
72
- * With an array: when `autoEdit` is false, the videos play one after another (looping the
73
- * sequence as needed); when `autoEdit` is true, cuts switch (shuffle) between the given
74
- * videos instead of auto-detecting scene changes within a single video.
75
- */
76
- path: string | string[];
77
- autoEdit?: boolean;
78
- /** When `autoEdit` is true, disables the brief camera shake applied on cuts, or every 2 beats when there are no cuts (default true). */
79
- cameraShake?: boolean;
80
- };
81
- outVideo: {
82
- path: string;
83
- subtitles?:
84
- | string
85
- | {
86
- path?: string;
87
- rawContent?: string;
88
- alignment?: 'top' | 'middle' | 'bottom';
89
- };
90
- fps?: number;
91
- resolution?: {
92
- width: number;
93
- height: number;
94
- };
95
- spectrum?: {
96
- width?: SpectrumSizeValue;
97
- height?: SpectrumSizeValue;
98
- x?: number | PositionAliasName;
99
- y?: number | PositionAliasName;
100
- rotation?: RotationAliasName;
101
- effect?: SpectrumEffect;
102
- color?: Color | string;
103
- opacity?: string;
104
- };
105
- polar?: {
106
- x?: number | PositionAliasName;
107
- y?: number | PositionAliasName;
108
- innerRadius?: number;
109
- maxBarLength?: number;
110
- barWidth?: number;
111
- effect?: SpectrumEffect;
112
- color?: Color | string;
113
- opacity?: string;
114
- }
115
- };
116
- tweaks?: {
117
- ffmpeg_cfr?: string;
118
- ffmpeg_preset?: string;
119
- frame_processing_delay?: number;
120
- timeouts?: {
121
- readVideoFrame?: number;
122
- waitDrain?: number;
123
- waitForProcessExit?: number;
124
- };
125
- };
126
- }
127
-
128
- export type SpectrumSizeValue = number | string;
129
-
130
- export type PositionAliasName =
131
- 'left' |
132
- 'center' |
133
- 'right' |
134
- 'top' |
135
- 'middle' |
136
- 'bottom';
137
-
138
- export type RotationAliasName = typeof rotationAliasValues[number];
139
-
140
- const sleep = (timeout: number) =>
141
- new Promise(resolve => setTimeout(resolve, timeout));
142
-
143
- const createVisualizerFrameGenerator = (
144
- config: Config,
145
- backgroundWidth: number,
146
- backgroundHeight: number,
147
- defaultColor: Color,
148
- spectrumBusMargin: number
149
- ): (params: CommonVisualizerFrameProps) => EncodedBmp => {
150
- if (config.outVideo.spectrum) {
151
- const createSpectrumVisualizerFrame = createSpectrumVisualizerFrameGenerator();
152
- const spectrumWidth = getSpectrumWidthAbsolute(config, backgroundWidth);
153
- const spectrumHeight = getSpectrumHeightAbsolute(config, backgroundHeight);
154
- const spectrumX = getSpectrumXAbsolute(config, spectrumWidth, backgroundWidth);
155
- const spectrumY = getSpectrumYAbsolute(config, spectrumHeight, backgroundHeight);
156
- const spectrumRotation = getSpectrumRotation(config);
157
- const spectrumColor = getSpectrumColor(config) || invertColor(defaultColor);
158
- const spectrumEffect = getSpectrumEffect(config);
159
- const spectrumOpacity = getSpectrumOpacityParsed(config);
160
-
161
- return (params: CommonVisualizerFrameProps) => {
162
- return createSpectrumVisualizerFrame({
163
- ...params,
164
- size: { width: spectrumWidth, height: spectrumHeight },
165
- position: { x: spectrumX, y: spectrumY },
166
- rotation: spectrumRotation,
167
- margin: spectrumBusMargin,
168
- color: spectrumColor,
169
- opacity: spectrumOpacity,
170
- spectrumEffect,
171
- });
172
- };
173
- }
174
-
175
- if (config.outVideo.polar) {
176
- const createPolarVisualizerFrame = createPolarVisualizerFrameGenerator();
177
- const polarX = getPolarXAbsolute(config, backgroundWidth);
178
- const polarY = getPolarYAbsolute(config, backgroundHeight);
179
- const polarInnerRadius = getPolarInnerRadius(config);
180
- const polarMaxBarLength = getPolarMaxBarLength(config);
181
- const polarBarWidth = getPolarBarWidth(config);
182
- const polarColor = getPolarColor(config) || invertColor(defaultColor);
183
- const polarEffect = getPolarEffect(config);
184
- const polarOpacity = getPolarOpacityParsed(config);
185
-
186
- return (params: CommonVisualizerFrameProps) => {
187
- return createPolarVisualizerFrame({
188
- ...params,
189
- centerX: polarX,
190
- centerY: polarY,
191
- innerRadius: polarInnerRadius,
192
- maxBarLength: polarMaxBarLength,
193
- barWidth: polarBarWidth,
194
- color: polarColor,
195
- opacity: polarOpacity,
196
- spectrumEffect: polarEffect,
197
- });
198
- };
199
- }
200
-
201
- return (params: CommonVisualizerFrameProps) => params.backgroundImageBuffer;
202
- };
203
-
204
- interface PreProcessedAudio {
205
- spectrums: number[][];
206
- beatFrameIndices: number[];
207
- beatEvents: { frameIndex: number; intensity: number }[];
208
- }
209
-
210
- const PRE_PROCESS_PROGRESS_SHARE = 60;
211
- const POST_AUDIO_PROGRESS_SHARE = 10;
212
- const RENDER_PROGRESS_SHARE = 30;
213
-
214
- const preProcessAudio = async (
215
- audioBuffer: Buffer,
216
- sampleRate: number,
217
- fps: number,
218
- framesCount: number,
219
- onPreProcessProgress?: (totalPercent: number) => void,
220
- ): Promise<PreProcessedAudio> => {
221
- if (framesCount === 0) {
222
- for (let t = 1; t <= PRE_PROCESS_PROGRESS_SHARE; t += 1) {
223
- onPreProcessProgress?.(t);
224
- }
225
- return { spectrums: [], beatFrameIndices: [], beatEvents: [] };
226
- }
227
- const audioDataStep = Math.trunc(audioBuffer.length / framesCount);
228
- const processingBuffer = new Float32Array(PROCESSING_BUFFER_SIZE).fill(0);
229
- const skipFramesCount = fps < 45 ? 1 : 2;
230
- const processSpectrum = createSpectrumsProcessor(sampleRate, skipFramesCount);
231
- const detectBeat = createBeatDetector(fps);
232
-
233
- const spectrums: number[][] = [];
234
- const beatFrameIndices: number[] = [];
235
- const beatEvents: { frameIndex: number; intensity: number }[] = [];
236
- let nextPreProcessMilestone = 1;
237
-
238
- for (let i = 0; i < framesCount; i++) {
239
- const currentFrameData = PCM_FORMAT.parseFunction(audioBuffer, i * audioDataStep, i * audioDataStep + audioDataStep);
240
- const frameDataLength = Math.min(currentFrameData.length, PROCESSING_BUFFER_SIZE);
241
- const frameDataTailStart = currentFrameData.length - frameDataLength;
242
- const frameDataToProcess = currentFrameData.slice(frameDataTailStart);
243
-
244
- processingBuffer.copyWithin(0, frameDataLength);
245
- processingBuffer.set(frameDataToProcess, PROCESSING_BUFFER_SIZE - frameDataLength);
246
-
247
- const audioDataParser = () => Array.from(processingBuffer);
248
- const spectrum = processSpectrum(audioDataParser);
249
- const beat = detectBeat(spectrum);
250
-
251
- spectrums.push(spectrum);
252
- if (beat.isBeat) {
253
- beatFrameIndices.push(i);
254
- beatEvents.push({ frameIndex: i, intensity: beat.intensity });
255
- }
256
-
257
- const p =
258
- PRE_PROCESS_PROGRESS_SHARE * (i + 1) / framesCount;
259
- while (
260
- nextPreProcessMilestone <= PRE_PROCESS_PROGRESS_SHARE
261
- && p >= nextPreProcessMilestone
262
- ) {
263
- onPreProcessProgress?.(nextPreProcessMilestone);
264
- nextPreProcessMilestone += 1;
265
- }
266
-
267
- await waitForEventLoop();
268
- }
269
-
270
- return { spectrums, beatFrameIndices, beatEvents };
271
- };
272
-
273
- async function prepareBackgroundForRender(params: {
274
- useVideoBackground: boolean;
275
- backgroundVideoPaths: string[] | undefined;
276
- backgroundImagePath: string | undefined;
277
- beatFrameIndices: number[];
278
- beatIntensities?: number[];
279
- framesCount: number;
280
- outputResolution: ReturnType<typeof getOutputResolution>;
281
- fps: number;
282
- autoEditVideo: boolean;
283
- readVideoFrameTimeout: number;
284
- tempo?: TempoEstimate | null;
285
- /** First video to play for `video.path` arrays without `autoEdit`. */
286
- startVideoIndex?: number;
287
- }): Promise<{
288
- backgroundWidth: number;
289
- backgroundHeight: number;
290
- defaultColor: Color;
291
- staticBackgroundBuffer: EncodedBmp;
292
- videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
293
- videoFrameSize: number;
294
- encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
295
- concatFilePath?: string;
296
- cutFrameIndices: number[];
297
- }> {
298
- const {
299
- useVideoBackground,
300
- backgroundVideoPaths,
301
- backgroundImagePath,
302
- beatFrameIndices,
303
- beatIntensities,
304
- framesCount,
305
- outputResolution,
306
- fps,
307
- autoEditVideo,
308
- readVideoFrameTimeout,
309
- tempo,
310
- startVideoIndex = 0,
311
- } = params;
312
-
313
- if (useVideoBackground && backgroundVideoPaths && backgroundVideoPaths.length > 0) {
314
- const videoInfos = await Promise.all(backgroundVideoPaths.map(videoPath => getVideoInfo(videoPath)));
315
- const primaryVideoInfo = videoInfos[0];
316
- const backgroundWidth = outputResolution?.width ?? primaryVideoInfo.width;
317
- const backgroundHeight = outputResolution?.height ?? primaryVideoInfo.height;
318
-
319
- const videoFrameSize = backgroundWidth * backgroundHeight * 3;
320
- const encodeVideoFrame = createBgrFrameEncoder({ width: backgroundWidth, height: backgroundHeight });
321
-
322
- let segments: VideoSegment[];
323
- if (backgroundVideoPaths.length > 1) {
324
- segments = autoEditVideo
325
- ? buildShuffledVideoSegments(
326
- beatFrameIndices,
327
- framesCount,
328
- backgroundVideoPaths.length,
329
- fps,
330
- {
331
- beatIntensities,
332
- ...(tempo ? { tempo } : {}),
333
- },
334
- )
335
- : buildSequentialVideoSegments(
336
- framesCount,
337
- videoInfos.map(info => info.duration),
338
- fps,
339
- startVideoIndex % backgroundVideoPaths.length,
340
- );
341
- } else {
342
- const sceneChanges = autoEditVideo
343
- ? await detectSceneChanges(backgroundVideoPaths[0])
344
- : [];
345
- segments = buildBeatSyncedSegments(
346
- beatFrameIndices,
347
- framesCount,
348
- sceneChanges,
349
- primaryVideoInfo.duration,
350
- fps,
351
- {
352
- beatIntensities,
353
- ...(autoEditVideo && tempo ? { tempo } : {}),
354
- },
355
- );
356
- }
357
-
358
- const videoSources = videoInfos.map((info, i) => ({ path: backgroundVideoPaths[i], duration: info.duration }));
359
- const concatFilePath = writeConcatFile(segments, videoSources, fps);
360
- const cutFrameIndices = autoEditVideo ? getCutFrameIndices(segments) : [];
361
-
362
- const videoFrameReader = spawnConcatVideoFrameReader({
363
- concatFilePath,
364
- fps,
365
- totalFrames: framesCount,
366
- ...(outputResolution && {
367
- width: backgroundWidth,
368
- height: backgroundHeight,
369
- sourceWidth: primaryVideoInfo.width,
370
- sourceHeight: primaryVideoInfo.height,
371
- }),
372
- });
373
-
374
- const firstFrame = await readVideoFrame(
375
- videoFrameReader.stdout,
376
- videoFrameSize,
377
- readVideoFrameTimeout,
378
- );
379
- if (!firstFrame) {
380
- throw new Error(`Could not read frames from video: ${backgroundVideoPaths.join(', ')}`);
381
- }
382
- const defaultColor = getVideoFrameColor(firstFrame, backgroundWidth, backgroundHeight);
383
- const staticBackgroundBuffer = encodeVideoFrame(firstFrame);
384
- return {
385
- backgroundWidth,
386
- backgroundHeight,
387
- defaultColor,
388
- staticBackgroundBuffer,
389
- videoFrameReader,
390
- videoFrameSize,
391
- encodeVideoFrame,
392
- concatFilePath,
393
- cutFrameIndices,
394
- };
395
- }
396
-
397
- if (!backgroundImagePath) {
398
- throw new Error('Background image path is required when not using video background.');
399
- }
400
- const backgroundImageBmpBuffer = await convertToBmp(
401
- backgroundImagePath,
402
- outputResolution?.width,
403
- outputResolution?.height,
404
- );
405
- const backgroundImage = parseImage(backgroundImageBmpBuffer);
406
- const backgroundWidth = backgroundImage.width;
407
- const backgroundHeight = backgroundImage.height;
408
- const defaultColor = getImageColor(backgroundImage);
409
-
410
- const bpmEncoder = createBpmEncoder({ width: backgroundWidth, height: backgroundHeight });
411
- const staticBackgroundBuffer = bpmEncoder(backgroundImage.data);
412
- return {
413
- backgroundWidth,
414
- backgroundHeight,
415
- defaultColor,
416
- staticBackgroundBuffer,
417
- videoFrameSize: 0,
418
- cutFrameIndices: [],
419
- };
420
- }
421
-
422
- async function resolveBackgroundFrameBuffer(params: {
423
- frameIndex: number;
424
- useVideoBackground: boolean;
425
- videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
426
- videoFrameSize: number;
427
- encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
428
- staticBackgroundBuffer: EncodedBmp;
429
- readVideoFrameTimeout: number;
430
- }): Promise<{ frameBuffer: EncodedBmp; frameReadFailed: boolean }> {
431
- const {
432
- frameIndex,
433
- useVideoBackground,
434
- videoFrameReader,
435
- videoFrameSize,
436
- encodeVideoFrame,
437
- staticBackgroundBuffer,
438
- readVideoFrameTimeout,
439
- } = params;
440
-
441
- if (useVideoBackground && videoFrameReader && encodeVideoFrame) {
442
- if (frameIndex === 0) {
443
- return { frameBuffer: staticBackgroundBuffer, frameReadFailed: false };
444
- }
445
- const videoFrame = await readVideoFrame(
446
- videoFrameReader.stdout,
447
- videoFrameSize,
448
- readVideoFrameTimeout,
449
- );
450
- if (videoFrame) {
451
- return { frameBuffer: encodeVideoFrame(videoFrame), frameReadFailed: false };
452
- }
453
- return { frameBuffer: staticBackgroundBuffer, frameReadFailed: false };
454
- }
455
- return { frameBuffer: staticBackgroundBuffer, frameReadFailed: false };
456
- }
457
-
458
- export interface RenderAudioVisualizerResult {
459
- exitCode: number;
460
- /** Human-readable failure reason; omitted on full success (exitCode 0). */
461
- reason?: string;
462
- /** Absolute paths of video files written successfully (exit code 0, full pass, no early stop). */
463
- outputVideoFiles: string[];
464
- }
465
-
466
- export const renderAudioVisualizer = (config: Config, onProgress?: (progress: number) => any, shouldStop?: () => boolean) =>
467
- new Promise<RenderAudioVisualizerResult>(async (resolve) => {
468
- if (config.outVideo.spectrum && config.outVideo.polar) {
469
- throw new Error('Cannot use both "spectrum" and "polar" options. Please specify only one visualizer type.');
470
- }
471
- if (!config.image && !config.video) {
472
- throw new Error('Either "image" or "video" must be specified as the background source.');
473
- }
474
- if (config.image && config.video) {
475
- throw new Error('Cannot use both "image" and "video" options. Please specify only one background source.');
476
- }
477
-
478
- const audioFilePath = getAudioFilePath(config);
479
- const outVideoPath = getOutVideoPath(config);
480
- const subtitleSpec = getSubtitleRenderSpec(config);
481
- let subtitleFilePath: string | undefined;
482
- let subtitleFileIsTemporary = false;
483
- let subtitleAlignmentAss = 2;
484
- if (subtitleSpec) {
485
- subtitleAlignmentAss = subtitleAlignmentToAss(subtitleSpec.alignment);
486
- if (subtitleSpec.source.kind === 'file') {
487
- const absPath = subtitleSpec.source.path;
488
- const ext = path.extname(absPath).toLowerCase();
489
- if (ext === '.lrc') {
490
- const raw = readFileSync(absPath, 'utf-8');
491
- subtitleFilePath = writeSubtitlesFile(lrcToSrt(raw));
492
- subtitleFileIsTemporary = true;
493
- } else {
494
- subtitleFilePath = absPath;
495
- }
496
- } else {
497
- subtitleFilePath = writeSubtitlesFile(
498
- normalizeInlineSubtitlesToSrt(subtitleSpec.source.text),
499
- );
500
- subtitleFileIsTemporary = true;
501
- }
502
- }
503
- const backgroundVideoPaths = getBackgroundVideoPaths(config);
504
- const backgroundImagePath = getBackgroundImagePath(config);
505
- const useVideoBackground = !!backgroundVideoPaths && backgroundVideoPaths.length > 0;
506
-
507
- const audioReader = await createAudioBuffer(audioFilePath, FFMPEG_FORMAT);
508
- const audioBuffer = audioReader.audioBuffer;
509
- const sampleRate = audioReader.sampleRate;
510
- if (!sampleRate) {
511
- throw new Error('ffmpeg didn\'t show audio sample rate');
512
- }
513
-
514
- const spectrumBusMargin = getSpectrumBusMargin();
515
- const FPS = getFPS(config);
516
- const ffmpeg_cfr = getFfmpeg_cfr(config);
517
- const ffmpeg_preset = getFfmpeg_preset(config);
518
- const frame_processing_delay = getFrame_processing_delay(config);
519
- const videoTimeouts = getVideoTimeouts(config);
520
-
521
- const audioDuration = audioBuffer.length / sampleRate;
522
- const framesCount = Math.trunc(audioDuration * FPS);
523
- const outputResolution = getOutputResolution(config);
524
-
525
- let maxProgressReported = -1;
526
- const reportProgress = (progress: number) => {
527
- if (!onProgress) {
528
- return;
529
- }
530
- const normalized = +Math.min(100, Math.max(0, progress)).toFixed(2);
531
- if (normalized <= maxProgressReported) {
532
- return;
533
- }
534
- maxProgressReported = normalized;
535
- onProgress(normalized);
536
- };
537
- reportProgress(0);
538
-
539
- const preprocessed = await preProcessAudio(
540
- audioBuffer,
541
- sampleRate,
542
- FPS,
543
- framesCount,
544
- (progress: number) => {
545
- reportProgress(progress);
546
- },
547
- );
548
- reportProgress(PRE_PROCESS_PROGRESS_SHARE);
549
-
550
- const autoHighlight = getAudioAutoHighlight(config);
551
- let spectrumsForRender = preprocessed.spectrums;
552
- let beatIndicesForRender = preprocessed.beatFrameIndices;
553
- let beatIntensitiesForRender = preprocessed.beatEvents.map(event => event.intensity);
554
- let framesCountForRender = framesCount;
555
- type HighlightSliceResult = ReturnType<typeof computeHighlightSlice> extends Promise<
556
- infer R
557
- >
558
- ? R
559
- : never;
560
- let highlightSlice: HighlightSliceResult | undefined;
561
-
562
- if (autoHighlight) {
563
- reportProgress(PRE_PROCESS_PROGRESS_SHARE + 1);
564
- highlightSlice = await computeHighlightSlice(
565
- FPS,
566
- framesCount,
567
- preprocessed.spectrums,
568
- preprocessed.beatEvents,
569
- getAudioAutoHighlightCount(config),
570
- );
571
- spectrumsForRender = highlightSlice.spectrums;
572
- beatIndicesForRender = highlightSlice.beatFrameIndices;
573
- beatIntensitiesForRender = highlightSlice.beatIntensities;
574
- framesCountForRender = highlightSlice.highlightFrames;
575
- reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
576
- } else {
577
- reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
578
- }
579
-
580
- const autoEditVideo = getAutoEditVideo(config);
581
- const cameraShakeEnabled = getCameraShakeEnabled(config);
582
- const shakeDelayFrames = Math.round(FPS * CAMERA_SHAKE_DELAY_SECONDS);
583
- let trackTempo: TempoEstimate | null = null;
584
- if (autoEditVideo) {
585
- const sampleRateNum = Number(sampleRate);
586
- const maxSamples = Math.floor(sampleRateNum * MAX_TEMPO_ANALYSIS_SECONDS);
587
- const tempoBuffer = audioBuffer.length > maxSamples
588
- ? audioBuffer.slice(0, maxSamples)
589
- : audioBuffer;
590
- trackTempo = estimateTempo(pcmU8ToFloatSamples(tempoBuffer), sampleRateNum, FPS);
591
- }
592
-
593
- const separateHighlightFiles =
594
- autoHighlight &&
595
- highlightSlice &&
596
- highlightSlice.runs.length > 1;
597
-
598
- type VideoRenderPass = {
599
- outPath: string;
600
- spectrums: number[][];
601
- beatIndices: number[];
602
- beatIntensities: number[];
603
- frameCount: number;
604
- startFrame: number;
605
- audioSegment: import('./highlight').HighlightAudioSegment | undefined;
606
- };
607
-
608
- const passes: VideoRenderPass[] =
609
- separateHighlightFiles && highlightSlice
610
- ? highlightSlice.runs.map((run, i) => {
611
- const dir = path.dirname(outVideoPath);
612
- const ext = path.extname(outVideoPath);
613
- const base = path.basename(outVideoPath, ext);
614
- const numberedPath = path.resolve(dir, `${base}-${i + 1}${ext}`);
615
- return {
616
- outPath: numberedPath,
617
- spectrums: run.spectrums,
618
- beatIndices: run.beatFrameIndices,
619
- beatIntensities: run.beatIntensities,
620
- frameCount: run.highlightFrames,
621
- startFrame: run.startFrame,
622
- audioSegment: run.audioSegment,
623
- };
624
- })
625
- : [
626
- {
627
- outPath: outVideoPath,
628
- spectrums: spectrumsForRender,
629
- beatIndices: beatIndicesForRender,
630
- beatIntensities: beatIntensitiesForRender,
631
- frameCount: framesCountForRender,
632
- startFrame: highlightSlice?.startFrame ?? 0,
633
- audioSegment:
634
- autoHighlight &&
635
- highlightSlice &&
636
- highlightSlice.audioSegments.length > 0
637
- ? highlightSlice.audioSegments[0]
638
- : undefined,
639
- },
640
- ];
641
-
642
- const totalPassFrames =
643
- passes.reduce((sum, pass) => sum + pass.frameCount, 0);
644
- const totalRenderMilestones = totalPassFrames + (passes.length * 2);
645
- let renderMilestonesCompleted = 0;
646
- const reportRenderProgress = (milestoneIncrement = 1) => {
647
- renderMilestonesCompleted += milestoneIncrement;
648
- reportProgress(
649
- PRE_PROCESS_PROGRESS_SHARE
650
- + POST_AUDIO_PROGRESS_SHARE
651
- + (RENDER_PROGRESS_SHARE * renderMilestonesCompleted / Math.max(1, totalRenderMilestones)),
652
- );
653
- };
654
- let lastExitCode = 0;
655
- let exitReason: string | undefined;
656
- const outputVideoFiles: string[] = [];
657
- try {
658
- passLoop: for (let passIndex = 0; passIndex < passes.length; passIndex++) {
659
- const pass = passes[passIndex];
660
- const tempo = trackTempo
661
- ? tempoForWindow(trackTempo, pass.startFrame, FPS, pass.frameCount, pass.beatIndices)
662
- : null;
663
- const shakeAmplitudes = tempo
664
- ? buildCutShakeAmplitudes(tempo.periodFrames)
665
- : undefined;
666
- const {
667
- backgroundWidth,
668
- backgroundHeight,
669
- defaultColor,
670
- staticBackgroundBuffer,
671
- videoFrameReader,
672
- videoFrameSize,
673
- encodeVideoFrame,
674
- concatFilePath,
675
- cutFrameIndices,
676
- } = await prepareBackgroundForRender({
677
- useVideoBackground,
678
- backgroundVideoPaths,
679
- backgroundImagePath,
680
- beatFrameIndices: pass.beatIndices,
681
- beatIntensities: pass.beatIntensities,
682
- framesCount: pass.frameCount,
683
- outputResolution,
684
- fps: FPS,
685
- autoEditVideo,
686
- readVideoFrameTimeout: videoTimeouts.readVideoFrame,
687
- ...(tempo ? { tempo } : {}),
688
- // Each separate highlight output opens with a different video from the array.
689
- startVideoIndex: passIndex,
690
- });
691
- reportRenderProgress();
692
-
693
- const beatShakeFrames = () => (tempo
694
- ? beatGridFrameIndices(tempo, pass.frameCount, CAMERA_SHAKE_EVERY_BEATS)
695
- : pass.beatIndices.filter(frameIndex => frameIndex > 0).filter((_, i) => i % CAMERA_SHAKE_EVERY_BEATS === 0));
696
- const shakeFrames = new Set(
697
- autoEditVideo && cameraShakeEnabled
698
- ? (cutFrameIndices.length > 0 ? cutFrameIndices : beatShakeFrames())
699
- : [],
700
- );
701
- const createVisualizerFrame = createVisualizerFrameGenerator(
702
- config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
703
- );
704
-
705
- const ffmpegVideoWriter = spawnFfmpegVideoWriter({
706
- audioFilename: audioFilePath,
707
- videoFileName: pass.outPath,
708
- ...(subtitleFilePath && {
709
- subtitleFilename: subtitleFilePath,
710
- subtitleAlignmentAss,
711
- }),
712
- fps: FPS,
713
- ...(pass.audioSegment && { audioSegment: pass.audioSegment }),
714
- ...(ffmpeg_cfr && { crf: ffmpeg_cfr }),
715
- ...(ffmpeg_preset && { preset: ffmpeg_preset }),
716
- });
717
- const exitPromise = waitForProcessExit(
718
- ffmpegVideoWriter,
719
- videoTimeouts.waitForProcessExit,
720
- );
721
-
722
- let stoppedEarly = false;
723
- for (let i = 0; i < pass.frameCount; i++) {
724
- const spectrum = pass.spectrums[i];
725
- const { frameBuffer: backgroundImageBuffer, frameReadFailed } = await resolveBackgroundFrameBuffer({
726
- frameIndex: i,
727
- useVideoBackground,
728
- videoFrameReader,
729
- videoFrameSize,
730
- encodeVideoFrame,
731
- staticBackgroundBuffer,
732
- readVideoFrameTimeout: videoTimeouts.readVideoFrame,
733
- });
734
-
735
- const commonVisualizerFrameProps: CommonVisualizerFrameProps = {
736
- backgroundImageBuffer,
737
- spectrum,
738
- };
739
- const frameImage = createVisualizerFrame(commonVisualizerFrameProps);
740
- const shakeOffset = getCutShakeOffset(i, shakeFrames, shakeAmplitudes, shakeDelayFrames);
741
- if (shakeOffset.x !== 0 || shakeOffset.y !== 0) {
742
- applyCameraShake(frameImage, backgroundWidth, backgroundHeight, shakeOffset.x, shakeOffset.y);
743
- }
744
- const isFrameProcessed = ffmpegVideoWriter.stdin.write(frameImage.data);
745
- if (!isFrameProcessed) {
746
- const isDrained = await waitDrain(
747
- ffmpegVideoWriter.stdin,
748
- ffmpegVideoWriter,
749
- videoTimeouts.waitDrain,
750
- );
751
- if (!isDrained) {
752
- stoppedEarly = true;
753
- exitReason = 'ffmpeg stdin drain failed';
754
- break;
755
- }
756
- }
757
- if (shouldStop && shouldStop()) {
758
- stoppedEarly = true;
759
- exitReason = 'render stopped by shouldStop callback';
760
- break;
761
- }
762
- if (frame_processing_delay) {
763
- await sleep(frame_processing_delay);
764
- }
765
- await waitForEventLoop();
766
- reportRenderProgress();
767
- }
768
-
769
- if (videoFrameReader) {
770
- videoFrameReader.kill();
771
- }
772
- if (concatFilePath) {
773
- cleanupConcatFile(concatFilePath);
774
- }
775
- ffmpegVideoWriter.stdin.end();
776
-
777
- const { exitCode, reason: ffmpegExitReason } = await exitPromise;
778
- lastExitCode = exitCode;
779
- if (!exitReason && ffmpegExitReason) {
780
- exitReason = ffmpegExitReason;
781
- }
782
- if (lastExitCode === 0 && !stoppedEarly) {
783
- reportRenderProgress();
784
- }
785
-
786
- if (lastExitCode === 0 && !stoppedEarly) {
787
- outputVideoFiles.push(pass.outPath);
788
- }
789
-
790
- if (stoppedEarly || lastExitCode !== 0) {
791
- if (exitReason && lastExitCode === 0) {
792
- lastExitCode = 1;
793
- }
794
- break passLoop;
795
- }
796
- }
797
-
798
- if (lastExitCode === 0 && outputVideoFiles.length === passes.length) {
799
- reportProgress(100);
800
- }
801
- if (exitReason) {
802
- console.error(exitReason);
803
- }
804
-
805
- resolve({ exitCode: lastExitCode, reason: exitReason, outputVideoFiles });
806
- } finally {
807
- if (subtitleFilePath && subtitleFileIsTemporary) {
808
- cleanupTempFile(subtitleFilePath);
809
- }
810
- }
811
- });
1
+ import path from 'path';
2
+ import { readFileSync } from 'fs';
3
+ import {
4
+ getAudioFilePath,
5
+ getBackgroundImagePath,
6
+ getBackgroundVideoPaths,
7
+ getHookVideoPath,
8
+ getOutVideoPath,
9
+ getSubtitleRenderSpec,
10
+ subtitleAlignmentToAss,
11
+ getFPS,
12
+ getSpectrumBusMargin,
13
+ getSpectrumWidthAbsolute,
14
+ getSpectrumHeightAbsolute,
15
+ getSpectrumXAbsolute,
16
+ getSpectrumYAbsolute,
17
+ getSpectrumColor,
18
+ getSpectrumOpacityParsed,
19
+ getFfmpeg_cfr,
20
+ getFfmpeg_preset,
21
+ getFrame_processing_delay,
22
+ getVideoTimeouts,
23
+ getOutputResolution,
24
+ rotationAliasValues,
25
+ getSpectrumRotation,
26
+ getSpectrumEffect,
27
+ SpectrumEffect,
28
+ getPolarXAbsolute,
29
+ getPolarYAbsolute,
30
+ getPolarInnerRadius,
31
+ getPolarMaxBarLength,
32
+ getPolarBarWidth,
33
+ getPolarEffect,
34
+ getPolarColor,
35
+ getPolarOpacityParsed,
36
+ getAutoEditVideo,
37
+ getCameraShakeEnabled,
38
+ getAudioAutoHighlight,
39
+ getAudioAutoHighlightCount,
40
+ } from './config';
41
+ import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor, pcmU8ToFloatSamples } from './audio';
42
+ import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps, applyCameraShake, getCutShakeOffset, buildCutShakeAmplitudes, CAMERA_SHAKE_DELAY_SECONDS, CAMERA_SHAKE_EVERY_BEATS } from './image';
43
+ import { normalizeInlineSubtitlesToSrt, lrcToSrt } from './subtitleConvert';
44
+ import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, computeHookFrameCount, getVideoFrameCount, getCutFrameIndices, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile, VideoSegment } from './video';
45
+ import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
46
+ import { createBeatDetector, estimateTempo, TempoEstimate, beatGridFrameIndices, tempoForWindow, shiftTempoPhase, MAX_TEMPO_ANALYSIS_SECONDS } from './beats';
47
+ export { BeatInfo, BeatDetectorOptions, TempoEstimate, estimateTempo, beatGridFrameIndices, shiftTempoPhase, tempoForWindow } from './beats';
48
+ import { computeHighlightSlice, HIGHLIGHT_DURATION_SEC } from './highlight';
49
+ import { waitForEventLoop } from './waitForEventLoop';
50
+ export { computeHighlightSlice, HIGHLIGHT_DURATION_SEC, MAX_BEAT_SNAP_SEC, BeatFrameEvent, HighlightAudioSegment, HighlightRun, HighlightBeatGrid } from './highlight';
51
+
52
+ export const PCM_FORMAT = {
53
+ bit: 8,
54
+ sign: 'u',
55
+ parseFunction: bufferToUInt8
56
+ };
57
+ const FFMPEG_FORMAT = `${PCM_FORMAT.sign}${PCM_FORMAT.bit}`;
58
+ const PROCESSING_BUFFER_SIZE = Math.pow(2, 12);
59
+
60
+ export interface Config {
61
+ audio: {
62
+ path: string;
63
+ autoHighlight?: boolean;
64
+ /** When `autoHighlight` is true, number of non-overlapping ~15s beat-aligned windows to stitch (default 1). */
65
+ autoHighlightCount?: number;
66
+ };
67
+ image?: {
68
+ path: string;
69
+ };
70
+ video?: {
71
+ /**
72
+ * A single video path, or an array of video paths.
73
+ * With an array: when `autoEdit` is false, the videos play one after another (looping the
74
+ * sequence as needed); when `autoEdit` is true, cuts switch (shuffle) between the given
75
+ * videos instead of auto-detecting scene changes within a single video.
76
+ */
77
+ path: string | string[];
78
+ /**
79
+ * Video played once, always in full, before the `path` video(s). When `audio.autoHighlight`
80
+ * is true, each highlight is chosen so the hook ends on a detected drop and `path` starts with it,
81
+ * playing ~15s from the drop (the hook comes on top of the ~15s rather than being counted in it).
82
+ * If the drop is closer to the track start than the hook's length, the track is preceded by silence.
83
+ * The hook's sound, if any, is mixed over the track, which fades in from silence to full volume while the hook plays.
84
+ */
85
+ hookPath?: string;
86
+ autoEdit?: boolean;
87
+ /** When `autoEdit` is true, disables the brief camera shake applied on cuts, or every 2 beats when there are no cuts (default true). */
88
+ cameraShake?: boolean;
89
+ };
90
+ outVideo: {
91
+ path: string;
92
+ subtitles?:
93
+ | string
94
+ | {
95
+ path?: string;
96
+ rawContent?: string;
97
+ alignment?: 'top' | 'middle' | 'bottom';
98
+ };
99
+ fps?: number;
100
+ resolution?: {
101
+ width: number;
102
+ height: number;
103
+ };
104
+ spectrum?: {
105
+ width?: SpectrumSizeValue;
106
+ height?: SpectrumSizeValue;
107
+ x?: number | PositionAliasName;
108
+ y?: number | PositionAliasName;
109
+ rotation?: RotationAliasName;
110
+ effect?: SpectrumEffect;
111
+ color?: Color | string;
112
+ opacity?: string;
113
+ };
114
+ polar?: {
115
+ x?: number | PositionAliasName;
116
+ y?: number | PositionAliasName;
117
+ innerRadius?: number;
118
+ maxBarLength?: number;
119
+ barWidth?: number;
120
+ effect?: SpectrumEffect;
121
+ color?: Color | string;
122
+ opacity?: string;
123
+ }
124
+ };
125
+ tweaks?: {
126
+ ffmpeg_cfr?: string;
127
+ ffmpeg_preset?: string;
128
+ frame_processing_delay?: number;
129
+ timeouts?: {
130
+ readVideoFrame?: number;
131
+ waitDrain?: number;
132
+ waitForProcessExit?: number;
133
+ };
134
+ };
135
+ }
136
+
137
+ export type SpectrumSizeValue = number | string;
138
+
139
+ export type PositionAliasName =
140
+ 'left' |
141
+ 'center' |
142
+ 'right' |
143
+ 'top' |
144
+ 'middle' |
145
+ 'bottom';
146
+
147
+ export type RotationAliasName = typeof rotationAliasValues[number];
148
+
149
+ const sleep = (timeout: number) =>
150
+ new Promise(resolve => setTimeout(resolve, timeout));
151
+
152
+ const createVisualizerFrameGenerator = (
153
+ config: Config,
154
+ backgroundWidth: number,
155
+ backgroundHeight: number,
156
+ defaultColor: Color,
157
+ spectrumBusMargin: number
158
+ ): (params: CommonVisualizerFrameProps) => EncodedBmp => {
159
+ if (config.outVideo.spectrum) {
160
+ const createSpectrumVisualizerFrame = createSpectrumVisualizerFrameGenerator();
161
+ const spectrumWidth = getSpectrumWidthAbsolute(config, backgroundWidth);
162
+ const spectrumHeight = getSpectrumHeightAbsolute(config, backgroundHeight);
163
+ const spectrumX = getSpectrumXAbsolute(config, spectrumWidth, backgroundWidth);
164
+ const spectrumY = getSpectrumYAbsolute(config, spectrumHeight, backgroundHeight);
165
+ const spectrumRotation = getSpectrumRotation(config);
166
+ const spectrumColor = getSpectrumColor(config) || invertColor(defaultColor);
167
+ const spectrumEffect = getSpectrumEffect(config);
168
+ const spectrumOpacity = getSpectrumOpacityParsed(config);
169
+
170
+ return (params: CommonVisualizerFrameProps) => {
171
+ return createSpectrumVisualizerFrame({
172
+ ...params,
173
+ size: { width: spectrumWidth, height: spectrumHeight },
174
+ position: { x: spectrumX, y: spectrumY },
175
+ rotation: spectrumRotation,
176
+ margin: spectrumBusMargin,
177
+ color: spectrumColor,
178
+ opacity: spectrumOpacity,
179
+ spectrumEffect,
180
+ });
181
+ };
182
+ }
183
+
184
+ if (config.outVideo.polar) {
185
+ const createPolarVisualizerFrame = createPolarVisualizerFrameGenerator();
186
+ const polarX = getPolarXAbsolute(config, backgroundWidth);
187
+ const polarY = getPolarYAbsolute(config, backgroundHeight);
188
+ const polarInnerRadius = getPolarInnerRadius(config);
189
+ const polarMaxBarLength = getPolarMaxBarLength(config);
190
+ const polarBarWidth = getPolarBarWidth(config);
191
+ const polarColor = getPolarColor(config) || invertColor(defaultColor);
192
+ const polarEffect = getPolarEffect(config);
193
+ const polarOpacity = getPolarOpacityParsed(config);
194
+
195
+ return (params: CommonVisualizerFrameProps) => {
196
+ return createPolarVisualizerFrame({
197
+ ...params,
198
+ centerX: polarX,
199
+ centerY: polarY,
200
+ innerRadius: polarInnerRadius,
201
+ maxBarLength: polarMaxBarLength,
202
+ barWidth: polarBarWidth,
203
+ color: polarColor,
204
+ opacity: polarOpacity,
205
+ spectrumEffect: polarEffect,
206
+ });
207
+ };
208
+ }
209
+
210
+ return (params: CommonVisualizerFrameProps) => params.backgroundImageBuffer;
211
+ };
212
+
213
+ interface PreProcessedAudio {
214
+ spectrums: number[][];
215
+ beatFrameIndices: number[];
216
+ beatEvents: { frameIndex: number; intensity: number }[];
217
+ }
218
+
219
+ const PRE_PROCESS_PROGRESS_SHARE = 60;
220
+ const POST_AUDIO_PROGRESS_SHARE = 10;
221
+ const RENDER_PROGRESS_SHARE = 30;
222
+
223
+ const preProcessAudio = async (
224
+ audioBuffer: Buffer,
225
+ sampleRate: number,
226
+ fps: number,
227
+ framesCount: number,
228
+ onPreProcessProgress?: (totalPercent: number) => void,
229
+ ): Promise<PreProcessedAudio> => {
230
+ if (framesCount === 0) {
231
+ for (let t = 1; t <= PRE_PROCESS_PROGRESS_SHARE; t += 1) {
232
+ onPreProcessProgress?.(t);
233
+ }
234
+ return { spectrums: [], beatFrameIndices: [], beatEvents: [] };
235
+ }
236
+ const audioDataStep = Math.trunc(audioBuffer.length / framesCount);
237
+ const processingBuffer = new Float32Array(PROCESSING_BUFFER_SIZE).fill(0);
238
+ const skipFramesCount = fps < 45 ? 1 : 2;
239
+ const processSpectrum = createSpectrumsProcessor(sampleRate, skipFramesCount);
240
+ const detectBeat = createBeatDetector(fps);
241
+
242
+ const spectrums: number[][] = [];
243
+ const beatFrameIndices: number[] = [];
244
+ const beatEvents: { frameIndex: number; intensity: number }[] = [];
245
+ let nextPreProcessMilestone = 1;
246
+
247
+ for (let i = 0; i < framesCount; i++) {
248
+ const currentFrameData = PCM_FORMAT.parseFunction(audioBuffer, i * audioDataStep, i * audioDataStep + audioDataStep);
249
+ const frameDataLength = Math.min(currentFrameData.length, PROCESSING_BUFFER_SIZE);
250
+ const frameDataTailStart = currentFrameData.length - frameDataLength;
251
+ const frameDataToProcess = currentFrameData.slice(frameDataTailStart);
252
+
253
+ processingBuffer.copyWithin(0, frameDataLength);
254
+ processingBuffer.set(frameDataToProcess, PROCESSING_BUFFER_SIZE - frameDataLength);
255
+
256
+ const audioDataParser = () => Array.from(processingBuffer);
257
+ const spectrum = processSpectrum(audioDataParser);
258
+ const beat = detectBeat(spectrum);
259
+
260
+ spectrums.push(spectrum);
261
+ if (beat.isBeat) {
262
+ beatFrameIndices.push(i);
263
+ beatEvents.push({ frameIndex: i, intensity: beat.intensity });
264
+ }
265
+
266
+ const p =
267
+ PRE_PROCESS_PROGRESS_SHARE * (i + 1) / framesCount;
268
+ while (
269
+ nextPreProcessMilestone <= PRE_PROCESS_PROGRESS_SHARE
270
+ && p >= nextPreProcessMilestone
271
+ ) {
272
+ onPreProcessProgress?.(nextPreProcessMilestone);
273
+ nextPreProcessMilestone += 1;
274
+ }
275
+
276
+ await waitForEventLoop();
277
+ }
278
+
279
+ return { spectrums, beatFrameIndices, beatEvents };
280
+ };
281
+
282
+ async function prepareBackgroundForRender(params: {
283
+ useVideoBackground: boolean;
284
+ backgroundVideoPaths: string[] | undefined;
285
+ backgroundImagePath: string | undefined;
286
+ beatFrameIndices: number[];
287
+ beatIntensities?: number[];
288
+ framesCount: number;
289
+ outputResolution: ReturnType<typeof getOutputResolution>;
290
+ fps: number;
291
+ autoEditVideo: boolean;
292
+ readVideoFrameTimeout: number;
293
+ tempo?: TempoEstimate | null;
294
+ /** First video to play for `video.path` arrays without `autoEdit`. */
295
+ startVideoIndex?: number;
296
+ /** Video played once before the background videos. */
297
+ hookVideoPath?: string;
298
+ /** Hook length in frames (the lead-in up to a detected drop); the full hook when unset. */
299
+ hookFrameCount?: number;
300
+ }): Promise<{
301
+ backgroundWidth: number;
302
+ backgroundHeight: number;
303
+ defaultColor: Color;
304
+ staticBackgroundBuffer: EncodedBmp;
305
+ videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
306
+ videoFrameSize: number;
307
+ encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
308
+ concatFilePath?: string;
309
+ cutFrameIndices: number[];
310
+ /** Hook video whose sound plays over the first `frameCount` output frames. */
311
+ hookAudio?: { path: string; frameCount: number };
312
+ }> {
313
+ const {
314
+ useVideoBackground,
315
+ backgroundVideoPaths,
316
+ backgroundImagePath,
317
+ beatFrameIndices,
318
+ beatIntensities,
319
+ framesCount,
320
+ outputResolution,
321
+ fps,
322
+ autoEditVideo,
323
+ readVideoFrameTimeout,
324
+ tempo,
325
+ startVideoIndex = 0,
326
+ hookVideoPath,
327
+ hookFrameCount,
328
+ } = params;
329
+
330
+ if (useVideoBackground && backgroundVideoPaths && backgroundVideoPaths.length > 0) {
331
+ const videoInfos = await Promise.all(backgroundVideoPaths.map(videoPath => getVideoInfo(videoPath)));
332
+ const primaryVideoInfo = videoInfos[0];
333
+ const backgroundWidth = outputResolution?.width ?? primaryVideoInfo.width;
334
+ const backgroundHeight = outputResolution?.height ?? primaryVideoInfo.height;
335
+
336
+ const videoFrameSize = backgroundWidth * backgroundHeight * 3;
337
+ const encodeVideoFrame = createBgrFrameEncoder({ width: backgroundWidth, height: backgroundHeight });
338
+
339
+ const hookVideoInfo = hookVideoPath ? await getVideoInfo(hookVideoPath) : undefined;
340
+ // Real frame count, so the hook never has to wrap back to its start.
341
+ const hookAvailableFrames = hookVideoPath ? await getVideoFrameCount(hookVideoPath, fps) : 0;
342
+ const hookFrames = !hookVideoInfo
343
+ ? 0
344
+ : hookFrameCount !== undefined
345
+ ? Math.min(hookFrameCount, hookAvailableFrames, framesCount)
346
+ : computeHookFrameCount(hookAvailableFrames / fps, fps, framesCount);
347
+ // The background videos fill the frames after the hook, on a timeline starting at the hook's end.
348
+ const mainFramesCount = framesCount - hookFrames;
349
+ const mainBeatFrameIndices: number[] = [];
350
+ const mainBeatIntensities: number[] | undefined = beatIntensities ? [] : undefined;
351
+ beatFrameIndices.forEach((frameIndex, i) => {
352
+ if (frameIndex >= hookFrames) {
353
+ mainBeatFrameIndices.push(frameIndex - hookFrames);
354
+ mainBeatIntensities?.push(beatIntensities![i]);
355
+ }
356
+ });
357
+ const mainTempo = tempo ? shiftTempoPhase(tempo, hookFrames) : tempo;
358
+
359
+ let segments: VideoSegment[];
360
+ if (mainFramesCount <= 0) {
361
+ segments = [];
362
+ } else if (backgroundVideoPaths.length > 1) {
363
+ segments = autoEditVideo
364
+ ? buildShuffledVideoSegments(
365
+ mainBeatFrameIndices,
366
+ mainFramesCount,
367
+ backgroundVideoPaths.length,
368
+ fps,
369
+ {
370
+ beatIntensities: mainBeatIntensities,
371
+ ...(mainTempo ? { tempo: mainTempo } : {}),
372
+ },
373
+ )
374
+ : buildSequentialVideoSegments(
375
+ mainFramesCount,
376
+ videoInfos.map(info => info.duration),
377
+ fps,
378
+ startVideoIndex % backgroundVideoPaths.length,
379
+ );
380
+ } else {
381
+ const sceneChanges = autoEditVideo
382
+ ? await detectSceneChanges(backgroundVideoPaths[0])
383
+ : [];
384
+ segments = buildBeatSyncedSegments(
385
+ mainBeatFrameIndices,
386
+ mainFramesCount,
387
+ sceneChanges,
388
+ primaryVideoInfo.duration,
389
+ fps,
390
+ {
391
+ beatIntensities: mainBeatIntensities,
392
+ ...(autoEditVideo && mainTempo ? { tempo: mainTempo } : {}),
393
+ },
394
+ );
395
+ }
396
+
397
+ const videoSources = videoInfos.map((info, i) => ({ path: backgroundVideoPaths[i], duration: info.duration }));
398
+ if (hookVideoPath && hookVideoInfo && hookFrames > 0) {
399
+ segments = [
400
+ { outputStartFrame: 0, videoSeekSeconds: 0, frameCount: hookFrames, videoIndex: videoSources.length },
401
+ ...segments.map(segment => ({
402
+ ...segment,
403
+ outputStartFrame: segment.outputStartFrame + hookFrames,
404
+ })),
405
+ ];
406
+ videoSources.push({ path: hookVideoPath, duration: hookAvailableFrames / fps });
407
+ }
408
+ const concatFilePath = writeConcatFile(segments, videoSources, fps);
409
+ const cutFrameIndices = autoEditVideo ? getCutFrameIndices(segments) : [];
410
+
411
+ const videoFrameReader = spawnConcatVideoFrameReader({
412
+ concatFilePath,
413
+ fps,
414
+ totalFrames: framesCount,
415
+ ...(outputResolution && {
416
+ width: backgroundWidth,
417
+ height: backgroundHeight,
418
+ sourceWidth: primaryVideoInfo.width,
419
+ sourceHeight: primaryVideoInfo.height,
420
+ }),
421
+ });
422
+
423
+ const firstFrame = await readVideoFrame(
424
+ videoFrameReader.stdout,
425
+ videoFrameSize,
426
+ readVideoFrameTimeout,
427
+ );
428
+ if (!firstFrame) {
429
+ throw new Error(`Could not read frames from video: ${videoSources.map(source => source.path).join(', ')}`);
430
+ }
431
+ const defaultColor = getVideoFrameColor(firstFrame, backgroundWidth, backgroundHeight);
432
+ const staticBackgroundBuffer = encodeVideoFrame(firstFrame);
433
+ return {
434
+ backgroundWidth,
435
+ backgroundHeight,
436
+ defaultColor,
437
+ staticBackgroundBuffer,
438
+ videoFrameReader,
439
+ videoFrameSize,
440
+ encodeVideoFrame,
441
+ concatFilePath,
442
+ cutFrameIndices,
443
+ ...(hookVideoPath && hookVideoInfo?.hasAudio && hookFrames > 0 && {
444
+ hookAudio: { path: hookVideoPath, frameCount: hookFrames },
445
+ }),
446
+ };
447
+ }
448
+
449
+ if (!backgroundImagePath) {
450
+ throw new Error('Background image path is required when not using video background.');
451
+ }
452
+ const backgroundImageBmpBuffer = await convertToBmp(
453
+ backgroundImagePath,
454
+ outputResolution?.width,
455
+ outputResolution?.height,
456
+ );
457
+ const backgroundImage = parseImage(backgroundImageBmpBuffer);
458
+ const backgroundWidth = backgroundImage.width;
459
+ const backgroundHeight = backgroundImage.height;
460
+ const defaultColor = getImageColor(backgroundImage);
461
+
462
+ const bpmEncoder = createBpmEncoder({ width: backgroundWidth, height: backgroundHeight });
463
+ const staticBackgroundBuffer = bpmEncoder(backgroundImage.data);
464
+ return {
465
+ backgroundWidth,
466
+ backgroundHeight,
467
+ defaultColor,
468
+ staticBackgroundBuffer,
469
+ videoFrameSize: 0,
470
+ cutFrameIndices: [],
471
+ };
472
+ }
473
+
474
+ async function resolveBackgroundFrameBuffer(params: {
475
+ frameIndex: number;
476
+ useVideoBackground: boolean;
477
+ videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
478
+ videoFrameSize: number;
479
+ encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
480
+ staticBackgroundBuffer: EncodedBmp;
481
+ readVideoFrameTimeout: number;
482
+ }): Promise<{ frameBuffer: EncodedBmp; frameReadFailed: boolean }> {
483
+ const {
484
+ frameIndex,
485
+ useVideoBackground,
486
+ videoFrameReader,
487
+ videoFrameSize,
488
+ encodeVideoFrame,
489
+ staticBackgroundBuffer,
490
+ readVideoFrameTimeout,
491
+ } = params;
492
+
493
+ if (useVideoBackground && videoFrameReader && encodeVideoFrame) {
494
+ if (frameIndex === 0) {
495
+ return { frameBuffer: staticBackgroundBuffer, frameReadFailed: false };
496
+ }
497
+ const videoFrame = await readVideoFrame(
498
+ videoFrameReader.stdout,
499
+ videoFrameSize,
500
+ readVideoFrameTimeout,
501
+ );
502
+ if (videoFrame) {
503
+ return { frameBuffer: encodeVideoFrame(videoFrame), frameReadFailed: false };
504
+ }
505
+ return { frameBuffer: staticBackgroundBuffer, frameReadFailed: false };
506
+ }
507
+ return { frameBuffer: staticBackgroundBuffer, frameReadFailed: false };
508
+ }
509
+
510
+ export interface RenderAudioVisualizerResult {
511
+ exitCode: number;
512
+ /** Human-readable failure reason; omitted on full success (exitCode 0). */
513
+ reason?: string;
514
+ /** Absolute paths of video files written successfully (exit code 0, full pass, no early stop). */
515
+ outputVideoFiles: string[];
516
+ }
517
+
518
+ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: number) => any, shouldStop?: () => boolean) =>
519
+ new Promise<RenderAudioVisualizerResult>(async (resolve) => {
520
+ if (config.outVideo.spectrum && config.outVideo.polar) {
521
+ throw new Error('Cannot use both "spectrum" and "polar" options. Please specify only one visualizer type.');
522
+ }
523
+ if (!config.image && !config.video) {
524
+ throw new Error('Either "image" or "video" must be specified as the background source.');
525
+ }
526
+ if (config.image && config.video) {
527
+ throw new Error('Cannot use both "image" and "video" options. Please specify only one background source.');
528
+ }
529
+
530
+ const audioFilePath = getAudioFilePath(config);
531
+ const outVideoPath = getOutVideoPath(config);
532
+ const subtitleSpec = getSubtitleRenderSpec(config);
533
+ let subtitleFilePath: string | undefined;
534
+ let subtitleFileIsTemporary = false;
535
+ let subtitleAlignmentAss = 2;
536
+ if (subtitleSpec) {
537
+ subtitleAlignmentAss = subtitleAlignmentToAss(subtitleSpec.alignment);
538
+ if (subtitleSpec.source.kind === 'file') {
539
+ const absPath = subtitleSpec.source.path;
540
+ const ext = path.extname(absPath).toLowerCase();
541
+ if (ext === '.lrc') {
542
+ const raw = readFileSync(absPath, 'utf-8');
543
+ subtitleFilePath = writeSubtitlesFile(lrcToSrt(raw));
544
+ subtitleFileIsTemporary = true;
545
+ } else {
546
+ subtitleFilePath = absPath;
547
+ }
548
+ } else {
549
+ subtitleFilePath = writeSubtitlesFile(
550
+ normalizeInlineSubtitlesToSrt(subtitleSpec.source.text),
551
+ );
552
+ subtitleFileIsTemporary = true;
553
+ }
554
+ }
555
+ const backgroundVideoPaths = getBackgroundVideoPaths(config);
556
+ const hookVideoPath = getHookVideoPath(config);
557
+ const backgroundImagePath = getBackgroundImagePath(config);
558
+ const useVideoBackground = !!backgroundVideoPaths && backgroundVideoPaths.length > 0;
559
+
560
+ const audioReader = await createAudioBuffer(audioFilePath, FFMPEG_FORMAT);
561
+ const audioBuffer = audioReader.audioBuffer;
562
+ const sampleRate = audioReader.sampleRate;
563
+ if (!sampleRate) {
564
+ throw new Error('ffmpeg didn\'t show audio sample rate');
565
+ }
566
+
567
+ const spectrumBusMargin = getSpectrumBusMargin();
568
+ const FPS = getFPS(config);
569
+ const ffmpeg_cfr = getFfmpeg_cfr(config);
570
+ const ffmpeg_preset = getFfmpeg_preset(config);
571
+ const frame_processing_delay = getFrame_processing_delay(config);
572
+ const videoTimeouts = getVideoTimeouts(config);
573
+
574
+ const audioDuration = audioBuffer.length / sampleRate;
575
+ const framesCount = Math.trunc(audioDuration * FPS);
576
+ const outputResolution = getOutputResolution(config);
577
+
578
+ let maxProgressReported = -1;
579
+ const reportProgress = (progress: number) => {
580
+ if (!onProgress) {
581
+ return;
582
+ }
583
+ const normalized = +Math.min(100, Math.max(0, progress)).toFixed(2);
584
+ if (normalized <= maxProgressReported) {
585
+ return;
586
+ }
587
+ maxProgressReported = normalized;
588
+ onProgress(normalized);
589
+ };
590
+ reportProgress(0);
591
+
592
+ const preprocessed = await preProcessAudio(
593
+ audioBuffer,
594
+ sampleRate,
595
+ FPS,
596
+ framesCount,
597
+ (progress: number) => {
598
+ reportProgress(progress);
599
+ },
600
+ );
601
+ reportProgress(PRE_PROCESS_PROGRESS_SHARE);
602
+
603
+ const autoHighlight = getAudioAutoHighlight(config);
604
+ const autoEditVideo = getAutoEditVideo(config);
605
+ // Beat grid for video cuts (autoEdit) and for snapping highlight boundaries to beats.
606
+ let trackTempo: TempoEstimate | null = null;
607
+ if (autoEditVideo || autoHighlight) {
608
+ const sampleRateNum = Number(sampleRate);
609
+ const maxSamples = Math.floor(sampleRateNum * MAX_TEMPO_ANALYSIS_SECONDS);
610
+ const tempoBuffer = audioBuffer.length > maxSamples
611
+ ? audioBuffer.slice(0, maxSamples)
612
+ : audioBuffer;
613
+ trackTempo = estimateTempo(pcmU8ToFloatSamples(tempoBuffer), sampleRateNum, FPS);
614
+ }
615
+ let spectrumsForRender = preprocessed.spectrums;
616
+ let beatIndicesForRender = preprocessed.beatFrameIndices;
617
+ let beatIntensitiesForRender = preprocessed.beatEvents.map(event => event.intensity);
618
+ let framesCountForRender = framesCount;
619
+ type HighlightSliceResult = ReturnType<typeof computeHighlightSlice> extends Promise<
620
+ infer R
621
+ >
622
+ ? R
623
+ : never;
624
+ let highlightSlice: HighlightSliceResult | undefined;
625
+
626
+ if (autoHighlight) {
627
+ reportProgress(PRE_PROCESS_PROGRESS_SHARE + 1);
628
+ // With a hook, highlights are built so the hook ends on a detected drop.
629
+ const hookLeadInFrames = hookVideoPath
630
+ ? await getVideoFrameCount(hookVideoPath, FPS)
631
+ : undefined;
632
+ highlightSlice = await computeHighlightSlice(
633
+ FPS,
634
+ framesCount,
635
+ preprocessed.spectrums,
636
+ preprocessed.beatEvents,
637
+ getAudioAutoHighlightCount(config),
638
+ trackTempo,
639
+ hookLeadInFrames,
640
+ );
641
+ spectrumsForRender = highlightSlice.spectrums;
642
+ beatIndicesForRender = highlightSlice.beatFrameIndices;
643
+ beatIntensitiesForRender = highlightSlice.beatIntensities;
644
+ framesCountForRender = highlightSlice.highlightFrames;
645
+ reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
646
+ } else {
647
+ reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
648
+ }
649
+
650
+ const cameraShakeEnabled = getCameraShakeEnabled(config);
651
+ const shakeDelayFrames = Math.round(FPS * CAMERA_SHAKE_DELAY_SECONDS);
652
+
653
+ const separateHighlightFiles =
654
+ autoHighlight &&
655
+ highlightSlice &&
656
+ highlightSlice.runs.length > 1;
657
+
658
+ type VideoRenderPass = {
659
+ outPath: string;
660
+ spectrums: number[][];
661
+ beatIndices: number[];
662
+ beatIntensities: number[];
663
+ frameCount: number;
664
+ startFrame: number;
665
+ audioSegment: import('./highlight').HighlightAudioSegment | undefined;
666
+ /** Frames until the detected drop, where the hook should end. */
667
+ leadInFrames?: number;
668
+ };
669
+
670
+ const passes: VideoRenderPass[] =
671
+ separateHighlightFiles && highlightSlice
672
+ ? highlightSlice.runs.map((run, i) => {
673
+ const dir = path.dirname(outVideoPath);
674
+ const ext = path.extname(outVideoPath);
675
+ const base = path.basename(outVideoPath, ext);
676
+ const numberedPath = path.resolve(dir, `${base}-${i + 1}${ext}`);
677
+ return {
678
+ outPath: numberedPath,
679
+ spectrums: run.spectrums,
680
+ beatIndices: run.beatFrameIndices,
681
+ beatIntensities: run.beatIntensities,
682
+ frameCount: run.highlightFrames,
683
+ startFrame: run.startFrame,
684
+ audioSegment: run.audioSegment,
685
+ leadInFrames: run.leadInFrames,
686
+ };
687
+ })
688
+ : [
689
+ {
690
+ outPath: outVideoPath,
691
+ spectrums: spectrumsForRender,
692
+ beatIndices: beatIndicesForRender,
693
+ beatIntensities: beatIntensitiesForRender,
694
+ frameCount: framesCountForRender,
695
+ startFrame: highlightSlice?.startFrame ?? 0,
696
+ audioSegment:
697
+ autoHighlight &&
698
+ highlightSlice &&
699
+ highlightSlice.audioSegments.length > 0
700
+ ? highlightSlice.audioSegments[0]
701
+ : undefined,
702
+ leadInFrames: highlightSlice?.runs.length === 1 ? highlightSlice.runs[0].leadInFrames : undefined,
703
+ },
704
+ ];
705
+
706
+ const totalPassFrames =
707
+ passes.reduce((sum, pass) => sum + pass.frameCount, 0);
708
+ const totalRenderMilestones = totalPassFrames + (passes.length * 2);
709
+ let renderMilestonesCompleted = 0;
710
+ const reportRenderProgress = (milestoneIncrement = 1) => {
711
+ renderMilestonesCompleted += milestoneIncrement;
712
+ reportProgress(
713
+ PRE_PROCESS_PROGRESS_SHARE
714
+ + POST_AUDIO_PROGRESS_SHARE
715
+ + (RENDER_PROGRESS_SHARE * renderMilestonesCompleted / Math.max(1, totalRenderMilestones)),
716
+ );
717
+ };
718
+ let lastExitCode = 0;
719
+ let exitReason: string | undefined;
720
+ const outputVideoFiles: string[] = [];
721
+ try {
722
+ passLoop: for (let passIndex = 0; passIndex < passes.length; passIndex++) {
723
+ const pass = passes[passIndex];
724
+ const windowTempo = trackTempo
725
+ ? tempoForWindow(trackTempo, pass.startFrame, FPS, pass.frameCount, pass.beatIndices)
726
+ : null;
727
+ const tempo = autoEditVideo ? windowTempo : null;
728
+ const shakeAmplitudes = tempo
729
+ ? buildCutShakeAmplitudes(tempo.periodFrames)
730
+ : undefined;
731
+ const {
732
+ backgroundWidth,
733
+ backgroundHeight,
734
+ defaultColor,
735
+ staticBackgroundBuffer,
736
+ videoFrameReader,
737
+ videoFrameSize,
738
+ encodeVideoFrame,
739
+ concatFilePath,
740
+ cutFrameIndices,
741
+ hookAudio,
742
+ } = await prepareBackgroundForRender({
743
+ useVideoBackground,
744
+ backgroundVideoPaths,
745
+ backgroundImagePath,
746
+ beatFrameIndices: pass.beatIndices,
747
+ beatIntensities: pass.beatIntensities,
748
+ framesCount: pass.frameCount,
749
+ outputResolution,
750
+ fps: FPS,
751
+ autoEditVideo,
752
+ readVideoFrameTimeout: videoTimeouts.readVideoFrame,
753
+ ...(tempo ? { tempo } : {}),
754
+ // Each separate highlight output opens with a different video from the array.
755
+ startVideoIndex: passIndex,
756
+ hookVideoPath,
757
+ ...(pass.leadInFrames !== undefined ? { hookFrameCount: pass.leadInFrames } : {}),
758
+ });
759
+ reportRenderProgress();
760
+
761
+ const beatShakeFrames = () => (tempo
762
+ ? beatGridFrameIndices(tempo, pass.frameCount, CAMERA_SHAKE_EVERY_BEATS)
763
+ : pass.beatIndices.filter(frameIndex => frameIndex > 0).filter((_, i) => i % CAMERA_SHAKE_EVERY_BEATS === 0));
764
+ const shakeFrames = new Set(
765
+ autoEditVideo && cameraShakeEnabled
766
+ ? (cutFrameIndices.length > 0 ? cutFrameIndices : beatShakeFrames())
767
+ : [],
768
+ );
769
+ const createVisualizerFrame = createVisualizerFrameGenerator(
770
+ config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
771
+ );
772
+
773
+ const ffmpegVideoWriter = spawnFfmpegVideoWriter({
774
+ audioFilename: audioFilePath,
775
+ videoFileName: pass.outPath,
776
+ ...(subtitleFilePath && {
777
+ subtitleFilename: subtitleFilePath,
778
+ subtitleAlignmentAss,
779
+ }),
780
+ fps: FPS,
781
+ ...(pass.audioSegment && { audioSegment: pass.audioSegment }),
782
+ ...(hookAudio && {
783
+ overlayAudio: { filename: hookAudio.path, durationSeconds: hookAudio.frameCount / FPS },
784
+ }),
785
+ ...(ffmpeg_cfr && { crf: ffmpeg_cfr }),
786
+ ...(ffmpeg_preset && { preset: ffmpeg_preset }),
787
+ });
788
+ const exitPromise = waitForProcessExit(
789
+ ffmpegVideoWriter,
790
+ videoTimeouts.waitForProcessExit,
791
+ );
792
+
793
+ let stoppedEarly = false;
794
+ for (let i = 0; i < pass.frameCount; i++) {
795
+ const spectrum = pass.spectrums[i];
796
+ const { frameBuffer: backgroundImageBuffer, frameReadFailed } = await resolveBackgroundFrameBuffer({
797
+ frameIndex: i,
798
+ useVideoBackground,
799
+ videoFrameReader,
800
+ videoFrameSize,
801
+ encodeVideoFrame,
802
+ staticBackgroundBuffer,
803
+ readVideoFrameTimeout: videoTimeouts.readVideoFrame,
804
+ });
805
+
806
+ const commonVisualizerFrameProps: CommonVisualizerFrameProps = {
807
+ backgroundImageBuffer,
808
+ spectrum,
809
+ };
810
+ const frameImage = createVisualizerFrame(commonVisualizerFrameProps);
811
+ const shakeOffset = getCutShakeOffset(i, shakeFrames, shakeAmplitudes, shakeDelayFrames);
812
+ if (shakeOffset.x !== 0 || shakeOffset.y !== 0) {
813
+ applyCameraShake(frameImage, backgroundWidth, backgroundHeight, shakeOffset.x, shakeOffset.y);
814
+ }
815
+ const isFrameProcessed = ffmpegVideoWriter.stdin.write(frameImage.data);
816
+ if (!isFrameProcessed) {
817
+ const isDrained = await waitDrain(
818
+ ffmpegVideoWriter.stdin,
819
+ ffmpegVideoWriter,
820
+ videoTimeouts.waitDrain,
821
+ );
822
+ if (!isDrained) {
823
+ stoppedEarly = true;
824
+ exitReason = 'ffmpeg stdin drain failed';
825
+ break;
826
+ }
827
+ }
828
+ if (shouldStop && shouldStop()) {
829
+ stoppedEarly = true;
830
+ exitReason = 'render stopped by shouldStop callback';
831
+ break;
832
+ }
833
+ if (frame_processing_delay) {
834
+ await sleep(frame_processing_delay);
835
+ }
836
+ await waitForEventLoop();
837
+ reportRenderProgress();
838
+ }
839
+
840
+ if (videoFrameReader) {
841
+ videoFrameReader.kill();
842
+ }
843
+ if (concatFilePath) {
844
+ cleanupConcatFile(concatFilePath);
845
+ }
846
+ ffmpegVideoWriter.stdin.end();
847
+
848
+ const { exitCode, reason: ffmpegExitReason } = await exitPromise;
849
+ lastExitCode = exitCode;
850
+ if (!exitReason && ffmpegExitReason) {
851
+ exitReason = ffmpegExitReason;
852
+ }
853
+ if (lastExitCode === 0 && !stoppedEarly) {
854
+ reportRenderProgress();
855
+ }
856
+
857
+ if (lastExitCode === 0 && !stoppedEarly) {
858
+ outputVideoFiles.push(pass.outPath);
859
+ }
860
+
861
+ if (stoppedEarly || lastExitCode !== 0) {
862
+ if (exitReason && lastExitCode === 0) {
863
+ lastExitCode = 1;
864
+ }
865
+ break passLoop;
866
+ }
867
+ }
868
+
869
+ if (lastExitCode === 0 && outputVideoFiles.length === passes.length) {
870
+ reportProgress(100);
871
+ }
872
+ if (exitReason) {
873
+ console.error(exitReason);
874
+ }
875
+
876
+ resolve({ exitCode: lastExitCode, reason: exitReason, outputVideoFiles });
877
+ } finally {
878
+ if (subtitleFilePath && subtitleFileIsTemporary) {
879
+ cleanupTempFile(subtitleFilePath);
880
+ }
881
+ }
882
+ });