Compare commits

...

2 Commits

Author SHA1 Message Date
e76c37ef56 5.14 시작전 2026-05-14 09:38:45 +09:00
8b5c92194f wndrks 2026-05-13 11:21:48 +09:00
71 changed files with 15722 additions and 1347 deletions

32
AGENTS.md Normal file
View File

@@ -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.

9
OBJECT_TYPE_QUERY.md Normal file
View File

@@ -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 | |

View File

@@ -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 | | |

View File

@@ -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 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 312 KiB

View File

@@ -90,37 +90,98 @@
BorderBrush="#25405D"
BorderThickness="1"
CornerRadius="18">
<StackPanel Spacing="8">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="{x:Bind ViewModel.CurrentPreviewWidth, Mode=OneWay}"
Height="{x:Bind ViewModel.CurrentPreviewHeight, Mode=OneWay}"
VerticalAlignment="Center"
Background="#0B1624"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="8">
<Image
Source="{x:Bind ViewModel.CurrentPreviewSource, Mode=OneWay}"
Stretch="Uniform" />
</Border>
<StackPanel
Grid.Column="1"
Spacing="6"
VerticalAlignment="Center">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="현재" />
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="22"
FontSize="20"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.CurrentItemName, Mode=OneWay}" />
Text="{x:Bind ViewModel.CurrentItemName, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.QueueSummary, Mode=OneWay}" />
Text="{x:Bind ViewModel.CurrentPreviewStatusLabel, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.QueueSummary, Mode=OneWay}"
TextWrapping="WrapWholeWords"
MaxLines="2" />
</StackPanel>
</Grid>
</Border>
<Border
Grid.Column="1"
Padding="16"
Background="#1E2438"
BorderBrush="#5D4B35"
BorderThickness="1"
Background="#48260A"
BorderBrush="#FFB81C"
BorderThickness="2"
CornerRadius="18">
<StackPanel Spacing="8">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="다음" />
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="{x:Bind ViewModel.NextPreviewWidth, Mode=OneWay}"
Height="{x:Bind ViewModel.NextPreviewHeight, Mode=OneWay}"
VerticalAlignment="Center"
Background="#0B1624"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="8">
<Image
Source="{x:Bind ViewModel.NextPreviewSource, Mode=OneWay}"
Stretch="Uniform" />
</Border>
<StackPanel
Grid.Column="1"
Spacing="6"
VerticalAlignment="Center">
<TextBlock
Foreground="#FFD166"
Style="{StaticResource ConsoleLabelTextStyle}"
Text="다음" />
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="22"
FontSize="20"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.NextItemName, Mode=OneWay}" />
Text="{x:Bind ViewModel.NextItemName, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.NextPreviewStatusLabel, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.LoopSummary, Mode=OneWay}" />
</StackPanel>
</Grid>
</Border>
<Border
@@ -265,15 +326,9 @@
Text="초" />
</StackPanel>
<ToggleSwitch
Grid.Row="1"
Grid.Column="0"
Header="반복"
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
<ComboBox
Grid.Row="1"
Grid.Column="1"
Grid.Column="0"
Width="150"
Header="빈 스케줄"
DisplayMemberPath="Label"
@@ -282,7 +337,7 @@
<Button
Grid.Row="1"
Grid.Column="2"
Grid.Column="1"
Width="22"
Height="22"
MinWidth="22"
@@ -303,9 +358,23 @@
</Button>
</Grid>
<Border
Padding="12"
Background="#101C2E"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="10">
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="선택컷 송출 제어" />
<StackPanel
Orientation="Horizontal"
Spacing="10">
<Button
Command="{x:Bind ViewModel.DirectPrepareCommand}"
Content="준비"
Style="{StaticResource ConsoleGhostButtonStyle}" />
<Button
Command="{x:Bind ViewModel.DirectStartCommand}"
Content="시작"
@@ -315,6 +384,8 @@
Content="정지"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
</StackPanel>
</Border>
<Border
Padding="12"
@@ -709,6 +780,13 @@
HorizontalAlignment="Right"
Orientation="Horizontal"
Spacing="8">
<ToggleSwitch
Header="반복"
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
<Button
Command="{x:Bind ViewModel.SchedulePrepareCommand}"
Content="준비"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Command="{x:Bind ViewModel.StartCommand}"
Content="스케줄 시작"
@@ -758,13 +836,13 @@
Margin="0,0,0,10"
Opacity="{x:Bind CardOpacity, Mode=OneWay}"
Padding="14"
Background="#122033"
BorderBrush="#27405F"
Background="{x:Bind StateCardBackgroundBrush, Mode=OneWay}"
BorderBrush="{x:Bind StateCardBorderBrush, Mode=OneWay}"
BorderThickness="1"
CornerRadius="18">
<Grid ColumnSpacing="14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="8" />
<ColumnDefinition Width="10" />
<ColumnDefinition Width="140" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
@@ -778,7 +856,7 @@
<StackPanel Grid.Column="1" Spacing="6">
<Border
Padding="10,6"
Background="#1A2E47"
Background="{x:Bind StateBadgeBackgroundBrush, Mode=OneWay}"
CornerRadius="12">
<TextBlock
Style="{StaticResource MiniSignalTextStyle}"

View File

@@ -11,6 +11,7 @@ public enum AppPage
TurnoutData,
CountingData,
Data,
CareerPromiseData,
CutList,
Settings,
Log

View File

@@ -69,6 +69,21 @@ public sealed class CandidateEntry : ObservableObject
[JsonIgnore]
public double? BroadcastCountedRate { get; set; }
[JsonIgnore]
public int BroadcastRank { get; set; }
[JsonIgnore]
public int BroadcastSeatCount { get; set; }
[JsonIgnore]
public bool BroadcastCountingClosed { get; set; }
[JsonIgnore]
public bool IsWithinBroadcastSeatCount =>
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,

View File

@@ -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,35 @@ 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
: string.IsNullOrWhiteSpace(_renderedPreviewStatusLabel)
? "실데이터 프리뷰 준비 중"
: _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 +275,61 @@ 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 UpdateRenderedPreviewStatus(string statusLabel)
{
_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 +353,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

View File

@@ -8,6 +8,15 @@ public enum CutCategory
MetropolitanCouncil,
LocalCouncil,
NationalAssembly,
BottomTopTwo,
BottomTopThree,
BottomCurrentLeader,
BottomWinner,
BottomAllCandidates,
BottomTurnoutSido,
BottomTurnoutDistrict,
BottomEarlyTurnout,
BottomElectionDayTurnout,
PreElection,
Historical,
Turnout,

View File

@@ -32,6 +32,8 @@ public sealed class ElectionDataSnapshot
public required DateTimeOffset ReceivedAt { get; init; }
public string ReferenceTimeLabel { get; init; } = string.Empty;
public IReadOnlyList<PreElectionHistoricalTurnoutEntry> HistoricalTurnoutHistory { get; init; } =
Array.Empty<PreElectionHistoricalTurnoutEntry>();
@@ -72,4 +74,5 @@ public sealed record TurnoutBoardSlotEntry(
string Label,
double TurnoutRate,
bool IsNational = false,
string RegionLabel = "");
string RegionLabel = "",
bool HasTurnoutData = true);

View File

@@ -3,6 +3,7 @@ namespace Tornado3_2026Election.Domain;
public enum VideoWallLayoutPreset
{
Auto,
Standard5760x1080,
UltraWide11520x1080
Wall3840x810,
Wall2880x1080,
UltraWide8316x1080
}

View File

@@ -44,13 +44,14 @@
</Border>
</NavigationView.PaneHeader>
<NavigationView.MenuItems>
<NavigationViewItem Content="노멀" Tag="normal" Visibility="{x:Bind ViewModel.NormalMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Play" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="좌상단" Tag="top-left" Visibility="{x:Bind ViewModel.TopLeftMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="PreviewLink" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="하단" Tag="bottom" Visibility="{x:Bind ViewModel.BottomMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Download" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="비디오월" Tag="videowall" Visibility="{x:Bind ViewModel.VideoWallMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Video" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="노멀" Tag="normal" Visibility="{x:Bind ViewModel.NormalMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.NormalChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="Play" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="좌상단" Tag="top-left" Visibility="{x:Bind ViewModel.TopLeftMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.TopLeftChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="PreviewLink" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="하단" Tag="bottom" Visibility="{x:Bind ViewModel.BottomMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.BottomChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="Download" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="비디오월" Tag="videowall" Visibility="{x:Bind ViewModel.VideoWallMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.VideoWallChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="Video" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="사전데이터" Tag="pre-election-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Library" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="투표데이터" Tag="turnout-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="표데이터" Tag="counting-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="공약데이터" Tag="career-promises"><NavigationViewItem.Icon><SymbolIcon Symbol="Contact" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="표데이터" Tag="turnout-data"><NavigationViewItem.Icon><PathIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Data="M4,2 H13 V3 H4 Z M4,2 H5 V22 H4 Z M4,21 H18 V22 H4 Z M17,7 H18 V22 H17 Z M13,2 L18,7 H13 Z M6,8 H8 V10 H6 Z M10,8 H15 V9 H10 Z M6,12 H8 V14 H6 Z M10,12 H15 V13 H10 Z M6,16 H8 V18 H6 Z M10,16 H14 V17 H10 Z" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="개표데이터" Tag="counting-data"><NavigationViewItem.Icon><PathIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Data="M10,2 H15 V8 H10 Z M11,4 H14 V5 H11 Z M4,9 H20 V11 H4 Z M6,7 L10,5 L11,6 L7,8 Z M18,7 L14,5 L13,6 L17,8 Z M5,12 H19 L17,21 H7 Z M7,14 L8,19 H16 L17,14 Z" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="컷리스트" Tag="cut-list"><NavigationViewItem.Icon><SymbolIcon Symbol="Bullets" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="설정" Tag="settings"><NavigationViewItem.Icon><SymbolIcon Symbol="Setting" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="로그" Tag="log"><NavigationViewItem.Icon><SymbolIcon Symbol="Document" /></NavigationViewItem.Icon></NavigationViewItem>
@@ -812,23 +813,66 @@
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.CareerPromiseDataVisibility, Mode=OneWay}">
<StackPanel Spacing="20">
<Border Padding="20"
Background="{StaticResource ControlRoomPanelGradientBrush}"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="24"
Visibility="{x:Bind ViewModel.Data.CareerPromiseVisibility, Mode=OneWay}">
CornerRadius="24">
<StackPanel Spacing="16">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="공약데이터" />
<Grid ColumnSpacing="12" RowSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220" />
<ColumnDefinition Width="260" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ComboBox Header="선거 종류"
DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.Data.CareerPromiseElectionTypeOptions, Mode=OneWay}"
SelectedValue="{x:Bind ViewModel.Data.ElectionType, Mode=TwoWay}"
SelectedValuePath="Value" />
<ComboBox Grid.Column="1"
Header="선거구명"
DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.Data.DistrictViewOptions, Mode=OneWay}"
SelectedValue="{x:Bind ViewModel.Data.SelectedDistrictViewName, Mode=TwoWay}"
SelectedValuePath="Value" />
<StackPanel Grid.Column="2" VerticalAlignment="Bottom" Spacing="4">
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.Data.CareerPromiseContextText, Mode=OneWay}"
TextWrapping="Wrap" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.Data.StatusText, Mode=OneWay}"
TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="3"
Command="{x:Bind ViewModel.Data.ManualRefreshCommand}"
Content="후보 갱신"
Style="{StaticResource ConsolePrimaryButtonStyle}"
VerticalAlignment="Bottom" />
</Grid>
</StackPanel>
</Border>
<Border Padding="20"
Background="{StaticResource ControlRoomPanelGradientBrush}"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="24">
<StackPanel Spacing="14">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="경력 컷 공약" />
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="4">
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.Data.CareerPromiseContextText, Mode=OneWay}"
TextWrapping="Wrap" />
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="후보 공약" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.Data.CareerPromiseStatusText, Mode=OneWay}"
TextWrapping="Wrap" />
@@ -837,9 +881,9 @@
TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1"
Command="{x:Bind ViewModel.Data.SaveCareerPromisesCommand}"
Content="공약 저장"
Style="{StaticResource ConsoleGhostButtonStyle}" />
Command="{x:Bind ViewModel.Data.AddCareerPromiseRowCommand}"
Content="행 추가"
Style="{StaticResource ConsolePrimaryButtonStyle}" />
</Grid>
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.Data.CareerPromiseSaveStateText, Mode=OneWay}"
@@ -852,41 +896,49 @@
VerticalScrollBarVisibility="Disabled"
VerticalScrollMode="Disabled">
<StackPanel Spacing="0">
<Grid MinWidth="1250">
<Grid MinWidth="1480">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="190" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="150" />
<ColumnDefinition Width="180" />
<ColumnDefinition Width="170" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="260" />
<ColumnDefinition Width="260" />
<ColumnDefinition Width="280" />
<ColumnDefinition Width="90" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="후보코드" /></Border>
<Border Grid.Column="1" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="이름" /></Border>
<Border Grid.Column="2" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="정당" /></Border>
<Border Grid.Column="3" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 1" /></Border>
<Border Grid.Column="4" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 2" /></Border>
<Border Grid.Column="5" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 3" /></Border>
<Border Grid.Column="0" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="선거구명" /></Border>
<Border Grid.Column="1" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="후보코드" /></Border>
<Border Grid.Column="2" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="이름" /></Border>
<Border Grid.Column="3" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="정당" /></Border>
<Border Grid.Column="4" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 1" /></Border>
<Border Grid.Column="5" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 2" /></Border>
<Border Grid.Column="6" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 3" /></Border>
<Border Grid.Column="7" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="삭제" /></Border>
</Grid>
<ItemsControl ItemsSource="{x:Bind ViewModel.Data.CareerPromiseRows, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CareerPromiseEditRowViewModel">
<Grid MinWidth="1250">
<Grid MinWidth="1480">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="190" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="150" />
<ColumnDefinition Width="180" />
<ColumnDefinition Width="170" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="260" />
<ColumnDefinition Width="260" />
<ColumnDefinition Width="280" />
<ColumnDefinition Width="90" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind CandidateCode}" TextWrapping="WrapWholeWords" /></Border>
<Border Grid.Column="1" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind CandidateName}" TextWrapping="WrapWholeWords" /></Border>
<Border Grid.Column="2" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind Party}" TextWrapping="WrapWholeWords" /></Border>
<Border Grid.Column="3" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="첫 번째 공약" Text="{x:Bind Promise1, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
<Border Grid.Column="4" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText=" 번째 공약" Text="{x:Bind Promise2, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
<Border Grid.Column="5" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText=" 번째 공약" Text="{x:Bind Promise3, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
<Border Grid.Column="0" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="선거구명" Text="{x:Bind DistrictName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
<Border Grid.Column="1" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="후보코드" Text="{x:Bind CandidateCode, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
<Border Grid.Column="2" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="이름" Text="{x:Bind CandidateName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
<Border Grid.Column="3" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="정당" Text="{x:Bind Party, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
<Border Grid.Column="4" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText=" 번째 공약" Text="{x:Bind Promise1, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
<Border Grid.Column="5" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText=" 번째 공약" Text="{x:Bind Promise2, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
<Border Grid.Column="6" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="세 번째 공약" Text="{x:Bind Promise3, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
<Border Grid.Column="7" Padding="8" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><Button Command="{x:Bind DeleteCommand}" Content="삭제" Style="{StaticResource ConsoleGhostButtonStyle}" /></Border>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -1190,6 +1242,47 @@
</Border>
</Grid>
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="14">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="Karisma 레이어" />
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<NumberBox Header="노멀"
Maximum="99"
Minimum="0"
SmallChange="1"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.Settings.NormalLayerNo, Mode=TwoWay}" />
<NumberBox Grid.Column="1"
Header="좌상단"
Maximum="99"
Minimum="0"
SmallChange="1"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.Settings.TopLeftLayerNo, Mode=TwoWay}" />
<NumberBox Grid.Column="2"
Header="하단"
Maximum="99"
Minimum="0"
SmallChange="1"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.Settings.BottomLayerNo, Mode=TwoWay}" />
<NumberBox Grid.Column="3"
Header="비디오월"
Maximum="99"
Minimum="0"
SmallChange="1"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.Settings.VideoWallLayerNo, Mode=TwoWay}" />
</Grid>
</StackPanel>
</Border>
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="14">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="권역 설정" />

View File

@@ -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",

View File

@@ -11,7 +11,7 @@
<Identity
Name="8472d715-ce0c-4ed2-8f7d-7e330428ce82"
Publisher="CN=Comtrophy"
Version="1.0.3.0" />
Version="1.0.3.1" />
<mp:PhoneIdentity PhoneProductId="8472d715-ce0c-4ed2-8f7d-7e330428ce82" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>

View File

@@ -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;

View File

@@ -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<CareerPromiseCatalog>(json, SerializerOptions)
?? new CareerPromiseCatalog();
return NormalizeCatalog(JsonSerializer.Deserialize<CareerPromiseCatalog>(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<CareerPromiseEntry> SafeEntries(IEnumerable<CareerPromiseEntry>? entries)
{
return entries?.Where(entry => entry is not null) ?? Array.Empty<CareerPromiseEntry>();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,16 +8,25 @@ public static class CutCategoryResolver
{
private static readonly IReadOnlyList<CutCategory> OrderedCategories =
[
CutCategory.Title,
CutCategory.MetropolitanHead,
CutCategory.LocalHead,
CutCategory.Superintendent,
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,
CutCategory.Title
CutCategory.Turnout
];
public static IReadOnlyList<CutCategory> GetOrderedCategories() => OrderedCategories;
@@ -28,13 +37,23 @@ public static class CutCategoryResolver
return category switch
{
CutCategory.MetropolitanHead => Contains(formatName, "광역단체장"),
CutCategory.MetropolitanHead => IsMetropolitanHeadFormat(formatName),
CutCategory.LocalHead => Contains(formatName, "기초단체장"),
CutCategory.Superintendent => Contains(formatName, "교육감"),
CutCategory.MetropolitanCouncil => Contains(formatName, "광역의원"),
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,45 @@ public static class CutCategoryResolver
{
return value.Contains(token, StringComparison.Ordinal);
}
private static bool IsMetropolitanHeadFormat(string formatName)
{
return Contains(formatName, "광역단체장") ||
string.Equals(formatName, "사전_역대당선자", 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));
}
}

View File

@@ -9,13 +9,16 @@ public sealed class CutDebugStateStore
private readonly object _syncRoot = new();
private readonly Dictionary<BroadcastChannel, CutDebugSettings> _settingsByChannel = new();
private readonly Dictionary<string, CutDebugTemplateState> _templateStates = new(StringComparer.Ordinal);
private bool _isDebugFeatureEnabled = true;
private bool _isDebugFeatureEnabled;
public CutDebugStateStore()
{
foreach (var channel in Enum.GetValues<BroadcastChannel>())
{
_settingsByChannel[channel] = new CutDebugSettings();
_settingsByChannel[channel] = new CutDebugSettings
{
IsFeatureEnabled = _isDebugFeatureEnabled
};
}
}

View File

@@ -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;
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
@@ -69,6 +70,19 @@ public static class CutThumbnailAssetCatalog
}
public static bool HasThumbnail(string templateId)
{
foreach (var candidateTemplateId in EnumerateThumbnailTemplateIds(templateId))
{
if (HasThumbnailPath(candidateTemplateId))
{
return true;
}
}
return false;
}
private static bool HasThumbnailPath(string templateId)
{
var projectPath = TryGetProjectAssetPath(templateId);
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
@@ -99,21 +113,88 @@ public static class CutThumbnailAssetCatalog
public static string ResolvePreferredDisplayPath(string templateId)
{
var projectPath = TryGetProjectAssetPath(templateId);
foreach (var candidateTemplateId in EnumerateThumbnailTemplateIds(templateId))
{
var projectPath = TryGetProjectAssetPath(candidateTemplateId);
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
{
return projectPath;
}
var bundledPath = GetBundledAssetPath(templateId);
var bundledPath = GetBundledAssetPath(candidateTemplateId);
if (File.Exists(bundledPath))
{
return bundledPath;
}
}
return Path.Combine(AppContext.BaseDirectory, FallbackAssetPath);
}
private static IEnumerable<string> EnumerateThumbnailTemplateIds(string templateId)
{
var preferredTemplateId = ResolvePreferredThumbnailTemplateId(templateId);
if (!string.Equals(preferredTemplateId, templateId, StringComparison.Ordinal))
{
yield return preferredTemplateId;
yield return templateId;
yield break;
}
yield return templateId;
var fallbackTemplateId = ResolveThumbnailTemplateId(templateId);
if (!string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
{
yield return fallbackTemplateId;
}
}
private static string ResolvePreferredThumbnailTemplateId(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);
if (folder is not null &&
string.Equals(folder, "Elect2026_Normal_민방", StringComparison.Ordinal) &&
string.Equals(fileName, "투표율", StringComparison.Ordinal))
{
return Path.Combine(folder, "투표율_사진");
}
return templateId;
}
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);

View File

@@ -59,11 +59,13 @@ public sealed class FormatCatalogService
"당선_광역의원",
"당선_기초단체장",
"당선_기초의원",
"사전투표율",
"사전투표율_시도",
"사전투표율_시군구",
"전후보_광역단체장",
"전후보_교육감",
"전후보_기초단체장",
"투표율"));
"투표율_시도",
"투표율_시군구"));
formats.AddRange(CreateFormats(
BroadcastChannel.Normal,
@@ -84,6 +86,12 @@ public sealed class FormatCatalogService
"1-3위_ani_기초단체장",
"1-3위_기초단체장_5760",
"1-3위_보궐선거",
"2880_광역의원표",
"2880_기초의원표",
"810_광역의원표",
"810_기초의원표",
"8316_광역의원표",
"8316_기초의원표",
"경력_광역단체장_in",
"경력_기초단체장_in",
"광역의원표",
@@ -127,10 +135,8 @@ public sealed class FormatCatalogService
"접전_기초단체장",
"초접전_광역단체장",
"초접전_기초단체장",
"투표율_사진",
"투표율",
"투표율_선거구별 사전",
"투표율_시도별",
"투표율_영상",
"판세_광역단체장",
"판세_기초단체장",
"판세_기초단체장_5760"));
@@ -166,12 +172,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 +202,30 @@ public sealed class FormatCatalogService
DurationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
defaultCutDurationSeconds,
recommendedChannel,
baseName)
baseName),
SceneIdOverride = sceneIdOverride
}
]
};
}
}
private static string? ResolveSceneIdOverride(string relativeFolder, string baseName)
{
if (string.Equals(relativeFolder, "Elect2026_Normal_민방", StringComparison.Ordinal) &&
string.Equals(baseName, "투표율", StringComparison.Ordinal))
{
return Path.Combine(relativeFolder, "투표율_사진");
}
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 +245,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"),
@@ -270,9 +298,12 @@ public sealed class FormatCatalogService
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L_1")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
[Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
[Path.Combine("Elect2026_Normal_민방", "투표율_사진")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_영상")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_7680")] = Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_5760"),
[Path.Combine("Elect2026_Top_민방", "투표율_loop")] = Path.Combine("Elect2026_Top_민방", "투표율"),
[Path.Combine("Elect2026_Top_민방", "투표율_선거구별_loop")] = Path.Combine("Elect2026_Top_민방", "투표율_선거구별")
@@ -281,7 +312,9 @@ public sealed class FormatCatalogService
private static bool IsAvailableInBothPhases(string baseName)
{
return baseName.StartsWith("사전_역대당선", StringComparison.Ordinal);
return ScheduleTemplatePolicy.IsTitleFormat(baseName) ||
baseName.StartsWith("사전_역대당선", StringComparison.Ordinal) ||
baseName.StartsWith("경력_", StringComparison.Ordinal);
}
private static bool IsHistoricalPreElectionWinnerFormat(string baseName)
@@ -311,18 +344,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 +375,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;

View File

@@ -32,9 +32,34 @@ public interface ITornado3Adapter
string imageRootPath,
CancellationToken cancellationToken);
Task<bool> TryCapturePendingCutPreviewAsync(
BroadcastChannel channel,
string fileName,
int width,
int height,
int frame,
CancellationToken cancellationToken);
Task<bool> 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 ShowPreparedFirstFrameAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task ClearOutputAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken);
}

