releases/tests/Paca.Tests/Core/MediaEnginePipelineTests.cs

1551 lines
57 KiB
C#

using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Paca.App.Browser.Features;
using Paca.Browser;
using Paca.Core;
using Paca.Core.Detection;
using Paca.Core.Net;
using Paca.Core.Platform;
using Xunit;
namespace Paca.Tests.Core;
/// <summary>
/// 미디어 엔진 & 고도화 프로세싱 메가 파이프라인 (Muxer/Codec, 자막, 오디오 노멀라이징, 챕터 분할, HDR 톤매핑, 스마트 트림 등).
/// </summary>
/// <summary>
/// 22차: 고성능 미디어 리먹스 & 코덱 호환성 & 자막/오디오 트랙 추출 파이프라인 검증.
/// </summary>
public class Milestone22MuxerAndCodecTests
{
private sealed class RecordingProcessRunner : IProcessRunner
{
public ProcessSpec? LastSpec { get; private set; }
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
LastSpec = spec;
return Task.FromResult(new ProcessResult(0, "OK", ""));
}
}
[Fact]
public async Task 미디어_오디오_트랙_추출_명령_파이프라인()
{
var runner = new RecordingProcessRunner();
var muxer = new DesktopMediaMuxer(runner);
await muxer.ExtractAudioAsync("ffmpeg.exe", "input.mp4", "output.mp3", "mp3", CancellationToken.None);
Xunit.Assert.NotNull(runner.LastSpec);
Xunit.Assert.Equal("ffmpeg.exe", runner.LastSpec.ExePath);
Xunit.Assert.Contains("-vn", runner.LastSpec.Args);
Xunit.Assert.Contains("output.mp3", runner.LastSpec.Args);
}
[Fact]
public async Task 미디어_자막_병합_명령_파이프라인()
{
var runner = new RecordingProcessRunner();
var muxer = new DesktopMediaMuxer(runner);
await muxer.MergeSubtitlesAsync("ffmpeg.exe", "input.mp4", "sub.vtt", "output.mp4", CancellationToken.None);
Xunit.Assert.NotNull(runner.LastSpec);
Xunit.Assert.Equal("ffmpeg.exe", runner.LastSpec.ExePath);
Xunit.Assert.Contains("sub.vtt", runner.LastSpec.Args);
Xunit.Assert.Contains("-c", runner.LastSpec.Args);
Xunit.Assert.Contains("mov_text", runner.LastSpec.Args);
}
[Fact]
public async Task 미디어_썸네일_커버_임베딩_명령_파이프라인()
{
var runner = new RecordingProcessRunner();
var muxer = new DesktopMediaMuxer(runner);
await muxer.EmbedThumbnailAsync("ffmpeg.exe", "input.mp4", "cover.jpg", "output.mp4", CancellationToken.None);
Xunit.Assert.NotNull(runner.LastSpec);
Xunit.Assert.Equal("ffmpeg.exe", runner.LastSpec.ExePath);
Xunit.Assert.Contains("cover.jpg", runner.LastSpec.Args);
Xunit.Assert.Contains("-disposition:v:1", runner.LastSpec.Args);
}
[Fact]
public void 미디어_컨버전_포맷_해결_유효성_검증()
{
var audioFormats = new[] { "mp3", "m4a", "aac", "wav", "flac" };
foreach (var format in audioFormats)
{
Xunit.Assert.True(DesktopMediaMuxer.IsSupportedAudioFormat(format));
}
}
}
/// <summary>
/// 27차: 미디어 분석 인텔리전스 & 스트림 메타데이터 엔진 검증.
/// </summary>
public class Milestone27MediaAnalysisIntelligenceTests
{
[Fact]
public void FFprobe_JSON_출력_스트림_메타데이터_정밀_파싱()
{
var sampleFfprobeJson = @"{
""streams"": [
{
""codec_type"": ""video"",
""codec_name"": ""h264"",
""width"": 1920,
""height"": 1080,
""r_frame_rate"": ""60/1"",
""bit_rate"": ""4500000""
},
{
""codec_type"": ""audio"",
""codec_name"": ""aac"",
""sample_rate"": ""48000"",
""channels"": 2,
""bit_rate"": ""192000""
}
],
""format"": {
""filename"": ""sample.mp4"",
""duration"": ""124.5"",
""size"": ""72000000"",
""bit_rate"": ""4692000""
}
}";
var report = MediaMetadataExtractor.ParseFfprobeOutput(sampleFfprobeJson);
Xunit.Assert.NotNull(report);
Xunit.Assert.Equal(1920, report.Width);
Xunit.Assert.Equal(1080, report.Height);
Xunit.Assert.Equal("1080p", report.ResolutionLabel);
Xunit.Assert.Equal("h264", report.VideoCodec);
Xunit.Assert.Equal("aac", report.AudioCodec);
Xunit.Assert.Equal(60.0, report.Fps);
Xunit.Assert.Equal(124.5, report.DurationSec);
Xunit.Assert.Equal(2, report.AudioChannels);
Xunit.Assert.True(report.IsHd);
}
[Fact]
public void 비트레이트_기반_화질_등급_평가_검증()
{
var qualityHigh = MediaMetadataExtractor.EvaluateQualityGrade(8_000_000, 1080);
Xunit.Assert.Equal("초고화질 (Very High)", qualityHigh);
var qualityNormal = MediaMetadataExtractor.EvaluateQualityGrade(3_000_000, 1080);
Xunit.Assert.Equal("표준 고화질 (Standard HD)", qualityNormal);
var qualitySd = MediaMetadataExtractor.EvaluateQualityGrade(800_000, 480);
Xunit.Assert.Equal("일반 화질 (SD)", qualitySd);
}
[Fact]
public void 챕터_타임스탬프_인덱싱_검증()
{
var rawChapters = new[]
{
new ChapterInfo(1, "인트로", 0.0, 15.0),
new ChapterInfo(2, "본문", 15.0, 100.0),
new ChapterInfo(3, "아웃트로", 100.0, 120.0)
};
var indexed = MediaMetadataExtractor.IndexChapters(rawChapters);
Xunit.Assert.Equal(3, indexed.Count);
Xunit.Assert.Equal("인트로", indexed[0].Title);
Xunit.Assert.Equal(15.0, indexed[0].Duration);
Xunit.Assert.Equal(85.0, indexed[1].Duration);
}
[Fact]
public void 손상_스트림_진단_및_복구_가능_여부_판정()
{
var corruptedJson = @"{ ""streams"": [], ""format"": {} }";
var report = MediaMetadataExtractor.ParseFfprobeOutput(corruptedJson);
Xunit.Assert.NotNull(report);
Xunit.Assert.False(report.IsValid);
Xunit.Assert.True(report.HasWarnings);
}
}
/// <summary>
/// 28차: 스마트 챕터 분할 및 메타데이터 태깅 파이프라인 검증.
/// </summary>
public class Milestone28ChapterSplitterTests
{
private sealed class RecordingProcessRunner : IProcessRunner
{
public List<ProcessSpec> Specs { get; } = new();
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
Specs.Add(spec);
return Task.FromResult(new ProcessResult(0, "OK", ""));
}
}
[Fact]
public async Task 챕터별_개별_파일_분할_인자_생성_검증()
{
var runner = new RecordingProcessRunner();
var splitter = new ChapterSplitter(runner);
var chapters = new[]
{
new ChapterInfo(1, "01 Intro", 0.0, 30.0),
new ChapterInfo(2, "02 Main", 30.0, 150.0)
};
var outputDir = @"C:\output";
var outputs = await splitter.SplitByChaptersAsync("ffmpeg.exe", "video.mp4", chapters, outputDir, CancellationToken.None);
Xunit.Assert.Equal(2, outputs.Count);
Xunit.Assert.Equal(2, runner.Specs.Count);
Xunit.Assert.Contains("-ss", runner.Specs[0].Args);
Xunit.Assert.Contains("-to", runner.Specs[0].Args);
Xunit.Assert.Contains("-c", runner.Specs[0].Args);
Xunit.Assert.Contains("copy", runner.Specs[0].Args);
}
[Fact]
public void 챕터_파일명_안전화_검증()
{
var sanitized = ChapterSplitter.GetChapterFileName(1, "Intro: Special? / Part 1", "mp4");
Xunit.Assert.DoesNotContain(":", sanitized);
Xunit.Assert.DoesNotContain("?", sanitized);
Xunit.Assert.DoesNotContain("/", sanitized);
Xunit.Assert.EndsWith(".mp4", sanitized);
}
[Fact]
public void 단일_챕터_또는_빈_챕터_시_예외_없이_원본_유지()
{
var runner = new RecordingProcessRunner();
var splitter = new ChapterSplitter(runner);
var emptyChapters = Array.Empty<ChapterInfo>();
var outputs = splitter.PlanSplits("video.mp4", emptyChapters, @"C:\output");
Xunit.Assert.Single(outputs);
Xunit.Assert.Equal("video.mp4", outputs[0]);
}
[Fact]
public void 챕터_FFmetadata_텍스트_생성_검증()
{
var chapters = new[]
{
new ChapterInfo(1, "Intro", 0.0, 10.0),
new ChapterInfo(2, "Outro", 10.0, 20.0)
};
var metadataText = ChapterSplitter.GenerateFfmetadata(chapters, "Sample Title");
Xunit.Assert.Contains(";FFMETADATA1", metadataText);
Xunit.Assert.Contains("title=Intro", metadataText);
Xunit.Assert.Contains("START=0", metadataText);
Xunit.Assert.Contains("END=10000", metadataText);
}
}
/// <summary>
/// 29차: EBU R128 오디오 라우드니스 노멀라이제이션 & 무음 제거 파이프라인 검증.
/// </summary>
public class Milestone29AudioNormalizerTests
{
private sealed class RecordingProcessRunner : IProcessRunner
{
public List<ProcessSpec> Specs { get; } = new();
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
Specs.Add(spec);
var sampleStdout = "{\n" +
" \"input_i\": \"-24.50\",\n" +
" \"input_tp\": \"-1.20\",\n" +
" \"input_lra\": \"8.50\",\n" +
" \"input_thresh\": \"-35.00\"\n" +
"}";
return Task.FromResult(new ProcessResult(0, sampleStdout, ""));
}
}
[Fact]
public async Task Loudnorm_1차_측정_및_2차_적용_명령_파이프라인()
{
var runner = new RecordingProcessRunner();
var normalizer = new AudioNormalizer(runner);
var spec = new LoudnessSpec(TargetLufs: -14.0, TruePeak: -1.0, LoudnessRange: 11.0);
var res = await normalizer.NormalizeAudioAsync("ffmpeg.exe", "input.mp4", "output.mp4", spec, CancellationToken.None);
Xunit.Assert.Equal("output.mp4", res);
Xunit.Assert.Equal(2, runner.Specs.Count); // 1-pass measure, 2-pass apply
Xunit.Assert.Contains("-af", runner.Specs[1].Args);
var filterArg = runner.Specs[1].Args.First(a => a.StartsWith("loudnorm="));
Xunit.Assert.Contains("I=-14", filterArg);
Xunit.Assert.Contains("TP=-1", filterArg);
}
[Fact]
public void Loudnorm_측정_JSON_파싱_검증()
{
var json = @"{
""input_i"": ""-28.30"",
""input_tp"": ""-4.50"",
""input_lra"": ""12.10"",
""input_thresh"": ""-39.20""
}";
var measured = AudioNormalizer.ParseLoudnessStats(json);
Xunit.Assert.NotNull(measured);
Xunit.Assert.Equal(-28.30, measured.MeasuredI);
Xunit.Assert.Equal(-4.50, measured.MeasuredTp);
Xunit.Assert.Equal(12.10, measured.MeasuredLra);
Xunit.Assert.Equal(-39.20, measured.MeasuredThresh);
}
[Fact]
public void 무음_제거_필터_문자열_구성_검증()
{
var filter = AudioNormalizer.BuildSilenceRemoveFilter(thresholdDb: -50.0, minDurationSec: 0.5);
Xunit.Assert.Contains("silenceremove=", filter);
Xunit.Assert.Contains("-50dB", filter);
Xunit.Assert.Contains("0.5", filter);
}
[Fact]
public void 표준_프리셋_값_유효성_검증()
{
var broadcast = LoudnessSpec.EbuR128;
Xunit.Assert.Equal(-23.0, broadcast.TargetLufs);
var web = LoudnessSpec.WebStandard;
Xunit.Assert.Equal(-14.0, web.TargetLufs);
}
}
/// <summary>
/// 31차: 브라우저 실시간 웹 번역 & Live Caption 자막 오버레이 엔진 검증.
/// </summary>
public class Milestone31LiveCaptionEngineTests
{
[Fact]
public void WebVTT_실시간_자막_파싱_및_타임코드_정렬_검증()
{
var sampleVtt = @"WEBVTT
00:00:01.000 --> 00:00:04.000
Hello and welcome to the live stream.
00:00:04.500 --> 00:00:08.000
Today we are discussing system architecture.
";
var engine = new LiveCaptionEngine();
engine.LoadWebVtt(sampleVtt);
Xunit.Assert.Equal(2, engine.CueCount);
var cueAt2Sec = engine.GetActiveCue(2.5);
Xunit.Assert.NotNull(cueAt2Sec);
Xunit.Assert.Equal("Hello and welcome to the live stream.", cueAt2Sec.Text);
var cueAt5Sec = engine.GetActiveCue(5.0);
Xunit.Assert.NotNull(cueAt5Sec);
Xunit.Assert.Equal("Today we are discussing system architecture.", cueAt5Sec.Text);
var cueAt10Sec = engine.GetActiveCue(10.0);
Xunit.Assert.Null(cueAt10Sec);
}
[Fact]
public void 타임코드_초단위_변환_정밀도_검증()
{
var sec1 = LiveCaptionEngine.ParseTimeCode("00:01:23.456");
Xunit.Assert.Equal(83.456, sec1, 3);
var sec2 = LiveCaptionEngine.ParseTimeCode("02:00.500");
Xunit.Assert.Equal(120.500, sec2, 3);
}
[Fact]
public void 자막_오버레이_스타일_및_위치_설정_검증()
{
var style = new CaptionStyle(FontSize: 18, TextColor: "#FFFFFF", BackgroundColor: "#CC000000", Position: CaptionPosition.BottomCenter);
Xunit.Assert.Equal(18, style.FontSize);
Xunit.Assert.Equal(CaptionPosition.BottomCenter, style.Position);
}
[Fact]
public void 번역_텍스트_병합_및_이중_언어_표시_포맷_검증()
{
var formatted = LiveCaptionEngine.FormatBilingual("Hello world", "안녕하세요 세계");
Xunit.Assert.Equal("Hello world\n안녕하세요 세계", formatted);
}
}
/// <summary>
/// 32차: HDR10 / Dolby Vision 10-bit 색공간 보존 및 SDR 톤매핑 파이프라인 검증.
/// </summary>
public class Milestone32HdrToneMapperTests
{
private sealed class RecordingProcessRunner : IProcessRunner
{
public List<ProcessSpec> Specs { get; } = new();
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
Specs.Add(spec);
return Task.FromResult(new ProcessResult(0, "OK", ""));
}
}
[Fact]
public void HDR_색공간_특성_판별_검증()
{
var isHdr1 = HdrToneMapper.IsHdrContent("bt2020nc", "smpte2084", "arib-std-b67");
Xunit.Assert.True(isHdr1);
var isHdr2 = HdrToneMapper.IsHdrContent("bt709", "bt709", "bt709");
Xunit.Assert.False(isHdr2);
}
[Fact]
public void SDR_톤매핑_필터_문자열_생성_검증()
{
var filter = HdrToneMapper.BuildToneMapFilter(ToneMapAlgorithm.Mobius, targetNits: 100);
Xunit.Assert.Contains("zscale=", filter);
Xunit.Assert.Contains("tonemap=mobius", filter);
Xunit.Assert.Contains("bt709", filter);
}
[Fact]
public void HDR_10비트_무손실_패스스루_인자_검증()
{
var args = HdrToneMapper.Build10BitPassthroughArgs("input.mkv", "output.mp4");
Xunit.Assert.Contains("-c:v", args);
Xunit.Assert.Contains("copy", args);
Xunit.Assert.Contains("-c:a", args);
}
[Fact]
public async Task 톤매핑_변환_프로세스_실행_검증()
{
var runner = new RecordingProcessRunner();
var mapper = new HdrToneMapper(runner);
var outPath = await mapper.ConvertToSdrAsync("ffmpeg.exe", "hdr_sample.mkv", "sdr_sample.mp4", ToneMapAlgorithm.Hable, CancellationToken.None);
Xunit.Assert.Equal("sdr_sample.mp4", outPath);
Xunit.Assert.Single(runner.Specs);
Xunit.Assert.Contains("-vf", runner.Specs[0].Args);
Xunit.Assert.Contains("tonemap=hable", runner.Specs[0].Args.First(a => a.Contains("tonemap=")));
}
}
/// <summary>
/// 33차: 퍼셉추얼 비디오 해시(pHash) 기반 중복 미디어 검출 파이프라인 검증.
/// </summary>
public class Milestone33VideoDeduplicatorTests
{
[Fact]
public void 해밍_거리_비트_차이_계산_검증()
{
ulong hash1 = 0b10101010_10101010;
ulong hash2 = 0b10101010_10101011; // 1비트 차이
ulong hash3 = 0b01010101_01010101; // 전체 반전 (16비트 차이)
Xunit.Assert.Equal(1, VideoDeduplicator.ComputeHammingDistance(hash1, hash2));
Xunit.Assert.Equal(16, VideoDeduplicator.ComputeHammingDistance(hash1, hash3));
}
[Fact]
public void 유사도_임계치_중복_판정_검증()
{
ulong original = 0xAAAA_BBBB_CCCC_DDDD;
ulong nearDuplicate = 0xAAAA_BBBB_CCCC_DDDC; // 1비트 차이 (1101 vs 1100)
ulong totallyDifferent = 0x1111_2222_3333_4444;
var deduplicator = new VideoDeduplicator(maxHammingThreshold: 5);
deduplicator.RegisterVideo("video_original.mp4", original);
var match1 = deduplicator.FindDuplicate(nearDuplicate);
Xunit.Assert.NotNull(match1);
Xunit.Assert.Equal("video_original.mp4", match1.FilePath);
Xunit.Assert.Equal(1, match1.HammingDistance);
var match2 = deduplicator.FindDuplicate(totallyDifferent);
Xunit.Assert.Null(match2);
}
[Fact]
public void 그레이스케일_배열_기반_64비트_pHash_생성_검증()
{
// 8x8 그레이스케일 픽셀 밝기 배열 (64개)
byte[] pixels = new byte[64];
for (int i = 0; i < 32; i++) pixels[i] = 200; // 상단 밝음
for (int i = 32; i < 64; i++) pixels[i] = 50; // 하단 어두움
var hash = VideoDeduplicator.ComputeHashFromPixels(pixels);
Xunit.Assert.NotEqual(0UL, hash);
// 동일 픽셀이면 동일 해시
var hash2 = VideoDeduplicator.ComputeHashFromPixels(pixels);
Xunit.Assert.Equal(hash, hash2);
}
[Fact]
public void 등록된_미디어_라이브러리_클리어_및_카운트_검증()
{
var deduplicator = new VideoDeduplicator();
deduplicator.RegisterVideo("v1.mp4", 0x1234);
deduplicator.RegisterVideo("v2.mp4", 0x5678);
Xunit.Assert.Equal(2, deduplicator.RegisteredCount);
deduplicator.Clear();
Xunit.Assert.Equal(0, deduplicator.RegisteredCount);
}
}
/// <summary>
/// 36차: 실시간 라이브 스트림 DVR 녹화 & 롤링 타임머신 버퍼 엔진 검증.
/// </summary>
public class Milestone36LiveDvrRecorderTests
{
[Fact]
public void 세그먼트_순차_추가_및_롤링_버퍼_초과_삭제_검증()
{
var recorder = new LiveDvrRecorder(maxBufferDurationSec: 30.0);
recorder.AddSegment(new LiveSegment(Sequence: 1, DurationSec: 10.0, FilePath: "chunk1.ts"));
recorder.AddSegment(new LiveSegment(Sequence: 2, DurationSec: 10.0, FilePath: "chunk2.ts"));
recorder.AddSegment(new LiveSegment(Sequence: 3, DurationSec: 10.0, FilePath: "chunk3.ts"));
recorder.AddSegment(new LiveSegment(Sequence: 4, DurationSec: 10.0, FilePath: "chunk4.ts")); // 40초 (초과 -> chunk1 삭제)
Xunit.Assert.Equal(3, recorder.SegmentCount);
Xunit.Assert.Equal(30.0, recorder.TotalDurationSec);
Xunit.Assert.Equal(2, recorder.FirstSequence);
Xunit.Assert.Equal(4, recorder.LastSequence);
}
[Fact]
public void 타임머신_시점_추출_및_세그먼트_목록_슬라이스_검증()
{
var recorder = new LiveDvrRecorder(maxBufferDurationSec: 60.0);
for (int i = 1; i <= 6; i++)
{
recorder.AddSegment(new LiveSegment(Sequence: i, DurationSec: 10.0, FilePath: $"chunk{i}.ts"));
}
// 마지막 20초 (chunk 5, 6)
var recent20Sec = recorder.GetSegmentsForTimeWindow(targetDurationSec: 20.0);
Xunit.Assert.Equal(2, recent20Sec.Count);
Xunit.Assert.Equal(5, recent20Sec[0].Sequence);
Xunit.Assert.Equal(6, recent20Sec[1].Sequence);
}
[Fact]
public void 중복_세그먼트_수신_시_무시_검증()
{
var recorder = new LiveDvrRecorder();
recorder.AddSegment(new LiveSegment(Sequence: 10, DurationSec: 5.0, FilePath: "chunk10.ts"));
recorder.AddSegment(new LiveSegment(Sequence: 10, DurationSec: 5.0, FilePath: "chunk10_dup.ts"));
Xunit.Assert.Equal(1, recorder.SegmentCount);
}
[Fact]
public void 녹화_상태_토글_및_통계_리포트_검증()
{
var recorder = new LiveDvrRecorder();
Xunit.Assert.False(recorder.IsRecording);
recorder.StartRecording();
Xunit.Assert.True(recorder.IsRecording);
recorder.AddSegment(new LiveSegment(Sequence: 1, DurationSec: 6.0, FilePath: "chunk1.ts"));
var stats = recorder.GetStats();
Xunit.Assert.True(stats.IsRecording);
Xunit.Assert.Equal(6.0, stats.DurationSec);
Xunit.Assert.Equal(1, stats.SegmentCount);
recorder.StopRecording();
Xunit.Assert.False(recorder.IsRecording);
}
}
/// <summary>
/// 38차: 플레이어 듀얼 자막 & 다중 오디오 트랙 스위처 파이프라인 검증.
/// </summary>
public class Milestone38DualSubtitleEngineTests
{
[Fact]
public void 주_자막과_보조_자막_동시_매칭_검증()
{
var engine = new DualSubtitleEngine();
engine.LoadPrimaryVtt("00:00:01.000 --> 00:00:05.000\nHello World");
engine.LoadSecondaryVtt("00:00:01.000 --> 00:00:05.000\n안녕하세요 세계");
var pair = engine.GetDualCues(3.0);
Xunit.Assert.NotNull(pair);
Xunit.Assert.Equal("Hello World", pair.PrimaryText);
Xunit.Assert.Equal("안녕하세요 세계", pair.SecondaryText);
}
[Fact]
public void 보조_자막만_존재하거나_비동기_타임라인_시_개별_매칭()
{
var engine = new DualSubtitleEngine();
engine.LoadPrimaryVtt("00:00:01.000 --> 00:00:03.000\nFirst Part");
engine.LoadSecondaryVtt("00:00:02.000 --> 00:00:04.000\n두번째 파트");
var pairAt2_5 = engine.GetDualCues(2.5);
Xunit.Assert.NotNull(pairAt2_5);
Xunit.Assert.Equal("First Part", pairAt2_5.PrimaryText);
Xunit.Assert.Equal("두번째 파트", pairAt2_5.SecondaryText);
var pairAt3_5 = engine.GetDualCues(3.5);
Xunit.Assert.NotNull(pairAt3_5);
Xunit.Assert.Null(pairAt3_5.PrimaryText);
Xunit.Assert.Equal("두번째 파트", pairAt3_5.SecondaryText);
}
[Fact]
public void 다중_오디오_트랙_목록_선택_및_전환_검증()
{
var engine = new DualSubtitleEngine();
engine.RegisterAudioTrack(new AudioTrackInfo(1, "ko", "한국어 (원본)", IsDefault: true));
engine.RegisterAudioTrack(new AudioTrackInfo(2, "en", "English Commentary", IsDefault: false));
Xunit.Assert.Equal(2, engine.AudioTracks.Count);
Xunit.Assert.Equal(1, engine.SelectedAudioTrackId);
engine.SelectAudioTrack(2);
Xunit.Assert.Equal(2, engine.SelectedAudioTrackId);
}
[Fact]
public void 듀얼_자막_레이아웃_설정_검증()
{
var config = new DualSubtitleLayoutConfig(PrimaryColor: "#FFFFFF", SecondaryColor: "#FFD700", PrimaryFontSize: 20, SecondaryFontSize: 16);
Xunit.Assert.Equal("#FFD700", config.SecondaryColor);
Xunit.Assert.Equal(20, config.PrimaryFontSize);
Xunit.Assert.Equal(16, config.SecondaryFontSize);
}
}
/// <summary>
/// 39차: GPU 하드웨어 가속 인코더 감지 및 인코딩 파라미터 최적화 파이프라인 검증.
/// </summary>
public class Milestone39HardwareEncoderDetectorTests
{
private sealed class MockEncoderRunner : IProcessRunner
{
private readonly string _encodersOutput;
public MockEncoderRunner(string encodersOutput)
{
_encodersOutput = encodersOutput;
}
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
return Task.FromResult(new ProcessResult(0, _encodersOutput, ""));
}
}
[Fact]
public async Task NVIDIA_NVENC_하드웨어_인코더_감지_및_우선순위_선정()
{
var sampleEncoders = "Encoders:\n" +
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n" +
" V..... hevc_nvenc NVIDIA NVENC hevc encoder (codec hevc)\n" +
" V..... libx264 libx264 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (codec h264)\n";
var runner = new MockEncoderRunner(sampleEncoders);
var detector = new HardwareEncoderDetector(runner);
var capabilities = await detector.DetectCapabilitiesAsync("ffmpeg.exe", CancellationToken.None);
Xunit.Assert.True(capabilities.HasNvidiaNvenc);
Xunit.Assert.False(capabilities.HasIntelQsv);
Xunit.Assert.False(capabilities.HasAmdAmf);
var bestH264 = detector.GetBestH264Encoder(capabilities);
Xunit.Assert.Equal("h264_nvenc", bestH264);
var bestHevc = detector.GetBestHevcEncoder(capabilities);
Xunit.Assert.Equal("hevc_nvenc", bestHevc);
}
[Fact]
public async Task 하드웨어_인코더_부재_시_소프트웨어_libx264_폴백_선정()
{
var sampleEncoders = "Encoders:\n" +
" V..... libx264 libx264 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (codec h264)\n" +
" V..... libx265 libx265 H.265 / HEVC (codec hevc)\n";
var runner = new MockEncoderRunner(sampleEncoders);
var detector = new HardwareEncoderDetector(runner);
var capabilities = await detector.DetectCapabilitiesAsync("ffmpeg.exe", CancellationToken.None);
Xunit.Assert.False(capabilities.HasNvidiaNvenc);
var bestH264 = detector.GetBestH264Encoder(capabilities);
Xunit.Assert.Equal("libx264", bestH264);
}
[Fact]
public void 인코더별_프리셋_및_품질_인자_구성_검증()
{
var nvencArgs = HardwareEncoderDetector.BuildEncodingArgs("h264_nvenc", crfOrCqp: 19, preset: "p4");
Xunit.Assert.Contains("-c:v", nvencArgs);
Xunit.Assert.Contains("h264_nvenc", nvencArgs);
Xunit.Assert.Contains("-cq", nvencArgs);
var libx264Args = HardwareEncoderDetector.BuildEncodingArgs("libx264", crfOrCqp: 19, preset: "fast");
Xunit.Assert.Contains("-crf", libx264Args);
Xunit.Assert.Contains("-preset", libx264Args);
}
[Fact]
public void Intel_QSV_AMD_AMF_파싱_검증()
{
var sample = " V..... h264_qsv H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (Intel Quick Sync Video acceleration) (codec h264)\n" +
" V..... h264_amf AMD AMF H.264 Encoder (codec h264)\n";
var caps = HardwareEncoderDetector.ParseCapabilities(sample);
Xunit.Assert.True(caps.HasIntelQsv);
Xunit.Assert.True(caps.HasAmdAmf);
}
}
/// <summary>
/// 41차: 스마트 AI 비디오 하이라이트 & 숏폼 자동 클립퍼 파이프라인 검증.
/// </summary>
public class Milestone41HighlightShortsClipperTests
{
private sealed class RecordingProcessRunner : IProcessRunner
{
public List<ProcessSpec> Specs { get; } = new();
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
Specs.Add(spec);
return Task.FromResult(new ProcessResult(0, "OK", ""));
}
}
[Fact]
public void 세로_숏폼_9대16_크롭_필터_문자열_생성_검증()
{
var filter = HighlightShortsClipper.BuildVerticalCropFilter(inputWidth: 1920, inputHeight: 1080);
Xunit.Assert.Contains("crop=", filter);
Xunit.Assert.Contains("ih*9/16", filter);
}
[Fact]
public void 볼륨_피크_지점_기반_하이라이트_구간_추출_검증()
{
// 10초 단위 볼륨 샘플들 (-dB)
var volumeSamples = new[]
{
new VolumeSample(TimeSec: 10.0, VolumeDb: -25.0),
new VolumeSample(TimeSec: 20.0, VolumeDb: -12.0), // Peak!
new VolumeSample(TimeSec: 30.0, VolumeDb: -28.0),
new VolumeSample(TimeSec: 40.0, VolumeDb: -10.0), // Peak!
new VolumeSample(TimeSec: 50.0, VolumeDb: -30.0)
};
var highlights = HighlightShortsClipper.DetectHighlightWindows(volumeSamples, windowDurationSec: 15.0, topCount: 2);
Xunit.Assert.Equal(2, highlights.Count);
Xunit.Assert.Equal(40.0, highlights[0].CenterTimeSec);
Xunit.Assert.Equal(20.0, highlights[1].CenterTimeSec);
}
[Fact]
public async Task 숏폼_클립_추출_및_인코딩_프로세스_실행_검증()
{
var runner = new RecordingProcessRunner();
var clipper = new HighlightShortsClipper(runner);
var clip = new HighlightClip(StartSec: 35.0, DurationSec: 15.0, Title: "Best Moment");
var outPath = await clipper.ExportShortsClipAsync("ffmpeg.exe", "video.mp4", "shorts_out.mp4", clip, isVerticalShorts: true, CancellationToken.None);
Xunit.Assert.Equal("shorts_out.mp4", outPath);
Xunit.Assert.Single(runner.Specs);
Xunit.Assert.Contains("-ss", runner.Specs[0].Args);
Xunit.Assert.Contains("-vf", runner.Specs[0].Args);
Xunit.Assert.Contains("crop=", runner.Specs[0].Args.First(a => a.Contains("crop=")));
}
[Fact]
public void 유효하지_않은_클립_지정_시_예외_검증()
{
var runner = new RecordingProcessRunner();
var clipper = new HighlightShortsClipper(runner);
var invalidClip = new HighlightClip(StartSec: -5.0, DurationSec: 0, Title: "Invalid");
Xunit.Assert.False(invalidClip.IsValid());
}
}
/// <summary>
/// 42차: 분산 멀티소스 세그먼트 고속 가속기 및 미러 헬스 가디언 검증.
/// </summary>
public class Milestone42MultiSourceSegmentAcceleratorTests
{
[Fact]
public void 다중_미러_등록_및_청크_라운드로빈_분배_검증()
{
var accelerator = new MultiSourceSegmentAccelerator();
accelerator.AddMirror("https://cdn1.example.com/stream");
accelerator.AddMirror("https://cdn2.example.com/stream");
var mirror1 = accelerator.SelectBestMirror(chunkIndex: 0);
var mirror2 = accelerator.SelectBestMirror(chunkIndex: 1);
var mirror3 = accelerator.SelectBestMirror(chunkIndex: 2);
Xunit.Assert.Equal("https://cdn1.example.com/stream", mirror1);
Xunit.Assert.Equal("https://cdn2.example.com/stream", mirror2);
Xunit.Assert.Equal("https://cdn1.example.com/stream", mirror3);
}
[Fact]
public void 응답_실패_시_불량_미러_점수_하락_및_자동_제외()
{
var accelerator = new MultiSourceSegmentAccelerator(maxFailuresBeforeDrop: 2);
accelerator.AddMirror("https://fast-cdn.com");
accelerator.AddMirror("https://slow-failing-cdn.com");
// 불량 미러에 2회 실패 기록
accelerator.RecordFailure("https://slow-failing-cdn.com");
accelerator.RecordFailure("https://slow-failing-cdn.com");
// 정상 미러만 선택되어야 함
var best = accelerator.SelectBestMirror(chunkIndex: 0);
Xunit.Assert.Equal("https://fast-cdn.com", best);
var best2 = accelerator.SelectBestMirror(chunkIndex: 1);
Xunit.Assert.Equal("https://fast-cdn.com", best2);
}
[Fact]
public void 미러별_응답속도_측정_및_가장_빠른_미러_우선순위()
{
var accelerator = new MultiSourceSegmentAccelerator();
accelerator.AddMirror("https://cdn-100ms.com");
accelerator.AddMirror("https://cdn-10ms.com");
accelerator.RecordLatency("https://cdn-100ms.com", TimeSpan.FromMilliseconds(100));
accelerator.RecordLatency("https://cdn-10ms.com", TimeSpan.FromMilliseconds(10));
var fastest = accelerator.GetFastestMirror();
Xunit.Assert.Equal("https://cdn-10ms.com", fastest);
}
[Fact]
public void 모든_미러_탈락_시_기본_미러_폴백_유지()
{
var accelerator = new MultiSourceSegmentAccelerator(maxFailuresBeforeDrop: 1);
accelerator.AddMirror("https://cdn.only.com");
accelerator.RecordFailure("https://cdn.only.com");
var fallback = accelerator.SelectBestMirror(chunkIndex: 0);
Xunit.Assert.Equal("https://cdn.only.com", fallback); // 마지막 하나는 폴백으로 유지
}
}
/// <summary>
/// 43차: 무손실 스마트 트림 및 I-프레임 키프레임 정렬 컷팅기 검증.
/// </summary>
public class Milestone43LosslessSmartTrimmerTests
{
private sealed class RecordingProcessRunner : IProcessRunner
{
public List<ProcessSpec> Specs { get; } = new();
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
Specs.Add(spec);
return Task.FromResult(new ProcessResult(0, "OK", ""));
}
}
[Fact]
public void 가장_가까운_키프레임_스냅_계산_검증()
{
// 0s, 5s, 10s, 15s, 20s 에 키프레임(I-프레임) 존재
var keyframes = new[] { 0.0, 5.0, 10.0, 15.0, 20.0 };
var snappedStart = LosslessSmartTrimmer.FindNearestKeyframe(keyframes, targetSec: 4.8);
Xunit.Assert.Equal(5.0, snappedStart);
var snappedStart2 = LosslessSmartTrimmer.FindNearestKeyframe(keyframes, targetSec: 1.2);
Xunit.Assert.Equal(0.0, snappedStart2);
}
[Fact]
public async Task 무손실_스트림카피_트리밍_명령어_생성_검증()
{
var runner = new RecordingProcessRunner();
var trimmer = new LosslessSmartTrimmer(runner);
var outPath = await trimmer.TrimLosslessAsync("ffmpeg.exe", "source.mp4", "trimmed.mp4", startSec: 10.0, endSec: 25.0, CancellationToken.None);
Xunit.Assert.Equal("trimmed.mp4", outPath);
Xunit.Assert.Single(runner.Specs);
Xunit.Assert.Contains("-ss", runner.Specs[0].Args);
Xunit.Assert.Contains("-to", runner.Specs[0].Args);
Xunit.Assert.Contains("-c", runner.Specs[0].Args);
Xunit.Assert.Contains("copy", runner.Specs[0].Args);
}
[Fact]
public async Task 시작시간이_종료시간보다_크면_예외()
{
var runner = new RecordingProcessRunner();
var trimmer = new LosslessSmartTrimmer(runner);
await Xunit.Assert.ThrowsAsync<ArgumentException>(() =>
trimmer.TrimLosslessAsync("ffmpeg.exe", "source.mp4", "trimmed.mp4", startSec: 30.0, endSec: 10.0, CancellationToken.None));
}
[Fact]
public void 키프레임_목록이_비어있으면_목표시간_그대로_반환()
{
var result = LosslessSmartTrimmer.FindNearestKeyframe(Array.Empty<double>(), targetSec: 12.34);
Xunit.Assert.Equal(12.34, result);
}
}
/// <summary>
/// 45차: VAD 음성 활동 감지 및 AI 자막 생성 어댑터 파이프라인 검증.
/// </summary>
public class Milestone45VoiceActivitySubtitleAdapterTests
{
[Fact]
public void 오디오_에너지_기반_음성_발화구간_VAD_검출_검증()
{
// 1초 단위 오디오 RMS 에너지 (0.0 ~ 1.0)
var energyProfile = new[]
{
0.01, 0.02, // 무음
0.45, 0.55, 0.60, 0.40, // 발화 1 (2s ~ 5s)
0.02, 0.01, // 무음
0.70, 0.80, 0.65 // 발화 2 (8s ~ 10s)
};
var segments = VoiceActivitySubtitleAdapter.DetectSpeechSegments(energyProfile, speechThreshold: 0.1, minDurationSec: 2.0);
Xunit.Assert.Equal(2, segments.Count);
Xunit.Assert.Equal(2.0, segments[0].StartSec);
Xunit.Assert.Equal(6.0, segments[0].EndSec);
Xunit.Assert.Equal(8.0, segments[1].StartSec);
}
[Fact]
public void VAD_구간_기반_WebVTT_템플릿_자동_생성_검증()
{
var segments = new[]
{
new SpeechSegment(StartSec: 1.5, EndSec: 4.2),
new SpeechSegment(StartSec: 5.0, EndSec: 8.8)
};
var vtt = VoiceActivitySubtitleAdapter.GenerateVttSkeleton(segments);
Xunit.Assert.StartsWith("WEBVTT", vtt);
Xunit.Assert.Contains("00:00:01.500 --> 00:00:04.200", vtt);
Xunit.Assert.Contains("00:00:05.000 --> 00:00:08.800", vtt);
}
[Fact]
public void 타임코드_밀리초_포맷터_정밀도_검증()
{
var tc = VoiceActivitySubtitleAdapter.FormatTimecode(3661.123);
Xunit.Assert.Equal("01:01:01.123", tc);
}
[Fact]
public void 빈_에너지_프로필_처리_검증()
{
var empty = VoiceActivitySubtitleAdapter.DetectSpeechSegments(Array.Empty<double>(), speechThreshold: 0.1);
Xunit.Assert.Empty(empty);
}
}
/// <summary>
/// 46차: 지능형 대역폭 예측 및 동적 청크 프리패칭 윈도우 엔진 검증.
/// </summary>
public class Milestone46PredictiveChunkPrefetcherTests
{
[Fact]
public void 다운로드_속도_이동평균_대역폭_예측_검증()
{
var prefetcher = new PredictiveChunkPrefetcher(initialWindowSize: 3, maxWindowSize: 10);
// 속도 샘플 추가 (bytes/sec)
prefetcher.RecordChunkDownload(chunkSizeBytes: 1_000_000, duration: TimeSpan.FromMilliseconds(500)); // 2MB/s
prefetcher.RecordChunkDownload(chunkSizeBytes: 1_000_000, duration: TimeSpan.FromMilliseconds(250)); // 4MB/s
var estimatedSpeed = prefetcher.GetEstimatedSpeedBytesPerSec();
Xunit.Assert.True(estimatedSpeed >= 2_000_000 && estimatedSpeed <= 4_000_000);
}
[Fact]
public void 고속_네트워크_감지_시_프리패치_윈도우_자동_확장()
{
var prefetcher = new PredictiveChunkPrefetcher(initialWindowSize: 2, maxWindowSize: 8);
// 초고속 다운로드 (10MB/s 이상)
for (int i = 0; i < 5; i++)
{
prefetcher.RecordChunkDownload(chunkSizeBytes: 5_000_000, duration: TimeSpan.FromMilliseconds(100));
}
var windowSize = prefetcher.CalculateOptimalPrefetchWindow();
Xunit.Assert.True(windowSize >= 5); // 윈도우 확장
}
[Fact]
public void 네트워크_속도_급락_시_윈도우_크기_축소_방어()
{
var prefetcher = new PredictiveChunkPrefetcher(initialWindowSize: 6, maxWindowSize: 10);
// 저속 다운로드 (100KB/s)
for (int i = 0; i < 5; i++)
{
prefetcher.RecordChunkDownload(chunkSizeBytes: 100_000, duration: TimeSpan.FromMilliseconds(1000));
}
var windowSize = prefetcher.CalculateOptimalPrefetchWindow();
Xunit.Assert.True(windowSize <= 2); // 윈도우 축소로 버퍼 낭비 방지
}
[Fact]
public void 윈도우_경계값_범위_초과_방어_검증()
{
var prefetcher = new PredictiveChunkPrefetcher(minWindowSize: 1, maxWindowSize: 5);
var window = prefetcher.CalculateOptimalPrefetchWindow();
Xunit.Assert.InRange(window, 1, 5);
}
}
/// <summary>
/// 47차: 비디오 워터마크 & 타임스탬프 번인 엔진 검증.
/// </summary>
public class Milestone47VideoWatermarkBurnerTests
{
private sealed class RecordingProcessRunner : IProcessRunner
{
public List<ProcessSpec> Specs { get; } = new();
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
Specs.Add(spec);
return Task.FromResult(new ProcessResult(0, "OK", ""));
}
}
[Fact]
public void 우하단_로고_워터마크_필터_문자열_생성_검증()
{
var filter = VideoWatermarkBurner.BuildOverlayFilter(WatermarkPosition.BottomRight, margin: 20, opacity: 0.8);
Xunit.Assert.Contains("overlay=W-w-20:H-h-20", filter);
Xunit.Assert.Contains("format=auto", filter);
}
[Fact]
public void 텍스트_타임스탬프_번인_필터_생성_검증()
{
var filter = VideoWatermarkBurner.BuildDrawTextFilter("VideoDownloader Archival", fontSize: 24, fontColor: "white");
Xunit.Assert.Contains("drawtext=text='VideoDownloader Archival'", filter);
Xunit.Assert.Contains("fontsize=24", filter);
Xunit.Assert.Contains("fontcolor=white", filter);
}
[Fact]
public async Task 워터마크_인코딩_명령어_파이프라인_실행_검증()
{
var runner = new RecordingProcessRunner();
var burner = new VideoWatermarkBurner(runner);
var spec = new WatermarkSpec(
WatermarkImagePath: "logo.png",
Position: WatermarkPosition.TopLeft,
Margin: 15,
Opacity: 0.7
);
var outPath = await burner.ApplyWatermarkAsync("ffmpeg.exe", "input.mp4", "watermarked.mp4", spec, CancellationToken.None);
Xunit.Assert.Equal("watermarked.mp4", outPath);
Xunit.Assert.Single(runner.Specs);
Xunit.Assert.Contains("-filter_complex", runner.Specs[0].Args);
Xunit.Assert.Contains("overlay=15:15", runner.Specs[0].Args.First(a => a.Contains("overlay=")));
}
[Fact]
public void 유효하지_않은_투명도_설정_예외_검증()
{
Xunit.Assert.Throws<ArgumentOutOfRangeException>(() =>
VideoWatermarkBurner.BuildOverlayFilter(WatermarkPosition.Center, margin: 10, opacity: 1.5));
}
}
/// <summary>
/// 48차: 360도 VR 및 Ambisonics 공간 오디오(Spatial Audio) 메타데이터 보존 엔진 검증.
/// </summary>
public class Milestone48VrSpatialAudioEngineTests
{
private sealed class RecordingProcessRunner : IProcessRunner
{
public List<ProcessSpec> Specs { get; } = new();
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
Specs.Add(spec);
return Task.FromResult(new ProcessResult(0, "OK", ""));
}
}
[Fact]
public void VR360_Equirectangular_투영_메타데이터_판별_검증()
{
var isVr = VrSpatialAudioEngine.IsVr360Content(projectionType: "equirectangular", stereoMode: "left-right");
Xunit.Assert.True(isVr);
var isNotVr = VrSpatialAudioEngine.IsVr360Content(projectionType: "flat", stereoMode: "mono");
Xunit.Assert.False(isNotVr);
}
[Fact]
public void Ambisonics_4채널_공간음향_채널레이아웃_판별_검증()
{
var isSpatial = VrSpatialAudioEngine.IsSpatialAudio(audioChannels: 4, channelLayout: "ambisonic/quad");
Xunit.Assert.True(isSpatial);
var isStereo = VrSpatialAudioEngine.IsSpatialAudio(audioChannels: 2, channelLayout: "stereo");
Xunit.Assert.False(isStereo);
}
[Fact]
public async Task VR_공간메타데이터_보존_MP4_인젝션_명령어_검증()
{
var runner = new RecordingProcessRunner();
var engine = new VrSpatialAudioEngine(runner);
var vrMeta = new VrSpatialMetadata(
Projection: "equirectangular",
StereoMode: "mono",
AmbisonicOrder: 1
);
var outPath = await engine.InjectVrMetadataAsync("ffmpeg.exe", "source_vr.mp4", "injected_vr.mp4", vrMeta, CancellationToken.None);
Xunit.Assert.Equal("injected_vr.mp4", outPath);
Xunit.Assert.Single(runner.Specs);
Xunit.Assert.Contains("-c", runner.Specs[0].Args);
Xunit.Assert.Contains("copy", runner.Specs[0].Args);
}
[Fact]
public void 투영방식이_비어있으면_일반_영상으로_판정()
{
var isVr = VrSpatialAudioEngine.IsVr360Content(null, null);
Xunit.Assert.False(isVr);
}
}
/// <summary>
/// 49차: 비디오 노이즈 제거 및 AI 업스케일러 어댑터 파이프라인 검증.
/// </summary>
public class Milestone49VideoDenoiseUpscalerTests
{
private sealed class RecordingProcessRunner : IProcessRunner
{
public List<ProcessSpec> Specs { get; } = new();
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
Specs.Add(spec);
return Task.FromResult(new ProcessResult(0, "OK", ""));
}
}
[Fact]
public void 디노이즈_강도별_FFmpeg_hqdn3d_필터_생성_검증()
{
var filterLight = VideoDenoiseUpscaler.BuildDenoiseFilter(DenoiseStrength.Light);
Xunit.Assert.Equal("hqdn3d=2.0:1.5:2.0:1.5", filterLight);
var filterHeavy = VideoDenoiseUpscaler.BuildDenoiseFilter(DenoiseStrength.Heavy);
Xunit.Assert.Equal("hqdn3d=6.0:4.5:6.0:4.5", filterHeavy);
}
[Fact]
public void AI_초해상도_Lanczos_업스케일_필터_생성_검증()
{
var filter = VideoDenoiseUpscaler.BuildUpscaleFilter(targetWidth: 1920, targetHeight: 1080, useUnsharp: true);
Xunit.Assert.Contains("scale=1920:1080:flags=lanczos", filter);
Xunit.Assert.Contains("unsharp=5:5:1.0:5:5:0.0", filter);
}
[Fact]
public async Task 디노이즈_업스케일_통합_인코딩_프로세스_실행_검증()
{
var runner = new RecordingProcessRunner();
var upscaler = new VideoDenoiseUpscaler(runner);
var spec = new DenoiseUpscaleSpec(
Denoise: DenoiseStrength.Medium,
TargetWidth: 1920,
TargetHeight: 1080,
ApplyUnsharpMask: true
);
var outPath = await upscaler.EnhanceVideoAsync("ffmpeg.exe", "low_res.mp4", "enhanced.mp4", spec, CancellationToken.None);
Xunit.Assert.Equal("enhanced.mp4", outPath);
Xunit.Assert.Single(runner.Specs);
Xunit.Assert.Contains("-vf", runner.Specs[0].Args);
Xunit.Assert.Contains("hqdn3d", runner.Specs[0].Args.First(a => a.Contains("hqdn3d")));
Xunit.Assert.Contains("scale=1920:1080", runner.Specs[0].Args.First(a => a.Contains("scale=")));
}
[Fact]
public void 디노이즈_없음_지정_시_스케일만_적용()
{
var spec = new DenoiseUpscaleSpec(Denoise: DenoiseStrength.None, TargetWidth: 1280, TargetHeight: 720);
var combinedFilter = VideoDenoiseUpscaler.BuildCombinedFilter(spec);
Xunit.Assert.DoesNotContain("hqdn3d", combinedFilter);
Xunit.Assert.Contains("scale=1280:720", combinedFilter);
}
}
/// <summary>
/// Phase 61: P2P Swarm & Multi-CDN Mesh Downloader 전방위 검증 테스트 스위트.
/// WebTorrent/BitTorrent Merkle 트리 피스 검증, 비트필드 조립, 다중 CDN 세그먼트 레이싱을 검증합니다.
/// </summary>
public class P2pSwarmAndMultiCdnPhase61Tests
{
[Fact]
public void P2pSwarm_매니페스트_생성_및_Merkle_루트_해시_계산_검증()
{
byte[] payload = new byte[1024 * 1024 + 500]; // 약 1MB + 500바이트
new Random(42).NextBytes(payload);
var manifest = P2pSwarmEngine.CreateManifestFromBytes("sample_video.mp4", payload, pieceLength: 256 * 1024);
Xunit.Assert.Equal("sample_video.mp4", manifest.Name);
Xunit.Assert.Equal(payload.Length, manifest.TotalSize);
Xunit.Assert.Equal(5, manifest.PieceHashes.Count); // 5개 피스
Xunit.Assert.False(string.IsNullOrEmpty(manifest.MerkleRoot));
Xunit.Assert.False(string.IsNullOrEmpty(manifest.InfoHash));
}
[Fact]
public void P2pSwarm_피스_주입_해시_무결성_검증_및_손상_데이터_거부()
{
byte[] payload = new byte[512 * 1024];
new Random(99).NextBytes(payload);
var manifest = P2pSwarmEngine.CreateManifestFromBytes("chunk.ts", payload, pieceLength: 256 * 1024);
var engine = new P2pSwarmEngine();
engine.InitializeSession(manifest);
Xunit.Assert.Equal(0, engine.VerifiedPieceCount);
Xunit.Assert.Equal(2, engine.GetMissingPieceIndices().Count);
// 1. 유효 피스 0 주입
byte[] piece0 = new byte[256 * 1024];
Buffer.BlockCopy(payload, 0, piece0, 0, piece0.Length);
bool ok0 = engine.IngestPieceData(0, piece0);
Xunit.Assert.True(ok0);
Xunit.Assert.Equal(1, engine.VerifiedPieceCount);
Xunit.Assert.Equal(50.0, engine.ProgressPercentage);
// 2. 손상된 피스 1 주입 (거부되어야 함)
byte[] corruptPiece1 = new byte[256 * 1024];
Buffer.BlockCopy(payload, 256 * 1024, corruptPiece1, 0, corruptPiece1.Length);
corruptPiece1[100] ^= 0xFF; // 비트 플립
bool corruptResult = engine.IngestPieceData(1, corruptPiece1);
Xunit.Assert.False(corruptResult);
Xunit.Assert.Equal(1, engine.VerifiedPieceCount);
// 3. 정상 피스 1 주입 및 전체 페이로드 조립
byte[] validPiece1 = new byte[256 * 1024];
Buffer.BlockCopy(payload, 256 * 1024, validPiece1, 0, validPiece1.Length);
bool ok1 = engine.IngestPieceData(1, validPiece1);
Xunit.Assert.True(ok1);
Xunit.Assert.Equal(2, engine.VerifiedPieceCount);
Xunit.Assert.Equal(100.0, engine.ProgressPercentage);
byte[]? assembled = engine.AssemblePayload();
Xunit.Assert.NotNull(assembled);
Xunit.Assert.Equal(payload, assembled);
}
[Fact]
public async Task MultiCdn_다중_미러_세그먼트_레이싱_및_승자_선택_검증()
{
var handler = new MockHttpMessageHandler();
var httpClient = new HttpClient(handler);
var downloader = new MultiCdnMeshDownloader(httpClient);
var mirrorFast = new Uri("https://cdn1.fast.com/seg001.ts");
var mirrorSlow = new Uri("https://cdn2.slow.com/seg001.ts");
handler.RegisterResponse(mirrorFast, new byte[] { 0x47, 0x01, 0x02 }, delayMs: 10);
handler.RegisterResponse(mirrorSlow, new byte[] { 0x47, 0x01, 0x02 }, delayMs: 200);
downloader.RegisterMirror(new Uri("https://cdn1.fast.com"));
downloader.RegisterMirror(new Uri("https://cdn2.slow.com"));
var result = await downloader.RaceDownloadSegmentAsync(new[] { mirrorFast, mirrorSlow }, staggerDelayMs: 20);
Xunit.Assert.True(result.IsSuccess);
Xunit.Assert.Equal(mirrorFast, result.WinningMirror);
Xunit.Assert.Equal(3, result.Data.Length);
Xunit.Assert.Equal(0x47, result.Data[0]);
var ranked = downloader.GetRankedMirrors();
Xunit.Assert.Equal(2, ranked.Count);
Xunit.Assert.True(ranked[0].HealthScore >= ranked[1].HealthScore);
}
private sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<string, (byte[] Data, int DelayMs, int StatusCode)> _responses = new();
public void RegisterResponse(Uri uri, byte[] data, int delayMs = 0, int statusCode = 200)
{
_responses[uri.ToString()] = (data, delayMs, statusCode);
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri?.ToString() ?? "";
if (_responses.TryGetValue(url, out var item))
{
if (item.DelayMs > 0)
{
await Task.Delay(item.DelayMs, cancellationToken);
}
var resp = new HttpResponseMessage((System.Net.HttpStatusCode)item.StatusCode);
resp.Content = new ByteArrayContent(item.Data);
return resp;
}
return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
}
}
}
/// <summary>
/// Phase 62: On-Device AI Neural Media Lab 전방위 검증 테스트 스위트.
/// Whisper 자막 생성, SRT/VTT 포맷팅, AI 숏폼 하이라이트 감지, 신경망 셰이더 업스케일러를 검증합니다.
/// </summary>
public class OnDeviceAiNeuralMediaLabPhase62Tests
{
[Fact]
public void NeuralSubtitle_음성인식_단어별_타임스탬프_및_SRT_VTT_변환_검증()
{
var text = "안녕하세요. 파카 미디어 브라우저입니다. 차세대 확장 기능을 테스트합니다.";
var result = NeuralSubtitleEngine.CreateMockTranscription(text, totalDurationSeconds: 12.0, language: "ko");
Xunit.Assert.Equal("ko", result.Language);
Xunit.Assert.Equal(12.0, result.DurationSeconds);
Xunit.Assert.Equal(3, result.Segments.Count);
var seg1 = result.Segments[0];
Xunit.Assert.Equal("안녕하세요", seg1.Text);
Xunit.Assert.Equal(0.0, seg1.StartSeconds);
Xunit.Assert.Equal(4.0, seg1.EndSeconds);
Xunit.Assert.Single(seg1.Words);
// SRT Export
var srt = result.ToSrt();
Xunit.Assert.Contains("00:00:00,000 --> 00:00:04,000", srt);
Xunit.Assert.Contains("안녕하세요", srt);
// VTT Export
var vtt = result.ToVtt();
Xunit.Assert.StartsWith("WEBVTT", vtt);
Xunit.Assert.Contains("00:00:00.000 --> 00:00:04.000", vtt);
}
[Fact]
public void AiHighlightClipper_하이라이트_구간_점수_계산_및_랭킹_검증()
{
// 120초 비디오, 60초 지점에서 오디오 에너지와 씬 전환이 가장 높음
var highlights = AiHighlightClipper.DetectHighlights(
totalDurationSeconds: 120.0,
audioEnergyProvider: sec => sec >= 50 && sec <= 80 ? 0.95 : 0.2,
sceneChangeProvider: sec => sec >= 50 && sec <= 80 ? 0.85 : 0.1,
windowSeconds: 30.0
);
Xunit.Assert.Equal(4, highlights.Count);
// 1등 하이라이트가 60~90초 또는 30~60초 구간이어야 함
var best = highlights[0];
Xunit.Assert.True(best.TotalScore > 0.6);
Xunit.Assert.Contains("하이라이트", best.SuggestedTitle);
}
[Fact]
public void AiHighlightClipper_9대16_수직_쇼츠_FFmpeg_인자_배열_검증()
{
var spec = new ShortsClipSpec
{
StartSeconds = 45.5,
DurationSeconds = 55.0,
VerticalCrop9x16 = true,
Title = "Viral Shorts"
};
var args = AiHighlightClipper.BuildFfmpegShortsCommand("input_1080p.mp4", "shorts_9x16.mp4", spec);
Xunit.Assert.Contains("-ss", args);
Xunit.Assert.Contains("45.50", args);
Xunit.Assert.Contains("-t", args);
Xunit.Assert.Contains("55.00", args);
Xunit.Assert.Contains("-vf", args);
Xunit.Assert.Contains(args, a => a.Contains("crop=ih*9/16"));
Xunit.Assert.Equal("shorts_9x16.mp4", args.Last());
}
[Fact]
public void NeuralShaderUpscaler_HLSL_셰이더_코드_생성_검증()
{
var config = new ShaderUpscaleConfig
{
Algorithm = UpscalerAlgorithm.Anime4K_Ultra,
TargetWidth = 3840,
TargetHeight = 2160,
Sharpness = 1.2
};
var hlsl = NeuralShaderUpscaler.GenerateHlslShaderCode(config);
Xunit.Assert.Contains("Anime4K_Ultra", hlsl);
Xunit.Assert.Contains("3840x2160", hlsl);
Xunit.Assert.Contains("PS_NeuralUpscale", hlsl);
Xunit.Assert.Contains("Texture2D", hlsl);
}
}
/// <summary>
/// Phase 65: Ultra-Low Latency Casting & Dynamic Transcoder Studio 전방위 검증 테스트 스위트.
/// </summary>
public class UltraLowLatencyCastingAndTranscoderPhase65Tests
{
[Fact]
public void WebRtcCastingEngine_세션_생성_오퍼_SDP_발행_및_종료_검증()
{
var engine = new Paca.Core.Media.WebRtcCastingEngine();
var session = engine.CreateCastSession("LG OLED 4K TV");
Assert.NotNull(session.SessionId);
Assert.Equal("LG OLED 4K TV", session.TargetDeviceName);
Assert.True(session.IsConnected);
Assert.True(session.EstimatedLatencyMs < 50.0);
var offer = engine.GenerateOffer(session.SessionId, 1920, 1080, 60);
Assert.Equal("offer", offer.Type);
Assert.Contains("v=0", offer.Sdp);
Assert.Contains("H264/90000", offer.Sdp);
bool terminated = engine.TerminateSession(session.SessionId);
Assert.True(terminated);
}
[Fact]
public void HardwareTranscoderStudio_NVENC_AV1_10Bit_HDR_FFmpeg_인자_배열_검증()
{
var spec = new Paca.Core.Media.TranscodePresetSpec
{
Backend = Paca.Core.Media.HardwareEncoderBackend.NvidiaNvenc,
Codec = Paca.Core.Media.VideoCodecTarget.AV1,
TargetBitrateKbps = 8000,
Enable10BitHdr = true,
AudioCodec = "libopus",
AudioBitrateKbps = 256
};
var args = Paca.Core.Media.HardwareTranscoderStudio.BuildFfmpegTranscodeCommand("raw_4k.mkv", "out_av1_hdr.mp4", spec);
Assert.Contains("-i", args);
Assert.Contains("av1_nvenc", args);
Assert.Contains("yuv420p10le", args);
Assert.Contains("bt2020", args);
Assert.Contains("8000k", args);
Assert.Contains("libopus", args);
Assert.Equal("out_av1_hdr.mp4", args.Last());
}
[Fact]
public void FftAudioWaveformEngine_10밴드_이퀄라이저_프리셋_및_FFmpeg_필터_문자열_검증()
{
var bassBoost = Paca.Core.Media.FftAudioWaveformEngine.GetPresetBands(Paca.Core.Media.EqualizerPreset.BassBoost);
Assert.Equal(10, bassBoost.Count);
Assert.Equal(6.0, bassBoost[0].GainDb); // 32Hz +6dB
var filter = Paca.Core.Media.FftAudioWaveformEngine.BuildFfmpegEqualizerFilter(bassBoost);
Assert.Contains("equalizer=f=32:t=q:w=1:g=6.0", filter);
Assert.Contains("equalizer=f=16000:t=q:w=1:g=0.0", filter);
}
}