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")}"); var logService = new LogService(); if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, 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) .Where(template => string.IsNullOrWhiteSpace(options.Filter) || template.Id.Contains(options.Filter, StringComparison.OrdinalIgnoreCase) || template.Name.Contains(options.Filter, StringComparison.OrdinalIgnoreCase)) .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut))) .ToList(); 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(), 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); var snapshotB = CreateSnapshot(item.Template.Name, index, variant: 1, preElection); 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(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(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; } 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) { 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), 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 CreateCandidates(string templateName, ScenarioMetadata metadata, int variant) { var candidateNames = ResolveCandidateNames(templateName); var parties = ResolveParties(candidateNames.Length); var shares = ResolveVoteShares(templateName, candidateNames.Length, variant); var automaticJudgement = ResolveAutomaticJudgement(templateName); var candidates = new List(candidateNames.Length); for (var index = 0; index < candidateNames.Length; index++) { candidates.Add(new CandidateEntry { CandidateCode = (index + 1).ToString(), Name = candidateNames[index], Party = parties[index], ColorParty = parties[index], 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 CreateHistoricalTurnout(ScenarioMetadata metadata, int variant) { var years = new[] { 2010, 2014, 2018, 2022 }; var rates = new[] { 56.1, 58.4, 61.8, 64.2 + variant * 1.4 }; var entries = new List(years.Length); for (var index = 0; index < years.Length; index++) { var electors = metadata.TotalExpectedVotes - 50000 + (index * 25000); entries.Add(new PreElectionHistoricalTurnoutEntry { ElectionOrder = 5 + index, Year = years[index], Electors = electors, Votes = (int)Math.Round(electors * rates[index] / 100d, MidpointRounding.AwayFromZero), TurnoutRate = rates[index] }); } return entries; } private static IReadOnlyList 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 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); var hdc = graphics.GetHdc(); try { if (PrintWindow(handle, hdc, 0)) { return bitmap; } } finally { graphics.ReleaseHdc(hdc); } 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,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 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 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; 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 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 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; } } }