This commit is contained in:
2026-05-02 05:35:16 +09:00
parent 57aeba4bb8
commit e40a2a568e
36 changed files with 3198 additions and 411 deletions

View File

@@ -431,6 +431,8 @@ internal sealed class CatalogEventHandler : KAEventHandler
internal sealed class CatalogOptions
{
private const string FixedT3CutPath = @"D:\Elect2026\T3_Cut";
public string Host { get; private set; }
public int Port { get; private set; }
public TimeSpan Timeout { get; private set; }
@@ -444,11 +446,7 @@ internal sealed class CatalogOptions
Host = "127.0.0.1";
Port = 30001;
Timeout = TimeSpan.FromSeconds(5);
RootPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"Tornado3 Data",
"T3_Cut",
"T3_Cut");
RootPath = FixedT3CutPath;
OutputPath = Path.Combine(Environment.CurrentDirectory, "SCENE_OBJECT_CATALOG.md");
SceneFilter = string.Empty;
MaxScenes = 0;
@@ -474,7 +472,7 @@ internal sealed class CatalogOptions
index++;
break;
case "--root" when index + 1 < args.Length:
options.RootPath = Path.GetFullPath(args[++index]);
index++;
break;
case "--output" when index + 1 < args.Length:
options.OutputPath = Path.GetFullPath(args[++index]);

View File

