using System.Diagnostics; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using KAsyncEngineLib; using Tornado3_2026Election.Domain; using Tornado3_2026Election.Services; internal static class CutFileAudit { private static readonly string[] ChannelFolders = [ "Elect2026_Bottom_민방", "Elect2026_Normal_민방", "Elect2026_Top_민방" ]; private static readonly string[] SemanticPrefixes = [ "개표율", "기준시", "기호", "기호텍스트", "득표수", "득표수바", "득표율", "바", "선거구명", "순위", "시도명", "유권자수", "유확당", "의석수", "전국투표율", "정당명", "정당바", "정당색", "정당심볼", "정당원", "정당판", "차트", "투표율", "원", "투표자수", "표차", "후보명", "후보사진", "공약", "공약그룹", "그룹", "그래프" ]; private static readonly string[] ColorInstructionPrefixes = [ "정당바", "정당판", "정당원", "정당색", "정당심볼", "그룹", "득표율", "득표수바", "기호", "정당명" ]; private static readonly string[] ImageValuePrefixes = [ "유확당", "후보사진" ]; private static readonly string[] CounterPrefixes = [ "득표율", "투표율", "전국투표율", "의석수" ]; private static readonly string[] CandidateNames = [ "김하늘", "이준호", "박소라", "최민석", "정서윤", "한지후" ]; private static readonly string[] StressCandidateNames = [ "김하늘테스트후보", "이준호긴이름후보", "박소라소수점후보", "최민석검증후보", "정서윤후보", "한지후후보" ]; private static readonly string[] Parties = [ "더불어민주당", "국민의힘", "조국혁신당", "개혁신당", "무소속", "진보당" ]; private static readonly IReadOnlyDictionary DefaultBindings = new Dictionary { [BroadcastChannel.Normal] = new(0, 0), [BroadcastChannel.TopLeft] = new(0, 1), [BroadcastChannel.Bottom] = new(0, 2) }; public static async Task RunAsync(string[] args) { Console.OutputEncoding = Encoding.UTF8; var options = CutFileAuditOptions.Parse(args); Directory.CreateDirectory(options.OutputPath); Directory.CreateDirectory(options.CapturePath); Console.WriteLine("Cut-file audit starting."); Console.WriteLine($"- Root: {options.RootPath}"); Console.WriteLine($"- Output: {options.OutputPath}"); Console.WriteLine($"- Scenarios: {options.ScenarioMode}"); var pgmWindow = TryFindPgmWindow(); if (pgmWindow is null) { Console.WriteLine("PGM window was not found. Start Tornado3 PGM before using --audit-cut-files."); return 1; } var scenes = EnumerateScenes(options) .Take(options.Limit ?? int.MaxValue) .ToArray(); if (scenes.Length == 0) { Console.WriteLine("No top-level .tscn files matched the requested filters."); return 1; } Console.WriteLine($"- Scenes: {scenes.Length}"); Console.WriteLine($"- PGM: {pgmWindow.Value.Bounds.Width}x{pgmWindow.Value.Bounds.Height}"); Console.WriteLine(); var rgbCatalog = LoadRgbCatalog(options.RootPath); var assetCatalog = AuditAssetCatalog.Create(options.RootPath, options.OutputPath); var results = scenes .Select((scene, index) => AnalyzeScene(scene, index + 1, options.RootPath, rgbCatalog)) .ToList(); AddFamilyComparisonIssues(results); var logService = new LogService(); var manager = new TornadoManager(options.Host, options.Port, logService); var liveFailures = 0; try { await manager.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false); await OutAllAsync(manager, CancellationToken.None).ConfigureAwait(false); foreach (var result in results) { Console.WriteLine($"[{result.Index}/{results.Count}] {result.RelativePath}"); try { await AuditSceneLiveAsync(manager, pgmWindow.Value, result, options, assetCatalog).ConfigureAwait(false); } catch (Exception ex) { result.CaptureFailed = true; result.CaptureFailureDetail = ex.ToString(); liveFailures++; } finally { try { await OutAllAsync(manager, CancellationToken.None).ConfigureAwait(false); } catch { } await Task.Delay(options.BetweenDelayMs, CancellationToken.None).ConfigureAwait(false); result.FinalizeStatus(); } } } finally { try { await OutAllAsync(manager, CancellationToken.None).ConfigureAwait(false); } catch { } manager.Dispose(); } WriteReports(options, results, rgbCatalog); Console.WriteLine(); Console.WriteLine("Summary"); Console.WriteLine($"- pass: {results.Count(result => result.Status == "pass")}"); Console.WriteLine($"- issue: {results.Count(result => result.Status == "issue")}"); Console.WriteLine($"- needs-guidance: {results.Count(result => result.Status == "needs-guidance")}"); Console.WriteLine($"- capture-failed: {results.Count(result => result.Status == "capture-failed")}"); Console.WriteLine($"- live failures: {liveFailures}"); Console.WriteLine($"- report: {Path.Combine(options.OutputPath, "CUT_FILE_AUDIT_REPORT.md")}"); return liveFailures == 0 ? 0 : 1; } private static IEnumerable EnumerateScenes(CutFileAuditOptions options) { foreach (var folderName in ChannelFolders) { var folderPath = Path.Combine(options.RootPath, folderName); if (!Directory.Exists(folderPath)) { continue; } foreach (var scenePath in Directory.EnumerateFiles(folderPath, "*.tscn", SearchOption.TopDirectoryOnly) .OrderBy(path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase)) { var baseName = Path.GetFileNameWithoutExtension(scenePath); if (options.IncludeBaseNames.Count > 0 && !options.IncludeBaseNames.Contains(baseName)) { continue; } if (!string.IsNullOrWhiteSpace(options.Filter) && !scenePath.Contains(options.Filter, StringComparison.OrdinalIgnoreCase) && !baseName.Contains(options.Filter, StringComparison.OrdinalIgnoreCase)) { continue; } yield return new AuditScene( scenePath, Path.GetRelativePath(options.RootPath, scenePath), folderName, baseName, ResolveChannel(folderName)); } } } private static AuditSceneResult AnalyzeScene( AuditScene scene, int index, string rootPath, IReadOnlyDictionary rgbCatalog) { var tags = ExtractAuditTagNames(scene.ScenePath); var result = new AuditSceneResult { Index = index, ScenePath = scene.ScenePath, RelativePath = scene.RelativePath, FolderName = scene.FolderName, BaseName = scene.BaseName, FamilyKey = BuildFamilyKey(scene.FolderName, scene.BaseName), Channel = scene.Channel.ToString(), Tags = tags, TagCount = tags.Count }; foreach (var tag in tags.Where(tag => Regex.IsMatch(tag, @"\d{2}\s+\d+", RegexOptions.CultureInvariant))) { result.TagIssues.Add($"공백/잘못된 suffix 의심 태그: `{tag}`"); } AddRequiredTagIssues(result); AddPromiseGroupIssues(result); AddKnownManualChecklist(result); AddRgbGuidanceIssues(result, rootPath, rgbCatalog); result.FinalizeStatus(); return result; } private static async Task AuditSceneLiveAsync( TornadoManager manager, PgmWindow pgmWindow, AuditSceneResult result, CutFileAuditOptions options, AuditAssetCatalog assetCatalog) { var binding = ResolveBinding(result.Channel); var alias = $"cut_file_audit_{result.Channel}_{result.Index:000}"; var scenarios = ResolveScenarios(options.ScenarioMode); await OutAllAsync(manager, CancellationToken.None).ConfigureAwait(false); await Task.Delay(options.BetweenDelayMs, CancellationToken.None).ConfigureAwait(false); await manager.LoadSceneAsync(result.ScenePath, alias, forceReload: true, CancellationToken.None).ConfigureAwait(false); await manager.PrepareAsync(binding.OutputChannelIndex, binding.LayerNo, alias, CancellationToken.None).ConfigureAwait(false); await manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, CancellationToken.None).ConfigureAwait(false); await Task.Delay(ResolveOnAirDelayMs(result.BaseName, options), CancellationToken.None).ConfigureAwait(false); var baselineCapture = CaptureAuditPgm(result, pgmWindow, options, "baseline", "final"); await manager.PlayOutAsync(binding.OutputChannelIndex, binding.LayerNo, cutOut: false, CancellationToken.None).ConfigureAwait(false); await Task.Delay(options.BetweenDelayMs, CancellationToken.None).ConfigureAwait(false); for (var scenarioIndex = 0; scenarioIndex < scenarios.Count; scenarioIndex++) { var scenario = scenarios[scenarioIndex]; var payload = BuildScenePayload(result, scenario, assetCatalog); await manager.ApplyValuesAsync( alias, Array.Empty(), payload.Values, payload.CounterNumberKeys, Array.Empty(), Array.Empty(), payload.StyleColorUpdates, payload.VisibilityUpdates, CancellationToken.None) .ConfigureAwait(false); await manager.PrepareAsync(binding.OutputChannelIndex, binding.LayerNo, alias, CancellationToken.None).ConfigureAwait(false); await manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, CancellationToken.None).ConfigureAwait(false); if (IsHistoricalTurnout(result.BaseName) && options.CaptureHistoricalMidFrame) { await Task.Delay(options.HistoricalMidDelayMs, CancellationToken.None).ConfigureAwait(false); _ = CaptureAuditPgm(result, pgmWindow, options, scenario.ToString(), "mid"); await Task.Delay(Math.Max(0, ResolveOnAirDelayMs(result.BaseName, options) - options.HistoricalMidDelayMs), CancellationToken.None).ConfigureAwait(false); var finalCapture = CaptureAuditPgm(result, pgmWindow, options, scenario.ToString(), "final"); AddBaselineVisualDiff(result, baselineCapture, finalCapture); } else { await Task.Delay(ResolveOnAirDelayMs(result.BaseName, options), CancellationToken.None).ConfigureAwait(false); var finalCapture = CaptureAuditPgm(result, pgmWindow, options, scenario.ToString(), "final"); AddBaselineVisualDiff(result, baselineCapture, finalCapture); } await manager.PlayOutAsync(binding.OutputChannelIndex, binding.LayerNo, cutOut: false, CancellationToken.None).ConfigureAwait(false); await Task.Delay(options.BetweenDelayMs, CancellationToken.None).ConfigureAwait(false); } } private static ScenePayload BuildScenePayload( AuditSceneResult result, AuditScenario scenario, AuditAssetCatalog assetCatalog) { var values = new Dictionary(StringComparer.OrdinalIgnoreCase); var counters = new List(); var styleColors = new List(); var visibility = new List(); var seenStyle = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var tag in result.Tags) { var prefix = ResolvePrefix(tag); var slot = ResolveSlot(tag); if (ImageValuePrefixes.Contains(prefix, StringComparer.Ordinal)) { var imageValue = ResolveImageValue(result, prefix, slot, scenario, assetCatalog); if (!string.IsNullOrWhiteSpace(imageValue)) { values[tag] = imageValue; } } else if (ShouldApplyTextValue(prefix)) { values[tag] = ResolveTextValue(prefix, slot, result.BaseName, scenario); } if (CounterPrefixes.Contains(prefix, StringComparer.Ordinal)) { counters.Add(new KarismaCounterNumberKeyUpdate(tag, 1, ResolveCounterValue(prefix, slot, scenario))); } if (string.Equals(prefix, "유확당", StringComparison.Ordinal) || string.Equals(prefix, "그룹", StringComparison.Ordinal) || string.Equals(prefix, "공약그룹", StringComparison.Ordinal)) { visibility.Add(new KarismaVisibilityUpdate(tag, true)); } if (result.RgbSpec is not null && result.RgbSpec.Sections.TryGetValue(NormalizeKey(prefix), out var section) && section.StyleBinding is not null && seenStyle.Add(tag)) { var party = Parties[(Math.Max(slot, 1) - 1) % Parties.Length]; if (section.TryGetPartyColor(party, out var color)) { styleColors.Add(new KarismaStyleColorUpdate( tag, section.StyleBinding.Value.StyleType, section.StyleBinding.Value.Order, color.R, color.G, color.B)); if (string.Equals(NormalizeKey(section.DisplayName), NormalizeKey("정당바"), StringComparison.OrdinalIgnoreCase) && section.StyleBinding.Value.Order != 0) { styleColors.Add(new KarismaStyleColorUpdate( tag, section.StyleBinding.Value.StyleType, 0, color.R, color.G, color.B)); } } } } return new ScenePayload(values, counters, styleColors, visibility); } private static bool ShouldApplyTextValue(string prefix) { if (prefix.StartsWith("공약", StringComparison.Ordinal) && !string.Equals(prefix, "공약그룹", StringComparison.Ordinal)) { return true; } return prefix is "개표율" or "기준시" or "기호" or "기호텍스트" or "득표수" or "득표율" or "선거구명" or "순위" or "시도명" or "유권자수" or "의석수" or "전국투표율" or "정당명" or "투표율" or "원" or "투표자수" or "표차" or "후보명"; } private static string ResolveTextValue(string prefix, int slot, string baseName, AuditScenario scenario) { var names = scenario == AuditScenario.Stress ? StressCandidateNames : CandidateNames; var nameIndex = Math.Clamp(slot - 1, 0, names.Length - 1); var partyIndex = Math.Clamp(slot - 1, 0, Parties.Length - 1); return prefix switch { "순위" => slot.ToString("0"), "기호" or "기호텍스트" => scenario == AuditScenario.Stress ? (slot + 10).ToString("0") : slot.ToString("0"), "후보명" => names[nameIndex], "정당명" => Parties[partyIndex], "득표수" => scenario == AuditScenario.Stress && slot >= 3 ? "123" : $"{1_234_567 - (slot * 123_456):N0}", "득표율" => $"{ResolveCounterValue(prefix, slot, scenario):0.0}%", "투표율" or "전국투표율" => $"{ResolveCounterValue(prefix, slot, scenario):0.0}%", "표차" or "득표차" => scenario == AuditScenario.Stress ? "23" : "12,345", "시도명" => scenario == AuditScenario.Stress ? "충청남도테스트" : "충남", "선거구명" => scenario == AuditScenario.Stress ? "천안시서북구긴선거구명" : "천안시", "개표율" => "개표 71.3%", "기준시" => "14시 기준", "유권자수" => "1,234,567", "투표자수" => "765,432", "의석수" => ResolveCounterValue(prefix, slot, scenario).ToString("0"), var value when value.StartsWith("공약", StringComparison.Ordinal) => $"공약{slot:00} 테스트 문구", _ => baseName.Contains("투표율", StringComparison.Ordinal) ? "61.4%" : $"검증{slot:00}" }; } private static double ResolveCounterValue(string prefix, int slot, AuditScenario scenario) { if (scenario == AuditScenario.Stress) { if (string.Equals(prefix, "득표율", StringComparison.Ordinal) && slot >= 3) { return 0.3; } if (string.Equals(prefix, "투표율", StringComparison.Ordinal) && slot >= 5) { return 0.3; } } if (scenario == AuditScenario.Variant) { return slot switch { 1 => 38.7, 2 => 51.8, 3 => 6.1, 4 => 3.4, _ => 1.2 }; } return slot switch { 1 => 51.8, 2 => 38.7, 3 => 6.1, 4 => 3.4, 5 => 0.9, _ => 0.3 }; } private static string? ResolveImageValue( AuditSceneResult result, string prefix, int slot, AuditScenario scenario, AuditAssetCatalog assetCatalog) { if (string.Equals(prefix, "후보사진", StringComparison.Ordinal)) { return assetCatalog.GetCandidateImage(result.FolderName, result.RelativePath, slot, scenario); } if (string.Equals(prefix, "유확당", StringComparison.Ordinal)) { return assetCatalog.GetJudgementTag(result.FolderName, slot, scenario); } return null; } private static RgbColor? ResolveRgbColor(string prefix, string party, RgbSpec? rgbSpec) { if (rgbSpec is null) { return null; } var candidates = prefix switch { "유확당" or "정당심볼" => new[] { "정당심볼", "정당바", "정당판" }, "그룹" => new[] { "그룹", "정당바", "정당판" }, "바" => new[] { "정당바", "정당판" }, _ => new[] { prefix } }; foreach (var sectionName in candidates) { if (rgbSpec.Sections.TryGetValue(NormalizeKey(sectionName), out var section) && section.TryGetPartyColor(party, out var color)) { return color; } } return null; } private static AuditCapture CaptureAuditPgm( AuditSceneResult result, PgmWindow pgmWindow, CutFileAuditOptions options, string scenario, string frameLabel) { var fileName = $"{result.Index:000}_{SanitizeFileName(result.FolderName)}_{SanitizeFileName(result.BaseName)}_{scenario.ToLowerInvariant()}_{frameLabel}.png"; var outputPath = Path.Combine(options.CapturePath, fileName); Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); using var bitmap = CaptureWindowBitmap(pgmWindow.Handle, pgmWindow.Bounds); bitmap.Save(outputPath, ImageFormat.Png); var capture = new AuditCapture(scenario, frameLabel, outputPath, ComputeSha256(outputPath)); result.Captures.Add(capture); return capture; } private static void AddBaselineVisualDiff( AuditSceneResult result, AuditCapture baselineCapture, AuditCapture appliedCapture) { var diff = ComputeVisualDiff(baselineCapture, appliedCapture); result.VisualDiffs.Add(diff); if (HasPartyVisualSurface(result) && (diff.ChangedPixelRatio >= 0.35 || diff.AverageRgbDelta >= 18.0)) { result.VisualReviewNotes.Add( $"무변수 baseline 대비 `{appliedCapture.Scenario}` 적용 후 화면 변화 큼: changed {diff.ChangedPixelRatio:P1}, avg RGB delta {diff.AverageRgbDelta:0.0}. 정당색/이미지 의도 외 변화 여부 PGM 캡처 비교 필요"); } } private static AuditVisualDiff ComputeVisualDiff(AuditCapture baselineCapture, AuditCapture appliedCapture) { if (string.Equals(baselineCapture.Hash, appliedCapture.Hash, StringComparison.OrdinalIgnoreCase)) { return new AuditVisualDiff(appliedCapture.Scenario, 0, 0, baselineCapture.Path, appliedCapture.Path); } using var baseline = new Bitmap(baselineCapture.Path); using var applied = new Bitmap(appliedCapture.Path); var width = Math.Min(baseline.Width, applied.Width); var height = Math.Min(baseline.Height, applied.Height); const int sampleStep = 4; var sampleCount = 0; var changedCount = 0; long totalDelta = 0; for (var y = 0; y < height; y += sampleStep) { for (var x = 0; x < width; x += sampleStep) { var before = baseline.GetPixel(x, y); var after = applied.GetPixel(x, y); var pixelDelta = Math.Abs(before.R - after.R) + Math.Abs(before.G - after.G) + Math.Abs(before.B - after.B); totalDelta += pixelDelta; sampleCount++; if (pixelDelta / 3.0 >= 24.0) { changedCount++; } } } if (sampleCount == 0) { return new AuditVisualDiff(appliedCapture.Scenario, 0, 0, baselineCapture.Path, appliedCapture.Path); } return new AuditVisualDiff( appliedCapture.Scenario, changedCount / (double)sampleCount, totalDelta / (double)(sampleCount * 3), baselineCapture.Path, appliedCapture.Path); } private static bool HasPartyVisualSurface(AuditSceneResult result) { return result.Tags .Select(ResolvePrefix) .Any(prefix => ImageValuePrefixes.Contains(prefix, StringComparer.Ordinal) || ColorInstructionPrefixes.Contains(prefix, StringComparer.Ordinal)); } private static int ResolveOnAirDelayMs(string baseName, CutFileAuditOptions options) { if (IsHistoricalTurnout(baseName)) { return Math.Max(options.OnAirDelayMs, options.HistoricalFinalDelayMs); } return baseName.Contains("ani", StringComparison.OrdinalIgnoreCase) || baseName.Contains("_loop", StringComparison.OrdinalIgnoreCase) ? Math.Max(options.OnAirDelayMs, 3500) : options.OnAirDelayMs; } private static async Task OutAllAsync(TornadoManager manager, CancellationToken cancellationToken) { foreach (var binding in DefaultBindings.Values) { try { await manager.PlayOutAsync(binding.OutputChannelIndex, binding.LayerNo, cutOut: false, cancellationToken).ConfigureAwait(false); } catch { } } } private static void AddRequiredTagIssues(AuditSceneResult result) { if (result.FolderName == "Elect2026_Top_민방" && result.BaseName.Contains("2인", StringComparison.Ordinal)) { RequireTags(result, "좌상단 2인 컷 후보명 태그 누락 의심", "후보명01", "후보명02"); } if (string.Equals(result.BaseName, "투표율_사진", StringComparison.Ordinal)) { RequireAnyPrefix(result, "투표율_사진 `(14시 기준)` 태그 누락 의심", "기준시"); } if (IsHistoricalTurnout(result.BaseName)) { RequireTags(result, "사전_역대투표율 원 태그 누락 의심", "원01", "원02", "원03", "원04", "원05", "원06"); result.VisualReviewNotes.Add("사전_역대투표율 알파 처리 확인: mid/final PGM 캡처 비교 필요"); } if ((string.Equals(result.BaseName, "1-3위_ani_광역단체장", StringComparison.Ordinal) || string.Equals(result.BaseName, "1-3위_보궐선거", StringComparison.Ordinal)) && !result.Tags.Contains("순위03")) { result.TagIssues.Add("`순위03` 태그 누락 의심: 사용자가 요청한 `순위01 2` → `순위03` 재검증 필요"); } } private static void AddPromiseGroupIssues(AuditSceneResult result) { if (!result.BaseName.StartsWith("경력_", StringComparison.Ordinal)) { return; } result.VisualReviewNotes.Add("경력 컷 기호 두 자리 영역 침범 확인: stress 캡처에서 기호 11/12 적용"); var groupSlots = result.Tags .Select(tag => Regex.Match(tag, @"^공약그룹(?\d{2})", RegexOptions.CultureInvariant)) .Where(match => match.Success) .Select(match => match.Groups["slot"].Value) .ToHashSet(StringComparer.Ordinal); var promiseSlots = result.Tags .Select(tag => Regex.Match(tag, @"^공약(?\d{2})", RegexOptions.CultureInvariant)) .Where(match => match.Success) .Select(match => match.Groups["slot"].Value) .ToHashSet(StringComparer.Ordinal); foreach (var slot in groupSlots) { if (!promiseSlots.Contains(slot)) { result.TagIssues.Add($"공약그룹{slot}에 대응하는 공약{slot} 태그 누락 의심"); } } } private static void AddKnownManualChecklist(AuditSceneResult result) { if (result.BaseName is "1-2위_ani_광역단체장") { result.ColorGuidanceIssues.Add("정당명 좌/우 색상 지침 없음: RGB txt는 정당판/정당바/득표율 중심"); } if (result.BaseName is "1-2위_ani_기초단체장") { result.ColorGuidanceIssues.Add("득표수 색상값 지침 없음: 정당색과 미묘하게 다른 화면 색상 여부 확인 필요"); } if (result.BaseName is "1-2위_광역단체장_시도별영상" or "1-2위_기초단체장_시도별영상") { result.ColorGuidanceIssues.Add("RGB txt 기준 변경 시 샘플 색상 차이 여부 확인 필요"); } if (result.BaseName is "1-3위_ani_기초단체장") { result.VisualReviewNotes.Add("순위 하늘색 그라데이션이 모든 정당 적용인지 확인 필요"); } if (result.BaseName.StartsWith("모든후보_", StringComparison.Ordinal) || result.BaseName.StartsWith("전후보_", StringComparison.Ordinal)) { result.VisualReviewNotes.Add("0.3% 극소 득표율 stress 캡처에서 음수/역방향 표기 확인 필요"); } if (result.FolderName == "Elect2026_Top_민방" && string.Equals(result.BaseName, "투표율_선거구별", StringComparison.Ordinal)) { result.TagIssues.Add("좌상단 투표율_선거구함 태그 형태가 잘못되어 있으나 사용자 요청에 따라 미변경, 재작업 필요 시 별도 언급"); } } private static void AddRgbGuidanceIssues( AuditSceneResult result, string rootPath, IReadOnlyDictionary rgbCatalog) { var spec = ResolveRgbSpec(result, rootPath, rgbCatalog, out var mappingKind); result.RgbSpecPath = spec?.Path; result.RgbMappingKind = mappingKind; result.RgbSpec = spec; var colorPrefixesInScene = result.Tags .Select(ResolvePrefix) .Where(prefix => ColorInstructionPrefixes.Contains(prefix, StringComparer.Ordinal)) .Distinct(StringComparer.Ordinal) .OrderBy(prefix => prefix, StringComparer.Ordinal) .ToArray(); if (colorPrefixesInScene.Length == 0) { return; } if (spec is null) { result.ColorGuidanceIssues.Add($"정당/후보 색상 관련 태그({string.Join(", ", colorPrefixesInScene)})가 있으나 RGB 기준 파일 없음"); return; } if (!string.Equals(mappingKind, "exact", StringComparison.Ordinal) && !string.Equals(mappingKind, "explicit", StringComparison.Ordinal)) { result.ColorGuidanceIssues.Add($"RGB 기준 파일 안내 필요: `{Path.GetFileName(spec.Path)}` ({mappingKind})"); } foreach (var prefix in colorPrefixesInScene) { if (!spec.Sections.ContainsKey(NormalizeKey(prefix))) { result.ColorGuidanceIssues.Add($"`{prefix}` 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음"); } } } private static void AddFamilyComparisonIssues(IReadOnlyList results) { foreach (var group in results.GroupBy(result => result.FamilyKey).Where(group => group.Count() > 1)) { var baseline = group .OrderBy(result => GetVariantWeight(result.BaseName)) .ThenBy(result => result.BaseName, StringComparer.OrdinalIgnoreCase) .First(); var baselineTags = baseline.Tags.ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var result in group) { if (ReferenceEquals(result, baseline)) { continue; } var missing = baselineTags .Except(result.Tags, StringComparer.OrdinalIgnoreCase) .Where(IsImportantTag) .Take(10) .ToArray(); var extra = result.Tags .Except(baselineTags, StringComparer.OrdinalIgnoreCase) .Where(IsImportantTag) .Take(10) .ToArray(); if (missing.Length > 0 || extra.Length > 0) { result.TagIssues.Add( $"계열 태그 차이: 기준 `{baseline.BaseName}` 대비 missing=[{string.Join(", ", missing)}], extra=[{string.Join(", ", extra)}]"); } } } } private static IReadOnlyDictionary LoadRgbCatalog(string rootPath) { var specs = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var folderName in ChannelFolders) { var rgbDirectory = Path.Combine(rootPath, folderName, "RGB"); if (!Directory.Exists(rgbDirectory)) { continue; } foreach (var path in Directory.EnumerateFiles(rgbDirectory, "*.txt", SearchOption.TopDirectoryOnly)) { var key = BuildRgbCatalogKey(folderName, Path.GetFileNameWithoutExtension(path)); specs[key] = ParseRgbSpec(folderName, path); } } return specs; } private static RgbSpec ParseRgbSpec(string folderName, string path) { var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); var currentHeaders = new List(); var headerBuilder = new StringBuilder(); var inHeader = false; foreach (var rawLine in File.ReadLines(path, Encoding.UTF8)) { var line = rawLine.Trim(); if (string.IsNullOrWhiteSpace(line)) { continue; } if (line.StartsWith("(", StringComparison.Ordinal)) { headerBuilder.Clear(); headerBuilder.AppendLine(line); inHeader = !line.Contains(')'); if (!inHeader) { currentHeaders = ParseRgbHeaders(headerBuilder.ToString()); } continue; } if (inHeader) { headerBuilder.AppendLine(line); if (line.Contains(')')) { inHeader = false; currentHeaders = ParseRgbHeaders(headerBuilder.ToString()); } continue; } if (line.StartsWith("R", StringComparison.OrdinalIgnoreCase) || currentHeaders.Count == 0 || !TryParseRgbRow(line, out var partyName, out var color)) { continue; } foreach (var header in currentHeaders) { var key = NormalizeKey(header.SectionName); if (!sections.TryGetValue(key, out var section)) { section = new RgbSection(header.SectionName, header.StyleBinding, new Dictionary(StringComparer.OrdinalIgnoreCase)); sections[key] = section; } section.PartyColors[NormalizeKey(partyName)] = color; } } return new RgbSpec(folderName, Path.GetFileNameWithoutExtension(path), path, sections); } private static List ParseRgbHeaders(string rawHeader) { var header = rawHeader.Trim().Trim('(', ')').Replace("\r", string.Empty); var result = new List(); foreach (var rawLine in header.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var match = Regex.Match(rawLine, @"(?
[^:]+)\s*:\s*(?.*)", RegexOptions.CultureInvariant); if (!match.Success) { continue; } result.Add(new RgbSectionHeader(match.Groups["section"].Value.Trim(), ParseStyleBinding(match.Groups["binding"].Value))); } return result; } private static RgbStyleBinding? ParseStyleBinding(string raw) { var match = Regex.Match(raw, @"(?face|edge|shadow|underline|frame)(?:\s*(?\d+)번째)?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); if (!match.Success) { return null; } var styleType = match.Groups["target"].Value.ToLowerInvariant() switch { "face" => eKStyleType.STYLE_TYPE_FACE, "edge" => eKStyleType.STYLE_TYPE_EDGE, "shadow" => eKStyleType.STYLE_TYPE_SHADOW, "underline" => eKStyleType.STYLE_TYPE_UNDERLINE, "frame" => eKStyleType.STYLE_TYPE_FRAME, _ => eKStyleType.STYLE_TYPE_FACE }; var order = match.Groups["order"].Success && int.TryParse(match.Groups["order"].Value, out var parsedOrder) ? Math.Max(0, parsedOrder - 1) : 0; return new RgbStyleBinding(styleType, order); } private static bool TryParseRgbRow(string line, out string partyName, out RgbColor color) { partyName = string.Empty; color = default; var parts = Regex.Split(line, @"\s+").Where(part => !string.IsNullOrWhiteSpace(part)).ToArray(); if (parts.Length < 4) { return false; } if (!byte.TryParse(parts[^3], out var r) || !byte.TryParse(parts[^2], out var g) || !byte.TryParse(parts[^1], out var b)) { return false; } partyName = string.Join(string.Empty, parts.Take(parts.Length - 3)); color = new RgbColor(r, g, b); return !string.IsNullOrWhiteSpace(partyName); } private static RgbSpec? ResolveRgbSpec( AuditSceneResult result, string rootPath, IReadOnlyDictionary rgbCatalog, out string mappingKind) { var normalizedBaseName = NormalizeVariantName(result.BaseName); var explicitBaseName = TryResolveExplicitRgbSpec(result.FolderName, normalizedBaseName); if (!string.IsNullOrWhiteSpace(explicitBaseName) && rgbCatalog.TryGetValue(BuildRgbCatalogKey(result.FolderName, explicitBaseName), out var explicitSpec)) { mappingKind = "explicit"; return explicitSpec; } if (rgbCatalog.TryGetValue(BuildRgbCatalogKey(result.FolderName, result.BaseName), out var exactSpec) || rgbCatalog.TryGetValue(BuildRgbCatalogKey(result.FolderName, normalizedBaseName), out exactSpec)) { mappingKind = "exact"; return exactSpec; } var folderSpecs = rgbCatalog.Values .Where(spec => string.Equals(spec.FolderName, result.FolderName, StringComparison.OrdinalIgnoreCase)) .Select(spec => new { Spec = spec, Score = ScoreRgbSpecMatch(normalizedBaseName, spec.SpecBaseName) }) .Where(item => item.Score > 0) .OrderByDescending(item => item.Score) .ThenBy(item => item.Spec.Path, StringComparer.OrdinalIgnoreCase) .ToArray(); if (folderSpecs.Length > 0) { mappingKind = "heuristic"; return folderSpecs[0].Spec; } mappingKind = "none"; return null; } private static string? TryResolveExplicitRgbSpec(string folderName, string templateName) { var mappings = BuildExplicitRgbSpecMap(); return mappings.TryGetValue($"{folderName}|{templateName}", out var specBaseName) ? specBaseName : null; } private static IReadOnlyDictionary BuildExplicitRgbSpecMap() { var mappings = new Dictionary(StringComparer.OrdinalIgnoreCase); void Add(string folderName, string specBaseName, params string[] templateNames) { foreach (var templateName in templateNames) { mappings[$"{folderName}|{templateName}"] = specBaseName; } } Add("Elect2026_Normal_민방", "1-2위_ani_광역단체장", "1-2위_ani_광역단체장"); Add("Elect2026_Normal_민방", "1-2위_ani_기초단체장", "1-2위_ani_기초단체장"); Add("Elect2026_Normal_민방", "1-2위_광역단체장, 보궐", "1-2위_광역단체장", "1-2위_보궐선거"); Add("Elect2026_Normal_민방", "1-2위_광역단체장,기초단체장_시도별영상", "1-2위_광역단체장_시도별영상", "1-2위_기초단체장_시도별영상"); Add("Elect2026_Normal_민방", "1-2위_교육감", "1-2위_교육감"); Add("Elect2026_Normal_민방", "1-2위_기초단체장", "1-2위_기초단체장"); Add("Elect2026_Normal_민방", "1-3위_ani_광역단체장,보궐", "1-3위_ani_광역단체장", "1-3위_보궐선거"); Add("Elect2026_Normal_민방", "1-3위_ani_기초단체장(5760동일)", "1-3위_ani_기초단체장", "1-3위_기초단체장"); Add("Elect2026_Normal_민방", "경력", "경력_광역단체장_in", "경력_기초단체장_in"); Add("Elect2026_Normal_민방", "당선", "당선_광역단체장", "당선_광역의원", "당선_기초단체장", "당선_기초의원"); Add("Elect2026_Normal_민방", "당선_교육감", "당선_교육감"); Add("Elect2026_Normal_민방", "모든후보", "모든후보_광역단체장", "모든후보_기초단체장"); Add("Elect2026_Normal_민방", "모든후보_교육감", "모든후보_교육감"); Add("Elect2026_Normal_민방", "사전_역대당선", "사전_역대당선자", "사전_역대당선자_기초단체장"); Add("Elect2026_Normal_민방", "사전_역대당선_교육감", "사전_역대당선자_교육감"); Add("Elect2026_Normal_민방", "이시각1위_광역단체장", "이시각1위_광역단체장"); Add("Elect2026_Normal_민방", "이시각1위_광역단체장_5760", "이시각1위_광역단체장_5760"); Add("Elect2026_Normal_민방", "이시각1위_기초단체장(5760동일)", "이시각1위_기초단체장"); Add("Elect2026_Normal_민방", "접전,초접전", "접전_광역단체장", "접전_기초단체장", "초접전_광역단체장", "초접전_기초단체장"); Add("Elect2026_Normal_민방", "판세_광역단체장", "판세_광역단체장", "판세_기초단체장"); Add("Elect2026_Bottom_민방", "1-2위, 1-3위, 이시각1위", "1-2위_광역단체장", "1-2위_기초단체장", "1-3위_광역단체장", "1-3위_기초단체장", "1위_광역단체장", "1위_기초단체장"); Add("Elect2026_Bottom_민방", "당선", "당선_광역단체장", "당선_광역의원", "당선_기초단체장", "당선_기초의원"); Add("Elect2026_Bottom_민방", "모든후보", "전후보_광역단체장", "전후보_기초단체장"); Add("Elect2026_Bottom_민방", "모든후보_교육감", "전후보_교육감"); Add("Elect2026_Top_민방", "1-2위_사진", "광역단체장_2인", "기초단체장_2인"); Add("Elect2026_Top_민방", "1-2위_텍스트", "광역단체장_2인_텍스트", "기초단체장_2인_텍스트"); return mappings; } private static int ScoreRgbSpecMatch(string templateName, string specBaseName) { if (string.Equals(templateName, specBaseName, StringComparison.OrdinalIgnoreCase)) { return 1000; } var templateTokens = Tokenize(templateName); var specTokens = Tokenize(specBaseName); var score = templateTokens.Count(token => specTokens.Contains(token)) * 100; if (NormalizeKey(templateName).Contains(NormalizeKey(specBaseName), StringComparison.OrdinalIgnoreCase) || NormalizeKey(specBaseName).Contains(NormalizeKey(templateName), StringComparison.OrdinalIgnoreCase)) { score += 250; } return score; } private static IReadOnlyList ExtractAuditTagNames(string scenePath) { var data = File.ReadAllBytes(scenePath); var candidates = new HashSet(StringComparer.Ordinal); for (var index = 0; index + 8 <= data.Length; index++) { var length = BitConverter.ToInt32(data, index); if (length < 2 || length > 64) { continue; } var byteLength = length * 2; var start = index + 4; if (start + byteLength > data.Length) { continue; } string value; try { value = Encoding.Unicode.GetString(data, start, byteLength).Trim(); } catch { continue; } if (IsAuditTagName(value)) { candidates.Add(value); } } return candidates.OrderBy(value => value, StringComparer.Ordinal).ToArray(); } private static bool IsAuditTagName(string value) { if (string.IsNullOrWhiteSpace(value) || value.Length is < 2 or > 64) { return false; } if (value.Contains('\\') || value.Contains('/') || value.Contains(':') || value.Contains('.')) { return false; } if (value.StartsWith("OBJECT_TYPE_", StringComparison.Ordinal) || value.StartsWith("RESULT_", StringComparison.Ordinal)) { return false; } if (!value.All(ch => ch is >= '0' and <= '9' || ch is >= 'A' and <= 'Z' || ch is >= 'a' and <= 'z' || ch is >= '\uAC00' and <= '\uD7A3' || ch is '_' or '-' or ' ')) { return false; } return SemanticPrefixes.Any(prefix => value.StartsWith(prefix, StringComparison.Ordinal)) || Regex.IsMatch(value, @"^[가-힣A-Za-z_]+[0-9]{1,2}\s+[0-9]+$", RegexOptions.CultureInvariant); } private static void RequireTags(AuditSceneResult result, string message, params string[] tags) { var missing = tags.Where(tag => !result.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)).ToArray(); if (missing.Length > 0) { result.TagIssues.Add($"{message}: missing [{string.Join(", ", missing)}]"); } } private static void RequireAnyPrefix(AuditSceneResult result, string message, string prefix) { if (!result.Tags.Any(tag => tag.StartsWith(prefix, StringComparison.Ordinal))) { result.TagIssues.Add(message); } } private static bool IsHistoricalTurnout(string baseName) { return baseName.Contains("사전_역대투표율", StringComparison.Ordinal); } private static bool IsImportantTag(string tag) { var prefix = ResolvePrefix(tag); return SemanticPrefixes.Contains(prefix, StringComparer.Ordinal); } private static int GetVariantWeight(string baseName) { var value = baseName; if (Regex.IsMatch(value, @"^(810|2880|8316)_", RegexOptions.CultureInvariant)) { return 5; } if (value.Contains("_loop", StringComparison.OrdinalIgnoreCase)) { return 4; } if (value.Contains("_L", StringComparison.OrdinalIgnoreCase)) { return 3; } if (value.Contains("_HD", StringComparison.OrdinalIgnoreCase) || value.Contains("_5760", StringComparison.OrdinalIgnoreCase)) { return 2; } return 0; } private static BroadcastChannel ResolveChannel(string folderName) { return folderName switch { "Elect2026_Bottom_민방" => BroadcastChannel.Bottom, "Elect2026_Top_민방" => BroadcastChannel.TopLeft, _ => BroadcastChannel.Normal }; } private static AuditChannelBinding ResolveBinding(string channel) { var broadcastChannel = Enum.Parse(channel); var envName = broadcastChannel switch { BroadcastChannel.Normal => "TORNADO_KARISMA_BIND_NORMAL", BroadcastChannel.TopLeft => "TORNADO_KARISMA_BIND_TOPLEFT", BroadcastChannel.Bottom => "TORNADO_KARISMA_BIND_BOTTOM", _ => string.Empty }; if (!string.IsNullOrWhiteSpace(envName)) { var raw = Environment.GetEnvironmentVariable(envName); if (!string.IsNullOrWhiteSpace(raw)) { var parts = raw.Split(':'); if (parts.Length == 2 && int.TryParse(parts[0], out var output) && int.TryParse(parts[1], out var layer)) { return new AuditChannelBinding(output, layer); } } } return DefaultBindings[broadcastChannel]; } private static IReadOnlyList ResolveScenarios(AuditScenarioMode mode) { return mode switch { AuditScenarioMode.Basic => [AuditScenario.Basic], AuditScenarioMode.Variant => [AuditScenario.Variant], AuditScenarioMode.Stress => [AuditScenario.Stress], _ => [AuditScenario.Basic, AuditScenario.Variant, AuditScenario.Stress] }; } private static string BuildFamilyKey(string folderName, string baseName) { return $"{folderName}|{NormalizeVariantName(baseName)}"; } private static string NormalizeVariantName(string baseName) { var value = Regex.Replace(baseName, @"^(810|2880|8316)_", string.Empty, RegexOptions.CultureInvariant); value = Regex.Replace(value, @"_loop$", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); value = Regex.Replace(value, @"_END$", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); value = Regex.Replace(value, @"_L_1$", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); value = Regex.Replace(value, @"_L$", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); value = Regex.Replace(value, @"_HD$", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); value = Regex.Replace(value, @"_5760$", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); value = Regex.Replace(value, @"_7680$", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); value = Regex.Replace(value, @"_1$", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); return value; } private static string ResolvePrefix(string tag) { var compact = tag.Replace(" ", string.Empty, StringComparison.Ordinal); foreach (var prefix in SemanticPrefixes.OrderByDescending(prefix => prefix.Length)) { if (compact.StartsWith(prefix, StringComparison.Ordinal)) { return prefix; } } return Regex.Replace(compact, @"\d.*$", string.Empty, RegexOptions.CultureInvariant); } private static int ResolveSlot(string tag) { var match = Regex.Match(tag, @"(?\d{1,2})", RegexOptions.CultureInvariant); return match.Success && int.TryParse(match.Groups["slot"].Value, out var slot) ? Math.Max(1, slot) : 1; } private static string BuildRgbCatalogKey(string folderName, string specBaseName) { return $"{folderName}|{specBaseName}"; } private static string NormalizeKey(string value) { return Regex.Replace(value, @"[^\p{L}\p{Nd}]+", string.Empty, RegexOptions.CultureInvariant); } private static HashSet Tokenize(string value) { return value .Split(['_', ',', ' ', '(', ')'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(NormalizeKey) .Where(token => !string.IsNullOrWhiteSpace(token)) .ToHashSet(StringComparer.OrdinalIgnoreCase); } private static PgmWindow? TryFindPgmWindow() { var process = Process.GetProcessesByName("Tornado3") .FirstOrDefault(candidate => string.Equals(candidate.MainWindowTitle, "PGM", StringComparison.Ordinal)); if (process is null || process.MainWindowHandle == IntPtr.Zero) { return null; } if (TryGetDwmExtendedFrameBounds(process.MainWindowHandle, out var dwmBounds)) { return new PgmWindow(process.MainWindowHandle, dwmBounds); } if (TryGetClientBounds(process.MainWindowHandle, out var clientBounds)) { return new PgmWindow(process.MainWindowHandle, clientBounds); } if (!GetWindowRect(process.MainWindowHandle, out var rect)) { return null; } return new PgmWindow(process.MainWindowHandle, new Rect(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top)); } private static bool TryGetDwmExtendedFrameBounds(IntPtr handle, out Rect bounds) { bounds = default; var result = DwmGetWindowAttribute( handle, DwmwaExtendedFrameBounds, out var rect, Marshal.SizeOf()); if (result != 0) { return false; } var width = rect.Right - rect.Left; var height = rect.Bottom - rect.Top; if (width <= 0 || height <= 0) { return false; } bounds = new Rect(rect.Left, rect.Top, width, height); return true; } private static bool TryGetClientBounds(IntPtr handle, out Rect bounds) { bounds = default; if (!GetClientRect(handle, out var rect)) { return false; } var width = rect.Right - rect.Left; var height = rect.Bottom - rect.Top; if (width <= 0 || height <= 0) { return false; } var point = new NativePoint(rect.Left, rect.Top); if (!ClientToScreen(handle, ref point)) { return false; } bounds = new Rect(point.X, point.Y, width, height); return true; } private static Bitmap CaptureWindowBitmap(IntPtr handle, Rect bounds) { var width = Math.Max(1, bounds.Width); var height = Math.Max(1, bounds.Height); var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); using var graphics = Graphics.FromImage(bitmap); graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, new Size(width, height), CopyPixelOperation.SourceCopy); return bitmap; } private static string ComputeSha256(string path) { using var stream = File.OpenRead(path); using var sha = SHA256.Create(); return Convert.ToHexString(sha.ComputeHash(stream)); } private static string SanitizeFileName(string value) { var invalidChars = Path.GetInvalidFileNameChars(); var builder = new StringBuilder(value.Length); foreach (var ch in value) { builder.Append(invalidChars.Contains(ch) ? '_' : ch); } var sanitized = builder.ToString().Trim(); return string.IsNullOrWhiteSpace(sanitized) ? "cut" : sanitized; } private static void WriteReports( CutFileAuditOptions options, IReadOnlyList results, IReadOnlyDictionary rgbCatalog) { var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; File.WriteAllText(Path.Combine(options.OutputPath, "results.json"), JsonSerializer.Serialize(results, jsonOptions), Encoding.UTF8); WriteCsv(Path.Combine(options.OutputPath, "results.csv"), results); WriteMarkdown(Path.Combine(options.OutputPath, "CUT_FILE_AUDIT_REPORT.md"), options, results, rgbCatalog); } private static void WriteCsv(string path, IReadOnlyList results) { var builder = new StringBuilder(); builder.AppendLine("Index,Status,Folder,BaseName,Channel,TagCount,RgbSpec,RgbMapping,TagIssues,ColorGuidanceIssues,VisualReviewNotes,VisualDiffs,Captures,Failure"); foreach (var result in results) { builder.AppendLine(string.Join(",", Csv(result.Index.ToString()), Csv(result.Status), Csv(result.FolderName), Csv(result.BaseName), Csv(result.Channel), Csv(result.TagCount.ToString()), Csv(result.RgbSpecPath is null ? string.Empty : Path.GetFileName(result.RgbSpecPath)), Csv(result.RgbMappingKind ?? string.Empty), Csv(string.Join(" | ", result.TagIssues)), Csv(string.Join(" | ", result.ColorGuidanceIssues)), Csv(string.Join(" | ", result.VisualReviewNotes)), Csv(string.Join(" | ", result.VisualDiffs.Select(diff => $"{diff.Scenario}:changed={diff.ChangedPixelRatio:P1}:avgDelta={diff.AverageRgbDelta:0.0}"))), Csv(string.Join(" | ", result.Captures.Select(capture => $"{capture.Scenario}:{capture.FrameLabel}:{capture.Path}"))), Csv(result.CaptureFailureDetail ?? string.Empty))); } File.WriteAllText(path, builder.ToString(), Encoding.UTF8); } private static void WriteMarkdown( string path, CutFileAuditOptions options, IReadOnlyList results, IReadOnlyDictionary rgbCatalog) { var builder = new StringBuilder(); builder.AppendLine("# 컷파일 태그/색상 지침 전수 확인 리포트"); builder.AppendLine(); builder.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}"); builder.AppendLine($"- Root: `{options.RootPath}`"); builder.AppendLine($"- Output: `{options.OutputPath}`"); builder.AppendLine($"- Scope: 채널별 최상위 `.tscn` {results.Count}개"); builder.AppendLine($"- RGB txt: {rgbCatalog.Count}개"); builder.AppendLine($"- Scenarios: `{options.ScenarioMode}`"); builder.AppendLine(); builder.AppendLine("## Status Counts"); builder.AppendLine(); foreach (var status in new[] { "pass", "issue", "needs-guidance", "capture-failed" }) { builder.AppendLine($"- `{status}`: {results.Count(result => result.Status == status)}"); } builder.AppendLine(); builder.AppendLine("## 주요 이슈"); builder.AppendLine(); foreach (var result in results.Where(result => result.Status != "pass").OrderBy(result => result.Status).ThenBy(result => result.RelativePath, StringComparer.OrdinalIgnoreCase)) { builder.AppendLine($"### `{result.RelativePath}`"); builder.AppendLine(); builder.AppendLine($"- Status: `{result.Status}`"); builder.AppendLine($"- Tags: {result.TagCount}"); builder.AppendLine($"- RGB: `{(result.RgbSpecPath is null ? "(none)" : Path.GetFileName(result.RgbSpecPath))}` / `{result.RgbMappingKind}`"); if (result.CaptureFailed) { builder.AppendLine($"- Capture: failed - {EscapeInline(result.CaptureFailureDetail ?? string.Empty)}"); } else { builder.AppendLine($"- Captures: {result.Captures.Count}"); } AppendIssueList(builder, "Tag Issues", result.TagIssues); AppendIssueList(builder, "Color Guidance", result.ColorGuidanceIssues); AppendIssueList(builder, "Visual Review", result.VisualReviewNotes); AppendVisualDiffList(builder, result.VisualDiffs); builder.AppendLine(); } builder.AppendLine("## 파일"); builder.AppendLine(); builder.AppendLine("- `results.csv`"); builder.AppendLine("- `results.json`"); builder.AppendLine("- `captures/*.png`"); File.WriteAllText(path, builder.ToString(), Encoding.UTF8); } private static void AppendIssueList(StringBuilder builder, string title, IReadOnlyList issues) { if (issues.Count == 0) { return; } builder.AppendLine($"- {title}:"); foreach (var issue in issues.Take(20)) { builder.AppendLine($" - {EscapeInline(issue)}"); } if (issues.Count > 20) { builder.AppendLine($" - ... (+{issues.Count - 20})"); } } private static void AppendVisualDiffList(StringBuilder builder, IReadOnlyList diffs) { if (diffs.Count == 0) { return; } builder.AppendLine("- Baseline Diffs:"); foreach (var diff in diffs.Take(6)) { builder.AppendLine($" - `{diff.Scenario}`: changed {diff.ChangedPixelRatio:P1}, avg RGB delta {diff.AverageRgbDelta:0.0}"); } if (diffs.Count > 6) { builder.AppendLine($" - ... (+{diffs.Count - 6})"); } } private static string Csv(string value) { return $"\"{value.Replace("\"", "\"\"")}\""; } private static string EscapeInline(string value) { return value.Replace("|", "\\|", StringComparison.Ordinal); } [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetClientRect(IntPtr hWnd, out NativeRect lpRect); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool ClientToScreen(IntPtr hWnd, ref NativePoint lpPoint); [DllImport("dwmapi.dll")] private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out NativeRect pvAttribute, int cbAttribute); private const int DwmwaExtendedFrameBounds = 9; private readonly record struct AuditScene(string ScenePath, string RelativePath, string FolderName, string BaseName, BroadcastChannel Channel); private readonly record struct AuditChannelBinding(int OutputChannelIndex, int LayerNo); private readonly record struct ScenePayload( IReadOnlyDictionary Values, IReadOnlyList CounterNumberKeys, IReadOnlyList StyleColorUpdates, IReadOnlyList VisibilityUpdates); private readonly record struct RgbColor(byte R, byte G, byte B); private readonly record struct RgbStyleBinding(eKStyleType StyleType, int Order); private readonly record struct RgbSectionHeader(string SectionName, RgbStyleBinding? StyleBinding); private readonly record struct AuditCapture(string Scenario, string FrameLabel, string Path, string Hash); private readonly record struct AuditVisualDiff(string Scenario, double ChangedPixelRatio, double AverageRgbDelta, string BaselinePath, string AppliedPath); private readonly record struct PgmWindow(IntPtr Handle, Rect Bounds); private readonly record struct Rect(int Left, int Top, int Width, int Height); [StructLayout(LayoutKind.Sequential)] private readonly struct NativeRect { public readonly int Left; public readonly int Top; public readonly int Right; public readonly int Bottom; } [StructLayout(LayoutKind.Sequential)] private struct NativePoint { public int X; public int Y; public NativePoint(int x, int y) { X = x; Y = y; } } private sealed record RgbSpec( string FolderName, string SpecBaseName, string Path, IReadOnlyDictionary Sections); private sealed record RgbSection( string DisplayName, RgbStyleBinding? StyleBinding, Dictionary PartyColors) { public bool TryGetPartyColor(string partyName, out RgbColor color) { return PartyColors.TryGetValue(NormalizeKey(partyName), out color) || PartyColors.TryGetValue("무기타", out color); } } private sealed class AuditSceneResult { public int Index { get; init; } public string ScenePath { get; init; } = string.Empty; public string RelativePath { get; init; } = string.Empty; public string FolderName { get; init; } = string.Empty; public string BaseName { get; init; } = string.Empty; public string FamilyKey { get; init; } = string.Empty; public string Channel { get; init; } = string.Empty; public int TagCount { get; init; } public IReadOnlyList Tags { get; init; } = []; public string? RgbSpecPath { get; set; } public string? RgbMappingKind { get; set; } public RgbSpec? RgbSpec { get; set; } public List TagIssues { get; } = []; public List ColorGuidanceIssues { get; } = []; public List VisualReviewNotes { get; } = []; public List Captures { get; } = []; public List VisualDiffs { get; } = []; public bool CaptureFailed { get; set; } public string? CaptureFailureDetail { get; set; } public string Status { get; private set; } = "pass"; public void FinalizeStatus() { Status = CaptureFailed ? "capture-failed" : TagIssues.Count > 0 ? "issue" : ColorGuidanceIssues.Count > 0 || VisualReviewNotes.Count > 0 ? "needs-guidance" : "pass"; } } private sealed class AuditAssetCatalog { private static readonly string[] CandidateImageExtensions = [".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tga"]; private readonly string _rootPath; private readonly string _fallbackCandidateImagePath; private readonly Dictionary _candidateImageCache = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _judgementTagCache = new(StringComparer.OrdinalIgnoreCase); private AuditAssetCatalog(string rootPath, string fallbackCandidateImagePath) { _rootPath = rootPath; _fallbackCandidateImagePath = fallbackCandidateImagePath; } public static AuditAssetCatalog Create(string rootPath, string outputPath) { var assetPath = Path.Combine(outputPath, "_audit_assets"); Directory.CreateDirectory(assetPath); var fallbackCandidateImagePath = Path.Combine(assetPath, "candidate_photo_fallback.png"); WriteSolidPng(fallbackCandidateImagePath, Color.FromArgb(255, 90, 110, 130), Color.White, "PHOTO"); return new AuditAssetCatalog(rootPath, fallbackCandidateImagePath); } public string GetCandidateImage(string folderName, string sceneKey, int slot, AuditScenario scenario) { var images = GetCandidateImages(folderName); if (images.Length == 0) { images = GetCandidateImages("Elect2026_Normal_민방"); } if (images.Length == 0) { return _fallbackCandidateImagePath; } var index = StableIndex($"{sceneKey}|{slot}|{scenario}", images.Length); return images[index]; } public string? GetJudgementTag(string folderName, int slot, AuditScenario scenario) { var label = scenario == AuditScenario.Stress ? "확실" : slot switch { 1 => "당선", 2 => "유력", _ => "확실" }; var key = $"{folderName}|{label}"; if (_judgementTagCache.TryGetValue(key, out var cached)) { return cached; } foreach (var folder in FolderSearchOrder(folderName)) { var tagPath = Path.Combine(_rootPath, folder, "Images", "Tag"); foreach (var fileName in new[] { $"{label}.vrv", $"{label}_still.png" }) { var path = Path.Combine(tagPath, fileName); if (File.Exists(path)) { _judgementTagCache[key] = path; return path; } } } _judgementTagCache[key] = null; return null; } private string[] GetCandidateImages(string folderName) { if (_candidateImageCache.TryGetValue(folderName, out var cached)) { return cached; } var photoPath = Path.Combine(_rootPath, folderName, "Images", "Photo"); var images = Directory.Exists(photoPath) ? Directory.EnumerateFiles(photoPath, "*.*", SearchOption.TopDirectoryOnly) .Where(path => Path.GetFileName(path).StartsWith("sample", StringComparison.OrdinalIgnoreCase) && CandidateImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) .OrderBy(path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase) .ToArray() : Array.Empty(); _candidateImageCache[folderName] = images; return images; } private static IEnumerable FolderSearchOrder(string folderName) { yield return folderName; foreach (var fallback in ChannelFolders) { if (!string.Equals(fallback, folderName, StringComparison.OrdinalIgnoreCase)) { yield return fallback; } } } private static int StableIndex(string key, int count) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); return (int)(BitConverter.ToUInt32(hash, 0) % (uint)count); } private static void WriteSolidPng(string path, Color background, Color foreground, string text) { if (File.Exists(path)) { return; } using var bitmap = new Bitmap(384, 384, PixelFormat.Format32bppArgb); using var graphics = Graphics.FromImage(bitmap); graphics.Clear(background); using var pen = new Pen(foreground, 16); graphics.DrawRectangle(pen, 16, 16, 352, 352); using var font = new Font("Arial", 48, FontStyle.Bold, GraphicsUnit.Pixel); using var brush = new SolidBrush(foreground); var label = text.Length > 6 ? text[..6] : text; var size = graphics.MeasureString(label, font); graphics.DrawString(label, font, brush, (bitmap.Width - size.Width) / 2, (bitmap.Height - size.Height) / 2); bitmap.Save(path, ImageFormat.Png); } } private enum AuditScenario { Basic, Variant, Stress } private enum AuditScenarioMode { All, Basic, Variant, Stress } private sealed record CutFileAuditOptions { public string RootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath(); public string OutputPath { get; init; } = string.Empty; public string CapturePath => Path.Combine(OutputPath, "captures"); public string Host { get; init; } = "127.0.0.1"; public int Port { get; init; } = 30001; public string? Filter { get; init; } public IReadOnlySet IncludeBaseNames { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); public int? Limit { get; init; } public int OnAirDelayMs { get; init; } = 1200; public int BetweenDelayMs { get; init; } = 250; public int HistoricalMidDelayMs { get; init; } = 1200; public int HistoricalFinalDelayMs { get; init; } = 8000; public bool CaptureHistoricalMidFrame { get; init; } = true; public AuditScenarioMode ScenarioMode { get; init; } = AuditScenarioMode.All; public static CutFileAuditOptions Parse(string[] args) { var repoRoot = Environment.CurrentDirectory; var options = new CutFileAuditOptions { OutputPath = Path.Combine(repoRoot, "artifacts", "cut-file-audit", DateTime.Now.ToString("yyyyMMdd_HHmmss")), Host = string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TORNADO_KARISMA_HOST")) ? "127.0.0.1" : Environment.GetEnvironmentVariable("TORNADO_KARISMA_HOST")!, Port = int.TryParse(Environment.GetEnvironmentVariable("TORNADO_KARISMA_PORT"), out var envPort) && envPort > 0 ? envPort : 30001 }; for (var index = 0; index < args.Length; index++) { switch (args[index]) { case "--root": _ = RequireValue(args, ref index, "--root"); break; case "--output": options = options with { OutputPath = RequireValue(args, ref index, "--output") }; break; case "--filter": options = options with { Filter = RequireValue(args, ref index, "--filter") }; break; case "--include": options = options with { IncludeBaseNames = ParseIncludeBaseNames(RequireValue(args, ref index, "--include")) }; break; case "--limit": options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) }; break; case "--onair-delay-ms": options = options with { OnAirDelayMs = int.Parse(RequireValue(args, ref index, "--onair-delay-ms")) }; break; case "--between-delay-ms": options = options with { BetweenDelayMs = int.Parse(RequireValue(args, ref index, "--between-delay-ms")) }; break; case "--historical-mid-delay-ms": options = options with { HistoricalMidDelayMs = int.Parse(RequireValue(args, ref index, "--historical-mid-delay-ms")) }; break; case "--historical-final-delay-ms": options = options with { HistoricalFinalDelayMs = int.Parse(RequireValue(args, ref index, "--historical-final-delay-ms")) }; break; case "--no-historical-mid-frame": options = options with { CaptureHistoricalMidFrame = false }; break; case "--scenario": options = options with { ScenarioMode = ParseScenarioMode(RequireValue(args, ref index, "--scenario")) }; break; default: throw new ArgumentException($"Unknown option: {args[index]}"); } } return options with { RootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()), OutputPath = Path.GetFullPath(options.OutputPath) }; } private static IReadOnlySet ParseIncludeBaseNames(string raw) { return raw .Split(['|', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToHashSet(StringComparer.OrdinalIgnoreCase); } private static AuditScenarioMode ParseScenarioMode(string raw) { return raw.Trim().ToLowerInvariant() switch { "all" => AuditScenarioMode.All, "basic" => AuditScenarioMode.Basic, "variant" => AuditScenarioMode.Variant, "stress" => AuditScenarioMode.Stress, _ => throw new ArgumentException($"Unknown scenario mode: {raw}") }; } private static string RequireValue(string[] args, ref int index, string optionName) { index++; if (index >= args.Length) { throw new ArgumentException($"Missing value for {optionName}."); } return args[index]; } } }