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:
parent
5050551c95
commit
214789b656
4 changed files with 812 additions and 26 deletions
|
|
@ -78,6 +78,12 @@ public sealed record ConvertOptions
|
|||
|
||||
public AiOptions Ai { get; init; } = new();
|
||||
|
||||
/// <summary>영상 인코딩 상세 옵션(FfmpegProvider). 출력이 영상 컨테이너일 때 적용.</summary>
|
||||
public VideoEncodeOptions Video { get; init; } = new();
|
||||
|
||||
/// <summary>오디오 인코딩 상세 옵션(FfmpegProvider). 영상의 오디오 트랙 + 오디오 전용 출력에 적용.</summary>
|
||||
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 }
|
||||
|
||||
/// <summary>레이트 컨트롤(품질/용량 통제) 모드.</summary>
|
||||
public enum RateControlMode
|
||||
{
|
||||
/// <summary>상수 품질(CRF) — 품질만 정하고 용량은 가변. 가장 일반적.</summary>
|
||||
Crf,
|
||||
/// <summary>평균 비트레이트(ABR) — 목표 비트레이트 1패스.</summary>
|
||||
AverageBitrate,
|
||||
/// <summary>제약된 CRF — CRF 품질 + 최대 비트레이트 상한(스트리밍).</summary>
|
||||
ConstrainedCrf,
|
||||
/// <summary>고정 비트레이트(CBR) — 방송/스트리밍.</summary>
|
||||
Cbr,
|
||||
/// <summary>2패스 — 목표 비트레이트에서 최고 품질(2회 인코딩).</summary>
|
||||
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 }
|
||||
|
||||
/// <summary>영상 인코딩 상세 옵션. 모두 불변(get;init). 출력이 영상 컨테이너(mp4/mkv/webm/mov/avi)일 때만 적용.</summary>
|
||||
public sealed record VideoEncodeOptions
|
||||
{
|
||||
public VideoCodec Codec { get; init; } = VideoCodec.Auto;
|
||||
public RateControlMode RateControl { get; init; } = RateControlMode.Crf;
|
||||
|
||||
/// <summary>CRF 값(낮을수록 고화질). x264/x265 0~51, vp9/av1 0~63.</summary>
|
||||
public int Crf { get; init; } = 23;
|
||||
|
||||
/// <summary>비트레이트 모드의 목표 비디오 비트레이트(kbps).</summary>
|
||||
public int? VideoBitrateKbps { get; init; }
|
||||
|
||||
/// <summary>VBV 최대 비트레이트 상한(kbps). ConstrainedCrf/제약 시. bufsize는 2배 자동.</summary>
|
||||
public int? VideoMaxrateKbps { get; init; }
|
||||
|
||||
public VideoSpeedPreset Preset { get; init; } = VideoSpeedPreset.Medium;
|
||||
|
||||
/// <summary>x264/x265: film/animation/grain/zerolatency 등. av1: 0|1|2.</summary>
|
||||
public string? Tune { get; init; }
|
||||
|
||||
/// <summary>x264: baseline/main/high/high10 …, x265: main/main10 …. null=자동.</summary>
|
||||
public string? Profile { get; init; }
|
||||
|
||||
/// <summary>예: 4.0 / 5.1. null=자동.</summary>
|
||||
public string? Level { get; init; }
|
||||
|
||||
/// <summary>yuv420p(8bit·최대호환), yuv420p10le(10bit), yuv444p …</summary>
|
||||
public string PixelFormat { get; init; } = "yuv420p";
|
||||
|
||||
/// <summary>키프레임 간격(GOP, 프레임 수). null=인코더 자동.</summary>
|
||||
public int? GopSize { get; init; }
|
||||
|
||||
/// <summary>B-프레임 수(CPU 코덱). null=자동.</summary>
|
||||
public int? BFrames { get; init; }
|
||||
|
||||
public bool Lossless { get; init; }
|
||||
|
||||
/// <summary>출력 가로 해상도(px). null=원본 유지. 높이는 종횡비 자동(-2).</summary>
|
||||
public int? ScaleWidth { get; init; }
|
||||
|
||||
/// <summary>입력이 더 클 때만 축소(업스케일 방지).</summary>
|
||||
public bool DownscaleOnly { get; init; } = true;
|
||||
|
||||
public double? Fps { get; init; }
|
||||
|
||||
/// <summary>crop=w:h:x:y (x/y 생략 시 중앙). null=없음.</summary>
|
||||
public string? Crop { get; init; }
|
||||
|
||||
public RotateMode Rotate { get; init; } = RotateMode.None;
|
||||
public DeinterlaceMode Deinterlace { get; init; } = DeinterlaceMode.Off;
|
||||
|
||||
/// <summary>hqdn3d | nlmeans. null=없음.</summary>
|
||||
public string? Denoise { get; init; }
|
||||
|
||||
/// <summary>mp4/mov 웹 스트리밍 최적화(moov atom 앞으로).</summary>
|
||||
public bool FastStart { get; init; } = true;
|
||||
|
||||
/// <summary>GPU(NVENC 등) 적응형 양자화로 화질 향상.</summary>
|
||||
public bool SpatialAq { get; init; } = true;
|
||||
|
||||
public TimeSpan? TrimStart { get; init; }
|
||||
public TimeSpan? TrimEnd { get; init; }
|
||||
|
||||
/// <summary>미노출 옵션을 위한 원시 ffmpeg 출력 인자(예: "-x265-params no-sao=1").</summary>
|
||||
public string? RawArgs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>오디오 인코딩 상세 옵션. 영상의 오디오 트랙 + 오디오 전용 출력에 적용.</summary>
|
||||
public sealed record AudioEncodeOptions
|
||||
{
|
||||
public AudioCodec Codec { get; init; } = AudioCodec.Auto;
|
||||
public AudioRateMode RateMode { get; init; } = AudioRateMode.Bitrate;
|
||||
|
||||
public int AudioBitrateKbps { get; init; } = 192;
|
||||
|
||||
/// <summary>MP3(LAME) VBR 품질 0~9 (낮을수록 고품질).</summary>
|
||||
public int Mp3VbrQuality { get; init; } = 2;
|
||||
|
||||
/// <summary>OGG Vorbis VBR 품질 -1~10 (높을수록 고품질).</summary>
|
||||
public double VorbisVbrQuality { get; init; } = 5.0;
|
||||
|
||||
/// <summary>FLAC 압축 레벨 0~12 (무손실, 크기/속도만 변화).</summary>
|
||||
public int FlacCompressionLevel { get; init; } = 5;
|
||||
|
||||
/// <summary>WAV PCM 포맷: pcm_s16le(16bit) / pcm_s24le(24bit) / pcm_f32le(32bit float).</summary>
|
||||
public string PcmFormat { get; init; } = "pcm_s16le";
|
||||
|
||||
public int? SampleRate { get; init; }
|
||||
|
||||
/// <summary>채널 수: 1(모노)/2(스테레오)/6(5.1)/8(7.1). null=원본.</summary>
|
||||
public int? Channels { get; init; }
|
||||
|
||||
/// <summary>EBU R128 음량 정규화(loudnorm).</summary>
|
||||
public bool Loudnorm { get; init; }
|
||||
|
||||
/// <summary>단순 음량 배수(Loudnorm과 상호배타).</summary>
|
||||
public double? VolumeGain { get; init; }
|
||||
|
||||
/// <summary>오디오 원본 무손실 패스스루(영상만 재인코딩 — 속도↑).</summary>
|
||||
public bool Copy { get; init; }
|
||||
}
|
||||
|
|
|
|||
379
src/Everything2Everything.Core/Converters/FfmpegArgBuilder.cs
Normal file
379
src/Everything2Everything.Core/Converters/FfmpegArgBuilder.cs
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Everything2Everything.Core.Providers;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
/// <summary>하드웨어 인코더 계층 — NVENC→QSV→AMF→CPU 순으로 폴백.</summary>
|
||||
public enum EncoderTier { Nvenc, Qsv, Amf, Cpu }
|
||||
|
||||
/// <summary>
|
||||
/// VideoEncodeOptions/AudioEncodeOptions를 실제 ffmpeg 출력 인자 문자열로 변환한다.
|
||||
/// WPF/FFMpegCore 비의존 순수 함수 모음이라 헤드리스 단위 테스트가 가능하다(인자 생성 검증).
|
||||
/// 인코더 계층(EncoderTier)별로 동일 옵션을 다른 인자로 매핑한다(소프트웨어 CRF/preset ↔ NVENC -cq/-preset p1~p7 등).
|
||||
/// </summary>
|
||||
public static class FfmpegArgBuilder
|
||||
{
|
||||
private static string Ci(double v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>요청 코덱 + 컨테이너 호환 + 자동선택(고해상도 HEVC)을 반영해 실제 사용할 코덱을 해소.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>해소된 코덱 + 계층 → ffmpeg 인코더 이름.</summary>
|
||||
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",
|
||||
};
|
||||
|
||||
/// <summary>GPU(HW 인코더) 시도가 가능한 조합인가. h264/h265 + mp4/mkv/mov + 8bit 4:2:0만.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>오디오 코덱(Auto면 출력 확장자/컨테이너에서 매핑) → ffmpeg 인코더 이름.</summary>
|
||||
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";
|
||||
|
||||
/// <summary>출력 인자 전체(-c:v … -vf … -c:a … -movflags …)를 한 문자열로 구성.</summary>
|
||||
public static string BuildOutputArguments(ConvertOptions options, string outExt, EncoderTier tier, int sourceWidth)
|
||||
{
|
||||
outExt = ConversionPair.Normalize(outExt);
|
||||
var args = new List<string>();
|
||||
|
||||
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<string> 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<string> 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) ────────────────────────────────────────────────────────────
|
||||
/// <summary>crop → deinterlace → denoise → scale → transpose/flip 순으로 콤마 체인 구성.</summary>
|
||||
public static string BuildVideoFilterChain(VideoEncodeOptions v)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
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<string> 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<string> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<bool> 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<bool> 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<bool> 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)
|
||||
|
|
|
|||
203
src/Everything2Everything.Tests/FfmpegArgBuilderTests.cs
Normal file
203
src/Everything2Everything.Tests/FfmpegArgBuilderTests.cs
Normal 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 선호
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue