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