View File

@@ -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);

View File

@@ -0,0 +1,12 @@
using KAsyncEngineLib;
namespace Tornado3_2026Election.Services;
public readonly record struct KarismaCropKeyUpdate(
string ObjectName,
int KeyIndex,
float Left,
float Top,
float Right,
float Bottom,
eKCropKey CropKey);

View File

@@ -13,10 +13,18 @@ public class KarismaEventHandler : KAEventHandler
private readonly Action<int>? _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<int>? _pendingConnect;
private readonly Dictionary<string, TaskCompletionSource<eKResult>> _pendingLoadScenes = new(StringComparer.OrdinalIgnoreCase);
private TaskCompletionSource<eKResult>? _pendingEndTransaction;
private readonly Dictionary<string, TaskCompletionSource<eKResult>> _pendingUpdateTextures = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<(int OutputChannelIndex, int LayerNo), TaskCompletionSource<eKResult>> _pendingScenePrepares = new();
private TaskCompletionSource<(eKResult Result, string SceneName)>? _pendingSaveSceneImage;
private TaskCompletionSource<(eKResult Result, int OutputChannelIndex, int LayerNo)>? _pendingSaveMixedPreviewImage;
public KarismaEventHandler(LogService logService, Action<int>? onConnect = null, Action<int>? onClose = null)
{
@@ -149,6 +157,106 @@ public class KarismaEventHandler : KAEventHandler
}
}
public Task<eKResult> BeginEndTransactionWait()
{
lock (_endTransactionSync)
{
if (_pendingEndTransaction is not null)
{
throw new InvalidOperationException("Another EndTransaction request is already pending.");
}
_pendingEndTransaction = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
return _pendingEndTransaction.Task;
}
}
public void CancelPendingEndTransaction(Exception? error = null)
{
TaskCompletionSource<eKResult>? completion;
lock (_endTransactionSync)
{
completion = _pendingEndTransaction;
_pendingEndTransaction = null;
}
CompleteOrCancel(completion, error);
}
public Task<eKResult> 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<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingUpdateTextures[sceneName] = completion;
return completion.Task;
}
}
public void CancelPendingUpdateTextures(string sceneName, Exception? error = null)
{
if (string.IsNullOrWhiteSpace(sceneName))
{
return;
}
TaskCompletionSource<eKResult>? completion;
lock (_updateTexturesSync)
{
if (!_pendingUpdateTextures.TryGetValue(sceneName, out completion))
{
return;
}
_pendingUpdateTextures.Remove(sceneName);
}
CompleteOrCancel(completion, error);
}
public Task<eKResult> 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<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingScenePrepares[key] = completion;
return completion.Task;
}
}
public void CancelPendingScenePrepare(int outputChannelIndex, int layerNo, Exception? error = null)
{
TaskCompletionSource<eKResult>? 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,45 @@ 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 +349,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<eKResult>? 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 +392,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 +439,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<eKResult>? 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) { }
@@ -316,7 +511,7 @@ public class KarismaEventHandler : KAEventHandler
virtual public void OnSetCylinderAngleKey(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetSphereAngleKey(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetCircleAngleKey(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetCropKey(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetCropKey(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetCropKey), Result, $"scene={SceneName} object={ObjectName}");
virtual public void OnSetCountDown(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetPosition(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetRotation(eKResult Result, string SceneName, string ObjectName) { }
@@ -336,7 +531,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 +646,23 @@ public class KarismaEventHandler : KAEventHandler
completion.TrySetResult(result);
}
private void CompletePendingScenePrepare(int outputChannelIndex, int layerNo, eKResult result)
{
TaskCompletionSource<eKResult>? 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<int>? completion;
@@ -462,4 +674,79 @@ public class KarismaEventHandler : KAEventHandler
completion?.TrySetResult(errorCode);
}
private static void CancelPendingSceneOperation(
object syncRoot,
Dictionary<string, TaskCompletionSource<eKResult>> pendingOperations,
string sceneName,
Exception? error)
{
if (string.IsNullOrWhiteSpace(sceneName))
{
return;
}
TaskCompletionSource<eKResult>? completion;
lock (syncRoot)
{
if (!pendingOperations.TryGetValue(sceneName, out completion))
{
return;
}
pendingOperations.Remove(sceneName);
}
CompleteOrCancel(completion, error);
}
private static void CompletePendingSceneOperation(
object syncRoot,
Dictionary<string, TaskCompletionSource<eKResult>> pendingOperations,
string sceneName,
eKResult result)
{
TaskCompletionSource<eKResult>? completion = null;
lock (syncRoot)
{
if (!string.IsNullOrWhiteSpace(sceneName) &&
pendingOperations.TryGetValue(sceneName, out completion))
{
pendingOperations.Remove(sceneName);
}
else if (pendingOperations.Count == 1)
{
string? keyToRemove = null;
foreach (var pair in pendingOperations)
{
keyToRemove = pair.Key;
completion = pair.Value;
break;
}
if (!string.IsNullOrWhiteSpace(keyToRemove))
{
pendingOperations.Remove(keyToRemove);
}
}
}
completion?.TrySetResult(result);
}
private static void CompleteOrCancel<TResult>(TaskCompletionSource<TResult>? completion, Exception? error)
{
if (completion is null)
{
return;
}
if (error is null)
{
completion.TrySetCanceled();
return;
}
completion.TrySetException(error);
}
}

View File

@@ -7,4 +7,5 @@ public readonly record struct KarismaPositionUpdate(
float X,
float Y,
float Z,
eKVectorType VectorType);
eKVectorType VectorType,
int KeyIndex = -1);

View File

@@ -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")));
}
}

View File

@@ -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;
}

View File

@@ -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<ThumbnailGenerationResult> GenerateAsync(
TornadoManager manager,
IReadOnlyList<FormatTemplateDefinition> 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))

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
@@ -7,6 +8,12 @@ namespace Tornado3_2026Election.Services;
public sealed class LogService
{
private const int MaxEntries = 400;
private const long MaxDebugLogBytes = 2 * 1024 * 1024;
private static readonly object FileSync = new();
private static readonly string DebugLogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Tornado3_2026Election",
"debug.log");
public ObservableCollection<LogEntry> Entries { get; } = [];
@@ -20,6 +27,8 @@ public sealed class LogService
private void Add(LogLevel level, string message)
{
WriteDebugLog(level, message);
Common.UiDispatcher.Enqueue(() =>
{
Entries.Insert(0, new LogEntry
@@ -35,4 +44,32 @@ public sealed class LogService
}
});
}
private static void WriteDebugLog(LogLevel level, string message)
{
try
{
lock (FileSync)
{
var directory = Path.GetDirectoryName(DebugLogPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
if (File.Exists(DebugLogPath) && new FileInfo(DebugLogPath).Length > MaxDebugLogBytes)
{
File.Delete(DebugLogPath);
}
File.AppendAllText(
DebugLogPath,
$"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz} [{level}] {message}{Environment.NewLine}");
}
}
catch
{
// Logging must not affect live CG control.
}
}
}

View File

@@ -74,6 +74,33 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
}, cancellationToken).ConfigureAwait(false);
}
public Task<bool> TryCapturePendingCutPreviewAsync(
BroadcastChannel channel,
string fileName,
int width,
int height,
int frame,
CancellationToken cancellationToken)
{
return Task.FromResult(false);
}
public Task<bool> TryCaptureCutPreviewAsync(
BroadcastChannel channel,
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
BroadcastStationProfile station,
string imageRootPath,
string fileName,
int width,
int height,
int frame,
CancellationToken cancellationToken)
{
return Task.FromResult(false);
}
public async Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
@@ -84,6 +111,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
}, cancellationToken).ConfigureAwait(false);
}
public async Task ShowPreparedFirstFrameAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
{
State = TornadoConnectionState.Ready;
await Task.Delay(40, cancellationToken).ConfigureAwait(false);
_logService.Info($"[{channel}] Show prepared first frame on PGM");
}, cancellationToken).ConfigureAwait(false);
}
public async Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
@@ -94,6 +131,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
}, cancellationToken).ConfigureAwait(false);
}
public async Task ClearOutputAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
{
State = TornadoConnectionState.Idle;
await Task.Delay(30, cancellationToken).ConfigureAwait(false);
_logService.Info($"[{channel}] Clear output layer");
}, cancellationToken).ConfigureAwait(false);
}
public async Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>

View File

@@ -49,6 +49,33 @@ internal static class PartyColorCatalog
return GenerateSolidColorPng(templateName, partyName, usage, sectionName, color);
}
public static string ResolveFallbackAssetPathForSection(
string templateFolderPath,
string templateName,
string sectionName,
string partyName,
PartyColorAssetUsage usage)
{
var catalog = LoadCatalog(templateFolderPath, templateName);
if (catalog is null ||
string.IsNullOrWhiteSpace(sectionName) ||
string.IsNullOrWhiteSpace(partyName) ||
!catalog.Sections.TryGetValue(NormalizeSectionKey(sectionName), out var section))
{
return string.Empty;
}
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName).Concat(OtherPartyFallbackKeys))
{
if (section.PartyColors.TryGetValue(candidatePartyName, out var color))
{
return GenerateSolidColorPng(templateName, partyName, usage, section.DisplayName, color);
}
}
return string.Empty;
}
public static bool HasStyleColorBinding(string templateFolderPath, string templateName, string sectionName)
{
var catalog = LoadCatalog(templateFolderPath, templateName);
@@ -129,7 +156,12 @@ internal static class PartyColorCatalog
var folderName = Path.GetFileName(Path.GetFullPath(templateFolderPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (TryGetExplicitRgbSpecBaseName(folderName, templateName, out var explicitSpecBaseName) &&
!string.IsNullOrWhiteSpace(explicitSpecBaseName))
string.IsNullOrWhiteSpace(explicitSpecBaseName))
{
return null;
}
if (!string.IsNullOrWhiteSpace(explicitSpecBaseName))
{
var explicitSpecPath = Path.Combine(rgbDirectoryPath, explicitSpecBaseName + ".txt");
if (File.Exists(explicitSpecPath))
@@ -238,6 +270,11 @@ internal static class PartyColorCatalog
if (line.StartsWith("(", StringComparison.Ordinal))
{
if (inHeader)
{
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
}
headerBuilder.Clear();
headerBuilder.AppendLine(line);
inHeader = !line.Contains(')');
@@ -250,6 +287,8 @@ internal static class PartyColorCatalog
}
if (inHeader)
{
if (IsHeaderContinuationLine(line))
{
headerBuilder.AppendLine(line);
if (line.Contains(')'))
@@ -261,6 +300,10 @@ internal static class PartyColorCatalog
continue;
}
inHeader = false;
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
}
if (currentSectionHeaders is null || currentSectionHeaders.Count == 0 || line.StartsWith("R", StringComparison.OrdinalIgnoreCase))
{
continue;
@@ -301,6 +344,17 @@ internal static class PartyColorCatalog
StringComparer.OrdinalIgnoreCase));
}
private static bool IsHeaderContinuationLine(string line)
{
if (line.Contains(')'))
{
return true;
}
var normalizedLine = line.Trim().Trim('(', ')').Trim();
return TryParseSectionHeaderLine(normalizedLine, out _, out _);
}
private static List<SectionHeaderEntry> ExtractSectionHeaders(string header)
{
var entries = new List<SectionHeaderEntry>();
@@ -844,7 +898,12 @@ internal static class PartyColorCatalog
"Elect2026_Normal_민방",
"이시각1위_광역단체장",
"이시각1위_광역단체장",
"이시각1위_광역단체장_HD",
"이시각1위_광역단체장_HD");
Add(
mappings,
"Elect2026_Normal_민방",
"이시각1위_광역단체장_5760",
"이시각1위_광역단체장_5760",
"이시각1위_광역단체장_L");
Add(
mappings,
@@ -867,8 +926,15 @@ internal static class PartyColorCatalog
"판세_광역단체장",
"판세_광역단체장",
"판세_기초단체장",
"역대시도판세_광역단체장",
"역대시도판세_기초단체장",
"판세_기초단체장_5760",
"판세_기초단체장_7680");
Add(
mappings,
"Elect2026_Normal_민방",
string.Empty,
"사전_역대투표율");
Add(
mappings,
@@ -912,7 +978,6 @@ internal static class PartyColorCatalog
"1-2위_텍스트",
"광역단체장_2인_텍스트",
"기초단체장_2인_텍스트");
return mappings;
}
}

View File

