1
0
Fork 0

feat(ffmpeg): 영상·오디오 상세 인코딩 옵션 (코덱·CRF·비트레이트·해상도·오디오 등)

HandBrake/Shutter Encoder급 인코더 옵션을 추가(웹 리서치 7-agent 종합 기반). 코어 + 테스트.

- ConvertOptions: VideoEncodeOptions/AudioEncodeOptions 불변 record + enum(VideoCodec/AudioCodec/
  RateControlMode/AudioRateMode/VideoSpeedPreset/RotateMode/DeinterlaceMode). 영상 24필드·오디오 14필드.
- FfmpegArgBuilder(신규, 순수·테스트가능): 옵션→ffmpeg 출력 인자 매핑.
  * 레이트 컨트롤 5모드: CRF / 평균비트레이트(ABR) / 제약CRF(+maxrate) / CBR / 2패스.
  * 코덱 해소 + 컨테이너 호환(webm→vp9/av1 강제, Auto는 >4096px HEVC).
  * 프리셋 매핑: x264 문자열 / svtav1 숫자 / vp9 cpu-used / NVENC p1~p7.
  * HW 계층(EncoderTier): NVENC/QSV/AMF/CPU별 인자(CRF↔CQ/global_quality/qvbr).
  * -vf 필터 체인(crop→deinterlace→denoise→scale→transpose), 다운스케일 전용(min(W,iw)).
  * 오디오: 코덱별 비트레이트 vs VBR(-q:a), flac compression_level, wav pcm, 샘플레이트/채널/loudnorm/copy.
  * faststart(mp4/mov만), pix_fmt/profile/level/gop/bframes, RawArgs 탈출구.
- FfmpegProvider: RunAsync(bool)→다단계 폴백(NVENC→QSV→AMF→CPU, 계층별 인자 재생성) + 2패스(passlog 격리)
  + 트림(입력 Seek + WithDuration). 현행 자동선택/프레임추출/GPU폴백 동작 보존(Codec=Auto 경로).
- FfmpegArgBuilderTests 23개: 5모드·코덱강제·NVENC매핑·필터순서·오디오모드·faststart·GPU적용 판정.

83 → 106개 테스트 전부 그린, 빌드 0경고/0오류. UI 노출은 후속 슬라이스(GUI 스모크).
This commit is contained in:
Yun Chan 2026-06-02 20:12:16 +09:00
parent 5050551c95
commit 214789b656
4 changed files with 812 additions and 26 deletions

View file

