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

View File

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

View File

@@ -69,6 +69,21 @@ public sealed class CandidateEntry : ObservableObject
[JsonIgnore] [JsonIgnore]
public double? BroadcastCountedRate { get; set; } 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 public int VoteCount
{ {
get => _voteCount; get => _voteCount;
@@ -217,6 +232,9 @@ public sealed class CandidateEntry : ObservableObject
BroadcastElectionDistrictName = BroadcastElectionDistrictName, BroadcastElectionDistrictName = BroadcastElectionDistrictName,
BroadcastDistrictCode = BroadcastDistrictCode, BroadcastDistrictCode = BroadcastDistrictCode,
BroadcastCountedRate = BroadcastCountedRate, BroadcastCountedRate = BroadcastCountedRate,
BroadcastRank = BroadcastRank,
BroadcastSeatCount = BroadcastSeatCount,
BroadcastCountingClosed = BroadcastCountingClosed,
VoteCount = VoteCount, VoteCount = VoteCount,
VoteRate = VoteRate, VoteRate = VoteRate,
HasImage = HasImage, HasImage = HasImage,

View File

@@ -20,6 +20,13 @@ public sealed class ChannelScheduleItem : ObservableObject
private double _thumbnailWidth = 160; private double _thumbnailWidth = 160;
private double _thumbnailHeight = 90; private double _thumbnailHeight = 90;
private ImageSource? _thumbnailSource; 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(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -88,6 +95,9 @@ public sealed class ChannelScheduleItem : ObservableObject
{ {
OnPropertyChanged(nameof(StateLabel)); OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(StateBrush)); OnPropertyChanged(nameof(StateBrush));
OnPropertyChanged(nameof(StateCardBackgroundBrush));
OnPropertyChanged(nameof(StateCardBorderBrush));
OnPropertyChanged(nameof(StateBadgeBackgroundBrush));
OnPropertyChanged(nameof(CardOpacity)); OnPropertyChanged(nameof(CardOpacity));
OnPropertyChanged(nameof(CanDelete)); OnPropertyChanged(nameof(CanDelete));
} }
@@ -133,11 +143,43 @@ public sealed class ChannelScheduleItem : ObservableObject
[JsonIgnore] [JsonIgnore]
public SolidColorBrush StateBrush => new(State switch 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.OnAir => ColorHelper.FromArgb(255, 239, 68, 68),
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
_ => ColorHelper.FromArgb(255, 100, 116, 139) _ => 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] [JsonIgnore]
public double CardOpacity => State == ScheduleQueueItemState.Completed ? 0.45 : 1.0; public double CardOpacity => State == ScheduleQueueItemState.Completed ? 0.45 : 1.0;
@@ -179,6 +221,35 @@ public sealed class ChannelScheduleItem : ObservableObject
[JsonIgnore] [JsonIgnore]
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(FormatId); 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] [JsonIgnore]
public double ThumbnailWidth public double ThumbnailWidth
{ {
@@ -204,6 +275,61 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(ThumbnailStatusLabel)); 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) public void UpdateThumbnailLayout(ThumbnailDisplayMetrics metrics)
{ {
ThumbnailWidth = metrics.Width; ThumbnailWidth = metrics.Width;
@@ -227,6 +353,21 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(DurationApplyStatusLabel)); 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) public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
{ {
var selectedRegion = regionOption ?? new ScheduleRegionOption var selectedRegion = regionOption ?? new ScheduleRegionOption

View File

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

View File

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

View File

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

View File

@@ -44,13 +44,14 @@
</Border> </Border>
</NavigationView.PaneHeader> </NavigationView.PaneHeader>
<NavigationView.MenuItems> <NavigationView.MenuItems>
<NavigationViewItem Content="노멀" Tag="normal" Visibility="{x:Bind ViewModel.NormalMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Play" /></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 Symbol="PreviewLink" /></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 Symbol="Download" /></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 Symbol="Video" /></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="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="career-promises"><NavigationViewItem.Icon><SymbolIcon Symbol="Contact" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="표데이터" Tag="counting-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></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="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="settings"><NavigationViewItem.Icon><SymbolIcon Symbol="Setting" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="로그" Tag="log"><NavigationViewItem.Icon><SymbolIcon Symbol="Document" /></NavigationViewItem.Icon></NavigationViewItem> <NavigationViewItem Content="로그" Tag="log"><NavigationViewItem.Icon><SymbolIcon Symbol="Document" /></NavigationViewItem.Icon></NavigationViewItem>
@@ -812,23 +813,66 @@
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.CareerPromiseDataVisibility, Mode=OneWay}">
<StackPanel Spacing="20">
<Border Padding="20" <Border Padding="20"
Background="{StaticResource ControlRoomPanelGradientBrush}" Background="{StaticResource ControlRoomPanelGradientBrush}"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1" BorderThickness="1"
CornerRadius="24" CornerRadius="24">
Visibility="{x:Bind ViewModel.Data.CareerPromiseVisibility, Mode=OneWay}"> <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"> <StackPanel Spacing="14">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="경력 컷 공약" />
<Grid ColumnSpacing="12"> <Grid ColumnSpacing="12">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" <TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="후보 공약" />
Text="{x:Bind ViewModel.Data.CareerPromiseContextText, Mode=OneWay}"
TextWrapping="Wrap" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" <TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.Data.CareerPromiseStatusText, Mode=OneWay}" Text="{x:Bind ViewModel.Data.CareerPromiseStatusText, Mode=OneWay}"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
@@ -837,9 +881,9 @@
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</StackPanel> </StackPanel>
<Button Grid.Column="1" <Button Grid.Column="1"
Command="{x:Bind ViewModel.Data.SaveCareerPromisesCommand}" Command="{x:Bind ViewModel.Data.AddCareerPromiseRowCommand}"
Content="공약 저장" Content="행 추가"
Style="{StaticResource ConsoleGhostButtonStyle}" /> Style="{StaticResource ConsolePrimaryButtonStyle}" />
</Grid> </Grid>
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" <TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.Data.CareerPromiseSaveStateText, Mode=OneWay}" Text="{x:Bind ViewModel.Data.CareerPromiseSaveStateText, Mode=OneWay}"
@@ -852,41 +896,49 @@
VerticalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Disabled"
VerticalScrollMode="Disabled"> VerticalScrollMode="Disabled">
<StackPanel Spacing="0"> <StackPanel Spacing="0">
<Grid MinWidth="1250"> <Grid MinWidth="1480">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="190" />
<ColumnDefinition Width="120" /> <ColumnDefinition Width="120" />
<ColumnDefinition Width="150" /> <ColumnDefinition Width="150" />
<ColumnDefinition Width="180" /> <ColumnDefinition Width="170" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="260" /> <ColumnDefinition Width="260" />
<ColumnDefinition Width="260" /> <ColumnDefinition Width="90" />
<ColumnDefinition Width="280" />
</Grid.ColumnDefinitions> </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="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="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="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="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="공약 2" /></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="공약 3" /></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> </Grid>
<ItemsControl ItemsSource="{x:Bind ViewModel.Data.CareerPromiseRows, Mode=OneWay}"> <ItemsControl ItemsSource="{x:Bind ViewModel.Data.CareerPromiseRows, Mode=OneWay}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CareerPromiseEditRowViewModel"> <DataTemplate x:DataType="vm:CareerPromiseEditRowViewModel">
<Grid MinWidth="1250"> <Grid MinWidth="1480">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="190" />
<ColumnDefinition Width="120" /> <ColumnDefinition Width="120" />
<ColumnDefinition Width="150" /> <ColumnDefinition Width="150" />
<ColumnDefinition Width="180" /> <ColumnDefinition Width="170" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="260" /> <ColumnDefinition Width="260" />
<ColumnDefinition Width="260" /> <ColumnDefinition Width="90" />
<ColumnDefinition Width="280" />
</Grid.ColumnDefinitions> </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="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" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind CandidateName}" TextWrapping="WrapWholeWords" /></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" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind Party}" TextWrapping="WrapWholeWords" /></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 Promise1, 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 Promise2, 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 Promise3, 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> </Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
@@ -1190,6 +1242,47 @@
</Border> </Border>
</Grid> </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"> <Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="권역 설정" /> <TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="권역 설정" />

View File

@@ -442,7 +442,7 @@ public sealed partial class MainWindow : Window
return; return;
} }
ViewModel.Data.SelectDistrictOverviewCard(card.DistrictViewName); ViewModel.Data.SelectDistrictOverviewCard(card);
} }
private void EnsureNavigationSelection() private void EnsureNavigationSelection()
@@ -462,6 +462,7 @@ public sealed partial class MainWindow : Window
AppPage.TurnoutData => "turnout-data", AppPage.TurnoutData => "turnout-data",
AppPage.CountingData => "counting-data", AppPage.CountingData => "counting-data",
AppPage.Data => ViewModel.Data.IsPreElectionPhase ? "turnout-data" : "counting-data", AppPage.Data => ViewModel.Data.IsPreElectionPhase ? "turnout-data" : "counting-data",
AppPage.CareerPromiseData => "career-promises",
AppPage.CutList => "cut-list", AppPage.CutList => "cut-list",
AppPage.Settings => "settings", AppPage.Settings => "settings",
AppPage.Log => "log", AppPage.Log => "log",

View File

@@ -11,7 +11,7 @@
<Identity <Identity
Name="8472d715-ce0c-4ed2-8f7d-7e330428ce82" Name="8472d715-ce0c-4ed2-8f7d-7e330428ce82"
Publisher="CN=Comtrophy" 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"/> <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 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; public bool IsPollingEnabled { get; set; } = true;

View File