@@ -327,9 +327,42 @@ public sealed class PreElectionHistoryService
}
}
if (string.Equals(canonicalElectionType, SupportedElectionTypes[2], StringComparison.Ordinal))
{
return ResolveUniqueBasicDistrictHistory(canonicalElectionType, regionName, districtName);
}
return null;
}
private PreElectionHistoryRecord? ResolveUniqueBasicDistrictHistory(string electionType, string? regionName, string? districtName)
{
if (!_recordsByElectionType.TryGetValue(electionType, out var records))
{
return null;
}
var districtTokens = new[] { districtName, regionName }
.Select(NormalizeBasicDistrictToken)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (districtTokens.Length == 0)
{
return null;
}
var matches = records
.Where(record => districtTokens.Contains(NormalizeBasicDistrictToken(record.DistrictName), StringComparer.OrdinalIgnoreCase) ||
districtTokens.Contains(NormalizeBasicDistrictToken(record.DisplayName), StringComparer.OrdinalIgnoreCase))
.GroupBy(record => record.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.Take(2)
.ToArray();
return matches.Length == 1 ? matches[0] : null;
}
public static string NormalizeElectionType(string? electionType)
{
return electionType switch
@@ -347,18 +380,7 @@ public sealed class PreElectionHistoryService
return string.Empty;
}
var trimmed = value.Trim();
foreach (var regionLabel in RegionLabels)
{
if (trimmed.Contains(regionLabel, StringComparison.OrdinalIgnoreCase))
{
return RegionAliases[regionLabel];
}
}
return RegionAliases.TryGetValue(trimmed, out var normalized)
? normalized
: string.Empty;
return NormalizeRegionKeys(value).FirstOrDefault() ?? string.Empty;
}
private static IReadOnlyDictionary<string, PreElectionHistoryRecord> BuildLookupIndex(
@@ -406,7 +428,7 @@ public sealed class PreElectionHistoryService
}
}
var regionKey = ResolveRegionKey(regionName, districtName, additionalValues);
var regionKeys = ResolveRegionKeys(regionName, districtName, additionalValues);
var districtKeys = new[]
{
NormalizeBasicDistrictToken(districtName),
@@ -418,6 +440,8 @@ public sealed class PreElectionHistoryService
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var regionKey in regionKeys)
{
foreach (var districtKey in districtKeys)
{
var combined = BuildBasicLookupKey(regionKey, districtKey);
@@ -426,6 +450,7 @@ public sealed class PreElectionHistoryService
yield return combined;
}
}
}
yield break;
}
@@ -463,6 +488,41 @@ public sealed class PreElectionHistoryService
return string.Empty;
}
private static IReadOnlyList<string> ResolveRegionKeys(string? regionName, string? districtName, IEnumerable<string?> additionalValues)
{
return new[] { regionName, districtName }
.Concat(additionalValues)
.SelectMany(NormalizeRegionKeys)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IEnumerable<string> NormalizeRegionKeys(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
yield break;
}
var trimmed = value.Trim();
var yielded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var regionLabel in RegionLabels)
{
if (trimmed.Contains(regionLabel, StringComparison.OrdinalIgnoreCase) &&
RegionAliases.TryGetValue(regionLabel, out var normalized) &&
yielded.Add(normalized))
{
yield return normalized;
}
}
if (RegionAliases.TryGetValue(trimmed, out var exactMatch) && yielded.Add(exactMatch))
{
yield return exactMatch;
}
}
private static string BuildBasicLookupKey(string regionKey, string districtKey)
{
if (string.IsNullOrWhiteSpace(regionKey) || string.IsNullOrWhiteSpace(districtKey))
@@ -483,7 +543,7 @@ public sealed class PreElectionHistoryService
var normalized = StripBasicDistrictDisambiguation(value.Trim());
foreach (var regionLabel in RegionLabels)
{
normalized = normalized.Replace(regionLabel, string.Empty, StringComparison.OrdinalIgnoreCase);
normalized = RemoveLeadingRegionLabel(normalized, regionLabel);
}
normalized = normalized
@@ -498,6 +558,22 @@ public sealed class PreElectionHistoryService
return normalized;
}
private static string RemoveLeadingRegionLabel(string value, string regionLabel)
{
if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(regionLabel))
{
return value;
}
var trimmed = value.TrimStart();
if (!trimmed.StartsWith(regionLabel, StringComparison.OrdinalIgnoreCase))
{
return value;
}
return trimmed[regionLabel.Length..].TrimStart();
}
private static string NormalizeBasicDistrictDisplayName(
string? districtName,
string? displayName,

View File

@@ -86,15 +86,15 @@ public sealed class SbsElectionApiClient : IDisposable
["충청북도"] = "43",
["충남"] = "44",
["충청남도"] = "44",
["전남"] = "29",
["전라남도"] = "29",
["전남"] = "46",
["전라남도"] = "46",
["경북"] = "47",
["경상북도"] = "47",
["경남"] = "48",
["경상남도"] = "48",
["제주"] = "50",
["제주도"] = "50",
["제주특별자치도"] = "50",
["제주"] = "49",
["제주도"] = "49",
["제주특별자치도"] = "49",
["강원"] = "52",
["강원도"] = "52",
["강원특별자치도"] = "52",
@@ -240,8 +240,10 @@ public sealed class SbsElectionApiClient : IDisposable
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes);
var seatCount = Math.Max(0, item.Region?.SeatCount ?? 0);
var countingClosed = item.GaepyoMagam;
var judgementCandidates = (item.Hubojas ?? [])
.Select(MapCandidate)
.Select(candidate => MapCandidate(candidate, seatCount, countingClosed))
.Where(candidate => candidate.EffectiveJudgement != CandidateJudgement.None)
.OrderBy(candidate => ResolveJudgementDisplayPriority(candidate.EffectiveJudgement))
.ThenByDescending(candidate => candidate.VoteCount)
@@ -250,6 +252,7 @@ public sealed class SbsElectionApiClient : IDisposable
overviewItems.Add((order, new CountingOverviewItem(
DisplayName: districtOption.DisplayName,
DistrictCode: districtOption.DistrictCode,
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
CountedVotes: countedVotes,
TotalVotes: totalVotes,
@@ -400,6 +403,7 @@ public sealed class SbsElectionApiClient : IDisposable
var turnoutRate = electors <= 0
? 0
: Math.Round(voters * 100d / electors, 1, MidpointRounding.AwayFromZero);
var referenceTimeLabel = FormatSbsReportTimeLabel(item.LastReportTime);
turnoutItems.Add((order, new TurnoutOverviewItem(
districtOption.DisplayName,
@@ -408,10 +412,12 @@ public sealed class SbsElectionApiClient : IDisposable
districtOption.DistrictCode,
electors,
voters,
turnoutRate)));
turnoutRate,
referenceTimeLabel)));
}
}
var overviewReferenceTimeLabel = ResolveLatestReferenceTimeLabel(turnoutItems.Select(item => item.Item.ReferenceTimeLabel));
return new TurnoutOverviewResult(
turnoutItems
.OrderBy(item => item.Order)
@@ -419,7 +425,8 @@ public sealed class SbsElectionApiClient : IDisposable
.ToArray(),
totalExpectedVotes,
turnoutVotes,
DateTimeOffset.Now);
DateTimeOffset.Now,
overviewReferenceTimeLabel);
}
private static string ResolveTurnoutRegionCode(
@@ -490,9 +497,12 @@ public sealed class SbsElectionApiClient : IDisposable
CountedRate: null,
CountedVotes: null,
RemainingVotes: null,
SeatCount: 0,
CountingClosed: false,
Candidates: null,
ReceivedAt: DateTimeOffset.Now,
SourcePath: $"GET /tupyo/{turnoutQuery.SungerType}/{turnoutQuery.RegionSegment}?ids={turnoutTarget.TurnoutRegionCode}");
SourcePath: $"GET /tupyo/{turnoutQuery.SungerType}/{turnoutQuery.RegionSegment}?ids={turnoutTarget.TurnoutRegionCode}",
ReferenceTimeLabel: FormatSbsReportTimeLabel(item.LastReportTime));
}
private static TurnoutQueryDefinition? ResolveTurnoutQuery(SbsElectionConfiguration configuration)
@@ -506,6 +516,78 @@ public sealed class SbsElectionApiClient : IDisposable
};
}
private static string ResolveLatestReferenceTimeLabel(IEnumerable<string> referenceTimeLabels)
{
return referenceTimeLabels
.Select(label => label?.Trim() ?? string.Empty)
.Where(label => !string.IsNullOrWhiteSpace(label))
.Select(label => new
{
Label = label,
SortValue = TryParseReferenceTimeLabel(label, out var totalMinutes) ? totalMinutes : -1
})
.OrderByDescending(item => item.SortValue)
.ThenByDescending(item => item.Label, StringComparer.Ordinal)
.Select(item => item.Label)
.FirstOrDefault() ?? string.Empty;
}
private static string FormatSbsReportTimeLabel(double? lastReportTime)
{
if (!lastReportTime.HasValue || double.IsNaN(lastReportTime.Value) || double.IsInfinity(lastReportTime.Value))
{
return string.Empty;
}
var value = lastReportTime.Value;
int hour;
int minute;
if (value >= 100d)
{
var compactTime = (int)Math.Round(value, MidpointRounding.AwayFromZero);
hour = compactTime / 100;
minute = compactTime % 100;
}
else
{
var totalMinutes = (int)Math.Round(value * 60d, MidpointRounding.AwayFromZero);
hour = totalMinutes / 60;
minute = totalMinutes % 60;
}
hour = ((hour % 24) + 24) % 24;
minute = Math.Clamp(minute, 0, 59);
return minute == 0
? FormattableString.Invariant($"{hour}시 기준")
: FormattableString.Invariant($"{hour}시 {minute:00}분 기준");
}
private static bool TryParseReferenceTimeLabel(string label, out int totalMinutes)
{
totalMinutes = -1;
if (string.IsNullOrWhiteSpace(label))
{
return false;
}
var digits = new string(label.Where(char.IsDigit).ToArray());
if (digits.Length == 0 || !int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{
return false;
}
if (digits.Length <= 2)
{
totalMinutes = value * 60;
return true;
}
var hour = value / 100;
var minute = value % 100;
totalMinutes = hour * 60 + minute;
return true;
}
private async Task<TurnoutTarget> ResolveTurnoutTargetAsync(
SbsElectionConfiguration configuration,
string districtName,
@@ -673,6 +755,30 @@ public sealed class SbsElectionApiClient : IDisposable
IReadOnlyList<string> sidoCodes,
CancellationToken cancellationToken)
{
if (CanDeriveDistrictsFromCounting(configuration))
{
if (sidoCodes.Count == 0)
{
return await GetCountingItemsForPathAsync(
configuration,
BuildCountingPath(configuration, string.Empty),
cancellationToken).ConfigureAwait(false);
}
var basicResults = new List<SbsCountingResponseItem>();
foreach (var sidoChunk in sidoCodes.Chunk(24))
{
var sidos = string.Join(",", sidoChunk.Select(Uri.EscapeDataString));
var path = BuildCountingPath(configuration, $"sidos={sidos}");
basicResults.AddRange(await GetCountingItemsForPathAsync(
configuration,
path,
cancellationToken).ConfigureAwait(false));
}
return basicResults;
}
var results = new List<SbsCountingResponseItem>();
foreach (var sidoChunk in sidoCodes.Chunk(24))
@@ -688,6 +794,15 @@ public sealed class SbsElectionApiClient : IDisposable
return results;
}
private static bool IsCountingItemInSido(
SbsCountingItem item,
ISet<string> sidoCodes)
{
var sidoCode = item.Region?.Name1Id;
return !string.IsNullOrWhiteSpace(sidoCode) &&
sidoCodes.Contains(sidoCode);
}
private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsForPathAsync(
SbsElectionConfiguration configuration,
string path,
@@ -739,8 +854,10 @@ public sealed class SbsElectionApiClient : IDisposable
SbsCountingItem item,
string sourcePath)
{
var seatCount = Math.Max(0, item.Region?.SeatCount ?? 0);
var countingClosed = item.GaepyoMagam;
var candidates = (item.Hubojas ?? [])
.Select(MapCandidate)
.Select(candidate => MapCandidate(candidate, seatCount, countingClosed))
.OrderByDescending(candidate => candidate.VoteCount)
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
.ToArray();
@@ -750,7 +867,7 @@ public sealed class SbsElectionApiClient : IDisposable
var outputRegionName = BuildOutputRegionName(regionName);
var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, fallbackRegion);
var displayName = configuration.SungerType is 2 or 4 or 5 or 6
? BuildFullDistrictDisplayName(regionName, districtLabel)
? BuildFullDistrictDisplayName(outputRegionName, districtLabel)
: regionName;
return new SbsElectionRefreshResult(
@@ -763,14 +880,26 @@ public sealed class SbsElectionApiClient : IDisposable
CountedRate: item.Total?.GaepyoRate,
CountedVotes: item.Total?.Gaepyosu,
RemainingVotes: item.Total?.UncountedPyosu,
SeatCount: seatCount,
CountingClosed: countingClosed,
Candidates: candidates,
ReceivedAt: DateTimeOffset.Now,
SourcePath: sourcePath);
}
private static CandidateEntry MapCandidate(SbsCandidateItem item)
private static CandidateEntry MapCandidate(SbsCandidateItem item, int seatCount, bool countingClosed)
{
var total = item.Total ?? new SbsCandidateVoteSnapshot();
var judgement = MapJudgement(item.Degree);
if (judgement == CandidateJudgement.None &&
countingClosed &&
seatCount > 0 &&
total.Rank > 0 &&
total.Rank <= seatCount)
{
judgement = CandidateJudgement.ElectedAfterCountComplete;
}
return new CandidateEntry
{
CandidateCode = string.IsNullOrWhiteSpace(item.Giho) ? (item.Name ?? "후보") : item.Giho,
@@ -780,7 +909,10 @@ public sealed class SbsElectionApiClient : IDisposable
VoteCount = total.Dugpyosu,
VoteRate = total.DugpyoRate,
HasImage = true,
ManualJudgement = MapJudgement(item.Degree)
ManualJudgement = judgement,
BroadcastRank = total.Rank,
BroadcastSeatCount = seatCount,
BroadcastCountingClosed = countingClosed
};
}
@@ -1099,21 +1231,24 @@ public sealed class SbsElectionApiClient : IDisposable
UriComponents.SchemeAndServer | UriComponents.Path,
UriFormat.SafeUnescaped,
StringComparison.OrdinalIgnoreCase) == 0 &&
configuration.SungerType == 6;
IsBasicCouncilCountingType(configuration.SungerType);
private static bool CanQueryCountingBySido(
SbsElectionConfiguration configuration,
IReadOnlyList<DistrictSelectionOption> districts)
=> configuration.SungerType == 6 &&
=> IsBasicCouncilCountingType(configuration.SungerType) &&
CanDeriveDistrictsFromCounting(configuration) &&
districts.Count > 0 &&
districts.All(district => !string.IsNullOrWhiteSpace(district.ParentRegionCode));
private static bool CanQueryBasicCouncilByDistrictId(SbsElectionConfiguration configuration)
=> configuration.SungerType == 6 && CanDeriveDistrictsFromCounting(configuration);
=> IsBasicCouncilCountingType(configuration.SungerType) && CanDeriveDistrictsFromCounting(configuration);
private static bool ShouldCacheBasicCouncilCounting(SbsElectionConfiguration configuration)
=> configuration.SungerType == 6 && CanDeriveDistrictsFromCounting(configuration);
=> IsBasicCouncilCountingType(configuration.SungerType) && CanDeriveDistrictsFromCounting(configuration);
private static bool IsBasicCouncilCountingType(int sungerType)
=> sungerType is 5 or 6;
public static IReadOnlyList<string> ResolveBasicApiSidoCodes(IEnumerable<string> regionNames)
{
@@ -1258,12 +1393,11 @@ public sealed class SbsElectionApiClient : IDisposable
private static string BuildElectionDistrictLabel(string? officeName, string? shortName)
{
if (!string.IsNullOrWhiteSpace(officeName))
{
return officeName.Trim();
}
var label = !string.IsNullOrWhiteSpace(officeName)
? officeName.Trim()
: shortName?.Trim() ?? string.Empty;
return shortName?.Trim() ?? string.Empty;
return NormalizeElectionDistrictDisplayLabel(label);
}
private static string BuildMayorGovernorLabel(string regionName, string? officeName)
@@ -1377,9 +1511,30 @@ public sealed class SbsElectionApiClient : IDisposable
return regionName;
}
if (districtLabel.StartsWith(regionName, StringComparison.Ordinal))
{
return districtLabel;
}
return $"{regionName} {districtLabel}";
}
private static string NormalizeElectionDistrictDisplayLabel(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim();
foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
{
normalized = normalized.Replace(pair.Value, pair.Key, StringComparison.Ordinal);
}
return normalized;
}
private static DistrictSelectionOption CreateDistrictSelectionOption(int sungerType, SbsRegionInfo region)
{
var regionName = ExpandRegionName(region.Name1 ?? region.Name);
@@ -1391,8 +1546,9 @@ public sealed class SbsElectionApiClient : IDisposable
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region),
_ => regionName
};
var displayRegionName = sungerType is 5 or 6 ? outputRegionName : regionName;
var displayName = sungerType is 2 or 4 or 5 or 6
? BuildFullDistrictDisplayName(regionName, districtName)
? BuildFullDistrictDisplayName(displayRegionName, districtName)
: regionName;
var turnoutRegionCode = sungerType switch
{
@@ -1538,12 +1694,16 @@ public sealed class SbsElectionApiClient : IDisposable
double? CountedRate,
int? CountedVotes,
int? RemainingVotes,
int SeatCount,
bool CountingClosed,
IReadOnlyList<CandidateEntry>? Candidates,
DateTimeOffset ReceivedAt,
string SourcePath);
string SourcePath,
string ReferenceTimeLabel = "");
public sealed record CountingOverviewItem(
string DisplayName,
string DistrictCode,
double CountedRate,
int CountedVotes,
int TotalVotes,
@@ -1558,13 +1718,15 @@ public sealed class SbsElectionApiClient : IDisposable
string DistrictCode,
int TotalExpectedVotes,
int TurnoutVotes,
double TurnoutRate);
double TurnoutRate,
string ReferenceTimeLabel = "");
public sealed record TurnoutOverviewResult(
IReadOnlyList<TurnoutOverviewItem> Items,
int TotalExpectedVotes,
int TurnoutVotes,
DateTimeOffset ReceivedAt)
DateTimeOffset ReceivedAt,
string ReferenceTimeLabel = "")
{
public double NationalTurnoutRate => TotalExpectedVotes <= 0
? 0
@@ -1605,6 +1767,9 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("order")]
public int Order { get; set; }
[JsonPropertyName("seatCount")]
public int SeatCount { get; set; }
}
private sealed class SbsTurnoutItem
@@ -1615,6 +1780,9 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("sungerinsu")]
public int Sungerinsu { get; set; }
[JsonPropertyName("lastReportTime")]
public double? LastReportTime { get; set; }
[JsonPropertyName("total")]
public SbsTurnoutVoteSnapshot? Total { get; set; }
}
@@ -1653,6 +1821,9 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("order")]
public int Order { get; set; }
[JsonPropertyName("seatCount")]
public int SeatCount { get; set; }
}
private sealed class SbsTurnoutVoteSnapshot
@@ -1671,6 +1842,9 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("hubojas")]
public List<SbsCandidateItem>? Hubojas { get; set; }
[JsonPropertyName("gaepyoMagam")]
public bool GaepyoMagam { get; set; }
}
private readonly record struct SbsCountingResponseItem(

View File

@@ -5,6 +5,7 @@ namespace Tornado3_2026Election.Services;
internal static class ScheduleTemplatePolicy
{
private const double MinimumCutDurationSeconds = 1d;
public const string SingleRegionLabel = "단일";
public static double GetMinimumCutDurationSeconds(FormatTemplateDefinition template)
@@ -14,25 +15,7 @@ internal static class ScheduleTemplatePolicy
public static double GetMinimumCutDurationSeconds(BroadcastChannel channel, string? templateName)
{
var name = templateName ?? string.Empty;
if (name.Contains("영상", StringComparison.Ordinal) ||
name.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("사전_", StringComparison.Ordinal))
{
return 10d;
}
if (channel == BroadcastChannel.VideoWall)
{
return 8d;
}
if (channel is BroadcastChannel.Normal or BroadcastChannel.Bottom)
{
return 8d;
}
return 6d;
return MinimumCutDurationSeconds;
}
public static double NormalizeCutDurationSeconds(double durationSeconds, FormatTemplateDefinition template)

View File

@@ -7,7 +7,8 @@ public sealed class StationCatalogService
{
private readonly IReadOnlyList<BroadcastStationProfile> _stations =
[
new BroadcastStationProfile { Id = "KNN", Name = "KNN", LogoAssetPath = @"Assets\Stations\knn.png", RegionFilters = ["부산", "울산", "경남"] },
new BroadcastStationProfile { Id = "KNN", Name = "KNN", LogoAssetPath = @"Assets\Stations\knn.png", RegionFilters = ["부산", "경남"], VideoWallLayoutPreset = VideoWallLayoutPreset.UltraWide8316x1080 },
new BroadcastStationProfile { Id = "UBC", Name = "UBC", LogoAssetPath = @"Assets\Stations\ubc.png", RegionFilters = ["울산"] },
new BroadcastStationProfile { Id = "TBC", Name = "TBC", LogoAssetPath = @"Assets\Stations\tbc.png", RegionFilters = ["대구", "경북"] },
new BroadcastStationProfile { Id = "KBC", Name = "KBC", LogoAssetPath = @"Assets\Stations\kbc.png", RegionFilters = ["광주", "전남"] },
new BroadcastStationProfile { Id = "G1", Name = "G1", LogoAssetPath = @"Assets\Stations\g1.png", RegionFilters = ["강원"] },

View File

@@ -7,7 +7,8 @@ public enum ThumbnailDisplayContext
{
CutList,
Queue,
Preview
Preview,
PlaybackPreview
}
public readonly record struct ThumbnailDisplayMetrics(double Width, double Height);
@@ -15,8 +16,9 @@ public readonly record struct ThumbnailDisplayMetrics(double Width, double Heigh
public static class ThumbnailLayoutResolver
{
private const double HdAspectRatio = 1920d / 1080d;
private const double StandardVideoWallAspectRatio = 5760d / 1080d;
private const double UltraWideVideoWallAspectRatio = 11520d / 1080d;
private const double VideoWall3840x810AspectRatio = 3840d / 810d;
private const double VideoWall2880x1080AspectRatio = 2880d / 1080d;
private const double VideoWall8316x1080AspectRatio = 8316d / 1080d;
public static ThumbnailDisplayMetrics ResolveDisplayMetrics(
FormatTemplateDefinition template,
@@ -55,6 +57,7 @@ public static class ThumbnailLayoutResolver
var (maxWidth, maxHeight) = context switch
{
ThumbnailDisplayContext.Preview => (480d, 180d),
ThumbnailDisplayContext.PlaybackPreview => (220d, 124d),
ThumbnailDisplayContext.CutList => (320d, 90d),
ThumbnailDisplayContext.Queue => (320d, 90d),
_ => (320d, 180d)
@@ -81,7 +84,7 @@ public static class ThumbnailLayoutResolver
return sceneAspectRatio;
}
return StandardVideoWallAspectRatio;
return VideoWall8316x1080AspectRatio;
}
if (TryGetSceneAspectRatio(sceneWidth, sceneHeight, out var resolvedSceneAspectRatio))
@@ -96,11 +99,14 @@ public static class ThumbnailLayoutResolver
{
switch (videoWallLayoutPreset)
{
case VideoWallLayoutPreset.Standard5760x1080:
aspectRatio = StandardVideoWallAspectRatio;
case VideoWallLayoutPreset.Wall3840x810:
aspectRatio = VideoWall3840x810AspectRatio;
return true;
case VideoWallLayoutPreset.UltraWide11520x1080:
aspectRatio = UltraWideVideoWallAspectRatio;
case VideoWallLayoutPreset.Wall2880x1080:
aspectRatio = VideoWall2880x1080AspectRatio;
return true;
case VideoWallLayoutPreset.UltraWide8316x1080:
aspectRatio = VideoWall8316x1080AspectRatio;
return true;
default:
aspectRatio = 0;

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
@@ -19,6 +20,7 @@ public sealed class TornadoManager : IDisposable
private readonly LogService _logService;
private readonly StaDispatcher _dispatcher;
private readonly Timer _reconnectTimer;
private readonly SemaphoreSlim _asyncOperationLock = new(1, 1);
private readonly Dictionary<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, DateTime> _sceneWriteTimes = new(StringComparer.OrdinalIgnoreCase);
@@ -54,7 +56,16 @@ public sealed class TornadoManager : IDisposable
return EnsureConnectedInternalAsync(cancellationToken);
}
public async Task<string> LoadSceneAsync(string scenePath, string sceneAlias, CancellationToken cancellationToken)
public Task<string> LoadSceneAsync(string scenePath, string sceneAlias, CancellationToken cancellationToken)
{
return LoadSceneAsync(scenePath, sceneAlias, forceReload: false, cancellationToken);
}
public async Task<string> LoadSceneAsync(
string scenePath,
string sceneAlias,
bool forceReload,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(scenePath))
{
@@ -66,13 +77,17 @@ public sealed class TornadoManager : IDisposable
throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
}
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var sceneWriteTime = ResolveSceneWriteTime(scenePath);
var existingAlias = await _dispatcher.InvokeAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
if (_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
if (!forceReload &&
_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase) &&
_sceneWriteTimes.TryGetValue(sceneAlias, out var existingWriteTime) &&
existingWriteTime == sceneWriteTime)
@@ -98,22 +113,23 @@ public sealed class TornadoManager : IDisposable
ThrowIfDisposed();
EnsureConnectedCore();
var forceReload = _scenePaths.ContainsKey(sceneAlias);
var scene = forceReload
var shouldForceReload = forceReload || _scenePaths.ContainsKey(sceneAlias);
var scene = shouldForceReload
? _engine!.LoadSceneForce(scenePath, sceneAlias)
: _engine!.LoadScene(scenePath, sceneAlias);
_logService.Info(
$"Karisma {(forceReload ? "LoadSceneForce" : "LoadScene")}() return={(scene is null ? "null" : "scene-handle")} alias={sceneAlias} path={scenePath}");
$"Karisma {(shouldForceReload ? "LoadSceneForce" : "LoadScene")}() return={(scene is null ? "null" : "scene-handle")} alias={sceneAlias} path={scenePath}");
_scenes[sceneAlias] = scene ?? throw new InvalidOperationException($"Failed to load Karisma scene: {scenePath}");
_scenePaths[sceneAlias] = scenePath;
_sceneWriteTimes[sceneAlias] = sceneWriteTime;
}, cancellationToken).ConfigureAwait(false);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(AsyncOperationTimeout);
var result = await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
var result = await WaitForKarismaCallbackAsync(
completion,
cancellationToken,
$"LoadScene '{sceneAlias}'").ConfigureAwait(false);
if (result != eKResult.RESULT_SUCCESS)
{
throw new InvalidOperationException($"LoadScene failed for '{sceneAlias}': {result} ({(int)result})");
@@ -141,24 +157,46 @@ public sealed class TornadoManager : IDisposable
throw;
}
}
finally
{
_asyncOperationLock.Release();
}
}
public Task ApplyValuesAsync(
public async Task ApplyValuesAsync(
string sceneAlias,
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesBeforeValue,
IReadOnlyDictionary<string, string> values,
IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
IReadOnlyList<KarismaChartCellUpdate> chartCellUpdates,
IReadOnlyList<KarismaPositionUpdate> positionUpdates,
IReadOnlyList<KarismaCropKeyUpdate> cropKeyUpdates,
IReadOnlyList<KarismaStyleColorUpdate> styleColorUpdates,
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesAfterValue,
CancellationToken cancellationToken)
{
return _dispatcher.InvokeAsync(() =>
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
Task<eKResult>? transactionCompletion = null;
var eventHandler = GetEventHandlerCore();
try
{
await _dispatcher.InvokeAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
var scene = GetSceneCore(sceneAlias);
var counterNumberKeyObjectNames = counterNumberKeys
.Where(update => update.KeyIndex != 0 && !string.IsNullOrWhiteSpace(update.ObjectName))
.Select(update => update.ObjectName)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var counterSetValueObjectNames = counterNumberKeys
.Where(update => update.AllowSetValue && !string.IsNullOrWhiteSpace(update.ObjectName))
.Select(update => update.ObjectName)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
_engine!.BeginTransaction();
try
{
@@ -179,6 +217,13 @@ public sealed class TornadoManager : IDisposable
continue;
}
if (counterNumberKeyObjectNames.Contains(pair.Key) &&
!counterSetValueObjectNames.Contains(pair.Key) &&
sceneObject is IKACounter)
{
continue;
}
sceneObject.SetValue(pair.Value ?? string.Empty);
}
catch (Exception ex)
@@ -196,6 +241,11 @@ public sealed class TornadoManager : IDisposable
try
{
if (counterNumberKey.KeyIndex == 0 && !counterNumberKey.AllowKeyZero)
{
continue;
}
var sceneObject = scene.GetObject(counterNumberKey.ObjectName);
if (sceneObject is not IKACounter counter)
{
@@ -254,16 +304,58 @@ public sealed class TornadoManager : IDisposable
continue;
}
if (positionUpdate.KeyIndex >= 0)
{
sceneObject.SetPositionKey(
positionUpdate.KeyIndex,
positionUpdate.X,
positionUpdate.Y,
positionUpdate.Z,
positionUpdate.VectorType);
}
else
{
sceneObject.SetPosition(
positionUpdate.X,
positionUpdate.Y,
positionUpdate.Z,
positionUpdate.VectorType);
}
}
catch (Exception ex)
{
_logService.Warning(
$"Karisma position update skipped: scene={sceneAlias} object={positionUpdate.ObjectName} reason={ex.Message}");
$"Karisma position update skipped: scene={sceneAlias} object={positionUpdate.ObjectName} keyIndex={positionUpdate.KeyIndex} reason={ex.Message}");
}
}
foreach (var cropKeyUpdate in cropKeyUpdates)
{
if (string.IsNullOrWhiteSpace(cropKeyUpdate.ObjectName))
{
continue;
}
try
{
var sceneObject = scene.GetObject(cropKeyUpdate.ObjectName);
if (sceneObject is null)
{
continue;
}
sceneObject.SetCropKey(
cropKeyUpdate.KeyIndex,
cropKeyUpdate.Left,
cropKeyUpdate.Top,
cropKeyUpdate.Right,
cropKeyUpdate.Bottom,
cropKeyUpdate.CropKey);
}
catch (Exception ex)
{
_logService.Warning(
$"Karisma crop-key update skipped: scene={sceneAlias} object={cropKeyUpdate.ObjectName} keyIndex={cropKeyUpdate.KeyIndex} reason={ex.Message}");
}
}
@@ -303,9 +395,33 @@ public sealed class TornadoManager : IDisposable
}
finally
{
transactionCompletion = eventHandler.BeginEndTransactionWait();
_engine!.EndTransaction();
}
}, cancellationToken);
}, cancellationToken).ConfigureAwait(false);
if (transactionCompletion is not null)
{
var transactionResult = await WaitForKarismaResultAsync(
transactionCompletion,
cancellationToken,
$"EndTransaction scene={sceneAlias}").ConfigureAwait(false);
ThrowIfKarismaFailed(transactionResult, $"EndTransaction failed for scene={sceneAlias}");
}
await UpdateSceneTexturesAsync(sceneAlias, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
eventHandler.CancelPendingEndTransaction(ex);
eventHandler.CancelPendingUpdateTextures(sceneAlias, ex);
throw;
}
}
finally
{
_asyncOperationLock.Release();
}
}
private static DateTime ResolveSceneWriteTime(string scenePath)
@@ -347,19 +463,74 @@ public sealed class TornadoManager : IDisposable
}
}
public Task PrepareAsync(int outputChannelIndex, int layerNo, string sceneAlias, CancellationToken cancellationToken)
private async Task UpdateSceneTexturesAsync(string sceneAlias, CancellationToken cancellationToken)
{
return _dispatcher.InvokeAsync(() =>
var eventHandler = GetEventHandlerCore();
var completion = eventHandler.BeginUpdateTexturesWait(sceneAlias);
try
{
await _dispatcher.InvokeAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
GetSceneCore(sceneAlias).UpdateTextures();
}, cancellationToken).ConfigureAwait(false);
var result = await WaitForKarismaResultAsync(
completion,
cancellationToken,
$"UpdateTextures scene={sceneAlias}").ConfigureAwait(false);
ThrowIfKarismaFailed(result, $"UpdateTextures failed for scene={sceneAlias}");
}
catch (Exception ex)
{
eventHandler.CancelPendingUpdateTextures(sceneAlias, ex);
throw;
}
}
public async Task PrepareAsync(int outputChannelIndex, int layerNo, string sceneAlias, CancellationToken cancellationToken)
{
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var eventHandler = GetEventHandlerCore();
var completion = eventHandler.BeginScenePrepareWait(outputChannelIndex, layerNo);
try
{
await _dispatcher.InvokeAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
GetScenePlayerCore(outputChannelIndex).Prepare(layerNo, GetSceneCore(sceneAlias));
}, cancellationToken);
}, cancellationToken).ConfigureAwait(false);
var result = await WaitForKarismaResultAsync(
completion,
cancellationToken,
$"Prepare output={outputChannelIndex} layer={layerNo} scene={sceneAlias}").ConfigureAwait(false);
ThrowIfKarismaFailed(result, $"Prepare failed for output={outputChannelIndex} layer={layerNo} scene={sceneAlias}");
// Give Karisma one render tick after Prepare before a mixed-preview screenshot is requested.
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
eventHandler.CancelPendingScenePrepare(outputChannelIndex, layerNo, ex);
throw;
}
}
finally
{
_asyncOperationLock.Release();
}
}
public Task PlayAsync(int outputChannelIndex, int layerNo, bool cutIn, CancellationToken cancellationToken)
public async Task PlayAsync(int outputChannelIndex, int layerNo, bool cutIn, CancellationToken cancellationToken)
{
return _dispatcher.InvokeAsync(() =>
await InvokeExclusiveAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
@@ -375,9 +546,39 @@ public sealed class TornadoManager : IDisposable
}, cancellationToken);
}
public Task PlayOutAsync(int outputChannelIndex, int layerNo, bool cutOut, CancellationToken cancellationToken)
public async Task PauseAsync(int outputChannelIndex, int layerNo, CancellationToken cancellationToken)
{
return _dispatcher.InvokeAsync(() =>
await InvokeExclusiveAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
GetScenePlayerCore(outputChannelIndex).Pause(layerNo);
}, cancellationToken);
}
public async Task ResumeAsync(int outputChannelIndex, int layerNo, CancellationToken cancellationToken)
{
await InvokeExclusiveAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
GetScenePlayerCore(outputChannelIndex).Resume(layerNo);
}, cancellationToken);
}
public async Task StopAsync(int outputChannelIndex, int layerNo, CancellationToken cancellationToken)
{
await InvokeExclusiveAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
GetScenePlayerCore(outputChannelIndex).Stop(layerNo);
}, cancellationToken);
}
public async Task PlayOutAsync(int outputChannelIndex, int layerNo, bool cutOut, CancellationToken cancellationToken)
{
await InvokeExclusiveAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
@@ -393,6 +594,82 @@ public sealed class TornadoManager : IDisposable
}, cancellationToken);
}
public async Task ClearNextPreviewAsync(int outputChannelIndex, int layerNo, CancellationToken cancellationToken)
{
await InvokeExclusiveAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
GetScenePlayerCore(outputChannelIndex).ClearNextPreview(layerNo);
}, cancellationToken);
}
public async Task SaveMixedPreviewImageAsync(
int outputChannelIndex,
int layerNo,
string fileName,
int width,
int height,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("Image file path is required.", nameof(fileName));
}
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await _dispatcher.InvokeAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
}, cancellationToken).ConfigureAwait(false);
var eventHandler = GetEventHandlerCore();
var completion = eventHandler.BeginSaveMixedPreviewImageWait();
try
{
await _dispatcher.InvokeAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
var directory = Path.GetDirectoryName(fileName);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
GetScenePlayerCore(outputChannelIndex).SaveMixedPreviewImage(fileName, width, height);
}, cancellationToken).ConfigureAwait(false);
var result = await WaitForKarismaCallbackAsync(
completion,
cancellationToken,
$"SaveMixedPreviewImage output={outputChannelIndex} layer={layerNo}").ConfigureAwait(false);
if (result.Result != eKResult.RESULT_SUCCESS)
{
throw new InvalidOperationException(
$"SaveMixedPreviewImage failed for output={outputChannelIndex} layer={layerNo}: {result.Result} ({(int)result.Result})");
}
await WaitForFileAsync(fileName, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
eventHandler.CancelPendingSaveMixedPreviewImage(ex);
throw;
}
}
finally
{
_asyncOperationLock.Release();
}
}
public async Task SaveSceneImageAsync(
string sceneAlias,
string fileName,
@@ -411,6 +688,9 @@ public sealed class TornadoManager : IDisposable
throw new ArgumentException("Image file path is required.", nameof(fileName));
}
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await _dispatcher.InvokeAsync(() =>
{
ThrowIfDisposed();
@@ -436,9 +716,10 @@ public sealed class TornadoManager : IDisposable
GetSceneCore(sceneAlias).SaveSceneImage(fileName, width, height, frame);
}, cancellationToken).ConfigureAwait(false);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
var result = await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
var result = await WaitForKarismaCallbackAsync(
completion,
cancellationToken,
$"SaveSceneImage scene={sceneAlias}").ConfigureAwait(false);
if (result.Result != eKResult.RESULT_SUCCESS)
{
@@ -453,10 +734,15 @@ public sealed class TornadoManager : IDisposable
throw;
}
}
public Task UnloadSceneAsync(string sceneAlias, CancellationToken cancellationToken)
finally
{
return _dispatcher.InvokeAsync(() =>
_asyncOperationLock.Release();
}
}
public async Task UnloadSceneAsync(string sceneAlias, CancellationToken cancellationToken)
{
await InvokeExclusiveAsync(() =>
{
ThrowIfDisposed();
@@ -471,9 +757,9 @@ public sealed class TornadoManager : IDisposable
}, cancellationToken);
}
public Task TriggerAsync(int outputChannelIndex, int layerNo, string animationName, CancellationToken cancellationToken)
public async Task TriggerAsync(int outputChannelIndex, int layerNo, string animationName, CancellationToken cancellationToken)
{
return _dispatcher.InvokeAsync(() =>
await InvokeExclusiveAsync(() =>
{
ThrowIfDisposed();
EnsureConnectedCore();
@@ -674,7 +960,57 @@ public sealed class TornadoManager : IDisposable
return _eventHandler ?? throw new InvalidOperationException("Karisma event handler is unavailable.");
}
private async Task InvokeExclusiveAsync(Action action, CancellationToken cancellationToken)
{
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await _dispatcher.InvokeAsync(action, cancellationToken).ConfigureAwait(false);
}
finally
{
_asyncOperationLock.Release();
}
}
private static Task<eKResult> WaitForKarismaResultAsync(
Task<eKResult> completion,
CancellationToken cancellationToken,
string operationName)
{
return WaitForKarismaCallbackAsync(completion, cancellationToken, operationName);
}
private static async Task<T> WaitForKarismaCallbackAsync<T>(
Task<T> completion,
CancellationToken cancellationToken,
string operationName)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(AsyncOperationTimeout);
try
{
return await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException($"{operationName} timed out after {AsyncOperationTimeout.TotalSeconds:0.#} seconds.");
}
}
private static void ThrowIfKarismaFailed(eKResult result, string message)
{
if (result != eKResult.RESULT_SUCCESS)
{
throw new InvalidOperationException($"{message}: {result} ({(int)result})");
}
}
private async Task EnsureConnectedInternalAsync(CancellationToken cancellationToken)
{
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
Task<int>? connectCompletion = null;
try
@@ -714,9 +1050,10 @@ public sealed class TornadoManager : IDisposable
return;
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(AsyncOperationTimeout);
var errorCode = await connectCompletion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
var errorCode = await WaitForKarismaCallbackAsync(
connectCompletion,
cancellationToken,
"Connect").ConfigureAwait(false);
if (errorCode != 0)
{
throw new InvalidOperationException($"Karisma Async Engine OnConnect failed: errorCode={errorCode}");
@@ -744,6 +1081,11 @@ public sealed class TornadoManager : IDisposable
throw;
}
}
finally
{
_asyncOperationLock.Release();
}
}
private static async Task WaitForFileAsync(string fileName, CancellationToken cancellationToken)
{

View File

@@ -133,12 +133,12 @@
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
<GenerateAppInstallerFile>True</GenerateAppInstallerFile>
<AppxPackageSigningEnabled>True</AppxPackageSigningEnabled>
<PackageCertificateKeyFile>Tornado3_2026Election_TemporaryKey.pfx</PackageCertificateKeyFile>
<PackageCertificateThumbprint>E691A33C64DF20A204FFD4F096B9C3EB4B95709C</PackageCertificateThumbprint>
<AppxPackageSigningTimestampDigestAlgorithm>SHA256</AppxPackageSigningTimestampDigestAlgorithm>
<AppxAutoIncrementPackageRevision>True</AppxAutoIncrementPackageRevision>
<GenerateTestArtifacts>True</GenerateTestArtifacts>
<AppxBundle>Never</AppxBundle>
<AppInstallerUri>http://172.30.1.36/</AppInstallerUri>
<AppInstallerUri>http://122.34.248.185/msix/</AppInstallerUri>
<HoursBetweenUpdateChecks>0</HoursBetweenUpdateChecks>
</PropertyGroup>
</Project>

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
@@ -6,6 +7,10 @@ namespace Tornado3_2026Election.ViewModels;
public sealed class CareerPromiseEditRowViewModel : ObservableObject
{
private string _districtName;
private string _candidateCode;
private string _candidateName;
private string _party;
private string _promise1;
private string _promise2;
private string _promise3;
@@ -14,21 +19,45 @@ public sealed class CareerPromiseEditRowViewModel : ObservableObject
string candidateCode,
string candidateName,
string party,
IReadOnlyList<string>? promises = null)
IReadOnlyList<string>? promises = null,
string districtName = "",
Action<CareerPromiseEditRowViewModel>? deleteAction = null)
{
CandidateCode = candidateCode ?? string.Empty;
CandidateName = candidateName ?? string.Empty;
Party = party ?? string.Empty;
_candidateCode = candidateCode ?? string.Empty;
_candidateName = candidateName ?? string.Empty;
_party = party ?? string.Empty;
_districtName = districtName ?? string.Empty;
_promise1 = GetPromise(promises, 0);
_promise2 = GetPromise(promises, 1);
_promise3 = GetPromise(promises, 2);
DeleteCommand = new RelayCommand(() => deleteAction?.Invoke(this), () => deleteAction is not null);
}
public string CandidateCode { get; }
public RelayCommand DeleteCommand { get; }
public string CandidateName { get; }
public string DistrictName
{
get => _districtName;
set => SetProperty(ref _districtName, value ?? string.Empty);
}
public string Party { get; }
public string CandidateCode
{
get => _candidateCode;
set => SetProperty(ref _candidateCode, value ?? string.Empty);
}
public string CandidateName
{
get => _candidateName;
set => SetProperty(ref _candidateName, value ?? string.Empty);
}
public string Party
{
get => _party;
set => SetProperty(ref _party, value ?? string.Empty);
}
public string Promise1
{
@@ -80,20 +109,28 @@ public sealed class CareerPromiseEditRowViewModel : ObservableObject
string districtName)
{
var normalizedCandidateCode = CandidateCode.Trim();
if (string.IsNullOrWhiteSpace(normalizedCandidateCode) || !HasAnyPromise)
var normalizedCandidateName = CandidateName.Trim();
var normalizedParty = Party.Trim();
if (!HasAnyPromise ||
string.IsNullOrWhiteSpace(normalizedCandidateName) ||
string.IsNullOrWhiteSpace(normalizedParty))
{
return null;
}
var normalizedDistrictName = string.IsNullOrWhiteSpace(DistrictName)
? districtName?.Trim() ?? string.Empty
: DistrictName.Trim();
return new CareerPromiseEntry
{
StationId = stationId?.Trim() ?? string.Empty,
ElectionType = electionType?.Trim() ?? string.Empty,
DistrictCode = districtCode?.Trim() ?? string.Empty,
DistrictName = districtName?.Trim() ?? string.Empty,
DistrictName = normalizedDistrictName,
CandidateCode = normalizedCandidateCode,
CandidateName = CandidateName.Trim(),
Party = Party.Trim(),
CandidateName = normalizedCandidateName,
Party = normalizedParty,
Promises = new[] { Promise1.Trim(), Promise2.Trim(), Promise3.Trim() }
};
}

