중간저장 04.20
This commit is contained in:
@@ -11,9 +11,41 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
|
||||
<Reference Include="Interop.KAsyncEngineLib">
|
||||
<HintPath>$(KarismaSdkDir)\Bin\C#\Interop.KAsyncEngineLib.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\Tornado3_2026Election\Common\ObservableObject.cs" Link="AppSource\Common\ObservableObject.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\BroadcastChannel.cs" Link="AppSource\Domain\BroadcastChannel.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\BroadcastPhase.cs" Link="AppSource\Domain\BroadcastPhase.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\BroadcastStationProfile.cs" Link="AppSource\Domain\BroadcastStationProfile.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\CandidateEntry.cs" Link="AppSource\Domain\CandidateEntry.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\CandidateJudgement.cs" Link="AppSource\Domain\CandidateJudgement.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\ElectionDataSnapshot.cs" Link="AppSource\Domain\ElectionDataSnapshot.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\FormatCutDefinition.cs" Link="AppSource\Domain\FormatCutDefinition.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\FormatTemplateDefinition.cs" Link="AppSource\Domain\FormatTemplateDefinition.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\LogEntry.cs" Link="AppSource\Domain\LogEntry.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\LogLevel.cs" Link="AppSource\Domain\LogLevel.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\LoopMode.cs" Link="AppSource\Domain\LoopMode.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\PreElectionHistoryModels.cs" Link="AppSource\Domain\PreElectionHistoryModels.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Domain\TornadoConnectionState.cs" Link="AppSource\Domain\TornadoConnectionState.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\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.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" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaStyleColorUpdate.cs" Link="AppSource\Services\KarismaStyleColorUpdate.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaTornado3Adapter.cs" Link="AppSource\Services\KarismaTornado3Adapter.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaVisibilityUpdate.cs" Link="AppSource\Services\KarismaVisibilityUpdate.cs" />
|
||||
<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\TornadoManager.cs" Link="AppSource\Services\TornadoManager.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\TornadoPathResolver.cs" Link="AppSource\Services\TornadoPathResolver.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
717
tools/KarismaTcpProbe/LiveCutValidation.cs
Normal file
717
tools/KarismaTcpProbe/LiveCutValidation.cs
Normal file
@@ -0,0 +1,717 @@
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Tornado3_2026Election.Domain;
|
||||
using Tornado3_2026Election.Services;
|
||||
|
||||
internal static class LiveCutValidation
|
||||
{
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
var options = LiveCutValidationOptions.Parse(args);
|
||||
Directory.CreateDirectory(options.OutputPath);
|
||||
|
||||
Console.WriteLine("Karisma live-cut validation starting.");
|
||||
Console.WriteLine($"- Image Root: {options.ImageRootPath}");
|
||||
Console.WriteLine($"- Output: {options.OutputPath}");
|
||||
Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}");
|
||||
|
||||
var logService = new LogService();
|
||||
if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, out var adapter) || !adapter.IsLiveCg)
|
||||
{
|
||||
Console.WriteLine("Karisma adapter is not available. Validation cannot continue.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var station = new BroadcastStationProfile
|
||||
{
|
||||
Id = "TJB",
|
||||
Name = "TJB",
|
||||
LogoAssetPath = options.StationLogoPath,
|
||||
RegionFilters = ["대전", "세종", "충남"]
|
||||
};
|
||||
|
||||
var cutItems = new FormatCatalogService().GetAll()
|
||||
.Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
|
||||
.Where(template => string.IsNullOrWhiteSpace(options.Filter) ||
|
||||
template.Id.Contains(options.Filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
template.Name.Contains(options.Filter, StringComparison.OrdinalIgnoreCase))
|
||||
.SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut)))
|
||||
.ToList();
|
||||
|
||||
if (options.Limit is int limit && limit > 0)
|
||||
{
|
||||
cutItems = cutItems.Take(limit).ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"- Cuts: {cutItems.Count}");
|
||||
Console.WriteLine();
|
||||
|
||||
var pgmWindow = TryFindPgmWindow();
|
||||
var results = new List<LiveCutValidationResult>();
|
||||
|
||||
try
|
||||
{
|
||||
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
for (var index = 0; index < cutItems.Count; index++)
|
||||
{
|
||||
var item = cutItems[index];
|
||||
var preElection = ShouldUsePreElectionSnapshot(item.Template.Name);
|
||||
var result = new LiveCutValidationResult
|
||||
{
|
||||
Index = index + 1,
|
||||
TemplateId = item.Template.Id,
|
||||
TemplateName = item.Template.Name,
|
||||
CutName = item.Cut.Name,
|
||||
Channel = item.Template.RecommendedChannel.ToString(),
|
||||
Phase = preElection ? BroadcastPhase.PreElection.ToString() : BroadcastPhase.Counting.ToString(),
|
||||
OutputVisibleInPgm = pgmWindow is not null &&
|
||||
item.Template.RecommendedChannel != BroadcastChannel.VideoWall
|
||||
};
|
||||
|
||||
Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id}");
|
||||
|
||||
try
|
||||
{
|
||||
await OutAllAsync(adapter).ConfigureAwait(false);
|
||||
await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
|
||||
|
||||
var snapshotA = CreateSnapshot(item.Template.Name, index, variant: 0, preElection);
|
||||
var snapshotB = CreateSnapshot(item.Template.Name, index, variant: 1, preElection);
|
||||
|
||||
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotA, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
|
||||
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||
await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||
await Task.Delay(options.OnAirDelayMs).ConfigureAwait(false);
|
||||
|
||||
result.CaptureAPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_A.png");
|
||||
result.HashA = CapturePgm(pgmWindow, result.CaptureAPath, !result.OutputVisibleInPgm);
|
||||
|
||||
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotB, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
|
||||
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||
await adapter.TakeAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||
await Task.Delay(options.OnAirDelayMs).ConfigureAwait(false);
|
||||
|
||||
result.CaptureBPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_B.png");
|
||||
result.HashB = CapturePgm(pgmWindow, result.CaptureBPath, !result.OutputVisibleInPgm);
|
||||
result.VisualChanged = !string.Equals(result.HashA, result.HashB, StringComparison.OrdinalIgnoreCase);
|
||||
result.Success = true;
|
||||
result.Detail = result.OutputVisibleInPgm
|
||||
? (result.VisualChanged ? "A/B capture changed" : "A/B capture hash identical")
|
||||
: "VideoWall output is not visible in the current PGM window";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
result.Success = false;
|
||||
result.Detail = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await adapter.OutAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await OutAllAsync(adapter).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
if (adapter is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
WriteReports(options, results);
|
||||
|
||||
var successCount = results.Count(result => result.Success);
|
||||
var changedCount = results.Count(result => result.Success && result.VisualChanged);
|
||||
var unchangedCount = results.Count(result => result.Success && result.OutputVisibleInPgm && !result.VisualChanged);
|
||||
var failureCount = results.Count(result => !result.Success);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Summary");
|
||||
Console.WriteLine($"- Success: {successCount}/{results.Count}");
|
||||
Console.WriteLine($"- Visual Changed: {changedCount}");
|
||||
Console.WriteLine($"- Unchanged Captures: {unchangedCount}");
|
||||
Console.WriteLine($"- Failures: {failureCount}");
|
||||
Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}");
|
||||
|
||||
return failureCount == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task OutAllAsync(ITornado3Adapter adapter)
|
||||
{
|
||||
foreach (var channel in new[]
|
||||
{
|
||||
BroadcastChannel.Normal,
|
||||
BroadcastChannel.TopLeft,
|
||||
BroadcastChannel.Bottom,
|
||||
BroadcastChannel.VideoWall
|
||||
})
|
||||
{
|
||||
try
|
||||
{
|
||||
await adapter.OutAsync(channel, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldUsePreElectionSnapshot(string templateName)
|
||||
{
|
||||
return templateName.Contains("투표율", StringComparison.Ordinal) ||
|
||||
templateName.StartsWith("사전_", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static ElectionDataSnapshot CreateSnapshot(string templateName, int index, int variant, bool preElection)
|
||||
{
|
||||
var metadata = BuildScenarioMetadata(templateName, index, variant);
|
||||
return new ElectionDataSnapshot
|
||||
{
|
||||
BroadcastPhase = preElection ? BroadcastPhase.PreElection : BroadcastPhase.Counting,
|
||||
ElectionType = metadata.ElectionType,
|
||||
DistrictName = metadata.DistrictName,
|
||||
DistrictCode = metadata.DistrictCode,
|
||||
RegionName = metadata.RegionName,
|
||||
ElectionDistrictName = metadata.ElectionDistrictName,
|
||||
Candidates = preElection ? Array.Empty<CandidateEntry>() : CreateCandidates(templateName, metadata, variant),
|
||||
TotalExpectedVotes = metadata.TotalExpectedVotes,
|
||||
TurnoutVotes = metadata.TurnoutVotes,
|
||||
CountedVotesFromApi = metadata.CountedVotes,
|
||||
RemainingVotesFromApi = Math.Max(0, metadata.TurnoutVotes - metadata.CountedVotes),
|
||||
CountedRateFromApi = metadata.CountedRate,
|
||||
ReceivedAt = metadata.ReceivedAt,
|
||||
HistoricalTurnoutHistory = CreateHistoricalTurnout(metadata, variant),
|
||||
HistoricalWinnerHistory = CreateHistoricalWinners(variant),
|
||||
TurnoutBoardSlots = CreateTurnoutBoardSlots(metadata, variant),
|
||||
NationalTurnoutRateOverride = metadata.NationalTurnoutRate
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CandidateEntry> CreateCandidates(string templateName, ScenarioMetadata metadata, int variant)
|
||||
{
|
||||
var candidateNames = ResolveCandidateNames(templateName);
|
||||
var parties = ResolveParties(candidateNames.Length);
|
||||
var shares = ResolveVoteShares(templateName, candidateNames.Length, variant);
|
||||
var automaticJudgement = ResolveAutomaticJudgement(templateName);
|
||||
|
||||
var candidates = new List<CandidateEntry>(candidateNames.Length);
|
||||
for (var index = 0; index < candidateNames.Length; index++)
|
||||
{
|
||||
candidates.Add(new CandidateEntry
|
||||
{
|
||||
CandidateCode = (index + 1).ToString(),
|
||||
Name = candidateNames[index],
|
||||
Party = parties[index],
|
||||
ColorParty = parties[index],
|
||||
VoteRate = shares[index],
|
||||
VoteCount = (int)Math.Round(metadata.CountedVotes * shares[index] / 100d, MidpointRounding.AwayFromZero),
|
||||
HasImage = true,
|
||||
ManualJudgement = CandidateJudgement.None,
|
||||
AutomaticJudgement = index == 0 ? automaticJudgement : CandidateJudgement.None
|
||||
});
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PreElectionHistoricalTurnoutEntry> CreateHistoricalTurnout(ScenarioMetadata metadata, int variant)
|
||||
{
|
||||
var years = new[] { 2010, 2014, 2018, 2022 };
|
||||
var rates = new[] { 56.1, 58.4, 61.8, 64.2 + variant * 1.4 };
|
||||
var entries = new List<PreElectionHistoricalTurnoutEntry>(years.Length);
|
||||
|
||||
for (var index = 0; index < years.Length; index++)
|
||||
{
|
||||
var electors = metadata.TotalExpectedVotes - 50000 + (index * 25000);
|
||||
entries.Add(new PreElectionHistoricalTurnoutEntry
|
||||
{
|
||||
ElectionOrder = 5 + index,
|
||||
Year = years[index],
|
||||
Electors = electors,
|
||||
Votes = (int)Math.Round(electors * rates[index] / 100d, MidpointRounding.AwayFromZero),
|
||||
TurnoutRate = rates[index]
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PreElectionHistoricalWinnerEntry> CreateHistoricalWinners(int variant)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new PreElectionHistoricalWinnerEntry
|
||||
{
|
||||
ElectionOrder = 5,
|
||||
Year = 2010,
|
||||
Name = "김민수",
|
||||
Party = "열린우리당",
|
||||
ColorParty = "열린우리당",
|
||||
Note = string.Empty
|
||||
},
|
||||
new PreElectionHistoricalWinnerEntry
|
||||
{
|
||||
ElectionOrder = 6,
|
||||
Year = 2014,
|
||||
Name = "박정우",
|
||||
Party = "새누리당",
|
||||
ColorParty = "새누리당",
|
||||
Note = string.Empty
|
||||
},
|
||||
new PreElectionHistoricalWinnerEntry
|
||||
{
|
||||
ElectionOrder = 7,
|
||||
Year = 2018,
|
||||
Name = "이서연",
|
||||
Party = "더불어민주당",
|
||||
ColorParty = "더불어민주당",
|
||||
Note = string.Empty
|
||||
},
|
||||
new PreElectionHistoricalWinnerEntry
|
||||
{
|
||||
ElectionOrder = 8,
|
||||
Year = 2022,
|
||||
Name = variant == 0 ? "최도윤" : "최서준",
|
||||
Party = variant == 0 ? "국민의힘" : "더불어민주당",
|
||||
ColorParty = variant == 0 ? "국민의힘" : "더불어민주당",
|
||||
Note = variant == 0 ? "재선" : "정권교체"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TurnoutBoardSlotEntry> CreateTurnoutBoardSlots(ScenarioMetadata metadata, int variant)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new TurnoutBoardSlotEntry(1, "전국", metadata.NationalTurnoutRate, true),
|
||||
new TurnoutBoardSlotEntry(2, metadata.RegionName, 61.4 + (metadata.TemplateSeed % 5) + variant * 1.7),
|
||||
new TurnoutBoardSlotEntry(3, "세종", 59.2 + (metadata.TemplateSeed % 3) + variant * 1.3),
|
||||
new TurnoutBoardSlotEntry(4, "충북", 57.8 + (metadata.TemplateSeed % 4) + variant * 1.1),
|
||||
new TurnoutBoardSlotEntry(5, "충남", 60.1 + (metadata.TemplateSeed % 2) + variant * 1.5)
|
||||
};
|
||||
}
|
||||
|
||||
private static string[] ResolveCandidateNames(string templateName)
|
||||
{
|
||||
if (templateName.Contains("교육감", StringComparison.Ordinal))
|
||||
{
|
||||
return ["김하늘", "이준호", "박소라"];
|
||||
}
|
||||
|
||||
if (templateName.Contains("기초의원", StringComparison.Ordinal))
|
||||
{
|
||||
return ["김민재", "이소율", "박태훈", "최수빈"];
|
||||
}
|
||||
|
||||
if (templateName.Contains("기초단체장", StringComparison.Ordinal))
|
||||
{
|
||||
return ["윤서진", "강민호", "정다온", "한지후"];
|
||||
}
|
||||
|
||||
if (templateName.Contains("광역의원", StringComparison.Ordinal))
|
||||
{
|
||||
return ["송현우", "배지민", "임서아", "조하람"];
|
||||
}
|
||||
|
||||
return ["김하늘", "이준호", "박소라", "최민석", "정서윤"];
|
||||
}
|
||||
|
||||
private static string[] ResolveParties(int count)
|
||||
{
|
||||
return new[] { "더불어민주당", "국민의힘", "조국혁신당", "개혁신당", "무소속" }
|
||||
.Take(count)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static double[] ResolveVoteShares(string templateName, int count, int variant)
|
||||
{
|
||||
double[] shares = templateName switch
|
||||
{
|
||||
var name when name.Contains("초접전", StringComparison.Ordinal) => [50.1 + (variant * 0.2), 49.6 - (variant * 0.1), 0.3, 0, 0],
|
||||
var name when name.Contains("접전", StringComparison.Ordinal) => [50.9 + (variant * 0.2), 47.9 - (variant * 0.1), 1.2, 0, 0],
|
||||
var name when name.Contains("당선", StringComparison.Ordinal) => [56.4 + (variant * 0.5), 31.2 - (variant * 0.2), 8.7, 3.7, 0],
|
||||
var name when name.Contains("판세", StringComparison.Ordinal) => [53.1 + (variant * 0.3), 37.6 - (variant * 0.2), 6.5, 2.8, 0],
|
||||
var name when name.Contains("이시각1위", StringComparison.Ordinal) => [52.4 + (variant * 0.4), 39.5 - (variant * 0.2), 5.1, 3.0, 0],
|
||||
var name when name.Contains("1-3위", StringComparison.Ordinal) => [47.8 + (variant * 0.3), 34.4 + (variant * 0.1), 13.2 - (variant * 0.2), 3.1, 1.5],
|
||||
var name when name.Contains("모든후보", StringComparison.Ordinal) || name.Contains("전후보", StringComparison.Ordinal) => [43.9 + (variant * 0.3), 30.5 + (variant * 0.1), 11.8, 7.2, 6.6 - (variant * 0.4)],
|
||||
_ => [51.8 + (variant * 0.3), 38.7 - (variant * 0.2), 6.1, 3.4, 0]
|
||||
};
|
||||
|
||||
shares = shares.Take(count).ToArray();
|
||||
var total = shares.Sum();
|
||||
for (var index = 0; index < shares.Length; index++)
|
||||
{
|
||||
shares[index] = Math.Round(shares[index] * 100d / total, 1, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
var delta = 100d - shares.Sum();
|
||||
shares[0] = Math.Round(shares[0] + delta, 1, MidpointRounding.AwayFromZero);
|
||||
return shares;
|
||||
}
|
||||
|
||||
private static CandidateJudgement ResolveAutomaticJudgement(string templateName)
|
||||
{
|
||||
if (templateName.Contains("당선", StringComparison.Ordinal))
|
||||
{
|
||||
return CandidateJudgement.Elected;
|
||||
}
|
||||
|
||||
if (templateName.Contains("판세", StringComparison.Ordinal) ||
|
||||
templateName.Contains("이시각1위", StringComparison.Ordinal))
|
||||
{
|
||||
return CandidateJudgement.Leading;
|
||||
}
|
||||
|
||||
if (templateName.Contains("접전", StringComparison.Ordinal) ||
|
||||
templateName.Contains("초접전", StringComparison.Ordinal))
|
||||
{
|
||||
return CandidateJudgement.None;
|
||||
}
|
||||
|
||||
return CandidateJudgement.Leading;
|
||||
}
|
||||
|
||||
private static ScenarioMetadata BuildScenarioMetadata(string templateName, int index, int variant)
|
||||
{
|
||||
var seed = index + 1;
|
||||
var totalExpectedVotes = 1_800_000 + (seed * 12_500);
|
||||
var turnoutVotes = 1_050_000 + (seed * 7_500) + (variant * 31_000);
|
||||
var countedRate = Math.Min(97.9, 68.4 + ((seed % 6) * 2.1) + (variant * 6.4));
|
||||
var countedVotes = (int)Math.Round(turnoutVotes * countedRate / 100d, MidpointRounding.AwayFromZero);
|
||||
|
||||
if (templateName.Contains("교육감", StringComparison.Ordinal))
|
||||
{
|
||||
return new ScenarioMetadata("교육감", "30", "대전광역시", "대전", "대전광역시교육감", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 58.7 + (seed % 4) + (variant * 1.9), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
||||
}
|
||||
|
||||
if (templateName.Contains("기초의원", StringComparison.Ordinal))
|
||||
{
|
||||
return new ScenarioMetadata("기초의원", "44131", "천안시 가선거구", "충남", "천안시의원 가선거구", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 54.8 + (seed % 5) + (variant * 1.7), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
||||
}
|
||||
|
||||
if (templateName.Contains("기초단체장", StringComparison.Ordinal))
|
||||
{
|
||||
return new ScenarioMetadata("기초단체장", "44131", "천안시", "충남", "천안시장", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 56.1 + (seed % 4) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
||||
}
|
||||
|
||||
if (templateName.Contains("광역의원", StringComparison.Ordinal))
|
||||
{
|
||||
return new ScenarioMetadata("광역의원", "44001", "충남 제1선거구", "충남", "충남도의원 제1선거구", totalExpectedVotes / 3, turnoutVotes / 3, countedVotes / 3, countedRate, 55.4 + (seed % 3) + (variant * 1.6), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
||||
}
|
||||
|
||||
return new ScenarioMetadata("광역단체장", "44", "충청남도", "충남", "충남도지사", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 57.3 + (seed % 5) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
||||
}
|
||||
|
||||
private static string CapturePgm(PgmWindow? pgmWindow, string outputPath, bool skipCapture)
|
||||
{
|
||||
if (skipCapture || pgmWindow is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
using var bitmap = CaptureWindowBitmap(pgmWindow.Value.Handle, pgmWindow.Value.Bounds);
|
||||
bitmap.Save(outputPath, ImageFormat.Png);
|
||||
return ComputeSha256(outputPath);
|
||||
}
|
||||
|
||||
private static Bitmap CaptureWindowBitmap(IntPtr handle, Rect bounds)
|
||||
{
|
||||
var width = Math.Max(1, bounds.Width);
|
||||
var height = Math.Max(1, bounds.Height);
|
||||
var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
|
||||
var hdc = graphics.GetHdc();
|
||||
try
|
||||
{
|
||||
if (PrintWindow(handle, hdc, 0))
|
||||
{
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
graphics.ReleaseHdc(hdc);
|
||||
}
|
||||
|
||||
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, new Size(width, height), CopyPixelOperation.SourceCopy);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var sha256 = SHA256.Create();
|
||||
return Convert.ToHexString(sha256.ComputeHash(stream));
|
||||
}
|
||||
|
||||
private static PgmWindow? TryFindPgmWindow()
|
||||
{
|
||||
var process = Process.GetProcessesByName("Tornado3")
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.MainWindowTitle, "PGM", StringComparison.Ordinal));
|
||||
if (process is null || process.MainWindowHandle == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!GetWindowRect(process.MainWindowHandle, out var rect))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PgmWindow(process.MainWindowHandle, new Rect(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
|
||||
}
|
||||
|
||||
private static void WriteReports(LiveCutValidationOptions options, IReadOnlyList<LiveCutValidationResult> results)
|
||||
{
|
||||
var csvPath = Path.Combine(options.OutputPath, "results.csv");
|
||||
var jsonPath = Path.Combine(options.OutputPath, "results.json");
|
||||
var summaryPath = Path.Combine(options.OutputPath, "summary.md");
|
||||
|
||||
var csv = new StringBuilder();
|
||||
csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Phase,Success,VisualChanged,OutputVisibleInPgm,CaptureAPath,CaptureBPath,HashA,HashB,Detail");
|
||||
foreach (var result in results)
|
||||
{
|
||||
csv.AppendLine(string.Join(",",
|
||||
Csv(result.Index.ToString()),
|
||||
Csv(result.TemplateId),
|
||||
Csv(result.TemplateName),
|
||||
Csv(result.CutName),
|
||||
Csv(result.Channel),
|
||||
Csv(result.Phase),
|
||||
Csv(result.Success.ToString()),
|
||||
Csv(result.VisualChanged.ToString()),
|
||||
Csv(result.OutputVisibleInPgm.ToString()),
|
||||
Csv(result.CaptureAPath ?? string.Empty),
|
||||
Csv(result.CaptureBPath ?? string.Empty),
|
||||
Csv(result.HashA ?? string.Empty),
|
||||
Csv(result.HashB ?? string.Empty),
|
||||
Csv(result.Detail ?? string.Empty)));
|
||||
}
|
||||
|
||||
File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8);
|
||||
File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8);
|
||||
|
||||
var successCount = results.Count(result => result.Success);
|
||||
var changedCount = results.Count(result => result.Success && result.VisualChanged);
|
||||
var unchanged = results.Where(result => result.Success && result.OutputVisibleInPgm && !result.VisualChanged).ToList();
|
||||
var failures = results.Where(result => !result.Success).ToList();
|
||||
|
||||
var summary = new StringBuilder();
|
||||
summary.AppendLine("# Live Cut Validation");
|
||||
summary.AppendLine();
|
||||
summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
|
||||
summary.AppendLine($"- Image Root: {options.ImageRootPath}");
|
||||
summary.AppendLine($"- Output: {options.OutputPath}");
|
||||
summary.AppendLine($"- Success: {successCount}/{results.Count}");
|
||||
summary.AppendLine($"- Visual Changed: {changedCount}");
|
||||
summary.AppendLine($"- Unchanged Captures: {unchanged.Count}");
|
||||
summary.AppendLine($"- Failures: {failures.Count}");
|
||||
summary.AppendLine();
|
||||
|
||||
if (unchanged.Count > 0)
|
||||
{
|
||||
summary.AppendLine("## Unchanged Captures");
|
||||
summary.AppendLine();
|
||||
foreach (var result in unchanged)
|
||||
{
|
||||
summary.AppendLine($"- `{result.TemplateId}`: {result.Detail}");
|
||||
}
|
||||
|
||||
summary.AppendLine();
|
||||
}
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
summary.AppendLine("## Failures");
|
||||
summary.AppendLine();
|
||||
foreach (var result in failures)
|
||||
{
|
||||
summary.AppendLine($"- `{result.TemplateId}`: {result.Detail}");
|
||||
}
|
||||
|
||||
summary.AppendLine();
|
||||
}
|
||||
|
||||
summary.AppendLine("## Files");
|
||||
summary.AppendLine();
|
||||
summary.AppendLine("- `results.csv`");
|
||||
summary.AppendLine("- `results.json`");
|
||||
summary.AppendLine("- `*_A.png`, `*_B.png`");
|
||||
|
||||
File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static string Csv(string value)
|
||||
{
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (var character in value)
|
||||
{
|
||||
builder.Append(invalidChars.Contains(character) ? '_' : character);
|
||||
}
|
||||
|
||||
var sanitized = builder.ToString().Trim();
|
||||
if (sanitized.Length > 80)
|
||||
{
|
||||
sanitized = sanitized[..80];
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "cut" : sanitized;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect);
|
||||
|
||||
private sealed record LiveCutValidationOptions
|
||||
{
|
||||
public string ImageRootPath { get; init; } = string.Empty;
|
||||
public string OutputPath { get; init; } = string.Empty;
|
||||
public string StationLogoPath { get; init; } = string.Empty;
|
||||
public string? Filter { get; init; }
|
||||
public int? Limit { get; init; }
|
||||
public int OnAirDelayMs { get; init; } = 900;
|
||||
public int BetweenDelayMs { get; init; } = 250;
|
||||
public bool IncludeVideoWall { get; init; }
|
||||
|
||||
public static LiveCutValidationOptions Parse(string[] args)
|
||||
{
|
||||
var repoRoot = Environment.CurrentDirectory;
|
||||
var options = new LiveCutValidationOptions
|
||||
{
|
||||
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
|
||||
OutputPath = Path.Combine(repoRoot, "artifacts", "live-cut-validation", DateTime.Now.ToString("yyyyMMdd_HHmmss")),
|
||||
StationLogoPath = Path.Combine(repoRoot, "Tornado3_2026Election", "bin", "x64", "Debug", "net8.0-windows10.0.19041.0", "win-x64", "Assets", "Stations", "tjb.png")
|
||||
};
|
||||
|
||||
for (var index = 0; index < args.Length; index++)
|
||||
{
|
||||
switch (args[index])
|
||||
{
|
||||
case "--image-root":
|
||||
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
|
||||
break;
|
||||
case "--output":
|
||||
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
|
||||
break;
|
||||
case "--filter":
|
||||
options = options with { Filter = RequireValue(args, ref index, "--filter") };
|
||||
break;
|
||||
case "--limit":
|
||||
options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) };
|
||||
break;
|
||||
case "--onair-delay-ms":
|
||||
options = options with { OnAirDelayMs = int.Parse(RequireValue(args, ref index, "--onair-delay-ms")) };
|
||||
break;
|
||||
case "--between-delay-ms":
|
||||
options = options with { BetweenDelayMs = int.Parse(RequireValue(args, ref index, "--between-delay-ms")) };
|
||||
break;
|
||||
case "--include-videowall":
|
||||
options = options with { IncludeVideoWall = true };
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown option: {args[index]}");
|
||||
}
|
||||
}
|
||||
|
||||
return options with
|
||||
{
|
||||
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
|
||||
OutputPath = Path.GetFullPath(options.OutputPath),
|
||||
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
|
||||
};
|
||||
}
|
||||
|
||||
private static string RequireValue(string[] args, ref int index, string optionName)
|
||||
{
|
||||
index++;
|
||||
if (index >= args.Length)
|
||||
{
|
||||
throw new ArgumentException($"Missing value for {optionName}.");
|
||||
}
|
||||
|
||||
return args[index];
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LiveCutValidationResult
|
||||
{
|
||||
public int Index { get; init; }
|
||||
public string TemplateId { get; init; } = string.Empty;
|
||||
public string TemplateName { get; init; } = string.Empty;
|
||||
public string CutName { get; init; } = string.Empty;
|
||||
public string Channel { get; init; } = string.Empty;
|
||||
public string Phase { get; init; } = string.Empty;
|
||||
public bool Success { get; set; }
|
||||
public bool VisualChanged { get; set; }
|
||||
public bool OutputVisibleInPgm { get; set; }
|
||||
public string? CaptureAPath { get; set; }
|
||||
public string? CaptureBPath { get; set; }
|
||||
public string? HashA { get; set; }
|
||||
public string? HashB { get; set; }
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
|
||||
private readonly record struct LiveCutWorkItem(FormatTemplateDefinition Template, FormatCutDefinition Cut);
|
||||
|
||||
private readonly record struct ScenarioMetadata(
|
||||
string ElectionType,
|
||||
string DistrictCode,
|
||||
string DistrictName,
|
||||
string RegionName,
|
||||
string ElectionDistrictName,
|
||||
int TotalExpectedVotes,
|
||||
int TurnoutVotes,
|
||||
int CountedVotes,
|
||||
double CountedRate,
|
||||
double NationalTurnoutRate,
|
||||
DateTimeOffset ReceivedAt,
|
||||
int TemplateSeed);
|
||||
|
||||
private readonly record struct PgmWindow(IntPtr Handle, Rect Bounds);
|
||||
|
||||
private readonly record struct Rect(int Left, int Top, int Width, int Height);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private readonly struct NativeRect
|
||||
{
|
||||
public int Left { get; init; }
|
||||
public int Top { get; init; }
|
||||
public int Right { get; init; }
|
||||
public int Bottom { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,35 @@ if (args.Length > 0 && string.Equals(args[0], "--inspect-tscn-folder", StringCom
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && string.Equals(args[0], "--save-scene-image", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var saveImageOptions = SaveSceneImageOptions.Parse(args[1..]);
|
||||
Console.WriteLine(
|
||||
$"Karisma scene image save starting. target={saveImageOptions.Connection.Host}:{saveImageOptions.Connection.Port} " +
|
||||
$"scene={saveImageOptions.ScenePath} output={saveImageOptions.OutputPath} size={saveImageOptions.Width}x{saveImageOptions.Height} frame={saveImageOptions.Frame}");
|
||||
var saveImageResult = await SaveSceneImageAsync(saveImageOptions).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Summary");
|
||||
Console.WriteLine($"- SDK Connect(): {(saveImageResult.ConnectRequestAccepted ? "ACCEPTED" : "REJECTED")}");
|
||||
Console.WriteLine($"- Scene Load: {saveImageResult.SceneLoadOutcome}");
|
||||
Console.WriteLine($"- Save Scene Image: {saveImageResult.SaveOutcome}");
|
||||
Console.WriteLine($"- Output: {saveImageResult.OutputPath}");
|
||||
Console.WriteLine($"- Detail: {saveImageResult.Detail}");
|
||||
Environment.ExitCode = saveImageResult.ConnectRequestAccepted &&
|
||||
saveImageResult.SceneLoadOutcome == "SUCCESS" &&
|
||||
saveImageResult.SaveOutcome == "SUCCESS"
|
||||
? 0
|
||||
: 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Environment.ExitCode = await LiveCutValidation.RunAsync(args[1..]).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var options = ProbeOptions.Parse(args);
|
||||
|
||||
Console.WriteLine($"Karisma TCP probe starting. target={options.Host}:{options.Port} timeout={options.Timeout.TotalSeconds:0}s");
|
||||
@@ -321,6 +350,152 @@ static Task<CounterProbeResult> ProbeCounterAsync(CounterProbeOptions options)
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions options)
|
||||
{
|
||||
var completion = new TaskCompletionSource<SaveSceneImageProbeResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
ProbeEventHandler? handler = null;
|
||||
IKAEngine? engine = null;
|
||||
IKAScene? scene = null;
|
||||
try
|
||||
{
|
||||
handler = new ProbeEventHandler();
|
||||
engine = (IKAEngine)new KAEngineClass();
|
||||
|
||||
Console.WriteLine("[SAVE-IMAGE] Calling Connect()...");
|
||||
var connectRequested = engine.Connect(options.Connection.Host, options.Connection.Port, handler);
|
||||
Console.WriteLine($"[SAVE-IMAGE] Connect() returned {(connectRequested != 0 ? "TRUE" : "FALSE")} raw={connectRequested}");
|
||||
if (connectRequested == 0)
|
||||
{
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(false, "NOT_RUN", "NOT_RUN", options.OutputPath, "Connect() returned 0."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.ConnectTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "NOT_RUN", "TIMEOUT", options.OutputPath, "OnConnect timed out."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (handler.ConnectTask.Result != 0)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "NOT_RUN", "FAILED", options.OutputPath, $"OnConnect errorCode={handler.ConnectTask.Result}"));
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("[SAVE-IMAGE] Loading scene...");
|
||||
handler.ResetLoadSceneTask();
|
||||
scene = engine.LoadScene(options.ScenePath, options.SceneAlias);
|
||||
if (scene is null)
|
||||
{
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "FAILED", "NOT_RUN", options.OutputPath, "LoadScene returned null."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.LoadSceneTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "TIMEOUT", "NOT_RUN", options.OutputPath, "OnLoadScene timed out."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (handler.LoadSceneTask.Result != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, handler.LoadSceneTask.Result.ToString(), "NOT_RUN", options.OutputPath, $"OnLoadScene result={handler.LoadSceneTask.Result}"));
|
||||
return;
|
||||
}
|
||||
|
||||
var outputDirectory = Path.GetDirectoryName(options.OutputPath);
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
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))
|
||||
{
|
||||
var info = new FileInfo(options.OutputPath);
|
||||
if (info.Length > 0)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", options.OutputPath, $"Saved {info.Length} bytes."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(50);
|
||||
}
|
||||
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, "Image file was not created."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(false, "EXCEPTION", "EXCEPTION", options.OutputPath, ex.ToString()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (scene is not null && handler is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
TryUnloadScene(handler, scene, options.Connection.Timeout);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (engine is not null && handler is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
engine.Disconnect();
|
||||
handler.CloseTask.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "KarismaSaveSceneImageProbe"
|
||||
};
|
||||
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
static Task<SceneCatalogProbeResult> CatalogScenesAsync(SceneCatalogOptions options)
|
||||
{
|
||||
var completion = new TaskCompletionSource<SceneCatalogProbeResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -1381,6 +1556,70 @@ internal sealed record CounterProbeOptions(
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record SaveSceneImageOptions(
|
||||
ProbeOptions Connection,
|
||||
string ScenePath,
|
||||
string SceneAlias,
|
||||
string OutputPath,
|
||||
int Width,
|
||||
int Height,
|
||||
int Frame)
|
||||
{
|
||||
public static SaveSceneImageOptions Parse(string[] args)
|
||||
{
|
||||
var connection = ProbeOptions.Parse(args);
|
||||
string? scenePath = null;
|
||||
string? sceneAlias = null;
|
||||
string? outputPath = null;
|
||||
var width = 320;
|
||||
var height = 180;
|
||||
var frame = -1;
|
||||
|
||||
for (var index = 0; index < args.Length; index++)
|
||||
{
|
||||
switch (args[index])
|
||||
{
|
||||
case "--scene" when index + 1 < args.Length:
|
||||
scenePath = args[++index];
|
||||
break;
|
||||
case "--alias" when index + 1 < args.Length:
|
||||
sceneAlias = args[++index];
|
||||
break;
|
||||
case "--output" when index + 1 < args.Length:
|
||||
outputPath = args[++index];
|
||||
break;
|
||||
case "--width" when index + 1 < args.Length && int.TryParse(args[index + 1], out var parsedWidth):
|
||||
width = parsedWidth;
|
||||
index++;
|
||||
break;
|
||||
case "--height" when index + 1 < args.Length && int.TryParse(args[index + 1], out var parsedHeight):
|
||||
height = parsedHeight;
|
||||
index++;
|
||||
break;
|
||||
case "--frame" when index + 1 < args.Length && int.TryParse(args[index + 1], out var parsedFrame):
|
||||
frame = parsedFrame;
|
||||
index++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scenePath))
|
||||
{
|
||||
throw new ArgumentException("--scene is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
throw new ArgumentException("--output is required.");
|
||||
}
|
||||
|
||||
scenePath = Path.GetFullPath(scenePath);
|
||||
outputPath = Path.GetFullPath(outputPath);
|
||||
sceneAlias ??= Path.GetFileNameWithoutExtension(scenePath);
|
||||
return new SaveSceneImageOptions(connection, scenePath, sceneAlias, outputPath, width, height, frame);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record SceneCatalogOptions(
|
||||
ProbeOptions Connection,
|
||||
string RootPath,
|
||||
@@ -1531,6 +1770,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
|
||||
|
||||
internal sealed record SdkProbeResult(bool ConnectRequestAccepted, string ConnectOutcome, string Detail);
|
||||
internal sealed record CounterProbeResult(bool ConnectRequestAccepted, string SceneLoadOutcome, string CounterOutcome, string Detail);
|
||||
internal sealed record SaveSceneImageProbeResult(bool ConnectRequestAccepted, string SceneLoadOutcome, string SaveOutcome, string OutputPath, string Detail);
|
||||
internal sealed record SceneCatalogProbeResult(
|
||||
bool ConnectRequestAccepted,
|
||||
int SceneCount,
|
||||
@@ -1597,6 +1837,7 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
||||
private TaskCompletionSource<eKResult> _styleColorTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private TaskCompletionSource<eKResult> _visibleTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private TaskCompletionSource<eKResult> _setValueTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private TaskCompletionSource<eKResult> _saveSceneImageTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private TaskCompletionSource<ObjectInfosProbeResult> _objectInfosTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private IKAScene? _sceneToQueryOnLoad;
|
||||
|
||||
@@ -1616,6 +1857,8 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
||||
|
||||
public Task<eKResult> SetValueTask => _setValueTask.Task;
|
||||
|
||||
public Task<eKResult> SaveSceneImageTask => _saveSceneImageTask.Task;
|
||||
|
||||
public Task<ObjectInfosProbeResult> ObjectInfosTask => _objectInfosTask.Task;
|
||||
|
||||
public void ResetLoadSceneTask() => _loadSceneTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -1630,6 +1873,8 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
||||
|
||||
public void ResetSetValueTask() => _setValueTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void ResetSaveSceneImageTask() => _saveSceneImageTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void ResetObjectInfosTask() => _objectInfosTask = new TaskCompletionSource<ObjectInfosProbeResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void ConfigureQueryObjectInfosOnLoad(IKAScene scene) => _sceneToQueryOnLoad = scene;
|
||||
@@ -1717,7 +1962,11 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
||||
public void OnSetBackgroundVideoPlayInfo(eKResult Result, string SceneName) { }
|
||||
public void OnQueryBackgroundVideoPlayInfo(eKResult Result, string SceneName, ref sKVideoPlayInfo pVideoPlayInfo) { }
|
||||
public void OnSetSceneEffectType(eKResult Result, string SceneName) { }
|
||||
public void OnSaveSceneImage(eKResult Result, string SceneName) { }
|
||||
public void OnSaveSceneImage(eKResult Result, string SceneName)
|
||||
{
|
||||
Console.WriteLine($"[SDK] OnSaveSceneImage result={Result} scene={SceneName}");
|
||||
_saveSceneImageTask.TrySetResult(Result);
|
||||
}
|
||||
public void OnSaveScene(eKResult Result, string SceneName) { }
|
||||
public void OnUnloadScene(eKResult Result, string SceneName)
|
||||
{
|
||||
|
||||
22
tools/KarismaTcpProbe/UiDispatcher.Compat.cs
Normal file
22
tools/KarismaTcpProbe/UiDispatcher.Compat.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Tornado3_2026Election.Common;
|
||||
|
||||
public static class UiDispatcher
|
||||
{
|
||||
public static void Initialize(object? dispatcherQueue)
|
||||
{
|
||||
}
|
||||
|
||||
public static void Enqueue(Action action)
|
||||
{
|
||||
action();
|
||||
}
|
||||
|
||||
public static Task EnqueueAsync(Action action)
|
||||
{
|
||||
action();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user