@@ -24,6 +24,7 @@ public sealed class CareerPromiseService
{ {
_logService = logService; _logService = logService;
FilePath = ResolveFilePath(); FilePath = ResolveFilePath();
TryMigrateLegacyCatalog();
_catalog = LoadCatalog(FilePath); _catalog = LoadCatalog(FilePath);
} }
@@ -35,14 +36,12 @@ public sealed class CareerPromiseService
string districtCode, string districtCode,
string districtName) string districtName)
{ {
var normalizedStationId = stationId?.Trim() ?? string.Empty;
var normalizedElectionType = electionType?.Trim() ?? string.Empty; var normalizedElectionType = electionType?.Trim() ?? string.Empty;
var normalizedDistrictCode = districtCode?.Trim() ?? string.Empty; var normalizedDistrictCode = districtCode?.Trim() ?? string.Empty;
var normalizedDistrictName = districtName?.Trim() ?? string.Empty; var normalizedDistrictName = districtName?.Trim() ?? string.Empty;
return _catalog.Entries return SafeEntries(_catalog.Entries)
.Where(entry => .Where(entry =>
string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) && string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) &&
MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName)) MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName))
.ToArray(); .ToArray();
@@ -62,9 +61,8 @@ public sealed class CareerPromiseService
var normalizedDistrictCode = districtCode?.Trim() ?? string.Empty; var normalizedDistrictCode = districtCode?.Trim() ?? string.Empty;
var normalizedDistrictName = districtName?.Trim() ?? string.Empty; var normalizedDistrictName = districtName?.Trim() ?? string.Empty;
var retainedEntries = _catalog.Entries var retainedEntries = SafeEntries(_catalog.Entries)
.Where(entry => .Where(entry =>
!string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) || !string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) ||
!MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName)) !MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName))
.ToList(); .ToList();
@@ -103,8 +101,14 @@ public sealed class CareerPromiseService
string districtCode, string districtCode,
string districtName) string districtName)
{ {
var candidateCode = entry.CandidateCode?.Trim() ?? string.Empty; var candidateName = entry.CandidateName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(candidateCode)) 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; return null;
} }
@@ -123,10 +127,10 @@ public sealed class CareerPromiseService
StationId = stationId, StationId = stationId,
ElectionType = electionType, ElectionType = electionType,
DistrictCode = districtCode, DistrictCode = districtCode,
DistrictName = districtName, DistrictName = resolvedDistrictName,
CandidateCode = candidateCode, CandidateCode = entry.CandidateCode?.Trim() ?? string.Empty,
CandidateName = entry.CandidateName?.Trim() ?? string.Empty, CandidateName = candidateName,
Party = entry.Party?.Trim() ?? string.Empty, Party = party,
Promises = promises Promises = promises
}; };
} }
@@ -136,17 +140,17 @@ public sealed class CareerPromiseService
string districtCode, string districtCode,
string districtName) 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 normalizedEntryDistrictName = NormalizeLookupKey(entry.DistrictName);
var normalizedDistrictName = NormalizeLookupKey(districtName); var normalizedDistrictName = NormalizeLookupKey(districtName);
return !string.IsNullOrWhiteSpace(normalizedDistrictName) && if (!string.IsNullOrWhiteSpace(normalizedDistrictName) &&
string.Equals(normalizedEntryDistrictName, normalizedDistrictName, StringComparison.Ordinal); !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) private static string NormalizeLookupKey(string? value)
@@ -170,8 +174,7 @@ public sealed class CareerPromiseService
try try
{ {
var json = File.ReadAllText(filePath); var json = File.ReadAllText(filePath);
return JsonSerializer.Deserialize<CareerPromiseCatalog>(json, SerializerOptions) return NormalizeCatalog(JsonSerializer.Deserialize<CareerPromiseCatalog>(json, SerializerOptions));
?? new CareerPromiseCatalog();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -189,10 +192,21 @@ public sealed class CareerPromiseService
} }
var json = JsonSerializer.Serialize(catalog, SerializerOptions); 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() 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( return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
@@ -200,4 +214,49 @@ public sealed class CareerPromiseService
"CustomerData", "CustomerData",
"career-promises.json"); "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 = private static readonly IReadOnlyList<CutCategory> OrderedCategories =
[ [
CutCategory.Title,
CutCategory.MetropolitanHead, CutCategory.MetropolitanHead,
CutCategory.LocalHead, CutCategory.LocalHead,
CutCategory.Superintendent, CutCategory.Superintendent,
CutCategory.MetropolitanCouncil, CutCategory.MetropolitanCouncil,
CutCategory.LocalCouncil, CutCategory.LocalCouncil,
CutCategory.NationalAssembly, CutCategory.NationalAssembly,
CutCategory.BottomTopTwo,
CutCategory.BottomTopThree,
CutCategory.BottomCurrentLeader,
CutCategory.BottomWinner,
CutCategory.BottomAllCandidates,
CutCategory.BottomTurnoutSido,
CutCategory.BottomTurnoutDistrict,
CutCategory.BottomEarlyTurnout,
CutCategory.BottomElectionDayTurnout,
CutCategory.PreElection, CutCategory.PreElection,
CutCategory.Historical, CutCategory.Historical,
CutCategory.Turnout, CutCategory.Turnout
CutCategory.Title
]; ];
public static IReadOnlyList<CutCategory> GetOrderedCategories() => OrderedCategories; public static IReadOnlyList<CutCategory> GetOrderedCategories() => OrderedCategories;
@@ -28,13 +37,23 @@ public static class CutCategoryResolver
return category switch return category switch
{ {
CutCategory.MetropolitanHead => Contains(formatName, "광역단체장"), CutCategory.MetropolitanHead => IsMetropolitanHeadFormat(formatName),
CutCategory.LocalHead => Contains(formatName, "기초단체장"), CutCategory.LocalHead => Contains(formatName, "기초단체장"),
CutCategory.Superintendent => Contains(formatName, "교육감"), CutCategory.Superintendent => Contains(formatName, "교육감"),
CutCategory.MetropolitanCouncil => Contains(formatName, "광역의원"), CutCategory.MetropolitanCouncil => Contains(formatName, "광역의원"),
CutCategory.LocalCouncil => Contains(formatName, "기초의원"), CutCategory.LocalCouncil => Contains(formatName, "기초의원"),
CutCategory.NationalAssembly => Contains(formatName, "보궐선거") || CutCategory.NationalAssembly => Contains(formatName, "보궐선거") ||
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.PreElection => Contains(formatName, "사전"),
CutCategory.Historical => Contains(formatName, "역대"), CutCategory.Historical => Contains(formatName, "역대"),
CutCategory.Turnout => Contains(formatName, "투표율"), CutCategory.Turnout => Contains(formatName, "투표율"),
@@ -47,11 +66,21 @@ public static class CutCategoryResolver
{ {
return category switch return category switch
{ {
CutCategory.MetropolitanHead => "광역단체장",
CutCategory.LocalHead => "기초단체장", CutCategory.LocalHead => "기초단체장",
CutCategory.Superintendent => "교육감", CutCategory.Superintendent => "교육감",
CutCategory.MetropolitanCouncil => "광역의원", CutCategory.MetropolitanCouncil => "광역의원",
CutCategory.LocalCouncil => "기초의원", CutCategory.LocalCouncil => "기초의원",
CutCategory.NationalAssembly => "국회의원", 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.PreElection => "사전",
CutCategory.Historical => "역대", CutCategory.Historical => "역대",
CutCategory.Turnout => "투표율", CutCategory.Turnout => "투표율",
@@ -64,4 +93,45 @@ public static class CutCategoryResolver
{ {
return value.Contains(token, StringComparison.Ordinal); 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 object _syncRoot = new();
private readonly Dictionary<BroadcastChannel, CutDebugSettings> _settingsByChannel = new(); private readonly Dictionary<BroadcastChannel, CutDebugSettings> _settingsByChannel = new();
private readonly Dictionary<string, CutDebugTemplateState> _templateStates = new(StringComparer.Ordinal); private readonly Dictionary<string, CutDebugTemplateState> _templateStates = new(StringComparer.Ordinal);
private bool _isDebugFeatureEnabled = true; private bool _isDebugFeatureEnabled;
public CutDebugStateStore() public CutDebugStateStore()
{ {
foreach (var channel in Enum.GetValues<BroadcastChannel>()) 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;
using System.Collections.Generic;
using System.IO; using System.IO;
using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.UI.Xaml.Media.Imaging;
@@ -69,6 +70,19 @@ public static class CutThumbnailAssetCatalog
} }
public static bool HasThumbnail(string templateId) 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); var projectPath = TryGetProjectAssetPath(templateId);
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath)) if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
@@ -99,21 +113,88 @@ public static class CutThumbnailAssetCatalog
public static string ResolvePreferredDisplayPath(string templateId) 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)) if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
{ {
return projectPath; return projectPath;
} }
var bundledPath = GetBundledAssetPath(templateId); var bundledPath = GetBundledAssetPath(candidateTemplateId);
if (File.Exists(bundledPath)) if (File.Exists(bundledPath))
{ {
return bundledPath; return bundledPath;
} }
}
return Path.Combine(AppContext.BaseDirectory, FallbackAssetPath); 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() private static string? TryGetProjectRoot()
{ {
var current = new DirectoryInfo(AppContext.BaseDirectory); var current = new DirectoryInfo(AppContext.BaseDirectory);

View File

@@ -59,11 +59,13 @@ public sealed class FormatCatalogService
"당선_광역의원", "당선_광역의원",
"당선_기초단체장", "당선_기초단체장",
"당선_기초의원", "당선_기초의원",
"사전투표율", "사전투표율_시도",
"사전투표율_시군구",
"전후보_광역단체장", "전후보_광역단체장",
"전후보_교육감", "전후보_교육감",
"전후보_기초단체장", "전후보_기초단체장",
"투표율")); "투표율_시도",
"투표율_시군구"));
formats.AddRange(CreateFormats( formats.AddRange(CreateFormats(
BroadcastChannel.Normal, BroadcastChannel.Normal,
@@ -84,6 +86,12 @@ public sealed class FormatCatalogService
"1-3위_ani_기초단체장", "1-3위_ani_기초단체장",
"1-3위_기초단체장_5760", "1-3위_기초단체장_5760",
"1-3위_보궐선거", "1-3위_보궐선거",
"2880_광역의원표",
"2880_기초의원표",
"810_광역의원표",
"810_기초의원표",
"8316_광역의원표",
"8316_기초의원표",
"경력_광역단체장_in", "경력_광역단체장_in",
"경력_기초단체장_in", "경력_기초단체장_in",
"광역의원표", "광역의원표",
@@ -127,10 +135,8 @@ public sealed class FormatCatalogService
"접전_기초단체장", "접전_기초단체장",
"초접전_광역단체장", "초접전_광역단체장",
"초접전_기초단체장", "초접전_기초단체장",
"투표율_사진", "투표율",
"투표율_선거구별 사전", "투표율_선거구별 사전",
"투표율_시도별",
"투표율_영상",
"판세_광역단체장", "판세_광역단체장",
"판세_기초단체장", "판세_기초단체장",
"판세_기초단체장_5760")); "판세_기초단체장_5760"));
@@ -166,12 +172,15 @@ public sealed class FormatCatalogService
{ {
var isAvailableInBothPhases = IsAvailableInBothPhases(baseName); var isAvailableInBothPhases = IsAvailableInBothPhases(baseName);
var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(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); var recommendedChannel = ResolveRecommendedChannel(channel, baseName, sceneResolution);
yield return new FormatTemplateDefinition yield return new FormatTemplateDefinition
{ {
Id = Path.Combine(relativeFolder, baseName), Id = formatId,
Name = baseName, Name = baseName,
Description = $"{relativeFolder} 컷", Description = $"{relativeFolder} 컷",
RecommendedChannel = recommendedChannel, RecommendedChannel = recommendedChannel,
@@ -193,13 +202,30 @@ public sealed class FormatCatalogService
DurationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds( DurationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
defaultCutDurationSeconds, defaultCutDurationSeconds,
recommendedChannel, 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) private static bool IsPreElectionOnlyFormat(string baseName)
{ {
return baseName.Contains("투표율", StringComparison.Ordinal); 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_민방", "당선_기초의원_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_민방", "전후보_기초단체장_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_광역단체장_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위_ani_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-2위_ani_기초단체장_5760"),
[Path.Combine("Elect2026_Normal_민방", "1-2위_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-2위_광역단체장_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")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L_1")] = 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_민방", "투표율_시도별_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"), [Path.Combine("Elect2026_Normal_민방", "투표율_사진")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"), [Path.Combine("Elect2026_Normal_민방", "투표율_시도별")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L_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_민방", "판세_기초단체장_7680")] = Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_5760"), [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_민방", "투표율"),
[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) 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) private static bool IsHistoricalPreElectionWinnerFormat(string baseName)
@@ -311,18 +344,21 @@ public sealed class FormatCatalogService
private static bool IsVideoWallFormat(string baseName) 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); 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)) if (string.IsNullOrWhiteSpace(t3CutPath))
{ {
return null; return null;
} }
var scenePath = Path.Combine(t3CutPath, relativeFolder, baseName + ".tscn"); var scenePath = Path.Combine(t3CutPath, sceneId + ".tscn");
return KarismaSceneResolutionReader.TryRead(scenePath, out var resolution) return KarismaSceneResolutionReader.TryRead(scenePath, out var resolution)
? resolution ? resolution
: null; : null;
@@ -339,9 +375,7 @@ public sealed class FormatCatalogService
return false; return false;
} }
channel = sceneResolution.Value is { Width: 1920, Height: 1080 } channel = sceneResolution.Value.Width > 1920
? BroadcastChannel.Normal
: sceneResolution.Value.Width > 1920 && sceneResolution.Value.Height == 1080
? BroadcastChannel.VideoWall ? BroadcastChannel.VideoWall
: BroadcastChannel.Normal; : BroadcastChannel.Normal;

View File

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

View File