@@ -58,7 +58,7 @@ internal static class CurrentApiCutDiagnostics
Console.WriteLine($"- Station: {(options.AllStations ? "ALL" : options.StationId)}");
Console.WriteLine($"- Region Scope: {options.RegionScope}");
Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}");
Console.WriteLine($"- Simulated Sends: {(options.SimulateSend ? options.SendLimit.ToString() : "off")}");
Console.WriteLine($"- Send Mode: {ResolveSendModeLabel(options)}");
Console.WriteLine($"- Output: {options.OutputPath}");
var stationCatalog = new StationCatalogService().GetAll();
@@ -80,6 +80,9 @@ internal static class CurrentApiCutDiagnostics
.Where(template => string.IsNullOrWhiteSpace(options.Filter) ||
template.Id.Contains(options.Filter, StringComparison.OrdinalIgnoreCase) ||
template.Name.Contains(options.Filter, StringComparison.OrdinalIgnoreCase))
.Where(template => string.IsNullOrWhiteSpace(options.ExcludeFilter) ||
(!template.Id.Contains(options.ExcludeFilter, StringComparison.OrdinalIgnoreCase) &&
!template.Name.Contains(options.ExcludeFilter, StringComparison.OrdinalIgnoreCase)))
.ToArray();
if (options.TemplateLimit is int templateLimit && templateLimit > 0)
@@ -99,7 +102,17 @@ internal static class CurrentApiCutDiagnostics
using var apiClient = new SbsElectionApiClient();
var logService = new LogService();
var adapter = options.SimulateSend ? new MockTornado3Adapter(logService) : null;
var preElectionHistoryService = new PreElectionHistoryService(logService);
ITornado3Adapter? adapter;
try
{
adapter = CreateSendAdapter(options, logService);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
return 1;
}
var districtCache = new Dictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>>(StringComparer.Ordinal);
var results = new List<CurrentApiCutDiagnosticResult>();
var simulatedSendCount = 0;
@@ -109,7 +122,17 @@ internal static class CurrentApiCutDiagnostics
foreach (var template in formats)
{
var electionType = ResolveScheduleElectionType(template.Name, phase, options.DefaultElectionType);
var districts = await GetDistrictsAsync(apiClient, districtCache, electionType).ConfigureAwait(false);
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts;
try
{
districts = await GetDistrictsAsync(apiClient, districtCache, electionType, station).ConfigureAwait(false);
}
catch (Exception ex)
{
results.Add(CurrentApiCutDiagnosticResult.DistrictLoadFailed(station, template, phase, electionType, ex.Message));
continue;
}
var targets = ResolveTargets(districts, station, options)
.ToArray();
@@ -136,11 +159,24 @@ internal static class CurrentApiCutDiagnostics
try
{
var refreshResult = await apiClient
.RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
.ConfigureAwait(false);
var snapshot = CreateSnapshot(phase, electionType, refreshResult);
PopulateDataFields(result, snapshot, refreshResult.SourcePath);
ElectionDataSnapshot snapshot;
if (UsesStoredPreElectionHistory(template))
{
snapshot = CreateStoredPreElectionHistorySnapshot(
phase,
electionType,
target,
preElectionHistoryService);
PopulateDataFields(result, snapshot, "stored pre-election history");
}
else
{
var refreshResult = await apiClient
.RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
.ConfigureAwait(false);
snapshot = CreateSnapshot(phase, electionType, refreshResult);
PopulateDataFields(result, snapshot, refreshResult.SourcePath);
}
if (!ValidateSnapshotForFormat(template, snapshot, out var validationError, out var warning))
{
@@ -152,8 +188,10 @@ internal static class CurrentApiCutDiagnostics
{
await SimulateSendAsync(adapter, station, template, snapshot, options.ImageRootPath).ConfigureAwait(false);
simulatedSendCount++;
result.Status = "sent-mock";
result.Detail = "validated and mock send completed";
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
result.Detail = options.LiveSend
? "validated and live send completed"
: "validated and mock send completed";
result.Warning = warning;
}
else
@@ -174,6 +212,11 @@ internal static class CurrentApiCutDiagnostics
}
}
if (adapter is IDisposable disposable)
{
disposable.Dispose();
}
WriteReports(options, results);
PrintSummary(results, options.OutputPath);
@@ -182,15 +225,53 @@ internal static class CurrentApiCutDiagnostics
: 0;
}
private static ITornado3Adapter? CreateSendAdapter(CurrentApiCutDiagnosticsOptions options, LogService logService)
{
if (!options.SimulateSend)
{
return null;
}
if (!options.LiveSend)
{
return new MockTornado3Adapter(logService);
}
var cutDebugStateStore = new CutDebugStateStore();
if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) ||
!adapter.IsLiveCg)
{
throw new InvalidOperationException("Karisma adapter is not available. Live send cannot continue.");
}
return adapter;
}
private static string ResolveSendModeLabel(CurrentApiCutDiagnosticsOptions options)
{
if (!options.SimulateSend)
{
return "off";
}
return options.LiveSend
? $"live ({options.SendLimit})"
: $"mock ({options.SendLimit})";
}
private static async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetDistrictsAsync(
SbsElectionApiClient apiClient,
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
string electionType)
string electionType,
BroadcastStationProfile station)
{
if (!districtCache.TryGetValue(electionType, out var districts))
var cacheKey = $"{electionType}|{string.Join(",", station.RegionFilters)}";
if (!districtCache.TryGetValue(cacheKey, out var districts))
{
districts = await apiClient.GetDistrictOptionsAsync(electionType, CancellationToken.None).ConfigureAwait(false);
districtCache[electionType] = districts;
districts = await apiClient
.GetDistrictOptionsAsync(electionType, station.RegionFilters, CancellationToken.None)
.ConfigureAwait(false);
districtCache[cacheKey] = districts;
}
return districts;
@@ -265,6 +346,42 @@ internal static class CurrentApiCutDiagnostics
};
}
private static ElectionDataSnapshot CreateStoredPreElectionHistorySnapshot(
BroadcastPhase phase,
string electionType,
SbsElectionApiClient.DistrictSelectionOption target,
PreElectionHistoryService preElectionHistoryService)
{
var regionName = target.RegionName ?? string.Empty;
var districtName = !string.IsNullOrWhiteSpace(target.DistrictName)
? target.DistrictName
: !string.IsNullOrWhiteSpace(target.DisplayName)
? target.DisplayName
: regionName;
var history = preElectionHistoryService.ResolveHistory(electionType, regionName, districtName);
return new ElectionDataSnapshot
{
BroadcastPhase = phase,
ElectionType = electionType,
DistrictName = districtName,
DistrictCode = target.DistrictCode,
RegionName = regionName,
ElectionDistrictName = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
Candidates = Array.Empty<CandidateEntry>(),
TotalExpectedVotes = 0,
TurnoutVotes = 0,
CountedVotesFromApi = null,
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = DateTimeOffset.Now,
HistoricalTurnoutHistory = history?.TurnoutHistory.OrderBy(entry => entry.Year).ToArray()
?? Array.Empty<PreElectionHistoricalTurnoutEntry>(),
HistoricalWinnerHistory = history?.WinnerHistory.OrderBy(entry => entry.ElectionOrder).ToArray()
?? Array.Empty<PreElectionHistoricalWinnerEntry>()
};
}
private static async Task SimulateSendAsync(
ITornado3Adapter adapter,
BroadcastStationProfile station,
@@ -274,11 +391,84 @@ internal static class CurrentApiCutDiagnostics
{
foreach (var cut in template.Cuts)
{
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
Exception? lastException = null;
for (var attempt = 1; attempt <= 3; attempt++)
{
try
{
await SendSingleCutAsync(adapter, station, template, cut, snapshot, imageRootPath).ConfigureAwait(false);
lastException = null;
break;
}
catch (Exception ex) when (attempt < 3)
{
lastException = ex;
await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false);
await Task.Delay(750, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
lastException = ex;
}
}
if (lastException is not null)
{
throw lastException;
}
}
}
private static async Task SendSingleCutAsync(
ITornado3Adapter adapter,
BroadcastStationProfile station,
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
string imageRootPath)
{
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "connect");
try
{
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, imageRootPath, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "apply");
await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "prepare");
await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
await adapter.OutAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "take");
}
finally
{
await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false);
if (adapter.IsLiveCg)
{
await Task.Delay(250, CancellationToken.None).ConfigureAwait(false);
}
}
}
private static async Task TryOutAsync(ITornado3Adapter adapter, BroadcastChannel channel)
{
try
{
await adapter.OutAsync(channel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "out");
}
catch
{
if (!adapter.IsLiveCg)
{
throw;
}
}
}
private static void ThrowIfAdapterErrored(ITornado3Adapter adapter, string action)
{
if (adapter.State == TornadoConnectionState.Error)
{
throw new InvalidOperationException($"Karisma live send failed during {action}.");
}
}
@@ -290,6 +480,30 @@ internal static class CurrentApiCutDiagnostics
{
warning = string.Empty;
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
{
if (snapshot.HistoricalTurnoutHistory.Count == 0)
{
errorMessage = "historical turnout data is empty";
return false;
}
errorMessage = string.Empty;
return true;
}
if (ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name))
{
if (snapshot.HistoricalWinnerHistory.Count == 0)
{
errorMessage = "historical winner data is empty";
return false;
}
errorMessage = string.Empty;
return true;
}
if (IsTurnoutTemplate(template) &&
(snapshot.TurnoutVotes <= 0 || snapshot.TurnoutRate <= 0))
{
@@ -355,6 +569,12 @@ internal static class CurrentApiCutDiagnostics
return true;
}
private static bool UsesStoredPreElectionHistory(FormatTemplateDefinition template)
{
return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) ||
ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name);
}
private static string JoinWarning(string current, string next)
{
if (string.IsNullOrWhiteSpace(current))
@@ -443,15 +663,27 @@ internal static class CurrentApiCutDiagnostics
return "교육감";
}
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal) ||
resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
if (resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
{
return "기초의원";
}
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal))
{
return "기초단체장";
}
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal) ||
resolvedFormatName.Contains("광역의원", StringComparison.Ordinal) ||
resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
if (resolvedFormatName.Contains("광역의원", StringComparison.Ordinal))
{
return "광역의원";
}
if (resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
{
return "국회의원";
}
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal))
{
return "광역단체장";
}
@@ -578,8 +810,12 @@ internal static class CurrentApiCutDiagnostics
public string Filter { get; init; } = string.Empty;
public string ExcludeFilter { get; init; } = string.Empty;
public bool SimulateSend { get; init; } = true;
public bool LiveSend { get; init; }
public int SendLimit { get; init; } = 24;
public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath();
@@ -601,9 +837,10 @@ internal static class CurrentApiCutDiagnostics
var includeVideoWall = false;
int? templateLimit = null;
var filter = string.Empty;
var excludeFilter = string.Empty;
var simulateSend = true;
var liveSend = false;
var sendLimit = 24;
var imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
var outputPath = Path.Combine(
"artifacts",
"current-api-cut-diagnostics",
@@ -644,8 +881,16 @@ internal static class CurrentApiCutDiagnostics
case "--filter":
filter = NextValue();
break;
case "--exclude-filter":
excludeFilter = NextValue();
break;
case "--no-send":
simulateSend = false;
liveSend = false;
break;
case "--live-send":
simulateSend = true;
liveSend = true;
break;
case "--send-limit":
if (int.TryParse(NextValue(), out var parsedSendLimit))
@@ -654,7 +899,7 @@ internal static class CurrentApiCutDiagnostics
}
break;
case "--image-root":
imageRootPath = TornadoPathResolver.NormalizeConfiguredPath(NextValue());
_ = NextValue();
break;
case "--output":
outputPath = NextValue();
@@ -675,9 +920,11 @@ internal static class CurrentApiCutDiagnostics
IncludeVideoWall = includeVideoWall,
TemplateLimit = templateLimit,
Filter = filter,
ExcludeFilter = excludeFilter,
SimulateSend = simulateSend,
LiveSend = liveSend,
SendLimit = sendLimit,
ImageRootPath = imageRootPath,
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
OutputPath = outputPath,
DefaultElectionType = defaultElectionType
};
@@ -751,5 +998,25 @@ internal static class CurrentApiCutDiagnostics
Detail = "no matching schedule regions"
};
}
public static CurrentApiCutDiagnosticResult DistrictLoadFailed(
BroadcastStationProfile station,
FormatTemplateDefinition template,
BroadcastPhase phase,
string electionType,
string detail)
{
return new CurrentApiCutDiagnosticResult
{
Station = station.Id,
Channel = template.RecommendedChannel.ToString(),
TemplateId = template.Id,
TemplateName = template.Name,
Phase = phase.ToString(),
ElectionType = electionType,
Status = "api-or-send-failed",
Detail = detail
};
}
}
}

