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/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 { spawnFfmpegVideoWriter, waitDrain, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, writeConcatFile, spawnConcatVideoFrameReader, cleanupConcatFile } from './video';
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
- if (config.outVideo.spectrum && config.outVideo.polar) {
380
- throw new Error('Cannot use both "spectrum" and "polar" options. Please specify only one visualizer type.');
381
- }
382
- if (!config.image && !config.video) {
383
- throw new Error('Either "image" or "video" must be specified as the background source.');
384
- }
385
- if (config.image && config.video) {
386
- throw new Error('Cannot use both "image" and "video" options. Please specify only one background source.');
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
- let maxProgressReported = -1;
413
- const reportProgress = (progress: number) => {
414
- if (!onProgress) {
415
- return;
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 normalized = +Math.min(100, Math.max(0, progress)).toFixed(2);
418
- if (normalized <= maxProgressReported) {
419
- return;
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
- const preprocessed = await preProcessAudio(
427
- audioBuffer,
428
- sampleRate,
429
- FPS,
430
- framesCount,
431
- (progress: number) => {
432
- reportProgress(progress);
433
- },
434
- );
435
- reportProgress(PRE_PROCESS_PROGRESS_SHARE);
436
-
437
- const autoHighlight = getAudioAutoHighlight(config);
438
- let spectrumsForRender = preprocessed.spectrums;
439
- let beatIndicesForRender = preprocessed.beatFrameIndices;
440
- let framesCountForRender = framesCount;
441
- type HighlightSliceResult = ReturnType<typeof computeHighlightSlice> extends Promise<
442
- infer R
443
- >
444
- ? R
445
- : never;
446
- let highlightSlice: HighlightSliceResult | undefined;
447
-
448
- if (autoHighlight) {
449
- reportProgress(PRE_PROCESS_PROGRESS_SHARE + 1);
450
- highlightSlice = await computeHighlightSlice(
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
- preprocessed.spectrums,
454
- preprocessed.beatEvents,
455
- getAudioAutoHighlightCount(config),
466
+ (progress: number) => {
467
+ reportProgress(progress);
468
+ },
456
469
  );
457
- spectrumsForRender = highlightSlice.spectrums;
458
- beatIndicesForRender = highlightSlice.beatFrameIndices;
459
- framesCountForRender = highlightSlice.highlightFrames;
460
- reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
461
- } else {
462
- reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
463
- }
464
-
465
- const separateHighlightFiles =
466
- autoHighlight &&
467
- highlightSlice &&
468
- highlightSlice.runs.length > 1;
469
-
470
- type VideoRenderPass = {
471
- outPath: string;
472
- spectrums: number[][];
473
- beatIndices: number[];
474
- frameCount: number;
475
- audioSegment: import('./highlight').HighlightAudioSegment | undefined;
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
- const passes: VideoRenderPass[] =
479
- separateHighlightFiles && highlightSlice
480
- ? highlightSlice.runs.map((run, i) => {
481
- const dir = path.dirname(outVideoPath);
482
- const ext = path.extname(outVideoPath);
483
- const base = path.basename(outVideoPath, ext);
484
- const numberedPath = path.resolve(dir, `${base}-${i + 1}${ext}`);
485
- return {
486
- outPath: numberedPath,
487
- spectrums: run.spectrums,
488
- beatIndices: run.beatFrameIndices,
489
- frameCount: run.highlightFrames,
490
- audioSegment: run.audioSegment,
491
- };
492
- })
493
- : [
494
- {
495
- outPath: outVideoPath,
496
- spectrums: spectrumsForRender,
497
- beatIndices: beatIndicesForRender,
498
- frameCount: framesCountForRender,
499
- audioSegment:
500
- autoHighlight &&
501
- highlightSlice &&
502
- highlightSlice.audioSegments.length > 0
503
- ? highlightSlice.audioSegments[0]
504
- : undefined,
505
- },
506
- ];
507
-
508
- const totalPassFrames =
509
- passes.reduce((sum, pass) => sum + pass.frameCount, 0);
510
- const totalRenderMilestones = totalPassFrames + (passes.length * 2);
511
- let renderMilestonesCompleted = 0;
512
- const reportRenderProgress = (milestoneIncrement = 1) => {
513
- renderMilestonesCompleted += milestoneIncrement;
514
- reportProgress(
515
- PRE_PROCESS_PROGRESS_SHARE
516
- + POST_AUDIO_PROGRESS_SHARE
517
- + (RENDER_PROGRESS_SHARE * renderMilestonesCompleted / Math.max(1, totalRenderMilestones)),
518
- );
519
- };
520
- let lastExitCode = 0;
521
- const outputVideoFiles: string[] = [];
522
-
523
- passLoop: for (const pass of passes) {
524
- const {
525
- backgroundWidth,
526
- backgroundHeight,
527
- defaultColor,
528
- staticBackgroundBuffer,
529
- videoFrameReader,
530
- videoFrameSize,
531
- encodeVideoFrame,
532
- concatFilePath,
533
- } = await prepareBackgroundForRender({
534
- useVideoBackground,
535
- backgroundVideoPath,
536
- backgroundImagePath,
537
- beatFrameIndices: pass.beatIndices,
538
- framesCount: pass.frameCount,
539
- outputResolution,
540
- fps: FPS,
541
- autoEditVideo: getAutoEditVideo(config),
542
- });
543
- reportRenderProgress();
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
- const createVisualizerFrame = createVisualizerFrameGenerator(
546
- config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
547
- );
645
+ if (videoFrameReader) {
646
+ videoFrameReader.kill();
647
+ }
648
+ if (concatFilePath) {
649
+ cleanupConcatFile(concatFilePath);
650
+ }
651
+ ffmpegVideoWriter.stdin.end();
548
652
 
549
- const ffmpegVideoWriter = spawnFfmpegVideoWriter({
550
- audioFilename: audioFilePath,
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
- const exitPromise = new Promise<number>(res => {
559
- let settled = false;
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
- let stoppedEarly = false;
570
- for (let i = 0; i < pass.frameCount; i++) {
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
- if (lastExitCode === 0 && !stoppedEarly) {
618
- outputVideoFiles.push(pass.outPath);
619
- }
665
+ if (lastExitCode === 0 && outputVideoFiles.length === passes.length) {
666
+ reportProgress(100);
667
+ }
620
668
 
621
- if (stoppedEarly || lastExitCode !== 0) {
622
- break passLoop;
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;