View File

@@ -7,6 +7,7 @@ using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Tornado3_2026Election.Common;
@@ -19,6 +20,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
private static readonly Brush PlaybackActiveIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 90, 84));
private static readonly Brush PlaybackIdleIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 183, 197, 216));
private readonly ChannelScheduleEngine _engine;
private readonly ITornado3Adapter _adapter;
private readonly CutDebugStateStore _cutDebugStateStore;
@@ -33,6 +36,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private ScheduleRegionOption? _selectedRegionOption;
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
private CancellationTokenSource? _directPlaybackCts;
private ChannelScheduleItem? _preparedDirectItem;
private string _preparedDirectFormatId = string.Empty;
private string _preparedDirectRegionKey = string.Empty;
private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
private int _regionOptionsRevision;
@@ -78,8 +84,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
];
Queue = engine.Queue;
SchedulePrepareCommand = new AsyncRelayCommand(PrepareScheduleAsync);
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
StopCommand = new AsyncRelayCommand(StopAsync);
DirectPrepareCommand = new AsyncRelayCommand(DirectPrepareAsync, CanDirectStart);
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
@@ -138,10 +146,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public ObservableCollection<ChannelScheduleItem> Queue { get; }
public AsyncRelayCommand SchedulePrepareCommand { get; }
public AsyncRelayCommand StartCommand { get; }
public AsyncRelayCommand StopCommand { get; }
public AsyncRelayCommand DirectPrepareCommand { get; }
public AsyncRelayCommand DirectStartCommand { get; }
public AsyncRelayCommand DirectStopCommand { get; }
@@ -227,6 +239,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SyncSelectedCutDebugTemplate();
_ = RebuildRegionOptionsAsync();
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
}
@@ -240,6 +253,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
if (SetProperty(ref _selectedRegionOption, value))
{
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
}
@@ -324,13 +338,41 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public string AdapterStateText => $"Tornado 상태: {AdapterStateLabel}";
public string TransmissionLabel => Queue.Any(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)
public bool IsPlaying => Queue.Any(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)
|| _engine.ActivePlaybackItem is not null;
public Brush PlaybackIconBrush => IsPlaying ? PlaybackActiveIconBrush : PlaybackIdleIconBrush;
public string TransmissionLabel => _engine.ActivePlaybackItem?.State == ScheduleQueueItemState.Sending
? "준비"
: IsPlaying
? "송출 중"
: "대기";
public string CurrentItemName => Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.DisplayName ?? "대기 화면";
public string CurrentItemName => CurrentPlaybackItem?.DisplayName ?? "대기 화면";
public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "다음 컷 없음";
public string NextItemName => InternalNextPlaybackItem?.InternalNextPreviewDisplayName
?? QueueNextPlaybackItem?.DisplayName
?? "다음 컷 없음";
public ImageSource? CurrentPreviewSource => CurrentPlaybackItem?.PreviewSource;
public ImageSource? NextPreviewSource => InternalNextPlaybackItem?.InternalNextPreviewSource
?? QueueNextPlaybackItem?.PreviewSource;
public string CurrentPreviewStatusLabel => CurrentPlaybackItem?.PreviewStatusLabel ?? "송출 중인 컷 없음";
public string NextPreviewStatusLabel => InternalNextPlaybackItem?.InternalNextPreviewStatusLabel
?? QueueNextPlaybackItem?.PreviewStatusLabel
?? "다음 컷 없음";
public double CurrentPreviewWidth => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Width;
public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height;
public double NextPreviewWidth => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Width;
public double NextPreviewHeight => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Height;
public int QueuedItemCount => Queue.Count(item => item.State == ScheduleQueueItemState.Queued);
@@ -340,8 +382,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
get
{
var current = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.DisplayName ?? "-";
var next = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "-";
var current = CurrentPlaybackItem?.DisplayName ?? "-";
var next = InternalNextPlaybackItem?.InternalNextPreviewDisplayName
?? QueueNextPlaybackItem?.DisplayName
?? "-";
return $"현재 {current} / 다음 {next} / 대기 {Queue.Count(item => item.State == ScheduleQueueItemState.Queued)}";
}
}
@@ -428,6 +472,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public double SelectedFormatThumbnailHeight => _selectedFormatThumbnailHeight;
private ChannelScheduleItem? CurrentPlaybackItem =>
_engine.ActivePlaybackItem;
private ChannelScheduleItem? InternalNextPlaybackItem =>
CurrentPlaybackItem is { HasInternalNextPreview: true } item ? item : null;
private ChannelScheduleItem? QueueNextPlaybackItem =>
Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next);
private ChannelScheduleItem? NextPlaybackItem =>
InternalNextPlaybackItem ?? QueueNextPlaybackItem;
public async Task RefreshRegionOptionsAsync()
{
await RebuildRegionOptionsAsync();
@@ -466,6 +522,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_videoWallLayoutPreset = videoWallLayoutPreset;
UpdateSelectedFormatThumbnailMetrics();
ApplyQueueThumbnailLayouts();
NotifyPlaybackPreviewChanged();
}
private async Task StartAsync()
@@ -475,6 +532,21 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_logService.Info($"[{Title}] 큐를 시작");
}
private async Task PrepareScheduleAsync()
{
try
{
await _engine.PrepareNextAsync(CancellationToken.None).ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 스케줄 다음 컷 준비");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
RefreshSummary();
_logService.Error($"[{Title}] Schedule prepare failed: {ex.Message}");
}
}
private async Task StopAsync()
{
await _engine.StopAsync().ConfigureAwait(false);
@@ -482,6 +554,57 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_logService.Info($"[{Title}] 큐를 종료");
}
private async Task DirectPrepareAsync()
{
var selectedFormat = SelectedFormat;
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
if (selectedFormat is null || regionOption is null)
{
_logService.Warning($"[{Title}] 바로 송출 준비할 컷과 지역을 먼저 선택해 주세요.");
return;
}
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
_directPlaybackCts?.Cancel();
_directPlaybackCts?.Dispose();
var prepareCts = new CancellationTokenSource();
_directPlaybackCts = prepareCts;
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
selectedFormat,
_videoWallLayoutPreset,
ThumbnailDisplayContext.Queue));
_preparedDirectItem = item;
_preparedDirectFormatId = selectedFormat.Id;
_preparedDirectRegionKey = BuildRegionOptionKey(regionOption);
try
{
_logService.Info($"[{Title}] 선택 컷 준비: {selectedFormat.Name} / {regionOption.Label}");
await _engine.PrepareDirectAsync(item, selectedFormat, prepareCts.Token).ConfigureAwait(false);
if (!prepareCts.IsCancellationRequested)
{
_logService.Info($"[{Title}] 선택 컷 준비 완료: {selectedFormat.Name}");
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
ClearPreparedDirectState(item);
_logService.Error($"[{Title}] 선택 컷 준비 실패: {ex.Message}");
}
finally
{
if (ReferenceEquals(_directPlaybackCts, prepareCts))
{
_directPlaybackCts = null;
}
prepareCts.Dispose();
RefreshSummary();
}
}
private async Task DirectStartAsync()
{
var selectedFormat = SelectedFormat;
@@ -492,22 +615,24 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return;
}
if (!selectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
var preparedItem = ResolvePreparedDirectItem(selectedFormat, regionOption);
if (preparedItem is null)
{
_logService.Warning($"[{Title}] 현재 단계에서는 '{selectedFormat.Name}' 컷을 바로 송출할 수 없습니다.");
return;
}
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
_directPlaybackCts?.Cancel();
_directPlaybackCts?.Dispose();
}
var playbackCts = new CancellationTokenSource();
_directPlaybackCts = playbackCts;
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
var item = preparedItem ?? ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
if (preparedItem is null)
{
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
selectedFormat,
_videoWallLayoutPreset,
ThumbnailDisplayContext.Queue));
}
try
{
@@ -524,6 +649,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
}
finally
{
ClearPreparedDirectState(item);
if (ReferenceEquals(_directPlaybackCts, playbackCts))
{
_directPlaybackCts = null;
@@ -538,10 +664,49 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
_directPlaybackCts?.Cancel();
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
ClearPreparedDirectState(_preparedDirectItem);
_engine.ClearDirectPlayback();
RefreshSummary();
_logService.Info($"[{Title}] 선택 컷 송출 정지");
}
private ChannelScheduleItem? ResolvePreparedDirectItem(
FormatTemplateDefinition selectedFormat,
ScheduleRegionOption regionOption)
{
if (_preparedDirectItem is null ||
!_engine.IsPreparedItem(_preparedDirectItem) ||
!string.Equals(_preparedDirectFormatId, selectedFormat.Id, StringComparison.Ordinal) ||
!string.Equals(_preparedDirectRegionKey, BuildRegionOptionKey(regionOption), StringComparison.Ordinal))
{
return null;
}
return _preparedDirectItem;
}
private void ClearPreparedDirectState(ChannelScheduleItem? item)
{
if (item is not null && !ReferenceEquals(_preparedDirectItem, item))
{
return;
}
_preparedDirectItem = null;
_preparedDirectFormatId = string.Empty;
_preparedDirectRegionKey = string.Empty;
}
private static string BuildRegionOptionKey(ScheduleRegionOption regionOption)
{
return string.Join(
"\u001F",
regionOption.Scope,
regionOption.ElectionType ?? string.Empty,
regionOption.Label ?? string.Empty,
regionOption.DistrictCode ?? string.Empty);
}
private async Task ForceNextAsync()
{
await _engine.ForceNextAsync().ConfigureAwait(false);
@@ -563,12 +728,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return;
}
if (!SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
{
_logService.Warning($"[{Title}] 현재 단계에서는 '{SelectedFormat.Name}' 컷을 추가할 수 없습니다.");
return;
}
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
if (regionOption is null)
{
@@ -675,9 +834,19 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(AdapterState),
nameof(AdapterStateText),
nameof(AdapterStateLabel),
nameof(IsPlaying),
nameof(PlaybackIconBrush),
nameof(TransmissionLabel),
nameof(CurrentItemName),
nameof(NextItemName),
nameof(CurrentPreviewSource),
nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel),
nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth),
nameof(NextPreviewHeight),
nameof(QueuedItemCount),
nameof(QueueFootnote),
nameof(QueueSummary),
@@ -691,7 +860,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private bool CanAddFormat()
{
return SelectedFormat is not null &&
SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase) &&
SelectedRegionOption is not null;
}
@@ -716,7 +884,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
var selectedFormatId = SelectedFormat?.Id;
var selectedCategory = SelectedFormatCategoryOption?.Value;
var filteredFormats = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
.ToArray();
@@ -738,6 +905,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
UpdateSelectedFormatThumbnailMetrics();
SyncSelectedCutDebugTemplate();
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(QueueFootnote));
}
@@ -745,10 +913,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void RebuildFormatCategoryOptions()
{
var selectedCategory = SelectedFormatCategoryOption?.Value;
var formatsInCurrentPhase = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.ToArray();
var options = CreateFormatCategoryOptions(formatsInCurrentPhase);
var options = CreateFormatCategoryOptions(_allFormats);
FormatCategoryOptions.Clear();
foreach (var option in options)
@@ -817,6 +982,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption = null;
_lastRegionOptionFormatId = string.Empty;
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
return;
}
@@ -844,6 +1010,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
_lastRegionOptionFormatId = selectedFormat.Id;
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
@@ -897,7 +1064,87 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.OldItems is not null)
{
foreach (var item in e.OldItems.OfType<ChannelScheduleItem>())
{
item.PropertyChanged -= QueueItem_PropertyChanged;
}
}
if (e.NewItems is not null)
{
foreach (var item in e.NewItems.OfType<ChannelScheduleItem>())
{
item.PropertyChanged -= QueueItem_PropertyChanged;
item.PropertyChanged += QueueItem_PropertyChanged;
}
}
ApplyQueueThumbnailLayouts();
NotifyPlaybackStateChanged();
NotifyPlaybackPreviewChanged();
}
private void QueueItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ChannelScheduleItem.State)
or nameof(ChannelScheduleItem.DisplayName)
or nameof(ChannelScheduleItem.CurrentRegionLabel)
or nameof(ChannelScheduleItem.PreviewSource)
or nameof(ChannelScheduleItem.PreviewStatusLabel)
or nameof(ChannelScheduleItem.InternalNextPreviewSource)
or nameof(ChannelScheduleItem.InternalNextPreviewStatusLabel)
or nameof(ChannelScheduleItem.InternalNextPreviewDisplayName)
or nameof(ChannelScheduleItem.HasInternalNextPreview)
or nameof(ChannelScheduleItem.ThumbnailSource))
{
if (e.PropertyName is nameof(ChannelScheduleItem.State)
or nameof(ChannelScheduleItem.DisplayName)
or nameof(ChannelScheduleItem.CurrentRegionLabel))
{
NotifyPlaybackStateChanged();
}
NotifyPlaybackPreviewChanged();
}
}
private void NotifyPlaybackStateChanged()
{
OnPropertyChanged(
nameof(IsPlaying),
nameof(PlaybackIconBrush),
nameof(TransmissionLabel),
nameof(CurrentItemName),
nameof(NextItemName),
nameof(QueuedItemCount),
nameof(QueueFootnote),
nameof(QueueSummary));
}
private void NotifyPlaybackPreviewChanged()
{
OnPropertyChanged(
nameof(CurrentPreviewSource),
nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel),
nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth),
nameof(NextPreviewHeight));
}
private ThumbnailDisplayMetrics ResolvePlaybackPreviewMetrics(ChannelScheduleItem? item)
{
var template = item is null
? null
: _allFormats.FirstOrDefault(format => string.Equals(format.Id, item.FormatId, StringComparison.Ordinal));
return template is null
? ThumbnailLayoutResolver.ResolveDisplayMetrics(Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.PlaybackPreview)
: ThumbnailLayoutResolver.ResolveDisplayMetrics(template, _videoWallLayoutPreset, ThumbnailDisplayContext.PlaybackPreview);
}
private static ScheduleRegionOption? ResolvePreferredRegionOption(
@@ -948,11 +1195,35 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private static bool UsesAllDefaultRegionScope(FormatTemplateDefinition format)
{
if (IsBottomTurnoutSidoFormat(format))
{
return true;
}
if (IsBottomTurnoutDistrictFormat(format))
{
return false;
}
var source = $"{format.Name} {format.Id}";
return source.Contains("광역단체장", StringComparison.Ordinal) ||
source.Contains("교육감", StringComparison.Ordinal);
}
private static bool IsBottomTurnoutSidoFormat(FormatTemplateDefinition format)
{
return format.RecommendedChannel == BroadcastChannel.Bottom &&
(string.Equals(format.Name, "사전투표율_시도", StringComparison.Ordinal) ||
string.Equals(format.Name, "투표율_시도", StringComparison.Ordinal));
}
private static bool IsBottomTurnoutDistrictFormat(FormatTemplateDefinition format)
{
return format.RecommendedChannel == BroadcastChannel.Bottom &&
(string.Equals(format.Name, "사전투표율_시군구", StringComparison.Ordinal) ||
string.Equals(format.Name, "투표율_시군구", StringComparison.Ordinal));
}
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
{
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@ public sealed class DistrictOverviewCardViewModel
{
public required string DistrictViewName { get; init; }
public string DistrictCode { get; init; } = string.Empty;
public required string RegionName { get; init; }
public required string CountedRateDisplay { get; init; }

View File

@@ -22,6 +22,8 @@ public sealed class MainViewModel : ObservableObject
{
private static readonly Brush ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen);
private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed);
private static readonly Brush DataReceivingNavigationBrush = new SolidColorBrush(Colors.LimeGreen);
private static readonly Brush DataWaitingNavigationBrush = new SolidColorBrush(Colors.White);
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
private FormatCatalogService _formatCatalogService;
private readonly AppStateStore _stateStore;
@@ -35,7 +37,7 @@ public sealed class MainViewModel : ObservableObject
private bool _isSituationRoomExpanded;
private bool _suppressAutomaticSave;
private bool _isSyncingQueuedCutDurations;
private CancellationTokenSource? _automaticSaveCts;
private int _automaticSaveRevision;
private int? _windowX;
private int? _windowY;
private int? _windowWidth;
@@ -85,7 +87,11 @@ public sealed class MainViewModel : ObservableObject
_cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled);
Settings.PropertyChanged += Settings_PropertyChanged;
Data.PropertyChanged += Data_PropertyChanged;
_sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath, _cutDebugStateStore);
_sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(
_logService,
() => Settings.ImageRootPath,
_cutDebugStateStore,
Settings.GetKarismaLayerNo);
NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀", _sharedTornadoAdapter);
TopLeftChannel = CreateChannelViewModel(BroadcastChannel.TopLeft, "좌상단", _sharedTornadoAdapter);
@@ -208,6 +214,7 @@ public sealed class MainViewModel : ObservableObject
nameof(VideoWallVisibility),
nameof(PreElectionDataVisibility),
nameof(DataVisibility),
nameof(CareerPromiseDataVisibility),
nameof(CutListVisibility),
nameof(SettingsVisibility),
nameof(LogVisibility),
@@ -226,6 +233,7 @@ public sealed class MainViewModel : ObservableObject
AppPage.PreElectionData => "사전데이터",
AppPage.TurnoutData => "투표데이터",
AppPage.CountingData or AppPage.Data => "개표데이터",
AppPage.CareerPromiseData => "공약데이터",
AppPage.CutList => "컷리스트",
AppPage.Settings => "설정",
AppPage.Log => "로그",
@@ -270,6 +278,8 @@ public sealed class MainViewModel : ObservableObject
public Visibility DataVisibility => IsLiveDataPage(CurrentPage) ? Visibility.Visible : Visibility.Collapsed;
public Visibility CareerPromiseDataVisibility => CurrentPage == AppPage.CareerPromiseData ? Visibility.Visible : Visibility.Collapsed;
public Visibility CutListVisibility => CurrentPage == AppPage.CutList ? Visibility.Visible : Visibility.Collapsed;
public Visibility SettingsVisibility => CurrentPage == AppPage.Settings ? Visibility.Visible : Visibility.Collapsed;
@@ -398,6 +408,10 @@ public sealed class MainViewModel : ObservableObject
public Brush CgIntegrationBrush => IsCgConnected ? ConnectedStatusBrush : DisconnectedStatusBrush;
public Brush DataNavigationIconBrush => Data.HasLiveDataSignal
? DataReceivingNavigationBrush
: DataWaitingNavigationBrush;
public string CgIntegrationDetail
{
get
@@ -502,6 +516,7 @@ public sealed class MainViewModel : ObservableObject
"turnout-data" => AppPage.TurnoutData,
"counting-data" => AppPage.CountingData,
"data" => Data.IsPreElectionPhase ? AppPage.TurnoutData : AppPage.CountingData,
"career-promises" => AppPage.CareerPromiseData,
"cut-list" => AppPage.CutList,
"settings" => AppPage.Settings,
"log" => AppPage.Log,
@@ -509,6 +524,11 @@ public sealed class MainViewModel : ObservableObject
};
CurrentPage = targetPage;
if (targetPage == AppPage.CareerPromiseData)
{
Data.EnsureCareerPromiseElectionType();
}
SyncBroadcastPhaseForLiveDataPage(targetPage);
}
@@ -532,7 +552,7 @@ public sealed class MainViewModel : ObservableObject
var targetPhase = page switch
{
AppPage.TurnoutData => BroadcastPhase.PreElection,
AppPage.CountingData or AppPage.Data => BroadcastPhase.Counting,
AppPage.CountingData or AppPage.Data or AppPage.CareerPromiseData => BroadcastPhase.Counting,
_ => (BroadcastPhase?)null
};
@@ -688,9 +708,14 @@ public sealed class MainViewModel : ObservableObject
try
{
var result = await _thumbnailGeneratorService
.GenerateAsync(
_formatCatalogService.GetAll(),
var templates = _formatCatalogService.GetAll();
var result = _sharedTornadoAdapter is KarismaTornado3Adapter karismaAdapter
? await karismaAdapter.GenerateThumbnailsAsync(
templates,
Settings.SelectedStationVideoWallLayoutPreset,
CancellationToken.None)
: await _thumbnailGeneratorService.GenerateAsync(
templates,
Settings.ImageRootPath,
Settings.SelectedStationVideoWallLayoutPreset,
CancellationToken.None);
@@ -767,6 +792,11 @@ public sealed class MainViewModel : ObservableObject
OnPropertyChanged(nameof(HeaderStatus));
}
if (args.PropertyName is nameof(DataViewModel.HasLiveDataSignal))
{
OnPropertyChanged(nameof(DataNavigationIconBrush));
}
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
or nameof(DataViewModel.BroadcastPhase)
or nameof(DataViewModel.ElectionType)
@@ -875,6 +905,10 @@ public sealed class MainViewModel : ObservableObject
private void ApplyState(AppState state)
{
Settings.IsDebugFeaturesEnabled = state.IsDebugFeaturesEnabled;
Settings.NormalLayerNo = state.NormalLayerNo;
Settings.TopLeftLayerNo = state.TopLeftLayerNo;
Settings.BottomLayerNo = state.BottomLayerNo;
Settings.VideoWallLayerNo = state.VideoWallLayerNo;
if (RestoreSelection.RestoreStations)
{
@@ -956,54 +990,31 @@ public sealed class MainViewModel : ObservableObject
return;
}
CancelPendingAutomaticSave();
var automaticSaveCts = new CancellationTokenSource();
_automaticSaveCts = automaticSaveCts;
_ = RunAutomaticSaveAsync(automaticSaveCts);
var revision = Interlocked.Increment(ref _automaticSaveRevision);
_ = RunAutomaticSaveAsync(revision);
}
private async Task RunAutomaticSaveAsync(CancellationTokenSource automaticSaveCts)
private async Task RunAutomaticSaveAsync(int revision)
{
try
{
var cancellationToken = automaticSaveCts.Token;
await Task.Delay(AutomaticSaveDelay, cancellationToken).ConfigureAwait(false);
if (_suppressAutomaticSave || cancellationToken.IsCancellationRequested)
await Task.Delay(AutomaticSaveDelay).ConfigureAwait(false);
if (_suppressAutomaticSave || revision != Volatile.Read(ref _automaticSaveRevision))
{
return;
}
await SaveStateCoreAsync(writeLog: false).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_logService.Warning($"자동 저장 실패: {ex.Message}");
}
finally
{
if (ReferenceEquals(_automaticSaveCts, automaticSaveCts))
{
_automaticSaveCts = null;
}
automaticSaveCts.Dispose();
}
}
private void CancelPendingAutomaticSave()
{
if (_automaticSaveCts is null)
{
return;
}
_automaticSaveCts.Cancel();
_automaticSaveCts.Dispose();
_automaticSaveCts = null;
Interlocked.Increment(ref _automaticSaveRevision);
}
private async Task SaveStateCoreAsync(bool writeLog)
@@ -1024,6 +1035,10 @@ public sealed class MainViewModel : ObservableObject
WindowHeight = _windowHeight ?? 0,
IsWindowMaximized = _isWindowMaximized,
IsDebugFeaturesEnabled = Settings.IsDebugFeaturesEnabled,
NormalLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.Normal),
TopLeftLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.TopLeft),
BottomLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.Bottom),
VideoWallLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.VideoWall),
OperationMode = OperationMode.ToString(),
BroadcastPhase = Data.BroadcastPhase.ToString(),
IsPollingEnabled = Data.IsPollingEnabled,
@@ -1094,6 +1109,7 @@ public sealed class MainViewModel : ObservableObject
Data,
Settings.BuildSelectedStationProfile,
() => Settings.ImageRootPath,
() => Settings.SelectedStationVideoWallLayoutPreset,
formatId => _formatCatalogService.FindById(formatId),
_logService);

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using Tornado3_2026Election.Common;
@@ -11,12 +12,17 @@ public sealed class SettingsViewModel : ObservableObject
{
private string _selectedStationId = "KNN";
private string _imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
private bool _isDebugFeaturesEnabled = true;
private bool _isDebugFeaturesEnabled;
private int _normalLayerNo;
private int _topLeftLayerNo = 1;
private int _bottomLayerNo = 2;
private int _videoWallLayerNo;
private readonly IReadOnlyList<SelectionOption<VideoWallLayoutPreset>> _videoWallLayoutOptions =
[
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Auto, "자동"),
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Standard5760x1080, "5760 x 1080"),
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.UltraWide11520x1080, "11520 x 1080")
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Wall3840x810, "3840 x 810"),
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Wall2880x1080, "2880 x 1080"),
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.UltraWide8316x1080, "8316 x 1080")
];
public SettingsViewModel(IEnumerable<BroadcastStationProfile> stations)
@@ -84,6 +90,30 @@ public sealed class SettingsViewModel : ObservableObject
set => SetProperty(ref _isDebugFeaturesEnabled, value);
}
public double NormalLayerNo
{
get => _normalLayerNo;
set => SetLayerNo(ref _normalLayerNo, value, nameof(NormalLayerNo));
}
public double TopLeftLayerNo
{
get => _topLeftLayerNo;
set => SetLayerNo(ref _topLeftLayerNo, value, nameof(TopLeftLayerNo));
}
public double BottomLayerNo
{
get => _bottomLayerNo;
set => SetLayerNo(ref _bottomLayerNo, value, nameof(BottomLayerNo));
}
public double VideoWallLayerNo
{
get => _videoWallLayerNo;
set => SetLayerNo(ref _videoWallLayerNo, value, nameof(VideoWallLayerNo));
}
public StationFilterItemViewModel SelectedStation
=> Stations.FirstOrDefault(station => station.Id == SelectedStationId) ?? Stations[0];
@@ -125,9 +155,35 @@ public sealed class SettingsViewModel : ObservableObject
public string SelectedStationVideoWallLayoutSummary => SelectedStation.VideoWallLayoutSummary;
public int GetKarismaLayerNo(BroadcastChannel channel)
{
return channel switch
{
BroadcastChannel.TopLeft => _topLeftLayerNo,
BroadcastChannel.Bottom => _bottomLayerNo,
BroadcastChannel.VideoWall => _videoWallLayerNo,
_ => _normalLayerNo
};
}
public BroadcastStationProfile BuildSelectedStationProfile()
{
return SelectedStation.ToProfile();
}
private void SetLayerNo(ref int field, double value, string propertyName)
{
SetProperty(ref field, NormalizeLayerNo(value), propertyName);
}
private static int NormalizeLayerNo(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return 0;
}
return Math.Clamp((int)Math.Round(value, MidpointRounding.AwayFromZero), 0, 99);
}
}

