5.14 시작전

This commit is contained in:
2026-05-14 09:38:45 +09:00
parent 8b5c92194f
commit e76c37ef56
24 changed files with 3638 additions and 717 deletions

View File

@@ -155,7 +155,9 @@ internal static class CurrentApiCutDiagnostics
districtCache,
electionType,
station,
options.RegionScope == "all" || IsNormalPanseMapTemplate(template))
options.RegionScope == "all" || IsNormalPanseMapTemplate(template),
template,
preElectionHistoryService)
.ConfigureAwait(false);
}
catch (Exception ex)
@@ -620,6 +622,10 @@ internal static class CurrentApiCutDiagnostics
? overview.TurnoutVotes
: primaryItem?.TurnoutVotes ?? 0;
var snapshotReferenceTimeLabel = includeNationalSlot
? overview.ReferenceTimeLabel
: FirstNonWhiteSpace(primaryItem?.ReferenceTimeLabel, overview.ReferenceTimeLabel);
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase.PreElection,
@@ -634,7 +640,8 @@ internal static class CurrentApiCutDiagnostics
CountedVotesFromApi = null,
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = DateTimeOffset.Now,
ReceivedAt = overview.ReceivedAt == default ? DateTimeOffset.Now : overview.ReceivedAt,
ReferenceTimeLabel = snapshotReferenceTimeLabel,
TurnoutBoardSlots = turnoutBoardSlots,
NationalTurnoutRateOverride = overview.NationalTurnoutRate
};
@@ -1104,6 +1111,7 @@ internal static class CurrentApiCutDiagnostics
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = overview.ReceivedAt == default ? DateTimeOffset.Now : overview.ReceivedAt,
ReferenceTimeLabel = FirstNonWhiteSpace(item.ReferenceTimeLabel, overview.ReferenceTimeLabel),
NationalTurnoutRateOverride = overview.NationalTurnoutRate
};
}
@@ -1318,8 +1326,15 @@ internal static class CurrentApiCutDiagnostics
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
string electionType,
BroadcastStationProfile station,
bool useAllRegions = false)
bool useAllRegions,
FormatTemplateDefinition template,
PreElectionHistoryService preElectionHistoryService)
{
if (UsesHistoricalStoredOptions(template))
{
return GetStoredHistoryDistricts(electionType, preElectionHistoryService);
}
var regionFilters = useAllRegions ? Array.Empty<string>() : station.RegionFilters;
var cacheKey = $"{electionType}|{string.Join(",", regionFilters)}";
if (!districtCache.TryGetValue(cacheKey, out var districts))
@@ -1333,6 +1348,59 @@ internal static class CurrentApiCutDiagnostics
return districts;
}
private static IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> GetStoredHistoryDistricts(
string electionType,
PreElectionHistoryService preElectionHistoryService)
{
return preElectionHistoryService
.GetSelectionRecords(electionType)
.Where(record => !string.IsNullOrWhiteSpace(ResolveStoredHistoryDisplayName(record)))
.OrderBy(record => SbsElectionApiClient.ResolveBasicApiSidoCode(record.RegionName), StringComparer.Ordinal)
.ThenBy(record => ResolveStoredHistoryDisplayName(record), StringComparer.Ordinal)
.Select(CreateStoredHistoryDistrict)
.ToArray();
}
private static SbsElectionApiClient.DistrictSelectionOption CreateStoredHistoryDistrict(
PreElectionHistoryRecord record)
{
var regionName = string.IsNullOrWhiteSpace(record.RegionName)
? record.DisplayName
: record.RegionName;
var districtName = string.IsNullOrWhiteSpace(record.DistrictName)
? ResolveStoredHistoryDisplayName(record)
: record.DistrictName;
var parentRegionCode = SbsElectionApiClient.ResolveBasicApiSidoCode(regionName);
var districtCode = string.Equals(
PreElectionHistoryService.NormalizeElectionType(record.ElectionType),
"기초단체장",
StringComparison.Ordinal)
? record.Key
: parentRegionCode;
return new SbsElectionApiClient.DistrictSelectionOption(
ResolveStoredHistoryDisplayName(record),
districtCode,
regionName,
districtName,
parentRegionCode);
}
private static string ResolveStoredHistoryDisplayName(PreElectionHistoryRecord record)
{
if (!string.IsNullOrWhiteSpace(record.DisplayName))
{
return record.DisplayName;
}
if (!string.IsNullOrWhiteSpace(record.DistrictName))
{
return record.DistrictName;
}
return record.RegionName ?? string.Empty;
}
private static IEnumerable<SbsElectionApiClient.DistrictSelectionOption> ResolveTargets(
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts,
BroadcastStationProfile station,
@@ -1398,7 +1466,8 @@ internal static class CurrentApiCutDiagnostics
CountedVotesFromApi = refreshResult.CountedVotes,
RemainingVotesFromApi = refreshResult.RemainingVotes,
CountedRateFromApi = refreshResult.CountedRate,
ReceivedAt = refreshResult.ReceivedAt == default ? DateTimeOffset.Now : refreshResult.ReceivedAt
ReceivedAt = refreshResult.ReceivedAt == default ? DateTimeOffset.Now : refreshResult.ReceivedAt,
ReferenceTimeLabel = refreshResult.ReferenceTimeLabel
};
}
@@ -2297,12 +2366,22 @@ internal static class CurrentApiCutDiagnostics
return "기초단체장";
}
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
{
return "광역단체장";
}
return ResolveScheduleElectionType(template.Name, phase, defaultElectionType);
}
private static string ResolveScheduleElectionType(string? formatName, BroadcastPhase phase, string defaultElectionType)
{
var resolvedFormatName = formatName ?? string.Empty;
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(resolvedFormatName))
{
return "광역단체장";
}
if (resolvedFormatName.Contains("교육감", StringComparison.Ordinal))
{
return "교육감";
@@ -2343,6 +2422,12 @@ internal static class CurrentApiCutDiagnostics
return defaultElectionType;
}
private static bool UsesHistoricalStoredOptions(FormatTemplateDefinition template)
{
return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) ||
ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name);
}
private static bool SupportsPreElectionTurnout(string? electionType)
{
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) ||

