nodejs-audio-visualizer 4.2.6 → 4.3.7
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/README.md +5 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +57 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +78 -34
- package/dist/index.js.map +1 -1
- package/dist/subtitleConvert.d.ts +3 -0
- package/dist/subtitleConvert.js +62 -0
- package/dist/subtitleConvert.js.map +1 -0
- package/dist/video.d.ts +5 -0
- package/dist/video.js +24 -3
- package/dist/video.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +80 -0
- package/src/index.ts +282 -235
- package/src/subtitleConvert.ts +63 -0
- package/src/tests/config.test.ts +69 -0
- package/src/tests/subtitleConvert.test.ts +23 -0
- package/src/tests/video.test.ts +67 -0
- package/src/video.ts +36 -2
package/src/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
2
3
|
import {
|
|
3
4
|
getAudioFilePath,
|
|
4
5
|
getBackgroundImagePath,
|
|
5
6
|
getBackgroundVideoPath,
|
|
6
7
|
getOutVideoPath,
|
|
8
|
+
getSubtitleRenderSpec,
|
|
9
|
+
subtitleAlignmentToAss,
|
|
7
10
|
getFPS,
|
|
8
11
|
getSpectrumBusMargin,
|
|
9
12
|
getSpectrumWidthAbsolute,
|
|
@@ -34,7 +37,8 @@ import {
|
|
|
34
37
|
} from './config';
|
|
35
38
|
import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor } from './audio';
|
|
36
39
|
import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
|
|
37
|
-
import {
|
|
40
|
+
import { normalizeInlineSubtitlesToSrt, lrcToSrt } from './subtitleConvert';
|
|
41
|
+
import { spawnFfmpegVideoWriter, waitDrain, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile } from './video';
|
|
38
42
|
import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
|
|
39
43
|
import { createBeatDetector } from './beats';
|
|
40
44
|
export { BeatInfo, BeatDetectorOptions } from './beats';
|
|
@@ -66,6 +70,13 @@ export interface Config {
|
|
|
66
70
|
};
|
|
67
71
|
outVideo: {
|
|
68
72
|
path: string;
|
|
73
|
+
subtitles?:
|
|
74
|
+
| string
|
|
75
|
+
| {
|
|
76
|
+
path?: string;
|
|
77
|
+
rawContent?: string;
|
|
78
|
+
alignment?: 'top' | 'middle' | 'bottom';
|
|
79
|
+
};
|
|
69
80
|
fps?: number;
|
|
70
81
|
resolution?: {
|
|
71
82
|
width: number;
|
|
@@ -375,257 +386,293 @@ export interface RenderAudioVisualizerResult {
|
|
|
375
386
|
}
|
|
376
387
|
|
|
377
388
|
export const renderAudioVisualizer = (config: Config, onProgress?: (progress: number) => any, shouldStop?: () => boolean) =>
|
|
378
|
-
new Promise<RenderAudioVisualizerResult>(async (resolve) => {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const audioFilePath = getAudioFilePath(config);
|
|
390
|
-
const outVideoPath = getOutVideoPath(config);
|
|
391
|
-
const backgroundVideoPath = getBackgroundVideoPath(config);
|
|
392
|
-
const backgroundImagePath = getBackgroundImagePath(config);
|
|
393
|
-
const useVideoBackground = !!backgroundVideoPath;
|
|
394
|
-
|
|
395
|
-
const audioReader = await createAudioBuffer(audioFilePath, FFMPEG_FORMAT);
|
|
396
|
-
const audioBuffer = audioReader.audioBuffer;
|
|
397
|
-
const sampleRate = audioReader.sampleRate;
|
|
398
|
-
if (!sampleRate) {
|
|
399
|
-
throw new Error('ffmpeg didn\'t show audio sample rate');
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const spectrumBusMargin = getSpectrumBusMargin();
|
|
403
|
-
const FPS = getFPS(config);
|
|
404
|
-
const ffmpeg_cfr = getFfmpeg_cfr(config);
|
|
405
|
-
const ffmpeg_preset = getFfmpeg_preset(config);
|
|
406
|
-
const frame_processing_delay = getFrame_processing_delay(config);
|
|
407
|
-
|
|
408
|
-
const audioDuration = audioBuffer.length / sampleRate;
|
|
409
|
-
const framesCount = Math.trunc(audioDuration * FPS);
|
|
410
|
-
const outputResolution = getOutputResolution(config);
|
|
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
|
+
}
|
|
411
400
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
}
|
|
416
425
|
}
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
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');
|
|
420
435
|
}
|
|
421
|
-
maxProgressReported = normalized;
|
|
422
|
-
onProgress(normalized);
|
|
423
|
-
};
|
|
424
|
-
reportProgress(0);
|
|
425
436
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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,
|
|
451
464
|
FPS,
|
|
452
465
|
framesCount,
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
466
|
+
(progress: number) => {
|
|
467
|
+
reportProgress(progress);
|
|
468
|
+
},
|
|
456
469
|
);
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
highlightSlice
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
+
}
|
|
477
499
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
+
}
|
|
544
644
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
645
|
+
if (videoFrameReader) {
|
|
646
|
+
videoFrameReader.kill();
|
|
647
|
+
}
|
|
648
|
+
if (concatFilePath) {
|
|
649
|
+
cleanupConcatFile(concatFilePath);
|
|
650
|
+
}
|
|
651
|
+
ffmpegVideoWriter.stdin.end();
|
|
548
652
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
videoFileName: pass.outPath,
|
|
552
|
-
fps: FPS,
|
|
553
|
-
...(pass.audioSegment && { audioSegment: pass.audioSegment }),
|
|
554
|
-
...(ffmpeg_cfr && { crf: ffmpeg_cfr }),
|
|
555
|
-
...(ffmpeg_preset && { preset: ffmpeg_preset }),
|
|
556
|
-
});
|
|
653
|
+
lastExitCode = await exitPromise;
|
|
654
|
+
reportRenderProgress();
|
|
557
655
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
ffmpegVideoWriter.on('exit', (code: number | null) => {
|
|
561
|
-
if (settled) {
|
|
562
|
-
return;
|
|
656
|
+
if (lastExitCode === 0 && !stoppedEarly) {
|
|
657
|
+
outputVideoFiles.push(pass.outPath);
|
|
563
658
|
}
|
|
564
|
-
settled = true;
|
|
565
|
-
res(code ?? 0);
|
|
566
|
-
});
|
|
567
|
-
});
|
|
568
659
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const spectrum = pass.spectrums[i];
|
|
572
|
-
|
|
573
|
-
const backgroundImageBuffer = await resolveBackgroundFrameBuffer({
|
|
574
|
-
frameIndex: i,
|
|
575
|
-
useVideoBackground,
|
|
576
|
-
videoFrameReader,
|
|
577
|
-
videoFrameSize,
|
|
578
|
-
encodeVideoFrame,
|
|
579
|
-
staticBackgroundBuffer,
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
const commonVisualizerFrameProps: CommonVisualizerFrameProps = {
|
|
583
|
-
backgroundImageBuffer,
|
|
584
|
-
spectrum,
|
|
585
|
-
};
|
|
586
|
-
const frameImage = createVisualizerFrame(commonVisualizerFrameProps);
|
|
587
|
-
const isFrameProcessed = ffmpegVideoWriter.stdin.write(frameImage.data);
|
|
588
|
-
if (!isFrameProcessed) {
|
|
589
|
-
const isDrained = await waitDrain(ffmpegVideoWriter.stdin, ffmpegVideoWriter);
|
|
590
|
-
if (!isDrained) {
|
|
591
|
-
stoppedEarly = true;
|
|
592
|
-
break;
|
|
660
|
+
if (stoppedEarly || lastExitCode !== 0) {
|
|
661
|
+
break passLoop;
|
|
593
662
|
}
|
|
594
663
|
}
|
|
595
|
-
if (shouldStop && shouldStop()) {
|
|
596
|
-
stoppedEarly = true;
|
|
597
|
-
break;
|
|
598
|
-
}
|
|
599
|
-
if (frame_processing_delay) {
|
|
600
|
-
await sleep(frame_processing_delay);
|
|
601
|
-
}
|
|
602
|
-
await waitForEventLoop();
|
|
603
|
-
reportRenderProgress();
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
if (videoFrameReader) {
|
|
607
|
-
videoFrameReader.kill();
|
|
608
|
-
}
|
|
609
|
-
if (concatFilePath) {
|
|
610
|
-
cleanupConcatFile(concatFilePath);
|
|
611
|
-
}
|
|
612
|
-
ffmpegVideoWriter.stdin.end();
|
|
613
|
-
|
|
614
|
-
lastExitCode = await exitPromise;
|
|
615
|
-
reportRenderProgress();
|
|
616
664
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
665
|
+
if (lastExitCode === 0 && outputVideoFiles.length === passes.length) {
|
|
666
|
+
reportProgress(100);
|
|
667
|
+
}
|
|
620
668
|
|
|
621
|
-
|
|
622
|
-
|
|
669
|
+
resolve({ exitCode: lastExitCode, outputVideoFiles });
|
|
670
|
+
} finally {
|
|
671
|
+
if (subtitleFilePath && subtitleFileIsTemporary) {
|
|
672
|
+
cleanupTempFile(subtitleFilePath);
|
|
673
|
+
}
|
|
623
674
|
}
|
|
675
|
+
} catch (error) {
|
|
676
|
+
reject(error);
|
|
624
677
|
}
|
|
625
|
-
|
|
626
|
-
if (lastExitCode === 0 && outputVideoFiles.length === passes.length) {
|
|
627
|
-
reportProgress(100);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
resolve({ exitCode: lastExitCode, outputVideoFiles });
|
|
631
678
|
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** LRC timestamp line: [mm:ss.xx] text — hundredths optional after dot. */
|
|
2
|
+
const LRC_LINE = /^\[(\d+):(\d+)(?:\.(\d+))?\]\s*(.*)$/;
|
|
3
|
+
|
|
4
|
+
export const isLikelyLrc = (text: string): boolean => {
|
|
5
|
+
const body = text.replace(/^\uFEFF/, '');
|
|
6
|
+
for (const line of body.split(/\r?\n/)) {
|
|
7
|
+
const t = line.trim();
|
|
8
|
+
if (t.length === 0) {
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
return LRC_LINE.test(t);
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const msToSrtTime = (ms: number): string => {
|
|
17
|
+
const h = Math.floor(ms / 3600000);
|
|
18
|
+
const m = Math.floor((ms % 3600000) / 60000);
|
|
19
|
+
const s = Math.floor((ms % 60000) / 1000);
|
|
20
|
+
const milli = ms % 1000;
|
|
21
|
+
const pad = (n: number, w: number) => String(n).padStart(w, '0');
|
|
22
|
+
return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(milli, 3)}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TAIL_MS = 2000;
|
|
26
|
+
|
|
27
|
+
export const lrcToSrt = (lrc: string): string => {
|
|
28
|
+
const body = lrc.replace(/^\uFEFF/, '');
|
|
29
|
+
const entries: { startMs: number; text: string }[] = [];
|
|
30
|
+
for (const line of body.split(/\r?\n/)) {
|
|
31
|
+
const match = line.trim().match(LRC_LINE);
|
|
32
|
+
if (!match) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const min = parseInt(match[1], 10);
|
|
36
|
+
const sec = parseInt(match[2], 10);
|
|
37
|
+
const centisec =
|
|
38
|
+
match[3] !== undefined
|
|
39
|
+
? parseInt(match[3].padEnd(2, '0').slice(0, 2), 10)
|
|
40
|
+
: 0;
|
|
41
|
+
const startMs = (min * 60 + sec) * 1000 + centisec * 10;
|
|
42
|
+
const text = (match[4] || '').trim();
|
|
43
|
+
if (text.length > 0) {
|
|
44
|
+
entries.push({ startMs, text });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
entries.sort((a, b) => a.startMs - b.startMs);
|
|
48
|
+
let out = '';
|
|
49
|
+
for (let i = 0; i < entries.length; i++) {
|
|
50
|
+
const start = entries[i].startMs;
|
|
51
|
+
const end =
|
|
52
|
+
i + 1 < entries.length
|
|
53
|
+
? Math.max(entries[i + 1].startMs, start + 1)
|
|
54
|
+
: start + DEFAULT_TAIL_MS;
|
|
55
|
+
out += `${i + 1}\n`;
|
|
56
|
+
out += `${msToSrtTime(start)} --> ${msToSrtTime(end)}\n`;
|
|
57
|
+
out += `${entries[i].text}\n\n`;
|
|
58
|
+
}
|
|
59
|
+
return out.length > 0 ? out.replace(/\n*$/, '\n') : '';
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const normalizeInlineSubtitlesToSrt = (text: string): string =>
|
|
63
|
+
isLikelyLrc(text) ? lrcToSrt(text) : text;
|