nodejs-audio-visualizer 4.3.7 → 4.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +75 -75
  3. package/dist/audio.d.ts +12 -12
  4. package/dist/audio.js +120 -120
  5. package/dist/beats.d.ts +19 -19
  6. package/dist/beats.js +43 -43
  7. package/dist/bpmEncoder.d.ts +14 -14
  8. package/dist/bpmEncoder.js +97 -97
  9. package/dist/config.d.ts +67 -67
  10. package/dist/config.js +356 -356
  11. package/dist/dsp.d.ts +16 -16
  12. package/dist/dsp.js +76 -76
  13. package/dist/highlight.d.ts +32 -32
  14. package/dist/highlight.js +325 -325
  15. package/dist/image.d.ts +79 -79
  16. package/dist/image.js +458 -458
  17. package/dist/index.d.ts +72 -72
  18. package/dist/index.js +539 -541
  19. package/dist/index.js.map +1 -1
  20. package/dist/subtitleConvert.d.ts +3 -3
  21. package/dist/subtitleConvert.js +61 -61
  22. package/dist/video.d.ts +77 -72
  23. package/dist/video.js +407 -338
  24. package/dist/video.js.map +1 -1
  25. package/dist/waitForEventLoop.d.ts +1 -1
  26. package/dist/waitForEventLoop.js +5 -5
  27. package/package.json +61 -61
  28. package/src/audio.ts +120 -120
  29. package/src/beats.ts +68 -68
  30. package/src/bpmEncoder.ts +97 -97
  31. package/src/config.ts +462 -462
  32. package/src/dsp.ts +95 -95
  33. package/src/highlight.ts +284 -284
  34. package/src/image.ts +602 -602
  35. package/src/index.ts +693 -678
  36. package/src/subtitleConvert.ts +63 -63
  37. package/src/tests/audio.test.ts +96 -96
  38. package/src/tests/config.test.ts +475 -475
  39. package/src/tests/dsp.test.ts +24 -24
  40. package/src/tests/highlight.test.ts +178 -178
  41. package/src/tests/image.test.ts +222 -222
  42. package/src/tests/index.test.ts +156 -63
  43. package/src/tests/subtitleConvert.test.ts +23 -23
  44. package/src/tests/video.test.ts +214 -138
  45. package/src/typings.d.ts +6 -6
  46. package/src/video.ts +517 -437
  47. package/src/waitForEventLoop.ts +2 -2