@@ -1,3 +1,8 @@
namespace Tornado3_2026Election.Services; 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 Action<int>? _onClose;
private readonly object _connectSync = new(); private readonly object _connectSync = new();
private readonly object _loadSceneSync = 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 _saveSceneImageSync = new();
private readonly object _saveMixedPreviewImageSync = new();
private TaskCompletionSource<int>? _pendingConnect; private TaskCompletionSource<int>? _pendingConnect;
private readonly Dictionary<string, TaskCompletionSource<eKResult>> _pendingLoadScenes = new(StringComparer.OrdinalIgnoreCase); 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, string SceneName)>? _pendingSaveSceneImage;
private TaskCompletionSource<(eKResult Result, int OutputChannelIndex, int LayerNo)>? _pendingSaveMixedPreviewImage;
public KarismaEventHandler(LogService logService, Action<int>? onConnect = null, Action<int>? onClose = null) 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() public Task<(eKResult Result, string SceneName)> BeginSaveSceneImageWait()
{ {
lock (_saveSceneImageSync) lock (_saveSceneImageSync)
@@ -186,6 +294,45 @@ public class KarismaEventHandler : KAEventHandler
completion.TrySetException(error); 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) public void OnLoadScene(eKResult Result, string SceneName)
{ {
LogResult(nameof(OnLoadScene), Result, $"scene={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 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 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 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); public void OnHeartBeat(eKResult Result) => LogResult(nameof(OnHeartBeat), Result);
virtual public void OnUnloadAll(eKResult Result) { } virtual public void OnUnloadAll(eKResult Result) { }
virtual public void OnSetTrialPlayoutMode(eKResult Result) { } virtual public void OnSetTrialPlayoutMode(eKResult Result) { }
virtual public void OnCheckVersion(eKResult Result, string ServerVersion, string SDKVersion) { } virtual public void OnCheckVersion(eKResult Result, string ServerVersion, string SDKVersion) { }
virtual public void OnSetAudioOutput(eKResult Result) { } 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 OnScenePrepare(eKResult Result, int OutputChannelIndex, int LayerNo)
public void OnScenePrepareEx(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnScenePrepareEx), Result, $"output={OutputChannelIndex} layer={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 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 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}"); 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) { } 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}"); 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 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 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 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}"); 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 OnSaveScene(eKResult Result, string SceneName) { }
virtual public void OnUnloadScene(eKResult Result, string SceneName) { } virtual public void OnUnloadScene(eKResult Result, string SceneName) { }
virtual public void OnReloadScene(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 OnSetSceneAudioFile(eKResult Result, string SceneName) { }
virtual public void OnEnableSceneAudio(eKResult Result, string SceneName) { } virtual public void OnEnableSceneAudio(eKResult Result, string SceneName) { }
virtual public void OnSetSceneDuration(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 OnSetCylinderAngleKey(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetSphereAngleKey(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 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 OnSetCountDown(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetPosition(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) { } 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 OnModifyPathPoint(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnInitScrollObject(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 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 OnSetCounterRange(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetCounterRemainingTime(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) { } virtual public void OnSetCounterElapsedTime(eKResult Result, string SceneName, string ObjectName) { }
@@ -451,6 +646,23 @@ public class KarismaEventHandler : KAEventHandler
completion.TrySetResult(result); 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) private void CompletePendingConnect(int errorCode)
{ {
TaskCompletionSource<int>? completion; TaskCompletionSource<int>? completion;
@@ -462,4 +674,79 @@ public class KarismaEventHandler : KAEventHandler
completion?.TrySetResult(errorCode); 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 X,
float Y, float Y,
float Z, float Z,
eKVectorType VectorType); eKVectorType VectorType,
int KeyIndex = -1);

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using Tornado3_2026Election.Domain; using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services; namespace Tornado3_2026Election.Services;
@@ -12,7 +13,7 @@ internal static class KarismaSceneResolver
bool useLoop, bool useLoop,
bool useEnd = false) bool useEnd = false)
{ {
return ResolveScene(template, null, t3CutPath, useLoop, useEnd); return ResolveScene(template, template.Cuts.FirstOrDefault(), t3CutPath, useLoop, useEnd);
} }
public static KarismaResolvedScene ResolveScene( public static KarismaResolvedScene ResolveScene(
@@ -25,17 +26,16 @@ internal static class KarismaSceneResolver
var sceneId = string.IsNullOrWhiteSpace(cut?.SceneIdOverride) var sceneId = string.IsNullOrWhiteSpace(cut?.SceneIdOverride)
? template.Id ? template.Id
: cut.SceneIdOverride!; : cut.SceneIdOverride!;
var hasSceneOverride = !string.IsNullOrWhiteSpace(cut?.SceneIdOverride);
var baseScenePath = Path.Combine(t3CutPath, sceneId + ".tscn"); var baseScenePath = Path.Combine(t3CutPath, sceneId + ".tscn");
var loopScenePath = Path.Combine(t3CutPath, template.Id + "_loop.tscn"); var loopScenePath = Path.Combine(t3CutPath, sceneId + "_loop.tscn");
var endScenePath = Path.Combine(t3CutPath, template.Id + "_END.tscn"); var endScenePath = Path.Combine(t3CutPath, sceneId + "_END.tscn");
string selectedPath; string selectedPath;
if (useEnd && File.Exists(endScenePath)) if (useEnd && File.Exists(endScenePath))
{ {
selectedPath = endScenePath; selectedPath = endScenePath;
} }
else if (!hasSceneOverride && useLoop && File.Exists(loopScenePath)) else if (useLoop && File.Exists(loopScenePath))
{ {
selectedPath = loopScenePath; selectedPath = loopScenePath;
} }
@@ -43,7 +43,7 @@ internal static class KarismaSceneResolver
{ {
selectedPath = baseScenePath; selectedPath = baseScenePath;
} }
else if (!hasSceneOverride && File.Exists(loopScenePath)) else if (File.Exists(loopScenePath))
{ {
selectedPath = loopScenePath; selectedPath = loopScenePath;
} }
@@ -59,7 +59,10 @@ internal static class KarismaSceneResolver
public static bool HasEndScene(FormatTemplateDefinition template, string t3CutPath) 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; return KarismaSceneVariableKind.Counter;
} }
if (IsLikelyCounterVariableName(variableName))
{
return KarismaSceneVariableKind.Counter;
}
if (variableName.StartsWith("\uC720\uD655\uB2F9", StringComparison.OrdinalIgnoreCase)) if (variableName.StartsWith("\uC720\uD655\uB2F9", StringComparison.OrdinalIgnoreCase))
{ {
return KarismaSceneVariableKind.VideoResource; return KarismaSceneVariableKind.VideoResource;
@@ -246,6 +241,11 @@ public sealed class KarismaSceneVariableCatalog
return KarismaSceneVariableKind.Image; return KarismaSceneVariableKind.Image;
} }
if (IsLikelyCounterVariableName(variableName))
{
return KarismaSceneVariableKind.Counter;
}
return KarismaSceneVariableKind.Text; return KarismaSceneVariableKind.Text;
} }

View File

@@ -37,6 +37,28 @@ public sealed class KarismaThumbnailGeneratorService
throw new DirectoryNotFoundException("유효한 T3_Cut 경로를 찾지 못했습니다."); 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(); var projectAssetRoot = CutThumbnailAssetCatalog.TryGetProjectAssetRoot();
if (string.IsNullOrWhiteSpace(projectAssetRoot)) if (string.IsNullOrWhiteSpace(projectAssetRoot))
{ {
@@ -45,11 +67,9 @@ public sealed class KarismaThumbnailGeneratorService
Directory.CreateDirectory(projectAssetRoot); Directory.CreateDirectory(projectAssetRoot);
var (host, port) = ResolveConnectionSettings();
var generatedCount = 0; var generatedCount = 0;
var failedCount = 0; var failedCount = 0;
using var manager = new TornadoManager(host, port, _logService);
await manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); await manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
foreach (var template in templates.OrderBy(template => template.Id, StringComparer.Ordinal)) foreach (var template in templates.OrderBy(template => template.Id, StringComparer.Ordinal))
@@ -69,7 +89,7 @@ public sealed class KarismaThumbnailGeneratorService
try try
{ {
var resolvedScene = KarismaSceneResolver.ResolveScene(template, t3CutPath, useLoop: false); var resolvedScene = KarismaSceneResolver.ResolveScene(template, t3CutPath, useLoop: false);
sceneAlias = resolvedScene.Alias; sceneAlias = $"{resolvedScene.Alias}__thumbnail";
var targetDirectory = Path.GetDirectoryName(targetPath); var targetDirectory = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetDirectory)) if (!string.IsNullOrWhiteSpace(targetDirectory))

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO;
using Tornado3_2026Election.Domain; using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services; namespace Tornado3_2026Election.Services;
@@ -7,6 +8,12 @@ namespace Tornado3_2026Election.Services;
public sealed class LogService public sealed class LogService
{ {
private const int MaxEntries = 400; 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; } = []; public ObservableCollection<LogEntry> Entries { get; } = [];
@@ -20,6 +27,8 @@ public sealed class LogService
private void Add(LogLevel level, string message) private void Add(LogLevel level, string message)
{ {
WriteDebugLog(level, message);
Common.UiDispatcher.Enqueue(() => Common.UiDispatcher.Enqueue(() =>
{ {
Entries.Insert(0, new LogEntry 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); }, 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) public async Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{ {
await ExecuteWithTimeoutAsync(async () => await ExecuteWithTimeoutAsync(async () =>
@@ -84,6 +111,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
}, cancellationToken).ConfigureAwait(false); }, 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) public async Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{ {
await ExecuteWithTimeoutAsync(async () => await ExecuteWithTimeoutAsync(async () =>
@@ -94,6 +131,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
}, cancellationToken).ConfigureAwait(false); }, 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) public async Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{ {
await ExecuteWithTimeoutAsync(async () => await ExecuteWithTimeoutAsync(async () =>

View File

@@ -49,6 +49,33 @@ internal static class PartyColorCatalog
return GenerateSolidColorPng(templateName, partyName, usage, sectionName, color); 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) public static bool HasStyleColorBinding(string templateFolderPath, string templateName, string sectionName)
{ {
var catalog = LoadCatalog(templateFolderPath, templateName); 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)); var folderName = Path.GetFileName(Path.GetFullPath(templateFolderPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (TryGetExplicitRgbSpecBaseName(folderName, templateName, out var explicitSpecBaseName) && 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"); var explicitSpecPath = Path.Combine(rgbDirectoryPath, explicitSpecBaseName + ".txt");
if (File.Exists(explicitSpecPath)) if (File.Exists(explicitSpecPath))
@@ -238,6 +270,11 @@ internal static class PartyColorCatalog
if (line.StartsWith("(", StringComparison.Ordinal)) if (line.StartsWith("(", StringComparison.Ordinal))
{ {
if (inHeader)
{
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
}
headerBuilder.Clear(); headerBuilder.Clear();
headerBuilder.AppendLine(line); headerBuilder.AppendLine(line);
inHeader = !line.Contains(')'); inHeader = !line.Contains(')');
@@ -250,6 +287,8 @@ internal static class PartyColorCatalog
} }
if (inHeader) if (inHeader)
{
if (IsHeaderContinuationLine(line))
{ {
headerBuilder.AppendLine(line); headerBuilder.AppendLine(line);
if (line.Contains(')')) if (line.Contains(')'))
@@ -261,6 +300,10 @@ internal static class PartyColorCatalog
continue; continue;
} }
inHeader = false;
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
}
if (currentSectionHeaders is null || currentSectionHeaders.Count == 0 || line.StartsWith("R", StringComparison.OrdinalIgnoreCase)) if (currentSectionHeaders is null || currentSectionHeaders.Count == 0 || line.StartsWith("R", StringComparison.OrdinalIgnoreCase))
{ {
continue; continue;
@@ -301,6 +344,17 @@ internal static class PartyColorCatalog
StringComparer.OrdinalIgnoreCase)); 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) private static List<SectionHeaderEntry> ExtractSectionHeaders(string header)
{ {
var entries = new List<SectionHeaderEntry>(); var entries = new List<SectionHeaderEntry>();
@@ -844,7 +898,12 @@ internal static class PartyColorCatalog
"Elect2026_Normal_민방", "Elect2026_Normal_민방",
"이시각1위_광역단체장", "이시각1위_광역단체장",
"이시각1위_광역단체장", "이시각1위_광역단체장",
"이시각1위_광역단체장_HD", "이시각1위_광역단체장_HD");
Add(
mappings,
"Elect2026_Normal_민방",
"이시각1위_광역단체장_5760",
"이시각1위_광역단체장_5760",
"이시각1위_광역단체장_L"); "이시각1위_광역단체장_L");
Add( Add(
mappings, mappings,
@@ -867,8 +926,15 @@ internal static class PartyColorCatalog
"판세_광역단체장", "판세_광역단체장",
"판세_광역단체장", "판세_광역단체장",
"판세_기초단체장", "판세_기초단체장",
"역대시도판세_광역단체장",
"역대시도판세_기초단체장",
"판세_기초단체장_5760", "판세_기초단체장_5760",
"판세_기초단체장_7680"); "판세_기초단체장_7680");
Add(
mappings,
"Elect2026_Normal_민방",
string.Empty,
"사전_역대투표율");
Add( Add(
mappings, mappings,
@@ -912,7 +978,6 @@ internal static class PartyColorCatalog
"1-2위_텍스트", "1-2위_텍스트",
"광역단체장_2인_텍스트", "광역단체장_2인_텍스트",
"기초단체장_2인_텍스트"); "기초단체장_2인_텍스트");
return mappings; 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; 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) public static string NormalizeElectionType(string? electionType)
{ {
return electionType switch return electionType switch
@@ -347,18 +380,7 @@ public sealed class PreElectionHistoryService
return string.Empty; return string.Empty;
} }
var trimmed = value.Trim(); return NormalizeRegionKeys(value).FirstOrDefault() ?? string.Empty;
foreach (var regionLabel in RegionLabels)
{
if (trimmed.Contains(regionLabel, StringComparison.OrdinalIgnoreCase))
{
return RegionAliases[regionLabel];
}
}
return RegionAliases.TryGetValue(trimmed, out var normalized)
? normalized
: string.Empty;
} }
private static IReadOnlyDictionary<string, PreElectionHistoryRecord> BuildLookupIndex( 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[] var districtKeys = new[]
{ {
NormalizeBasicDistrictToken(districtName), NormalizeBasicDistrictToken(districtName),
@@ -418,6 +440,8 @@ public sealed class PreElectionHistoryService
.Where(value => !string.IsNullOrWhiteSpace(value)) .Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase); .Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var regionKey in regionKeys)
{
foreach (var districtKey in districtKeys) foreach (var districtKey in districtKeys)
{ {
var combined = BuildBasicLookupKey(regionKey, districtKey); var combined = BuildBasicLookupKey(regionKey, districtKey);
@@ -426,6 +450,7 @@ public sealed class PreElectionHistoryService
yield return combined; yield return combined;
} }
} }
}
yield break; yield break;
} }
@@ -463,6 +488,41 @@ public sealed class PreElectionHistoryService
return string.Empty; 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) private static string BuildBasicLookupKey(string regionKey, string districtKey)
{ {
if (string.IsNullOrWhiteSpace(regionKey) || string.IsNullOrWhiteSpace(districtKey)) if (string.IsNullOrWhiteSpace(regionKey) || string.IsNullOrWhiteSpace(districtKey))
@@ -483,7 +543,7 @@ public sealed class PreElectionHistoryService
var normalized = StripBasicDistrictDisambiguation(value.Trim()); var normalized = StripBasicDistrictDisambiguation(value.Trim());
foreach (var regionLabel in RegionLabels) foreach (var regionLabel in RegionLabels)
{ {
normalized = normalized.Replace(regionLabel, string.Empty, StringComparison.OrdinalIgnoreCase); normalized = RemoveLeadingRegionLabel(normalized, regionLabel);
} }
normalized = normalized normalized = normalized
@@ -498,6 +558,22 @@ public sealed class PreElectionHistoryService
return normalized; 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( private static string NormalizeBasicDistrictDisplayName(
string? districtName, string? districtName,
string? displayName, string? displayName,

View File

@@ -86,15 +86,15 @@ public sealed class SbsElectionApiClient : IDisposable
["충청북도"] = "43", ["충청북도"] = "43",
["충남"] = "44", ["충남"] = "44",
["충청남도"] = "44", ["충청남도"] = "44",
["전남"] = "29", ["전남"] = "46",
["전라남도"] = "29", ["전라남도"] = "46",
["경북"] = "47", ["경북"] = "47",
["경상북도"] = "47", ["경상북도"] = "47",
["경남"] = "48", ["경남"] = "48",
["경상남도"] = "48", ["경상남도"] = "48",
["제주"] = "50", ["제주"] = "49",
["제주도"] = "50", ["제주도"] = "49",
["제주특별자치도"] = "50", ["제주특별자치도"] = "49",
["강원"] = "52", ["강원"] = "52",
["강원도"] = "52", ["강원도"] = "52",
["강원특별자치도"] = "52", ["강원특별자치도"] = "52",
@@ -240,8 +240,10 @@ public sealed class SbsElectionApiClient : IDisposable
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0); var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes); var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes); 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 ?? []) var judgementCandidates = (item.Hubojas ?? [])
.Select(MapCandidate) .Select(candidate => MapCandidate(candidate, seatCount, countingClosed))
.Where(candidate => candidate.EffectiveJudgement != CandidateJudgement.None) .Where(candidate => candidate.EffectiveJudgement != CandidateJudgement.None)
.OrderBy(candidate => ResolveJudgementDisplayPriority(candidate.EffectiveJudgement)) .OrderBy(candidate => ResolveJudgementDisplayPriority(candidate.EffectiveJudgement))
.ThenByDescending(candidate => candidate.VoteCount) .ThenByDescending(candidate => candidate.VoteCount)
@@ -250,6 +252,7 @@ public sealed class SbsElectionApiClient : IDisposable
overviewItems.Add((order, new CountingOverviewItem( overviewItems.Add((order, new CountingOverviewItem(
DisplayName: districtOption.DisplayName, DisplayName: districtOption.DisplayName,
DistrictCode: districtOption.DistrictCode,
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero), CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
CountedVotes: countedVotes, CountedVotes: countedVotes,
TotalVotes: totalVotes, TotalVotes: totalVotes,
@@ -400,6 +403,7 @@ public sealed class SbsElectionApiClient : IDisposable
var turnoutRate = electors <= 0 var turnoutRate = electors <= 0
? 0 ? 0
: Math.Round(voters * 100d / electors, 1, MidpointRounding.AwayFromZero); : Math.Round(voters * 100d / electors, 1, MidpointRounding.AwayFromZero);
var referenceTimeLabel = FormatSbsReportTimeLabel(item.LastReportTime);
turnoutItems.Add((order, new TurnoutOverviewItem( turnoutItems.Add((order, new TurnoutOverviewItem(
districtOption.DisplayName, districtOption.DisplayName,
@@ -408,10 +412,12 @@ public sealed class SbsElectionApiClient : IDisposable
districtOption.DistrictCode, districtOption.DistrictCode,
electors, electors,
voters, voters,
turnoutRate))); turnoutRate,
referenceTimeLabel)));
} }
} }
var overviewReferenceTimeLabel = ResolveLatestReferenceTimeLabel(turnoutItems.Select(item => item.Item.ReferenceTimeLabel));
return new TurnoutOverviewResult( return new TurnoutOverviewResult(
turnoutItems turnoutItems
.OrderBy(item => item.Order) .OrderBy(item => item.Order)
@@ -419,7 +425,8 @@ public sealed class SbsElectionApiClient : IDisposable
.ToArray(), .ToArray(),
totalExpectedVotes, totalExpectedVotes,
turnoutVotes, turnoutVotes,
DateTimeOffset.Now); DateTimeOffset.Now,
overviewReferenceTimeLabel);
} }
private static string ResolveTurnoutRegionCode( private static string ResolveTurnoutRegionCode(
@@ -490,9 +497,12 @@ public sealed class SbsElectionApiClient : IDisposable
CountedRate: null, CountedRate: null,
CountedVotes: null, CountedVotes: null,
RemainingVotes: null, RemainingVotes: null,
SeatCount: 0,
CountingClosed: false,
Candidates: null, Candidates: null,
ReceivedAt: DateTimeOffset.Now, 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) 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( private async Task<TurnoutTarget> ResolveTurnoutTargetAsync(
SbsElectionConfiguration configuration, SbsElectionConfiguration configuration,
string districtName, string districtName,
@@ -673,6 +755,30 @@ public sealed class SbsElectionApiClient : IDisposable
IReadOnlyList<string> sidoCodes, IReadOnlyList<string> sidoCodes,
CancellationToken cancellationToken) 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>(); var results = new List<SbsCountingResponseItem>();
foreach (var sidoChunk in sidoCodes.Chunk(24)) foreach (var sidoChunk in sidoCodes.Chunk(24))
@@ -688,6 +794,15 @@ public sealed class SbsElectionApiClient : IDisposable
return results; 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( private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsForPathAsync(
SbsElectionConfiguration configuration, SbsElectionConfiguration configuration,
string path, string path,
@@ -739,8 +854,10 @@ public sealed class SbsElectionApiClient : IDisposable
SbsCountingItem item, SbsCountingItem item,
string sourcePath) string sourcePath)
{ {
var seatCount = Math.Max(0, item.Region?.SeatCount ?? 0);
var countingClosed = item.GaepyoMagam;
var candidates = (item.Hubojas ?? []) var candidates = (item.Hubojas ?? [])
.Select(MapCandidate) .Select(candidate => MapCandidate(candidate, seatCount, countingClosed))
.OrderByDescending(candidate => candidate.VoteCount) .OrderByDescending(candidate => candidate.VoteCount)
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal) .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
.ToArray(); .ToArray();
@@ -750,7 +867,7 @@ public sealed class SbsElectionApiClient : IDisposable
var outputRegionName = BuildOutputRegionName(regionName); var outputRegionName = BuildOutputRegionName(regionName);
var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, fallbackRegion); var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, fallbackRegion);
var displayName = configuration.SungerType is 2 or 4 or 5 or 6 var displayName = configuration.SungerType is 2 or 4 or 5 or 6
? BuildFullDistrictDisplayName(regionName, districtLabel) ? BuildFullDistrictDisplayName(outputRegionName, districtLabel)
: regionName; : regionName;
return new SbsElectionRefreshResult( return new SbsElectionRefreshResult(
@@ -763,14 +880,26 @@ public sealed class SbsElectionApiClient : IDisposable
CountedRate: item.Total?.GaepyoRate, CountedRate: item.Total?.GaepyoRate,
CountedVotes: item.Total?.Gaepyosu, CountedVotes: item.Total?.Gaepyosu,
RemainingVotes: item.Total?.UncountedPyosu, RemainingVotes: item.Total?.UncountedPyosu,
SeatCount: seatCount,
CountingClosed: countingClosed,
Candidates: candidates, Candidates: candidates,
ReceivedAt: DateTimeOffset.Now, ReceivedAt: DateTimeOffset.Now,
SourcePath: sourcePath); 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 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 return new CandidateEntry
{ {
CandidateCode = string.IsNullOrWhiteSpace(item.Giho) ? (item.Name ?? "후보") : item.Giho, CandidateCode = string.IsNullOrWhiteSpace(item.Giho) ? (item.Name ?? "후보") : item.Giho,
@@ -780,7 +909,10 @@ public sealed class SbsElectionApiClient : IDisposable
VoteCount = total.Dugpyosu, VoteCount = total.Dugpyosu,
VoteRate = total.DugpyoRate, VoteRate = total.DugpyoRate,
HasImage = true, 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, UriComponents.SchemeAndServer | UriComponents.Path,
UriFormat.SafeUnescaped, UriFormat.SafeUnescaped,
StringComparison.OrdinalIgnoreCase) == 0 && StringComparison.OrdinalIgnoreCase) == 0 &&
configuration.SungerType == 6; IsBasicCouncilCountingType(configuration.SungerType);
private static bool CanQueryCountingBySido( private static bool CanQueryCountingBySido(
SbsElectionConfiguration configuration, SbsElectionConfiguration configuration,
IReadOnlyList<DistrictSelectionOption> districts) IReadOnlyList<DistrictSelectionOption> districts)
=> configuration.SungerType == 6 && => IsBasicCouncilCountingType(configuration.SungerType) &&
CanDeriveDistrictsFromCounting(configuration) && CanDeriveDistrictsFromCounting(configuration) &&
districts.Count > 0 && districts.Count > 0 &&
districts.All(district => !string.IsNullOrWhiteSpace(district.ParentRegionCode)); districts.All(district => !string.IsNullOrWhiteSpace(district.ParentRegionCode));
private static bool CanQueryBasicCouncilByDistrictId(SbsElectionConfiguration configuration) private static bool CanQueryBasicCouncilByDistrictId(SbsElectionConfiguration configuration)
=> configuration.SungerType == 6 && CanDeriveDistrictsFromCounting(configuration); => IsBasicCouncilCountingType(configuration.SungerType) && CanDeriveDistrictsFromCounting(configuration);
private static bool ShouldCacheBasicCouncilCounting(SbsElectionConfiguration 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) 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) private static string BuildElectionDistrictLabel(string? officeName, string? shortName)
{ {
if (!string.IsNullOrWhiteSpace(officeName)) var label = !string.IsNullOrWhiteSpace(officeName)
{ ? officeName.Trim()
return officeName.Trim(); : shortName?.Trim() ?? string.Empty;
}
return shortName?.Trim() ?? string.Empty; return NormalizeElectionDistrictDisplayLabel(label);
} }
private static string BuildMayorGovernorLabel(string regionName, string? officeName) private static string BuildMayorGovernorLabel(string regionName, string? officeName)
@@ -1377,9 +1511,30 @@ public sealed class SbsElectionApiClient : IDisposable
return regionName; return regionName;
} }
if (districtLabel.StartsWith(regionName, StringComparison.Ordinal))
{
return districtLabel;
}
return $"{regionName} {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) private static DistrictSelectionOption CreateDistrictSelectionOption(int sungerType, SbsRegionInfo region)
{ {
var regionName = ExpandRegionName(region.Name1 ?? region.Name); 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), 2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region),
_ => regionName _ => regionName
}; };
var displayRegionName = sungerType is 5 or 6 ? outputRegionName : regionName;
var displayName = sungerType is 2 or 4 or 5 or 6 var displayName = sungerType is 2 or 4 or 5 or 6
? BuildFullDistrictDisplayName(regionName, districtName) ? BuildFullDistrictDisplayName(displayRegionName, districtName)
: regionName; : regionName;
var turnoutRegionCode = sungerType switch var turnoutRegionCode = sungerType switch
{ {
@@ -1538,12 +1694,16 @@ public sealed class SbsElectionApiClient : IDisposable
double? CountedRate, double? CountedRate,
int? CountedVotes, int? CountedVotes,
int? RemainingVotes, int? RemainingVotes,
int SeatCount,
bool CountingClosed,
IReadOnlyList<CandidateEntry>? Candidates, IReadOnlyList<CandidateEntry>? Candidates,
DateTimeOffset ReceivedAt, DateTimeOffset ReceivedAt,
string SourcePath); string SourcePath,
string ReferenceTimeLabel = "");
public sealed record CountingOverviewItem( public sealed record CountingOverviewItem(
string DisplayName, string DisplayName,
string DistrictCode,
double CountedRate, double CountedRate,
int CountedVotes, int CountedVotes,
int TotalVotes, int TotalVotes,
@@ -1558,13 +1718,15 @@ public sealed class SbsElectionApiClient : IDisposable
string DistrictCode, string DistrictCode,
int TotalExpectedVotes, int TotalExpectedVotes,
int TurnoutVotes, int TurnoutVotes,
double TurnoutRate); double TurnoutRate,
string ReferenceTimeLabel = "");
public sealed record TurnoutOverviewResult( public sealed record TurnoutOverviewResult(
IReadOnlyList<TurnoutOverviewItem> Items, IReadOnlyList<TurnoutOverviewItem> Items,
int TotalExpectedVotes, int TotalExpectedVotes,
int TurnoutVotes, int TurnoutVotes,
DateTimeOffset ReceivedAt) DateTimeOffset ReceivedAt,
string ReferenceTimeLabel = "")
{ {
public double NationalTurnoutRate => TotalExpectedVotes <= 0 public double NationalTurnoutRate => TotalExpectedVotes <= 0
? 0 ? 0
@@ -1605,6 +1767,9 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("order")] [JsonPropertyName("order")]
public int Order { get; set; } public int Order { get; set; }
[JsonPropertyName("seatCount")]
public int SeatCount { get; set; }
} }
private sealed class SbsTurnoutItem private sealed class SbsTurnoutItem
@@ -1615,6 +1780,9 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("sungerinsu")] [JsonPropertyName("sungerinsu")]
public int Sungerinsu { get; set; } public int Sungerinsu { get; set; }
[JsonPropertyName("lastReportTime")]
public double? LastReportTime { get; set; }
[JsonPropertyName("total")] [JsonPropertyName("total")]
public SbsTurnoutVoteSnapshot? Total { get; set; } public SbsTurnoutVoteSnapshot? Total { get; set; }
} }
@@ -1653,6 +1821,9 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("order")] [JsonPropertyName("order")]
public int Order { get; set; } public int Order { get; set; }
[JsonPropertyName("seatCount")]
public int SeatCount { get; set; }
} }
private sealed class SbsTurnoutVoteSnapshot private sealed class SbsTurnoutVoteSnapshot
@@ -1671,6 +1842,9 @@ public sealed class SbsElectionApiClient : IDisposable
[JsonPropertyName("hubojas")] [JsonPropertyName("hubojas")]
public List<SbsCandidateItem>? Hubojas { get; set; } public List<SbsCandidateItem>? Hubojas { get; set; }
[JsonPropertyName("gaepyoMagam")]
public bool GaepyoMagam { get; set; }
} }
private readonly record struct SbsCountingResponseItem( private readonly record struct SbsCountingResponseItem(

View File

@@ -5,6 +5,7 @@ namespace Tornado3_2026Election.Services;
internal static class ScheduleTemplatePolicy internal static class ScheduleTemplatePolicy
{ {
private const double MinimumCutDurationSeconds = 1d;
public const string SingleRegionLabel = "단일"; public const string SingleRegionLabel = "단일";
public static double GetMinimumCutDurationSeconds(FormatTemplateDefinition template) public static double GetMinimumCutDurationSeconds(FormatTemplateDefinition template)
@@ -14,25 +15,7 @@ internal static class ScheduleTemplatePolicy
public static double GetMinimumCutDurationSeconds(BroadcastChannel channel, string? templateName) public static double GetMinimumCutDurationSeconds(BroadcastChannel channel, string? templateName)
{ {
var name = templateName ?? string.Empty; return MinimumCutDurationSeconds;
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;
} }
public static double NormalizeCutDurationSeconds(double durationSeconds, FormatTemplateDefinition template) public static double NormalizeCutDurationSeconds(double durationSeconds, FormatTemplateDefinition template)

View File

@@ -7,7 +7,8 @@ public sealed class StationCatalogService
{ {
private readonly IReadOnlyList<BroadcastStationProfile> _stations = 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 = "TBC", Name = "TBC", LogoAssetPath = @"Assets\Stations\tbc.png", RegionFilters = ["대구", "경북"] },
new BroadcastStationProfile { Id = "KBC", Name = "KBC", LogoAssetPath = @"Assets\Stations\kbc.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 = ["강원"] }, new BroadcastStationProfile { Id = "G1", Name = "G1", LogoAssetPath = @"Assets\Stations\g1.png", RegionFilters = ["강원"] },

View File

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

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -19,6 +20,7 @@ public sealed class TornadoManager : IDisposable
private readonly LogService _logService; private readonly LogService _logService;
private readonly StaDispatcher _dispatcher; private readonly StaDispatcher _dispatcher;
private readonly Timer _reconnectTimer; private readonly Timer _reconnectTimer;
private readonly SemaphoreSlim _asyncOperationLock = new(1, 1);
private readonly Dictionary<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, DateTime> _sceneWriteTimes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, DateTime> _sceneWriteTimes = new(StringComparer.OrdinalIgnoreCase);
@@ -54,7 +56,16 @@ public sealed class TornadoManager : IDisposable
return EnsureConnectedInternalAsync(cancellationToken); 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)) if (string.IsNullOrWhiteSpace(scenePath))
{ {
@@ -66,13 +77,17 @@ public sealed class TornadoManager : IDisposable
throw new ArgumentException("Scene alias is required.", nameof(sceneAlias)); throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
} }
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var sceneWriteTime = ResolveSceneWriteTime(scenePath); var sceneWriteTime = ResolveSceneWriteTime(scenePath);
var existingAlias = await _dispatcher.InvokeAsync(() => var existingAlias = await _dispatcher.InvokeAsync(() =>
{ {
ThrowIfDisposed(); ThrowIfDisposed();
EnsureConnectedCore(); EnsureConnectedCore();
if (_scenePaths.TryGetValue(sceneAlias, out var existingPath) && if (!forceReload &&
_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase) && string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase) &&
_sceneWriteTimes.TryGetValue(sceneAlias, out var existingWriteTime) && _sceneWriteTimes.TryGetValue(sceneAlias, out var existingWriteTime) &&
existingWriteTime == sceneWriteTime) existingWriteTime == sceneWriteTime)
@@ -98,22 +113,23 @@ public sealed class TornadoManager : IDisposable
ThrowIfDisposed(); ThrowIfDisposed();
EnsureConnectedCore(); EnsureConnectedCore();
var forceReload = _scenePaths.ContainsKey(sceneAlias); var shouldForceReload = forceReload || _scenePaths.ContainsKey(sceneAlias);
var scene = forceReload var scene = shouldForceReload
? _engine!.LoadSceneForce(scenePath, sceneAlias) ? _engine!.LoadSceneForce(scenePath, sceneAlias)
: _engine!.LoadScene(scenePath, sceneAlias); : _engine!.LoadScene(scenePath, sceneAlias);
_logService.Info( _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}"); _scenes[sceneAlias] = scene ?? throw new InvalidOperationException($"Failed to load Karisma scene: {scenePath}");
_scenePaths[sceneAlias] = scenePath; _scenePaths[sceneAlias] = scenePath;
_sceneWriteTimes[sceneAlias] = sceneWriteTime; _sceneWriteTimes[sceneAlias] = sceneWriteTime;
}, cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var result = await WaitForKarismaCallbackAsync(
timeoutCts.CancelAfter(AsyncOperationTimeout); completion,
var result = await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false); cancellationToken,
$"LoadScene '{sceneAlias}'").ConfigureAwait(false);
if (result != eKResult.RESULT_SUCCESS) if (result != eKResult.RESULT_SUCCESS)
{ {
throw new InvalidOperationException($"LoadScene failed for '{sceneAlias}': {result} ({(int)result})"); throw new InvalidOperationException($"LoadScene failed for '{sceneAlias}': {result} ({(int)result})");
@@ -141,24 +157,46 @@ public sealed class TornadoManager : IDisposable
throw; throw;
} }
} }
finally
{
_asyncOperationLock.Release();
}
}
public Task ApplyValuesAsync( public async Task ApplyValuesAsync(
string sceneAlias, string sceneAlias,
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesBeforeValue,
IReadOnlyDictionary<string, string> values, IReadOnlyDictionary<string, string> values,
IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys, IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
IReadOnlyList<KarismaChartCellUpdate> chartCellUpdates, IReadOnlyList<KarismaChartCellUpdate> chartCellUpdates,
IReadOnlyList<KarismaPositionUpdate> positionUpdates, IReadOnlyList<KarismaPositionUpdate> positionUpdates,
IReadOnlyList<KarismaCropKeyUpdate> cropKeyUpdates,
IReadOnlyList<KarismaStyleColorUpdate> styleColorUpdates, IReadOnlyList<KarismaStyleColorUpdate> styleColorUpdates,
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesAfterValue, IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesAfterValue,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
return _dispatcher.InvokeAsync(() => await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
Task<eKResult>? transactionCompletion = null;
var eventHandler = GetEventHandlerCore();
try
{
await _dispatcher.InvokeAsync(() =>
{ {
ThrowIfDisposed(); ThrowIfDisposed();
EnsureConnectedCore(); EnsureConnectedCore();
var scene = GetSceneCore(sceneAlias); 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(); _engine!.BeginTransaction();
try try
{ {
@@ -179,6 +217,13 @@ public sealed class TornadoManager : IDisposable
continue; continue;
} }
if (counterNumberKeyObjectNames.Contains(pair.Key) &&
!counterSetValueObjectNames.Contains(pair.Key) &&
sceneObject is IKACounter)
{
continue;
}
sceneObject.SetValue(pair.Value ?? string.Empty); sceneObject.SetValue(pair.Value ?? string.Empty);
} }
catch (Exception ex) catch (Exception ex)
@@ -196,6 +241,11 @@ public sealed class TornadoManager : IDisposable
try try
{ {
if (counterNumberKey.KeyIndex == 0 && !counterNumberKey.AllowKeyZero)
{
continue;
}
var sceneObject = scene.GetObject(counterNumberKey.ObjectName); var sceneObject = scene.GetObject(counterNumberKey.ObjectName);
if (sceneObject is not IKACounter counter) if (sceneObject is not IKACounter counter)
{ {
@@ -254,16 +304,58 @@ public sealed class TornadoManager : IDisposable
continue; continue;
} }
if (positionUpdate.KeyIndex >= 0)
{
sceneObject.SetPositionKey(
positionUpdate.KeyIndex,
positionUpdate.X,
positionUpdate.Y,
positionUpdate.Z,
positionUpdate.VectorType);
}
else
{
sceneObject.SetPosition( sceneObject.SetPosition(
positionUpdate.X, positionUpdate.X,
positionUpdate.Y, positionUpdate.Y,
positionUpdate.Z, positionUpdate.Z,
positionUpdate.VectorType); positionUpdate.VectorType);
} }
}
catch (Exception ex) catch (Exception ex)
{ {
_logService.Warning( _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 finally
{ {
transactionCompletion = eventHandler.BeginEndTransactionWait();
_engine!.EndTransaction(); _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) 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(); ThrowIfDisposed();
EnsureConnectedCore(); EnsureConnectedCore();
GetScenePlayerCore(outputChannelIndex).Prepare(layerNo, GetSceneCore(sceneAlias)); 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(); ThrowIfDisposed();
EnsureConnectedCore(); EnsureConnectedCore();
@@ -375,9 +546,39 @@ public sealed class TornadoManager : IDisposable
}, cancellationToken); }, 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(); ThrowIfDisposed();
EnsureConnectedCore(); EnsureConnectedCore();
@@ -393,6 +594,82 @@ public sealed class TornadoManager : IDisposable
}, cancellationToken); }, 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( public async Task SaveSceneImageAsync(
string sceneAlias, string sceneAlias,
string fileName, string fileName,
@@ -411,6 +688,9 @@ public sealed class TornadoManager : IDisposable
throw new ArgumentException("Image file path is required.", nameof(fileName)); throw new ArgumentException("Image file path is required.", nameof(fileName));
} }
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await _dispatcher.InvokeAsync(() => await _dispatcher.InvokeAsync(() =>
{ {
ThrowIfDisposed(); ThrowIfDisposed();
@@ -436,9 +716,10 @@ public sealed class TornadoManager : IDisposable
GetSceneCore(sceneAlias).SaveSceneImage(fileName, width, height, frame); GetSceneCore(sceneAlias).SaveSceneImage(fileName, width, height, frame);
}, cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var result = await WaitForKarismaCallbackAsync(
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); completion,
var result = await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false); cancellationToken,
$"SaveSceneImage scene={sceneAlias}").ConfigureAwait(false);
if (result.Result != eKResult.RESULT_SUCCESS) if (result.Result != eKResult.RESULT_SUCCESS)
{ {
@@ -453,10 +734,15 @@ public sealed class TornadoManager : IDisposable
throw; throw;
} }
} }
finally
public Task UnloadSceneAsync(string sceneAlias, CancellationToken cancellationToken)
{ {
return _dispatcher.InvokeAsync(() => _asyncOperationLock.Release();
}
}
public async Task UnloadSceneAsync(string sceneAlias, CancellationToken cancellationToken)
{
await InvokeExclusiveAsync(() =>
{ {
ThrowIfDisposed(); ThrowIfDisposed();
@@ -471,9 +757,9 @@ public sealed class TornadoManager : IDisposable
}, cancellationToken); }, 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(); ThrowIfDisposed();
EnsureConnectedCore(); EnsureConnectedCore();
@@ -674,7 +960,57 @@ public sealed class TornadoManager : IDisposable
return _eventHandler ?? throw new InvalidOperationException("Karisma event handler is unavailable."); 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) private async Task EnsureConnectedInternalAsync(CancellationToken cancellationToken)
{
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{ {
Task<int>? connectCompletion = null; Task<int>? connectCompletion = null;
try try
@@ -714,9 +1050,10 @@ public sealed class TornadoManager : IDisposable
return; return;
} }
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var errorCode = await WaitForKarismaCallbackAsync(
timeoutCts.CancelAfter(AsyncOperationTimeout); connectCompletion,
var errorCode = await connectCompletion.WaitAsync(timeoutCts.Token).ConfigureAwait(false); cancellationToken,
"Connect").ConfigureAwait(false);
if (errorCode != 0) if (errorCode != 0)
{ {
throw new InvalidOperationException($"Karisma Async Engine OnConnect failed: errorCode={errorCode}"); throw new InvalidOperationException($"Karisma Async Engine OnConnect failed: errorCode={errorCode}");
@@ -744,6 +1081,11 @@ public sealed class TornadoManager : IDisposable
throw; throw;
} }
} }
finally
{
_asyncOperationLock.Release();
}
}
private static async Task WaitForFileAsync(string fileName, CancellationToken cancellationToken) private static async Task WaitForFileAsync(string fileName, CancellationToken cancellationToken)
{ {

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.UI;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media;
using Tornado3_2026Election.Common; 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 TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
private static readonly Regex PeopleSlotCountPattern = new(@"(\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 ChannelScheduleEngine _engine;
private readonly ITornado3Adapter _adapter; private readonly ITornado3Adapter _adapter;
private readonly CutDebugStateStore _cutDebugStateStore; private readonly CutDebugStateStore _cutDebugStateStore;
@@ -33,6 +36,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private ScheduleRegionOption? _selectedRegionOption; private ScheduleRegionOption? _selectedRegionOption;
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption; private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
private CancellationTokenSource? _directPlaybackCts; private CancellationTokenSource? _directPlaybackCts;
private ChannelScheduleItem? _preparedDirectItem;
private string _preparedDirectFormatId = string.Empty;
private string _preparedDirectRegionKey = string.Empty;
private bool _loopEnabled; private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut; private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
private int _regionOptionsRevision; private int _regionOptionsRevision;
@@ -78,8 +84,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
]; ];
Queue = engine.Queue; Queue = engine.Queue;
SchedulePrepareCommand = new AsyncRelayCommand(PrepareScheduleAsync);
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true); StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
StopCommand = new AsyncRelayCommand(StopAsync); StopCommand = new AsyncRelayCommand(StopAsync);
DirectPrepareCommand = new AsyncRelayCommand(DirectPrepareAsync, CanDirectStart);
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true); DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync); DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync); ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
@@ -138,10 +146,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public ObservableCollection<ChannelScheduleItem> Queue { get; } public ObservableCollection<ChannelScheduleItem> Queue { get; }
public AsyncRelayCommand SchedulePrepareCommand { get; }
public AsyncRelayCommand StartCommand { get; } public AsyncRelayCommand StartCommand { get; }
public AsyncRelayCommand StopCommand { get; } public AsyncRelayCommand StopCommand { get; }
public AsyncRelayCommand DirectPrepareCommand { get; }
public AsyncRelayCommand DirectStartCommand { get; } public AsyncRelayCommand DirectStartCommand { get; }
public AsyncRelayCommand DirectStopCommand { get; } public AsyncRelayCommand DirectStopCommand { get; }
@@ -227,6 +239,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SyncSelectedCutDebugTemplate(); SyncSelectedCutDebugTemplate();
_ = RebuildRegionOptionsAsync(); _ = RebuildRegionOptionsAsync();
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged(); DirectStartCommand.NotifyCanExecuteChanged();
} }
} }
@@ -240,6 +253,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
if (SetProperty(ref _selectedRegionOption, value)) if (SetProperty(ref _selectedRegionOption, value))
{ {
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged(); DirectStartCommand.NotifyCanExecuteChanged();
} }
} }
@@ -324,13 +338,41 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public string AdapterStateText => $"Tornado 상태: {AdapterStateLabel}"; 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); public int QueuedItemCount => Queue.Count(item => item.State == ScheduleQueueItemState.Queued);
@@ -340,8 +382,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{ {
get get
{ {
var current = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.DisplayName ?? "-"; var current = CurrentPlaybackItem?.DisplayName ?? "-";
var next = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "-"; var next = InternalNextPlaybackItem?.InternalNextPreviewDisplayName
?? QueueNextPlaybackItem?.DisplayName
?? "-";
return $"현재 {current} / 다음 {next} / 대기 {Queue.Count(item => item.State == ScheduleQueueItemState.Queued)}"; return $"현재 {current} / 다음 {next} / 대기 {Queue.Count(item => item.State == ScheduleQueueItemState.Queued)}";
} }
} }
@@ -428,6 +472,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public double SelectedFormatThumbnailHeight => _selectedFormatThumbnailHeight; 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() public async Task RefreshRegionOptionsAsync()
{ {
await RebuildRegionOptionsAsync(); await RebuildRegionOptionsAsync();
@@ -466,6 +522,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_videoWallLayoutPreset = videoWallLayoutPreset; _videoWallLayoutPreset = videoWallLayoutPreset;
UpdateSelectedFormatThumbnailMetrics(); UpdateSelectedFormatThumbnailMetrics();
ApplyQueueThumbnailLayouts(); ApplyQueueThumbnailLayouts();
NotifyPlaybackPreviewChanged();
} }
private async Task StartAsync() private async Task StartAsync()
@@ -475,6 +532,21 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_logService.Info($"[{Title}] 큐를 시작"); _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() private async Task StopAsync()
{ {
await _engine.StopAsync().ConfigureAwait(false); await _engine.StopAsync().ConfigureAwait(false);
@@ -482,6 +554,57 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_logService.Info($"[{Title}] 큐를 종료"); _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() private async Task DirectStartAsync()
{ {
var selectedFormat = SelectedFormat; var selectedFormat = SelectedFormat;
@@ -492,22 +615,24 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return; 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); await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
_directPlaybackCts?.Cancel(); _directPlaybackCts?.Cancel();
_directPlaybackCts?.Dispose();
}
var playbackCts = new CancellationTokenSource(); var playbackCts = new CancellationTokenSource();
_directPlaybackCts = playbackCts; _directPlaybackCts = playbackCts;
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption); var item = preparedItem ?? ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
if (preparedItem is null)
{
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics( item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
selectedFormat, selectedFormat,
_videoWallLayoutPreset, _videoWallLayoutPreset,
ThumbnailDisplayContext.Queue)); ThumbnailDisplayContext.Queue));
}
try try
{ {
@@ -524,6 +649,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
} }
finally finally
{ {
ClearPreparedDirectState(item);
if (ReferenceEquals(_directPlaybackCts, playbackCts)) if (ReferenceEquals(_directPlaybackCts, playbackCts))
{ {
_directPlaybackCts = null; _directPlaybackCts = null;
@@ -538,10 +664,49 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{ {
_directPlaybackCts?.Cancel(); _directPlaybackCts?.Cancel();
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false); await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
ClearPreparedDirectState(_preparedDirectItem);
_engine.ClearDirectPlayback();
RefreshSummary(); RefreshSummary();
_logService.Info($"[{Title}] 선택 컷 송출 정지"); _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() private async Task ForceNextAsync()
{ {
await _engine.ForceNextAsync().ConfigureAwait(false); await _engine.ForceNextAsync().ConfigureAwait(false);
@@ -563,12 +728,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return; return;
} }
if (!SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
{
_logService.Warning($"[{Title}] 현재 단계에서는 '{SelectedFormat.Name}' 컷을 추가할 수 없습니다.");
return;
}
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault(); var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
if (regionOption is null) if (regionOption is null)
{ {
@@ -675,9 +834,19 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(AdapterState), nameof(AdapterState),
nameof(AdapterStateText), nameof(AdapterStateText),
nameof(AdapterStateLabel), nameof(AdapterStateLabel),
nameof(IsPlaying),
nameof(PlaybackIconBrush),
nameof(TransmissionLabel), nameof(TransmissionLabel),
nameof(CurrentItemName), nameof(CurrentItemName),
nameof(NextItemName), nameof(NextItemName),
nameof(CurrentPreviewSource),
nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel),
nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth),
nameof(NextPreviewHeight),
nameof(QueuedItemCount), nameof(QueuedItemCount),
nameof(QueueFootnote), nameof(QueueFootnote),
nameof(QueueSummary), nameof(QueueSummary),
@@ -691,7 +860,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private bool CanAddFormat() private bool CanAddFormat()
{ {
return SelectedFormat is not null && return SelectedFormat is not null &&
SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase) &&
SelectedRegionOption is not null; SelectedRegionOption is not null;
} }
@@ -716,7 +884,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
var selectedFormatId = SelectedFormat?.Id; var selectedFormatId = SelectedFormat?.Id;
var selectedCategory = SelectedFormatCategoryOption?.Value; var selectedCategory = SelectedFormatCategoryOption?.Value;
var filteredFormats = _allFormats var filteredFormats = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value)) .Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
.ToArray(); .ToArray();
@@ -738,6 +905,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
UpdateSelectedFormatThumbnailMetrics(); UpdateSelectedFormatThumbnailMetrics();
SyncSelectedCutDebugTemplate(); SyncSelectedCutDebugTemplate();
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged(); DirectStartCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(QueueFootnote)); OnPropertyChanged(nameof(QueueFootnote));
} }
@@ -745,10 +913,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void RebuildFormatCategoryOptions() private void RebuildFormatCategoryOptions()
{ {
var selectedCategory = SelectedFormatCategoryOption?.Value; var selectedCategory = SelectedFormatCategoryOption?.Value;
var formatsInCurrentPhase = _allFormats var options = CreateFormatCategoryOptions(_allFormats);
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.ToArray();
var options = CreateFormatCategoryOptions(formatsInCurrentPhase);
FormatCategoryOptions.Clear(); FormatCategoryOptions.Clear();
foreach (var option in options) foreach (var option in options)
@@ -817,6 +982,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption = null; SelectedRegionOption = null;
_lastRegionOptionFormatId = string.Empty; _lastRegionOptionFormatId = string.Empty;
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged(); DirectStartCommand.NotifyCanExecuteChanged();
return; return;
} }
@@ -844,6 +1010,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection); SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
_lastRegionOptionFormatId = selectedFormat.Id; _lastRegionOptionFormatId = selectedFormat.Id;
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged(); DirectStartCommand.NotifyCanExecuteChanged();
} }
@@ -897,7 +1064,87 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) 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(); 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( private static ScheduleRegionOption? ResolvePreferredRegionOption(
@@ -948,11 +1195,35 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private static bool UsesAllDefaultRegionScope(FormatTemplateDefinition format) private static bool UsesAllDefaultRegionScope(FormatTemplateDefinition format)
{ {
if (IsBottomTurnoutSidoFormat(format))
{
return true;
}
if (IsBottomTurnoutDistrictFormat(format))
{
return false;
}
var source = $"{format.Name} {format.Id}"; var source = $"{format.Name} {format.Id}";
return source.Contains("광역단체장", StringComparison.Ordinal) || return source.Contains("광역단체장", StringComparison.Ordinal) ||
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) private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
{ {
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == 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 required string DistrictViewName { get; init; }
public string DistrictCode { get; init; } = string.Empty;
public required string RegionName { get; init; } public required string RegionName { get; init; }
public required string CountedRateDisplay { 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 ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen);
private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed); 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 static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
private FormatCatalogService _formatCatalogService; private FormatCatalogService _formatCatalogService;
private readonly AppStateStore _stateStore; private readonly AppStateStore _stateStore;
@@ -35,7 +37,7 @@ public sealed class MainViewModel : ObservableObject
private bool _isSituationRoomExpanded; private bool _isSituationRoomExpanded;
private bool _suppressAutomaticSave; private bool _suppressAutomaticSave;
private bool _isSyncingQueuedCutDurations; private bool _isSyncingQueuedCutDurations;
private CancellationTokenSource? _automaticSaveCts; private int _automaticSaveRevision;
private int? _windowX; private int? _windowX;
private int? _windowY; private int? _windowY;
private int? _windowWidth; private int? _windowWidth;
@@ -85,7 +87,11 @@ public sealed class MainViewModel : ObservableObject
_cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled); _cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled);
Settings.PropertyChanged += Settings_PropertyChanged; Settings.PropertyChanged += Settings_PropertyChanged;
Data.PropertyChanged += Data_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); NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀", _sharedTornadoAdapter);
TopLeftChannel = CreateChannelViewModel(BroadcastChannel.TopLeft, "좌상단", _sharedTornadoAdapter); TopLeftChannel = CreateChannelViewModel(BroadcastChannel.TopLeft, "좌상단", _sharedTornadoAdapter);
@@ -208,6 +214,7 @@ public sealed class MainViewModel : ObservableObject
nameof(VideoWallVisibility), nameof(VideoWallVisibility),
nameof(PreElectionDataVisibility), nameof(PreElectionDataVisibility),
nameof(DataVisibility), nameof(DataVisibility),
nameof(CareerPromiseDataVisibility),
nameof(CutListVisibility), nameof(CutListVisibility),
nameof(SettingsVisibility), nameof(SettingsVisibility),
nameof(LogVisibility), nameof(LogVisibility),
@@ -226,6 +233,7 @@ public sealed class MainViewModel : ObservableObject
AppPage.PreElectionData => "사전데이터", AppPage.PreElectionData => "사전데이터",
AppPage.TurnoutData => "투표데이터", AppPage.TurnoutData => "투표데이터",
AppPage.CountingData or AppPage.Data => "개표데이터", AppPage.CountingData or AppPage.Data => "개표데이터",
AppPage.CareerPromiseData => "공약데이터",
AppPage.CutList => "컷리스트", AppPage.CutList => "컷리스트",
AppPage.Settings => "설정", AppPage.Settings => "설정",
AppPage.Log => "로그", AppPage.Log => "로그",
@@ -270,6 +278,8 @@ public sealed class MainViewModel : ObservableObject
public Visibility DataVisibility => IsLiveDataPage(CurrentPage) ? Visibility.Visible : Visibility.Collapsed; 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 CutListVisibility => CurrentPage == AppPage.CutList ? Visibility.Visible : Visibility.Collapsed;
public Visibility SettingsVisibility => CurrentPage == AppPage.Settings ? 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 CgIntegrationBrush => IsCgConnected ? ConnectedStatusBrush : DisconnectedStatusBrush;
public Brush DataNavigationIconBrush => Data.HasLiveDataSignal
? DataReceivingNavigationBrush
: DataWaitingNavigationBrush;
public string CgIntegrationDetail public string CgIntegrationDetail
{ {
get get
@@ -502,6 +516,7 @@ public sealed class MainViewModel : ObservableObject
"turnout-data" => AppPage.TurnoutData, "turnout-data" => AppPage.TurnoutData,
"counting-data" => AppPage.CountingData, "counting-data" => AppPage.CountingData,
"data" => Data.IsPreElectionPhase ? AppPage.TurnoutData : AppPage.CountingData, "data" => Data.IsPreElectionPhase ? AppPage.TurnoutData : AppPage.CountingData,
"career-promises" => AppPage.CareerPromiseData,
"cut-list" => AppPage.CutList, "cut-list" => AppPage.CutList,
"settings" => AppPage.Settings, "settings" => AppPage.Settings,
"log" => AppPage.Log, "log" => AppPage.Log,
@@ -509,6 +524,11 @@ public sealed class MainViewModel : ObservableObject
}; };
CurrentPage = targetPage; CurrentPage = targetPage;
if (targetPage == AppPage.CareerPromiseData)
{
Data.EnsureCareerPromiseElectionType();
}
SyncBroadcastPhaseForLiveDataPage(targetPage); SyncBroadcastPhaseForLiveDataPage(targetPage);
} }
@@ -532,7 +552,7 @@ public sealed class MainViewModel : ObservableObject
var targetPhase = page switch var targetPhase = page switch
{ {
AppPage.TurnoutData => BroadcastPhase.PreElection, AppPage.TurnoutData => BroadcastPhase.PreElection,
AppPage.CountingData or AppPage.Data => BroadcastPhase.Counting, AppPage.CountingData or AppPage.Data or AppPage.CareerPromiseData => BroadcastPhase.Counting,
_ => (BroadcastPhase?)null _ => (BroadcastPhase?)null
}; };
@@ -688,9 +708,14 @@ public sealed class MainViewModel : ObservableObject
try try
{ {
var result = await _thumbnailGeneratorService var templates = _formatCatalogService.GetAll();
.GenerateAsync( var result = _sharedTornadoAdapter is KarismaTornado3Adapter karismaAdapter
_formatCatalogService.GetAll(), ? await karismaAdapter.GenerateThumbnailsAsync(
templates,
Settings.SelectedStationVideoWallLayoutPreset,
CancellationToken.None)
: await _thumbnailGeneratorService.GenerateAsync(
templates,
Settings.ImageRootPath, Settings.ImageRootPath,
Settings.SelectedStationVideoWallLayoutPreset, Settings.SelectedStationVideoWallLayoutPreset,
CancellationToken.None); CancellationToken.None);
@@ -767,6 +792,11 @@ public sealed class MainViewModel : ObservableObject
OnPropertyChanged(nameof(HeaderStatus)); OnPropertyChanged(nameof(HeaderStatus));
} }
if (args.PropertyName is nameof(DataViewModel.HasLiveDataSignal))
{
OnPropertyChanged(nameof(DataNavigationIconBrush));
}
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled) if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
or nameof(DataViewModel.BroadcastPhase) or nameof(DataViewModel.BroadcastPhase)
or nameof(DataViewModel.ElectionType) or nameof(DataViewModel.ElectionType)
@@ -875,6 +905,10 @@ public sealed class MainViewModel : ObservableObject
private void ApplyState(AppState state) private void ApplyState(AppState state)
{ {
Settings.IsDebugFeaturesEnabled = state.IsDebugFeaturesEnabled; Settings.IsDebugFeaturesEnabled = state.IsDebugFeaturesEnabled;
Settings.NormalLayerNo = state.NormalLayerNo;
Settings.TopLeftLayerNo = state.TopLeftLayerNo;
Settings.BottomLayerNo = state.BottomLayerNo;
Settings.VideoWallLayerNo = state.VideoWallLayerNo;
if (RestoreSelection.RestoreStations) if (RestoreSelection.RestoreStations)
{ {
@@ -956,54 +990,31 @@ public sealed class MainViewModel : ObservableObject
return; return;
} }
CancelPendingAutomaticSave(); var revision = Interlocked.Increment(ref _automaticSaveRevision);
_ = RunAutomaticSaveAsync(revision);
var automaticSaveCts = new CancellationTokenSource();
_automaticSaveCts = automaticSaveCts;
_ = RunAutomaticSaveAsync(automaticSaveCts);
} }
private async Task RunAutomaticSaveAsync(CancellationTokenSource automaticSaveCts) private async Task RunAutomaticSaveAsync(int revision)
{ {
try try
{ {
var cancellationToken = automaticSaveCts.Token; await Task.Delay(AutomaticSaveDelay).ConfigureAwait(false);
await Task.Delay(AutomaticSaveDelay, cancellationToken).ConfigureAwait(false); if (_suppressAutomaticSave || revision != Volatile.Read(ref _automaticSaveRevision))
if (_suppressAutomaticSave || cancellationToken.IsCancellationRequested)
{ {
return; return;
} }
await SaveStateCoreAsync(writeLog: false).ConfigureAwait(false); await SaveStateCoreAsync(writeLog: false).ConfigureAwait(false);
} }
catch (OperationCanceledException)
{
}
catch (Exception ex) catch (Exception ex)
{ {
_logService.Warning($"자동 저장 실패: {ex.Message}"); _logService.Warning($"자동 저장 실패: {ex.Message}");
} }
finally
{
if (ReferenceEquals(_automaticSaveCts, automaticSaveCts))
{
_automaticSaveCts = null;
}
automaticSaveCts.Dispose();
}
} }
private void CancelPendingAutomaticSave() private void CancelPendingAutomaticSave()
{ {
if (_automaticSaveCts is null) Interlocked.Increment(ref _automaticSaveRevision);
{
return;
}
_automaticSaveCts.Cancel();
_automaticSaveCts.Dispose();
_automaticSaveCts = null;
} }
private async Task SaveStateCoreAsync(bool writeLog) private async Task SaveStateCoreAsync(bool writeLog)
@@ -1024,6 +1035,10 @@ public sealed class MainViewModel : ObservableObject
WindowHeight = _windowHeight ?? 0, WindowHeight = _windowHeight ?? 0,
IsWindowMaximized = _isWindowMaximized, IsWindowMaximized = _isWindowMaximized,
IsDebugFeaturesEnabled = Settings.IsDebugFeaturesEnabled, 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(), OperationMode = OperationMode.ToString(),
BroadcastPhase = Data.BroadcastPhase.ToString(), BroadcastPhase = Data.BroadcastPhase.ToString(),
IsPollingEnabled = Data.IsPollingEnabled, IsPollingEnabled = Data.IsPollingEnabled,
@@ -1094,6 +1109,7 @@ public sealed class MainViewModel : ObservableObject
Data, Data,
Settings.BuildSelectedStationProfile, Settings.BuildSelectedStationProfile,
() => Settings.ImageRootPath, () => Settings.ImageRootPath,
() => Settings.SelectedStationVideoWallLayoutPreset,
formatId => _formatCatalogService.FindById(formatId), formatId => _formatCatalogService.FindById(formatId),
_logService); _logService);

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using Tornado3_2026Election.Common; using Tornado3_2026Election.Common;
@@ -11,12 +12,17 @@ public sealed class SettingsViewModel : ObservableObject
{ {
private string _selectedStationId = "KNN"; private string _selectedStationId = "KNN";
private string _imageRootPath = TornadoPathResolver.GetDefaultT3CutPath(); 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 = private readonly IReadOnlyList<SelectionOption<VideoWallLayoutPreset>> _videoWallLayoutOptions =
[ [
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Auto, "자동"), new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Auto, "자동"),
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Standard5760x1080, "5760 x 1080"), new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Wall3840x810, "3840 x 810"),
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.UltraWide11520x1080, "11520 x 1080") new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Wall2880x1080, "2880 x 1080"),
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.UltraWide8316x1080, "8316 x 1080")
]; ];
public SettingsViewModel(IEnumerable<BroadcastStationProfile> stations) public SettingsViewModel(IEnumerable<BroadcastStationProfile> stations)
@@ -84,6 +90,30 @@ public sealed class SettingsViewModel : ObservableObject
set => SetProperty(ref _isDebugFeaturesEnabled, value); 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 public StationFilterItemViewModel SelectedStation
=> Stations.FirstOrDefault(station => station.Id == SelectedStationId) ?? Stations[0]; => Stations.FirstOrDefault(station => station.Id == SelectedStationId) ?? Stations[0];
@@ -125,9 +155,35 @@ public sealed class SettingsViewModel : ObservableObject
public string SelectedStationVideoWallLayoutSummary => SelectedStation.VideoWallLayoutSummary; 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() public BroadcastStationProfile BuildSelectedStationProfile()
{ {
return SelectedStation.ToProfile(); 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 public string VideoWallLayoutSummary => VideoWallLayoutPreset switch
{ {
VideoWallLayoutPreset.Standard5760x1080 => "5760 x 1080 비디오월", VideoWallLayoutPreset.Wall3840x810 => "3840 x 810 VideoWall",
VideoWallLayoutPreset.UltraWide11520x1080 => "11520 x 1080 비디오월", 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\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaChartCellUpdate.cs" Link="AppSource\Services\KarismaChartCellUpdate.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\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\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaPositionUpdate.cs" Link="AppSource\Services\KarismaPositionUpdate.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" /> <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;
using System.Globalization;
using System.Net.Sockets; using System.Net.Sockets;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using KAsyncEngineLib; using KAsyncEngineLib;
@@ -319,6 +321,12 @@ if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComp
return; 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)) if (args.Length > 0 && string.Equals(args[0], "--validate-current-api-cuts", StringComparison.OrdinalIgnoreCase))
{ {
Environment.ExitCode = await CurrentApiCutDiagnostics.RunAsync(args[1..]).ConfigureAwait(false); 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; 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); var options = ProbeOptions.Parse(args);
Console.WriteLine($"Karisma TCP probe starting. target={options.Host}:{options.Port} timeout={options.Timeout.TotalSeconds:0}s"); 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) if (options.MaterialOpacity is not null)
{ {
Console.WriteLine( Console.WriteLine(
@@ -976,31 +1017,38 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
} }
} }
var positionKeyUpdates = new List<PositionKeyUpdate>();
if (options.PositionKey is not null) if (options.PositionKey is not null)
{
positionKeyUpdates.Add(options.PositionKey);
}
positionKeyUpdates.AddRange(options.PositionKeys);
foreach (var positionKeyUpdate in positionKeyUpdates)
{ {
Console.WriteLine( Console.WriteLine(
$"[SAVE-IMAGE] Setting position key object={options.PositionKey.ObjectName} index={options.PositionKey.KeyIndex} " + $"[SAVE-IMAGE] Setting position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
$"value=({options.PositionKey.X},{options.PositionKey.Y},{options.PositionKey.Z}) vector={options.PositionKey.VectorType}..."); $"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
var sceneObject = scene.GetObject(options.PositionKey.ObjectName); var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
if (sceneObject is null) if (sceneObject is null)
{ {
completion.TrySetResult( 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; return;
} }
handler.ResetPositionKeyTask(); handler.ResetPositionKeyTask();
sceneObject.SetPositionKey( sceneObject.SetPositionKey(
options.PositionKey.KeyIndex, positionKeyUpdate.KeyIndex,
options.PositionKey.X, positionKeyUpdate.X,
options.PositionKey.Y, positionKeyUpdate.Y,
options.PositionKey.Z, positionKeyUpdate.Z,
options.PositionKey.VectorType); positionKeyUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout)) if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
{ {
completion.TrySetResult( 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; return;
} }
@@ -1008,7 +1056,7 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
if (positionKeyResult != eKResult.RESULT_SUCCESS) if (positionKeyResult != eKResult.RESULT_SUCCESS)
{ {
completion.TrySetResult( 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; 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)) if (!string.IsNullOrWhiteSpace(outputDirectory))
{ {
Directory.CreateDirectory(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(); 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)) 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; return;
} }
@@ -1179,28 +1321,42 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
if (saveResult != eKResult.RESULT_SUCCESS) if (saveResult != eKResult.RESULT_SUCCESS)
{ {
completion.TrySetResult( 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; return;
} }
var savedThisFrame = false;
var fileWaitDeadline = DateTime.UtcNow + options.Connection.Timeout; var fileWaitDeadline = DateTime.UtcNow + options.Connection.Timeout;
while (DateTime.UtcNow < fileWaitDeadline) 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) if (info.Length > 0)
{ {
completion.TrySetResult( totalBytes += info.Length;
new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", options.OutputPath, $"Saved {info.Length} bytes.")); savedThisFrame = true;
return; break;
} }
} }
Thread.Sleep(50); 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) catch (Exception ex)
{ {
@@ -1283,7 +1439,16 @@ static Task<SceneCatalogProbeResult> CatalogScenesAsync(SceneCatalogOptions opti
.EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories) .EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories)
.Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) || .Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) ||
path.Contains(options.SceneFilter, StringComparison.OrdinalIgnoreCase)) 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) .Take(options.MaxScenes ?? int.MaxValue)
.ToArray(); .ToArray();
@@ -2309,142 +2474,7 @@ static Task<SceneValidationProbeResult> ValidateSceneOperationsAsync(SceneValida
foreach (var operation in operations) foreach (var operation in operations)
{ {
Console.WriteLine($"[VALIDATE] {operation.Method} object={operation.ObjectName}"); results.Add(ApplySceneOperation(handler, scene, operation, options.Connection.Timeout));
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));
} }
WriteSceneValidationMarkdown(options, results); WriteSceneValidationMarkdown(options, results);
@@ -2532,7 +2562,16 @@ static Task<FolderInspectionProbeResult> InspectTscnFolderAsync(FolderInspection
.EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories) .EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories)
.Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) || .Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) ||
path.Contains(options.SceneFilter, StringComparison.OrdinalIgnoreCase)) 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) .Take(options.MaxScenes ?? int.MaxValue)
.ToArray(); .ToArray();
@@ -2865,7 +2904,12 @@ static void WriteFolderInspectionMarkdown(
static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOptions options) 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 var operations = JsonSerializer.Deserialize<List<SceneValidationOperation>>(json, new JsonSerializerOptions
{ {
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
@@ -2875,13 +2919,143 @@ static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOp
{ {
if (!string.IsNullOrWhiteSpace(operation.Value)) 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; 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) static string DescribeOperationPayload(SceneValidationOperation operation)
{ {
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase)) if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
@@ -3387,17 +3561,24 @@ internal sealed record SaveSceneImageOptions(
int Width, int Width,
int Height, int Height,
int Frame, int Frame,
IReadOnlyList<int> Frames,
string? OutputDirectory,
string OutputPattern,
string? SetObjectName, string? SetObjectName,
string? SetObjectValue, string? SetObjectValue,
string? VisibleObjectName, string? VisibleObjectName,
bool? VisibleObjectValue, bool? VisibleObjectValue,
VariableNameUpdate? VariableName, VariableNameUpdate? VariableName,
CloneObjectUpdate? CloneObject, CloneObjectUpdate? CloneObject,
string? OperationsPath,
MaterialOpacityUpdate? MaterialOpacity, MaterialOpacityUpdate? MaterialOpacity,
SizeUpdate? Size, SizeUpdate? Size,
PositionUpdate? Position, PositionUpdate? Position,
IReadOnlyList<PositionUpdate> Positions, IReadOnlyList<PositionUpdate> Positions,
PositionKeyUpdate? PositionKey, PositionKeyUpdate? PositionKey,
IReadOnlyList<PositionKeyUpdate> PositionKeys,
IReadOnlyList<PositionUpdate> PostPositions,
IReadOnlyList<PositionKeyUpdate> PostPositionKeys,
string? ChartObjectName, string? ChartObjectName,
string? ChartCsvPath, string? ChartCsvPath,
IReadOnlyList<ChartCellUpdate> ChartCells, IReadOnlyList<ChartCellUpdate> ChartCells,
@@ -3411,6 +3592,9 @@ internal sealed record SaveSceneImageOptions(
string? scenePath = null; string? scenePath = null;
string? sceneAlias = null; string? sceneAlias = null;
string? outputPath = null; string? outputPath = null;
string? outputDirectory = null;
string outputPattern = "frame_{0:D4}.png";
IReadOnlyList<int> frames = Array.Empty<int>();
string? setObjectName = null; string? setObjectName = null;
string? setObjectValue = null; string? setObjectValue = null;
string? visibleObjectName = null; string? visibleObjectName = null;
@@ -3419,6 +3603,7 @@ internal sealed record SaveSceneImageOptions(
string? variableNameValue = null; string? variableNameValue = null;
string? cloneSourceObjectName = null; string? cloneSourceObjectName = null;
string? cloneVariableName = null; string? cloneVariableName = null;
string? operationsPath = null;
string? materialOpacityObjectName = null; string? materialOpacityObjectName = null;
float? materialOpacityValue = null; float? materialOpacityValue = null;
string? sizeObjectName = null; string? sizeObjectName = null;
@@ -3429,6 +3614,9 @@ internal sealed record SaveSceneImageOptions(
string? positionKeyObjectName = null; string? positionKeyObjectName = null;
int positionKeyIndex = 1; int positionKeyIndex = 1;
string? positionKeyRaw = null; string? positionKeyRaw = null;
string? positionKeysRaw = null;
string? postPositionsRaw = null;
string? postPositionKeysRaw = null;
string? chartObjectName = null; string? chartObjectName = null;
string? chartCsvPath = null; string? chartCsvPath = null;
string? chartCellsRaw = null; string? chartCellsRaw = null;
@@ -3452,6 +3640,12 @@ internal sealed record SaveSceneImageOptions(
case "--output" when index + 1 < args.Length: case "--output" when index + 1 < args.Length:
outputPath = args[++index]; outputPath = args[++index];
break; 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: case "--set-object" when index + 1 < args.Length:
setObjectName = args[++index]; setObjectName = args[++index];
break; break;
@@ -3481,6 +3675,9 @@ internal sealed record SaveSceneImageOptions(
case "--clone-name" when index + 1 < args.Length: case "--clone-name" when index + 1 < args.Length:
cloneVariableName = args[++index]; cloneVariableName = args[++index];
break; break;
case "--operations" when index + 1 < args.Length:
operationsPath = args[++index];
break;
case "--material-opacity-object" when index + 1 < args.Length: case "--material-opacity-object" when index + 1 < args.Length:
materialOpacityObjectName = args[++index]; materialOpacityObjectName = args[++index];
break; break;
@@ -3513,6 +3710,15 @@ internal sealed record SaveSceneImageOptions(
case "--position-key" when index + 1 < args.Length: case "--position-key" when index + 1 < args.Length:
positionKeyRaw = args[++index]; positionKeyRaw = args[++index];
break; 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: case "--chart-object" when index + 1 < args.Length:
chartObjectName = args[++index]; chartObjectName = args[++index];
break; break;
@@ -3543,6 +3749,9 @@ internal sealed record SaveSceneImageOptions(
frame = parsedFrame; frame = parsedFrame;
index++; index++;
break; 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."); 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."); throw new ArgumentException("--output is required.");
} }
scenePath = Path.GetFullPath(scenePath); scenePath = Path.GetFullPath(scenePath);
outputPath = Path.GetFullPath(outputPath); outputPath = Path.GetFullPath(outputPath);
operationsPath = string.IsNullOrWhiteSpace(operationsPath)
? null
: Path.GetFullPath(operationsPath);
sceneAlias ??= Path.GetFileNameWithoutExtension(scenePath); sceneAlias ??= Path.GetFileNameWithoutExtension(scenePath);
return new SaveSceneImageOptions( return new SaveSceneImageOptions(
connection, connection,
@@ -3567,17 +3790,24 @@ internal sealed record SaveSceneImageOptions(
width, width,
height, height,
frame, frame,
frames,
outputDirectory,
outputPattern,
setObjectName, setObjectName,
setObjectValue, setObjectValue,
visibleObjectName, visibleObjectName,
visibleObjectValue, visibleObjectValue,
ParseVariableName(variableNameObjectName, variableNameValue), ParseVariableName(variableNameObjectName, variableNameValue),
ParseCloneObject(cloneSourceObjectName, cloneVariableName), ParseCloneObject(cloneSourceObjectName, cloneVariableName),
operationsPath,
ParseMaterialOpacity(materialOpacityObjectName, materialOpacityValue), ParseMaterialOpacity(materialOpacityObjectName, materialOpacityValue),
ParseSize(sizeObjectName, sizeRaw), ParseSize(sizeObjectName, sizeRaw),
ParsePosition(positionObjectName, positionRaw), ParsePosition(positionObjectName, positionRaw),
ParsePositions(positionsRaw), ParsePositions(positionsRaw),
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw), ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
ParsePositionKeys(positionKeysRaw),
ParsePositions(postPositionsRaw),
ParsePositionKeys(postPositionKeysRaw),
chartObjectName, chartObjectName,
chartCsvPath, chartCsvPath,
ParseChartCells(chartCellsRaw), ParseChartCells(chartCellsRaw),
@@ -3586,6 +3816,53 @@ internal sealed record SaveSceneImageOptions(
ParsePathModifications(modifyPathRaw)); 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) private static CloneObjectUpdate? ParseCloneObject(string? sourceObjectName, string? variableName)
{ {
if (string.IsNullOrWhiteSpace(sourceObjectName) || string.IsNullOrWhiteSpace(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); 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) private static IReadOnlyList<ChartCellUpdate> ParseChartCells(string? raw)
{ {
if (string.IsNullOrWhiteSpace(raw)) if (string.IsNullOrWhiteSpace(raw))
@@ -3857,7 +4166,7 @@ internal sealed record SceneCatalogOptions(
switch (args[index]) switch (args[index])
{ {
case "--root" when index + 1 < args.Length: case "--root" when index + 1 < args.Length:
index++; rootPath = args[++index];
break; break;
case "--output" when index + 1 < args.Length: case "--output" when index + 1 < args.Length:
outputPath = args[++index]; outputPath = args[++index];
@@ -4195,7 +4504,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
switch (args[index]) switch (args[index])
{ {
case "--root" when index + 1 < args.Length: case "--root" when index + 1 < args.Length:
index++; rootPath = args[++index];
break; break;
case "--output" when index + 1 < args.Length: case "--output" when index + 1 < args.Length:
outputPath = args[++index]; outputPath = args[++index];
@@ -4358,6 +4667,8 @@ internal sealed class SceneValidationOperation
public int A { get; set; } = 255; public int A { get; set; } = 255;
public bool Visible { get; set; } public bool Visible { get; set; }
public bool ContinueOnFailure { get; set; }
} }
internal sealed record SceneOperationValidationResult(string ObjectName, string Method, string Payload, string Result, string Detail); 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"
```