View File

@@ -274,6 +274,7 @@ internal static class CutFileAudit
payload.CounterNumberKeys,
Array.Empty<KarismaChartCellUpdate>(),
Array.Empty<KarismaPositionUpdate>(),
Array.Empty<KarismaCropKeyUpdate>(),
payload.StyleColorUpdates,
payload.VisibilityUpdates,
CancellationToken.None)
@@ -524,6 +525,10 @@ internal static class CutFileAudit
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)!);
@@ -961,6 +966,12 @@ internal static class CutFileAudit
{
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))
{
@@ -1027,11 +1038,12 @@ internal static class CutFileAudit
Add("Elect2026_Normal_민방", "모든후보_교육감", "모든후보_교육감");
Add("Elect2026_Normal_민방", "사전_역대당선", "사전_역대당선자", "사전_역대당선자_기초단체장");
Add("Elect2026_Normal_민방", "사전_역대당선_교육감", "사전_역대당선자_교육감");
Add("Elect2026_Normal_민방", "이시각1위_광역단체장", "이시각1위_광역단체장");
Add("Elect2026_Normal_민방", "이시각1위_광역단체장_5760", "이시각1위_광역단체장_5760");
Add("Elect2026_Normal_민방", "이시각1위_기초단체장(5760동일)", "이시각1위_기초단체장");
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_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_민방", "모든후보", "전후보_광역단체장", "전후보_기초단체장");
@@ -1296,29 +1308,62 @@ internal static class CutFileAudit
private static PgmWindow? TryFindPgmWindow()
{
var process = Process.GetProcessesByName("Tornado3")
.FirstOrDefault(candidate => string.Equals(candidate.MainWindowTitle, "PGM", StringComparison.Ordinal));
if (process is null || process.MainWindowHandle == IntPtr.Zero)
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(process.MainWindowHandle, out var dwmBounds))
if (TryGetDwmExtendedFrameBounds(handle, out var dwmBounds))
{
return new PgmWindow(process.MainWindowHandle, dwmBounds);
return new PgmWindow(handle, dwmBounds);
}
if (TryGetClientBounds(process.MainWindowHandle, out var clientBounds))
if (TryGetClientBounds(handle, out var clientBounds))
{
return new PgmWindow(process.MainWindowHandle, clientBounds);
return new PgmWindow(handle, clientBounds);
}
if (!GetWindowRect(process.MainWindowHandle, out var rect))
if (!GetWindowRect(handle, out var rect))
{
return null;
}
return new PgmWindow(process.MainWindowHandle, new Rect(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
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)
@@ -1544,6 +1589,27 @@ internal static class CutFileAudit
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);
@@ -1560,6 +1626,8 @@ internal static class CutFileAudit
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);

