diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
new file mode 100644
index 0000000..b874cb1
--- /dev/null
+++ b/.agents/plugins/marketplace.json
@@ -0,0 +1,20 @@
+{
+ "name": "tornado3-local",
+ "interface": {
+ "displayName": "Tornado3 Local Plugins"
+ },
+ "plugins": [
+ {
+ "name": "cut-design-debugger",
+ "source": {
+ "source": "local",
+ "path": "./plugins/cut-design-debugger"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Productivity"
+ }
+ ]
+}
diff --git a/Tornado3_2026Election/Assets/AppIcon.ico b/Tornado3_2026Election/Assets/AppIcon.ico
index c592dea..a6efb9b 100644
Binary files a/Tornado3_2026Election/Assets/AppIcon.ico and b/Tornado3_2026Election/Assets/AppIcon.ico differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Bottom_민방/당선_기초의원.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Bottom_민방/당선_기초의원.png
index 6fb401b..98b50c1 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Bottom_민방/당선_기초의원.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Bottom_민방/당선_기초의원.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_ani_기초단체장_5760.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_ani_기초단체장_5760.png
index da214c6..bb2c120 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_ani_기초단체장_5760.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_ani_기초단체장_5760.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_광역단체장_5760.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_광역단체장_5760.png
index 06de73b..61c2d7d 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_광역단체장_5760.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_광역단체장_5760.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_기초단체장.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_기초단체장.png
index 3d724b8..5831d59 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_기초단체장.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-2위_기초단체장.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-3위_기초단체장_5760.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-3위_기초단체장_5760.png
index 956fd8b..ca4229f 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-3위_기초단체장_5760.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-3위_기초단체장_5760.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-3위_보궐선거.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-3위_보궐선거.png
index 9b760a1..b78d55d 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-3위_보궐선거.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-3위_보궐선거.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/경력_광역단체장_in.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/경력_광역단체장_in.png
index 92e8787..0267ae5 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/경력_광역단체장_in.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/경력_광역단체장_in.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/경력_기초단체장_in.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/경력_기초단체장_in.png
index 2bb10b6..572dbb3 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/경력_기초단체장_in.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/경력_기초단체장_in.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/광역의원표.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/광역의원표.png
index b939035..5e2ac9f 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/광역의원표.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/광역의원표.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/기초의원표.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/기초의원표.png
index 9830caf..f19524c 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/기초의원표.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/기초의원표.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_광역단체장.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_광역단체장.png
index 555a4f8..e770003 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_광역단체장.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_광역단체장.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_광역의원.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_광역의원.png
index 73d3de1..2743684 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_광역의원.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_광역의원.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_교육감.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_교육감.png
index 3b0fa76..f0ba5d0 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_교육감.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_교육감.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_기초단체장.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_기초단체장.png
index e0e1c08..9740992 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_기초단체장.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_기초단체장.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_기초의원.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_기초의원.png
index 13c8621..e520216 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_기초의원.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/당선_기초의원.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_광역단체장_5760.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_광역단체장_5760.png
index 9438db5..911f540 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_광역단체장_5760.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_광역단체장_5760.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_교육감_5760.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_교육감_5760.png
index 2d143e0..5d4ef6b 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_교육감_5760.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_교육감_5760.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_기초단체장_5760.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_기초단체장_5760.png
index a71f885..def3098 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_기초단체장_5760.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/모든후보_기초단체장_5760.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/민방_타이틀.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/민방_타이틀.png
index fff99e1..638287c 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/민방_타이틀.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/민방_타이틀.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/민방_타이틀_5760_nologo.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/민방_타이틀_5760_nologo.png
index a4105c0..10104ab 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/민방_타이틀_5760_nologo.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/민방_타이틀_5760_nologo.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/사전_역대투표율_5760.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/사전_역대투표율_5760.png
index f97e32c..f4e5c97 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/사전_역대투표율_5760.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/사전_역대투표율_5760.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_광역단체장.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_광역단체장.png
index 2bd4a17..e6a7eec 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_광역단체장.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_광역단체장.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장.png
index 927b5a3..21ad948 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장_HD.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장_HD.png
index c88bcfc..b98343e 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장_HD.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장_HD.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/투표율_시도별.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/투표율_시도별.png
index 9424aa2..1b24729 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/투표율_시도별.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/투표율_시도별.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/판세_기초단체장_5760.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/판세_기초단체장_5760.png
index 1cd10e9..21c57ca 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/판세_기초단체장_5760.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/판세_기초단체장_5760.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Top_민방/기초단체장_2인.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Top_민방/기초단체장_2인.png
index 2cd94aa..811f285 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Top_민방/기초단체장_2인.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Top_민방/기초단체장_2인.png differ
diff --git a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
index fe7686a..f075829 100644
--- a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
+++ b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
@@ -158,7 +158,7 @@
-
+
@@ -258,6 +258,308 @@
Content="큐 초기화"
Style="{StaticResource ConsoleGhostButtonStyle}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Stretch="Uniform" />
-
+
@@ -401,15 +704,15 @@
Spacing="6"
VerticalAlignment="Center">
+ Stretch="Uniform" />
RegionFilters { get; init; }
+
+ public VideoWallLayoutPreset VideoWallLayoutPreset { get; init; } = VideoWallLayoutPreset.Auto;
}
diff --git a/Tornado3_2026Election/Domain/ChannelScheduleItem.cs b/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
index f640204..914866d 100644
--- a/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
+++ b/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
@@ -16,6 +16,8 @@ public sealed class ChannelScheduleItem : ObservableObject
private string _currentRegionLabel = string.Empty;
private double _defaultCutDurationSeconds;
private int _totalCuts;
+ private double _thumbnailWidth = 160;
+ private double _thumbnailHeight = 90;
private ImageSource? _thumbnailSource;
public Guid Id { get; set; } = Guid.NewGuid();
@@ -140,6 +142,20 @@ public sealed class ChannelScheduleItem : ObservableObject
[JsonIgnore]
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(FormatId);
+ [JsonIgnore]
+ public double ThumbnailWidth
+ {
+ get => _thumbnailWidth;
+ private set => SetProperty(ref _thumbnailWidth, value);
+ }
+
+ [JsonIgnore]
+ public double ThumbnailHeight
+ {
+ get => _thumbnailHeight;
+ private set => SetProperty(ref _thumbnailHeight, value);
+ }
+
[JsonIgnore]
public string ThumbnailStatusLabel => HasThumbnail ? "등록 썸네일" : "기본 아이콘";
@@ -151,6 +167,12 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(ThumbnailStatusLabel));
}
+ public void UpdateThumbnailLayout(ThumbnailDisplayMetrics metrics)
+ {
+ ThumbnailWidth = metrics.Width;
+ ThumbnailHeight = metrics.Height;
+ }
+
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
{
var selectedRegion = regionOption ?? new ScheduleRegionOption
diff --git a/Tornado3_2026Election/Domain/CutDebugItemState.cs b/Tornado3_2026Election/Domain/CutDebugItemState.cs
new file mode 100644
index 0000000..0fde0fe
--- /dev/null
+++ b/Tornado3_2026Election/Domain/CutDebugItemState.cs
@@ -0,0 +1,261 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using Tornado3_2026Election.Common;
+
+namespace Tornado3_2026Election.Domain;
+
+public enum CutDebugItemKind
+{
+ TextValue,
+ ImageValue,
+ Counter,
+ StyleColor,
+ Visibility
+}
+
+public readonly record struct CutDebugItemDescriptor(
+ string Key,
+ CutDebugItemKind Kind,
+ string GroupLabel);
+
+public sealed class CutDebugItemState : ObservableObject
+{
+ private readonly Action _onIsEnabledChanged;
+ private bool _isEnabled;
+
+ public CutDebugItemState(
+ string key,
+ CutDebugItemKind kind,
+ string groupLabel,
+ bool isEnabled,
+ Action onIsEnabledChanged)
+ {
+ Key = key;
+ Kind = kind;
+ GroupLabel = groupLabel;
+ _isEnabled = isEnabled;
+ _onIsEnabledChanged = onIsEnabledChanged;
+ }
+
+ public string Key { get; }
+
+ public CutDebugItemKind Kind { get; }
+
+ public string GroupLabel { get; }
+
+ public string KindLabel => Kind switch
+ {
+ CutDebugItemKind.TextValue => "텍스트",
+ CutDebugItemKind.ImageValue => "이미지",
+ CutDebugItemKind.Counter => "카운터",
+ CutDebugItemKind.StyleColor => "색상",
+ CutDebugItemKind.Visibility => "표시",
+ _ => "기타"
+ };
+
+ public bool IsEnabled
+ {
+ get => _isEnabled;
+ set
+ {
+ if (SetProperty(ref _isEnabled, value))
+ {
+ _onIsEnabledChanged(value);
+ }
+ }
+ }
+}
+
+public sealed class CutDebugTemplateState
+{
+ private readonly ConcurrentDictionary _enabledStates = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary _overrides = new(StringComparer.OrdinalIgnoreCase);
+
+ public CutDebugTemplateState(string formatId, string displayName)
+ {
+ FormatId = formatId;
+ DisplayName = displayName;
+ Items = [];
+ }
+
+ public string FormatId { get; }
+
+ public string DisplayName { get; private set; }
+
+ public ObservableCollection Items { get; }
+
+ public void UpdateDisplayName(string displayName)
+ {
+ if (!string.IsNullOrWhiteSpace(displayName))
+ {
+ DisplayName = displayName;
+ }
+ }
+
+ public void SyncItems(IEnumerable descriptors)
+ {
+ var normalizedDescriptors = descriptors
+ .Where(descriptor => !string.IsNullOrWhiteSpace(descriptor.Key))
+ .Select(descriptor => descriptor with { Key = NormalizeKey(descriptor.Key) })
+ .GroupBy(descriptor => ComposeStateKey(descriptor.Kind, descriptor.Key), StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .OrderBy(descriptor => (int)descriptor.Kind)
+ .ThenBy(descriptor => descriptor.Key, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ var activeKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+ Items.Clear();
+
+ foreach (var descriptor in normalizedDescriptors)
+ {
+ var stateKey = ComposeStateKey(descriptor.Kind, descriptor.Key);
+ activeKeys.Add(stateKey);
+
+ var initialState = _enabledStates.TryGetValue(stateKey, out var enabled) ? enabled : true;
+ _enabledStates[stateKey] = initialState;
+ Items.Add(new CutDebugItemState(
+ descriptor.Key,
+ descriptor.Kind,
+ descriptor.GroupLabel,
+ initialState,
+ isEnabled => _enabledStates[stateKey] = isEnabled));
+ }
+
+ foreach (var staleKey in _enabledStates.Keys.Where(key => !activeKeys.Contains(key)).ToArray())
+ {
+ _enabledStates.TryRemove(staleKey, out _);
+ }
+
+ foreach (var staleKey in _overrides.Keys.Where(key => !activeKeys.Contains(key)).ToArray())
+ {
+ _overrides.TryRemove(staleKey, out _);
+ }
+ }
+
+ public bool IsEnabled(string key, CutDebugItemKind kind)
+ {
+ var stateKey = ComposeStateKey(kind, NormalizeKey(key));
+ return !_enabledStates.TryGetValue(stateKey, out var enabled) || enabled;
+ }
+
+ public void SetOverride(string key, CutDebugItemKind kind, CutDebugOverride overrideValue)
+ {
+ var stateKey = ComposeStateKey(kind, NormalizeKey(key));
+ if (overrideValue.Mode == CutDebugOverrideMode.None)
+ {
+ _overrides.TryRemove(stateKey, out _);
+ return;
+ }
+
+ _overrides[stateKey] = overrideValue;
+ }
+
+ public bool TryGetOverride(string key, CutDebugItemKind kind, out CutDebugOverride overrideValue)
+ {
+ var stateKey = ComposeStateKey(kind, NormalizeKey(key));
+ if (_overrides.TryGetValue(stateKey, out overrideValue) &&
+ overrideValue.Mode != CutDebugOverrideMode.None)
+ {
+ return true;
+ }
+
+ overrideValue = CutDebugOverride.None;
+ return false;
+ }
+
+ public void ClearOverrides()
+ {
+ _overrides.Clear();
+ }
+
+ public static string NormalizeKey(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ return string.Empty;
+ }
+
+ if (string.Equals(key, "유확당", StringComparison.Ordinal))
+ {
+ return "유확당01";
+ }
+
+ foreach (var prefix in IndexedPrefixes)
+ {
+ if (!key.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var suffix = key.Substring(prefix.Length);
+ if (suffix.Length == 0)
+ {
+ return BareSlotOnePrefixes.Contains(prefix, StringComparer.Ordinal)
+ ? $"{prefix}01"
+ : key;
+ }
+
+ if (suffix.Length <= 2 && suffix.All(char.IsDigit) && int.TryParse(suffix, out var index))
+ {
+ return $"{prefix}{index:00}";
+ }
+
+ return key;
+ }
+
+ return key;
+ }
+
+ private static string ComposeStateKey(CutDebugItemKind kind, string key)
+ {
+ return $"{kind}|{key}";
+ }
+
+ private static readonly string[] IndexedPrefixes =
+ [
+ "순위",
+ "기호",
+ "기호텍스트",
+ "후보명",
+ "정당명",
+ "득표수",
+ "득표율",
+ "표차",
+ "득표차",
+ "선거구명",
+ "시도명",
+ "개표율",
+ "투표율",
+ "전국투표율",
+ "기준시",
+ "유권자수",
+ "투표자수",
+ "유확당",
+ "후보사진",
+ "득표수바",
+ "정당바",
+ "정당판",
+ "정당원",
+ "정당색",
+ "정당심볼",
+ "그룹",
+ "공약그룹",
+ "공약",
+ "바"
+ ];
+
+ private static readonly string[] BareSlotOnePrefixes =
+ [
+ "선거구명",
+ "시도명",
+ "개표율",
+ "투표율",
+ "전국투표율",
+ "기준시",
+ "유권자수",
+ "투표자수"
+ ];
+}
diff --git a/Tornado3_2026Election/Domain/CutDebugOverride.cs b/Tornado3_2026Election/Domain/CutDebugOverride.cs
new file mode 100644
index 0000000..49f0d43
--- /dev/null
+++ b/Tornado3_2026Election/Domain/CutDebugOverride.cs
@@ -0,0 +1,40 @@
+namespace Tornado3_2026Election.Domain;
+
+public enum CutDebugOverrideMode
+{
+ None,
+ Replace
+}
+
+public readonly record struct CutDebugOverride(
+ CutDebugOverrideMode Mode,
+ string? StringValue,
+ double? NumberValue,
+ bool? BooleanValue,
+ byte R,
+ byte G,
+ byte B,
+ byte A)
+{
+ public static CutDebugOverride None => new(CutDebugOverrideMode.None, null, null, null, 0, 0, 0, byte.MaxValue);
+
+ public static CutDebugOverride ForString(string value)
+ {
+ return new(CutDebugOverrideMode.Replace, value, null, null, 0, 0, 0, byte.MaxValue);
+ }
+
+ public static CutDebugOverride ForNumber(double value)
+ {
+ return new(CutDebugOverrideMode.Replace, null, value, null, 0, 0, 0, byte.MaxValue);
+ }
+
+ public static CutDebugOverride ForVisibility(bool value)
+ {
+ return new(CutDebugOverrideMode.Replace, null, null, value, 0, 0, 0, byte.MaxValue);
+ }
+
+ public static CutDebugOverride ForColor(byte r, byte g, byte b, byte a = byte.MaxValue)
+ {
+ return new(CutDebugOverrideMode.Replace, null, null, null, r, g, b, a);
+ }
+}
diff --git a/Tornado3_2026Election/Domain/CutDebugSettings.cs b/Tornado3_2026Election/Domain/CutDebugSettings.cs
new file mode 100644
index 0000000..799971d
--- /dev/null
+++ b/Tornado3_2026Election/Domain/CutDebugSettings.cs
@@ -0,0 +1,155 @@
+using Tornado3_2026Election.Common;
+
+namespace Tornado3_2026Election.Domain;
+
+public sealed class CutDebugSettings : ObservableObject
+{
+ private bool _isEnabled;
+ private bool _applyTextValues = true;
+ private bool _applyImageValues = true;
+ private bool _applyVisibilityValues = true;
+ private bool _applyVoteRateTextValues = true;
+ private bool _applyVoteRateCounterValues = true;
+ private bool _applyPartyBarStyleColors = true;
+ private bool _applyPartyPlateStyleColors = true;
+ private bool _applyVoteRateStyleColors = true;
+
+ public bool IsEnabled
+ {
+ get => _isEnabled;
+ set
+ {
+ if (SetProperty(ref _isEnabled, value))
+ {
+ OnPropertyChanged(nameof(Summary));
+ }
+ }
+ }
+
+ public bool ApplyTextValues
+ {
+ get => _applyTextValues;
+ set
+ {
+ if (SetProperty(ref _applyTextValues, value))
+ {
+ OnPropertyChanged(nameof(Summary));
+ }
+ }
+ }
+
+ public bool ApplyImageValues
+ {
+ get => _applyImageValues;
+ set
+ {
+ if (SetProperty(ref _applyImageValues, value))
+ {
+ OnPropertyChanged(nameof(Summary));
+ }
+ }
+ }
+
+ public bool ApplyVisibilityValues
+ {
+ get => _applyVisibilityValues;
+ set
+ {
+ if (SetProperty(ref _applyVisibilityValues, value))
+ {
+ OnPropertyChanged(nameof(Summary));
+ }
+ }
+ }
+
+ public bool ApplyVoteRateTextValues
+ {
+ get => _applyVoteRateTextValues;
+ set
+ {
+ if (SetProperty(ref _applyVoteRateTextValues, value))
+ {
+ OnPropertyChanged(nameof(Summary));
+ }
+ }
+ }
+
+ public bool ApplyVoteRateCounterValues
+ {
+ get => _applyVoteRateCounterValues;
+ set
+ {
+ if (SetProperty(ref _applyVoteRateCounterValues, value))
+ {
+ OnPropertyChanged(nameof(Summary));
+ }
+ }
+ }
+
+ public bool ApplyPartyBarStyleColors
+ {
+ get => _applyPartyBarStyleColors;
+ set
+ {
+ if (SetProperty(ref _applyPartyBarStyleColors, value))
+ {
+ OnPropertyChanged(nameof(Summary));
+ }
+ }
+ }
+
+ public bool ApplyPartyPlateStyleColors
+ {
+ get => _applyPartyPlateStyleColors;
+ set
+ {
+ if (SetProperty(ref _applyPartyPlateStyleColors, value))
+ {
+ OnPropertyChanged(nameof(Summary));
+ }
+ }
+ }
+
+ public bool ApplyVoteRateStyleColors
+ {
+ get => _applyVoteRateStyleColors;
+ set
+ {
+ if (SetProperty(ref _applyVoteRateStyleColors, value))
+ {
+ OnPropertyChanged(nameof(Summary));
+ }
+ }
+ }
+
+ public string Summary => !IsEnabled
+ ? "디버그 OFF - 현재와 동일하게 전체 송출"
+ : $"텍스트 {ToOnOff(ApplyTextValues)}, 이미지 {ToOnOff(ApplyImageValues)}, 표시/숨김 {ToOnOff(ApplyVisibilityValues)}, 득표율 텍스트 {ToOnOff(ApplyTextValues && ApplyVoteRateTextValues)}, 득표율 카운터 {ToOnOff(ApplyVoteRateCounterValues)}, 정당 바/막대 색상 {ToOnOff(ApplyPartyBarStyleColors)}, 정당 판/문자 색상 {ToOnOff(ApplyPartyPlateStyleColors)}, 득표율 색상 {ToOnOff(ApplyVoteRateStyleColors)}";
+
+ public CutDebugSettingsSnapshot CreateSnapshot()
+ {
+ return new CutDebugSettingsSnapshot(
+ IsEnabled,
+ ApplyTextValues,
+ ApplyImageValues,
+ ApplyVisibilityValues,
+ ApplyVoteRateTextValues,
+ ApplyVoteRateCounterValues,
+ ApplyPartyBarStyleColors,
+ ApplyPartyPlateStyleColors,
+ ApplyVoteRateStyleColors);
+ }
+
+ private static string ToOnOff(bool value) => value ? "ON" : "OFF";
+}
+
+public readonly record struct CutDebugSettingsSnapshot(
+ bool IsEnabled,
+ bool ApplyTextValues,
+ bool ApplyImageValues,
+ bool ApplyVisibilityValues,
+ bool ApplyVoteRateTextValues,
+ bool ApplyVoteRateCounterValues,
+ bool ApplyPartyBarStyleColors,
+ bool ApplyPartyPlateStyleColors,
+ bool ApplyVoteRateStyleColors);
diff --git a/Tornado3_2026Election/Domain/CutListElectionCategory.cs b/Tornado3_2026Election/Domain/CutListElectionCategory.cs
new file mode 100644
index 0000000..cd1603e
--- /dev/null
+++ b/Tornado3_2026Election/Domain/CutListElectionCategory.cs
@@ -0,0 +1,10 @@
+namespace Tornado3_2026Election.Domain;
+
+public enum CutListElectionCategory
+{
+ MetropolitanHead,
+ MetropolitanCouncil,
+ Superintendent,
+ LocalHead,
+ LocalCouncil
+}
diff --git a/Tornado3_2026Election/Domain/FormatTemplateDefinition.cs b/Tornado3_2026Election/Domain/FormatTemplateDefinition.cs
index f23099f..18effd9 100644
--- a/Tornado3_2026Election/Domain/FormatTemplateDefinition.cs
+++ b/Tornado3_2026Election/Domain/FormatTemplateDefinition.cs
@@ -24,6 +24,10 @@ public sealed class FormatTemplateDefinition
public required IReadOnlyList Cuts { get; init; }
+ public int? SceneWidth { get; init; }
+
+ public int? SceneHeight { get; init; }
+
public bool IsAvailableInPhase(BroadcastPhase phase)
{
return phase switch
diff --git a/Tornado3_2026Election/Domain/VideoWallLayoutPreset.cs b/Tornado3_2026Election/Domain/VideoWallLayoutPreset.cs
new file mode 100644
index 0000000..35f96ac
--- /dev/null
+++ b/Tornado3_2026Election/Domain/VideoWallLayoutPreset.cs
@@ -0,0 +1,8 @@
+namespace Tornado3_2026Election.Domain;
+
+public enum VideoWallLayoutPreset
+{
+ Auto,
+ Standard5760x1080,
+ UltraWide11520x1080
+}
diff --git a/Tornado3_2026Election/MainWindow.xaml b/Tornado3_2026Election/MainWindow.xaml
index 52f2845..2eac14b 100644
--- a/Tornado3_2026Election/MainWindow.xaml
+++ b/Tornado3_2026Election/MainWindow.xaml
@@ -470,9 +470,6 @@
-
@@ -512,7 +509,8 @@
Background="#132338"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
- CornerRadius="8">
+ CornerRadius="8"
+ Tapped="DistrictOverviewCard_Tapped">
+
@@ -782,7 +781,12 @@
DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.CutListFilterOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedCutListFilterOption, Mode=TwoWay}" />
-
+
-
diff --git a/Tornado3_2026Election/MainWindow.xaml.cs b/Tornado3_2026Election/MainWindow.xaml.cs
index ccbc424..6bc01e9 100644
--- a/Tornado3_2026Election/MainWindow.xaml.cs
+++ b/Tornado3_2026Election/MainWindow.xaml.cs
@@ -1,6 +1,7 @@
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
using System;
using System.IO;
using System.Runtime.InteropServices;
@@ -432,6 +433,17 @@ public sealed partial class MainWindow : Window
}
}
+ private void DistrictOverviewCard_Tapped(object sender, TappedRoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement element ||
+ element.DataContext is not DistrictOverviewCardViewModel card)
+ {
+ return;
+ }
+
+ ViewModel.Data.SelectDistrictOverviewCard(card.DistrictViewName);
+ }
+
private void EnsureNavigationSelection()
{
if (!ViewModel.IsPageAvailable(ViewModel.CurrentPage))
diff --git a/Tornado3_2026Election/Persistence/AppState.cs b/Tornado3_2026Election/Persistence/AppState.cs
index 9e9c928..106291b 100644
--- a/Tornado3_2026Election/Persistence/AppState.cs
+++ b/Tornado3_2026Election/Persistence/AppState.cs
@@ -52,4 +52,6 @@ public sealed class AppState
public Dictionary CutDurations { get; set; } = [];
public Dictionary StationRegionFilters { get; set; } = [];
+
+ public Dictionary StationVideoWallLayouts { get; set; } = [];
}
diff --git a/Tornado3_2026Election/Services/CutAppearancePolicyCatalog.cs b/Tornado3_2026Election/Services/CutAppearancePolicyCatalog.cs
new file mode 100644
index 0000000..bacd7cb
--- /dev/null
+++ b/Tornado3_2026Election/Services/CutAppearancePolicyCatalog.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+
+namespace Tornado3_2026Election.Services;
+
+internal static class CutAppearancePolicyCatalog
+{
+ private static readonly IReadOnlyDictionary> DefaultAppearanceSectionsByTemplate =
+ new Dictionary>(StringComparer.Ordinal)
+ {
+ ["1-2위_ani_광역단체장"] = CreateSectionSet(
+ "정당판",
+ "정당바",
+ "득표수바",
+ "정당원",
+ "정당색",
+ "정당명",
+ "득표율")
+ };
+
+ public static bool UsesTemplateDefaultAppearance(string templateName, string sectionName)
+ {
+ return !string.IsNullOrWhiteSpace(templateName) &&
+ !string.IsNullOrWhiteSpace(sectionName) &&
+ DefaultAppearanceSectionsByTemplate.TryGetValue(templateName, out var sections) &&
+ sections.Contains(sectionName);
+ }
+
+ private static IReadOnlySet CreateSectionSet(params string[] sections)
+ {
+ return new HashSet(sections, StringComparer.Ordinal);
+ }
+}
diff --git a/Tornado3_2026Election/Services/CutDebugStateStore.cs b/Tornado3_2026Election/Services/CutDebugStateStore.cs
new file mode 100644
index 0000000..26eeab2
--- /dev/null
+++ b/Tornado3_2026Election/Services/CutDebugStateStore.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using Tornado3_2026Election.Domain;
+
+namespace Tornado3_2026Election.Services;
+
+public sealed class CutDebugStateStore
+{
+ private readonly object _syncRoot = new();
+ private readonly Dictionary _settingsByChannel = new();
+ private readonly Dictionary _templateStates = new(StringComparer.Ordinal);
+
+ public CutDebugStateStore()
+ {
+ foreach (var channel in Enum.GetValues())
+ {
+ _settingsByChannel[channel] = new CutDebugSettings();
+ }
+ }
+
+ public CutDebugSettings Get(BroadcastChannel channel)
+ {
+ return _settingsByChannel[channel];
+ }
+
+ public CutDebugTemplateState GetTemplate(BroadcastChannel channel, string formatId, string displayName)
+ {
+ var templateKey = BuildTemplateKey(channel, formatId);
+ lock (_syncRoot)
+ {
+ if (!_templateStates.TryGetValue(templateKey, out var templateState))
+ {
+ templateState = new CutDebugTemplateState(formatId, displayName);
+ _templateStates[templateKey] = templateState;
+ }
+ else
+ {
+ templateState.UpdateDisplayName(displayName);
+ }
+
+ return templateState;
+ }
+ }
+
+ public CutDebugTemplateState? FindTemplate(BroadcastChannel channel, string formatId)
+ {
+ var templateKey = BuildTemplateKey(channel, formatId);
+ lock (_syncRoot)
+ {
+ return _templateStates.TryGetValue(templateKey, out var templateState)
+ ? templateState
+ : null;
+ }
+ }
+
+ private static string BuildTemplateKey(BroadcastChannel channel, string formatId)
+ {
+ return $"{channel}|{formatId}";
+ }
+}
diff --git a/Tornado3_2026Election/Services/CutListElectionCategoryResolver.cs b/Tornado3_2026Election/Services/CutListElectionCategoryResolver.cs
new file mode 100644
index 0000000..4d6e1ff
--- /dev/null
+++ b/Tornado3_2026Election/Services/CutListElectionCategoryResolver.cs
@@ -0,0 +1,52 @@
+using System;
+using Tornado3_2026Election.Domain;
+
+namespace Tornado3_2026Election.Services;
+
+public static class CutListElectionCategoryResolver
+{
+ public static CutListElectionCategory Resolve(string? formatName)
+ {
+ var resolvedFormatName = formatName ?? string.Empty;
+
+ if (resolvedFormatName.Contains("\uAD50\uC721\uAC10", StringComparison.Ordinal))
+ {
+ return CutListElectionCategory.Superintendent;
+ }
+
+ if (resolvedFormatName.Contains("\uAE30\uCD08\uC758\uC6D0", StringComparison.Ordinal))
+ {
+ return CutListElectionCategory.LocalCouncil;
+ }
+
+ if (resolvedFormatName.Contains("\uAE30\uCD08\uB2E8\uCCB4\uC7A5", StringComparison.Ordinal))
+ {
+ return CutListElectionCategory.LocalHead;
+ }
+
+ if (resolvedFormatName.Contains("\uAD11\uC5ED\uC758\uC6D0", StringComparison.Ordinal))
+ {
+ return CutListElectionCategory.MetropolitanCouncil;
+ }
+
+ if (resolvedFormatName.Contains("\uAD11\uC5ED\uB2E8\uCCB4\uC7A5", StringComparison.Ordinal) ||
+ resolvedFormatName.Contains("\uBCF4\uAADC\uC120\uAC70", StringComparison.Ordinal))
+ {
+ return CutListElectionCategory.MetropolitanHead;
+ }
+
+ return CutListElectionCategory.MetropolitanHead;
+ }
+
+ public static string GetLabel(CutListElectionCategory category)
+ {
+ return category switch
+ {
+ CutListElectionCategory.MetropolitanCouncil => "\uAD11\uC5ED\uC758\uC6D0",
+ CutListElectionCategory.Superintendent => "\uAD50\uC721\uAC10",
+ CutListElectionCategory.LocalHead => "\uAE30\uCD08\uB2E8\uCCB4\uC7A5",
+ CutListElectionCategory.LocalCouncil => "\uAE30\uCD08\uC758\uC6D0",
+ _ => "\uAD11\uC5ED\uB2E8\uCCB4\uC7A5"
+ };
+ }
+}
diff --git a/Tornado3_2026Election/Services/FormatCatalogService.cs b/Tornado3_2026Election/Services/FormatCatalogService.cs
index 0a472fe..9e07dbf 100644
--- a/Tornado3_2026Election/Services/FormatCatalogService.cs
+++ b/Tornado3_2026Election/Services/FormatCatalogService.cs
@@ -9,7 +9,14 @@ namespace Tornado3_2026Election.Services;
public sealed class FormatCatalogService
{
private static readonly IReadOnlyDictionary LegacyFormatAliases = BuildLegacyFormatAliases();
- private readonly IReadOnlyList _formats = BuildFormats();
+ private readonly string _t3CutPath;
+ private readonly IReadOnlyList _formats;
+
+ public FormatCatalogService(string? configuredT3CutPath = null)
+ {
+ _t3CutPath = ResolveT3CutPath(configuredT3CutPath);
+ _formats = BuildFormats(_t3CutPath);
+ }
public IReadOnlyList GetAll() => _formats;
@@ -33,7 +40,7 @@ public sealed class FormatCatalogService
return _formats.FirstOrDefault(format => string.Equals(format.Id, formatId, StringComparison.Ordinal));
}
- private static IReadOnlyList BuildFormats()
+ private static IReadOnlyList BuildFormats(string t3CutPath)
{
List formats = [];
@@ -41,6 +48,7 @@ public sealed class FormatCatalogService
BroadcastChannel.Bottom,
"Elect2026_Bottom_민방",
8,
+ t3CutPath,
"1-2위_광역단체장",
"1-2위_기초단체장",
"1-3위_광역단체장",
@@ -61,6 +69,7 @@ public sealed class FormatCatalogService
BroadcastChannel.Normal,
"Elect2026_Normal_민방",
10,
+ t3CutPath,
"1-2위_ani_광역단체장",
"1-2위_ani_기초단체장",
"1-2위_ani_기초단체장_5760",
@@ -129,6 +138,7 @@ public sealed class FormatCatalogService
BroadcastChannel.TopLeft,
"Elect2026_Top_민방",
6,
+ t3CutPath,
"광역단체장_2인",
"광역단체장_2인_텍스트",
"기초단체장_2인",
@@ -148,24 +158,28 @@ public sealed class FormatCatalogService
BroadcastChannel channel,
string relativeFolder,
double defaultCutDurationSeconds,
+ string t3CutPath,
params string[] baseNames)
{
foreach (var baseName in baseNames)
{
var isAvailableInBothPhases = IsAvailableInBothPhases(baseName);
var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(baseName);
+ var sceneResolution = TryReadSceneResolution(relativeFolder, baseName, t3CutPath);
yield return new FormatTemplateDefinition
{
Id = Path.Combine(relativeFolder, baseName),
Name = baseName,
Description = $"{relativeFolder} 컷",
- RecommendedChannel = ResolveRecommendedChannel(channel, baseName),
+ RecommendedChannel = ResolveRecommendedChannel(channel, baseName, sceneResolution),
RequiresImage = false,
SupportsPreElection = isAvailableInBothPhases || isPreElectionOnlyFormat,
SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat,
RequiresCandidateData = !isPreElectionOnlyFormat && !IsHistoricalPreElectionWinnerFormat(baseName),
LoopMode = LoopMode.None,
+ SceneWidth = sceneResolution?.Width,
+ SceneHeight = sceneResolution?.Height,
Cuts =
[
new FormatCutDefinition
@@ -233,8 +247,21 @@ public sealed class FormatCatalogService
return baseName.StartsWith("사전_역대당선", StringComparison.Ordinal);
}
- private static BroadcastChannel ResolveRecommendedChannel(BroadcastChannel fallbackChannel, string baseName)
+ private static BroadcastChannel ResolveRecommendedChannel(
+ BroadcastChannel fallbackChannel,
+ string baseName,
+ KarismaSceneResolution? sceneResolution)
{
+ if (fallbackChannel != BroadcastChannel.Normal)
+ {
+ return fallbackChannel;
+ }
+
+ if (TryResolveRecommendedChannelFromSceneSize(sceneResolution, out var resolvedChannel))
+ {
+ return resolvedChannel;
+ }
+
return IsVideoWallFormat(baseName)
? BroadcastChannel.VideoWall
: fallbackChannel;
@@ -245,4 +272,48 @@ public sealed class FormatCatalogService
return baseName.Contains("_5760", StringComparison.Ordinal) ||
baseName.Contains("_L", StringComparison.Ordinal);
}
+
+ private static KarismaSceneResolution? TryReadSceneResolution(string relativeFolder, string baseName, string t3CutPath)
+ {
+ if (string.IsNullOrWhiteSpace(t3CutPath))
+ {
+ return null;
+ }
+
+ var scenePath = Path.Combine(t3CutPath, relativeFolder, baseName + ".tscn");
+ return KarismaSceneResolutionReader.TryRead(scenePath, out var resolution)
+ ? resolution
+ : null;
+ }
+
+ private static bool TryResolveRecommendedChannelFromSceneSize(
+ KarismaSceneResolution? sceneResolution,
+ out BroadcastChannel channel)
+ {
+ channel = BroadcastChannel.Normal;
+
+ if (sceneResolution is null)
+ {
+ return false;
+ }
+
+ channel = sceneResolution.Value is { Width: 1920, Height: 1080 }
+ ? BroadcastChannel.Normal
+ : sceneResolution.Value.Width > 1920 && sceneResolution.Value.Height == 1080
+ ? BroadcastChannel.VideoWall
+ : BroadcastChannel.Normal;
+
+ return true;
+ }
+
+ private static string ResolveT3CutPath(string? configuredT3CutPath)
+ {
+ var normalizedPath = TornadoPathResolver.NormalizeConfiguredPath(configuredT3CutPath);
+ if (!string.IsNullOrWhiteSpace(normalizedPath))
+ {
+ return normalizedPath;
+ }
+
+ return TornadoPathResolver.GetDefaultT3CutPath();
+ }
}
diff --git a/Tornado3_2026Election/Services/KarismaSceneResolutionReader.cs b/Tornado3_2026Election/Services/KarismaSceneResolutionReader.cs
new file mode 100644
index 0000000..f0741b2
--- /dev/null
+++ b/Tornado3_2026Election/Services/KarismaSceneResolutionReader.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Buffers.Binary;
+using System.IO;
+
+namespace Tornado3_2026Election.Services;
+
+public static class KarismaSceneResolutionReader
+{
+ private const int HeaderScanLength = 1024;
+ private const int Float50Bits = 1112014848;
+ private const int Float1000Bits = 1148846080;
+
+ public static bool TryRead(string scenePath, out KarismaSceneResolution resolution)
+ {
+ resolution = default;
+
+ if (string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
+ {
+ return false;
+ }
+
+ try
+ {
+ using var stream = File.OpenRead(scenePath);
+ Span header = stackalloc byte[HeaderScanLength];
+ var bytesRead = stream.Read(header);
+ if (bytesRead < 16)
+ {
+ return false;
+ }
+
+ var headerSlice = header[..bytesRead];
+ for (var offset = 0; offset <= headerSlice.Length - 16; offset += 4)
+ {
+ var width = BinaryPrimitives.ReadInt32LittleEndian(headerSlice.Slice(offset, 4));
+ var height = BinaryPrimitives.ReadInt32LittleEndian(headerSlice.Slice(offset + 4, 4));
+ var marker50 = BinaryPrimitives.ReadInt32LittleEndian(headerSlice.Slice(offset + 8, 4));
+ var marker1000 = BinaryPrimitives.ReadInt32LittleEndian(headerSlice.Slice(offset + 12, 4));
+
+ if (!IsPlausibleDimension(width, height) ||
+ marker50 != Float50Bits ||
+ marker1000 != Float1000Bits)
+ {
+ continue;
+ }
+
+ resolution = new KarismaSceneResolution(width, height);
+ return true;
+ }
+ }
+ catch
+ {
+ }
+
+ return false;
+ }
+
+ private static bool IsPlausibleDimension(int width, int height)
+ {
+ return width is >= 320 and <= 20000 &&
+ height is >= 180 and <= 12000;
+ }
+}
+
+public readonly record struct KarismaSceneResolution(int Width, int Height);
diff --git a/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs b/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
index 9b09929..02b0b61 100644
--- a/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
+++ b/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
@@ -10,8 +10,9 @@ public sealed class KarismaSceneVariableCatalog
{
private static readonly string[] PreferredReportNames =
[
- "TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md",
"TSCN_VARIABLE_DISCOVERY_E_DRIVE.md",
+ "TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md",
+ "TSCN_VARIABLE_DISCOVERY_ONE.md",
"TSCN_VARIABLE_DISCOVERY.md"
];
@@ -28,8 +29,8 @@ public sealed class KarismaSceneVariableCatalog
public static KarismaSceneVariableCatalog Load(LogService logService)
{
- var reportPath = FindDiscoveryReportPath();
- if (string.IsNullOrWhiteSpace(reportPath) || !File.Exists(reportPath))
+ var reportPaths = FindDiscoveryReportPaths().ToArray();
+ if (reportPaths.Length == 0)
{
logService.Warning("Karisma scene variable catalog report was not found. Falling back to runtime value heuristics.");
return new KarismaSceneVariableCatalog(
@@ -38,8 +39,17 @@ public sealed class KarismaSceneVariableCatalog
try
{
- var scenes = ParseReport(reportPath);
- logService.Info($"Karisma scene variable catalog loaded: scenes={scenes.Count} source='{reportPath}'.");
+ var mergedScenes = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (var reportPath in reportPaths)
+ {
+ MergeScenes(mergedScenes, ParseReport(reportPath));
+ }
+
+ var scenes = mergedScenes.ToDictionary(
+ pair => pair.Key,
+ pair => (IReadOnlyDictionary)pair.Value,
+ StringComparer.OrdinalIgnoreCase);
+ logService.Info($"Karisma scene variable catalog loaded: scenes={scenes.Count} sources={reportPaths.Length}.");
return new KarismaSceneVariableCatalog(scenes);
}
catch (Exception ex)
@@ -60,22 +70,45 @@ public sealed class KarismaSceneVariableCatalog
}
var relativePath = NormalizeRelativePath(Path.GetRelativePath(t3CutPath, scenePath));
- return _scenes.TryGetValue(relativePath, out var variables)
- ? variables
- : EmptySceneVariables;
+ if (_scenes.TryGetValue(relativePath, out var variables))
+ {
+ return variables;
+ }
+
+ var fileName = Path.GetFileName(relativePath);
+ if (!string.IsNullOrWhiteSpace(fileName))
+ {
+ var fileNameMatches = _scenes
+ .Where(pair => string.Equals(Path.GetFileName(pair.Key), fileName, StringComparison.OrdinalIgnoreCase))
+ .Take(2)
+ .ToArray();
+ if (fileNameMatches.Length == 1)
+ {
+ return fileNameMatches[0].Value;
+ }
+ }
+
+ return EmptySceneVariables;
}
private static IReadOnlyDictionary> ParseReport(string reportPath)
{
var scenes = new Dictionary>(StringComparer.OrdinalIgnoreCase);
string? currentScene = null;
+ string? reportRootRelativePath = null;
foreach (var rawLine in File.ReadLines(reportPath, Encoding.UTF8))
{
var line = rawLine.Trim();
+ if (TryParseReportRoot(line, out var reportRoot))
+ {
+ reportRootRelativePath = NormalizeReportRootRelativePath(reportRoot);
+ continue;
+ }
+
if (TryParseSceneHeader(line, out var sceneRelativePath))
{
- currentScene = NormalizeRelativePath(sceneRelativePath);
+ currentScene = NormalizeSceneKey(reportRootRelativePath, sceneRelativePath);
if (!scenes.ContainsKey(currentScene))
{
scenes[currentScene] = new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -124,6 +157,25 @@ public sealed class KarismaSceneVariableCatalog
StringComparer.OrdinalIgnoreCase);
}
+ private static void MergeScenes(
+ IDictionary> target,
+ IReadOnlyDictionary> source)
+ {
+ foreach (var (scenePath, variables) in source)
+ {
+ if (!target.TryGetValue(scenePath, out var mergedVariables))
+ {
+ mergedVariables = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ target[scenePath] = mergedVariables;
+ }
+
+ foreach (var (variableName, definition) in variables)
+ {
+ mergedVariables[variableName] = definition;
+ }
+ }
+ }
+
private static bool TryParseSceneHeader(string line, out string sceneRelativePath)
{
sceneRelativePath = string.Empty;
@@ -136,6 +188,19 @@ public sealed class KarismaSceneVariableCatalog
return !string.IsNullOrWhiteSpace(sceneRelativePath);
}
+ private static bool TryParseReportRoot(string line, out string reportRoot)
+ {
+ reportRoot = string.Empty;
+ const string prefix = "- Root: `";
+ if (!line.StartsWith(prefix, StringComparison.Ordinal) || !line.EndsWith('`'))
+ {
+ return false;
+ }
+
+ reportRoot = line.Substring(prefix.Length, line.Length - prefix.Length - 1);
+ return !string.IsNullOrWhiteSpace(reportRoot);
+ }
+
private static List SplitMarkdownRow(string line)
{
var cells = line.Split('|');
@@ -158,6 +223,11 @@ public sealed class KarismaSceneVariableCatalog
return KarismaSceneVariableKind.Counter;
}
+ if (IsLikelyCounterVariableName(variableName))
+ {
+ return KarismaSceneVariableKind.Counter;
+ }
+
if (variableName.StartsWith("\uC720\uD655\uB2F9", StringComparison.OrdinalIgnoreCase))
{
return KarismaSceneVariableKind.VideoResource;
@@ -179,8 +249,18 @@ public sealed class KarismaSceneVariableCatalog
return KarismaSceneVariableKind.Text;
}
- private static string? FindDiscoveryReportPath()
+ private static bool IsLikelyCounterVariableName(string variableName)
{
+ return variableName.StartsWith("득표율", StringComparison.Ordinal) ||
+ variableName.StartsWith("투표율", StringComparison.Ordinal) ||
+ variableName.StartsWith("전국투표율", StringComparison.Ordinal);
+ }
+
+ private static IEnumerable FindDiscoveryReportPaths()
+ {
+ var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var reportPaths = new List();
+
foreach (var startPath in EnumerateSearchRoots())
{
var current = startPath;
@@ -189,23 +269,25 @@ public sealed class KarismaSceneVariableCatalog
foreach (var reportName in PreferredReportNames)
{
var candidate = Path.Combine(current, reportName);
- if (File.Exists(candidate))
+ if (File.Exists(candidate) && seenPaths.Add(candidate))
{
- return candidate;
+ reportPaths.Add(candidate);
}
}
- var wildcardCandidate = TryFindLatestDiscoveryReport(current);
- if (!string.IsNullOrWhiteSpace(wildcardCandidate))
+ foreach (var wildcardCandidate in TryFindDiscoveryReports(current))
{
- return wildcardCandidate;
+ if (seenPaths.Add(wildcardCandidate))
+ {
+ reportPaths.Add(wildcardCandidate);
+ }
}
current = Path.GetDirectoryName(current);
}
}
- return null;
+ return reportPaths;
}
private static IEnumerable EnumerateSearchRoots()
@@ -222,7 +304,7 @@ public sealed class KarismaSceneVariableCatalog
return roots;
}
- private static string? TryFindLatestDiscoveryReport(string directoryPath)
+ private static IEnumerable TryFindDiscoveryReports(string directoryPath)
{
try
{
@@ -230,12 +312,51 @@ public sealed class KarismaSceneVariableCatalog
.Where(path => !Path.GetFileName(path).Contains("SAMPLE", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(path => File.GetLastWriteTimeUtc(path))
.ThenBy(path => path, StringComparer.OrdinalIgnoreCase)
- .FirstOrDefault();
+ .ToArray();
}
catch
+ {
+ return [];
+ }
+ }
+
+ private static string NormalizeSceneKey(string? reportRootRelativePath, string sceneRelativePath)
+ {
+ var normalizedScenePath = NormalizeRelativePath(sceneRelativePath);
+ if (string.IsNullOrWhiteSpace(reportRootRelativePath) ||
+ string.IsNullOrWhiteSpace(normalizedScenePath) ||
+ normalizedScenePath.Contains('\\'))
+ {
+ return normalizedScenePath;
+ }
+
+ return NormalizeRelativePath(Path.Combine(reportRootRelativePath, normalizedScenePath));
+ }
+
+ private static string? NormalizeReportRootRelativePath(string reportRootPath)
+ {
+ if (string.IsNullOrWhiteSpace(reportRootPath))
{
return null;
}
+
+ var normalized = reportRootPath.Replace('/', '\\').Trim().TrimEnd('\\');
+ if (normalized.EndsWith("\\T3_Cut", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Empty;
+ }
+
+ const string marker = "\\T3_Cut\\";
+ var markerIndex = normalized.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (markerIndex >= 0)
+ {
+ return NormalizeRelativePath(normalized[(markerIndex + marker.Length)..]);
+ }
+
+ var leafFolder = Path.GetFileName(normalized);
+ return string.IsNullOrWhiteSpace(leafFolder)
+ ? null
+ : NormalizeRelativePath(leafFolder);
}
private static string NormalizeRelativePath(string relativePath)
diff --git a/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs b/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs
index 1144aa6..e62bea7 100644
--- a/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs
+++ b/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs
@@ -11,8 +11,6 @@ namespace Tornado3_2026Election.Services;
public sealed class KarismaThumbnailGeneratorService
{
private const int DefaultKarismaPort = 30001;
- private const int ThumbnailWidth = 320;
- private const int ThumbnailHeight = 180;
private const int ThumbnailFrame = -1;
private readonly LogService _logService;
@@ -29,6 +27,7 @@ public sealed class KarismaThumbnailGeneratorService
public async Task GenerateAsync(
IReadOnlyList templates,
string configuredT3CutPath,
+ VideoWallLayoutPreset videoWallLayoutPreset,
CancellationToken cancellationToken)
{
var t3CutPath = string.IsNullOrWhiteSpace(configuredT3CutPath)
@@ -80,12 +79,13 @@ public sealed class KarismaThumbnailGeneratorService
Directory.CreateDirectory(targetDirectory);
}
+ var thumbnailSize = ThumbnailLayoutResolver.ResolveGenerationSize(template, videoWallLayoutPreset);
await manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false);
await manager.SaveSceneImageAsync(
resolvedScene.Alias,
targetPath,
- ThumbnailWidth,
- ThumbnailHeight,
+ thumbnailSize.Width,
+ thumbnailSize.Height,
ThumbnailFrame,
cancellationToken).ConfigureAwait(false);
diff --git a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
index 96c250c..616f36c 100644
--- a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
+++ b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
@@ -62,6 +62,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private readonly LogService _logService;
private readonly Func _t3CutPathProvider;
private readonly KarismaSceneVariableCatalog _sceneVariableCatalog;
+ private readonly CutDebugStateStore _cutDebugStateStore;
private readonly IReadOnlyDictionary _bindings;
private readonly string _connectionTarget;
private readonly Dictionary _pendingScenes = new();
@@ -74,6 +75,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
LogService logService,
Func t3CutPathProvider,
KarismaSceneVariableCatalog sceneVariableCatalog,
+ CutDebugStateStore cutDebugStateStore,
string connectionTarget,
IReadOnlyDictionary bindings)
{
@@ -81,6 +83,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
_logService = logService;
_t3CutPathProvider = t3CutPathProvider;
_sceneVariableCatalog = sceneVariableCatalog;
+ _cutDebugStateStore = cutDebugStateStore;
_connectionTarget = connectionTarget;
_bindings = bindings;
_manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty);
@@ -113,14 +116,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
public event EventHandler? ConnectionChanged;
- public static ITornado3Adapter CreateOrFallback(LogService logService, Func t3CutPathProvider)
+ public static ITornado3Adapter CreateOrFallback(LogService logService, Func t3CutPathProvider, CutDebugStateStore cutDebugStateStore)
{
- return TryCreate(logService, t3CutPathProvider, out var adapter)
+ return TryCreate(logService, t3CutPathProvider, cutDebugStateStore, out var adapter)
? adapter
: new MockTornado3Adapter(logService);
}
- public static bool TryCreate(LogService logService, Func t3CutPathProvider, out ITornado3Adapter adapter)
+ public static bool TryCreate(LogService logService, Func t3CutPathProvider, CutDebugStateStore cutDebugStateStore, out ITornado3Adapter adapter)
{
var host = Environment.GetEnvironmentVariable("TORNADO_KARISMA_HOST");
if (string.IsNullOrWhiteSpace(host))
@@ -159,6 +162,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
logService,
t3CutPathProvider,
sceneVariableCatalog,
+ cutDebugStateStore,
$"{host}:{port}",
BuildBindings());
return true;
@@ -198,23 +202,72 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
var judgementVisibilityUpdates = BuildJudgementVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
var historicalWinnerVisibilityUpdates = BuildHistoricalWinnerVisibilityUpdates(template, cut, snapshot, sceneVariables);
var careerPromiseVisibilityUpdates = BuildCareerPromiseVisibilityUpdates(template, cut, snapshot, sceneVariables);
+ var cutDebug = _cutDebugStateStore.Get(channel).CreateSnapshot();
+ var templateDebug = _cutDebugStateStore.FindTemplate(channel, template.Id);
+ var filteredValues = FilterObjectValues(values, sceneVariables, cutDebug, templateDebug);
+ var filteredCounterNumberKeys = FilterCounterNumberKeyUpdates(counterNumberKeys, cutDebug, templateDebug);
+ var filteredStyleColorUpdates = FilterStyleColorUpdates(styleColorUpdates, cutDebug, templateDebug);
+ var filteredJudgementVisibilityUpdates = FilterVisibilityUpdatePair(judgementVisibilityUpdates, cutDebug, templateDebug);
+ var filteredHistoricalWinnerVisibilityUpdates = FilterVisibilityUpdatePair(historicalWinnerVisibilityUpdates, cutDebug, templateDebug);
+ var filteredCareerPromiseVisibilityUpdates = FilterVisibilityUpdatePair(careerPromiseVisibilityUpdates, cutDebug, templateDebug);
+ var overriddenValues = ApplyObjectValueOverrides(filteredValues, sceneVariables, templateDebug);
+ var overriddenCounterNumberKeys = ApplyCounterNumberKeyOverrides(filteredCounterNumberKeys, sceneVariables, templateDebug);
+ var overriddenStyleColorUpdates = ApplyStyleColorOverrides(filteredStyleColorUpdates, templateDebug);
+ var overriddenJudgementVisibilityUpdates = ApplyVisibilityOverrides(filteredJudgementVisibilityUpdates, sceneVariables, templateDebug);
+ var overriddenHistoricalWinnerVisibilityUpdates = ApplyVisibilityOverrides(filteredHistoricalWinnerVisibilityUpdates, sceneVariables, templateDebug);
+ var overriddenCareerPromiseVisibilityUpdates = ApplyVisibilityOverrides(filteredCareerPromiseVisibilityUpdates, sceneVariables, templateDebug);
LogUnsupportedSceneVariables(channel, template, sceneVariables);
+ LogCutDebugSummary(
+ channel,
+ template,
+ cut,
+ cutDebug,
+ values.Count,
+ overriddenValues.Count,
+ overriddenValues.Keys,
+ counterNumberKeys.Count,
+ overriddenCounterNumberKeys.Count,
+ overriddenCounterNumberKeys.Select(update => update.ObjectName),
+ styleColorUpdates.Count,
+ overriddenStyleColorUpdates.Count,
+ overriddenStyleColorUpdates.Select(update => update.ObjectName),
+ judgementVisibilityUpdates.HideBeforeValue.Count + judgementVisibilityUpdates.ShowAfterValue.Count +
+ historicalWinnerVisibilityUpdates.HideBeforeValue.Count + historicalWinnerVisibilityUpdates.ShowAfterValue.Count +
+ careerPromiseVisibilityUpdates.HideBeforeValue.Count + careerPromiseVisibilityUpdates.ShowAfterValue.Count,
+ overriddenJudgementVisibilityUpdates.HideBeforeValue.Count + overriddenJudgementVisibilityUpdates.ShowAfterValue.Count +
+ overriddenHistoricalWinnerVisibilityUpdates.HideBeforeValue.Count + overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue.Count +
+ overriddenCareerPromiseVisibilityUpdates.HideBeforeValue.Count + overriddenCareerPromiseVisibilityUpdates.ShowAfterValue.Count,
+ overriddenJudgementVisibilityUpdates.HideBeforeValue
+ .Concat(overriddenJudgementVisibilityUpdates.ShowAfterValue)
+ .Concat(overriddenHistoricalWinnerVisibilityUpdates.HideBeforeValue)
+ .Concat(overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue)
+ .Concat(overriddenCareerPromiseVisibilityUpdates.HideBeforeValue)
+ .Concat(overriddenCareerPromiseVisibilityUpdates.ShowAfterValue)
+ .Select(update => update.ObjectName));
+ LogCutDebugOverrides(
+ channel,
+ template,
+ cutDebug,
+ filteredValues,
+ overriddenValues,
+ filteredCounterNumberKeys,
+ overriddenCounterNumberKeys);
State = TornadoConnectionState.Sending;
await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false);
await _manager.ApplyValuesAsync(
resolvedScene.Alias,
- judgementVisibilityUpdates.HideBeforeValue
- .Concat(historicalWinnerVisibilityUpdates.HideBeforeValue)
- .Concat(careerPromiseVisibilityUpdates.HideBeforeValue)
+ overriddenJudgementVisibilityUpdates.HideBeforeValue
+ .Concat(overriddenHistoricalWinnerVisibilityUpdates.HideBeforeValue)
+ .Concat(overriddenCareerPromiseVisibilityUpdates.HideBeforeValue)
.ToArray(),
- values,
- counterNumberKeys,
- styleColorUpdates,
- judgementVisibilityUpdates.ShowAfterValue
- .Concat(historicalWinnerVisibilityUpdates.ShowAfterValue)
- .Concat(careerPromiseVisibilityUpdates.ShowAfterValue)
+ overriddenValues,
+ overriddenCounterNumberKeys,
+ overriddenStyleColorUpdates,
+ overriddenJudgementVisibilityUpdates.ShowAfterValue
+ .Concat(overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue)
+ .Concat(overriddenCareerPromiseVisibilityUpdates.ShowAfterValue)
.ToArray(),
cancellationToken).ConfigureAwait(false);
@@ -715,6 +768,16 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
ElectionDataSnapshot snapshot,
IReadOnlyDictionary sceneVariables)
{
+ if (IsHistoricalTurnoutTemplate(template.Name))
+ {
+ return BuildHistoricalTurnoutCounterNumberKeyUpdates(snapshot, sceneVariables);
+ }
+
+ if (IsTurnoutTemplate(template.Name))
+ {
+ return BuildTurnoutCounterNumberKeyUpdates(snapshot, sceneVariables);
+ }
+
if (!IsAnimatedTemplate(template))
{
return Array.Empty();
@@ -747,6 +810,621 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates;
}
+ private static IReadOnlyList BuildTurnoutCounterNumberKeyUpdates(
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var updates = new List();
+
+ void AddOrUpdate(string variableName, double numberValue)
+ {
+ if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
+ {
+ return;
+ }
+
+ var update = new KarismaCounterNumberKeyUpdate(
+ variableName,
+ 1,
+ Math.Round(numberValue, 1, MidpointRounding.AwayFromZero));
+ var index = updates.FindIndex(existing => string.Equals(existing.ObjectName, variableName, StringComparison.OrdinalIgnoreCase));
+ if (index >= 0)
+ {
+ updates[index] = update;
+ }
+ else
+ {
+ updates.Add(update);
+ }
+ }
+
+ if (snapshot.TurnoutBoardSlots.Count > 0)
+ {
+ foreach (var slotEntry in snapshot.TurnoutBoardSlots.OrderBy(entry => entry.Slot))
+ {
+ AddOrUpdate($"투표율{slotEntry.Slot:00}", slotEntry.TurnoutRate);
+ }
+ }
+ else
+ {
+ AddOrUpdate("투표율01", snapshot.TurnoutRate);
+ }
+
+ AddOrUpdate("전국투표율01", snapshot.NationalTurnoutRate);
+
+ return updates;
+ }
+
+ private static IReadOnlyList BuildHistoricalTurnoutCounterNumberKeyUpdates(
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var updates = new List(6);
+ for (var slot = 1; slot <= 6; slot++)
+ {
+ var variableName = $"투표율{slot:00}";
+ if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
+ {
+ continue;
+ }
+
+ updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, 0));
+ }
+
+ var orderedTurnout = snapshot.HistoricalTurnoutHistory
+ .OrderBy(entry => entry.Year)
+ .ToArray();
+ foreach (var turnout in orderedTurnout)
+ {
+ var slot = turnout.ElectionOrder - 2;
+ if (slot is < 1 or > 6)
+ {
+ continue;
+ }
+
+ var variableName = $"투표율{slot:00}";
+ if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
+ {
+ continue;
+ }
+
+ var index = updates.FindIndex(update => string.Equals(update.ObjectName, variableName, StringComparison.OrdinalIgnoreCase));
+ var update = new KarismaCounterNumberKeyUpdate(
+ variableName,
+ 1,
+ Math.Round(turnout.TurnoutRate, 1, MidpointRounding.AwayFromZero));
+
+ if (index >= 0)
+ {
+ updates[index] = update;
+ }
+ else
+ {
+ updates.Add(update);
+ }
+ }
+
+ return updates;
+ }
+
+ private void LogCutDebugSummary(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ CutDebugSettingsSnapshot cutDebug,
+ int originalValueCount,
+ int filteredValueCount,
+ IEnumerable filteredValueKeys,
+ int originalCounterCount,
+ int filteredCounterCount,
+ IEnumerable filteredCounterKeys,
+ int originalStyleCount,
+ int filteredStyleCount,
+ IEnumerable filteredStyleKeys,
+ int originalVisibilityCount,
+ int filteredVisibilityCount,
+ IEnumerable filteredVisibilityKeys)
+ {
+ if (!cutDebug.IsEnabled)
+ {
+ return;
+ }
+
+ var valuePreview = BuildDebugKeyPreview(filteredValueKeys);
+ var counterPreview = BuildDebugKeyPreview(filteredCounterKeys);
+ var stylePreview = BuildDebugKeyPreview(filteredStyleKeys);
+ var visibilityPreview = BuildDebugKeyPreview(filteredVisibilityKeys);
+
+ _logService.Info(
+ $"[{channel}] Cut debug active {template.Name}/{cut.Name}: " +
+ $"text={(cutDebug.ApplyTextValues ? "ON" : "OFF")}, " +
+ $"image={(cutDebug.ApplyImageValues ? "ON" : "OFF")}, " +
+ $"visibility={(cutDebug.ApplyVisibilityValues ? "ON" : "OFF")}, " +
+ $"voteRateText={(cutDebug.ApplyTextValues && cutDebug.ApplyVoteRateTextValues ? "ON" : "OFF")}, " +
+ $"voteRateCounter={(cutDebug.ApplyVoteRateCounterValues ? "ON" : "OFF")}, " +
+ $"partyBarColor={(cutDebug.ApplyPartyBarStyleColors ? "ON" : "OFF")}, " +
+ $"partyPlateColor={(cutDebug.ApplyPartyPlateStyleColors ? "ON" : "OFF")}, " +
+ $"voteRateColor={(cutDebug.ApplyVoteRateStyleColors ? "ON" : "OFF")} | " +
+ $"values {filteredValueCount}/{originalValueCount}, " +
+ $"counters {filteredCounterCount}/{originalCounterCount}, " +
+ $"styles {filteredStyleCount}/{originalStyleCount}, " +
+ $"visibility {filteredVisibilityCount}/{originalVisibilityCount} | " +
+ $"valueKeys={valuePreview} | " +
+ $"counterKeys={counterPreview} | " +
+ $"styleKeys={stylePreview} | " +
+ $"visibilityKeys={visibilityPreview}");
+ }
+
+ private static string BuildDebugKeyPreview(IEnumerable keys)
+ {
+ var orderedKeys = keys
+ .Where(key => !string.IsNullOrWhiteSpace(key))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (orderedKeys.Length == 0)
+ {
+ return "(none)";
+ }
+
+ const int previewLimit = 8;
+ return orderedKeys.Length <= previewLimit
+ ? string.Join(", ", orderedKeys)
+ : $"{string.Join(", ", orderedKeys.Take(previewLimit))}, ... (+{orderedKeys.Length - previewLimit})";
+ }
+
+ private void LogCutDebugOverrides(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ CutDebugSettingsSnapshot cutDebug,
+ IReadOnlyDictionary filteredValues,
+ IReadOnlyDictionary overriddenValues,
+ IReadOnlyList filteredCounterNumberKeys,
+ IReadOnlyList overriddenCounterNumberKeys)
+ {
+ if (!cutDebug.IsEnabled)
+ {
+ return;
+ }
+
+ var valueChanges = overriddenValues
+ .Where(pair =>
+ !filteredValues.TryGetValue(pair.Key, out var originalValue) ||
+ !string.Equals(originalValue, pair.Value, StringComparison.Ordinal))
+ .Select(pair => $"{pair.Key}={TrimDebugValue(pair.Value)}")
+ .Take(8)
+ .ToArray();
+
+ var filteredCounterLookup = filteredCounterNumberKeys.ToDictionary(
+ update => $"{update.ObjectName}|{update.KeyIndex}",
+ update => update.Number,
+ StringComparer.OrdinalIgnoreCase);
+ var counterChanges = overriddenCounterNumberKeys
+ .Where(update =>
+ {
+ var lookupKey = $"{update.ObjectName}|{update.KeyIndex}";
+ return !filteredCounterLookup.TryGetValue(lookupKey, out var originalNumber) ||
+ Math.Abs(originalNumber - update.Number) > 0.0001d;
+ })
+ .Select(update => $"{update.ObjectName}#{update.KeyIndex}={update.Number:0.###}")
+ .Take(8)
+ .ToArray();
+
+ _logService.Info(
+ $"[{channel}] Cut debug overrides {template.Name}: " +
+ $"values={(valueChanges.Length == 0 ? "(none)" : string.Join(", ", valueChanges))} | " +
+ $"counters={(counterChanges.Length == 0 ? "(none)" : string.Join(", ", counterChanges))}");
+ }
+
+ private static string TrimDebugValue(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ var fileName = Path.GetFileName(value);
+ if (!string.IsNullOrWhiteSpace(fileName))
+ {
+ value = fileName;
+ }
+
+ return value.Length <= 48
+ ? value
+ : $"{value[..45]}...";
+ }
+
+ private static Dictionary FilterObjectValues(
+ IReadOnlyDictionary values,
+ IReadOnlyDictionary sceneVariables,
+ CutDebugSettingsSnapshot cutDebug,
+ CutDebugTemplateState? templateDebug)
+ {
+ if (!cutDebug.IsEnabled)
+ {
+ return new Dictionary(values, StringComparer.OrdinalIgnoreCase);
+ }
+
+ var filtered = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var pair in values)
+ {
+ if (!ShouldApplyObjectValue(pair.Key, sceneVariables, cutDebug, templateDebug))
+ {
+ continue;
+ }
+
+ filtered[pair.Key] = pair.Value;
+ }
+
+ return filtered;
+ }
+
+ private static IReadOnlyList FilterCounterNumberKeyUpdates(
+ IReadOnlyList updates,
+ CutDebugSettingsSnapshot cutDebug,
+ CutDebugTemplateState? templateDebug)
+ {
+ if (!cutDebug.IsEnabled)
+ {
+ return updates;
+ }
+
+ return updates
+ .Where(update =>
+ {
+ if (cutDebug.IsEnabled && !cutDebug.ApplyVoteRateCounterValues && IsVoteRateVariable(update.ObjectName))
+ {
+ return false;
+ }
+
+ return templateDebug is null || templateDebug.IsEnabled(update.ObjectName, CutDebugItemKind.Counter);
+ })
+ .ToArray();
+ }
+
+ private static IReadOnlyList FilterStyleColorUpdates(
+ IReadOnlyList updates,
+ CutDebugSettingsSnapshot cutDebug,
+ CutDebugTemplateState? templateDebug)
+ {
+ if (!cutDebug.IsEnabled)
+ {
+ return updates;
+ }
+
+ return updates
+ .Where(update => ShouldApplyStyleColor(update.ObjectName, cutDebug, templateDebug))
+ .ToArray();
+ }
+
+ private static (IReadOnlyList HideBeforeValue, IReadOnlyList ShowAfterValue) FilterVisibilityUpdatePair(
+ (IReadOnlyList HideBeforeValue, IReadOnlyList ShowAfterValue) updates,
+ CutDebugSettingsSnapshot cutDebug,
+ CutDebugTemplateState? templateDebug)
+ {
+ if (!cutDebug.IsEnabled)
+ {
+ return updates;
+ }
+
+ if (cutDebug.IsEnabled && !cutDebug.ApplyVisibilityValues)
+ {
+ return (Array.Empty(), Array.Empty());
+ }
+
+ return (
+ updates.HideBeforeValue
+ .Where(update => templateDebug is null || templateDebug.IsEnabled(update.ObjectName, CutDebugItemKind.Visibility))
+ .ToArray(),
+ updates.ShowAfterValue
+ .Where(update => templateDebug is null || templateDebug.IsEnabled(update.ObjectName, CutDebugItemKind.Visibility))
+ .ToArray());
+ }
+
+ private static Dictionary ApplyObjectValueOverrides(
+ IReadOnlyDictionary values,
+ IReadOnlyDictionary sceneVariables,
+ CutDebugTemplateState? templateDebug)
+ {
+ var result = new Dictionary(values, StringComparer.OrdinalIgnoreCase);
+ if (templateDebug is null)
+ {
+ return result;
+ }
+
+ foreach (var item in templateDebug.Items)
+ {
+ if (item.Kind is not (CutDebugItemKind.TextValue or CutDebugItemKind.ImageValue) ||
+ !templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue) ||
+ string.IsNullOrWhiteSpace(overrideValue.StringValue))
+ {
+ continue;
+ }
+
+ var targets = result.Keys
+ .Where(key => MatchesDebugItemKey(key, item.Key))
+ .ToArray();
+ if (targets.Length == 0)
+ {
+ targets = sceneVariables.Keys
+ .Where(key => MatchesDebugItemKey(key, item.Key))
+ .ToArray();
+ }
+
+ if (targets.Length == 0)
+ {
+ targets = [item.Key];
+ }
+
+ foreach (var target in targets)
+ {
+ result[target] = overrideValue.StringValue;
+ }
+ }
+
+ return result;
+ }
+
+ private static IReadOnlyList ApplyCounterNumberKeyOverrides(
+ IReadOnlyList updates,
+ IReadOnlyDictionary sceneVariables,
+ CutDebugTemplateState? templateDebug)
+ {
+ if (templateDebug is null)
+ {
+ return updates;
+ }
+
+ var result = updates.ToList();
+ foreach (var item in templateDebug.Items)
+ {
+ if (item.Kind != CutDebugItemKind.Counter ||
+ !templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue) ||
+ overrideValue.NumberValue is not double numberValue)
+ {
+ continue;
+ }
+
+ var matched = false;
+ for (var index = 0; index < result.Count; index++)
+ {
+ if (!MatchesDebugItemKey(result[index].ObjectName, item.Key))
+ {
+ continue;
+ }
+
+ result[index] = new KarismaCounterNumberKeyUpdate(
+ result[index].ObjectName,
+ result[index].KeyIndex,
+ numberValue);
+ matched = true;
+ }
+
+ if (matched)
+ {
+ continue;
+ }
+
+ var sceneTargets = sceneVariables.Keys
+ .Where(key => MatchesDebugItemKey(key, item.Key))
+ .ToArray();
+ foreach (var target in sceneTargets)
+ {
+ result.Add(new KarismaCounterNumberKeyUpdate(target, 1, numberValue));
+ matched = true;
+ }
+
+ if (!matched)
+ {
+ result.Add(new KarismaCounterNumberKeyUpdate(item.Key, 1, numberValue));
+ }
+ }
+
+ return result;
+ }
+
+ private static IReadOnlyList ApplyStyleColorOverrides(
+ IReadOnlyList updates,
+ CutDebugTemplateState? templateDebug)
+ {
+ if (templateDebug is null)
+ {
+ return updates;
+ }
+
+ var result = updates.ToList();
+ foreach (var item in templateDebug.Items)
+ {
+ if (item.Kind != CutDebugItemKind.StyleColor ||
+ !templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue))
+ {
+ continue;
+ }
+
+ for (var index = 0; index < result.Count; index++)
+ {
+ if (!MatchesDebugItemKey(result[index].ObjectName, item.Key))
+ {
+ continue;
+ }
+
+ result[index] = result[index] with
+ {
+ R = overrideValue.R,
+ G = overrideValue.G,
+ B = overrideValue.B,
+ A = overrideValue.A
+ };
+ }
+ }
+
+ return result;
+ }
+
+ private static (IReadOnlyList HideBeforeValue, IReadOnlyList ShowAfterValue) ApplyVisibilityOverrides(
+ (IReadOnlyList HideBeforeValue, IReadOnlyList ShowAfterValue) updates,
+ IReadOnlyDictionary sceneVariables,
+ CutDebugTemplateState? templateDebug)
+ {
+ if (templateDebug is null)
+ {
+ return updates;
+ }
+
+ var hide = updates.HideBeforeValue.ToList();
+ var show = updates.ShowAfterValue.ToList();
+ foreach (var item in templateDebug.Items)
+ {
+ if (item.Kind != CutDebugItemKind.Visibility ||
+ !templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue) ||
+ overrideValue.BooleanValue is not bool isVisible)
+ {
+ continue;
+ }
+
+ var targets = hide.Select(update => update.ObjectName)
+ .Concat(show.Select(update => update.ObjectName))
+ .Concat(sceneVariables.Keys)
+ .Where(key => MatchesDebugItemKey(key, item.Key))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ hide.RemoveAll(update => MatchesDebugItemKey(update.ObjectName, item.Key));
+ show.RemoveAll(update => MatchesDebugItemKey(update.ObjectName, item.Key));
+ if (targets.Length == 0)
+ {
+ targets = [item.Key];
+ }
+
+ var targetList = isVisible ? show : hide;
+ foreach (var target in targets)
+ {
+ targetList.Add(new KarismaVisibilityUpdate(target, isVisible));
+ }
+ }
+
+ return (hide, show);
+ }
+
+ private static bool MatchesDebugItemKey(string candidateKey, string debugKey)
+ {
+ return string.Equals(
+ CutDebugTemplateState.NormalizeKey(candidateKey),
+ CutDebugTemplateState.NormalizeKey(debugKey),
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool ShouldApplyObjectValue(
+ string variableName,
+ IReadOnlyDictionary sceneVariables,
+ CutDebugSettingsSnapshot cutDebug,
+ CutDebugTemplateState? templateDebug)
+ {
+ if (!cutDebug.IsEnabled)
+ {
+ return true;
+ }
+
+ var hasDefinition = sceneVariables.TryGetValue(variableName, out var variableDefinition);
+ var isImageValue =
+ (hasDefinition && variableDefinition is not null &&
+ variableDefinition.Kind is KarismaSceneVariableKind.Image or KarismaSceneVariableKind.VideoResource) ||
+ (!hasDefinition && IsImageValueVariable(variableName));
+
+ if (isImageValue)
+ {
+ return (!cutDebug.IsEnabled || cutDebug.ApplyImageValues) &&
+ (templateDebug is null || templateDebug.IsEnabled(variableName, CutDebugItemKind.ImageValue));
+ }
+
+ if (cutDebug.IsEnabled && !cutDebug.ApplyTextValues)
+ {
+ return false;
+ }
+
+ if (cutDebug.IsEnabled && IsVoteRateVariable(variableName) && !cutDebug.ApplyVoteRateTextValues)
+ {
+ return false;
+ }
+
+ return templateDebug is null || templateDebug.IsEnabled(variableName, CutDebugItemKind.TextValue);
+ }
+
+ private static bool ShouldApplyStyleColor(
+ string objectName,
+ CutDebugSettingsSnapshot cutDebug,
+ CutDebugTemplateState? templateDebug)
+ {
+ if (!cutDebug.IsEnabled)
+ {
+ return true;
+ }
+
+ if (templateDebug is not null && !templateDebug.IsEnabled(objectName, CutDebugItemKind.StyleColor))
+ {
+ return false;
+ }
+
+ if (cutDebug.IsEnabled && IsVoteRateVariable(objectName))
+ {
+ return cutDebug.ApplyVoteRateStyleColors;
+ }
+
+ if (cutDebug.IsEnabled && IsPartyBarStyleObjectName(objectName))
+ {
+ return cutDebug.ApplyPartyBarStyleColors;
+ }
+
+ if (cutDebug.IsEnabled && IsPartyPlateStyleObjectName(objectName))
+ {
+ return cutDebug.ApplyPartyPlateStyleColors;
+ }
+
+ return true;
+ }
+
+ private static bool IsVoteRateVariable(string variableName)
+ {
+ return string.Equals(variableName, "득표율", StringComparison.Ordinal) ||
+ string.Equals(variableName, "투표율", StringComparison.Ordinal) ||
+ string.Equals(variableName, "전국투표율", StringComparison.Ordinal) ||
+ MatchesIndexedVariable(variableName, "득표율") ||
+ MatchesIndexedVariable(variableName, "투표율") ||
+ MatchesIndexedVariable(variableName, "전국투표율");
+ }
+
+ private static bool IsImageValueVariable(string variableName)
+ {
+ return IsJudgementVariableName(variableName) ||
+ MatchesIndexedVariable(variableName, "후보사진") ||
+ MatchesIndexedVariable(variableName, "득표수바") ||
+ MatchesIndexedVariable(variableName, "정당바") ||
+ MatchesIndexedVariable(variableName, "정당판") ||
+ MatchesIndexedVariable(variableName, "정당원") ||
+ MatchesIndexedVariable(variableName, "정당색") ||
+ MatchesIndexedVariable(variableName, "정당심볼") ||
+ MatchesIndexedVariable(variableName, "공약그룹") ||
+ MatchesIndexedVariable(variableName, "그룹") ||
+ MatchesIndexedVariable(variableName, "바");
+ }
+
+ private static bool IsPartyBarStyleObjectName(string variableName)
+ {
+ return MatchesIndexedVariable(variableName, "득표수바") ||
+ MatchesIndexedVariable(variableName, "정당바");
+ }
+
+ private static bool IsPartyPlateStyleObjectName(string variableName)
+ {
+ return MatchesIndexedVariable(variableName, "기호") ||
+ MatchesIndexedVariable(variableName, "기호텍스트") ||
+ MatchesIndexedVariable(variableName, "정당판") ||
+ MatchesIndexedVariable(variableName, "정당원") ||
+ MatchesIndexedVariable(variableName, "정당색") ||
+ MatchesIndexedVariable(variableName, "정당명");
+ }
+
private static CandidateEntry[] GetOrderedCandidates(
FormatTemplateDefinition template,
FormatCutDefinition cut,
@@ -1379,6 +2057,16 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string? value,
params string[] keys)
{
+ if (ShouldUseTemplateDefaultAppearance(templateName, sectionName))
+ {
+ foreach (var key in keys)
+ {
+ values.Remove(key);
+ }
+
+ return;
+ }
+
if (PartyColorCatalog.HasStyleColorBinding(templateFolderPath, templateName, sectionName) &&
!ShouldPreferAssetAliasForStyleBoundSection(templateName, sectionName))
{
@@ -1441,6 +2129,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string sectionName,
params string[] objectNames)
{
+ if (ShouldUseTemplateDefaultAppearance(templateName, sectionName))
+ {
+ return;
+ }
+
if (ShouldPreferAssetAliasForStyleBoundSection(templateName, sectionName))
{
return;
@@ -1488,8 +2181,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return false;
}
- return string.Equals(templateName, "1-2위_ani_광역단체장", StringComparison.Ordinal) &&
- string.Equals(sectionName, "정당판", StringComparison.Ordinal);
+ return false;
+ }
+
+ private static bool ShouldUseTemplateDefaultAppearance(string templateName, string sectionName)
+ {
+ return CutAppearancePolicyCatalog.UsesTemplateDefaultAppearance(templateName, sectionName);
}
private static void SetRankAliases(
diff --git a/Tornado3_2026Election/Services/ThumbnailLayoutResolver.cs b/Tornado3_2026Election/Services/ThumbnailLayoutResolver.cs
new file mode 100644
index 0000000..c8b841a
--- /dev/null
+++ b/Tornado3_2026Election/Services/ThumbnailLayoutResolver.cs
@@ -0,0 +1,137 @@
+using System;
+using Tornado3_2026Election.Domain;
+
+namespace Tornado3_2026Election.Services;
+
+public enum ThumbnailDisplayContext
+{
+ CutList,
+ Queue,
+ Preview
+}
+
+public readonly record struct ThumbnailDisplayMetrics(double Width, double Height);
+
+public static class ThumbnailLayoutResolver
+{
+ private const double HdAspectRatio = 1920d / 1080d;
+ private const double StandardVideoWallAspectRatio = 5760d / 1080d;
+ private const double UltraWideVideoWallAspectRatio = 11520d / 1080d;
+
+ public static ThumbnailDisplayMetrics ResolveDisplayMetrics(
+ FormatTemplateDefinition template,
+ VideoWallLayoutPreset videoWallLayoutPreset,
+ ThumbnailDisplayContext context)
+ {
+ return ResolveDisplayMetrics(template.RecommendedChannel, template.SceneWidth, template.SceneHeight, videoWallLayoutPreset, context);
+ }
+
+ public static ThumbnailDisplayMetrics ResolveDisplayMetrics(
+ BroadcastChannel channel,
+ VideoWallLayoutPreset videoWallLayoutPreset,
+ ThumbnailDisplayContext context)
+ {
+ return ResolveDisplayMetrics(channel, null, null, videoWallLayoutPreset, context);
+ }
+
+ public static (int Width, int Height) ResolveGenerationSize(
+ FormatTemplateDefinition template,
+ VideoWallLayoutPreset videoWallLayoutPreset)
+ {
+ const int thumbnailHeight = 180;
+ var aspectRatio = ResolveAspectRatio(template.RecommendedChannel, template.SceneWidth, template.SceneHeight, videoWallLayoutPreset);
+ var thumbnailWidth = Math.Max(1, (int)Math.Round(thumbnailHeight * aspectRatio, MidpointRounding.AwayFromZero));
+ return (thumbnailWidth, thumbnailHeight);
+ }
+
+ private static ThumbnailDisplayMetrics ResolveDisplayMetrics(
+ BroadcastChannel channel,
+ int? sceneWidth,
+ int? sceneHeight,
+ VideoWallLayoutPreset videoWallLayoutPreset,
+ ThumbnailDisplayContext context)
+ {
+ var aspectRatio = ResolveAspectRatio(channel, sceneWidth, sceneHeight, videoWallLayoutPreset);
+ var (maxWidth, maxHeight) = context switch
+ {
+ ThumbnailDisplayContext.Preview => (480d, 180d),
+ ThumbnailDisplayContext.CutList => (320d, 90d),
+ ThumbnailDisplayContext.Queue => (320d, 90d),
+ _ => (320d, 180d)
+ };
+
+ return FitWithin(aspectRatio, maxWidth, maxHeight);
+ }
+
+ private static double ResolveAspectRatio(
+ BroadcastChannel channel,
+ int? sceneWidth,
+ int? sceneHeight,
+ VideoWallLayoutPreset videoWallLayoutPreset)
+ {
+ if (channel == BroadcastChannel.VideoWall)
+ {
+ if (TryGetPresetAspectRatio(videoWallLayoutPreset, out var presetAspectRatio))
+ {
+ return presetAspectRatio;
+ }
+
+ if (TryGetSceneAspectRatio(sceneWidth, sceneHeight, out var sceneAspectRatio))
+ {
+ return sceneAspectRatio;
+ }
+
+ return StandardVideoWallAspectRatio;
+ }
+
+ if (TryGetSceneAspectRatio(sceneWidth, sceneHeight, out var resolvedSceneAspectRatio))
+ {
+ return resolvedSceneAspectRatio;
+ }
+
+ return HdAspectRatio;
+ }
+
+ private static bool TryGetPresetAspectRatio(VideoWallLayoutPreset videoWallLayoutPreset, out double aspectRatio)
+ {
+ switch (videoWallLayoutPreset)
+ {
+ case VideoWallLayoutPreset.Standard5760x1080:
+ aspectRatio = StandardVideoWallAspectRatio;
+ return true;
+ case VideoWallLayoutPreset.UltraWide11520x1080:
+ aspectRatio = UltraWideVideoWallAspectRatio;
+ return true;
+ default:
+ aspectRatio = 0;
+ return false;
+ }
+ }
+
+ private static bool TryGetSceneAspectRatio(int? sceneWidth, int? sceneHeight, out double aspectRatio)
+ {
+ if (sceneWidth is > 0 && sceneHeight is > 0)
+ {
+ aspectRatio = (double)sceneWidth.Value / sceneHeight.Value;
+ return true;
+ }
+
+ aspectRatio = 0;
+ return false;
+ }
+
+ private static ThumbnailDisplayMetrics FitWithin(double aspectRatio, double maxWidth, double maxHeight)
+ {
+ var width = maxWidth;
+ var height = width / aspectRatio;
+ if (height > maxHeight)
+ {
+ height = maxHeight;
+ width = height * aspectRatio;
+ }
+
+ return new ThumbnailDisplayMetrics(
+ Math.Max(1, Math.Round(width, 0, MidpointRounding.AwayFromZero)),
+ Math.Max(1, Math.Round(height, 0, MidpointRounding.AwayFromZero)));
+ }
+}
diff --git a/Tornado3_2026Election/Tornado3_2026Election.csproj b/Tornado3_2026Election/Tornado3_2026Election.csproj
index 9ee5240..3c5cb13 100644
--- a/Tornado3_2026Election/Tornado3_2026Election.csproj
+++ b/Tornado3_2026Election/Tornado3_2026Election.csproj
@@ -37,21 +37,33 @@
PreserveNewest
-
+
+ PreserveNewest
+
PreserveNewest
-
+
+ PreserveNewest
+
PreserveNewest
-
+
+ PreserveNewest
+
PreserveNewest
-
-
-
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
PreserveNewest
@@ -64,7 +76,9 @@
PreserveNewest
-
+
+ PreserveNewest
+
PreserveNewest
diff --git a/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs b/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
index 8b129ba..e6ca087 100644
--- a/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
@@ -1,7 +1,10 @@
+using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.UI.Xaml.Media;
@@ -13,17 +16,25 @@ namespace Tornado3_2026Election.ViewModels;
public sealed class ChannelScheduleViewModel : ObservableObject
{
+ private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
+ private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
private readonly ChannelScheduleEngine _engine;
private readonly ITornado3Adapter _adapter;
+ private readonly CutDebugStateStore _cutDebugStateStore;
private readonly DataViewModel _data;
private readonly LogService _logService;
- private readonly IReadOnlyList _allFormats;
+ private readonly ObservableCollection _emptyCutDebugItems = [];
+ private IReadOnlyList _allFormats;
private FormatTemplateDefinition? _selectedFormat;
+ private CutDebugTemplateState? _selectedCutDebugTemplate;
private ScheduleRegionOption? _selectedRegionOption;
private SelectionOption? _selectedEmptyBehaviorOption;
private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
private int _regionOptionsRevision;
+ private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
+ private double _selectedFormatThumbnailWidth = 320;
+ private double _selectedFormatThumbnailHeight = 180;
public ChannelScheduleViewModel(
BroadcastChannel channel,
@@ -31,6 +42,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
IReadOnlyList formats,
DataViewModel data,
ITornado3Adapter adapter,
+ CutDebugSettings cutDebug,
+ CutDebugStateStore cutDebugStateStore,
ChannelScheduleEngine engine,
LogService logService)
{
@@ -38,6 +51,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
Title = title;
_data = data;
_adapter = adapter;
+ CutDebug = cutDebug;
+ _cutDebugStateStore = cutDebugStateStore;
_engine = engine;
_logService = logService;
_allFormats = formats.ToArray();
@@ -65,10 +80,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_engine.QueueChanged += (_, _) => RefreshSummary();
_adapter.StateChanged += (_, _) => RefreshSummary();
_adapter.ConnectionChanged += (_, _) => RefreshSummary();
+ CutDebug.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CutDebugSummary));
_data.PropertyChanged += Data_PropertyChanged;
+ Queue.CollectionChanged += Queue_CollectionChanged;
RebuildAvailableFormats();
_ = RebuildRegionOptionsAsync();
+ UpdateSelectedFormatThumbnailMetrics();
+ ApplyQueueThumbnailLayouts();
RefreshSummary();
}
@@ -104,6 +123,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public AsyncRelayCommand ForceQueueNextCommand { get; }
+ public CutDebugSettings CutDebug { get; }
+
public RelayCommand AddFormatCommand { get; }
public RelayCommand ResetQueueCommand { get; }
@@ -124,6 +145,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
if (SetProperty(ref _selectedFormat, value))
{
NotifySelectedFormatPreviewChanged();
+ SyncSelectedCutDebugTemplate();
_ = RebuildRegionOptionsAsync();
AddFormatCommand.NotifyCanExecuteChanged();
}
@@ -239,10 +261,44 @@ public sealed class ChannelScheduleViewModel : ObservableObject
? "등록된 썸네일을 표시 중"
: "썸네일이 없어 기본 아이콘을 표시 중";
+ public string CutDebugSummary => CutDebug.Summary;
+
+ public ObservableCollection CutDebugItems => _selectedCutDebugTemplate?.Items ?? _emptyCutDebugItems;
+
+ public int CutDebugItemCount => CutDebugItems.Count;
+
+ public string CutDebugTextTargets => BuildCutDebugTextTargets(SelectedFormat);
+
+ public string CutDebugImageTargets => BuildCutDebugImageTargets(SelectedFormat);
+
+ public string CutDebugVisibilityTargets => BuildCutDebugVisibilityTargets(SelectedFormat);
+
+ public string CutDebugVoteRateTextTargets => BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat));
+
+ public string CutDebugVoteRateCounterTargets => $"{BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat))} key#1";
+
+ public string CutDebugPartyBarColorTargets => JoinTargets(
+ BuildIndexedTargetRange("기호", ResolveCutDebugSlotCount(SelectedFormat)),
+ BuildIndexedTargetRange("기호텍스트", ResolveCutDebugSlotCount(SelectedFormat)),
+ BuildIndexedTargetRange("득표수바", ResolveCutDebugSlotCount(SelectedFormat)),
+ BuildIndexedTargetRange("정당바", ResolveCutDebugSlotCount(SelectedFormat)));
+
+ public string CutDebugPartyPlateColorTargets => JoinTargets(
+ BuildIndexedTargetRange("정당판", ResolveCutDebugSlotCount(SelectedFormat)),
+ BuildIndexedTargetRange("정당원", ResolveCutDebugSlotCount(SelectedFormat)),
+ BuildIndexedTargetRange("정당색", ResolveCutDebugSlotCount(SelectedFormat)),
+ BuildIndexedTargetRange("정당명", ResolveCutDebugSlotCount(SelectedFormat)));
+
+ public string CutDebugVoteRateColorTargets => $"{BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat))} edge/face";
+
public ImageSource? SelectedFormatThumbnailSource => SelectedFormat is null
? null
: CutThumbnailAssetCatalog.CreateImageSource(SelectedFormat);
+ public double SelectedFormatThumbnailWidth => _selectedFormatThumbnailWidth;
+
+ public double SelectedFormatThumbnailHeight => _selectedFormatThumbnailHeight;
+
public async Task RefreshRegionOptionsAsync()
{
await RebuildRegionOptionsAsync();
@@ -261,6 +317,27 @@ public sealed class ChannelScheduleViewModel : ObservableObject
}
}
+ public void UpdateFormats(IReadOnlyList formats)
+ {
+ _allFormats = formats.ToArray();
+ RebuildAvailableFormats();
+ _ = RebuildRegionOptionsAsync();
+ ApplyQueueThumbnailLayouts();
+ RefreshSummary();
+ }
+
+ public void UpdateVideoWallLayoutPreset(VideoWallLayoutPreset videoWallLayoutPreset)
+ {
+ if (_videoWallLayoutPreset == videoWallLayoutPreset)
+ {
+ return;
+ }
+
+ _videoWallLayoutPreset = videoWallLayoutPreset;
+ UpdateSelectedFormatThumbnailMetrics();
+ ApplyQueueThumbnailLayouts();
+ }
+
private async Task StartAsync()
{
await _engine.StartAsync().ConfigureAwait(false);
@@ -309,7 +386,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return;
}
- _engine.Enqueue(ChannelScheduleItem.FromTemplate(SelectedFormat, regionOption));
+ var item = ChannelScheduleItem.FromTemplate(SelectedFormat, regionOption);
+ item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(SelectedFormat, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue));
+ _engine.Enqueue(item);
RefreshSummary();
}
@@ -388,6 +467,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void RebuildAvailableFormats()
{
+ var selectedFormatId = SelectedFormat?.Id;
var filteredFormats = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.ToArray();
@@ -398,11 +478,17 @@ public sealed class ChannelScheduleViewModel : ObservableObject
AvailableFormats.Add(format);
}
- if (SelectedFormat is null || !AvailableFormats.Contains(SelectedFormat))
+ var nextSelectedFormat = !string.IsNullOrWhiteSpace(selectedFormatId)
+ ? AvailableFormats.FirstOrDefault(format => string.Equals(format.Id, selectedFormatId, StringComparison.Ordinal))
+ : null;
+ nextSelectedFormat ??= AvailableFormats.FirstOrDefault();
+ if (!ReferenceEquals(SelectedFormat, nextSelectedFormat))
{
- SelectedFormat = AvailableFormats.FirstOrDefault();
+ SelectedFormat = nextSelectedFormat;
}
+ UpdateSelectedFormatThumbnailMetrics();
+ SyncSelectedCutDebugTemplate();
AddFormatCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(QueueFootnote));
}
@@ -439,12 +525,28 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void NotifySelectedFormatPreviewChanged()
{
+ UpdateSelectedFormatThumbnailMetrics();
OnPropertyChanged(
+ nameof(CutDebugTextTargets),
+ nameof(CutDebugImageTargets),
+ nameof(CutDebugVisibilityTargets),
+ nameof(CutDebugVoteRateTextTargets),
+ nameof(CutDebugVoteRateCounterTargets),
+ nameof(CutDebugPartyBarColorTargets),
+ nameof(CutDebugPartyPlateColorTargets),
+ nameof(CutDebugVoteRateColorTargets),
nameof(SelectedFormatName),
nameof(SelectedFormatDescription),
nameof(SelectedFormatPath),
nameof(SelectedFormatThumbnailStatus),
- nameof(SelectedFormatThumbnailSource));
+ nameof(SelectedFormatThumbnailSource),
+ nameof(SelectedFormatThumbnailWidth),
+ nameof(SelectedFormatThumbnailHeight));
+ }
+
+ private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ ApplyQueueThumbnailLayouts();
}
private static ScheduleRegionOption? ResolvePreferredRegionOption(
@@ -479,4 +581,222 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
}
+
+ private void UpdateSelectedFormatThumbnailMetrics()
+ {
+ var metrics = SelectedFormat is null
+ ? ThumbnailLayoutResolver.ResolveDisplayMetrics(Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.Preview)
+ : ThumbnailLayoutResolver.ResolveDisplayMetrics(SelectedFormat, _videoWallLayoutPreset, ThumbnailDisplayContext.Preview);
+
+ SetProperty(ref _selectedFormatThumbnailWidth, metrics.Width, nameof(SelectedFormatThumbnailWidth));
+ SetProperty(ref _selectedFormatThumbnailHeight, metrics.Height, nameof(SelectedFormatThumbnailHeight));
+ }
+
+ private void ApplyQueueThumbnailLayouts()
+ {
+ foreach (var item in Queue)
+ {
+ var template = _allFormats.FirstOrDefault(format => string.Equals(format.Id, item.FormatId, StringComparison.Ordinal));
+ var metrics = template is null
+ ? ThumbnailLayoutResolver.ResolveDisplayMetrics(item.Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue)
+ : ThumbnailLayoutResolver.ResolveDisplayMetrics(template, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue);
+ item.UpdateThumbnailLayout(metrics);
+ }
+ }
+
+ private void SyncSelectedCutDebugTemplate()
+ {
+ if (SelectedFormat is null)
+ {
+ _selectedCutDebugTemplate = null;
+ OnPropertyChanged(nameof(CutDebugItems), nameof(CutDebugItemCount));
+ return;
+ }
+
+ var templateState = _cutDebugStateStore.GetTemplate(Channel, SelectedFormat.Id, SelectedFormat.Name);
+ templateState.SyncItems(BuildCutDebugItemDescriptors(SelectedFormat));
+ _selectedCutDebugTemplate = templateState;
+ OnPropertyChanged(nameof(CutDebugItems), nameof(CutDebugItemCount));
+ }
+
+ private static string BuildCutDebugTextTargets(FormatTemplateDefinition? format)
+ {
+ var slotCount = ResolveCutDebugSlotCount(format);
+ return JoinTargets(
+ "시도명01",
+ "개표율01",
+ BuildIndexedTargetRange("순위", slotCount),
+ BuildIndexedTargetRange("후보명", slotCount),
+ BuildIndexedTargetRange("정당명", slotCount),
+ BuildIndexedTargetRange("득표수", slotCount),
+ BuildIndexedTargetRange("득표율", slotCount));
+ }
+
+ private static string BuildCutDebugImageTargets(FormatTemplateDefinition? format)
+ {
+ var slotCount = ResolveCutDebugSlotCount(format);
+ var chartTarget = ShouldExcludeHistoricalTurnoutGraph(format) ? string.Empty : "차트01";
+ return JoinTargets(
+ chartTarget,
+ BuildIndexedTargetRange("유확당", slotCount),
+ BuildIndexedTargetRange("점선", slotCount),
+ BuildIndexedTargetRange("후보사진", slotCount),
+ BuildIndexedTargetRange("득표수바", slotCount),
+ BuildIndexedTargetRange("정당바", slotCount),
+ BuildIndexedTargetRange("정당판", slotCount),
+ BuildIndexedTargetRange("정당원", slotCount),
+ BuildIndexedTargetRange("정당색", slotCount),
+ BuildIndexedTargetRange("정당심볼", slotCount));
+ }
+
+ private static string BuildCutDebugVisibilityTargets(FormatTemplateDefinition? format)
+ {
+ var slotCount = ResolveCutDebugSlotCount(format);
+ var chartTarget = ShouldExcludeHistoricalTurnoutGraph(format) ? string.Empty : "차트01";
+ return JoinTargets(
+ chartTarget,
+ BuildIndexedTargetRange("점선", slotCount),
+ BuildIndexedTargetRange("유확당", slotCount),
+ BuildIndexedTargetRange("그룹", slotCount),
+ BuildIndexedTargetRange("공약그룹", 3));
+ }
+
+ private static int ResolveCutDebugSlotCount(FormatTemplateDefinition? format)
+ {
+ if (format is null)
+ {
+ return 2;
+ }
+
+ var source = $"{format.Name} {format.Id}";
+ var topRankMatch = TopRankSlotCountPattern.Match(source);
+ if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlotCount))
+ {
+ return Math.Max(1, topRankSlotCount);
+ }
+
+ var peopleMatch = PeopleSlotCountPattern.Match(source);
+ if (peopleMatch.Success && int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlotCount))
+ {
+ return Math.Max(1, peopleSlotCount);
+ }
+
+ return 2;
+ }
+
+ private static string BuildIndexedTargetRange(string prefix, int slotCount)
+ {
+ slotCount = Math.Max(1, slotCount);
+ return slotCount == 1
+ ? $"{prefix}01"
+ : $"{prefix}01~{slotCount:00}";
+ }
+
+ private static string JoinTargets(params string[] targets)
+ {
+ return string.Join(", ", targets.Where(target => !string.IsNullOrWhiteSpace(target)));
+ }
+
+ private static IReadOnlyList BuildCutDebugItemDescriptors(FormatTemplateDefinition? format)
+ {
+ if (format is null)
+ {
+ return Array.Empty();
+ }
+
+ var slotCount = ResolveCutDebugSlotCount(format);
+ var descriptors = new List();
+
+ AddDescriptor(descriptors, "선거구명01", CutDebugItemKind.TextValue, "공통 텍스트");
+ AddDescriptor(descriptors, "시도명01", CutDebugItemKind.TextValue, "공통 텍스트");
+ AddDescriptor(descriptors, "개표율01", CutDebugItemKind.TextValue, "공통 텍스트");
+ AddDescriptor(descriptors, "투표율01", CutDebugItemKind.TextValue, "공통 텍스트");
+ AddDescriptor(descriptors, "전국투표율01", CutDebugItemKind.TextValue, "공통 텍스트");
+ AddDescriptor(descriptors, "기준시01", CutDebugItemKind.TextValue, "공통 텍스트");
+ AddDescriptor(descriptors, "기준시02", CutDebugItemKind.TextValue, "공통 텍스트");
+ AddDescriptor(descriptors, "유권자수01", CutDebugItemKind.TextValue, "공통 텍스트");
+ AddDescriptor(descriptors, "투표자수01", CutDebugItemKind.TextValue, "공통 텍스트");
+ AddDescriptor(descriptors, "전국투표율01", CutDebugItemKind.Counter, "카운터");
+ if (!ShouldExcludeHistoricalTurnoutGraph(format))
+ {
+ AddDescriptor(descriptors, "차트01", CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddDescriptor(descriptors, "차트01", CutDebugItemKind.Visibility, "표시/숨김");
+ }
+
+ AddIndexedDescriptors(descriptors, "순위", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "기호", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "기호텍스트", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "후보명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "정당명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "득표수", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "표차", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "득표차", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "선거구명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "시도명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "개표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "투표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
+ AddIndexedDescriptors(descriptors, "공약", 3, CutDebugItemKind.TextValue, "공약 텍스트");
+
+ AddIndexedDescriptors(descriptors, "유확당", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "후보사진", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "득표수바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "정당바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "정당판", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "정당원", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "정당색", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "정당심볼", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "그룹", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "공약그룹", 3, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+ AddIndexedDescriptors(descriptors, "점선", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
+
+ AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.Counter, "카운터");
+ AddIndexedDescriptors(descriptors, "투표율", slotCount, CutDebugItemKind.Counter, "카운터");
+
+ AddIndexedDescriptors(descriptors, "기호", slotCount, CutDebugItemKind.StyleColor, "색상");
+ AddIndexedDescriptors(descriptors, "기호텍스트", slotCount, CutDebugItemKind.StyleColor, "색상");
+ AddIndexedDescriptors(descriptors, "득표수바", slotCount, CutDebugItemKind.StyleColor, "색상");
+ AddIndexedDescriptors(descriptors, "정당바", slotCount, CutDebugItemKind.StyleColor, "색상");
+ AddIndexedDescriptors(descriptors, "정당판", slotCount, CutDebugItemKind.StyleColor, "색상");
+ AddIndexedDescriptors(descriptors, "정당원", slotCount, CutDebugItemKind.StyleColor, "색상");
+ AddIndexedDescriptors(descriptors, "정당색", slotCount, CutDebugItemKind.StyleColor, "색상");
+ AddIndexedDescriptors(descriptors, "정당명", slotCount, CutDebugItemKind.StyleColor, "색상");
+ AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.StyleColor, "색상");
+
+ AddIndexedDescriptors(descriptors, "유확당", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
+ AddIndexedDescriptors(descriptors, "그룹", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
+ AddIndexedDescriptors(descriptors, "공약그룹", 3, CutDebugItemKind.Visibility, "표시/숨김");
+ AddIndexedDescriptors(descriptors, "점선", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
+
+ return descriptors;
+ }
+
+ private static void AddIndexedDescriptors(
+ ICollection descriptors,
+ string prefix,
+ int slotCount,
+ CutDebugItemKind kind,
+ string groupLabel)
+ {
+ for (var slot = 1; slot <= Math.Max(1, slotCount); slot++)
+ {
+ AddDescriptor(descriptors, $"{prefix}{slot:00}", kind, groupLabel);
+ }
+ }
+
+ private static void AddDescriptor(
+ ICollection descriptors,
+ string key,
+ CutDebugItemKind kind,
+ string groupLabel)
+ {
+ descriptors.Add(new CutDebugItemDescriptor(key, kind, groupLabel));
+ }
+
+ private static bool ShouldExcludeHistoricalTurnoutGraph(FormatTemplateDefinition? format)
+ {
+ return format is not null &&
+ format.Name.StartsWith("사전_역대투표율", StringComparison.Ordinal);
+ }
}
diff --git a/Tornado3_2026Election/ViewModels/CutListEntryViewModel.cs b/Tornado3_2026Election/ViewModels/CutListEntryViewModel.cs
index bf171e6..43c1ecb 100644
--- a/Tornado3_2026Election/ViewModels/CutListEntryViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/CutListEntryViewModel.cs
@@ -12,19 +12,25 @@ public sealed class CutListEntryViewModel : ObservableObject
private readonly FormatTemplateDefinition _template;
private readonly FormatCutDefinition _cut;
private readonly Action _durationChanged;
+ private VideoWallLayoutPreset _videoWallLayoutPreset;
private double _durationSeconds;
+ private double _thumbnailWidth;
+ private double _thumbnailHeight;
private ImageSource? _thumbnailSource;
public CutListEntryViewModel(
FormatTemplateDefinition template,
FormatCutDefinition cut,
- Action durationChanged)
+ Action durationChanged,
+ VideoWallLayoutPreset videoWallLayoutPreset)
{
_template = template;
_cut = cut;
_durationChanged = durationChanged;
+ _videoWallLayoutPreset = videoWallLayoutPreset;
_durationSeconds = cut.DurationSeconds;
_thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(template);
+ ApplyThumbnailLayout();
}
public string FormatId => _template.Id;
@@ -46,10 +52,18 @@ public sealed class CutListEntryViewModel : ObservableObject
public string Description => _template.Description;
+ public CutListElectionCategory ElectionCategory => CutListElectionCategoryResolver.Resolve(_template.Name);
+
+ public string ElectionCategoryLabel => CutListElectionCategoryResolver.GetLabel(ElectionCategory);
+
public ImageSource? ThumbnailSource => _thumbnailSource;
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(_template);
+ public double ThumbnailWidth => _thumbnailWidth;
+
+ public double ThumbnailHeight => _thumbnailHeight;
+
public double DurationSeconds
{
get => _durationSeconds;
@@ -90,4 +104,22 @@ public sealed class CutListEntryViewModel : ObservableObject
_thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(_template);
OnPropertyChanged(nameof(ThumbnailSource), nameof(HasThumbnail));
}
+
+ public void UpdateVideoWallLayoutPreset(VideoWallLayoutPreset videoWallLayoutPreset)
+ {
+ if (_videoWallLayoutPreset == videoWallLayoutPreset)
+ {
+ return;
+ }
+
+ _videoWallLayoutPreset = videoWallLayoutPreset;
+ ApplyThumbnailLayout();
+ }
+
+ private void ApplyThumbnailLayout()
+ {
+ var metrics = ThumbnailLayoutResolver.ResolveDisplayMetrics(_template, _videoWallLayoutPreset, ThumbnailDisplayContext.CutList);
+ SetProperty(ref _thumbnailWidth, metrics.Width, nameof(ThumbnailWidth));
+ SetProperty(ref _thumbnailHeight, metrics.Height, nameof(ThumbnailHeight));
+ }
}
diff --git a/Tornado3_2026Election/ViewModels/DataViewModel.cs b/Tornado3_2026Election/ViewModels/DataViewModel.cs
index 873adc0..1c00a4f 100644
--- a/Tornado3_2026Election/ViewModels/DataViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/DataViewModel.cs
@@ -154,7 +154,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private IReadOnlyList _districtSelectionSource = DefaultDistrictOptions;
private HashSet _configuredRegions = new(StringComparer.OrdinalIgnoreCase);
private bool _showOnlyConfiguredRegions;
- private string _selectedDistrictViewName = string.Empty;
+ private string _selectedDistrictViewName = StationRegionOverviewOptionValue;
private bool _isRefreshingDistrictOverview;
private string _districtOverviewStatusText = "전체보기를 선택하면 지역별 개표율을 확인할 수 있습니다.";
private string _selectedStationId = "KNN";
@@ -265,6 +265,16 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
public AsyncRelayCommand SavePreElectionHistoryCommand { get; }
+ public void SelectDistrictOverviewCard(string? districtViewName)
+ {
+ if (!IsDistrictOverviewMode || string.IsNullOrWhiteSpace(districtViewName))
+ {
+ return;
+ }
+
+ SelectedDistrictViewName = districtViewName.Trim();
+ }
+
public BroadcastPhase BroadcastPhase
{
get => _broadcastPhase;
@@ -592,7 +602,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return;
}
- var normalizedValue = string.IsNullOrWhiteSpace(value) ? DistrictName : value;
+ var normalizedValue = string.IsNullOrWhiteSpace(value)
+ ? StationRegionOverviewOptionValue
+ : value;
if (!SetProperty(ref _selectedDistrictViewName, normalizedValue))
{
return;
@@ -2269,6 +2281,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
ReplaceDistrictOverviewCards(
overviewItems.Select(item => new DistrictOverviewCardViewModel
{
+ DistrictViewName = item.DisplayName,
RegionName = item.DisplayName,
CountedRateDisplay = $"{item.CountedRate:0.0}%",
DetailText = $"개표 {item.CountedVotes:N0} / 남은표 {item.UncountedVotes:N0}"
diff --git a/Tornado3_2026Election/ViewModels/DistrictOverviewCardViewModel.cs b/Tornado3_2026Election/ViewModels/DistrictOverviewCardViewModel.cs
index d7e1fe5..63bd3f7 100644
--- a/Tornado3_2026Election/ViewModels/DistrictOverviewCardViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/DistrictOverviewCardViewModel.cs
@@ -2,6 +2,8 @@ namespace Tornado3_2026Election.ViewModels;
public sealed class DistrictOverviewCardViewModel
{
+ public required string DistrictViewName { get; init; }
+
public required string RegionName { get; init; }
public required string CountedRateDisplay { get; init; }
diff --git a/Tornado3_2026Election/ViewModels/MainViewModel.cs b/Tornado3_2026Election/ViewModels/MainViewModel.cs
index 1e36e82..8f11042 100644
--- a/Tornado3_2026Election/ViewModels/MainViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/MainViewModel.cs
@@ -22,10 +22,11 @@ public sealed class MainViewModel : ObservableObject
private static readonly Brush ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen);
private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed);
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
- private readonly FormatCatalogService _formatCatalogService;
+ private FormatCatalogService _formatCatalogService;
private readonly AppStateStore _stateStore;
private readonly LogService _logService;
private readonly KarismaThumbnailGeneratorService _thumbnailGeneratorService;
+ private readonly CutDebugStateStore _cutDebugStateStore;
private readonly ITornado3Adapter _sharedTornadoAdapter;
private readonly SemaphoreSlim _stateSaveLock = new(1, 1);
private AppPage _currentPage = AppPage.Normal;
@@ -41,17 +42,18 @@ public sealed class MainViewModel : ObservableObject
private SelectionOption? _selectedLogFilterOption;
private readonly List<(BroadcastChannel Channel, CutListEntryViewModel Entry)> _allCutListEntries = [];
private SelectionOption? _selectedCutListFilterOption;
+ private SelectionOption? _selectedCutListCategoryOption;
private string _thumbnailGenerationStatus = string.Empty;
public MainViewModel()
{
- _formatCatalogService = new FormatCatalogService();
_stateStore = new AppStateStore();
_logService = new LogService();
+ Settings = new SettingsViewModel(new StationCatalogService().GetAll());
+ _formatCatalogService = new FormatCatalogService(Settings.ImageRootPath);
_thumbnailGeneratorService = new KarismaThumbnailGeneratorService(_logService);
Data = new DataViewModel(_logService);
- Settings = new SettingsViewModel(new StationCatalogService().GetAll());
var selectedStationProfile = Settings.BuildSelectedStationProfile();
Data.SetConfiguredRegions(selectedStationProfile.RegionFilters);
Data.SetSelectedStationContext(selectedStationProfile.Id, selectedStationProfile.Name);
@@ -71,13 +73,24 @@ public sealed class MainViewModel : ObservableObject
new SelectionOption(BroadcastChannel.Bottom, "하단"),
new SelectionOption(BroadcastChannel.VideoWall, "비디오월")
];
+ CutListCategoryOptions =
+ [
+ new SelectionOption(null, "\uC804\uCCB4"),
+ new SelectionOption(CutListElectionCategory.MetropolitanHead, "\uAD11\uC5ED\uB2E8\uCCB4\uC7A5"),
+ new SelectionOption(CutListElectionCategory.MetropolitanCouncil, "\uAD11\uC5ED\uC758\uC6D0"),
+ new SelectionOption(CutListElectionCategory.Superintendent, "\uAD50\uC721\uAC10"),
+ new SelectionOption(CutListElectionCategory.LocalHead, "\uAE30\uCD08\uB2E8\uCCB4\uC7A5"),
+ new SelectionOption(CutListElectionCategory.LocalCouncil, "\uAE30\uCD08\uC758\uC6D0")
+ ];
FilteredLogs = [];
CutListItems = [];
_selectedCutListFilterOption = CutListFilterOptions[0];
+ _selectedCutListCategoryOption = CutListCategoryOptions[0];
Settings.PropertyChanged += Settings_PropertyChanged;
Data.PropertyChanged += Data_PropertyChanged;
- _sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath);
+ _cutDebugStateStore = new CutDebugStateStore();
+ _sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath, _cutDebugStateStore);
NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀", _sharedTornadoAdapter);
TopLeftChannel = CreateChannelViewModel(BroadcastChannel.TopLeft, "좌상단", _sharedTornadoAdapter);
@@ -85,6 +98,7 @@ public sealed class MainViewModel : ObservableObject
VideoWallChannel = CreateChannelViewModel(BroadcastChannel.VideoWall, "비디오월", _sharedTornadoAdapter);
Channels = [NormalChannel, TopLeftChannel, BottomChannel, VideoWallChannel];
+ UpdateChannelThumbnailLayouts();
BuildCutListEntries();
foreach (var channel in Channels)
{
@@ -157,6 +171,8 @@ public sealed class MainViewModel : ObservableObject
public IReadOnlyList> CutListFilterOptions { get; }
+ public IReadOnlyList> CutListCategoryOptions { get; }
+
public ChannelOperationMode OperationMode
{
get => _operationMode;
@@ -278,12 +294,23 @@ public sealed class MainViewModel : ObservableObject
{
var totalCount = _allCutListEntries.Count;
var filteredCount = CutListItems.Count;
- if (SelectedCutListFilterOption?.Value is null)
+ var selectedFilters = new List();
+ if (SelectedCutListFilterOption?.Value is not null)
+ {
+ selectedFilters.Add(SelectedCutListFilterOption.Label);
+ }
+
+ if (SelectedCutListCategoryOption?.Value is not null)
+ {
+ selectedFilters.Add(SelectedCutListCategoryOption.Label);
+ }
+
+ if (selectedFilters.Count == 0)
{
return $"등록 컷 {totalCount}개";
}
- return $"{SelectedCutListFilterOption.Label} 컷 {filteredCount}개 / 전체 {totalCount}개";
+ return $"{string.Join(" / ", selectedFilters)} 컷 {filteredCount}개 / 전체 {totalCount}개";
}
}
@@ -359,6 +386,23 @@ public sealed class MainViewModel : ObservableObject
}
}
+ public SelectionOption? SelectedCutListCategoryOption
+ {
+ get => _selectedCutListCategoryOption;
+ set
+ {
+ if (value is null)
+ {
+ return;
+ }
+
+ if (SetProperty(ref _selectedCutListCategoryOption, value))
+ {
+ ApplyCutListFilter();
+ }
+ }
+ }
+
public string LogFilterSummary => $"표시 {FilteredLogs.Count}건 / 전체 {Logs.Count}건";
public string CgIntegrationSummary => IsCgConnected ? "Connected" : "Disconnected";
@@ -624,7 +668,11 @@ public sealed class MainViewModel : ObservableObject
try
{
var result = await _thumbnailGeneratorService
- .GenerateAsync(_formatCatalogService.GetAll(), Settings.ImageRootPath, CancellationToken.None);
+ .GenerateAsync(
+ _formatCatalogService.GetAll(),
+ Settings.ImageRootPath,
+ Settings.SelectedStationVideoWallLayoutPreset,
+ CancellationToken.None);
RefreshCutListThumbnails();
foreach (var channel in Channels)
@@ -667,8 +715,20 @@ public sealed class MainViewModel : ObservableObject
QueueAutomaticSave();
}
- if (args.PropertyName is nameof(SettingsViewModel.SelectedStationId) or nameof(SettingsViewModel.ImageRootPath))
+ if (args.PropertyName is nameof(SettingsViewModel.SelectedStationId)
+ or nameof(SettingsViewModel.SelectedStationVideoWallLayoutPreset)
+ or nameof(SettingsViewModel.ImageRootPath))
{
+ if (args.PropertyName == nameof(SettingsViewModel.ImageRootPath))
+ {
+ ReloadFormatCatalog();
+ }
+ else
+ {
+ UpdateChannelThumbnailLayouts();
+ UpdateCutListThumbnailLayouts();
+ }
+
_ = WarmupSharedCgConnectionAsync();
GenerateCutThumbnailsCommand.NotifyCanExecuteChanged();
}
@@ -751,7 +811,16 @@ public sealed class MainViewModel : ObservableObject
{
station.RegionFiltersText = filters;
}
+
+ if (state.StationVideoWallLayouts.TryGetValue(station.Id, out var videoWallLayoutValue) &&
+ Enum.TryParse(videoWallLayoutValue, ignoreCase: true, out var videoWallLayoutPreset))
+ {
+ station.VideoWallLayoutPreset = videoWallLayoutPreset;
+ }
}
+
+ UpdateChannelThumbnailLayouts();
+ UpdateCutListThumbnailLayouts();
}
if (RestoreSelection.RestoreStatusValues)
@@ -901,7 +970,10 @@ public sealed class MainViewModel : ObservableObject
}).ToList(),
Channels = BuildChannelStateMap(),
CutDurations = BuildCutDurationMap(),
- StationRegionFilters = Settings.Stations.ToDictionary(station => station.Id, station => station.RegionFiltersText)
+ StationRegionFilters = Settings.Stations.ToDictionary(station => station.Id, station => station.RegionFiltersText),
+ StationVideoWallLayouts = Settings.Stations.ToDictionary(
+ station => station.Id,
+ station => station.VideoWallLayoutPreset.ToString())
};
await _stateStore.SaveAsync(state);
@@ -946,16 +1018,35 @@ public sealed class MainViewModel : ObservableObject
formatId => _formatCatalogService.FindById(formatId),
_logService);
+ var cutDebug = _cutDebugStateStore.Get(channel);
return new ChannelScheduleViewModel(
channel,
title,
_formatCatalogService.GetByChannel(channel),
Data,
adapter,
+ cutDebug,
+ _cutDebugStateStore,
engine,
_logService);
}
+ private void ReloadFormatCatalog()
+ {
+ var cutDurations = BuildCutDurationMap();
+ _formatCatalogService = new FormatCatalogService(Settings.ImageRootPath);
+
+ NormalChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.Normal));
+ TopLeftChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.TopLeft));
+ BottomChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.Bottom));
+ VideoWallChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.VideoWall));
+
+ UpdateChannelThumbnailLayouts();
+ BuildCutListEntries();
+ ApplyCutDurations(cutDurations);
+ SyncAllQueuedCutDurations();
+ }
+
private Dictionary BuildChannelStateMap()
{
return Channels.ToDictionary(
@@ -1061,9 +1152,16 @@ public sealed class MainViewModel : ObservableObject
{
var entries = _formatCatalogService
.GetAll()
- .OrderBy(template => template.RecommendedChannel)
+ .OrderBy(template => CutListElectionCategoryResolver.Resolve(template.Name))
+ .ThenBy(template => template.RecommendedChannel)
.ThenBy(template => template.Name, StringComparer.Ordinal)
- .SelectMany(template => template.Cuts.Select(cut => (template.RecommendedChannel, Entry: new CutListEntryViewModel(template, cut, OnCutDurationChanged))))
+ .SelectMany(template => template.Cuts.Select(cut => (
+ template.RecommendedChannel,
+ Entry: new CutListEntryViewModel(
+ template,
+ cut,
+ OnCutDurationChanged,
+ Settings.SelectedStationVideoWallLayoutPreset))))
.ToArray();
_allCutListEntries.Clear();
@@ -1114,8 +1212,11 @@ public sealed class MainViewModel : ObservableObject
private void ApplyCutListFilter()
{
var selectedChannel = SelectedCutListFilterOption?.Value;
+ var selectedCategory = SelectedCutListCategoryOption?.Value;
var filteredEntries = _allCutListEntries
- .Where(item => selectedChannel is null || item.Channel == selectedChannel.Value)
+ .Where(item =>
+ (selectedChannel is null || item.Channel == selectedChannel.Value) &&
+ (selectedCategory is null || item.Entry.ElectionCategory == selectedCategory.Value))
.Select(item => item.Entry)
.ToArray();
@@ -1138,6 +1239,24 @@ public sealed class MainViewModel : ObservableObject
OnPropertyChanged(nameof(CutThumbnailSummary));
}
+ private void UpdateChannelThumbnailLayouts()
+ {
+ var videoWallLayoutPreset = Settings.SelectedStationVideoWallLayoutPreset;
+ foreach (var channel in Channels)
+ {
+ channel.UpdateVideoWallLayoutPreset(videoWallLayoutPreset);
+ }
+ }
+
+ private void UpdateCutListThumbnailLayouts()
+ {
+ var videoWallLayoutPreset = Settings.SelectedStationVideoWallLayoutPreset;
+ foreach (var item in _allCutListEntries)
+ {
+ item.Entry.UpdateVideoWallLayoutPreset(videoWallLayoutPreset);
+ }
+ }
+
private string BuildInitialThumbnailGenerationStatus()
{
return KarismaThumbnailGeneratorService.IsGenerationAvailable()
diff --git a/Tornado3_2026Election/ViewModels/SettingsViewModel.cs b/Tornado3_2026Election/ViewModels/SettingsViewModel.cs
index 459caf4..fd2c6d1 100644
--- a/Tornado3_2026Election/ViewModels/SettingsViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/SettingsViewModel.cs
@@ -11,6 +11,12 @@ public sealed class SettingsViewModel : ObservableObject
{
private string _selectedStationId = "KNN";
private string _imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
+ private readonly IReadOnlyList> _videoWallLayoutOptions =
+ [
+ new SelectionOption(VideoWallLayoutPreset.Auto, "자동"),
+ new SelectionOption(VideoWallLayoutPreset.Standard5760x1080, "5760 x 1080"),
+ new SelectionOption(VideoWallLayoutPreset.UltraWide11520x1080, "11520 x 1080")
+ ];
public SettingsViewModel(IEnumerable stations)
{
@@ -25,6 +31,11 @@ public sealed class SettingsViewModel : ObservableObject
{
OnPropertyChanged(nameof(SelectedStationRegionSummary));
}
+
+ if (station == SelectedStation && args.PropertyName is nameof(StationFilterItemViewModel.VideoWallLayoutPreset) or nameof(StationFilterItemViewModel.VideoWallLayoutSummary))
+ {
+ OnPropertyChanged(nameof(SelectedStationVideoWallLayoutPreset), nameof(SelectedStationVideoWallLayoutSummary));
+ }
};
}
@@ -36,6 +47,8 @@ public sealed class SettingsViewModel : ObservableObject
public ObservableCollection Stations { get; }
+ public IReadOnlyList> VideoWallLayoutOptions => _videoWallLayoutOptions;
+
public string SelectedStationId
{
get => _selectedStationId;
@@ -43,7 +56,13 @@ public sealed class SettingsViewModel : ObservableObject
{
if (SetProperty(ref _selectedStationId, value))
{
- OnPropertyChanged(nameof(SelectedStation), nameof(SelectedStationLogoAssetPath), nameof(SelectedStationRegions), nameof(SelectedStationRegionSummary));
+ OnPropertyChanged(
+ nameof(SelectedStation),
+ nameof(SelectedStationLogoAssetPath),
+ nameof(SelectedStationRegions),
+ nameof(SelectedStationRegionSummary),
+ nameof(SelectedStationVideoWallLayoutPreset),
+ nameof(SelectedStationVideoWallLayoutSummary));
}
}
}
@@ -63,6 +82,23 @@ public sealed class SettingsViewModel : ObservableObject
public string SelectedStationRegionSummary => SelectedStation.RegionSelectionSummary;
+ public VideoWallLayoutPreset SelectedStationVideoWallLayoutPreset
+ {
+ get => SelectedStation.VideoWallLayoutPreset;
+ set
+ {
+ if (SelectedStation.VideoWallLayoutPreset == value)
+ {
+ return;
+ }
+
+ SelectedStation.VideoWallLayoutPreset = value;
+ OnPropertyChanged(nameof(SelectedStationVideoWallLayoutPreset), nameof(SelectedStationVideoWallLayoutSummary));
+ }
+ }
+
+ public string SelectedStationVideoWallLayoutSummary => SelectedStation.VideoWallLayoutSummary;
+
public BroadcastStationProfile BuildSelectedStationProfile()
{
return SelectedStation.ToProfile();
diff --git a/Tornado3_2026Election/ViewModels/StationFilterItemViewModel.cs b/Tornado3_2026Election/ViewModels/StationFilterItemViewModel.cs
index ce931ac..c041405 100644
--- a/Tornado3_2026Election/ViewModels/StationFilterItemViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/StationFilterItemViewModel.cs
@@ -71,11 +71,14 @@ public sealed class StationFilterItemViewModel : ObservableObject
["제주특별자치도"] = "제주"
};
+ private VideoWallLayoutPreset _videoWallLayoutPreset;
+
public StationFilterItemViewModel(BroadcastStationProfile station)
{
Id = station.Id;
Name = station.Name;
LogoAssetPath = station.LogoAssetPath;
+ _videoWallLayoutPreset = station.VideoWallLayoutPreset;
var selectedRegions = ParseRegions(station.RegionFilters);
Regions = new ObservableCollection(
@@ -90,6 +93,18 @@ public sealed class StationFilterItemViewModel : ObservableObject
public ObservableCollection Regions { get; }
+ public VideoWallLayoutPreset VideoWallLayoutPreset
+ {
+ get => _videoWallLayoutPreset;
+ set
+ {
+ if (SetProperty(ref _videoWallLayoutPreset, value))
+ {
+ OnPropertyChanged(nameof(VideoWallLayoutSummary));
+ }
+ }
+ }
+
public string RegionFiltersText
{
get => string.Join(", ", Regions.Where(region => region.IsSelected).Select(region => region.Name));
@@ -101,6 +116,13 @@ public sealed class StationFilterItemViewModel : ObservableObject
public string RegionSelectionSummary
=> SelectedRegionCount == 0 ? "선택된 시도가 없습니다." : $"선택된 시도 {SelectedRegionCount}개";
+ public string VideoWallLayoutSummary => VideoWallLayoutPreset switch
+ {
+ VideoWallLayoutPreset.Standard5760x1080 => "5760 x 1080 비디오월",
+ VideoWallLayoutPreset.UltraWide11520x1080 => "11520 x 1080 비디오월",
+ _ => "씬 기준 자동 감지"
+ };
+
public BroadcastStationProfile ToProfile()
{
return new BroadcastStationProfile
@@ -108,6 +130,7 @@ public sealed class StationFilterItemViewModel : ObservableObject
Id = Id,
Name = Name,
LogoAssetPath = LogoAssetPath,
+ VideoWallLayoutPreset = VideoWallLayoutPreset,
RegionFilters = Regions
.Where(region => region.IsSelected)
.Select(region => region.Name)
diff --git a/plugins/cut-design-debugger/.codex-plugin/plugin.json b/plugins/cut-design-debugger/.codex-plugin/plugin.json
new file mode 100644
index 0000000..de996cf
--- /dev/null
+++ b/plugins/cut-design-debugger/.codex-plugin/plugin.json
@@ -0,0 +1,29 @@
+{
+ "name": "cut-design-debugger",
+ "version": "0.1.0",
+ "description": "Repository-local tools for changing and validating Tornado3/Karisma election cuts.",
+ "keywords": [
+ "tornado3",
+ "karisma",
+ "election-cuts"
+ ],
+ "skills": "./skills/",
+ "interface": {
+ "displayName": "Cut Design Debugger",
+ "shortDescription": "Modify, inspect, and validate Tornado3 election cuts.",
+ "longDescription": "Use repo-local skills and validation wrappers to change cut layouts, debug scene mappings, and verify Karisma output.",
+ "developerName": "Repo Local",
+ "category": "Productivity",
+ "capabilities": [
+ "Interactive",
+ "Write",
+ "Debug"
+ ],
+ "defaultPrompt": [
+ "Inspect this cut and make the smallest safe design change.",
+ "Find why this cut is missing scene values or assets.",
+ "Run validation for this cut and summarize what failed."
+ ],
+ "brandColor": "#2563EB"
+ }
+}
diff --git a/plugins/cut-design-debugger/skills/cut-design-debugger/SKILL.md b/plugins/cut-design-debugger/skills/cut-design-debugger/SKILL.md
new file mode 100644
index 0000000..75c29bf
--- /dev/null
+++ b/plugins/cut-design-debugger/skills/cut-design-debugger/SKILL.md
@@ -0,0 +1,46 @@
+---
+name: cut-design-debugger
+description: Modify and validate Tornado3/Karisma election cut designs in this repository. Use when Codex needs to change a cut layout or asset mapping, troubleshoot scene-variable visibility or style issues, inspect T3_Cut scene behavior, or run repo-local validation after a cut change.
+---
+
+# Cut Design Debugger
+
+Use this skill to make the smallest safe change to a cut-related workflow, then validate it with the repo's existing Karisma tools.
+
+## Workflow
+
+1. Identify the target before editing.
+- Confirm the requested template, cut, channel, and visible symptom.
+- Search the repo spec first: `SYSTEM_SPEC.md`, `RGB_SPEC_CUT_MAPPING.md`, and `CURRENT_IMPLEMENTATION_STATUS_2026-04-17.md`.
+- Map the request to the owning code before changing anything. Read [repo-map.md](references/repo-map.md) when the affected area is not obvious.
+
+2. Edit the smallest surface that can explain the behavior.
+- Catalog or cut-list problems usually live in `FormatCatalogService`, `MainViewModel`, or thumbnail helpers.
+- Scene resolution, path, and asset lookup issues usually live in `KarismaSceneResolver`, `KarismaSceneVariableCatalog`, `TornadoPathResolver`, or `KarismaTornado3Adapter`.
+- Runtime value, visibility, color, and candidate-slot logic usually lives in `KarismaTornado3Adapter`.
+- If the problem exists only inside external `T3_Cut` assets, call that out explicitly before assuming a repo-side fix exists.
+
+3. Validate in layers after every meaningful change.
+- Always run `dotnet build Tornado3_2026Election.slnx` when code changed.
+- Use [validation-workflow.md](references/validation-workflow.md) for command selection.
+- For a scoped live pass, prefer `scripts/validate-cut.ps1`.
+- For scene-level snapshots or raw object checks, use `tools/KarismaTcpProbe` directly.
+- If live Karisma or `T3_Cut` is unavailable, still run the build and document the missing external dependency.
+
+4. Report the result in operational terms.
+- Name the files changed.
+- List the commands run.
+- Say what was verified, what remains unverified, and whether external Karisma or `T3_Cut` access blocked anything.
+
+## Repo Notes
+
+- Treat the repo as the source of truth for cut metadata and validation helpers.
+- Treat `T3_Cut` as an external dependency that may contain the real scene or asset causing the issue.
+- Prefer targeted validation with a template or cut filter instead of sweeping the whole catalog unless the user asks for a broad audit.
+- Reuse existing `tools/KarismaTcpProbe/scene-ops/*.json` fixtures when they match the symptom instead of inventing a new validation format.
+
+## Resources
+
+- `scripts/validate-cut.ps1`: run scoped live validation against the existing `KarismaTcpProbe` tool.
+- [repo-map.md](references/repo-map.md): load when you need to find the owning file quickly.
+- [validation-workflow.md](references/validation-workflow.md): load when you need the exact validation command for the current symptom.
diff --git a/plugins/cut-design-debugger/skills/cut-design-debugger/agents/openai.yaml b/plugins/cut-design-debugger/skills/cut-design-debugger/agents/openai.yaml
new file mode 100644
index 0000000..a4f492f
--- /dev/null
+++ b/plugins/cut-design-debugger/skills/cut-design-debugger/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Cut Design Debugger"
+ short_description: "Modify and validate Tornado3 election cut designs."
+ default_prompt: "Inspect the requested cut, make the smallest safe design or mapping change, then run the relevant validation steps and summarize the result."
diff --git a/plugins/cut-design-debugger/skills/cut-design-debugger/references/repo-map.md b/plugins/cut-design-debugger/skills/cut-design-debugger/references/repo-map.md
new file mode 100644
index 0000000..e345780
--- /dev/null
+++ b/plugins/cut-design-debugger/skills/cut-design-debugger/references/repo-map.md
@@ -0,0 +1,35 @@
+# Repo Map
+
+Use this reference to decide where a cut-related change belongs.
+
+## Specs and current behavior
+
+- `SYSTEM_SPEC.md`: high-level broadcast, catalog, thumbnail, and `T3_Cut` rules.
+- `RGB_SPEC_CUT_MAPPING.md`: color and cut mapping notes.
+- `CURRENT_IMPLEMENTATION_STATUS_2026-04-17.md`: implemented behavior and current constraints.
+
+## UI and cut-list behavior
+
+- `Tornado3_2026Election/MainWindow.xaml`: cut-list and control UI.
+- `Tornado3_2026Election/ViewModels/MainViewModel.cs`: cut-list items, thumbnail generation command, filter state.
+- `Tornado3_2026Election/ViewModels/CutListEntryViewModel.cs`: per-cut thumbnail and duration state.
+
+## Catalog, scene lookup, and paths
+
+- `Tornado3_2026Election/Services/FormatCatalogService.cs`: template and cut catalog.
+- `Tornado3_2026Election/Services/KarismaSceneResolver.cs`: resolve the actual `.tscn` or `_loop.tscn` path.
+- `Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs`: scene-variable discovery and lookup cache.
+- `Tornado3_2026Election/Services/TornadoPathResolver.cs`: default and normalized `T3_Cut` path handling.
+- `Tornado3_2026Election/Services/CutThumbnailAssetCatalog.cs`: project thumbnail asset locations.
+
+## Runtime apply logic
+
+- `Tornado3_2026Election/Services/KarismaTornado3Adapter.cs`: object values, style colors, visibility, candidate slots, asset lookup, live apply flow.
+- `Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs`: generate thumbnail PNGs from Karisma.
+- `Tornado3_2026Election/Services/MockTornado3Adapter.cs`: fallback behavior when live Karisma is unavailable.
+
+## Validation tools
+
+- `tools/KarismaTcpProbe/Program.cs`: raw commands for scene validation, folder inspection, scene image capture, and live-cut validation.
+- `tools/KarismaTcpProbe/LiveCutValidation.cs`: A/B live validation runner and report generation.
+- `tools/KarismaTcpProbe/scene-ops/*.json`: reusable scene operation fixtures for targeted checks.
diff --git a/plugins/cut-design-debugger/skills/cut-design-debugger/references/validation-workflow.md b/plugins/cut-design-debugger/skills/cut-design-debugger/references/validation-workflow.md
new file mode 100644
index 0000000..135c9cb
--- /dev/null
+++ b/plugins/cut-design-debugger/skills/cut-design-debugger/references/validation-workflow.md
@@ -0,0 +1,57 @@
+# Validation Workflow
+
+Choose the lightest command that still proves the change.
+
+## 1. Always build after code changes
+
+```powershell
+dotnet build Tornado3_2026Election.slnx
+```
+
+## 2. Run scoped live validation for a cut or template
+
+Use the local wrapper when Karisma and `T3_Cut` are available.
+
+```powershell
+powershell -ExecutionPolicy Bypass -File plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1 `
+ -ImageRootPath 'C:\Path\To\T3_Cut' `
+ -Filter '1-2위_ani_광역단체장'
+```
+
+Useful flags:
+
+- `-Limit 1`: validate only the first matching item.
+- `-IncludeVideoWall`: include VideoWall templates.
+- `-OutputPath `: write reports to a custom artifact directory.
+
+## 3. Capture a scene image without running the full live pass
+
+Use this when the problem is visual and you already know the exact `.tscn` scene path.
+
+```powershell
+dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
+ --save-scene-image `
+ --scene 'C:\Path\To\T3_Cut\SomeScene.tscn' `
+ --output artifacts\scene-captures\some-scene.png
+```
+
+## 4. Validate scene operations against a fixture
+
+Use this when the issue is about values, visibility, or style updates for known scene objects.
+
+```powershell
+dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
+ --validate-scene-values `
+ --scene 'C:\Path\To\T3_Cut\SomeScene.tscn' `
+ --operations tools/KarismaTcpProbe/scene-ops/1-2위_ani_광역단체장_style.json `
+ --output artifacts\scene-validation\style.md
+```
+
+## 5. Inspect a whole folder when the object names are unclear
+
+```powershell
+dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
+ --inspect-tscn-folder `
+ --root 'C:\Path\To\T3_Cut' `
+ --output artifacts\scene-inspection\inspection.md
+```
diff --git a/plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1 b/plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1
new file mode 100644
index 0000000..e8d7d66
--- /dev/null
+++ b/plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1
@@ -0,0 +1,65 @@
+param(
+ [string]$ImageRootPath = "",
+ [string]$Filter = "",
+ [string]$OutputPath = "",
+ [int]$Limit = 0,
+ [int]$OnAirDelayMs = 900,
+ [int]$BetweenDelayMs = 250,
+ [switch]$IncludeVideoWall
+)
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..\..")).Path
+$probeProject = Join-Path $repoRoot "tools\KarismaTcpProbe\KarismaTcpProbe.csproj"
+
+if ([string]::IsNullOrWhiteSpace($OutputPath))
+{
+ $timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
+ $OutputPath = Join-Path $repoRoot "artifacts\cut-design-debugger\live-cut-validation\$timestamp"
+}
+
+$commandArgs = @(
+ "run",
+ "--project", $probeProject,
+ "--",
+ "--validate-live-cuts",
+ "--output", $OutputPath,
+ "--onair-delay-ms", $OnAirDelayMs.ToString(),
+ "--between-delay-ms", $BetweenDelayMs.ToString()
+)
+
+if (-not [string]::IsNullOrWhiteSpace($ImageRootPath))
+{
+ $commandArgs += @("--image-root", $ImageRootPath)
+}
+
+if (-not [string]::IsNullOrWhiteSpace($Filter))
+{
+ $commandArgs += @("--filter", $Filter)
+}
+
+if ($Limit -gt 0)
+{
+ $commandArgs += @("--limit", $Limit.ToString())
+}
+
+if ($IncludeVideoWall)
+{
+ $commandArgs += "--include-videowall"
+}
+
+Write-Host "Running live-cut validation..."
+Write-Host "Repo Root : $repoRoot"
+Write-Host "Output : $OutputPath"
+if (-not [string]::IsNullOrWhiteSpace($Filter))
+{
+ Write-Host "Filter : $Filter"
+}
+
+& dotnet @commandArgs
+if ($LASTEXITCODE -ne 0)
+{
+ exit $LASTEXITCODE
+}
+
+$summaryPath = Join-Path $OutputPath "summary.md"
+Write-Host "Validation report: $summaryPath"
diff --git a/tools/KarismaTcpProbe/CutDebugRecommendationCatalog.cs b/tools/KarismaTcpProbe/CutDebugRecommendationCatalog.cs
new file mode 100644
index 0000000..8869693
--- /dev/null
+++ b/tools/KarismaTcpProbe/CutDebugRecommendationCatalog.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using Tornado3_2026Election.Domain;
+
+internal readonly record struct CutDebugRecommendation(string Key, CutDebugItemKind Kind);
+
+internal static class CutDebugRecommendationCatalog
+{
+ private static readonly Lazy> Recommendations =
+ new(LoadRecommendations);
+
+ public static bool TryGetRecommendation(string templateId, out CutDebugRecommendation recommendation)
+ {
+ return Recommendations.Value.TryGetValue(templateId, out recommendation);
+ }
+
+ public static int Count => Recommendations.Value.Count;
+
+ private static IReadOnlyDictionary LoadRecommendations()
+ {
+ var path = FindRecommendationPath();
+ if (path is null || !File.Exists(path))
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ var recommendations = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var rawLine in File.ReadLines(path, Encoding.UTF8).Skip(1))
+ {
+ if (string.IsNullOrWhiteSpace(rawLine))
+ {
+ continue;
+ }
+
+ var columns = rawLine.Split('\t');
+ if (columns.Length < 3)
+ {
+ continue;
+ }
+
+ var templateId = columns[0].Trim();
+ var key = columns[1].Trim();
+ if (!Enum.TryParse(columns[2].Trim(), ignoreCase: true, out var kind) ||
+ string.IsNullOrWhiteSpace(templateId) ||
+ string.IsNullOrWhiteSpace(key))
+ {
+ continue;
+ }
+
+ recommendations[templateId] = new CutDebugRecommendation(key, kind);
+ }
+
+ return recommendations;
+ }
+
+ private static string? FindRecommendationPath()
+ {
+ foreach (var root in EnumerateSearchRoots())
+ {
+ var candidate = Path.Combine(root, "tools", "KarismaTcpProbe", "cut-debug-recommendations.tsv");
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ private static IEnumerable EnumerateSearchRoots()
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
+ {
+ if (string.IsNullOrWhiteSpace(start))
+ {
+ continue;
+ }
+
+ var directory = new DirectoryInfo(Path.GetFullPath(start));
+ while (directory is not null)
+ {
+ if (seen.Add(directory.FullName))
+ {
+ yield return directory.FullName;
+ }
+
+ directory = directory.Parent;
+ }
+ }
+ }
+}
diff --git a/tools/KarismaTcpProbe/KarismaTcpProbe.csproj b/tools/KarismaTcpProbe/KarismaTcpProbe.csproj
index e811556..f5e8720 100644
--- a/tools/KarismaTcpProbe/KarismaTcpProbe.csproj
+++ b/tools/KarismaTcpProbe/KarismaTcpProbe.csproj
@@ -25,6 +25,9 @@
+
+
+
@@ -33,10 +36,14 @@
+
+
+
+
diff --git a/tools/KarismaTcpProbe/LiveCutValidation.cs b/tools/KarismaTcpProbe/LiveCutValidation.cs
index 5d6931d..a905c66 100644
--- a/tools/KarismaTcpProbe/LiveCutValidation.cs
+++ b/tools/KarismaTcpProbe/LiveCutValidation.cs
@@ -21,7 +21,8 @@ internal static class LiveCutValidation
Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}");
var logService = new LogService();
- if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, out var adapter) || !adapter.IsLiveCg)
+ var cutDebugStateStore = new CutDebugStateStore();
+ if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg)
{
Console.WriteLine("Karisma adapter is not available. Validation cannot continue.");
return 1;
@@ -37,12 +38,11 @@ internal static class LiveCutValidation
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();
+ cutItems = ApplyTemplateFilter(cutItems, options.Filter);
+
if (options.Limit is int limit && limit > 0)
{
cutItems = cutItems.Take(limit).ToList();
@@ -81,13 +81,13 @@ 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);
- var snapshotB = CreateSnapshot(item.Template.Name, index, variant: 1, preElection);
+ var snapshotA = CreateSnapshot(item.Template.Name, index, variant: 0, preElection, options.SwapTopTwoCandidates);
+ var snapshotB = CreateSnapshot(item.Template.Name, index, variant: 1, preElection, options.SwapTopTwoCandidates);
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);
+ 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);
@@ -95,7 +95,7 @@ internal static class LiveCutValidation
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);
+ 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);
@@ -159,6 +159,1170 @@ internal static class LiveCutValidation
return failureCount == 0 ? 0 : 1;
}
+ public static async Task RunCutDebugSweepAsync(string[] args)
+ {
+ var options = CutDebugSweepOptions.Parse(args);
+ Directory.CreateDirectory(options.OutputPath);
+
+ Console.WriteLine("Karisma cut-debug sweep starting.");
+ Console.WriteLine($"- Image Root: {options.ImageRootPath}");
+ Console.WriteLine($"- Output: {options.OutputPath}");
+ Console.WriteLine($"- Mode: {options.Mode}");
+ Console.WriteLine($"- Filter: {(string.IsNullOrWhiteSpace(options.Filter) ? "(none)" : options.Filter)}");
+ Console.WriteLine($"- Cut Filter: {(string.IsNullOrWhiteSpace(options.CutName) ? "(none)" : options.CutName)}");
+ Console.WriteLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}");
+ Console.WriteLine($"- Recommendations Loaded: {CutDebugRecommendationCatalog.Count}");
+
+ var pgmWindow = TryFindPgmWindow();
+ if (pgmWindow is null)
+ {
+ Console.WriteLine("PGM window was not found. Open the PGM window first and rerun.");
+ return 1;
+ }
+
+ var logService = new LogService();
+ var cutDebugStateStore = new CutDebugStateStore();
+ if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) || !adapter.IsLiveCg)
+ {
+ Console.WriteLine("Karisma adapter is not available. Sweep cannot continue.");
+ return 1;
+ }
+
+ var station = CreateValidationStation(options.StationLogoPath);
+ var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
+ var cutItems = new FormatCatalogService().GetAll()
+ .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
+ .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut)))
+ .ToList();
+
+ cutItems = ApplyTemplateFilter(cutItems, options.Filter);
+ cutItems = ApplyCutFilter(cutItems, options.CutName);
+ if (options.ExcludeHistoricalTurnout)
+ {
+ cutItems = cutItems
+ .Where(item => !IsHistoricalTurnoutTemplate(item.Template.Name))
+ .ToList();
+ }
+
+ if (options.Limit is int limit && limit > 0)
+ {
+ cutItems = cutItems.Take(limit).ToList();
+ }
+
+ if (cutItems.Count == 0)
+ {
+ Console.WriteLine("No cuts matched the requested filter.");
+ return 1;
+ }
+
+ Console.WriteLine($"- Cuts: {cutItems.Count}");
+ Console.WriteLine();
+
+ var results = new List();
+ var replacementAssets = EnsureCutDebugReplacementAssets(options.OutputPath);
+
+ try
+ {
+ await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
+
+ for (var index = 0; index < cutItems.Count; index++)
+ {
+ var item = cutItems[index];
+ Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id} / {item.Cut.Name}");
+
+ var result = new CutDebugSweepResult
+ {
+ Index = index + 1,
+ TemplateId = item.Template.Id,
+ TemplateName = item.Template.Name,
+ CutName = item.Cut.Name,
+ Channel = item.Template.RecommendedChannel.ToString()
+ };
+ CutDebugTemplateState? templateState = null;
+
+ try
+ {
+ var resolvedScene = KarismaSceneResolver.ResolveScene(item.Template, options.ImageRootPath, useLoop: false);
+ result.ScenePath = resolvedScene.Path;
+
+ var sceneVariables = sceneVariableCatalog.GetSceneVariables(options.ImageRootPath, resolvedScene.Path);
+ templateState = cutDebugStateStore.GetTemplate(item.Template.RecommendedChannel, item.Template.Id, item.Template.Name);
+ templateState.SyncItems(BuildCutDebugDescriptors(item.Template, sceneVariables));
+ if (CutDebugRecommendationCatalog.TryGetRecommendation(item.Template.Id, out var recommendation))
+ {
+ Console.WriteLine($" preferred: {recommendation.Key} ({recommendation.Kind})");
+ }
+
+ var sweepItems = OrderSweepItems(item.Template.Id, templateState.Items, sceneVariables)
+ .Where(debugItem => options.IncludeItem(debugItem.Key, debugItem.Kind))
+ .ToList();
+ var hasExplicitItemFilter = !string.IsNullOrWhiteSpace(options.KeyFilter) ||
+ !string.IsNullOrWhiteSpace(options.KindFilter);
+ var targetChangedItems = 0;
+
+ if (options.MaxItems is int maxItems && maxItems > 0)
+ {
+ if (hasExplicitItemFilter)
+ {
+ sweepItems = sweepItems.Take(maxItems).ToList();
+ }
+ else
+ {
+ targetChangedItems = maxItems;
+ }
+ }
+
+ result.ItemCount = sweepItems.Count;
+
+ var settings = cutDebugStateStore.Get(item.Template.RecommendedChannel);
+ EnableAllDebugCategories(settings);
+ templateState.ClearOverrides();
+ SetAllItemsEnabled(templateState.Items, true);
+
+ var preElection = ShouldUsePreElectionSnapshot(item.Template.Name);
+ var snapshot = CreateSnapshot(item.Template.Name, index, variant: 0, preElection, options.SwapTopTwoCandidates);
+
+ var baselineAPath = Path.Combine(
+ options.OutputPath,
+ $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_A.png");
+ var baselineBPath = Path.Combine(
+ options.OutputPath,
+ $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_B.png");
+
+ var baselineA = await CaptureSweepImageAsync(
+ adapter,
+ pgmWindow.Value,
+ item,
+ snapshot,
+ station,
+ options,
+ baselineAPath).ConfigureAwait(false);
+
+ var baselineB = await CaptureSweepImageAsync(
+ adapter,
+ pgmWindow.Value,
+ item,
+ snapshot,
+ station,
+ options,
+ baselineBPath).ConfigureAwait(false);
+
+ result.BaselineAPath = baselineA.Path;
+ result.BaselineBPath = baselineB.Path;
+ result.BaselineHash = baselineB.Hash;
+
+ var baselineDiffPath = Path.Combine(
+ options.OutputPath,
+ $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_baseline_diff.png");
+ var baselineDiff = CompareImages(baselineA.Path, baselineB.Path, baselineDiffPath);
+ result.NoiseDiffPath = baselineDiffPath;
+ result.NoiseChangedPixels = baselineDiff.ChangedPixels;
+ result.SignificanceThresholdPixels = Math.Max(250, baselineDiff.ChangedPixels * 2);
+
+ var changedItemCount = 0;
+ foreach (var sweepItem in sweepItems)
+ {
+ templateState.ClearOverrides();
+ SetAllItemsEnabled(templateState.Items, true);
+ ConfigureSweepItem(templateState, sweepItem, options.Mode, replacementAssets);
+
+ var caseToken = $"{sweepItem.Key}_{sweepItem.Kind}_{options.Mode}";
+ var casePath = Path.Combine(
+ options.OutputPath,
+ $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_{SanitizeFileName(caseToken)}.png");
+ var diffPath = Path.Combine(
+ options.OutputPath,
+ $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_{SanitizeFileName(item.Cut.Name)}_{SanitizeFileName(caseToken)}_diff.png");
+
+ var logCountBefore = logService.Entries.Count;
+ var caseCapture = await CaptureSweepImageAsync(
+ adapter,
+ pgmWindow.Value,
+ item,
+ snapshot,
+ station,
+ options,
+ casePath).ConfigureAwait(false);
+ var logPreview = CaptureRecentLogs(logService, logCountBefore, 30);
+
+ var diff = CompareImages(baselineB.Path, caseCapture.Path, diffPath);
+ var itemResult = new CutDebugItemSweepResult
+ {
+ Key = sweepItem.Key,
+ Kind = sweepItem.Kind.ToString(),
+ GroupLabel = sweepItem.GroupLabel,
+ CapturePath = caseCapture.Path,
+ DiffPath = diffPath,
+ Hash = caseCapture.Hash,
+ ChangedPixels = diff.ChangedPixels,
+ ChangeRatio = diff.ChangeRatio,
+ AverageChannelDelta = diff.AverageChannelDelta,
+ SignificantChange = diff.ChangedPixels > result.SignificanceThresholdPixels,
+ BoundingBox = diff.BoundingBox,
+ LogPreview = logPreview,
+ Detail = diff.ChangedPixels == 0
+ ? $"No visual change ({options.Mode})"
+ : $"mode={options.Mode}, pixels={diff.ChangedPixels}, ratio={diff.ChangeRatio:P4}, bbox={diff.BoundingBox ?? "(none)"}"
+ };
+ result.Items.Add(itemResult);
+
+ if (itemResult.ChangedPixels > 0)
+ {
+ changedItemCount++;
+ if (targetChangedItems > 0 && changedItemCount >= targetChangedItems)
+ {
+ break;
+ }
+ }
+ }
+
+ result.Success = true;
+ result.ItemCount = result.Items.Count;
+ result.Detail = targetChangedItems > 0
+ ? $"items={result.ItemCount}, targetChanged={targetChangedItems}, threshold={result.SignificanceThresholdPixels}, noise={result.NoiseChangedPixels}"
+ : $"items={result.ItemCount}, threshold={result.SignificanceThresholdPixels}, noise={result.NoiseChangedPixels}";
+ }
+ catch (Exception ex)
+ {
+ result.Success = false;
+ result.Detail = ex.Message;
+ }
+ finally
+ {
+ templateState?.ClearOverrides();
+
+ try
+ {
+ await OutAllAsync(adapter).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();
+ }
+ }
+
+ WriteCutDebugSweepReports(options, results);
+
+ var totalItems = results.Sum(result => result.Items.Count);
+ var changedItems = results.Sum(result => result.Items.Count(item => item.ChangedPixels > 0));
+ var significantItems = results.Sum(result => result.Items.Count(item => item.SignificantChange));
+ var failures = results.Count(result => !result.Success);
+
+ Console.WriteLine();
+ Console.WriteLine("Summary");
+ Console.WriteLine($"- Cuts: {results.Count}");
+ Console.WriteLine($"- Swept Items: {totalItems}");
+ Console.WriteLine($"- Changed Items: {changedItems}");
+ Console.WriteLine($"- Significant Items: {significantItems}");
+ Console.WriteLine($"- Failures: {failures}");
+ Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}");
+
+ return failures == 0 ? 0 : 1;
+ }
+
+ public static int RunCutDebugCoverageReport(string[] args)
+ {
+ var options = CutDebugCoverageOptions.Parse(args);
+ Directory.CreateDirectory(options.OutputPath);
+
+ Console.WriteLine("Karisma cut-debug coverage report starting.");
+ Console.WriteLine($"- Image Root: {options.ImageRootPath}");
+ Console.WriteLine($"- Output: {options.OutputPath}");
+ Console.WriteLine($"- Filter: {(string.IsNullOrWhiteSpace(options.Filter) ? "(none)" : options.Filter)}");
+ Console.WriteLine($"- Cut Filter: {(string.IsNullOrWhiteSpace(options.CutName) ? "(none)" : options.CutName)}");
+ Console.WriteLine($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}");
+
+ var logService = new LogService();
+ var cutDebugStateStore = new CutDebugStateStore();
+ var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
+ var cutItems = new FormatCatalogService().GetAll()
+ .Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
+ .SelectMany(template => template.Cuts.Select(cut => new LiveCutWorkItem(template, cut)))
+ .ToList();
+
+ cutItems = ApplyTemplateFilter(cutItems, options.Filter);
+ cutItems = ApplyCutFilter(cutItems, options.CutName);
+
+ if (options.ExcludeHistoricalTurnout)
+ {
+ cutItems = cutItems
+ .Where(item => !IsHistoricalTurnoutTemplate(item.Template.Name))
+ .ToList();
+ }
+
+ if (options.Limit is int limit && limit > 0)
+ {
+ cutItems = cutItems.Take(limit).ToList();
+ }
+
+ if (cutItems.Count == 0)
+ {
+ Console.WriteLine("No cuts matched the requested filter.");
+ return 1;
+ }
+
+ Console.WriteLine($"- Cuts: {cutItems.Count}");
+ Console.WriteLine();
+
+ var results = new List();
+
+ for (var index = 0; index < cutItems.Count; index++)
+ {
+ var item = cutItems[index];
+ var result = new CutDebugCoverageResult
+ {
+ Index = index + 1,
+ TemplateId = item.Template.Id,
+ TemplateName = item.Template.Name,
+ CutName = item.Cut.Name,
+ Channel = item.Template.RecommendedChannel.ToString(),
+ ExcludedHistoricalTurnout = options.ExcludeHistoricalTurnout && IsHistoricalTurnoutTemplate(item.Template.Name)
+ };
+
+ Console.WriteLine($"[{index + 1}/{cutItems.Count}] {item.Template.Id} / {item.Cut.Name}");
+
+ try
+ {
+ var resolvedScene = KarismaSceneResolver.ResolveScene(item.Template, options.ImageRootPath, useLoop: false);
+ var sceneVariables = sceneVariableCatalog.GetSceneVariables(options.ImageRootPath, resolvedScene.Path);
+ var templateState = cutDebugStateStore.GetTemplate(item.Template.RecommendedChannel, item.Template.Id, item.Template.Name);
+ templateState.SyncItems(BuildCutDebugDescriptors(item.Template, sceneVariables));
+
+ result.ScenePath = resolvedScene.Path;
+ result.SceneVariableCount = sceneVariables.Count;
+ result.ItemCount = templateState.Items.Count;
+ result.TextItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.TextValue);
+ result.ImageItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.ImageValue);
+ result.CounterItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.Counter);
+ result.StyleItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.StyleColor);
+ result.VisibilityItemCount = templateState.Items.Count(debugItem => debugItem.Kind == CutDebugItemKind.Visibility);
+ result.SampleKeys = string.Join(", ", templateState.Items.Take(12).Select(debugItem => $"{debugItem.Key}:{debugItem.Kind}"));
+ result.Success = true;
+ result.Detail = $"sceneVars={result.SceneVariableCount}, items={result.ItemCount}";
+ }
+ catch (Exception ex)
+ {
+ result.Success = false;
+ result.Detail = ex.Message;
+ }
+
+ results.Add(result);
+ }
+
+ WriteCutDebugCoverageReports(options, results);
+
+ var successCount = results.Count(result => result.Success);
+ var zeroCoverageCount = results.Count(result => result.Success && result.ItemCount == 0);
+ var failureCount = results.Count(result => !result.Success);
+
+ Console.WriteLine();
+ Console.WriteLine("Summary");
+ Console.WriteLine($"- Success: {successCount}/{results.Count}");
+ Console.WriteLine($"- Zero Coverage: {zeroCoverageCount}");
+ Console.WriteLine($"- Failures: {failureCount}");
+ Console.WriteLine($"- Report: {Path.Combine(options.OutputPath, "summary.md")}");
+
+ return failureCount == 0 ? 0 : 1;
+ }
+
+ private static async Task CaptureSweepImageAsync(
+ ITornado3Adapter adapter,
+ PgmWindow pgmWindow,
+ LiveCutWorkItem item,
+ ElectionDataSnapshot snapshot,
+ BroadcastStationProfile station,
+ CutDebugSweepOptions options,
+ string outputPath)
+ {
+ await OutAllAsync(adapter).ConfigureAwait(false);
+ await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
+
+ await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshot, 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(ResolveSweepOnAirDelayMs(item.Template, options)).ConfigureAwait(false);
+
+ return new SweepCaptureResult(outputPath, CapturePgm(pgmWindow, outputPath, skipCapture: false));
+ }
+
+ private static void EnableAllDebugCategories(CutDebugSettings settings)
+ {
+ settings.IsEnabled = true;
+ settings.ApplyTextValues = true;
+ settings.ApplyImageValues = true;
+ settings.ApplyVisibilityValues = true;
+ settings.ApplyVoteRateTextValues = true;
+ settings.ApplyVoteRateCounterValues = true;
+ settings.ApplyPartyBarStyleColors = true;
+ settings.ApplyPartyPlateStyleColors = true;
+ settings.ApplyVoteRateStyleColors = true;
+ }
+
+ private static void SetAllItemsEnabled(IEnumerable items, bool isEnabled)
+ {
+ foreach (var item in items)
+ {
+ item.IsEnabled = isEnabled;
+ }
+ }
+
+ private static IReadOnlyList OrderSweepItems(
+ string templateId,
+ IEnumerable items,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var sceneVariableKeys = sceneVariables.Keys
+ .Select(CutDebugTemplateState.NormalizeKey)
+ .Where(key => !string.IsNullOrWhiteSpace(key))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var hasRecommendation = CutDebugRecommendationCatalog.TryGetRecommendation(templateId, out var recommendation);
+
+ return items
+ .OrderBy(item => hasRecommendation ? !MatchesRecommendation(item, recommendation) : false)
+ .ThenBy(item => !sceneVariableKeys.Contains(CutDebugTemplateState.NormalizeKey(item.Key)))
+ .ThenBy(item => GetSweepKeyPriority(item.Key))
+ .ThenBy(item => GetSweepKindPriority(item.Kind))
+ .ThenBy(item => ResolveDebugItemIndex(item.Key) ?? int.MaxValue)
+ .ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ private static bool MatchesRecommendation(CutDebugItemState item, CutDebugRecommendation recommendation)
+ {
+ return item.Kind == recommendation.Kind &&
+ string.Equals(
+ CutDebugTemplateState.NormalizeKey(item.Key),
+ CutDebugTemplateState.NormalizeKey(recommendation.Key),
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static void ConfigureSweepItem(
+ CutDebugTemplateState templateState,
+ CutDebugItemState sweepItem,
+ CutDebugSweepMode mode,
+ CutDebugReplacementAssets replacementAssets)
+ {
+ if (mode == CutDebugSweepMode.Disable)
+ {
+ sweepItem.IsEnabled = false;
+ return;
+ }
+
+ var itemIndex = ResolveDebugItemIndex(sweepItem.Key);
+ switch (sweepItem.Kind)
+ {
+ case CutDebugItemKind.TextValue:
+ templateState.SetOverride(
+ sweepItem.Key,
+ sweepItem.Kind,
+ CutDebugOverride.ForString(itemIndex.HasValue ? $"DBG{itemIndex:00}" : "DEBUG"));
+ break;
+ case CutDebugItemKind.ImageValue:
+ templateState.SetOverride(
+ sweepItem.Key,
+ sweepItem.Kind,
+ CutDebugOverride.ForString(itemIndex.GetValueOrDefault(1) % 2 == 0
+ ? replacementAssets.CyanPath
+ : replacementAssets.MagentaPath));
+ break;
+ case CutDebugItemKind.Counter:
+ templateState.SetOverride(
+ sweepItem.Key,
+ sweepItem.Kind,
+ CutDebugOverride.ForNumber(itemIndex.GetValueOrDefault(1) % 2 == 0 ? 12.3 : 87.6));
+ break;
+ case CutDebugItemKind.StyleColor:
+ templateState.SetOverride(
+ sweepItem.Key,
+ sweepItem.Kind,
+ itemIndex.GetValueOrDefault(1) % 2 == 0
+ ? CutDebugOverride.ForColor(0, 255, 255)
+ : CutDebugOverride.ForColor(255, 0, 255));
+ break;
+ case CutDebugItemKind.Visibility:
+ templateState.SetOverride(
+ sweepItem.Key,
+ sweepItem.Kind,
+ CutDebugOverride.ForVisibility(false));
+ break;
+ }
+ }
+
+ private static CutDebugReplacementAssets EnsureCutDebugReplacementAssets(string outputPath)
+ {
+ var assetsPath = Path.Combine(outputPath, "_debug_assets");
+ Directory.CreateDirectory(assetsPath);
+
+ var magentaPath = Path.Combine(assetsPath, "debug_magenta.png");
+ var cyanPath = Path.Combine(assetsPath, "debug_cyan.png");
+ WriteDebugReplacementAsset(magentaPath, Color.FromArgb(255, 255, 0, 255), Color.FromArgb(255, 255, 255, 0));
+ WriteDebugReplacementAsset(cyanPath, Color.FromArgb(255, 0, 255, 255), Color.FromArgb(255, 0, 0, 0));
+ return new CutDebugReplacementAssets(magentaPath, cyanPath);
+ }
+
+ private static void WriteDebugReplacementAsset(string path, Color background, Color accent)
+ {
+ if (File.Exists(path))
+ {
+ return;
+ }
+
+ using var bitmap = new Bitmap(384, 384, PixelFormat.Format32bppArgb);
+ using var graphics = Graphics.FromImage(bitmap);
+ graphics.Clear(background);
+
+ using var borderPen = new Pen(accent, 24);
+ graphics.DrawRectangle(borderPen, 12, 12, bitmap.Width - 24, bitmap.Height - 24);
+ graphics.DrawLine(borderPen, 24, 24, bitmap.Width - 24, bitmap.Height - 24);
+ graphics.DrawLine(borderPen, bitmap.Width - 24, 24, 24, bitmap.Height - 24);
+
+ using var font = new Font("Arial", 72, FontStyle.Bold, GraphicsUnit.Pixel);
+ using var brush = new SolidBrush(accent);
+ var text = "DBG";
+ var size = graphics.MeasureString(text, font);
+ graphics.DrawString(
+ text,
+ font,
+ brush,
+ (bitmap.Width - size.Width) / 2,
+ (bitmap.Height - size.Height) / 2);
+
+ bitmap.Save(path, ImageFormat.Png);
+ }
+
+ private static int? ResolveDebugItemIndex(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key) || key.Length < 2)
+ {
+ return null;
+ }
+
+ var suffix = key[^2..];
+ return int.TryParse(suffix, out var parsed) && parsed > 0
+ ? parsed
+ : null;
+ }
+
+ private static int GetSweepKindPriority(CutDebugItemKind kind)
+ {
+ return kind switch
+ {
+ CutDebugItemKind.ImageValue => 0,
+ CutDebugItemKind.Counter => 1,
+ CutDebugItemKind.TextValue => 2,
+ CutDebugItemKind.StyleColor => 3,
+ CutDebugItemKind.Visibility => 4,
+ _ => 9
+ };
+ }
+
+ private static int GetSweepKeyPriority(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ return 99;
+ }
+
+ var normalizedKey = CutDebugTemplateState.NormalizeKey(key);
+ return normalizedKey switch
+ {
+ var value when value.StartsWith("후보사진", StringComparison.OrdinalIgnoreCase) => 0,
+ var value when value.StartsWith("후보명", StringComparison.OrdinalIgnoreCase) => 1,
+ var value when value.StartsWith("투표율", StringComparison.OrdinalIgnoreCase) => 2,
+ var value when value.StartsWith("전국투표율", StringComparison.OrdinalIgnoreCase) => 2,
+ var value when value.StartsWith("득표율", StringComparison.OrdinalIgnoreCase) => 2,
+ 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) => 6,
+ var value when value.StartsWith("유확당", StringComparison.OrdinalIgnoreCase) => 7,
+ var value when value.StartsWith("정당바", StringComparison.OrdinalIgnoreCase) => 8,
+ var value when value.StartsWith("정당판", StringComparison.OrdinalIgnoreCase) => 8,
+ var value when value.StartsWith("득표수바", StringComparison.OrdinalIgnoreCase) => 8,
+ var value when value.StartsWith("기호", StringComparison.OrdinalIgnoreCase) => 9,
+ var value when value.StartsWith("순위", StringComparison.OrdinalIgnoreCase) => 9,
+ var value when value.StartsWith("기준시", StringComparison.OrdinalIgnoreCase) => 10,
+ _ => 20
+ };
+ }
+
+ private static int ResolveSweepOnAirDelayMs(FormatTemplateDefinition template, CutDebugSweepOptions options)
+ {
+ return ResolveTemplateOnAirDelayMs(template, options.OnAirDelayMs);
+ }
+
+ private static int ResolveTemplateOnAirDelayMs(FormatTemplateDefinition template, int configuredDelay)
+ {
+ var delay = configuredDelay;
+ if (template.Name.StartsWith("사전_역대투표율", StringComparison.Ordinal))
+ {
+ return Math.Max(delay, 8000);
+ }
+
+ return template.Id.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
+ template.Name.Contains("ani", StringComparison.OrdinalIgnoreCase)
+ ? Math.Max(delay, 3500)
+ : delay;
+ }
+
+ private static string? CaptureRecentLogs(LogService logService, int previousCount, int maxLines)
+ {
+ var addedCount = Math.Max(0, logService.Entries.Count - previousCount);
+ if (addedCount == 0)
+ {
+ return null;
+ }
+
+ return string.Join(
+ " || ",
+ logService.Entries
+ .Take(Math.Min(addedCount, maxLines))
+ .Select(entry => entry.Message));
+ }
+
+ private static BroadcastStationProfile CreateValidationStation(string stationLogoPath)
+ {
+ return new BroadcastStationProfile
+ {
+ Id = "AUTO",
+ Name = "AUTO",
+ LogoAssetPath = stationLogoPath,
+ RegionFilters = ["서울", "인천", "충남"]
+ };
+ }
+
+ private static IReadOnlyList BuildCutDebugDescriptors(
+ FormatTemplateDefinition template,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var descriptors = new List();
+
+ foreach (var variable in sceneVariables.Values.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase))
+ {
+ var normalizedKey = CutDebugTemplateState.NormalizeKey(variable.Name);
+ if (string.IsNullOrWhiteSpace(normalizedKey))
+ {
+ continue;
+ }
+
+ if (ShouldExcludeHistoricalTurnoutGraph(template, normalizedKey))
+ {
+ continue;
+ }
+
+ var primaryKind = variable.Kind is KarismaSceneVariableKind.Image or KarismaSceneVariableKind.VideoResource
+ ? CutDebugItemKind.ImageValue
+ : CutDebugItemKind.TextValue;
+ descriptors.Add(new CutDebugItemDescriptor(normalizedKey, primaryKind, ResolveDebugGroupLabel(normalizedKey, primaryKind)));
+
+ if (variable.Kind == KarismaSceneVariableKind.Counter || IsVoteRateDebugKey(normalizedKey))
+ {
+ descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.Counter, "counter"));
+ }
+
+ if (IsStyleDebugKey(normalizedKey))
+ {
+ descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.StyleColor, "style"));
+ }
+
+ if (IsVisibilityDebugKey(normalizedKey))
+ {
+ descriptors.Add(new CutDebugItemDescriptor(normalizedKey, CutDebugItemKind.Visibility, "visibility"));
+ }
+ }
+
+ descriptors.AddRange(
+ BuildHeuristicCutDebugDescriptors(template)
+ .Where(descriptor => ShouldIncludeHeuristicDescriptor(template, sceneVariables, descriptor)));
+
+ return descriptors;
+ }
+
+ private static bool ShouldExcludeHistoricalTurnoutGraph(
+ FormatTemplateDefinition template,
+ string normalizedKey)
+ {
+ return IsHistoricalTurnoutTemplate(template.Name) &&
+ string.Equals(normalizedKey, "차트01", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool ShouldIncludeHeuristicDescriptor(
+ FormatTemplateDefinition template,
+ IReadOnlyDictionary sceneVariables,
+ CutDebugItemDescriptor descriptor)
+ {
+ var normalizedKey = CutDebugTemplateState.NormalizeKey(descriptor.Key);
+ if (ShouldExcludeHistoricalTurnoutGraph(template, normalizedKey))
+ {
+ return false;
+ }
+
+ if (string.Equals(normalizedKey, "전국투표율01", StringComparison.OrdinalIgnoreCase))
+ {
+ return sceneVariables.Keys.Any(key =>
+ string.Equals(
+ CutDebugTemplateState.NormalizeKey(key),
+ normalizedKey,
+ StringComparison.OrdinalIgnoreCase));
+ }
+
+ return true;
+ }
+
+ private static IEnumerable BuildHeuristicCutDebugDescriptors(FormatTemplateDefinition template)
+ {
+ var slotCount = ResolveDebugSlotCount(template);
+
+ foreach (var prefix in new[] { "시도명", "개표율", "투표율", "전국투표율", "기준시", "유권자수", "투표자수" })
+ {
+ yield return new CutDebugItemDescriptor($"{prefix}01", CutDebugItemKind.TextValue, "common");
+ }
+
+ foreach (var prefix in new[] { "순위", "기호", "기호텍스트", "후보명", "정당명", "득표수", "득표율", "표차", "득표차", "선거구명", "시도명", "개표율", "투표율" })
+ {
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.TextValue, "text");
+ }
+ }
+
+ foreach (var prefix in new[] { "유확당", "후보사진", "득표수바", "정당바", "정당판", "정당원", "정당색", "정당심볼", "그룹", "바" })
+ {
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.ImageValue, "image");
+ }
+ }
+
+ foreach (var slot in Enumerable.Range(1, slotCount))
+ {
+ yield return new CutDebugItemDescriptor($"득표율{slot:00}", CutDebugItemKind.Counter, "counter");
+ }
+
+ yield return new CutDebugItemDescriptor("전국투표율01", CutDebugItemKind.Counter, "counter");
+
+ foreach (var prefix in new[] { "기호", "기호텍스트", "득표수바", "정당바", "정당판", "정당원", "정당색", "정당명", "득표율" })
+ {
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.StyleColor, "style");
+ }
+ }
+
+ foreach (var prefix in new[] { "유확당", "그룹" })
+ {
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ yield return new CutDebugItemDescriptor($"{prefix}{slot:00}", CutDebugItemKind.Visibility, "visibility");
+ }
+ }
+
+ foreach (var slot in Enumerable.Range(1, 3))
+ {
+ yield return new CutDebugItemDescriptor($"공약그룹{slot:00}", CutDebugItemKind.Visibility, "visibility");
+ }
+ }
+
+ private static int ResolveDebugSlotCount(FormatTemplateDefinition template)
+ {
+ 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))
+ {
+ return Math.Max(1, topRankSlots);
+ }
+
+ var peopleMatch = System.Text.RegularExpressions.Regex.Match(source, @"(\d+)인");
+ if (peopleMatch.Success && int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlots))
+ {
+ return Math.Max(1, peopleSlots);
+ }
+
+ return 2;
+ }
+
+ private static string ResolveDebugGroupLabel(string key, CutDebugItemKind kind)
+ {
+ return kind switch
+ {
+ CutDebugItemKind.TextValue when key.StartsWith("공약", StringComparison.Ordinal) => "promise",
+ CutDebugItemKind.TextValue => "text",
+ CutDebugItemKind.ImageValue => "image",
+ CutDebugItemKind.Counter => "counter",
+ CutDebugItemKind.StyleColor => "style",
+ CutDebugItemKind.Visibility => "visibility",
+ _ => "other"
+ };
+ }
+
+ private static bool IsVoteRateDebugKey(string key)
+ {
+ return key.StartsWith("득표율", StringComparison.Ordinal) ||
+ key.StartsWith("투표율", StringComparison.Ordinal) ||
+ key.StartsWith("전국투표율", StringComparison.Ordinal);
+ }
+
+ private static bool IsHistoricalTurnoutTemplate(string templateName)
+ {
+ return templateName.StartsWith("사전_역대투표율", StringComparison.Ordinal);
+ }
+
+ private static bool IsStyleDebugKey(string key)
+ {
+ return key.StartsWith("기호", StringComparison.Ordinal) ||
+ key.StartsWith("득표수바", StringComparison.Ordinal) ||
+ key.StartsWith("정당바", StringComparison.Ordinal) ||
+ key.StartsWith("정당판", StringComparison.Ordinal) ||
+ key.StartsWith("정당원", StringComparison.Ordinal) ||
+ key.StartsWith("정당색", StringComparison.Ordinal) ||
+ key.StartsWith("정당명", StringComparison.Ordinal) ||
+ key.StartsWith("득표율", StringComparison.Ordinal);
+ }
+
+ private static bool IsVisibilityDebugKey(string key)
+ {
+ return key.StartsWith("유확당", StringComparison.Ordinal) ||
+ key.StartsWith("그룹", StringComparison.Ordinal) ||
+ key.StartsWith("공약그룹", StringComparison.Ordinal);
+ }
+
+ private static ImageDiffResult CompareImages(string baselinePath, string candidatePath, string diffPath)
+ {
+ using var baselineOriginal = new Bitmap(baselinePath);
+ using var candidateOriginal = new Bitmap(candidatePath);
+ using var baseline = EnsureArgbBitmap(baselineOriginal);
+ using var candidate = EnsureArgbBitmap(candidateOriginal);
+
+ if (baseline.Width != candidate.Width || baseline.Height != candidate.Height)
+ {
+ throw new InvalidOperationException("Baseline and candidate captures have different dimensions.");
+ }
+
+ var width = baseline.Width;
+ var height = baseline.Height;
+ using var diffBitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
+
+ var baselineData = baseline.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
+ var candidateData = candidate.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
+ var diffData = diffBitmap.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
+
+ try
+ {
+ var baselineStride = Math.Abs(baselineData.Stride);
+ var candidateStride = Math.Abs(candidateData.Stride);
+ var diffStride = Math.Abs(diffData.Stride);
+ var baselineBytes = new byte[baselineStride * height];
+ var candidateBytes = new byte[candidateStride * height];
+ var diffBytes = new byte[diffStride * height];
+
+ Marshal.Copy(baselineData.Scan0, baselineBytes, 0, baselineBytes.Length);
+ Marshal.Copy(candidateData.Scan0, candidateBytes, 0, candidateBytes.Length);
+
+ var changedPixels = 0;
+ long totalDelta = 0;
+ int? minX = null;
+ int? minY = null;
+ int? maxX = null;
+ int? maxY = null;
+
+ for (var y = 0; y < height; y++)
+ {
+ var baselineRowOffset = y * baselineStride;
+ var candidateRowOffset = y * candidateStride;
+ var diffRowOffset = y * diffStride;
+ for (var x = 0; x < width; x++)
+ {
+ var baselineOffset = baselineRowOffset + (x * 4);
+ var candidateOffset = candidateRowOffset + (x * 4);
+ var diffOffset = diffRowOffset + (x * 4);
+ var blueDelta = Math.Abs(baselineBytes[baselineOffset] - candidateBytes[candidateOffset]);
+ var greenDelta = Math.Abs(baselineBytes[baselineOffset + 1] - candidateBytes[candidateOffset + 1]);
+ var redDelta = Math.Abs(baselineBytes[baselineOffset + 2] - candidateBytes[candidateOffset + 2]);
+ var alphaDelta = Math.Abs(baselineBytes[baselineOffset + 3] - candidateBytes[candidateOffset + 3]);
+ var delta = blueDelta + greenDelta + redDelta + alphaDelta;
+
+ if (delta > 24)
+ {
+ changedPixels++;
+ totalDelta += delta;
+ minX = !minX.HasValue || x < minX.Value ? x : minX.Value;
+ minY = !minY.HasValue || y < minY.Value ? y : minY.Value;
+ maxX = !maxX.HasValue || x > maxX.Value ? x : maxX.Value;
+ maxY = !maxY.HasValue || y > maxY.Value ? y : maxY.Value;
+
+ diffBytes[diffOffset] = 0;
+ diffBytes[diffOffset + 1] = (byte)Math.Min(255, delta);
+ diffBytes[diffOffset + 2] = 255;
+ diffBytes[diffOffset + 3] = 255;
+ }
+ else
+ {
+ diffBytes[diffOffset] = 0;
+ diffBytes[diffOffset + 1] = 0;
+ diffBytes[diffOffset + 2] = 0;
+ diffBytes[diffOffset + 3] = 255;
+ }
+ }
+ }
+
+ Marshal.Copy(diffBytes, 0, diffData.Scan0, diffBytes.Length);
+ Directory.CreateDirectory(Path.GetDirectoryName(diffPath)!);
+ diffBitmap.Save(diffPath, ImageFormat.Png);
+
+ var totalPixels = Math.Max(1, width * height);
+ var boundingBox = minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue
+ ? $"{minX.Value},{minY.Value} - {maxX.Value},{maxY.Value}"
+ : null;
+
+ return new ImageDiffResult(
+ changedPixels,
+ changedPixels / (double)totalPixels,
+ changedPixels == 0 ? 0 : totalDelta / (double)changedPixels,
+ boundingBox);
+ }
+ finally
+ {
+ baseline.UnlockBits(baselineData);
+ candidate.UnlockBits(candidateData);
+ diffBitmap.UnlockBits(diffData);
+ }
+ }
+
+ private static Bitmap EnsureArgbBitmap(Bitmap source)
+ {
+ if (source.PixelFormat == PixelFormat.Format32bppArgb)
+ {
+ return (Bitmap)source.Clone();
+ }
+
+ var converted = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb);
+ using var graphics = Graphics.FromImage(converted);
+ graphics.DrawImage(source, 0, 0, source.Width, source.Height);
+ return converted;
+ }
+
+ private static void WriteCutDebugSweepReports(CutDebugSweepOptions options, IReadOnlyList results)
+ {
+ var jsonPath = Path.Combine(options.OutputPath, "results.json");
+ var csvPath = Path.Combine(options.OutputPath, "items.csv");
+ var summaryPath = Path.Combine(options.OutputPath, "summary.md");
+
+ File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8);
+
+ var csv = new StringBuilder();
+ csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Key,Kind,GroupLabel,ChangedPixels,ChangeRatio,AverageChannelDelta,SignificantChange,BoundingBox,CapturePath,DiffPath,LogPreview,Detail");
+ foreach (var result in results)
+ {
+ foreach (var item in result.Items)
+ {
+ csv.AppendLine(string.Join(",",
+ Csv(result.Index.ToString()),
+ Csv(result.TemplateId),
+ Csv(result.TemplateName),
+ Csv(result.CutName),
+ Csv(result.Channel),
+ Csv(item.Key),
+ Csv(item.Kind),
+ Csv(item.GroupLabel),
+ Csv(item.ChangedPixels.ToString()),
+ Csv(item.ChangeRatio.ToString("0.000000")),
+ Csv(item.AverageChannelDelta.ToString("0.00")),
+ Csv(item.SignificantChange.ToString()),
+ Csv(item.BoundingBox ?? string.Empty),
+ Csv(item.CapturePath ?? string.Empty),
+ Csv(item.DiffPath ?? string.Empty),
+ Csv(item.LogPreview ?? string.Empty),
+ Csv(item.Detail ?? string.Empty)));
+ }
+ }
+
+ File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8);
+
+ var summary = new StringBuilder();
+ summary.AppendLine("# Cut Debug Sweep");
+ 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($"- Mode: {options.Mode}");
+ summary.AppendLine($"- Cuts: {results.Count}");
+ summary.AppendLine($"- Total Items: {results.Sum(result => result.Items.Count)}");
+ summary.AppendLine($"- Changed Items: {results.Sum(result => result.Items.Count(item => item.ChangedPixels > 0))}");
+ summary.AppendLine($"- Significant Items: {results.Sum(result => result.Items.Count(item => item.SignificantChange))}");
+ summary.AppendLine();
+
+ foreach (var result in results)
+ {
+ summary.AppendLine($"## `{result.TemplateId}` / `{result.CutName}`");
+ summary.AppendLine();
+ summary.AppendLine($"- Success: {result.Success}");
+ summary.AppendLine($"- Scene: `{result.ScenePath}`");
+ summary.AppendLine($"- Items: {result.ItemCount}");
+ summary.AppendLine($"- Changed Items: {result.Items.Count(item => item.ChangedPixels > 0)}");
+ summary.AppendLine($"- Baseline Noise Pixels: {result.NoiseChangedPixels}");
+ summary.AppendLine($"- Significant Threshold Pixels: {result.SignificanceThresholdPixels}");
+ summary.AppendLine($"- Detail: {result.Detail}");
+ summary.AppendLine();
+
+ var topItems = result.Items
+ .OrderByDescending(item => item.SignificantChange)
+ .ThenByDescending(item => item.ChangedPixels)
+ .Take(20)
+ .ToList();
+
+ if (topItems.Count == 0)
+ {
+ summary.AppendLine("- No item results.");
+ summary.AppendLine();
+ continue;
+ }
+
+ summary.AppendLine("| Key | Kind | Group | Pixels | Ratio | Significant | Bounding Box |");
+ summary.AppendLine("| --- | --- | --- | ---: | ---: | --- | --- |");
+ foreach (var item in topItems)
+ {
+ summary.AppendLine($"| `{item.Key}` | `{item.Kind}` | `{item.GroupLabel}` | {item.ChangedPixels} | {item.ChangeRatio:0.000000} | {(item.SignificantChange ? "yes" : "no")} | `{item.BoundingBox ?? string.Empty}` |");
+ }
+
+ summary.AppendLine();
+ }
+
+ summary.AppendLine("## Files");
+ summary.AppendLine();
+ summary.AppendLine("- `results.json`");
+ summary.AppendLine("- `items.csv`");
+ summary.AppendLine("- `*_baseline_A.png`, `*_baseline_B.png`, `*_baseline_diff.png`");
+ summary.AppendLine("- `*_{Kind}_{Mode}.png`, `*_diff.png`");
+
+ File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8);
+ }
+
+ private static void WriteCutDebugCoverageReports(CutDebugCoverageOptions options, IReadOnlyList results)
+ {
+ var jsonPath = Path.Combine(options.OutputPath, "results.json");
+ var csvPath = Path.Combine(options.OutputPath, "results.csv");
+ var summaryPath = Path.Combine(options.OutputPath, "summary.md");
+
+ File.WriteAllText(jsonPath, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }), Encoding.UTF8);
+
+ var csv = new StringBuilder();
+ csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Success,ScenePath,SceneVariableCount,ItemCount,TextItemCount,ImageItemCount,CounterItemCount,StyleItemCount,VisibilityItemCount,SampleKeys,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.Success.ToString()),
+ Csv(result.ScenePath ?? string.Empty),
+ Csv(result.SceneVariableCount.ToString()),
+ Csv(result.ItemCount.ToString()),
+ Csv(result.TextItemCount.ToString()),
+ Csv(result.ImageItemCount.ToString()),
+ Csv(result.CounterItemCount.ToString()),
+ Csv(result.StyleItemCount.ToString()),
+ Csv(result.VisibilityItemCount.ToString()),
+ Csv(result.SampleKeys ?? string.Empty),
+ Csv(result.Detail ?? string.Empty)));
+ }
+
+ File.WriteAllText(csvPath, csv.ToString(), Encoding.UTF8);
+
+ var zeroCoverage = results
+ .Where(result => result.Success && result.ItemCount == 0)
+ .OrderBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(result => result.CutName, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var failures = results
+ .Where(result => !result.Success)
+ .OrderBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(result => result.CutName, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var strongestCoverage = results
+ .Where(result => result.Success)
+ .OrderByDescending(result => result.ItemCount)
+ .ThenBy(result => result.TemplateId, StringComparer.OrdinalIgnoreCase)
+ .Take(15)
+ .ToList();
+
+ var summary = new StringBuilder();
+ summary.AppendLine("# Cut Debug Coverage");
+ 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($"- Exclude Historical Turnout: {(options.ExcludeHistoricalTurnout ? "yes" : "no")}");
+ summary.AppendLine($"- Cuts: {results.Count}");
+ summary.AppendLine($"- Zero Coverage: {zeroCoverage.Count}");
+ summary.AppendLine($"- Failures: {failures.Count}");
+ summary.AppendLine();
+
+ if (zeroCoverage.Count > 0)
+ {
+ summary.AppendLine("## Zero Coverage");
+ summary.AppendLine();
+ foreach (var result in zeroCoverage)
+ {
+ summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`");
+ }
+
+ summary.AppendLine();
+ }
+
+ if (failures.Count > 0)
+ {
+ summary.AppendLine("## Failures");
+ summary.AppendLine();
+ foreach (var result in failures)
+ {
+ summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: {result.Detail}");
+ }
+
+ summary.AppendLine();
+ }
+
+ if (strongestCoverage.Count > 0)
+ {
+ summary.AppendLine("## Top Coverage");
+ summary.AppendLine();
+ foreach (var result in strongestCoverage)
+ {
+ summary.AppendLine($"- `{result.TemplateId}` / `{result.CutName}`: items={result.ItemCount}, sceneVars={result.SceneVariableCount}");
+ }
+
+ summary.AppendLine();
+ }
+
+ summary.AppendLine("## Files");
+ summary.AppendLine();
+ summary.AppendLine("- `results.csv`");
+ summary.AppendLine("- `results.json`");
+
+ File.WriteAllText(summaryPath, summary.ToString(), Encoding.UTF8);
+ }
+
private static async Task OutAllAsync(ITornado3Adapter adapter)
{
foreach (var channel in new[]
@@ -185,7 +1349,7 @@ internal static class LiveCutValidation
templateName.StartsWith("사전_", StringComparison.Ordinal);
}
- private static ElectionDataSnapshot CreateSnapshot(string templateName, int index, int variant, bool preElection)
+ private static ElectionDataSnapshot CreateSnapshot(string templateName, int index, int variant, bool preElection, bool swapTopTwoCandidates)
{
var metadata = BuildScenarioMetadata(templateName, index, variant);
return new ElectionDataSnapshot
@@ -196,7 +1360,7 @@ internal static class LiveCutValidation
DistrictCode = metadata.DistrictCode,
RegionName = metadata.RegionName,
ElectionDistrictName = metadata.ElectionDistrictName,
- Candidates = preElection ? Array.Empty() : CreateCandidates(templateName, metadata, variant),
+ Candidates = preElection ? Array.Empty() : CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates),
TotalExpectedVotes = metadata.TotalExpectedVotes,
TurnoutVotes = metadata.TurnoutVotes,
CountedVotesFromApi = metadata.CountedVotes,
@@ -210,22 +1374,29 @@ internal static class LiveCutValidation
};
}
- private static IReadOnlyList CreateCandidates(string templateName, ScenarioMetadata metadata, int variant)
+ private static IReadOnlyList CreateCandidates(string templateName, ScenarioMetadata metadata, int variant, bool swapTopTwoCandidates)
{
var candidateNames = ResolveCandidateNames(templateName);
var parties = ResolveParties(candidateNames.Length);
var shares = ResolveVoteShares(templateName, candidateNames.Length, variant);
var automaticJudgement = ResolveAutomaticJudgement(templateName);
+ var identityOrder = Enumerable.Range(0, candidateNames.Length).ToArray();
+
+ if (swapTopTwoCandidates && identityOrder.Length >= 2)
+ {
+ (identityOrder[0], identityOrder[1]) = (identityOrder[1], identityOrder[0]);
+ }
var candidates = new List(candidateNames.Length);
for (var index = 0; index < candidateNames.Length; index++)
{
+ var identityIndex = identityOrder[index];
candidates.Add(new CandidateEntry
{
CandidateCode = (index + 1).ToString(),
- Name = candidateNames[index],
- Party = parties[index],
- ColorParty = parties[index],
+ Name = candidateNames[identityIndex],
+ Party = parties[identityIndex],
+ ColorParty = parties[identityIndex],
VoteRate = shares[index],
VoteCount = (int)Math.Round(metadata.CountedVotes * shares[index] / 100d, MidpointRounding.AwayFromZero),
HasImage = true,
@@ -239,20 +1410,26 @@ internal static class LiveCutValidation
private static IReadOnlyList CreateHistoricalTurnout(ScenarioMetadata metadata, int variant)
{
- var years = new[] { 2010, 2014, 2018, 2022 };
- var rates = new[] { 56.1, 58.4, 61.8, 64.2 + variant * 1.4 };
- var entries = new List(years.Length);
+ // The historical turnout scenes currently ship with a fixed line/marker drawing in the source tscn.
+ // For automated debug captures, seed the text values so they follow that baked-in graph shape.
+ var seeds = string.Equals(metadata.ElectionType, "교육감", StringComparison.Ordinal)
+ ? HistoricalTurnoutEducationSeeds
+ : HistoricalTurnoutLocalSeeds;
+ var entries = new List(seeds.Count);
+ var variantOffset = variant * 0.5;
- for (var index = 0; index < years.Length; index++)
+ for (var index = 0; index < seeds.Count; index++)
{
- var electors = metadata.TotalExpectedVotes - 50000 + (index * 25000);
+ var seed = seeds[index];
+ var electors = Math.Max(1000, metadata.TotalExpectedVotes - 75000 + (index * 25000));
+ var rate = Math.Clamp(seed.TurnoutRate + variantOffset, 0.1, 99.9);
entries.Add(new PreElectionHistoricalTurnoutEntry
{
- ElectionOrder = 5 + index,
- Year = years[index],
+ ElectionOrder = seed.ElectionOrder,
+ Year = seed.Year,
Electors = electors,
- Votes = (int)Math.Round(electors * rates[index] / 100d, MidpointRounding.AwayFromZero),
- TurnoutRate = rates[index]
+ Votes = (int)Math.Round(electors * rate / 100d, MidpointRounding.AwayFromZero),
+ TurnoutRate = rate
});
}
@@ -445,19 +1622,6 @@ internal static class LiveCutValidation
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;
}
@@ -571,6 +1735,54 @@ internal static class LiveCutValidation
return $"\"{value.Replace("\"", "\"\"")}\"";
}
+ private static List ApplyTemplateFilter(IEnumerable items, string? filter)
+ {
+ var list = items.ToList();
+ if (string.IsNullOrWhiteSpace(filter))
+ {
+ return list;
+ }
+
+ var exactMatches = list
+ .Where(item =>
+ string.Equals(item.Template.Id, filter, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(item.Template.Name, filter, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (exactMatches.Count > 0)
+ {
+ return exactMatches;
+ }
+
+ return list
+ .Where(item =>
+ item.Template.Id.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
+ item.Template.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ }
+
+ private static List ApplyCutFilter(IEnumerable items, string? cutName)
+ {
+ var list = items.ToList();
+ if (string.IsNullOrWhiteSpace(cutName))
+ {
+ return list;
+ }
+
+ var exactMatches = list
+ .Where(item => string.Equals(item.Cut.Name, cutName, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (exactMatches.Count > 0)
+ {
+ return exactMatches;
+ }
+
+ return list
+ .Where(item => item.Cut.Name.Contains(cutName, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ }
+
private static string SanitizeFileName(string value)
{
var invalidChars = Path.GetInvalidFileNameChars();
@@ -607,6 +1819,7 @@ internal static class LiveCutValidation
public int OnAirDelayMs { get; init; } = 900;
public int BetweenDelayMs { get; init; } = 250;
public bool IncludeVideoWall { get; init; }
+ public bool SwapTopTwoCandidates { get; init; }
public static LiveCutValidationOptions Parse(string[] args)
{
@@ -643,6 +1856,9 @@ internal static class LiveCutValidation
case "--include-videowall":
options = options with { IncludeVideoWall = true };
break;
+ case "--swap-top-two":
+ options = options with { SwapTopTwoCandidates = true };
+ break;
default:
throw new ArgumentException($"Unknown option: {args[index]}");
}
@@ -668,6 +1884,277 @@ internal static class LiveCutValidation
}
}
+ private sealed record CutDebugCoverageOptions
+ {
+ public string ImageRootPath { get; init; } = string.Empty;
+ public string OutputPath { get; init; } = string.Empty;
+ public string? Filter { get; init; }
+ public string? CutName { get; init; }
+ public int? Limit { get; init; }
+ public bool IncludeVideoWall { get; init; }
+ public bool ExcludeHistoricalTurnout { get; init; }
+
+ public static CutDebugCoverageOptions Parse(string[] args)
+ {
+ var repoRoot = Environment.CurrentDirectory;
+ var options = new CutDebugCoverageOptions
+ {
+ ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
+ OutputPath = Path.Combine(repoRoot, "artifacts", "cut-debug-coverage", DateTime.Now.ToString("yyyyMMdd_HHmmss"))
+ };
+
+ 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 "--cut-name":
+ options = options with { CutName = RequireValue(args, ref index, "--cut-name") };
+ break;
+ case "--limit":
+ options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) };
+ break;
+ case "--include-videowall":
+ options = options with { IncludeVideoWall = true };
+ break;
+ case "--exclude-historical-turnout":
+ options = options with { ExcludeHistoricalTurnout = true };
+ break;
+ default:
+ throw new ArgumentException($"Unknown option: {args[index]}");
+ }
+ }
+
+ return options with
+ {
+ ImageRootPath = Path.GetFullPath(options.ImageRootPath),
+ OutputPath = Path.GetFullPath(options.OutputPath)
+ };
+ }
+
+ 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 record CutDebugSweepOptions
+ {
+ 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 string? CutName { get; init; }
+ public int? Limit { get; init; }
+ public int? MaxItems { get; init; }
+ public int OnAirDelayMs { get; init; } = 1200;
+ public int BetweenDelayMs { get; init; } = 300;
+ public bool IncludeVideoWall { get; init; }
+ public bool ExcludeHistoricalTurnout { get; init; }
+ public string? KindFilter { get; init; }
+ public string? KeyFilter { get; init; }
+ public CutDebugSweepMode Mode { get; init; } = CutDebugSweepMode.Disable;
+ public bool SwapTopTwoCandidates { get; init; }
+
+ public static CutDebugSweepOptions Parse(string[] args)
+ {
+ var repoRoot = Environment.CurrentDirectory;
+ var options = new CutDebugSweepOptions
+ {
+ ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
+ OutputPath = Path.Combine(repoRoot, "artifacts", "cut-debug-sweep", 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 "--station-logo":
+ options = options with { StationLogoPath = RequireValue(args, ref index, "--station-logo") };
+ break;
+ case "--filter":
+ options = options with { Filter = RequireValue(args, ref index, "--filter") };
+ break;
+ case "--cut-name":
+ options = options with { CutName = RequireValue(args, ref index, "--cut-name") };
+ break;
+ case "--limit":
+ options = options with { Limit = int.Parse(RequireValue(args, ref index, "--limit")) };
+ break;
+ case "--max-items":
+ options = options with { MaxItems = int.Parse(RequireValue(args, ref index, "--max-items")) };
+ 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;
+ case "--exclude-historical-turnout":
+ options = options with { ExcludeHistoricalTurnout = true };
+ break;
+ case "--swap-top-two":
+ options = options with { SwapTopTwoCandidates = true };
+ break;
+ case "--kind":
+ options = options with { KindFilter = RequireValue(args, ref index, "--kind") };
+ break;
+ case "--key":
+ options = options with { KeyFilter = RequireValue(args, ref index, "--key") };
+ break;
+ case "--mode":
+ options = options with { Mode = ParseMode(RequireValue(args, ref index, "--mode")) };
+ 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)
+ };
+ }
+
+ public bool IncludeItem(string key, CutDebugItemKind kind)
+ {
+ if (!string.IsNullOrWhiteSpace(KeyFilter) &&
+ !key.Contains(KeyFilter, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(KindFilter) &&
+ !string.Equals(kind.ToString(), KindFilter, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static CutDebugSweepMode ParseMode(string raw)
+ {
+ return raw.ToLowerInvariant() switch
+ {
+ "off" or "disable" => CutDebugSweepMode.Disable,
+ "replace" or "override" => CutDebugSweepMode.Replace,
+ _ => throw new ArgumentException($"Unknown sweep mode: {raw}")
+ };
+ }
+
+ private static string RequireValue(string[] args, ref int index, string optionName)
+ {
+ index++;
+ if (index >= args.Length)
+ {
+ throw new ArgumentException($"Missing value for {optionName}.");
+ }
+
+ return args[index];
+ }
+ }
+
+ private enum CutDebugSweepMode
+ {
+ Disable,
+ Replace
+ }
+
+ private readonly record struct CutDebugReplacementAssets(string MagentaPath, string CyanPath);
+
+ private sealed class CutDebugSweepResult
+ {
+ 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 ScenePath { get; set; } = string.Empty;
+ public bool Success { get; set; }
+ public int ItemCount { get; set; }
+ public string? BaselineAPath { get; set; }
+ public string? BaselineBPath { get; set; }
+ public string? BaselineHash { get; set; }
+ public string? NoiseDiffPath { get; set; }
+ public int NoiseChangedPixels { get; set; }
+ public int SignificanceThresholdPixels { get; set; }
+ public List Items { get; } = [];
+ public string? Detail { get; set; }
+ }
+
+ private sealed class CutDebugCoverageResult
+ {
+ 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? ScenePath { get; set; }
+ public bool Success { get; set; }
+ public bool ExcludedHistoricalTurnout { get; set; }
+ public int SceneVariableCount { get; set; }
+ public int ItemCount { get; set; }
+ public int TextItemCount { get; set; }
+ public int ImageItemCount { get; set; }
+ public int CounterItemCount { get; set; }
+ public int StyleItemCount { get; set; }
+ public int VisibilityItemCount { get; set; }
+ public string? SampleKeys { get; set; }
+ public string? Detail { get; set; }
+ }
+
+ private sealed class CutDebugItemSweepResult
+ {
+ public string Key { get; init; } = string.Empty;
+ public string Kind { get; init; } = string.Empty;
+ public string GroupLabel { get; init; } = string.Empty;
+ public string? CapturePath { get; init; }
+ public string? DiffPath { get; init; }
+ public string? Hash { get; init; }
+ public int ChangedPixels { get; init; }
+ public double ChangeRatio { get; init; }
+ public double AverageChannelDelta { get; init; }
+ public bool SignificantChange { get; init; }
+ public string? BoundingBox { get; init; }
+ public string? LogPreview { get; init; }
+ public string? Detail { get; init; }
+ }
+
+ private readonly record struct SweepCaptureResult(string Path, string Hash);
+
+ private readonly record struct ImageDiffResult(
+ int ChangedPixels,
+ double ChangeRatio,
+ double AverageChannelDelta,
+ string? BoundingBox);
+
private sealed class LiveCutValidationResult
{
public int Index { get; init; }
@@ -702,6 +2189,29 @@ internal static class LiveCutValidation
DateTimeOffset ReceivedAt,
int TemplateSeed);
+ private readonly record struct HistoricalTurnoutSeed(
+ int ElectionOrder,
+ int Year,
+ double TurnoutRate);
+
+ private static readonly IReadOnlyList HistoricalTurnoutLocalSeeds =
+ [
+ new(3, 2002, 55.4),
+ new(4, 2006, 42.8),
+ new(5, 2010, 56.9),
+ new(6, 2014, 45.1),
+ new(7, 2018, 45.8),
+ new(8, 2022, 55.7)
+ ];
+
+ private static readonly IReadOnlyList HistoricalTurnoutEducationSeeds =
+ [
+ new(5, 2010, 53.6),
+ new(6, 2014, 47.9),
+ new(7, 2018, 56.1),
+ new(8, 2022, 50.4)
+ ];
+
private readonly record struct PgmWindow(IntPtr Handle, Rect Bounds);
private readonly record struct Rect(int Left, int Top, int Width, int Height);
diff --git a/tools/KarismaTcpProbe/Program.cs b/tools/KarismaTcpProbe/Program.cs
index 24789cc..bc32fd9 100644
--- a/tools/KarismaTcpProbe/Program.cs
+++ b/tools/KarismaTcpProbe/Program.cs
@@ -34,6 +34,38 @@ if (args.Length > 0 && string.Equals(args[0], "--test-counter", StringComparison
return;
}
+if (args.Length > 0 && string.Equals(args[0], "--inspect-chart", StringComparison.OrdinalIgnoreCase))
+{
+ var chartOptions = ChartInspectionOptions.Parse(args[1..]);
+ Console.WriteLine(
+ $"Karisma chart inspection starting. target={chartOptions.Connection.Host}:{chartOptions.Connection.Port} " +
+ $"scene={chartOptions.ScenePath} object={chartOptions.ObjectName}");
+ var chartResult = await InspectChartAsync(chartOptions).ConfigureAwait(false);
+
+ Console.WriteLine();
+ Console.WriteLine("Summary");
+ Console.WriteLine($"- SDK Connect(): {(chartResult.ConnectRequestAccepted ? "ACCEPTED" : "REJECTED")}");
+ Console.WriteLine($"- Scene Load: {chartResult.SceneLoadOutcome}");
+ Console.WriteLine($"- Chart Inspection: {chartResult.InspectionOutcome}");
+ Console.WriteLine($"- Rows: {chartResult.RowCount}");
+ Console.WriteLine($"- Columns: {chartResult.ColumnCount}");
+ if (chartResult.Cells.Count > 0)
+ {
+ Console.WriteLine("- Cells:");
+ foreach (var cell in chartResult.Cells)
+ {
+ Console.WriteLine($" - ({cell.Row},{cell.Column}) = {cell.Value}");
+ }
+ }
+ Console.WriteLine($"- Detail: {chartResult.Detail}");
+ Environment.ExitCode = chartResult.ConnectRequestAccepted &&
+ chartResult.SceneLoadOutcome == "SUCCESS" &&
+ chartResult.InspectionOutcome == "SUCCESS"
+ ? 0
+ : 1;
+ return;
+}
+
if (args.Length > 0 && string.Equals(args[0], "--catalog-scenes", StringComparison.OrdinalIgnoreCase))
{
var catalogOptions = SceneCatalogOptions.Parse(args[1..]);
@@ -64,6 +96,144 @@ if (args.Length > 0 && string.Equals(args[0], "--catalog-scenes", StringComparis
return;
}
+if (args.Length > 0 && string.Equals(args[0], "--inspect-scene-objects", StringComparison.OrdinalIgnoreCase))
+{
+ var inspectionOptions = SceneObjectInspectionOptions.Parse(args[1..]);
+ Console.WriteLine(
+ $"Karisma scene object inspection starting. target={inspectionOptions.Connection.Host}:{inspectionOptions.Connection.Port} " +
+ $"scene={inspectionOptions.ScenePath} output={inspectionOptions.OutputPath}");
+ var inspectionResult = await InspectSceneObjectsAsync(inspectionOptions).ConfigureAwait(false);
+
+ Console.WriteLine();
+ Console.WriteLine("Summary");
+ Console.WriteLine($"- SDK Connect(): {(inspectionResult.ConnectRequestAccepted ? "ACCEPTED" : "REJECTED")}");
+ Console.WriteLine($"- Scene Load: {inspectionResult.SceneLoadOutcome}");
+ Console.WriteLine($"- Object Infos: {inspectionResult.ObjectInfoOutcome}");
+ Console.WriteLine($"- Objects: {inspectionResult.Objects.Count}");
+ Console.WriteLine($"- Output: {inspectionResult.OutputPath}");
+ if (!string.IsNullOrWhiteSpace(inspectionResult.Detail))
+ {
+ Console.WriteLine($"- Detail: {inspectionResult.Detail}");
+ }
+
+ Environment.ExitCode = inspectionResult.ConnectRequestAccepted &&
+ inspectionResult.SceneLoadOutcome == "SUCCESS" &&
+ inspectionResult.ObjectInfoOutcome == "SUCCESS"
+ ? 0
+ : 1;
+ return;
+}
+
+if (args.Length > 0 && string.Equals(args[0], "--query-object-types", StringComparison.OrdinalIgnoreCase))
+{
+ var objectTypeOptions = ObjectTypeQueryOptions.Parse(args[1..]);
+ Console.WriteLine(
+ $"Karisma object type query starting. target={objectTypeOptions.Connection.Host}:{objectTypeOptions.Connection.Port} " +
+ $"scene={objectTypeOptions.ScenePath} objects={objectTypeOptions.ObjectNames.Count}");
+ var objectTypeResult = await QueryObjectTypesAsync(objectTypeOptions).ConfigureAwait(false);
+
+ Console.WriteLine();
+ Console.WriteLine("Summary");
+ Console.WriteLine($"- SDK Connect(): {(objectTypeResult.ConnectRequestAccepted ? "ACCEPTED" : "REJECTED")}");
+ Console.WriteLine($"- Scene Load: {objectTypeResult.SceneLoadOutcome}");
+ Console.WriteLine($"- Queried: {objectTypeResult.Items.Count}");
+ Console.WriteLine($"- Output: {objectTypeResult.OutputPath}");
+ if (!string.IsNullOrWhiteSpace(objectTypeResult.Detail))
+ {
+ Console.WriteLine($"- Detail: {objectTypeResult.Detail}");
+ }
+
+ foreach (var item in objectTypeResult.Items)
+ {
+ Console.WriteLine($" - {item.ObjectName}: {item.Result} {item.ObjectType}");
+ }
+
+ Environment.ExitCode = objectTypeResult.ConnectRequestAccepted &&
+ objectTypeResult.SceneLoadOutcome == "SUCCESS" &&
+ objectTypeResult.Items.All(item => string.Equals(item.Result, eKResult.RESULT_SUCCESS.ToString(), StringComparison.Ordinal))
+ ? 0
+ : 1;
+ return;
+}
+
+if (args.Length > 0 && string.Equals(args[0], "--inspect-object-runtime", StringComparison.OrdinalIgnoreCase))
+{
+ var runtimeOptions = ObjectTypeQueryOptions.Parse(args[1..]);
+ Console.WriteLine(
+ $"Karisma object runtime inspection starting. target={runtimeOptions.Connection.Host}:{runtimeOptions.Connection.Port} " +
+ $"scene={runtimeOptions.ScenePath} objects={runtimeOptions.ObjectNames.Count} output={runtimeOptions.OutputPath}");
+ var runtimeResult = await InspectObjectRuntimeAsync(runtimeOptions).ConfigureAwait(false);
+
+ Console.WriteLine();
+ Console.WriteLine("Summary");
+ Console.WriteLine($"- SDK Connect(): {(runtimeResult.ConnectRequestAccepted ? "ACCEPTED" : "REJECTED")}");
+ Console.WriteLine($"- Scene Load: {runtimeResult.SceneLoadOutcome}");
+ Console.WriteLine($"- Objects Inspected: {runtimeResult.Items.Count}");
+ Console.WriteLine($"- Output: {runtimeResult.OutputPath}");
+ if (!string.IsNullOrWhiteSpace(runtimeResult.Detail))
+ {
+ Console.WriteLine($"- Detail: {runtimeResult.Detail}");
+ }
+
+ Environment.ExitCode = runtimeResult.ConnectRequestAccepted &&
+ runtimeResult.SceneLoadOutcome == "SUCCESS"
+ ? 0
+ : 1;
+ return;
+}
+
+if (args.Length > 0 && string.Equals(args[0], "--query-objects-by-screen-point", StringComparison.OrdinalIgnoreCase))
+{
+ var screenPointOptions = ScreenPointInspectionOptions.Parse(args[1..]);
+ Console.WriteLine(
+ $"Karisma screen-point inspection starting. target={screenPointOptions.Connection.Host}:{screenPointOptions.Connection.Port} " +
+ $"scene={screenPointOptions.ScenePath} points={screenPointOptions.Points.Count} output={screenPointOptions.OutputPath}");
+ var screenPointResult = await InspectScreenPointsAsync(screenPointOptions).ConfigureAwait(false);
+
+ Console.WriteLine();
+ Console.WriteLine("Summary");
+ Console.WriteLine($"- SDK Connect(): {(screenPointResult.ConnectRequestAccepted ? "ACCEPTED" : "REJECTED")}");
+ Console.WriteLine($"- Scene Load: {screenPointResult.SceneLoadOutcome}");
+ Console.WriteLine($"- Points Inspected: {screenPointResult.Items.Count}");
+ Console.WriteLine($"- Output: {screenPointResult.OutputPath}");
+ if (!string.IsNullOrWhiteSpace(screenPointResult.Detail))
+ {
+ Console.WriteLine($"- Detail: {screenPointResult.Detail}");
+ }
+
+ Environment.ExitCode = screenPointResult.ConnectRequestAccepted &&
+ screenPointResult.SceneLoadOutcome == "SUCCESS"
+ ? 0
+ : 1;
+ return;
+}
+
+if (args.Length > 0 && string.Equals(args[0], "--inspect-scene-capabilities", StringComparison.OrdinalIgnoreCase))
+{
+ var capabilityOptions = SceneCapabilityInspectionOptions.Parse(args[1..]);
+ Console.WriteLine(
+ $"Karisma scene capability inspection starting. target={capabilityOptions.Connection.Host}:{capabilityOptions.Connection.Port} " +
+ $"scene={capabilityOptions.ScenePath} output={capabilityOptions.OutputPath}");
+ var capabilityResult = await InspectSceneCapabilitiesAsync(capabilityOptions).ConfigureAwait(false);
+
+ Console.WriteLine();
+ Console.WriteLine("Summary");
+ Console.WriteLine($"- SDK Connect(): {(capabilityResult.ConnectRequestAccepted ? "ACCEPTED" : "REJECTED")}");
+ Console.WriteLine($"- Scene Load: {capabilityResult.SceneLoadOutcome}");
+ Console.WriteLine($"- Candidates Inspected: {capabilityResult.Items.Count}");
+ Console.WriteLine($"- Output: {capabilityResult.OutputPath}");
+ if (!string.IsNullOrWhiteSpace(capabilityResult.Detail))
+ {
+ Console.WriteLine($"- Detail: {capabilityResult.Detail}");
+ }
+
+ Environment.ExitCode = capabilityResult.ConnectRequestAccepted &&
+ capabilityResult.SceneLoadOutcome == "SUCCESS"
+ ? 0
+ : 1;
+ return;
+}
+
if (args.Length > 0 && string.Equals(args[0], "--validate-scene-values", StringComparison.OrdinalIgnoreCase))
{
var validationOptions = SceneValidationOptions.Parse(args[1..]);
@@ -148,6 +318,18 @@ if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComp
return;
}
+if (args.Length > 0 && string.Equals(args[0], "--sweep-cut-debug", StringComparison.OrdinalIgnoreCase))
+{
+ Environment.ExitCode = await LiveCutValidation.RunCutDebugSweepAsync(args[1..]).ConfigureAwait(false);
+ return;
+}
+
+if (args.Length > 0 && string.Equals(args[0], "--report-cut-debug-coverage", StringComparison.OrdinalIgnoreCase))
+{
+ Environment.ExitCode = LiveCutValidation.RunCutDebugCoverageReport(args[1..]);
+ return;
+}
+
var options = ProbeOptions.Parse(args);
Console.WriteLine($"Karisma TCP probe starting. target={options.Host}:{options.Port} timeout={options.Timeout.TotalSeconds:0}s");
@@ -350,6 +532,147 @@ static Task ProbeCounterAsync(CounterProbeOptions options)
return completion.Task;
}
+static Task InspectChartAsync(ChartInspectionOptions options)
+{
+ var completion = new TaskCompletionSource(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("[CHART] Calling Connect()...");
+ var connectRequested = engine.Connect(options.Connection.Host, options.Connection.Port, handler);
+ Console.WriteLine($"[CHART] Connect() returned {(connectRequested != 0 ? "TRUE" : "FALSE")} raw={connectRequested}");
+ if (connectRequested == 0)
+ {
+ completion.TrySetResult(new ChartInspectionProbeResult(false, "NOT_RUN", "NOT_RUN", 0, 0, Array.Empty(), "Connect() returned 0."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.ConnectTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(new ChartInspectionProbeResult(true, "NOT_RUN", "TIMEOUT", 0, 0, Array.Empty(), "OnConnect timed out."));
+ return;
+ }
+
+ if (handler.ConnectTask.Result != 0)
+ {
+ completion.TrySetResult(
+ new ChartInspectionProbeResult(true, "NOT_RUN", "FAILED", 0, 0, Array.Empty(), $"OnConnect errorCode={handler.ConnectTask.Result}"));
+ return;
+ }
+
+ Console.WriteLine("[CHART] Loading scene...");
+ handler.ResetLoadSceneTask();
+ scene = engine.LoadScene(options.ScenePath, options.SceneAlias);
+ if (scene is null)
+ {
+ completion.TrySetResult(new ChartInspectionProbeResult(true, "FAILED", "NOT_RUN", 0, 0, Array.Empty(), "LoadScene returned null."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.LoadSceneTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(new ChartInspectionProbeResult(true, "TIMEOUT", "NOT_RUN", 0, 0, Array.Empty(), "OnLoadScene timed out."));
+ return;
+ }
+
+ var loadSceneResult = handler.LoadSceneTask.Result;
+ if (loadSceneResult != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new ChartInspectionProbeResult(true, loadSceneResult.ToString(), "NOT_RUN", 0, 0, Array.Empty(), $"OnLoadScene result={loadSceneResult}"));
+ return;
+ }
+
+ Console.WriteLine("[CHART] Resolving object...");
+ var sceneObject = scene.GetObject(options.ObjectName);
+ if (sceneObject is not IKAChart chart)
+ {
+ completion.TrySetResult(
+ new ChartInspectionProbeResult(
+ true,
+ "SUCCESS",
+ "FAILED",
+ 0,
+ 0,
+ Array.Empty(),
+ sceneObject is null
+ ? $"Object '{options.ObjectName}' was not found."
+ : $"Object '{options.ObjectName}' is not an IKAChart ({sceneObject.GetType().FullName})."));
+ return;
+ }
+
+ Console.WriteLine("[CHART] Calling QueryChartDataTable()...");
+ handler.ResetChartDataTableTask();
+ chart.QueryChartDataTable();
+
+ if (!WaitForTaskWithMessagePump(handler.ChartDataTableTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new ChartInspectionProbeResult(true, "SUCCESS", "TIMEOUT", 0, 0, Array.Empty(), "OnQueryChartDataTable timed out."));
+ return;
+ }
+
+ var chartResult = handler.ChartDataTableTask.Result;
+ completion.TrySetResult(
+ new ChartInspectionProbeResult(
+ true,
+ "SUCCESS",
+ chartResult.Result == eKResult.RESULT_SUCCESS ? "SUCCESS" : chartResult.Result.ToString(),
+ chartResult.RowCount,
+ chartResult.ColumnCount,
+ chartResult.Cells,
+ string.IsNullOrWhiteSpace(chartResult.Detail)
+ ? $"OnQueryChartDataTable result={chartResult.Result}"
+ : chartResult.Detail));
+ }
+ catch (Exception ex)
+ {
+ completion.TrySetResult(new ChartInspectionProbeResult(false, "EXCEPTION", "EXCEPTION", 0, 0, Array.Empty(), 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 = "KarismaChartInspectionProbe"
+ };
+
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ return completion.Task;
+}
+
static Task SaveSceneImageAsync(SaveSceneImageOptions options)
{
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -408,6 +731,311 @@ static Task SaveSceneImageAsync(SaveSceneImageOptions
return;
}
+ if (!string.IsNullOrWhiteSpace(options.SetObjectName))
+ {
+ Console.WriteLine($"[SAVE-IMAGE] Setting value object={options.SetObjectName}...");
+ var sceneObject = scene.GetObject(options.SetObjectName);
+ if (sceneObject is null)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.SetObjectName}' was not found."));
+ return;
+ }
+
+ handler.ResetSetValueTask();
+ sceneObject.SetValue(options.SetObjectValue ?? string.Empty);
+
+ if (!WaitForTaskWithMessagePump(handler.SetValueTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetValue timed out for '{options.SetObjectName}'."));
+ return;
+ }
+
+ var setValueResult = handler.SetValueTask.Result;
+ if (setValueResult != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", setValueResult.ToString(), options.OutputPath, $"OnSetValue result={setValueResult} object={options.SetObjectName}"));
+ return;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.VisibleObjectName) && options.VisibleObjectValue.HasValue)
+ {
+ Console.WriteLine($"[SAVE-IMAGE] Setting visibility object={options.VisibleObjectName} visible={options.VisibleObjectValue.Value}...");
+ var sceneObject = scene.GetObject(options.VisibleObjectName);
+ if (sceneObject is null)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.VisibleObjectName}' was not found."));
+ return;
+ }
+
+ handler.ResetVisibleTask();
+ sceneObject.SetVisible(options.VisibleObjectValue.Value ? 1 : 0);
+
+ if (!WaitForTaskWithMessagePump(handler.VisibleTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetVisible timed out for '{options.VisibleObjectName}'."));
+ return;
+ }
+
+ var visibleResult = handler.VisibleTask.Result;
+ if (visibleResult != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", visibleResult.ToString(), options.OutputPath, $"OnSetVisible result={visibleResult} object={options.VisibleObjectName}"));
+ return;
+ }
+ }
+
+ if (options.Size is not null)
+ {
+ Console.WriteLine(
+ $"[SAVE-IMAGE] Setting size object={options.Size.ObjectName} value=({options.Size.Width},{options.Size.Height})...");
+ var sceneObject = scene.GetObject(options.Size.ObjectName);
+ if (sceneObject is null)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.Size.ObjectName}' was not found."));
+ return;
+ }
+
+ handler.ResetSizeTask();
+ sceneObject.SetSize(options.Size.Width, options.Size.Height);
+
+ if (!WaitForTaskWithMessagePump(handler.SizeTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetSize timed out for '{options.Size.ObjectName}'." ));
+ return;
+ }
+
+ var sizeResult = handler.SizeTask.Result;
+ if (sizeResult != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", sizeResult.ToString(), options.OutputPath, $"OnSetSize result={sizeResult} object={options.Size.ObjectName}"));
+ return;
+ }
+ }
+
+ if (options.Position is not null)
+ {
+ 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);
+ if (sceneObject is null)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.Position.ObjectName}' was not found."));
+ return;
+ }
+
+ handler.ResetPositionTask();
+ sceneObject.SetPosition(
+ options.Position.X,
+ options.Position.Y,
+ options.Position.Z,
+ options.Position.VectorType);
+
+ if (!WaitForTaskWithMessagePump(handler.PositionTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{options.Position.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={options.Position.ObjectName}"));
+ return;
+ }
+ }
+
+ if (options.PositionKey is not null)
+ {
+ 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);
+ if (sceneObject is null)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.PositionKey.ObjectName}' was not found."));
+ return;
+ }
+
+ handler.ResetPositionKeyTask();
+ sceneObject.SetPositionKey(
+ options.PositionKey.KeyIndex,
+ options.PositionKey.X,
+ options.PositionKey.Y,
+ options.PositionKey.Z,
+ options.PositionKey.VectorType);
+
+ if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{options.PositionKey.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={options.PositionKey.ObjectName}"));
+ return;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.ChartObjectName))
+ {
+ var sceneObject = scene.GetObject(options.ChartObjectName);
+ if (sceneObject is not IKAChart chart)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(
+ true,
+ "SUCCESS",
+ "FAILED",
+ options.OutputPath,
+ sceneObject is null
+ ? $"Object '{options.ChartObjectName}' was not found."
+ : $"Object '{options.ChartObjectName}' does not implement IKAChart ({sceneObject.GetType().FullName})."));
+ return;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.ChartCsvPath))
+ {
+ Console.WriteLine($"[SAVE-IMAGE] Setting chart csv object={options.ChartObjectName} csv={options.ChartCsvPath}...");
+ handler.ResetChartMutationTask();
+ chart.SetChartCSVFile(options.ChartCsvPath);
+ if (!WaitForTaskWithMessagePump(handler.ChartMutationTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetChartCSVFile timed out for '{options.ChartObjectName}'." ));
+ return;
+ }
+
+ if (handler.ChartMutationTask.Result != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", handler.ChartMutationTask.Result.ToString(), options.OutputPath, $"OnSetChartCSVFile result={handler.ChartMutationTask.Result} object={options.ChartObjectName}"));
+ return;
+ }
+ }
+
+ foreach (var cell in options.ChartCells)
+ {
+ Console.WriteLine($"[SAVE-IMAGE] Setting chart cell object={options.ChartObjectName} row={cell.Row} col={cell.Column} value={cell.Value}...");
+ handler.ResetChartMutationTask();
+ chart.SetChartCellData(cell.Row, cell.Column, cell.Value);
+ if (!WaitForTaskWithMessagePump(handler.ChartMutationTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetChartCellData timed out for '{options.ChartObjectName}'." ));
+ return;
+ }
+
+ if (handler.ChartMutationTask.Result != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", handler.ChartMutationTask.Result.ToString(), options.OutputPath, $"OnSetChartCellData result={handler.ChartMutationTask.Result} object={options.ChartObjectName} row={cell.Row} col={cell.Column}"));
+ return;
+ }
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.PathObjectName))
+ {
+ Console.WriteLine($"[SAVE-IMAGE] Rebuilding path object={options.PathObjectName}...");
+ var sceneObject = scene.GetObject(options.PathObjectName);
+ if (sceneObject is not IKAPath path)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(
+ true,
+ "SUCCESS",
+ "FAILED",
+ options.OutputPath,
+ sceneObject is null
+ ? $"Object '{options.PathObjectName}' was not found."
+ : $"Object '{options.PathObjectName}' does not implement IKAPath ({sceneObject.GetType().FullName})."));
+ return;
+ }
+
+ if (options.PathPoints.Count > 0)
+ {
+ handler.ResetPathPointTask();
+ path.ClearPathPoints();
+ if (!WaitForTaskWithMessagePump(handler.PathPointTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnClearPathPoints timed out for '{options.PathObjectName}'."));
+ return;
+ }
+
+ if (handler.PathPointTask.Result != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", handler.PathPointTask.Result.ToString(), options.OutputPath, $"OnClearPathPoints result={handler.PathPointTask.Result} object={options.PathObjectName}"));
+ return;
+ }
+
+ foreach (var point in options.PathPoints)
+ {
+ handler.ResetPathPointTask();
+ path.AddPathPoint(point.X, point.Y, point.Z);
+
+ if (!WaitForTaskWithMessagePump(handler.PathPointTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnAddPathPoint timed out for '{options.PathObjectName}'."));
+ return;
+ }
+
+ if (handler.PathPointTask.Result != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", handler.PathPointTask.Result.ToString(), options.OutputPath, $"OnAddPathPoint result={handler.PathPointTask.Result} object={options.PathObjectName}"));
+ return;
+ }
+ }
+ }
+
+ foreach (var modification in options.PathModifications)
+ {
+ handler.ResetPathPointTask();
+ path.ModifyPathPoint(
+ modification.Index,
+ modification.X,
+ modification.Y,
+ modification.Z,
+ modification.VectorType);
+
+ if (!WaitForTaskWithMessagePump(handler.PathPointTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnModifyPathPoint timed out for '{options.PathObjectName}'."));
+ return;
+ }
+
+ if (handler.PathPointTask.Result != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new SaveSceneImageProbeResult(true, "SUCCESS", handler.PathPointTask.Result.ToString(), options.OutputPath, $"OnModifyPathPoint result={handler.PathPointTask.Result} object={options.PathObjectName} index={modification.Index}"));
+ return;
+ }
+ }
+ }
+
var outputDirectory = Path.GetDirectoryName(options.OutputPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
@@ -632,6 +1260,879 @@ static Task CatalogScenesAsync(SceneCatalogOptions opti
return completion.Task;
}
+static Task InspectSceneObjectsAsync(SceneObjectInspectionOptions options)
+{
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var thread = new Thread(() =>
+ {
+ IKAScene? scene = null;
+ ProbeEventHandler? handler = null;
+ IKAEngine? engine = null;
+
+ try
+ {
+ handler = new ProbeEventHandler();
+ engine = (IKAEngine)new KAEngineClass();
+
+ Console.WriteLine("[INSPECT-OBJECTS] Calling Connect()...");
+ var connectRequested = engine.Connect(options.Connection.Host, options.Connection.Port, handler);
+ Console.WriteLine($"[INSPECT-OBJECTS] Connect() returned {(connectRequested != 0 ? "TRUE" : "FALSE")} raw={connectRequested}");
+ if (connectRequested == 0)
+ {
+ completion.TrySetResult(
+ new SceneObjectInspectionProbeResult(false, "NOT_RUN", "NOT_RUN", options.OutputPath, Array.Empty(), "Connect() returned 0."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.ConnectTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SceneObjectInspectionProbeResult(true, "NOT_RUN", "NOT_RUN", options.OutputPath, Array.Empty(), "OnConnect timed out."));
+ return;
+ }
+
+ if (handler.ConnectTask.Result != 0)
+ {
+ completion.TrySetResult(
+ new SceneObjectInspectionProbeResult(true, "NOT_RUN", "NOT_RUN", options.OutputPath, Array.Empty(), $"OnConnect errorCode={handler.ConnectTask.Result}"));
+ return;
+ }
+
+ Console.WriteLine("[INSPECT-OBJECTS] Loading scene...");
+ handler.ResetLoadSceneTask();
+ scene = engine.LoadScene(options.ScenePath, options.SceneAlias);
+ if (scene is null)
+ {
+ completion.TrySetResult(
+ new SceneObjectInspectionProbeResult(true, "FAILED", "NOT_RUN", options.OutputPath, Array.Empty(), "LoadScene returned null."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.LoadSceneTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SceneObjectInspectionProbeResult(true, "TIMEOUT", "NOT_RUN", options.OutputPath, Array.Empty(), "OnLoadScene timed out."));
+ return;
+ }
+
+ if (handler.LoadSceneTask.Result != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new SceneObjectInspectionProbeResult(true, handler.LoadSceneTask.Result.ToString(), "NOT_RUN", options.OutputPath, Array.Empty(), $"OnLoadScene result={handler.LoadSceneTask.Result}"));
+ return;
+ }
+
+ Console.WriteLine("[INSPECT-OBJECTS] Querying object infos...");
+ handler.ResetObjectInfosTask();
+ scene.QueryObjectInfos();
+ if (!WaitForTaskWithMessagePump(handler.ObjectInfosTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SceneObjectInspectionProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, Array.Empty(), "OnQueryObjectInfos timed out."));
+ return;
+ }
+
+ var objectInfosResult = handler.ObjectInfosTask.Result;
+ var objects = objectInfosResult.Objects
+ .Where(item => string.IsNullOrWhiteSpace(options.NameFilter) ||
+ item.Name.Contains(options.NameFilter, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ WriteSceneObjectInspectionReport(options, objectInfosResult, objects);
+ completion.TrySetResult(
+ new SceneObjectInspectionProbeResult(
+ true,
+ "SUCCESS",
+ objectInfosResult.Result.ToString(),
+ options.OutputPath,
+ objects,
+ objectInfosResult.Detail));
+ }
+ catch (Exception ex)
+ {
+ completion.TrySetResult(
+ new SceneObjectInspectionProbeResult(false, "EXCEPTION", "EXCEPTION", options.OutputPath, Array.Empty(), 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 = "KarismaSceneObjectInspection"
+ };
+
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ return completion.Task;
+}
+
+static Task QueryObjectTypesAsync(ObjectTypeQueryOptions options)
+{
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var thread = new Thread(() =>
+ {
+ IKAScene? scene = null;
+ ProbeEventHandler? handler = null;
+ IKAEngine? engine = null;
+
+ try
+ {
+ handler = new ProbeEventHandler();
+ engine = (IKAEngine)new KAEngineClass();
+
+ Console.WriteLine("[QUERY-OBJECT-TYPES] Calling Connect()...");
+ var connectRequested = engine.Connect(options.Connection.Host, options.Connection.Port, handler);
+ Console.WriteLine($"[QUERY-OBJECT-TYPES] Connect() returned {(connectRequested != 0 ? "TRUE" : "FALSE")} raw={connectRequested}");
+ if (connectRequested == 0)
+ {
+ completion.TrySetResult(
+ new ObjectTypeQueryProbeResult(false, "NOT_RUN", options.OutputPath, Array.Empty(), "Connect() returned 0."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.ConnectTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new ObjectTypeQueryProbeResult(true, "NOT_RUN", options.OutputPath, Array.Empty(), "OnConnect timed out."));
+ return;
+ }
+
+ if (handler.ConnectTask.Result != 0)
+ {
+ completion.TrySetResult(
+ new ObjectTypeQueryProbeResult(true, "NOT_RUN", options.OutputPath, Array.Empty(), $"OnConnect errorCode={handler.ConnectTask.Result}"));
+ return;
+ }
+
+ Console.WriteLine("[QUERY-OBJECT-TYPES] Loading scene...");
+ handler.ResetLoadSceneTask();
+ scene = engine.LoadScene(options.ScenePath, options.SceneAlias);
+ if (scene is null)
+ {
+ completion.TrySetResult(
+ new ObjectTypeQueryProbeResult(true, "FAILED", options.OutputPath, Array.Empty(), "LoadScene returned null."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.LoadSceneTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new ObjectTypeQueryProbeResult(true, "TIMEOUT", options.OutputPath, Array.Empty(), "OnLoadScene timed out."));
+ return;
+ }
+
+ if (handler.LoadSceneTask.Result != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new ObjectTypeQueryProbeResult(true, handler.LoadSceneTask.Result.ToString(), options.OutputPath, Array.Empty(), $"OnLoadScene result={handler.LoadSceneTask.Result}"));
+ return;
+ }
+
+ var items = new List(options.ObjectNames.Count);
+ foreach (var objectName in options.ObjectNames)
+ {
+ Console.WriteLine($"[QUERY-OBJECT-TYPES] Querying object={objectName}...");
+ var sceneObject = scene.GetObject(objectName);
+ if (sceneObject is null)
+ {
+ items.Add(new ObjectTypeQueryItem(objectName, "OBJECT_NOT_FOUND", eKObjectType.OBJECT_TYPE_UNKNOWN, "GetObject returned null."));
+ continue;
+ }
+
+ handler.ResetObjectTypeTask();
+ sceneObject.QueryObjectType();
+ if (!WaitForTaskWithMessagePump(handler.ObjectTypeTask, options.Connection.Timeout))
+ {
+ items.Add(new ObjectTypeQueryItem(objectName, "TIMEOUT", eKObjectType.OBJECT_TYPE_UNKNOWN, "OnQueryObjectType timed out."));
+ continue;
+ }
+
+ var objectTypeResult = handler.ObjectTypeTask.Result;
+ items.Add(new ObjectTypeQueryItem(
+ objectName,
+ objectTypeResult.Result.ToString(),
+ objectTypeResult.ObjectType,
+ objectTypeResult.Detail));
+ }
+
+ WriteObjectTypeQueryReport(options, items);
+ completion.TrySetResult(new ObjectTypeQueryProbeResult(true, "SUCCESS", options.OutputPath, items, string.Empty));
+ }
+ catch (Exception ex)
+ {
+ completion.TrySetResult(new ObjectTypeQueryProbeResult(false, "EXCEPTION", options.OutputPath, Array.Empty(), 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 = "KarismaObjectTypeQuery"
+ };
+
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ return completion.Task;
+}
+
+static Task InspectObjectRuntimeAsync(ObjectTypeQueryOptions options)
+{
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var thread = new Thread(() =>
+ {
+ IKAScene? scene = null;
+ ProbeEventHandler? handler = null;
+ IKAEngine? engine = null;
+
+ try
+ {
+ handler = new ProbeEventHandler();
+ engine = (IKAEngine)new KAEngineClass();
+
+ Console.WriteLine("[INSPECT-OBJECT-RUNTIME] Calling Connect()...");
+ var connectRequested = engine.Connect(options.Connection.Host, options.Connection.Port, handler);
+ Console.WriteLine($"[INSPECT-OBJECT-RUNTIME] Connect() returned {(connectRequested != 0 ? "TRUE" : "FALSE")} raw={connectRequested}");
+ if (connectRequested == 0)
+ {
+ completion.TrySetResult(
+ new ObjectRuntimeInspectionProbeResult(false, "NOT_RUN", options.OutputPath, Array.Empty(), "Connect() returned 0."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.ConnectTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new ObjectRuntimeInspectionProbeResult(true, "NOT_RUN", options.OutputPath, Array.Empty(), "OnConnect timed out."));
+ return;
+ }
+
+ if (handler.ConnectTask.Result != 0)
+ {
+ completion.TrySetResult(
+ new ObjectRuntimeInspectionProbeResult(true, "NOT_RUN", options.OutputPath, Array.Empty(), $"OnConnect errorCode={handler.ConnectTask.Result}"));
+ return;
+ }
+
+ Console.WriteLine("[INSPECT-OBJECT-RUNTIME] Loading scene...");
+ handler.ResetLoadSceneTask();
+ scene = engine.LoadScene(options.ScenePath, options.SceneAlias);
+ if (scene is null)
+ {
+ completion.TrySetResult(
+ new ObjectRuntimeInspectionProbeResult(true, "FAILED", options.OutputPath, Array.Empty(), "LoadScene returned null."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.LoadSceneTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new ObjectRuntimeInspectionProbeResult(true, "TIMEOUT", options.OutputPath, Array.Empty(), "OnLoadScene timed out."));
+ return;
+ }
+
+ if (handler.LoadSceneTask.Result != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new ObjectRuntimeInspectionProbeResult(true, handler.LoadSceneTask.Result.ToString(), options.OutputPath, Array.Empty(), $"OnLoadScene result={handler.LoadSceneTask.Result}"));
+ return;
+ }
+
+ var items = new List(options.ObjectNames.Count);
+ foreach (var objectName in options.ObjectNames)
+ {
+ Console.WriteLine($"[INSPECT-OBJECT-RUNTIME] Inspecting object={objectName}...");
+ var sceneObject = scene.GetObject(objectName);
+ if (sceneObject is null)
+ {
+ items.Add(new ObjectRuntimeInspectionItem(
+ objectName,
+ "OBJECT_NOT_FOUND",
+ eKObjectType.OBJECT_TYPE_UNKNOWN,
+ string.Empty,
+ "OBJECT_NOT_FOUND",
+ null,
+ null,
+ null,
+ "GetObject returned null."));
+ continue;
+ }
+
+ var detailParts = new List();
+ string animationName;
+ try
+ {
+ animationName = sceneObject.GetAnimationName() ?? string.Empty;
+ }
+ catch (Exception ex)
+ {
+ animationName = string.Empty;
+ detailParts.Add($"GetAnimationName: {ex.Message}");
+ }
+
+ handler.ResetObjectTypeTask();
+ sceneObject.QueryObjectType();
+ string typeResultText;
+ var objectType = eKObjectType.OBJECT_TYPE_UNKNOWN;
+ if (!WaitForTaskWithMessagePump(handler.ObjectTypeTask, options.Connection.Timeout))
+ {
+ typeResultText = "TIMEOUT";
+ detailParts.Add("OnQueryObjectType timed out.");
+ }
+ else
+ {
+ var objectTypeResult = handler.ObjectTypeTask.Result;
+ typeResultText = objectTypeResult.Result.ToString();
+ objectType = objectTypeResult.ObjectType;
+ if (!string.IsNullOrWhiteSpace(objectTypeResult.Detail))
+ {
+ detailParts.Add($"QueryObjectType: {objectTypeResult.Detail}");
+ }
+ }
+
+ handler.ResetQueryPositionTask();
+ sceneObject.QueryPosition();
+ string positionResultText;
+ float? x = null;
+ float? y = null;
+ float? z = null;
+ if (!WaitForTaskWithMessagePump(handler.QueryPositionTask, options.Connection.Timeout))
+ {
+ positionResultText = "TIMEOUT";
+ detailParts.Add("OnQueryPosition timed out.");
+ }
+ else
+ {
+ var positionResult = handler.QueryPositionTask.Result;
+ positionResultText = positionResult.Result.ToString();
+ if (positionResult.Result == eKResult.RESULT_SUCCESS)
+ {
+ x = positionResult.X;
+ y = positionResult.Y;
+ z = positionResult.Z;
+ }
+
+ if (!string.IsNullOrWhiteSpace(positionResult.Detail))
+ {
+ detailParts.Add($"QueryPosition: {positionResult.Detail}");
+ }
+ }
+
+ items.Add(new ObjectRuntimeInspectionItem(
+ objectName,
+ typeResultText,
+ objectType,
+ animationName,
+ positionResultText,
+ x,
+ y,
+ z,
+ string.Join("; ", detailParts.Where(part => !string.IsNullOrWhiteSpace(part)))));
+ }
+
+ WriteObjectRuntimeInspectionReport(options, items);
+ completion.TrySetResult(new ObjectRuntimeInspectionProbeResult(true, "SUCCESS", options.OutputPath, items, string.Empty));
+ }
+ catch (Exception ex)
+ {
+ completion.TrySetResult(new ObjectRuntimeInspectionProbeResult(false, "EXCEPTION", options.OutputPath, Array.Empty(), 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 = "KarismaObjectRuntimeInspection"
+ };
+
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ return completion.Task;
+}
+
+static Task InspectScreenPointsAsync(ScreenPointInspectionOptions options)
+{
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var thread = new Thread(() =>
+ {
+ IKAScene? scene = null;
+ ProbeEventHandler? handler = null;
+ IKAEngine? engine = null;
+
+ try
+ {
+ handler = new ProbeEventHandler();
+ engine = (IKAEngine)new KAEngineClass();
+
+ Console.WriteLine("[QUERY-SCREEN-POINT] Calling Connect()...");
+ var connectRequested = engine.Connect(options.Connection.Host, options.Connection.Port, handler);
+ Console.WriteLine($"[QUERY-SCREEN-POINT] Connect() returned {(connectRequested != 0 ? "TRUE" : "FALSE")} raw={connectRequested}");
+ if (connectRequested == 0)
+ {
+ completion.TrySetResult(
+ new ScreenPointInspectionProbeResult(false, "NOT_RUN", options.OutputPath, Array.Empty(), "Connect() returned 0."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.ConnectTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new ScreenPointInspectionProbeResult(true, "NOT_RUN", options.OutputPath, Array.Empty(), "OnConnect timed out."));
+ return;
+ }
+
+ if (handler.ConnectTask.Result != 0)
+ {
+ completion.TrySetResult(
+ new ScreenPointInspectionProbeResult(true, "NOT_RUN", options.OutputPath, Array.Empty(), $"OnConnect errorCode={handler.ConnectTask.Result}"));
+ return;
+ }
+
+ Console.WriteLine("[QUERY-SCREEN-POINT] Loading scene...");
+ handler.ResetLoadSceneTask();
+ scene = engine.LoadScene(options.ScenePath, options.SceneAlias);
+ if (scene is null)
+ {
+ completion.TrySetResult(
+ new ScreenPointInspectionProbeResult(true, "FAILED", options.OutputPath, Array.Empty(), "LoadScene returned null."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.LoadSceneTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new ScreenPointInspectionProbeResult(true, "TIMEOUT", options.OutputPath, Array.Empty(), "OnLoadScene timed out."));
+ return;
+ }
+
+ if (handler.LoadSceneTask.Result != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new ScreenPointInspectionProbeResult(true, handler.LoadSceneTask.Result.ToString(), options.OutputPath, Array.Empty(), $"OnLoadScene result={handler.LoadSceneTask.Result}"));
+ return;
+ }
+
+ var items = new List(options.Points.Count);
+ foreach (var point in options.Points)
+ {
+ Console.WriteLine($"[QUERY-SCREEN-POINT] Querying point=({point.X},{point.Y})...");
+ handler.ResetObjectInfosTask();
+ scene.QueryObjectInfosByScreenPoint(point.X, point.Y);
+ if (!WaitForTaskWithMessagePump(handler.ObjectInfosTask, options.Connection.Timeout))
+ {
+ items.Add(new ScreenPointInspectionItem(point.X, point.Y, "TIMEOUT", Array.Empty(), "OnQueryObjectInfosByScreenPoint timed out."));
+ continue;
+ }
+
+ var objectInfosResult = handler.ObjectInfosTask.Result;
+ items.Add(new ScreenPointInspectionItem(
+ point.X,
+ point.Y,
+ objectInfosResult.Result.ToString(),
+ objectInfosResult.Objects,
+ objectInfosResult.Detail));
+ }
+
+ WriteScreenPointInspectionReport(options, items);
+ completion.TrySetResult(new ScreenPointInspectionProbeResult(true, "SUCCESS", options.OutputPath, items, string.Empty));
+ }
+ catch (Exception ex)
+ {
+ completion.TrySetResult(new ScreenPointInspectionProbeResult(false, "EXCEPTION", options.OutputPath, Array.Empty(), 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 = "KarismaScreenPointInspection"
+ };
+
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ return completion.Task;
+}
+
+static Task InspectSceneCapabilitiesAsync(SceneCapabilityInspectionOptions options)
+{
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var thread = new Thread(() =>
+ {
+ IKAScene? scene = null;
+ ProbeEventHandler? handler = null;
+ IKAEngine? engine = null;
+
+ try
+ {
+ handler = new ProbeEventHandler();
+ engine = (IKAEngine)new KAEngineClass();
+
+ var samplePngPath = FindFirstFile(Environment.CurrentDirectory, "*.png");
+ Console.WriteLine($"[INSPECT-CAPABILITIES] Sample PNG={(samplePngPath ?? "(none)")}");
+
+ Console.WriteLine("[INSPECT-CAPABILITIES] Calling Connect()...");
+ var connectRequested = engine.Connect(options.Connection.Host, options.Connection.Port, handler);
+ Console.WriteLine($"[INSPECT-CAPABILITIES] Connect() returned {(connectRequested != 0 ? "TRUE" : "FALSE")} raw={connectRequested}");
+ if (connectRequested == 0)
+ {
+ completion.TrySetResult(
+ new SceneCapabilityInspectionProbeResult(false, "NOT_RUN", options.OutputPath, Array.Empty(), "Connect() returned 0."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.ConnectTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SceneCapabilityInspectionProbeResult(true, "NOT_RUN", options.OutputPath, Array.Empty(), "OnConnect timed out."));
+ return;
+ }
+
+ if (handler.ConnectTask.Result != 0)
+ {
+ completion.TrySetResult(
+ new SceneCapabilityInspectionProbeResult(true, "NOT_RUN", options.OutputPath, Array.Empty(), $"OnConnect errorCode={handler.ConnectTask.Result}"));
+ return;
+ }
+
+ Console.WriteLine("[INSPECT-CAPABILITIES] Loading scene...");
+ handler.ResetLoadSceneTask();
+ scene = engine.LoadScene(options.ScenePath, options.SceneAlias);
+ if (scene is null)
+ {
+ completion.TrySetResult(
+ new SceneCapabilityInspectionProbeResult(true, "FAILED", options.OutputPath, Array.Empty(), "LoadScene returned null."));
+ return;
+ }
+
+ if (!WaitForTaskWithMessagePump(handler.LoadSceneTask, options.Connection.Timeout))
+ {
+ completion.TrySetResult(
+ new SceneCapabilityInspectionProbeResult(true, "TIMEOUT", options.OutputPath, Array.Empty(), "OnLoadScene timed out."));
+ return;
+ }
+
+ if (handler.LoadSceneTask.Result != eKResult.RESULT_SUCCESS)
+ {
+ completion.TrySetResult(
+ new SceneCapabilityInspectionProbeResult(true, handler.LoadSceneTask.Result.ToString(), options.OutputPath, Array.Empty(), $"OnLoadScene result={handler.LoadSceneTask.Result}"));
+ return;
+ }
+
+ var candidates = ExtractCandidateNames(options.ScenePath)
+ .Where(candidate => options.Keywords.Count == 0 ||
+ options.Keywords.Any(keyword => candidate.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
+ .OrderBy(candidate => candidate, StringComparer.OrdinalIgnoreCase)
+ .Take(options.MaxCandidates ?? int.MaxValue)
+ .ToArray();
+
+ var items = new List(candidates.Length);
+ foreach (var candidate in candidates)
+ {
+ Console.WriteLine($"[INSPECT-CAPABILITIES] Inspecting candidate={candidate}...");
+ KAObject? sceneObject;
+ try
+ {
+ sceneObject = scene.GetObject(candidate);
+ }
+ catch (Exception ex)
+ {
+ items.Add(new SceneCapabilityInspectionItem(
+ candidate,
+ false,
+ string.Empty,
+ false,
+ false,
+ false,
+ "GET_OBJECT_EXCEPTION",
+ eKObjectType.OBJECT_TYPE_UNKNOWN,
+ string.Empty,
+ null,
+ null,
+ null,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ ex.Message));
+ continue;
+ }
+
+ if (sceneObject is null)
+ {
+ items.Add(new SceneCapabilityInspectionItem(
+ candidate,
+ false,
+ string.Empty,
+ false,
+ false,
+ false,
+ "OBJECT_NOT_FOUND",
+ eKObjectType.OBJECT_TYPE_UNKNOWN,
+ string.Empty,
+ null,
+ null,
+ null,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ "GetObject returned null."));
+ continue;
+ }
+
+ var detailParts = new List();
+ string animationName;
+ try
+ {
+ animationName = sceneObject.GetAnimationName() ?? string.Empty;
+ }
+ catch (Exception ex)
+ {
+ animationName = string.Empty;
+ detailParts.Add($"GetAnimationName: {ex.Message}");
+ }
+
+ handler.ResetObjectTypeTask();
+ sceneObject.QueryObjectType();
+ string queryTypeResult;
+ var objectType = eKObjectType.OBJECT_TYPE_UNKNOWN;
+ if (!WaitForTaskWithMessagePump(handler.ObjectTypeTask, options.Connection.Timeout))
+ {
+ queryTypeResult = "TIMEOUT";
+ detailParts.Add("OnQueryObjectType timed out.");
+ }
+ else
+ {
+ var callbackResult = handler.ObjectTypeTask.Result;
+ queryTypeResult = callbackResult.Result.ToString();
+ objectType = callbackResult.ObjectType;
+ }
+
+ var isChart = objectType == eKObjectType.OBJECT_TYPE_CHART;
+ var isCounter = objectType == eKObjectType.OBJECT_TYPE_COUNTER;
+ var isPath = objectType is eKObjectType.OBJECT_TYPE_PATH or eKObjectType.OBJECT_TYPE_PATHSHAPE;
+
+ handler.ResetQueryPositionTask();
+ sceneObject.QueryPosition();
+ string queryPositionResult;
+ float? x = null;
+ float? y = null;
+ float? z = null;
+ if (!WaitForTaskWithMessagePump(handler.QueryPositionTask, options.Connection.Timeout))
+ {
+ queryPositionResult = "TIMEOUT";
+ detailParts.Add("OnQueryPosition timed out.");
+ }
+ else
+ {
+ var callbackResult = handler.QueryPositionTask.Result;
+ queryPositionResult = callbackResult.Result.ToString();
+ if (callbackResult.Result == eKResult.RESULT_SUCCESS)
+ {
+ x = callbackResult.X;
+ y = callbackResult.Y;
+ z = callbackResult.Z;
+ }
+ }
+
+ var setPositionResult = string.Empty;
+ if (x.HasValue && y.HasValue && z.HasValue)
+ {
+ handler.ResetPositionTask();
+ sceneObject.SetPosition(x.Value, y.Value, z.Value, eKVectorType.VECTOR_TYPE_XYZ);
+ if (!WaitForTaskWithMessagePump(handler.PositionTask, options.Connection.Timeout))
+ {
+ setPositionResult = "TIMEOUT";
+ detailParts.Add("OnSetPosition timed out.");
+ }
+ else
+ {
+ setPositionResult = handler.PositionTask.Result.ToString();
+ }
+ }
+
+ var setValueTextResult = TrySetValueOnObject(handler, sceneObject, "__TCP_VALIDATE__", options.Connection.Timeout).ToString();
+ var setValueImageResult = string.Empty;
+ if (!string.IsNullOrWhiteSpace(samplePngPath))
+ {
+ setValueImageResult = TrySetValueOnObject(handler, sceneObject, samplePngPath, options.Connection.Timeout).ToString();
+ }
+
+ var counterKeyResult = string.Empty;
+ if (isCounter && sceneObject is IKACounter counter)
+ {
+ handler.ResetCounterNumberKeyTask();
+ counter.SetCounterNumberKey(1, 1d);
+ if (!WaitForTaskWithMessagePump(handler.CounterNumberKeyTask, options.Connection.Timeout))
+ {
+ counterKeyResult = "TIMEOUT";
+ detailParts.Add("OnSetCounterNumberKey timed out.");
+ }
+ else
+ {
+ counterKeyResult = handler.CounterNumberKeyTask.Result.ToString();
+ }
+ }
+
+ items.Add(new SceneCapabilityInspectionItem(
+ candidate,
+ true,
+ animationName,
+ isChart,
+ isCounter,
+ isPath,
+ queryTypeResult,
+ objectType,
+ queryPositionResult,
+ x,
+ y,
+ z,
+ setPositionResult,
+ setValueTextResult,
+ setValueImageResult,
+ counterKeyResult,
+ string.Join("; ", detailParts.Where(part => !string.IsNullOrWhiteSpace(part)))));
+ }
+
+ WriteSceneCapabilityInspectionReport(options, items);
+ completion.TrySetResult(new SceneCapabilityInspectionProbeResult(true, "SUCCESS", options.OutputPath, items, string.Empty));
+ }
+ catch (Exception ex)
+ {
+ completion.TrySetResult(new SceneCapabilityInspectionProbeResult(false, "EXCEPTION", options.OutputPath, Array.Empty(), 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 = "KarismaSceneCapabilityInspection"
+ };
+
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ return completion.Task;
+}
+
static Task ValidateSceneOperationsAsync(SceneValidationOptions options)
{
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -1376,6 +2877,163 @@ static void WriteCatalogMarkdown(
}
}
+static void WriteSceneObjectInspectionReport(
+ SceneObjectInspectionOptions options,
+ ObjectInfosProbeResult probeResult,
+ IReadOnlyList objects)
+{
+ Directory.CreateDirectory(Path.GetDirectoryName(options.OutputPath)!);
+
+ using var writer = new StreamWriter(options.OutputPath, false, new System.Text.UTF8Encoding(false));
+ writer.WriteLine("# Scene Object Inspection");
+ writer.WriteLine();
+ writer.WriteLine($"- Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
+ writer.WriteLine($"- Scene: `{options.ScenePath}`");
+ writer.WriteLine($"- Query Result: `{probeResult.Result}`");
+ writer.WriteLine($"- Object Count: {objects.Count}");
+ if (!string.IsNullOrWhiteSpace(options.NameFilter))
+ {
+ writer.WriteLine($"- Name Filter: `{options.NameFilter}`");
+ }
+
+ if (!string.IsNullOrWhiteSpace(probeResult.Detail))
+ {
+ writer.WriteLine($"- Detail: {EscapeInline(probeResult.Detail)}");
+ }
+
+ writer.WriteLine();
+ writer.WriteLine("## Type Counts");
+ writer.WriteLine();
+ foreach (var typeGroup in objects.GroupBy(item => item.ObjectType).OrderBy(group => group.Key.ToString(), StringComparer.OrdinalIgnoreCase))
+ {
+ writer.WriteLine($"- `{typeGroup.Key}`: {typeGroup.Count()}");
+ }
+
+ writer.WriteLine();
+ writer.WriteLine("## Objects");
+ writer.WriteLine();
+ writer.WriteLine("| Name | Type | Visible | Value |");
+ writer.WriteLine("| --- | --- | --- | --- |");
+ foreach (var item in objects)
+ {
+ writer.WriteLine(
+ $"| {EscapeCell(item.Name)} | {EscapeCell(item.ObjectType.ToString())} | {(item.Visible ? "yes" : "no")} | {EscapeCell(item.Value)} |");
+ }
+}
+
+static void WriteObjectTypeQueryReport(
+ ObjectTypeQueryOptions options,
+ IReadOnlyList items)
+{
+ Directory.CreateDirectory(Path.GetDirectoryName(options.OutputPath)!);
+
+ using var writer = new StreamWriter(options.OutputPath, false, new System.Text.UTF8Encoding(false));
+ writer.WriteLine("# Object Type Query");
+ writer.WriteLine();
+ writer.WriteLine($"- Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
+ writer.WriteLine($"- Scene: `{options.ScenePath}`");
+ writer.WriteLine($"- Objects Queried: {items.Count}");
+ writer.WriteLine();
+ writer.WriteLine("| Object | Result | Type | Detail |");
+ writer.WriteLine("| --- | --- | --- | --- |");
+ foreach (var item in items)
+ {
+ writer.WriteLine(
+ $"| {EscapeCell(item.ObjectName)} | {EscapeCell(item.Result)} | {EscapeCell(item.ObjectType.ToString())} | {EscapeCell(item.Detail)} |");
+ }
+}
+
+static void WriteObjectRuntimeInspectionReport(
+ ObjectTypeQueryOptions options,
+ IReadOnlyList items)
+{
+ Directory.CreateDirectory(Path.GetDirectoryName(options.OutputPath)!);
+
+ using var writer = new StreamWriter(options.OutputPath, false, new System.Text.UTF8Encoding(false));
+ writer.WriteLine("# Object Runtime Inspection");
+ writer.WriteLine();
+ writer.WriteLine($"- Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
+ writer.WriteLine($"- Scene: `{options.ScenePath}`");
+ writer.WriteLine($"- Objects Inspected: {items.Count}");
+ writer.WriteLine();
+ writer.WriteLine("| Object | Type Result | Type | Animation | Position Result | X | Y | Z | Detail |");
+ writer.WriteLine("| --- | --- | --- | --- | --- | --- | --- | --- | --- |");
+ foreach (var item in items)
+ {
+ writer.WriteLine(
+ $"| {EscapeCell(item.ObjectName)} | {EscapeCell(item.TypeResult)} | {EscapeCell(item.ObjectType.ToString())} | {EscapeCell(item.AnimationName)} | {EscapeCell(item.PositionResult)} | {EscapeCell(FormatCoordinate(item.X))} | {EscapeCell(FormatCoordinate(item.Y))} | {EscapeCell(FormatCoordinate(item.Z))} | {EscapeCell(item.Detail)} |");
+ }
+}
+
+static void WriteScreenPointInspectionReport(
+ ScreenPointInspectionOptions options,
+ IReadOnlyList items)
+{
+ Directory.CreateDirectory(Path.GetDirectoryName(options.OutputPath)!);
+
+ using var writer = new StreamWriter(options.OutputPath, false, new System.Text.UTF8Encoding(false));
+ writer.WriteLine("# Screen Point Inspection");
+ writer.WriteLine();
+ writer.WriteLine($"- Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
+ writer.WriteLine($"- Scene: `{options.ScenePath}`");
+ writer.WriteLine($"- Points Inspected: {items.Count}");
+ writer.WriteLine();
+
+ foreach (var item in items)
+ {
+ writer.WriteLine($"## ({item.X}, {item.Y})");
+ writer.WriteLine();
+ writer.WriteLine($"- Result: `{item.Result}`");
+ if (!string.IsNullOrWhiteSpace(item.Detail))
+ {
+ writer.WriteLine($"- Detail: {EscapeInline(item.Detail)}");
+ }
+
+ writer.WriteLine();
+ writer.WriteLine("| Name | Type | Visible | Value |");
+ writer.WriteLine("| --- | --- | --- | --- |");
+ foreach (var sceneObject in item.Objects)
+ {
+ writer.WriteLine(
+ $"| {EscapeCell(sceneObject.Name)} | {EscapeCell(sceneObject.ObjectType.ToString())} | {(sceneObject.Visible ? "yes" : "no")} | {EscapeCell(sceneObject.Value)} |");
+ }
+
+ if (item.Objects.Count == 0)
+ {
+ writer.WriteLine("_No objects returned._");
+ }
+
+ writer.WriteLine();
+ }
+}
+
+static void WriteSceneCapabilityInspectionReport(
+ SceneCapabilityInspectionOptions options,
+ IReadOnlyList items)
+{
+ Directory.CreateDirectory(Path.GetDirectoryName(options.OutputPath)!);
+
+ using var writer = new StreamWriter(options.OutputPath, false, new System.Text.UTF8Encoding(false));
+ writer.WriteLine("# Scene Capability Inspection");
+ writer.WriteLine();
+ writer.WriteLine($"- Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
+ writer.WriteLine($"- Scene: `{options.ScenePath}`");
+ writer.WriteLine($"- Candidate Count: {items.Count}");
+ if (options.Keywords.Count > 0)
+ {
+ writer.WriteLine($"- Keywords: `{string.Join("; ", options.Keywords)}`");
+ }
+
+ writer.WriteLine();
+ writer.WriteLine("| Candidate | Found | Anim | Chart | Counter | Path | QueryType | Type | QueryPos | X | Y | Z | SetPos | SetValueText | SetValueImage | CounterKey | Detail |");
+ writer.WriteLine("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |");
+ foreach (var item in items)
+ {
+ writer.WriteLine(
+ $"| {EscapeCell(item.Candidate)} | {(item.ObjectFound ? "yes" : "no")} | {EscapeCell(item.AnimationName)} | {(item.IsChart ? "yes" : "no")} | {(item.IsCounter ? "yes" : "no")} | {(item.IsPath ? "yes" : "no")} | {EscapeCell(item.QueryTypeResult)} | {EscapeCell(item.ObjectType.ToString())} | {EscapeCell(item.QueryPositionResult)} | {EscapeCell(FormatCoordinate(item.X))} | {EscapeCell(FormatCoordinate(item.Y))} | {EscapeCell(FormatCoordinate(item.Z))} | {EscapeCell(item.SetPositionResult)} | {EscapeCell(item.SetValueTextResult)} | {EscapeCell(item.SetValueImageResult)} | {EscapeCell(item.CounterKeyResult)} | {EscapeCell(item.Detail)} |");
+ }
+}
+
static string EscapeCell(string? value)
{
if (string.IsNullOrEmpty(value))
@@ -1391,6 +3049,8 @@ static string EscapeCell(string? value)
static string EscapeInline(string? value) => EscapeCell(value);
+static string FormatCoordinate(float? value) => value.HasValue ? value.Value.ToString("0.###") : string.Empty;
+
static Task ProbeSdkAsync(ProbeOptions options)
{
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -1556,6 +3216,51 @@ internal sealed record CounterProbeOptions(
}
}
+internal sealed record ChartInspectionOptions(
+ ProbeOptions Connection,
+ string ScenePath,
+ string SceneAlias,
+ string ObjectName)
+{
+ public static ChartInspectionOptions Parse(string[] args)
+ {
+ var connection = ProbeOptions.Parse(args);
+ string? scenePath = null;
+ string? sceneAlias = null;
+ string? objectName = null;
+
+ 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 "--object" when index + 1 < args.Length:
+ objectName = args[++index];
+ break;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(scenePath))
+ {
+ throw new ArgumentException("--scene is required.");
+ }
+
+ if (string.IsNullOrWhiteSpace(objectName))
+ {
+ throw new ArgumentException("--object is required.");
+ }
+
+ scenePath = Path.GetFullPath(scenePath);
+ sceneAlias ??= Path.GetFileNameWithoutExtension(scenePath);
+ return new ChartInspectionOptions(connection, scenePath, sceneAlias, objectName);
+ }
+}
+
internal sealed record SaveSceneImageOptions(
ProbeOptions Connection,
string ScenePath,
@@ -1563,7 +3268,20 @@ internal sealed record SaveSceneImageOptions(
string OutputPath,
int Width,
int Height,
- int Frame)
+ int Frame,
+ string? SetObjectName,
+ string? SetObjectValue,
+ string? VisibleObjectName,
+ bool? VisibleObjectValue,
+ SizeUpdate? Size,
+ PositionUpdate? Position,
+ PositionKeyUpdate? PositionKey,
+ string? ChartObjectName,
+ string? ChartCsvPath,
+ IReadOnlyList ChartCells,
+ string? PathObjectName,
+ IReadOnlyList PathPoints,
+ IReadOnlyList PathModifications)
{
public static SaveSceneImageOptions Parse(string[] args)
{
@@ -1571,6 +3289,23 @@ internal sealed record SaveSceneImageOptions(
string? scenePath = null;
string? sceneAlias = null;
string? outputPath = null;
+ string? setObjectName = null;
+ string? setObjectValue = null;
+ string? visibleObjectName = null;
+ bool? visibleObjectValue = null;
+ string? sizeObjectName = null;
+ string? sizeRaw = null;
+ string? positionObjectName = null;
+ string? positionRaw = null;
+ string? positionKeyObjectName = null;
+ int positionKeyIndex = 1;
+ string? positionKeyRaw = null;
+ string? chartObjectName = null;
+ string? chartCsvPath = null;
+ string? chartCellsRaw = null;
+ string? pathObjectName = null;
+ string? pathPointsRaw = null;
+ string? modifyPathRaw = null;
var width = 320;
var height = 180;
var frame = -1;
@@ -1588,6 +3323,63 @@ internal sealed record SaveSceneImageOptions(
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
break;
+ case "--set-object" when index + 1 < args.Length:
+ setObjectName = args[++index];
+ break;
+ case "--set-value" when index + 1 < args.Length:
+ setObjectValue = args[++index];
+ break;
+ case "--visible-object" when index + 1 < args.Length:
+ visibleObjectName = args[++index];
+ break;
+ case "--visible" when index + 1 < args.Length:
+ visibleObjectValue = args[++index] switch
+ {
+ "1" or "true" or "True" => true,
+ "0" or "false" or "False" => false,
+ _ => throw new ArgumentException("--visible must be true/false/1/0.")
+ };
+ break;
+ case "--size-object" when index + 1 < args.Length:
+ sizeObjectName = args[++index];
+ break;
+ case "--size" when index + 1 < args.Length:
+ sizeRaw = args[++index];
+ break;
+ case "--position-object" when index + 1 < args.Length:
+ positionObjectName = args[++index];
+ break;
+ case "--position" when index + 1 < args.Length:
+ positionRaw = args[++index];
+ break;
+ case "--position-key-object" when index + 1 < args.Length:
+ positionKeyObjectName = args[++index];
+ break;
+ case "--position-key-index" when index + 1 < args.Length && int.TryParse(args[index + 1], out var parsedPositionKeyIndex):
+ positionKeyIndex = parsedPositionKeyIndex;
+ index++;
+ break;
+ case "--position-key" when index + 1 < args.Length:
+ positionKeyRaw = args[++index];
+ break;
+ case "--chart-object" when index + 1 < args.Length:
+ chartObjectName = args[++index];
+ break;
+ case "--chart-csv" when index + 1 < args.Length:
+ chartCsvPath = args[++index];
+ break;
+ case "--chart-cells" when index + 1 < args.Length:
+ chartCellsRaw = args[++index];
+ break;
+ case "--path-object" when index + 1 < args.Length:
+ pathObjectName = args[++index];
+ break;
+ case "--path-points" when index + 1 < args.Length:
+ pathPointsRaw = args[++index];
+ break;
+ case "--modify-path" when index + 1 < args.Length:
+ modifyPathRaw = args[++index];
+ break;
case "--width" when index + 1 < args.Length && int.TryParse(args[index + 1], out var parsedWidth):
width = parsedWidth;
index++;
@@ -1616,7 +3408,221 @@ internal sealed record SaveSceneImageOptions(
scenePath = Path.GetFullPath(scenePath);
outputPath = Path.GetFullPath(outputPath);
sceneAlias ??= Path.GetFileNameWithoutExtension(scenePath);
- return new SaveSceneImageOptions(connection, scenePath, sceneAlias, outputPath, width, height, frame);
+ return new SaveSceneImageOptions(
+ connection,
+ scenePath,
+ sceneAlias,
+ outputPath,
+ width,
+ height,
+ frame,
+ setObjectName,
+ setObjectValue,
+ visibleObjectName,
+ visibleObjectValue,
+ ParseSize(sizeObjectName, sizeRaw),
+ ParsePosition(positionObjectName, positionRaw),
+ ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
+ chartObjectName,
+ chartCsvPath,
+ ParseChartCells(chartCellsRaw),
+ pathObjectName,
+ ParsePathPoints(pathPointsRaw),
+ ParsePathModifications(modifyPathRaw));
+ }
+
+ private static SizeUpdate? ParseSize(string? objectName, string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
+ {
+ return null;
+ }
+
+ var parts = raw.Split(',', StringSplitOptions.TrimEntries);
+ if (parts.Length != 2 ||
+ !float.TryParse(parts[0], out var width) ||
+ !float.TryParse(parts[1], out var height))
+ {
+ throw new ArgumentException("--size must be 'width,height'.");
+ }
+
+ return new SizeUpdate(objectName, width, height);
+ }
+
+ private static PositionUpdate? ParsePosition(string? objectName, string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
+ {
+ return null;
+ }
+
+ var vectorType = eKVectorType.VECTOR_TYPE_XYZ;
+ var rawParts = raw.Split(':', StringSplitOptions.TrimEntries);
+ var coordinatesRaw = rawParts[0];
+ if (rawParts.Length > 1 && !Enum.TryParse(rawParts[1], true, out vectorType))
+ {
+ throw new ArgumentException($"Unknown vector type '{rawParts[1]}'.");
+ }
+
+ var coordinates = coordinatesRaw.Split(',', StringSplitOptions.TrimEntries);
+ if (coordinates.Length < 2 ||
+ !float.TryParse(coordinates[0], out var x) ||
+ !float.TryParse(coordinates[1], out var y))
+ {
+ throw new ArgumentException("--position must be 'x,y[,z][:VectorType]'.");
+ }
+
+ var z = 0f;
+ if (coordinates.Length > 2 && !float.TryParse(coordinates[2], out z))
+ {
+ throw new ArgumentException("--position z must be numeric.");
+ }
+
+ return new PositionUpdate(objectName, x, y, z, vectorType);
+ }
+
+ private static PositionKeyUpdate? ParsePositionKey(string? objectName, int keyIndex, string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
+ {
+ return null;
+ }
+
+ var vectorType = eKVectorType.VECTOR_TYPE_XYZ;
+ var rawParts = raw.Split(':', StringSplitOptions.TrimEntries);
+ var coordinatesRaw = rawParts[0];
+ if (rawParts.Length > 1 && !Enum.TryParse(rawParts[1], true, out vectorType))
+ {
+ throw new ArgumentException($"Unknown vector type '{rawParts[1]}'.");
+ }
+
+ var coordinates = coordinatesRaw.Split(',', StringSplitOptions.TrimEntries);
+ if (coordinates.Length < 2 ||
+ !float.TryParse(coordinates[0], out var x) ||
+ !float.TryParse(coordinates[1], out var y))
+ {
+ throw new ArgumentException("--position-key must be 'x,y[,z][:VectorType]'.");
+ }
+
+ var z = 0f;
+ if (coordinates.Length > 2 && !float.TryParse(coordinates[2], out z))
+ {
+ throw new ArgumentException("--position-key z must be numeric.");
+ }
+
+ return new PositionKeyUpdate(objectName, keyIndex, x, y, z, vectorType);
+ }
+
+ private static IReadOnlyList ParseChartCells(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return Array.Empty