1551 lines
57 KiB
C#
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);
|
|
}
|
|
}
|