package/src/index.ts CHANGED
@@ -1,678 +1,693 @@
1
- import path from 'path';
2
- import { readFileSync } from 'fs';
3
- import {
4
- getAudioFilePath,
5
- getBackgroundImagePath,
6
- getBackgroundVideoPath,
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
- getOutputResolution,
22
- rotationAliasValues,
23
- getSpectrumRotation,
24
- getSpectrumEffect,
25
- SpectrumEffect,
26
- getPolarXAbsolute,
27
- getPolarYAbsolute,
28
- getPolarInnerRadius,
29
- getPolarMaxBarLength,
30
- getPolarBarWidth,
31
- getPolarEffect,
32
- getPolarColor,
33
- getPolarOpacityParsed,
34
- getAutoEditVideo,
35
- getAudioAutoHighlight,
36
- getAudioAutoHighlightCount,
37
- } from './config';
38
- import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor } from './audio';
39
- import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
40
- import { normalizeInlineSubtitlesToSrt, lrcToSrt } from './subtitleConvert';
41
- import { spawnFfmpegVideoWriter, waitDrain, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile } from './video';
42
- import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
43
- import { createBeatDetector } from './beats';
44
- export { BeatInfo, BeatDetectorOptions } from './beats';
45
- import { computeHighlightSlice, HIGHLIGHT_DURATION_SEC } from './highlight';
46
- import { waitForEventLoop } from './waitForEventLoop';
47
- export { computeHighlightSlice, HIGHLIGHT_DURATION_SEC, BeatFrameEvent, HighlightAudioSegment, HighlightRun } from './highlight';
48
-
49
- export const PCM_FORMAT = {
50
- bit: 8,
51
- sign: 'u',
52
- parseFunction: bufferToUInt8
53
- };
54
- const FFMPEG_FORMAT = `${PCM_FORMAT.sign}${PCM_FORMAT.bit}`;
55
- const PROCESSING_BUFFER_SIZE = Math.pow(2, 12);
56
-
57
- export interface Config {
58
- audio: {
59
- path: string;
60
- autoHighlight?: boolean;
61
- /** When `autoHighlight` is true, number of non-overlapping 15s windows to stitch (default 1). */
62
- autoHighlightCount?: number;
63
- };
64
- image?: {
65
- path: string;
66
- };
67
- video?: {
68
- path: string;
69
- autoEdit?: boolean;
70
- };
71
- outVideo: {
72
- path: string;
73
- subtitles?:
74
- | string
75
- | {
76
- path?: string;
77
- rawContent?: string;
78
- alignment?: 'top' | 'middle' | 'bottom';
79
- };
80
- fps?: number;
81
- resolution?: {
82
- width: number;
83
- height: number;
84
- };
85
- spectrum?: {
86
- width?: SpectrumSizeValue;
87
- height?: SpectrumSizeValue;
88
- x?: number | PositionAliasName;
89
- y?: number | PositionAliasName;
90
- rotation?: RotationAliasName;
91
- effect?: SpectrumEffect;
92
- color?: Color | string;
93
- opacity?: string;
94
- };
95
- polar?: {
96
- x?: number | PositionAliasName;
97
- y?: number | PositionAliasName;
98
- innerRadius?: number;
99
- maxBarLength?: number;
100
- barWidth?: number;
101
- effect?: SpectrumEffect;
102
- color?: Color | string;
103
- opacity?: string;
104
- }
105
- };
106
- tweaks?: {
107
- ffmpeg_cfr?: string;
108
- ffmpeg_preset?: string;
109
- frame_processing_delay?: number;
110
- };
111
- }
112
-
113
- export type SpectrumSizeValue = number | string;
114
-
115
- export type PositionAliasName =
116
- 'left' |
117
- 'center' |
118
- 'right' |
119
- 'top' |
120
- 'middle' |
121
- 'bottom';
122
-
123
- export type RotationAliasName = typeof rotationAliasValues[number];
124
-
125
- const sleep = (timeout: number) =>
126
- new Promise(resolve => setTimeout(resolve, timeout));
127
-
128
- const createVisualizerFrameGenerator = (
129
- config: Config,
130
- backgroundWidth: number,
131
- backgroundHeight: number,
132
- defaultColor: Color,
133
- spectrumBusMargin: number
134
- ): (params: CommonVisualizerFrameProps) => EncodedBmp => {
135
- if (!config.outVideo.spectrum) {
136
- const createPolarVisualizerFrame = createPolarVisualizerFrameGenerator();
137
- const polarX = getPolarXAbsolute(config, backgroundWidth);
138
- const polarY = getPolarYAbsolute(config, backgroundHeight);
139
- const polarInnerRadius = getPolarInnerRadius(config);
140
- const polarMaxBarLength = getPolarMaxBarLength(config);
141
- const polarBarWidth = getPolarBarWidth(config);
142
- const polarColor = getPolarColor(config) || invertColor(defaultColor);
143
- const polarEffect = getPolarEffect(config);
144
- const polarOpacity = getPolarOpacityParsed(config);
145
-
146
- return (params: CommonVisualizerFrameProps) => {
147
- return createPolarVisualizerFrame({
148
- ...params,
149
- centerX: polarX,
150
- centerY: polarY,
151
- innerRadius: polarInnerRadius,
152
- maxBarLength: polarMaxBarLength,
153
- barWidth: polarBarWidth,
154
- color: polarColor,
155
- opacity: polarOpacity,
156
- spectrumEffect: polarEffect,
157
- });
158
- };
159
- }
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
- interface PreProcessedAudio {
185
- spectrums: number[][];
186
- beatFrameIndices: number[];
187
- beatEvents: { frameIndex: number; intensity: number }[];
188
- }
189
-
190
- const PRE_PROCESS_PROGRESS_SHARE = 60;
191
- const POST_AUDIO_PROGRESS_SHARE = 10;
192
- const RENDER_PROGRESS_SHARE = 30;
193
-
194
- const preProcessAudio = async (
195
- audioBuffer: Buffer,
196
- sampleRate: number,
197
- fps: number,
198
- framesCount: number,
199
- onPreProcessProgress?: (totalPercent: number) => void,
200
- ): Promise<PreProcessedAudio> => {
201
- if (framesCount === 0) {
202
- for (let t = 1; t <= PRE_PROCESS_PROGRESS_SHARE; t += 1) {
203
- onPreProcessProgress?.(t);
204
- }
205
- return { spectrums: [], beatFrameIndices: [], beatEvents: [] };
206
- }
207
- const audioDataStep = Math.trunc(audioBuffer.length / framesCount);
208
- const processingBuffer = new Float32Array(PROCESSING_BUFFER_SIZE).fill(0);
209
- const skipFramesCount = fps < 45 ? 1 : 2;
210
- const processSpectrum = createSpectrumsProcessor(sampleRate, skipFramesCount);
211
- const detectBeat = createBeatDetector(fps);
212
-
213
- const spectrums: number[][] = [];
214
- const beatFrameIndices: number[] = [];
215
- const beatEvents: { frameIndex: number; intensity: number }[] = [];
216
- let nextPreProcessMilestone = 1;
217
-
218
- for (let i = 0; i < framesCount; i++) {
219
- const currentFrameData = PCM_FORMAT.parseFunction(audioBuffer, i * audioDataStep, i * audioDataStep + audioDataStep);
220
- processingBuffer.copyWithin(0, currentFrameData.length);
221
- processingBuffer.set(currentFrameData, PROCESSING_BUFFER_SIZE - currentFrameData.length);
222
-
223
- const audioDataParser = () => Array.from(processingBuffer);
224
- const spectrum = processSpectrum(audioDataParser);
225
- const beat = detectBeat(spectrum);
226
-
227
- spectrums.push(spectrum);
228
- if (beat.isBeat) {
229
- beatFrameIndices.push(i);
230
- beatEvents.push({ frameIndex: i, intensity: beat.intensity });
231
- }
232
-
233
- const p =
234
- PRE_PROCESS_PROGRESS_SHARE * (i + 1) / framesCount;
235
- while (
236
- nextPreProcessMilestone <= PRE_PROCESS_PROGRESS_SHARE
237
- && p >= nextPreProcessMilestone
238
- ) {
239
- onPreProcessProgress?.(nextPreProcessMilestone);
240
- nextPreProcessMilestone += 1;
241
- }
242
-
243
- await waitForEventLoop();
244
- }
245
-
246
- return { spectrums, beatFrameIndices, beatEvents };
247
- };
248
-
249
- async function prepareBackgroundForRender(params: {
250
- useVideoBackground: boolean;
251
- backgroundVideoPath: string | undefined;
252
- backgroundImagePath: string | undefined;
253
- beatFrameIndices: number[];
254
- framesCount: number;
255
- outputResolution: ReturnType<typeof getOutputResolution>;
256
- fps: number;
257
- autoEditVideo: boolean;
258
- }): Promise<{
259
- backgroundWidth: number;
260
- backgroundHeight: number;
261
- defaultColor: Color;
262
- staticBackgroundBuffer: EncodedBmp;
263
- videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
264
- videoFrameSize: number;
265
- encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
266
- concatFilePath?: string;
267
- }> {
268
- const {
269
- useVideoBackground,
270
- backgroundVideoPath,
271
- backgroundImagePath,
272
- beatFrameIndices,
273
- framesCount,
274
- outputResolution,
275
- fps,
276
- autoEditVideo,
277
- } = params;
278
-
279
- if (useVideoBackground && backgroundVideoPath) {
280
- const videoInfo = await getVideoInfo(backgroundVideoPath);
281
- const sceneChanges = autoEditVideo
282
- ? await detectSceneChanges(backgroundVideoPath)
283
- : [];
284
- const backgroundWidth = outputResolution?.width ?? videoInfo.width;
285
- const backgroundHeight = outputResolution?.height ?? videoInfo.height;
286
-
287
- const videoFrameSize = backgroundWidth * backgroundHeight * 3;
288
- const encodeVideoFrame = createBgrFrameEncoder({ width: backgroundWidth, height: backgroundHeight });
289
-
290
- const segments = buildBeatSyncedSegments(
291
- beatFrameIndices,
292
- framesCount,
293
- sceneChanges,
294
- videoInfo.duration,
295
- );
296
-
297
- const concatFilePath = writeConcatFile(segments, backgroundVideoPath, videoInfo.duration, fps);
298
-
299
- const videoFrameReader = spawnConcatVideoFrameReader({
300
- concatFilePath,
301
- fps,
302
- totalFrames: framesCount,
303
- ...(outputResolution && {
304
- width: backgroundWidth,
305
- height: backgroundHeight,
306
- sourceWidth: videoInfo.width,
307
- sourceHeight: videoInfo.height,
308
- }),
309
- });
310
-
311
- const firstFrame = await readVideoFrame(videoFrameReader.stdout, videoFrameSize);
312
- if (!firstFrame) {
313
- throw new Error(`Could not read frames from video: ${backgroundVideoPath}`);
314
- }
315
- const defaultColor = getVideoFrameColor(firstFrame, backgroundWidth, backgroundHeight);
316
- const staticBackgroundBuffer = encodeVideoFrame(firstFrame);
317
- return {
318
- backgroundWidth,
319
- backgroundHeight,
320
- defaultColor,
321
- staticBackgroundBuffer,
322
- videoFrameReader,
323
- videoFrameSize,
324
- encodeVideoFrame,
325
- concatFilePath,
326
- };
327
- }
328
-
329
- if (!backgroundImagePath) {
330
- throw new Error('Background image path is required when not using video background.');
331
- }
332
- const backgroundImageBmpBuffer = await convertToBmp(
333
- backgroundImagePath,
334
- outputResolution?.width,
335
- outputResolution?.height,
336
- );
337
- const backgroundImage = parseImage(backgroundImageBmpBuffer);
338
- const backgroundWidth = backgroundImage.width;
339
- const backgroundHeight = backgroundImage.height;
340
- const defaultColor = getImageColor(backgroundImage);
341
-
342
- const bpmEncoder = createBpmEncoder({ width: backgroundWidth, height: backgroundHeight });
343
- const staticBackgroundBuffer = bpmEncoder(backgroundImage.data);
344
- return {
345
- backgroundWidth,
346
- backgroundHeight,
347
- defaultColor,
348
- staticBackgroundBuffer,
349
- videoFrameSize: 0,
350
- };
351
- }
352
-
353
- async function resolveBackgroundFrameBuffer(params: {
354
- frameIndex: number;
355
- useVideoBackground: boolean;
356
- videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
357
- videoFrameSize: number;
358
- encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
359
- staticBackgroundBuffer: EncodedBmp;
360
- }): Promise<EncodedBmp> {
361
- const {
362
- frameIndex,
363
- useVideoBackground,
364
- videoFrameReader,
365
- videoFrameSize,
366
- encodeVideoFrame,
367
- staticBackgroundBuffer,
368
- } = params;
369
-
370
- if (useVideoBackground && videoFrameReader && encodeVideoFrame) {
371
- if (frameIndex === 0) {
372
- return staticBackgroundBuffer;
373
- }
374
- const videoFrame = await readVideoFrame(videoFrameReader.stdout, videoFrameSize);
375
- return videoFrame
376
- ? encodeVideoFrame(videoFrame)
377
- : staticBackgroundBuffer;
378
- }
379
- return staticBackgroundBuffer;
380
- }
381
-
382
- export interface RenderAudioVisualizerResult {
383
- exitCode: number;
384
- /** Absolute paths of video files written successfully (exit code 0, full pass, no early stop). */
385
- outputVideoFiles: string[];
386
- }
387
-
388
- export const renderAudioVisualizer = (config: Config, onProgress?: (progress: number) => any, shouldStop?: () => boolean) =>
389
- new Promise<RenderAudioVisualizerResult>(async (resolve, reject) => {
390
- try {
391
- if (config.outVideo.spectrum && config.outVideo.polar) {
392
- throw new Error('Cannot use both "spectrum" and "polar" options. Please specify only one visualizer type.');
393
- }
394
- if (!config.image && !config.video) {
395
- throw new Error('Either "image" or "video" must be specified as the background source.');
396
- }
397
- if (config.image && config.video) {
398
- throw new Error('Cannot use both "image" and "video" options. Please specify only one background source.');
399
- }
400
-
401
- const audioFilePath = getAudioFilePath(config);
402
- const outVideoPath = getOutVideoPath(config);
403
- const subtitleSpec = getSubtitleRenderSpec(config);
404
- let subtitleFilePath: string | undefined;
405
- let subtitleFileIsTemporary = false;
406
- let subtitleAlignmentAss = 2;
407
- if (subtitleSpec) {
408
- subtitleAlignmentAss = subtitleAlignmentToAss(subtitleSpec.alignment);
409
- if (subtitleSpec.source.kind === 'file') {
410
- const absPath = subtitleSpec.source.path;
411
- const ext = path.extname(absPath).toLowerCase();
412
- if (ext === '.lrc') {
413
- const raw = readFileSync(absPath, 'utf-8');
414
- subtitleFilePath = writeSubtitlesFile(lrcToSrt(raw));
415
- subtitleFileIsTemporary = true;
416
- } else {
417
- subtitleFilePath = absPath;
418
- }
419
- } else {
420
- subtitleFilePath = writeSubtitlesFile(
421
- normalizeInlineSubtitlesToSrt(subtitleSpec.source.text),
422
- );
423
- subtitleFileIsTemporary = true;
424
- }
425
- }
426
- const backgroundVideoPath = getBackgroundVideoPath(config);
427
- const backgroundImagePath = getBackgroundImagePath(config);
428
- const useVideoBackground = !!backgroundVideoPath;
429
-
430
- const audioReader = await createAudioBuffer(audioFilePath, FFMPEG_FORMAT);
431
- const audioBuffer = audioReader.audioBuffer;
432
- const sampleRate = audioReader.sampleRate;
433
- if (!sampleRate) {
434
- throw new Error('ffmpeg didn\'t show audio sample rate');
435
- }
436
-
437
- const spectrumBusMargin = getSpectrumBusMargin();
438
- const FPS = getFPS(config);
439
- const ffmpeg_cfr = getFfmpeg_cfr(config);
440
- const ffmpeg_preset = getFfmpeg_preset(config);
441
- const frame_processing_delay = getFrame_processing_delay(config);
442
-
443
- const audioDuration = audioBuffer.length / sampleRate;
444
- const framesCount = Math.trunc(audioDuration * FPS);
445
- const outputResolution = getOutputResolution(config);
446
-
447
- let maxProgressReported = -1;
448
- const reportProgress = (progress: number) => {
449
- if (!onProgress) {
450
- return;
451
- }
452
- const normalized = +Math.min(100, Math.max(0, progress)).toFixed(2);
453
- if (normalized <= maxProgressReported) {
454
- return;
455
- }
456
- maxProgressReported = normalized;
457
- onProgress(normalized);
458
- };
459
- reportProgress(0);
460
-
461
- const preprocessed = await preProcessAudio(
462
- audioBuffer,
463
- sampleRate,
464
- FPS,
465
- framesCount,
466
- (progress: number) => {
467
- reportProgress(progress);
468
- },
469
- );
470
- reportProgress(PRE_PROCESS_PROGRESS_SHARE);
471
-
472
- const autoHighlight = getAudioAutoHighlight(config);
473
- let spectrumsForRender = preprocessed.spectrums;
474
- let beatIndicesForRender = preprocessed.beatFrameIndices;
475
- let framesCountForRender = framesCount;
476
- type HighlightSliceResult = ReturnType<typeof computeHighlightSlice> extends Promise<
477
- infer R
478
- >
479
- ? R
480
- : never;
481
- let highlightSlice: HighlightSliceResult | undefined;
482
-
483
- if (autoHighlight) {
484
- reportProgress(PRE_PROCESS_PROGRESS_SHARE + 1);
485
- highlightSlice = await computeHighlightSlice(
486
- FPS,
487
- framesCount,
488
- preprocessed.spectrums,
489
- preprocessed.beatEvents,
490
- getAudioAutoHighlightCount(config),
491
- );
492
- spectrumsForRender = highlightSlice.spectrums;
493
- beatIndicesForRender = highlightSlice.beatFrameIndices;
494
- framesCountForRender = highlightSlice.highlightFrames;
495
- reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
496
- } else {
497
- reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
498
- }
499
-
500
- const separateHighlightFiles =
501
- autoHighlight &&
502
- highlightSlice &&
503
- highlightSlice.runs.length > 1;
504
-
505
- type VideoRenderPass = {
506
- outPath: string;
507
- spectrums: number[][];
508
- beatIndices: number[];
509
- frameCount: number;
510
- audioSegment: import('./highlight').HighlightAudioSegment | undefined;
511
- };
512
-
513
- const passes: VideoRenderPass[] =
514
- separateHighlightFiles && highlightSlice
515
- ? highlightSlice.runs.map((run, i) => {
516
- const dir = path.dirname(outVideoPath);
517
- const ext = path.extname(outVideoPath);
518
- const base = path.basename(outVideoPath, ext);
519
- const numberedPath = path.resolve(dir, `${base}-${i + 1}${ext}`);
520
- return {
521
- outPath: numberedPath,
522
- spectrums: run.spectrums,
523
- beatIndices: run.beatFrameIndices,
524
- frameCount: run.highlightFrames,
525
- audioSegment: run.audioSegment,
526
- };
527
- })
528
- : [
529
- {
530
- outPath: outVideoPath,
531
- spectrums: spectrumsForRender,
532
- beatIndices: beatIndicesForRender,
533
- frameCount: framesCountForRender,
534
- audioSegment:
535
- autoHighlight &&
536
- highlightSlice &&
537
- highlightSlice.audioSegments.length > 0
538
- ? highlightSlice.audioSegments[0]
539
- : undefined,
540
- },
541
- ];
542
-
543
- const totalPassFrames =
544
- passes.reduce((sum, pass) => sum + pass.frameCount, 0);
545
- const totalRenderMilestones = totalPassFrames + (passes.length * 2);
546
- let renderMilestonesCompleted = 0;
547
- const reportRenderProgress = (milestoneIncrement = 1) => {
548
- renderMilestonesCompleted += milestoneIncrement;
549
- reportProgress(
550
- PRE_PROCESS_PROGRESS_SHARE
551
- + POST_AUDIO_PROGRESS_SHARE
552
- + (RENDER_PROGRESS_SHARE * renderMilestonesCompleted / Math.max(1, totalRenderMilestones)),
553
- );
554
- };
555
- let lastExitCode = 0;
556
- const outputVideoFiles: string[] = [];
557
- try {
558
- passLoop: for (const pass of passes) {
559
- const {
560
- backgroundWidth,
561
- backgroundHeight,
562
- defaultColor,
563
- staticBackgroundBuffer,
564
- videoFrameReader,
565
- videoFrameSize,
566
- encodeVideoFrame,
567
- concatFilePath,
568
- } = await prepareBackgroundForRender({
569
- useVideoBackground,
570
- backgroundVideoPath,
571
- backgroundImagePath,
572
- beatFrameIndices: pass.beatIndices,
573
- framesCount: pass.frameCount,
574
- outputResolution,
575
- fps: FPS,
576
- autoEditVideo: getAutoEditVideo(config),
577
- });
578
- reportRenderProgress();
579
-
580
- const createVisualizerFrame = createVisualizerFrameGenerator(
581
- config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
582
- );
583
-
584
- const ffmpegVideoWriter = spawnFfmpegVideoWriter({
585
- audioFilename: audioFilePath,
586
- videoFileName: pass.outPath,
587
- ...(subtitleFilePath && {
588
- subtitleFilename: subtitleFilePath,
589
- subtitleAlignmentAss,
590
- }),
591
- fps: FPS,
592
- ...(pass.audioSegment && { audioSegment: pass.audioSegment }),
593
- ...(ffmpeg_cfr && { crf: ffmpeg_cfr }),
594
- ...(ffmpeg_preset && { preset: ffmpeg_preset }),
595
- });
596
-
597
- const exitPromise = new Promise<number>(res => {
598
- let settled = false;
599
- ffmpegVideoWriter.on('exit', (code: number | null) => {
600
- if (settled) {
601
- return;
602
- }
603
- settled = true;
604
- res(code ?? 0);
605
- });
606
- });
607
-
608
- let stoppedEarly = false;
609
- for (let i = 0; i < pass.frameCount; i++) {
610
- const spectrum = pass.spectrums[i];
611
-
612
- const backgroundImageBuffer = await resolveBackgroundFrameBuffer({
613
- frameIndex: i,
614
- useVideoBackground,
615
- videoFrameReader,
616
- videoFrameSize,
617
- encodeVideoFrame,
618
- staticBackgroundBuffer,
619
- });
620
-
621
- const commonVisualizerFrameProps: CommonVisualizerFrameProps = {
622
- backgroundImageBuffer,
623
- spectrum,
624
- };
625
- const frameImage = createVisualizerFrame(commonVisualizerFrameProps);
626
- const isFrameProcessed = ffmpegVideoWriter.stdin.write(frameImage.data);
627
- if (!isFrameProcessed) {
628
- const isDrained = await waitDrain(ffmpegVideoWriter.stdin, ffmpegVideoWriter);
629
- if (!isDrained) {
630
- stoppedEarly = true;
631
- break;
632
- }
633
- }
634
- if (shouldStop && shouldStop()) {
635
- stoppedEarly = true;
636
- break;
637
- }
638
- if (frame_processing_delay) {
639
- await sleep(frame_processing_delay);
640
- }
641
- await waitForEventLoop();
642
- reportRenderProgress();
643
- }
644
-
645
- if (videoFrameReader) {
646
- videoFrameReader.kill();
647
- }
648
- if (concatFilePath) {
649
- cleanupConcatFile(concatFilePath);
650
- }
651
- ffmpegVideoWriter.stdin.end();
652
-
653
- lastExitCode = await exitPromise;
654
- reportRenderProgress();
655
-
656
- if (lastExitCode === 0 && !stoppedEarly) {
657
- outputVideoFiles.push(pass.outPath);
658
- }
659
-
660
- if (stoppedEarly || lastExitCode !== 0) {
661
- break passLoop;
662
- }
663
- }
664
-
665
- if (lastExitCode === 0 && outputVideoFiles.length === passes.length) {
666
- reportProgress(100);
667
- }
668
-
669
- resolve({ exitCode: lastExitCode, outputVideoFiles });
670
- } finally {
671
- if (subtitleFilePath && subtitleFileIsTemporary) {
672
- cleanupTempFile(subtitleFilePath);
673
- }
674
- }
675
- } catch (error) {
676
- reject(error);
677
- }
678
- });
1
+ import path from 'path';
2
+ import { readFileSync } from 'fs';
3
+ import {
4
+ getAudioFilePath,
5
+ getBackgroundImagePath,
6
+ getBackgroundVideoPath,
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
+ getOutputResolution,
22
+ rotationAliasValues,
23
+ getSpectrumRotation,
24
+ getSpectrumEffect,
25
+ SpectrumEffect,
26
+ getPolarXAbsolute,
27
+ getPolarYAbsolute,
28
+ getPolarInnerRadius,
29
+ getPolarMaxBarLength,
30
+ getPolarBarWidth,
31
+ getPolarEffect,
32
+ getPolarColor,
33
+ getPolarOpacityParsed,
34
+ getAutoEditVideo,
35
+ getAudioAutoHighlight,
36
+ getAudioAutoHighlightCount,
37
+ } from './config';
38
+ import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor } from './audio';
39
+ import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
40
+ import { normalizeInlineSubtitlesToSrt, lrcToSrt } from './subtitleConvert';
41
+ import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile } from './video';
42
+ import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
43
+ import { createBeatDetector } from './beats';
44
+ export { BeatInfo, BeatDetectorOptions } from './beats';
45
+ import { computeHighlightSlice, HIGHLIGHT_DURATION_SEC } from './highlight';
46
+ import { waitForEventLoop } from './waitForEventLoop';
47
+ export { computeHighlightSlice, HIGHLIGHT_DURATION_SEC, BeatFrameEvent, HighlightAudioSegment, HighlightRun } from './highlight';
48
+
49
+ export const PCM_FORMAT = {
50
+ bit: 8,
51
+ sign: 'u',
52
+ parseFunction: bufferToUInt8
53
+ };
54
+ const FFMPEG_FORMAT = `${PCM_FORMAT.sign}${PCM_FORMAT.bit}`;
55
+ const PROCESSING_BUFFER_SIZE = Math.pow(2, 12);
56
+ const MAX_CONSECUTIVE_VIDEO_FRAME_READ_FAILURES = 5;
57
+
58
+ export interface Config {
59
+ audio: {
60
+ path: string;
61
+ autoHighlight?: boolean;
62
+ /** When `autoHighlight` is true, number of non-overlapping 15s windows to stitch (default 1). */
63
+ autoHighlightCount?: number;
64
+ };
65
+ image?: {
66
+ path: string;
67
+ };
68
+ video?: {
69
+ path: string;
70
+ autoEdit?: boolean;
71
+ };
72
+ outVideo: {
73
+ path: string;
74
+ subtitles?:
75
+ | string
76
+ | {
77
+ path?: string;
78
+ rawContent?: string;
79
+ alignment?: 'top' | 'middle' | 'bottom';
80
+ };
81
+ fps?: number;
82
+ resolution?: {
83
+ width: number;
84
+ height: number;
85
+ };
86
+ spectrum?: {
87
+ width?: SpectrumSizeValue;
88
+ height?: SpectrumSizeValue;
89
+ x?: number | PositionAliasName;
90
+ y?: number | PositionAliasName;
91
+ rotation?: RotationAliasName;
92
+ effect?: SpectrumEffect;
93
+ color?: Color | string;
94
+ opacity?: string;
95
+ };
96
+ polar?: {
97
+ x?: number | PositionAliasName;
98
+ y?: number | PositionAliasName;
99
+ innerRadius?: number;
100
+ maxBarLength?: number;
101
+ barWidth?: number;
102
+ effect?: SpectrumEffect;
103
+ color?: Color | string;
104
+ opacity?: string;
105
+ }
106
+ };
107
+ tweaks?: {
108
+ ffmpeg_cfr?: string;
109
+ ffmpeg_preset?: string;
110
+ frame_processing_delay?: number;
111
+ };
112
+ }
113
+
114
+ export type SpectrumSizeValue = number | string;
115
+
116
+ export type PositionAliasName =
117
+ 'left' |
118
+ 'center' |
119
+ 'right' |
120
+ 'top' |
121
+ 'middle' |
122
+ 'bottom';
123
+
124
+ export type RotationAliasName = typeof rotationAliasValues[number];
125
+
126
+ const sleep = (timeout: number) =>
127
+ new Promise(resolve => setTimeout(resolve, timeout));
128
+
129
+ const createVisualizerFrameGenerator = (
130
+ config: Config,
131
+ backgroundWidth: number,
132
+ backgroundHeight: number,
133
+ defaultColor: Color,
134
+ spectrumBusMargin: number
135
+ ): (params: CommonVisualizerFrameProps) => EncodedBmp => {
136
+ if (!config.outVideo.spectrum) {
137
+ const createPolarVisualizerFrame = createPolarVisualizerFrameGenerator();
138
+ const polarX = getPolarXAbsolute(config, backgroundWidth);
139
+ const polarY = getPolarYAbsolute(config, backgroundHeight);
140
+ const polarInnerRadius = getPolarInnerRadius(config);
141
+ const polarMaxBarLength = getPolarMaxBarLength(config);
142
+ const polarBarWidth = getPolarBarWidth(config);
143
+ const polarColor = getPolarColor(config) || invertColor(defaultColor);
144
+ const polarEffect = getPolarEffect(config);
145
+ const polarOpacity = getPolarOpacityParsed(config);
146
+
147
+ return (params: CommonVisualizerFrameProps) => {
148
+ return createPolarVisualizerFrame({
149
+ ...params,
150
+ centerX: polarX,
151
+ centerY: polarY,
152
+ innerRadius: polarInnerRadius,
153
+ maxBarLength: polarMaxBarLength,
154
+ barWidth: polarBarWidth,
155
+ color: polarColor,
156
+ opacity: polarOpacity,
157
+ spectrumEffect: polarEffect,
158
+ });
159
+ };
160
+ }
161
+ const createSpectrumVisualizerFrame = createSpectrumVisualizerFrameGenerator();
162
+ const spectrumWidth = getSpectrumWidthAbsolute(config, backgroundWidth);
163
+ const spectrumHeight = getSpectrumHeightAbsolute(config, backgroundHeight);
164
+ const spectrumX = getSpectrumXAbsolute(config, spectrumWidth, backgroundWidth);
165
+ const spectrumY = getSpectrumYAbsolute(config, spectrumHeight, backgroundHeight);
166
+ const spectrumRotation = getSpectrumRotation(config);
167
+ const spectrumColor = getSpectrumColor(config) || invertColor(defaultColor);
168
+ const spectrumEffect = getSpectrumEffect(config);
169
+ const spectrumOpacity = getSpectrumOpacityParsed(config);
170
+
171
+ return (params: CommonVisualizerFrameProps) => {
172
+ return createSpectrumVisualizerFrame({
173
+ ...params,
174
+ size: { width: spectrumWidth, height: spectrumHeight },
175
+ position: { x: spectrumX, y: spectrumY },
176
+ rotation: spectrumRotation,
177
+ margin: spectrumBusMargin,
178
+ color: spectrumColor,
179
+ opacity: spectrumOpacity,
180
+ spectrumEffect,
181
+ });
182
+ };
183
+ };
184
+
185
+ interface PreProcessedAudio {
186
+ spectrums: number[][];
187
+ beatFrameIndices: number[];
188
+ beatEvents: { frameIndex: number; intensity: number }[];
189
+ }
190
+
191
+ const PRE_PROCESS_PROGRESS_SHARE = 60;
192
+ const POST_AUDIO_PROGRESS_SHARE = 10;
193
+ const RENDER_PROGRESS_SHARE = 30;
194
+
195
+ const preProcessAudio = async (
196
+ audioBuffer: Buffer,
197
+ sampleRate: number,
198
+ fps: number,
199
+ framesCount: number,
200
+ onPreProcessProgress?: (totalPercent: number) => void,
201
+ ): Promise<PreProcessedAudio> => {
202
+ if (framesCount === 0) {
203
+ for (let t = 1; t <= PRE_PROCESS_PROGRESS_SHARE; t += 1) {
204
+ onPreProcessProgress?.(t);
205
+ }
206
+ return { spectrums: [], beatFrameIndices: [], beatEvents: [] };
207
+ }
208
+ const audioDataStep = Math.trunc(audioBuffer.length / framesCount);
209
+ const processingBuffer = new Float32Array(PROCESSING_BUFFER_SIZE).fill(0);
210
+ const skipFramesCount = fps < 45 ? 1 : 2;
211
+ const processSpectrum = createSpectrumsProcessor(sampleRate, skipFramesCount);
212
+ const detectBeat = createBeatDetector(fps);
213
+
214
+ const spectrums: number[][] = [];
215
+ const beatFrameIndices: number[] = [];
216
+ const beatEvents: { frameIndex: number; intensity: number }[] = [];
217
+ let nextPreProcessMilestone = 1;
218
+
219
+ for (let i = 0; i < framesCount; i++) {
220
+ const currentFrameData = PCM_FORMAT.parseFunction(audioBuffer, i * audioDataStep, i * audioDataStep + audioDataStep);
221
+ const frameDataLength = Math.min(currentFrameData.length, PROCESSING_BUFFER_SIZE);
222
+ const frameDataTailStart = currentFrameData.length - frameDataLength;
223
+ const frameDataToProcess = currentFrameData.slice(frameDataTailStart);
224
+
225
+ processingBuffer.copyWithin(0, frameDataLength);
226
+ processingBuffer.set(frameDataToProcess, PROCESSING_BUFFER_SIZE - frameDataLength);
227
+
228
+ const audioDataParser = () => Array.from(processingBuffer);
229
+ const spectrum = processSpectrum(audioDataParser);
230
+ const beat = detectBeat(spectrum);
231
+
232
+ spectrums.push(spectrum);
233
+ if (beat.isBeat) {
234
+ beatFrameIndices.push(i);
235
+ beatEvents.push({ frameIndex: i, intensity: beat.intensity });
236
+ }
237
+
238
+ const p =
239
+ PRE_PROCESS_PROGRESS_SHARE * (i + 1) / framesCount;
240
+ while (
241
+ nextPreProcessMilestone <= PRE_PROCESS_PROGRESS_SHARE
242
+ && p >= nextPreProcessMilestone
243
+ ) {
244
+ onPreProcessProgress?.(nextPreProcessMilestone);
245
+ nextPreProcessMilestone += 1;
246
+ }
247
+
248
+ await waitForEventLoop();
249
+ }
250
+
251
+ return { spectrums, beatFrameIndices, beatEvents };
252
+ };
253
+
254
+ async function prepareBackgroundForRender(params: {
255
+ useVideoBackground: boolean;
256
+ backgroundVideoPath: string | undefined;
257
+ backgroundImagePath: string | undefined;
258
+ beatFrameIndices: number[];
259
+ framesCount: number;
260
+ outputResolution: ReturnType<typeof getOutputResolution>;
261
+ fps: number;
262
+ autoEditVideo: boolean;
263
+ }): Promise<{
264
+ backgroundWidth: number;
265
+ backgroundHeight: number;
266
+ defaultColor: Color;
267
+ staticBackgroundBuffer: EncodedBmp;
268
+ videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
269
+ videoFrameSize: number;
270
+ encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
271
+ concatFilePath?: string;
272
+ }> {
273
+ const {
274
+ useVideoBackground,
275
+ backgroundVideoPath,
276
+ backgroundImagePath,
277
+ beatFrameIndices,
278
+ framesCount,
279
+ outputResolution,
280
+ fps,
281
+ autoEditVideo,
282
+ } = params;
283
+
284
+ if (useVideoBackground && backgroundVideoPath) {
285
+ const videoInfo = await getVideoInfo(backgroundVideoPath);
286
+ const sceneChanges = autoEditVideo
287
+ ? await detectSceneChanges(backgroundVideoPath)
288
+ : [];
289
+ const backgroundWidth = outputResolution?.width ?? videoInfo.width;
290
+ const backgroundHeight = outputResolution?.height ?? videoInfo.height;
291
+
292
+ const videoFrameSize = backgroundWidth * backgroundHeight * 3;
293
+ const encodeVideoFrame = createBgrFrameEncoder({ width: backgroundWidth, height: backgroundHeight });
294
+
295
+ const segments = buildBeatSyncedSegments(
296
+ beatFrameIndices,
297
+ framesCount,
298
+ sceneChanges,
299
+ videoInfo.duration,
300
+ );
301
+
302
+ const concatFilePath = writeConcatFile(segments, backgroundVideoPath, videoInfo.duration, fps);
303
+
304
+ const videoFrameReader = spawnConcatVideoFrameReader({
305
+ concatFilePath,
306
+ fps,
307
+ totalFrames: framesCount,
308
+ ...(outputResolution && {
309
+ width: backgroundWidth,
310
+ height: backgroundHeight,
311
+ sourceWidth: videoInfo.width,
312
+ sourceHeight: videoInfo.height,
313
+ }),
314
+ });
315
+
316
+ const firstFrame = await readVideoFrame(videoFrameReader.stdout, videoFrameSize);
317
+ if (!firstFrame) {
318
+ throw new Error(`Could not read frames from video: ${backgroundVideoPath}`);
319
+ }
320
+ const defaultColor = getVideoFrameColor(firstFrame, backgroundWidth, backgroundHeight);
321
+ const staticBackgroundBuffer = encodeVideoFrame(firstFrame);
322
+ return {
323
+ backgroundWidth,
324
+ backgroundHeight,
325
+ defaultColor,
326
+ staticBackgroundBuffer,
327
+ videoFrameReader,
328
+ videoFrameSize,
329
+ encodeVideoFrame,
330
+ concatFilePath,
331
+ };
332
+ }
333
+
334
+ if (!backgroundImagePath) {
335
+ throw new Error('Background image path is required when not using video background.');
336
+ }
337
+ const backgroundImageBmpBuffer = await convertToBmp(
338
+ backgroundImagePath,
339
+ outputResolution?.width,
340
+ outputResolution?.height,
341
+ );
342
+ const backgroundImage = parseImage(backgroundImageBmpBuffer);
343
+ const backgroundWidth = backgroundImage.width;
344
+ const backgroundHeight = backgroundImage.height;
345
+ const defaultColor = getImageColor(backgroundImage);
346
+
347
+ const bpmEncoder = createBpmEncoder({ width: backgroundWidth, height: backgroundHeight });
348
+ const staticBackgroundBuffer = bpmEncoder(backgroundImage.data);
349
+ return {
350
+ backgroundWidth,
351
+ backgroundHeight,
352
+ defaultColor,
353
+ staticBackgroundBuffer,
354
+ videoFrameSize: 0,
355
+ };
356
+ }
357
+
358
+ async function resolveBackgroundFrameBuffer(params: {
359
+ frameIndex: number;
360
+ useVideoBackground: boolean;
361
+ videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
362
+ videoFrameSize: number;
363
+ encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
364
+ staticBackgroundBuffer: EncodedBmp;
365
+ }): Promise<{ frameBuffer: EncodedBmp; frameReadFailed: boolean }> {
366
+ const {
367
+ frameIndex,
368
+ useVideoBackground,
369
+ videoFrameReader,
370
+ videoFrameSize,
371
+ encodeVideoFrame,
372
+ staticBackgroundBuffer,
373
+ } = params;
374
+
375
+ if (useVideoBackground && videoFrameReader && encodeVideoFrame) {
376
+ if (frameIndex === 0) {
377
+ return { frameBuffer: staticBackgroundBuffer, frameReadFailed: false };
378
+ }
379
+ const videoFrame = await readVideoFrame(videoFrameReader.stdout, videoFrameSize);
380
+ if (videoFrame) {
381
+ return { frameBuffer: encodeVideoFrame(videoFrame), frameReadFailed: false };
382
+ }
383
+ return { frameBuffer: staticBackgroundBuffer, frameReadFailed: true };
384
+ }
385
+ return { frameBuffer: staticBackgroundBuffer, frameReadFailed: false };
386
+ }
387
+
388
+ export interface RenderAudioVisualizerResult {
389
+ exitCode: number;
390
+ /** Absolute paths of video files written successfully (exit code 0, full pass, no early stop). */
391
+ outputVideoFiles: string[];
392
+ }
393
+
394
+ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: number) => any, shouldStop?: () => boolean) =>
395
+ new Promise<RenderAudioVisualizerResult>(async (resolve) => {
396
+ if (config.outVideo.spectrum && config.outVideo.polar) {
397
+ throw new Error('Cannot use both "spectrum" and "polar" options. Please specify only one visualizer type.');
398
+ }
399
+ if (!config.image && !config.video) {
400
+ throw new Error('Either "image" or "video" must be specified as the background source.');
401
+ }
402
+ if (config.image && config.video) {
403
+ throw new Error('Cannot use both "image" and "video" options. Please specify only one background source.');
404
+ }
405
+
406
+ const audioFilePath = getAudioFilePath(config);
407
+ const outVideoPath = getOutVideoPath(config);
408
+ const subtitleSpec = getSubtitleRenderSpec(config);
409
+ let subtitleFilePath: string | undefined;
410
+ let subtitleFileIsTemporary = false;
411
+ let subtitleAlignmentAss = 2;
412
+ if (subtitleSpec) {
413
+ subtitleAlignmentAss = subtitleAlignmentToAss(subtitleSpec.alignment);
414
+ if (subtitleSpec.source.kind === 'file') {
415
+ const absPath = subtitleSpec.source.path;
416
+ const ext = path.extname(absPath).toLowerCase();
417
+ if (ext === '.lrc') {
418
+ const raw = readFileSync(absPath, 'utf-8');
419
+ subtitleFilePath = writeSubtitlesFile(lrcToSrt(raw));
420
+ subtitleFileIsTemporary = true;
421
+ } else {
422
+ subtitleFilePath = absPath;
423
+ }
424
+ } else {
425
+ subtitleFilePath = writeSubtitlesFile(
426
+ normalizeInlineSubtitlesToSrt(subtitleSpec.source.text),
427
+ );
428
+ subtitleFileIsTemporary = true;
429
+ }
430
+ }
431
+ const backgroundVideoPath = getBackgroundVideoPath(config);
432
+ const backgroundImagePath = getBackgroundImagePath(config);
433
+ const useVideoBackground = !!backgroundVideoPath;
434
+
435
+ const audioReader = await createAudioBuffer(audioFilePath, FFMPEG_FORMAT);
436
+ const audioBuffer = audioReader.audioBuffer;
437
+ const sampleRate = audioReader.sampleRate;
438
+ if (!sampleRate) {
439
+ throw new Error('ffmpeg didn\'t show audio sample rate');
440
+ }
441
+
442
+ const spectrumBusMargin = getSpectrumBusMargin();
443
+ const FPS = getFPS(config);
444
+ const ffmpeg_cfr = getFfmpeg_cfr(config);
445
+ const ffmpeg_preset = getFfmpeg_preset(config);
446
+ const frame_processing_delay = getFrame_processing_delay(config);
447
+
448
+ const audioDuration = audioBuffer.length / sampleRate;
449
+ const framesCount = Math.trunc(audioDuration * FPS);
450
+ const outputResolution = getOutputResolution(config);
451
+
452
+ let maxProgressReported = -1;
453
+ const reportProgress = (progress: number) => {
454
+ if (!onProgress) {
455
+ return;
456
+ }
457
+ const normalized = +Math.min(100, Math.max(0, progress)).toFixed(2);
458
+ if (normalized <= maxProgressReported) {
459
+ return;
460
+ }
461
+ maxProgressReported = normalized;
462
+ onProgress(normalized);
463
+ };
464
+ reportProgress(0);
465
+
466
+ const preprocessed = await preProcessAudio(
467
+ audioBuffer,
468
+ sampleRate,
469
+ FPS,
470
+ framesCount,
471
+ (progress: number) => {
472
+ reportProgress(progress);
473
+ },
474
+ );
475
+ reportProgress(PRE_PROCESS_PROGRESS_SHARE);
476
+
477
+ const autoHighlight = getAudioAutoHighlight(config);
478
+ let spectrumsForRender = preprocessed.spectrums;
479
+ let beatIndicesForRender = preprocessed.beatFrameIndices;
480
+ let framesCountForRender = framesCount;
481
+ type HighlightSliceResult = ReturnType<typeof computeHighlightSlice> extends Promise<
482
+ infer R
483
+ >
484
+ ? R
485
+ : never;
486
+ let highlightSlice: HighlightSliceResult | undefined;
487
+
488
+ if (autoHighlight) {
489
+ reportProgress(PRE_PROCESS_PROGRESS_SHARE + 1);
490
+ highlightSlice = await computeHighlightSlice(
491
+ FPS,
492
+ framesCount,
493
+ preprocessed.spectrums,
494
+ preprocessed.beatEvents,
495
+ getAudioAutoHighlightCount(config),
496
+ );
497
+ spectrumsForRender = highlightSlice.spectrums;
498
+ beatIndicesForRender = highlightSlice.beatFrameIndices;
499
+ framesCountForRender = highlightSlice.highlightFrames;
500
+ reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
501
+ } else {
502
+ reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
503
+ }
504
+
505
+ const separateHighlightFiles =
506
+ autoHighlight &&
507
+ highlightSlice &&
508
+ highlightSlice.runs.length > 1;
509
+
510
+ type VideoRenderPass = {
511
+ outPath: string;
512
+ spectrums: number[][];
513
+ beatIndices: number[];
514
+ frameCount: number;
515
+ audioSegment: import('./highlight').HighlightAudioSegment | undefined;
516
+ };
517
+
518
+ const passes: VideoRenderPass[] =
519
+ separateHighlightFiles && highlightSlice
520
+ ? highlightSlice.runs.map((run, i) => {
521
+ const dir = path.dirname(outVideoPath);
522
+ const ext = path.extname(outVideoPath);
523
+ const base = path.basename(outVideoPath, ext);
524
+ const numberedPath = path.resolve(dir, `${base}-${i + 1}${ext}`);
525
+ return {
526
+ outPath: numberedPath,
527
+ spectrums: run.spectrums,
528
+ beatIndices: run.beatFrameIndices,
529
+ frameCount: run.highlightFrames,
530
+ audioSegment: run.audioSegment,
531
+ };
532
+ })
533
+ : [
534
+ {
535
+ outPath: outVideoPath,
536
+ spectrums: spectrumsForRender,
537
+ beatIndices: beatIndicesForRender,
538
+ frameCount: framesCountForRender,
539
+ audioSegment:
540
+ autoHighlight &&
541
+ highlightSlice &&
542
+ highlightSlice.audioSegments.length > 0
543
+ ? highlightSlice.audioSegments[0]
544
+ : undefined,
545
+ },
546
+ ];
547
+
548
+ const totalPassFrames =
549
+ passes.reduce((sum, pass) => sum + pass.frameCount, 0);
550
+ const totalRenderMilestones = totalPassFrames + (passes.length * 2);
551
+ let renderMilestonesCompleted = 0;
552
+ const reportRenderProgress = (milestoneIncrement = 1) => {
553
+ renderMilestonesCompleted += milestoneIncrement;
554
+ reportProgress(
555
+ PRE_PROCESS_PROGRESS_SHARE
556
+ + POST_AUDIO_PROGRESS_SHARE
557
+ + (RENDER_PROGRESS_SHARE * renderMilestonesCompleted / Math.max(1, totalRenderMilestones)),
558
+ );
559
+ };
560
+ let lastExitCode = 0;
561
+ let renderAbortReason: string | undefined;
562
+ const outputVideoFiles: string[] = [];
563
+ try {
564
+ passLoop: for (const pass of passes) {
565
+ const {
566
+ backgroundWidth,
567
+ backgroundHeight,
568
+ defaultColor,
569
+ staticBackgroundBuffer,
570
+ videoFrameReader,
571
+ videoFrameSize,
572
+ encodeVideoFrame,
573
+ concatFilePath,
574
+ } = await prepareBackgroundForRender({
575
+ useVideoBackground,
576
+ backgroundVideoPath,
577
+ backgroundImagePath,
578
+ beatFrameIndices: pass.beatIndices,
579
+ framesCount: pass.frameCount,
580
+ outputResolution,
581
+ fps: FPS,
582
+ autoEditVideo: getAutoEditVideo(config),
583
+ });
584
+ reportRenderProgress();
585
+
586
+ const createVisualizerFrame = createVisualizerFrameGenerator(
587
+ config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
588
+ );
589
+
590
+ const ffmpegVideoWriter = spawnFfmpegVideoWriter({
591
+ audioFilename: audioFilePath,
592
+ videoFileName: pass.outPath,
593
+ ...(subtitleFilePath && {
594
+ subtitleFilename: subtitleFilePath,
595
+ subtitleAlignmentAss,
596
+ }),
597
+ fps: FPS,
598
+ ...(pass.audioSegment && { audioSegment: pass.audioSegment }),
599
+ ...(ffmpeg_cfr && { crf: ffmpeg_cfr }),
600
+ ...(ffmpeg_preset && { preset: ffmpeg_preset }),
601
+ });
602
+ const exitPromise = waitForProcessExit(ffmpegVideoWriter);
603
+
604
+ let stoppedEarly = false;
605
+ let consecutiveVideoFrameReadFailures = 0;
606
+ for (let i = 0; i < pass.frameCount; i++) {
607
+ const spectrum = pass.spectrums[i];
608
+ const { frameBuffer: backgroundImageBuffer, frameReadFailed } = await resolveBackgroundFrameBuffer({
609
+ frameIndex: i,
610
+ useVideoBackground,
611
+ videoFrameReader,
612
+ videoFrameSize,
613
+ encodeVideoFrame,
614
+ staticBackgroundBuffer,
615
+ });
616
+ if (frameReadFailed) {
617
+ consecutiveVideoFrameReadFailures += 1;
618
+ if (consecutiveVideoFrameReadFailures >= MAX_CONSECUTIVE_VIDEO_FRAME_READ_FAILURES) {
619
+ renderAbortReason = [
620
+ 'Background video frame reads failed repeatedly.',
621
+ `Failed ${consecutiveVideoFrameReadFailures} times in a row`,
622
+ `while rendering pass output "${pass.outPath}".`,
623
+ ].join(' ');
624
+ stoppedEarly = true;
625
+ break;
626
+ }
627
+ } else {
628
+ consecutiveVideoFrameReadFailures = 0;
629
+ }
630
+
631
+ const commonVisualizerFrameProps: CommonVisualizerFrameProps = {
632
+ backgroundImageBuffer,
633
+ spectrum,
634
+ };
635
+ const frameImage = createVisualizerFrame(commonVisualizerFrameProps);
636
+ const isFrameProcessed = ffmpegVideoWriter.stdin.write(frameImage.data);
637
+ if (!isFrameProcessed) {
638
+ const isDrained = await waitDrain(ffmpegVideoWriter.stdin, ffmpegVideoWriter);
639
+ if (!isDrained) {
640
+ stoppedEarly = true;
641
+ break;
642
+ }
643
+ }
644
+ if (shouldStop && shouldStop()) {
645
+ stoppedEarly = true;
646
+ break;
647
+ }
648
+ if (frame_processing_delay) {
649
+ await sleep(frame_processing_delay);
650
+ }
651
+ await waitForEventLoop();
652
+ reportRenderProgress();
653
+ }
654
+
655
+ if (videoFrameReader) {
656
+ videoFrameReader.kill();
657
+ }
658
+ if (concatFilePath) {
659
+ cleanupConcatFile(concatFilePath);
660
+ }
661
+ ffmpegVideoWriter.stdin.end();
662
+
663
+ lastExitCode = await exitPromise;
664
+ if (lastExitCode === 0 && !stoppedEarly) {
665
+ reportRenderProgress();
666
+ }
667
+
668
+ if (lastExitCode === 0 && !stoppedEarly) {
669
+ outputVideoFiles.push(pass.outPath);
670
+ }
671
+
672
+ if (stoppedEarly || lastExitCode !== 0) {
673
+ if (renderAbortReason && lastExitCode === 0) {
674
+ lastExitCode = 1;
675
+ }
676
+ break passLoop;
677
+ }
678
+ }
679
+
680
+ if (lastExitCode === 0 && outputVideoFiles.length === passes.length) {
681
+ reportProgress(100);
682
+ }
683
+ if (renderAbortReason) {
684
+ console.error(renderAbortReason);
685
+ }
686
+
687
+ resolve({ exitCode: lastExitCode, outputVideoFiles });
688
+ } finally {
689
+ if (subtitleFilePath && subtitleFileIsTemporary) {
690
+ cleanupTempFile(subtitleFilePath);
691
+ }
692
+ }
693
+ });