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 선호
+ }
+}