View File

@@ -118,8 +118,9 @@ public sealed class StationFilterItemViewModel : ObservableObject
public string VideoWallLayoutSummary => VideoWallLayoutPreset switch
{
VideoWallLayoutPreset.Standard5760x1080 => "5760 x 1080 비디오월",
VideoWallLayoutPreset.UltraWide11520x1080 => "11520 x 1080 비디오월",
VideoWallLayoutPreset.Wall3840x810 => "3840 x 810 VideoWall",
VideoWallLayoutPreset.Wall2880x1080 => "2880 x 1080 VideoWall",
VideoWallLayoutPreset.UltraWide8316x1080 => "8316 x 1080 VideoWall",
_ => "씬 기준 자동 감지"
};

Binary file not shown.

View File

@@ -0,0 +1,886 @@
from __future__ import annotations
import csv
import hashlib
import os
from pathlib import Path
from typing import Iterable
from PIL import Image
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
ROOT = Path(__file__).resolve().parents[1]
AUDIT_DIR = ROOT / "artifacts" / "cut-file-audit" / "full_20260509_114655"
STABLE_AUDIT_DIR = ROOT / "artifacts" / "cut-file-audit" / "stable_pgm_dwm_20260510_10s"
RESULTS_CSV = AUDIT_DIR / "results.csv"
CAPTURE_DIR = AUDIT_DIR / "captures"
THUMB_DIR = ROOT / "Tornado3_2026Election" / "Assets" / "Thumbnail"
LIVE_DIR = ROOT / "artifacts" / "live-cut-validation" / "20260418_232730"
MEDIA_DIR = ROOT / "artifacts" / "design_issue_ppt_media_clean"
OUT_PATH = ROOT / "artifacts" / "design_tag_color_issues_20260510_clean_v2.pptx"
FONT = "Malgun Gothic"
SECTIONS = [
{
"title": "1-2위_ani_광역단체장",
"keys": ["1-2위_ani_광역단체장"],
"mode": "pair",
"badges": ["색상/RGB", "확인 필요"],
"bullets": [
"정당명 좌/우 색상 적용 지침이 없음.",
"RGB txt는 정당판/정당바/득표율 중심으로만 안내되어 있음.",
"RGB txt와 기본 컷 색상 차이가 존재함.",
],
},
{
"title": "1-2위_ani_기초단체장",
"keys": ["1-2위_ani_기초단체장"],
"mode": "pair",
"badges": ["색상/RGB", "확인 필요"],
"bullets": [
"득표수 색상값 지침이 없음.",
"정당색 RGB를 차용하려 했으나 실제 화면 색상과 차이가 있음.",
],
},
{
"title": "1-2위 시도별영상 계열",
"keys": ["1-2위_광역단체장_시도별영상", "1-2위_기초단체장_시도별영상"],
"mode": "grid",
"badges": ["색상/RGB", "운영 확인"],
"bullets": [
"RGB txt와 기본 컷 색상 차이가 존재함.",
"득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
"시도별 영상이 있는 지역은 해당 지역 컷을 로드해 활용하면 되는지 확인 필요.",
"예: 시도별_02_부산, 시도별_03_대구 등 지역별 컷 사용 여부.",
],
},
{
"title": "접전/초접전 단체장",
"keys": ["접전_광역단체장", "접전_기초단체장", "초접전_광역단체장", "초접전_기초단체장"],
"mode": "grid",
"badges": ["색상/RGB"],
"bullets": [
"득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
"정당바 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
],
},
{
"title": "1-3위_ani_광역단체장 / 1-3위_보궐선거",
"keys": ["1-3위_ani_광역단체장", "1-3위_보궐선거"],
"mode": "grid",
"badges": ["태그 의심", "색상/RGB"],
"bullets": [
"공백/잘못된 suffix 의심 태그: '순위01 2'.",
"'순위03' 태그 누락 의심. '순위01 2''순위03'으로 변경 후 재검증 필요.",
"그룹, 득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
],
},
{
"title": "1-3위_ani_기초단체장",
"keys": ["1-3위_ani_기초단체장"],
"mode": "pair",
"badges": ["색상/RGB", "확인 필요"],
"bullets": [
"순위 하늘색 그라데이션이 모든 정당에 적용되는 지침인지 확인 필요.",
"그룹, 득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
"기타 RGB txt 참조 시 색상 차이가 다소 존재함.",
],
},
{
"title": "Bottom 1-3위/1위 단체장",
"keys": ["1-3위_광역단체장", "1-3위_기초단체장", "1위_광역단체장", "1위_기초단체장"],
"mode": "grid",
"badges": ["태그 의심", "색상/RGB"],
"bullets": [
"Bottom 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1', 'data01 2'.",
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
],
},
{
"title": "1-3위_기초단체장 확장 계열",
"keys": ["1-3위_기초단체장_5760", "1-3위_기초단체장_L", "2880_1-3위_기초단체장", "8316_1-3위_기초단체장"],
"mode": "grid",
"badges": ["색상/RGB"],
"bullets": [
"1-3위_기초단체장_5760, L, 2880/810/8316 계열 대상.",
"그룹, 득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
],
},
{
"title": "2880_1위 단체장",
"keys": ["2880_1위_광역단체장", "2880_1위_기초단체장"],
"mode": "grid",
"badges": ["RGB 매핑", "확인 필요"],
"bullets": [
"RGB 기준 파일이 heuristic으로 연결됨.",
"각각 이시각1위 계열 RGB txt를 기준으로 봐도 되는지 안내 필요.",
"그룹, 득표율, 정당명, 정당바/정당색 색상 지침 일부가 RGB txt에 없음.",
],
},
{
"title": "당선 계열",
"keys": ["당선_광역단체장", "당선_광역의원", "당선_교육감", "당선_기초단체장", "당선_기초의원"],
"mode": "grid",
"badges": ["태그 의심", "색상/RGB", "색상 미적용"],
"bullets": [
"Bottom loop 포함 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1', 'data01 2'.",
"하단 RGB/당선.txt에 당선바(정당바) 색상 기준이 있으나 실제 하단 당선 컷에 적용되지 않음.",
"같은 파일에 득표수/득표율 색상 기준도 있으므로 적용 대상 태그와 매핑 방식 확인 필요.",
"2880/810/8316 및 HD/L 계열도 득표율 색상 지침 확인 필요.",
],
},
{
"title": "광역의원표 / 기초의원표",
"keys": ["광역의원표", "기초의원표", "광역의원표_HD", "광역의원표_L_1"],
"mode": "grid",
"badges": ["색상/RGB", "태그 체계", "캡처 실패"],
"bullets": [
"정당바 색상 태그가 있으나 RGB 기준 파일 또는 같은 섹션 지침이 없음.",
"loop/HD/L 계열에서 의석수 태그 체계가 기준과 다름.",
"의석수01A~04A 누락, 의석수0101A 등 추가 태그 확인 필요.",
"광역의원표_HD, 광역의원표_HD_loop, 광역의원표_L_1은 index out of range 캡처 실패 확인 필요.",
],
},
{
"title": "모든후보 계열",
"keys": ["모든후보_광역단체장", "모든후보_기초단체장", "모든후보_교육감", "모든후보_기초단체장_L"],
"mode": "grid",
"badges": ["색상/RGB", "태그 체계", "캡처 실패"],
"bullets": [
"득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
"값이 0.3%처럼 매우 적은 경우 음수/역방향 표기 가능성 확인 필요.",
"5760/L/2880/810/8316 계열에서 그룹01~03, 득표수02~03, 득표율02~03, 순위02~03 등 추가 태그가 기준과 다름.",
"2880_모든후보_기초단체장은 '개표율01 1', '선거구명01 1' 태그가 있어 기준 태그와 불일치함.",
"모든후보_기초단체장_L은 index out of range 캡처 실패 확인 필요.",
],
},
{
"title": "전후보 계열",
"keys": ["전후보_광역단체장", "전후보_교육감", "전후보_기초단체장"],
"mode": "grid",
"badges": ["태그 의심", "색상/RGB"],
"bullets": [
"공백/잘못된 suffix 의심 태그: 'data01 1', 'data01 2'.",
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
"값이 0.3%처럼 매우 적은 경우 음수/역방향 표기 가능성 확인 필요.",
],
},
{
"title": "경력 계열",
"keys": ["경력_광역단체장_in", "경력_기초단체장_in"],
"mode": "grid",
"badges": ["레이아웃", "RGB 매핑"],
"bullets": [
"기호가 두 자리(11/12)일 때 영역 침범 여부 확인 필요.",
"loop 컷은 RGB 기준 파일이 heuristic으로 '경력.txt'에 연결되어 있어 해당 기준 사용 가능 여부 안내 필요.",
],
},
{
"title": "사전_역대투표율",
"keys": ["사전_역대투표율", "사전_역대투표율_loop"],
"mode": "grid",
"badges": ["태그 누락", "알파 확인"],
"bullets": [
"원01~원06 태그 누락 의심.",
"애니메이션 mid/final PGM 캡처 기준 알파 처리 확인 필요.",
],
},
{
"title": "사전투표율",
"keys": ["사전투표율"],
"mode": "pair",
"badges": ["태그 의심", "색상/RGB"],
"bullets": [
"Bottom 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1'~'data01 7'.",
"그룹 색상 태그가 있으나 RGB 기준 파일 없음.",
],
},
{
"title": "사전_역대당선자 계열",
"keys": ["사전_역대당선자", "사전_역대당선자_교육감", "사전_역대당선자_기초단체장"],
"mode": "grid",
"badges": ["색상/RGB"],
"bullets": [
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
],
},
{
"title": "시도별 지역 컷",
"keys": ["시도별_02_부산", "시도별_03_대구", "시도별_05_전남광주", "시도별_16_경남"],
"mode": "grid",
"badges": ["RGB 기준 없음"],
"bullets": [
"대상: 부산, 대구, 전남광주, 대전, 세종, 울산, 강원, 충남, 전북, 경북, 경남.",
"득표율, 정당명, 정당바 색상 관련 태그가 있으나 RGB 기준 파일 없음.",
],
},
{
"title": "역대시도판세 계열",
"keys": ["역대시도판세_광역단체장", "역대시도판세_기초단체장"],
"mode": "grid",
"badges": ["RGB 매핑", "색상/RGB"],
"bullets": [
"RGB 기준 파일이 heuristic으로 연결됨.",
"광역은 '판세_광역단체장.txt', 기초는 '1-2위_ani_기초단체장.txt' 기준 사용 가능 여부 안내 필요.",
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
],
},
{
"title": "이시각1위_광역단체장",
"keys": ["이시각1위_광역단체장", "이시각1위_광역단체장_HD"],
"mode": "grid",
"badges": ["색상/RGB", "태그 불일치"],
"bullets": [
"그룹, 득표율, 정당명, 정당바, 정당색 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
"HD 컷은 기준 대비 개표율02~03, 그룹01~03, 득표수02~03, 득표율02~03, 선거구명02 등이 누락되어 태그 불일치 확인 필요.",
],
},
{
"title": "이시각1위_기초단체장",
"keys": ["이시각1위_기초단체장", "이시각1위_기초단체장_HD"],
"mode": "grid",
"badges": ["색상/RGB", "태그 불일치"],
"bullets": [
"그룹, 득표율 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
"HD 컷은 기준 대비 3위 관련 태그가 누락되어 확인 필요.",
"누락 의심: 개표율03, 그룹03, 득표수03, 득표율03, 선거구명03, 시도명03, 유확당03, 정당명03, 정당바03, 정당색03.",
],
},
{
"title": "판세_광역단체장",
"keys": ["판세_광역단체장"],
"mode": "pair",
"badges": ["색상/RGB"],
"bullets": [
"정당명, 정당바 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
],
},
{
"title": "판세_기초단체장",
"keys": ["판세_기초단체장"],
"mode": "pair",
"badges": ["색상/RGB"],
"bullets": [
"득표율 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
],
},
{
"title": "판세 좌상단 계열",
"keys": ["판세_광역단체장", "판세_광역의원", "판세_교육감", "판세_기초단체장", "판세_기초의원"],
"mode": "grid",
"badges": ["RGB 기준 없음"],
"bullets": [
"좌상단 판세 계열 대상.",
"정당명, 정당바 색상 관련 태그가 있으나 RGB 기준 파일 없음.",
],
},
{
"title": "투표율",
"keys": ["투표율", "투표율_loop"],
"mode": "grid",
"badges": ["태그 의심", "태그 불일치", "색상/RGB"],
"bullets": [
"Bottom 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1'~'data01 7'.",
"loop 컷은 기준시 태그가 빠지고 기준시01/기준시02가 추가되어 태그 불일치 확인 필요.",
"그룹 색상 태그가 있으나 RGB 기준 파일 없음.",
],
},
{
"title": "투표율_사진",
"keys": ["투표율_사진"],
"mode": "pair",
"badges": ["태그 누락"],
"bullets": [
"텍스트 '(14시 기준)' 태그 누락 의심.",
],
},
{
"title": "투표율_선거구별 (좌상단)",
"keys": ["투표율_선거구별"],
"mode": "pair",
"badges": ["태그 의심", "색상/RGB"],
"bullets": [
"잘못된 태그 형태로 되어 있으나 사용자 요청에 따라 미변경.",
"변경 시 재작업 필요 여부 별도 언급 필요.",
"공백/잘못된 suffix 의심 태그: '시도명01 1'~'시도명01 3', '투표율01 1'~'투표율01 2'.",
"그룹 색상 태그가 있으나 RGB 기준 파일 없음.",
],
},
{
"title": "투표율_시도별",
"keys": ["투표율_시도별", "투표율_시도별_L"],
"mode": "grid",
"badges": ["태그 불일치"],
"bullets": [
"loop 계열에서 기준 대비 '유권자수' 태그가 추가되어 태그 불일치 확인 필요.",
],
},
{
"title": "2인 후보 좌상단 계열",
"keys": ["광역단체장_2인", "기초단체장_2인", "기초단체장_2인_텍스트"],
"mode": "grid",
"badges": ["태그 누락", "색상/RGB"],
"bullets": [
"광역단체장_2인, 기초단체장_2인, 기초단체장_2인_텍스트 대상.",
"후보명01, 후보명02 태그 누락 의심.",
"정당심볼 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
],
},
{
"title": "광역단체장_2인_텍스트",
"keys": ["광역단체장_2인_텍스트"],
"mode": "pair",
"badges": ["색상/RGB"],
"bullets": [
"득표율, 정당심볼 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
],
},
{
"title": "8316 계열",
"keys": ["8316_광역단체장", "8316_광역의원"],
"mode": "grid",
"badges": ["RGB 매핑", "색상/RGB"],
"bullets": [
"8316_광역단체장은 RGB 기준 파일이 heuristic으로 '1-2위_ani_광역단체장.txt'에 연결되어 있어 기준 사용 가능 여부 안내 필요.",
"8316_광역의원은 득표율, 정당명 색상 관련 태그가 있으나 RGB 기준 파일 없음.",
],
},
]
NO_ISSUE_ITEMS = [
"1-2위_광역단체장 (Bottom/Normal 5760/L 포함)",
"1-2위_광역단체장_loop (Bottom)",
"1-2위_기초단체장",
"1-2위_기초단체장 (Bottom)",
"1-2위_기초단체장_loop (Bottom)",
"1-2위_교육감",
"1-2위_보궐선거",
"1-2위_ani_기초단체장_5760",
"1-2위_ani_기초단체장_L",
"2880_1-2위_ani_기초단체장",
"810_1-2위_ani_기초단체장",
"2880_1-2위_광역단체장",
"810_1-2위_광역단체장",
"8316_1-2위_광역단체장",
"민방_타이틀 계열",
"투표율_02_부산, 투표율_03_대구, 투표율_05_전남광주",
"투표율_06_대전, 투표율_07_세종, 투표율_08_울산",
"투표율_10_강원, 투표율_12_충남, 투표율_14_전북",
"투표율_15_경북, 투표율_16_경남",
"투표율_선거구별 사전",
"투표율_시도별",
"투표율_시도별_L",
"투표율_영상",
"투표율 (좌상단)",
]
def load_rows(paths: Iterable[Path]) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for path in paths:
if not path.exists():
continue
with path.open("r", encoding="utf-8-sig", newline="") as f:
rows.extend(csv.DictReader(f))
return rows
def all_pngs(*dirs: Path) -> list[Path]:
paths: list[Path] = []
for d in dirs:
if d.exists():
paths.extend(d.rglob("*.png"))
return paths
def parse_captures(value: str) -> list[tuple[str, Path]]:
captures: list[tuple[str, Path]] = []
if not value:
return captures
for token in value.split(" | "):
parts = token.split(":", 2)
if len(parts) != 3:
continue
label, _stage, path = parts
p = Path(path)
if p.exists():
captures.append((label, p))
return captures
def row_sort_key(row: dict[str, str], key: str) -> tuple[int, int, int, int]:
status = row.get("Status", "")
basename = row.get("BaseName", "")
folder = row.get("Folder", "")
exact = 0 if basename == key else 1
status_rank = {"issue": 0, "needs-guidance": 1, "capture-failed": 2, "pass": 3}.get(status, 4)
folder_rank = 0 if "Normal" in folder else (1 if "Bottom" in folder else 2)
return exact, status_rank, folder_rank, len(basename)
def matching_rows(rows: list[dict[str, str]], key: str) -> list[dict[str, str]]:
exact = [r for r in rows if r.get("BaseName") == key]
contains = [
r for r in rows
if r not in exact and (key in r.get("BaseName", "") or r.get("BaseName", "") in key)
]
return sorted(exact + contains, key=lambda r: row_sort_key(r, key))
def first_thumb(pngs: list[Path], key: str) -> Path | None:
exact = [p for p in pngs if p.stem == key]
if exact:
return sorted(exact, key=lambda p: len(str(p)))[0]
contains = [p for p in pngs if key in p.stem or p.stem in key]
if contains:
return sorted(contains, key=lambda p: len(str(p)))[0]
return None
def image_items_for_section(section: dict, rows: list[dict[str, str]], pngs: list[Path]) -> list[tuple[Path, str]]:
items: list[tuple[Path, str]] = []
keys = section["keys"]
mode = section.get("mode", "pair")
if mode == "pair":
for key in keys:
for row in matching_rows(rows, key):
captures = parse_captures(row.get("Captures", ""))
if captures:
preferred = []
for label in ("baseline", "Basic", "Variant", "Stress"):
preferred.extend([(p, f"{row['Folder']} / {row['BaseName']} - {label}") for lbl, p in captures if lbl == label])
if preferred:
return preferred[:2]
thumb = first_thumb(pngs, key)
if thumb:
return [(thumb, f"thumbnail / {key}")]
return []
for key in keys:
added = False
for row in matching_rows(rows, key):
captures = parse_captures(row.get("Captures", ""))
if captures:
label, path = next(((lbl, p) for lbl, p in captures if lbl in ("Basic", "Variant")), captures[0])
items.append((path, f"{row['Folder']} / {row['BaseName']} - {label}"))
added = True
break
if not added:
thumb = first_thumb(pngs, key)
if thumb:
items.append((thumb, f"thumbnail / {key}"))
if len(items) >= 4:
break
return items[:4]
def preprocess_image(path: Path) -> Path:
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:12]
out = MEDIA_DIR / f"{digest}_{path.name}"
if out.exists():
return out
with Image.open(path) as im:
im = im.convert("RGBA")
bg = Image.new("RGBA", im.size, (24, 29, 39, 255))
composed = Image.alpha_composite(bg, im)
composed.convert("RGB").save(out, quality=95)
return out
def set_run_font(run, size: float, color: tuple[int, int, int] = (20, 25, 35), bold: bool = False):
run.font.name = FONT
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = RGBColor(*color)
def add_text(slide, text: str, x: float, y: float, w: float, h: float, size: float = 14,
color: tuple[int, int, int] = (20, 25, 35), bold: bool = False,
align=PP_ALIGN.LEFT):
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
tf = box.text_frame
tf.clear()
tf.margin_left = Inches(0.05)
tf.margin_right = Inches(0.05)
tf.margin_top = Inches(0.02)
tf.margin_bottom = Inches(0.02)
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
set_run_font(run, size, color, bold)
return box
def add_badges(slide, badges: Iterable[str], x: float, y: float):
colors = {
"색상/RGB": (28, 98, 177),
"RGB 기준 없음": (179, 87, 23),
"RGB 매핑": (110, 73, 190),
"태그 의심": (184, 51, 74),
"태그 누락": (184, 51, 74),
"태그 불일치": (184, 51, 74),
"태그 체계": (184, 51, 74),
"캡처 실패": (153, 52, 52),
"확인 필요": (76, 115, 42),
"운영 확인": (76, 115, 42),
"레이아웃": (77, 91, 112),
"알파 확인": (77, 91, 112),
}
cx = x
for badge in badges:
fill = colors.get(badge, (80, 90, 105))
width = max(0.8, min(1.45, 0.24 + len(badge) * 0.16))
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(cx), Inches(y), Inches(width), Inches(0.28))
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(*fill)
shape.line.fill.background()
tf = shape.text_frame
tf.clear()
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
run = p.add_run()
run.text = badge
set_run_font(run, 8.5, (255, 255, 255), True)
cx += width + 0.12
def add_bullets(slide, bullets: list[str], x: float, y: float, w: float, h: float):
panel = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
panel.fill.solid()
panel.fill.fore_color.rgb = RGBColor(246, 248, 251)
panel.line.color.rgb = RGBColor(218, 224, 232)
panel.line.width = Pt(1)
tf = panel.text_frame
tf.clear()
tf.margin_left = Inches(0.22)
tf.margin_right = Inches(0.16)
tf.margin_top = Inches(0.15)
tf.margin_bottom = Inches(0.12)
tf.word_wrap = True
for idx, bullet in enumerate(bullets):
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
p.text = f"{bullet}"
p.level = 0
p.space_after = Pt(4)
p.font.name = FONT
p.font.size = Pt(11.2 if len(bullets) <= 3 else 10.4)
p.font.color.rgb = RGBColor(31, 41, 55)
def fit_box(img_path: Path, x: float, y: float, w: float, h: float) -> tuple[float, float, float, float]:
with Image.open(img_path) as im:
iw, ih = im.size
scale = min(w / iw, h / ih)
nw = iw * scale
nh = ih * scale
return x + (w - nw) / 2, y + (h - nh) / 2, nw, nh
def add_image(slide, path: Path, caption: str, x: float, y: float, w: float, h: float):
bg = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
bg.fill.solid()
bg.fill.fore_color.rgb = RGBColor(24, 29, 39)
bg.line.color.rgb = RGBColor(200, 207, 217)
bg.line.width = Pt(0.8)
prepped = preprocess_image(path)
ix, iy, iw, ih = fit_box(prepped, x + 0.04, y + 0.04, w - 0.08, h - 0.36)
slide.shapes.add_picture(str(prepped), Inches(ix), Inches(iy), Inches(iw), Inches(ih))
add_text(slide, caption, x + 0.08, y + h - 0.28, w - 0.16, 0.18, 6.8, (207, 216, 226), False, PP_ALIGN.CENTER)
def add_issue_slide(prs: Presentation, section: dict, rows: list[dict[str, str]], pngs: list[Path]):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, (255, 255, 255))
add_header(slide, section["title"])
add_badges(slide, section.get("badges", []), 0.62, 0.72)
images = image_items_for_section(section, rows, pngs)
if len(images) <= 2:
left_w = 7.15
if len(images) == 1:
add_image(slide, images[0][0], images[0][1], 0.62, 1.22, left_w, 4.75)
elif len(images) == 2:
add_image(slide, images[0][0], images[0][1], 0.62, 1.22, left_w, 2.28)
add_image(slide, images[1][0], images[1][1], 0.62, 3.72, left_w, 2.28)
else:
add_empty_image_note(slide, 0.62, 1.22, left_w, 4.75)
else:
boxes = [(0.62, 1.22), (4.25, 1.22), (0.62, 3.72), (4.25, 3.72)]
for (path, caption), (x, y) in zip(images, boxes):
add_image(slide, path, caption, x, y, 3.35, 2.28)
add_bullets(slide, section["bullets"], 8.18, 1.22, 4.55, 4.75)
add_footer(slide, "Source: PGM DWM 10s recapture first, full_20260509_114655 and Assets/Thumbnail fallback")
def add_empty_image_note(slide, x: float, y: float, w: float, h: float):
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(246, 248, 251)
shape.line.color.rgb = RGBColor(218, 224, 232)
tf = shape.text_frame
tf.clear()
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
run = p.add_run()
run.text = "매칭 가능한 캡처/썸네일 없음"
set_run_font(run, 16, (95, 106, 122), True)
def set_background(slide, color: tuple[int, int, int]):
bg = slide.background
bg.fill.solid()
bg.fill.fore_color.rgb = RGBColor(*color)
def add_header(slide, title: str):
add_text(slide, title, 0.55, 0.28, 9.2, 0.38, 20, (22, 27, 36), True)
line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(0.98), Inches(12.25), Inches(0.02))
line.fill.solid()
line.fill.fore_color.rgb = RGBColor(222, 227, 235)
line.line.fill.background()
def add_footer(slide, text: str):
add_text(slide, text, 0.6, 7.1, 12.1, 0.2, 7.5, (115, 126, 143))
def add_title_slide(prs: Presentation):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, (18, 24, 35))
add_text(slide, "Tornado3 2026 Election", 0.75, 1.2, 11.8, 0.45, 18, (172, 190, 214), True)
add_text(slide, "디자인 태그/색상 이슈 정리", 0.75, 1.72, 11.8, 0.82, 34, (255, 255, 255), True)
add_text(slide, "스크린샷 포함 검토용 PPT · 2026-05-09", 0.78, 2.66, 9.2, 0.35, 15, (226, 232, 240))
add_text(slide, "범위: RGB txt 지침 누락, 기본 컷 색상 차이, 태그 suffix/누락 의심, 계열별 태그 불일치, 캡처 실패", 0.78, 3.22, 11.6, 0.5, 13, (202, 213, 226))
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(0.82), Inches(4.6), Inches(11.68), Inches(1.35))
card.fill.solid()
card.fill.fore_color.rgb = RGBColor(30, 41, 59)
card.line.color.rgb = RGBColor(64, 82, 110)
add_text(slide, "사용 캡처", 1.08, 4.83, 2.0, 0.25, 12, (148, 163, 184), True)
add_text(slide, "artifacts/cut-file-audit/stable_pgm_dwm_20260510_10s/captures + Assets/Thumbnail fallback", 1.08, 5.18, 10.8, 0.28, 12, (241, 245, 249))
def add_summary_slide(prs: Presentation, rows: list[dict[str, str]]):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, (255, 255, 255))
add_header(slide, "요약")
counts: dict[str, int] = {}
for row in rows:
counts[row.get("Status", "")] = counts.get(row.get("Status", ""), 0) + 1
cards = [
("이슈/확인 필요 섹션", str(len(SECTIONS)), (28, 98, 177)),
("issue", str(counts.get("issue", 0)), (184, 51, 74)),
("needs-guidance", str(counts.get("needs-guidance", 0)), (179, 87, 23)),
("capture-failed", str(counts.get("capture-failed", 0)), (153, 52, 52)),
("pass", str(counts.get("pass", 0)), (76, 115, 42)),
]
x = 0.62
for title, value, color in cards:
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(1.35), Inches(2.3), Inches(1.18))
card.fill.solid()
card.fill.fore_color.rgb = RGBColor(247, 249, 252)
card.line.color.rgb = RGBColor(218, 224, 232)
add_text(slide, title, x + 0.18, 1.55, 1.95, 0.24, 9.5, (88, 101, 120), True, PP_ALIGN.CENTER)
add_text(slide, value, x + 0.18, 1.88, 1.95, 0.4, 24, color, True, PP_ALIGN.CENTER)
x += 2.45
add_text(slide, "핵심 판단 축", 0.7, 3.02, 4.0, 0.3, 17, (22, 27, 36), True)
summary_bullets = [
"RGB txt에 색상 섹션이 없거나, heuristic 매핑 기준 확인이 필요한 컷이 다수 있음.",
"공백이 포함된 suffix 태그('data01 1', '순위01 2' 등)는 정식 태그인지 재검증 필요.",
"HD/L/loop/2880/810/8316 계열에서 기준 컷과 태그 체계가 달라지는 항목이 있음.",
"일부 컷은 index out of range 캡처 실패가 발생하여 런타임 쪽 확인이 필요함.",
]
add_bullets(slide, summary_bullets, 0.7, 3.45, 12.0, 2.4)
add_footer(slide, "Counts are from results.csv; issue list follows the user-provided review notes.")
def add_no_issue_slides(prs: Presentation):
for idx in range(0, len(NO_ISSUE_ITEMS), 12):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, (255, 255, 255))
suffix = "" if idx == 0 else f" ({idx // 12 + 1})"
add_header(slide, f"특이사항 발견 못함{suffix}")
chunk = NO_ISSUE_ITEMS[idx:idx + 12]
add_bullets(slide, chunk, 0.85, 1.35, 11.7, 5.25)
add_footer(slide, "사용자 기록 기준: 별도 이상 징후 없음으로 분류된 컷")
def add_badges(slide, badges: Iterable[str], x: float, y: float):
if not badges:
return
add_text(slide, ", ".join(badges), x, y, 7.8, 0.24, 9.5, (104, 116, 133))
def add_bullets(slide, bullets: list[str], x: float, y: float, w: float, h: float):
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
tf = box.text_frame
tf.clear()
tf.margin_left = Inches(0.02)
tf.margin_right = Inches(0.02)
tf.margin_top = Inches(0.02)
tf.margin_bottom = Inches(0.02)
tf.word_wrap = True
size = 11.4 if len(bullets) <= 3 else 10.4
for idx, bullet in enumerate(bullets):
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
p.text = f"- {bullet}"
p.space_after = Pt(6)
p.font.name = FONT
p.font.size = Pt(size)
p.font.color.rgb = RGBColor(31, 41, 55)
def preprocess_image(path: Path) -> Path:
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:12]
out = MEDIA_DIR / f"clean_{digest}_{path.name}"
if out.exists():
return out
with Image.open(path) as im:
if im.mode in ("RGBA", "LA") or ("transparency" in im.info):
im = im.convert("RGBA")
bg = Image.new("RGBA", im.size, (255, 255, 255, 255))
im = Image.alpha_composite(bg, im).convert("RGB")
else:
im = im.convert("RGB")
im.save(out, quality=95)
return out
def add_image(slide, path: Path, caption: str, x: float, y: float, w: float, h: float):
prepped = preprocess_image(path)
caption_h = 0.28
ix, iy, iw, ih = fit_box(prepped, x, y, w, h - caption_h)
slide.shapes.add_picture(str(prepped), Inches(ix), Inches(iy), Inches(iw), Inches(ih))
add_text(slide, caption, x, y + h - caption_h + 0.05, w, 0.18, 6.8, (92, 103, 118), False, PP_ALIGN.CENTER)
def add_issue_slide(prs: Presentation, section: dict, rows: list[dict[str, str]], pngs: list[Path]):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, (255, 255, 255))
add_header(slide, section["title"])
add_badges(slide, section.get("badges", []), 0.67, 0.73)
images = image_items_for_section(section, rows, pngs)
if not images:
add_empty_image_note(slide, 0.68, 1.28, 7.25, 4.85)
elif len(images) == 1:
add_image(slide, images[0][0], images[0][1], 0.68, 1.28, 7.25, 4.85)
else:
boxes = [
(0.68, 1.28, 3.55, 2.45),
(4.40, 1.28, 3.55, 2.45),
(0.68, 4.08, 3.55, 2.45),
(4.40, 4.08, 3.55, 2.45),
]
for (path, caption), (x, y, w, h) in zip(images, boxes):
add_image(slide, path, caption, x, y, w, h)
add_text(slide, "확인 내용", 8.45, 1.28, 3.95, 0.3, 13, (22, 27, 36), True)
add_bullets(slide, section["bullets"], 8.45, 1.72, 4.15, 4.65)
add_footer(slide, "Source: PGM DWM 10s recapture first, full_20260509_114655 and Assets/Thumbnail fallback")
def add_empty_image_note(slide, x: float, y: float, w: float, h: float):
add_text(slide, "매칭 가능한 캡처/썸네일 없음", x, y + h / 2 - 0.18, w, 0.35, 14, (95, 106, 122), True, PP_ALIGN.CENTER)
def add_header(slide, title: str):
add_text(slide, title, 0.62, 0.28, 11.9, 0.42, 21, (22, 27, 36), True)
def add_footer(slide, text: str):
add_text(slide, text, 0.66, 7.08, 12.1, 0.2, 7.3, (130, 140, 154))
def add_title_slide(prs: Presentation):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, (255, 255, 255))
add_text(slide, "Tornado3 2026 Election", 0.78, 1.25, 11.6, 0.4, 17, (91, 105, 124), True)
add_text(slide, "디자인 태그/색상 이슈 정리", 0.78, 1.78, 11.6, 0.72, 33, (22, 27, 36), True)
add_text(slide, "PGM 재캡처 적용본 · 2026-05-10", 0.8, 2.82, 8.8, 0.35, 14, (80, 92, 110))
add_text(slide, "캡처 기준: artifacts/cut-file-audit/stable_pgm_dwm_20260510_10s", 0.8, 3.32, 10.8, 0.3, 11.5, (104, 116, 133))
add_text(slide, "RGB txt 지침 누락, 기본 컷 색상 차이, 태그 suffix/누락 의심, 계열별 태그 불일치, 캡처 실패 항목을 정리했습니다.", 0.8, 4.08, 11.4, 0.55, 13, (49, 60, 75))
def add_summary_slide(prs: Presentation, rows: list[dict[str, str]]):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, (255, 255, 255))
add_header(slide, "요약")
counts: dict[str, int] = {}
for row in rows:
status = row.get("Status", "")
counts[status] = counts.get(status, 0) + 1
summary_lines = [
f"이슈/확인 필요 섹션: {len(SECTIONS)}",
f"issue: {counts.get('issue', 0)}",
f"needs-guidance: {counts.get('needs-guidance', 0)}",
f"capture-failed: {counts.get('capture-failed', 0)}",
f"pass: {counts.get('pass', 0)}",
]
for idx, line in enumerate(summary_lines):
add_text(slide, line, 0.82, 1.28 + idx * 0.44, 4.8, 0.32, 15, (31, 41, 55), idx == 0)
add_text(slide, "핵심 판단 축", 0.82, 4.0, 4.0, 0.3, 17, (22, 27, 36), True)
summary_bullets = [
"RGB txt에 색상 섹션이 없거나 heuristic 매핑 기준 확인이 필요한 컷이 다수 있음.",
"공백이 포함된 suffix 태그와 누락 의심 태그는 정식 태그인지 재검증 필요.",
"HD/L/loop/2880/810/8316 계열에서 기준 컷과 태그 체계가 달라지는 항목이 있음.",
"일부 컷은 index out of range 캡처 실패가 발생해 원본 쪽 확인 필요.",
]
add_bullets(slide, summary_bullets, 0.82, 4.48, 11.5, 1.7)
add_footer(slide, "Counts are from results.csv; issue list follows the user-provided review notes.")
def add_no_issue_slides(prs: Presentation):
for idx in range(0, len(NO_ISSUE_ITEMS), 12):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, (255, 255, 255))
suffix = "" if idx == 0 else f" ({idx // 12 + 1})"
add_header(slide, f"특이사항 발견 못함{suffix}")
chunk = NO_ISSUE_ITEMS[idx:idx + 12]
add_bullets(slide, chunk, 0.86, 1.28, 11.5, 5.25)
add_footer(slide, "사용자 기록 기준: 별도 이상 징후 없음으로 분류된 컷")
def build():
summary_rows = load_rows([STABLE_AUDIT_DIR / "results.csv"])
image_rows = load_rows([STABLE_AUDIT_DIR / "results.csv", RESULTS_CSV])
pngs = all_pngs(STABLE_AUDIT_DIR / "captures", CAPTURE_DIR, THUMB_DIR, LIVE_DIR)
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
add_title_slide(prs)
add_summary_slide(prs, summary_rows)
for section in SECTIONS:
add_issue_slide(prs, section, image_rows, pngs)
add_no_issue_slides(prs)
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
prs.save(OUT_PATH)
print(OUT_PATH)
print(f"slides={len(prs.slides)}")
if __name__ == "__main__":
build()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,7 @@
<Compile Include="..\..\Tornado3_2026Election\Services\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaChartCellUpdate.cs" Link="AppSource\Services\KarismaChartCellUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCropKeyUpdate.cs" Link="AppSource\Services\KarismaCropKeyUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaPositionUpdate.cs" Link="AppSource\Services\KarismaPositionUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolutionReader.cs" Link="AppSource\Services\KarismaSceneResolutionReader.cs" />

