diff --git a/src/Everything2Everything.Core/ConvertOptions.cs b/src/Everything2Everything.Core/ConvertOptions.cs index 34eb93a..09da632 100644 --- a/src/Everything2Everything.Core/ConvertOptions.cs +++ b/src/Everything2Everything.Core/ConvertOptions.cs @@ -78,6 +78,12 @@ public sealed record ConvertOptions public AiOptions Ai { get; init; } = new(); + /// 영상 인코딩 상세 옵션(FfmpegProvider). 출력이 영상 컨테이너일 때 적용. + public VideoEncodeOptions Video { get; init; } = new(); + + /// 오디오 인코딩 상세 옵션(FfmpegProvider). 영상의 오디오 트랙 + 오디오 전용 출력에 적용. + public AudioEncodeOptions Audio { get; init; } = new(); + public static ConvertOptions Quick() => new(); } @@ -172,3 +178,133 @@ public sealed record AiOptions public int MaxOutputTokens { get; init; } = 2000; } + +// ── 영상·오디오 인코딩 옵션 (FfmpegProvider) ────────────────────────────────────────────── + +public enum VideoCodec { Auto, H264, H265, Vp9, Av1, Copy } +public enum AudioCodec { Auto, Aac, Mp3, Opus, Vorbis, Flac, Pcm, Copy } + +/// 레이트 컨트롤(품질/용량 통제) 모드. +public enum RateControlMode +{ + /// 상수 품질(CRF) — 품질만 정하고 용량은 가변. 가장 일반적. + Crf, + /// 평균 비트레이트(ABR) — 목표 비트레이트 1패스. + AverageBitrate, + /// 제약된 CRF — CRF 품질 + 최대 비트레이트 상한(스트리밍). + ConstrainedCrf, + /// 고정 비트레이트(CBR) — 방송/스트리밍. + Cbr, + /// 2패스 — 목표 비트레이트에서 최고 품질(2회 인코딩). + TwoPass, +} + +public enum AudioRateMode { Bitrate, Vbr } + +public enum VideoSpeedPreset { UltraFast, SuperFast, VeryFast, Faster, Fast, Medium, Slow, Slower, VerySlow } + +public enum RotateMode { None, Cw90, Ccw90, Rotate180, FlipH, FlipV } + +public enum DeinterlaceMode { Off, Yadif, Bwdif } + +/// 영상 인코딩 상세 옵션. 모두 불변(get;init). 출력이 영상 컨테이너(mp4/mkv/webm/mov/avi)일 때만 적용. +public sealed record VideoEncodeOptions +{ + public VideoCodec Codec { get; init; } = VideoCodec.Auto; + public RateControlMode RateControl { get; init; } = RateControlMode.Crf; + + /// CRF 값(낮을수록 고화질). x264/x265 0~51, vp9/av1 0~63. + public int Crf { get; init; } = 23; + + /// 비트레이트 모드의 목표 비디오 비트레이트(kbps). + public int? VideoBitrateKbps { get; init; } + + /// VBV 최대 비트레이트 상한(kbps). ConstrainedCrf/제약 시. bufsize는 2배 자동. + public int? VideoMaxrateKbps { get; init; } + + public VideoSpeedPreset Preset { get; init; } = VideoSpeedPreset.Medium; + + /// x264/x265: film/animation/grain/zerolatency 등. av1: 0|1|2. + public string? Tune { get; init; } + + /// x264: baseline/main/high/high10 …, x265: main/main10 …. null=자동. + public string? Profile { get; init; } + + /// 예: 4.0 / 5.1. null=자동. + public string? Level { get; init; } + + /// yuv420p(8bit·최대호환), yuv420p10le(10bit), yuv444p … + public string PixelFormat { get; init; } = "yuv420p"; + + /// 키프레임 간격(GOP, 프레임 수). null=인코더 자동. + public int? GopSize { get; init; } + + /// B-프레임 수(CPU 코덱). null=자동. + public int? BFrames { get; init; } + + public bool Lossless { get; init; } + + /// 출력 가로 해상도(px). null=원본 유지. 높이는 종횡비 자동(-2). + public int? ScaleWidth { get; init; } + + /// 입력이 더 클 때만 축소(업스케일 방지). + public bool DownscaleOnly { get; init; } = true; + + public double? Fps { get; init; } + + /// crop=w:h:x:y (x/y 생략 시 중앙). null=없음. + public string? Crop { get; init; } + + public RotateMode Rotate { get; init; } = RotateMode.None; + public DeinterlaceMode Deinterlace { get; init; } = DeinterlaceMode.Off; + + /// hqdn3d | nlmeans. null=없음. + public string? Denoise { get; init; } + + /// mp4/mov 웹 스트리밍 최적화(moov atom 앞으로). + public bool FastStart { get; init; } = true; + + /// GPU(NVENC 등) 적응형 양자화로 화질 향상. + public bool SpatialAq { get; init; } = true; + + public TimeSpan? TrimStart { get; init; } + public TimeSpan? TrimEnd { get; init; } + + /// 미노출 옵션을 위한 원시 ffmpeg 출력 인자(예: "-x265-params no-sao=1"). + public string? RawArgs { get; init; } +} + +/// 오디오 인코딩 상세 옵션. 영상의 오디오 트랙 + 오디오 전용 출력에 적용. +public sealed record AudioEncodeOptions +{ + public AudioCodec Codec { get; init; } = AudioCodec.Auto; + public AudioRateMode RateMode { get; init; } = AudioRateMode.Bitrate; + + public int AudioBitrateKbps { get; init; } = 192; + + /// MP3(LAME) VBR 품질 0~9 (낮을수록 고품질). + public int Mp3VbrQuality { get; init; } = 2; + + /// OGG Vorbis VBR 품질 -1~10 (높을수록 고품질). + public double VorbisVbrQuality { get; init; } = 5.0; + + /// FLAC 압축 레벨 0~12 (무손실, 크기/속도만 변화). + public int FlacCompressionLevel { get; init; } = 5; + + /// WAV PCM 포맷: pcm_s16le(16bit) / pcm_s24le(24bit) / pcm_f32le(32bit float). + public string PcmFormat { get; init; } = "pcm_s16le"; + + public int? SampleRate { get; init; } + + /// 채널 수: 1(모노)/2(스테레오)/6(5.1)/8(7.1). null=원본. + public int? Channels { get; init; } + + /// EBU R128 음량 정규화(loudnorm). + public bool Loudnorm { get; init; } + + /// 단순 음량 배수(Loudnorm과 상호배타). + public double? VolumeGain { get; init; } + + /// 오디오 원본 무손실 패스스루(영상만 재인코딩 — 속도↑). + public bool Copy { get; init; } +} diff --git a/src/Everything2Everything.Core/Converters/FfmpegArgBuilder.cs b/src/Everything2Everything.Core/Converters/FfmpegArgBuilder.cs new file mode 100644 index 0000000..863a143 --- /dev/null +++ b/src/Everything2Everything.Core/Converters/FfmpegArgBuilder.cs @@ -0,0 +1,379 @@ +using System.Globalization; +using System.Text; +using Everything2Everything.Core.Providers; + +namespace Everything2Everything.Core.Converters; + +/// 하드웨어 인코더 계층 — NVENC→QSV→AMF→CPU 순으로 폴백. +public enum EncoderTier { Nvenc, Qsv, Amf, Cpu } + +/// +/// VideoEncodeOptions/AudioEncodeOptions를 실제 ffmpeg 출력 인자 문자열로 변환한다. +/// WPF/FFMpegCore 비의존 순수 함수 모음이라 헤드리스 단위 테스트가 가능하다(인자 생성 검증). +/// 인코더 계층(EncoderTier)별로 동일 옵션을 다른 인자로 매핑한다(소프트웨어 CRF/preset ↔ NVENC -cq/-preset p1~p7 등). +/// +public static class FfmpegArgBuilder +{ + private static string Ci(double v) => v.ToString(CultureInfo.InvariantCulture); + + /// 요청 코덱 + 컨테이너 호환 + 자동선택(고해상도 HEVC)을 반영해 실제 사용할 코덱을 해소. + public static VideoCodec ResolveVideoCodec(VideoCodec requested, string outExt, int sourceWidth) + { + outExt = ConversionPair.Normalize(outExt); + if (requested == VideoCodec.Copy) return VideoCodec.Copy; + + // webm은 vp9/av1만 허용 — 잘못된 조합을 원천 차단 + if (outExt == ".webm") + return requested is VideoCodec.Av1 ? VideoCodec.Av1 : VideoCodec.Vp9; + + if (requested != VideoCodec.Auto) return requested; + + // Auto: 8K 등 4096px 초과는 H.264가 인코딩 못 하므로 HEVC로(현행 동작 보존) + return sourceWidth > 4096 ? VideoCodec.H265 : VideoCodec.H264; + } + + /// 해소된 코덱 + 계층 → ffmpeg 인코더 이름. + public static string VideoEncoderName(VideoCodec codec, EncoderTier tier) => codec switch + { + VideoCodec.Copy => "copy", + VideoCodec.Vp9 => "libvpx-vp9", + VideoCodec.Av1 => "libsvtav1", + VideoCodec.H264 => tier switch + { + EncoderTier.Nvenc => "h264_nvenc", + EncoderTier.Qsv => "h264_qsv", + EncoderTier.Amf => "h264_amf", + _ => "libx264", + }, + VideoCodec.H265 => tier switch + { + EncoderTier.Nvenc => "hevc_nvenc", + EncoderTier.Qsv => "hevc_qsv", + EncoderTier.Amf => "hevc_amf", + _ => "libx265", + }, + _ => "libx264", + }; + + /// GPU(HW 인코더) 시도가 가능한 조합인가. h264/h265 + mp4/mkv/mov + 8bit 4:2:0만. + public static bool GpuApplicable(VideoEncodeOptions v, VideoCodec resolved, string outExt) + { + outExt = ConversionPair.Normalize(outExt); + if (resolved is not (VideoCodec.H264 or VideoCodec.H265)) return false; + if (outExt is not (".mp4" or ".mkv" or ".mov")) return false; + if (v.Lossless) return false; + // 10bit/4:4:4는 HW 인코더 지원이 제한적 — CPU 경로 선호 + if (!string.IsNullOrEmpty(v.PixelFormat) && (v.PixelFormat.Contains("10le") || v.PixelFormat.Contains("444"))) return false; + return true; + } + + /// 오디오 코덱(Auto면 출력 확장자/컨테이너에서 매핑) → ffmpeg 인코더 이름. + public static string ResolveAudioEncoder(AudioCodec codec, string outExt) + { + outExt = ConversionPair.Normalize(outExt); + if (codec == AudioCodec.Copy) return "copy"; + if (codec != AudioCodec.Auto) + return codec switch + { + AudioCodec.Aac => "aac", + AudioCodec.Mp3 => "libmp3lame", + AudioCodec.Opus => "libopus", + AudioCodec.Vorbis => "libvorbis", + AudioCodec.Flac => "flac", + AudioCodec.Pcm => "pcm_s16le", + _ => "aac", + }; + + // Auto: 오디오 전용 컨테이너는 확장자로, 영상 컨테이너는 webm→opus / 그 외 aac + return outExt switch + { + ".mp3" => "libmp3lame", + ".aac" or ".m4a" => "aac", + ".opus" => "libopus", + ".ogg" => "libvorbis", + ".flac" => "flac", + ".wav" => "pcm_s16le", + ".webm" => "libopus", + _ => "aac", + }; + } + + public static bool IsAudioContainer(string outExt) + => ConversionPair.Normalize(outExt) is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg" or ".flac" or ".wav"; + + /// 출력 인자 전체(-c:v … -vf … -c:a … -movflags …)를 한 문자열로 구성. + public static string BuildOutputArguments(ConvertOptions options, string outExt, EncoderTier tier, int sourceWidth) + { + outExt = ConversionPair.Normalize(outExt); + var args = new List(); + + if (IsAudioContainer(outExt)) + { + args.Add("-vn"); // 비디오 스트림 제거(오디오 전용 출력) + AddAudioArgs(args, options.Audio, outExt); + return Join(args); + } + + var v = options.Video; + var codec = ResolveVideoCodec(v.Codec, outExt, sourceWidth); + args.Add($"-c:v {VideoEncoderName(codec, tier)}"); + + if (codec != VideoCodec.Copy) + { + AddRateControl(args, v, codec, tier); + args.Add(PresetArg(v.Preset, codec, tier)); + + if (!string.IsNullOrWhiteSpace(v.Tune)) args.Add(TuneArg(v.Tune!, codec)); + if (!string.IsNullOrWhiteSpace(v.Profile)) args.Add($"-profile:v {v.Profile}"); + if (!string.IsNullOrWhiteSpace(v.Level)) args.Add($"-level {v.Level}"); + if (!string.IsNullOrWhiteSpace(v.PixelFormat)) args.Add($"-pix_fmt {v.PixelFormat}"); + if (v.GopSize is int g and > 0) args.Add($"-g {g}"); + if (v.BFrames is int bf and >= 0 && tier == EncoderTier.Cpu) args.Add($"-bf {bf}"); + if (tier != EncoderTier.Cpu && v.SpatialAq) AddSpatialAq(args, tier); + + var vf = BuildVideoFilterChain(v); + if (vf.Length > 0) args.Add($"-vf \"{vf}\""); + if (v.Fps is double fps and > 0) args.Add($"-r {Ci(fps)}"); + + if (!string.IsNullOrWhiteSpace(v.RawArgs)) args.Add(v.RawArgs!.Trim()); + } + + // 오디오 트랙 + AddAudioArgs(args, options.Audio, outExt); + + // faststart (mp4/mov만) + if (v.FastStart && outExt is ".mp4" or ".mov") args.Add("-movflags +faststart"); + + return Join(args); + } + + // ── 레이트 컨트롤 ──────────────────────────────────────────────────────────────────── + private static void AddRateControl(List args, VideoEncodeOptions v, VideoCodec codec, EncoderTier tier) + { + if (v.Lossless) + { + if (codec == VideoCodec.H264 && tier == EncoderTier.Cpu) args.Add("-crf 0"); + else if (codec == VideoCodec.H265 && tier == EncoderTier.Cpu) args.Add("-x265-params lossless=1"); + else args.Add("-cq 0"); // HW 근사 + return; + } + + var b = v.VideoBitrateKbps ?? 0; + var max = v.VideoMaxrateKbps ?? 0; + var needsB0 = codec is VideoCodec.Vp9 or VideoCodec.Av1; // CRF 활성 신호 + + switch (v.RateControl) + { + case RateControlMode.Crf: + if (tier == EncoderTier.Cpu) + { + args.Add($"-crf {v.Crf}"); + if (needsB0) args.Add("-b:v 0"); + } + else if (tier == EncoderTier.Nvenc) { args.Add("-rc vbr"); args.Add($"-cq {v.Crf}"); } + else if (tier == EncoderTier.Qsv) { args.Add($"-global_quality {v.Crf}"); } + else { args.Add("-rc qvbr"); args.Add($"-qvbr_quality_level {v.Crf}"); } // AMF + break; + + case RateControlMode.AverageBitrate: + case RateControlMode.TwoPass: // 2패스는 provider가 -pass 1/2를 덧붙임 + if (tier == EncoderTier.Nvenc) args.Add("-rc vbr"); + args.Add($"-b:v {b}k"); + break; + + case RateControlMode.ConstrainedCrf: + if (tier == EncoderTier.Cpu) + { + args.Add($"-crf {v.Crf}"); + if (needsB0) args.Add("-b:v 0"); + } + else if (tier == EncoderTier.Nvenc) { args.Add("-rc vbr"); args.Add($"-cq {v.Crf}"); } + else if (tier == EncoderTier.Qsv) { args.Add($"-global_quality {v.Crf}"); } + else { args.Add("-rc qvbr"); args.Add($"-qvbr_quality_level {v.Crf}"); } + if (max > 0) { args.Add($"-maxrate {max}k"); args.Add($"-bufsize {max * 2}k"); } + break; + + case RateControlMode.Cbr: + if (tier == EncoderTier.Nvenc) { args.Add("-rc cbr"); args.Add($"-b:v {b}k"); } + else { args.Add($"-b:v {b}k"); args.Add($"-minrate {b}k"); args.Add($"-maxrate {b}k"); args.Add($"-bufsize {b * 2}k"); } + break; + } + } + + // ── 프리셋 ────────────────────────────────────────────────────────────────────────── + private static string PresetArg(VideoSpeedPreset preset, VideoCodec codec, EncoderTier tier) + { + if (tier == EncoderTier.Nvenc) return $"-preset {NvencPreset(preset)} -tune hq"; + if (tier == EncoderTier.Amf) return $"-quality {AmfQuality(preset)}"; + if (tier == EncoderTier.Qsv) return $"-preset {X26xPreset(preset)}"; + + // CPU + return codec switch + { + VideoCodec.Av1 => $"-preset {SvtAv1Preset(preset)}", + VideoCodec.Vp9 => $"-deadline good -cpu-used {Vp9CpuUsed(preset)}", + _ => $"-preset {X26xPreset(preset)}", + }; + } + + private static string X26xPreset(VideoSpeedPreset p) => p switch + { + VideoSpeedPreset.UltraFast => "ultrafast", + VideoSpeedPreset.SuperFast => "superfast", + VideoSpeedPreset.VeryFast => "veryfast", + VideoSpeedPreset.Faster => "faster", + VideoSpeedPreset.Fast => "fast", + VideoSpeedPreset.Medium => "medium", + VideoSpeedPreset.Slow => "slow", + VideoSpeedPreset.Slower => "slower", + VideoSpeedPreset.VerySlow => "veryslow", + _ => "medium", + }; + + // libsvtav1: 낮을수록 느림·고품질 + private static int SvtAv1Preset(VideoSpeedPreset p) => p switch + { + VideoSpeedPreset.UltraFast => 12, + VideoSpeedPreset.SuperFast => 11, + VideoSpeedPreset.VeryFast => 10, + VideoSpeedPreset.Faster => 9, + VideoSpeedPreset.Fast => 8, + VideoSpeedPreset.Medium => 7, + VideoSpeedPreset.Slow => 5, + VideoSpeedPreset.Slower => 4, + VideoSpeedPreset.VerySlow => 2, + _ => 7, + }; + + // vp9 -cpu-used: 낮을수록 느림·고품질 (0~8) + private static int Vp9CpuUsed(VideoSpeedPreset p) => p switch + { + VideoSpeedPreset.UltraFast => 8, + VideoSpeedPreset.SuperFast => 6, + VideoSpeedPreset.VeryFast => 5, + VideoSpeedPreset.Faster => 4, + VideoSpeedPreset.Fast => 3, + VideoSpeedPreset.Medium => 2, + VideoSpeedPreset.Slow => 1, + VideoSpeedPreset.Slower => 1, + VideoSpeedPreset.VerySlow => 0, + _ => 2, + }; + + // NVENC p1(가장 빠름)~p7(가장 느림·고품질) + private static string NvencPreset(VideoSpeedPreset p) => p switch + { + VideoSpeedPreset.UltraFast => "p1", + VideoSpeedPreset.SuperFast => "p1", + VideoSpeedPreset.VeryFast => "p2", + VideoSpeedPreset.Faster => "p3", + VideoSpeedPreset.Fast => "p3", + VideoSpeedPreset.Medium => "p4", + VideoSpeedPreset.Slow => "p5", + VideoSpeedPreset.Slower => "p6", + VideoSpeedPreset.VerySlow => "p7", + _ => "p4", + }; + + private static string AmfQuality(VideoSpeedPreset p) => p switch + { + VideoSpeedPreset.UltraFast or VideoSpeedPreset.SuperFast or VideoSpeedPreset.VeryFast => "speed", + VideoSpeedPreset.Slow or VideoSpeedPreset.Slower or VideoSpeedPreset.VerySlow => "quality", + _ => "balanced", + }; + + private static string TuneArg(string tune, VideoCodec codec) + => codec == VideoCodec.Av1 ? $"-svtav1-params tune={tune}" : $"-tune {tune}"; + + private static void AddSpatialAq(List args, EncoderTier tier) + { + switch (tier) + { + case EncoderTier.Nvenc: args.Add("-spatial-aq 1"); args.Add("-aq-strength 8"); args.Add("-rc-lookahead 20"); break; + case EncoderTier.Amf: args.Add("-vbaq 1"); break; + case EncoderTier.Qsv: args.Add("-extbrc 1"); break; + } + } + + // ── 영상 필터 체인 (-vf) ──────────────────────────────────────────────────────────── + /// crop → deinterlace → denoise → scale → transpose/flip 순으로 콤마 체인 구성. + public static string BuildVideoFilterChain(VideoEncodeOptions v) + { + var parts = new List(); + + if (!string.IsNullOrWhiteSpace(v.Crop)) parts.Add($"crop={v.Crop}"); + + switch (v.Deinterlace) + { + case DeinterlaceMode.Yadif: parts.Add("yadif"); break; + case DeinterlaceMode.Bwdif: parts.Add("bwdif=mode=send_frame:parity=auto"); break; + } + + if (!string.IsNullOrWhiteSpace(v.Denoise)) parts.Add(v.Denoise!); + + if (v.ScaleWidth is int w and > 0) + parts.Add(v.DownscaleOnly ? $"scale='min({w},iw)':-2" : $"scale={w}:-2"); + + switch (v.Rotate) + { + case RotateMode.Cw90: parts.Add("transpose=1"); break; + case RotateMode.Ccw90: parts.Add("transpose=2"); break; + case RotateMode.Rotate180: parts.Add("transpose=1,transpose=1"); break; + case RotateMode.FlipH: parts.Add("hflip"); break; + case RotateMode.FlipV: parts.Add("vflip"); break; + } + + return string.Join(",", parts); + } + + // ── 오디오 인자 ───────────────────────────────────────────────────────────────────── + private static void AddAudioArgs(List args, AudioEncodeOptions a, string outExt) + { + if (a.Copy || a.Codec == AudioCodec.Copy) { args.Add("-c:a copy"); return; } + + var encoder = ResolveAudioEncoder(a.Codec, outExt); + args.Add($"-c:a {encoder}"); + + if (encoder.StartsWith("pcm_")) + { + // WAV: PcmFormat이 코덱 자체 — 비트레이트/품질 개념 없음 + } + else if (encoder == "flac") + { + args.Add($"-compression_level {Math.Clamp(a.FlacCompressionLevel, 0, 12)}"); + } + else if (a.RateMode == AudioRateMode.Vbr) + { + switch (encoder) + { + case "libmp3lame": args.Add($"-q:a {Math.Clamp(a.Mp3VbrQuality, 0, 9)}"); break; + case "libvorbis": args.Add($"-q:a {Ci(Math.Clamp(a.VorbisVbrQuality, -1, 10))}"); break; + case "libopus": args.Add("-vbr on"); args.Add($"-b:a {a.AudioBitrateKbps}k"); break; + default: args.Add($"-b:a {a.AudioBitrateKbps}k"); break; // aac native VBR은 experimental → CBR + } + } + else + { + args.Add($"-b:a {a.AudioBitrateKbps}k"); + } + + if (a.SampleRate is int sr and > 0) args.Add($"-ar {sr}"); + if (a.Channels is int ch and > 0) args.Add($"-ac {ch}"); + + // 오디오 필터(-af) — Loudnorm 우선, 아니면 VolumeGain + if (a.Loudnorm) args.Add("-af loudnorm=I=-16:TP=-1.5:LRA=11"); + else if (a.VolumeGain is double vol && Math.Abs(vol - 1.0) > 0.001) args.Add($"-af volume={Ci(vol)}"); + } + + private static string Join(List args) + { + var sb = new StringBuilder(); + foreach (var a in args) + { + if (string.IsNullOrWhiteSpace(a)) continue; + if (sb.Length > 0) sb.Append(' '); + sb.Append(a.Trim()); + } + return sb.ToString(); + } +} diff --git a/src/Everything2Everything.Core/Converters/FfmpegProvider.cs b/src/Everything2Everything.Core/Converters/FfmpegProvider.cs index a7136bf..47330f7 100644 --- a/src/Everything2Everything.Core/Converters/FfmpegProvider.cs +++ b/src/Everything2Everything.Core/Converters/FfmpegProvider.cs @@ -99,46 +99,114 @@ public sealed class FfmpegProvider : IConverterProvider } catch { /* 분석 실패 시 진행률·코덱은 기본값 사용 */ } - // H.264는 4096px 초과(>4K, 예: 8K 7680px)를 인코딩하지 못해 "encoder를 열 수 없음"으로 즉시 실패한다. - // → 고해상도 입력은 8K까지 지원하는 HEVC(H.265)로 인코딩한다. - var highRes = videoWidth > 4096; - var h264Container = outExt is ".mp4" or ".mkv" or ".mov"; - // GPU(NVENC) 가속은 mp4/mkv/mov 컨테이너에만 적용 시도하고, 실패하면 CPU로 폴백 - var tryGpu = options.VideoPreferGpu && h264Container; + var v = options.Video; + var isAudioOut = FfmpegArgBuilder.IsAudioContainer(outExt); + var resolvedCodec = FfmpegArgBuilder.ResolveVideoCodec(v.Codec, outExt, videoWidth); - async Task RunAsync(bool gpu) + // 입력측 트림(시킹) 적용 — VideoEncodeOptions.TrimStart/TrimEnd는 영상·오디오 출력 공통. + void AddInput(FFMpegArgumentOptions inOpt) { + if (v.TrimStart is TimeSpan ss && ss > TimeSpan.Zero) inOpt.Seek(ss); + } + void AddDuration(FFMpegArgumentOptions o) + { + if (v.TrimEnd is TimeSpan en && en > TimeSpan.Zero) + { + var start = v.TrimStart ?? TimeSpan.Zero; + if (en > start) o.WithDuration(en - start); + } + } + + // 한 계층(인코더)으로 단일 패스 실행. 인자는 FfmpegArgBuilder가 계층별로 생성. + async Task RunSingleAsync(EncoderTier tier) + { + var outputArgs = FfmpegArgBuilder.BuildOutputArguments(options, outExt, tier, videoWidth); var processor = FFMpegArguments - .FromFileInput(sourcePath) - .OutputToFile(outPath, overwrite: true, o => - { - if (gpu) o.WithVideoCodec(highRes ? "hevc_nvenc" : "h264_nvenc"); - else if (h264Container) o.WithVideoCodec(highRes ? "libx265" : "libx264"); - // webm/avi/gif 등 비(非) H.264/HEVC 컨테이너는 컨테이너 기본 코덱(vp9 등)을 사용 - }) + .FromFileInput(sourcePath, true, AddInput) + .OutputToFile(outPath, overwrite: true, o => { o.WithCustomArgument(outputArgs); AddDuration(o); }) .CancellableThrough(cancellationToken); if (total > TimeSpan.Zero) - processor = processor.NotifyOnProgress( - percent => progress?.Report(Math.Clamp(percent / 100.0, 0, 1)), total); + processor = processor.NotifyOnProgress(p => progress?.Report(Math.Clamp(p / 100.0, 0, 1)), total); return await processor.ProcessAsynchronously(throwOnError: true, ffOptions).ConfigureAwait(false); } - bool ok; - try + // 2패스(목표 비트레이트 최고 품질) — ffmpeg 2회 호출, passlog는 임시 경로로 격리. + async Task RunTwoPassAsync() { - ok = await RunAsync(tryGpu).ConfigureAwait(false); + var passLog = Path.Combine(Path.GetTempPath(), "e2e_2pass_" + Guid.NewGuid().ToString("N")); + try + { + var args = FfmpegArgBuilder.BuildOutputArguments(options, outExt, EncoderTier.Cpu, videoWidth); + await FFMpegArguments + .FromFileInput(sourcePath, true, AddInput) + .OutputToFile("NUL", overwrite: true, o => + { + o.WithCustomArgument(args); + o.WithCustomArgument($"-pass 1 -passlogfile \"{passLog}\" -an -f null"); + AddDuration(o); + }) + .CancellableThrough(cancellationToken) + .ProcessAsynchronously(throwOnError: true, ffOptions).ConfigureAwait(false); + progress?.Report(0.5); + + var proc2 = FFMpegArguments + .FromFileInput(sourcePath, true, AddInput) + .OutputToFile(outPath, overwrite: true, o => + { + o.WithCustomArgument(args); + o.WithCustomArgument($"-pass 2 -passlogfile \"{passLog}\""); + AddDuration(o); + }) + .CancellableThrough(cancellationToken); + if (total > TimeSpan.Zero) + proc2 = proc2.NotifyOnProgress(p => progress?.Report(Math.Clamp(0.5 + p / 200.0, 0, 1)), total); + return await proc2.ProcessAsynchronously(throwOnError: true, ffOptions).ConfigureAwait(false); + } + finally + { + try + { + foreach (var f in Directory.GetFiles(Path.GetTempPath(), Path.GetFileName(passLog) + "*")) + File.Delete(f); + } + catch { /* 임시 로그 정리 실패 무시 */ } + } } - catch when (tryGpu && !cancellationToken.IsCancellationRequested) + + var twoPass = !isAudioOut && v.RateControl == RateControlMode.TwoPass && v.VideoBitrateKbps is > 0; + + // 시도할 인코더 계층: GPU 적용 가능하면 NVENC→QSV→AMF→CPU, 아니면 CPU만. + var gpuOk = !isAudioOut && options.VideoPreferGpu + && FfmpegArgBuilder.GpuApplicable(v, resolvedCodec, outExt) && !twoPass; + var tiers = gpuOk + ? new[] { EncoderTier.Nvenc, EncoderTier.Qsv, EncoderTier.Amf, EncoderTier.Cpu } + : new[] { EncoderTier.Cpu }; + + bool ok = false; + Exception? lastEx = null; + for (var i = 0; i < tiers.Length; i++) { - // NVENC 미지원(GPU 없음 등) → CPU 인코더로 폴백 - progress?.Report(0.05); - ok = await RunAsync(false).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + try + { + ok = twoPass ? await RunTwoPassAsync().ConfigureAwait(false) + : await RunSingleAsync(tiers[i]).ConfigureAwait(false); + if (ok) break; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) when (i < tiers.Length - 1) + { + // 이 계층(예: NVENC GPU 없음)이 실패하면 다음 계층으로 폴백하며 인자를 재생성. + lastEx = ex; + progress?.Report(0.05); + } } progress?.Report(1.0); - return ok && File.Exists(outPath) - ? ConvertResult.Ok(sourcePath, new[] { outPath }) - : ConvertResult.Fail(sourcePath, "FFmpeg 변환에 실패했습니다."); + if (ok && File.Exists(outPath)) + return ConvertResult.Ok(sourcePath, new[] { outPath }); + return ConvertResult.Fail(sourcePath, + lastEx is null ? "FFmpeg 변환에 실패했습니다." : $"FFmpeg 변환 실패: {lastEx.Message}", lastEx); } catch (OperationCanceledException) { throw; } catch (Exception ex) diff --git a/src/Everything2Everything.Tests/FfmpegArgBuilderTests.cs b/src/Everything2Everything.Tests/FfmpegArgBuilderTests.cs new file mode 100644 index 0000000..4ee09ff --- /dev/null +++ b/src/Everything2Everything.Tests/FfmpegArgBuilderTests.cs @@ -0,0 +1,203 @@ +using Everything2Everything.Core; +using Everything2Everything.Core.Converters; +using Xunit; + +namespace Everything2Everything.Tests; + +/// +/// FfmpegArgBuilder 인자 생성 단위 테스트 — ffmpeg 실행 없이(헤드리스) 옵션→ffmpeg 인자 매핑을 검증한다. +/// 레이트 컨트롤 5모드, 코덱 해소·컨테이너 호환, 프리셋·NVENC 매핑, -vf 필터 체인, 오디오 모드, faststart 등. +/// +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 선호 + } +}