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 Tornado3_2026Election.Domain; using Tornado3_2026Election.Services; internal static class LiveCutValidation { public static async Task RunAsync(string[] args) { var options = LiveCutValidationOptions.Parse(args); Directory.CreateDirectory(options.OutputPath); Console.WriteLine("Karisma live-cut validation starting."); Console.WriteLine($"- Image Root: {options.ImageRootPath}"); Console.WriteLine($"- Output: {options.OutputPath}"); Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}"); Console.WriteLine($"- Capture Mode: {options.CaptureMode}"); var logService = new LogService(); var cutDebugStateStore = new CutDebugStateStore(); if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg) { Console.WriteLine("Karisma adapter is not available. Validation cannot continue."); return 1; } var station = new BroadcastStationProfile { Id = "TJB", Name = "TJB", LogoAssetPath = options.StationLogoPath, RegionFilters = ["대전", "세종", "충남"] }; var cutItems = new FormatCatalogService().GetAll() .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall) .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut))) .ToList(); cutItems = ApplyTemplateFilter(cutItems, options.Filter); if (options.Limit is int limit && limit > 0) { cutItems = cutItems.Take(limit).ToList(); } Console.WriteLine($"- Cuts: {cutItems.Count}"); Console.WriteLine(); var pgmWindow = TryFindPgmWindow(); var results = new List(); try { await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false); for (var index = 0; index < cutItems.Count; index++) { var item = cutItems[index]; var preElection = ShouldUsePreElectionSnapshot(item.Template.Name); var result = new LiveCutValidationResult { Index = index + 1, TemplateId = item.Template.Id, TemplateName = item.Template.Name, CutName = item.Cut.Name, Channel = item.Template.RecommendedChannel.ToString(), Phase = preElection ? BroadcastPhase.PreElection.ToString() : BroadcastPhase.Counting.ToString(), CaptureMode = options.CaptureMode.ToString(), CaptureComparable = options.CaptureMode == LiveCutCaptureMode.Scene || (pgmWindow is not null && item.Template.RecommendedChannel != BroadcastChannel.VideoWall), OutputVisibleInPgm = pgmWindow is not null && item.Template.RecommendedChannel != BroadcastChannel.VideoWall }; Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id}"); try { await OutAllAsync(adapter).ConfigureAwait(false); await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false); var snapshotA = CreateSnapshot( item.Template.Name, index, variant: 0, preElection, options.SwapTopTwoCandidates, options.CycleTopThreeCandidates, options.StressTopRankValues); var snapshotB = CreateSnapshot( item.Template.Name, index, variant: 1, preElection, options.SwapTopTwoCandidates, options.CycleTopThreeCandidates, options.StressTopRankValues); await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotA, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false); await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false); result.CaptureAPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_A.png"); result.HashA = await CaptureValidationImageAsync( adapter, pgmWindow, item, options, result.CaptureAPath, result.OutputVisibleInPgm, CancellationToken.None).ConfigureAwait(false); await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotB, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false); await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false); result.CaptureBPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_B.png"); result.HashB = await CaptureValidationImageAsync( adapter, pgmWindow, item, options, result.CaptureBPath, result.OutputVisibleInPgm, CancellationToken.None).ConfigureAwait(false); result.VisualChanged = !string.Equals(result.HashA, result.HashB, StringComparison.OrdinalIgnoreCase); result.Success = true; result.Detail = result.CaptureComparable ? (result.VisualChanged ? $"{options.CaptureMode} A/B capture changed" : $"{options.CaptureMode} A/B capture hash identical") : "VideoWall output is not visible in the current PGM window"; } catch (Exception exception) { result.Success = false; result.Detail = exception.Message; } finally { try { await adapter.OutAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); } catch { } await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false); results.Add(result); } } } finally { try { await OutAllAsync(adapter).ConfigureAwait(false); } catch { } if (adapter is IDisposable disposable) { disposable.Dispose(); } } WriteReports(options, results); var successCount = results.Count(result => result.Success); var changedCount = results.Count(result => result.Success && result.VisualChanged); var unchangedCount = results.Count(result => result.Success && result.CaptureComparable && !result.VisualChanged); var failureCount = results.Count(result => !result.Success); Console.WriteLine(); Console.WriteLine("Summary"); Console.WriteLine($"- Success: {successCount}/{results.Count}"); Console.WriteLine($"- Visual Changed: {changedCount}"); Console.WriteLine($"- Unchanged Captures: {unchangedCount}"); Console.WriteLine($"- Failures: {failureCount}"); Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}"); return failureCount == 0 ? 0 : 1; } public static async Task RunCutDebugSweepAsync(string[] args) { var options = CutDebugSweepOptions.Parse(args); Directory.CreateDirectory(options.OutputPath); Console.WriteLine("Karisma cut-debug sweep starting."); Console.WriteLine($"- Image Root: {options.ImageRootPath}"); Console.WriteLine($"- Output: {options.OutputPath}"); Console.WriteLine($"- Mode: {options.Mode}"); Console.WriteLine($"- Filter: {(string.IsNullOrWhiteSpace(options.Filter) ? "(none)" : options.Filter)}"); Console.WriteLine($"- Cut Filter: {(string.IsNullOrWhiteSpace(options.CutName) ? "(none)" : options.CutName)}"); Console.WriteLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}"); Console.WriteLine($"- Recommendations Loaded: {CutDebugRecommendationCatalog.Count}"); var pgmWindow = TryFindPgmWindow(); if (pgmWindow is null) { Console.WriteLine("PGM window was not found. Open the PGM window first and rerun."); return 1; } var logService = new LogService(); var cutDebugStateStore = new CutDebugStateStore(); if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg) { Console.WriteLine("Karisma adapter is not available. Sweep cannot continue."); return 1; } var station = CreateValidationStation(options.StationLogoPath); var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService); var cutItems = new FormatCatalogService().GetAll() .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall) .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut))) .ToList(); cutItems = ApplyTemplateFilter(cutItems, options.Filter); cutItems = ApplyCutFilter(cutItems, options.CutName); if (options.ExcludeHistoricalTurnout) { cutItems = cutItems .Where(item => !IsHistoricalTurnoutTemplate(item.Template.Name)) .ToList(); } if (options.Limit is int limit && limit > 0) { cutItems = cutItems.Take(limit).ToList(); } if (cutItems.Count == 0) { Console.WriteLine("No cuts matched the requested filter."); return 1; } Console.WriteLine($"- Cuts: {cutItems.Count}"); Console.WriteLine(); var results = new List(); var replacementAssets = EnsureCutDebugReplacementAssets(options.OutputPath); try { await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false); for (var index = 0; index < cutItems.Count; index++) { var item = cutItems[index]; Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id} / {item.Cut.Name}"); var result = new CutDebugSweepResult { Index = index + 1, TemplateId = item.Template.Id, TemplateName = item.Template.Name, CutName = item.Cut.Name, Channel = item.Template.RecommendedChannel.ToString() }; CutDebugTemplateState? templateState = null; try { var resolvedScene = KarismaSceneResolver.ResolveScene(item.Template, options.ImageRootPath, useLoop: false); result.ScenePath = resolvedScene.Path; var sceneVariables = sceneVariableCatalog.GetSceneVariables(options.ImageRootPath, resolvedScene.Path); templateState = cutDebugStateStore.GetTemplate(item.Template.RecommendedChannel, item.Template.Id, item.Template.Name); templateState.SyncItems(BuildCutDebugDescriptors(item.Template, sceneVariables)); if (CutDebugRecommendationCatalog.TryGetRecommendation(item.Template.Id, out var recommendation)) { Console.WriteLine($" preferred: {recommendation.Key} ({recommendation.Kind})"); } var sweepItems = OrderSweepItems(item.Template.Id, templateState.Items, sceneVariables) .Where(debugItem => options.IncludeItem(debugItem.Key, debugItem.Kind)) .ToList(); var hasExplicitItemFilter = !string.IsNullOrWhiteSpace(options.KeyFilter) || !string.IsNullOrWhiteSpace(options.KindFilter); var targetChangedItems = 0; if (options.MaxItems is int maxItems && maxItems > 0) { if (hasExplicitItemFilter) { sweepItems = sweepItems.Take(maxItems).ToList(); } else { targetChangedItems = maxItems; } } result.ItemCount = sweepItems.Count; var settings = cutDebugStateStore.Get(item.Template.RecommendedChannel); EnableAllDebugCategories(settings); templateState.ClearOverrides(); SetAllItemsEnabled(templateState.Items, true); var preElection = ShouldUsePreElectionSnapshot(item.Template.Name); var snapshot = CreateSnapshot(item.Template.Name, index, variant: 0, preElection, options.SwapTopTwoCandidates); var baselineAPath = Path.Combine( options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_A.png"); var baselineBPath = Path.Combine( options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_B.png"); var baselineA = await CaptureSweepImageAsync( adapter, pgmWindow.Value, item, snapshot, station, options, baselineAPath).ConfigureAwait(false); var baselineB = await CaptureSweepImageAsync( adapter, pgmWindow.Value, item, snapshot, station, options, baselineBPath).ConfigureAwait(false); result.BaselineAPath = baselineA.Path; result.BaselineBPath = baselineB.Path; result.BaselineHash = baselineB.Hash; var baselineDiffPath = Path.Combine( options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_diff.png"); var baselineDiff = CompareImages(baselineA.Path, baselineB.Path, baselineDiffPath); result.NoiseDiffPath = baselineDiffPath; result.NoiseChangedPixels = baselineDiff.ChangedPixels; result.SignificanceThresholdPixels = Math.Max(250, baselineDiff.ChangedPixels * 2); var changedItemCount = 0; foreach (var sweepItem in sweepItems) { templateState.ClearOverrides(); SetAllItemsEnabled(templateState.Items, true); ConfigureSweepItem(templateState, sweepItem, options.Mode, replacementAssets); var caseToken = $"{sweepItem.Key}_{sweepItem.Kind}_{options.Mode}"; var casePath = Path.Combine( options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_{SanitizeFileName(caseToken)}.png"); var diffPath = Path.Combine( options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_{SanitizeFileName(caseToken)}_diff.png"); var logCountBefore = logService.Entries.Count; var caseCapture = await CaptureSweepImageAsync( adapter, pgmWindow.Value, item, snapshot, station, options, casePath).ConfigureAwait(false); var logPreview = CaptureRecentLogs(logService, logCountBefore, 30); var diff = CompareImages(baselineB.Path, caseCapture.Path, diffPath); var itemResult = new CutDebugItemSweepResult { Key = sweepItem.Key, Kind = sweepItem.Kind.ToString(), GroupLabel = sweepItem.GroupLabel, CapturePath = caseCapture.Path, DiffPath = diffPath, Hash = caseCapture.Hash, ChangedPixels = diff.ChangedPixels, ChangeRatio = diff.ChangeRatio, AverageChannelDelta = diff.AverageChannelDelta, SignificantChange = diff.ChangedPixels > result.SignificanceThresholdPixels, BoundingBox = diff.BoundingBox, LogPreview = logPreview, Detail = diff.ChangedPixels == 0 ? $"No visual change ({options.Mode})" : $"mode={options.Mode}, pixels={diff.ChangedPixels}, ratio={diff.ChangeRatio:P4}, bbox={diff.BoundingBox ?? "(none)"}" }; result.Items.Add(itemResult); if (itemResult.ChangedPixels > 0) { changedItemCount++; if (targetChangedItems > 0 && changedItemCount >= targetChangedItems) { break; } } } result.Success = true; result.ItemCount = result.Items.Count; result.Detail = targetChangedItems > 0 ? $"items={result.ItemCount}, targetChanged={targetChangedItems}, threshold={result.SignificanceThresholdPixels}, noise={result.NoiseChangedPixels}" : $"items={result.ItemCount}, threshold={result.SignificanceThresholdPixels}, noise={result.NoiseChangedPixels}"; } catch (Exception ex) { result.Success = false; result.Detail = ex.Message; } finally { templateState?.ClearOverrides(); try { await OutAllAsync(adapter).ConfigureAwait(false); } catch { } await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false); results.Add(result); } } } finally { try { await OutAllAsync(adapter).ConfigureAwait(false); } catch { } if (adapter is IDisposable disposable) { disposable.Dispose(); } } WriteCutDebugSweepReports(options, results); var totalItems = results.Sum(result => result.Items.Count); var changedItems = results.Sum(result => result.Items.Count(item => item.ChangedPixels > 0)); var significantItems = results.Sum(result => result.Items.Count(item => item.SignificantChange)); var failures = results.Count(result => !result.Success); Console.WriteLine(); Console.WriteLine("Summary"); Console.WriteLine($"- Cuts: {results.Count}"); Console.WriteLine($"- Swept Items: {totalItems}"); Console.WriteLine($"- Changed Items: {changedItems}"); Console.WriteLine($"- Significant Items: {significantItems}"); Console.WriteLine($"- Failures: {failures}"); Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}"); return failures == 0 ? 0 : 1; } public static int RunCutDebugCoverageReport(string[] args) { var options = CutDebugCoverageOptions.Parse(args); Directory.CreateDirectory(options.OutputPath); Console.WriteLine("Karisma cut-debug coverage report starting."); Console.WriteLine($"- Image Root: {options.ImageRootPath}"); Console.WriteLine($"- Output: {options.OutputPath}"); Console.WriteLine($"- Filter: {(string.IsNullOrWhiteSpace(options.Filter) ? "(none)" : options.Filter)}"); Console.WriteLine($"- Cut Filter: {(string.IsNullOrWhiteSpace(options.CutName) ? "(none)" : options.CutName)}"); Console.WriteLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}"); var logService = new LogService(); var cutDebugStateStore = new CutDebugStateStore(); var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService); var cutItems = new FormatCatalogService().GetAll() .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall) .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut))) .ToList(); cutItems = ApplyTemplateFilter(cutItems, options.Filter); cutItems = ApplyCutFilter(cutItems, options.CutName); if (options.ExcludeHistoricalTurnout) { cutItems = cutItems .Where(item => !IsHistoricalTurnoutTemplate(item.Template.Name)) .ToList(); } if (options.Limit is int limit && limit > 0) { cutItems = cutItems.Take(limit).ToList(); } if (cutItems.Count == 0) { Console.WriteLine("No cuts matched the requested filter."); return 1; } Console.WriteLine($"- Cuts: {cutItems.Count}"); Console.WriteLine(); var results = new List(); for (var index = 0; index < cutItems.Count; index++) { var item = cutItems[index]; var result = new CutDebugCoverageResult { Index = index + 1, TemplateId = item.Template.Id, TemplateName = item.Template.Name, CutName = item.Cut.Name, Channel = item.Template.RecommendedChannel.ToString(), ExcludedHistoricalTurnout = options.ExcludeHistoricalTurnout && IsHistoricalTurnoutTemplate(item.Template.Name) }; Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id} / {item.Cut.Name}"); try { var resolvedScene = KarismaSceneResolver.ResolveScene(item.Template, options.ImageRootPath, useLoop: false); var sceneVariables = sceneVariableCatalog.GetSceneVariables(options.ImageRootPath, resolvedScene.Path); var templateState = cutDebugStateStore.GetTemplate(item.Template.RecommendedChannel, item.Template.Id, item.Template.Name); templateState.SyncItems(BuildCutDebugDescriptors(item.Template, sceneVariables)); result.ScenePath = resolvedScene.Path; result.SceneVariableCount = sceneVariables.Count; result.ItemCount = templateState.Items.Count; result.TextItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.TextValue); result.ImageItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.ImageValue); result.CounterItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.Counter); result.StyleItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.StyleColor); result.VisibilityItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.Visibility); result.SampleKeys = string.Join(", ", templateState.Items.Take(12).Select(debugItem => $"{debugItem.Key}:{debugItem.Kind}")); result.Success = true; result.Detail = $"sceneVars={result.SceneVariableCount}, items={result.ItemCount}"; } catch (Exception ex) { result.Success = false; result.Detail = ex.Message; } results.Add(result); } WriteCutDebugCoverageReports(options, results); var successCount = results.Count(result => result.Success); var zeroCoverageCount = results.Count(result => result.Success && result.ItemCount == 0); var failureCount = results.Count(result => !result.Success); Console.WriteLine(); Console.WriteLine("Summary"); Console.WriteLine($"- Success: {successCount}/{results.Count}"); Console.WriteLine($"- Zero Coverage: {zeroCoverageCount}"); Console.WriteLine($"- Failures: {failureCount}"); Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}"); return failureCount == 0 ? 0 : 1; } public static async Task RunPartyColorAuditAsync(string[] args) { var options = PartyColorAuditOptions.Parse(args); Directory.CreateDirectory(options.OutputPath); Console.WriteLine("Karisma party-color live audit starting."); Console.WriteLine($"- Image Root: {options.ImageRootPath}"); Console.WriteLine($"- Output: {options.OutputPath}"); Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}"); Console.WriteLine($"- Capture Mode: {options.CaptureMode}"); Console.WriteLine("- Data Path: ApplyCutAsync -> Prepare -> Take -> Capture"); var pgmWindow = options.CaptureMode == LiveCutCaptureMode.Pgm ? TryFindPgmWindow() : null; if (options.CaptureMode == LiveCutCaptureMode.Pgm && pgmWindow is null) { Console.WriteLine("PGM window was not found. Open the PGM window or rerun with --capture-mode scene."); return 1; } var logService = new LogService(); var cutDebugStateStore = new CutDebugStateStore(); if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg) { Console.WriteLine("Karisma adapter is not available. Audit cannot continue."); return 1; } var station = CreateValidationStation(options.StationLogoPath); var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService); var cutItems = new FormatCatalogService().GetAll() .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall) .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut))) .ToList(); cutItems = ApplyTemplateFilter(cutItems, options.Filter); cutItems = ApplyCutFilter(cutItems, options.CutName); var results = new List(); var targetItems = new List<(LiveCutWorkItem Item, PartyColorAuditResult Result)>(); for (var index = 0; index < cutItems.Count; index++) { var item = cutItems[index]; var result = new PartyColorAuditResult { Index = index + 1, TemplateId = item.Template.Id, TemplateName = item.Template.Name, CutName = item.Cut.Name, Channel = item.Template.RecommendedChannel.ToString(), CaptureMode = options.CaptureMode.ToString() }; try { var resolvedScene = KarismaSceneResolver.ResolveScene( item.Template, item.Cut, options.ImageRootPath, useLoop: false, useEnd: item.Cut.UseEndScene); result.ScenePath = resolvedScene.Path; var sceneVariables = sceneVariableCatalog.GetSceneVariables(options.ImageRootPath, resolvedScene.Path); var descriptors = BuildCutDebugDescriptors(item.Template, sceneVariables); result.SceneVariableCount = sceneVariables.Count; result.TextItemCount = descriptors.Count(descriptor => descriptor.Kind == CutDebugItemKind.TextValue); result.ImageItemCount = descriptors.Count(descriptor => descriptor.Kind == CutDebugItemKind.ImageValue); result.CounterItemCount = descriptors.Count(descriptor => descriptor.Kind == CutDebugItemKind.Counter); result.StyleItemCount = descriptors.Count(descriptor => descriptor.Kind == CutDebugItemKind.StyleColor); result.VisibilityItemCount = descriptors.Count(descriptor => descriptor.Kind == CutDebugItemKind.Visibility); var decision = ClassifyPartyColorAuditTarget(item.Template, descriptors); result.VerificationTarget = decision.Include; result.ExclusionReason = decision.Reason; result.TargetReason = decision.Reason; result.Verdict = decision.Include ? "Pending" : "Excluded"; if (decision.Include) { targetItems.Add((item, result)); } } catch (Exception ex) { result.VerificationTarget = false; result.ExclusionReason = "Scene metadata could not be inspected."; result.Verdict = "Fail"; result.Detail = ex.Message; } results.Add(result); } if (options.Limit is int limit && limit > 0 && targetItems.Count > limit) { var selected = targetItems.Take(limit).ToHashSet(); foreach (var skipped in targetItems.Skip(limit)) { skipped.Result.VerificationTarget = false; skipped.Result.ExclusionReason = "Skipped by --limit."; skipped.Result.Verdict = "Excluded"; } targetItems = targetItems.Where(entry => selected.Contains(entry)).ToList(); } Console.WriteLine($"- Catalog Cuts: {results.Count}"); Console.WriteLine($"- Audit Targets: {targetItems.Count}"); Console.WriteLine(); if (targetItems.Count == 0) { WritePartyColorAuditReports(options, results); Console.WriteLine("No cuts matched the party-color audit target rules."); return 1; } TornadoManager? rawManager = null; try { await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false); rawManager = ResolveAdapterTornadoManager(adapter); for (var targetIndex = 0; targetIndex < targetItems.Count; targetIndex++) { var (item, result) = targetItems[targetIndex]; Console.WriteLine($"[{targetIndex + 1}/{targetItems.Count}] {item.Template.Id} / {item.Cut.Name}"); try { await OutAllAsync(adapter).ConfigureAwait(false); await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false); var fileStem = $"{targetIndex + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}"; result.Step1OriginalCapturePath = Path.Combine(options.OutputPath, $"{fileStem}_step1_original.png"); result.Step2SamePartyCapturePath = Path.Combine(options.OutputPath, $"{fileStem}_step2_same_party_data.png"); result.Step3OtherPartyCapturePath = Path.Combine(options.OutputPath, $"{fileStem}_step3_other_party_data.png"); result.Step1VsStep2DiffPath = Path.Combine(options.OutputPath, $"{fileStem}_step1_vs_step2_diff.png"); result.Step2VsStep3DiffPath = Path.Combine(options.OutputPath, $"{fileStem}_step2_vs_step3_diff.png"); result.CaptureFrame = ResolveFinalSceneCaptureFrame(item.Template, item.Cut); result.Step1Hash = await CaptureOriginalSceneImageAsync( rawManager, pgmWindow, item, options, result.Step1OriginalCapturePath).ConfigureAwait(false); result.Step1ColorStats = AnalyzePartyColorFamily(result.Step1OriginalCapturePath); var samePartyFamily = ResolveAuditCandidateFamily( item.Template.Name, result.Step1ColorStats.DominantFamily); var otherPartyFamily = ResolveOtherAuditCandidateFamily(item.Template.Name, samePartyFamily); var useRedLikeSameParty = samePartyFamily == PartyColorFamily.Red; var preElection = ShouldUsePreElectionSnapshot(item.Template.Name); var snapshotSameParty = CreateSnapshot( item.Template.Name, targetIndex, variant: 0, preElection, useRedLikeSameParty, cycleTopThreeCandidates: false, stressTopRankValues: true, forcedPartyFamily: samePartyFamily); var snapshotOtherParty = CreateSnapshot( item.Template.Name, targetIndex, variant: 1, preElection, otherPartyFamily is null ? !useRedLikeSameParty : otherPartyFamily == PartyColorFamily.Red, cycleTopThreeCandidates: false, stressTopRankValues: true, forcedPartyFamily: otherPartyFamily); result.Step2Hash = await CapturePartyColorDataImageAsync( adapter, pgmWindow, item, snapshotSameParty, station, options, result.Step2SamePartyCapturePath).ConfigureAwait(false); result.Step2ColorStats = AnalyzePartyColorFamily(result.Step2SamePartyCapturePath); result.Step3Hash = await CapturePartyColorDataImageAsync( adapter, pgmWindow, item, snapshotOtherParty, station, options, result.Step3OtherPartyCapturePath).ConfigureAwait(false); result.Step3ColorStats = AnalyzePartyColorFamily(result.Step3OtherPartyCapturePath); var step1VsStep2Diff = CompareImages( result.Step1OriginalCapturePath, result.Step2SamePartyCapturePath, result.Step1VsStep2DiffPath); var step2VsStep3Diff = CompareImages( result.Step2SamePartyCapturePath, result.Step3OtherPartyCapturePath, result.Step2VsStep3DiffPath); result.Step1VsStep2ChangedPixels = step1VsStep2Diff.ChangedPixels; result.Step1VsStep2ChangeRatio = step1VsStep2Diff.ChangeRatio; result.Step2VsStep3ChangedPixels = step2VsStep3Diff.ChangedPixels; result.Step2VsStep3ChangeRatio = step2VsStep3Diff.ChangeRatio; var samePartyComparison = EvaluateSamePartyColor( result.Step1ColorStats, result.Step2ColorStats, step1VsStep2Diff); var otherPartyComparison = EvaluateOtherPartyColorChange( result.Step2ColorStats, result.Step3ColorStats, step2VsStep3Diff); result.Step1VsStep2Result = samePartyComparison.Detail; result.Step2VsStep3Result = otherPartyComparison.Detail; result.ReviewRequired = samePartyComparison.Status == "Review" || otherPartyComparison.Status == "Review"; result.Verdict = samePartyComparison.Status == "Fail" || otherPartyComparison.Status == "Fail" ? "Fail" : "Pass"; result.Detail = BuildPartyColorAuditDetail(result); if (result.Verdict == "Fail") { result.SuspectedCodeLocation = PartyColorAuditSuspectedCodeLocation; result.FixTarget = PartyColorAuditFixTarget; } } catch (Exception ex) { result.Verdict = "Fail"; result.Detail = ex.Message; result.SuspectedCodeLocation = PartyColorAuditSuspectedCodeLocation; result.FixTarget = PartyColorAuditFixTarget; } finally { try { await OutAllAsync(adapter).ConfigureAwait(false); } catch { } await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false); } } } finally { try { await OutAllAsync(adapter).ConfigureAwait(false); } catch { } if (adapter is IDisposable disposable) { disposable.Dispose(); } } WritePartyColorAuditReports(options, results); var auditedCount = results.Count(result => result.VerificationTarget); var passCount = results.Count(result => result.VerificationTarget && result.Verdict == "Pass"); var failCount = results.Count(result => result.VerificationTarget && result.Verdict == "Fail"); var reviewCount = results.Count(result => result.VerificationTarget && result.ReviewRequired); var excludedCount = results.Count(result => !result.VerificationTarget); Console.WriteLine(); Console.WriteLine("Summary"); Console.WriteLine($"- Audited Cuts: {auditedCount}"); Console.WriteLine($"- Pass: {passCount}"); Console.WriteLine($"- Fail: {failCount}"); Console.WriteLine($"- Review Required: {reviewCount}"); Console.WriteLine($"- Excluded: {excludedCount}"); Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}"); return failCount == 0 ? 0 : 1; } private static PartyColorAuditTargetDecision ClassifyPartyColorAuditTarget( FormatTemplateDefinition template, IReadOnlyList descriptors) { if (TryResolvePartyColorAuditExclusion(template, out var exclusionReason)) { return new PartyColorAuditTargetDecision(false, exclusionReason); } if (!LooksLikePartyColorAuditTemplate(template)) { return new PartyColorAuditTargetDecision(false, "Excluded: no party/candidate/tendency color workflow in the cut name."); } var hasStyleColor = descriptors.Any(descriptor => descriptor.Kind == CutDebugItemKind.StyleColor); var hasDynamicDataSurface = descriptors.Any(descriptor => descriptor.Kind is CutDebugItemKind.TextValue or CutDebugItemKind.ImageValue or CutDebugItemKind.Counter); if (!template.RequiresCandidateData && !hasStyleColor) { return new PartyColorAuditTargetDecision(false, "Excluded: no candidate data or style-color surface detected."); } if (!hasStyleColor && !hasDynamicDataSurface) { return new PartyColorAuditTargetDecision(false, "Excluded: no dynamic text/image/counter/style items detected."); } if (hasStyleColor) { return new PartyColorAuditTargetDecision(true, "Included: dynamic style-color items detected."); } return new PartyColorAuditTargetDecision(true, "Included: candidate-data cut with dynamic data surfaces."); } private static bool TryResolvePartyColorAuditExclusion( FormatTemplateDefinition template, out string reason) { var name = template.Name; if (name.Contains("투표율", StringComparison.Ordinal)) { reason = "Excluded: turnout cut."; return true; } if (name.Contains("타이틀", StringComparison.Ordinal)) { reason = "Excluded: title cut."; return true; } if (name.Contains("의원표", StringComparison.Ordinal)) { reason = "Excluded: proportional council vote-count cut; party color/image should not change."; return true; } if (name.StartsWith("사전_역대투표율", StringComparison.Ordinal)) { reason = "Excluded: historical turnout cut."; return true; } reason = string.Empty; return false; } private static bool LooksLikePartyColorAuditTemplate(FormatTemplateDefinition template) { var name = template.Name; return name.StartsWith("1위_", StringComparison.Ordinal) || name.StartsWith("1-2위", StringComparison.Ordinal) || name.StartsWith("1-3위", StringComparison.Ordinal) || name.StartsWith("당선_", StringComparison.Ordinal) || name.StartsWith("전후보_", StringComparison.Ordinal) || name.StartsWith("모든후보_", StringComparison.Ordinal) || name.StartsWith("이시각1위_", StringComparison.Ordinal) || name.StartsWith("접전_", StringComparison.Ordinal) || name.StartsWith("초접전_", StringComparison.Ordinal) || name.StartsWith("경력_", StringComparison.Ordinal) || name.StartsWith("판세_", StringComparison.Ordinal) || name.Contains("의원표", StringComparison.Ordinal) || name.Contains("역대당선자", StringComparison.Ordinal) || name.Contains("교육감", StringComparison.Ordinal) || template.RequiresCandidateData; } private static async Task CaptureOriginalSceneImageAsync( TornadoManager manager, PgmWindow? pgmWindow, LiveCutWorkItem item, PartyColorAuditOptions options, string outputPath) { var resolvedScene = KarismaSceneResolver.ResolveScene( item.Template, item.Cut, options.ImageRootPath, useLoop: false, useEnd: item.Cut.UseEndScene); var alias = $"audit_raw_{item.Template.Id}_{Guid.NewGuid():N}" .Replace('\\', '_') .Replace('/', '_'); try { await manager.LoadSceneAsync(resolvedScene.Path, alias, forceReload: true, cancellationToken: CancellationToken.None) .ConfigureAwait(false); if (options.CaptureMode == LiveCutCaptureMode.Scene) { var (width, height) = ResolveSceneCaptureSize(item.Template); var frame = ResolveFinalSceneCaptureFrame(item.Template, item.Cut); await manager.SaveSceneImageAsync(alias, outputPath, width, height, frame, CancellationToken.None) .ConfigureAwait(false); return ComputeSha256(outputPath); } if (pgmWindow is null) { throw new InvalidOperationException("PGM capture requested, but the PGM window was not found."); } var binding = ResolveAuditBinding(item.Template.RecommendedChannel); await manager.PrepareAsync(binding.OutputChannelIndex, binding.LayerNo, alias, CancellationToken.None) .ConfigureAwait(false); await manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, cancellationToken: CancellationToken.None) .ConfigureAwait(false); await Task.Delay(ResolveFinalPgmCaptureDelayMs(item.Template, item.Cut)).ConfigureAwait(false); return CapturePgm(pgmWindow, outputPath, skipCapture: false); } finally { try { await manager.UnloadSceneAsync(alias, CancellationToken.None).ConfigureAwait(false); } catch { } } } private static async Task CapturePartyColorDataImageAsync( ITornado3Adapter adapter, PgmWindow? pgmWindow, LiveCutWorkItem item, ElectionDataSnapshot snapshot, BroadcastStationProfile station, PartyColorAuditOptions options, string outputPath) { await OutAllAsync(adapter).ConfigureAwait(false); await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false); await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshot, station, options.ImageRootPath, CancellationToken.None) .ConfigureAwait(false); await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); if (options.CaptureMode == LiveCutCaptureMode.Pgm) { await Task.Delay(ResolveFinalPgmCaptureDelayMs(item.Template, item.Cut)).ConfigureAwait(false); } if (options.CaptureMode == LiveCutCaptureMode.Pgm) { var outputVisibleInPgm = pgmWindow is not null && item.Template.RecommendedChannel != BroadcastChannel.VideoWall; return CapturePgm(pgmWindow, outputPath, !outputVisibleInPgm); } if (adapter is not KarismaTornado3Adapter karismaAdapter) { throw new InvalidOperationException("Scene capture mode requires the Karisma adapter."); } var (width, height) = ResolveSceneCaptureSize(item.Template); var frame = ResolveFinalSceneCaptureFrame(item.Template, item.Cut); await karismaAdapter.SavePendingSceneImageAsync( item.Template.RecommendedChannel, outputPath, width, height, frame, cancellationToken: CancellationToken.None) .ConfigureAwait(false); return ComputeSha256(outputPath); } private static int ResolveFinalSceneCaptureFrame(FormatTemplateDefinition template, FormatCutDefinition cut) { const int framesPerSecond = 30; var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template); return Math.Max(0, (int)Math.Round(durationSeconds * framesPerSecond, MidpointRounding.AwayFromZero) - 1); } private static int ResolveFinalPgmCaptureDelayMs(FormatTemplateDefinition template, FormatCutDefinition cut) { var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template); return Math.Max(ResolveTemplateOnAirDelayMs(template, 900), (int)Math.Ceiling(durationSeconds * 1000d)); } private static TornadoManager ResolveAdapterTornadoManager(ITornado3Adapter adapter) { if (adapter is not KarismaTornado3Adapter karismaAdapter) { throw new InvalidOperationException("Party color audit requires the Karisma adapter."); } var managerField = typeof(KarismaTornado3Adapter).GetField( "_manager", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); return managerField?.GetValue(karismaAdapter) as TornadoManager ?? throw new InvalidOperationException("Karisma manager could not be resolved for raw scene capture."); } private static AuditKarismaBinding ResolveAuditBinding(BroadcastChannel channel) { return channel switch { BroadcastChannel.Normal => ParseAuditBinding("TORNADO_KARISMA_BIND_NORMAL", new AuditKarismaBinding(0, 0)), BroadcastChannel.TopLeft => ParseAuditBinding("TORNADO_KARISMA_BIND_TOPLEFT", new AuditKarismaBinding(0, 1)), BroadcastChannel.Bottom => ParseAuditBinding("TORNADO_KARISMA_BIND_BOTTOM", new AuditKarismaBinding(0, 2)), BroadcastChannel.VideoWall => ParseAuditBinding("TORNADO_KARISMA_BIND_VIDEOWALL", new AuditKarismaBinding(1, 0)), _ => new AuditKarismaBinding(0, 0) }; } private static AuditKarismaBinding ParseAuditBinding(string environmentVariableName, AuditKarismaBinding fallback) { var raw = Environment.GetEnvironmentVariable(environmentVariableName); if (string.IsNullOrWhiteSpace(raw)) { return fallback; } var parts = raw.Split(':', ',', ';'); return parts.Length >= 2 && int.TryParse(parts[0], out var outputChannelIndex) && int.TryParse(parts[1], out var layerNo) ? new AuditKarismaBinding(outputChannelIndex, layerNo) : fallback; } private static PartyColorStats AnalyzePartyColorFamily(string imagePath) { using var bitmap = new Bitmap(imagePath); var redPixels = 0; var bluePixels = 0; var greenPixels = 0; var coloredPixels = 0; var step = Math.Max(1, Math.Min(bitmap.Width, bitmap.Height) / 360); for (var y = 0; y < bitmap.Height; y += step) { for (var x = 0; x < bitmap.Width; x += step) { var color = bitmap.GetPixel(x, y); if (color.A < 128) { continue; } var max = Math.Max(color.R, Math.Max(color.G, color.B)); var min = Math.Min(color.R, Math.Min(color.G, color.B)); if (max < 50 || max == 0) { continue; } var saturation = (max - min) / (double)max; if (saturation < 0.28) { continue; } var hue = color.GetHue(); if ((hue <= 15 || hue >= 345) && color.R >= 120 && color.R > color.G * 1.35 && color.R > color.B * 1.35) { redPixels++; coloredPixels++; } else if (hue is >= 185 and <= 265 && color.B >= 90 && color.B > color.R * 1.2 && color.B > color.G * 1.05) { bluePixels++; coloredPixels++; } else if (hue is >= 70 and <= 170 && color.G >= 90 && color.G > color.R * 1.15 && color.G > color.B * 1.15) { greenPixels++; coloredPixels++; } } } var dominant = ResolveDominantPartyColor(redPixels, bluePixels, greenPixels, coloredPixels); return new PartyColorStats { DominantFamily = dominant, RedPixels = redPixels, BluePixels = bluePixels, GreenPixels = greenPixels, ColoredPixels = coloredPixels }; } private static PartyColorFamily ResolveDominantPartyColor(int redPixels, int bluePixels, int greenPixels, int coloredPixels) { if (coloredPixels < 120) { return PartyColorFamily.Unknown; } var ranked = new[] { (Family: PartyColorFamily.Red, Count: redPixels), (Family: PartyColorFamily.Blue, Count: bluePixels), (Family: PartyColorFamily.Green, Count: greenPixels) } .OrderByDescending(item => item.Count) .ToArray(); if (ranked[0].Count < 120) { return PartyColorFamily.Unknown; } if (ranked[1].Count > 0 && ranked[0].Count < ranked[1].Count * 1.18) { return PartyColorFamily.Mixed; } return ranked[0].Family; } private static AuditComparison EvaluateSamePartyColor( PartyColorStats step1, PartyColorStats step2, ImageDiffResult diff) { if (step1.DominantFamily is PartyColorFamily.Unknown or PartyColorFamily.Mixed || step2.DominantFamily is PartyColorFamily.Unknown or PartyColorFamily.Mixed) { return new AuditComparison( "Review", $"Review: same-party comparison needs visual check. step1={step1.DominantFamily}, step2={step2.DominantFamily}, diff={diff.ChangeRatio:P3}."); } if (step1.DominantFamily != step2.DominantFamily) { return new AuditComparison( "Fail", $"Fail: same-party color family changed from {step1.DominantFamily} to {step2.DominantFamily}."); } return new AuditComparison( "Pass", $"Pass: same-party dominant color family stayed {step2.DominantFamily}; diff={diff.ChangeRatio:P3}."); } private static AuditComparison EvaluateOtherPartyColorChange( PartyColorStats step2, PartyColorStats step3, ImageDiffResult diff) { if (diff.ChangedPixels < 250 && diff.ChangeRatio < 0.0005) { return new AuditComparison( "Fail", $"Fail: changing party data produced almost no visual change. diff={diff.ChangeRatio:P3}."); } if (step2.DominantFamily is PartyColorFamily.Unknown or PartyColorFamily.Mixed || step3.DominantFamily is PartyColorFamily.Unknown or PartyColorFamily.Mixed) { return new AuditComparison( "Review", $"Review: other-party comparison needs visual check. step2={step2.DominantFamily}, step3={step3.DominantFamily}, diff={diff.ChangeRatio:P3}."); } if (step2.DominantFamily == step3.DominantFamily) { return new AuditComparison( "Review", $"Review: screen changed, but dominant color family stayed {step3.DominantFamily}; diff={diff.ChangeRatio:P3}."); } return new AuditComparison( "Pass", $"Pass: dominant color family changed from {step2.DominantFamily} to {step3.DominantFamily}; diff={diff.ChangeRatio:P3}."); } private static string BuildPartyColorAuditDetail(PartyColorAuditResult result) { return string.Join( " | ", new[] { $"step1={result.Step1ColorStats?.DominantFamily}", $"step2={result.Step2ColorStats?.DominantFamily}", $"step3={result.Step3ColorStats?.DominantFamily}", $"step1vs2={result.Step1VsStep2ChangeRatio:P3}", $"step2vs3={result.Step2VsStep3ChangeRatio:P3}" }); } private static void WritePartyColorAuditReports(PartyColorAuditOptions options, IReadOnlyList results) { var jsonPath = Path.Combine(options.OutputPath, "results.json"); var csvPath = Path.Combine(options.OutputPath, "results.csv"); var excludedPath = Path.Combine(options.OutputPath, "excluded.csv"); var summaryPath = Path.Combine(options.OutputPath, "summary.md"); File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8); var csv = new StringBuilder(); csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,CaptureFrame,VerificationTarget,ExclusionReason,Step1OriginalCapturePath,Step2SamePartyCapturePath,Step3OtherPartyCapturePath,Step1VsStep2Result,Step2VsStep3Result,Verdict,ReviewRequired,Step1Family,Step2Family,Step3Family,Step1VsStep2ChangeRatio,Step2VsStep3ChangeRatio,SuspectedCodeLocation,FixTarget,Detail"); foreach (var result in results.Where(result => result.VerificationTarget)) { csv.AppendLine(string.Join(",", Csv(result.Index.ToString()), Csv(result.TemplateId), Csv(result.TemplateName), Csv(result.CutName), Csv(result.Channel), Csv(result.CaptureFrame.ToString()), Csv(result.VerificationTarget.ToString()), Csv(result.ExclusionReason ?? string.Empty), Csv(result.Step1OriginalCapturePath ?? string.Empty), Csv(result.Step2SamePartyCapturePath ?? string.Empty), Csv(result.Step3OtherPartyCapturePath ?? string.Empty), Csv(result.Step1VsStep2Result ?? string.Empty), Csv(result.Step2VsStep3Result ?? string.Empty), Csv(result.Verdict), Csv(result.ReviewRequired.ToString()), Csv(result.Step1ColorStats?.DominantFamily.ToString() ?? string.Empty), Csv(result.Step2ColorStats?.DominantFamily.ToString() ?? string.Empty), Csv(result.Step3ColorStats?.DominantFamily.ToString() ?? string.Empty), Csv(result.Step1VsStep2ChangeRatio.ToString("0.000000")), Csv(result.Step2VsStep3ChangeRatio.ToString("0.000000")), Csv(result.SuspectedCodeLocation ?? string.Empty), Csv(result.FixTarget ?? string.Empty), Csv(result.Detail ?? string.Empty))); } File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8); var excluded = new StringBuilder(); excluded.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,ExclusionReason,SceneVariableCount,TextItemCount,ImageItemCount,CounterItemCount,StyleItemCount,VisibilityItemCount"); foreach (var result in results.Where(result => !result.VerificationTarget)) { excluded.AppendLine(string.Join(",", Csv(result.Index.ToString()), Csv(result.TemplateId), Csv(result.TemplateName), Csv(result.CutName), Csv(result.Channel), Csv(result.ExclusionReason ?? string.Empty), Csv(result.SceneVariableCount.ToString()), Csv(result.TextItemCount.ToString()), Csv(result.ImageItemCount.ToString()), Csv(result.CounterItemCount.ToString()), Csv(result.StyleItemCount.ToString()), Csv(result.VisibilityItemCount.ToString()))); } File.WriteAllText(excludedPath, excluded.ToString(), Encoding.UTF8); var audited = results.Where(result => result.VerificationTarget).ToList(); var failures = audited.Where(result => result.Verdict == "Fail").ToList(); var review = audited.Where(result => result.ReviewRequired).ToList(); var summary = new StringBuilder(); summary.AppendLine("# Party Color Live Audit"); summary.AppendLine(); summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}"); summary.AppendLine($"- Image Root: {options.ImageRootPath}"); summary.AppendLine($"- Capture Mode: {options.CaptureMode}"); summary.AppendLine("- Scene captures use the explicit final frame derived from cut duration, not `frame=-1`."); summary.AppendLine($"- Catalog Cuts: {results.Count}"); summary.AppendLine($"- Audited Cuts: {audited.Count}"); summary.AppendLine($"- Pass: {audited.Count(result => result.Verdict == "Pass")}"); summary.AppendLine($"- Fail: {failures.Count}"); summary.AppendLine($"- Review Required: {review.Count}"); summary.AppendLine($"- Excluded Cuts: {results.Count(result => !result.VerificationTarget)}"); summary.AppendLine(); if (failures.Count > 0) { summary.AppendLine("## Failures"); summary.AppendLine(); foreach (var result in failures) { summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: {result.Step1VsStep2Result} {result.Step2VsStep3Result}"); } summary.AppendLine(); } if (review.Count > 0) { summary.AppendLine("## Needs Visual Review"); summary.AppendLine(); foreach (var result in review.Take(40)) { summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: {result.Step1VsStep2Result} {result.Step2VsStep3Result}"); } if (review.Count > 40) { summary.AppendLine($"- ... {review.Count - 40} more"); } summary.AppendLine(); } summary.AppendLine("## Files"); summary.AppendLine(); summary.AppendLine("- `results.csv`"); summary.AppendLine("- `results.json`"); summary.AppendLine("- `excluded.csv`"); summary.AppendLine("- `*_step1_original.png`"); summary.AppendLine("- `*_step2_same_party_data.png`"); summary.AppendLine("- `*_step3_other_party_data.png`"); summary.AppendLine("- `*_diff.png`"); File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8); } private static async Task CaptureSweepImageAsync( ITornado3Adapter adapter, PgmWindow pgmWindow, LiveCutWorkItem item, ElectionDataSnapshot snapshot, BroadcastStationProfile station, CutDebugSweepOptions options, string outputPath) { await OutAllAsync(adapter).ConfigureAwait(false); await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false); await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshot, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false); await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false); await Task.Delay(ResolveSweepOnAirDelayMs(item.Template, options)).ConfigureAwait(false); return new SweepCaptureResult(outputPath, CapturePgm(pgmWindow, outputPath, skipCapture: false)); } private static void EnableAllDebugCategories(CutDebugSettings settings) { settings.IsEnabled = true; settings.ApplyTextValues = true; settings.ApplyImageValues = true; settings.ApplyVisibilityValues = true; settings.ApplyVoteRateTextValues = true; settings.ApplyVoteRateCounterValues = true; settings.ApplyPartyBarStyleColors = true; settings.ApplyPartyPlateStyleColors = true; settings.ApplyVoteRateStyleColors = true; } private static void SetAllItemsEnabled(IEnumerable items, bool isEnabled) { foreach (var item in items) { item.IsEnabled = isEnabled; } } private static IReadOnlyList OrderSweepItems( string templateId, IEnumerable items, IReadOnlyDictionary sceneVariables) { var sceneVariableKeys = sceneVariables.Keys .Select(CutDebugTemplateState.NormalizeKey) .Where(key => !string.IsNullOrWhiteSpace(key)) .ToHashSet(StringComparer.OrdinalIgnoreCase); var hasRecommendation = CutDebugRecommendationCatalog.TryGetRecommendation(templateId, out var recommendation); return items .OrderBy(item => hasRecommendation ? !MatchesRecommendation(item, recommendation) : false) .ThenBy(item => !sceneVariableKeys.Contains(CutDebugTemplateState.NormalizeKey(item.Key))) .ThenBy(item => GetSweepKeyPriority(item.Key)) .ThenBy(item => GetSweepKindPriority(item.Kind)) .ThenBy(item => ResolveDebugItemIndex(item.Key) ?? int.MaxValue) .ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase) .ToArray(); } private static bool MatchesRecommendation(CutDebugItemState item, CutDebugRecommendation recommendation) { return item.Kind == recommendation.Kind && string.Equals( CutDebugTemplateState.NormalizeKey(item.Key), CutDebugTemplateState.NormalizeKey(recommendation.Key), StringComparison.OrdinalIgnoreCase); } private static void ConfigureSweepItem( CutDebugTemplateState templateState, CutDebugItemState sweepItem, CutDebugSweepMode mode, CutDebugReplacementAssets replacementAssets) { if (mode == CutDebugSweepMode.Disable) { sweepItem.IsEnabled = false; return; } var itemIndex = ResolveDebugItemIndex(sweepItem.Key); switch (sweepItem.Kind) { case CutDebugItemKind.TextValue: templateState.SetOverride( sweepItem.Key, sweepItem.Kind, CutDebugOverride.ForString(itemIndex.HasValue ? $"DBG{itemIndex:00}" : "DEBUG")); break; case CutDebugItemKind.ImageValue: templateState.SetOverride( sweepItem.Key, sweepItem.Kind, CutDebugOverride.ForString(itemIndex.GetValueOrDefault(1) % 2 == 0 ? replacementAssets.CyanPath : replacementAssets.MagentaPath)); break; case CutDebugItemKind.Counter: templateState.SetOverride( sweepItem.Key, sweepItem.Kind, CutDebugOverride.ForNumber(itemIndex.GetValueOrDefault(1) % 2 == 0 ? 12.3 : 87.6)); break; case CutDebugItemKind.StyleColor: templateState.SetOverride( sweepItem.Key, sweepItem.Kind, itemIndex.GetValueOrDefault(1) % 2 == 0 ? CutDebugOverride.ForColor(0, 255, 255) : CutDebugOverride.ForColor(255, 0, 255)); break; case CutDebugItemKind.Visibility: templateState.SetOverride( sweepItem.Key, sweepItem.Kind, CutDebugOverride.ForVisibility(false)); break; } } private static CutDebugReplacementAssets EnsureCutDebugReplacementAssets(string outputPath) { var assetsPath = Path.Combine(outputPath, "_debug_assets"); Directory.CreateDirectory(assetsPath); var magentaPath = Path.Combine(assetsPath, "debug_magenta.png"); var cyanPath = Path.Combine(assetsPath, "debug_cyan.png"); WriteDebugReplacementAsset(magentaPath, Color.FromArgb(255, 255, 0, 255), Color.FromArgb(255, 255, 255, 0)); WriteDebugReplacementAsset(cyanPath, Color.FromArgb(255, 0, 255, 255), Color.FromArgb(255, 0, 0, 0)); return new CutDebugReplacementAssets(magentaPath, cyanPath); } private static void WriteDebugReplacementAsset(string path, Color background, Color accent) { 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 borderPen = new Pen(accent, 24); graphics.DrawRectangle(borderPen, 12, 12, bitmap.Width - 24, bitmap.Height - 24); graphics.DrawLine(borderPen, 24, 24, bitmap.Width - 24, bitmap.Height - 24); graphics.DrawLine(borderPen, bitmap.Width - 24, 24, 24, bitmap.Height - 24); using var font = new Font("Arial", 72, FontStyle.Bold, GraphicsUnit.Pixel); using var brush = new SolidBrush(accent); var text = "DBG"; var size = graphics.MeasureString(text, font); graphics.DrawString( text, font, brush, (bitmap.Width - size.Width) / 2, (bitmap.Height - size.Height) / 2); bitmap.Save(path, ImageFormat.Png); } private static int? ResolveDebugItemIndex(string key) { if (string.IsNullOrWhiteSpace(key) || key.Length < 2) { return null; } var suffix = key[^2..]; return int.TryParse(suffix, out var parsed) && parsed > 0 ? parsed : null; } private static int GetSweepKindPriority(CutDebugItemKind kind) { return kind switch { CutDebugItemKind.ImageValue => 0, CutDebugItemKind.Counter => 1, CutDebugItemKind.TextValue => 2, CutDebugItemKind.StyleColor => 3, CutDebugItemKind.Visibility => 4, _ => 9 }; } private static int GetSweepKeyPriority(string key) { if (string.IsNullOrWhiteSpace(key)) { return 99; } var normalizedKey = CutDebugTemplateState.NormalizeKey(key); return normalizedKey switch { var value when value.StartsWith("후보사진", StringComparison.OrdinalIgnoreCase) => 0, var value when value.StartsWith("후보명", StringComparison.OrdinalIgnoreCase) => 1, var value when value.StartsWith("투표율", StringComparison.OrdinalIgnoreCase) => 2, var value when value.StartsWith("전국투표율", StringComparison.OrdinalIgnoreCase) => 2, var value when value.StartsWith("득표율", StringComparison.OrdinalIgnoreCase) => 2, var value when value.StartsWith("개표율", StringComparison.OrdinalIgnoreCase) => 3, var value when value.StartsWith("선거구명", StringComparison.OrdinalIgnoreCase) => 4, var value when value.StartsWith("시도명", StringComparison.OrdinalIgnoreCase) => 4, var value when value.StartsWith("의석수", StringComparison.OrdinalIgnoreCase) => 5, var value when value.StartsWith("득표수", StringComparison.OrdinalIgnoreCase) => 5, var value when value.StartsWith("정당명", StringComparison.OrdinalIgnoreCase) => 6, var value when value.StartsWith("유확당", StringComparison.OrdinalIgnoreCase) => 7, var value when value.StartsWith("정당바", StringComparison.OrdinalIgnoreCase) => 8, var value when value.StartsWith("정당판", StringComparison.OrdinalIgnoreCase) => 8, var value when value.StartsWith("득표수바", StringComparison.OrdinalIgnoreCase) => 8, var value when value.StartsWith("기호", StringComparison.OrdinalIgnoreCase) => 9, var value when value.StartsWith("순위", StringComparison.OrdinalIgnoreCase) => 9, var value when value.StartsWith("기준시", StringComparison.OrdinalIgnoreCase) => 10, _ => 20 }; } private static int ResolveSweepOnAirDelayMs(FormatTemplateDefinition template, CutDebugSweepOptions options) { return ResolveTemplateOnAirDelayMs(template, options.OnAirDelayMs); } private static int ResolveTemplateOnAirDelayMs(FormatTemplateDefinition template, int configuredDelay) { var delay = configuredDelay; if (template.Name.StartsWith("사전_역대투표율", StringComparison.Ordinal)) { return Math.Max(delay, 8000); } return template.Id.Contains("ani", StringComparison.OrdinalIgnoreCase) || template.Name.Contains("ani", StringComparison.OrdinalIgnoreCase) ? Math.Max(delay, 3500) : delay; } private static string? CaptureRecentLogs(LogService logService, int previousCount, int maxLines) { var addedCount = Math.Max(0, logService.Entries.Count - previousCount); if (addedCount == 0) { return null; } return string.Join( " || ", logService.Entries .Take(Math.Min(addedCount, maxLines)) .Select(entry => entry.Message)); } private static BroadcastStationProfile CreateValidationStation(string stationLogoPath) { return new BroadcastStationProfile { Id = "AUTO", Name = "AUTO", LogoAssetPath = stationLogoPath, RegionFilters = ["서울", "인천", "충남"] }; } private static IReadOnlyList BuildCutDebugDescriptors( FormatTemplateDefinition template, IReadOnlyDictionary sceneVariables) { var descriptors = new List(); foreach (var variable in sceneVariables.Values.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)) { var normalizedKey = CutDebugTemplateState.NormalizeKey(variable.Name); if (string.IsNullOrWhiteSpace(normalizedKey)) { continue; } if (ShouldExcludeHistoricalTurnoutGraph(template, normalizedKey)) { continue; } var primaryKind = variable.Kind is KarismaSceneVariableKind.Image or KarismaSceneVariableKind.VideoResource ? CutDebugItemKind.ImageValue : CutDebugItemKind.TextValue; descriptors.Add(new CutDebugItemDescriptor(normalizedKey, primaryKind, ResolveDebugGroupLabel(normalizedKey, primaryKind))); if (variable.Kind == KarismaSceneVariableKind.Counter || IsVoteRateDebugKey(normalizedKey)) { descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.Counter, "counter")); } if (IsStyleDebugKey(normalizedKey)) { descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.StyleColor, "style")); } if (IsVisibilityDebugKey(normalizedKey)) { descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.Visibility, "visibility")); } } descriptors.AddRange( BuildHeuristicCutDebugDescriptors(template) .Where(descriptor => ShouldIncludeHeuristicDescriptor(template, sceneVariables, descriptor))); return descriptors; } private static bool ShouldExcludeHistoricalTurnoutGraph( FormatTemplateDefinition template, string normalizedKey) { return IsHistoricalTurnoutTemplate(template.Name) && string.Equals(normalizedKey, "차트01", StringComparison.OrdinalIgnoreCase); } private static bool ShouldIncludeHeuristicDescriptor( FormatTemplateDefinition template, IReadOnlyDictionary sceneVariables, CutDebugItemDescriptor descriptor) { var normalizedKey = CutDebugTemplateState.NormalizeKey(descriptor.Key); if (ShouldExcludeHistoricalTurnoutGraph(template, normalizedKey)) { return false; } if (string.Equals(normalizedKey, "전국투표율01", StringComparison.OrdinalIgnoreCase)) { return sceneVariables.Keys.Any(key => string.Equals( CutDebugTemplateState.NormalizeKey(key), normalizedKey, StringComparison.OrdinalIgnoreCase)); } return true; } private static IEnumerable BuildHeuristicCutDebugDescriptors(FormatTemplateDefinition template) { var slotCount = ResolveDebugSlotCount(template); foreach (var prefix in new[] { "시도명", "개표율", "투표율", "전국투표율", "기준시", "유권자수", "투표자수" }) { yield return new CutDebugItemDescriptor($"{prefix}01", CutDebugItemKind.TextValue, "common"); } foreach (var prefix in new[] { "순위", "기호", "기호텍스트", "후보명", "정당명", "의석수", "득표수", "득표율", "표차", "득표차", "선거구명", "시도명", "개표율", "투표율" }) { for (var slot = 1; slot <= slotCount; slot++) { yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.TextValue, "text"); } } foreach (var prefix in new[] { "유확당", "후보사진", "득표수바", "정당바", "정당판", "정당원", "정당색", "정당심볼", "그룹", "바" }) { for (var slot = 1; slot <= slotCount; slot++) { yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.ImageValue, "image"); } } foreach (var slot in Enumerable.Range(1, slotCount)) { yield return new CutDebugItemDescriptor($"득표율{slot:00}", CutDebugItemKind.Counter, "counter"); } yield return new CutDebugItemDescriptor("전국투표율01", CutDebugItemKind.Counter, "counter"); foreach (var prefix in new[] { "기호", "기호텍스트", "득표수바", "정당바", "정당판", "정당원", "정당색", "정당명", "득표율" }) { for (var slot = 1; slot <= slotCount; slot++) { yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.StyleColor, "style"); } } foreach (var prefix in new[] { "유확당", "그룹" }) { for (var slot = 1; slot <= slotCount; slot++) { yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.Visibility, "visibility"); } } foreach (var slot in Enumerable.Range(1, 3)) { yield return new CutDebugItemDescriptor($"공약그룹{slot:00}", CutDebugItemKind.Visibility, "visibility"); } } private static int ResolveDebugSlotCount(FormatTemplateDefinition template) { if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name)) { return 6; } var source = $"{template.Name} {template.Id}"; var topRankMatch = System.Text.RegularExpressions.Regex.Match(source, @"1-(\d+)위"); if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlots)) { return Math.Max(1, topRankSlots); } var peopleMatch = System.Text.RegularExpressions.Regex.Match(source, @"(\d+)인"); if (peopleMatch.Success && int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlots)) { return Math.Max(1, peopleSlots); } return 2; } private static string ResolveDebugGroupLabel(string key, CutDebugItemKind kind) { return kind switch { CutDebugItemKind.TextValue when key.StartsWith("공약", StringComparison.Ordinal) => "promise", CutDebugItemKind.TextValue => "text", CutDebugItemKind.ImageValue => "image", CutDebugItemKind.Counter => "counter", CutDebugItemKind.StyleColor => "style", CutDebugItemKind.Visibility => "visibility", _ => "other" }; } private static bool IsVoteRateDebugKey(string key) { return key.StartsWith("득표율", StringComparison.Ordinal) || key.StartsWith("투표율", StringComparison.Ordinal) || key.StartsWith("전국투표율", StringComparison.Ordinal); } private static bool IsHistoricalTurnoutTemplate(string templateName) { return templateName.StartsWith("사전_역대투표율", StringComparison.Ordinal); } private static bool IsStyleDebugKey(string key) { return key.StartsWith("기호", StringComparison.Ordinal) || key.StartsWith("득표수바", StringComparison.Ordinal) || key.StartsWith("정당바", StringComparison.Ordinal) || key.StartsWith("정당판", StringComparison.Ordinal) || key.StartsWith("정당원", StringComparison.Ordinal) || key.StartsWith("정당색", StringComparison.Ordinal) || key.StartsWith("정당명", StringComparison.Ordinal) || key.StartsWith("득표율", StringComparison.Ordinal); } private static bool IsVisibilityDebugKey(string key) { return key.StartsWith("유확당", StringComparison.Ordinal) || key.StartsWith("그룹", StringComparison.Ordinal) || key.StartsWith("공약그룹", StringComparison.Ordinal); } private static ImageDiffResult CompareImages(string baselinePath, string candidatePath, string diffPath) { using var baselineOriginal = new Bitmap(baselinePath); using var candidateOriginal = new Bitmap(candidatePath); using var baseline = EnsureArgbBitmap(baselineOriginal); using var candidate = EnsureArgbBitmap(candidateOriginal); if (baseline.Width != candidate.Width || baseline.Height != candidate.Height) { throw new InvalidOperationException("Baseline and candidate captures have different dimensions."); } var width = baseline.Width; var height = baseline.Height; using var diffBitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); var baselineData = baseline.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); var candidateData = candidate.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); var diffData = diffBitmap.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); try { var baselineStride = Math.Abs(baselineData.Stride); var candidateStride = Math.Abs(candidateData.Stride); var diffStride = Math.Abs(diffData.Stride); var baselineBytes = new byte[baselineStride * height]; var candidateBytes = new byte[candidateStride * height]; var diffBytes = new byte[diffStride * height]; Marshal.Copy(baselineData.Scan0, baselineBytes, 0, baselineBytes.Length); Marshal.Copy(candidateData.Scan0, candidateBytes, 0, candidateBytes.Length); var changedPixels = 0; long totalDelta = 0; int? minX = null; int? minY = null; int? maxX = null; int? maxY = null; for (var y = 0; y < height; y++) { var baselineRowOffset = y * baselineStride; var candidateRowOffset = y * candidateStride; var diffRowOffset = y * diffStride; for (var x = 0; x < width; x++) { var baselineOffset = baselineRowOffset + (x * 4); var candidateOffset = candidateRowOffset + (x * 4); var diffOffset = diffRowOffset + (x * 4); var blueDelta = Math.Abs(baselineBytes[baselineOffset] - candidateBytes[candidateOffset]); var greenDelta = Math.Abs(baselineBytes[baselineOffset + 1] - candidateBytes[candidateOffset + 1]); var redDelta = Math.Abs(baselineBytes[baselineOffset + 2] - candidateBytes[candidateOffset + 2]); var alphaDelta = Math.Abs(baselineBytes[baselineOffset + 3] - candidateBytes[candidateOffset + 3]); var delta = blueDelta + greenDelta + redDelta + alphaDelta; if (delta > 24) { changedPixels++; totalDelta += delta; minX = !minX.HasValue || x < minX.Value ? x : minX.Value; minY = !minY.HasValue || y < minY.Value ? y : minY.Value; maxX = !maxX.HasValue || x > maxX.Value ? x : maxX.Value; maxY = !maxY.HasValue || y > maxY.Value ? y : maxY.Value; diffBytes[diffOffset] = 0; diffBytes[diffOffset + 1] = (byte)Math.Min(255, delta); diffBytes[diffOffset + 2] = 255; diffBytes[diffOffset + 3] = 255; } else { diffBytes[diffOffset] = 0; diffBytes[diffOffset + 1] = 0; diffBytes[diffOffset + 2] = 0; diffBytes[diffOffset + 3] = 255; } } } Marshal.Copy(diffBytes, 0, diffData.Scan0, diffBytes.Length); Directory.CreateDirectory(Path.GetDirectoryName(diffPath)!); diffBitmap.Save(diffPath, ImageFormat.Png); var totalPixels = Math.Max(1, width * height); var boundingBox = minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue ? $"{minX.Value},{minY.Value} - {maxX.Value},{maxY.Value}" : null; return new ImageDiffResult( changedPixels, changedPixels / (double)totalPixels, changedPixels == 0 ? 0 : totalDelta / (double)changedPixels, boundingBox); } finally { baseline.UnlockBits(baselineData); candidate.UnlockBits(candidateData); diffBitmap.UnlockBits(diffData); } } private static Bitmap EnsureArgbBitmap(Bitmap source) { if (source.PixelFormat == PixelFormat.Format32bppArgb) { return (Bitmap)source.Clone(); } var converted = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb); using var graphics = Graphics.FromImage(converted); graphics.DrawImage(source, 0, 0, source.Width, source.Height); return converted; } private static void WriteCutDebugSweepReports(CutDebugSweepOptions options, IReadOnlyList results) { var jsonPath = Path.Combine(options.OutputPath, "results.json"); var csvPath = Path.Combine(options.OutputPath, "items.csv"); var summaryPath = Path.Combine(options.OutputPath, "summary.md"); File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8); var csv = new StringBuilder(); csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Key,Kind,GroupLabel,ChangedPixels,ChangeRatio,AverageChannelDelta,SignificantChange,BoundingBox,CapturePath,DiffPath,LogPreview,Detail"); foreach (var result in results) { foreach (var item in result.Items) { csv.AppendLine(string.Join(",", Csv(result.Index.ToString()), Csv(result.TemplateId), Csv(result.TemplateName), Csv(result.CutName), Csv(result.Channel), Csv(item.Key), Csv(item.Kind), Csv(item.GroupLabel), Csv(item.ChangedPixels.ToString()), Csv(item.ChangeRatio.ToString("0.000000")), Csv(item.AverageChannelDelta.ToString("0.00")), Csv(item.SignificantChange.ToString()), Csv(item.BoundingBox ?? string.Empty), Csv(item.CapturePath ?? string.Empty), Csv(item.DiffPath ?? string.Empty), Csv(item.LogPreview ?? string.Empty), Csv(item.Detail ?? string.Empty))); } } File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8); var summary = new StringBuilder(); summary.AppendLine("# Cut Debug Sweep"); summary.AppendLine(); summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}"); summary.AppendLine($"- Image Root: {options.ImageRootPath}"); summary.AppendLine($"- Output: {options.OutputPath}"); summary.AppendLine($"- Mode: {options.Mode}"); summary.AppendLine($"- Cuts: {results.Count}"); summary.AppendLine($"- Total Items: {results.Sum(result => result.Items.Count)}"); summary.AppendLine($"- Changed Items: {results.Sum(result => result.Items.Count(item => item.ChangedPixels > 0))}"); summary.AppendLine($"- Significant Items: {results.Sum(result => result.Items.Count(item => item.SignificantChange))}"); summary.AppendLine(); foreach (var result in results) { summary.AppendLine($"## `{result.TemplateId}` / `{result.CutName}`"); summary.AppendLine(); summary.AppendLine($"- Success: {result.Success}"); summary.AppendLine($"- Scene: `{result.ScenePath}`"); summary.AppendLine($"- Items: {result.ItemCount}"); summary.AppendLine($"- Changed Items: {result.Items.Count(item => item.ChangedPixels > 0)}"); summary.AppendLine($"- Baseline Noise Pixels: {result.NoiseChangedPixels}"); summary.AppendLine($"- Significant Threshold Pixels: {result.SignificanceThresholdPixels}"); summary.AppendLine($"- Detail: {result.Detail}"); summary.AppendLine(); var topItems = result.Items .OrderByDescending(item => item.SignificantChange) .ThenByDescending(item => item.ChangedPixels) .Take(20) .ToList(); if (topItems.Count == 0) { summary.AppendLine("- No item results."); summary.AppendLine(); continue; } summary.AppendLine("| Key | Kind | Group | Pixels | Ratio | Significant | Bounding Box |"); summary.AppendLine("| --- | --- | --- | ---: | ---: | --- | --- |"); foreach (var item in topItems) { summary.AppendLine($"| `{item.Key}` | `{item.Kind}` | `{item.GroupLabel}` | {item.ChangedPixels} | {item.ChangeRatio:0.000000} | {(item.SignificantChange ? "yes" : "no")} | `{item.BoundingBox ?? string.Empty}` |"); } summary.AppendLine(); } summary.AppendLine("## Files"); summary.AppendLine(); summary.AppendLine("- `results.json`"); summary.AppendLine("- `items.csv`"); summary.AppendLine("- `*_baseline_A.png`, `*_baseline_B.png`, `*_baseline_diff.png`"); summary.AppendLine("- `*_{Kind}_{Mode}.png`, `*_diff.png`"); File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8); } private static void WriteCutDebugCoverageReports(CutDebugCoverageOptions options, IReadOnlyList results) { var jsonPath = Path.Combine(options.OutputPath, "results.json"); var csvPath = Path.Combine(options.OutputPath, "results.csv"); var summaryPath = Path.Combine(options.OutputPath, "summary.md"); File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8); var csv = new StringBuilder(); csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Success,ScenePath,SceneVariableCount,ItemCount,TextItemCount,ImageItemCount,CounterItemCount,StyleItemCount,VisibilityItemCount,SampleKeys,Detail"); foreach (var result in results) { csv.AppendLine(string.Join(",", Csv(result.Index.ToString()), Csv(result.TemplateId), Csv(result.TemplateName), Csv(result.CutName), Csv(result.Channel), Csv(result.Success.ToString()), Csv(result.ScenePath ?? string.Empty), Csv(result.SceneVariableCount.ToString()), Csv(result.ItemCount.ToString()), Csv(result.TextItemCount.ToString()), Csv(result.ImageItemCount.ToString()), Csv(result.CounterItemCount.ToString()), Csv(result.StyleItemCount.ToString()), Csv(result.VisibilityItemCount.ToString()), Csv(result.SampleKeys ?? string.Empty), Csv(result.Detail ?? string.Empty))); } File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8); var zeroCoverage = results .Where(result => result.Success && result.ItemCount == 0) .OrderBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase) .ThenBy(result => result.CutName, StringComparer.OrdinalIgnoreCase) .ToList(); var failures = results .Where(result => !result.Success) .OrderBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase) .ThenBy(result => result.CutName, StringComparer.OrdinalIgnoreCase) .ToList(); var strongestCoverage = results .Where(result => result.Success) .OrderByDescending(result => result.ItemCount) .ThenBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase) .Take(15) .ToList(); var summary = new StringBuilder(); summary.AppendLine("# Cut Debug Coverage"); summary.AppendLine(); summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}"); summary.AppendLine($"- Image Root: {options.ImageRootPath}"); summary.AppendLine($"- Output: {options.OutputPath}"); summary.AppendLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}"); summary.AppendLine($"- Cuts: {results.Count}"); summary.AppendLine($"- Zero Coverage: {zeroCoverage.Count}"); summary.AppendLine($"- Failures: {failures.Count}"); summary.AppendLine(); if (zeroCoverage.Count > 0) { summary.AppendLine("## Zero Coverage"); summary.AppendLine(); foreach (var result in zeroCoverage) { summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`"); } summary.AppendLine(); } if (failures.Count > 0) { summary.AppendLine("## Failures"); summary.AppendLine(); foreach (var result in failures) { summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: {result.Detail}"); } summary.AppendLine(); } if (strongestCoverage.Count > 0) { summary.AppendLine("## Top Coverage"); summary.AppendLine(); foreach (var result in strongestCoverage) { summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: items={result.ItemCount}, sceneVars={result.SceneVariableCount}"); } summary.AppendLine(); } summary.AppendLine("## Files"); summary.AppendLine(); summary.AppendLine("- `results.csv`"); summary.AppendLine("- `results.json`"); File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8); } private static async Task OutAllAsync(ITornado3Adapter adapter) { foreach (var channel in new[] { BroadcastChannel.Normal, BroadcastChannel.TopLeft, BroadcastChannel.Bottom, BroadcastChannel.VideoWall }) { try { await adapter.OutAsync(channel, CancellationToken.None).ConfigureAwait(false); } catch { } } } private static bool ShouldUsePreElectionSnapshot(string templateName) { return templateName.Contains("투표율", StringComparison.Ordinal) || templateName.StartsWith("사전_", StringComparison.Ordinal); } private static ElectionDataSnapshot CreateSnapshot( string templateName, int index, int variant, bool preElection, bool swapTopTwoCandidates, bool cycleTopThreeCandidates = false, bool stressTopRankValues = false, PartyColorFamily? forcedPartyFamily = null) { var metadata = BuildScenarioMetadata(templateName, index, variant); return new ElectionDataSnapshot { BroadcastPhase = preElection ? BroadcastPhase.PreElection : BroadcastPhase.Counting, ElectionType = metadata.ElectionType, DistrictName = metadata.DistrictName, DistrictCode = metadata.DistrictCode, RegionName = metadata.RegionName, ElectionDistrictName = metadata.ElectionDistrictName, Candidates = preElection ? Array.Empty() : CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates, cycleTopThreeCandidates, stressTopRankValues, forcedPartyFamily), TotalExpectedVotes = metadata.TotalExpectedVotes, TurnoutVotes = metadata.TurnoutVotes, CountedVotesFromApi = metadata.CountedVotes, RemainingVotesFromApi = Math.Max(0, metadata.TurnoutVotes - metadata.CountedVotes), CountedRateFromApi = metadata.CountedRate, ReceivedAt = metadata.ReceivedAt, HistoricalTurnoutHistory = CreateHistoricalTurnout(metadata, variant), HistoricalWinnerHistory = CreateHistoricalWinners(templateName, variant, forcedPartyFamily), TurnoutBoardSlots = CreateTurnoutBoardSlots(metadata, variant), NationalTurnoutRateOverride = metadata.NationalTurnoutRate }; } private static IReadOnlyList CreateCandidates( string templateName, ScenarioMetadata metadata, int variant, bool swapTopTwoCandidates, bool cycleTopThreeCandidates, bool stressTopRankValues, PartyColorFamily? forcedPartyFamily) { if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(templateName)) { return CreateCouncilSeatCandidates(templateName, metadata, variant, stressTopRankValues); } var candidateNames = ResolveCandidateNames(templateName); var parties = ResolveAuditParties(templateName, candidateNames.Length, forcedPartyFamily); var shares = ResolveVoteShares(templateName, candidateNames.Length, variant); if (stressTopRankValues && variant % 2 == 1) { shares = StressTopRankShares(shares); } var automaticJudgement = ResolveAutomaticJudgement(templateName); var identityOrder = Enumerable.Range(0, candidateNames.Length).ToArray(); if (swapTopTwoCandidates && identityOrder.Length >= 2) { (identityOrder[0], identityOrder[1]) = (identityOrder[1], identityOrder[0]); } if (cycleTopThreeCandidates && variant % 2 == 1) { CycleTopCandidateIdentities(identityOrder); } var candidates = new List(candidateNames.Length); for (var index = 0; index < candidateNames.Length; index++) { var identityIndex = identityOrder[index]; candidates.Add(new CandidateEntry { CandidateCode = (index + 1).ToString(), Name = candidateNames[identityIndex], Party = parties[identityIndex], ColorParty = parties[identityIndex], VoteRate = shares[index], VoteCount = (int)Math.Round(metadata.CountedVotes * shares[index] / 100d, MidpointRounding.AwayFromZero), HasImage = true, ManualJudgement = CandidateJudgement.None, AutomaticJudgement = index == 0 ? automaticJudgement : CandidateJudgement.None }); } return candidates; } private static IReadOnlyList CreateCouncilSeatCandidates( string templateName, ScenarioMetadata metadata, int variant, bool stressTopRankValues) { var parties = ResolveParties(6); var totalSeats = ResolveCouncilSeatPattern(templateName, variant, stressTopRankValues) .Take(parties.Length) .ToArray(); var proportionalSeats = ResolveCouncilSeatProportionalPattern(templateName, variant, stressTopRankValues) .Take(parties.Length) .ToArray(); var candidates = new List(parties.Length * 2); for (var partyIndex = 0; partyIndex < parties.Length; partyIndex++) { var party = parties[partyIndex]; var proportionalCount = Math.Min(totalSeats[partyIndex], proportionalSeats.ElementAtOrDefault(partyIndex)); var districtCount = Math.Max(0, totalSeats[partyIndex] - proportionalCount); candidates.Add(CreateCouncilSeatSummaryCandidate($"SEAT:D:{partyIndex + 1:00}", party, partyIndex, districtCount)); if (proportionalCount > 0) { candidates.Add(CreateCouncilSeatSummaryCandidate($"SEAT:P:{partyIndex + 1:00}", party, partyIndex, proportionalCount)); } } return candidates; } private static CandidateEntry CreateCouncilSeatSummaryCandidate(string candidateCode, string party, int partyIndex, int seatCount) { return new CandidateEntry { CandidateCode = candidateCode, BallotNumber = (partyIndex + 1).ToString(), Name = party, Party = party, ColorParty = party, VoteRate = seatCount, VoteCount = seatCount, HasImage = false, ManualJudgement = CandidateJudgement.None, AutomaticJudgement = CandidateJudgement.Elected }; } private static int[] ResolveCouncilSeatPattern(string templateName, int variant, bool stressTopRankValues) { if (templateName.Contains("기초의원", StringComparison.Ordinal)) { return stressTopRankValues && variant % 2 == 1 ? [34, 21, 13, 7, 3, 1] : [29, 25, 9, 5, 2, 1]; } return stressTopRankValues && variant % 2 == 1 ? [22, 17, 9, 4, 2, 1] : [18, 15, 6, 3, 1, 1]; } private static int[] ResolveCouncilSeatProportionalPattern(string templateName, int variant, bool stressTopRankValues) { if (templateName.Contains("기초의원", StringComparison.Ordinal)) { return stressTopRankValues && variant % 2 == 1 ? [5, 3, 2, 1, 0, 0] : [4, 3, 1, 0, 0, 0]; } return stressTopRankValues && variant % 2 == 1 ? [4, 3, 2, 1, 0, 0] : [3, 3, 1, 0, 0, 0]; } private static void CycleTopCandidateIdentities(int[] identityOrder) { var topCount = Math.Min(3, identityOrder.Length); if (topCount < 2) { return; } var first = identityOrder[0]; for (var index = 0; index < topCount - 1; index++) { identityOrder[index] = identityOrder[index + 1]; } identityOrder[topCount - 1] = first; } private static IReadOnlyList CreateHistoricalTurnout(ScenarioMetadata metadata, int variant) { // The historical turnout scenes currently ship with a fixed line/marker drawing in the source tscn. // For automated debug captures, seed the text values so they follow that baked-in graph shape. var seeds = string.Equals(metadata.ElectionType, "교육감", StringComparison.Ordinal) ? HistoricalTurnoutEducationSeeds : HistoricalTurnoutLocalSeeds; var entries = new List(seeds.Count); var variantOffset = variant * 0.5; for (var index = 0; index < seeds.Count; index++) { var seed = seeds[index]; var electors = Math.Max(1000, metadata.TotalExpectedVotes - 75000 + (index * 25000)); var rate = Math.Clamp(seed.TurnoutRate + variantOffset, 0.1, 99.9); entries.Add(new PreElectionHistoricalTurnoutEntry { ElectionOrder = seed.ElectionOrder, Year = seed.Year, Electors = electors, Votes = (int)Math.Round(electors * rate / 100d, MidpointRounding.AwayFromZero), TurnoutRate = rate }); } return entries; } private static IReadOnlyList CreateHistoricalWinners( string templateName, int variant, PartyColorFamily? forcedPartyFamily) { var forcedParty = ResolveAuditPartyName(templateName, forcedPartyFamily); string PartyOrDefault(string fallback) => string.IsNullOrWhiteSpace(forcedParty) ? fallback : forcedParty; return new[] { new PreElectionHistoricalWinnerEntry { ElectionOrder = 5, Year = 2010, Name = "김민수", Party = PartyOrDefault("열린우리당"), ColorParty = PartyOrDefault("열린우리당"), Note = string.Empty }, new PreElectionHistoricalWinnerEntry { ElectionOrder = 6, Year = 2014, Name = "박정우", Party = PartyOrDefault("새누리당"), ColorParty = PartyOrDefault("새누리당"), Note = string.Empty }, new PreElectionHistoricalWinnerEntry { ElectionOrder = 7, Year = 2018, Name = "이서연", Party = PartyOrDefault("더불어민주당"), ColorParty = PartyOrDefault("더불어민주당"), Note = string.Empty }, new PreElectionHistoricalWinnerEntry { ElectionOrder = 8, Year = 2022, Name = variant == 0 ? "최도윤" : "최서준", Party = PartyOrDefault(variant == 0 ? "국민의힘" : "더불어민주당"), ColorParty = PartyOrDefault(variant == 0 ? "국민의힘" : "더불어민주당"), Note = variant == 0 ? "재선" : "정권교체" } }; } private static IReadOnlyList CreateTurnoutBoardSlots(ScenarioMetadata metadata, int variant) { return new[] { new TurnoutBoardSlotEntry(1, "전국", metadata.NationalTurnoutRate, true), new TurnoutBoardSlotEntry(2, metadata.RegionName, 61.4 + (metadata.TemplateSeed % 5) + variant * 1.7), new TurnoutBoardSlotEntry(3, "세종", 59.2 + (metadata.TemplateSeed % 3) + variant * 1.3), new TurnoutBoardSlotEntry(4, "충북", 57.8 + (metadata.TemplateSeed % 4) + variant * 1.1), new TurnoutBoardSlotEntry(5, "충남", 60.1 + (metadata.TemplateSeed % 2) + variant * 1.5) }; } private static string[] ResolveCandidateNames(string templateName) { if (templateName.Contains("교육감", StringComparison.Ordinal)) { return ["김하늘", "이준호", "박소라"]; } if (templateName.Contains("기초의원", StringComparison.Ordinal)) { return ["김민재", "이소율", "박태훈", "최수빈"]; } if (templateName.Contains("기초단체장", StringComparison.Ordinal)) { return ["윤서진", "강민호", "정다온", "한지후"]; } if (templateName.Contains("광역의원", StringComparison.Ordinal)) { return ["송현우", "배지민", "임서아", "조하람"]; } return ["김하늘", "이준호", "박소라", "최민석", "정서윤"]; } private static string[] ResolveParties(int count) { return new[] { "더불어민주당", "국민의힘", "조국혁신당", "개혁신당", "무소속" } .Take(count) .ToArray(); } private static string[] ResolveEducationParties(int count) { return new[] { "\uC9C4\uBCF4", "\uBCF4\uC218", "\uC911\uB3C4", "\uC9C4\uBCF4", "\uBCF4\uC218" } .Take(count) .ToArray(); } private static string[] ResolveAuditParties( string templateName, int count, PartyColorFamily? forcedPartyFamily) { var forcedParty = ResolveAuditPartyName(templateName, forcedPartyFamily); if (!string.IsNullOrWhiteSpace(forcedParty)) { return Enumerable.Repeat(forcedParty, count).ToArray(); } return IsEducationTemplateName(templateName) ? ResolveEducationParties(count) : ResolveParties(count); } private static string? ResolveAuditPartyName(string templateName, PartyColorFamily? forcedPartyFamily) { if (forcedPartyFamily is null or PartyColorFamily.Unknown or PartyColorFamily.Mixed) { return null; } if (IsEducationTemplateName(templateName)) { return forcedPartyFamily.Value switch { PartyColorFamily.Green => "진보", PartyColorFamily.Red => "보수", PartyColorFamily.Blue => "중도", _ => null }; } return forcedPartyFamily.Value switch { PartyColorFamily.Red => "국민의힘", PartyColorFamily.Blue => "더불어민주당", PartyColorFamily.Green => "조국혁신당", _ => null }; } private static PartyColorFamily? ResolveAuditCandidateFamily( string templateName, PartyColorFamily family) { if (family is PartyColorFamily.Unknown or PartyColorFamily.Mixed) { return null; } if (!IsEducationTemplateName(templateName) && family == PartyColorFamily.Green) { return PartyColorFamily.Blue; } return family; } private static PartyColorFamily? ResolveOtherAuditCandidateFamily( string templateName, PartyColorFamily? samePartyFamily) { if (IsEducationTemplateName(templateName)) { return samePartyFamily switch { PartyColorFamily.Green => PartyColorFamily.Red, PartyColorFamily.Red => PartyColorFamily.Green, PartyColorFamily.Blue => PartyColorFamily.Green, _ => null }; } return samePartyFamily switch { PartyColorFamily.Red => PartyColorFamily.Blue, PartyColorFamily.Blue => PartyColorFamily.Red, _ => null }; } private static bool IsEducationTemplateName(string templateName) { return templateName.Contains("\uAD50\uC721\uAC10", StringComparison.Ordinal); } private static double[] ResolveVoteShares(string templateName, int count, int variant) { double[] shares = templateName switch { var name when name.Contains("초접전", StringComparison.Ordinal) => [50.1 + (variant * 0.2), 49.6 - (variant * 0.1), 0.3, 0, 0], var name when name.Contains("접전", StringComparison.Ordinal) => [50.9 + (variant * 0.2), 47.9 - (variant * 0.1), 1.2, 0, 0], var name when name.Contains("당선", StringComparison.Ordinal) => [56.4 + (variant * 0.5), 31.2 - (variant * 0.2), 8.7, 3.7, 0], var name when name.Contains("판세", StringComparison.Ordinal) => [53.1 + (variant * 0.3), 37.6 - (variant * 0.2), 6.5, 2.8, 0], var name when name.Contains("이시각1위", StringComparison.Ordinal) => [52.4 + (variant * 0.4), 39.5 - (variant * 0.2), 5.1, 3.0, 0], var name when name.Contains("1-3위", StringComparison.Ordinal) => [47.8 + (variant * 0.3), 34.4 + (variant * 0.1), 13.2 - (variant * 0.2), 3.1, 1.5], var name when name.Contains("모든후보", StringComparison.Ordinal) || name.Contains("전후보", StringComparison.Ordinal) => [43.9 + (variant * 0.3), 30.5 + (variant * 0.1), 11.8, 7.2, 6.6 - (variant * 0.4)], _ => [51.8 + (variant * 0.3), 38.7 - (variant * 0.2), 6.1, 3.4, 0] }; shares = shares.Take(count).ToArray(); var total = shares.Sum(); for (var index = 0; index < shares.Length; index++) { shares[index] = Math.Round(shares[index] * 100d / total, 1, MidpointRounding.AwayFromZero); } var delta = 100d - shares.Sum(); shares[0] = Math.Round(shares[0] + delta, 1, MidpointRounding.AwayFromZero); return shares; } private static double[] StressTopRankShares(double[] shares) { var stressed = shares.ToArray(); if (stressed.Length >= 3) { var trailingTotal = Math.Round(stressed.Skip(3).Sum(), 1, MidpointRounding.AwayFromZero); var topTotal = Math.Max(0d, 100d - trailingTotal); stressed[0] = Math.Round(topTotal * 0.46d, 1, MidpointRounding.AwayFromZero); stressed[1] = Math.Round(topTotal * 0.32d, 1, MidpointRounding.AwayFromZero); stressed[2] = Math.Round(topTotal - stressed[0] - stressed[1], 1, MidpointRounding.AwayFromZero); } else if (stressed.Length == 2) { stressed[0] = 62.7d; stressed[1] = 37.3d; } else if (stressed.Length == 1) { stressed[0] = 99.9d; } var delta = Math.Round(100d - stressed.Sum(), 1, MidpointRounding.AwayFromZero); if (stressed.Length > 0) { stressed[0] = Math.Round(stressed[0] + delta, 1, MidpointRounding.AwayFromZero); } return stressed; } private static CandidateJudgement ResolveAutomaticJudgement(string templateName) { if (templateName.Contains("당선", StringComparison.Ordinal)) { return CandidateJudgement.Elected; } if (templateName.Contains("판세", StringComparison.Ordinal) || templateName.Contains("이시각1위", StringComparison.Ordinal)) { return CandidateJudgement.Leading; } if (templateName.Contains("접전", StringComparison.Ordinal) || templateName.Contains("초접전", StringComparison.Ordinal)) { return CandidateJudgement.None; } return CandidateJudgement.Leading; } private static ScenarioMetadata BuildScenarioMetadata(string templateName, int index, int variant) { var seed = index + 1; var totalExpectedVotes = 1_800_000 + (seed * 12_500); var turnoutVotes = 1_050_000 + (seed * 7_500) + (variant * 31_000); var countedRate = Math.Min(97.9, 68.4 + ((seed % 6) * 2.1) + (variant * 6.4)); var countedVotes = (int)Math.Round(turnoutVotes * countedRate / 100d, MidpointRounding.AwayFromZero); if (templateName.Contains("교육감", StringComparison.Ordinal)) { return new ScenarioMetadata("교육감", "30", "대전광역시", "대전", "대전광역시교육감", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 58.7 + (seed % 4) + (variant * 1.9), DateTimeOffset.Now.AddMinutes(seed + variant), seed); } if (templateName.Contains("기초의원", StringComparison.Ordinal)) { return new ScenarioMetadata("기초의원", "44131", "천안시 가선거구", "충남", "천안시의원 가선거구", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 54.8 + (seed % 5) + (variant * 1.7), DateTimeOffset.Now.AddMinutes(seed + variant), seed); } if (templateName.Contains("기초단체장", StringComparison.Ordinal)) { return new ScenarioMetadata("기초단체장", "44131", "천안시", "충남", "천안시장", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 56.1 + (seed % 4) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed); } if (templateName.Contains("광역의원", StringComparison.Ordinal)) { return new ScenarioMetadata("광역의원", "44001", "충남 제1선거구", "충남", "충남도의원 제1선거구", totalExpectedVotes / 3, turnoutVotes / 3, countedVotes / 3, countedRate, 55.4 + (seed % 3) + (variant * 1.6), DateTimeOffset.Now.AddMinutes(seed + variant), seed); } if (templateName.Contains("보궐선거", StringComparison.Ordinal)) { return new ScenarioMetadata("국회의원", "2411502", "평택 을", "경기", "평택 을", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 56.5 + (seed % 4) + (variant * 1.5), DateTimeOffset.Now.AddMinutes(seed + variant), seed); } return new ScenarioMetadata("광역단체장", "44", "충청남도", "충남", "충남도지사", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 57.3 + (seed % 5) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed); } private static string CapturePgm(PgmWindow? pgmWindow, string outputPath, bool skipCapture) { if (skipCapture || pgmWindow is null) { return string.Empty; } Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); using var bitmap = CaptureWindowBitmap(pgmWindow.Value.Handle, pgmWindow.Value.Bounds); bitmap.Save(outputPath, ImageFormat.Png); return ComputeSha256(outputPath); } private static async Task CaptureValidationImageAsync( ITornado3Adapter adapter, PgmWindow? pgmWindow, LiveCutWorkItem item, LiveCutValidationOptions options, string outputPath, bool outputVisibleInPgm, CancellationToken cancellationToken) { if (options.CaptureMode == LiveCutCaptureMode.Pgm) { return CapturePgm(pgmWindow, outputPath, !outputVisibleInPgm); } if (adapter is not KarismaTornado3Adapter karismaAdapter) { throw new InvalidOperationException("Scene capture mode requires the Karisma adapter."); } var (width, height) = ResolveSceneCaptureSize(item.Template); var frame = ResolveFinalSceneCaptureFrame(item.Template, item.Cut); await karismaAdapter.SavePendingSceneImageAsync( item.Template.RecommendedChannel, outputPath, width, height, frame, cancellationToken: cancellationToken) .ConfigureAwait(false); return ComputeSha256(outputPath); } private static (int Width, int Height) ResolveSceneCaptureSize(FormatTemplateDefinition template) { var sourceWidth = template.SceneWidth.GetValueOrDefault(1920); var sourceHeight = template.SceneHeight.GetValueOrDefault(1080); if (sourceWidth <= 0 || sourceHeight <= 0) { return (1280, 720); } const int maxWidth = 1280; if (sourceWidth <= maxWidth) { return (sourceWidth, sourceHeight); } var scale = maxWidth / (double)sourceWidth; return (maxWidth, Math.Max(1, (int)Math.Round(sourceHeight * scale, MidpointRounding.AwayFromZero))); } 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 sha256 = SHA256.Create(); return Convert.ToHexString(sha256.ComputeHash(stream)); } 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 (!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 void WriteReports(LiveCutValidationOptions options, IReadOnlyList results) { var csvPath = Path.Combine(options.OutputPath, "results.csv"); var jsonPath = Path.Combine(options.OutputPath, "results.json"); var summaryPath = Path.Combine(options.OutputPath, "summary.md"); var csv = new StringBuilder(); csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Phase,CaptureMode,CaptureComparable,Success,VisualChanged,OutputVisibleInPgm,CaptureAPath,CaptureBPath,HashA,HashB,Detail"); foreach (var result in results) { csv.AppendLine(string.Join(",", Csv(result.Index.ToString()), Csv(result.TemplateId), Csv(result.TemplateName), Csv(result.CutName), Csv(result.Channel), Csv(result.Phase), Csv(result.CaptureMode), Csv(result.CaptureComparable.ToString()), Csv(result.Success.ToString()), Csv(result.VisualChanged.ToString()), Csv(result.OutputVisibleInPgm.ToString()), Csv(result.CaptureAPath ?? string.Empty), Csv(result.CaptureBPath ?? string.Empty), Csv(result.HashA ?? string.Empty), Csv(result.HashB ?? string.Empty), Csv(result.Detail ?? string.Empty))); } File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8); File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8); var successCount = results.Count(result => result.Success); var changedCount = results.Count(result => result.Success && result.VisualChanged); var unchanged = results.Where(result => result.Success && result.CaptureComparable && !result.VisualChanged).ToList(); var failures = results.Where(result => !result.Success).ToList(); var summary = new StringBuilder(); summary.AppendLine("# Live Cut Validation"); summary.AppendLine(); summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}"); summary.AppendLine($"- Image Root: {options.ImageRootPath}"); summary.AppendLine($"- Output: {options.OutputPath}"); summary.AppendLine($"- Capture Mode: {options.CaptureMode}"); summary.AppendLine($"- Success: {successCount}/{results.Count}"); summary.AppendLine($"- Visual Changed: {changedCount}"); summary.AppendLine($"- Unchanged Captures: {unchanged.Count}"); summary.AppendLine($"- Failures: {failures.Count}"); summary.AppendLine(); if (unchanged.Count > 0) { summary.AppendLine("## Unchanged Captures"); summary.AppendLine(); foreach (var result in unchanged) { summary.AppendLine($"- `{result.TemplateId}`: {result.Detail}"); } summary.AppendLine(); } if (failures.Count > 0) { summary.AppendLine("## Failures"); summary.AppendLine(); foreach (var result in failures) { summary.AppendLine($"- `{result.TemplateId}`: {result.Detail}"); } summary.AppendLine(); } summary.AppendLine("## Files"); summary.AppendLine(); summary.AppendLine("- `results.csv`"); summary.AppendLine("- `results.json`"); summary.AppendLine("- `*_A.png`, `*_B.png`"); File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8); } private static string Csv(string value) { return $"\"{value.Replace("\"", "\"\"")}\""; } private static List ApplyTemplateFilter(IEnumerable items, string? filter) { var list = items.ToList(); if (string.IsNullOrWhiteSpace(filter)) { return list; } var exactMatches = list .Where(item => string.Equals(item.Template.Id, filter, StringComparison.OrdinalIgnoreCase) || string.Equals(item.Template.Name, filter, StringComparison.OrdinalIgnoreCase)) .ToList(); if (exactMatches.Count > 0) { return exactMatches; } return list .Where(item => item.Template.Id.Contains(filter, StringComparison.OrdinalIgnoreCase) || item.Template.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) .ToList(); } private static List ApplyCutFilter(IEnumerable items, string? cutName) { var list = items.ToList(); if (string.IsNullOrWhiteSpace(cutName)) { return list; } var exactMatches = list .Where(item => string.Equals(item.Cut.Name, cutName, StringComparison.OrdinalIgnoreCase)) .ToList(); if (exactMatches.Count > 0) { return exactMatches; } return list .Where(item => item.Cut.Name.Contains(cutName, StringComparison.OrdinalIgnoreCase)) .ToList(); } private static string SanitizeFileName(string value) { var invalidChars = Path.GetInvalidFileNameChars(); var builder = new StringBuilder(value.Length); foreach (var character in value) { builder.Append(invalidChars.Contains(character) ? '_' : character); } var sanitized = builder.ToString().Trim(); if (sanitized.Length > 80) { sanitized = sanitized[..80]; } return string.IsNullOrWhiteSpace(sanitized) ? "cut" : sanitized; } [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect); private sealed record LiveCutValidationOptions { public string ImageRootPath { get; init; } = string.Empty; public string OutputPath { get; init; } = string.Empty; public string StationLogoPath { get; init; } = string.Empty; public string? Filter { get; init; } public int? Limit { get; init; } public int OnAirDelayMs { get; init; } = 900; public int BetweenDelayMs { get; init; } = 250; public bool IncludeVideoWall { get; init; } public bool SwapTopTwoCandidates { get; init; } public LiveCutCaptureMode CaptureMode { get; init; } = LiveCutCaptureMode.Pgm; public bool CycleTopThreeCandidates { get; init; } public bool StressTopRankValues { get; init; } public static LiveCutValidationOptions Parse(string[] args) { var repoRoot = Environment.CurrentDirectory; var options = new LiveCutValidationOptions { ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(), OutputPath = Path.Combine(repoRoot, "artifacts", "live-cut-validation", DateTime.Now.ToString("yyyyMMdd_HHmmss")), StationLogoPath = Path.Combine(repoRoot, "Tornado3_2026Election", "bin", "x64", "Debug", "net8.0-windows10.0.19041.0", "win-x64", "Assets", "Stations", "tjb.png") }; for (var index = 0; index < args.Length; index++) { switch (args[index]) { case "--image-root": _ = RequireValue(args, ref index, "--image-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 "--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 "--include-videowall": options = options with { IncludeVideoWall = true }; break; case "--swap-top-two": options = options with { SwapTopTwoCandidates = true }; break; case "--cycle-top-three": options = options with { CycleTopThreeCandidates = true }; break; case "--stress-top-ranks": options = options with { StressTopRankValues = true }; break; case "--capture-mode": options = options with { CaptureMode = ParseCaptureMode(RequireValue(args, ref index, "--capture-mode")) }; break; default: throw new ArgumentException($"Unknown option: {args[index]}"); } } return options with { ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()), OutputPath = Path.GetFullPath(options.OutputPath), StationLogoPath = Path.GetFullPath(options.StationLogoPath) }; } 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]; } private static LiveCutCaptureMode ParseCaptureMode(string raw) { return raw.Trim().ToLowerInvariant() switch { "pgm" or "window" => LiveCutCaptureMode.Pgm, "scene" or "save-scene-image" => LiveCutCaptureMode.Scene, _ => throw new ArgumentException($"Unknown capture mode: {raw}") }; } } private sealed record PartyColorAuditOptions { public string ImageRootPath { get; init; } = string.Empty; public string OutputPath { get; init; } = string.Empty; public string StationLogoPath { get; init; } = string.Empty; public string? Filter { get; init; } public string? CutName { get; init; } public int? Limit { get; init; } public int OnAirDelayMs { get; init; } = 900; public int BetweenDelayMs { get; init; } = 250; public bool IncludeVideoWall { get; init; } public LiveCutCaptureMode CaptureMode { get; init; } = LiveCutCaptureMode.Scene; public static PartyColorAuditOptions Parse(string[] args) { var repoRoot = Environment.CurrentDirectory; var options = new PartyColorAuditOptions { ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(), OutputPath = Path.Combine(repoRoot, "artifacts", "party-color-live-audit", DateTime.Now.ToString("yyyyMMdd_HHmmss")), StationLogoPath = Path.Combine(repoRoot, "Tornado3_2026Election", "bin", "x64", "Debug", "net8.0-windows10.0.19041.0", "win-x64", "Assets", "Stations", "tjb.png") }; for (var index = 0; index < args.Length; index++) { switch (args[index]) { case "--image-root": options = options with { ImageRootPath = RequireValue(args, ref index, "--image-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 "--cut-name": options = options with { CutName = RequireValue(args, ref index, "--cut-name") }; 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 "--include-videowall": options = options with { IncludeVideoWall = true }; break; case "--capture-mode": options = options with { CaptureMode = ParseCaptureMode(RequireValue(args, ref index, "--capture-mode")) }; break; default: throw new ArgumentException($"Unknown option: {args[index]}"); } } return options with { ImageRootPath = Path.GetFullPath(options.ImageRootPath), OutputPath = Path.GetFullPath(options.OutputPath), StationLogoPath = Path.GetFullPath(options.StationLogoPath) }; } 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]; } private static LiveCutCaptureMode ParseCaptureMode(string raw) { return raw.Trim().ToLowerInvariant() switch { "pgm" or "window" => LiveCutCaptureMode.Pgm, "scene" or "save-scene-image" => LiveCutCaptureMode.Scene, _ => throw new ArgumentException($"Unknown capture mode: {raw}") }; } } private sealed record CutDebugCoverageOptions { public string ImageRootPath { get; init; } = string.Empty; public string OutputPath { get; init; } = string.Empty; public string? Filter { get; init; } public string? CutName { get; init; } public int? Limit { get; init; } public bool IncludeVideoWall { get; init; } public bool ExcludeHistoricalTurnout { get; init; } public static CutDebugCoverageOptions Parse(string[] args) { var repoRoot = Environment.CurrentDirectory; var options = new CutDebugCoverageOptions { ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(), OutputPath = Path.Combine(repoRoot, "artifacts", "cut-debug-coverage", DateTime.Now.ToString("yyyyMMdd_HHmmss")) }; for (var index = 0; index < args.Length; index++) { switch (args[index]) { case "--image-root": _ = RequireValue(args, ref index, "--image-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 "--cut-name": options = options with { CutName = RequireValue(args, ref index, "--cut-name") }; break; case "--limit": options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) }; break; case "--include-videowall": options = options with { IncludeVideoWall = true }; break; case "--exclude-historical-turnout": options = options with { ExcludeHistoricalTurnout = true }; break; default: throw new ArgumentException($"Unknown option: {args[index]}"); } } return options with { ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()), OutputPath = Path.GetFullPath(options.OutputPath) }; } 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]; } } private sealed record CutDebugSweepOptions { public string ImageRootPath { get; init; } = string.Empty; public string OutputPath { get; init; } = string.Empty; public string StationLogoPath { get; init; } = string.Empty; public string? Filter { get; init; } public string? CutName { get; init; } public int? Limit { get; init; } public int? MaxItems { get; init; } public int OnAirDelayMs { get; init; } = 1200; public int BetweenDelayMs { get; init; } = 300; public bool IncludeVideoWall { get; init; } public bool ExcludeHistoricalTurnout { get; init; } public string? KindFilter { get; init; } public string? KeyFilter { get; init; } public CutDebugSweepMode Mode { get; init; } = CutDebugSweepMode.Disable; public bool SwapTopTwoCandidates { get; init; } public static CutDebugSweepOptions Parse(string[] args) { var repoRoot = Environment.CurrentDirectory; var options = new CutDebugSweepOptions { ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(), OutputPath = Path.Combine(repoRoot, "artifacts", "cut-debug-sweep", DateTime.Now.ToString("yyyyMMdd_HHmmss")), StationLogoPath = Path.Combine(repoRoot, "Tornado3_2026Election", "bin", "x64", "Debug", "net8.0-windows10.0.19041.0", "win-x64", "Assets", "Stations", "tjb.png") }; for (var index = 0; index < args.Length; index++) { switch (args[index]) { case "--image-root": _ = RequireValue(args, ref index, "--image-root"); break; case "--output": options = options with { OutputPath = RequireValue(args, ref index, "--output") }; break; case "--station-logo": options = options with { StationLogoPath = RequireValue(args, ref index, "--station-logo") }; break; case "--filter": options = options with { Filter = RequireValue(args, ref index, "--filter") }; break; case "--cut-name": options = options with { CutName = RequireValue(args, ref index, "--cut-name") }; break; case "--limit": options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) }; break; case "--max-items": options = options with { MaxItems = int.Parse(RequireValue(args, ref index, "--max-items")) }; 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 "--include-videowall": options = options with { IncludeVideoWall = true }; break; case "--exclude-historical-turnout": options = options with { ExcludeHistoricalTurnout = true }; break; case "--swap-top-two": options = options with { SwapTopTwoCandidates = true }; break; case "--kind": options = options with { KindFilter = RequireValue(args, ref index, "--kind") }; break; case "--key": options = options with { KeyFilter = RequireValue(args, ref index, "--key") }; break; case "--mode": options = options with { Mode = ParseMode(RequireValue(args, ref index, "--mode")) }; break; default: throw new ArgumentException($"Unknown option: {args[index]}"); } } return options with { ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()), OutputPath = Path.GetFullPath(options.OutputPath), StationLogoPath = Path.GetFullPath(options.StationLogoPath) }; } public bool IncludeItem(string key, CutDebugItemKind kind) { if (!string.IsNullOrWhiteSpace(KeyFilter) && !key.Contains(KeyFilter, StringComparison.OrdinalIgnoreCase)) { return false; } if (!string.IsNullOrWhiteSpace(KindFilter) && !string.Equals(kind.ToString(), KindFilter, StringComparison.OrdinalIgnoreCase)) { return false; } return true; } private static CutDebugSweepMode ParseMode(string raw) { return raw.ToLowerInvariant() switch { "off" or "disable" => CutDebugSweepMode.Disable, "replace" or "override" => CutDebugSweepMode.Replace, _ => throw new ArgumentException($"Unknown sweep 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]; } } private enum CutDebugSweepMode { Disable, Replace } private enum LiveCutCaptureMode { Pgm, Scene } private enum PartyColorFamily { Unknown, Mixed, Red, Blue, Green } private const string PartyColorAuditSuspectedCodeLocation = "Tornado3_2026Election/Services/KarismaTornado3Adapter.cs: ResolveCandidateAssetColorParty, AddStyleColorUpdates, SetOptionalAssetAliasesUnlessStyleBound; Tornado3_2026Election/Services/PartyColorCatalog.cs: TryResolveStyleColor, ResolveFallbackAssetPath"; private const string PartyColorAuditFixTarget = "KarismaTornado3Adapter.ResolveCandidateAssetColorParty / AddStyleColorUpdates / PartyColorCatalog rgb mapping"; private readonly record struct PartyColorAuditTargetDecision(bool Include, string Reason); private readonly record struct AuditKarismaBinding(int OutputChannelIndex, int LayerNo); private readonly record struct AuditComparison(string Status, string Detail); private readonly record struct CutDebugReplacementAssets(string MagentaPath, string CyanPath); private sealed class PartyColorStats { public PartyColorFamily DominantFamily { get; init; } public int RedPixels { get; init; } public int BluePixels { get; init; } public int GreenPixels { get; init; } public int ColoredPixels { get; init; } } private sealed class CutDebugSweepResult { public int Index { get; init; } public string TemplateId { get; init; } = string.Empty; public string TemplateName { get; init; } = string.Empty; public string CutName { get; init; } = string.Empty; public string Channel { get; init; } = string.Empty; public string ScenePath { get; set; } = string.Empty; public bool Success { get; set; } public int ItemCount { get; set; } public string? BaselineAPath { get; set; } public string? BaselineBPath { get; set; } public string? BaselineHash { get; set; } public string? NoiseDiffPath { get; set; } public int NoiseChangedPixels { get; set; } public int SignificanceThresholdPixels { get; set; } public List Items { get; } = []; public string? Detail { get; set; } } private sealed class CutDebugCoverageResult { public int Index { get; init; } public string TemplateId { get; init; } = string.Empty; public string TemplateName { get; init; } = string.Empty; public string CutName { get; init; } = string.Empty; public string Channel { get; init; } = string.Empty; public string? ScenePath { get; set; } public bool Success { get; set; } public bool ExcludedHistoricalTurnout { get; set; } public int SceneVariableCount { get; set; } public int ItemCount { get; set; } public int TextItemCount { get; set; } public int ImageItemCount { get; set; } public int CounterItemCount { get; set; } public int StyleItemCount { get; set; } public int VisibilityItemCount { get; set; } public string? SampleKeys { get; set; } public string? Detail { get; set; } } private sealed class PartyColorAuditResult { public int Index { get; init; } public string TemplateId { get; init; } = string.Empty; public string TemplateName { get; init; } = string.Empty; public string CutName { get; init; } = string.Empty; public string Channel { get; init; } = string.Empty; public string CaptureMode { get; init; } = string.Empty; public string? ScenePath { get; set; } public int CaptureFrame { get; set; } public bool VerificationTarget { get; set; } public string? ExclusionReason { get; set; } public string? TargetReason { get; set; } public int SceneVariableCount { get; set; } public int TextItemCount { get; set; } public int ImageItemCount { get; set; } public int CounterItemCount { get; set; } public int StyleItemCount { get; set; } public int VisibilityItemCount { get; set; } public string? Step1OriginalCapturePath { get; set; } public string? Step2SamePartyCapturePath { get; set; } public string? Step3OtherPartyCapturePath { get; set; } public string? Step1VsStep2DiffPath { get; set; } public string? Step2VsStep3DiffPath { get; set; } public string? Step1Hash { get; set; } public string? Step2Hash { get; set; } public string? Step3Hash { get; set; } public PartyColorStats? Step1ColorStats { get; set; } public PartyColorStats? Step2ColorStats { get; set; } public PartyColorStats? Step3ColorStats { get; set; } public string? Step1VsStep2Result { get; set; } public string? Step2VsStep3Result { get; set; } public int Step1VsStep2ChangedPixels { get; set; } public double Step1VsStep2ChangeRatio { get; set; } public int Step2VsStep3ChangedPixels { get; set; } public double Step2VsStep3ChangeRatio { get; set; } public string Verdict { get; set; } = string.Empty; public bool ReviewRequired { get; set; } public string? Detail { get; set; } public string? SuspectedCodeLocation { get; set; } public string? FixTarget { get; set; } } private sealed class CutDebugItemSweepResult { public string Key { get; init; } = string.Empty; public string Kind { get; init; } = string.Empty; public string GroupLabel { get; init; } = string.Empty; public string? CapturePath { get; init; } public string? DiffPath { get; init; } public string? Hash { get; init; } public int ChangedPixels { get; init; } public double ChangeRatio { get; init; } public double AverageChannelDelta { get; init; } public bool SignificantChange { get; init; } public string? BoundingBox { get; init; } public string? LogPreview { get; init; } public string? Detail { get; init; } } private readonly record struct SweepCaptureResult(string Path, string Hash); private readonly record struct ImageDiffResult( int ChangedPixels, double ChangeRatio, double AverageChannelDelta, string? BoundingBox); private sealed class LiveCutValidationResult { public int Index { get; init; } public string TemplateId { get; init; } = string.Empty; public string TemplateName { get; init; } = string.Empty; public string CutName { get; init; } = string.Empty; public string Channel { get; init; } = string.Empty; public string Phase { get; init; } = string.Empty; public string CaptureMode { get; set; } = string.Empty; public bool CaptureComparable { get; set; } public bool Success { get; set; } public bool VisualChanged { get; set; } public bool OutputVisibleInPgm { get; set; } public string? CaptureAPath { get; set; } public string? CaptureBPath { get; set; } public string? HashA { get; set; } public string? HashB { get; set; } public string? Detail { get; set; } } private readonly record struct LiveCutWorkItem(FormatTemplateDefinition Template, FormatCutDefinition Cut); private readonly record struct ScenarioMetadata( string ElectionType, string DistrictCode, string DistrictName, string RegionName, string ElectionDistrictName, int TotalExpectedVotes, int TurnoutVotes, int CountedVotes, double CountedRate, double NationalTurnoutRate, DateTimeOffset ReceivedAt, int TemplateSeed); private readonly record struct HistoricalTurnoutSeed( int ElectionOrder, int Year, double TurnoutRate); private static readonly IReadOnlyList HistoricalTurnoutLocalSeeds = [ new(3, 2002, 55.4), new(4, 2006, 42.8), new(5, 2010, 56.9), new(6, 2014, 45.1), new(7, 2018, 45.8), new(8, 2022, 55.7) ]; private static readonly IReadOnlyList HistoricalTurnoutEducationSeeds = [ new(5, 2010, 53.6), new(6, 2014, 47.9), new(7, 2018, 56.1), new(8, 2022, 50.4) ]; 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 int Left { get; init; } public int Top { get; init; } public int Right { get; init; } public int Bottom { get; init; } } }