View File

@@ -0,0 +1,27 @@
using System.Threading;
using System.Threading.Tasks;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class KarismaThumbnailGeneratorService
{
public KarismaThumbnailGeneratorService(LogService logService)
{
}
public Task<ThumbnailGenerationResult> GenerateAsync(
TornadoManager manager,
IReadOnlyList<FormatTemplateDefinition> templates,
string t3CutPath,
VideoWallLayoutPreset videoWallLayoutPreset,
CancellationToken cancellationToken)
{
return Task.FromResult(new ThumbnailGenerationResult(0, 0));
}
}
public readonly record struct ThumbnailGenerationResult(int GeneratedCount, int FailedCount)
{
public int TotalCount => GeneratedCount + FailedCount;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
using System;
using System.Globalization;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using KAsyncEngineLib;
@@ -319,6 +321,12 @@ if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComp
return;
}
if (args.Length > 0 && string.Equals(args[0], "--audit-party-colors-live", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = await LiveCutValidation.RunPartyColorAuditAsync(args[1..]).ConfigureAwait(false);
return;
}
if (args.Length > 0 && string.Equals(args[0], "--validate-current-api-cuts", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = await CurrentApiCutDiagnostics.RunAsync(args[1..]).ConfigureAwait(false);
@@ -337,6 +345,12 @@ if (args.Length > 0 && string.Equals(args[0], "--report-cut-debug-coverage", Str
return;
}
if (args.Length > 0 && string.Equals(args[0], "--audit-cut-files", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = await CutFileAudit.RunAsync(args[1..]).ConfigureAwait(false);
return;
}
var options = ProbeOptions.Parse(args);
Console.WriteLine($"Karisma TCP probe starting. target={options.Host}:{options.Port} timeout={options.Timeout.TotalSeconds:0}s");
@@ -862,6 +876,33 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
IReadOnlyList<SceneValidationOperation> operations = string.IsNullOrWhiteSpace(options.OperationsPath)
? Array.Empty<SceneValidationOperation>()
: LoadSceneOperations(options.ScenePath, options.OperationsPath);
foreach (var operation in operations)
{
var operationResult = ApplySceneOperation(handler, scene!, operation, options.Connection.Timeout);
if (!string.Equals(operationResult.Result, eKResult.RESULT_SUCCESS.ToString(), StringComparison.Ordinal))
{
if (operation.ContinueOnFailure)
{
Console.WriteLine(
$"[SAVE-IMAGE] Optional operation {operationResult.Method} failed for '{operationResult.ObjectName}': " +
$"{operationResult.Result} {operationResult.Detail}");
continue;
}
completion.TrySetResult(
new SaveSceneImageProbeResult(
true,
"SUCCESS",
operationResult.Result,
options.OutputPath,
$"Operation {operationResult.Method} failed for '{operationResult.ObjectName}': {operationResult.Detail}"));
return;
}
}
if (options.MaterialOpacity is not null)
{
Console.WriteLine(
@@ -976,31 +1017,38 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
var positionKeyUpdates = new List<PositionKeyUpdate>();
if (options.PositionKey is not null)
{
positionKeyUpdates.Add(options.PositionKey);
}
positionKeyUpdates.AddRange(options.PositionKeys);
foreach (var positionKeyUpdate in positionKeyUpdates)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting position key object={options.PositionKey.ObjectName} index={options.PositionKey.KeyIndex} " +
$"value=({options.PositionKey.X},{options.PositionKey.Y},{options.PositionKey.Z}) vector={options.PositionKey.VectorType}...");
var sceneObject = scene.GetObject(options.PositionKey.ObjectName);
$"[SAVE-IMAGE] Setting position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
$"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.PositionKey.ObjectName}' was not found."));
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionKeyUpdate.ObjectName}' was not found."));
return;
}
handler.ResetPositionKeyTask();
sceneObject.SetPositionKey(
options.PositionKey.KeyIndex,
options.PositionKey.X,
options.PositionKey.Y,
options.PositionKey.Z,
options.PositionKey.VectorType);
positionKeyUpdate.KeyIndex,
positionKeyUpdate.X,
positionKeyUpdate.Y,
positionKeyUpdate.Z,
positionKeyUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{options.PositionKey.ObjectName}'." ));
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{positionKeyUpdate.ObjectName}'." ));
return;
}
@@ -1008,7 +1056,7 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
if (positionKeyResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={options.PositionKey.ObjectName}"));
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={positionKeyUpdate.ObjectName}"));
return;
}
}
@@ -1154,24 +1202,118 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
var outputDirectory = Path.GetDirectoryName(options.OutputPath);
foreach (var positionUpdate in options.PostPositions)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting post-chart position object={positionUpdate.ObjectName} " +
$"value=({positionUpdate.X},{positionUpdate.Y},{positionUpdate.Z}) vector={positionUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionUpdate.ObjectName}' was not found."));
return;
}
handler.ResetPositionTask();
sceneObject.SetPosition(
positionUpdate.X,
positionUpdate.Y,
positionUpdate.Z,
positionUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{positionUpdate.ObjectName}'." ));
return;
}
var positionResult = handler.PositionTask.Result;
if (positionResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={positionUpdate.ObjectName}"));
return;
}
}
foreach (var positionKeyUpdate in options.PostPositionKeys)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting post-chart position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
$"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionKeyUpdate.ObjectName}' was not found."));
return;
}
handler.ResetPositionKeyTask();
sceneObject.SetPositionKey(
positionKeyUpdate.KeyIndex,
positionKeyUpdate.X,
positionKeyUpdate.Y,
positionKeyUpdate.Z,
positionKeyUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{positionKeyUpdate.ObjectName}'." ));
return;
}
var positionKeyResult = handler.PositionKeyTask.Result;
if (positionKeyResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={positionKeyUpdate.ObjectName}"));
return;
}
}
var captures = new List<(string OutputPath, int Frame)>();
if (options.Frames.Count > 0)
{
var captureDirectory = options.OutputDirectory ?? options.OutputPath;
foreach (var captureFrame in options.Frames)
{
captures.Add((
Path.GetFullPath(Path.Combine(
captureDirectory,
string.Format(CultureInfo.InvariantCulture, options.OutputPattern, captureFrame))),
captureFrame));
}
}
else
{
captures.Add((options.OutputPath, options.Frame));
}
long totalBytes = 0;
foreach (var capture in captures)
{
var outputDirectory = Path.GetDirectoryName(capture.OutputPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
if (File.Exists(options.OutputPath))
if (File.Exists(capture.OutputPath))
{
File.Delete(options.OutputPath);
File.Delete(capture.OutputPath);
}
Console.WriteLine("[SAVE-IMAGE] Calling SaveSceneImage()...");
Console.WriteLine($"[SAVE-IMAGE] Calling SaveSceneImage() frame={capture.Frame} output={capture.OutputPath}...");
handler.ResetSaveSceneImageTask();
scene.SaveSceneImage(options.OutputPath, options.Width, options.Height, options.Frame);
scene.SaveSceneImage(capture.OutputPath, options.Width, options.Height, capture.Frame);
if (!WaitForTaskWithMessagePump(handler.SaveSceneImageTask, options.Connection.Timeout))
{
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, "OnSaveSceneImage timed out."));
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", capture.OutputPath, "OnSaveSceneImage timed out."));
return;
}
@@ -1179,28 +1321,42 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
if (saveResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", saveResult.ToString(), options.OutputPath, $"OnSaveSceneImage result={saveResult}"));
new SaveSceneImageProbeResult(true, "SUCCESS", saveResult.ToString(), capture.OutputPath, $"OnSaveSceneImage result={saveResult}"));
return;
}
var savedThisFrame = false;
var fileWaitDeadline = DateTime.UtcNow + options.Connection.Timeout;
while (DateTime.UtcNow < fileWaitDeadline)
{
if (File.Exists(options.OutputPath))
if (File.Exists(capture.OutputPath))
{
var info = new FileInfo(options.OutputPath);
var info = new FileInfo(capture.OutputPath);
if (info.Length > 0)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", options.OutputPath, $"Saved {info.Length} bytes."));
return;
totalBytes += info.Length;
savedThisFrame = true;
break;
}
}
Thread.Sleep(50);
}
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, "Image file was not created."));
if (!savedThisFrame)
{
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", capture.OutputPath, "Image file was not created."));
return;
}
}
var resultOutput = options.Frames.Count > 0
? options.OutputDirectory ?? options.OutputPath
: options.OutputPath;
var detail = captures.Count == 1
? $"Saved {totalBytes} bytes."
: $"Saved {captures.Count} frames ({totalBytes} bytes).";
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", resultOutput, detail));
}
catch (Exception ex)
{
@@ -1283,7 +1439,16 @@ static Task<SceneCatalogProbeResult> CatalogScenesAsync(SceneCatalogOptions opti
.EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories)
.Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) ||
path.Contains(options.SceneFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(path => new
{
Path = path,
RelativePath = Path.GetRelativePath(options.RootPath, path)
})
.OrderBy(item => item.RelativePath.Count(character =>
character == Path.DirectorySeparatorChar ||
character == Path.AltDirectorySeparatorChar))
.ThenBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase)
.Select(item => item.Path)
.Take(options.MaxScenes ?? int.MaxValue)
.ToArray();
@@ -2309,142 +2474,7 @@ static Task<SceneValidationProbeResult> ValidateSceneOperationsAsync(SceneValida
foreach (var operation in operations)
{
Console.WriteLine($"[VALIDATE] {operation.Method} object={operation.ObjectName}");
var sceneObject = scene.GetObject(operation.ObjectName);
if (sceneObject is null)
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"OBJECT_NOT_FOUND",
"scene.GetObject returned null."));
continue;
}
if (string.Equals(operation.Method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase))
{
if (sceneObject is not IKACounter counter)
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"NOT_A_COUNTER",
"Object does not implement IKACounter."));
continue;
}
handler.ResetCounterNumberKeyTask();
counter.SetCounterNumberKey(operation.KeyIndex, operation.Number);
if (!WaitForTaskWithMessagePump(handler.CounterNumberKeyTask, options.Connection.Timeout))
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetCounterNumberKey timed out."));
continue;
}
var callbackResult = handler.CounterNumberKeyTask.Result;
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
callbackResult.ToString(),
string.Empty));
continue;
}
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
{
handler.ResetVisibleTask();
sceneObject.SetVisible(operation.Visible ? 1 : 0);
if (!WaitForTaskWithMessagePump(handler.VisibleTask, options.Connection.Timeout))
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetVisible timed out."));
continue;
}
var callbackResult = handler.VisibleTask.Result;
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
callbackResult.ToString(),
string.Empty));
continue;
}
if (string.Equals(operation.Method, "SetStyleColor", StringComparison.OrdinalIgnoreCase))
{
if (sceneObject is not IKAStyle style)
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"NOT_A_STYLE_OBJECT",
"Object does not implement IKAStyle."));
continue;
}
handler.ResetStyleColorTask();
style.SetStyleColor(
ParseStyleType(operation.StyleType),
operation.Order,
operation.R,
operation.G,
operation.B,
operation.A);
if (!WaitForTaskWithMessagePump(handler.StyleColorTask, options.Connection.Timeout))
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetStyleColor timed out."));
continue;
}
var callbackResult = handler.StyleColorTask.Result;
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
callbackResult.ToString(),
string.Empty));
continue;
}
handler.ResetSetValueTask();
sceneObject.SetValue(operation.Value ?? string.Empty);
if (!WaitForTaskWithMessagePump(handler.SetValueTask, options.Connection.Timeout))
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetValue timed out."));
continue;
}
var setValueResult = handler.SetValueTask.Result;
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
setValueResult.ToString(),
string.Empty));
results.Add(ApplySceneOperation(handler, scene, operation, options.Connection.Timeout));
}
WriteSceneValidationMarkdown(options, results);
@@ -2532,7 +2562,16 @@ static Task<FolderInspectionProbeResult> InspectTscnFolderAsync(FolderInspection
.EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories)
.Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) ||
path.Contains(options.SceneFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(path => new
{
Path = path,
RelativePath = Path.GetRelativePath(options.RootPath, path)
})
.OrderBy(item => item.RelativePath.Count(character =>
character == Path.DirectorySeparatorChar ||
character == Path.AltDirectorySeparatorChar))
.ThenBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase)
.Select(item => item.Path)
.Take(options.MaxScenes ?? int.MaxValue)
.ToArray();
@@ -2865,7 +2904,12 @@ static void WriteFolderInspectionMarkdown(
static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOptions options)
{
var json = File.ReadAllText(options.OperationsPath);
return LoadSceneOperations(options.ScenePath, options.OperationsPath);
}
static List<SceneValidationOperation> LoadSceneOperations(string scenePath, string operationsPath)
{
var json = File.ReadAllText(operationsPath);
var operations = JsonSerializer.Deserialize<List<SceneValidationOperation>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
@@ -2875,13 +2919,143 @@ static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOp
{
if (!string.IsNullOrWhiteSpace(operation.Value))
{
operation.Value = operation.Value.Replace("${SCENE_DIR}", Path.GetDirectoryName(options.ScenePath) ?? string.Empty, StringComparison.Ordinal);
operation.Value = operation.Value.Replace("${SCENE_DIR}", Path.GetDirectoryName(scenePath) ?? string.Empty, StringComparison.Ordinal);
}
}
return operations;
}
static SceneOperationValidationResult ApplySceneOperation(
ProbeEventHandler handler,
IKAScene scene,
SceneValidationOperation operation,
TimeSpan timeout)
{
Console.WriteLine($"[VALIDATE] {operation.Method} object={operation.ObjectName}");
var sceneObject = scene.GetObject(operation.ObjectName);
if (sceneObject is null)
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"OBJECT_NOT_FOUND",
"scene.GetObject returned null.");
}
if (string.Equals(operation.Method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase))
{
if (sceneObject is not IKACounter counter)
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"NOT_A_COUNTER",
"Object does not implement IKACounter.");
}
handler.ResetCounterNumberKeyTask();
counter.SetCounterNumberKey(operation.KeyIndex, operation.Number);
if (!WaitForTaskWithMessagePump(handler.CounterNumberKeyTask, timeout))
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetCounterNumberKey timed out.");
}
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
handler.CounterNumberKeyTask.Result.ToString(),
string.Empty);
}
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
{
handler.ResetVisibleTask();
sceneObject.SetVisible(operation.Visible ? 1 : 0);
if (!WaitForTaskWithMessagePump(handler.VisibleTask, timeout))
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetVisible timed out.");
}
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
handler.VisibleTask.Result.ToString(),
string.Empty);
}
if (string.Equals(operation.Method, "SetStyleColor", StringComparison.OrdinalIgnoreCase))
{
if (sceneObject is not IKAStyle style)
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"NOT_A_STYLE_OBJECT",
"Object does not implement IKAStyle.");
}
handler.ResetStyleColorTask();
style.SetStyleColor(
ParseStyleType(operation.StyleType),
operation.Order,
operation.R,
operation.G,
operation.B,
operation.A);
if (!WaitForTaskWithMessagePump(handler.StyleColorTask, timeout))
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetStyleColor timed out.");
}
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
handler.StyleColorTask.Result.ToString(),
string.Empty);
}
handler.ResetSetValueTask();
sceneObject.SetValue(operation.Value ?? string.Empty);
if (!WaitForTaskWithMessagePump(handler.SetValueTask, timeout))
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetValue timed out.");
}
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
handler.SetValueTask.Result.ToString(),
string.Empty);
}
static string DescribeOperationPayload(SceneValidationOperation operation)
{
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
@@ -3387,17 +3561,24 @@ internal sealed record SaveSceneImageOptions(
int Width,
int Height,
int Frame,
IReadOnlyList<int> Frames,
string? OutputDirectory,
string OutputPattern,
string? SetObjectName,
string? SetObjectValue,
string? VisibleObjectName,
bool? VisibleObjectValue,
VariableNameUpdate? VariableName,
CloneObjectUpdate? CloneObject,
string? OperationsPath,
MaterialOpacityUpdate? MaterialOpacity,
SizeUpdate? Size,
PositionUpdate? Position,
IReadOnlyList<PositionUpdate> Positions,
PositionKeyUpdate? PositionKey,
IReadOnlyList<PositionKeyUpdate> PositionKeys,
IReadOnlyList<PositionUpdate> PostPositions,
IReadOnlyList<PositionKeyUpdate> PostPositionKeys,
string? ChartObjectName,
string? ChartCsvPath,
IReadOnlyList<ChartCellUpdate> ChartCells,
@@ -3411,6 +3592,9 @@ internal sealed record SaveSceneImageOptions(
string? scenePath = null;
string? sceneAlias = null;
string? outputPath = null;
string? outputDirectory = null;
string outputPattern = "frame_{0:D4}.png";
IReadOnlyList<int> frames = Array.Empty<int>();
string? setObjectName = null;
string? setObjectValue = null;
string? visibleObjectName = null;
@@ -3419,6 +3603,7 @@ internal sealed record SaveSceneImageOptions(
string? variableNameValue = null;
string? cloneSourceObjectName = null;
string? cloneVariableName = null;
string? operationsPath = null;
string? materialOpacityObjectName = null;
float? materialOpacityValue = null;
string? sizeObjectName = null;
@@ -3429,6 +3614,9 @@ internal sealed record SaveSceneImageOptions(
string? positionKeyObjectName = null;
int positionKeyIndex = 1;
string? positionKeyRaw = null;
string? positionKeysRaw = null;
string? postPositionsRaw = null;
string? postPositionKeysRaw = null;
string? chartObjectName = null;
string? chartCsvPath = null;
string? chartCellsRaw = null;
@@ -3452,6 +3640,12 @@ internal sealed record SaveSceneImageOptions(
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
break;
case "--output-dir" when index + 1 < args.Length:
outputDirectory = args[++index];
break;
case "--output-pattern" when index + 1 < args.Length:
outputPattern = args[++index];
break;
case "--set-object" when index + 1 < args.Length:
setObjectName = args[++index];
break;
@@ -3481,6 +3675,9 @@ internal sealed record SaveSceneImageOptions(
case "--clone-name" when index + 1 < args.Length:
cloneVariableName = args[++index];
break;
case "--operations" when index + 1 < args.Length:
operationsPath = args[++index];
break;
case "--material-opacity-object" when index + 1 < args.Length:
materialOpacityObjectName = args[++index];
break;
@@ -3513,6 +3710,15 @@ internal sealed record SaveSceneImageOptions(
case "--position-key" when index + 1 < args.Length:
positionKeyRaw = args[++index];
break;
case "--position-keys" when index + 1 < args.Length:
positionKeysRaw = args[++index];
break;
case "--post-positions" when index + 1 < args.Length:
postPositionsRaw = args[++index];
break;
case "--post-position-keys" when index + 1 < args.Length:
postPositionKeysRaw = args[++index];
break;
case "--chart-object" when index + 1 < args.Length:
chartObjectName = args[++index];
break;
@@ -3543,6 +3749,9 @@ internal sealed record SaveSceneImageOptions(
frame = parsedFrame;
index++;
break;
case "--frames" when index + 1 < args.Length:
frames = ParseFrameSequence(args[++index]);
break;
}
}
@@ -3551,13 +3760,27 @@ internal sealed record SaveSceneImageOptions(
throw new ArgumentException("--scene is required.");
}
if (string.IsNullOrWhiteSpace(outputPath))
if (frames.Count > 0)
{
outputDirectory ??= outputPath;
if (string.IsNullOrWhiteSpace(outputDirectory))
{
throw new ArgumentException("--output-dir is required when --frames is provided.");
}
outputDirectory = Path.GetFullPath(outputDirectory);
outputPath ??= outputDirectory;
}
else if (string.IsNullOrWhiteSpace(outputPath))
{
throw new ArgumentException("--output is required.");
}
scenePath = Path.GetFullPath(scenePath);
outputPath = Path.GetFullPath(outputPath);
operationsPath = string.IsNullOrWhiteSpace(operationsPath)
? null
: Path.GetFullPath(operationsPath);
sceneAlias ??= Path.GetFileNameWithoutExtension(scenePath);
return new SaveSceneImageOptions(
connection,
@@ -3567,17 +3790,24 @@ internal sealed record SaveSceneImageOptions(
width,
height,
frame,
frames,
outputDirectory,
outputPattern,
setObjectName,
setObjectValue,
visibleObjectName,
visibleObjectValue,
ParseVariableName(variableNameObjectName, variableNameValue),
ParseCloneObject(cloneSourceObjectName, cloneVariableName),
operationsPath,
ParseMaterialOpacity(materialOpacityObjectName, materialOpacityValue),
ParseSize(sizeObjectName, sizeRaw),
ParsePosition(positionObjectName, positionRaw),
ParsePositions(positionsRaw),
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
ParsePositionKeys(positionKeysRaw),
ParsePositions(postPositionsRaw),
ParsePositionKeys(postPositionKeysRaw),
chartObjectName,
chartCsvPath,
ParseChartCells(chartCellsRaw),
@@ -3586,6 +3816,53 @@ internal sealed record SaveSceneImageOptions(
ParsePathModifications(modifyPathRaw));
}
private static IReadOnlyList<int> ParseFrameSequence(string value)
{
var frames = new List<int>();
foreach (var token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var rangeMatch = Regex.Match(token, @"^(?<start>-?\d+)-(?<end>-?\d+)(?::(?<step>\d+))?$", RegexOptions.CultureInvariant);
if (rangeMatch.Success)
{
var start = int.Parse(rangeMatch.Groups["start"].Value, CultureInfo.InvariantCulture);
var end = int.Parse(rangeMatch.Groups["end"].Value, CultureInfo.InvariantCulture);
var step = rangeMatch.Groups["step"].Success
? int.Parse(rangeMatch.Groups["step"].Value, CultureInfo.InvariantCulture)
: 1;
if (step <= 0)
{
throw new ArgumentException("--frames range step must be greater than zero.");
}
if (start <= end)
{
for (var frame = start; frame <= end; frame += step)
{
frames.Add(frame);
}
}
else
{
for (var frame = start; frame >= end; frame -= step)
{
frames.Add(frame);
}
}
continue;
}
if (!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var singleFrame))
{
throw new ArgumentException($"Invalid frame token: {token}");
}
frames.Add(singleFrame);
}
return frames.Distinct().ToArray();
}
private static CloneObjectUpdate? ParseCloneObject(string? sourceObjectName, string? variableName)
{
if (string.IsNullOrWhiteSpace(sourceObjectName) || string.IsNullOrWhiteSpace(variableName))
@@ -3724,6 +4001,38 @@ internal sealed record SaveSceneImageOptions(
return new PositionKeyUpdate(objectName, keyIndex, x, y, z, vectorType);
}
private static IReadOnlyList<PositionKeyUpdate> ParsePositionKeys(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return Array.Empty<PositionKeyUpdate>();
}
var updates = new List<PositionKeyUpdate>();
foreach (var token in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var nameParts = token.Split('=', 2, StringSplitOptions.TrimEntries);
if (nameParts.Length != 2)
{
throw new ArgumentException($"Invalid position key update: {token}");
}
var objectAndKey = nameParts[0].Split('#', 2, StringSplitOptions.TrimEntries);
if (objectAndKey.Length != 2 || !int.TryParse(objectAndKey[1], out var keyIndex))
{
throw new ArgumentException($"Invalid position key object/index: {nameParts[0]}");
}
var update = ParsePositionKey(objectAndKey[0], keyIndex, nameParts[1]);
if (update is not null)
{
updates.Add(update);
}
}
return updates;
}
private static IReadOnlyList<ChartCellUpdate> ParseChartCells(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
@@ -3857,7 +4166,7 @@ internal sealed record SceneCatalogOptions(
switch (args[index])
{
case "--root" when index + 1 < args.Length:
index++;
rootPath = args[++index];
break;
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
@@ -4195,7 +4504,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
switch (args[index])
{
case "--root" when index + 1 < args.Length:
index++;
rootPath = args[++index];
break;
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
@@ -4358,6 +4667,8 @@ internal sealed class SceneValidationOperation
public int A { get; set; } = 255;
public bool Visible { get; set; }
public bool ContinueOnFailure { get; set; }
}
internal sealed record SceneOperationValidationResult(string ObjectName, string Method, string Payload, string Result, string Detail);

View File

@@ -0,0 +1,99 @@
param(
[string]$CertificatePath = (Join-Path $PSScriptRoot "Comtrophy_MSIX_Signing.cer"),
[string]$CertificateUri = "http://122.34.248.185/msix/Comtrophy_MSIX_Signing.cer",
[ValidateSet("LocalMachine", "CurrentUser")]
[string]$StoreScope = "LocalMachine",
[switch]$NoElevate,
[switch]$NoPause
)
$ErrorActionPreference = "Stop"
$ExpectedThumbprint = "E691A33C64DF20A204FFD4F096B9C3EB4B95709C"
$downloadedCertificate = $false
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Quote-Argument {
param([Parameter(Mandatory)][string]$Value)
'"' + $Value.Replace('"', '\"') + '"'
}
if ($StoreScope -eq "LocalMachine" -and -not (Test-IsAdministrator)) {
if ($NoElevate) {
throw "LocalMachine certificate import requires an elevated PowerShell session."
}
if (-not $PSCommandPath) {
throw "LocalMachine certificate import requires an elevated PowerShell session."
}
Write-Host "Restarting as administrator to trust the MSIX signing certificate for this PC..."
$arguments = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", (Quote-Argument $PSCommandPath),
"-CertificatePath", (Quote-Argument $CertificatePath),
"-CertificateUri", (Quote-Argument $CertificateUri),
"-StoreScope", $StoreScope,
"-NoElevate"
)
if ($NoPause) {
$arguments += "-NoPause"
}
$process = Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -Verb RunAs -Wait -PassThru
exit $process.ExitCode
}
if (-not (Test-Path -LiteralPath $CertificatePath)) {
$certificateDirectory = Split-Path -Parent $CertificatePath
if ($certificateDirectory -and -not (Test-Path -LiteralPath $certificateDirectory)) {
New-Item -ItemType Directory -Path $certificateDirectory -Force | Out-Null
}
Write-Host "Downloading MSIX signing certificate..."
Invoke-WebRequest -Uri $CertificateUri -OutFile $CertificatePath -UseBasicParsing
$downloadedCertificate = $true
}
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertificatePath)
if ($certificate.Thumbprint -ne $ExpectedThumbprint) {
throw "Unexpected certificate thumbprint. Expected $ExpectedThumbprint but got $($certificate.Thumbprint)."
}
$stores = @(
"Cert:\$StoreScope\TrustedPeople",
"Cert:\$StoreScope\Root"
)
foreach ($store in $stores) {
$existing = Get-ChildItem -Path $store | Where-Object { $_.Thumbprint -eq $ExpectedThumbprint }
if ($existing) {
Write-Host "Certificate already trusted in $store"
continue
}
Write-Host "Importing certificate into $store"
Import-Certificate -FilePath $CertificatePath -CertStoreLocation $store | Out-Null
}
Write-Host "MSIX signing certificate is trusted in $StoreScope for thumbprint $ExpectedThumbprint."
Write-Host ""
Write-Host "Certificate setup is complete."
Write-Host "Install the app separately with this link:"
Write-Host "http://122.34.248.185/msix/Tornado3_2026Election_x64.appinstaller"
if ($downloadedCertificate) {
Write-Host "Certificate saved to $CertificatePath"
}
if (-not $NoPause) {
Write-Host ""
Read-Host "Press Enter to close this window"
}

View File

@@ -0,0 +1,475 @@
param(
[string]$ProjectPath = (Join-Path $PSScriptRoot "..\..\Tornado3_2026Election\Tornado3_2026Election.csproj"),
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Release",
[string]$Platform = "x64",
[string]$RuntimeIdentifier = "win-x64",
[ValidatePattern("^\d+\.\d+\.\d+\.\d+$")]
[string]$PackageVersion,
[switch]$IncrementPackageRevision,
[string]$PublicBaseUri = "http://122.34.248.185/msix/",
[string]$NasHost = "192.168.200.129",
[int]$NasSshPort = 22,
[string]$NasUser = $env:NAS_USER,
[string]$NasRemotePath = "/volume1/web/msix",
[string]$SshKeyPath = $env:NAS_SSH_KEY,
[string]$CertificateThumbprint = "E691A33C64DF20A204FFD4F096B9C3EB4B95709C",
[string]$CertificateFileName = "Comtrophy_MSIX_Signing.cer",
[string]$InstallCertificateScriptPath = (Join-Path $PSScriptRoot "Install-ComtrophyMsixCertificate.ps1"),
[switch]$SkipPackageBuild,
[switch]$NoUpload,
[switch]$NoVerify
)
$ErrorActionPreference = "Stop"
function Resolve-FullPath {
param([Parameter(Mandatory)][string]$Path)
if (Test-Path -LiteralPath $Path) {
return (Resolve-Path -LiteralPath $Path).Path
}
$executionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
}
function Invoke-Checked {
param(
[Parameter(Mandatory)][string]$FilePath,
[Parameter(Mandatory)][string[]]$Arguments
)
Write-Host "> $FilePath $($Arguments -join ' ')"
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$FilePath failed with exit code $LASTEXITCODE."
}
}
function Test-IsUnderDirectory {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Directory
)
$normalizedPath = [System.IO.Path]::GetFullPath($Path).TrimEnd(
[System.IO.Path]::DirectorySeparatorChar,
[System.IO.Path]::AltDirectorySeparatorChar
)
$normalizedDirectory = [System.IO.Path]::GetFullPath($Directory).TrimEnd(
[System.IO.Path]::DirectorySeparatorChar,
[System.IO.Path]::AltDirectorySeparatorChar
)
return $normalizedPath.Equals($normalizedDirectory, [System.StringComparison]::OrdinalIgnoreCase) -or
$normalizedPath.StartsWith(
$normalizedDirectory + [System.IO.Path]::DirectorySeparatorChar,
[System.StringComparison]::OrdinalIgnoreCase
) -or
$normalizedPath.StartsWith(
$normalizedDirectory + [System.IO.Path]::AltDirectorySeparatorChar,
[System.StringComparison]::OrdinalIgnoreCase
)
}
function Find-NewestFile {
param(
[Parameter(Mandatory)][string]$Root,
[Parameter(Mandatory)][string]$Filter,
[DateTime]$NotBefore = [DateTime]::MinValue,
[string]$ExcludeDirectory
)
$resolvedExcludeDirectory = if ($ExcludeDirectory -and (Test-Path -LiteralPath $ExcludeDirectory)) {
Resolve-FullPath $ExcludeDirectory
}
else {
$null
}
Get-ChildItem -Path $Root -Recurse -File -Filter $Filter |
Where-Object {
$_.LastWriteTime -ge $NotBefore -and
(-not $resolvedExcludeDirectory -or -not (Test-IsUnderDirectory -Path $_.FullName -Directory $resolvedExcludeDirectory))
} |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
}
function Find-PackageFileByLeafName {
param(
[Parameter(Mandatory)][string]$PackageRoot,
[Parameter(Mandatory)][string]$LeafName,
[string]$PreferredPathFragment,
[string]$ExcludeDirectory
)
$resolvedExcludeDirectory = if ($ExcludeDirectory -and (Test-Path -LiteralPath $ExcludeDirectory)) {
Resolve-FullPath $ExcludeDirectory
}
else {
$null
}
$candidates = Get-ChildItem -Path $PackageRoot -Recurse -File -Filter $LeafName |
Where-Object {
-not $resolvedExcludeDirectory -or
-not (Test-IsUnderDirectory -Path $_.FullName -Directory $resolvedExcludeDirectory)
}
if ($PreferredPathFragment) {
$preferred = $candidates |
Where-Object { $_.FullName -like "*$PreferredPathFragment*" } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($preferred) {
return $preferred
}
}
$candidates | Sort-Object LastWriteTime -Descending | Select-Object -First 1
}
function Get-UriLeafName {
param([Parameter(Mandatory)][string]$Uri)
try {
return [System.IO.Path]::GetFileName(([System.Uri]$Uri).AbsolutePath)
}
catch {
return [System.IO.Path]::GetFileName($Uri.Replace("/", "\"))
}
}
function Ensure-CertificateFile {
param(
[Parameter(Mandatory)][string]$CertificatePath,
[Parameter(Mandatory)][string]$Thumbprint
)
if (Test-Path -LiteralPath $CertificatePath) {
return
}
$certificate = Get-ChildItem -Path Cert:\CurrentUser\My |
Where-Object { $_.Thumbprint -eq $Thumbprint } |
Select-Object -First 1
if (-not $certificate) {
throw "Could not find signing certificate $Thumbprint in Cert:\CurrentUser\My."
}
Export-Certificate -Cert $certificate -FilePath $CertificatePath -Force | Out-Null
}
function Get-PackageManifestVersion {
param([Parameter(Mandatory)][string]$ManifestPath)
if (-not (Test-Path -LiteralPath $ManifestPath)) {
throw "Package manifest was not found: $ManifestPath"
}
$manifestXml = New-Object System.Xml.XmlDocument
$manifestXml.Load($ManifestPath)
$namespaceManager = New-Object System.Xml.XmlNamespaceManager($manifestXml.NameTable)
$namespaceManager.AddNamespace("pkg", $manifestXml.DocumentElement.NamespaceURI)
$identityNode = $manifestXml.SelectSingleNode("/pkg:Package/pkg:Identity", $namespaceManager)
if (-not $identityNode) {
throw "Identity node was not found in $ManifestPath."
}
$identityNode.GetAttribute("Version")
}
function Get-NextPackageRevision {
param([Parameter(Mandatory)][string]$Version)
$parts = $Version.Split(".")
if ($parts.Count -ne 4) {
throw "Package version must have four parts: $Version"
}
$numbers = foreach ($part in $parts) {
[int]$part
}
$numbers[3] += 1
$numbers -join "."
}
function Set-PackageManifestVersion {
param(
[Parameter(Mandatory)][string]$ManifestPath,
[Parameter(Mandatory)][string]$Version
)
if (-not (Test-Path -LiteralPath $ManifestPath)) {
throw "Package manifest was not found: $ManifestPath"
}
$manifestXml = New-Object System.Xml.XmlDocument
$manifestXml.PreserveWhitespace = $true
$manifestXml.Load($ManifestPath)
$namespaceManager = New-Object System.Xml.XmlNamespaceManager($manifestXml.NameTable)
$namespaceManager.AddNamespace("pkg", $manifestXml.DocumentElement.NamespaceURI)
$identityNode = $manifestXml.SelectSingleNode("/pkg:Package/pkg:Identity", $namespaceManager)
if (-not $identityNode) {
throw "Identity node was not found in $ManifestPath."
}
$currentVersion = $identityNode.GetAttribute("Version")
if ($currentVersion -eq $Version) {
Write-Host "Package manifest version is already $Version"
return
}
$identityNode.SetAttribute("Version", $Version)
$manifestXml.Save($ManifestPath)
Write-Host "Updated package manifest version from $currentVersion to $Version"
}
function Join-PublicUri {
param(
[Parameter(Mandatory)][string]$BaseUri,
[Parameter(Mandatory)][string]$LeafName
)
$normalizedBase = $BaseUri
if (-not $normalizedBase.EndsWith("/")) {
$normalizedBase += "/"
}
"$normalizedBase$LeafName"
}
$projectFullPath = Resolve-FullPath $ProjectPath
$projectDirectory = Split-Path -Parent $projectFullPath
$manifestPath = Join-Path $projectDirectory "Package.appxmanifest"
$packageRoot = Join-Path $projectDirectory "AppPackages"
$stagingRoot = Join-Path $packageRoot "msix-publish-flat"
$certificatePath = Join-Path $stagingRoot $CertificateFileName
$normalizedPublicBaseUri = $PublicBaseUri
if (-not $normalizedPublicBaseUri.EndsWith("/")) {
$normalizedPublicBaseUri += "/"
}
if ($PackageVersion -and $IncrementPackageRevision) {
throw "Use either PackageVersion or IncrementPackageRevision, not both."
}
if ($PackageVersion -and $SkipPackageBuild) {
throw "PackageVersion requires a new package build. Remove -SkipPackageBuild and run again."
}
if ($IncrementPackageRevision -and $SkipPackageBuild) {
throw "IncrementPackageRevision requires a new package build. Remove -SkipPackageBuild and run again."
}
if ($IncrementPackageRevision) {
$currentPackageVersion = Get-PackageManifestVersion -ManifestPath $manifestPath
$PackageVersion = Get-NextPackageRevision -Version $currentPackageVersion
Write-Host "Auto-incrementing package version from $currentPackageVersion to $PackageVersion"
}
if ($PackageVersion) {
Set-PackageManifestVersion -ManifestPath $manifestPath -Version $PackageVersion
}
$signingCertificate = Get-ChildItem -Path Cert:\CurrentUser\My |
Where-Object { $_.Thumbprint -eq $CertificateThumbprint } |
Select-Object -First 1
if (-not $signingCertificate) {
throw "Signing certificate $CertificateThumbprint was not found in Cert:\CurrentUser\My."
}
if (-not $signingCertificate.HasPrivateKey) {
throw "Signing certificate $CertificateThumbprint does not have a private key."
}
$buildStartedAt = Get-Date
if (-not $SkipPackageBuild) {
$dotnetArgs = @(
"msbuild",
$projectFullPath,
"/restore",
"/t:Build",
"/p:Configuration=$Configuration",
"/p:Platform=$Platform",
"/p:RuntimeIdentifier=$RuntimeIdentifier",
"/p:GenerateAppxPackageOnBuild=true",
"/p:GenerateAppInstallerFile=true",
"/p:AppxPackageSigningEnabled=true",
"/p:PackageCertificateThumbprint=$CertificateThumbprint",
"/p:AppInstallerUri=$normalizedPublicBaseUri",
"/p:AppxBundle=Never"
)
Invoke-Checked -FilePath "dotnet" -Arguments $dotnetArgs
}
if (Test-Path -LiteralPath $stagingRoot) {
$resolvedStaging = Resolve-FullPath $stagingRoot
$resolvedPackageRoot = Resolve-FullPath $packageRoot
if (-not (Test-IsUnderDirectory -Path $resolvedStaging -Directory $resolvedPackageRoot)) {
throw "Refusing to clean staging path outside package root: $resolvedStaging"
}
Get-ChildItem -LiteralPath $stagingRoot -Force | Remove-Item -Recurse -Force
}
else {
New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null
}
$appInstallerFile = Find-NewestFile -Root $packageRoot -Filter "*_${Platform}.appinstaller" -NotBefore $(if ($SkipPackageBuild) { [DateTime]::MinValue } else { $buildStartedAt.AddMinutes(-2) }) -ExcludeDirectory $stagingRoot
if (-not $appInstallerFile) {
$appInstallerFile = Find-NewestFile -Root $packageRoot -Filter "*.appinstaller" -ExcludeDirectory $stagingRoot
}
if (-not $appInstallerFile) {
throw "Could not find an .appinstaller file under $packageRoot."
}
$stagedAppInstallerPath = Join-Path $stagingRoot $appInstallerFile.Name
Copy-Item -LiteralPath $appInstallerFile.FullName -Destination $stagedAppInstallerPath -Force
$appInstallerXml = New-Object System.Xml.XmlDocument
$appInstallerXml.PreserveWhitespace = $true
$appInstallerXml.Load($stagedAppInstallerPath)
$namespaceManager = New-Object System.Xml.XmlNamespaceManager($appInstallerXml.NameTable)
$namespaceManager.AddNamespace("ai", $appInstallerXml.DocumentElement.NamespaceURI)
$mainPackageNode = $appInstallerXml.SelectSingleNode("//ai:MainPackage", $namespaceManager)
if (-not $mainPackageNode) {
throw "MainPackage node was not found in $($appInstallerFile.FullName)."
}
$mainPackageLeafName = Get-UriLeafName $mainPackageNode.GetAttribute("Uri")
$mainPackageFile = Find-PackageFileByLeafName -PackageRoot $packageRoot -LeafName $mainPackageLeafName -ExcludeDirectory $stagingRoot
if (-not $mainPackageFile) {
$mainPackageFile = Get-ChildItem -Path $packageRoot -Recurse -File -Filter "*.msix" |
Where-Object {
$_.FullName -notlike "*\Dependencies\*" -and
-not (Test-IsUnderDirectory -Path $_.FullName -Directory $stagingRoot)
} |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
}
if (-not $mainPackageFile) {
throw "Could not find the main .msix package referenced by $($appInstallerFile.FullName)."
}
$signature = Get-AuthenticodeSignature -FilePath $mainPackageFile.FullName
if ($signature.Status -ne "Valid") {
throw "MSIX signature is not valid: $($signature.StatusMessage)"
}
if ($signature.SignerCertificate.Thumbprint -ne $CertificateThumbprint) {
throw "MSIX was signed with $($signature.SignerCertificate.Thumbprint), expected $CertificateThumbprint."
}
Copy-Item -LiteralPath $mainPackageFile.FullName -Destination (Join-Path $stagingRoot $mainPackageFile.Name) -Force
$appInstallerXml.DocumentElement.SetAttribute("Uri", (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $appInstallerFile.Name))
$mainPackageNode.SetAttribute("Uri", (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $mainPackageFile.Name))
$dependencyNodes = $appInstallerXml.SelectNodes("//ai:Dependencies/ai:Package", $namespaceManager)
foreach ($dependencyNode in $dependencyNodes) {
$dependencyLeafName = Get-UriLeafName $dependencyNode.GetAttribute("Uri")
$architecture = $dependencyNode.GetAttribute("ProcessorArchitecture")
$preferredFragment = if ($architecture) { "Dependencies\$architecture" } else { $null }
$dependencyFile = Find-PackageFileByLeafName -PackageRoot $packageRoot -LeafName $dependencyLeafName -PreferredPathFragment $preferredFragment -ExcludeDirectory $stagingRoot
if (-not $dependencyFile) {
throw "Could not find dependency package $dependencyLeafName."
}
Copy-Item -LiteralPath $dependencyFile.FullName -Destination (Join-Path $stagingRoot $dependencyFile.Name) -Force
$dependencyNode.SetAttribute("Uri", (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $dependencyFile.Name))
}
$appInstallerXml.Save($stagedAppInstallerPath)
Ensure-CertificateFile -CertificatePath $certificatePath -Thumbprint $CertificateThumbprint
if (Test-Path -LiteralPath $InstallCertificateScriptPath) {
Copy-Item -LiteralPath $InstallCertificateScriptPath -Destination (Join-Path $stagingRoot (Split-Path -Leaf $InstallCertificateScriptPath)) -Force
}
$stagedFiles = Get-ChildItem -Path $stagingRoot -File | Sort-Object Name
Write-Host ""
Write-Host "Prepared MSIX deployment files:"
$stagedFiles | ForEach-Object {
Write-Host (" - {0} ({1:N0} bytes)" -f $_.Name, $_.Length)
}
if (-not $NoUpload) {
if (-not $NasUser) {
throw "NasUser was not provided. Pass -NasUser <user> or set NAS_USER."
}
$sshArgs = @()
if ($NasSshPort -ne 22) {
$sshArgs += @("-p", [string]$NasSshPort)
}
if ($SshKeyPath) {
$sshArgs += @("-i", (Resolve-FullPath $SshKeyPath))
}
$sshArgs += @("${NasUser}@${NasHost}", "mkdir -p '$NasRemotePath'")
Invoke-Checked -FilePath "ssh" -Arguments $sshArgs
$scpArgs = @()
if ($NasSshPort -ne 22) {
$scpArgs += @("-P", [string]$NasSshPort)
}
if ($SshKeyPath) {
$scpArgs += @("-i", (Resolve-FullPath $SshKeyPath))
}
$scpArgs += $stagedFiles.FullName
$scpArgs += "${NasUser}@${NasHost}:$NasRemotePath/"
Invoke-Checked -FilePath "scp" -Arguments $scpArgs
}
if (-not $NoVerify) {
foreach ($file in $stagedFiles) {
$uri = Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $file.Name
try {
$response = Invoke-WebRequest -Uri $uri -Method Head -UseBasicParsing -TimeoutSec 20
}
catch {
Write-Warning "HEAD failed for $uri. Trying GET."
$response = Invoke-WebRequest -Uri $uri -UseBasicParsing -TimeoutSec 20
}
if ($response.StatusCode -lt 200 -or $response.StatusCode -gt 299) {
throw "Verification failed for $uri with status $($response.StatusCode)."
}
if (-not $NoUpload) {
$contentLengthHeader = $response.Headers["Content-Length"] | Select-Object -First 1
if ($contentLengthHeader) {
$remoteLength = [long]$contentLengthHeader
if ($remoteLength -ne $file.Length) {
throw "Verification failed for $uri. Remote length $remoteLength does not match local length $($file.Length)."
}
}
else {
Write-Warning "No Content-Length header returned for $uri; status verification only."
}
}
Write-Host "Verified $uri"
}
}
Write-Host "Staging directory:"
Write-Host $stagingRoot
Write-Host ""
Write-Host "App Installer URL:"
Write-Host (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $appInstallerFile.Name)

120
tools/msix/README.md Normal file
View File

@@ -0,0 +1,120 @@
# MSIX publish workflow
This folder contains the scripts used to build the MSIX package, flatten the
App Installer deployment files, upload them to the Synology NAS web folder, and
verify the public download URLs.
## Files
- `Publish-MsixToNas.ps1`: builds the app package, stages the deployable files,
uploads them to the NAS with SSH/SCP, and verifies the public URLs.
- `Install-ComtrophyMsixCertificate.ps1`: installs the MSIX signing certificate
for the current Windows user, then optionally opens the appinstaller URL.
## First-time NAS SSH setup
1. Create or choose a NAS user that can write to `/volume1/web/msix`.
2. Enable SSH on the Synology NAS.
3. Optional but recommended: create an SSH key for publishing.
```powershell
ssh-keygen -t ed25519 -f $env:USERPROFILE\.ssh\nas_msix_ed25519
type $env:USERPROFILE\.ssh\nas_msix_ed25519.pub
```
Add the printed public key to the NAS user's `~/.ssh/authorized_keys`.
Test the connection:
```powershell
ssh -i $env:USERPROFILE\.ssh\nas_msix_ed25519 <nas-user>@192.168.200.129 "ls -ld /volume1/web/msix"
```
## Publish a new build
Set the NAS login once in the current PowerShell session:
```powershell
$env:NAS_USER = "<nas-user>"
$env:NAS_SSH_KEY = "$env:USERPROFILE\.ssh\nas_msix_ed25519"
```
Build, package, upload, and verify:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Release -IncrementPackageRevision
```
Use `-IncrementPackageRevision` for normal approved deployments. It reads the
current `Package.appxmanifest` version and increments the fourth version part
before building. App Installer uses the MSIX package version to decide whether a
client should receive an update.
In Codex sessions, this is the command to run only after the user explicitly
approves publishing the finished work.
To publish a specific version instead:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Release -PackageVersion 1.0.3.2
```
To upload the latest already-built package without rebuilding:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Debug -SkipPackageBuild
```
To prepare files locally without uploading:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Debug -SkipPackageBuild -NoUpload
```
The default public base URL is:
```text
http://122.34.248.185/msix/
```
If the deployment should use the Synology DDNS name instead, pass:
```powershell
-PublicBaseUri "http://comtropy.synology.me/msix/"
```
## Installer link
After publish, the installer URL is:
```text
http://122.34.248.185/msix/Tornado3_2026Election_x64.appinstaller
```
The user PC must trust the signing certificate before installing the MSIX for
the first time. The script only installs the certificate; it does not run the
app installer. Approve the UAC administrator prompt when Windows asks:
```powershell
powershell -ExecutionPolicy Bypass -File .\Install-ComtrophyMsixCertificate.ps1
```
To run it directly from the NAS on a target PC:
```powershell
$script = Join-Path $env:TEMP "Install-ComtrophyMsixCertificate.ps1"
Invoke-WebRequest "http://122.34.248.185/msix/Install-ComtrophyMsixCertificate.ps1" -OutFile $script
powershell -ExecutionPolicy Bypass -File $script
```
After the certificate setup is complete, open the appinstaller link once to
install the app. After installation, run the app from the Windows Start menu, not
from the appinstaller link.
If installation fails with `0x800B0109`, confirm the certificate is present in
both local computer stores:
```powershell
Get-ChildItem Cert:\LocalMachine\TrustedPeople, Cert:\LocalMachine\Root |
Where-Object Thumbprint -eq "E691A33C64DF20A204FFD4F096B9C3EB4B95709C"
```