View File

@@ -41,8 +41,10 @@
<Compile Include="..\..\Tornado3_2026Election\Services\CutAppearancePolicyCatalog.cs" Link="AppSource\Services\CutAppearancePolicyCatalog.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\FormatCatalogService.cs" Link="AppSource\Services\FormatCatalogService.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaChartCellUpdate.cs" Link="AppSource\Services\KarismaChartCellUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaPositionUpdate.cs" Link="AppSource\Services\KarismaPositionUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolutionReader.cs" Link="AppSource\Services\KarismaSceneResolutionReader.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolver.cs" Link="AppSource\Services\KarismaSceneResolver.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneVariableCatalog.cs" Link="AppSource\Services\KarismaSceneVariableCatalog.cs" />
@@ -52,6 +54,8 @@
<Compile Include="..\..\Tornado3_2026Election\Services\LogService.cs" Link="AppSource\Services\LogService.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\MockTornado3Adapter.cs" Link="AppSource\Services\MockTornado3Adapter.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\PartyColorCatalog.cs" Link="AppSource\Services\PartyColorCatalog.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\PreElectionHistoryService.cs" Link="AppSource\Services\PreElectionHistoryService.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\ScheduleTemplatePolicy.cs" Link="AppSource\Services\ScheduleTemplatePolicy.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\SbsElectionApiClient.cs" Link="AppSource\Services\SbsElectionApiClient.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\StationCatalogService.cs" Link="AppSource\Services\StationCatalogService.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\TornadoManager.cs" Link="AppSource\Services\TornadoManager.cs" />

View File

