Files
Tornado3_2026Election/tools/KarismaTcpProbe/LiveCutValidation.cs
2026-04-22 13:30:24 +09:00

2228 lines
96 KiB
C#

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<int> 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")}");
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<LiveCutValidationResult>();
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(),
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);
var snapshotB = CreateSnapshot(item.Template.Name, index, variant: 1, preElection, options.SwapTopTwoCandidates);
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 = CapturePgm(pgmWindow, result.CaptureAPath, !result.OutputVisibleInPgm);
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 = CapturePgm(pgmWindow, result.CaptureBPath, !result.OutputVisibleInPgm);
result.VisualChanged = !string.Equals(result.HashA, result.HashB, StringComparison.OrdinalIgnoreCase);
result.Success = true;
result.Detail = result.OutputVisibleInPgm
? (result.VisualChanged ? "A/B capture changed" : "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.OutputVisibleInPgm && !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<int> 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<CutDebugSweepResult>();
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<CutDebugCoverageResult>();
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;
}
private static async Task<SweepCaptureResult> 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<CutDebugItemState> items, bool isEnabled)
{
foreach (var item in items)
{
item.IsEnabled = isEnabled;
}
}
private static IReadOnlyList<CutDebugItemState> OrderSweepItems(
string templateId,
IEnumerable<CutDebugItemState> items,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> 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) => 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<CutDebugItemDescriptor> BuildCutDebugDescriptors(
FormatTemplateDefinition template,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
var descriptors = new List<CutDebugItemDescriptor>();
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<string, KarismaSceneVariableDefinition> 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<CutDebugItemDescriptor> 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)
{
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<CutDebugSweepResult> 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<CutDebugCoverageResult> 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)
{
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<CandidateEntry>() : CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates),
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(variant),
TurnoutBoardSlots = CreateTurnoutBoardSlots(metadata, variant),
NationalTurnoutRateOverride = metadata.NationalTurnoutRate
};
}
private static IReadOnlyList<CandidateEntry> CreateCandidates(string templateName, ScenarioMetadata metadata, int variant, bool swapTopTwoCandidates)
{
var candidateNames = ResolveCandidateNames(templateName);
var parties = ResolveParties(candidateNames.Length);
var shares = ResolveVoteShares(templateName, candidateNames.Length, variant);
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]);
}
var candidates = new List<CandidateEntry>(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<PreElectionHistoricalTurnoutEntry> 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<PreElectionHistoricalTurnoutEntry>(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<PreElectionHistoricalWinnerEntry> CreateHistoricalWinners(int variant)
{
return new[]
{
new PreElectionHistoricalWinnerEntry
{
ElectionOrder = 5,
Year = 2010,
Name = "김민수",
Party = "열린우리당",
ColorParty = "열린우리당",
Note = string.Empty
},
new PreElectionHistoricalWinnerEntry
{
ElectionOrder = 6,
Year = 2014,
Name = "박정우",
Party = "새누리당",
ColorParty = "새누리당",
Note = string.Empty
},
new PreElectionHistoricalWinnerEntry
{
ElectionOrder = 7,
Year = 2018,
Name = "이서연",
Party = "더불어민주당",
ColorParty = "더불어민주당",
Note = string.Empty
},
new PreElectionHistoricalWinnerEntry
{
ElectionOrder = 8,
Year = 2022,
Name = variant == 0 ? "최도윤" : "최서준",
Party = variant == 0 ? "국민의힘" : "더불어민주당",
ColorParty = variant == 0 ? "국민의힘" : "더불어민주당",
Note = variant == 0 ? "재선" : "정권교체"
}
};
}
private static IReadOnlyList<TurnoutBoardSlotEntry> 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 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 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);
}
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 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<LiveCutValidationResult> 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,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.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.OutputVisibleInPgm && !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($"- 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<LiveCutWorkItem> ApplyTemplateFilter(IEnumerable<LiveCutWorkItem> 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<LiveCutWorkItem> ApplyCutFilter(IEnumerable<LiveCutWorkItem> 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 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":
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 "--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;
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 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":
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 "--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(options.ImageRootPath),
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":
options = options with { ImageRootPath = 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(options.ImageRootPath),
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 readonly record struct CutDebugReplacementAssets(string MagentaPath, string CyanPath);
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<CutDebugItemSweepResult> 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 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 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<HistoricalTurnoutSeed> 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<HistoricalTurnoutSeed> 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; }
}
}