View File

@@ -43,6 +43,7 @@
<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\KarismaCropKeyUpdate.cs" Link="AppSource\Services\KarismaCropKeyUpdate.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" />

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
using System;
using System.Globalization;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using KAsyncEngineLib;
@@ -319,6 +321,12 @@ if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComp
return;
}
if (args.Length > 0 && string.Equals(args[0], "--audit-party-colors-live", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = await LiveCutValidation.RunPartyColorAuditAsync(args[1..]).ConfigureAwait(false);
return;
}
if (args.Length > 0 && string.Equals(args[0], "--validate-current-api-cuts", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = await CurrentApiCutDiagnostics.RunAsync(args[1..]).ConfigureAwait(false);
@@ -876,6 +884,14 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
var operationResult = ApplySceneOperation(handler, scene!, operation, options.Connection.Timeout);
if (!string.Equals(operationResult.Result, eKResult.RESULT_SUCCESS.ToString(), StringComparison.Ordinal))
{
if (operation.ContinueOnFailure)
{
Console.WriteLine(
$"[SAVE-IMAGE] Optional operation {operationResult.Method} failed for '{operationResult.ObjectName}': " +
$"{operationResult.Result} {operationResult.Detail}");
continue;
}
completion.TrySetResult(
new SaveSceneImageProbeResult(
true,
@@ -1001,31 +1017,38 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
var positionKeyUpdates = new List<PositionKeyUpdate>();
if (options.PositionKey is not null)
{
positionKeyUpdates.Add(options.PositionKey);
}
positionKeyUpdates.AddRange(options.PositionKeys);
foreach (var positionKeyUpdate in positionKeyUpdates)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting position key object={options.PositionKey.ObjectName} index={options.PositionKey.KeyIndex} " +
$"value=({options.PositionKey.X},{options.PositionKey.Y},{options.PositionKey.Z}) vector={options.PositionKey.VectorType}...");
var sceneObject = scene.GetObject(options.PositionKey.ObjectName);
$"[SAVE-IMAGE] Setting position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
$"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.PositionKey.ObjectName}' was not found."));
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionKeyUpdate.ObjectName}' was not found."));
return;
}
handler.ResetPositionKeyTask();
sceneObject.SetPositionKey(
options.PositionKey.KeyIndex,
options.PositionKey.X,
options.PositionKey.Y,
options.PositionKey.Z,
options.PositionKey.VectorType);
positionKeyUpdate.KeyIndex,
positionKeyUpdate.X,
positionKeyUpdate.Y,
positionKeyUpdate.Z,
positionKeyUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{options.PositionKey.ObjectName}'." ));
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{positionKeyUpdate.ObjectName}'." ));
return;
}
@@ -1033,7 +1056,7 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
if (positionKeyResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={options.PositionKey.ObjectName}"));
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={positionKeyUpdate.ObjectName}"));
return;
}
}
@@ -1179,53 +1202,161 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
var outputDirectory = Path.GetDirectoryName(options.OutputPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
foreach (var positionUpdate in options.PostPositions)
{
Directory.CreateDirectory(outputDirectory);
}
if (File.Exists(options.OutputPath))
{
File.Delete(options.OutputPath);
}
Console.WriteLine("[SAVE-IMAGE] Calling SaveSceneImage()...");
handler.ResetSaveSceneImageTask();
scene.SaveSceneImage(options.OutputPath, options.Width, options.Height, options.Frame);
if (!WaitForTaskWithMessagePump(handler.SaveSceneImageTask, options.Connection.Timeout))
{
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, "OnSaveSceneImage timed out."));
return;
}
var saveResult = handler.SaveSceneImageTask.Result;
if (saveResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", saveResult.ToString(), options.OutputPath, $"OnSaveSceneImage result={saveResult}"));
return;
}
var fileWaitDeadline = DateTime.UtcNow + options.Connection.Timeout;
while (DateTime.UtcNow < fileWaitDeadline)
{
if (File.Exists(options.OutputPath))
Console.WriteLine(
$"[SAVE-IMAGE] Setting post-chart position object={positionUpdate.ObjectName} " +
$"value=({positionUpdate.X},{positionUpdate.Y},{positionUpdate.Z}) vector={positionUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
if (sceneObject is null)
{
var info = new FileInfo(options.OutputPath);
if (info.Length > 0)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", options.OutputPath, $"Saved {info.Length} bytes."));
return;
}
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionUpdate.ObjectName}' was not found."));
return;
}
Thread.Sleep(50);
handler.ResetPositionTask();
sceneObject.SetPosition(
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 '{positionUpdate.ObjectName}'." ));
return;
}
var positionResult = handler.PositionTask.Result;
if (positionResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={positionUpdate.ObjectName}"));
return;
}
}
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, "Image file was not created."));
foreach (var positionKeyUpdate in options.PostPositionKeys)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting post-chart position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
$"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionKeyUpdate.ObjectName}' was not found."));
return;
}
handler.ResetPositionKeyTask();
sceneObject.SetPositionKey(
positionKeyUpdate.KeyIndex,
positionKeyUpdate.X,
positionKeyUpdate.Y,
positionKeyUpdate.Z,
positionKeyUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{positionKeyUpdate.ObjectName}'." ));
return;
}
var positionKeyResult = handler.PositionKeyTask.Result;
if (positionKeyResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={positionKeyUpdate.ObjectName}"));
return;
}
}
var captures = new List<(string OutputPath, int Frame)>();
if (options.Frames.Count > 0)
{
var captureDirectory = options.OutputDirectory ?? options.OutputPath;
foreach (var captureFrame in options.Frames)
{
captures.Add((
Path.GetFullPath(Path.Combine(
captureDirectory,
string.Format(CultureInfo.InvariantCulture, options.OutputPattern, captureFrame))),
captureFrame));
}
}
else
{
captures.Add((options.OutputPath, options.Frame));
}
long totalBytes = 0;
foreach (var capture in captures)
{
var outputDirectory = Path.GetDirectoryName(capture.OutputPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
if (File.Exists(capture.OutputPath))
{
File.Delete(capture.OutputPath);
}
Console.WriteLine($"[SAVE-IMAGE] Calling SaveSceneImage() frame={capture.Frame} output={capture.OutputPath}...");
handler.ResetSaveSceneImageTask();
scene.SaveSceneImage(capture.OutputPath, options.Width, options.Height, capture.Frame);
if (!WaitForTaskWithMessagePump(handler.SaveSceneImageTask, options.Connection.Timeout))
{
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", capture.OutputPath, "OnSaveSceneImage timed out."));
return;
}
var saveResult = handler.SaveSceneImageTask.Result;
if (saveResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", saveResult.ToString(), capture.OutputPath, $"OnSaveSceneImage result={saveResult}"));
return;
}
var savedThisFrame = false;
var fileWaitDeadline = DateTime.UtcNow + options.Connection.Timeout;
while (DateTime.UtcNow < fileWaitDeadline)
{
if (File.Exists(capture.OutputPath))
{
var info = new FileInfo(capture.OutputPath);
if (info.Length > 0)
{
totalBytes += info.Length;
savedThisFrame = true;
break;
}
}
Thread.Sleep(50);
}
if (!savedThisFrame)
{
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", capture.OutputPath, "Image file was not created."));
return;
}
}
var resultOutput = options.Frames.Count > 0
? options.OutputDirectory ?? options.OutputPath
: options.OutputPath;
var detail = captures.Count == 1
? $"Saved {totalBytes} bytes."
: $"Saved {captures.Count} frames ({totalBytes} bytes).";
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", resultOutput, detail));
}
catch (Exception ex)
{
@@ -3430,6 +3561,9 @@ internal sealed record SaveSceneImageOptions(
int Width,
int Height,
int Frame,
IReadOnlyList<int> Frames,
string? OutputDirectory,
string OutputPattern,
string? SetObjectName,
string? SetObjectValue,
string? VisibleObjectName,
@@ -3442,6 +3576,9 @@ internal sealed record SaveSceneImageOptions(
PositionUpdate? Position,
IReadOnlyList<PositionUpdate> Positions,
PositionKeyUpdate? PositionKey,
IReadOnlyList<PositionKeyUpdate> PositionKeys,
IReadOnlyList<PositionUpdate> PostPositions,
IReadOnlyList<PositionKeyUpdate> PostPositionKeys,
string? ChartObjectName,
string? ChartCsvPath,
IReadOnlyList<ChartCellUpdate> ChartCells,
@@ -3455,6 +3592,9 @@ internal sealed record SaveSceneImageOptions(
string? scenePath = null;
string? sceneAlias = null;
string? outputPath = null;
string? outputDirectory = null;
string outputPattern = "frame_{0:D4}.png";
IReadOnlyList<int> frames = Array.Empty<int>();
string? setObjectName = null;
string? setObjectValue = null;
string? visibleObjectName = null;
@@ -3474,6 +3614,9 @@ internal sealed record SaveSceneImageOptions(
string? positionKeyObjectName = null;
int positionKeyIndex = 1;
string? positionKeyRaw = null;
string? positionKeysRaw = null;
string? postPositionsRaw = null;
string? postPositionKeysRaw = null;
string? chartObjectName = null;
string? chartCsvPath = null;
string? chartCellsRaw = null;
@@ -3497,6 +3640,12 @@ internal sealed record SaveSceneImageOptions(
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
break;
case "--output-dir" when index + 1 < args.Length:
outputDirectory = args[++index];
break;
case "--output-pattern" when index + 1 < args.Length:
outputPattern = args[++index];
break;
case "--set-object" when index + 1 < args.Length:
setObjectName = args[++index];
break;
@@ -3561,6 +3710,15 @@ internal sealed record SaveSceneImageOptions(
case "--position-key" when index + 1 < args.Length:
positionKeyRaw = args[++index];
break;
case "--position-keys" when index + 1 < args.Length:
positionKeysRaw = args[++index];
break;
case "--post-positions" when index + 1 < args.Length:
postPositionsRaw = args[++index];
break;
case "--post-position-keys" when index + 1 < args.Length:
postPositionKeysRaw = args[++index];
break;
case "--chart-object" when index + 1 < args.Length:
chartObjectName = args[++index];
break;
@@ -3591,6 +3749,9 @@ internal sealed record SaveSceneImageOptions(
frame = parsedFrame;
index++;
break;
case "--frames" when index + 1 < args.Length:
frames = ParseFrameSequence(args[++index]);
break;
}
}
@@ -3599,7 +3760,18 @@ internal sealed record SaveSceneImageOptions(
throw new ArgumentException("--scene is required.");
}
if (string.IsNullOrWhiteSpace(outputPath))
if (frames.Count > 0)
{
outputDirectory ??= outputPath;
if (string.IsNullOrWhiteSpace(outputDirectory))
{
throw new ArgumentException("--output-dir is required when --frames is provided.");
}
outputDirectory = Path.GetFullPath(outputDirectory);
outputPath ??= outputDirectory;
}
else if (string.IsNullOrWhiteSpace(outputPath))
{
throw new ArgumentException("--output is required.");
}
@@ -3618,6 +3790,9 @@ internal sealed record SaveSceneImageOptions(
width,
height,
frame,
frames,
outputDirectory,
outputPattern,
setObjectName,
setObjectValue,
visibleObjectName,
@@ -3630,6 +3805,9 @@ internal sealed record SaveSceneImageOptions(
ParsePosition(positionObjectName, positionRaw),
ParsePositions(positionsRaw),
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
ParsePositionKeys(positionKeysRaw),
ParsePositions(postPositionsRaw),
ParsePositionKeys(postPositionKeysRaw),
chartObjectName,
chartCsvPath,
ParseChartCells(chartCellsRaw),
@@ -3638,6 +3816,53 @@ internal sealed record SaveSceneImageOptions(
ParsePathModifications(modifyPathRaw));
}
private static IReadOnlyList<int> ParseFrameSequence(string value)
{
var frames = new List<int>();
foreach (var token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var rangeMatch = Regex.Match(token, @"^(?<start>-?\d+)-(?<end>-?\d+)(?::(?<step>\d+))?$", RegexOptions.CultureInvariant);
if (rangeMatch.Success)
{
var start = int.Parse(rangeMatch.Groups["start"].Value, CultureInfo.InvariantCulture);
var end = int.Parse(rangeMatch.Groups["end"].Value, CultureInfo.InvariantCulture);
var step = rangeMatch.Groups["step"].Success
? int.Parse(rangeMatch.Groups["step"].Value, CultureInfo.InvariantCulture)
: 1;
if (step <= 0)
{
throw new ArgumentException("--frames range step must be greater than zero.");
}
if (start <= end)
{
for (var frame = start; frame <= end; frame += step)
{
frames.Add(frame);
}
}
else
{
for (var frame = start; frame >= end; frame -= step)
{
frames.Add(frame);
}
}
continue;
}
if (!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var singleFrame))
{
throw new ArgumentException($"Invalid frame token: {token}");
}
frames.Add(singleFrame);
}
return frames.Distinct().ToArray();
}
private static CloneObjectUpdate? ParseCloneObject(string? sourceObjectName, string? variableName)
{
if (string.IsNullOrWhiteSpace(sourceObjectName) || string.IsNullOrWhiteSpace(variableName))
@@ -3776,6 +4001,38 @@ internal sealed record SaveSceneImageOptions(
return new PositionKeyUpdate(objectName, keyIndex, x, y, z, vectorType);
}
private static IReadOnlyList<PositionKeyUpdate> ParsePositionKeys(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return Array.Empty<PositionKeyUpdate>();
}
var updates = new List<PositionKeyUpdate>();
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 key update: {token}");
}
var objectAndKey = nameParts[0].Split('#', 2, StringSplitOptions.TrimEntries);
if (objectAndKey.Length != 2 || !int.TryParse(objectAndKey[1], out var keyIndex))
{
throw new ArgumentException($"Invalid position key object/index: {nameParts[0]}");
}
var update = ParsePositionKey(objectAndKey[0], keyIndex, nameParts[1]);
if (update is not null)
{
updates.Add(update);
}
}
return updates;
}
private static IReadOnlyList<ChartCellUpdate> ParseChartCells(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
@@ -4410,6 +4667,8 @@ internal sealed class SceneValidationOperation
public int A { get; set; } = 255;
public bool Visible { get; set; }
public bool ContinueOnFailure { get; set; }
}
internal sealed record SceneOperationValidationResult(string ObjectName, string Method, string Payload, string Result, string Detail);