@@ -19,6 +19,7 @@ internal static class LiveCutValidation
Console.WriteLine($"- Image Root: {options.ImageRootPath}");
Console.WriteLine($"- Output: {options.OutputPath}");
Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}");
Console.WriteLine($"- Capture Mode: {options.CaptureMode}");
var logService = new LogService();
var cutDebugStateStore = new CutDebugStateStore();
@@ -70,6 +71,9 @@ internal static class LiveCutValidation
CutName = item.Cut.Name,
Channel = item.Template.RecommendedChannel.ToString(),
Phase = preElection ? BroadcastPhase.PreElection.ToString() : BroadcastPhase.Counting.ToString(),
CaptureMode = options.CaptureMode.ToString(),
CaptureComparable = options.CaptureMode == LiveCutCaptureMode.Scene ||
(pgmWindow is not null && item.Template.RecommendedChannel != BroadcastChannel.VideoWall),
OutputVisibleInPgm = pgmWindow is not null &&
item.Template.RecommendedChannel != BroadcastChannel.VideoWall
};
@@ -81,8 +85,22 @@ internal static class LiveCutValidation
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);
var snapshotA = CreateSnapshot(
item.Template.Name,
index,
variant: 0,
preElection,
options.SwapTopTwoCandidates,
options.CycleTopThreeCandidates,
options.StressTopRankValues);
var snapshotB = CreateSnapshot(
item.Template.Name,
index,
variant: 1,
preElection,
options.SwapTopTwoCandidates,
options.CycleTopThreeCandidates,
options.StressTopRankValues);
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotA, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
@@ -90,7 +108,14 @@ internal static class LiveCutValidation
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);
result.HashA = await CaptureValidationImageAsync(
adapter,
pgmWindow,
item,
options,
result.CaptureAPath,
result.OutputVisibleInPgm,
CancellationToken.None).ConfigureAwait(false);
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotB, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
@@ -98,11 +123,18 @@ internal static class LiveCutValidation
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.HashB = await CaptureValidationImageAsync(
adapter,
pgmWindow,
item,
options,
result.CaptureBPath,
result.OutputVisibleInPgm,
CancellationToken.None).ConfigureAwait(false);
result.VisualChanged = !string.Equals(result.HashA, result.HashB, StringComparison.OrdinalIgnoreCase);
result.Success = true;
result.Detail = result.OutputVisibleInPgm
? (result.VisualChanged ? "A/B capture changed" : "A/B capture hash identical")
result.Detail = result.CaptureComparable
? (result.VisualChanged ? $"{options.CaptureMode} A/B capture changed" : $"{options.CaptureMode} A/B capture hash identical")
: "VideoWall output is not visible in the current PGM window";
}
catch (Exception exception)
@@ -145,7 +177,7 @@ internal static class LiveCutValidation
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 unchangedCount = results.Count(result => result.Success && result.CaptureComparable && !result.VisualChanged);
var failureCount = results.Count(result => !result.Success);
Console.WriteLine();
@@ -753,6 +785,7 @@ internal static class LiveCutValidation
var value when value.StartsWith("개표율", StringComparison.OrdinalIgnoreCase) => 3,
var value when value.StartsWith("선거구명", StringComparison.OrdinalIgnoreCase) => 4,
var value when value.StartsWith("시도명", StringComparison.OrdinalIgnoreCase) => 4,
var value when value.StartsWith("의석수", StringComparison.OrdinalIgnoreCase) => 5,
var value when value.StartsWith("득표수", StringComparison.OrdinalIgnoreCase) => 5,
var value when value.StartsWith("정당명", StringComparison.OrdinalIgnoreCase) => 6,
var value when value.StartsWith("유확당", StringComparison.OrdinalIgnoreCase) => 7,
@@ -898,7 +931,7 @@ internal static class LiveCutValidation
yield return new CutDebugItemDescriptor($"{prefix}01", CutDebugItemKind.TextValue, "common");
}
foreach (var prefix in new[] { "순위", "기호", "기호텍스트", "후보명", "정당명", "득표수", "득표율", "표차", "득표차", "선거구명", "시도명", "개표율", "투표율" })
foreach (var prefix in new[] { "순위", "기호", "기호텍스트", "후보명", "정당명", "의석수", "득표수", "득표율", "표차", "득표차", "선거구명", "시도명", "개표율", "투표율" })
{
for (var slot = 1; slot <= slotCount; slot++)
{
@@ -945,6 +978,11 @@ internal static class LiveCutValidation
private static int ResolveDebugSlotCount(FormatTemplateDefinition template)
{
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return 6;
}
var source = $"{template.Name} {template.Id}";
var topRankMatch = System.Text.RegularExpressions.Regex.Match(source, @"1-(\d+)위");
if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlots))
@@ -1349,7 +1387,14 @@ internal static class LiveCutValidation
templateName.StartsWith("사전_", StringComparison.Ordinal);
}
private static ElectionDataSnapshot CreateSnapshot(string templateName, int index, int variant, bool preElection, bool swapTopTwoCandidates)
private static ElectionDataSnapshot CreateSnapshot(
string templateName,
int index,
int variant,
bool preElection,
bool swapTopTwoCandidates,
bool cycleTopThreeCandidates = false,
bool stressTopRankValues = false)
{
var metadata = BuildScenarioMetadata(templateName, index, variant);
return new ElectionDataSnapshot
@@ -1360,7 +1405,9 @@ internal static class LiveCutValidation
DistrictCode = metadata.DistrictCode,
RegionName = metadata.RegionName,
ElectionDistrictName = metadata.ElectionDistrictName,
Candidates = preElection ? Array.Empty<CandidateEntry>() : CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates),
Candidates = preElection
? Array.Empty<CandidateEntry>()
: CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates, cycleTopThreeCandidates, stressTopRankValues),
TotalExpectedVotes = metadata.TotalExpectedVotes,
TurnoutVotes = metadata.TurnoutVotes,
CountedVotesFromApi = metadata.CountedVotes,
@@ -1374,11 +1421,27 @@ internal static class LiveCutValidation
};
}
private static IReadOnlyList<CandidateEntry> CreateCandidates(string templateName, ScenarioMetadata metadata, int variant, bool swapTopTwoCandidates)
private static IReadOnlyList<CandidateEntry> CreateCandidates(
string templateName,
ScenarioMetadata metadata,
int variant,
bool swapTopTwoCandidates,
bool cycleTopThreeCandidates,
bool stressTopRankValues)
{
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(templateName))
{
return CreateCouncilSeatCandidates(templateName, metadata, variant, stressTopRankValues);
}
var candidateNames = ResolveCandidateNames(templateName);
var parties = ResolveParties(candidateNames.Length);
var shares = ResolveVoteShares(templateName, candidateNames.Length, variant);
if (stressTopRankValues && variant % 2 == 1)
{
shares = StressTopRankShares(shares);
}
var automaticJudgement = ResolveAutomaticJudgement(templateName);
var identityOrder = Enumerable.Range(0, candidateNames.Length).ToArray();
@@ -1387,6 +1450,11 @@ internal static class LiveCutValidation
(identityOrder[0], identityOrder[1]) = (identityOrder[1], identityOrder[0]);
}
if (cycleTopThreeCandidates && variant % 2 == 1)
{
CycleTopCandidateIdentities(identityOrder);
}
var candidates = new List<CandidateEntry>(candidateNames.Length);
for (var index = 0; index < candidateNames.Length; index++)
{
@@ -1408,6 +1476,99 @@ internal static class LiveCutValidation
return candidates;
}
private static IReadOnlyList<CandidateEntry> CreateCouncilSeatCandidates(
string templateName,
ScenarioMetadata metadata,
int variant,
bool stressTopRankValues)
{
var parties = ResolveParties(6);
var totalSeats = ResolveCouncilSeatPattern(templateName, variant, stressTopRankValues)
.Take(parties.Length)
.ToArray();
var proportionalSeats = ResolveCouncilSeatProportionalPattern(templateName, variant, stressTopRankValues)
.Take(parties.Length)
.ToArray();
var candidates = new List<CandidateEntry>(parties.Length * 2);
for (var partyIndex = 0; partyIndex < parties.Length; partyIndex++)
{
var party = parties[partyIndex];
var proportionalCount = Math.Min(totalSeats[partyIndex], proportionalSeats.ElementAtOrDefault(partyIndex));
var districtCount = Math.Max(0, totalSeats[partyIndex] - proportionalCount);
candidates.Add(CreateCouncilSeatSummaryCandidate($"SEAT:D:{partyIndex + 1:00}", party, partyIndex, districtCount));
if (proportionalCount > 0)
{
candidates.Add(CreateCouncilSeatSummaryCandidate($"SEAT:P:{partyIndex + 1:00}", party, partyIndex, proportionalCount));
}
}
return candidates;
}
private static CandidateEntry CreateCouncilSeatSummaryCandidate(string candidateCode, string party, int partyIndex, int seatCount)
{
return new CandidateEntry
{
CandidateCode = candidateCode,
BallotNumber = (partyIndex + 1).ToString(),
Name = party,
Party = party,
ColorParty = party,
VoteRate = seatCount,
VoteCount = seatCount,
HasImage = false,
ManualJudgement = CandidateJudgement.None,
AutomaticJudgement = CandidateJudgement.Elected
};
}
private static int[] ResolveCouncilSeatPattern(string templateName, int variant, bool stressTopRankValues)
{
if (templateName.Contains("기초의원", StringComparison.Ordinal))
{
return stressTopRankValues && variant % 2 == 1
? [34, 21, 13, 7, 3, 1]
: [29, 25, 9, 5, 2, 1];
}
return stressTopRankValues && variant % 2 == 1
? [22, 17, 9, 4, 2, 1]
: [18, 15, 6, 3, 1, 1];
}
private static int[] ResolveCouncilSeatProportionalPattern(string templateName, int variant, bool stressTopRankValues)
{
if (templateName.Contains("기초의원", StringComparison.Ordinal))
{
return stressTopRankValues && variant % 2 == 1
? [5, 3, 2, 1, 0, 0]
: [4, 3, 1, 0, 0, 0];
}
return stressTopRankValues && variant % 2 == 1
? [4, 3, 2, 1, 0, 0]
: [3, 3, 1, 0, 0, 0];
}
private static void CycleTopCandidateIdentities(int[] identityOrder)
{
var topCount = Math.Min(3, identityOrder.Length);
if (topCount < 2)
{
return;
}
var first = identityOrder[0];
for (var index = 0; index < topCount - 1; index++)
{
identityOrder[index] = identityOrder[index + 1];
}
identityOrder[topCount - 1] = first;
}
private static IReadOnlyList<PreElectionHistoricalTurnoutEntry> CreateHistoricalTurnout(ScenarioMetadata metadata, int variant)
{
// The historical turnout scenes currently ship with a fixed line/marker drawing in the source tscn.
@@ -1549,6 +1710,36 @@ internal static class LiveCutValidation
return shares;
}
private static double[] StressTopRankShares(double[] shares)
{
var stressed = shares.ToArray();
if (stressed.Length >= 3)
{
var trailingTotal = Math.Round(stressed.Skip(3).Sum(), 1, MidpointRounding.AwayFromZero);
var topTotal = Math.Max(0d, 100d - trailingTotal);
stressed[0] = Math.Round(topTotal * 0.46d, 1, MidpointRounding.AwayFromZero);
stressed[1] = Math.Round(topTotal * 0.32d, 1, MidpointRounding.AwayFromZero);
stressed[2] = Math.Round(topTotal - stressed[0] - stressed[1], 1, MidpointRounding.AwayFromZero);
}
else if (stressed.Length == 2)
{
stressed[0] = 62.7d;
stressed[1] = 37.3d;
}
else if (stressed.Length == 1)
{
stressed[0] = 99.9d;
}
var delta = Math.Round(100d - stressed.Sum(), 1, MidpointRounding.AwayFromZero);
if (stressed.Length > 0)
{
stressed[0] = Math.Round(stressed[0] + delta, 1, MidpointRounding.AwayFromZero);
}
return stressed;
}
private static CandidateJudgement ResolveAutomaticJudgement(string templateName)
{
if (templateName.Contains("당선", StringComparison.Ordinal))
@@ -1599,6 +1790,11 @@ internal static class LiveCutValidation
return new ScenarioMetadata("광역의원", "44001", "충남 제1선거구", "충남", "충남도의원 제1선거구", totalExpectedVotes / 3, turnoutVotes / 3, countedVotes / 3, countedRate, 55.4 + (seed % 3) + (variant * 1.6), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
}
if (templateName.Contains("보궐선거", StringComparison.Ordinal))
{
return new ScenarioMetadata("국회의원", "2411502", "평택 을", "경기", "평택 을", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 56.5 + (seed % 4) + (variant * 1.5), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
}
return new ScenarioMetadata("광역단체장", "44", "충청남도", "충남", "충남도지사", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 57.3 + (seed % 5) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
}
@@ -1615,6 +1811,57 @@ internal static class LiveCutValidation
return ComputeSha256(outputPath);
}
private static async Task<string> CaptureValidationImageAsync(
ITornado3Adapter adapter,
PgmWindow? pgmWindow,
LiveCutWorkItem item,
LiveCutValidationOptions options,
string outputPath,
bool outputVisibleInPgm,
CancellationToken cancellationToken)
{
if (options.CaptureMode == LiveCutCaptureMode.Pgm)
{
return CapturePgm(pgmWindow, outputPath, !outputVisibleInPgm);
}
if (adapter is not KarismaTornado3Adapter karismaAdapter)
{
throw new InvalidOperationException("Scene capture mode requires the Karisma adapter.");
}
var (width, height) = ResolveSceneCaptureSize(item.Template);
await karismaAdapter.SavePendingSceneImageAsync(
item.Template.RecommendedChannel,
outputPath,
width,
height,
frame: -1,
cancellationToken)
.ConfigureAwait(false);
return ComputeSha256(outputPath);
}
private static (int Width, int Height) ResolveSceneCaptureSize(FormatTemplateDefinition template)
{
var sourceWidth = template.SceneWidth.GetValueOrDefault(1920);
var sourceHeight = template.SceneHeight.GetValueOrDefault(1080);
if (sourceWidth <= 0 || sourceHeight <= 0)
{
return (1280, 720);
}
const int maxWidth = 1280;
if (sourceWidth <= maxWidth)
{
return (sourceWidth, sourceHeight);
}
var scale = maxWidth / (double)sourceWidth;
return (maxWidth, Math.Max(1, (int)Math.Round(sourceHeight * scale, MidpointRounding.AwayFromZero)));
}
private static Bitmap CaptureWindowBitmap(IntPtr handle, Rect bounds)
{
var width = Math.Max(1, bounds.Width);
@@ -1657,7 +1904,7 @@ internal static class LiveCutValidation
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");
csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Phase,CaptureMode,CaptureComparable,Success,VisualChanged,OutputVisibleInPgm,CaptureAPath,CaptureBPath,HashA,HashB,Detail");
foreach (var result in results)
{
csv.AppendLine(string.Join(",",
@@ -1667,6 +1914,8 @@ internal static class LiveCutValidation
Csv(result.CutName),
Csv(result.Channel),
Csv(result.Phase),
Csv(result.CaptureMode),
Csv(result.CaptureComparable.ToString()),
Csv(result.Success.ToString()),
Csv(result.VisualChanged.ToString()),
Csv(result.OutputVisibleInPgm.ToString()),
@@ -1682,7 +1931,7 @@ internal static class LiveCutValidation
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 unchanged = results.Where(result => result.Success && result.CaptureComparable && !result.VisualChanged).ToList();
var failures = results.Where(result => !result.Success).ToList();
var summary = new StringBuilder();
@@ -1691,6 +1940,7 @@ internal static class LiveCutValidation
summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
summary.AppendLine($"- Image Root: {options.ImageRootPath}");
summary.AppendLine($"- Output: {options.OutputPath}");
summary.AppendLine($"- Capture Mode: {options.CaptureMode}");
summary.AppendLine($"- Success: {successCount}/{results.Count}");
summary.AppendLine($"- Visual Changed: {changedCount}");
summary.AppendLine($"- Unchanged Captures: {unchanged.Count}");
@@ -1820,6 +2070,9 @@ internal static class LiveCutValidation
public int BetweenDelayMs { get; init; } = 250;
public bool IncludeVideoWall { get; init; }
public bool SwapTopTwoCandidates { get; init; }
public LiveCutCaptureMode CaptureMode { get; init; } = LiveCutCaptureMode.Pgm;
public bool CycleTopThreeCandidates { get; init; }
public bool StressTopRankValues { get; init; }
public static LiveCutValidationOptions Parse(string[] args)
{
@@ -1836,7 +2089,7 @@ internal static class LiveCutValidation
switch (args[index])
{
case "--image-root":
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
_ = RequireValue(args, ref index, "--image-root");
break;
case "--output":
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
@@ -1859,6 +2112,15 @@ internal static class LiveCutValidation
case "--swap-top-two":
options = options with { SwapTopTwoCandidates = true };
break;
case "--cycle-top-three":
options = options with { CycleTopThreeCandidates = true };
break;
case "--stress-top-ranks":
options = options with { StressTopRankValues = true };
break;
case "--capture-mode":
options = options with { CaptureMode = ParseCaptureMode(RequireValue(args, ref index, "--capture-mode")) };
break;
default:
throw new ArgumentException($"Unknown option: {args[index]}");
}
@@ -1866,7 +2128,7 @@ internal static class LiveCutValidation
return options with
{
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
OutputPath = Path.GetFullPath(options.OutputPath),
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
};
@@ -1882,6 +2144,16 @@ internal static class LiveCutValidation
return args[index];
}
private static LiveCutCaptureMode ParseCaptureMode(string raw)
{
return raw.Trim().ToLowerInvariant() switch
{
"pgm" or "window" => LiveCutCaptureMode.Pgm,
"scene" or "save-scene-image" => LiveCutCaptureMode.Scene,
_ => throw new ArgumentException($"Unknown capture mode: {raw}")
};
}
}
private sealed record CutDebugCoverageOptions
@@ -1908,7 +2180,7 @@ internal static class LiveCutValidation
switch (args[index])
{
case "--image-root":
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
_ = RequireValue(args, ref index, "--image-root");
break;
case "--output":
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
@@ -1935,7 +2207,7 @@ internal static class LiveCutValidation
return options with
{
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
OutputPath = Path.GetFullPath(options.OutputPath)
};
}
@@ -1985,7 +2257,7 @@ internal static class LiveCutValidation
switch (args[index])
{
case "--image-root":
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
_ = RequireValue(args, ref index, "--image-root");
break;
case "--output":
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
@@ -2036,7 +2308,7 @@ internal static class LiveCutValidation
return options with
{
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
OutputPath = Path.GetFullPath(options.OutputPath),
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
};
@@ -2087,6 +2359,12 @@ internal static class LiveCutValidation
Replace
}
private enum LiveCutCaptureMode
{
Pgm,
Scene
}
private readonly record struct CutDebugReplacementAssets(string MagentaPath, string CyanPath);
private sealed class CutDebugSweepResult
@@ -2163,6 +2441,8 @@ internal static class LiveCutValidation
public string CutName { get; init; } = string.Empty;
public string Channel { get; init; } = string.Empty;
public string Phase { get; init; } = string.Empty;
public string CaptureMode { get; set; } = string.Empty;
public bool CaptureComparable { get; set; }
public bool Success { get; set; }
public bool VisualChanged { get; set; }
public bool OutputVisibleInPgm { get; set; }

View File

@@ -5,6 +5,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using KAsyncEngineLib;
using Tornado3_2026Election.Services;
if (args.Length > 0 && string.Equals(args[0], "--reflect-api", StringComparison.OrdinalIgnoreCase))
{
@@ -737,6 +738,70 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
return;
}
if (options.CloneObject is not null)
{
Console.WriteLine(
$"[SAVE-IMAGE] Adding clone source={options.CloneObject.SourceObjectName} " +
$"variable={options.CloneObject.VariableName}...");
var sceneObject = scene.GetObject(options.CloneObject.SourceObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.CloneObject.SourceObjectName}' was not found."));
return;
}
handler.ResetAddCloneObjectTask();
scene.AddCloneObject(sceneObject, options.CloneObject.VariableName);
if (!WaitForTaskWithMessagePump(handler.AddCloneObjectTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnAddCloneObject timed out for '{options.CloneObject.SourceObjectName}'." ));
return;
}
var cloneResult = handler.AddCloneObjectTask.Result;
if (cloneResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", cloneResult.ToString(), options.OutputPath, $"OnAddCloneObject result={cloneResult} source={options.CloneObject.SourceObjectName} variable={options.CloneObject.VariableName}"));
return;
}
}
if (options.VariableName is not null)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting variable name object={options.VariableName.ObjectName} " +
$"value={options.VariableName.VariableName}...");
var sceneObject = scene.GetObject(options.VariableName.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.VariableName.ObjectName}' was not found."));
return;
}
handler.ResetVariableNameTask();
sceneObject.SetVariableName(options.VariableName.VariableName);
if (!WaitForTaskWithMessagePump(handler.VariableNameTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetVariableName timed out for '{options.VariableName.ObjectName}'." ));
return;
}
var variableNameResult = handler.VariableNameTask.Result;
if (variableNameResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", variableNameResult.ToString(), options.OutputPath, $"OnSetVariableName result={variableNameResult} object={options.VariableName.ObjectName}"));
return;
}
}
if (!string.IsNullOrWhiteSpace(options.SetObjectName))
{
Console.WriteLine($"[SAVE-IMAGE] Setting value object={options.SetObjectName}...");
@@ -797,6 +862,46 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
if (options.MaterialOpacity is not null)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting material opacity object={options.MaterialOpacity.ObjectName} " +
$"value={options.MaterialOpacity.Opacity}...");
var sceneObject = scene.GetObject(options.MaterialOpacity.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.MaterialOpacity.ObjectName}' was not found."));
return;
}
var material = sceneObject.GetTargetMaterial(eKMaterialTarget.MATERIAL_TARGET_DEFAULT);
if (material is not IKAMaterial targetMaterial)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.MaterialOpacity.ObjectName}' did not return a material."));
return;
}
handler.ResetMaterialOpacityTask();
targetMaterial.SetTransparencyOpacity(options.MaterialOpacity.Opacity);
if (!WaitForTaskWithMessagePump(handler.MaterialOpacityTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetTransparencyOpacity timed out for '{options.MaterialOpacity.ObjectName}'." ));
return;
}
var opacityResult = handler.MaterialOpacityTask.Result;
if (opacityResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", opacityResult.ToString(), options.OutputPath, $"OnSetTransparencyOpacity result={opacityResult} object={options.MaterialOpacity.ObjectName}"));
return;
}
}
if (options.Size is not null)
{
Console.WriteLine(
@@ -828,30 +933,37 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
var positionUpdates = new List<PositionUpdate>();
if (options.Position is not null)
{
positionUpdates.Add(options.Position);
}
positionUpdates.AddRange(options.Positions);
foreach (var positionUpdate in positionUpdates)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting position object={options.Position.ObjectName} " +
$"value=({options.Position.X},{options.Position.Y},{options.Position.Z}) vector={options.Position.VectorType}...");
var sceneObject = scene.GetObject(options.Position.ObjectName);
$"[SAVE-IMAGE] Setting position object={positionUpdate.ObjectName} " +
$"value=({positionUpdate.X},{positionUpdate.Y},{positionUpdate.Z}) vector={positionUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.Position.ObjectName}' was not found."));
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionUpdate.ObjectName}' was not found."));
return;
}
handler.ResetPositionTask();
sceneObject.SetPosition(
options.Position.X,
options.Position.Y,
options.Position.Z,
options.Position.VectorType);
positionUpdate.X,
positionUpdate.Y,
positionUpdate.Z,
positionUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{options.Position.ObjectName}'." ));
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{positionUpdate.ObjectName}'." ));
return;
}
@@ -859,7 +971,7 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
if (positionResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={options.Position.ObjectName}"));
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={positionUpdate.ObjectName}"));
return;
}
}
@@ -3279,8 +3391,12 @@ internal sealed record SaveSceneImageOptions(
string? SetObjectValue,
string? VisibleObjectName,
bool? VisibleObjectValue,
VariableNameUpdate? VariableName,
CloneObjectUpdate? CloneObject,
MaterialOpacityUpdate? MaterialOpacity,
SizeUpdate? Size,
PositionUpdate? Position,
IReadOnlyList<PositionUpdate> Positions,
PositionKeyUpdate? PositionKey,
string? ChartObjectName,
string? ChartCsvPath,
@@ -3299,10 +3415,17 @@ internal sealed record SaveSceneImageOptions(
string? setObjectValue = null;
string? visibleObjectName = null;
bool? visibleObjectValue = null;
string? variableNameObjectName = null;
string? variableNameValue = null;
string? cloneSourceObjectName = null;
string? cloneVariableName = null;
string? materialOpacityObjectName = null;
float? materialOpacityValue = null;
string? sizeObjectName = null;
string? sizeRaw = null;
string? positionObjectName = null;
string? positionRaw = null;
string? positionsRaw = null;
string? positionKeyObjectName = null;
int positionKeyIndex = 1;
string? positionKeyRaw = null;
@@ -3346,6 +3469,25 @@ internal sealed record SaveSceneImageOptions(
_ => throw new ArgumentException("--visible must be true/false/1/0.")
};
break;
case "--variable-name-object" when index + 1 < args.Length:
variableNameObjectName = args[++index];
break;
case "--variable-name" when index + 1 < args.Length:
variableNameValue = args[++index];
break;
case "--clone-source" when index + 1 < args.Length:
cloneSourceObjectName = args[++index];
break;
case "--clone-name" when index + 1 < args.Length:
cloneVariableName = args[++index];
break;
case "--material-opacity-object" when index + 1 < args.Length:
materialOpacityObjectName = args[++index];
break;
case "--material-opacity" when index + 1 < args.Length && float.TryParse(args[index + 1], out var parsedMaterialOpacity):
materialOpacityValue = parsedMaterialOpacity;
index++;
break;
case "--size-object" when index + 1 < args.Length:
sizeObjectName = args[++index];
break;
@@ -3358,6 +3500,9 @@ internal sealed record SaveSceneImageOptions(
case "--position" when index + 1 < args.Length:
positionRaw = args[++index];
break;
case "--positions" when index + 1 < args.Length:
positionsRaw = args[++index];
break;
case "--position-key-object" when index + 1 < args.Length:
positionKeyObjectName = args[++index];
break;
@@ -3426,8 +3571,12 @@ internal sealed record SaveSceneImageOptions(
setObjectValue,
visibleObjectName,
visibleObjectValue,
ParseVariableName(variableNameObjectName, variableNameValue),
ParseCloneObject(cloneSourceObjectName, cloneVariableName),
ParseMaterialOpacity(materialOpacityObjectName, materialOpacityValue),
ParseSize(sizeObjectName, sizeRaw),
ParsePosition(positionObjectName, positionRaw),
ParsePositions(positionsRaw),
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
chartObjectName,
chartCsvPath,
@@ -3437,6 +3586,36 @@ internal sealed record SaveSceneImageOptions(
ParsePathModifications(modifyPathRaw));
}
private static CloneObjectUpdate? ParseCloneObject(string? sourceObjectName, string? variableName)
{
if (string.IsNullOrWhiteSpace(sourceObjectName) || string.IsNullOrWhiteSpace(variableName))
{
return null;
}
return new CloneObjectUpdate(sourceObjectName, variableName);
}
private static MaterialOpacityUpdate? ParseMaterialOpacity(string? objectName, float? opacity)
{
if (string.IsNullOrWhiteSpace(objectName) || !opacity.HasValue)
{
return null;
}
return new MaterialOpacityUpdate(objectName, opacity.Value);
}
private static VariableNameUpdate? ParseVariableName(string? objectName, string? variableName)
{
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(variableName))
{
return null;
}
return new VariableNameUpdate(objectName, variableName);
}
private static SizeUpdate? ParseSize(string? objectName, string? raw)
{
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
@@ -3487,6 +3666,32 @@ internal sealed record SaveSceneImageOptions(
return new PositionUpdate(objectName, x, y, z, vectorType);
}
private static IReadOnlyList<PositionUpdate> ParsePositions(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return Array.Empty<PositionUpdate>();
}
var updates = new List<PositionUpdate>();
foreach (var token in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var nameParts = token.Split('=', 2, StringSplitOptions.TrimEntries);
if (nameParts.Length != 2)
{
throw new ArgumentException($"Invalid position update: {token}");
}
var update = ParsePosition(nameParts[0], nameParts[1]);
if (update is not null)
{
updates.Add(update);
}
}
return updates;
}
private static PositionKeyUpdate? ParsePositionKey(string? objectName, int keyIndex, string? raw)
{
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
@@ -3642,7 +3847,7 @@ internal sealed record SceneCatalogOptions(
public static SceneCatalogOptions Parse(string[] args)
{
var connection = ProbeOptions.Parse(args);
string? rootPath = null;
var rootPath = TornadoPathResolver.GetDefaultT3CutPath();
string? outputPath = null;
string? sceneFilter = null;
int? maxScenes = null;
@@ -3652,7 +3857,7 @@ internal sealed record SceneCatalogOptions(
switch (args[index])
{
case "--root" when index + 1 < args.Length:
rootPath = args[++index];
index++;
break;
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
@@ -3667,12 +3872,6 @@ internal sealed record SceneCatalogOptions(
}
}
rootPath ??= Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"Tornado3 Data",
"T3_Cut",
"T3_Cut");
if (!Directory.Exists(rootPath))
{
throw new DirectoryNotFoundException($"Catalog root path does not exist: {rootPath}");
@@ -3986,7 +4185,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
public static FolderInspectionOptions Parse(string[] args)
{
var connection = ProbeOptions.Parse(args);
string? rootPath = null;
var rootPath = TornadoPathResolver.GetDefaultT3CutPath();
string? outputPath = null;
string? sceneFilter = null;
int? maxScenes = null;
@@ -3996,7 +4195,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
switch (args[index])
{
case "--root" when index + 1 < args.Length:
rootPath = args[++index];
index++;
break;
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
@@ -4011,11 +4210,6 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
}
}
if (string.IsNullOrWhiteSpace(rootPath))
{
throw new ArgumentException("--root is required.");
}
rootPath = Path.GetFullPath(rootPath);
outputPath ??= Path.Combine(Environment.CurrentDirectory, "TSCN_VARIABLE_DISCOVERY.md");
outputPath = Path.GetFullPath(outputPath);
@@ -4029,6 +4223,9 @@ internal sealed record ChartCellSnapshot(int Row, int Column, string Value);
internal sealed record ChartCellUpdate(int Row, int Column, float Value);
internal sealed record PathPoint3(float X, float Y, float Z);
internal sealed record SizeUpdate(string ObjectName, float Width, float Height);
internal sealed record VariableNameUpdate(string ObjectName, string VariableName);
internal sealed record CloneObjectUpdate(string SourceObjectName, string VariableName);
internal sealed record MaterialOpacityUpdate(string ObjectName, float Opacity);
internal sealed record PositionUpdate(string ObjectName, float X, float Y, float Z, eKVectorType VectorType);
internal sealed record PositionKeyUpdate(string ObjectName, int KeyIndex, float X, float Y, float Z, eKVectorType VectorType);
internal sealed record PathPointModification(int Index, float X, float Y, float Z, eKVectorType VectorType);
@@ -4176,6 +4373,9 @@ internal sealed class ProbeEventHandler : KAEventHandler
private TaskCompletionSource<eKResult> _counterNumberKeyTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _styleColorTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _visibleTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _variableNameTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _addCloneObjectTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _materialOpacityTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _setValueTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _sizeTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource<eKResult> _saveSceneImageTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -4203,6 +4403,12 @@ internal sealed class ProbeEventHandler : KAEventHandler
public Task<eKResult> VisibleTask => _visibleTask.Task;
public Task<eKResult> VariableNameTask => _variableNameTask.Task;
public Task<eKResult> AddCloneObjectTask => _addCloneObjectTask.Task;
public Task<eKResult> MaterialOpacityTask => _materialOpacityTask.Task;
public Task<eKResult> SetValueTask => _setValueTask.Task;
public Task<eKResult> SizeTask => _sizeTask.Task;
@@ -4235,6 +4441,12 @@ internal sealed class ProbeEventHandler : KAEventHandler
public void ResetVisibleTask() => _visibleTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetVariableNameTask() => _variableNameTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetAddCloneObjectTask() => _addCloneObjectTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetMaterialOpacityTask() => _materialOpacityTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetSetValueTask() => _setValueTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
public void ResetSizeTask() => _sizeTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -4363,7 +4575,6 @@ internal sealed class ProbeEventHandler : KAEventHandler
public void OnResetDuration(eKResult Result, string SceneName) { }
public void OnSetDuration(eKResult Result, string SceneName) { }
public void OnAddObject(eKResult Result, string SceneName) { }
public void OnAddCloneObject(eKResult Result, string SceneName) { }
public void OnUpdateThumbnail(eKResult Result, string SceneName) { }
public void OnExportVideo(eKResult Result, string SceneName) { }
public void OnStopVideoExporting(eKResult Result) { }
@@ -4462,6 +4673,21 @@ internal sealed class ProbeEventHandler : KAEventHandler
_visibleTask.TrySetResult(Result);
}
public void OnSetVariableName(eKResult Result, string SceneName, string ObjectName)
{
Console.WriteLine($"[SDK] OnSetVariableName result={Result} scene={SceneName} object={ObjectName}");
_variableNameTask.TrySetResult(Result);
}
public void OnAddCloneObject(eKResult Result, string SceneName)
{
Console.WriteLine($"[SDK] OnAddCloneObject result={Result} scene={SceneName}");
_addCloneObjectTask.TrySetResult(Result);
}
public void OnSetTransparencyOpacity(eKResult Result, string SceneName, string ObjectName)
{
Console.WriteLine($"[SDK] OnSetTransparencyOpacity result={Result} scene={SceneName} object={ObjectName}");
_materialOpacityTask.TrySetResult(Result);
}
public void OnSetValue(eKResult Result, string SceneName, string ObjectName)
{
if (Result != eKResult.RESULT_ERROR_NO_VARIABLE_OBJECT)
@@ -4573,7 +4799,6 @@ internal sealed class ProbeEventHandler : KAEventHandler
public void OnAddScrollObject(eKResult Result, string SceneName, string ObjectName) { }
public void OnAdjustScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetVariableName(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetLoftPositionKey(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetChangeOut(eKResult Result, string SceneName) { }
public void OnModifyPathPoint(eKResult Result, string SceneName, string ObjectName)
@@ -4596,7 +4821,6 @@ internal sealed class ProbeEventHandler : KAEventHandler
public void OnSetColorKey(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetEmissiveColor(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetEmissiveColorKey(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetTransparencyOpacity(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetTransparencyOpacityKey(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetExposure(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetExposureKey(eKResult Result, string SceneName, string ObjectName) { }