diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..3a8816a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,32 @@
+# Repository Instructions
+
+## MSIX publish after user approval
+
+When the user says a completed change is approved and asks to publish, deploy,
+patch, or update the MSIX, use the automated NAS publish script instead of
+manually editing package versions or copying files.
+
+Normal approved publish command:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Release -IncrementPackageRevision
+```
+
+Prerequisites:
+
+- `NAS_USER` must be set to the NAS account with write access to `/volume1/web/msix`.
+- `NAS_SSH_KEY` should point to the SSH private key for that NAS account.
+- The signing certificate thumbprint must remain
+ `E691A33C64DF20A204FFD4F096B9C3EB4B95709C`.
+
+The script will:
+
+1. Read `Tornado3_2026Election/Package.appxmanifest`.
+2. Increment the fourth package version part.
+3. Build and sign the MSIX package.
+4. Rewrite App Installer URLs to the public NAS path.
+5. Upload the files to the NAS over SSH/SCP.
+6. Verify the public URLs after upload.
+
+Do not run this publish command unless the user has explicitly approved
+publishing or deployment.
diff --git a/OBJECT_TYPE_QUERY.md b/OBJECT_TYPE_QUERY.md
new file mode 100644
index 0000000..724ee90
--- /dev/null
+++ b/OBJECT_TYPE_QUERY.md
@@ -0,0 +1,9 @@
+# Object Type Query
+
+- Generated: 2026-05-10 04:13:49
+- Scene: `D:\Elect2026\T3_Cut\Elect2026_Bottom_민방\전후보_광역단체장.tscn`
+- Objects Queried: 1
+
+| Object | Result | Type | Detail |
+| --- | --- | --- | --- |
+| 득표율01,득표율02,득표율03 | RESULT_ERROR_NO_VARIABLE_OBJECT | OBJECT_TYPE_UNKNOWN | |
diff --git a/SCENE_CAPABILITY_INSPECTION.md b/SCENE_CAPABILITY_INSPECTION.md
new file mode 100644
index 0000000..6426a63
--- /dev/null
+++ b/SCENE_CAPABILITY_INSPECTION.md
@@ -0,0 +1,42 @@
+# Scene Capability Inspection
+
+- Generated: 2026-05-10 04:24:05
+- Scene: `D:\Elect2026\T3_Cut\Elect2026_Bottom_민방\전후보_광역단체장_loop.tscn`
+- Candidate Count: 34
+
+| Candidate | Found | Anim | Chart | Counter | Path | QueryType | Type | QueryPos | X | Y | Z | SetPos | SetValueText | SetValueImage | CounterKey | Detail |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| bar | yes | | no | no | no | RESULT_ERROR_NO_VARIABLE_OBJECT | OBJECT_TYPE_UNKNOWN | RESULT_ERROR_NO_VARIABLE_OBJECT | | | | | RESULT_ERROR_NO_VARIABLE_OBJECT | RESULT_ERROR_NO_VARIABLE_OBJECT | | |
+| data01 | yes | | no | no | no | RESULT_ERROR_NO_VARIABLE_OBJECT | OBJECT_TYPE_UNKNOWN | RESULT_ERROR_NO_VARIABLE_OBJECT | | | | | RESULT_ERROR_NO_VARIABLE_OBJECT | RESULT_ERROR_NO_VARIABLE_OBJECT | | |
+| Root | yes | | no | no | no | RESULT_ERROR_NO_VARIABLE_OBJECT | OBJECT_TYPE_UNKNOWN | RESULT_ERROR_NO_VARIABLE_OBJECT | | | | | RESULT_ERROR_NO_VARIABLE_OBJECT | RESULT_ERROR_NO_VARIABLE_OBJECT | | |
+| 개표율01 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | -24.198 | 23.054 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 그룹01 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_UNKNOWN | RESULT_SUCCESS | -752.384 | -1 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 그룹02 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_UNKNOWN | RESULT_SUCCESS | -271.38 | -1 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 그룹03 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_UNKNOWN | RESULT_SUCCESS | 210.62 | -1 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 득표수01 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | 185.185 | -47.402 | -0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 득표수02 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | 185.185 | -47.402 | -0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 득표수03 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | 185.185 | -47.402 | -0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 득표율01 | yes | | no | yes | no | RESULT_SUCCESS | OBJECT_TYPE_COUNTER | RESULT_SUCCESS | 239 | -21 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | |
+| 득표율02 | yes | | no | yes | no | RESULT_SUCCESS | OBJECT_TYPE_COUNTER | RESULT_SUCCESS | 239 | -21 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | |
+| 득표율03 | yes | | no | yes | no | RESULT_SUCCESS | OBJECT_TYPE_COUNTER | RESULT_SUCCESS | 239 | -21 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | |
+| 마스크 | yes | | no | no | no | RESULT_ERROR_NO_VARIABLE_OBJECT | OBJECT_TYPE_UNKNOWN | RESULT_ERROR_NO_VARIABLE_OBJECT | | | | | RESULT_ERROR_NO_VARIABLE_OBJECT | RESULT_ERROR_NO_VARIABLE_OBJECT | | |
+| 선거구명01 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | -0.73 | -22.031 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 순위01 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_IMAGE | RESULT_SUCCESS | -182 | 9 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 순위02 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_IMAGE | RESULT_SUCCESS | -182 | 9 | -0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 순위03 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_IMAGE | RESULT_SUCCESS | -182 | 9 | -0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 시도 | yes | | no | no | no | RESULT_ERROR_NO_VARIABLE_OBJECT | OBJECT_TYPE_UNKNOWN | RESULT_ERROR_NO_VARIABLE_OBJECT | | | | | RESULT_ERROR_NO_VARIABLE_OBJECT | RESULT_ERROR_NO_VARIABLE_OBJECT | | |
+| 유확당01 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_IMAGE | RESULT_SUCCESS | -181 | 10 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 유확당02 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_IMAGE | RESULT_SUCCESS | -181 | 10 | -0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 유확당03 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_IMAGE | RESULT_SUCCESS | -181 | 10 | -0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 정당명01 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | 11.187 | 37.97 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 정당명02 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | 11.187 | 37.97 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 정당명03 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | 11.187 | 37.97 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 정당바01 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_RECT | RESULT_SUCCESS | 36.258 | 0 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 정당바02 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_RECT | RESULT_SUCCESS | 36.258 | 0 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 정당바03 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_RECT | RESULT_SUCCESS | 36.258 | 0 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 후보명01 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | 10.053 | -4.864 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 후보명02 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | 10.053 | -4.864 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 후보명03 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_TEXT | RESULT_SUCCESS | 10.053 | -4.864 | 0 | RESULT_SUCCESS | RESULT_SUCCESS | RESULT_SUCCESS | | |
+| 후보사진01 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_IMAGE | RESULT_SUCCESS | -94.972 | -16 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 후보사진02 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_IMAGE | RESULT_SUCCESS | -94.972 | -16 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
+| 후보사진03 | yes | | no | no | no | RESULT_SUCCESS | OBJECT_TYPE_IMAGE | RESULT_SUCCESS | -94.972 | -16 | 0 | RESULT_SUCCESS | RESULT_FAILURE | RESULT_SUCCESS | | |
diff --git a/TSCN_VARIABLE_DISCOVERY_TOP_2P.md b/TSCN_VARIABLE_DISCOVERY_TOP_2P.md
new file mode 100644
index 0000000..75730af
--- /dev/null
+++ b/TSCN_VARIABLE_DISCOVERY_TOP_2P.md
@@ -0,0 +1,193 @@
+# TSCN Variable Discovery
+
+- Generated: 2026-05-09 03:06:16
+- Root: `D:\Elect2026\T3_Cut\Elect2026_Top_민방`
+- Scene Count: 8
+- Discovered Variable Count: 112
+- Failure Count: 0
+
+## Method
+
+- Candidate names are extracted from each `.tscn` as UTF-16LE strings.
+- Each candidate is verified through Karisma TCP callbacks.
+- `SetValue(__TCP_VALIDATE__)`, valid `.png`, valid `.vrv`, and `SetCounterNumberKey(1, 1)` are tried as applicable.
+- Only callbacks that returned `RESULT_SUCCESS` are listed as discovered variables.
+
+## Scenes
+
+### `광역단체장_2인.tscn`
+
+- Candidate Count: 20
+- Discovered Variables: 14
+
+| Variable | Method | Payload | Result |
+| --- | --- | --- | --- |
+| 개표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 선거구명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 유확당01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 유확당02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 후보사진01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 후보사진02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+
+### `광역단체장_2인_텍스트.tscn`
+
+- Candidate Count: 18
+- Discovered Variables: 14
+
+| Variable | Method | Payload | Result |
+| --- | --- | --- | --- |
+| 개표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 선거구명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 유확당01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 유확당02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 후보명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 후보명02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+
+### `기초단체장_2인.tscn`
+
+- Candidate Count: 20
+- Discovered Variables: 15
+
+| Variable | Method | Payload | Result |
+| --- | --- | --- | --- |
+| 개표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 선거구명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 시도명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 유확당01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 유확당02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 후보사진01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 후보사진02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+
+### `기초단체장_2인_텍스트.tscn`
+
+- Candidate Count: 19
+- Discovered Variables: 13
+
+| Variable | Method | Payload | Result |
+| --- | --- | --- | --- |
+| 개표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 선거구명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 시도명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 유확당01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 유확당02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+
+### `Elect2026_Top_민방\광역단체장_2인.tscn`
+
+- Candidate Count: 20
+- Discovered Variables: 14
+
+| Variable | Method | Payload | Result |
+| --- | --- | --- | --- |
+| 개표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 선거구명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 유확당01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 유확당02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 후보사진01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 후보사진02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+
+### `Elect2026_Top_민방\광역단체장_2인_텍스트.tscn`
+
+- Candidate Count: 18
+- Discovered Variables: 14
+
+| Variable | Method | Payload | Result |
+| --- | --- | --- | --- |
+| 개표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 선거구명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 유확당01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 유확당02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 후보명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 후보명02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+
+### `Elect2026_Top_민방\기초단체장_2인.tscn`
+
+- Candidate Count: 20
+- Discovered Variables: 15
+
+| Variable | Method | Payload | Result |
+| --- | --- | --- | --- |
+| 개표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 선거구명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 시도명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 유확당01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 유확당02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 후보사진01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 후보사진02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+
+### `Elect2026_Top_민방\기초단체장_2인_텍스트.tscn`
+
+- Candidate Count: 19
+- Discovered Variables: 13
+
+| Variable | Method | Payload | Result |
+| --- | --- | --- | --- |
+| 개표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 득표율02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 선거구명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 순위02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 시도명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
+| 유확당01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 유확당02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당바02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼01 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+| 정당심볼02 | SetValue | D:\\Elect2026\\T3_Cut\\Elect2026_Top_민방\\Elect2026_Top_민방\\Images\\Dang\\Dang_Symbol\\개혁신당.png | RESULT_SUCCESS |
+
diff --git a/Tornado3_2026Election/Assets/Stations/ubc.png b/Tornado3_2026Election/Assets/Stations/ubc.png
new file mode 100644
index 0000000..68c14f3
Binary files /dev/null and b/Tornado3_2026Election/Assets/Stations/ubc.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 5831d59..3d724b8 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위_보궐선거.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/1-3위_보궐선거.png
index b78d55d..9b760a1 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 0267ae5..92e8787 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 572dbb3..2bb10b6 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_민방/이시각1위_광역단체장.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_광역단체장.png
index e6a7eec..4f62502 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위_광역단체장_L.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_광역단체장_L.png
index 9305e23..d47d1c1 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_광역단체장_L.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_광역단체장_L.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장.png
index 21ad948..bc13c82 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 b98343e..c88bcfc 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_민방/이시각1위_기초단체장_L.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장_L.png
index 2705228..b49c0ac 100644
Binary files a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장_L.png and b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/이시각1위_기초단체장_L.png differ
diff --git a/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/판세_기초단체장.png b/Tornado3_2026Election/Assets/Thumbnail/Elect2026_Normal_민방/판세_기초단체장.png
index b80f838..c164dca 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 21c57ca..162196b 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/Controls/ChannelSchedulePanel.xaml b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
index 518366c..d0c7baa 100644
--- a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
+++ b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
@@ -90,37 +90,98 @@
BorderBrush="#25405D"
BorderThickness="1"
CornerRadius="18">
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -778,7 +839,7 @@
+ BroadcastRank > 0 &&
+ BroadcastSeatCount > 0 &&
+ BroadcastRank <= BroadcastSeatCount;
+
public int VoteCount
{
get => _voteCount;
@@ -217,6 +232,9 @@ public sealed class CandidateEntry : ObservableObject
BroadcastElectionDistrictName = BroadcastElectionDistrictName,
BroadcastDistrictCode = BroadcastDistrictCode,
BroadcastCountedRate = BroadcastCountedRate,
+ BroadcastRank = BroadcastRank,
+ BroadcastSeatCount = BroadcastSeatCount,
+ BroadcastCountingClosed = BroadcastCountingClosed,
VoteCount = VoteCount,
VoteRate = VoteRate,
HasImage = HasImage,
diff --git a/Tornado3_2026Election/Domain/ChannelScheduleItem.cs b/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
index 4fc0add..de99f72 100644
--- a/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
+++ b/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
@@ -20,6 +20,13 @@ public sealed class ChannelScheduleItem : ObservableObject
private double _thumbnailWidth = 160;
private double _thumbnailHeight = 90;
private ImageSource? _thumbnailSource;
+ private string _renderedPreviewPath = string.Empty;
+ private ImageSource? _renderedPreviewSource;
+ private string _renderedPreviewStatusLabel = string.Empty;
+ private string _internalNextPreviewPath = string.Empty;
+ private ImageSource? _internalNextPreviewSource;
+ private string _internalNextPreviewStatusLabel = string.Empty;
+ private string _internalNextPreviewDisplayName = string.Empty;
public Guid Id { get; set; } = Guid.NewGuid();
@@ -88,6 +95,9 @@ public sealed class ChannelScheduleItem : ObservableObject
{
OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(StateBrush));
+ OnPropertyChanged(nameof(StateCardBackgroundBrush));
+ OnPropertyChanged(nameof(StateCardBorderBrush));
+ OnPropertyChanged(nameof(StateBadgeBackgroundBrush));
OnPropertyChanged(nameof(CardOpacity));
OnPropertyChanged(nameof(CanDelete));
}
@@ -133,11 +143,43 @@ public sealed class ChannelScheduleItem : ObservableObject
[JsonIgnore]
public SolidColorBrush StateBrush => new(State switch
{
- ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 245, 158, 11),
+ ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
+ ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 239, 68, 68),
+ ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
_ => ColorHelper.FromArgb(255, 100, 116, 139)
});
+ [JsonIgnore]
+ public SolidColorBrush StateCardBackgroundBrush => new(State switch
+ {
+ ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 72, 38, 10),
+ ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 64, 42, 16),
+ ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 58, 22, 24),
+ ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 54, 18, 31),
+ _ => ColorHelper.FromArgb(255, 18, 32, 51)
+ });
+
+ [JsonIgnore]
+ public SolidColorBrush StateCardBorderBrush => new(State switch
+ {
+ ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
+ ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
+ ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 255, 90, 84),
+ ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
+ _ => ColorHelper.FromArgb(255, 39, 64, 95)
+ });
+
+ [JsonIgnore]
+ public SolidColorBrush StateBadgeBackgroundBrush => new(State switch
+ {
+ ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 194, 65, 12),
+ ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 180, 83, 9),
+ ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 220, 38, 38),
+ ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 190, 18, 60),
+ _ => ColorHelper.FromArgb(255, 26, 46, 71)
+ });
+
[JsonIgnore]
public double CardOpacity => State == ScheduleQueueItemState.Completed ? 0.45 : 1.0;
@@ -179,6 +221,33 @@ public sealed class ChannelScheduleItem : ObservableObject
[JsonIgnore]
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(FormatId);
+ [JsonIgnore]
+ public ImageSource? PreviewSource => _renderedPreviewSource;
+
+ [JsonIgnore]
+ public bool HasRenderedPreview => _renderedPreviewSource is not null;
+
+ [JsonIgnore]
+ public string PreviewStatusLabel => HasRenderedPreview
+ ? _renderedPreviewStatusLabel
+ : "실데이터 프리뷰 준비 중";
+
+ [JsonIgnore]
+ public ImageSource? InternalNextPreviewSource => _internalNextPreviewSource;
+
+ [JsonIgnore]
+ public bool HasInternalNextPreview => _internalNextPreviewSource is not null;
+
+ [JsonIgnore]
+ public string InternalNextPreviewStatusLabel => HasInternalNextPreview
+ ? _internalNextPreviewStatusLabel
+ : "다음 지역 프리뷰 준비 중";
+
+ [JsonIgnore]
+ public string InternalNextPreviewDisplayName => string.IsNullOrWhiteSpace(_internalNextPreviewDisplayName)
+ ? DisplayName
+ : _internalNextPreviewDisplayName;
+
[JsonIgnore]
public double ThumbnailWidth
{
@@ -204,6 +273,55 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(ThumbnailStatusLabel));
}
+ public void UpdateRenderedPreview(string previewPath, string statusLabel)
+ {
+ _renderedPreviewPath = previewPath;
+ _renderedPreviewSource = CutPreviewAssetCatalog.CreateImageSource(previewPath);
+ _renderedPreviewStatusLabel = statusLabel;
+ OnPreviewChanged();
+ }
+
+ public void UpdateInternalNextPreview(string previewPath, string displayName, string statusLabel)
+ {
+ _internalNextPreviewPath = previewPath;
+ _internalNextPreviewSource = CutPreviewAssetCatalog.CreateImageSource(previewPath);
+ _internalNextPreviewDisplayName = displayName;
+ _internalNextPreviewStatusLabel = statusLabel;
+ OnInternalNextPreviewChanged();
+ }
+
+ public void ClearRenderedPreview()
+ {
+ if (string.IsNullOrWhiteSpace(_renderedPreviewPath) &&
+ _renderedPreviewSource is null &&
+ string.IsNullOrWhiteSpace(_renderedPreviewStatusLabel))
+ {
+ return;
+ }
+
+ _renderedPreviewPath = string.Empty;
+ _renderedPreviewSource = null;
+ _renderedPreviewStatusLabel = string.Empty;
+ OnPreviewChanged();
+ }
+
+ public void ClearInternalNextPreview()
+ {
+ if (string.IsNullOrWhiteSpace(_internalNextPreviewPath) &&
+ _internalNextPreviewSource is null &&
+ string.IsNullOrWhiteSpace(_internalNextPreviewStatusLabel) &&
+ string.IsNullOrWhiteSpace(_internalNextPreviewDisplayName))
+ {
+ return;
+ }
+
+ _internalNextPreviewPath = string.Empty;
+ _internalNextPreviewSource = null;
+ _internalNextPreviewStatusLabel = string.Empty;
+ _internalNextPreviewDisplayName = string.Empty;
+ OnInternalNextPreviewChanged();
+ }
+
public void UpdateThumbnailLayout(ThumbnailDisplayMetrics metrics)
{
ThumbnailWidth = metrics.Width;
@@ -227,6 +345,21 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(DurationApplyStatusLabel));
}
+ private void OnPreviewChanged()
+ {
+ OnPropertyChanged(nameof(PreviewSource));
+ OnPropertyChanged(nameof(HasRenderedPreview));
+ OnPropertyChanged(nameof(PreviewStatusLabel));
+ }
+
+ private void OnInternalNextPreviewChanged()
+ {
+ OnPropertyChanged(nameof(InternalNextPreviewSource));
+ OnPropertyChanged(nameof(HasInternalNextPreview));
+ OnPropertyChanged(nameof(InternalNextPreviewStatusLabel));
+ OnPropertyChanged(nameof(InternalNextPreviewDisplayName));
+ }
+
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
{
var selectedRegion = regionOption ?? new ScheduleRegionOption
diff --git a/Tornado3_2026Election/Domain/CutCategory.cs b/Tornado3_2026Election/Domain/CutCategory.cs
index ac6dabd..fb7387e 100644
--- a/Tornado3_2026Election/Domain/CutCategory.cs
+++ b/Tornado3_2026Election/Domain/CutCategory.cs
@@ -8,6 +8,15 @@ public enum CutCategory
MetropolitanCouncil,
LocalCouncil,
NationalAssembly,
+ BottomTopTwo,
+ BottomTopThree,
+ BottomCurrentLeader,
+ BottomWinner,
+ BottomAllCandidates,
+ BottomTurnoutSido,
+ BottomTurnoutDistrict,
+ BottomEarlyTurnout,
+ BottomElectionDayTurnout,
PreElection,
Historical,
Turnout,
diff --git a/Tornado3_2026Election/Domain/ElectionDataSnapshot.cs b/Tornado3_2026Election/Domain/ElectionDataSnapshot.cs
index 8b6d985..8c73327 100644
--- a/Tornado3_2026Election/Domain/ElectionDataSnapshot.cs
+++ b/Tornado3_2026Election/Domain/ElectionDataSnapshot.cs
@@ -32,6 +32,8 @@ public sealed class ElectionDataSnapshot
public required DateTimeOffset ReceivedAt { get; init; }
+ public string ReferenceTimeLabel { get; init; } = string.Empty;
+
public IReadOnlyList HistoricalTurnoutHistory { get; init; } =
Array.Empty();
diff --git a/Tornado3_2026Election/Domain/VideoWallLayoutPreset.cs b/Tornado3_2026Election/Domain/VideoWallLayoutPreset.cs
index 35f96ac..589e282 100644
--- a/Tornado3_2026Election/Domain/VideoWallLayoutPreset.cs
+++ b/Tornado3_2026Election/Domain/VideoWallLayoutPreset.cs
@@ -3,6 +3,7 @@ namespace Tornado3_2026Election.Domain;
public enum VideoWallLayoutPreset
{
Auto,
- Standard5760x1080,
- UltraWide11520x1080
+ Wall3840x810,
+ Wall2880x1080,
+ UltraWide8316x1080
}
diff --git a/Tornado3_2026Election/MainWindow.xaml b/Tornado3_2026Election/MainWindow.xaml
index 6bc65fe..797c320 100644
--- a/Tornado3_2026Election/MainWindow.xaml
+++ b/Tornado3_2026Election/MainWindow.xaml
@@ -44,13 +44,14 @@
-
-
-
-
+
+
+
+
-
-
+
+
+
@@ -812,23 +813,66 @@
+
+
+
+
+
+ CornerRadius="24">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
@@ -837,9 +881,9 @@
TextWrapping="Wrap" />
+ Command="{x:Bind ViewModel.Data.AddCareerPromiseRowCommand}"
+ Content="행 추가"
+ Style="{StaticResource ConsolePrimaryButtonStyle}" />
-
+
+
-
+
+
+
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
+
-
+
+
+
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -1190,6 +1242,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tornado3_2026Election/MainWindow.xaml.cs b/Tornado3_2026Election/MainWindow.xaml.cs
index 016f9fb..e1c813e 100644
--- a/Tornado3_2026Election/MainWindow.xaml.cs
+++ b/Tornado3_2026Election/MainWindow.xaml.cs
@@ -442,7 +442,7 @@ public sealed partial class MainWindow : Window
return;
}
- ViewModel.Data.SelectDistrictOverviewCard(card.DistrictViewName);
+ ViewModel.Data.SelectDistrictOverviewCard(card);
}
private void EnsureNavigationSelection()
@@ -462,6 +462,7 @@ public sealed partial class MainWindow : Window
AppPage.TurnoutData => "turnout-data",
AppPage.CountingData => "counting-data",
AppPage.Data => ViewModel.Data.IsPreElectionPhase ? "turnout-data" : "counting-data",
+ AppPage.CareerPromiseData => "career-promises",
AppPage.CutList => "cut-list",
AppPage.Settings => "settings",
AppPage.Log => "log",
diff --git a/Tornado3_2026Election/Package.appxmanifest b/Tornado3_2026Election/Package.appxmanifest
index 0c14841..0d5a0d8 100644
--- a/Tornado3_2026Election/Package.appxmanifest
+++ b/Tornado3_2026Election/Package.appxmanifest
@@ -11,7 +11,7 @@
+ Version="1.0.3.1" />
diff --git a/Tornado3_2026Election/Persistence/AppState.cs b/Tornado3_2026Election/Persistence/AppState.cs
index 0b49b26..6ccedcc 100644
--- a/Tornado3_2026Election/Persistence/AppState.cs
+++ b/Tornado3_2026Election/Persistence/AppState.cs
@@ -29,7 +29,15 @@ public sealed class AppState
public bool IsWindowMaximized { get; set; }
- public bool IsDebugFeaturesEnabled { get; set; } = true;
+ public bool IsDebugFeaturesEnabled { get; set; }
+
+ public int NormalLayerNo { get; set; }
+
+ public int TopLeftLayerNo { get; set; } = 1;
+
+ public int BottomLayerNo { get; set; } = 2;
+
+ public int VideoWallLayerNo { get; set; }
public bool IsPollingEnabled { get; set; } = true;
diff --git a/Tornado3_2026Election/Services/CareerPromiseService.cs b/Tornado3_2026Election/Services/CareerPromiseService.cs
index 93bc291..730a169 100644
--- a/Tornado3_2026Election/Services/CareerPromiseService.cs
+++ b/Tornado3_2026Election/Services/CareerPromiseService.cs
@@ -24,6 +24,7 @@ public sealed class CareerPromiseService
{
_logService = logService;
FilePath = ResolveFilePath();
+ TryMigrateLegacyCatalog();
_catalog = LoadCatalog(FilePath);
}
@@ -35,14 +36,12 @@ public sealed class CareerPromiseService
string districtCode,
string districtName)
{
- var normalizedStationId = stationId?.Trim() ?? string.Empty;
var normalizedElectionType = electionType?.Trim() ?? string.Empty;
var normalizedDistrictCode = districtCode?.Trim() ?? string.Empty;
var normalizedDistrictName = districtName?.Trim() ?? string.Empty;
- return _catalog.Entries
+ return SafeEntries(_catalog.Entries)
.Where(entry =>
- string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) &&
MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName))
.ToArray();
@@ -62,9 +61,8 @@ public sealed class CareerPromiseService
var normalizedDistrictCode = districtCode?.Trim() ?? string.Empty;
var normalizedDistrictName = districtName?.Trim() ?? string.Empty;
- var retainedEntries = _catalog.Entries
+ var retainedEntries = SafeEntries(_catalog.Entries)
.Where(entry =>
- !string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) ||
!MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName))
.ToList();
@@ -103,8 +101,14 @@ public sealed class CareerPromiseService
string districtCode,
string districtName)
{
- var candidateCode = entry.CandidateCode?.Trim() ?? string.Empty;
- if (string.IsNullOrWhiteSpace(candidateCode))
+ var candidateName = entry.CandidateName?.Trim() ?? string.Empty;
+ var party = entry.Party?.Trim() ?? string.Empty;
+ var resolvedDistrictName = string.IsNullOrWhiteSpace(entry.DistrictName)
+ ? districtName
+ : entry.DistrictName.Trim();
+ if (string.IsNullOrWhiteSpace(candidateName) ||
+ string.IsNullOrWhiteSpace(party) ||
+ string.IsNullOrWhiteSpace(resolvedDistrictName))
{
return null;
}
@@ -123,10 +127,10 @@ public sealed class CareerPromiseService
StationId = stationId,
ElectionType = electionType,
DistrictCode = districtCode,
- DistrictName = districtName,
- CandidateCode = candidateCode,
- CandidateName = entry.CandidateName?.Trim() ?? string.Empty,
- Party = entry.Party?.Trim() ?? string.Empty,
+ DistrictName = resolvedDistrictName,
+ CandidateCode = entry.CandidateCode?.Trim() ?? string.Empty,
+ CandidateName = candidateName,
+ Party = party,
Promises = promises
};
}
@@ -136,17 +140,17 @@ public sealed class CareerPromiseService
string districtCode,
string districtName)
{
- var entryDistrictCode = entry.DistrictCode?.Trim() ?? string.Empty;
- if (!string.IsNullOrWhiteSpace(districtCode) &&
- string.Equals(entryDistrictCode, districtCode, StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
-
var normalizedEntryDistrictName = NormalizeLookupKey(entry.DistrictName);
var normalizedDistrictName = NormalizeLookupKey(districtName);
- return !string.IsNullOrWhiteSpace(normalizedDistrictName) &&
- string.Equals(normalizedEntryDistrictName, normalizedDistrictName, StringComparison.Ordinal);
+ if (!string.IsNullOrWhiteSpace(normalizedDistrictName) &&
+ !string.IsNullOrWhiteSpace(normalizedEntryDistrictName))
+ {
+ return string.Equals(normalizedEntryDistrictName, normalizedDistrictName, StringComparison.Ordinal);
+ }
+
+ var entryDistrictCode = entry.DistrictCode?.Trim() ?? string.Empty;
+ return !string.IsNullOrWhiteSpace(districtCode) &&
+ string.Equals(entryDistrictCode, districtCode, StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeLookupKey(string? value)
@@ -170,8 +174,7 @@ public sealed class CareerPromiseService
try
{
var json = File.ReadAllText(filePath);
- return JsonSerializer.Deserialize(json, SerializerOptions)
- ?? new CareerPromiseCatalog();
+ return NormalizeCatalog(JsonSerializer.Deserialize(json, SerializerOptions));
}
catch (Exception ex)
{
@@ -189,10 +192,21 @@ public sealed class CareerPromiseService
}
var json = JsonSerializer.Serialize(catalog, SerializerOptions);
- File.WriteAllText(filePath, json);
+ var temporaryFilePath = $"{filePath}.tmp";
+ File.WriteAllText(temporaryFilePath, json);
+ File.Move(temporaryFilePath, filePath, overwrite: true);
}
private static string ResolveFilePath()
+ {
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Tornado3_2026Election",
+ "customer-data",
+ "career-promises.json");
+ }
+
+ private static string ResolveLegacyFilePath()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
@@ -200,4 +214,49 @@ public sealed class CareerPromiseService
"CustomerData",
"career-promises.json");
}
+
+ private void TryMigrateLegacyCatalog()
+ {
+ var legacyFilePath = ResolveLegacyFilePath();
+ if (File.Exists(FilePath) || !File.Exists(legacyFilePath))
+ {
+ return;
+ }
+
+ try
+ {
+ var directory = Path.GetDirectoryName(FilePath);
+ if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.Copy(legacyFilePath, FilePath, overwrite: false);
+ _logService.Info($"기존 공약데이터 저장본을 새 위치로 복사했습니다: {FilePath}");
+ }
+ catch (Exception ex)
+ {
+ _logService.Warning($"기존 공약데이터 저장본 복사에 실패했습니다: {ex.Message}");
+ }
+ }
+
+ private static CareerPromiseCatalog NormalizeCatalog(CareerPromiseCatalog? catalog)
+ {
+ if (catalog is null)
+ {
+ return new CareerPromiseCatalog();
+ }
+
+ return new CareerPromiseCatalog
+ {
+ Version = string.IsNullOrWhiteSpace(catalog.Version) ? "1" : catalog.Version,
+ UpdatedAt = catalog.UpdatedAt ?? string.Empty,
+ Entries = SafeEntries(catalog.Entries).ToArray()
+ };
+ }
+
+ private static IEnumerable SafeEntries(IEnumerable? entries)
+ {
+ return entries?.Where(entry => entry is not null) ?? Array.Empty();
+ }
}
diff --git a/Tornado3_2026Election/Services/ChannelScheduleEngine.cs b/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
index 3520653..4425d7a 100644
--- a/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
+++ b/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
@@ -5,16 +5,19 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class ChannelScheduleEngine
{
+ private const int PreviewFrame = -1;
private readonly ITornado3Adapter _adapter;
private readonly IDataRefreshGate _dataRefreshGate;
private readonly Func _stationProvider;
private readonly Func _imageRootProvider;
+ private readonly Func _videoWallLayoutPresetProvider;
private readonly Func _templateResolver;
private readonly LogService _logService;
private readonly SemaphoreSlim _executionLock = new(1, 1);
@@ -22,6 +25,7 @@ public sealed class ChannelScheduleEngine
private TaskCompletionSource? _advanceSignal;
private Guid? _preferredNextItemId;
private Guid? _skipCurrentItemId;
+ private ChannelScheduleItem? _directPlaybackItem;
public ChannelScheduleEngine(
BroadcastChannel channel,
@@ -30,6 +34,7 @@ public sealed class ChannelScheduleEngine
IDataRefreshGate dataRefreshGate,
Func stationProvider,
Func imageRootProvider,
+ Func videoWallLayoutPresetProvider,
Func templateResolver,
LogService logService)
{
@@ -39,6 +44,7 @@ public sealed class ChannelScheduleEngine
_dataRefreshGate = dataRefreshGate;
_stationProvider = stationProvider;
_imageRootProvider = imageRootProvider;
+ _videoWallLayoutPresetProvider = videoWallLayoutPresetProvider;
_templateResolver = templateResolver;
_logService = logService;
}
@@ -53,6 +59,11 @@ public sealed class ChannelScheduleEngine
public bool IsRunning { get; private set; }
+ public ChannelScheduleItem? ActivePlaybackItem =>
+ _directPlaybackItem?.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending
+ ? _directPlaybackItem
+ : Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
+
public event EventHandler? QueueChanged;
public async Task StartAsync()
@@ -88,6 +99,8 @@ public sealed class ChannelScheduleEngine
{
item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty;
+ item.ClearRenderedPreview();
+ item.ClearInternalNextPreview();
}
_preferredNextItemId = null;
@@ -105,6 +118,8 @@ public sealed class ChannelScheduleEngine
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
+ _directPlaybackItem = item;
+ QueueChanged?.Invoke(this, EventArgs.Empty);
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
await PlayItemAsync(item, template, cancellationToken).ConfigureAwait(false);
}
@@ -115,6 +130,7 @@ public sealed class ChannelScheduleEngine
}
finally
{
+ _directPlaybackItem = null;
_executionLock.Release();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
@@ -128,6 +144,8 @@ public sealed class ChannelScheduleEngine
item.State = ScheduleQueueItemState.Queued;
item.LastError = string.Empty;
item.CurrentRegionLabel = string.Empty;
+ item.ClearRenderedPreview();
+ item.ClearInternalNextPreview();
}
RefreshQueueMarkers();
@@ -356,31 +374,34 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup);
var isLastGroup = groupIndex == aggregateRegionGroups.Count - 1;
- for (var cutIndex = 0; cutIndex < resolvedCuts.Count; cutIndex++)
+ var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, aggregateSnapshot, hasEndScene && isLastGroup);
+ for (var cutIndex = 0; cutIndex < playbackCuts.Count; cutIndex++)
{
- var cut = ResolveScheduledCut(resolvedCuts[cutIndex], hasEndScene && isLastGroup, cutIndex == resolvedCuts.Count - 1);
- queueItem.State = ScheduleQueueItemState.Sending;
- RefreshQueueMarkers();
-
- await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
- await _adapter.ApplyCutAsync(Channel, template, cut, aggregateSnapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
- await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
- await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
-
- queueItem.State = ScheduleQueueItemState.OnAir;
- queueItem.LastPlayedAt = DateTimeOffset.Now;
- RefreshQueueMarkers();
-
- var signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- _advanceSignal = signal;
- if (ShouldSkipCurrentItem(queueItem))
+ var cut = playbackCuts[cutIndex];
+ Func>? nextPreviewFactory = null;
+ if (cutIndex + 1 < playbackCuts.Count)
{
- signal.TrySetResult(true);
+ var nextCut = playbackCuts[cutIndex + 1];
+ var nextRegionLabel = queueItem.CurrentRegionLabel;
+ nextPreviewFactory = _ => Task.FromResult(
+ new CutPreviewFrame(nextCut, aggregateSnapshot, nextRegionLabel));
+ }
+ else if (groupIndex + 1 < aggregateRegionGroups.Count)
+ {
+ var nextGroupIndex = groupIndex + 1;
+ nextPreviewFactory = token => TryBuildAggregatePreviewFrameAsync(
+ queueItem,
+ template,
+ station,
+ resolvedCuts,
+ aggregateRegionGroups,
+ nextGroupIndex,
+ hasEndScene,
+ token);
}
- var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
- var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
- await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
+ await PlayCutFrameAsync(queueItem, template, cut, aggregateSnapshot, station, imageRootPath, nextPreviewFactory, cancellationToken)
+ .ConfigureAwait(false);
if (ShouldSkipCurrentItem(queueItem))
{
break;
@@ -395,6 +416,7 @@ public sealed class ChannelScheduleEngine
}
queueItem.CurrentRegionLabel = string.Empty;
+ queueItem.ClearInternalNextPreview();
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
ClearSkipCurrentItem(queueItem);
@@ -436,30 +458,34 @@ public sealed class ChannelScheduleEngine
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastRegion);
queueItem.TotalCuts = playbackCuts.Count;
- foreach (var cut in playbackCuts)
+ for (var cutIndex = 0; cutIndex < playbackCuts.Count; cutIndex++)
{
- queueItem.State = ScheduleQueueItemState.Sending;
- RefreshQueueMarkers();
-
- await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
- await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
- await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
- await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
-
- queueItem.State = ScheduleQueueItemState.OnAir;
- queueItem.LastPlayedAt = DateTimeOffset.Now;
- RefreshQueueMarkers();
-
- var signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- _advanceSignal = signal;
- if (ShouldSkipCurrentItem(queueItem))
+ var cut = playbackCuts[cutIndex];
+ Func>? nextPreviewFactory = null;
+ if (cutIndex + 1 < playbackCuts.Count)
{
- signal.TrySetResult(true);
+ var nextCut = playbackCuts[cutIndex + 1];
+ var nextRegionLabel = regionTarget.DisplayName;
+ nextPreviewFactory = _ => Task.FromResult(
+ new CutPreviewFrame(nextCut, snapshot, nextRegionLabel));
+ }
+ else if (regionIndex + 1 < regionTargets.Count)
+ {
+ var nextRegionIndex = regionIndex + 1;
+ nextPreviewFactory = token => TryBuildRegionPreviewFrameAsync(
+ queueItem,
+ template,
+ station,
+ imageRootPath,
+ resolvedCuts,
+ regionTargets,
+ nextRegionIndex,
+ hasEndScene,
+ token);
}
- var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
- var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
- await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
+ await PlayCutFrameAsync(queueItem, template, cut, snapshot, station, imageRootPath, nextPreviewFactory, cancellationToken)
+ .ConfigureAwait(false);
if (ShouldSkipCurrentItem(queueItem))
{
break;
@@ -474,12 +500,405 @@ public sealed class ChannelScheduleEngine
}
queueItem.CurrentRegionLabel = string.Empty;
+ queueItem.ClearInternalNextPreview();
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
ClearSkipCurrentItem(queueItem);
RefreshQueueMarkers();
}
+ private async Task PlayCutFrameAsync(
+ ChannelScheduleItem queueItem,
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ ElectionDataSnapshot snapshot,
+ BroadcastStationProfile station,
+ string imageRootPath,
+ Func>? nextInternalPreviewFrameFactory,
+ CancellationToken cancellationToken)
+ {
+ queueItem.State = ScheduleQueueItemState.Sending;
+ RefreshQueueMarkers();
+ QueueChanged?.Invoke(this, EventArgs.Empty);
+
+ await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
+ await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
+ await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
+ await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
+
+ var onAirAt = DateTimeOffset.Now;
+ queueItem.State = ScheduleQueueItemState.OnAir;
+ queueItem.LastPlayedAt = onAirAt;
+ RefreshQueueMarkers();
+ QueueChanged?.Invoke(this, EventArgs.Empty);
+
+ var signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _advanceSignal = signal;
+ if (ShouldSkipCurrentItem(queueItem))
+ {
+ signal.TrySetResult(true);
+ }
+
+ await CaptureCurrentPreviewAsync(
+ queueItem,
+ template,
+ cut,
+ snapshot,
+ station,
+ imageRootPath,
+ cancellationToken).ConfigureAwait(false);
+ QueueChanged?.Invoke(this, EventArgs.Empty);
+ if (!ShouldSkipCurrentItem(queueItem))
+ {
+ CutPreviewFrame? nextInternalPreviewFrame = null;
+ if (nextInternalPreviewFrameFactory is not null)
+ {
+ try
+ {
+ nextInternalPreviewFrame = await nextInternalPreviewFrameFactory(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logService.Warning($"[{Channel}] 다음 지역 프리뷰 데이터 준비 실패: {queueItem.DisplayName} / {ex.Message}");
+ }
+ }
+
+ await CaptureNextPreviewAsync(queueItem, template, nextInternalPreviewFrame, station, imageRootPath, cancellationToken).ConfigureAwait(false);
+ QueueChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ if (ShouldSkipCurrentItem(queueItem))
+ {
+ return;
+ }
+
+ var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
+ var remainingDuration = TimeSpan.FromSeconds(durationSeconds) - (DateTimeOffset.Now - onAirAt);
+ if (remainingDuration <= TimeSpan.Zero)
+ {
+ return;
+ }
+
+ var delayTask = Task.Delay(remainingDuration, cancellationToken);
+ await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
+ }
+
+ private async Task CaptureCurrentPreviewAsync(
+ ChannelScheduleItem queueItem,
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ ElectionDataSnapshot snapshot,
+ BroadcastStationProfile station,
+ string imageRootPath,
+ CancellationToken cancellationToken)
+ {
+ if (!_adapter.IsLiveCg)
+ {
+ return;
+ }
+
+ var size = ThumbnailLayoutResolver.ResolveGenerationSize(template, _videoWallLayoutPresetProvider());
+ var previewPath = CutPreviewAssetCatalog.CreateCapturePath(Channel, queueItem.Id, "current");
+ var captured = await _adapter.TryCaptureCutPreviewAsync(
+ Channel,
+ template,
+ cut,
+ snapshot,
+ station,
+ imageRootPath,
+ previewPath,
+ size.Width,
+ size.Height,
+ PreviewFrame,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!captured)
+ {
+ return;
+ }
+
+ await UiDispatcher.EnqueueAsync(() =>
+ queueItem.UpdateRenderedPreview(previewPath, $"현재 변수 적용 캡처 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
+ }
+
+ private async Task CaptureNextPreviewAsync(
+ ChannelScheduleItem activeItem,
+ FormatTemplateDefinition activeTemplate,
+ CutPreviewFrame? internalNextPreviewFrame,
+ BroadcastStationProfile station,
+ string imageRootPath,
+ CancellationToken cancellationToken)
+ {
+ if (!_adapter.IsLiveCg)
+ {
+ return;
+ }
+
+ if (internalNextPreviewFrame is not null)
+ {
+ await CaptureInternalNextPreviewAsync(
+ activeItem,
+ activeTemplate,
+ internalNextPreviewFrame,
+ station,
+ imageRootPath,
+ cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ activeItem.ClearInternalNextPreview();
+ var nextItem = GetPreviewNextItem(activeItem);
+ if (nextItem is null)
+ {
+ return;
+ }
+
+ var template = _templateResolver(nextItem.FormatId);
+ if (template is null)
+ {
+ return;
+ }
+
+ CutPreviewFrame? previewFrame;
+ try
+ {
+ previewFrame = await TryBuildPreviewFrameAsync(nextItem, template, station, imageRootPath, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logService.Warning($"[{Channel}] 다음 컷 프리뷰 데이터 준비 실패: {nextItem.DisplayName} / {ex.Message}");
+ return;
+ }
+
+ if (previewFrame is null)
+ {
+ return;
+ }
+
+ var size = ThumbnailLayoutResolver.ResolveGenerationSize(template, _videoWallLayoutPresetProvider());
+ var previewPath = CutPreviewAssetCatalog.CreateCapturePath(Channel, nextItem.Id, "next");
+ var captured = await _adapter.TryCaptureCutPreviewAsync(
+ Channel,
+ template,
+ previewFrame.Cut,
+ previewFrame.Snapshot,
+ station,
+ imageRootPath,
+ previewPath,
+ size.Width,
+ size.Height,
+ PreviewFrame,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!captured)
+ {
+ return;
+ }
+
+ await UiDispatcher.EnqueueAsync(() =>
+ nextItem.UpdateRenderedPreview(previewPath, $"다음 변수 적용 캡처 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
+ }
+
+ private async Task CaptureInternalNextPreviewAsync(
+ ChannelScheduleItem activeItem,
+ FormatTemplateDefinition template,
+ CutPreviewFrame previewFrame,
+ BroadcastStationProfile station,
+ string imageRootPath,
+ CancellationToken cancellationToken)
+ {
+ var size = ThumbnailLayoutResolver.ResolveGenerationSize(template, _videoWallLayoutPresetProvider());
+ var previewPath = CutPreviewAssetCatalog.CreateCapturePath(Channel, activeItem.Id, "internal-next");
+ var captured = await _adapter.TryCaptureCutPreviewAsync(
+ Channel,
+ template,
+ previewFrame.Cut,
+ previewFrame.Snapshot,
+ station,
+ imageRootPath,
+ previewPath,
+ size.Width,
+ size.Height,
+ PreviewFrame,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!captured)
+ {
+ activeItem.ClearInternalNextPreview();
+ return;
+ }
+
+ var regionLabel = string.IsNullOrWhiteSpace(previewFrame.RegionLabel)
+ ? activeItem.SelectionRegionLabel
+ : previewFrame.RegionLabel;
+ var displayName = $"{activeItem.FormatName} / {regionLabel}";
+ await UiDispatcher.EnqueueAsync(() =>
+ activeItem.UpdateInternalNextPreview(previewPath, displayName, $"다음 지역 변수 적용 캡처 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
+ }
+
+ private async Task TryBuildPreviewFrameAsync(
+ ChannelScheduleItem queueItem,
+ FormatTemplateDefinition template,
+ BroadcastStationProfile station,
+ string imageRootPath,
+ CancellationToken cancellationToken)
+ {
+ await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
+
+ var resolvedCuts = ResolveCuts(template, station);
+ if (resolvedCuts.Count == 0)
+ {
+ return null;
+ }
+
+ var hasEndScene = KarismaSceneResolver.HasEndScene(template, imageRootPath);
+ var regionTargets = await _dataRefreshGate
+ .ResolveScheduleRegionTargetsAsync(queueItem, template, station, cancellationToken)
+ .ConfigureAwait(false);
+ if (regionTargets.Count == 0)
+ {
+ return null;
+ }
+
+ if (ShouldUseAggregateScheduleSnapshot(template))
+ {
+ var aggregateRegionGroups = ResolveAggregateScheduleRegionGroups(template, regionTargets);
+ for (var groupIndex = 0; groupIndex < aggregateRegionGroups.Count; groupIndex++)
+ {
+ var aggregateRegionGroup = aggregateRegionGroups[groupIndex];
+ var snapshot = await _dataRefreshGate
+ .GetAggregateScheduleSnapshotAsync(queueItem, template, station, aggregateRegionGroup, cancellationToken)
+ .ConfigureAwait(false);
+ if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out _))
+ {
+ continue;
+ }
+
+ var isLastGroup = groupIndex == aggregateRegionGroups.Count - 1;
+ var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastGroup);
+ if (playbackCuts.Count == 0)
+ {
+ continue;
+ }
+
+ var cut = playbackCuts[0];
+ return new CutPreviewFrame(cut, snapshot, ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup));
+ }
+
+ return null;
+ }
+
+ for (var regionIndex = 0; regionIndex < regionTargets.Count; regionIndex++)
+ {
+ var regionTarget = regionTargets[regionIndex];
+ var snapshot = await _dataRefreshGate
+ .GetScheduleSnapshotAsync(queueItem, template, regionTarget, cancellationToken)
+ .ConfigureAwait(false);
+ if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out _))
+ {
+ continue;
+ }
+
+ var isLastRegion = regionIndex == regionTargets.Count - 1;
+ var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastRegion);
+ if (playbackCuts.Count == 0)
+ {
+ continue;
+ }
+
+ return new CutPreviewFrame(playbackCuts[0], snapshot, regionTarget.DisplayName);
+ }
+
+ return null;
+ }
+
+ private async Task TryBuildRegionPreviewFrameAsync(
+ ChannelScheduleItem queueItem,
+ FormatTemplateDefinition template,
+ BroadcastStationProfile station,
+ string imageRootPath,
+ IReadOnlyList resolvedCuts,
+ IReadOnlyList regionTargets,
+ int startRegionIndex,
+ bool hasEndScene,
+ CancellationToken cancellationToken)
+ {
+ for (var regionIndex = startRegionIndex; regionIndex < regionTargets.Count; regionIndex++)
+ {
+ var regionTarget = regionTargets[regionIndex];
+ await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
+ var snapshot = await _dataRefreshGate
+ .GetScheduleSnapshotAsync(queueItem, template, regionTarget, cancellationToken)
+ .ConfigureAwait(false);
+ if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out _))
+ {
+ continue;
+ }
+
+ var isLastRegion = regionIndex == regionTargets.Count - 1;
+ var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastRegion);
+ if (playbackCuts.Count == 0)
+ {
+ continue;
+ }
+
+ return new CutPreviewFrame(playbackCuts[0], snapshot, regionTarget.DisplayName);
+ }
+
+ return null;
+ }
+
+ private async Task TryBuildAggregatePreviewFrameAsync(
+ ChannelScheduleItem queueItem,
+ FormatTemplateDefinition template,
+ BroadcastStationProfile station,
+ IReadOnlyList resolvedCuts,
+ IReadOnlyList> aggregateRegionGroups,
+ int startGroupIndex,
+ bool hasEndScene,
+ CancellationToken cancellationToken)
+ {
+ for (var groupIndex = startGroupIndex; groupIndex < aggregateRegionGroups.Count; groupIndex++)
+ {
+ var aggregateRegionGroup = aggregateRegionGroups[groupIndex];
+ await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
+ var snapshot = await _dataRefreshGate
+ .GetAggregateScheduleSnapshotAsync(queueItem, template, station, aggregateRegionGroup, cancellationToken)
+ .ConfigureAwait(false);
+ if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out _))
+ {
+ continue;
+ }
+
+ var isLastGroup = groupIndex == aggregateRegionGroups.Count - 1;
+ var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastGroup);
+ if (playbackCuts.Count == 0)
+ {
+ continue;
+ }
+
+ var cut = playbackCuts[0];
+ return new CutPreviewFrame(cut, snapshot, ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup));
+ }
+
+ return null;
+ }
+
+ private ChannelScheduleItem? GetPreviewNextItem(ChannelScheduleItem activeItem)
+ {
+ return Queue.FirstOrDefault(item => item != activeItem && item.State == ScheduleQueueItemState.Next)
+ ?? Queue.FirstOrDefault(item => item != activeItem && item.State == ScheduleQueueItemState.Queued);
+ }
+
private bool ShouldSkipCurrentItem(ChannelScheduleItem queueItem)
{
return _skipCurrentItemId == queueItem.Id;
@@ -500,6 +919,21 @@ public sealed class ChannelScheduleEngine
return true;
}
+ if (IsTopPanseTemplate(template))
+ {
+ return true;
+ }
+
+ if (IsNormalPanseMapTemplate(template))
+ {
+ return true;
+ }
+
+ if (IsBottomWinnerTemplate(template) || IsBasicCouncilWinnerTemplate(template))
+ {
+ return true;
+ }
+
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return true;
@@ -507,12 +941,12 @@ public sealed class ChannelScheduleEngine
if (template.RecommendedChannel == BroadcastChannel.Bottom)
{
- return string.Equals(template.Name, "사전투표율", StringComparison.Ordinal) ||
- string.Equals(template.Name, "투표율", StringComparison.Ordinal);
+ return IsBottomTurnoutBoardTemplate(template);
}
- return template.RecommendedChannel == BroadcastChannel.Normal &&
- string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
+ return (template.RecommendedChannel == BroadcastChannel.Normal &&
+ string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal)) ||
+ IsTopTurnoutDistrictBoardTemplate(template);
}
private static IReadOnlyList> ResolveAggregateScheduleRegionGroups(
@@ -521,10 +955,29 @@ public sealed class ChannelScheduleEngine
{
if (IsCurrentLeaderTemplate(template))
{
+ if (IsBottomCurrentLeaderTemplate(template))
+ {
+ return [regionTargets];
+ }
+
return ChunkRegionTargets(regionTargets, ResolveCurrentLeaderPageSize(template));
}
- if (!ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
+ if (IsBottomWinnerTemplate(template))
+ {
+ return [regionTargets];
+ }
+
+ if (IsTopTurnoutDistrictBoardTemplate(template))
+ {
+ return regionTargets
+ .GroupBy(ResolveCouncilSeatTableRegionKey, StringComparer.OrdinalIgnoreCase)
+ .SelectMany(group => ChunkRegionTargets(group.ToArray(), 3))
+ .ToArray();
+ }
+
+ if (!IsBasicCouncilWinnerTemplate(template) &&
+ !ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return [regionTargets];
}
@@ -568,6 +1021,12 @@ public sealed class ChannelScheduleEngine
ChannelScheduleItem queueItem,
IReadOnlyList regionTargets)
{
+ if (queueItem.Channel == BroadcastChannel.Normal &&
+ string.Equals(queueItem.FormatName, "판세_광역단체장", StringComparison.Ordinal))
+ {
+ return "전국";
+ }
+
var regionNames = regionTargets
.Select(target => target.RegionName)
.Where(regionName => !string.IsNullOrWhiteSpace(regionName))
@@ -583,6 +1042,35 @@ public sealed class ChannelScheduleEngine
: queueItem.SelectionRegionLabel;
}
+ private static bool IsTopTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
+ {
+ return template.RecommendedChannel == BroadcastChannel.TopLeft &&
+ string.Equals(template.Name, "투표율_선거구별", StringComparison.Ordinal);
+ }
+
+ private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Bottom &&
+ (string.Equals(template.Name, "사전투표율", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "사전투표율_시도", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "사전투표율_시군구", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "투표율", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "투표율_시도", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "투표율_시군구", StringComparison.Ordinal));
+ }
+
+ private static bool IsTopPanseTemplate(FormatTemplateDefinition template)
+ {
+ return template.RecommendedChannel == BroadcastChannel.TopLeft &&
+ template.Name.StartsWith("판세_", StringComparison.Ordinal);
+ }
+
+ private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Normal &&
+ string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal);
+ }
+
private static string NormalizeRegionKey(string value)
{
return string.Concat((value ?? string.Empty).Where(character => !char.IsWhiteSpace(character)));
@@ -641,6 +1129,11 @@ public sealed class ChannelScheduleEngine
return cutName;
}
+ if (template.RecommendedChannel == BroadcastChannel.Bottom)
+ {
+ return ResolveSuffixedCutName(cutName, "_loop");
+ }
+
if (isLastPage)
{
return ResolveSuffixedCutName(cutName, "_END");
@@ -722,27 +1215,61 @@ public sealed class ChannelScheduleEngine
private static bool IsAllCandidateTemplate(FormatTemplateDefinition template)
{
- return template.Name.StartsWith("모든후보_", StringComparison.Ordinal);
+ return template.Name.StartsWith("모든후보_", StringComparison.Ordinal) ||
+ template.Name.StartsWith("전후보_", StringComparison.Ordinal);
}
private static bool IsCurrentLeaderTemplate(FormatTemplateDefinition template)
{
- return template.Name.StartsWith("이시각1위_", StringComparison.Ordinal);
+ return template.Name.StartsWith("이시각1위_", StringComparison.Ordinal) ||
+ IsBottomCurrentLeaderTemplate(template);
+ }
+
+ private static bool IsBottomCurrentLeaderTemplate(FormatTemplateDefinition template)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Bottom &&
+ template.Name.StartsWith("1위_", StringComparison.Ordinal);
}
private static bool IsCandidatePagedTemplate(FormatTemplateDefinition template)
{
- return IsCareerTemplate(template) || IsAllCandidateTemplate(template);
+ return IsCareerTemplate(template) ||
+ IsAllCandidateTemplate(template) ||
+ IsBottomWinnerTemplate(template) ||
+ IsBottomCurrentLeaderTemplate(template);
+ }
+
+ private static bool IsBottomWinnerTemplate(FormatTemplateDefinition template)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Bottom &&
+ template.Name.StartsWith("당선_", StringComparison.Ordinal);
+ }
+
+ private static bool IsBasicCouncilWinnerTemplate(FormatTemplateDefinition template)
+ {
+ return template.Name.StartsWith("당선_", StringComparison.Ordinal) &&
+ (template.Name.Contains("광역의원", StringComparison.Ordinal) ||
+ template.Name.Contains("기초의원", StringComparison.Ordinal));
}
private static int ResolveCandidatePageSize(FormatTemplateDefinition template)
{
+ if (IsBottomWinnerTemplate(template) ||
+ IsBottomCurrentLeaderTemplate(template) ||
+ (template.RecommendedChannel == BroadcastChannel.Bottom && IsAllCandidateTemplate(template)))
+ {
+ return 3;
+ }
+
if (!IsAllCandidateTemplate(template))
{
return 1;
}
return template.SceneWidth >= 5000 ||
+ template.Name.Contains("8316", StringComparison.Ordinal) ||
+ template.Name.Contains("3840", StringComparison.Ordinal) ||
+ template.Name.Contains("2880", StringComparison.Ordinal) ||
template.Name.Contains("5760", StringComparison.Ordinal) ||
template.Id.Contains("_L", StringComparison.Ordinal)
? 3
@@ -751,6 +1278,12 @@ public sealed class ChannelScheduleEngine
private static int ResolveCurrentLeaderPageSize(FormatTemplateDefinition template)
{
+ if (template.RecommendedChannel == BroadcastChannel.Bottom &&
+ template.Name.StartsWith("1위_", StringComparison.Ordinal))
+ {
+ return 3;
+ }
+
if (template.Name.Contains("_L", StringComparison.Ordinal) ||
template.Id.Contains("_L", StringComparison.Ordinal) ||
template.SceneWidth >= 5000)
@@ -827,4 +1360,6 @@ public sealed class ChannelScheduleEngine
item.State = item == nextItem ? ScheduleQueueItemState.Next : ScheduleQueueItemState.Queued;
}
}
+
+ private sealed record CutPreviewFrame(FormatCutDefinition Cut, ElectionDataSnapshot Snapshot, string RegionLabel);
}
diff --git a/Tornado3_2026Election/Services/CutCategoryResolver.cs b/Tornado3_2026Election/Services/CutCategoryResolver.cs
index 9f88253..5f2f6b0 100644
--- a/Tornado3_2026Election/Services/CutCategoryResolver.cs
+++ b/Tornado3_2026Election/Services/CutCategoryResolver.cs
@@ -14,6 +14,15 @@ public static class CutCategoryResolver
CutCategory.MetropolitanCouncil,
CutCategory.LocalCouncil,
CutCategory.NationalAssembly,
+ CutCategory.BottomTopTwo,
+ CutCategory.BottomTopThree,
+ CutCategory.BottomCurrentLeader,
+ CutCategory.BottomWinner,
+ CutCategory.BottomAllCandidates,
+ CutCategory.BottomTurnoutSido,
+ CutCategory.BottomTurnoutDistrict,
+ CutCategory.BottomEarlyTurnout,
+ CutCategory.BottomElectionDayTurnout,
CutCategory.PreElection,
CutCategory.Historical,
CutCategory.Turnout,
@@ -35,6 +44,16 @@ public static class CutCategoryResolver
CutCategory.LocalCouncil => Contains(formatName, "기초의원"),
CutCategory.NationalAssembly => Contains(formatName, "보궐선거") ||
Contains(formatName, "국회의원"),
+ CutCategory.BottomTopTwo => IsBottomCountingTemplate(template, "1-2위_"),
+ CutCategory.BottomTopThree => IsBottomCountingTemplate(template, "1-3위_"),
+ CutCategory.BottomCurrentLeader => IsBottomCountingTemplate(template, "1위_"),
+ CutCategory.BottomWinner => IsBottomCountingTemplate(template, "당선_"),
+ CutCategory.BottomAllCandidates => IsBottomCountingTemplate(template, "전후보_") ||
+ IsBottomCountingTemplate(template, "모든후보_"),
+ CutCategory.BottomTurnoutSido => IsBottomTurnoutSidoTemplate(template),
+ CutCategory.BottomTurnoutDistrict => IsBottomTurnoutDistrictTemplate(template),
+ CutCategory.BottomEarlyTurnout => IsBottomEarlyTurnoutTemplate(template),
+ CutCategory.BottomElectionDayTurnout => IsBottomElectionDayTurnoutTemplate(template),
CutCategory.PreElection => Contains(formatName, "사전"),
CutCategory.Historical => Contains(formatName, "역대"),
CutCategory.Turnout => Contains(formatName, "투표율"),
@@ -47,11 +66,21 @@ public static class CutCategoryResolver
{
return category switch
{
+ CutCategory.MetropolitanHead => "광역단체장",
CutCategory.LocalHead => "기초단체장",
CutCategory.Superintendent => "교육감",
CutCategory.MetropolitanCouncil => "광역의원",
CutCategory.LocalCouncil => "기초의원",
CutCategory.NationalAssembly => "국회의원",
+ CutCategory.BottomTopTwo => "1-2위",
+ CutCategory.BottomTopThree => "1-3위",
+ CutCategory.BottomCurrentLeader => "1위",
+ CutCategory.BottomWinner => "당선",
+ CutCategory.BottomAllCandidates => "전후보",
+ CutCategory.BottomTurnoutSido => "시도",
+ CutCategory.BottomTurnoutDistrict => "시군구",
+ CutCategory.BottomEarlyTurnout => "사전투표율",
+ CutCategory.BottomElectionDayTurnout => "투표율",
CutCategory.PreElection => "사전",
CutCategory.Historical => "역대",
CutCategory.Turnout => "투표율",
@@ -64,4 +93,39 @@ public static class CutCategoryResolver
{
return value.Contains(token, StringComparison.Ordinal);
}
+
+ private static bool IsBottomCountingTemplate(FormatTemplateDefinition template, string prefix)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Bottom &&
+ template.SupportsCounting &&
+ template.Name.StartsWith(prefix, StringComparison.Ordinal);
+ }
+
+ private static bool IsBottomTurnoutSidoTemplate(FormatTemplateDefinition template)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Bottom &&
+ (string.Equals(template.Name, "사전투표율_시도", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "투표율_시도", StringComparison.Ordinal));
+ }
+
+ private static bool IsBottomTurnoutDistrictTemplate(FormatTemplateDefinition template)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Bottom &&
+ (string.Equals(template.Name, "사전투표율_시군구", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "투표율_시군구", StringComparison.Ordinal));
+ }
+
+ private static bool IsBottomEarlyTurnoutTemplate(FormatTemplateDefinition template)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Bottom &&
+ (string.Equals(template.Name, "사전투표율_시도", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "사전투표율_시군구", StringComparison.Ordinal));
+ }
+
+ private static bool IsBottomElectionDayTurnoutTemplate(FormatTemplateDefinition template)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Bottom &&
+ (string.Equals(template.Name, "투표율_시도", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "투표율_시군구", StringComparison.Ordinal));
+ }
}
diff --git a/Tornado3_2026Election/Services/CutDebugStateStore.cs b/Tornado3_2026Election/Services/CutDebugStateStore.cs
index 88f4f5a..3b63076 100644
--- a/Tornado3_2026Election/Services/CutDebugStateStore.cs
+++ b/Tornado3_2026Election/Services/CutDebugStateStore.cs
@@ -9,13 +9,16 @@ public sealed class CutDebugStateStore
private readonly object _syncRoot = new();
private readonly Dictionary _settingsByChannel = new();
private readonly Dictionary _templateStates = new(StringComparer.Ordinal);
- private bool _isDebugFeatureEnabled = true;
+ private bool _isDebugFeatureEnabled;
public CutDebugStateStore()
{
foreach (var channel in Enum.GetValues())
{
- _settingsByChannel[channel] = new CutDebugSettings();
+ _settingsByChannel[channel] = new CutDebugSettings
+ {
+ IsFeatureEnabled = _isDebugFeatureEnabled
+ };
}
}
diff --git a/Tornado3_2026Election/Services/CutPreviewAssetCatalog.cs b/Tornado3_2026Election/Services/CutPreviewAssetCatalog.cs
new file mode 100644
index 0000000..18dfa3c
--- /dev/null
+++ b/Tornado3_2026Election/Services/CutPreviewAssetCatalog.cs
@@ -0,0 +1,53 @@
+using System;
+using System.IO;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Tornado3_2026Election.Domain;
+
+namespace Tornado3_2026Election.Services;
+
+public static class CutPreviewAssetCatalog
+{
+ private const string PreviewRootFolderName = "Tornado3_2026Election";
+ private const string PreviewFolderName = "CutPreviews";
+
+ public static string CreateCapturePath(BroadcastChannel channel, Guid itemId, string role)
+ {
+ var directory = GetPreviewDirectory();
+ Directory.CreateDirectory(directory);
+
+ var timestamp = DateTimeOffset.Now.ToString("yyyyMMdd_HHmmss_fff");
+ var safeRole = SanitizeFileName(role);
+ return Path.Combine(directory, $"{channel}_{safeRole}_{itemId:N}_{timestamp}.png");
+ }
+
+ public static ImageSource? CreateImageSource(string? path)
+ {
+ return !string.IsNullOrWhiteSpace(path) && File.Exists(path)
+ ? new BitmapImage(new Uri(path, UriKind.Absolute))
+ : null;
+ }
+
+ private static string GetPreviewDirectory()
+ {
+ var localApplicationData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var root = string.IsNullOrWhiteSpace(localApplicationData)
+ ? AppContext.BaseDirectory
+ : localApplicationData;
+
+ return Path.Combine(root, PreviewRootFolderName, PreviewFolderName);
+ }
+
+ private static string SanitizeFileName(string value)
+ {
+ var invalidCharacters = Path.GetInvalidFileNameChars();
+ var sanitized = string.Join(
+ "_",
+ (value ?? string.Empty)
+ .Split(invalidCharacters, StringSplitOptions.RemoveEmptyEntries));
+
+ return string.IsNullOrWhiteSpace(sanitized)
+ ? "preview"
+ : sanitized;
+ }
+}
diff --git a/Tornado3_2026Election/Services/CutThumbnailAssetCatalog.cs b/Tornado3_2026Election/Services/CutThumbnailAssetCatalog.cs
index 413d787..443d9bd 100644
--- a/Tornado3_2026Election/Services/CutThumbnailAssetCatalog.cs
+++ b/Tornado3_2026Election/Services/CutThumbnailAssetCatalog.cs
@@ -69,6 +69,22 @@ public static class CutThumbnailAssetCatalog
}
public static bool HasThumbnail(string templateId)
+ {
+ if (HasThumbnailPath(templateId))
+ {
+ return true;
+ }
+
+ var fallbackTemplateId = ResolveThumbnailTemplateId(templateId);
+ if (string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return HasThumbnailPath(fallbackTemplateId);
+ }
+
+ private static bool HasThumbnailPath(string templateId)
{
var projectPath = TryGetProjectAssetPath(templateId);
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
@@ -111,9 +127,49 @@ public static class CutThumbnailAssetCatalog
return bundledPath;
}
+ var fallbackTemplateId = ResolveThumbnailTemplateId(templateId);
+ if (!string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
+ {
+ var fallbackProjectPath = TryGetProjectAssetPath(fallbackTemplateId);
+ if (!string.IsNullOrWhiteSpace(fallbackProjectPath) && File.Exists(fallbackProjectPath))
+ {
+ return fallbackProjectPath;
+ }
+
+ var fallbackBundledPath = GetBundledAssetPath(fallbackTemplateId);
+ if (File.Exists(fallbackBundledPath))
+ {
+ return fallbackBundledPath;
+ }
+ }
+
return Path.Combine(AppContext.BaseDirectory, FallbackAssetPath);
}
+ private static string ResolveThumbnailTemplateId(string templateId)
+ {
+ if (string.IsNullOrWhiteSpace(templateId))
+ {
+ return templateId;
+ }
+
+ var normalizedId = templateId.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
+ var folder = Path.GetDirectoryName(normalizedId);
+ var fileName = Path.GetFileName(normalizedId);
+ var canonicalName = fileName switch
+ {
+ "사전투표율_시도" or "사전투표율_시군구" => "사전투표율",
+ "투표율_시도" or "투표율_시군구" => "투표율",
+ _ => fileName
+ };
+
+ return string.Equals(fileName, canonicalName, StringComparison.Ordinal)
+ ? templateId
+ : string.IsNullOrWhiteSpace(folder)
+ ? canonicalName
+ : Path.Combine(folder, canonicalName);
+ }
+
private static string? TryGetProjectRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
diff --git a/Tornado3_2026Election/Services/FormatCatalogService.cs b/Tornado3_2026Election/Services/FormatCatalogService.cs
index 5c4b897..bd402eb 100644
--- a/Tornado3_2026Election/Services/FormatCatalogService.cs
+++ b/Tornado3_2026Election/Services/FormatCatalogService.cs
@@ -59,11 +59,13 @@ public sealed class FormatCatalogService
"당선_광역의원",
"당선_기초단체장",
"당선_기초의원",
- "사전투표율",
+ "사전투표율_시도",
+ "사전투표율_시군구",
"전후보_광역단체장",
"전후보_교육감",
"전후보_기초단체장",
- "투표율"));
+ "투표율_시도",
+ "투표율_시군구"));
formats.AddRange(CreateFormats(
BroadcastChannel.Normal,
@@ -166,12 +168,15 @@ public sealed class FormatCatalogService
{
var isAvailableInBothPhases = IsAvailableInBothPhases(baseName);
var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(baseName);
- var sceneResolution = TryReadSceneResolution(relativeFolder, baseName, t3CutPath);
+ var formatId = Path.Combine(relativeFolder, baseName);
+ var sceneIdOverride = ResolveSceneIdOverride(relativeFolder, baseName);
+ var sceneId = sceneIdOverride ?? formatId;
+ var sceneResolution = TryReadSceneResolution(sceneId, t3CutPath);
var recommendedChannel = ResolveRecommendedChannel(channel, baseName, sceneResolution);
yield return new FormatTemplateDefinition
{
- Id = Path.Combine(relativeFolder, baseName),
+ Id = formatId,
Name = baseName,
Description = $"{relativeFolder} 컷",
RecommendedChannel = recommendedChannel,
@@ -193,13 +198,24 @@ public sealed class FormatCatalogService
DurationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
defaultCutDurationSeconds,
recommendedChannel,
- baseName)
+ baseName),
+ SceneIdOverride = sceneIdOverride
}
]
};
}
}
+ private static string? ResolveSceneIdOverride(string relativeFolder, string baseName)
+ {
+ return baseName switch
+ {
+ "사전투표율_시도" or "사전투표율_시군구" => Path.Combine(relativeFolder, "사전투표율"),
+ "투표율_시도" or "투표율_시군구" => Path.Combine(relativeFolder, "투표율"),
+ _ => null
+ };
+ }
+
private static bool IsPreElectionOnlyFormat(string baseName)
{
return baseName.Contains("투표율", StringComparison.Ordinal);
@@ -219,11 +235,13 @@ public sealed class FormatCatalogService
[Path.Combine("Elect2026_Bottom_민방", "당선_광역의원_loop")] = Path.Combine("Elect2026_Bottom_민방", "당선_광역의원"),
[Path.Combine("Elect2026_Bottom_민방", "당선_기초단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "당선_기초단체장"),
[Path.Combine("Elect2026_Bottom_민방", "당선_기초의원_loop")] = Path.Combine("Elect2026_Bottom_민방", "당선_기초의원"),
- [Path.Combine("Elect2026_Bottom_민방", "사전투표율_loop")] = Path.Combine("Elect2026_Bottom_민방", "사전투표율"),
+ [Path.Combine("Elect2026_Bottom_민방", "사전투표율")] = Path.Combine("Elect2026_Bottom_민방", "사전투표율_시도"),
+ [Path.Combine("Elect2026_Bottom_민방", "사전투표율_loop")] = Path.Combine("Elect2026_Bottom_민방", "사전투표율_시도"),
[Path.Combine("Elect2026_Bottom_민방", "전후보_광역단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "전후보_광역단체장"),
[Path.Combine("Elect2026_Bottom_민방", "전후보_교육감_loop")] = Path.Combine("Elect2026_Bottom_민방", "전후보_교육감"),
[Path.Combine("Elect2026_Bottom_민방", "전후보_기초단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "전후보_기초단체장"),
- [Path.Combine("Elect2026_Bottom_민방", "투표율_loop")] = Path.Combine("Elect2026_Bottom_민방", "투표율"),
+ [Path.Combine("Elect2026_Bottom_민방", "투표율")] = Path.Combine("Elect2026_Bottom_민방", "투표율_시도"),
+ [Path.Combine("Elect2026_Bottom_민방", "투표율_loop")] = Path.Combine("Elect2026_Bottom_민방", "투표율_시도"),
[Path.Combine("Elect2026_Normal_민방", "1-2위_ani_광역단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "1-2위_ani_광역단체장"),
[Path.Combine("Elect2026_Normal_민방", "1-2위_ani_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-2위_ani_기초단체장_5760"),
[Path.Combine("Elect2026_Normal_민방", "1-2위_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-2위_광역단체장_5760"),
@@ -281,7 +299,8 @@ public sealed class FormatCatalogService
private static bool IsAvailableInBothPhases(string baseName)
{
- return baseName.StartsWith("사전_역대당선", StringComparison.Ordinal);
+ return baseName.StartsWith("사전_역대당선", StringComparison.Ordinal) ||
+ baseName.StartsWith("경력_", StringComparison.Ordinal);
}
private static bool IsHistoricalPreElectionWinnerFormat(string baseName)
@@ -311,18 +330,21 @@ public sealed class FormatCatalogService
private static bool IsVideoWallFormat(string baseName)
{
- return baseName.Contains("_5760", StringComparison.Ordinal) ||
+ return baseName.Contains("_3840", StringComparison.Ordinal) ||
+ baseName.Contains("_2880", StringComparison.Ordinal) ||
+ baseName.Contains("_8316", StringComparison.Ordinal) ||
+ baseName.Contains("_5760", StringComparison.Ordinal) ||
baseName.Contains("_L", StringComparison.Ordinal);
}
- private static KarismaSceneResolution? TryReadSceneResolution(string relativeFolder, string baseName, string t3CutPath)
+ private static KarismaSceneResolution? TryReadSceneResolution(string sceneId, string t3CutPath)
{
if (string.IsNullOrWhiteSpace(t3CutPath))
{
return null;
}
- var scenePath = Path.Combine(t3CutPath, relativeFolder, baseName + ".tscn");
+ var scenePath = Path.Combine(t3CutPath, sceneId + ".tscn");
return KarismaSceneResolutionReader.TryRead(scenePath, out var resolution)
? resolution
: null;
@@ -339,9 +361,7 @@ public sealed class FormatCatalogService
return false;
}
- channel = sceneResolution.Value is { Width: 1920, Height: 1080 }
- ? BroadcastChannel.Normal
- : sceneResolution.Value.Width > 1920 && sceneResolution.Value.Height == 1080
+ channel = sceneResolution.Value.Width > 1920
? BroadcastChannel.VideoWall
: BroadcastChannel.Normal;
diff --git a/Tornado3_2026Election/Services/ITornado3Adapter.cs b/Tornado3_2026Election/Services/ITornado3Adapter.cs
index 84e6c30..aafe210 100644
--- a/Tornado3_2026Election/Services/ITornado3Adapter.cs
+++ b/Tornado3_2026Election/Services/ITornado3Adapter.cs
@@ -32,6 +32,27 @@ public interface ITornado3Adapter
string imageRootPath,
CancellationToken cancellationToken);
+ Task TryCapturePendingCutPreviewAsync(
+ BroadcastChannel channel,
+ string fileName,
+ int width,
+ int height,
+ int frame,
+ CancellationToken cancellationToken);
+
+ Task TryCaptureCutPreviewAsync(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ ElectionDataSnapshot snapshot,
+ BroadcastStationProfile station,
+ string imageRootPath,
+ string fileName,
+ int width,
+ int height,
+ int frame,
+ CancellationToken cancellationToken);
+
Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken);
diff --git a/Tornado3_2026Election/Services/KarismaCounterNumberKeyUpdate.cs b/Tornado3_2026Election/Services/KarismaCounterNumberKeyUpdate.cs
index 908c822..b1e6d46 100644
--- a/Tornado3_2026Election/Services/KarismaCounterNumberKeyUpdate.cs
+++ b/Tornado3_2026Election/Services/KarismaCounterNumberKeyUpdate.cs
@@ -1,3 +1,8 @@
namespace Tornado3_2026Election.Services;
-public readonly record struct KarismaCounterNumberKeyUpdate(string ObjectName, int KeyIndex, double Number);
+public readonly record struct KarismaCounterNumberKeyUpdate(
+ string ObjectName,
+ int KeyIndex,
+ double Number,
+ bool AllowKeyZero = false,
+ bool AllowSetValue = false);
diff --git a/Tornado3_2026Election/Services/KarismaEventHandler.cs b/Tornado3_2026Election/Services/KarismaEventHandler.cs
index 543372b..db83955 100644
--- a/Tornado3_2026Election/Services/KarismaEventHandler.cs
+++ b/Tornado3_2026Election/Services/KarismaEventHandler.cs
@@ -13,10 +13,18 @@ public class KarismaEventHandler : KAEventHandler
private readonly Action? _onClose;
private readonly object _connectSync = new();
private readonly object _loadSceneSync = new();
+ private readonly object _endTransactionSync = new();
+ private readonly object _updateTexturesSync = new();
+ private readonly object _scenePrepareSync = new();
private readonly object _saveSceneImageSync = new();
+ private readonly object _saveMixedPreviewImageSync = new();
private TaskCompletionSource? _pendingConnect;
private readonly Dictionary> _pendingLoadScenes = new(StringComparer.OrdinalIgnoreCase);
+ private TaskCompletionSource? _pendingEndTransaction;
+ private readonly Dictionary> _pendingUpdateTextures = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary<(int OutputChannelIndex, int LayerNo), TaskCompletionSource> _pendingScenePrepares = new();
private TaskCompletionSource<(eKResult Result, string SceneName)>? _pendingSaveSceneImage;
+ private TaskCompletionSource<(eKResult Result, int OutputChannelIndex, int LayerNo)>? _pendingSaveMixedPreviewImage;
public KarismaEventHandler(LogService logService, Action? onConnect = null, Action? onClose = null)
{
@@ -149,6 +157,106 @@ public class KarismaEventHandler : KAEventHandler
}
}
+ public Task BeginEndTransactionWait()
+ {
+ lock (_endTransactionSync)
+ {
+ if (_pendingEndTransaction is not null)
+ {
+ throw new InvalidOperationException("Another EndTransaction request is already pending.");
+ }
+
+ _pendingEndTransaction = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ return _pendingEndTransaction.Task;
+ }
+ }
+
+ public void CancelPendingEndTransaction(Exception? error = null)
+ {
+ TaskCompletionSource? completion;
+ lock (_endTransactionSync)
+ {
+ completion = _pendingEndTransaction;
+ _pendingEndTransaction = null;
+ }
+
+ CompleteOrCancel(completion, error);
+ }
+
+ public Task BeginUpdateTexturesWait(string sceneName)
+ {
+ if (string.IsNullOrWhiteSpace(sceneName))
+ {
+ throw new ArgumentException("Scene name is required.", nameof(sceneName));
+ }
+
+ lock (_updateTexturesSync)
+ {
+ if (_pendingUpdateTextures.ContainsKey(sceneName))
+ {
+ throw new InvalidOperationException($"Another UpdateTextures request is already pending for '{sceneName}'.");
+ }
+
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _pendingUpdateTextures[sceneName] = completion;
+ return completion.Task;
+ }
+ }
+
+ public void CancelPendingUpdateTextures(string sceneName, Exception? error = null)
+ {
+ if (string.IsNullOrWhiteSpace(sceneName))
+ {
+ return;
+ }
+
+ TaskCompletionSource? completion;
+ lock (_updateTexturesSync)
+ {
+ if (!_pendingUpdateTextures.TryGetValue(sceneName, out completion))
+ {
+ return;
+ }
+
+ _pendingUpdateTextures.Remove(sceneName);
+ }
+
+ CompleteOrCancel(completion, error);
+ }
+
+ public Task BeginScenePrepareWait(int outputChannelIndex, int layerNo)
+ {
+ lock (_scenePrepareSync)
+ {
+ var key = (outputChannelIndex, layerNo);
+ if (_pendingScenePrepares.ContainsKey(key))
+ {
+ throw new InvalidOperationException($"Another scene Prepare request is already pending for output={outputChannelIndex} layer={layerNo}.");
+ }
+
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _pendingScenePrepares[key] = completion;
+ return completion.Task;
+ }
+ }
+
+ public void CancelPendingScenePrepare(int outputChannelIndex, int layerNo, Exception? error = null)
+ {
+ TaskCompletionSource? completion;
+ lock (_scenePrepareSync)
+ {
+ var key = (outputChannelIndex, layerNo);
+ if (!_pendingScenePrepares.TryGetValue(key, out completion))
+ {
+ return;
+ }
+
+ _pendingScenePrepares.Remove(key);
+ }
+
+ CompleteOrCancel(completion, error);
+ }
+
public Task<(eKResult Result, string SceneName)> BeginSaveSceneImageWait()
{
lock (_saveSceneImageSync)
@@ -186,6 +294,44 @@ public class KarismaEventHandler : KAEventHandler
completion.TrySetException(error);
}
+
+ public Task<(eKResult Result, int OutputChannelIndex, int LayerNo)> BeginSaveMixedPreviewImageWait()
+ {
+ lock (_saveMixedPreviewImageSync)
+ {
+ if (_pendingSaveMixedPreviewImage is not null)
+ {
+ throw new InvalidOperationException("Another SaveMixedPreviewImage request is already pending.");
+ }
+
+ _pendingSaveMixedPreviewImage = new TaskCompletionSource<(eKResult Result, int OutputChannelIndex, int LayerNo)>(TaskCreationOptions.RunContinuationsAsynchronously);
+ return _pendingSaveMixedPreviewImage.Task;
+ }
+ }
+
+ public void CancelPendingSaveMixedPreviewImage(Exception? error = null)
+ {
+ TaskCompletionSource<(eKResult Result, int OutputChannelIndex, int LayerNo)>? completion = null;
+
+ lock (_saveMixedPreviewImageSync)
+ {
+ completion = _pendingSaveMixedPreviewImage;
+ _pendingSaveMixedPreviewImage = null;
+ }
+
+ if (completion is null)
+ {
+ return;
+ }
+
+ if (error is null)
+ {
+ completion.TrySetCanceled();
+ return;
+ }
+
+ completion.TrySetException(error);
+ }
public void OnLoadScene(eKResult Result, string SceneName)
{
LogResult(nameof(OnLoadScene), Result, $"scene={SceneName}");
@@ -202,14 +348,34 @@ public class KarismaEventHandler : KAEventHandler
public void OnConnect(int ErrorCode) { if (ErrorCode == 0) { _logService.Info("CG callback OnConnect: success (errorCode=0)"); } else { _logService.Error($"CG callback OnConnect: failed (errorCode={ErrorCode})"); } CompletePendingConnect(ErrorCode); _onConnect?.Invoke(ErrorCode); }
public void OnClose(int ErrorCode) { if (ErrorCode == 0) { _logService.Info("CG callback OnClose: closed cleanly (errorCode=0)"); } else { _logService.Warning($"CG callback OnClose: closed with errorCode={ErrorCode}"); } _onClose?.Invoke(ErrorCode); }
public void OnBeginTransaction(eKResult Result) => LogResult(nameof(OnBeginTransaction), Result);
- public void OnEndTransaction(eKResult Result) => LogResult(nameof(OnEndTransaction), Result);
+ public void OnEndTransaction(eKResult Result)
+ {
+ LogResult(nameof(OnEndTransaction), Result);
+
+ TaskCompletionSource? completion;
+ lock (_endTransactionSync)
+ {
+ completion = _pendingEndTransaction;
+ _pendingEndTransaction = null;
+ }
+
+ completion?.TrySetResult(Result);
+ }
public void OnHeartBeat(eKResult Result) => LogResult(nameof(OnHeartBeat), Result);
virtual public void OnUnloadAll(eKResult Result) { }
virtual public void OnSetTrialPlayoutMode(eKResult Result) { }
virtual public void OnCheckVersion(eKResult Result, string ServerVersion, string SDKVersion) { }
virtual public void OnSetAudioOutput(eKResult Result) { }
- public void OnScenePrepare(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnScenePrepare), Result, $"output={OutputChannelIndex} layer={LayerNo}");
- public void OnScenePrepareEx(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnScenePrepareEx), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnScenePrepare(eKResult Result, int OutputChannelIndex, int LayerNo)
+ {
+ LogResult(nameof(OnScenePrepare), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ CompletePendingScenePrepare(OutputChannelIndex, LayerNo, Result);
+ }
+ public void OnScenePrepareEx(eKResult Result, int OutputChannelIndex, int LayerNo)
+ {
+ LogResult(nameof(OnScenePrepareEx), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ CompletePendingScenePrepare(OutputChannelIndex, LayerNo, Result);
+ }
public void OnPlay(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlay), Result, $"output={OutputChannelIndex} layer={LayerNo}");
public void OnPlayOut(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayOut), Result, $"output={OutputChannelIndex} layer={LayerNo}");
public void OnStop(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnStop), Result, $"output={OutputChannelIndex} layer={LayerNo}");
@@ -225,7 +391,19 @@ public class KarismaEventHandler : KAEventHandler
virtual public void OnSceneSaved(eKResult Result, string FileName) { }
public void OnTriggerObject(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnTriggerObject), Result, $"output={OutputChannelIndex} layer={LayerNo}");
virtual public void OnResumeBackground(eKResult Result, int OutputChannelIndex, int LayerNo) { }
- virtual public void OnSaveMixedPreviewImage(eKResult Result, int OutputChannelIndex, int LayerNo) { }
+ public void OnSaveMixedPreviewImage(eKResult Result, int OutputChannelIndex, int LayerNo)
+ {
+ LogResult(nameof(OnSaveMixedPreviewImage), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+
+ TaskCompletionSource<(eKResult Result, int OutputChannelIndex, int LayerNo)>? completion = null;
+ lock (_saveMixedPreviewImageSync)
+ {
+ completion = _pendingSaveMixedPreviewImage;
+ _pendingSaveMixedPreviewImage = null;
+ }
+
+ completion?.TrySetResult((Result, OutputChannelIndex, LayerNo));
+ }
public void OnPlayDirect(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayDirect), Result, $"output={OutputChannelIndex} layer={LayerNo}");
public void OnCutIn(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnCutIn), Result, $"output={OutputChannelIndex} layer={LayerNo}");
public void OnCutOut(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnCutOut), Result, $"output={OutputChannelIndex} layer={LayerNo}");
@@ -260,7 +438,23 @@ public class KarismaEventHandler : KAEventHandler
virtual public void OnSaveScene(eKResult Result, string SceneName) { }
virtual public void OnUnloadScene(eKResult Result, string SceneName) { }
virtual public void OnReloadScene(eKResult Result, string SceneName) { }
- virtual public void OnUpdateTextures(eKResult Result, string SceneName) { }
+ public void OnUpdateTextures(eKResult Result, string SceneName)
+ {
+ LogResult(nameof(OnUpdateTextures), Result, $"scene={SceneName}");
+
+ TaskCompletionSource? completion;
+ lock (_updateTexturesSync)
+ {
+ if (!_pendingUpdateTextures.TryGetValue(SceneName, out completion))
+ {
+ return;
+ }
+
+ _pendingUpdateTextures.Remove(SceneName);
+ }
+
+ completion.TrySetResult(Result);
+ }
virtual public void OnSetSceneAudioFile(eKResult Result, string SceneName) { }
virtual public void OnEnableSceneAudio(eKResult Result, string SceneName) { }
virtual public void OnSetSceneDuration(eKResult Result, string SceneName) { }
@@ -336,7 +530,7 @@ public class KarismaEventHandler : KAEventHandler
virtual public void OnModifyPathPoint(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnInitScrollObject(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetCounterInfo(eKResult Result, string SceneName, string ObjectName) { }
- virtual public void OnSetCounterNumber(eKResult Result, string SceneName, string ObjectName) { }
+ public void OnSetCounterNumber(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetCounterNumber), Result, $"scene={SceneName} object={ObjectName}");
virtual public void OnSetCounterRange(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetCounterRemainingTime(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetCounterElapsedTime(eKResult Result, string SceneName, string ObjectName) { }
@@ -451,6 +645,23 @@ public class KarismaEventHandler : KAEventHandler
completion.TrySetResult(result);
}
+ private void CompletePendingScenePrepare(int outputChannelIndex, int layerNo, eKResult result)
+ {
+ TaskCompletionSource? completion;
+ lock (_scenePrepareSync)
+ {
+ var key = (outputChannelIndex, layerNo);
+ if (!_pendingScenePrepares.TryGetValue(key, out completion))
+ {
+ return;
+ }
+
+ _pendingScenePrepares.Remove(key);
+ }
+
+ completion.TrySetResult(result);
+ }
+
private void CompletePendingConnect(int errorCode)
{
TaskCompletionSource? completion;
@@ -462,4 +673,20 @@ public class KarismaEventHandler : KAEventHandler
completion?.TrySetResult(errorCode);
}
+
+ private static void CompleteOrCancel(TaskCompletionSource? completion, Exception? error)
+ {
+ if (completion is null)
+ {
+ return;
+ }
+
+ if (error is null)
+ {
+ completion.TrySetCanceled();
+ return;
+ }
+
+ completion.TrySetException(error);
+ }
}
diff --git a/Tornado3_2026Election/Services/KarismaPositionUpdate.cs b/Tornado3_2026Election/Services/KarismaPositionUpdate.cs
index aabf92a..0c16c24 100644
--- a/Tornado3_2026Election/Services/KarismaPositionUpdate.cs
+++ b/Tornado3_2026Election/Services/KarismaPositionUpdate.cs
@@ -7,4 +7,5 @@ public readonly record struct KarismaPositionUpdate(
float X,
float Y,
float Z,
- eKVectorType VectorType);
+ eKVectorType VectorType,
+ int KeyIndex = -1);
diff --git a/Tornado3_2026Election/Services/KarismaSceneResolver.cs b/Tornado3_2026Election/Services/KarismaSceneResolver.cs
index 0bcf82e..b5cbdd9 100644
--- a/Tornado3_2026Election/Services/KarismaSceneResolver.cs
+++ b/Tornado3_2026Election/Services/KarismaSceneResolver.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using System.Linq;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
@@ -12,7 +13,7 @@ internal static class KarismaSceneResolver
bool useLoop,
bool useEnd = false)
{
- return ResolveScene(template, null, t3CutPath, useLoop, useEnd);
+ return ResolveScene(template, template.Cuts.FirstOrDefault(), t3CutPath, useLoop, useEnd);
}
public static KarismaResolvedScene ResolveScene(
@@ -25,17 +26,16 @@ internal static class KarismaSceneResolver
var sceneId = string.IsNullOrWhiteSpace(cut?.SceneIdOverride)
? template.Id
: cut.SceneIdOverride!;
- var hasSceneOverride = !string.IsNullOrWhiteSpace(cut?.SceneIdOverride);
var baseScenePath = Path.Combine(t3CutPath, sceneId + ".tscn");
- var loopScenePath = Path.Combine(t3CutPath, template.Id + "_loop.tscn");
- var endScenePath = Path.Combine(t3CutPath, template.Id + "_END.tscn");
+ var loopScenePath = Path.Combine(t3CutPath, sceneId + "_loop.tscn");
+ var endScenePath = Path.Combine(t3CutPath, sceneId + "_END.tscn");
string selectedPath;
if (useEnd && File.Exists(endScenePath))
{
selectedPath = endScenePath;
}
- else if (!hasSceneOverride && useLoop && File.Exists(loopScenePath))
+ else if (useLoop && File.Exists(loopScenePath))
{
selectedPath = loopScenePath;
}
@@ -43,7 +43,7 @@ internal static class KarismaSceneResolver
{
selectedPath = baseScenePath;
}
- else if (!hasSceneOverride && File.Exists(loopScenePath))
+ else if (File.Exists(loopScenePath))
{
selectedPath = loopScenePath;
}
@@ -59,7 +59,10 @@ internal static class KarismaSceneResolver
public static bool HasEndScene(FormatTemplateDefinition template, string t3CutPath)
{
- return File.Exists(Path.Combine(t3CutPath, template.Id + "_END.tscn"));
+ return template.Cuts
+ .Select(cut => string.IsNullOrWhiteSpace(cut.SceneIdOverride) ? template.Id : cut.SceneIdOverride!)
+ .Distinct(StringComparer.Ordinal)
+ .Any(sceneId => File.Exists(Path.Combine(t3CutPath, sceneId + "_END.tscn")));
}
}
diff --git a/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs b/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
index 5ffc074..598ab41 100644
--- a/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
+++ b/Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs
@@ -223,11 +223,6 @@ public sealed class KarismaSceneVariableCatalog
return KarismaSceneVariableKind.Counter;
}
- if (IsLikelyCounterVariableName(variableName))
- {
- return KarismaSceneVariableKind.Counter;
- }
-
if (variableName.StartsWith("\uC720\uD655\uB2F9", StringComparison.OrdinalIgnoreCase))
{
return KarismaSceneVariableKind.VideoResource;
@@ -246,6 +241,11 @@ public sealed class KarismaSceneVariableCatalog
return KarismaSceneVariableKind.Image;
}
+ if (IsLikelyCounterVariableName(variableName))
+ {
+ return KarismaSceneVariableKind.Counter;
+ }
+
return KarismaSceneVariableKind.Text;
}
diff --git a/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs b/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs
index 5df6366..8a34839 100644
--- a/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs
+++ b/Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs
@@ -37,6 +37,28 @@ public sealed class KarismaThumbnailGeneratorService
throw new DirectoryNotFoundException("유효한 T3_Cut 경로를 찾지 못했습니다.");
}
+ var (host, port) = ResolveConnectionSettings();
+ using var manager = new TornadoManager(host, port, _logService);
+ return await GenerateAsync(
+ manager,
+ templates,
+ t3CutPath,
+ videoWallLayoutPreset,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task GenerateAsync(
+ TornadoManager manager,
+ IReadOnlyList templates,
+ string t3CutPath,
+ VideoWallLayoutPreset videoWallLayoutPreset,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
+ {
+ throw new DirectoryNotFoundException("유효한 T3_Cut 경로를 찾지 못했습니다.");
+ }
+
var projectAssetRoot = CutThumbnailAssetCatalog.TryGetProjectAssetRoot();
if (string.IsNullOrWhiteSpace(projectAssetRoot))
{
@@ -45,11 +67,9 @@ public sealed class KarismaThumbnailGeneratorService
Directory.CreateDirectory(projectAssetRoot);
- var (host, port) = ResolveConnectionSettings();
var generatedCount = 0;
var failedCount = 0;
- using var manager = new TornadoManager(host, port, _logService);
await manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
foreach (var template in templates.OrderBy(template => template.Id, StringComparer.Ordinal))
@@ -69,7 +89,7 @@ public sealed class KarismaThumbnailGeneratorService
try
{
var resolvedScene = KarismaSceneResolver.ResolveScene(template, t3CutPath, useLoop: false);
- sceneAlias = resolvedScene.Alias;
+ sceneAlias = $"{resolvedScene.Alias}__thumbnail";
var targetDirectory = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetDirectory))
diff --git a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
index 226e8f4..9c8bc76 100644
--- a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
+++ b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -17,7 +18,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private const int DefaultCandidateSlotClearCount = 8;
private const int DefaultTurnoutSlotClearCount = 7;
private const string HistoricalTurnoutChartObjectName = "차트01";
- private const string HistoricalTurnoutCircleObjectPrefix = "투표율원";
+ private const string HistoricalTurnoutCircleObjectPrefix = "원";
private const int HistoricalTurnoutChartCellRowCount = 8;
private const float HistoricalTurnoutCounterBaseX = -0.37133408f;
private const float HistoricalTurnoutCounterBaseY = 24.838575f;
@@ -27,9 +28,30 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
// Scene-local Y scale: 100% stays at the template's top label position; lower rates move downward.
private const float HistoricalTurnoutCounterYUnitsPerPercent = 5.1f;
private const float HistoricalTurnoutCircleYUnitsPerPercent = 6.32f;
+ private const int NormalPreElectionTurnoutBarPositionKeyIndex = 1;
+ private const float NormalPreElectionTurnoutBarEmptyY = -547.2f;
+ private const float NormalPreElectionTurnoutBarFullY = -54.2f;
private const int DefaultCouncilSeatSlotCount = 6;
+ private static readonly float[] HistoricalWinnerSlotBaseX =
+ [
+ -753.63f,
+ -538.78f,
+ -323.92f,
+ -109.06f,
+ 105.80f,
+ 320.66f,
+ 535.51f,
+ 750.37f
+ ];
+ private const int DefaultTopPanseSlotCount = 3;
+ private const int DefaultNormalPanseMapSlotCount = 6;
private const string CouncilSeatCandidateCodePrefix = "SEAT:";
+ private const string PanseSummaryCandidateCodePrefix = "PANSE:";
+ private const string PanseDemocraticPartyLabel = "더불어민주당";
+ private const string PansePeoplePowerPartyLabel = "국민의힘";
+ private const string PanseOtherPartyLabel = "무·기타";
private static readonly string[] CandidatePhotoExtensions = [".png", ".jpg", ".jpeg", ".webp"];
+ private static readonly IReadOnlyDictionary EmptyCandidateRankMap = new Dictionary();
private static readonly string[] CandidateSlotVariablePrefixes =
[
"순위",
@@ -49,6 +71,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
"정당심볼",
"그룹"
];
+ private static readonly string[] TurnoutSlotVariablePrefixes =
+ [
+ "그룹",
+ "바",
+ "투표율",
+ "선거구명",
+ "시도명"
+ ];
private static readonly string[] CouncilSeatObjectSuffixes = ["A", "B", "C"];
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
@@ -73,6 +103,89 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
["경남"] = "경상남도",
["제주"] = "제주특별자치도"
};
+ private static readonly IReadOnlyDictionary NormalTurnoutProvinceSceneNames =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["부산"] = "투표율_02_부산",
+ ["대구"] = "투표율_03_대구",
+ ["광주"] = "투표율_05_전남광주",
+ ["전남"] = "투표율_05_전남광주",
+ ["대전"] = "투표율_06_대전",
+ ["세종"] = "투표율_07_세종",
+ ["울산"] = "투표율_08_울산",
+ ["강원"] = "투표율_10_강원",
+ ["충남"] = "투표율_12_충남",
+ ["전북"] = "투표율_14_전북",
+ ["경북"] = "투표율_15_경북",
+ ["경남"] = "투표율_16_경남"
+ };
+ private static readonly IReadOnlyDictionary NormalRankProvinceSceneNames =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["부산"] = "시도별_02_부산",
+ ["대구"] = "시도별_03_대구",
+ ["광주"] = "시도별_05_전남광주",
+ ["전남"] = "시도별_05_전남광주",
+ ["대전"] = "시도별_06_대전",
+ ["세종"] = "시도별_07_세종",
+ ["울산"] = "시도별_08_울산",
+ ["강원"] = "시도별_10_강원",
+ ["충남"] = "시도별_12_충남",
+ ["전북"] = "시도별_14_전북",
+ ["경북"] = "시도별_15_경북",
+ ["경남"] = "시도별_16_경남"
+ };
+ private static readonly IReadOnlyDictionary NormalLandmarkVideoFileNames =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["부산"] = "02_부산.mp4",
+ ["대구"] = "03_대구.mp4",
+ ["광주"] = "05_광주.mp4",
+ ["전남"] = "05_전남.mp4",
+ ["대전"] = "06_대전.mp4",
+ ["세종"] = "07_세종.mp4",
+ ["울산"] = "08_울산.mp4",
+ ["강원"] = "10_강원.mp4",
+ ["충남"] = "12_충남.mp4",
+ ["전북"] = "14_전북.mp4",
+ ["경북"] = "15_경북.mp4",
+ ["경남"] = "16_경남.mp4"
+ };
+ private static readonly IReadOnlyDictionary NormalLandmarkVideoSourceFileNames =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["광주"] = "05_전남광주.mp4",
+ ["전남"] = "05_전남광주.mp4"
+ };
+ private static readonly string[] NormalPanseMapRegions =
+ [
+ "서울",
+ "부산",
+ "대구",
+ "인천",
+ "광주",
+ "대전",
+ "울산",
+ "세종",
+ "경기",
+ "강원",
+ "충북",
+ "충남",
+ "전북",
+ "전남",
+ "경북",
+ "경남",
+ "제주"
+ ];
+ private static readonly NormalPansePartySlot[] NormalPanseMapPartySlots =
+ [
+ new("더불어민주당", "더불어민주당"),
+ new("국민의힘", "국민의힘"),
+ new("조국혁신당", "조국혁신당"),
+ new("개혁신당", "개혁신당"),
+ new("진보당", "진보당"),
+ new("무·기타", "무기타")
+ ];
private readonly TornadoManager _manager;
private readonly LogService _logService;
@@ -80,9 +193,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private readonly KarismaSceneVariableCatalog _sceneVariableCatalog;
private readonly CutDebugStateStore _cutDebugStateStore;
private readonly IReadOnlyDictionary _bindings;
+ private readonly Func? _channelLayerProvider;
private readonly string _connectionTarget;
private readonly Dictionary _pendingScenes = new();
private readonly Dictionary _pendingEndScenes = new();
+ private readonly Dictionary _pendingBindings = new();
+ private readonly Dictionary _activeBindings = new();
private readonly Dictionary _channelOnAir = new();
private TornadoConnectionState _state = TornadoConnectionState.Idle;
private bool _disposed;
@@ -94,7 +210,8 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
KarismaSceneVariableCatalog sceneVariableCatalog,
CutDebugStateStore cutDebugStateStore,
string connectionTarget,
- IReadOnlyDictionary bindings)
+ IReadOnlyDictionary bindings,
+ Func? channelLayerProvider)
{
_manager = manager;
_logService = logService;
@@ -103,6 +220,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
_cutDebugStateStore = cutDebugStateStore;
_connectionTarget = connectionTarget;
_bindings = bindings;
+ _channelLayerProvider = channelLayerProvider;
_manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty);
}
@@ -133,14 +251,23 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
public event EventHandler? ConnectionChanged;
- public static ITornado3Adapter CreateOrFallback(LogService logService, Func t3CutPathProvider, CutDebugStateStore cutDebugStateStore)
+ public static ITornado3Adapter CreateOrFallback(
+ LogService logService,
+ Func t3CutPathProvider,
+ CutDebugStateStore cutDebugStateStore,
+ Func? channelLayerProvider = null)
{
- return TryCreate(logService, t3CutPathProvider, cutDebugStateStore, out var adapter)
+ return TryCreate(logService, t3CutPathProvider, cutDebugStateStore, out var adapter, channelLayerProvider)
? adapter
: new MockTornado3Adapter(logService);
}
- public static bool TryCreate(LogService logService, Func t3CutPathProvider, CutDebugStateStore cutDebugStateStore, out ITornado3Adapter adapter)
+ public static bool TryCreate(
+ LogService logService,
+ Func t3CutPathProvider,
+ CutDebugStateStore cutDebugStateStore,
+ out ITornado3Adapter adapter,
+ Func? channelLayerProvider = null)
{
var host = Environment.GetEnvironmentVariable("TORNADO_KARISMA_HOST");
if (string.IsNullOrWhiteSpace(host))
@@ -173,7 +300,8 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
sceneVariableCatalog,
cutDebugStateStore,
$"{host}:{port}",
- BuildBindings());
+ BuildBindings(),
+ channelLayerProvider);
return true;
}
@@ -189,6 +317,20 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
cancellationToken).ConfigureAwait(false);
}
+ public Task GenerateThumbnailsAsync(
+ IReadOnlyList templates,
+ VideoWallLayoutPreset videoWallLayoutPreset,
+ CancellationToken cancellationToken)
+ {
+ var thumbnailGenerator = new KarismaThumbnailGeneratorService(_logService);
+ return thumbnailGenerator.GenerateAsync(
+ _manager,
+ templates,
+ ResolveT3CutPath(),
+ videoWallLayoutPreset,
+ cancellationToken);
+ }
+
public async Task ApplyCutAsync(
BroadcastChannel channel,
FormatTemplateDefinition template,
@@ -203,101 +345,35 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
{
var binding = ResolveBinding(channel);
var t3CutPath = ResolveT3CutPath();
- var resolvedScene = KarismaSceneResolver.ResolveScene(
+ var channelWasOnAir = IsChannelOnAir(channel);
+ var resolvedScene = ResolveSceneForSnapshot(
+ channel,
template,
cut,
+ snapshot,
t3CutPath,
- IsChannelOnAir(channel),
+ channelWasOnAir,
cut.UseEndScene);
- var sceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path);
- var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath, sceneVariables);
- var counterNumberKeys = BuildCounterNumberKeyUpdates(template, cut, snapshot, sceneVariables);
- var chartCellUpdates = BuildChartCellUpdates(template, snapshot, sceneVariables);
- var positionUpdates = BuildPositionUpdates(template, snapshot, sceneVariables);
- var styleColorUpdates = BuildStyleColorUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
- var candidateGroupVisibilityUpdates = BuildCandidateGroupVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
- 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 = cutDebug.IsEnabled
- ? _cutDebugStateStore.FindTemplate(channel, template.Id)
- : null;
- var filteredValues = FilterObjectValues(values, sceneVariables, cutDebug, templateDebug);
- var filteredCounterNumberKeys = FilterCounterNumberKeyUpdates(counterNumberKeys, cutDebug, templateDebug);
- var filteredStyleColorUpdates = FilterStyleColorUpdates(styleColorUpdates, cutDebug, templateDebug);
- var filteredCandidateGroupVisibilityUpdates = FilterVisibilityUpdatePair(candidateGroupVisibilityUpdates, 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 overriddenCandidateGroupVisibilityUpdates = ApplyVisibilityOverrides(filteredCandidateGroupVisibilityUpdates, sceneVariables, 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),
- candidateGroupVisibilityUpdates.HideBeforeValue.Count + candidateGroupVisibilityUpdates.ShowAfterValue.Count +
- judgementVisibilityUpdates.HideBeforeValue.Count + judgementVisibilityUpdates.ShowAfterValue.Count +
- historicalWinnerVisibilityUpdates.HideBeforeValue.Count + historicalWinnerVisibilityUpdates.ShowAfterValue.Count +
- careerPromiseVisibilityUpdates.HideBeforeValue.Count + careerPromiseVisibilityUpdates.ShowAfterValue.Count,
- overriddenCandidateGroupVisibilityUpdates.HideBeforeValue.Count + overriddenCandidateGroupVisibilityUpdates.ShowAfterValue.Count +
- overriddenJudgementVisibilityUpdates.HideBeforeValue.Count + overriddenJudgementVisibilityUpdates.ShowAfterValue.Count +
- overriddenHistoricalWinnerVisibilityUpdates.HideBeforeValue.Count + overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue.Count +
- overriddenCareerPromiseVisibilityUpdates.HideBeforeValue.Count + overriddenCareerPromiseVisibilityUpdates.ShowAfterValue.Count,
- overriddenCandidateGroupVisibilityUpdates.HideBeforeValue
- .Concat(overriddenCandidateGroupVisibilityUpdates.ShowAfterValue)
- .Concat(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(
+ var forceReloadScene = ShouldForceReloadScene(channel, template, resolvedScene);
+ if (forceReloadScene)
+ {
+ _logService.Info($"[{channel}] Force reloading turnout loop scene before value apply: {Path.GetFileName(resolvedScene.Path)}");
+ }
+
+ await LoadAndApplyCutSceneAsync(
+ channel,
+ template,
+ cut,
+ snapshot,
+ station,
+ t3CutPath,
+ resolvedScene,
resolvedScene.Alias,
- overriddenCandidateGroupVisibilityUpdates.HideBeforeValue
- .Concat(overriddenJudgementVisibilityUpdates.HideBeforeValue)
- .Concat(overriddenHistoricalWinnerVisibilityUpdates.HideBeforeValue)
- .Concat(overriddenCareerPromiseVisibilityUpdates.HideBeforeValue)
- .ToArray(),
- overriddenValues,
- overriddenCounterNumberKeys,
- chartCellUpdates,
- positionUpdates,
- overriddenStyleColorUpdates,
- overriddenCandidateGroupVisibilityUpdates.ShowAfterValue
- .Concat(overriddenJudgementVisibilityUpdates.ShowAfterValue)
- .Concat(overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue)
- .Concat(overriddenCareerPromiseVisibilityUpdates.ShowAfterValue)
- .ToArray(),
+ forceReloadScene,
+ logDiagnostics: true,
cancellationToken).ConfigureAwait(false);
_pendingScenes[channel] = resolvedScene.Alias;
@@ -308,6 +384,109 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
cancellationToken).ConfigureAwait(false);
}
+ public async Task TryCapturePendingCutPreviewAsync(
+ BroadcastChannel channel,
+ string fileName,
+ int width,
+ int height,
+ int frame,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var binding = _activeBindings.TryGetValue(channel, out var activeBinding)
+ ? activeBinding
+ : ResolveBinding(channel);
+ await _manager.SaveMixedPreviewImageAsync(
+ binding.OutputChannelIndex,
+ binding.LayerNo,
+ fileName,
+ width,
+ height,
+ cancellationToken)
+ .ConfigureAwait(false);
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logService.Warning($"[{channel}] 현재 컷 프리뷰 캡처 실패: {ex.Message}");
+ return false;
+ }
+ }
+
+ public async Task TryCaptureCutPreviewAsync(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ ElectionDataSnapshot snapshot,
+ BroadcastStationProfile station,
+ string imageRootPath,
+ string fileName,
+ int width,
+ int height,
+ int frame,
+ CancellationToken cancellationToken)
+ {
+ var previewAlias = string.Empty;
+ try
+ {
+ var t3CutPath = ResolveT3CutPath();
+ var resolvedScene = ResolveSceneForSnapshot(
+ channel,
+ template,
+ cut,
+ snapshot,
+ t3CutPath,
+ IsChannelOnAir(channel),
+ cut.UseEndScene);
+ previewAlias = BuildPreviewSceneAlias(channel, resolvedScene.Alias);
+
+ await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
+ await LoadAndApplyCutSceneAsync(
+ channel,
+ template,
+ cut,
+ snapshot,
+ station,
+ t3CutPath,
+ resolvedScene,
+ previewAlias,
+ forceReloadScene: true,
+ logDiagnostics: false,
+ cancellationToken).ConfigureAwait(false);
+ await _manager.SaveSceneImageAsync(previewAlias, fileName, width, height, frame, cancellationToken)
+ .ConfigureAwait(false);
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logService.Warning($"[{channel}] 컷 프리뷰 캡처 실패: {template.Name}/{cut.Name} / {ex.Message}");
+ return false;
+ }
+ finally
+ {
+ if (!string.IsNullOrWhiteSpace(previewAlias))
+ {
+ try
+ {
+ await _manager.UnloadSceneAsync(previewAlias, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logService.Warning($"[{channel}] 프리뷰 씬 언로드 실패: {previewAlias} / {ex.Message}");
+ }
+ }
+ }
+ }
+
public async Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteAsync(
@@ -321,6 +500,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
var binding = ResolveBinding(channel);
await _manager.PrepareAsync(binding.OutputChannelIndex, binding.LayerNo, sceneAlias, cancellationToken).ConfigureAwait(false);
+ _pendingBindings[channel] = binding;
State = TornadoConnectionState.Ready;
},
$"prepare {channel}",
@@ -332,10 +512,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
await ExecuteAsync(
async () =>
{
- var binding = ResolveBinding(channel);
+ var binding = _pendingBindings.TryGetValue(channel, out var pendingBinding)
+ ? pendingBinding
+ : ResolveBinding(channel);
await _manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, cancellationToken).ConfigureAwait(false);
var isEndScene = _pendingEndScenes.TryGetValue(channel, out var pendingEndScene) && pendingEndScene;
_channelOnAir[channel] = !isEndScene;
+ _activeBindings[channel] = binding;
+ _pendingBindings.Remove(channel);
State = TornadoConnectionState.OnAir;
},
$"take {channel}",
@@ -347,10 +531,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
await ExecuteAsync(
async () =>
{
- var binding = ResolveBinding(channel);
+ var binding = _activeBindings.TryGetValue(channel, out var activeBinding)
+ ? activeBinding
+ : ResolveBinding(channel);
await _manager.PlayOutAsync(binding.OutputChannelIndex, binding.LayerNo, cutOut: false, cancellationToken).ConfigureAwait(false);
_channelOnAir[channel] = false;
_pendingEndScenes[channel] = false;
+ _pendingBindings.Remove(channel);
+ _activeBindings.Remove(channel);
State = TornadoConnectionState.Idle;
},
$"out {channel}",
@@ -391,6 +579,588 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
_manager.Dispose();
}
+ private KarismaResolvedScene ResolveSceneForSnapshot(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ ElectionDataSnapshot snapshot,
+ string t3CutPath,
+ bool useLoop,
+ bool useEndScene)
+ {
+ var resolvedScene = KarismaSceneResolver.ResolveScene(
+ template,
+ cut,
+ t3CutPath,
+ useLoop,
+ useEndScene);
+ var unopposedWinnerScenePath = ResolveUnopposedWinnerScenePath(
+ template,
+ snapshot,
+ resolvedScene.Path);
+ if (!string.IsNullOrWhiteSpace(unopposedWinnerScenePath))
+ {
+ if (!string.Equals(resolvedScene.Path, unopposedWinnerScenePath, StringComparison.OrdinalIgnoreCase))
+ {
+ _logService.Info(
+ $"[{channel}] Unopposed winner scene selected: {Path.GetFileName(unopposedWinnerScenePath)} " +
+ $"alias={resolvedScene.Alias}");
+ }
+
+ return new KarismaResolvedScene(unopposedWinnerScenePath, resolvedScene.Alias);
+ }
+
+ var provinceScenePath = ResolveNormalProvinceScenePath(
+ template,
+ snapshot,
+ t3CutPath,
+ out var provinceSceneKind,
+ out var provinceSceneRegionName);
+ if (string.IsNullOrWhiteSpace(provinceScenePath))
+ {
+ provinceScenePath = ResolveNormalTurnoutVideoScenePath(
+ channel,
+ template,
+ snapshot,
+ resolvedScene.Path,
+ out provinceSceneKind,
+ out provinceSceneRegionName);
+ if (string.IsNullOrWhiteSpace(provinceScenePath))
+ {
+ return resolvedScene;
+ }
+ }
+
+ if (!string.Equals(resolvedScene.Path, provinceScenePath, StringComparison.OrdinalIgnoreCase))
+ {
+ _logService.Info(
+ $"[{channel}] Normal {provinceSceneKind} scene selected: {Path.GetFileName(provinceScenePath)} " +
+ $"region={provinceSceneRegionName} alias={resolvedScene.Alias}");
+ }
+
+ return new KarismaResolvedScene(provinceScenePath, resolvedScene.Alias);
+ }
+
+ private static string ResolveUnopposedWinnerScenePath(
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ string sourceScenePath)
+ {
+ if (!IsWinnerTemplate(template.Name) ||
+ !snapshot.Candidates.Any(ShouldHideVoteMetrics) ||
+ string.IsNullOrWhiteSpace(sourceScenePath) ||
+ !File.Exists(sourceScenePath))
+ {
+ return string.Empty;
+ }
+
+ var fileName = Path.GetFileName(sourceScenePath);
+ if (fileName.StartsWith("__generated_unopposed_", StringComparison.OrdinalIgnoreCase))
+ {
+ return sourceScenePath;
+ }
+
+ var directory = Path.GetDirectoryName(sourceScenePath);
+ if (string.IsNullOrWhiteSpace(directory))
+ {
+ return string.Empty;
+ }
+
+ var generatedScenePath = Path.Combine(directory, "__generated_unopposed_" + fileName);
+ return TryEnsureUnopposedWinnerSceneCopy(sourceScenePath, generatedScenePath)
+ ? generatedScenePath
+ : string.Empty;
+ }
+
+ private static bool TryEnsureUnopposedWinnerSceneCopy(
+ string sourceScenePath,
+ string generatedScenePath)
+ {
+ try
+ {
+ if (File.Exists(generatedScenePath) &&
+ File.GetLastWriteTimeUtc(generatedScenePath) >= File.GetLastWriteTimeUtc(sourceScenePath))
+ {
+ return true;
+ }
+
+ var bytes = File.ReadAllBytes(sourceScenePath);
+ var replacementCount = 0;
+ for (var index = 0; index + 1 < bytes.Length; index += 2)
+ {
+ if (bytes[index] != (byte)'%' || bytes[index + 1] != 0)
+ {
+ continue;
+ }
+
+ bytes[index] = (byte)' ';
+ replacementCount++;
+ }
+
+ if (replacementCount == 0)
+ {
+ return false;
+ }
+
+ File.WriteAllBytes(generatedScenePath, bytes);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static string ResolveNormalProvinceScenePath(
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ string t3CutPath,
+ out string sceneKind,
+ out string regionName)
+ {
+ regionName = string.Empty;
+ if (!TryResolveNormalProvinceSceneNames(template, out var sceneNames, out sceneKind))
+ {
+ return string.Empty;
+ }
+
+ regionName = ResolveNormalProvinceSceneRegionName(snapshot, sceneNames);
+ if (string.IsNullOrWhiteSpace(regionName) ||
+ !sceneNames.TryGetValue(regionName, out var sceneName))
+ {
+ return string.Empty;
+ }
+
+ var templatePath = Path.Combine(t3CutPath, template.Id);
+ var templateFolderPath = Path.GetDirectoryName(templatePath);
+ if (string.IsNullOrWhiteSpace(templateFolderPath))
+ {
+ return string.Empty;
+ }
+
+ var scenePath = Path.Combine(templateFolderPath, sceneName + ".tscn");
+ return File.Exists(scenePath)
+ ? scenePath
+ : string.Empty;
+ }
+
+ private string ResolveNormalTurnoutVideoScenePath(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ string sourceScenePath,
+ out string sceneKind,
+ out string regionName)
+ {
+ sceneKind = "turnout video";
+ regionName = string.Empty;
+ if (!string.Equals(template.Name, "투표율_영상", StringComparison.Ordinal))
+ {
+ return string.Empty;
+ }
+
+ regionName = ResolveNormalProvinceSceneRegionName(snapshot, NormalLandmarkVideoFileNames);
+ if (string.IsNullOrWhiteSpace(regionName) ||
+ !NormalLandmarkVideoFileNames.TryGetValue(regionName, out var landmarkFileName))
+ {
+ return string.Empty;
+ }
+
+ const string sourceRelativePath = @"Video\MP4\랜드마크\02_부산.mp4";
+ var targetRelativePath = Path.Combine("Video", "MP4", "랜드마크", landmarkFileName);
+ if (string.Equals(sourceRelativePath, targetRelativePath, StringComparison.Ordinal))
+ {
+ return sourceScenePath;
+ }
+
+ var templateFolderPath = Path.GetDirectoryName(sourceScenePath);
+ if (string.IsNullOrWhiteSpace(templateFolderPath) ||
+ !TryEnsureLandmarkVideoFile(templateFolderPath, regionName, landmarkFileName))
+ {
+ return string.Empty;
+ }
+
+ var generatedScenePath = Path.Combine(
+ templateFolderPath,
+ $"__generated_{template.Name}_{Path.GetFileNameWithoutExtension(landmarkFileName)}.tscn");
+ if (TryEnsureSameLengthScenePathPatch(sourceScenePath, generatedScenePath, sourceRelativePath, targetRelativePath))
+ {
+ return generatedScenePath;
+ }
+
+ _logService.Warning(
+ $"[{channel}] 투표율_영상 지역별 배경을 적용하지 못했습니다. region={regionName} source={sourceRelativePath} target={targetRelativePath}");
+ return string.Empty;
+ }
+
+ private static bool TryEnsureLandmarkVideoFile(
+ string templateFolderPath,
+ string regionName,
+ string landmarkFileName)
+ {
+ var landmarkFolderPath = Path.Combine(templateFolderPath, "Video", "MP4", "랜드마크");
+ var targetPath = Path.Combine(landmarkFolderPath, landmarkFileName);
+ if (File.Exists(targetPath))
+ {
+ return true;
+ }
+
+ if (!NormalLandmarkVideoSourceFileNames.TryGetValue(regionName, out var sourceFileName))
+ {
+ return false;
+ }
+
+ var sourcePath = Path.Combine(landmarkFolderPath, sourceFileName);
+ if (!File.Exists(sourcePath))
+ {
+ return false;
+ }
+
+ File.Copy(sourcePath, targetPath, overwrite: false);
+ return true;
+ }
+
+ private static bool TryEnsureSameLengthScenePathPatch(
+ string sourceScenePath,
+ string generatedScenePath,
+ string sourceRelativePath,
+ string targetRelativePath)
+ {
+ if (sourceRelativePath.Length != targetRelativePath.Length)
+ {
+ return false;
+ }
+
+ var sourceBytes = Encoding.Unicode.GetBytes(sourceRelativePath);
+ var targetBytes = Encoding.Unicode.GetBytes(targetRelativePath);
+
+ if (File.Exists(generatedScenePath) &&
+ File.GetLastWriteTimeUtc(generatedScenePath) >= File.GetLastWriteTimeUtc(sourceScenePath))
+ {
+ var generatedBytes = File.ReadAllBytes(generatedScenePath);
+ if (IndexOfBytes(generatedBytes, targetBytes, 0) >= 0 &&
+ IndexOfBytes(generatedBytes, sourceBytes, 0) < 0)
+ {
+ return true;
+ }
+ }
+
+ var sceneBytes = File.ReadAllBytes(sourceScenePath);
+ var replacementCount = 0;
+ var matchIndex = IndexOfBytes(sceneBytes, sourceBytes, 0);
+ while (matchIndex >= 0)
+ {
+ Array.Copy(targetBytes, 0, sceneBytes, matchIndex, targetBytes.Length);
+ replacementCount++;
+ matchIndex = IndexOfBytes(sceneBytes, sourceBytes, matchIndex + targetBytes.Length);
+ }
+
+ if (replacementCount == 0)
+ {
+ return false;
+ }
+
+ File.WriteAllBytes(generatedScenePath, sceneBytes);
+ return true;
+ }
+
+ private static int IndexOfBytes(byte[] source, byte[] pattern, int startIndex)
+ {
+ for (var index = Math.Max(0, startIndex); index <= source.Length - pattern.Length; index++)
+ {
+ var matched = true;
+ for (var patternIndex = 0; patternIndex < pattern.Length; patternIndex++)
+ {
+ if (source[index + patternIndex] == pattern[patternIndex])
+ {
+ continue;
+ }
+
+ matched = false;
+ break;
+ }
+
+ if (matched)
+ {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ private static bool TryResolveNormalProvinceSceneNames(
+ FormatTemplateDefinition template,
+ out IReadOnlyDictionary sceneNames,
+ out string sceneKind)
+ {
+ if (string.Equals(template.Name, "투표율_시도별", StringComparison.Ordinal))
+ {
+ sceneNames = NormalTurnoutProvinceSceneNames;
+ sceneKind = "turnout";
+ return true;
+ }
+
+ if (string.Equals(template.Name, "1-2위_광역단체장_시도별영상", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "1-2위_기초단체장_시도별영상", StringComparison.Ordinal))
+ {
+ sceneNames = NormalRankProvinceSceneNames;
+ sceneKind = "rank";
+ return true;
+ }
+
+ sceneNames = new Dictionary(StringComparer.Ordinal);
+ sceneKind = string.Empty;
+ return false;
+ }
+
+ private static string ResolveNormalProvinceSceneRegionName(
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneNames)
+ {
+ var regionCandidates = new[]
+ {
+ snapshot.RegionName,
+ snapshot.ElectionDistrictName,
+ snapshot.DistrictName
+ };
+
+ foreach (var value in regionCandidates)
+ {
+ var directRegionName = ResolveRegionNameFromText(value);
+ if (sceneNames.ContainsKey(directRegionName))
+ {
+ return directRegionName;
+ }
+ }
+
+ foreach (var value in regionCandidates)
+ {
+ var normalizedRegionName = NormalizeRegionName(value);
+ if (sceneNames.ContainsKey(normalizedRegionName))
+ {
+ return normalizedRegionName;
+ }
+
+ foreach (var regionName in sceneNames.Keys.OrderByDescending(name => name.Length))
+ {
+ if (normalizedRegionName.StartsWith(regionName, StringComparison.Ordinal))
+ {
+ return regionName;
+ }
+ }
+ }
+
+ return string.Empty;
+ }
+
+ private static string ResolveRegionNameFromText(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ var text = value.Replace(" ", string.Empty, StringComparison.Ordinal);
+ foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
+ {
+ var fullRegionName = pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal);
+ if (text.Contains(fullRegionName, StringComparison.Ordinal) ||
+ text.StartsWith(pair.Key, StringComparison.Ordinal))
+ {
+ return pair.Key;
+ }
+ }
+
+ return string.Empty;
+ }
+
+ private async Task LoadAndApplyCutSceneAsync(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ ElectionDataSnapshot snapshot,
+ BroadcastStationProfile station,
+ string t3CutPath,
+ KarismaResolvedScene resolvedScene,
+ string sceneAlias,
+ bool forceReloadScene,
+ bool logDiagnostics,
+ CancellationToken cancellationToken)
+ {
+ var payload = BuildSceneUpdatePayload(
+ channel,
+ template,
+ cut,
+ snapshot,
+ station,
+ t3CutPath,
+ resolvedScene,
+ logDiagnostics);
+
+ await _manager.LoadSceneAsync(resolvedScene.Path, sceneAlias, forceReloadScene, cancellationToken)
+ .ConfigureAwait(false);
+ await _manager.ApplyValuesAsync(
+ sceneAlias,
+ payload.VisibilityUpdatesBeforeValue,
+ payload.Values,
+ payload.CounterNumberKeys,
+ payload.ChartCellUpdates,
+ payload.PositionUpdates,
+ payload.StyleColorUpdates,
+ payload.VisibilityUpdatesAfterValue,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private SceneUpdatePayload BuildSceneUpdatePayload(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ ElectionDataSnapshot snapshot,
+ BroadcastStationProfile station,
+ string t3CutPath,
+ KarismaResolvedScene resolvedScene,
+ bool logDiagnostics)
+ {
+ var sceneVariables = ResolveSceneVariablesForPayload(template, t3CutPath, resolvedScene);
+ var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath, sceneVariables);
+ var counterNumberKeys = BuildCounterNumberKeyUpdates(template, cut, snapshot, sceneVariables);
+ var chartCellUpdates = BuildChartCellUpdates(template, snapshot, sceneVariables);
+ var positionUpdates = BuildPositionUpdates(template, snapshot, sceneVariables);
+ var styleColorUpdates = BuildStyleColorUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
+ var candidateGroupVisibilityUpdates = BuildCandidateGroupVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
+ var turnoutGroupVisibilityUpdates = BuildTurnoutGroupVisibilityUpdates(template, snapshot, sceneVariables);
+ 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 = cutDebug.IsEnabled
+ ? _cutDebugStateStore.FindTemplate(channel, template.Id)
+ : null;
+ var filteredValues = FilterObjectValues(values, sceneVariables, cutDebug, templateDebug);
+ var filteredCounterNumberKeys = FilterCounterNumberKeyUpdates(counterNumberKeys, cutDebug, templateDebug);
+ var filteredStyleColorUpdates = FilterStyleColorUpdates(styleColorUpdates, cutDebug, templateDebug);
+ var filteredCandidateGroupVisibilityUpdates = FilterVisibilityUpdatePair(candidateGroupVisibilityUpdates, cutDebug, templateDebug);
+ var filteredTurnoutGroupVisibilityUpdates = FilterVisibilityUpdatePair(turnoutGroupVisibilityUpdates, 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 overriddenCandidateGroupVisibilityUpdates = ApplyVisibilityOverrides(filteredCandidateGroupVisibilityUpdates, sceneVariables, templateDebug);
+ var overriddenTurnoutGroupVisibilityUpdates = ApplyVisibilityOverrides(filteredTurnoutGroupVisibilityUpdates, sceneVariables, templateDebug);
+ var overriddenJudgementVisibilityUpdates = ApplyVisibilityOverrides(filteredJudgementVisibilityUpdates, sceneVariables, templateDebug);
+ var overriddenHistoricalWinnerVisibilityUpdates = ApplyVisibilityOverrides(filteredHistoricalWinnerVisibilityUpdates, sceneVariables, templateDebug);
+ var overriddenCareerPromiseVisibilityUpdates = ApplyVisibilityOverrides(filteredCareerPromiseVisibilityUpdates, sceneVariables, templateDebug);
+ var sceneFileValues = FilterValuesBySceneFile(overriddenValues, resolvedScene.Path);
+ var sendCounterNumberKeys = UseFinalVoteRateCounterValuesOnly(
+ FilterCounterNumberKeyUpdatesBySceneFile(overriddenCounterNumberKeys, resolvedScene.Path));
+ var sendValues = RemoveEmptyResourceValues(sceneFileValues, sceneVariables);
+ var sendChartCellUpdates = FilterChartCellUpdatesBySceneFile(chartCellUpdates, resolvedScene.Path);
+ var sendPositionUpdates = FilterPositionUpdatesBySceneFile(positionUpdates, resolvedScene.Path);
+ var sendStyleColorUpdates = FilterStyleColorUpdatesBySceneFile(overriddenStyleColorUpdates, resolvedScene.Path);
+ var overriddenVisibilityUpdatesBeforeValue = overriddenCandidateGroupVisibilityUpdates.HideBeforeValue
+ .Concat(overriddenTurnoutGroupVisibilityUpdates.HideBeforeValue)
+ .Concat(overriddenJudgementVisibilityUpdates.HideBeforeValue)
+ .Concat(overriddenHistoricalWinnerVisibilityUpdates.HideBeforeValue)
+ .Concat(overriddenCareerPromiseVisibilityUpdates.HideBeforeValue)
+ .ToArray();
+ var overriddenVisibilityUpdatesAfterValue = overriddenCandidateGroupVisibilityUpdates.ShowAfterValue
+ .Concat(overriddenTurnoutGroupVisibilityUpdates.ShowAfterValue)
+ .Concat(overriddenJudgementVisibilityUpdates.ShowAfterValue)
+ .Concat(overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue)
+ .Concat(overriddenCareerPromiseVisibilityUpdates.ShowAfterValue)
+ .ToArray();
+ var sendVisibilityUpdatesBeforeValue = FilterVisibilityUpdatesBySceneFile(overriddenVisibilityUpdatesBeforeValue, resolvedScene.Path);
+ var sendVisibilityUpdatesAfterValue = FilterVisibilityUpdatesBySceneFile(overriddenVisibilityUpdatesAfterValue, resolvedScene.Path);
+
+ if (logDiagnostics)
+ {
+ LogMissingExpectedSceneVariables(channel, template, sceneVariables);
+ LogUnsupportedSceneVariables(channel, template, sceneVariables);
+ LogTurnoutValuePayload(
+ channel,
+ template,
+ resolvedScene,
+ snapshot,
+ sceneVariables,
+ sendValues,
+ sendCounterNumberKeys);
+ LogCutDebugSummary(
+ channel,
+ template,
+ cut,
+ cutDebug,
+ values.Count,
+ sendValues.Count,
+ sendValues.Keys,
+ counterNumberKeys.Count,
+ sendCounterNumberKeys.Count,
+ sendCounterNumberKeys.Select(update => update.ObjectName),
+ styleColorUpdates.Count,
+ sendStyleColorUpdates.Count,
+ sendStyleColorUpdates.Select(update => update.ObjectName),
+ candidateGroupVisibilityUpdates.HideBeforeValue.Count + candidateGroupVisibilityUpdates.ShowAfterValue.Count +
+ turnoutGroupVisibilityUpdates.HideBeforeValue.Count + turnoutGroupVisibilityUpdates.ShowAfterValue.Count +
+ judgementVisibilityUpdates.HideBeforeValue.Count + judgementVisibilityUpdates.ShowAfterValue.Count +
+ historicalWinnerVisibilityUpdates.HideBeforeValue.Count + historicalWinnerVisibilityUpdates.ShowAfterValue.Count +
+ careerPromiseVisibilityUpdates.HideBeforeValue.Count + careerPromiseVisibilityUpdates.ShowAfterValue.Count,
+ sendVisibilityUpdatesBeforeValue.Count + sendVisibilityUpdatesAfterValue.Count,
+ sendVisibilityUpdatesBeforeValue
+ .Concat(sendVisibilityUpdatesAfterValue)
+ .Select(update => update.ObjectName));
+ LogCutDebugOverrides(
+ channel,
+ template,
+ cutDebug,
+ filteredValues,
+ overriddenValues,
+ filteredCounterNumberKeys,
+ sendCounterNumberKeys);
+ }
+
+ return new SceneUpdatePayload(
+ sendValues,
+ sendCounterNumberKeys,
+ sendChartCellUpdates,
+ sendPositionUpdates,
+ sendStyleColorUpdates,
+ sendVisibilityUpdatesBeforeValue,
+ sendVisibilityUpdatesAfterValue);
+ }
+
+ private IReadOnlyDictionary ResolveSceneVariablesForPayload(
+ FormatTemplateDefinition template,
+ string t3CutPath,
+ KarismaResolvedScene resolvedScene)
+ {
+ var sceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path);
+ if (sceneVariables.Count > 0 || !IsNormalProvinceOverrideScenePath(resolvedScene.Path))
+ {
+ return sceneVariables;
+ }
+
+ var templateScenePath = Path.Combine(t3CutPath, template.Id + ".tscn");
+ var fallbackSceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, templateScenePath);
+ return fallbackSceneVariables.Count > 0
+ ? fallbackSceneVariables
+ : sceneVariables;
+ }
+
+ private static bool IsNormalProvinceOverrideScenePath(string scenePath)
+ {
+ var sceneName = Path.GetFileNameWithoutExtension(scenePath);
+ return sceneName.StartsWith("__generated_", StringComparison.Ordinal) ||
+ NormalTurnoutProvinceSceneNames.Values.Contains(sceneName, StringComparer.Ordinal) ||
+ NormalRankProvinceSceneNames.Values.Contains(sceneName, StringComparer.Ordinal);
+ }
+
+ private static string BuildPreviewSceneAlias(BroadcastChannel channel, string sceneAlias)
+ {
+ return $"{sceneAlias}__preview_{channel}";
+ }
+
private async Task ExecuteAsync(Func action, string actionName, CancellationToken cancellationToken)
{
try
@@ -411,9 +1181,29 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private KarismaChannelBinding ResolveBinding(BroadcastChannel channel)
{
- return _bindings.TryGetValue(channel, out var binding)
- ? binding
- : throw new InvalidOperationException($"No Karisma binding configured for channel: {channel}");
+ if (!_bindings.TryGetValue(channel, out var binding))
+ {
+ throw new InvalidOperationException($"No Karisma binding configured for channel: {channel}");
+ }
+
+ if (_channelLayerProvider is null)
+ {
+ return binding;
+ }
+
+ var layerNo = Math.Clamp(_channelLayerProvider(channel), 0, 99);
+ return binding with { LayerNo = layerNo };
+ }
+
+ private static bool ShouldForceReloadScene(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ KarismaResolvedScene resolvedScene)
+ {
+ return channel == BroadcastChannel.TopLeft &&
+ IsTurnoutTemplate(template.Name) &&
+ Path.GetFileNameWithoutExtension(resolvedScene.Path)
+ .EndsWith("_loop", StringComparison.OrdinalIgnoreCase);
}
private string ResolveT3CutPath()
@@ -469,8 +1259,10 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
IReadOnlyDictionary sceneVariables)
{
var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
- var countedRateDisplay = FormatCountedRateLabel(CalculateCountedRate(snapshot));
- var referenceTimeDisplay = FormatClock(snapshot.ReceivedAt);
+ var countedRateDisplay = ShouldUseCountingCompleteLabel(template, snapshot)
+ ? "개표완료"
+ : FormatCountedRateLabel(CalculateCountedRate(snapshot));
+ var referenceTimeDisplay = ResolveReferenceTimeDisplay(snapshot);
var totalExpectedVotesDisplay = FormatCount(snapshot.TotalExpectedVotes);
var turnoutVotesDisplay = FormatCount(snapshot.TurnoutVotes);
var turnoutRateDisplay = FormatRate(snapshot.TurnoutRate);
@@ -485,7 +1277,9 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
!isTurnoutTemplate &&
string.Equals(snapshot.ElectionType, "기초단체장", StringComparison.Ordinal);
var shouldUseTurnoutDistrictLabel = isTurnoutTemplate &&
- ShouldUseTurnoutDistrictLabel(snapshot, regionName, electionDistrictName);
+ !IsBottomTurnoutSidoTemplate(template) &&
+ (IsBottomTurnoutDistrictTemplate(template) ||
+ ShouldUseTurnoutDistrictLabel(snapshot, regionName, electionDistrictName));
var turnoutDistrictDisplay =
shouldUseTurnoutDistrictLabel
? ResolveTurnoutDistrictNameLabel(snapshot, regionName, electionDistrictName)
@@ -537,14 +1331,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
};
var candidateSlotCount = ResolveBroadcastCandidateSlotCount(template, cut, snapshot, sceneVariables);
- if (candidateSlotCount > 0)
+ if (candidateSlotCount > 0 && !IsTopPanseTemplate(template) && !IsNormalPanseMapTemplate(template))
{
- ClearCandidateSlotValues(values, candidateSlotCount);
+ ClearCandidateSlotValues(values, candidateSlotCount, template, sceneVariables);
}
if (isTurnoutTemplate)
{
- ClearTurnoutSlotValues(values, DefaultTurnoutSlotClearCount);
+ ClearTurnoutSlotValues(values, DefaultTurnoutSlotClearCount, sceneVariables);
}
ClearLeaderValues(values);
@@ -565,10 +1359,35 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
if (snapshot.TurnoutBoardSlots.Count > 0)
{
- ApplyTurnoutBoardValues(values, snapshot, t3CutPath, templateFolderPath);
+ ApplyTurnoutBoardValues(values, template, snapshot, t3CutPath, templateFolderPath);
+ }
+
+ if (IsTurnoutPhotoTemplate(template.Name))
+ {
+ ApplyTurnoutPhotoValues(values, t3CutPath, templateFolderPath);
+ }
+
+ if (IsTopPanseTemplate(template))
+ {
+ ApplyTopPanseSummaryValues(values, template, snapshot, sceneVariables);
+ return FilterValuesForScene(values, sceneVariables, template);
+ }
+
+ if (IsNormalPanseMapTemplate(template))
+ {
+ ApplyNormalPanseMapValues(values, snapshot, sceneVariables);
+ return FilterValuesForScene(values, sceneVariables, template);
+ }
+
+ if (ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(template.Name))
+ {
+ return FilterValuesForScene(values, sceneVariables, template);
}
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
+ var candidateVoteRanks = IsAllCandidateTemplate(template.Name)
+ ? BuildCandidateVoteRankMap(snapshot.Candidates)
+ : EmptyCandidateRankMap;
var candidateImagePaths = new Dictionary();
var candidatePhotoFallbackPool = CreateCandidatePhotoFallbackPool(t3CutPath, templateFolderPath);
@@ -577,15 +1396,16 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
var candidate = orderedCandidates[index];
var slot = index + 1;
var ballotNumberDisplay = ResolveBallotNumberDisplay(candidate);
- var voteCountDisplay = FormatCount(candidate.VoteCount);
- var voteRateDisplay = FormatRate(candidate.VoteRate);
+ var hideVoteMetrics = ShouldHideVoteMetrics(candidate);
+ var voteCountDisplay = hideVoteMetrics ? string.Empty : FormatCount(candidate.VoteCount);
+ var voteRateDisplay = hideVoteMetrics ? string.Empty : FormatRate(candidate.VoteRate);
var voteGapDisplay = FormatCount(CalculateVoteGap(orderedCandidates, index));
var candidateElectionDistrictDisplay = ResolveCandidateElectionDistrictDisplay(candidate, electionDistrictDisplay);
var candidateRegionLabelDisplay = ResolveCandidateRegionLabelDisplay(candidate, regionLabelDisplay);
var candidateCountedRateDisplay = candidate.BroadcastCountedRate.HasValue
? FormatRate(candidate.BroadcastCountedRate.Value)
: countedRateDisplay;
- var rank = cut.CandidateStartIndex + slot;
+ var rank = ResolveCandidateDisplayRank(template, cut, slot, candidate, candidateVoteRanks);
var rankDisplay = rank.ToString(CultureInfo.InvariantCulture);
var rankImagePath = ResolveRankAssetPath(t3CutPath, templateFolderPath, rank);
var judgementPath = ResolveJudgementAssetPath(t3CutPath, templateFolderPath, candidate.EffectiveJudgement);
@@ -595,29 +1415,39 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
candidate,
candidatePhotoFallbackPool,
candidateImagePaths);
- var partyBarPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.EffectiveColorParty, PartyAssetKind.Bar);
- var partyPlatePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.EffectiveColorParty, PartyAssetKind.Plate);
- var partyOutlinePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.EffectiveColorParty, PartyAssetKind.Outline);
- var partyColorPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.EffectiveColorParty, PartyAssetKind.Color);
- var partySymbolPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.EffectiveColorParty, PartyAssetKind.Symbol);
- var groupPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.EffectiveColorParty, PartyAssetKind.Group);
+ var candidateAssetColorParty = ResolveCandidateAssetColorParty(template, snapshot, candidate);
+ var partyBarPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Bar);
+ var partyPlatePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Plate);
+ var partyOutlinePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Outline);
+ var partyColorPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Color);
+ var partySymbolPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Symbol);
+ var groupPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidateAssetColorParty, PartyAssetKind.Group);
+ var graphColorPath = ResolvePartyGraphColorAssetPath(templateFolderPath, template.Name, candidateAssetColorParty);
values[$"Candidate{slot}Code"] = candidate.CandidateCode;
values[$"Candidate{slot}Name"] = candidate.Name;
values[$"Candidate{slot}Party"] = candidate.Party;
- values[$"Candidate{slot}VoteCount"] = candidate.VoteCount.ToString(CultureInfo.InvariantCulture);
+ values[$"Candidate{slot}VoteCount"] = hideVoteMetrics ? string.Empty : candidate.VoteCount.ToString(CultureInfo.InvariantCulture);
values[$"Candidate{slot}VoteCountDisplay"] = voteCountDisplay;
values[$"Candidate{slot}VoteRate"] = voteRateDisplay;
values[$"Candidate{slot}Judgement"] = candidate.EffectiveJudgementLabel;
values[$"Candidate{slot}ImagePath"] = candidateImagePath;
- SetRankAliases(values, sceneVariables, rankDisplay, rankImagePath, $"순위{slot:00}", $"순위{slot}");
+ SetRankAliases(values, sceneVariables, rankDisplay, rankImagePath, GetRankAliases(template, slot));
SetAliases(values, ballotNumberDisplay, $"기호{slot:00}", $"기호{slot}");
- SetAliases(values, "기호", $"기호텍스트{slot:00}", $"기호텍스트{slot}");
+ if (IsBottomWinnerTemplate(template))
+ {
+ SetBottomWinnerBallotNumberAliases(values, slot, ballotNumberDisplay);
+ }
+
+ SetAliases(values, ResolveBallotLabelDisplay(template, ballotNumberDisplay), $"기호텍스트{slot:00}", $"기호텍스트{slot}");
SetAliases(values, candidate.Name, $"후보명{slot:00}", $"후보명{slot}");
SetAliases(values, candidate.Party, $"정당명{slot:00}", $"정당명{slot}");
SetAliases(values, voteCountDisplay, $"득표수{slot:00}", $"득표수{slot}");
- SetAliases(values, voteRateDisplay, $"득표율{slot:00}", $"득표율{slot}");
+ var voteRateTagValue = ShouldUseNormalAllCandidateGovernorVoteRateSetValueZero(template, slot)
+ ? "0"
+ : voteRateDisplay;
+ SetAliases(values, voteRateTagValue, $"득표율{slot:00}", $"득표율{slot}");
SetAliases(values, voteGapDisplay, $"표차{slot:00}", $"표차{slot}", $"득표차{slot:00}", $"득표차{slot}");
SetAliases(
values,
@@ -632,23 +1462,25 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
SetAliases(values, candidateCountedRateDisplay, $"개표율{slot:00}", $"개표율{slot}");
SetOptionalAliases(values, judgementPath, GetJudgementAliases(slot));
SetOptionalAliases(values, candidateImagePath, $"후보사진{slot:00}", $"후보사진{slot}");
- SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template.Name, "득표수바", partyBarPath, $"득표수바{slot:00}", $"득표수바{slot}");
- SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template.Name, "정당바", partyBarPath, $"정당바{slot:00}", $"정당바{slot}");
- SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template.Name, "정당판", partyPlatePath, $"정당판{slot:00}", $"정당판{slot}");
- SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template.Name, "정당원", partyOutlinePath, $"정당원{slot:00}", $"정당원{slot}");
- SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template.Name, "정당색", partyColorPath, $"정당색{slot:00}", $"정당색{slot}");
+ SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template, "득표수바", partyBarPath, $"득표수바{slot:00}", $"득표수바{slot}");
+ SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template, "정당바", partyBarPath, $"정당바{slot:00}", $"정당바{slot}");
+ SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template, "정당판", partyPlatePath, $"정당판{slot:00}", $"정당판{slot}");
+ SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template, "정당원", partyOutlinePath, $"정당원{slot:00}", $"정당원{slot}");
+ SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template, "정당색", partyColorPath, $"정당색{slot:00}", $"정당색{slot}");
SetOptionalAliases(values, partySymbolPath, $"정당심볼{slot:00}", $"정당심볼{slot}");
SetOptionalAliases(values, groupPath, $"그룹{slot:00}", $"그룹{slot}");
+ SetOptionalAliases(values, graphColorPath, $"그래프{slot:00}", $"그래프{slot}");
}
if (orderedCandidates.FirstOrDefault() is { } leader)
{
+ var hideLeaderVoteMetrics = ShouldHideVoteMetrics(leader);
values["LeaderCode"] = leader.CandidateCode;
values["LeaderName"] = leader.Name;
values["LeaderParty"] = leader.Party;
- values["LeaderVoteCount"] = leader.VoteCount.ToString(CultureInfo.InvariantCulture);
- values["LeaderVoteCountDisplay"] = FormatCount(leader.VoteCount);
- values["LeaderVoteRate"] = FormatRate(leader.VoteRate);
+ values["LeaderVoteCount"] = hideLeaderVoteMetrics ? string.Empty : leader.VoteCount.ToString(CultureInfo.InvariantCulture);
+ values["LeaderVoteCountDisplay"] = hideLeaderVoteMetrics ? string.Empty : FormatCount(leader.VoteCount);
+ values["LeaderVoteRate"] = hideLeaderVoteMetrics ? string.Empty : FormatRate(leader.VoteRate);
values["LeaderJudgement"] = leader.EffectiveJudgementLabel;
values["LeaderImagePath"] = ResolveCandidateImagePath(
t3CutPath,
@@ -665,7 +1497,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
ApplyCouncilSeatTableValues(values, template, snapshot, t3CutPath, templateFolderPath, sceneVariables);
- return FilterValuesForScene(values, sceneVariables);
+ return FilterValuesForScene(values, sceneVariables, template);
}
private static void ApplyCouncilSeatTableValues(
@@ -693,6 +1525,305 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
}
}
+ private static void ApplyTopPanseSummaryValues(
+ IDictionary values,
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var rows = BuildTopPanseSummaries(template, snapshot);
+ var slotCount = ResolveTopPanseSlotCount(sceneVariables);
+ var shouldSetPartyLabels = !IsPanseEducationTemplate(template.Name);
+
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ var row = slot <= rows.Length ? rows[slot - 1] : default;
+ var partyLabel = string.IsNullOrWhiteSpace(row.Party) ? string.Empty : row.Party;
+ var countDisplay = row.Count > 0
+ ? row.Count.ToString(CultureInfo.InvariantCulture)
+ : "0";
+
+ if (shouldSetPartyLabels)
+ {
+ SetAliases(values, partyLabel, $"정당명{slot:00}", $"정당명{slot}");
+ }
+
+ SetAliases(values, countDisplay, $"의석수{slot:00}", $"의석수{slot}");
+ }
+ }
+
+ private static void ApplyNormalPanseMapValues(
+ IDictionary values,
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var rows = BuildNormalPanseMapSummaries(snapshot);
+ var slotCount = ResolveNormalPanseMapSlotCount(sceneVariables);
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ var count = slot <= rows.Length ? rows[slot - 1].Count : 0;
+ SetAliases(values, count.ToString(CultureInfo.InvariantCulture), $"의석수{slot:00}", $"의석수{slot}");
+ }
+ }
+
+ private static PanseSummary[] BuildNormalPanseMapSummaries(ElectionDataSnapshot snapshot)
+ {
+ var counts = snapshot.Candidates
+ .Where(candidate => !IsPanseSummaryCandidate(candidate))
+ .GroupBy(ResolveNormalPanseMapPartyKey, StringComparer.Ordinal)
+ .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
+
+ return NormalPanseMapPartySlots
+ .Select(slot => new PanseSummary(slot.Label, counts.TryGetValue(slot.ColorParty, out var count) ? count : 0))
+ .ToArray();
+ }
+
+ private static PanseSummary[] BuildTopPanseSummaries(
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot)
+ {
+ var candidates = snapshot.Candidates?.ToArray() ?? Array.Empty();
+ if (candidates.Length == 0)
+ {
+ return Array.Empty();
+ }
+
+ if (IsPanseEducationTemplate(template.Name))
+ {
+ return BuildEducationPanseSummaries(candidates);
+ }
+
+ var summaryCandidates = candidates
+ .Where(candidate => IsPanseSummaryCandidate(candidate) || IsCouncilSeatSummaryCandidate(candidate))
+ .ToArray();
+ if (summaryCandidates.Length > 0)
+ {
+ return BuildPartyPanseSummaries(summaryCandidates, ResolvePanseParty, useVoteCountAsCount: true);
+ }
+
+ var countedCandidates = candidates
+ .Where(candidate => CountsAsCouncilSeat(candidate.EffectiveJudgement))
+ .ToArray();
+ if (countedCandidates.Length > 0)
+ {
+ return BuildPartyPanseSummaries(countedCandidates, ResolvePanseParty, useVoteCountAsCount: false);
+ }
+
+ var leader = candidates
+ .OrderByDescending(candidate => candidate.VoteCount)
+ .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
+ .FirstOrDefault();
+
+ return leader is null
+ ? Array.Empty()
+ : BuildPartyPanseSummaries([leader], ResolvePanseParty, useVoteCountAsCount: false);
+ }
+
+ private static PanseSummary[] BuildEducationPanseSummaries(IReadOnlyList candidates)
+ {
+ var summaryCandidates = candidates
+ .Where(IsPanseSummaryCandidate)
+ .ToArray();
+ var sourceCandidates = summaryCandidates.Length > 0
+ ? summaryCandidates
+ : candidates
+ .OrderByDescending(candidate => candidate.VoteCount)
+ .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
+ .Take(1)
+ .ToArray();
+ var grouped = GroupPanseSummaryCandidates(
+ sourceCandidates,
+ ResolveEducationPanseParty,
+ useVoteCountAsCount: summaryCandidates.Length > 0)
+ .ToDictionary(row => NormalizePansePartyKey(row.Party), row => row.Count, StringComparer.OrdinalIgnoreCase);
+
+ var rows = new List(DefaultTopPanseSlotCount)
+ {
+ new("진보", grouped.TryGetValue("진보", out var progressiveCount) ? progressiveCount : 0),
+ new("보수", grouped.TryGetValue("보수", out var conservativeCount) ? conservativeCount : 0),
+ new("중도", grouped.TryGetValue("중도", out var moderateCount) ? moderateCount : 0)
+ };
+
+ var extraCount = grouped
+ .Where(pair => pair.Key is not ("진보" or "보수" or "중도"))
+ .Sum(pair => pair.Value);
+ if (extraCount > 0)
+ {
+ rows[^1] = new PanseSummary("기타", rows[^1].Count + extraCount);
+ }
+
+ return rows.ToArray();
+ }
+
+ private static PanseSummary[] GroupPanseSummaryCandidates(
+ IReadOnlyList candidates,
+ Func partySelector,
+ bool useVoteCountAsCount)
+ {
+ return candidates
+ .GroupBy(candidate => NormalizePansePartyKey(partySelector(candidate)), StringComparer.OrdinalIgnoreCase)
+ .Select(group =>
+ {
+ var first = group.First();
+ var party = partySelector(first).Trim();
+ if (string.IsNullOrWhiteSpace(party))
+ {
+ party = "무소속";
+ }
+
+ var count = group.Sum(candidate => useVoteCountAsCount ? Math.Max(0, candidate.VoteCount) : 1);
+ return new PanseSummary(party, count);
+ })
+ .Where(row => row.Count > 0)
+ .OrderByDescending(row => row.Count)
+ .ThenBy(row => row.Party, StringComparer.Ordinal)
+ .Take(DefaultTopPanseSlotCount)
+ .ToArray();
+ }
+
+ private static PanseSummary[] BuildPartyPanseSummaries(
+ IReadOnlyList candidates,
+ Func partySelector,
+ bool useVoteCountAsCount)
+ {
+ var counts = candidates
+ .GroupBy(candidate => ResolvePartyPanseGroup(partySelector(candidate)), StringComparer.Ordinal)
+ .ToDictionary(
+ group => group.Key,
+ group => group.Sum(candidate => useVoteCountAsCount ? Math.Max(0, candidate.VoteCount) : 1),
+ StringComparer.Ordinal);
+
+ return
+ [
+ new PanseSummary(PanseDemocraticPartyLabel, counts.TryGetValue(PanseDemocraticPartyLabel, out var democraticCount) ? democraticCount : 0),
+ new PanseSummary(PansePeoplePowerPartyLabel, counts.TryGetValue(PansePeoplePowerPartyLabel, out var peoplePowerCount) ? peoplePowerCount : 0),
+ new PanseSummary(PanseOtherPartyLabel, counts.TryGetValue(PanseOtherPartyLabel, out var otherCount) ? otherCount : 0)
+ ];
+ }
+
+ private static string ResolvePartyPanseGroup(string party)
+ {
+ var normalized = NormalizePansePartyKey(party);
+ if (IsPanseDemocraticPartyKey(normalized))
+ {
+ return PanseDemocraticPartyLabel;
+ }
+
+ if (IsPansePeoplePowerPartyKey(normalized))
+ {
+ return PansePeoplePowerPartyLabel;
+ }
+
+ return PanseOtherPartyLabel;
+ }
+
+ private static string ResolveNormalPanseMapPartyKey(CandidateEntry candidate)
+ {
+ var normalized = NormalizePansePartyKey(ResolvePanseParty(candidate));
+ if (IsPanseDemocraticPartyKey(normalized))
+ {
+ return "더불어민주당";
+ }
+
+ if (IsPansePeoplePowerPartyKey(normalized))
+ {
+ return "국민의힘";
+ }
+
+ foreach (var slot in NormalPanseMapPartySlots)
+ {
+ if (string.Equals(normalized, NormalizePansePartyKey(slot.ColorParty), StringComparison.Ordinal))
+ {
+ return slot.ColorParty;
+ }
+ }
+
+ return "무기타";
+ }
+
+ private static bool IsPanseDemocraticPartyKey(string normalizedParty)
+ {
+ return string.Equals(normalizedParty, "더불어민주당", StringComparison.Ordinal) ||
+ string.Equals(normalizedParty, "민주당", StringComparison.Ordinal);
+ }
+
+ private static bool IsPansePeoplePowerPartyKey(string normalizedParty)
+ {
+ return string.Equals(normalizedParty, "국민의힘", StringComparison.Ordinal);
+ }
+
+ private static bool IsPanseSummaryCandidate(CandidateEntry candidate)
+ {
+ return candidate.CandidateCode.StartsWith(PanseSummaryCandidateCodePrefix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string ResolvePanseParty(CandidateEntry candidate)
+ {
+ return FirstNonWhiteSpace(candidate.Party, candidate.EffectiveColorParty, "무소속");
+ }
+
+ private static string ResolveEducationPanseParty(CandidateEntry candidate)
+ {
+ var party = FirstNonWhiteSpace(candidate.Party, candidate.EffectiveColorParty, "중도");
+ var normalized = NormalizePansePartyKey(party);
+ if (normalized.Contains("진보", StringComparison.Ordinal))
+ {
+ return "진보";
+ }
+
+ if (normalized.Contains("보수", StringComparison.Ordinal))
+ {
+ return "보수";
+ }
+
+ if (normalized.Contains("중도", StringComparison.Ordinal))
+ {
+ return "중도";
+ }
+
+ return string.IsNullOrWhiteSpace(party) ? "중도" : party.Trim();
+ }
+
+ private static string ResolveCandidateAssetColorParty(
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ CandidateEntry candidate)
+ {
+ if (IsEducationColorTemplate(template.Name, snapshot))
+ {
+ return ResolveEducationAssetColorParty(candidate);
+ }
+
+ return FirstNonWhiteSpace(candidate.EffectiveColorParty, candidate.Party, "무기타");
+ }
+
+ private static string ResolveEducationAssetColorParty(CandidateEntry candidate)
+ {
+ var normalized = NormalizePansePartyKey(FirstNonWhiteSpace(candidate.Party, candidate.EffectiveColorParty, "중도"));
+ if (normalized.Contains("진보", StringComparison.Ordinal))
+ {
+ return "진보";
+ }
+
+ if (normalized.Contains("보수", StringComparison.Ordinal))
+ {
+ return "보수";
+ }
+
+ return "중도";
+ }
+
+ private static bool IsEducationColorTemplate(string templateName, ElectionDataSnapshot snapshot)
+ {
+ return string.Equals(snapshot.ElectionType, "교육감", StringComparison.Ordinal) ||
+ templateName.Contains("교육감", StringComparison.Ordinal);
+ }
+
+ private static string NormalizePansePartyKey(string party)
+ {
+ return string.Concat((party ?? string.Empty).Where(character => !char.IsWhiteSpace(character)));
+ }
+
private static CouncilSeatSummary[] BuildCouncilSeatSummaries(ElectionDataSnapshot snapshot)
{
var syntheticSeatRows = snapshot.Candidates
@@ -842,6 +1973,53 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return maxSlot > 0 ? maxSlot : DefaultCouncilSeatSlotCount;
}
+ private static int ResolveTopPanseSlotCount(IReadOnlyDictionary sceneVariables)
+ {
+ var maxSlot = 0;
+ foreach (var variableName in sceneVariables.Keys)
+ {
+ if ((TryParseCouncilSeatSlot(variableName, "의석수", out var seatSlot) ||
+ MatchesIndexedVariable(variableName, "정당명") && TryParseSimpleIndexedSlot(variableName, "정당명", out seatSlot)) &&
+ seatSlot > maxSlot)
+ {
+ maxSlot = seatSlot;
+ }
+ }
+
+ return maxSlot > 0 ? maxSlot : DefaultTopPanseSlotCount;
+ }
+
+ private static int ResolveNormalPanseMapSlotCount(IReadOnlyDictionary sceneVariables)
+ {
+ var maxSlot = 0;
+ foreach (var variableName in sceneVariables.Keys)
+ {
+ if (TryParseCouncilSeatSlot(variableName, "의석수", out var seatSlot) &&
+ seatSlot > maxSlot)
+ {
+ maxSlot = seatSlot;
+ }
+ }
+
+ return maxSlot > 0 ? maxSlot : DefaultNormalPanseMapSlotCount;
+ }
+
+ private static bool TryParseSimpleIndexedSlot(string variableName, string prefix, out int slot)
+ {
+ slot = 0;
+ if (string.IsNullOrWhiteSpace(variableName) ||
+ !variableName.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return int.TryParse(
+ variableName.Substring(prefix.Length),
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out slot);
+ }
+
private static void ApplyCouncilSeatSlotValue(
IDictionary values,
IReadOnlyDictionary sceneVariables,
@@ -943,7 +2121,17 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string t3CutPath,
IReadOnlyDictionary sceneVariables)
{
+ if (IsTopPanseTemplate(template))
+ {
+ return Array.Empty();
+ }
+
var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
+ if (IsNormalPanseMapTemplate(template))
+ {
+ return BuildNormalPanseMapStyleColorUpdates(template, snapshot, templateFolderPath, sceneVariables);
+ }
+
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
if (orderedCandidates.Length == 0)
{
@@ -956,20 +2144,82 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
{
var candidate = orderedCandidates[index];
var slot = index + 1;
- AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.EffectiveColorParty, "기호", $"기호{slot:00}", $"기호{slot}");
- AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.EffectiveColorParty, "기호텍스트", $"기호텍스트{slot:00}", $"기호텍스트{slot}");
- AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.EffectiveColorParty, "득표수바", $"득표수바{slot:00}", $"득표수바{slot}");
- AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.EffectiveColorParty, "정당바", $"정당바{slot:00}", $"정당바{slot}");
- AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.EffectiveColorParty, "정당판", $"정당판{slot:00}", $"정당판{slot}");
- AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.EffectiveColorParty, "정당원", $"정당원{slot:00}", $"정당원{slot}");
- AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.EffectiveColorParty, "정당색", $"정당색{slot:00}", $"정당색{slot}");
- AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.EffectiveColorParty, "정당명", $"정당명{slot:00}", $"정당명{slot}");
- AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.EffectiveColorParty, "득표율", $"득표율{slot:00}", $"득표율{slot}");
+ var candidateAssetColorParty = ResolveCandidateAssetColorParty(template, snapshot, candidate);
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "기호", $"기호{slot:00}", $"기호{slot}");
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "기호텍스트", $"기호텍스트{slot:00}", $"기호텍스트{slot}");
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, ResolveCandidateNameStyleColorSection(template.Name), $"후보명{slot:00}", $"후보명{slot}");
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "득표수바", $"득표수바{slot:00}", $"득표수바{slot}");
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "득표수", $"득표수{slot:00}", $"득표수{slot}");
+ AddStyleColorUpdates(
+ updates,
+ seen,
+ sceneVariables,
+ templateFolderPath,
+ template.Name,
+ candidateAssetColorParty,
+ "정당바",
+ !IsBottomWinnerTemplate(template),
+ IsBottomWinnerTemplate(template) ? new[] { 0 } : null,
+ $"정당바{slot:00}",
+ $"정당바{slot}");
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "정당판", $"정당판{slot:00}", $"정당판{slot}");
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "정당원", $"정당원{slot:00}", $"정당원{slot}");
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "정당색", $"정당색{slot:00}", $"정당색{slot}");
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "정당명", $"정당명{slot:00}", $"정당명{slot}");
+ if (ShouldHideVoteMetrics(candidate))
+ {
+ AddTransparentStyleColorUpdates(updates, seen, sceneVariables, $"득표율{slot:00}", $"득표율{slot}");
+ }
+ else
+ {
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "득표율", $"득표율{slot:00}", $"득표율{slot}");
+ }
+ if (HasPanseGraphVariables(sceneVariables))
+ {
+ AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidateAssetColorParty, "지역명", $"득표율{slot:00}", $"득표율{slot}");
+ }
}
return updates;
}
+ private static IReadOnlyList BuildNormalPanseMapStyleColorUpdates(
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ string templateFolderPath,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var updates = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var supportedRegions = NormalPanseMapRegions.ToHashSet(StringComparer.Ordinal);
+
+ foreach (var candidate in snapshot.Candidates.Where(candidate => !IsPanseSummaryCandidate(candidate)))
+ {
+ var regionName = ResolveNormalPanseMapRegionName(candidate, sceneVariables);
+ if (string.IsNullOrWhiteSpace(regionName) || !supportedRegions.Contains(regionName))
+ {
+ continue;
+ }
+
+ AddStyleColorUpdates(
+ updates,
+ seen,
+ sceneVariables,
+ templateFolderPath,
+ template.Name,
+ ResolveNormalPanseMapPartyKey(candidate),
+ "지역명",
+ regionName);
+ }
+
+ return updates;
+ }
+
+ private static bool HasPanseGraphVariables(IReadOnlyDictionary sceneVariables)
+ {
+ return sceneVariables.Keys.Any(variableName => MatchesIndexedVariable(variableName, "그래프"));
+ }
+
private static (IReadOnlyList HideBeforeValue, IReadOnlyList ShowAfterValue) BuildCandidateGroupVisibilityUpdates(
FormatTemplateDefinition template,
FormatCutDefinition cut,
@@ -977,6 +2227,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string t3CutPath,
IReadOnlyDictionary sceneVariables)
{
+ if (IsNormalPanseMapTemplate(template))
+ {
+ return (Array.Empty(), Array.Empty());
+ }
+
if (!template.RequiresCandidateData || IsHistoricalWinnerTemplate(template.Name) || IsCareerTemplate(template.Name))
{
return (Array.Empty(), Array.Empty());
@@ -993,21 +2248,185 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
var showAfterValue = new List(Math.Min(slotCount, orderedCandidates.Length));
var hidden = new HashSet(StringComparer.OrdinalIgnoreCase);
var shown = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var hasPanseGraphVariables = HasPanseGraphVariables(sceneVariables);
for (var slot = 1; slot <= slotCount; slot++)
{
- AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, $"그룹{slot:00}", $"그룹{slot}");
+ AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, GetCandidateVisualVisibilityAliases(slot));
+ AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, GetVoteMetricVisibilityAliases(slot));
+ if (hasPanseGraphVariables)
+ {
+ AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, $"그래프{slot:00}", $"그래프{slot}", $"득표율{slot:00}", $"득표율{slot}");
+ }
}
for (var index = 0; index < orderedCandidates.Length && index < slotCount; index++)
{
var slot = index + 1;
- AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, $"그룹{slot:00}", $"그룹{slot}");
+ AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, GetCandidateVisualVisibilityAliases(slot));
+ if (!ShouldHideVoteMetrics(orderedCandidates[index]))
+ {
+ AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, GetVoteMetricVisibilityAliases(slot));
+ }
+
+ if (hasPanseGraphVariables)
+ {
+ AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, $"그래프{slot:00}", $"그래프{slot}", $"득표율{slot:00}", $"득표율{slot}");
+ }
}
return (hideBeforeValue, showAfterValue);
}
+ private static string[] GetCandidateVisualVisibilityAliases(int slot)
+ {
+ return
+ [
+ $"그룹{slot:00}",
+ $"그룹{slot}",
+ $"정당바{slot:00}",
+ $"정당바{slot}",
+ $"정당판{slot:00}",
+ $"정당판{slot}",
+ $"정당원{slot:00}",
+ $"정당원{slot}",
+ $"정당색{slot:00}",
+ $"정당색{slot}",
+ $"정당심볼{slot:00}",
+ $"정당심볼{slot}",
+ $"후보사진{slot:00}",
+ $"후보사진{slot}"
+ ];
+ }
+
+ private static string[] GetVoteMetricVisibilityAliases(int slot)
+ {
+ return
+ [
+ $"득표수{slot:00}",
+ $"득표수{slot}",
+ $"득표율{slot:00}",
+ $"득표율{slot}"
+ ];
+ }
+
+ private static (IReadOnlyList HideBeforeValue, IReadOnlyList ShowAfterValue) BuildTurnoutGroupVisibilityUpdates(
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ if (!IsTurnoutTemplate(template.Name) || IsHistoricalTurnoutTemplate(template.Name))
+ {
+ return (Array.Empty(), Array.Empty());
+ }
+
+ var slotCount = ResolveTurnoutGroupSlotCount(sceneVariables);
+ if (slotCount <= 0)
+ {
+ return (Array.Empty(), Array.Empty());
+ }
+
+ var visibleSlots = snapshot.TurnoutBoardSlots.Count > 0
+ ? snapshot.TurnoutBoardSlots
+ .Where(entry => entry.Slot > 0 && entry.TurnoutRate > 0)
+ .Select(entry => entry.Slot)
+ .Distinct()
+ .ToArray()
+ : snapshot.TurnoutRate > 0
+ ? new[] { 1 }
+ : Array.Empty();
+
+ var hideBeforeValue = new List(slotCount);
+ var showAfterValue = new List(visibleSlots.Length);
+ var hidden = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var shown = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var visibleSlotSet = new HashSet(visibleSlots);
+
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ if (!visibleSlotSet.Contains(slot))
+ {
+ AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, GetTurnoutSlotVisibilityAliases(template, slot));
+ }
+ }
+
+ foreach (var slot in visibleSlots.Where(slot => slot <= slotCount))
+ {
+ AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, GetTurnoutSlotVisibilityAliases(template, slot));
+ }
+
+ return (hideBeforeValue, showAfterValue);
+ }
+
+ private static string[] GetTurnoutSlotVisibilityAliases(FormatTemplateDefinition template, int slot)
+ {
+ var aliases = new List
+ {
+ $"그룹{slot:00}",
+ $"그룹{slot}",
+ $"바{slot:00}",
+ $"바{slot}"
+ };
+
+ if (IsTopTurnoutDistrictBoardTemplate(template))
+ {
+ aliases.Add(GetTopTurnoutDistrictBoardRateAlias(slot));
+ aliases.Add(GetTopTurnoutDistrictBoardDistrictAlias(slot));
+ if (slot == 1)
+ {
+ aliases.Add("시도명01 1");
+ }
+ }
+ else
+ {
+ aliases.Add($"투표율{slot:00}");
+ aliases.Add($"투표율{slot}");
+ aliases.Add($"선거구명{slot:00}");
+ aliases.Add($"선거구명{slot}");
+ aliases.Add($"시도명{slot:00}");
+ aliases.Add($"시도명{slot}");
+ }
+
+ return aliases.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ }
+
+ private static int ResolveTurnoutGroupSlotCount(
+ IReadOnlyDictionary sceneVariables)
+ {
+ var slotCount = 0;
+ foreach (var variableName in sceneVariables.Keys)
+ {
+ if (!TryParseTurnoutSlotVariable(variableName, out var slot))
+ {
+ continue;
+ }
+
+ if (slot > 0)
+ {
+ slotCount = Math.Max(slotCount, slot);
+ }
+ }
+
+ return slotCount;
+ }
+
+ private static bool TryParseTurnoutSlotVariable(string variableName, out int slot)
+ {
+ foreach (var prefix in TurnoutSlotVariablePrefixes)
+ {
+ if (!variableName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var suffix = variableName[prefix.Length..];
+ return int.TryParse(suffix, NumberStyles.Integer, CultureInfo.InvariantCulture, out slot);
+ }
+
+ slot = 0;
+ return false;
+ }
+
private static (IReadOnlyList HideBeforeValue, IReadOnlyList ShowAfterValue) BuildJudgementVisibilityUpdates(
FormatTemplateDefinition template,
FormatCutDefinition cut,
@@ -1015,6 +2434,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string t3CutPath,
IReadOnlyDictionary sceneVariables)
{
+ if (IsNormalPanseMapTemplate(template))
+ {
+ return (Array.Empty(), Array.Empty());
+ }
+
var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
var hideBeforeValue = new List();
@@ -1080,14 +2504,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, $"그룹{slot:00}", $"그룹{slot}");
}
- var winnersByOrder = snapshot.HistoricalWinnerHistory
- .Where(entry => !string.IsNullOrWhiteSpace(entry.Name))
- .GroupBy(entry => entry.ElectionOrder)
- .ToDictionary(group => group.Key, group => group.First());
+ var winnersBySlot = ResolveHistoricalWinnerSlotMap(snapshot, slotCount);
for (var slot = 1; slot <= slotCount; slot++)
{
- if (!winnersByOrder.ContainsKey(slot))
+ if (!winnersBySlot.ContainsKey(slot))
{
continue;
}
@@ -1127,9 +2548,10 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
}
var promises = GetVisibleCareerPromises(leader);
- for (var slot = 1; slot <= promises.Length; slot++)
+ for (var visibleSlot = 1; visibleSlot <= promises.Length; visibleSlot++)
{
- AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, $"공약그룹{slot:00}", $"공약그룹{slot}");
+ var sceneSlot = ResolveCareerPromiseSceneSlot(visibleSlot);
+ AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, $"공약그룹{sceneSlot:00}", $"공약그룹{sceneSlot}");
}
return (hideBeforeValue, showAfterValue);
@@ -1148,7 +2570,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
if (IsTurnoutTemplate(template.Name))
{
- return BuildTurnoutCounterNumberKeyUpdates(snapshot, sceneVariables);
+ return BuildTurnoutCounterNumberKeyUpdates(template, snapshot, sceneVariables);
}
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
@@ -1156,6 +2578,16 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return BuildCouncilSeatCounterNumberKeyUpdates(snapshot, sceneVariables);
}
+ if (IsTopPanseTemplate(template))
+ {
+ return BuildTopPanseCounterNumberKeyUpdates(template, snapshot, sceneVariables);
+ }
+
+ if (IsNormalPanseMapTemplate(template))
+ {
+ return BuildNormalPanseMapCounterNumberKeyUpdates(snapshot, sceneVariables);
+ }
+
if (!IsAnimatedTemplate(template) && !HasVoteRateCounterVariables(sceneVariables))
{
return Array.Empty();
@@ -1168,26 +2600,64 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return Array.Empty();
}
- var updates = new List(slotCount);
+ var updates = new List(slotCount * 2);
+ var hasPanseGraphVariables = HasPanseGraphVariables(sceneVariables);
for (var slot = 1; slot <= slotCount; slot++)
{
+ if (hasPanseGraphVariables && slot > orderedCandidates.Length)
+ {
+ continue;
+ }
+
var variableName = $"득표율{slot:00}";
if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
{
continue;
}
- updates.Add(new KarismaCounterNumberKeyUpdate(
- variableName,
- 1,
- slot <= orderedCandidates.Length
- ? Math.Round(orderedCandidates[slot - 1].VoteRate, 1, MidpointRounding.AwayFromZero)
- : 0));
+ if (slot <= orderedCandidates.Length &&
+ ShouldHideVoteMetrics(orderedCandidates[slot - 1]))
+ {
+ continue;
+ }
+
+ var voteRate = slot <= orderedCandidates.Length
+ ? NormalizeRateForBroadcast(orderedCandidates[slot - 1].VoteRate)
+ : 0;
+ if (ShouldSendNormalAllCandidateGovernorVoteRateKeyZero(template, variableName))
+ {
+ updates.Add(new KarismaCounterNumberKeyUpdate(
+ variableName,
+ 0,
+ 0d,
+ AllowKeyZero: true,
+ AllowSetValue: true));
+ }
+
+ updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, voteRate));
}
return updates;
}
+ private static bool ShouldSendNormalAllCandidateGovernorVoteRateKeyZero(
+ FormatTemplateDefinition template,
+ string variableName)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Normal &&
+ string.Equals(template.Name, "모든후보_광역단체장", StringComparison.Ordinal) &&
+ string.Equals(variableName, "득표율01", StringComparison.Ordinal);
+ }
+
+ private static bool ShouldUseNormalAllCandidateGovernorVoteRateSetValueZero(
+ FormatTemplateDefinition template,
+ int slot)
+ {
+ return template.RecommendedChannel == BroadcastChannel.Normal &&
+ string.Equals(template.Name, "모든후보_광역단체장", StringComparison.Ordinal) &&
+ slot == 1;
+ }
+
private static bool HasVoteRateCounterVariables(IReadOnlyDictionary sceneVariables)
{
return sceneVariables.Values.Any(variable =>
@@ -1251,7 +2721,83 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
}
}
+ private static IReadOnlyList BuildTopPanseCounterNumberKeyUpdates(
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var rows = BuildTopPanseSummaries(template, snapshot);
+ var slotCount = ResolveTopPanseSlotCount(sceneVariables);
+ if (slotCount <= 0)
+ {
+ return Array.Empty();
+ }
+
+ var updates = new List(slotCount);
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ var count = slot <= rows.Length ? rows[slot - 1].Count : 0;
+ var matched = false;
+ foreach (var variableName in sceneVariables.Keys)
+ {
+ if (!TryParseCouncilSeatSlotColumn(variableName, "의석수", out var parsedSlot, out _) ||
+ parsedSlot != slot)
+ {
+ continue;
+ }
+
+ updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, count));
+ matched = true;
+ }
+
+ if (!matched && sceneVariables.Count == 0)
+ {
+ updates.Add(new KarismaCounterNumberKeyUpdate($"의석수{slot:00}", 1, count));
+ }
+ }
+
+ return updates;
+ }
+
+ private static IReadOnlyList BuildNormalPanseMapCounterNumberKeyUpdates(
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var rows = BuildNormalPanseMapSummaries(snapshot);
+ var slotCount = ResolveNormalPanseMapSlotCount(sceneVariables);
+ if (slotCount <= 0)
+ {
+ return Array.Empty();
+ }
+
+ var updates = new List(slotCount);
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ var count = slot <= rows.Length ? rows[slot - 1].Count : 0;
+ var matched = false;
+ foreach (var variableName in sceneVariables.Keys)
+ {
+ if (!TryParseCouncilSeatSlotColumn(variableName, "의석수", out var parsedSlot, out _) ||
+ parsedSlot != slot)
+ {
+ continue;
+ }
+
+ updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, count));
+ matched = true;
+ }
+
+ if (!matched && sceneVariables.Count == 0)
+ {
+ updates.Add(new KarismaCounterNumberKeyUpdate($"의석수{slot:00}", 1, count));
+ }
+ }
+
+ return updates;
+ }
+
private static IReadOnlyList BuildTurnoutCounterNumberKeyUpdates(
+ FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
IReadOnlyDictionary sceneVariables)
{
@@ -1259,7 +2805,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
void AddOrUpdate(string variableName, double numberValue)
{
- if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
+ if (!ShouldApplyCounterNumberKeyUpdate(template, variableName, sceneVariables))
{
return;
}
@@ -1280,20 +2826,45 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
}
var slotCount = ResolveTurnoutCounterSlotCount(sceneVariables);
+ if (IsTopTurnoutDistrictBoardTemplate(template))
+ {
+ AddTopTurnoutDistrictBoardCounterNumberKeyUpdates(snapshot, AddOrUpdate);
+ return updates;
+ }
+
if (snapshot.TurnoutBoardSlots.Count > 0)
{
var slotRates = snapshot.TurnoutBoardSlots
+ .Where(entry => entry.Slot > 0)
.GroupBy(entry => entry.Slot)
.ToDictionary(group => group.Key, group => group.First().TurnoutRate);
- for (var slot = 1; slot <= slotCount; slot++)
+ if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
{
- var rate = slotRates.TryGetValue(slot, out var slotRate) ? slotRate : 0d;
- AddOrUpdate($"투표율{slot:00}", rate);
- AddOrUpdate($"투표율{slot}", rate);
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ var rate = slotRates.TryGetValue(slot, out var slotRate) ? slotRate : 0d;
+ AddOrUpdate($"투표율{slot:00}", rate);
+ AddOrUpdate($"투표율{slot}", rate);
+ }
+ }
+ else
+ {
+ foreach (var pair in slotRates.Where(pair => pair.Value > 0).OrderBy(pair => pair.Key))
+ {
+ var slot = pair.Key;
+ if (slot > slotCount)
+ {
+ continue;
+ }
+
+ AddOrUpdate($"투표율{slot:00}", pair.Value);
+ AddOrUpdate($"투표율{slot}", pair.Value);
+ }
}
}
else
{
+ AddOrUpdate("투표율", snapshot.TurnoutRate);
AddOrUpdate("투표율01", snapshot.TurnoutRate);
AddOrUpdate("투표율1", snapshot.TurnoutRate);
for (var slot = 2; slot <= slotCount; slot++)
@@ -1303,12 +2874,45 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
}
}
+ AddOrUpdate("전국투표율", snapshot.NationalTurnoutRate);
AddOrUpdate("전국투표율01", snapshot.NationalTurnoutRate);
AddOrUpdate("전국투표율1", snapshot.NationalTurnoutRate);
return updates;
}
+ private static void AddTopTurnoutDistrictBoardCounterNumberKeyUpdates(
+ ElectionDataSnapshot snapshot,
+ Action addOrUpdate)
+ {
+ var visibleSlots = snapshot.TurnoutBoardSlots
+ .Where(entry => !entry.IsNational && entry.TurnoutRate > 0)
+ .OrderBy(entry => entry.Slot)
+ .Take(3)
+ .ToArray();
+
+ addOrUpdate("투표율01", visibleSlots.ElementAtOrDefault(0)?.TurnoutRate ?? 0d);
+ addOrUpdate("투표율01 1", visibleSlots.ElementAtOrDefault(1)?.TurnoutRate ?? 0d);
+ addOrUpdate("투표율01 2", visibleSlots.ElementAtOrDefault(2)?.TurnoutRate ?? 0d);
+ }
+
+ private static bool ShouldApplyCounterNumberKeyUpdate(
+ FormatTemplateDefinition template,
+ string variableName,
+ IReadOnlyDictionary sceneVariables)
+ {
+ if (IsTopTurnoutDistrictBoardTemplate(template))
+ {
+ return sceneVariables.Count == 0 ||
+ sceneVariables.ContainsKey(variableName) ||
+ IsTopTurnoutDistrictBoardVariable(variableName);
+ }
+
+ return sceneVariables.Count == 0 ||
+ sceneVariables.ContainsKey(variableName) ||
+ (IsTopTurnoutDistrictBoardTemplate(template) && IsTopTurnoutDistrictBoardVariable(variableName));
+ }
+
private static int ResolveTurnoutCounterSlotCount(
IReadOnlyDictionary sceneVariables)
{
@@ -1375,6 +2979,16 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
ElectionDataSnapshot snapshot,
IReadOnlyDictionary sceneVariables)
{
+ if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
+ {
+ return BuildNormalPreElectionTurnoutDistrictBoardPositionUpdates(snapshot, sceneVariables);
+ }
+
+ if (IsHistoricalWinnerTemplate(template.Name))
+ {
+ return BuildHistoricalWinnerPositionUpdates(snapshot, sceneVariables);
+ }
+
if (!IsHistoricalTurnoutTemplate(template.Name))
{
return Array.Empty();
@@ -1411,9 +3025,157 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates;
}
+ private static IReadOnlyList BuildNormalPreElectionTurnoutDistrictBoardPositionUpdates(
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var slotCount = ResolveTurnoutGroupSlotCount(sceneVariables);
+ if (slotCount <= 0)
+ {
+ slotCount = DefaultTurnoutSlotClearCount;
+ }
+
+ var ratesBySlot = snapshot.TurnoutBoardSlots
+ .Where(entry => entry.Slot > 0)
+ .GroupBy(entry => entry.Slot)
+ .ToDictionary(group => group.Key, group => group.First().TurnoutRate);
+ var updates = new List(slotCount);
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ for (var slot = 1; slot <= slotCount; slot++)
+ {
+ var turnoutRate = ratesBySlot.TryGetValue(slot, out var rate) ? rate : 0d;
+ var y = ResolveNormalPreElectionTurnoutBarY(turnoutRate);
+ AddNormalPreElectionTurnoutBarPositionUpdate(updates, seen, sceneVariables, $"바{slot:00}", y);
+ AddNormalPreElectionTurnoutBarPositionUpdate(updates, seen, sceneVariables, $"바{slot}", y);
+ }
+
+ return updates;
+ }
+
+ private static void AddNormalPreElectionTurnoutBarPositionUpdate(
+ ICollection updates,
+ ISet seen,
+ IReadOnlyDictionary sceneVariables,
+ string objectName,
+ float y)
+ {
+ if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(objectName))
+ {
+ return;
+ }
+
+ if (!seen.Add(objectName))
+ {
+ return;
+ }
+
+ updates.Add(new KarismaPositionUpdate(
+ objectName,
+ 0f,
+ y,
+ 0f,
+ eKVectorType.VECTOR_TYPE_Y,
+ NormalPreElectionTurnoutBarPositionKeyIndex));
+ }
+
+ private static IReadOnlyList BuildHistoricalWinnerPositionUpdates(
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var slotCount = ResolveHistoricalWinnerPositionSlotCount(sceneVariables);
+ if (slotCount <= 0)
+ {
+ slotCount = DefaultCandidateSlotClearCount;
+ }
+
+ slotCount = Math.Min(slotCount, HistoricalWinnerSlotBaseX.Length);
+ if (slotCount <= 0)
+ {
+ return Array.Empty();
+ }
+
+ var visibleSlots = snapshot.HistoricalWinnerHistory
+ .Where(entry => entry.ElectionOrder > 0 &&
+ entry.ElectionOrder <= slotCount &&
+ !string.IsNullOrWhiteSpace(entry.Name))
+ .GroupBy(entry => entry.ElectionOrder)
+ .Select(group => group.First().ElectionOrder)
+ .OrderBy(slot => slot)
+ .Take(slotCount)
+ .ToArray();
+ if (visibleSlots.Length == 0)
+ {
+ return Array.Empty();
+ }
+
+ var startSlot = Math.Max(1, ((slotCount - visibleSlots.Length) / 2) + 1);
+ var updates = new List(visibleSlots.Length);
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ for (var index = 0; index < visibleSlots.Length; index++)
+ {
+ var sourceSlot = visibleSlots[index];
+ var targetSlot = startSlot + index;
+ if (targetSlot is < 1 || targetSlot > HistoricalWinnerSlotBaseX.Length)
+ {
+ continue;
+ }
+
+ var x = HistoricalWinnerSlotBaseX[targetSlot - 1];
+ AddHistoricalWinnerGroupPositionUpdate(updates, seen, sceneVariables, $"그룹{sourceSlot:00}", x);
+ AddHistoricalWinnerGroupPositionUpdate(updates, seen, sceneVariables, $"그룹{sourceSlot}", x);
+ }
+
+ return updates;
+ }
+
+ private static int ResolveHistoricalWinnerPositionSlotCount(
+ IReadOnlyDictionary sceneVariables)
+ {
+ var slotCount = 0;
+ foreach (var variableName in sceneVariables.Keys)
+ {
+ if (TryParseSimpleIndexedSlot(variableName, "그룹", out var slot) && slot > 0)
+ {
+ slotCount = Math.Max(slotCount, slot);
+ }
+ }
+
+ return slotCount;
+ }
+
+ private static void AddHistoricalWinnerGroupPositionUpdate(
+ ICollection updates,
+ ISet seen,
+ IReadOnlyDictionary sceneVariables,
+ string objectName,
+ float x)
+ {
+ if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(objectName))
+ {
+ return;
+ }
+
+ if (!seen.Add(objectName))
+ {
+ return;
+ }
+
+ updates.Add(new KarismaPositionUpdate(
+ objectName,
+ x,
+ 0f,
+ 0f,
+ eKVectorType.VECTOR_TYPE_X));
+ }
+
private static bool ShouldMoveHistoricalTurnoutCircles(FormatTemplateDefinition template)
{
if (template.Name.Contains("_5760", StringComparison.Ordinal) ||
+ template.Name.Contains("_8316", StringComparison.Ordinal) ||
+ template.Name.Contains("_3840", StringComparison.Ordinal) ||
+ template.Name.Contains("_2880", StringComparison.Ordinal) ||
template.Name.EndsWith("_L", StringComparison.Ordinal) ||
template.Name.EndsWith("_L_1", StringComparison.Ordinal))
{
@@ -1506,6 +3268,66 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
(float)((100d - clampedRate) * HistoricalTurnoutCircleYUnitsPerPercent);
}
+ private static float ResolveNormalPreElectionTurnoutBarY(double turnoutRate)
+ {
+ var clampedRate = Math.Clamp(turnoutRate, 0d, 100d);
+ return NormalPreElectionTurnoutBarEmptyY +
+ (float)(clampedRate / 100d * (NormalPreElectionTurnoutBarFullY - NormalPreElectionTurnoutBarEmptyY));
+ }
+
+ private void LogTurnoutValuePayload(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ KarismaResolvedScene resolvedScene,
+ ElectionDataSnapshot snapshot,
+ IReadOnlyDictionary sceneVariables,
+ IReadOnlyDictionary values,
+ IReadOnlyList counterNumberKeys)
+ {
+ if (!IsTurnoutTemplate(template.Name))
+ {
+ return;
+ }
+
+ var sceneName = Path.GetFileName(resolvedScene.Path);
+ var turnoutText = values.TryGetValue("투표율01", out var turnoutValue) ? turnoutValue : "(missing)";
+ var nationalText = values.TryGetValue("전국투표율01", out var nationalValue) ? nationalValue : "(missing)";
+ var turnoutCounter = ResolveCounterPreview(counterNumberKeys, "투표율01");
+ var nationalCounter = ResolveCounterPreview(counterNumberKeys, "전국투표율01");
+ var hasTurnoutObject = sceneVariables.Count == 0 || sceneVariables.ContainsKey("투표율01");
+ var hasNationalObject = sceneVariables.Count == 0 || sceneVariables.ContainsKey("전국투표율01");
+ var provinceText = values.TryGetValue("시도명01", out var provinceValue)
+ ? provinceValue
+ : values.TryGetValue("시도명01 1", out provinceValue)
+ ? provinceValue
+ : string.Empty;
+ var slotPreview = snapshot.TurnoutBoardSlots.Count == 0
+ ? string.Empty
+ : string.Join(", ", snapshot.TurnoutBoardSlots
+ .OrderBy(slot => slot.Slot)
+ .Select(slot => $"{slot.Slot}:{slot.RegionLabel}/{slot.Label}={slot.TurnoutRate:0.0}"));
+
+ _logService.Info(
+ $"[{channel}] Turnout payload {template.Name}: " +
+ $"scene={sceneName}, " +
+ $"snapshot={snapshot.TurnoutRate:0.0}/{snapshot.NationalTurnoutRate:0.0}, " +
+ $"province={provinceText}, slots=[{slotPreview}], " +
+ $"text 투표율01={turnoutText}, 전국투표율01={nationalText}, " +
+ $"counter 투표율01={turnoutCounter}, 전국투표율01={nationalCounter}, " +
+ $"sceneVars 투표율01={(hasTurnoutObject ? "Y" : "N")}, 전국투표율01={(hasNationalObject ? "Y" : "N")}");
+ }
+
+ private static string ResolveCounterPreview(
+ IReadOnlyList counterNumberKeys,
+ string objectName)
+ {
+ var update = counterNumberKeys.FirstOrDefault(item =>
+ string.Equals(item.ObjectName, objectName, StringComparison.OrdinalIgnoreCase));
+ return string.IsNullOrWhiteSpace(update.ObjectName)
+ ? "(missing)"
+ : update.Number.ToString("0.0", CultureInfo.InvariantCulture);
+ }
+
private void LogCutDebugSummary(
BroadcastChannel channel,
FormatTemplateDefinition template,
@@ -1659,6 +3481,283 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return filtered;
}
+ private static Dictionary RemoveEmptyResourceValues(
+ IReadOnlyDictionary values,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var filtered = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var pair in values)
+ {
+ if (string.IsNullOrWhiteSpace(pair.Value) &&
+ sceneVariables.TryGetValue(pair.Key, out var variableDefinition) &&
+ variableDefinition.Kind is KarismaSceneVariableKind.Image or KarismaSceneVariableKind.VideoResource)
+ {
+ continue;
+ }
+
+ filtered[pair.Key] = pair.Value;
+ }
+
+ return filtered;
+ }
+
+ private static Dictionary FilterValuesBySceneFile(
+ IReadOnlyDictionary values,
+ string scenePath)
+ {
+ if (values.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
+ {
+ return new Dictionary(values, StringComparer.OrdinalIgnoreCase);
+ }
+
+ string sceneText;
+ try
+ {
+ sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
+ }
+ catch
+ {
+ return new Dictionary(values, StringComparer.OrdinalIgnoreCase);
+ }
+
+ if (!values.Keys.Any(key => SceneFileContainsVariableName(sceneText, key)))
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ return values
+ .Where(pair => SceneFileContainsVariableName(sceneText, pair.Key))
+ .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static bool SceneFileContainsVariableName(string sceneText, string variableName)
+ {
+ if (string.IsNullOrWhiteSpace(variableName))
+ {
+ return false;
+ }
+
+ var searchIndex = 0;
+ while (searchIndex < sceneText.Length)
+ {
+ var matchIndex = sceneText.IndexOf(variableName, searchIndex, StringComparison.Ordinal);
+ if (matchIndex < 0)
+ {
+ return false;
+ }
+
+ var beforeIndex = matchIndex - 1;
+ var afterIndex = matchIndex + variableName.Length;
+ if (IsSceneObjectNameBoundary(sceneText, beforeIndex) &&
+ IsSceneObjectNameBoundary(sceneText, afterIndex))
+ {
+ return true;
+ }
+
+ if (IsSceneBinaryStringMatch(sceneText, matchIndex, variableName.Length))
+ {
+ return true;
+ }
+
+ searchIndex = matchIndex + variableName.Length;
+ }
+
+ return false;
+ }
+
+ private static bool IsSceneBinaryStringMatch(string sceneText, int matchIndex, int nameLength)
+ {
+ if (nameLength <= 0 || matchIndex < 4)
+ {
+ return false;
+ }
+
+ var byteLength = nameLength * 2;
+ return sceneText[matchIndex - 3] == '\0' &&
+ sceneText[matchIndex - 2] == nameLength &&
+ sceneText[matchIndex - 1] == '\0' &&
+ (sceneText[matchIndex - 4] == byteLength ||
+ sceneText[matchIndex - 4] == '\u0004');
+ }
+
+ private static bool IsSceneObjectNameBoundary(string sceneText, int index)
+ {
+ if (index < 0 || index >= sceneText.Length)
+ {
+ return true;
+ }
+
+ var value = sceneText[index];
+ return !char.IsLetterOrDigit(value) && value != '_';
+ }
+
+ private static IReadOnlyList FilterCounterNumberKeyUpdatesBySceneFile(
+ IReadOnlyList updates,
+ string scenePath)
+ {
+ if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
+ {
+ return updates;
+ }
+
+ string sceneText;
+ try
+ {
+ sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
+ }
+ catch
+ {
+ return updates;
+ }
+
+ if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
+ {
+ return Array.Empty();
+ }
+
+ return updates
+ .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
+ .ToArray();
+ }
+
+ private static IReadOnlyList UseFinalVoteRateCounterValuesOnly(
+ IReadOnlyList updates)
+ {
+ if (updates.Count == 0)
+ {
+ return updates;
+ }
+
+ var filtered = updates
+ .Where(update => update.KeyIndex != 0 ||
+ update.AllowKeyZero ||
+ !IsCandidateVoteRateVariable(update.ObjectName))
+ .ToArray();
+ return filtered.Length == updates.Count ? updates : filtered;
+ }
+
+ private static bool IsCandidateVoteRateVariable(string variableName)
+ {
+ return string.Equals(variableName, "득표율", StringComparison.Ordinal) ||
+ MatchesIndexedVariable(variableName, "득표율");
+ }
+
+ private static IReadOnlyList FilterChartCellUpdatesBySceneFile(
+ IReadOnlyList updates,
+ string scenePath)
+ {
+ if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
+ {
+ return updates;
+ }
+
+ string sceneText;
+ try
+ {
+ sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
+ }
+ catch
+ {
+ return updates;
+ }
+
+ if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
+ {
+ return Array.Empty();
+ }
+
+ return updates
+ .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
+ .ToArray();
+ }
+
+ private static IReadOnlyList FilterPositionUpdatesBySceneFile(
+ IReadOnlyList updates,
+ string scenePath)
+ {
+ if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
+ {
+ return updates;
+ }
+
+ string sceneText;
+ try
+ {
+ sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
+ }
+ catch
+ {
+ return updates;
+ }
+
+ if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
+ {
+ return Array.Empty();
+ }
+
+ return updates
+ .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
+ .ToArray();
+ }
+
+ private static IReadOnlyList FilterStyleColorUpdatesBySceneFile(
+ IReadOnlyList updates,
+ string scenePath)
+ {
+ if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
+ {
+ return updates;
+ }
+
+ string sceneText;
+ try
+ {
+ sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
+ }
+ catch
+ {
+ return updates;
+ }
+
+ if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
+ {
+ return Array.Empty();
+ }
+
+ return updates
+ .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
+ .ToArray();
+ }
+
+ private static IReadOnlyList FilterVisibilityUpdatesBySceneFile(
+ IReadOnlyList updates,
+ string scenePath)
+ {
+ if (updates.Count == 0 || string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
+ {
+ return updates;
+ }
+
+ string sceneText;
+ try
+ {
+ sceneText = Encoding.Unicode.GetString(File.ReadAllBytes(scenePath));
+ }
+ catch
+ {
+ return updates;
+ }
+
+ if (!updates.Any(update => SceneFileContainsVariableName(sceneText, update.ObjectName)))
+ {
+ return Array.Empty();
+ }
+
+ return updates
+ .Where(update => SceneFileContainsVariableName(sceneText, update.ObjectName))
+ .ToArray();
+ }
+
private static IReadOnlyList FilterCounterNumberKeyUpdates(
IReadOnlyList updates,
CutDebugSettingsSnapshot cutDebug,
@@ -1672,7 +3771,10 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates
.Where(update =>
{
- if (cutDebug.IsEnabled && !cutDebug.ApplyVoteRateCounterValues && IsVoteRateVariable(update.ObjectName))
+ if (cutDebug.IsEnabled &&
+ !cutDebug.ApplyVoteRateCounterValues &&
+ IsVoteRateVariable(update.ObjectName) &&
+ !IsCommonTurnoutValueVariable(update.ObjectName))
{
return false;
}
@@ -1796,7 +3898,9 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
result[index] = new KarismaCounterNumberKeyUpdate(
result[index].ObjectName,
result[index].KeyIndex,
- numberValue);
+ numberValue,
+ result[index].AllowKeyZero,
+ result[index].AllowSetValue);
matched = true;
}
@@ -1942,7 +4046,10 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return false;
}
- if (cutDebug.IsEnabled && IsVoteRateVariable(variableName) && !cutDebug.ApplyVoteRateTextValues)
+ if (cutDebug.IsEnabled &&
+ IsVoteRateVariable(variableName) &&
+ !cutDebug.ApplyVoteRateTextValues &&
+ !IsCommonTurnoutValueVariable(variableName))
{
return false;
}
@@ -2046,8 +4153,6 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
{
orderedCandidates = snapshot.Candidates
.Where(candidate => IsElectedJudgement(candidate.EffectiveJudgement))
- .OrderByDescending(candidate => candidate.VoteCount)
- .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
.ToArray();
}
else if (IsCareerTemplate(template.Name))
@@ -2066,6 +4171,22 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
.Select(entry => entry.Candidate)
.ToArray();
}
+ else if (IsAllCandidateTemplate(template.Name))
+ {
+ orderedCandidates = snapshot.Candidates
+ .Select(candidate => new
+ {
+ Candidate = candidate,
+ SortKey = BuildBallotCandidateSortKey(candidate)
+ })
+ .OrderBy(entry => entry.SortKey.Group)
+ .ThenBy(entry => entry.SortKey.PrimaryNumber)
+ .ThenBy(entry => entry.SortKey.Suffix, StringComparer.Ordinal)
+ .ThenBy(entry => entry.SortKey.OriginalText, StringComparer.Ordinal)
+ .ThenBy(entry => entry.Candidate.Name, StringComparer.Ordinal)
+ .Select(entry => entry.Candidate)
+ .ToArray();
+ }
else
{
orderedCandidates = snapshot.Candidates
@@ -2090,6 +4211,34 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return orderedCandidates;
}
+ private static (int Group, int PrimaryNumber, string Suffix, string OriginalText) BuildBallotCandidateSortKey(CandidateEntry candidate)
+ {
+ var ballotNumber = ResolveBallotNumberDisplay(candidate).Trim();
+ if (string.IsNullOrWhiteSpace(ballotNumber))
+ {
+ return (2, int.MaxValue, string.Empty, string.Empty);
+ }
+
+ var numericPrefixLength = 0;
+ while (numericPrefixLength < ballotNumber.Length && char.IsDigit(ballotNumber[numericPrefixLength]))
+ {
+ numericPrefixLength++;
+ }
+
+ if (numericPrefixLength == 0)
+ {
+ return (1, int.MaxValue, ballotNumber, ballotNumber);
+ }
+
+ var primaryNumberText = ballotNumber[..numericPrefixLength];
+ if (!int.TryParse(primaryNumberText, NumberStyles.None, CultureInfo.InvariantCulture, out var primaryNumber))
+ {
+ primaryNumber = int.MaxValue;
+ }
+
+ return (0, primaryNumber, ballotNumber[numericPrefixLength..], ballotNumber);
+ }
+
private static (int Group, int PrimaryNumber, string Suffix, string OriginalText) BuildCareerCandidateSortKey(CandidateEntry candidate)
{
var ballotNumber = ResolveBallotNumberDisplay(candidate).Trim();
@@ -2119,6 +4268,48 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return (0, primaryNumber, suffix, ballotNumber);
}
+ private void LogMissingExpectedSceneVariables(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ IReadOnlyDictionary sceneVariables)
+ {
+ if (!RequiresTopTwoCandidateNameVariables(template) || sceneVariables.Count == 0)
+ {
+ return;
+ }
+
+ var missingVariables = new List();
+ for (var slot = 1; slot <= 2; slot++)
+ {
+ var variableName = $"후보명{slot:00}";
+ if (!sceneVariables.ContainsKey(variableName))
+ {
+ missingVariables.Add(variableName);
+ }
+ }
+
+ if (missingVariables.Count == 0)
+ {
+ return;
+ }
+
+ _logService.Warning(
+ $"[{channel}] Scene '{template.Id}' is missing candidate name variables: {string.Join(", ", missingVariables)}");
+ }
+
+ private static bool RequiresTopTwoCandidateNameVariables(FormatTemplateDefinition template)
+ {
+ if (template.RecommendedChannel != BroadcastChannel.TopLeft)
+ {
+ return false;
+ }
+
+ return string.Equals(template.Name, "광역단체장_2인", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "광역단체장_2인_텍스트", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "기초단체장_2인", StringComparison.Ordinal) ||
+ string.Equals(template.Name, "기초단체장_2인_텍스트", StringComparison.Ordinal);
+ }
+
private void LogUnsupportedSceneVariables(
BroadcastChannel channel,
FormatTemplateDefinition template,
@@ -2274,7 +4465,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return ResolveAssetAcrossRoots(
t3CutPath,
templateFolderPath,
- fileNames.Select(fileName => Path.Combine("Images", "Tag", fileName)));
+ fileNames.SelectMany(fileName => new[]
+ {
+ Path.Combine("Video", "Images", "Tag", fileName),
+ Path.Combine("Images", "Tag", fileName)
+ }));
}
private static string ResolvePartyAssetPath(
@@ -2349,6 +4544,38 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
: ResolveOtherPartyImageAssetPath(t3CutPath, templateFolderPath, templateName, assetKind);
}
+ private static string ResolvePartyGraphColorAssetPath(
+ string templateFolderPath,
+ string templateName,
+ string partyName)
+ {
+ return PartyColorCatalog.ResolveFallbackAssetPathForSection(
+ templateFolderPath,
+ templateName,
+ "지역명",
+ partyName,
+ PartyColorAssetUsage.Bar);
+ }
+
+ private static string ResolveTransparentPngAssetPath()
+ {
+ var cacheDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Tornado3_2026Election",
+ "GeneratedPartyAssets");
+ Directory.CreateDirectory(cacheDirectory);
+
+ var filePath = Path.Combine(cacheDirectory, "transparent_1x1.png");
+ if (!File.Exists(filePath))
+ {
+ File.WriteAllBytes(
+ filePath,
+ Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgYAAAAAMAASsJTYQAAAAASUVORK5CYII="));
+ }
+
+ return filePath;
+ }
+
private static void AddPartyAssetRelativePaths(
ICollection relativePaths,
string templateName,
@@ -2435,6 +4662,61 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return Math.Abs(orderedCandidates[index].VoteCount - orderedCandidates[comparisonIndex].VoteCount);
}
+ private static int ResolveCandidateDisplayRank(
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ int slot,
+ CandidateEntry candidate,
+ IReadOnlyDictionary candidateVoteRanks)
+ {
+ if (IsAllCandidateTemplate(template.Name))
+ {
+ if (candidate.BroadcastRank > 0)
+ {
+ return candidate.BroadcastRank;
+ }
+
+ if (candidateVoteRanks.TryGetValue(candidate.Id, out var voteRank))
+ {
+ return voteRank;
+ }
+
+ return 0;
+ }
+
+ return cut.CandidateStartIndex + slot;
+ }
+
+ private static IReadOnlyDictionary BuildCandidateVoteRankMap(IReadOnlyList candidates)
+ {
+ var rankedCandidates = candidates
+ .Where(candidate => candidate.VoteCount > 0)
+ .OrderByDescending(candidate => candidate.VoteCount)
+ .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
+ .ToArray();
+ if (rankedCandidates.Length == 0)
+ {
+ return EmptyCandidateRankMap;
+ }
+
+ var ranks = new Dictionary();
+ int? previousVoteCount = null;
+ var currentRank = 0;
+ for (var index = 0; index < rankedCandidates.Length; index++)
+ {
+ var candidate = rankedCandidates[index];
+ if (previousVoteCount != candidate.VoteCount)
+ {
+ currentRank = index + 1;
+ previousVoteCount = candidate.VoteCount;
+ }
+
+ ranks[candidate.Id] = currentRank;
+ }
+
+ return ranks;
+ }
+
private static string ResolveCandidateElectionDistrictDisplay(CandidateEntry candidate, string fallback)
{
return FirstNonWhiteSpace(
@@ -2497,6 +4779,36 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return normalizedPrefix + rawBallotNumber[numericPrefixLength..];
}
+ private static string ResolveBallotLabelDisplay(FormatTemplateDefinition template, string ballotNumberDisplay)
+ {
+ if (IsCareerTemplate(template.Name) && HasMultiDigitBallotNumber(ballotNumberDisplay))
+ {
+ return string.Empty;
+ }
+
+ return "기호";
+ }
+
+ private static bool HasMultiDigitBallotNumber(string ballotNumberDisplay)
+ {
+ var digitCount = 0;
+ foreach (var character in ballotNumberDisplay)
+ {
+ if (!char.IsDigit(character))
+ {
+ continue;
+ }
+
+ digitCount++;
+ if (digitCount >= 2)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private static bool IsAnimatedTemplate(FormatTemplateDefinition template)
{
return template.Id.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
@@ -2511,10 +4823,19 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
var currentLeaderSlotCount = IsCurrentLeaderTemplate(template.Name)
? ResolveCurrentLeaderPageSize(template.Name)
: 0;
+ var bottomWinnerSlotCount = IsBottomWinnerTemplate(template) ? 3 : 0;
+ var bottomAllCandidateSlotCount = template.RecommendedChannel == BroadcastChannel.Bottom && IsAllCandidateTemplate(template.Name)
+ ? 3
+ : 0;
var sceneSlotCount = ResolveSceneCandidateSlotCount(sceneVariables);
if (sceneSlotCount > 0)
{
- return Math.Max(sceneSlotCount, currentLeaderSlotCount);
+ return Math.Max(Math.Max(Math.Max(sceneSlotCount, currentLeaderSlotCount), bottomWinnerSlotCount), bottomAllCandidateSlotCount);
+ }
+
+ if (bottomWinnerSlotCount > 0 || bottomAllCandidateSlotCount > 0)
+ {
+ return Math.Max(bottomWinnerSlotCount, bottomAllCandidateSlotCount);
}
foreach (var source in new[] { cut.Name, template.Name, template.Id })
@@ -2629,13 +4950,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return peopleSlotCount;
}
- if (sourceName.StartsWith("이시각1위_", StringComparison.Ordinal))
+ if (IsCurrentLeaderTemplate(sourceName))
{
return ResolveCurrentLeaderPageSize(sourceName);
}
- if (sourceName.StartsWith("1위_", StringComparison.Ordinal) ||
- sourceName.StartsWith("당선_", StringComparison.Ordinal) ||
+ if (sourceName.StartsWith("당선_", StringComparison.Ordinal) ||
sourceName.StartsWith("경력_", StringComparison.Ordinal))
{
return 1;
@@ -2658,12 +4978,17 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return variableName is "선거구명" or "시도명" or "개표율" or "투표율" or "전국투표율" or
"기준시" or "기준시01" or "기준시02" or "유권자수" or "유권자수01" or "투표자수" or "투표자수01" or "유확당" ||
+ NormalPanseMapRegions.Contains(variableName, StringComparer.Ordinal) ||
+ IsBottomWinnerBallotNumberVariableName(variableName) ||
MatchesIndexedVariable(variableName, "선거구명") ||
MatchesIndexedVariable(variableName, "시도명") ||
+ MatchesIndexedVariable(variableName, HistoricalTurnoutCircleObjectPrefix) ||
MatchesIndexedVariable(variableName, "개표율") ||
MatchesIndexedVariable(variableName, "투표율") ||
MatchesIndexedVariable(variableName, "전국투표율") ||
+ MatchesIndexedVariable(variableName, "사진") ||
MatchesIndexedVariable(variableName, "순위") ||
+ IsSpecialRankVariableName(variableName) ||
MatchesIndexedVariable(variableName, "기호") ||
MatchesIndexedVariable(variableName, "기호텍스트") ||
MatchesIndexedVariable(variableName, "후보명") ||
@@ -2684,6 +5009,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
MatchesIndexedVariable(variableName, "정당심볼") ||
MatchesIndexedVariable(variableName, "공약") ||
MatchesIndexedVariable(variableName, "공약그룹") ||
+ MatchesIndexedVariable(variableName, "그래프") ||
MatchesIndexedVariable(variableName, "그룹") ||
MatchesIndexedVariable(variableName, "바");
}
@@ -2706,7 +5032,17 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private static string FormatRate(double value)
{
- return Math.Round(value, 1, MidpointRounding.AwayFromZero).ToString("0.0", CultureInfo.InvariantCulture);
+ return NormalizeRateForBroadcast(value).ToString("0.0", CultureInfo.InvariantCulture);
+ }
+
+ private static double NormalizeRateForBroadcast(double value)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value))
+ {
+ return 0d;
+ }
+
+ return Math.Round(Math.Clamp(value, 0d, 100d), 1, MidpointRounding.AwayFromZero);
}
private static string FormatCountedRateLabel(double value)
@@ -2714,17 +5050,45 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return FormattableString.Invariant($"개표 {FormatRate(value)}%");
}
+ private static bool ShouldUseCountingCompleteLabel(
+ FormatTemplateDefinition template,
+ ElectionDataSnapshot snapshot)
+ {
+ return IsWinnerTemplate(template.Name) &&
+ IsBasicCouncilElectionType(snapshot.ElectionType);
+ }
+
+ private static bool ShouldHideVoteMetrics(CandidateEntry candidate)
+ {
+ return candidate.EffectiveJudgement == CandidateJudgement.UnopposedElected &&
+ candidate.VoteCount <= 0;
+ }
+
private static string FormatClock(DateTimeOffset value)
{
return value.ToString("HH:mm", CultureInfo.InvariantCulture);
}
+ private static string ResolveReferenceTimeDisplay(ElectionDataSnapshot snapshot)
+ {
+ return string.IsNullOrWhiteSpace(snapshot.ReferenceTimeLabel)
+ ? FormatClock(snapshot.ReceivedAt)
+ : snapshot.ReferenceTimeLabel.Trim();
+ }
+
private static bool IsJudgementVariableName(string variableName)
{
return string.Equals(variableName, "유확당", StringComparison.Ordinal) ||
MatchesIndexedVariable(variableName, "유확당");
}
+ private static bool IsBottomWinnerBallotNumberVariableName(string variableName)
+ {
+ return string.Equals(variableName, "data01", StringComparison.Ordinal) ||
+ string.Equals(variableName, "data01 1", StringComparison.Ordinal) ||
+ string.Equals(variableName, "data01 2", StringComparison.Ordinal);
+ }
+
private static void SetAliases(IDictionary values, string value, params string[] keys)
{
foreach (var key in keys)
@@ -2736,8 +5100,35 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
}
}
- private static void ClearCandidateSlotValues(IDictionary values, int slotCount)
+ private static void SetBottomWinnerBallotNumberAliases(IDictionary values, int slot, string value)
{
+ switch (slot)
+ {
+ case 1:
+ SetAliases(values, value, "data01");
+ break;
+ case 2:
+ SetAliases(values, value, "data01 1");
+ break;
+ case 3:
+ SetAliases(values, value, "data01 2");
+ break;
+ }
+ }
+
+ private static void ClearCandidateSlotValues(
+ IDictionary values,
+ int slotCount,
+ FormatTemplateDefinition template,
+ IReadOnlyDictionary sceneVariables)
+ {
+ var transparentGraphPath = HasPanseGraphVariables(sceneVariables)
+ ? ResolveTransparentPngAssetPath()
+ : string.Empty;
+ var transparentImagePath = sceneVariables.Values.Any(variable => variable.Kind == KarismaSceneVariableKind.Image)
+ ? ResolveTransparentPngAssetPath()
+ : string.Empty;
+
for (var slot = 1; slot <= slotCount; slot++)
{
values[$"Candidate{slot}Code"] = string.Empty;
@@ -2749,8 +5140,15 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
values[$"Candidate{slot}Judgement"] = string.Empty;
values[$"Candidate{slot}ImagePath"] = string.Empty;
- SetAliases(values, string.Empty, $"순위{slot:00}", $"순위{slot}");
+ var rankAliases = GetRankAliases(template, slot);
+ var judgementAliases = GetJudgementAliases(slot);
+ SetAliases(values, ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, rankAliases), rankAliases);
SetAliases(values, string.Empty, $"기호{slot:00}", $"기호{slot}");
+ if (IsBottomWinnerTemplate(template))
+ {
+ SetBottomWinnerBallotNumberAliases(values, slot, string.Empty);
+ }
+
SetAliases(values, string.Empty, $"기호텍스트{slot:00}", $"기호텍스트{slot}");
SetAliases(values, string.Empty, $"후보명{slot:00}", $"후보명{slot}");
SetAliases(values, string.Empty, $"정당명{slot:00}", $"정당명{slot}");
@@ -2760,15 +5158,55 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
SetAliases(values, string.Empty, $"선거구명{slot:00}", $"선거구명{slot}");
SetAliases(values, string.Empty, $"시도명{slot:00}", $"시도명{slot}");
SetAliases(values, string.Empty, $"개표율{slot:00}", $"개표율{slot}");
- SetAliases(values, string.Empty, GetJudgementAliases(slot));
- SetAliases(values, string.Empty, $"후보사진{slot:00}", $"후보사진{slot}");
- SetAliases(values, string.Empty, $"득표수바{slot:00}", $"득표수바{slot}");
- SetAliases(values, string.Empty, $"정당바{slot:00}", $"정당바{slot}");
- SetAliases(values, string.Empty, $"정당판{slot:00}", $"정당판{slot}");
- SetAliases(values, string.Empty, $"정당원{slot:00}", $"정당원{slot}");
- SetAliases(values, string.Empty, $"정당색{slot:00}", $"정당색{slot}");
- SetAliases(values, string.Empty, $"정당심볼{slot:00}", $"정당심볼{slot}");
- SetAliases(values, string.Empty, $"그룹{slot:00}", $"그룹{slot}");
+ SetAliases(values, ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, judgementAliases), judgementAliases);
+ SetAliases(
+ values,
+ ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"후보사진{slot:00}", $"후보사진{slot}"),
+ $"후보사진{slot:00}",
+ $"후보사진{slot}");
+ SetAliases(
+ values,
+ ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"득표수바{slot:00}", $"득표수바{slot}"),
+ $"득표수바{slot:00}",
+ $"득표수바{slot}");
+ if (!ShouldUseStyleColorOnlyForBottomPartyBar(template.Name, "정당바"))
+ {
+ SetAliases(
+ values,
+ ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"정당바{slot:00}", $"정당바{slot}"),
+ $"정당바{slot:00}",
+ $"정당바{slot}");
+ }
+
+ SetAliases(
+ values,
+ ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"정당판{slot:00}", $"정당판{slot}"),
+ $"정당판{slot:00}",
+ $"정당판{slot}");
+ SetAliases(
+ values,
+ ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"정당원{slot:00}", $"정당원{slot}"),
+ $"정당원{slot:00}",
+ $"정당원{slot}");
+ SetAliases(
+ values,
+ ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"정당색{slot:00}", $"정당색{slot}"),
+ $"정당색{slot:00}",
+ $"정당색{slot}");
+ SetAliases(
+ values,
+ ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"정당심볼{slot:00}", $"정당심볼{slot}"),
+ $"정당심볼{slot:00}",
+ $"정당심볼{slot}");
+ SetAliases(
+ values,
+ ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"그룹{slot:00}", $"그룹{slot}"),
+ $"그룹{slot:00}",
+ $"그룹{slot}");
+ if (!string.IsNullOrWhiteSpace(transparentGraphPath))
+ {
+ SetAliases(values, transparentGraphPath, $"그래프{slot:00}", $"그래프{slot}");
+ }
}
}
@@ -2784,18 +5222,51 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
values["LeaderImagePath"] = string.Empty;
}
- private static void ClearTurnoutSlotValues(IDictionary values, int slotCount)
+ private static void ClearTurnoutSlotValues(
+ IDictionary values,
+ int slotCount,
+ IReadOnlyDictionary sceneVariables)
{
+ SetAliases(values, string.Empty, "시도명01 1", "시도명01 2", "시도명01 3", "투표율01 1", "투표율01 2");
+ var transparentImagePath = sceneVariables.Values.Any(variable => variable.Kind == KarismaSceneVariableKind.Image)
+ ? ResolveTransparentPngAssetPath()
+ : string.Empty;
+
for (var slot = 1; slot <= slotCount; slot++)
{
SetAliases(values, string.Empty, $"투표율{slot:00}", $"투표율{slot}");
SetAliases(values, string.Empty, $"선거구명{slot:00}", $"선거구명{slot}");
SetAliases(values, string.Empty, $"시도명{slot:00}", $"시도명{slot}");
- SetAliases(values, string.Empty, $"그룹{slot:00}", $"그룹{slot}");
- SetAliases(values, string.Empty, $"바{slot:00}", $"바{slot}");
+ SetAliases(
+ values,
+ ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"그룹{slot:00}", $"그룹{slot}"),
+ $"그룹{slot:00}",
+ $"그룹{slot}");
+ SetAliases(
+ values,
+ ResolveEmptyImageSlotValue(sceneVariables, transparentImagePath, $"바{slot:00}", $"바{slot}"),
+ $"바{slot:00}",
+ $"바{slot}");
}
}
+ private static string ResolveEmptyImageSlotValue(
+ IReadOnlyDictionary sceneVariables,
+ string transparentImagePath,
+ params string[] aliases)
+ {
+ if (string.IsNullOrWhiteSpace(transparentImagePath))
+ {
+ return string.Empty;
+ }
+
+ return aliases.Any(alias =>
+ sceneVariables.TryGetValue(alias, out var variable) &&
+ variable.Kind == KarismaSceneVariableKind.Image)
+ ? transparentImagePath
+ : string.Empty;
+ }
+
private static string[] GetJudgementAliases(int slot)
{
return slot == 1
@@ -2814,11 +5285,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private static void SetOptionalAssetAliasesUnlessStyleBound(
IDictionary values,
string templateFolderPath,
- string templateName,
+ FormatTemplateDefinition template,
string sectionName,
string? value,
params string[] keys)
{
+ var templateName = template.Name;
if (ShouldUseTemplateDefaultAppearance(templateName, sectionName))
{
foreach (var key in keys)
@@ -2830,7 +5302,8 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
}
if (PartyColorCatalog.HasStyleColorBinding(templateFolderPath, templateName, sectionName) &&
- !ShouldPreferAssetAliasForStyleBoundSection(templateName, sectionName))
+ !ShouldPreferAssetAliasForStyleBoundSection(templateName, sectionName) &&
+ !ShouldRestoreStyleBoundCandidateAsset(template, sectionName))
{
foreach (var key in keys)
{
@@ -2843,6 +5316,49 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
SetOptionalAliases(values, value, keys);
}
+ private static bool ShouldRestoreStyleBoundCandidateAsset(FormatTemplateDefinition template, string sectionName)
+ {
+ if (ShouldUseStyleColorOnlyForBottomPartyBar(template.Name, sectionName))
+ {
+ return false;
+ }
+
+ if (template.RecommendedChannel != BroadcastChannel.Bottom)
+ {
+ return false;
+ }
+
+ if (!IsBottomCandidateVisualTemplate(template.Name))
+ {
+ return false;
+ }
+
+ return sectionName is "득표수바" or "정당바" or "정당판" or "정당원" or "정당색";
+ }
+
+ private static bool ShouldUseStyleColorOnlyForBottomPartyBar(string templateName, string sectionName)
+ {
+ return string.Equals(sectionName, "정당바", StringComparison.Ordinal) &&
+ (string.Equals(templateName, "1위_광역단체장", StringComparison.Ordinal) ||
+ string.Equals(templateName, "1위_기초단체장", StringComparison.Ordinal) ||
+ string.Equals(templateName, "1-2위_광역단체장", StringComparison.Ordinal) ||
+ string.Equals(templateName, "1-2위_기초단체장", StringComparison.Ordinal) ||
+ string.Equals(templateName, "1-3위_광역단체장", StringComparison.Ordinal) ||
+ string.Equals(templateName, "1-3위_기초단체장", StringComparison.Ordinal) ||
+ string.Equals(templateName, "전후보_광역단체장", StringComparison.Ordinal) ||
+ string.Equals(templateName, "전후보_기초단체장", StringComparison.Ordinal));
+ }
+
+ private static bool IsBottomCandidateVisualTemplate(string templateName)
+ {
+ return templateName.StartsWith("1위_", StringComparison.Ordinal) ||
+ templateName.StartsWith("1-2위_", StringComparison.Ordinal) ||
+ templateName.StartsWith("1-3위_", StringComparison.Ordinal) ||
+ templateName.StartsWith("당선_", StringComparison.Ordinal) ||
+ templateName.StartsWith("전후보_", StringComparison.Ordinal) ||
+ templateName.StartsWith("모든후보_", StringComparison.Ordinal);
+ }
+
private static void AddVisibilityUpdates(
ICollection updates,
ISet seen,
@@ -2890,6 +5406,79 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string partyName,
string sectionName,
params string[] objectNames)
+ {
+ AddStyleColorUpdates(
+ updates,
+ seen,
+ sceneVariables,
+ templateFolderPath,
+ templateName,
+ partyName,
+ sectionName,
+ true,
+ objectNames);
+ }
+
+ private static void AddStyleColorUpdates(
+ ICollection updates,
+ ISet seen,
+ IReadOnlyDictionary sceneVariables,
+ string templateFolderPath,
+ string templateName,
+ string partyName,
+ string sectionName,
+ bool requireSceneVariable,
+ IReadOnlyCollection? additionalOrders,
+ params string[] objectNames)
+ {
+ AddStyleColorUpdatesCore(
+ updates,
+ seen,
+ sceneVariables,
+ templateFolderPath,
+ templateName,
+ partyName,
+ sectionName,
+ requireSceneVariable,
+ additionalOrders,
+ objectNames);
+ }
+
+ private static void AddStyleColorUpdates(
+ ICollection updates,
+ ISet seen,
+ IReadOnlyDictionary sceneVariables,
+ string templateFolderPath,
+ string templateName,
+ string partyName,
+ string sectionName,
+ bool requireSceneVariable,
+ params string[] objectNames)
+ {
+ AddStyleColorUpdatesCore(
+ updates,
+ seen,
+ sceneVariables,
+ templateFolderPath,
+ templateName,
+ partyName,
+ sectionName,
+ requireSceneVariable,
+ null,
+ objectNames);
+ }
+
+ private static void AddStyleColorUpdatesCore(
+ ICollection updates,
+ ISet seen,
+ IReadOnlyDictionary