cerevox 2.30.5 → 2.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -482,23 +482,19 @@ async function compileToFfmpeg(project, opts = {}) {
482
482
  if (!asset)
483
483
  throw new Error(`Missing asset: ${clip.assetId}`);
484
484
  const idx = inputIndexByAsset[asset.id];
485
- // For video tracks, we need to handle both video and audio from video files
486
485
  let sel;
487
486
  if (track.type === 'audio') {
488
487
  sel = `[${idx}:a]`;
489
488
  }
490
489
  else if (track.type === 'video') {
491
- // For video tracks, we process the video stream
492
490
  sel = `[${idx}:v]`;
493
- // Also extract audio from video files if the asset is a video
491
+ // 若视频素材自带音频,把音频也按同样时间处理
494
492
  if (asset.type === 'video' && (await checkVideoHasAudio(asset.uri))) {
495
493
  const audioSel = `[${idx}:a]`;
496
- // Process audio with same timing and effects as video
497
494
  const audioStart = clip.inMs / 1000;
498
495
  const audioDur = (clip.durationMs +
499
496
  (clip.transitionIn ? clip.transitionIn.durationMs : 0)) /
500
497
  1000;
501
- // Get real duration for audio speed adjustment
502
498
  let originalDurationMs = asset.durationMs;
503
499
  const realDuration = await getMediaDuration(asset.uri);
504
500
  if (realDuration !== null) {
@@ -512,79 +508,66 @@ async function compileToFfmpeg(project, opts = {}) {
512
508
  const speedRatio = originalDurationSec / targetDurationSec;
513
509
  let audioTrim;
514
510
  if (Math.abs(originalDurationSec - targetDurationSec) > 0.01) {
515
- // Duration mismatch: adjust playback speed using atempo
516
511
  audioTrim = `atrim=start=${audioStart}:duration=${originalDurationSec},asetpts=PTS-STARTPTS,atempo=${speedRatio}`;
517
512
  }
518
513
  else {
519
- // Duration matches: use normal trim
520
514
  audioTrim = `atrim=start=${audioStart}:duration=${audioDur},asetpts=PTS-STARTPTS`;
521
515
  }
522
- const audioShift = `adelay=${Math.round(clip.startMs)}|${Math.round(clip.startMs)}`;
516
+ // (①)更稳的 adelay,并在其后归零
517
+ const audioShift = clip.startMs > 0
518
+ ? `adelay=${Math.round(clip.startMs)}:all=1,asetpts=PTS-STARTPTS`
519
+ : '';
523
520
  const audioLabel = newLabel();
524
- // Apply same effects to audio as video (where applicable)
525
521
  const audioEffects = [];
526
522
  if (clip.effects) {
527
523
  for (const ef of clip.effects) {
528
524
  switch (ef.name) {
529
525
  case 'fadeIn': {
530
- const params = ef.params;
531
- const d = Number(params?.durationMs ?? 500) / 1000;
526
+ const d = Number(ef.params?.durationMs ?? 500) / 1000;
532
527
  audioEffects.push(`afade=t=in:st=0:d=${d}`);
533
528
  break;
534
529
  }
535
530
  case 'fadeOut': {
536
- const params = ef.params;
537
- const d = Number(params?.durationMs ?? 500) / 1000;
531
+ const d = Number(ef.params?.durationMs ?? 500) / 1000;
538
532
  audioEffects.push(`afade=t=out:st=${Math.max(0, audioDur - d)}:d=${d}`);
539
533
  break;
540
534
  }
541
535
  case 'gain': {
542
- const params = ef.params;
543
- const db = Number(params?.db ?? 0) || Number(params?.gain ?? 0);
536
+ const p = ef.params;
537
+ const db = Number(p?.db ?? 0) || Number(p?.gain ?? 0);
544
538
  let gain = Math.pow(10, db / 20);
545
- if (params?.volume) {
546
- gain *= params.volume;
547
- }
539
+ if (p?.volume)
540
+ gain *= p.volume;
548
541
  audioEffects.push(`volume=${gain.toFixed(2)}`);
549
542
  break;
550
543
  }
551
544
  case 'speed': {
552
- const params = ef.params;
553
- const rate = Number(params?.rate ?? 1);
554
- if (rate !== 1) {
545
+ const rate = Number(ef.params?.rate ?? 1);
546
+ if (rate !== 1)
555
547
  audioEffects.push(`atempo=${rate.toFixed(3)}`);
556
- }
557
548
  break;
558
549
  }
559
550
  }
560
551
  }
561
552
  }
562
- fg.push(`${audioSel}${audioTrim}${audioEffects.length ? ',' + audioEffects.join(',') : ''},${audioShift}[${audioLabel}]`);
553
+ // (②)仅音频分支进入 amix 前统一采样率/声道
554
+ fg.push(`${audioSel}${audioTrim}${audioEffects.length ? ',' + audioEffects.join(',') : ''}${audioShift ? ',' + audioShift : ''},aformat=sample_rates=48000:channel_layouts=stereo[${audioLabel}]`);
563
555
  videoAudioOuts.push(`[${audioLabel}]`);
564
556
  }
565
557
  }
566
558
  else {
567
- sel = `[${idx}:v]`; // fallback
559
+ sel = `[${idx}:v]`;
568
560
  }
569
561
  const start = clip.inMs / 1000;
570
- // Extend duration for transitionIn to compensate for overlap
571
562
  const dur = (clip.durationMs +
572
563
  (clip.transitionIn ? clip.transitionIn.durationMs : 0)) /
573
564
  1000;
574
- // For clips with transitionIn: add static frames at beginning and start earlier
575
- // For clips without transitionIn: use original startMs
576
565
  const hasTransition = clip.transitionIn && track.type === 'video';
577
566
  const transitionDuration = hasTransition
578
567
  ? clip.transitionIn.durationMs / 1000
579
568
  : 0;
580
- const actualStartSec = hasTransition
581
- ? (clip.startMs - clip.transitionIn.durationMs) / 1000
582
- : clip.startMs / 1000;
583
- // Get real duration from media file, fallback to asset.durationMs, then clip.durationMs
584
569
  let originalDurationMs = asset.durationMs;
585
- // Try to get real duration from file if asset.durationMs is missing or potentially inaccurate
586
570
  const realDuration = await getMediaDuration(asset.uri);
587
- // console.log(realDuration, asset.uri);
588
571
  if (realDuration !== null) {
589
572
  originalDurationMs = Math.round(realDuration * 1000);
590
573
  console.debug(`Dynamic duration detection: ${asset.uri} = ${originalDurationMs}ms (was ${asset.durationMs}ms)`);
@@ -597,105 +580,84 @@ async function compileToFfmpeg(project, opts = {}) {
597
580
  const speedRatio = originalDurationSec / targetDurationSec;
598
581
  let trim;
599
582
  if (track.type === 'audio') {
600
- // For audio tracks, distinguish between pure audio files and audio from video files
601
583
  if (asset.type === 'audio') {
602
- // Pure audio files: always use original speed, just trim to target duration
603
584
  trim = `atrim=start=${start}:duration=${targetDurationSec},asetpts=PTS-STARTPTS`;
604
585
  }
605
586
  else {
606
- // Audio from video files: check if we need to adjust playback speed
607
587
  if (Math.abs(originalDurationSec - targetDurationSec) > 0.01) {
608
- // Duration mismatch: adjust playback speed using atempo
609
588
  trim = `atrim=start=${start}:duration=${originalDurationSec},asetpts=PTS-STARTPTS,atempo=${speedRatio}`;
610
589
  }
611
590
  else {
612
- // Duration matches: use normal trim
613
591
  trim = `atrim=start=${start}:duration=${dur},asetpts=PTS-STARTPTS`;
614
592
  }
615
593
  }
616
594
  }
617
595
  else {
618
- // For video, check if we need to adjust playback speed
619
596
  if (Math.abs(originalDurationSec - targetDurationSec) > 0.01) {
620
- // Duration mismatch: adjust playback speed using setpts
621
597
  trim = `trim=start=${start}:duration=${originalDurationSec},setpts=PTS/(${speedRatio}),fps=${project.settings.fps}`;
622
598
  }
623
599
  else {
624
- // Duration matches: use normal trim
625
600
  trim = `trim=start=${start}:duration=${dur},setpts=PTS-STARTPTS,fps=${project.settings.fps}`;
626
601
  }
627
602
  }
628
603
  const shift = track.type === 'audio'
629
- ? `adelay=${Math.round(clip.startMs)}|${Math.round(clip.startMs)}`
604
+ ? clip.startMs > 0
605
+ ? `adelay=${Math.round(clip.startMs)}:all=1,asetpts=PTS-STARTPTS`
606
+ : ''
630
607
  : hasTransition
631
608
  ? `tpad=start_duration=${transitionDuration}:start_mode=clone`
632
- : ''; // 没有转场时不应用tpad,时长由concat或timeline处理
609
+ : '';
633
610
  // 背景音乐默认 gain
634
611
  if (clip.assetId.includes('bgm')) {
635
612
  const gainEffects = clip.effects?.find(e => e.name === 'gain');
636
613
  if (!gainEffects) {
637
614
  clip.effects = clip.effects || [];
638
- clip.effects.push({
639
- name: 'gain',
640
- params: {
641
- db: -15,
642
- },
643
- });
615
+ clip.effects.push({ name: 'gain', params: { db: -25 } });
644
616
  }
645
617
  }
646
- // Effects mapping (partial demo)
647
618
  const effects = [];
648
619
  if (clip.effects)
649
620
  for (const ef of clip.effects) {
650
621
  switch (ef.name) {
651
622
  case 'fadeIn': {
652
- const params = ef.params;
653
- const d = Number(params?.durationMs ?? 500) / 1000;
623
+ const d = Number(ef.params?.durationMs ?? 500) / 1000;
654
624
  effects.push(track.type === 'audio'
655
625
  ? `afade=t=in:st=0:d=${d}`
656
626
  : `fade=t=in:st=0:d=${d}`);
657
627
  break;
658
628
  }
659
629
  case 'fadeOut': {
660
- const params = ef.params;
661
- const d = Number(params?.durationMs ?? 500) / 1000;
630
+ const d = Number(ef.params?.durationMs ?? 500) / 1000;
662
631
  effects.push(track.type === 'audio'
663
632
  ? `afade=t=out:st=${Math.max(0, dur - d)}:d=${d}`
664
633
  : `fade=t=out:st=${Math.max(0, dur - d)}:d=${d}`);
665
634
  break;
666
635
  }
667
636
  case 'gain': {
668
- const params = ef.params;
669
- const db = Number(params?.db ?? 0) || Number(params?.gain ?? 0);
637
+ const p = ef.params;
638
+ const db = Number(p?.db ?? 0) || Number(p?.gain ?? 0);
670
639
  let gain = Math.pow(10, db / 20);
671
- if (params?.volume) {
672
- gain *= params.volume;
673
- }
674
- // if (track.id.includes('bgm')) {
675
- // gain *= 0.5;
676
- // }
640
+ if (p?.volume)
641
+ gain *= p.volume;
677
642
  effects.push(`volume=${gain.toFixed(2)}`);
678
643
  break;
679
644
  }
680
645
  case 'color': {
681
646
  if (track.type === 'video') {
682
- const params = ef.params;
683
- const p = params || {};
684
- effects.push(`eq=saturation=${p.saturation ?? 1}:contrast=${p.contrast ?? 1}:brightness=${p.brightness ?? 0}`);
647
+ const p = ef.params;
648
+ effects.push(`eq=saturation=${p?.saturation ?? 1}:contrast=${p?.contrast ?? 1}:brightness=${p?.brightness ?? 0}`);
685
649
  }
686
650
  break;
687
651
  }
688
652
  case 'blur': {
689
653
  if (track.type === 'video') {
690
- const params = ef.params;
691
- const s = Number(params?.strength ?? 10);
654
+ const s = Number(ef.params?.strength ?? 10);
692
655
  effects.push(`gblur=sigma=${s}`);
693
656
  }
694
657
  break;
695
658
  }
696
659
  case 'speed': {
697
- const params = ef.params;
698
- const rate = Number(params?.rate ?? 1);
660
+ const rate = Number(ef.params?.rate ?? 1);
699
661
  if (rate !== 1) {
700
662
  if (track.type === 'video')
701
663
  effects.push(`setpts=${(1 / rate).toFixed(6)}*PTS`);
@@ -708,24 +670,17 @@ async function compileToFfmpeg(project, opts = {}) {
708
670
  break;
709
671
  }
710
672
  }
711
- // Filters mapping - apply custom FFmpeg filters
712
- if (clip.filters) {
713
- for (const filter of clip.filters) {
714
- let filterStr = filter.name;
715
- if (filter.params && Object.keys(filter.params).length > 0) {
716
- const paramStr = Object.entries(filter.params)
717
- .map(([key, value]) => `${key}=${value}`)
718
- .join(':');
719
- filterStr += `=${paramStr}`;
720
- }
721
- effects.push(filterStr);
722
- }
723
- }
724
673
  const lIn = newLabel();
725
- const lScale = newLabel();
726
674
  const lOut = newLabel();
727
- fg.push(`${sel}${trim}${effects.length ? ',' + effects.join(',') : ''}[${lIn}]`);
728
- // Scale video clips to project resolution
675
+ const effStr = effects.length ? ',' + effects.join(',') : '';
676
+ // (②)仅音频分支统一 aformat;视频分支不加 aformat
677
+ if (track.type === 'audio') {
678
+ fg.push(`${sel}${trim}${effStr},aformat=sample_rates=48000:channel_layouts=stereo[${lIn}]`);
679
+ }
680
+ else {
681
+ fg.push(`${sel}${trim}${effStr}[${lIn}]`);
682
+ }
683
+ // Scale / shift
729
684
  if (track.type === 'video') {
730
685
  const scaleAndPad = `scale=${project.settings.resolution.width}:${project.settings.resolution.height}:force_original_aspect_ratio=decrease,pad=${project.settings.resolution.width}:${project.settings.resolution.height}:(ow-iw)/2:(oh-ih)/2,setsar=1`;
731
686
  if (shift) {
@@ -740,8 +695,8 @@ async function compileToFfmpeg(project, opts = {}) {
740
695
  fg.push(`[${lIn}]${shift}[${lOut}]`);
741
696
  }
742
697
  else {
743
- // No shift needed, just copy the label
744
- fg.push(`[${lIn}]acopy[${lOut}]`);
698
+ // (④)acopy -> anull
699
+ fg.push(`[${lIn}]anull[${lOut}]`);
745
700
  }
746
701
  }
747
702
  outs.push(`[${lOut}]`);
@@ -761,7 +716,8 @@ async function compileToFfmpeg(project, opts = {}) {
761
716
  else if (outs.length) {
762
717
  if (track.type === 'audio') {
763
718
  const l = newLabel();
764
- fg.push(`${outs.join('')}amix=inputs=${outs.length}:normalize=0[${l}]`);
719
+ // (③)amix 后追加对齐
720
+ fg.push(`${outs.join('')}amix=inputs=${outs.length}:normalize=0,aresample=async=1:first_pts=0,asetpts=N/SR/TB[${l}]`);
765
721
  audioTrackOuts.push(`[${l}]`);
766
722
  }
767
723
  else if (track.type === 'video') {
@@ -782,33 +738,33 @@ async function compileToFfmpeg(project, opts = {}) {
782
738
  }
783
739
  // Simple audio mixing: BGM + Dialog
784
740
  let finalAudioOuts = [...audioTrackOuts];
785
- // If we have multiple audio tracks, mix them together
786
741
  if (audioTrackOuts.length >= 1) {
787
- // Find the first audio track with only 1 clip (likely BGM) and reduce its volume
788
742
  const audioTracks = project.timeline.tracks.filter(t => t.type === 'audio');
789
743
  const bgmTrackIndex = audioTracks.findIndex(t => t.clips[0].assetId.includes('bgm'));
790
744
  const bgmClipTrackIndex = bgmTrackIndex != null
791
745
  ? bgmTrackIndex
792
746
  : audioTracks.findIndex(t => t.clips.length === 1);
793
- // const processedTracks = audioTrackOuts;
794
- const processedTracks = audioTrackOuts.map((track, index) => {
795
- if (index === bgmClipTrackIndex) {
747
+ // label 精确定位 BGM 对应的输出分支,避免被 audioTrackOuts 的顺序变化影响
748
+ const bgmOutLabel = bgmClipTrackIndex >= 0 && bgmClipTrackIndex < audioTrackOuts.length
749
+ ? audioTrackOuts[bgmClipTrackIndex]
750
+ : undefined;
751
+ const processedTracks = audioTrackOuts.map(track => {
752
+ if (track === bgmOutLabel) {
796
753
  // BGM track (single clip audio track)
797
- const volumeLabel = newLabel();
798
754
  const fadeoutLabel = newLabel();
799
- // Get BGM track duration to calculate fadeout start time
800
755
  const bgmTrack = audioTracks[bgmClipTrackIndex];
801
- const bgmClip = bgmTrack.clips[bgmTrack.clips.length - 1]; // 多BGM最后一个音轨
756
+ const bgmClip = bgmTrack.clips[bgmTrack.clips.length - 1]; // 多BGM取最后一个
802
757
  const bgmDurationSec = (bgmClip.startMs + bgmClip.durationMs) / 1000;
803
- const fadeoutStartSec = Math.max(0, bgmDurationSec - 3); // Start fadeout 3 seconds before end
804
- fg.push(`${track}volume=1.0[${volumeLabel}]`);
805
- fg.push(`[${volumeLabel}]afade=t=out:st=${fadeoutStartSec}:d=3[${fadeoutLabel}]`); // Add 3-second fadeout at the end
758
+ const fadeoutStartSec = Math.max(0, bgmDurationSec - 3); // 末尾 3s 淡出
759
+ // 去掉 volume=1.0,避免干扰你前面对 BGM 的任何增益调整
760
+ fg.push(`${track}afade=t=out:st=${fadeoutStartSec}:d=3[${fadeoutLabel}]`);
806
761
  return `[${fadeoutLabel}]`;
807
762
  }
808
763
  return track;
809
764
  });
810
765
  const l = newLabel();
811
- fg.push(`${processedTracks.join('')}amix=inputs=${processedTracks.length}:normalize=0[${l}]`);
766
+ // (③)amix 后追加对齐
767
+ fg.push(`${processedTracks.join('')}amix=inputs=${processedTracks.length}:normalize=0,aresample=async=1:first_pts=0,asetpts=N/SR/TB[${l}]`);
812
768
  finalAudioOuts = [`[${l}]`];
813
769
  }
814
770
  // Calculate total video duration
@@ -847,7 +803,8 @@ async function compileToFfmpeg(project, opts = {}) {
847
803
  const lIn = newLabel();
848
804
  const lOut = newLabel();
849
805
  fg.push(`${sel}atrim=start=0:duration=${dur},asetpts=PTS-STARTPTS[${lIn}]`);
850
- fg.push(`[${lIn}]adelay=${Math.round(tpadSec * 1000)}|${Math.round(tpadSec * 1000)}[${lOut}]`);
806
+ // (①)字幕延迟也用 :all=1,并归零
807
+ fg.push(`[${lIn}]adelay=${Math.round(tpadSec * 1000)}:all=1,asetpts=PTS-STARTPTS[${lOut}]`);
851
808
  dialogAudioOuts.push(`[${lOut}]`);
852
809
  }
853
810
  }
@@ -857,24 +814,23 @@ async function compileToFfmpeg(project, opts = {}) {
857
814
  // Process final audio output
858
815
  let outA = '';
859
816
  if (finalAudioOuts.length > 0) {
860
- // Use the ducked/mixed audio from timeline processing
861
817
  const audioOut = finalAudioOuts.length > 1
862
818
  ? (() => {
863
819
  const l = newLabel();
864
- fg.push(`${finalAudioOuts.join('')}amix=inputs=${finalAudioOuts.length}:normalize=0[${l}]`);
820
+ // (③)amix 后追加对齐
821
+ fg.push(`${finalAudioOuts.join('')}amix=inputs=${finalAudioOuts.length}:normalize=0,aresample=async=1:first_pts=0,asetpts=N/SR/TB[${l}]`);
865
822
  return `[${l}]`;
866
823
  })()
867
824
  : finalAudioOuts[0];
868
- // Audio processing completed (no additional padding)
869
825
  outA = audioOut;
870
826
  }
871
827
  else if (dialogAudioOuts.length > 0) {
872
- // Fallback: use subtitle audio if no timeline audio
873
828
  outA =
874
829
  dialogAudioOuts.length > 1
875
830
  ? (() => {
876
831
  const l = newLabel();
877
- fg.push(`${dialogAudioOuts.join('')}amix=inputs=${dialogAudioOuts.length}:normalize=0[${l}]`);
832
+ // (③)amix 后追加对齐
833
+ fg.push(`${dialogAudioOuts.join('')}amix=inputs=${dialogAudioOuts.length}:normalize=0,aresample=async=1:first_pts=0,asetpts=N/SR/TB[${l}]`);
878
834
  return `[${l}]`;
879
835
  })()
880
836
  : dialogAudioOuts[0];
@@ -884,12 +840,8 @@ async function compileToFfmpeg(project, opts = {}) {
884
840
  let finalV = outV;
885
841
  if (project.subtitles?.length && outV) {
886
842
  const libassOk = strat === 'ass' || (strat === 'auto' && hasLibass(ffmpegBin));
887
- // Default force_style for better CJK wrapping; adjust margins as needed.
888
- // NOTE: kept inside compile scope so it can be customized later if needed.
889
- // 保留默认的 MarginL 和 MarginR,不管是否有 position
890
843
  const defaultForceStyle = `WrapStyle=3,MarginL=60,MarginR=60`;
891
844
  if (libassOk) {
892
- // ASS mode
893
845
  const def = project.subtitles[0]?.style || {};
894
846
  const ass = composeAssFromItems(project.subtitles, def);
895
847
  const assName = opts.subtitlesFileName ?? 'subtitles.ass';
@@ -900,13 +852,10 @@ async function compileToFfmpeg(project, opts = {}) {
900
852
  .replace(/\\/g, '/')
901
853
  .replace(/:/g, '\\:')
902
854
  .replace(/'/g, "\\'");
903
- // Enable Unicode line breaking and provide a global WrapStyle fallback.
904
- // Keep quotes: they are parsed by FFmpeg filter args, not the shell.
905
855
  fg.push(`${finalV}subtitles=filename='${arg}':wrap_unicode=1:force_style='${defaultForceStyle}'[${l}]`);
906
856
  finalV = `[${l}]`;
907
857
  }
908
858
  else if (strat === 'srt') {
909
- // SRT mode
910
859
  const srt = composeSrtFromItems(project.subtitles);
911
860
  const srtName = opts.subtitlesFileName ?? 'subtitles.srt';
912
861
  const srtPath = path_1.default.join(tmpDir, srtName);
@@ -916,12 +865,10 @@ async function compileToFfmpeg(project, opts = {}) {
916
865
  .replace(/\\/g, '/')
917
866
  .replace(/:/g, '\\:')
918
867
  .replace(/'/g, "\\'");
919
- // SRT is also rendered by libass; enable Unicode wrapping here too.
920
868
  fg.push(`${outV}subtitles=filename='${arg}':wrap_unicode=1:force_style='${defaultForceStyle}'[${l}]`);
921
869
  finalV = `[${l}]`;
922
870
  }
923
871
  else {
924
- // drawtext fallback
925
872
  for (const sub of project.subtitles) {
926
873
  const l = newLabel();
927
874
  const ff = sub.style?.fontFamily
@@ -954,7 +901,7 @@ async function compileToFfmpeg(project, opts = {}) {
954
901
  ? '(0+40)'
955
902
  : vertAlign === 'middle'
956
903
  ? '(h/2-th/2)'
957
- : '(h-th-40)'; // bottom
904
+ : '(h-th-40)';
958
905
  const st = (sub.startMs / 1000).toFixed(3);
959
906
  const et = (sub.endMs / 1000).toFixed(3);
960
907
  fg.push(`${finalV}drawtext=text='${txt}'${ff}:fontcolor=${fc}:fontsize=${fs}:fontcolor_outline=${oc}:outline=${ow}:bold=${bo}:x=${exprX}:y=${exprY}:enable='between(t,${st},${et})'[${l}]`);
@@ -973,6 +920,11 @@ async function compileToFfmpeg(project, opts = {}) {
973
920
  PropagateID: (0, uuid_1.v4)(),
974
921
  ReservedCode2: '',
975
922
  };
923
+ // ✅ 计算输出文件,并在缺少扩展名时指定格式 (-f)
924
+ const container = project.export.container || 'mp4';
925
+ const outFile = project.export.outFile || opts.outFile || `output.${container}`;
926
+ // 是否有扩展名(简单判断 basename 里是否包含 “.ext”)
927
+ const hasExt = /\.[a-zA-Z0-9]{2,5}$/.test(require('path').basename(outFile));
976
928
  const args = [
977
929
  ...inputArgs,
978
930
  '-filter_complex',
@@ -996,102 +948,11 @@ async function compileToFfmpeg(project, opts = {}) {
996
948
  'use_metadata_tags',
997
949
  '-metadata',
998
950
  `AIGC=${JSON.stringify(aigcMetadata)}`,
999
- project.export.outFile ||
1000
- opts.outFile ||
1001
- `output.${project.export.container}`,
951
+ // ⬇️ 如果没有扩展名,则明确指定容器格式
952
+ ...(!hasExt ? ['-f', container] : []),
953
+ outFile,
1002
954
  ];
1003
- // Generate formatted command for better readability
1004
- const formatCmd = () => {
1005
- const inputArgs = [];
1006
- let filterComplex = '';
1007
- const outputArgs = [];
1008
- let i = 0;
1009
- while (i < args.length) {
1010
- if (args[i] === '-i') {
1011
- inputArgs.push(`-i ${args[i + 1]}`);
1012
- i += 2;
1013
- }
1014
- else if (args[i] === '-filter_complex') {
1015
- // Format filter_complex content with logical grouping like test-corrected-ffmpeg.sh
1016
- const filterContent = args[i + 1];
1017
- const filters = filterContent
1018
- .split(';')
1019
- .map(filter => filter.trim())
1020
- .filter(filter => filter.length > 0);
1021
- const videoProcessing = [];
1022
- const transitions = [];
1023
- const audioProcessing = [];
1024
- const mixing = [];
1025
- for (const filter of filters) {
1026
- if (/^\[\d+:v\]/.test(filter)) {
1027
- // Video input processing
1028
- videoProcessing.push(` ${filter};`);
1029
- }
1030
- else if (/^\[v\d+\]\[v\d+\]xfade/.test(filter) ||
1031
- /^\[x\d+\]\[v\d+\]xfade/.test(filter)) {
1032
- // Video transitions
1033
- transitions.push(` ${filter};`);
1034
- }
1035
- else if (/^\[\d+:a\]/.test(filter)) {
1036
- // Audio input processing
1037
- audioProcessing.push(` ${filter};`);
1038
- }
1039
- else if (/amix|volume|afade/.test(filter)) {
1040
- // Audio mixing
1041
- mixing.push(` ${filter};`);
1042
- }
1043
- else if (/subtitles/.test(filter)) {
1044
- // Subtitle processing
1045
- mixing.push(` ${filter};`);
1046
- }
1047
- else {
1048
- // Other filters, group with previous category
1049
- if (mixing.length > 0) {
1050
- mixing.push(` ${filter};`);
1051
- }
1052
- else if (audioProcessing.length > 0) {
1053
- audioProcessing.push(` ${filter};`);
1054
- }
1055
- else if (transitions.length > 0) {
1056
- transitions.push(` ${filter};`);
1057
- }
1058
- else {
1059
- videoProcessing.push(` ${filter};`);
1060
- }
1061
- }
1062
- }
1063
- const formattedSections = [];
1064
- if (videoProcessing.length > 0)
1065
- formattedSections.push(videoProcessing.join('\n'));
1066
- if (transitions.length > 0)
1067
- formattedSections.push(transitions.join('\n'));
1068
- if (audioProcessing.length > 0)
1069
- formattedSections.push(audioProcessing.join('\n'));
1070
- if (mixing.length > 0)
1071
- formattedSections.push(mixing.join('\n'));
1072
- const formattedFilter = formattedSections.join('\n').replace(/;$/, ''); // Remove last semicolon
1073
- filterComplex = `-filter_complex "\n${formattedFilter}\n"`;
1074
- i += 2;
1075
- }
1076
- else if (args[i] === '-map') {
1077
- outputArgs.push(`-map ${args[i + 1]}`);
1078
- i += 2;
1079
- }
1080
- else {
1081
- outputArgs.push(args[i]);
1082
- i++;
1083
- }
1084
- }
1085
- const parts = [
1086
- `${ffmpegBin} \\`,
1087
- `${inputArgs.join(' ')} \\`, // Join input args in one line
1088
- ...(filterComplex ? [`${filterComplex} \\`] : []),
1089
- outputArgs.join(' '), // Join output args in one line
1090
- ];
1091
- return parts.join('\n');
1092
- };
1093
955
  const cmd = (0, shell_quote_1.quote)([ffmpegBin, ...args]);
1094
- // const formattedCmd = formatCmd();
1095
956
  return { cmd, args, filterGraph, extraFiles, ffmpegBin };
1096
957
  }
1097
958
  // ===========================