@ -0,0 +1,203 @@
using Everything2Everything.Core;
using Everything2Everything.Core.Converters;
using Xunit;
namespace Everything2Everything.Tests;
/// <summary>
/// FfmpegArgBuilder 인자 생성 단위 테스트 — ffmpeg 실행 없이(헤드리스) 옵션→ffmpeg 인자 매핑을 검증한다.
/// 레이트 컨트롤 5모드, 코덱 해소·컨테이너 호환, 프리셋·NVENC 매핑, -vf 필터 체인, 오디오 모드, faststart 등.
/// </summary>
public class FfmpegArgBuilderTests
{
private static string Build(ConvertOptions o, string ext, EncoderTier tier = EncoderTier.Cpu, int width = 1920)
=> FfmpegArgBuilder.BuildOutputArguments(o, ext, tier, width);
[Fact]
public void Default_Mp4_Crf_Libx264_WithFaststart()
{
var args = Build(new ConvertOptions(), ".mp4");
Assert.Contains("-c:v libx264", args);
Assert.Contains("-crf 23", args);
Assert.Contains("-preset medium", args);
Assert.Contains("-pix_fmt yuv420p", args);
Assert.Contains("-c:a aac", args);
Assert.Contains("-b:a 192k", args);
Assert.Contains("-movflags +faststart", args);
}
[Fact]
public void Faststart_OnlyForMp4Mov_NotMkv()
{
Assert.Contains("+faststart", Build(new ConvertOptions(), ".mov"));
Assert.DoesNotContain("+faststart", Build(new ConvertOptions(), ".mkv"));
Assert.DoesNotContain("+faststart", Build(new ConvertOptions(), ".webm"));
}
[Fact]
public void AutoCodec_HighRes_UsesH265()
{
var args = Build(new ConvertOptions(), ".mp4", EncoderTier.Cpu, width: 7680);
Assert.Contains("-c:v libx265", args);
}
[Fact]
public void Webm_ForcesVp9_AndOpus_WithB0ForCrf()
{
var args = Build(new ConvertOptions(), ".webm");
Assert.Contains("-c:v libvpx-vp9", args);
Assert.Contains("-b:v 0", args); // vp9 CRF는 -b:v 0 동반 필수
Assert.Contains("-c:a libopus", args); // webm 오디오는 opus
}
[Fact]
public void Nvenc_MapsCrfToCq_AndPresetToP4()
{
var args = Build(new ConvertOptions(), ".mp4", EncoderTier.Nvenc);
Assert.Contains("-c:v h264_nvenc", args);
Assert.Contains("-rc vbr", args);
Assert.Contains("-cq 23", args);
Assert.Contains("-preset p4", args);
Assert.DoesNotContain("-crf", args); // NVENC은 CRF 대신 CQ
}
[Fact]
public void AverageBitrate_UsesBv()
{
var o = new ConvertOptions { Video = new VideoEncodeOptions { RateControl = RateControlMode.AverageBitrate, VideoBitrateKbps = 5000 } };
var args = Build(o, ".mp4");
Assert.Contains("-b:v 5000k", args);
Assert.DoesNotContain("-crf", args);
}
[Fact]
public void ConstrainedCrf_AddsMaxrateAndBufsize()
{
var o = new ConvertOptions { Video = new VideoEncodeOptions { RateControl = RateControlMode.ConstrainedCrf, Crf = 20, VideoMaxrateKbps = 8000 } };
var args = Build(o, ".mp4");
Assert.Contains("-crf 20", args);
Assert.Contains("-maxrate 8000k", args);
Assert.Contains("-bufsize 16000k", args); // bufsize = 2x maxrate
}
[Fact]
public void Cbr_AddsMinMaxBufsize()
{
var o = new ConvertOptions { Video = new VideoEncodeOptions { RateControl = RateControlMode.Cbr, VideoBitrateKbps = 4000 } };
var args = Build(o, ".mp4");
Assert.Contains("-minrate 4000k", args);
Assert.Contains("-maxrate 4000k", args);
Assert.Contains("-bufsize 8000k", args);
}
[Fact]
public void FilterChain_OrdersCropDeinterlaceScaleTranspose()
{
var o = new ConvertOptions
{
Video = new VideoEncodeOptions
{
Crop = "640:480:0:0",
Deinterlace = DeinterlaceMode.Yadif,
ScaleWidth = 1280,
DownscaleOnly = false,
Rotate = RotateMode.Cw90,
}
};
var args = Build(o, ".mp4");
Assert.Contains("-vf \"crop=640:480:0:0,yadif,scale=1280:-2,transpose=1\"", args);
}
[Fact]
public void Scale_DownscaleOnly_UsesMinExpression()
{
var o = new ConvertOptions { Video = new VideoEncodeOptions { ScaleWidth = 1280, DownscaleOnly = true } };
Assert.Contains("scale='min(1280,iw)':-2", Build(o, ".mp4"));
}
[Fact]
public void AudioOnly_Mp3_AddsVnAndLame()
{
var args = Build(new ConvertOptions(), ".mp3");
Assert.Contains("-vn", args); // 비디오 스트림 제거
Assert.Contains("-c:a libmp3lame", args);
Assert.Contains("-b:a 192k", args);
Assert.DoesNotContain("-c:v", args);
}
[Fact]
public void Mp3_VbrMode_UsesQa_NotBa()
{
var o = new ConvertOptions { Audio = new AudioEncodeOptions { RateMode = AudioRateMode.Vbr, Mp3VbrQuality = 2 } };
var args = Build(o, ".mp3");
Assert.Contains("-q:a 2", args);
Assert.DoesNotContain("-b:a", args);
}
[Fact]
public void Flac_UsesCompressionLevel_NoBitrate()
{
var args = Build(new ConvertOptions(), ".flac");
Assert.Contains("-c:a flac", args);
Assert.Contains("-compression_level 5", args);
Assert.DoesNotContain("-b:a", args);
}
[Fact]
public void Wav_UsesPcmCodec_NoBitrate()
{
var args = Build(new ConvertOptions(), ".wav");
Assert.Contains("-c:a pcm_s16le", args);
Assert.DoesNotContain("-b:a", args);
}
[Fact]
public void SampleRateChannels_AndLoudnorm()
{
var o = new ConvertOptions { Audio = new AudioEncodeOptions { SampleRate = 48000, Channels = 2, Loudnorm = true } };
var args = Build(o, ".m4a");
Assert.Contains("-ar 48000", args);
Assert.Contains("-ac 2", args);
Assert.Contains("-af loudnorm=I=-16:TP=-1.5:LRA=11", args);
}
[Fact]
public void AudioCopy_PassthroughInVideo()
{
var o = new ConvertOptions { Audio = new AudioEncodeOptions { Copy = true } };
var args = Build(o, ".mkv");
Assert.Contains("-c:a copy", args);
}
[Fact]
public void CopyVideoCodec_NoReencodeArgs()
{
var o = new ConvertOptions { Video = new VideoEncodeOptions { Codec = VideoCodec.Copy } };
var args = Build(o, ".mp4");
Assert.Contains("-c:v copy", args);
Assert.DoesNotContain("-crf", args);
Assert.DoesNotContain("-preset", args);
}
[Theory]
[InlineData(VideoCodec.Auto, ".mp4", 1920, VideoCodec.H264)]
[InlineData(VideoCodec.Auto, ".mp4", 7680, VideoCodec.H265)]
[InlineData(VideoCodec.H264, ".webm", 1920, VideoCodec.Vp9)] // webm은 h264 불가 → vp9 강제
[InlineData(VideoCodec.Av1, ".webm", 1920, VideoCodec.Av1)]
[InlineData(VideoCodec.H265, ".mp4", 1920, VideoCodec.H265)]
public void ResolveVideoCodec_EnforcesContainerCompat(VideoCodec requested, string ext, int width, VideoCodec expected)
{
Assert.Equal(expected, FfmpegArgBuilder.ResolveVideoCodec(requested, ext, width));
}
[Fact]
public void GpuApplicable_OnlyForH26xMp4Family_8bit()
{
var v = new VideoEncodeOptions();
Assert.True(FfmpegArgBuilder.GpuApplicable(v, VideoCodec.H264, ".mp4"));
Assert.True(FfmpegArgBuilder.GpuApplicable(v, VideoCodec.H265, ".mkv"));
Assert.False(FfmpegArgBuilder.GpuApplicable(v, VideoCodec.Vp9, ".webm")); // vp9 HW 미적용
Assert.False(FfmpegArgBuilder.GpuApplicable(v, VideoCodec.H264, ".avi")); // avi 미적용
Assert.False(FfmpegArgBuilder.GpuApplicable(v with { PixelFormat = "yuv420p10le" }, VideoCodec.H265, ".mp4")); // 10bit CPU 선호
}
}