Compare commits
2 Commits
960163dad8
...
e76c37ef56
| Author | SHA1 | Date | |
|---|---|---|---|
| e76c37ef56 | |||
| 8b5c92194f |
32
AGENTS.md
Normal 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
@@ -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 | |
|
||||
42
SCENE_CAPABILITY_INSPECTION.md
Normal 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 | | |
|
||||
193
TSCN_VARIABLE_DISCOVERY_TOP_2P.md
Normal 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 |
|
||||
|
||||
BIN
Tornado3_2026Election/Assets/Stations/ubc.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 312 KiB |
@@ -90,37 +90,98 @@
|
||||
BorderBrush="#25405D"
|
||||
BorderThickness="1"
|
||||
CornerRadius="18">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border
|
||||
Width="{x:Bind ViewModel.CurrentPreviewWidth, Mode=OneWay}"
|
||||
Height="{x:Bind ViewModel.CurrentPreviewHeight, Mode=OneWay}"
|
||||
VerticalAlignment="Center"
|
||||
Background="#0B1624"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<Image
|
||||
Source="{x:Bind ViewModel.CurrentPreviewSource, Mode=OneWay}"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
|
||||
<StackPanel
|
||||
Grid.Column="1"
|
||||
Spacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="현재" />
|
||||
<TextBlock
|
||||
FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="22"
|
||||
FontSize="20"
|
||||
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||
Text="{x:Bind ViewModel.CurrentItemName, Mode=OneWay}" />
|
||||
Text="{x:Bind ViewModel.CurrentItemName, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="{x:Bind ViewModel.QueueSummary, Mode=OneWay}" />
|
||||
Text="{x:Bind ViewModel.CurrentPreviewStatusLabel, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind ViewModel.QueueSummary, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
MaxLines="2" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
Grid.Column="1"
|
||||
Padding="16"
|
||||
Background="#1E2438"
|
||||
BorderBrush="#5D4B35"
|
||||
BorderThickness="1"
|
||||
Background="#48260A"
|
||||
BorderBrush="#FFB81C"
|
||||
BorderThickness="2"
|
||||
CornerRadius="18">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="다음" />
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border
|
||||
Width="{x:Bind ViewModel.NextPreviewWidth, Mode=OneWay}"
|
||||
Height="{x:Bind ViewModel.NextPreviewHeight, Mode=OneWay}"
|
||||
VerticalAlignment="Center"
|
||||
Background="#0B1624"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<Image
|
||||
Source="{x:Bind ViewModel.NextPreviewSource, Mode=OneWay}"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
|
||||
<StackPanel
|
||||
Grid.Column="1"
|
||||
Spacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock
|
||||
Foreground="#FFD166"
|
||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="다음" />
|
||||
<TextBlock
|
||||
FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="22"
|
||||
FontSize="20"
|
||||
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||
Text="{x:Bind ViewModel.NextItemName, Mode=OneWay}" />
|
||||
Text="{x:Bind ViewModel.NextItemName, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="{x:Bind ViewModel.NextPreviewStatusLabel, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind ViewModel.LoopSummary, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
@@ -265,15 +326,9 @@
|
||||
Text="초" />
|
||||
</StackPanel>
|
||||
|
||||
<ToggleSwitch
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Header="반복"
|
||||
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
|
||||
|
||||
<ComboBox
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Grid.Column="0"
|
||||
Width="150"
|
||||
Header="빈 스케줄"
|
||||
DisplayMemberPath="Label"
|
||||
@@ -282,7 +337,7 @@
|
||||
|
||||
<Button
|
||||
Grid.Row="1"
|
||||
Grid.Column="2"
|
||||
Grid.Column="1"
|
||||
Width="22"
|
||||
Height="22"
|
||||
MinWidth="22"
|
||||
@@ -303,9 +358,23 @@
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Border
|
||||
Padding="12"
|
||||
Background="#101C2E"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="선택컷 송출 제어" />
|
||||
<StackPanel
|
||||
Orientation="Horizontal"
|
||||
Spacing="10">
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.DirectPrepareCommand}"
|
||||
Content="준비"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.DirectStartCommand}"
|
||||
Content="시작"
|
||||
@@ -315,6 +384,8 @@
|
||||
Content="정지"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
Padding="12"
|
||||
@@ -709,6 +780,13 @@
|
||||
HorizontalAlignment="Right"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<ToggleSwitch
|
||||
Header="반복"
|
||||
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.SchedulePrepareCommand}"
|
||||
Content="준비"
|
||||
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.StartCommand}"
|
||||
Content="스케줄 시작"
|
||||
@@ -758,13 +836,13 @@
|
||||
Margin="0,0,0,10"
|
||||
Opacity="{x:Bind CardOpacity, Mode=OneWay}"
|
||||
Padding="14"
|
||||
Background="#122033"
|
||||
BorderBrush="#27405F"
|
||||
Background="{x:Bind StateCardBackgroundBrush, Mode=OneWay}"
|
||||
BorderBrush="{x:Bind StateCardBorderBrush, Mode=OneWay}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="18">
|
||||
<Grid ColumnSpacing="14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="8" />
|
||||
<ColumnDefinition Width="10" />
|
||||
<ColumnDefinition Width="140" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
@@ -778,7 +856,7 @@
|
||||
<StackPanel Grid.Column="1" Spacing="6">
|
||||
<Border
|
||||
Padding="10,6"
|
||||
Background="#1A2E47"
|
||||
Background="{x:Bind StateBadgeBackgroundBrush, Mode=OneWay}"
|
||||
CornerRadius="12">
|
||||
<TextBlock
|
||||
Style="{StaticResource MiniSignalTextStyle}"
|
||||
|
||||
@@ -11,6 +11,7 @@ public enum AppPage
|
||||
TurnoutData,
|
||||
CountingData,
|
||||
Data,
|
||||
CareerPromiseData,
|
||||
CutList,
|
||||
Settings,
|
||||
Log
|
||||
|
||||
@@ -69,6 +69,21 @@ public sealed class CandidateEntry : ObservableObject
|
||||
[JsonIgnore]
|
||||
public double? BroadcastCountedRate { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public int BroadcastRank { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public int BroadcastSeatCount { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool BroadcastCountingClosed { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsWithinBroadcastSeatCount =>
|
||||
BroadcastRank > 0 &&
|
||||
BroadcastSeatCount > 0 &&
|
||||
BroadcastRank <= BroadcastSeatCount;
|
||||
|
||||
public int VoteCount
|
||||
{
|
||||
get => _voteCount;
|
||||
@@ -217,6 +232,9 @@ public sealed class CandidateEntry : ObservableObject
|
||||
BroadcastElectionDistrictName = BroadcastElectionDistrictName,
|
||||
BroadcastDistrictCode = BroadcastDistrictCode,
|
||||
BroadcastCountedRate = BroadcastCountedRate,
|
||||
BroadcastRank = BroadcastRank,
|
||||
BroadcastSeatCount = BroadcastSeatCount,
|
||||
BroadcastCountingClosed = BroadcastCountingClosed,
|
||||
VoteCount = VoteCount,
|
||||
VoteRate = VoteRate,
|
||||
HasImage = HasImage,
|
||||
|
||||
@@ -20,6 +20,13 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
private double _thumbnailWidth = 160;
|
||||
private double _thumbnailHeight = 90;
|
||||
private ImageSource? _thumbnailSource;
|
||||
private string _renderedPreviewPath = string.Empty;
|
||||
private ImageSource? _renderedPreviewSource;
|
||||
private string _renderedPreviewStatusLabel = string.Empty;
|
||||
private string _internalNextPreviewPath = string.Empty;
|
||||
private ImageSource? _internalNextPreviewSource;
|
||||
private string _internalNextPreviewStatusLabel = string.Empty;
|
||||
private string _internalNextPreviewDisplayName = string.Empty;
|
||||
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
@@ -88,6 +95,9 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
{
|
||||
OnPropertyChanged(nameof(StateLabel));
|
||||
OnPropertyChanged(nameof(StateBrush));
|
||||
OnPropertyChanged(nameof(StateCardBackgroundBrush));
|
||||
OnPropertyChanged(nameof(StateCardBorderBrush));
|
||||
OnPropertyChanged(nameof(StateBadgeBackgroundBrush));
|
||||
OnPropertyChanged(nameof(CardOpacity));
|
||||
OnPropertyChanged(nameof(CanDelete));
|
||||
}
|
||||
@@ -133,11 +143,43 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
[JsonIgnore]
|
||||
public SolidColorBrush StateBrush => new(State switch
|
||||
{
|
||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 245, 158, 11),
|
||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
|
||||
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
|
||||
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 239, 68, 68),
|
||||
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
|
||||
_ => ColorHelper.FromArgb(255, 100, 116, 139)
|
||||
});
|
||||
|
||||
[JsonIgnore]
|
||||
public SolidColorBrush StateCardBackgroundBrush => new(State switch
|
||||
{
|
||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 72, 38, 10),
|
||||
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 64, 42, 16),
|
||||
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 58, 22, 24),
|
||||
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 54, 18, 31),
|
||||
_ => ColorHelper.FromArgb(255, 18, 32, 51)
|
||||
});
|
||||
|
||||
[JsonIgnore]
|
||||
public SolidColorBrush StateCardBorderBrush => new(State switch
|
||||
{
|
||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
|
||||
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
|
||||
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 255, 90, 84),
|
||||
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
|
||||
_ => ColorHelper.FromArgb(255, 39, 64, 95)
|
||||
});
|
||||
|
||||
[JsonIgnore]
|
||||
public SolidColorBrush StateBadgeBackgroundBrush => new(State switch
|
||||
{
|
||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 194, 65, 12),
|
||||
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 180, 83, 9),
|
||||
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 220, 38, 38),
|
||||
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 190, 18, 60),
|
||||
_ => ColorHelper.FromArgb(255, 26, 46, 71)
|
||||
});
|
||||
|
||||
[JsonIgnore]
|
||||
public double CardOpacity => State == ScheduleQueueItemState.Completed ? 0.45 : 1.0;
|
||||
|
||||
@@ -179,6 +221,35 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
[JsonIgnore]
|
||||
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(FormatId);
|
||||
|
||||
[JsonIgnore]
|
||||
public ImageSource? PreviewSource => _renderedPreviewSource;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasRenderedPreview => _renderedPreviewSource is not null;
|
||||
|
||||
[JsonIgnore]
|
||||
public string PreviewStatusLabel => HasRenderedPreview
|
||||
? _renderedPreviewStatusLabel
|
||||
: string.IsNullOrWhiteSpace(_renderedPreviewStatusLabel)
|
||||
? "실데이터 프리뷰 준비 중"
|
||||
: _renderedPreviewStatusLabel;
|
||||
|
||||
[JsonIgnore]
|
||||
public ImageSource? InternalNextPreviewSource => _internalNextPreviewSource;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasInternalNextPreview => _internalNextPreviewSource is not null;
|
||||
|
||||
[JsonIgnore]
|
||||
public string InternalNextPreviewStatusLabel => HasInternalNextPreview
|
||||
? _internalNextPreviewStatusLabel
|
||||
: "다음 지역 프리뷰 준비 중";
|
||||
|
||||
[JsonIgnore]
|
||||
public string InternalNextPreviewDisplayName => string.IsNullOrWhiteSpace(_internalNextPreviewDisplayName)
|
||||
? DisplayName
|
||||
: _internalNextPreviewDisplayName;
|
||||
|
||||
[JsonIgnore]
|
||||
public double ThumbnailWidth
|
||||
{
|
||||
@@ -204,6 +275,61 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
OnPropertyChanged(nameof(ThumbnailStatusLabel));
|
||||
}
|
||||
|
||||
public void UpdateRenderedPreview(string previewPath, string statusLabel)
|
||||
{
|
||||
_renderedPreviewPath = previewPath;
|
||||
_renderedPreviewSource = CutPreviewAssetCatalog.CreateImageSource(previewPath);
|
||||
_renderedPreviewStatusLabel = statusLabel;
|
||||
OnPreviewChanged();
|
||||
}
|
||||
|
||||
public void UpdateRenderedPreviewStatus(string statusLabel)
|
||||
{
|
||||
_renderedPreviewStatusLabel = statusLabel;
|
||||
OnPreviewChanged();
|
||||
}
|
||||
|
||||
public void UpdateInternalNextPreview(string previewPath, string displayName, string statusLabel)
|
||||
{
|
||||
_internalNextPreviewPath = previewPath;
|
||||
_internalNextPreviewSource = CutPreviewAssetCatalog.CreateImageSource(previewPath);
|
||||
_internalNextPreviewDisplayName = displayName;
|
||||
_internalNextPreviewStatusLabel = statusLabel;
|
||||
OnInternalNextPreviewChanged();
|
||||
}
|
||||
|
||||
public void ClearRenderedPreview()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_renderedPreviewPath) &&
|
||||
_renderedPreviewSource is null &&
|
||||
string.IsNullOrWhiteSpace(_renderedPreviewStatusLabel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_renderedPreviewPath = string.Empty;
|
||||
_renderedPreviewSource = null;
|
||||
_renderedPreviewStatusLabel = string.Empty;
|
||||
OnPreviewChanged();
|
||||
}
|
||||
|
||||
public void ClearInternalNextPreview()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_internalNextPreviewPath) &&
|
||||
_internalNextPreviewSource is null &&
|
||||
string.IsNullOrWhiteSpace(_internalNextPreviewStatusLabel) &&
|
||||
string.IsNullOrWhiteSpace(_internalNextPreviewDisplayName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_internalNextPreviewPath = string.Empty;
|
||||
_internalNextPreviewSource = null;
|
||||
_internalNextPreviewStatusLabel = string.Empty;
|
||||
_internalNextPreviewDisplayName = string.Empty;
|
||||
OnInternalNextPreviewChanged();
|
||||
}
|
||||
|
||||
public void UpdateThumbnailLayout(ThumbnailDisplayMetrics metrics)
|
||||
{
|
||||
ThumbnailWidth = metrics.Width;
|
||||
@@ -227,6 +353,21 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
OnPropertyChanged(nameof(DurationApplyStatusLabel));
|
||||
}
|
||||
|
||||
private void OnPreviewChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(PreviewSource));
|
||||
OnPropertyChanged(nameof(HasRenderedPreview));
|
||||
OnPropertyChanged(nameof(PreviewStatusLabel));
|
||||
}
|
||||
|
||||
private void OnInternalNextPreviewChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(InternalNextPreviewSource));
|
||||
OnPropertyChanged(nameof(HasInternalNextPreview));
|
||||
OnPropertyChanged(nameof(InternalNextPreviewStatusLabel));
|
||||
OnPropertyChanged(nameof(InternalNextPreviewDisplayName));
|
||||
}
|
||||
|
||||
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
|
||||
{
|
||||
var selectedRegion = regionOption ?? new ScheduleRegionOption
|
||||
|
||||
@@ -8,6 +8,15 @@ public enum CutCategory
|
||||
MetropolitanCouncil,
|
||||
LocalCouncil,
|
||||
NationalAssembly,
|
||||
BottomTopTwo,
|
||||
BottomTopThree,
|
||||
BottomCurrentLeader,
|
||||
BottomWinner,
|
||||
BottomAllCandidates,
|
||||
BottomTurnoutSido,
|
||||
BottomTurnoutDistrict,
|
||||
BottomEarlyTurnout,
|
||||
BottomElectionDayTurnout,
|
||||
PreElection,
|
||||
Historical,
|
||||
Turnout,
|
||||
|
||||
@@ -32,6 +32,8 @@ public sealed class ElectionDataSnapshot
|
||||
|
||||
public required DateTimeOffset ReceivedAt { get; init; }
|
||||
|
||||
public string ReferenceTimeLabel { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<PreElectionHistoricalTurnoutEntry> HistoricalTurnoutHistory { get; init; } =
|
||||
Array.Empty<PreElectionHistoricalTurnoutEntry>();
|
||||
|
||||
@@ -72,4 +74,5 @@ public sealed record TurnoutBoardSlotEntry(
|
||||
string Label,
|
||||
double TurnoutRate,
|
||||
bool IsNational = false,
|
||||
string RegionLabel = "");
|
||||
string RegionLabel = "",
|
||||
bool HasTurnoutData = true);
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace Tornado3_2026Election.Domain;
|
||||
public enum VideoWallLayoutPreset
|
||||
{
|
||||
Auto,
|
||||
Standard5760x1080,
|
||||
UltraWide11520x1080
|
||||
Wall3840x810,
|
||||
Wall2880x1080,
|
||||
UltraWide8316x1080
|
||||
}
|
||||
|
||||
@@ -44,13 +44,14 @@
|
||||
</Border>
|
||||
</NavigationView.PaneHeader>
|
||||
<NavigationView.MenuItems>
|
||||
<NavigationViewItem Content="노멀" Tag="normal" Visibility="{x:Bind ViewModel.NormalMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Play" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="좌상단" Tag="top-left" Visibility="{x:Bind ViewModel.TopLeftMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="PreviewLink" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="하단" Tag="bottom" Visibility="{x:Bind ViewModel.BottomMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Download" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="비디오월" Tag="videowall" Visibility="{x:Bind ViewModel.VideoWallMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Video" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="노멀" Tag="normal" Visibility="{x:Bind ViewModel.NormalMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.NormalChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="Play" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="좌상단" Tag="top-left" Visibility="{x:Bind ViewModel.TopLeftMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.TopLeftChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="PreviewLink" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="하단" Tag="bottom" Visibility="{x:Bind ViewModel.BottomMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.BottomChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="Download" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="비디오월" Tag="videowall" Visibility="{x:Bind ViewModel.VideoWallMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.VideoWallChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="Video" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="사전데이터" Tag="pre-election-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Library" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="투표데이터" Tag="turnout-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="개표데이터" Tag="counting-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="공약데이터" Tag="career-promises"><NavigationViewItem.Icon><SymbolIcon Symbol="Contact" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="투표데이터" Tag="turnout-data"><NavigationViewItem.Icon><PathIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Data="M4,2 H13 V3 H4 Z M4,2 H5 V22 H4 Z M4,21 H18 V22 H4 Z M17,7 H18 V22 H17 Z M13,2 L18,7 H13 Z M6,8 H8 V10 H6 Z M10,8 H15 V9 H10 Z M6,12 H8 V14 H6 Z M10,12 H15 V13 H10 Z M6,16 H8 V18 H6 Z M10,16 H14 V17 H10 Z" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="개표데이터" Tag="counting-data"><NavigationViewItem.Icon><PathIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Data="M10,2 H15 V8 H10 Z M11,4 H14 V5 H11 Z M4,9 H20 V11 H4 Z M6,7 L10,5 L11,6 L7,8 Z M18,7 L14,5 L13,6 L17,8 Z M5,12 H19 L17,21 H7 Z M7,14 L8,19 H16 L17,14 Z" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="컷리스트" Tag="cut-list"><NavigationViewItem.Icon><SymbolIcon Symbol="Bullets" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="설정" Tag="settings"><NavigationViewItem.Icon><SymbolIcon Symbol="Setting" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="로그" Tag="log"><NavigationViewItem.Icon><SymbolIcon Symbol="Document" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
@@ -812,23 +813,66 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer Visibility="{x:Bind ViewModel.CareerPromiseDataVisibility, Mode=OneWay}">
|
||||
<StackPanel Spacing="20">
|
||||
<Border Padding="20"
|
||||
Background="{StaticResource ControlRoomPanelGradientBrush}"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="24"
|
||||
Visibility="{x:Bind ViewModel.Data.CareerPromiseVisibility, Mode=OneWay}">
|
||||
CornerRadius="24">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="공약데이터" />
|
||||
<Grid ColumnSpacing="12" RowSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="220" />
|
||||
<ColumnDefinition Width="260" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ComboBox Header="선거 종류"
|
||||
DisplayMemberPath="Label"
|
||||
ItemsSource="{x:Bind ViewModel.Data.CareerPromiseElectionTypeOptions, Mode=OneWay}"
|
||||
SelectedValue="{x:Bind ViewModel.Data.ElectionType, Mode=TwoWay}"
|
||||
SelectedValuePath="Value" />
|
||||
<ComboBox Grid.Column="1"
|
||||
Header="선거구명"
|
||||
DisplayMemberPath="Label"
|
||||
ItemsSource="{x:Bind ViewModel.Data.DistrictViewOptions, Mode=OneWay}"
|
||||
SelectedValue="{x:Bind ViewModel.Data.SelectedDistrictViewName, Mode=TwoWay}"
|
||||
SelectedValuePath="Value" />
|
||||
<StackPanel Grid.Column="2" VerticalAlignment="Bottom" Spacing="4">
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="{x:Bind ViewModel.Data.CareerPromiseContextText, Mode=OneWay}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind ViewModel.Data.StatusText, Mode=OneWay}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="3"
|
||||
Command="{x:Bind ViewModel.Data.ManualRefreshCommand}"
|
||||
Content="후보 갱신"
|
||||
Style="{StaticResource ConsolePrimaryButtonStyle}"
|
||||
VerticalAlignment="Bottom" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="20"
|
||||
Background="{StaticResource ControlRoomPanelGradientBrush}"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="24">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="경력 컷 공약" />
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="{x:Bind ViewModel.Data.CareerPromiseContextText, Mode=OneWay}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="후보 공약" />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind ViewModel.Data.CareerPromiseStatusText, Mode=OneWay}"
|
||||
TextWrapping="Wrap" />
|
||||
@@ -837,9 +881,9 @@
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Command="{x:Bind ViewModel.Data.SaveCareerPromisesCommand}"
|
||||
Content="공약 저장"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
Command="{x:Bind ViewModel.Data.AddCareerPromiseRowCommand}"
|
||||
Content="행 추가"
|
||||
Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||
</Grid>
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind ViewModel.Data.CareerPromiseSaveStateText, Mode=OneWay}"
|
||||
@@ -852,41 +896,49 @@
|
||||
VerticalScrollBarVisibility="Disabled"
|
||||
VerticalScrollMode="Disabled">
|
||||
<StackPanel Spacing="0">
|
||||
<Grid MinWidth="1250">
|
||||
<Grid MinWidth="1480">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="190" />
|
||||
<ColumnDefinition Width="120" />
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="170" />
|
||||
<ColumnDefinition Width="250" />
|
||||
<ColumnDefinition Width="250" />
|
||||
<ColumnDefinition Width="260" />
|
||||
<ColumnDefinition Width="260" />
|
||||
<ColumnDefinition Width="280" />
|
||||
<ColumnDefinition Width="90" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="후보코드" /></Border>
|
||||
<Border Grid.Column="1" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="이름" /></Border>
|
||||
<Border Grid.Column="2" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="정당" /></Border>
|
||||
<Border Grid.Column="3" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 1" /></Border>
|
||||
<Border Grid.Column="4" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 2" /></Border>
|
||||
<Border Grid.Column="5" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 3" /></Border>
|
||||
<Border Grid.Column="0" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="선거구명" /></Border>
|
||||
<Border Grid.Column="1" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="후보코드" /></Border>
|
||||
<Border Grid.Column="2" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="이름" /></Border>
|
||||
<Border Grid.Column="3" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="정당" /></Border>
|
||||
<Border Grid.Column="4" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 1" /></Border>
|
||||
<Border Grid.Column="5" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 2" /></Border>
|
||||
<Border Grid.Column="6" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="공약 3" /></Border>
|
||||
<Border Grid.Column="7" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="삭제" /></Border>
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{x:Bind ViewModel.Data.CareerPromiseRows, Mode=OneWay}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CareerPromiseEditRowViewModel">
|
||||
<Grid MinWidth="1250">
|
||||
<Grid MinWidth="1480">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="190" />
|
||||
<ColumnDefinition Width="120" />
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="170" />
|
||||
<ColumnDefinition Width="250" />
|
||||
<ColumnDefinition Width="250" />
|
||||
<ColumnDefinition Width="260" />
|
||||
<ColumnDefinition Width="260" />
|
||||
<ColumnDefinition Width="280" />
|
||||
<ColumnDefinition Width="90" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind CandidateCode}" TextWrapping="WrapWholeWords" /></Border>
|
||||
<Border Grid.Column="1" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind CandidateName}" TextWrapping="WrapWholeWords" /></Border>
|
||||
<Border Grid.Column="2" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind Party}" TextWrapping="WrapWholeWords" /></Border>
|
||||
<Border Grid.Column="3" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="첫 번째 공약" Text="{x:Bind Promise1, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="4" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="두 번째 공약" Text="{x:Bind Promise2, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="5" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="세 번째 공약" Text="{x:Bind Promise3, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="0" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="선거구명" Text="{x:Bind DistrictName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="1" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="후보코드" Text="{x:Bind CandidateCode, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="2" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="이름" Text="{x:Bind CandidateName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="3" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="정당" Text="{x:Bind Party, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="4" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="첫 번째 공약" Text="{x:Bind Promise1, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="5" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="두 번째 공약" Text="{x:Bind Promise2, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="6" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBox BorderThickness="0" Background="Transparent" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" PlaceholderText="세 번째 공약" Text="{x:Bind Promise3, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="7" Padding="8" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><Button Command="{x:Bind DeleteCommand}" Content="삭제" Style="{StaticResource ConsoleGhostButtonStyle}" /></Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
@@ -1190,6 +1242,47 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="Karisma 레이어" />
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<NumberBox Header="노멀"
|
||||
Maximum="99"
|
||||
Minimum="0"
|
||||
SmallChange="1"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.Settings.NormalLayerNo, Mode=TwoWay}" />
|
||||
<NumberBox Grid.Column="1"
|
||||
Header="좌상단"
|
||||
Maximum="99"
|
||||
Minimum="0"
|
||||
SmallChange="1"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.Settings.TopLeftLayerNo, Mode=TwoWay}" />
|
||||
<NumberBox Grid.Column="2"
|
||||
Header="하단"
|
||||
Maximum="99"
|
||||
Minimum="0"
|
||||
SmallChange="1"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.Settings.BottomLayerNo, Mode=TwoWay}" />
|
||||
<NumberBox Grid.Column="3"
|
||||
Header="비디오월"
|
||||
Maximum="99"
|
||||
Minimum="0"
|
||||
SmallChange="1"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.Settings.VideoWallLayerNo, Mode=TwoWay}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="권역 설정" />
|
||||
|
||||
@@ -442,7 +442,7 @@ public sealed partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
ViewModel.Data.SelectDistrictOverviewCard(card.DistrictViewName);
|
||||
ViewModel.Data.SelectDistrictOverviewCard(card);
|
||||
}
|
||||
|
||||
private void EnsureNavigationSelection()
|
||||
@@ -462,6 +462,7 @@ public sealed partial class MainWindow : Window
|
||||
AppPage.TurnoutData => "turnout-data",
|
||||
AppPage.CountingData => "counting-data",
|
||||
AppPage.Data => ViewModel.Data.IsPreElectionPhase ? "turnout-data" : "counting-data",
|
||||
AppPage.CareerPromiseData => "career-promises",
|
||||
AppPage.CutList => "cut-list",
|
||||
AppPage.Settings => "settings",
|
||||
AppPage.Log => "log",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<Identity
|
||||
Name="8472d715-ce0c-4ed2-8f7d-7e330428ce82"
|
||||
Publisher="CN=Comtrophy"
|
||||
Version="1.0.3.0" />
|
||||
Version="1.0.3.1" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="8472d715-ce0c-4ed2-8f7d-7e330428ce82" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
|
||||
@@ -29,7 +29,15 @@ public sealed class AppState
|
||||
|
||||
public bool IsWindowMaximized { get; set; }
|
||||
|
||||
public bool IsDebugFeaturesEnabled { get; set; } = true;
|
||||
public bool IsDebugFeaturesEnabled { get; set; }
|
||||
|
||||
public int NormalLayerNo { get; set; }
|
||||
|
||||
public int TopLeftLayerNo { get; set; } = 1;
|
||||
|
||||
public int BottomLayerNo { get; set; } = 2;
|
||||
|
||||
public int VideoWallLayerNo { get; set; }
|
||||
|
||||
public bool IsPollingEnabled { get; set; } = true;
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class CareerPromiseService
|
||||
{
|
||||
_logService = logService;
|
||||
FilePath = ResolveFilePath();
|
||||
TryMigrateLegacyCatalog();
|
||||
_catalog = LoadCatalog(FilePath);
|
||||
}
|
||||
|
||||
@@ -35,14 +36,12 @@ public sealed class CareerPromiseService
|
||||
string districtCode,
|
||||
string districtName)
|
||||
{
|
||||
var normalizedStationId = stationId?.Trim() ?? string.Empty;
|
||||
var normalizedElectionType = electionType?.Trim() ?? string.Empty;
|
||||
var normalizedDistrictCode = districtCode?.Trim() ?? string.Empty;
|
||||
var normalizedDistrictName = districtName?.Trim() ?? string.Empty;
|
||||
|
||||
return _catalog.Entries
|
||||
return SafeEntries(_catalog.Entries)
|
||||
.Where(entry =>
|
||||
string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) &&
|
||||
MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName))
|
||||
.ToArray();
|
||||
@@ -62,9 +61,8 @@ public sealed class CareerPromiseService
|
||||
var normalizedDistrictCode = districtCode?.Trim() ?? string.Empty;
|
||||
var normalizedDistrictName = districtName?.Trim() ?? string.Empty;
|
||||
|
||||
var retainedEntries = _catalog.Entries
|
||||
var retainedEntries = SafeEntries(_catalog.Entries)
|
||||
.Where(entry =>
|
||||
!string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) ||
|
||||
!MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName))
|
||||
.ToList();
|
||||
@@ -103,8 +101,14 @@ public sealed class CareerPromiseService
|
||||
string districtCode,
|
||||
string districtName)
|
||||
{
|
||||
var candidateCode = entry.CandidateCode?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(candidateCode))
|
||||
var candidateName = entry.CandidateName?.Trim() ?? string.Empty;
|
||||
var party = entry.Party?.Trim() ?? string.Empty;
|
||||
var resolvedDistrictName = string.IsNullOrWhiteSpace(entry.DistrictName)
|
||||
? districtName
|
||||
: entry.DistrictName.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidateName) ||
|
||||
string.IsNullOrWhiteSpace(party) ||
|
||||
string.IsNullOrWhiteSpace(resolvedDistrictName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -123,10 +127,10 @@ public sealed class CareerPromiseService
|
||||
StationId = stationId,
|
||||
ElectionType = electionType,
|
||||
DistrictCode = districtCode,
|
||||
DistrictName = districtName,
|
||||
CandidateCode = candidateCode,
|
||||
CandidateName = entry.CandidateName?.Trim() ?? string.Empty,
|
||||
Party = entry.Party?.Trim() ?? string.Empty,
|
||||
DistrictName = resolvedDistrictName,
|
||||
CandidateCode = entry.CandidateCode?.Trim() ?? string.Empty,
|
||||
CandidateName = candidateName,
|
||||
Party = party,
|
||||
Promises = promises
|
||||
};
|
||||
}
|
||||
@@ -136,17 +140,17 @@ public sealed class CareerPromiseService
|
||||
string districtCode,
|
||||
string districtName)
|
||||
{
|
||||
var entryDistrictCode = entry.DistrictCode?.Trim() ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(districtCode) &&
|
||||
string.Equals(entryDistrictCode, districtCode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalizedEntryDistrictName = NormalizeLookupKey(entry.DistrictName);
|
||||
var normalizedDistrictName = NormalizeLookupKey(districtName);
|
||||
return !string.IsNullOrWhiteSpace(normalizedDistrictName) &&
|
||||
string.Equals(normalizedEntryDistrictName, normalizedDistrictName, StringComparison.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedDistrictName) &&
|
||||
!string.IsNullOrWhiteSpace(normalizedEntryDistrictName))
|
||||
{
|
||||
return string.Equals(normalizedEntryDistrictName, normalizedDistrictName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var entryDistrictCode = entry.DistrictCode?.Trim() ?? string.Empty;
|
||||
return !string.IsNullOrWhiteSpace(districtCode) &&
|
||||
string.Equals(entryDistrictCode, districtCode, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizeLookupKey(string? value)
|
||||
@@ -170,8 +174,7 @@ public sealed class CareerPromiseService
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
return JsonSerializer.Deserialize<CareerPromiseCatalog>(json, SerializerOptions)
|
||||
?? new CareerPromiseCatalog();
|
||||
return NormalizeCatalog(JsonSerializer.Deserialize<CareerPromiseCatalog>(json, SerializerOptions));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -189,10 +192,21 @@ public sealed class CareerPromiseService
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(catalog, SerializerOptions);
|
||||
File.WriteAllText(filePath, json);
|
||||
var temporaryFilePath = $"{filePath}.tmp";
|
||||
File.WriteAllText(temporaryFilePath, json);
|
||||
File.Move(temporaryFilePath, filePath, overwrite: true);
|
||||
}
|
||||
|
||||
private static string ResolveFilePath()
|
||||
{
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Tornado3_2026Election",
|
||||
"customer-data",
|
||||
"career-promises.json");
|
||||
}
|
||||
|
||||
private static string ResolveLegacyFilePath()
|
||||
{
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
@@ -200,4 +214,49 @@ public sealed class CareerPromiseService
|
||||
"CustomerData",
|
||||
"career-promises.json");
|
||||
}
|
||||
|
||||
private void TryMigrateLegacyCatalog()
|
||||
{
|
||||
var legacyFilePath = ResolveLegacyFilePath();
|
||||
if (File.Exists(FilePath) || !File.Exists(legacyFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(FilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.Copy(legacyFilePath, FilePath, overwrite: false);
|
||||
_logService.Info($"기존 공약데이터 저장본을 새 위치로 복사했습니다: {FilePath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning($"기존 공약데이터 저장본 복사에 실패했습니다: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static CareerPromiseCatalog NormalizeCatalog(CareerPromiseCatalog? catalog)
|
||||
{
|
||||
if (catalog is null)
|
||||
{
|
||||
return new CareerPromiseCatalog();
|
||||
}
|
||||
|
||||
return new CareerPromiseCatalog
|
||||
{
|
||||
Version = string.IsNullOrWhiteSpace(catalog.Version) ? "1" : catalog.Version,
|
||||
UpdatedAt = catalog.UpdatedAt ?? string.Empty,
|
||||
Entries = SafeEntries(catalog.Entries).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<CareerPromiseEntry> SafeEntries(IEnumerable<CareerPromiseEntry>? entries)
|
||||
{
|
||||
return entries?.Where(entry => entry is not null) ?? Array.Empty<CareerPromiseEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,25 @@ public static class CutCategoryResolver
|
||||
{
|
||||
private static readonly IReadOnlyList<CutCategory> OrderedCategories =
|
||||
[
|
||||
CutCategory.Title,
|
||||
CutCategory.MetropolitanHead,
|
||||
CutCategory.LocalHead,
|
||||
CutCategory.Superintendent,
|
||||
CutCategory.MetropolitanCouncil,
|
||||
CutCategory.LocalCouncil,
|
||||
CutCategory.NationalAssembly,
|
||||
CutCategory.BottomTopTwo,
|
||||
CutCategory.BottomTopThree,
|
||||
CutCategory.BottomCurrentLeader,
|
||||
CutCategory.BottomWinner,
|
||||
CutCategory.BottomAllCandidates,
|
||||
CutCategory.BottomTurnoutSido,
|
||||
CutCategory.BottomTurnoutDistrict,
|
||||
CutCategory.BottomEarlyTurnout,
|
||||
CutCategory.BottomElectionDayTurnout,
|
||||
CutCategory.PreElection,
|
||||
CutCategory.Historical,
|
||||
CutCategory.Turnout,
|
||||
CutCategory.Title
|
||||
CutCategory.Turnout
|
||||
];
|
||||
|
||||
public static IReadOnlyList<CutCategory> GetOrderedCategories() => OrderedCategories;
|
||||
@@ -28,13 +37,23 @@ public static class CutCategoryResolver
|
||||
|
||||
return category switch
|
||||
{
|
||||
CutCategory.MetropolitanHead => Contains(formatName, "광역단체장"),
|
||||
CutCategory.MetropolitanHead => IsMetropolitanHeadFormat(formatName),
|
||||
CutCategory.LocalHead => Contains(formatName, "기초단체장"),
|
||||
CutCategory.Superintendent => Contains(formatName, "교육감"),
|
||||
CutCategory.MetropolitanCouncil => Contains(formatName, "광역의원"),
|
||||
CutCategory.LocalCouncil => Contains(formatName, "기초의원"),
|
||||
CutCategory.NationalAssembly => Contains(formatName, "보궐선거") ||
|
||||
Contains(formatName, "국회의원"),
|
||||
CutCategory.BottomTopTwo => IsBottomCountingTemplate(template, "1-2위_"),
|
||||
CutCategory.BottomTopThree => IsBottomCountingTemplate(template, "1-3위_"),
|
||||
CutCategory.BottomCurrentLeader => IsBottomCountingTemplate(template, "1위_"),
|
||||
CutCategory.BottomWinner => IsBottomCountingTemplate(template, "당선_"),
|
||||
CutCategory.BottomAllCandidates => IsBottomCountingTemplate(template, "전후보_") ||
|
||||
IsBottomCountingTemplate(template, "모든후보_"),
|
||||
CutCategory.BottomTurnoutSido => IsBottomTurnoutSidoTemplate(template),
|
||||
CutCategory.BottomTurnoutDistrict => IsBottomTurnoutDistrictTemplate(template),
|
||||
CutCategory.BottomEarlyTurnout => IsBottomEarlyTurnoutTemplate(template),
|
||||
CutCategory.BottomElectionDayTurnout => IsBottomElectionDayTurnoutTemplate(template),
|
||||
CutCategory.PreElection => Contains(formatName, "사전"),
|
||||
CutCategory.Historical => Contains(formatName, "역대"),
|
||||
CutCategory.Turnout => Contains(formatName, "투표율"),
|
||||
@@ -47,11 +66,21 @@ public static class CutCategoryResolver
|
||||
{
|
||||
return category switch
|
||||
{
|
||||
CutCategory.MetropolitanHead => "광역단체장",
|
||||
CutCategory.LocalHead => "기초단체장",
|
||||
CutCategory.Superintendent => "교육감",
|
||||
CutCategory.MetropolitanCouncil => "광역의원",
|
||||
CutCategory.LocalCouncil => "기초의원",
|
||||
CutCategory.NationalAssembly => "국회의원",
|
||||
CutCategory.BottomTopTwo => "1-2위",
|
||||
CutCategory.BottomTopThree => "1-3위",
|
||||
CutCategory.BottomCurrentLeader => "1위",
|
||||
CutCategory.BottomWinner => "당선",
|
||||
CutCategory.BottomAllCandidates => "전후보",
|
||||
CutCategory.BottomTurnoutSido => "시도",
|
||||
CutCategory.BottomTurnoutDistrict => "시군구",
|
||||
CutCategory.BottomEarlyTurnout => "사전투표율",
|
||||
CutCategory.BottomElectionDayTurnout => "투표율",
|
||||
CutCategory.PreElection => "사전",
|
||||
CutCategory.Historical => "역대",
|
||||
CutCategory.Turnout => "투표율",
|
||||
@@ -64,4 +93,45 @@ public static class CutCategoryResolver
|
||||
{
|
||||
return value.Contains(token, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsMetropolitanHeadFormat(string formatName)
|
||||
{
|
||||
return Contains(formatName, "광역단체장") ||
|
||||
string.Equals(formatName, "사전_역대당선자", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsBottomCountingTemplate(FormatTemplateDefinition template, string prefix)
|
||||
{
|
||||
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||
template.SupportsCounting &&
|
||||
template.Name.StartsWith(prefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsBottomTurnoutSidoTemplate(FormatTemplateDefinition template)
|
||||
{
|
||||
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||
(string.Equals(template.Name, "사전투표율_시도", StringComparison.Ordinal) ||
|
||||
string.Equals(template.Name, "투표율_시도", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool IsBottomTurnoutDistrictTemplate(FormatTemplateDefinition template)
|
||||
{
|
||||
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||
(string.Equals(template.Name, "사전투표율_시군구", StringComparison.Ordinal) ||
|
||||
string.Equals(template.Name, "투표율_시군구", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool IsBottomEarlyTurnoutTemplate(FormatTemplateDefinition template)
|
||||
{
|
||||
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||
(string.Equals(template.Name, "사전투표율_시도", StringComparison.Ordinal) ||
|
||||
string.Equals(template.Name, "사전투표율_시군구", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool IsBottomElectionDayTurnoutTemplate(FormatTemplateDefinition template)
|
||||
{
|
||||
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||
(string.Equals(template.Name, "투표율_시도", StringComparison.Ordinal) ||
|
||||
string.Equals(template.Name, "투표율_시군구", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@ public sealed class CutDebugStateStore
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly Dictionary<BroadcastChannel, CutDebugSettings> _settingsByChannel = new();
|
||||
private readonly Dictionary<string, CutDebugTemplateState> _templateStates = new(StringComparer.Ordinal);
|
||||
private bool _isDebugFeatureEnabled = true;
|
||||
private bool _isDebugFeatureEnabled;
|
||||
|
||||
public CutDebugStateStore()
|
||||
{
|
||||
foreach (var channel in Enum.GetValues<BroadcastChannel>())
|
||||
{
|
||||
_settingsByChannel[channel] = new CutDebugSettings();
|
||||
_settingsByChannel[channel] = new CutDebugSettings
|
||||
{
|
||||
IsFeatureEnabled = _isDebugFeatureEnabled
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
Tornado3_2026Election/Services/CutPreviewAssetCatalog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
@@ -69,6 +70,19 @@ public static class CutThumbnailAssetCatalog
|
||||
}
|
||||
|
||||
public static bool HasThumbnail(string templateId)
|
||||
{
|
||||
foreach (var candidateTemplateId in EnumerateThumbnailTemplateIds(templateId))
|
||||
{
|
||||
if (HasThumbnailPath(candidateTemplateId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasThumbnailPath(string templateId)
|
||||
{
|
||||
var projectPath = TryGetProjectAssetPath(templateId);
|
||||
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
|
||||
@@ -99,21 +113,88 @@ public static class CutThumbnailAssetCatalog
|
||||
|
||||
public static string ResolvePreferredDisplayPath(string templateId)
|
||||
{
|
||||
var projectPath = TryGetProjectAssetPath(templateId);
|
||||
foreach (var candidateTemplateId in EnumerateThumbnailTemplateIds(templateId))
|
||||
{
|
||||
var projectPath = TryGetProjectAssetPath(candidateTemplateId);
|
||||
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
|
||||
{
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
var bundledPath = GetBundledAssetPath(templateId);
|
||||
var bundledPath = GetBundledAssetPath(candidateTemplateId);
|
||||
if (File.Exists(bundledPath))
|
||||
{
|
||||
return bundledPath;
|
||||
}
|
||||
}
|
||||
|
||||
return Path.Combine(AppContext.BaseDirectory, FallbackAssetPath);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateThumbnailTemplateIds(string templateId)
|
||||
{
|
||||
var preferredTemplateId = ResolvePreferredThumbnailTemplateId(templateId);
|
||||
if (!string.Equals(preferredTemplateId, templateId, StringComparison.Ordinal))
|
||||
{
|
||||
yield return preferredTemplateId;
|
||||
yield return templateId;
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return templateId;
|
||||
|
||||
var fallbackTemplateId = ResolveThumbnailTemplateId(templateId);
|
||||
if (!string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
|
||||
{
|
||||
yield return fallbackTemplateId;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePreferredThumbnailTemplateId(string templateId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(templateId))
|
||||
{
|
||||
return templateId;
|
||||
}
|
||||
|
||||
var normalizedId = templateId.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||
var folder = Path.GetDirectoryName(normalizedId);
|
||||
var fileName = Path.GetFileName(normalizedId);
|
||||
|
||||
if (folder is not null &&
|
||||
string.Equals(folder, "Elect2026_Normal_민방", StringComparison.Ordinal) &&
|
||||
string.Equals(fileName, "투표율", StringComparison.Ordinal))
|
||||
{
|
||||
return Path.Combine(folder, "투표율_사진");
|
||||
}
|
||||
|
||||
return templateId;
|
||||
}
|
||||
|
||||
private static string ResolveThumbnailTemplateId(string templateId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(templateId))
|
||||
{
|
||||
return templateId;
|
||||
}
|
||||
|
||||
var normalizedId = templateId.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||
var folder = Path.GetDirectoryName(normalizedId);
|
||||
var fileName = Path.GetFileName(normalizedId);
|
||||
var canonicalName = fileName switch
|
||||
{
|
||||
"사전투표율_시도" or "사전투표율_시군구" => "사전투표율",
|
||||
"투표율_시도" or "투표율_시군구" => "투표율",
|
||||
_ => fileName
|
||||
};
|
||||
|
||||
return string.Equals(fileName, canonicalName, StringComparison.Ordinal)
|
||||
? templateId
|
||||
: string.IsNullOrWhiteSpace(folder)
|
||||
? canonicalName
|
||||
: Path.Combine(folder, canonicalName);
|
||||
}
|
||||
|
||||
private static string? TryGetProjectRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
@@ -59,11 +59,13 @@ public sealed class FormatCatalogService
|
||||
"당선_광역의원",
|
||||
"당선_기초단체장",
|
||||
"당선_기초의원",
|
||||
"사전투표율",
|
||||
"사전투표율_시도",
|
||||
"사전투표율_시군구",
|
||||
"전후보_광역단체장",
|
||||
"전후보_교육감",
|
||||
"전후보_기초단체장",
|
||||
"투표율"));
|
||||
"투표율_시도",
|
||||
"투표율_시군구"));
|
||||
|
||||
formats.AddRange(CreateFormats(
|
||||
BroadcastChannel.Normal,
|
||||
@@ -84,6 +86,12 @@ public sealed class FormatCatalogService
|
||||
"1-3위_ani_기초단체장",
|
||||
"1-3위_기초단체장_5760",
|
||||
"1-3위_보궐선거",
|
||||
"2880_광역의원표",
|
||||
"2880_기초의원표",
|
||||
"810_광역의원표",
|
||||
"810_기초의원표",
|
||||
"8316_광역의원표",
|
||||
"8316_기초의원표",
|
||||
"경력_광역단체장_in",
|
||||
"경력_기초단체장_in",
|
||||
"광역의원표",
|
||||
@@ -127,10 +135,8 @@ public sealed class FormatCatalogService
|
||||
"접전_기초단체장",
|
||||
"초접전_광역단체장",
|
||||
"초접전_기초단체장",
|
||||
"투표율_사진",
|
||||
"투표율",
|
||||
"투표율_선거구별 사전",
|
||||
"투표율_시도별",
|
||||
"투표율_영상",
|
||||
"판세_광역단체장",
|
||||
"판세_기초단체장",
|
||||
"판세_기초단체장_5760"));
|
||||
@@ -166,12 +172,15 @@ public sealed class FormatCatalogService
|
||||
{
|
||||
var isAvailableInBothPhases = IsAvailableInBothPhases(baseName);
|
||||
var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(baseName);
|
||||
var sceneResolution = TryReadSceneResolution(relativeFolder, baseName, t3CutPath);
|
||||
var formatId = Path.Combine(relativeFolder, baseName);
|
||||
var sceneIdOverride = ResolveSceneIdOverride(relativeFolder, baseName);
|
||||
var sceneId = sceneIdOverride ?? formatId;
|
||||
var sceneResolution = TryReadSceneResolution(sceneId, t3CutPath);
|
||||
var recommendedChannel = ResolveRecommendedChannel(channel, baseName, sceneResolution);
|
||||
|
||||
yield return new FormatTemplateDefinition
|
||||
{
|
||||
Id = Path.Combine(relativeFolder, baseName),
|
||||
Id = formatId,
|
||||
Name = baseName,
|
||||
Description = $"{relativeFolder} 컷",
|
||||
RecommendedChannel = recommendedChannel,
|
||||
@@ -193,13 +202,30 @@ public sealed class FormatCatalogService
|
||||
DurationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
|
||||
defaultCutDurationSeconds,
|
||||
recommendedChannel,
|
||||
baseName)
|
||||
baseName),
|
||||
SceneIdOverride = sceneIdOverride
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveSceneIdOverride(string relativeFolder, string baseName)
|
||||
{
|
||||
if (string.Equals(relativeFolder, "Elect2026_Normal_민방", StringComparison.Ordinal) &&
|
||||
string.Equals(baseName, "투표율", StringComparison.Ordinal))
|
||||
{
|
||||
return Path.Combine(relativeFolder, "투표율_사진");
|
||||
}
|
||||
|
||||
return baseName switch
|
||||
{
|
||||
"사전투표율_시도" or "사전투표율_시군구" => Path.Combine(relativeFolder, "사전투표율"),
|
||||
"투표율_시도" or "투표율_시군구" => Path.Combine(relativeFolder, "투표율"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsPreElectionOnlyFormat(string baseName)
|
||||
{
|
||||
return baseName.Contains("투표율", StringComparison.Ordinal);
|
||||
@@ -219,11 +245,13 @@ public sealed class FormatCatalogService
|
||||
[Path.Combine("Elect2026_Bottom_민방", "당선_광역의원_loop")] = Path.Combine("Elect2026_Bottom_민방", "당선_광역의원"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "당선_기초단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "당선_기초단체장"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "당선_기초의원_loop")] = Path.Combine("Elect2026_Bottom_민방", "당선_기초의원"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "사전투표율_loop")] = Path.Combine("Elect2026_Bottom_민방", "사전투표율"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "사전투표율")] = Path.Combine("Elect2026_Bottom_민방", "사전투표율_시도"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "사전투표율_loop")] = Path.Combine("Elect2026_Bottom_민방", "사전투표율_시도"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "전후보_광역단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "전후보_광역단체장"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "전후보_교육감_loop")] = Path.Combine("Elect2026_Bottom_민방", "전후보_교육감"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "전후보_기초단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "전후보_기초단체장"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "투표율_loop")] = Path.Combine("Elect2026_Bottom_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "투표율")] = Path.Combine("Elect2026_Bottom_민방", "투표율_시도"),
|
||||
[Path.Combine("Elect2026_Bottom_민방", "투표율_loop")] = Path.Combine("Elect2026_Bottom_민방", "투표율_시도"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "1-2위_ani_광역단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "1-2위_ani_광역단체장"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "1-2위_ani_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-2위_ani_기초단체장_5760"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "1-2위_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-2위_광역단체장_5760"),
|
||||
@@ -270,9 +298,12 @@ public sealed class FormatCatalogService
|
||||
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L_1")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_사진")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_영상")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_7680")] = Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_5760"),
|
||||
[Path.Combine("Elect2026_Top_민방", "투표율_loop")] = Path.Combine("Elect2026_Top_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Top_민방", "투표율_선거구별_loop")] = Path.Combine("Elect2026_Top_민방", "투표율_선거구별")
|
||||
@@ -281,7 +312,9 @@ public sealed class FormatCatalogService
|
||||
|
||||
private static bool IsAvailableInBothPhases(string baseName)
|
||||
{
|
||||
return baseName.StartsWith("사전_역대당선", StringComparison.Ordinal);
|
||||
return ScheduleTemplatePolicy.IsTitleFormat(baseName) ||
|
||||
baseName.StartsWith("사전_역대당선", StringComparison.Ordinal) ||
|
||||
baseName.StartsWith("경력_", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsHistoricalPreElectionWinnerFormat(string baseName)
|
||||
@@ -311,18 +344,21 @@ public sealed class FormatCatalogService
|
||||
|
||||
private static bool IsVideoWallFormat(string baseName)
|
||||
{
|
||||
return baseName.Contains("_5760", StringComparison.Ordinal) ||
|
||||
return baseName.Contains("_3840", StringComparison.Ordinal) ||
|
||||
baseName.Contains("_2880", StringComparison.Ordinal) ||
|
||||
baseName.Contains("_8316", StringComparison.Ordinal) ||
|
||||
baseName.Contains("_5760", StringComparison.Ordinal) ||
|
||||
baseName.Contains("_L", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static KarismaSceneResolution? TryReadSceneResolution(string relativeFolder, string baseName, string t3CutPath)
|
||||
private static KarismaSceneResolution? TryReadSceneResolution(string sceneId, string t3CutPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(t3CutPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scenePath = Path.Combine(t3CutPath, relativeFolder, baseName + ".tscn");
|
||||
var scenePath = Path.Combine(t3CutPath, sceneId + ".tscn");
|
||||
return KarismaSceneResolutionReader.TryRead(scenePath, out var resolution)
|
||||
? resolution
|
||||
: null;
|
||||
@@ -339,9 +375,7 @@ public sealed class FormatCatalogService
|
||||
return false;
|
||||
}
|
||||
|
||||
channel = sceneResolution.Value is { Width: 1920, Height: 1080 }
|
||||
? BroadcastChannel.Normal
|
||||
: sceneResolution.Value.Width > 1920 && sceneResolution.Value.Height == 1080
|
||||
channel = sceneResolution.Value.Width > 1920
|
||||
? BroadcastChannel.VideoWall
|
||||
: BroadcastChannel.Normal;
|
||||
|
||||
|
||||
@@ -32,9 +32,34 @@ public interface ITornado3Adapter
|
||||
string imageRootPath,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> TryCapturePendingCutPreviewAsync(
|
||||
BroadcastChannel channel,
|
||||
string fileName,
|
||||
int width,
|
||||
int height,
|
||||
int frame,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> TryCaptureCutPreviewAsync(
|
||||
BroadcastChannel channel,
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut,
|
||||
ElectionDataSnapshot snapshot,
|
||||
BroadcastStationProfile station,
|
||||
string imageRootPath,
|
||||
string fileName,
|
||||
int width,
|
||||
int height,
|
||||
int frame,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken);
|
||||
|
||||
Task ShowPreparedFirstFrameAsync(BroadcastChannel channel, CancellationToken cancellationToken);
|
||||
|
||||
Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken);
|
||||
|
||||
Task ClearOutputAsync(BroadcastChannel channel, CancellationToken cancellationToken);
|
||||
|
||||
Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
namespace Tornado3_2026Election.Services;
|
||||
|
||||
public readonly record struct KarismaCounterNumberKeyUpdate(string ObjectName, int KeyIndex, double Number);
|
||||
public readonly record struct KarismaCounterNumberKeyUpdate(
|
||||
string ObjectName,
|
||||
int KeyIndex,
|
||||
double Number,
|
||||
bool AllowKeyZero = false,
|
||||
bool AllowSetValue = false);
|
||||
|
||||
12
Tornado3_2026Election/Services/KarismaCropKeyUpdate.cs
Normal 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);
|
||||
@@ -13,10 +13,18 @@ public class KarismaEventHandler : KAEventHandler
|
||||
private readonly Action<int>? _onClose;
|
||||
private readonly object _connectSync = new();
|
||||
private readonly object _loadSceneSync = new();
|
||||
private readonly object _endTransactionSync = new();
|
||||
private readonly object _updateTexturesSync = new();
|
||||
private readonly object _scenePrepareSync = new();
|
||||
private readonly object _saveSceneImageSync = new();
|
||||
private readonly object _saveMixedPreviewImageSync = new();
|
||||
private TaskCompletionSource<int>? _pendingConnect;
|
||||
private readonly Dictionary<string, TaskCompletionSource<eKResult>> _pendingLoadScenes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private TaskCompletionSource<eKResult>? _pendingEndTransaction;
|
||||
private readonly Dictionary<string, TaskCompletionSource<eKResult>> _pendingUpdateTextures = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<(int OutputChannelIndex, int LayerNo), TaskCompletionSource<eKResult>> _pendingScenePrepares = new();
|
||||
private TaskCompletionSource<(eKResult Result, string SceneName)>? _pendingSaveSceneImage;
|
||||
private TaskCompletionSource<(eKResult Result, int OutputChannelIndex, int LayerNo)>? _pendingSaveMixedPreviewImage;
|
||||
|
||||
public KarismaEventHandler(LogService logService, Action<int>? onConnect = null, Action<int>? onClose = null)
|
||||
{
|
||||
@@ -149,6 +157,106 @@ public class KarismaEventHandler : KAEventHandler
|
||||
}
|
||||
}
|
||||
|
||||
public Task<eKResult> BeginEndTransactionWait()
|
||||
{
|
||||
lock (_endTransactionSync)
|
||||
{
|
||||
if (_pendingEndTransaction is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Another EndTransaction request is already pending.");
|
||||
}
|
||||
|
||||
_pendingEndTransaction = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
return _pendingEndTransaction.Task;
|
||||
}
|
||||
}
|
||||
|
||||
public void CancelPendingEndTransaction(Exception? error = null)
|
||||
{
|
||||
TaskCompletionSource<eKResult>? completion;
|
||||
lock (_endTransactionSync)
|
||||
{
|
||||
completion = _pendingEndTransaction;
|
||||
_pendingEndTransaction = null;
|
||||
}
|
||||
|
||||
CompleteOrCancel(completion, error);
|
||||
}
|
||||
|
||||
public Task<eKResult> BeginUpdateTexturesWait(string sceneName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sceneName))
|
||||
{
|
||||
throw new ArgumentException("Scene name is required.", nameof(sceneName));
|
||||
}
|
||||
|
||||
lock (_updateTexturesSync)
|
||||
{
|
||||
if (_pendingUpdateTextures.ContainsKey(sceneName))
|
||||
{
|
||||
throw new InvalidOperationException($"Another UpdateTextures request is already pending for '{sceneName}'.");
|
||||
}
|
||||
|
||||
var completion = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pendingUpdateTextures[sceneName] = completion;
|
||||
return completion.Task;
|
||||
}
|
||||
}
|
||||
|
||||
public void CancelPendingUpdateTextures(string sceneName, Exception? error = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sceneName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TaskCompletionSource<eKResult>? completion;
|
||||
lock (_updateTexturesSync)
|
||||
{
|
||||
if (!_pendingUpdateTextures.TryGetValue(sceneName, out completion))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingUpdateTextures.Remove(sceneName);
|
||||
}
|
||||
|
||||
CompleteOrCancel(completion, error);
|
||||
}
|
||||
|
||||
public Task<eKResult> BeginScenePrepareWait(int outputChannelIndex, int layerNo)
|
||||
{
|
||||
lock (_scenePrepareSync)
|
||||
{
|
||||
var key = (outputChannelIndex, layerNo);
|
||||
if (_pendingScenePrepares.ContainsKey(key))
|
||||
{
|
||||
throw new InvalidOperationException($"Another scene Prepare request is already pending for output={outputChannelIndex} layer={layerNo}.");
|
||||
}
|
||||
|
||||
var completion = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pendingScenePrepares[key] = completion;
|
||||
return completion.Task;
|
||||
}
|
||||
}
|
||||
|
||||
public void CancelPendingScenePrepare(int outputChannelIndex, int layerNo, Exception? error = null)
|
||||
{
|
||||
TaskCompletionSource<eKResult>? completion;
|
||||
lock (_scenePrepareSync)
|
||||
{
|
||||
var key = (outputChannelIndex, layerNo);
|
||||
if (!_pendingScenePrepares.TryGetValue(key, out completion))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingScenePrepares.Remove(key);
|
||||
}
|
||||
|
||||
CompleteOrCancel(completion, error);
|
||||
}
|
||||
|
||||
public Task<(eKResult Result, string SceneName)> BeginSaveSceneImageWait()
|
||||
{
|
||||
lock (_saveSceneImageSync)
|
||||
@@ -186,6 +294,45 @@ public class KarismaEventHandler : KAEventHandler
|
||||
|
||||
completion.TrySetException(error);
|
||||
}
|
||||
|
||||
public Task<(eKResult Result, int OutputChannelIndex, int LayerNo)> BeginSaveMixedPreviewImageWait()
|
||||
{
|
||||
lock (_saveMixedPreviewImageSync)
|
||||
{
|
||||
if (_pendingSaveMixedPreviewImage is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Another SaveMixedPreviewImage request is already pending.");
|
||||
}
|
||||
|
||||
_pendingSaveMixedPreviewImage = new TaskCompletionSource<(eKResult Result, int OutputChannelIndex, int LayerNo)>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
return _pendingSaveMixedPreviewImage.Task;
|
||||
}
|
||||
}
|
||||
|
||||
public void CancelPendingSaveMixedPreviewImage(Exception? error = null)
|
||||
{
|
||||
TaskCompletionSource<(eKResult Result, int OutputChannelIndex, int LayerNo)>? completion = null;
|
||||
|
||||
lock (_saveMixedPreviewImageSync)
|
||||
{
|
||||
completion = _pendingSaveMixedPreviewImage;
|
||||
_pendingSaveMixedPreviewImage = null;
|
||||
}
|
||||
|
||||
if (completion is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (error is null)
|
||||
{
|
||||
completion.TrySetCanceled();
|
||||
return;
|
||||
}
|
||||
|
||||
completion.TrySetException(error);
|
||||
}
|
||||
|
||||
public void OnLoadScene(eKResult Result, string SceneName)
|
||||
{
|
||||
LogResult(nameof(OnLoadScene), Result, $"scene={SceneName}");
|
||||
@@ -202,14 +349,34 @@ public class KarismaEventHandler : KAEventHandler
|
||||
public void OnConnect(int ErrorCode) { if (ErrorCode == 0) { _logService.Info("CG callback OnConnect: success (errorCode=0)"); } else { _logService.Error($"CG callback OnConnect: failed (errorCode={ErrorCode})"); } CompletePendingConnect(ErrorCode); _onConnect?.Invoke(ErrorCode); }
|
||||
public void OnClose(int ErrorCode) { if (ErrorCode == 0) { _logService.Info("CG callback OnClose: closed cleanly (errorCode=0)"); } else { _logService.Warning($"CG callback OnClose: closed with errorCode={ErrorCode}"); } _onClose?.Invoke(ErrorCode); }
|
||||
public void OnBeginTransaction(eKResult Result) => LogResult(nameof(OnBeginTransaction), Result);
|
||||
public void OnEndTransaction(eKResult Result) => LogResult(nameof(OnEndTransaction), Result);
|
||||
public void OnEndTransaction(eKResult Result)
|
||||
{
|
||||
LogResult(nameof(OnEndTransaction), Result);
|
||||
|
||||
TaskCompletionSource<eKResult>? completion;
|
||||
lock (_endTransactionSync)
|
||||
{
|
||||
completion = _pendingEndTransaction;
|
||||
_pendingEndTransaction = null;
|
||||
}
|
||||
|
||||
completion?.TrySetResult(Result);
|
||||
}
|
||||
public void OnHeartBeat(eKResult Result) => LogResult(nameof(OnHeartBeat), Result);
|
||||
virtual public void OnUnloadAll(eKResult Result) { }
|
||||
virtual public void OnSetTrialPlayoutMode(eKResult Result) { }
|
||||
virtual public void OnCheckVersion(eKResult Result, string ServerVersion, string SDKVersion) { }
|
||||
virtual public void OnSetAudioOutput(eKResult Result) { }
|
||||
public void OnScenePrepare(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnScenePrepare), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnScenePrepareEx(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnScenePrepareEx), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnScenePrepare(eKResult Result, int OutputChannelIndex, int LayerNo)
|
||||
{
|
||||
LogResult(nameof(OnScenePrepare), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
CompletePendingScenePrepare(OutputChannelIndex, LayerNo, Result);
|
||||
}
|
||||
public void OnScenePrepareEx(eKResult Result, int OutputChannelIndex, int LayerNo)
|
||||
{
|
||||
LogResult(nameof(OnScenePrepareEx), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
CompletePendingScenePrepare(OutputChannelIndex, LayerNo, Result);
|
||||
}
|
||||
public void OnPlay(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlay), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnPlayOut(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayOut), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnStop(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnStop), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
@@ -225,7 +392,19 @@ public class KarismaEventHandler : KAEventHandler
|
||||
virtual public void OnSceneSaved(eKResult Result, string FileName) { }
|
||||
public void OnTriggerObject(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnTriggerObject), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
virtual public void OnResumeBackground(eKResult Result, int OutputChannelIndex, int LayerNo) { }
|
||||
virtual public void OnSaveMixedPreviewImage(eKResult Result, int OutputChannelIndex, int LayerNo) { }
|
||||
public void OnSaveMixedPreviewImage(eKResult Result, int OutputChannelIndex, int LayerNo)
|
||||
{
|
||||
LogResult(nameof(OnSaveMixedPreviewImage), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
|
||||
TaskCompletionSource<(eKResult Result, int OutputChannelIndex, int LayerNo)>? completion = null;
|
||||
lock (_saveMixedPreviewImageSync)
|
||||
{
|
||||
completion = _pendingSaveMixedPreviewImage;
|
||||
_pendingSaveMixedPreviewImage = null;
|
||||
}
|
||||
|
||||
completion?.TrySetResult((Result, OutputChannelIndex, LayerNo));
|
||||
}
|
||||
public void OnPlayDirect(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayDirect), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnCutIn(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnCutIn), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnCutOut(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnCutOut), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
@@ -260,7 +439,23 @@ public class KarismaEventHandler : KAEventHandler
|
||||
virtual public void OnSaveScene(eKResult Result, string SceneName) { }
|
||||
virtual public void OnUnloadScene(eKResult Result, string SceneName) { }
|
||||
virtual public void OnReloadScene(eKResult Result, string SceneName) { }
|
||||
virtual public void OnUpdateTextures(eKResult Result, string SceneName) { }
|
||||
public void OnUpdateTextures(eKResult Result, string SceneName)
|
||||
{
|
||||
LogResult(nameof(OnUpdateTextures), Result, $"scene={SceneName}");
|
||||
|
||||
TaskCompletionSource<eKResult>? completion;
|
||||
lock (_updateTexturesSync)
|
||||
{
|
||||
if (!_pendingUpdateTextures.TryGetValue(SceneName, out completion))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingUpdateTextures.Remove(SceneName);
|
||||
}
|
||||
|
||||
completion.TrySetResult(Result);
|
||||
}
|
||||
virtual public void OnSetSceneAudioFile(eKResult Result, string SceneName) { }
|
||||
virtual public void OnEnableSceneAudio(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetSceneDuration(eKResult Result, string SceneName) { }
|
||||
@@ -316,7 +511,7 @@ public class KarismaEventHandler : KAEventHandler
|
||||
virtual public void OnSetCylinderAngleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetSphereAngleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCircleAngleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCropKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetCropKey(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetCropKey), Result, $"scene={SceneName} object={ObjectName}");
|
||||
virtual public void OnSetCountDown(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetPosition(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetRotation(eKResult Result, string SceneName, string ObjectName) { }
|
||||
@@ -336,7 +531,7 @@ public class KarismaEventHandler : KAEventHandler
|
||||
virtual public void OnModifyPathPoint(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnInitScrollObject(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCounterInfo(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCounterNumber(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetCounterNumber(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetCounterNumber), Result, $"scene={SceneName} object={ObjectName}");
|
||||
virtual public void OnSetCounterRange(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCounterRemainingTime(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCounterElapsedTime(eKResult Result, string SceneName, string ObjectName) { }
|
||||
@@ -451,6 +646,23 @@ public class KarismaEventHandler : KAEventHandler
|
||||
completion.TrySetResult(result);
|
||||
}
|
||||
|
||||
private void CompletePendingScenePrepare(int outputChannelIndex, int layerNo, eKResult result)
|
||||
{
|
||||
TaskCompletionSource<eKResult>? completion;
|
||||
lock (_scenePrepareSync)
|
||||
{
|
||||
var key = (outputChannelIndex, layerNo);
|
||||
if (!_pendingScenePrepares.TryGetValue(key, out completion))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingScenePrepares.Remove(key);
|
||||
}
|
||||
|
||||
completion.TrySetResult(result);
|
||||
}
|
||||
|
||||
private void CompletePendingConnect(int errorCode)
|
||||
{
|
||||
TaskCompletionSource<int>? completion;
|
||||
@@ -462,4 +674,79 @@ public class KarismaEventHandler : KAEventHandler
|
||||
|
||||
completion?.TrySetResult(errorCode);
|
||||
}
|
||||
|
||||
private static void CancelPendingSceneOperation(
|
||||
object syncRoot,
|
||||
Dictionary<string, TaskCompletionSource<eKResult>> pendingOperations,
|
||||
string sceneName,
|
||||
Exception? error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sceneName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TaskCompletionSource<eKResult>? completion;
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (!pendingOperations.TryGetValue(sceneName, out completion))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingOperations.Remove(sceneName);
|
||||
}
|
||||
|
||||
CompleteOrCancel(completion, error);
|
||||
}
|
||||
|
||||
private static void CompletePendingSceneOperation(
|
||||
object syncRoot,
|
||||
Dictionary<string, TaskCompletionSource<eKResult>> pendingOperations,
|
||||
string sceneName,
|
||||
eKResult result)
|
||||
{
|
||||
TaskCompletionSource<eKResult>? completion = null;
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(sceneName) &&
|
||||
pendingOperations.TryGetValue(sceneName, out completion))
|
||||
{
|
||||
pendingOperations.Remove(sceneName);
|
||||
}
|
||||
else if (pendingOperations.Count == 1)
|
||||
{
|
||||
string? keyToRemove = null;
|
||||
foreach (var pair in pendingOperations)
|
||||
{
|
||||
keyToRemove = pair.Key;
|
||||
completion = pair.Value;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyToRemove))
|
||||
{
|
||||
pendingOperations.Remove(keyToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completion?.TrySetResult(result);
|
||||
}
|
||||
|
||||
private static void CompleteOrCancel<TResult>(TaskCompletionSource<TResult>? completion, Exception? error)
|
||||
{
|
||||
if (completion is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (error is null)
|
||||
{
|
||||
completion.TrySetCanceled();
|
||||
return;
|
||||
}
|
||||
|
||||
completion.TrySetException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ public readonly record struct KarismaPositionUpdate(
|
||||
float X,
|
||||
float Y,
|
||||
float Z,
|
||||
eKVectorType VectorType);
|
||||
eKVectorType VectorType,
|
||||
int KeyIndex = -1);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Tornado3_2026Election.Domain;
|
||||
|
||||
namespace Tornado3_2026Election.Services;
|
||||
@@ -12,7 +13,7 @@ internal static class KarismaSceneResolver
|
||||
bool useLoop,
|
||||
bool useEnd = false)
|
||||
{
|
||||
return ResolveScene(template, null, t3CutPath, useLoop, useEnd);
|
||||
return ResolveScene(template, template.Cuts.FirstOrDefault(), t3CutPath, useLoop, useEnd);
|
||||
}
|
||||
|
||||
public static KarismaResolvedScene ResolveScene(
|
||||
@@ -25,17 +26,16 @@ internal static class KarismaSceneResolver
|
||||
var sceneId = string.IsNullOrWhiteSpace(cut?.SceneIdOverride)
|
||||
? template.Id
|
||||
: cut.SceneIdOverride!;
|
||||
var hasSceneOverride = !string.IsNullOrWhiteSpace(cut?.SceneIdOverride);
|
||||
var baseScenePath = Path.Combine(t3CutPath, sceneId + ".tscn");
|
||||
var loopScenePath = Path.Combine(t3CutPath, template.Id + "_loop.tscn");
|
||||
var endScenePath = Path.Combine(t3CutPath, template.Id + "_END.tscn");
|
||||
var loopScenePath = Path.Combine(t3CutPath, sceneId + "_loop.tscn");
|
||||
var endScenePath = Path.Combine(t3CutPath, sceneId + "_END.tscn");
|
||||
|
||||
string selectedPath;
|
||||
if (useEnd && File.Exists(endScenePath))
|
||||
{
|
||||
selectedPath = endScenePath;
|
||||
}
|
||||
else if (!hasSceneOverride && useLoop && File.Exists(loopScenePath))
|
||||
else if (useLoop && File.Exists(loopScenePath))
|
||||
{
|
||||
selectedPath = loopScenePath;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ internal static class KarismaSceneResolver
|
||||
{
|
||||
selectedPath = baseScenePath;
|
||||
}
|
||||
else if (!hasSceneOverride && File.Exists(loopScenePath))
|
||||
else if (File.Exists(loopScenePath))
|
||||
{
|
||||
selectedPath = loopScenePath;
|
||||
}
|
||||
@@ -59,7 +59,10 @@ internal static class KarismaSceneResolver
|
||||
|
||||
public static bool HasEndScene(FormatTemplateDefinition template, string t3CutPath)
|
||||
{
|
||||
return File.Exists(Path.Combine(t3CutPath, template.Id + "_END.tscn"));
|
||||
return template.Cuts
|
||||
.Select(cut => string.IsNullOrWhiteSpace(cut.SceneIdOverride) ? template.Id : cut.SceneIdOverride!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Any(sceneId => File.Exists(Path.Combine(t3CutPath, sceneId + "_END.tscn")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -223,11 +223,6 @@ public sealed class KarismaSceneVariableCatalog
|
||||
return KarismaSceneVariableKind.Counter;
|
||||
}
|
||||
|
||||
if (IsLikelyCounterVariableName(variableName))
|
||||
{
|
||||
return KarismaSceneVariableKind.Counter;
|
||||
}
|
||||
|
||||
if (variableName.StartsWith("\uC720\uD655\uB2F9", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return KarismaSceneVariableKind.VideoResource;
|
||||
@@ -246,6 +241,11 @@ public sealed class KarismaSceneVariableCatalog
|
||||
return KarismaSceneVariableKind.Image;
|
||||
}
|
||||
|
||||
if (IsLikelyCounterVariableName(variableName))
|
||||
{
|
||||
return KarismaSceneVariableKind.Counter;
|
||||
}
|
||||
|
||||
return KarismaSceneVariableKind.Text;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,28 @@ public sealed class KarismaThumbnailGeneratorService
|
||||
throw new DirectoryNotFoundException("유효한 T3_Cut 경로를 찾지 못했습니다.");
|
||||
}
|
||||
|
||||
var (host, port) = ResolveConnectionSettings();
|
||||
using var manager = new TornadoManager(host, port, _logService);
|
||||
return await GenerateAsync(
|
||||
manager,
|
||||
templates,
|
||||
t3CutPath,
|
||||
videoWallLayoutPreset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ThumbnailGenerationResult> GenerateAsync(
|
||||
TornadoManager manager,
|
||||
IReadOnlyList<FormatTemplateDefinition> templates,
|
||||
string t3CutPath,
|
||||
VideoWallLayoutPreset videoWallLayoutPreset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
|
||||
{
|
||||
throw new DirectoryNotFoundException("유효한 T3_Cut 경로를 찾지 못했습니다.");
|
||||
}
|
||||
|
||||
var projectAssetRoot = CutThumbnailAssetCatalog.TryGetProjectAssetRoot();
|
||||
if (string.IsNullOrWhiteSpace(projectAssetRoot))
|
||||
{
|
||||
@@ -45,11 +67,9 @@ public sealed class KarismaThumbnailGeneratorService
|
||||
|
||||
Directory.CreateDirectory(projectAssetRoot);
|
||||
|
||||
var (host, port) = ResolveConnectionSettings();
|
||||
var generatedCount = 0;
|
||||
var failedCount = 0;
|
||||
|
||||
using var manager = new TornadoManager(host, port, _logService);
|
||||
await manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var template in templates.OrderBy(template => template.Id, StringComparer.Ordinal))
|
||||
@@ -69,7 +89,7 @@ public sealed class KarismaThumbnailGeneratorService
|
||||
try
|
||||
{
|
||||
var resolvedScene = KarismaSceneResolver.ResolveScene(template, t3CutPath, useLoop: false);
|
||||
sceneAlias = resolvedScene.Alias;
|
||||
sceneAlias = $"{resolvedScene.Alias}__thumbnail";
|
||||
|
||||
var targetDirectory = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrWhiteSpace(targetDirectory))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using Tornado3_2026Election.Domain;
|
||||
|
||||
namespace Tornado3_2026Election.Services;
|
||||
@@ -7,6 +8,12 @@ namespace Tornado3_2026Election.Services;
|
||||
public sealed class LogService
|
||||
{
|
||||
private const int MaxEntries = 400;
|
||||
private const long MaxDebugLogBytes = 2 * 1024 * 1024;
|
||||
private static readonly object FileSync = new();
|
||||
private static readonly string DebugLogPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Tornado3_2026Election",
|
||||
"debug.log");
|
||||
|
||||
public ObservableCollection<LogEntry> Entries { get; } = [];
|
||||
|
||||
@@ -20,6 +27,8 @@ public sealed class LogService
|
||||
|
||||
private void Add(LogLevel level, string message)
|
||||
{
|
||||
WriteDebugLog(level, message);
|
||||
|
||||
Common.UiDispatcher.Enqueue(() =>
|
||||
{
|
||||
Entries.Insert(0, new LogEntry
|
||||
@@ -35,4 +44,32 @@ public sealed class LogService
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void WriteDebugLog(LogLevel level, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (FileSync)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(DebugLogPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
if (File.Exists(DebugLogPath) && new FileInfo(DebugLogPath).Length > MaxDebugLogBytes)
|
||||
{
|
||||
File.Delete(DebugLogPath);
|
||||
}
|
||||
|
||||
File.AppendAllText(
|
||||
DebugLogPath,
|
||||
$"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz} [{level}] {message}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logging must not affect live CG control.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,33 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<bool> TryCapturePendingCutPreviewAsync(
|
||||
BroadcastChannel channel,
|
||||
string fileName,
|
||||
int width,
|
||||
int height,
|
||||
int frame,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> TryCaptureCutPreviewAsync(
|
||||
BroadcastChannel channel,
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut,
|
||||
ElectionDataSnapshot snapshot,
|
||||
BroadcastStationProfile station,
|
||||
string imageRootPath,
|
||||
string fileName,
|
||||
int width,
|
||||
int height,
|
||||
int frame,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public async Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteWithTimeoutAsync(async () =>
|
||||
@@ -84,6 +111,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ShowPreparedFirstFrameAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteWithTimeoutAsync(async () =>
|
||||
{
|
||||
State = TornadoConnectionState.Ready;
|
||||
await Task.Delay(40, cancellationToken).ConfigureAwait(false);
|
||||
_logService.Info($"[{channel}] Show prepared first frame on PGM");
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteWithTimeoutAsync(async () =>
|
||||
@@ -94,6 +131,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ClearOutputAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteWithTimeoutAsync(async () =>
|
||||
{
|
||||
State = TornadoConnectionState.Idle;
|
||||
await Task.Delay(30, cancellationToken).ConfigureAwait(false);
|
||||
_logService.Info($"[{channel}] Clear output layer");
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteWithTimeoutAsync(async () =>
|
||||
|
||||
@@ -49,6 +49,33 @@ internal static class PartyColorCatalog
|
||||
return GenerateSolidColorPng(templateName, partyName, usage, sectionName, color);
|
||||
}
|
||||
|
||||
public static string ResolveFallbackAssetPathForSection(
|
||||
string templateFolderPath,
|
||||
string templateName,
|
||||
string sectionName,
|
||||
string partyName,
|
||||
PartyColorAssetUsage usage)
|
||||
{
|
||||
var catalog = LoadCatalog(templateFolderPath, templateName);
|
||||
if (catalog is null ||
|
||||
string.IsNullOrWhiteSpace(sectionName) ||
|
||||
string.IsNullOrWhiteSpace(partyName) ||
|
||||
!catalog.Sections.TryGetValue(NormalizeSectionKey(sectionName), out var section))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName).Concat(OtherPartyFallbackKeys))
|
||||
{
|
||||
if (section.PartyColors.TryGetValue(candidatePartyName, out var color))
|
||||
{
|
||||
return GenerateSolidColorPng(templateName, partyName, usage, section.DisplayName, color);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public static bool HasStyleColorBinding(string templateFolderPath, string templateName, string sectionName)
|
||||
{
|
||||
var catalog = LoadCatalog(templateFolderPath, templateName);
|
||||
@@ -129,7 +156,12 @@ internal static class PartyColorCatalog
|
||||
|
||||
var folderName = Path.GetFileName(Path.GetFullPath(templateFolderPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (TryGetExplicitRgbSpecBaseName(folderName, templateName, out var explicitSpecBaseName) &&
|
||||
!string.IsNullOrWhiteSpace(explicitSpecBaseName))
|
||||
string.IsNullOrWhiteSpace(explicitSpecBaseName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(explicitSpecBaseName))
|
||||
{
|
||||
var explicitSpecPath = Path.Combine(rgbDirectoryPath, explicitSpecBaseName + ".txt");
|
||||
if (File.Exists(explicitSpecPath))
|
||||
@@ -238,6 +270,11 @@ internal static class PartyColorCatalog
|
||||
|
||||
if (line.StartsWith("(", StringComparison.Ordinal))
|
||||
{
|
||||
if (inHeader)
|
||||
{
|
||||
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
|
||||
}
|
||||
|
||||
headerBuilder.Clear();
|
||||
headerBuilder.AppendLine(line);
|
||||
inHeader = !line.Contains(')');
|
||||
@@ -250,6 +287,8 @@ internal static class PartyColorCatalog
|
||||
}
|
||||
|
||||
if (inHeader)
|
||||
{
|
||||
if (IsHeaderContinuationLine(line))
|
||||
{
|
||||
headerBuilder.AppendLine(line);
|
||||
if (line.Contains(')'))
|
||||
@@ -261,6 +300,10 @@ internal static class PartyColorCatalog
|
||||
continue;
|
||||
}
|
||||
|
||||
inHeader = false;
|
||||
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
|
||||
}
|
||||
|
||||
if (currentSectionHeaders is null || currentSectionHeaders.Count == 0 || line.StartsWith("R", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
@@ -301,6 +344,17 @@ internal static class PartyColorCatalog
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsHeaderContinuationLine(string line)
|
||||
{
|
||||
if (line.Contains(')'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalizedLine = line.Trim().Trim('(', ')').Trim();
|
||||
return TryParseSectionHeaderLine(normalizedLine, out _, out _);
|
||||
}
|
||||
|
||||
private static List<SectionHeaderEntry> ExtractSectionHeaders(string header)
|
||||
{
|
||||
var entries = new List<SectionHeaderEntry>();
|
||||
@@ -844,7 +898,12 @@ internal static class PartyColorCatalog
|
||||
"Elect2026_Normal_민방",
|
||||
"이시각1위_광역단체장",
|
||||
"이시각1위_광역단체장",
|
||||
"이시각1위_광역단체장_HD",
|
||||
"이시각1위_광역단체장_HD");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"이시각1위_광역단체장_5760",
|
||||
"이시각1위_광역단체장_5760",
|
||||
"이시각1위_광역단체장_L");
|
||||
Add(
|
||||
mappings,
|
||||
@@ -867,8 +926,15 @@ internal static class PartyColorCatalog
|
||||
"판세_광역단체장",
|
||||
"판세_광역단체장",
|
||||
"판세_기초단체장",
|
||||
"역대시도판세_광역단체장",
|
||||
"역대시도판세_기초단체장",
|
||||
"판세_기초단체장_5760",
|
||||
"판세_기초단체장_7680");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
string.Empty,
|
||||
"사전_역대투표율");
|
||||
|
||||
Add(
|
||||
mappings,
|
||||
@@ -912,7 +978,6 @@ internal static class PartyColorCatalog
|
||||
"1-2위_텍스트",
|
||||
"광역단체장_2인_텍스트",
|
||||
"기초단체장_2인_텍스트");
|
||||
|
||||
return mappings;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,9 +327,42 @@ public sealed class PreElectionHistoryService
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(canonicalElectionType, SupportedElectionTypes[2], StringComparison.Ordinal))
|
||||
{
|
||||
return ResolveUniqueBasicDistrictHistory(canonicalElectionType, regionName, districtName);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private PreElectionHistoryRecord? ResolveUniqueBasicDistrictHistory(string electionType, string? regionName, string? districtName)
|
||||
{
|
||||
if (!_recordsByElectionType.TryGetValue(electionType, out var records))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var districtTokens = new[] { districtName, regionName }
|
||||
.Select(NormalizeBasicDistrictToken)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (districtTokens.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var matches = records
|
||||
.Where(record => districtTokens.Contains(NormalizeBasicDistrictToken(record.DistrictName), StringComparer.OrdinalIgnoreCase) ||
|
||||
districtTokens.Contains(NormalizeBasicDistrictToken(record.DisplayName), StringComparer.OrdinalIgnoreCase))
|
||||
.GroupBy(record => record.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
|
||||
return matches.Length == 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
public static string NormalizeElectionType(string? electionType)
|
||||
{
|
||||
return electionType switch
|
||||
@@ -347,18 +380,7 @@ public sealed class PreElectionHistoryService
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
foreach (var regionLabel in RegionLabels)
|
||||
{
|
||||
if (trimmed.Contains(regionLabel, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return RegionAliases[regionLabel];
|
||||
}
|
||||
}
|
||||
|
||||
return RegionAliases.TryGetValue(trimmed, out var normalized)
|
||||
? normalized
|
||||
: string.Empty;
|
||||
return NormalizeRegionKeys(value).FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, PreElectionHistoryRecord> BuildLookupIndex(
|
||||
@@ -406,7 +428,7 @@ public sealed class PreElectionHistoryService
|
||||
}
|
||||
}
|
||||
|
||||
var regionKey = ResolveRegionKey(regionName, districtName, additionalValues);
|
||||
var regionKeys = ResolveRegionKeys(regionName, districtName, additionalValues);
|
||||
var districtKeys = new[]
|
||||
{
|
||||
NormalizeBasicDistrictToken(districtName),
|
||||
@@ -418,6 +440,8 @@ public sealed class PreElectionHistoryService
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var regionKey in regionKeys)
|
||||
{
|
||||
foreach (var districtKey in districtKeys)
|
||||
{
|
||||
var combined = BuildBasicLookupKey(regionKey, districtKey);
|
||||
@@ -426,6 +450,7 @@ public sealed class PreElectionHistoryService
|
||||
yield return combined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
@@ -463,6 +488,41 @@ public sealed class PreElectionHistoryService
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveRegionKeys(string? regionName, string? districtName, IEnumerable<string?> additionalValues)
|
||||
{
|
||||
return new[] { regionName, districtName }
|
||||
.Concat(additionalValues)
|
||||
.SelectMany(NormalizeRegionKeys)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> NormalizeRegionKeys(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var yielded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var regionLabel in RegionLabels)
|
||||
{
|
||||
if (trimmed.Contains(regionLabel, StringComparison.OrdinalIgnoreCase) &&
|
||||
RegionAliases.TryGetValue(regionLabel, out var normalized) &&
|
||||
yielded.Add(normalized))
|
||||
{
|
||||
yield return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (RegionAliases.TryGetValue(trimmed, out var exactMatch) && yielded.Add(exactMatch))
|
||||
{
|
||||
yield return exactMatch;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildBasicLookupKey(string regionKey, string districtKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(regionKey) || string.IsNullOrWhiteSpace(districtKey))
|
||||
@@ -483,7 +543,7 @@ public sealed class PreElectionHistoryService
|
||||
var normalized = StripBasicDistrictDisambiguation(value.Trim());
|
||||
foreach (var regionLabel in RegionLabels)
|
||||
{
|
||||
normalized = normalized.Replace(regionLabel, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
normalized = RemoveLeadingRegionLabel(normalized, regionLabel);
|
||||
}
|
||||
|
||||
normalized = normalized
|
||||
@@ -498,6 +558,22 @@ public sealed class PreElectionHistoryService
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RemoveLeadingRegionLabel(string value, string regionLabel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(regionLabel))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var trimmed = value.TrimStart();
|
||||
if (!trimmed.StartsWith(regionLabel, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return trimmed[regionLabel.Length..].TrimStart();
|
||||
}
|
||||
|
||||
private static string NormalizeBasicDistrictDisplayName(
|
||||
string? districtName,
|
||||
string? displayName,
|
||||
|
||||
@@ -86,15 +86,15 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
["충청북도"] = "43",
|
||||
["충남"] = "44",
|
||||
["충청남도"] = "44",
|
||||
["전남"] = "29",
|
||||
["전라남도"] = "29",
|
||||
["전남"] = "46",
|
||||
["전라남도"] = "46",
|
||||
["경북"] = "47",
|
||||
["경상북도"] = "47",
|
||||
["경남"] = "48",
|
||||
["경상남도"] = "48",
|
||||
["제주"] = "50",
|
||||
["제주도"] = "50",
|
||||
["제주특별자치도"] = "50",
|
||||
["제주"] = "49",
|
||||
["제주도"] = "49",
|
||||
["제주특별자치도"] = "49",
|
||||
["강원"] = "52",
|
||||
["강원도"] = "52",
|
||||
["강원특별자치도"] = "52",
|
||||
@@ -240,8 +240,10 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
|
||||
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
|
||||
var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes);
|
||||
var seatCount = Math.Max(0, item.Region?.SeatCount ?? 0);
|
||||
var countingClosed = item.GaepyoMagam;
|
||||
var judgementCandidates = (item.Hubojas ?? [])
|
||||
.Select(MapCandidate)
|
||||
.Select(candidate => MapCandidate(candidate, seatCount, countingClosed))
|
||||
.Where(candidate => candidate.EffectiveJudgement != CandidateJudgement.None)
|
||||
.OrderBy(candidate => ResolveJudgementDisplayPriority(candidate.EffectiveJudgement))
|
||||
.ThenByDescending(candidate => candidate.VoteCount)
|
||||
@@ -250,6 +252,7 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
|
||||
overviewItems.Add((order, new CountingOverviewItem(
|
||||
DisplayName: districtOption.DisplayName,
|
||||
DistrictCode: districtOption.DistrictCode,
|
||||
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
|
||||
CountedVotes: countedVotes,
|
||||
TotalVotes: totalVotes,
|
||||
@@ -400,6 +403,7 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
var turnoutRate = electors <= 0
|
||||
? 0
|
||||
: Math.Round(voters * 100d / electors, 1, MidpointRounding.AwayFromZero);
|
||||
var referenceTimeLabel = FormatSbsReportTimeLabel(item.LastReportTime);
|
||||
|
||||
turnoutItems.Add((order, new TurnoutOverviewItem(
|
||||
districtOption.DisplayName,
|
||||
@@ -408,10 +412,12 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
districtOption.DistrictCode,
|
||||
electors,
|
||||
voters,
|
||||
turnoutRate)));
|
||||
turnoutRate,
|
||||
referenceTimeLabel)));
|
||||
}
|
||||
}
|
||||
|
||||
var overviewReferenceTimeLabel = ResolveLatestReferenceTimeLabel(turnoutItems.Select(item => item.Item.ReferenceTimeLabel));
|
||||
return new TurnoutOverviewResult(
|
||||
turnoutItems
|
||||
.OrderBy(item => item.Order)
|
||||
@@ -419,7 +425,8 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
.ToArray(),
|
||||
totalExpectedVotes,
|
||||
turnoutVotes,
|
||||
DateTimeOffset.Now);
|
||||
DateTimeOffset.Now,
|
||||
overviewReferenceTimeLabel);
|
||||
}
|
||||
|
||||
private static string ResolveTurnoutRegionCode(
|
||||
@@ -490,9 +497,12 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
CountedRate: null,
|
||||
CountedVotes: null,
|
||||
RemainingVotes: null,
|
||||
SeatCount: 0,
|
||||
CountingClosed: false,
|
||||
Candidates: null,
|
||||
ReceivedAt: DateTimeOffset.Now,
|
||||
SourcePath: $"GET /tupyo/{turnoutQuery.SungerType}/{turnoutQuery.RegionSegment}?ids={turnoutTarget.TurnoutRegionCode}");
|
||||
SourcePath: $"GET /tupyo/{turnoutQuery.SungerType}/{turnoutQuery.RegionSegment}?ids={turnoutTarget.TurnoutRegionCode}",
|
||||
ReferenceTimeLabel: FormatSbsReportTimeLabel(item.LastReportTime));
|
||||
}
|
||||
|
||||
private static TurnoutQueryDefinition? ResolveTurnoutQuery(SbsElectionConfiguration configuration)
|
||||
@@ -506,6 +516,78 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveLatestReferenceTimeLabel(IEnumerable<string> referenceTimeLabels)
|
||||
{
|
||||
return referenceTimeLabels
|
||||
.Select(label => label?.Trim() ?? string.Empty)
|
||||
.Where(label => !string.IsNullOrWhiteSpace(label))
|
||||
.Select(label => new
|
||||
{
|
||||
Label = label,
|
||||
SortValue = TryParseReferenceTimeLabel(label, out var totalMinutes) ? totalMinutes : -1
|
||||
})
|
||||
.OrderByDescending(item => item.SortValue)
|
||||
.ThenByDescending(item => item.Label, StringComparer.Ordinal)
|
||||
.Select(item => item.Label)
|
||||
.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string FormatSbsReportTimeLabel(double? lastReportTime)
|
||||
{
|
||||
if (!lastReportTime.HasValue || double.IsNaN(lastReportTime.Value) || double.IsInfinity(lastReportTime.Value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var value = lastReportTime.Value;
|
||||
int hour;
|
||||
int minute;
|
||||
if (value >= 100d)
|
||||
{
|
||||
var compactTime = (int)Math.Round(value, MidpointRounding.AwayFromZero);
|
||||
hour = compactTime / 100;
|
||||
minute = compactTime % 100;
|
||||
}
|
||||
else
|
||||
{
|
||||
var totalMinutes = (int)Math.Round(value * 60d, MidpointRounding.AwayFromZero);
|
||||
hour = totalMinutes / 60;
|
||||
minute = totalMinutes % 60;
|
||||
}
|
||||
|
||||
hour = ((hour % 24) + 24) % 24;
|
||||
minute = Math.Clamp(minute, 0, 59);
|
||||
return minute == 0
|
||||
? FormattableString.Invariant($"{hour}시 기준")
|
||||
: FormattableString.Invariant($"{hour}시 {minute:00}분 기준");
|
||||
}
|
||||
|
||||
private static bool TryParseReferenceTimeLabel(string label, out int totalMinutes)
|
||||
{
|
||||
totalMinutes = -1;
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var digits = new string(label.Where(char.IsDigit).ToArray());
|
||||
if (digits.Length == 0 || !int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (digits.Length <= 2)
|
||||
{
|
||||
totalMinutes = value * 60;
|
||||
return true;
|
||||
}
|
||||
|
||||
var hour = value / 100;
|
||||
var minute = value % 100;
|
||||
totalMinutes = hour * 60 + minute;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<TurnoutTarget> ResolveTurnoutTargetAsync(
|
||||
SbsElectionConfiguration configuration,
|
||||
string districtName,
|
||||
@@ -673,6 +755,30 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
IReadOnlyList<string> sidoCodes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (CanDeriveDistrictsFromCounting(configuration))
|
||||
{
|
||||
if (sidoCodes.Count == 0)
|
||||
{
|
||||
return await GetCountingItemsForPathAsync(
|
||||
configuration,
|
||||
BuildCountingPath(configuration, string.Empty),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var basicResults = new List<SbsCountingResponseItem>();
|
||||
foreach (var sidoChunk in sidoCodes.Chunk(24))
|
||||
{
|
||||
var sidos = string.Join(",", sidoChunk.Select(Uri.EscapeDataString));
|
||||
var path = BuildCountingPath(configuration, $"sidos={sidos}");
|
||||
basicResults.AddRange(await GetCountingItemsForPathAsync(
|
||||
configuration,
|
||||
path,
|
||||
cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return basicResults;
|
||||
}
|
||||
|
||||
var results = new List<SbsCountingResponseItem>();
|
||||
|
||||
foreach (var sidoChunk in sidoCodes.Chunk(24))
|
||||
@@ -688,6 +794,15 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
return results;
|
||||
}
|
||||
|
||||
private static bool IsCountingItemInSido(
|
||||
SbsCountingItem item,
|
||||
ISet<string> sidoCodes)
|
||||
{
|
||||
var sidoCode = item.Region?.Name1Id;
|
||||
return !string.IsNullOrWhiteSpace(sidoCode) &&
|
||||
sidoCodes.Contains(sidoCode);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsForPathAsync(
|
||||
SbsElectionConfiguration configuration,
|
||||
string path,
|
||||
@@ -739,8 +854,10 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
SbsCountingItem item,
|
||||
string sourcePath)
|
||||
{
|
||||
var seatCount = Math.Max(0, item.Region?.SeatCount ?? 0);
|
||||
var countingClosed = item.GaepyoMagam;
|
||||
var candidates = (item.Hubojas ?? [])
|
||||
.Select(MapCandidate)
|
||||
.Select(candidate => MapCandidate(candidate, seatCount, countingClosed))
|
||||
.OrderByDescending(candidate => candidate.VoteCount)
|
||||
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
@@ -750,7 +867,7 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
var outputRegionName = BuildOutputRegionName(regionName);
|
||||
var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, fallbackRegion);
|
||||
var displayName = configuration.SungerType is 2 or 4 or 5 or 6
|
||||
? BuildFullDistrictDisplayName(regionName, districtLabel)
|
||||
? BuildFullDistrictDisplayName(outputRegionName, districtLabel)
|
||||
: regionName;
|
||||
|
||||
return new SbsElectionRefreshResult(
|
||||
@@ -763,14 +880,26 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
CountedRate: item.Total?.GaepyoRate,
|
||||
CountedVotes: item.Total?.Gaepyosu,
|
||||
RemainingVotes: item.Total?.UncountedPyosu,
|
||||
SeatCount: seatCount,
|
||||
CountingClosed: countingClosed,
|
||||
Candidates: candidates,
|
||||
ReceivedAt: DateTimeOffset.Now,
|
||||
SourcePath: sourcePath);
|
||||
}
|
||||
|
||||
private static CandidateEntry MapCandidate(SbsCandidateItem item)
|
||||
private static CandidateEntry MapCandidate(SbsCandidateItem item, int seatCount, bool countingClosed)
|
||||
{
|
||||
var total = item.Total ?? new SbsCandidateVoteSnapshot();
|
||||
var judgement = MapJudgement(item.Degree);
|
||||
if (judgement == CandidateJudgement.None &&
|
||||
countingClosed &&
|
||||
seatCount > 0 &&
|
||||
total.Rank > 0 &&
|
||||
total.Rank <= seatCount)
|
||||
{
|
||||
judgement = CandidateJudgement.ElectedAfterCountComplete;
|
||||
}
|
||||
|
||||
return new CandidateEntry
|
||||
{
|
||||
CandidateCode = string.IsNullOrWhiteSpace(item.Giho) ? (item.Name ?? "후보") : item.Giho,
|
||||
@@ -780,7 +909,10 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
VoteCount = total.Dugpyosu,
|
||||
VoteRate = total.DugpyoRate,
|
||||
HasImage = true,
|
||||
ManualJudgement = MapJudgement(item.Degree)
|
||||
ManualJudgement = judgement,
|
||||
BroadcastRank = total.Rank,
|
||||
BroadcastSeatCount = seatCount,
|
||||
BroadcastCountingClosed = countingClosed
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1099,21 +1231,24 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
UriComponents.SchemeAndServer | UriComponents.Path,
|
||||
UriFormat.SafeUnescaped,
|
||||
StringComparison.OrdinalIgnoreCase) == 0 &&
|
||||
configuration.SungerType == 6;
|
||||
IsBasicCouncilCountingType(configuration.SungerType);
|
||||
|
||||
private static bool CanQueryCountingBySido(
|
||||
SbsElectionConfiguration configuration,
|
||||
IReadOnlyList<DistrictSelectionOption> districts)
|
||||
=> configuration.SungerType == 6 &&
|
||||
=> IsBasicCouncilCountingType(configuration.SungerType) &&
|
||||
CanDeriveDistrictsFromCounting(configuration) &&
|
||||
districts.Count > 0 &&
|
||||
districts.All(district => !string.IsNullOrWhiteSpace(district.ParentRegionCode));
|
||||
|
||||
private static bool CanQueryBasicCouncilByDistrictId(SbsElectionConfiguration configuration)
|
||||
=> configuration.SungerType == 6 && CanDeriveDistrictsFromCounting(configuration);
|
||||
=> IsBasicCouncilCountingType(configuration.SungerType) && CanDeriveDistrictsFromCounting(configuration);
|
||||
|
||||
private static bool ShouldCacheBasicCouncilCounting(SbsElectionConfiguration configuration)
|
||||
=> configuration.SungerType == 6 && CanDeriveDistrictsFromCounting(configuration);
|
||||
=> IsBasicCouncilCountingType(configuration.SungerType) && CanDeriveDistrictsFromCounting(configuration);
|
||||
|
||||
private static bool IsBasicCouncilCountingType(int sungerType)
|
||||
=> sungerType is 5 or 6;
|
||||
|
||||
public static IReadOnlyList<string> ResolveBasicApiSidoCodes(IEnumerable<string> regionNames)
|
||||
{
|
||||
@@ -1258,12 +1393,11 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
|
||||
private static string BuildElectionDistrictLabel(string? officeName, string? shortName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(officeName))
|
||||
{
|
||||
return officeName.Trim();
|
||||
}
|
||||
var label = !string.IsNullOrWhiteSpace(officeName)
|
||||
? officeName.Trim()
|
||||
: shortName?.Trim() ?? string.Empty;
|
||||
|
||||
return shortName?.Trim() ?? string.Empty;
|
||||
return NormalizeElectionDistrictDisplayLabel(label);
|
||||
}
|
||||
|
||||
private static string BuildMayorGovernorLabel(string regionName, string? officeName)
|
||||
@@ -1377,9 +1511,30 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
return regionName;
|
||||
}
|
||||
|
||||
if (districtLabel.StartsWith(regionName, StringComparison.Ordinal))
|
||||
{
|
||||
return districtLabel;
|
||||
}
|
||||
|
||||
return $"{regionName} {districtLabel}";
|
||||
}
|
||||
|
||||
private static string NormalizeElectionDistrictDisplayLabel(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
|
||||
{
|
||||
normalized = normalized.Replace(pair.Value, pair.Key, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static DistrictSelectionOption CreateDistrictSelectionOption(int sungerType, SbsRegionInfo region)
|
||||
{
|
||||
var regionName = ExpandRegionName(region.Name1 ?? region.Name);
|
||||
@@ -1391,8 +1546,9 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region),
|
||||
_ => regionName
|
||||
};
|
||||
var displayRegionName = sungerType is 5 or 6 ? outputRegionName : regionName;
|
||||
var displayName = sungerType is 2 or 4 or 5 or 6
|
||||
? BuildFullDistrictDisplayName(regionName, districtName)
|
||||
? BuildFullDistrictDisplayName(displayRegionName, districtName)
|
||||
: regionName;
|
||||
var turnoutRegionCode = sungerType switch
|
||||
{
|
||||
@@ -1538,12 +1694,16 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
double? CountedRate,
|
||||
int? CountedVotes,
|
||||
int? RemainingVotes,
|
||||
int SeatCount,
|
||||
bool CountingClosed,
|
||||
IReadOnlyList<CandidateEntry>? Candidates,
|
||||
DateTimeOffset ReceivedAt,
|
||||
string SourcePath);
|
||||
string SourcePath,
|
||||
string ReferenceTimeLabel = "");
|
||||
|
||||
public sealed record CountingOverviewItem(
|
||||
string DisplayName,
|
||||
string DistrictCode,
|
||||
double CountedRate,
|
||||
int CountedVotes,
|
||||
int TotalVotes,
|
||||
@@ -1558,13 +1718,15 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
string DistrictCode,
|
||||
int TotalExpectedVotes,
|
||||
int TurnoutVotes,
|
||||
double TurnoutRate);
|
||||
double TurnoutRate,
|
||||
string ReferenceTimeLabel = "");
|
||||
|
||||
public sealed record TurnoutOverviewResult(
|
||||
IReadOnlyList<TurnoutOverviewItem> Items,
|
||||
int TotalExpectedVotes,
|
||||
int TurnoutVotes,
|
||||
DateTimeOffset ReceivedAt)
|
||||
DateTimeOffset ReceivedAt,
|
||||
string ReferenceTimeLabel = "")
|
||||
{
|
||||
public double NationalTurnoutRate => TotalExpectedVotes <= 0
|
||||
? 0
|
||||
@@ -1605,6 +1767,9 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; set; }
|
||||
|
||||
[JsonPropertyName("seatCount")]
|
||||
public int SeatCount { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SbsTurnoutItem
|
||||
@@ -1615,6 +1780,9 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
[JsonPropertyName("sungerinsu")]
|
||||
public int Sungerinsu { get; set; }
|
||||
|
||||
[JsonPropertyName("lastReportTime")]
|
||||
public double? LastReportTime { get; set; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public SbsTurnoutVoteSnapshot? Total { get; set; }
|
||||
}
|
||||
@@ -1653,6 +1821,9 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; set; }
|
||||
|
||||
[JsonPropertyName("seatCount")]
|
||||
public int SeatCount { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SbsTurnoutVoteSnapshot
|
||||
@@ -1671,6 +1842,9 @@ public sealed class SbsElectionApiClient : IDisposable
|
||||
|
||||
[JsonPropertyName("hubojas")]
|
||||
public List<SbsCandidateItem>? Hubojas { get; set; }
|
||||
|
||||
[JsonPropertyName("gaepyoMagam")]
|
||||
public bool GaepyoMagam { get; set; }
|
||||
}
|
||||
|
||||
private readonly record struct SbsCountingResponseItem(
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tornado3_2026Election.Services;
|
||||
|
||||
internal static class ScheduleTemplatePolicy
|
||||
{
|
||||
private const double MinimumCutDurationSeconds = 1d;
|
||||
public const string SingleRegionLabel = "단일";
|
||||
|
||||
public static double GetMinimumCutDurationSeconds(FormatTemplateDefinition template)
|
||||
@@ -14,25 +15,7 @@ internal static class ScheduleTemplatePolicy
|
||||
|
||||
public static double GetMinimumCutDurationSeconds(BroadcastChannel channel, string? templateName)
|
||||
{
|
||||
var name = templateName ?? string.Empty;
|
||||
if (name.Contains("영상", StringComparison.Ordinal) ||
|
||||
name.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.StartsWith("사전_", StringComparison.Ordinal))
|
||||
{
|
||||
return 10d;
|
||||
}
|
||||
|
||||
if (channel == BroadcastChannel.VideoWall)
|
||||
{
|
||||
return 8d;
|
||||
}
|
||||
|
||||
if (channel is BroadcastChannel.Normal or BroadcastChannel.Bottom)
|
||||
{
|
||||
return 8d;
|
||||
}
|
||||
|
||||
return 6d;
|
||||
return MinimumCutDurationSeconds;
|
||||
}
|
||||
|
||||
public static double NormalizeCutDurationSeconds(double durationSeconds, FormatTemplateDefinition template)
|
||||
|
||||
@@ -7,7 +7,8 @@ public sealed class StationCatalogService
|
||||
{
|
||||
private readonly IReadOnlyList<BroadcastStationProfile> _stations =
|
||||
[
|
||||
new BroadcastStationProfile { Id = "KNN", Name = "KNN", LogoAssetPath = @"Assets\Stations\knn.png", RegionFilters = ["부산", "울산", "경남"] },
|
||||
new BroadcastStationProfile { Id = "KNN", Name = "KNN", LogoAssetPath = @"Assets\Stations\knn.png", RegionFilters = ["부산", "경남"], VideoWallLayoutPreset = VideoWallLayoutPreset.UltraWide8316x1080 },
|
||||
new BroadcastStationProfile { Id = "UBC", Name = "UBC", LogoAssetPath = @"Assets\Stations\ubc.png", RegionFilters = ["울산"] },
|
||||
new BroadcastStationProfile { Id = "TBC", Name = "TBC", LogoAssetPath = @"Assets\Stations\tbc.png", RegionFilters = ["대구", "경북"] },
|
||||
new BroadcastStationProfile { Id = "KBC", Name = "KBC", LogoAssetPath = @"Assets\Stations\kbc.png", RegionFilters = ["광주", "전남"] },
|
||||
new BroadcastStationProfile { Id = "G1", Name = "G1", LogoAssetPath = @"Assets\Stations\g1.png", RegionFilters = ["강원"] },
|
||||
|
||||
@@ -7,7 +7,8 @@ public enum ThumbnailDisplayContext
|
||||
{
|
||||
CutList,
|
||||
Queue,
|
||||
Preview
|
||||
Preview,
|
||||
PlaybackPreview
|
||||
}
|
||||
|
||||
public readonly record struct ThumbnailDisplayMetrics(double Width, double Height);
|
||||
@@ -15,8 +16,9 @@ public readonly record struct ThumbnailDisplayMetrics(double Width, double Heigh
|
||||
public static class ThumbnailLayoutResolver
|
||||
{
|
||||
private const double HdAspectRatio = 1920d / 1080d;
|
||||
private const double StandardVideoWallAspectRatio = 5760d / 1080d;
|
||||
private const double UltraWideVideoWallAspectRatio = 11520d / 1080d;
|
||||
private const double VideoWall3840x810AspectRatio = 3840d / 810d;
|
||||
private const double VideoWall2880x1080AspectRatio = 2880d / 1080d;
|
||||
private const double VideoWall8316x1080AspectRatio = 8316d / 1080d;
|
||||
|
||||
public static ThumbnailDisplayMetrics ResolveDisplayMetrics(
|
||||
FormatTemplateDefinition template,
|
||||
@@ -55,6 +57,7 @@ public static class ThumbnailLayoutResolver
|
||||
var (maxWidth, maxHeight) = context switch
|
||||
{
|
||||
ThumbnailDisplayContext.Preview => (480d, 180d),
|
||||
ThumbnailDisplayContext.PlaybackPreview => (220d, 124d),
|
||||
ThumbnailDisplayContext.CutList => (320d, 90d),
|
||||
ThumbnailDisplayContext.Queue => (320d, 90d),
|
||||
_ => (320d, 180d)
|
||||
@@ -81,7 +84,7 @@ public static class ThumbnailLayoutResolver
|
||||
return sceneAspectRatio;
|
||||
}
|
||||
|
||||
return StandardVideoWallAspectRatio;
|
||||
return VideoWall8316x1080AspectRatio;
|
||||
}
|
||||
|
||||
if (TryGetSceneAspectRatio(sceneWidth, sceneHeight, out var resolvedSceneAspectRatio))
|
||||
@@ -96,11 +99,14 @@ public static class ThumbnailLayoutResolver
|
||||
{
|
||||
switch (videoWallLayoutPreset)
|
||||
{
|
||||
case VideoWallLayoutPreset.Standard5760x1080:
|
||||
aspectRatio = StandardVideoWallAspectRatio;
|
||||
case VideoWallLayoutPreset.Wall3840x810:
|
||||
aspectRatio = VideoWall3840x810AspectRatio;
|
||||
return true;
|
||||
case VideoWallLayoutPreset.UltraWide11520x1080:
|
||||
aspectRatio = UltraWideVideoWallAspectRatio;
|
||||
case VideoWallLayoutPreset.Wall2880x1080:
|
||||
aspectRatio = VideoWall2880x1080AspectRatio;
|
||||
return true;
|
||||
case VideoWallLayoutPreset.UltraWide8316x1080:
|
||||
aspectRatio = VideoWall8316x1080AspectRatio;
|
||||
return true;
|
||||
default:
|
||||
aspectRatio = 0;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -19,6 +20,7 @@ public sealed class TornadoManager : IDisposable
|
||||
private readonly LogService _logService;
|
||||
private readonly StaDispatcher _dispatcher;
|
||||
private readonly Timer _reconnectTimer;
|
||||
private readonly SemaphoreSlim _asyncOperationLock = new(1, 1);
|
||||
private readonly Dictionary<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, DateTime> _sceneWriteTimes = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -54,7 +56,16 @@ public sealed class TornadoManager : IDisposable
|
||||
return EnsureConnectedInternalAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<string> LoadSceneAsync(string scenePath, string sceneAlias, CancellationToken cancellationToken)
|
||||
public Task<string> LoadSceneAsync(string scenePath, string sceneAlias, CancellationToken cancellationToken)
|
||||
{
|
||||
return LoadSceneAsync(scenePath, sceneAlias, forceReload: false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<string> LoadSceneAsync(
|
||||
string scenePath,
|
||||
string sceneAlias,
|
||||
bool forceReload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scenePath))
|
||||
{
|
||||
@@ -66,13 +77,17 @@ public sealed class TornadoManager : IDisposable
|
||||
throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
|
||||
}
|
||||
|
||||
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var sceneWriteTime = ResolveSceneWriteTime(scenePath);
|
||||
var existingAlias = await _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
|
||||
if (_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
|
||||
if (!forceReload &&
|
||||
_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
|
||||
string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase) &&
|
||||
_sceneWriteTimes.TryGetValue(sceneAlias, out var existingWriteTime) &&
|
||||
existingWriteTime == sceneWriteTime)
|
||||
@@ -98,22 +113,23 @@ public sealed class TornadoManager : IDisposable
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
|
||||
var forceReload = _scenePaths.ContainsKey(sceneAlias);
|
||||
var scene = forceReload
|
||||
var shouldForceReload = forceReload || _scenePaths.ContainsKey(sceneAlias);
|
||||
var scene = shouldForceReload
|
||||
? _engine!.LoadSceneForce(scenePath, sceneAlias)
|
||||
: _engine!.LoadScene(scenePath, sceneAlias);
|
||||
|
||||
_logService.Info(
|
||||
$"Karisma {(forceReload ? "LoadSceneForce" : "LoadScene")}() return={(scene is null ? "null" : "scene-handle")} alias={sceneAlias} path={scenePath}");
|
||||
$"Karisma {(shouldForceReload ? "LoadSceneForce" : "LoadScene")}() return={(scene is null ? "null" : "scene-handle")} alias={sceneAlias} path={scenePath}");
|
||||
|
||||
_scenes[sceneAlias] = scene ?? throw new InvalidOperationException($"Failed to load Karisma scene: {scenePath}");
|
||||
_scenePaths[sceneAlias] = scenePath;
|
||||
_sceneWriteTimes[sceneAlias] = sceneWriteTime;
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(AsyncOperationTimeout);
|
||||
var result = await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
var result = await WaitForKarismaCallbackAsync(
|
||||
completion,
|
||||
cancellationToken,
|
||||
$"LoadScene '{sceneAlias}'").ConfigureAwait(false);
|
||||
if (result != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
throw new InvalidOperationException($"LoadScene failed for '{sceneAlias}': {result} ({(int)result})");
|
||||
@@ -141,24 +157,46 @@ public sealed class TornadoManager : IDisposable
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_asyncOperationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task ApplyValuesAsync(
|
||||
public async Task ApplyValuesAsync(
|
||||
string sceneAlias,
|
||||
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesBeforeValue,
|
||||
IReadOnlyDictionary<string, string> values,
|
||||
IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
|
||||
IReadOnlyList<KarismaChartCellUpdate> chartCellUpdates,
|
||||
IReadOnlyList<KarismaPositionUpdate> positionUpdates,
|
||||
IReadOnlyList<KarismaCropKeyUpdate> cropKeyUpdates,
|
||||
IReadOnlyList<KarismaStyleColorUpdate> styleColorUpdates,
|
||||
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesAfterValue,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
Task<eKResult>? transactionCompletion = null;
|
||||
var eventHandler = GetEventHandlerCore();
|
||||
|
||||
try
|
||||
{
|
||||
await _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
|
||||
var scene = GetSceneCore(sceneAlias);
|
||||
var counterNumberKeyObjectNames = counterNumberKeys
|
||||
.Where(update => update.KeyIndex != 0 && !string.IsNullOrWhiteSpace(update.ObjectName))
|
||||
.Select(update => update.ObjectName)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var counterSetValueObjectNames = counterNumberKeys
|
||||
.Where(update => update.AllowSetValue && !string.IsNullOrWhiteSpace(update.ObjectName))
|
||||
.Select(update => update.ObjectName)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
_engine!.BeginTransaction();
|
||||
try
|
||||
{
|
||||
@@ -179,6 +217,13 @@ public sealed class TornadoManager : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
if (counterNumberKeyObjectNames.Contains(pair.Key) &&
|
||||
!counterSetValueObjectNames.Contains(pair.Key) &&
|
||||
sceneObject is IKACounter)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sceneObject.SetValue(pair.Value ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -196,6 +241,11 @@ public sealed class TornadoManager : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
if (counterNumberKey.KeyIndex == 0 && !counterNumberKey.AllowKeyZero)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sceneObject = scene.GetObject(counterNumberKey.ObjectName);
|
||||
if (sceneObject is not IKACounter counter)
|
||||
{
|
||||
@@ -254,16 +304,58 @@ public sealed class TornadoManager : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
if (positionUpdate.KeyIndex >= 0)
|
||||
{
|
||||
sceneObject.SetPositionKey(
|
||||
positionUpdate.KeyIndex,
|
||||
positionUpdate.X,
|
||||
positionUpdate.Y,
|
||||
positionUpdate.Z,
|
||||
positionUpdate.VectorType);
|
||||
}
|
||||
else
|
||||
{
|
||||
sceneObject.SetPosition(
|
||||
positionUpdate.X,
|
||||
positionUpdate.Y,
|
||||
positionUpdate.Z,
|
||||
positionUpdate.VectorType);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning(
|
||||
$"Karisma position update skipped: scene={sceneAlias} object={positionUpdate.ObjectName} reason={ex.Message}");
|
||||
$"Karisma position update skipped: scene={sceneAlias} object={positionUpdate.ObjectName} keyIndex={positionUpdate.KeyIndex} reason={ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var cropKeyUpdate in cropKeyUpdates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cropKeyUpdate.ObjectName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sceneObject = scene.GetObject(cropKeyUpdate.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sceneObject.SetCropKey(
|
||||
cropKeyUpdate.KeyIndex,
|
||||
cropKeyUpdate.Left,
|
||||
cropKeyUpdate.Top,
|
||||
cropKeyUpdate.Right,
|
||||
cropKeyUpdate.Bottom,
|
||||
cropKeyUpdate.CropKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning(
|
||||
$"Karisma crop-key update skipped: scene={sceneAlias} object={cropKeyUpdate.ObjectName} keyIndex={cropKeyUpdate.KeyIndex} reason={ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,9 +395,33 @@ public sealed class TornadoManager : IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
transactionCompletion = eventHandler.BeginEndTransactionWait();
|
||||
_engine!.EndTransaction();
|
||||
}
|
||||
}, cancellationToken);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (transactionCompletion is not null)
|
||||
{
|
||||
var transactionResult = await WaitForKarismaResultAsync(
|
||||
transactionCompletion,
|
||||
cancellationToken,
|
||||
$"EndTransaction scene={sceneAlias}").ConfigureAwait(false);
|
||||
ThrowIfKarismaFailed(transactionResult, $"EndTransaction failed for scene={sceneAlias}");
|
||||
}
|
||||
|
||||
await UpdateSceneTexturesAsync(sceneAlias, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
eventHandler.CancelPendingEndTransaction(ex);
|
||||
eventHandler.CancelPendingUpdateTextures(sceneAlias, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_asyncOperationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime ResolveSceneWriteTime(string scenePath)
|
||||
@@ -347,19 +463,74 @@ public sealed class TornadoManager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public Task PrepareAsync(int outputChannelIndex, int layerNo, string sceneAlias, CancellationToken cancellationToken)
|
||||
private async Task UpdateSceneTexturesAsync(string sceneAlias, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
var eventHandler = GetEventHandlerCore();
|
||||
var completion = eventHandler.BeginUpdateTexturesWait(sceneAlias);
|
||||
|
||||
try
|
||||
{
|
||||
await _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
GetSceneCore(sceneAlias).UpdateTextures();
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await WaitForKarismaResultAsync(
|
||||
completion,
|
||||
cancellationToken,
|
||||
$"UpdateTextures scene={sceneAlias}").ConfigureAwait(false);
|
||||
ThrowIfKarismaFailed(result, $"UpdateTextures failed for scene={sceneAlias}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
eventHandler.CancelPendingUpdateTextures(sceneAlias, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PrepareAsync(int outputChannelIndex, int layerNo, string sceneAlias, CancellationToken cancellationToken)
|
||||
{
|
||||
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var eventHandler = GetEventHandlerCore();
|
||||
var completion = eventHandler.BeginScenePrepareWait(outputChannelIndex, layerNo);
|
||||
|
||||
try
|
||||
{
|
||||
await _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
GetScenePlayerCore(outputChannelIndex).Prepare(layerNo, GetSceneCore(sceneAlias));
|
||||
}, cancellationToken);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await WaitForKarismaResultAsync(
|
||||
completion,
|
||||
cancellationToken,
|
||||
$"Prepare output={outputChannelIndex} layer={layerNo} scene={sceneAlias}").ConfigureAwait(false);
|
||||
ThrowIfKarismaFailed(result, $"Prepare failed for output={outputChannelIndex} layer={layerNo} scene={sceneAlias}");
|
||||
|
||||
// Give Karisma one render tick after Prepare before a mixed-preview screenshot is requested.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
eventHandler.CancelPendingScenePrepare(outputChannelIndex, layerNo, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_asyncOperationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task PlayAsync(int outputChannelIndex, int layerNo, bool cutIn, CancellationToken cancellationToken)
|
||||
public async Task PlayAsync(int outputChannelIndex, int layerNo, bool cutIn, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
await InvokeExclusiveAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
@@ -375,9 +546,39 @@ public sealed class TornadoManager : IDisposable
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task PlayOutAsync(int outputChannelIndex, int layerNo, bool cutOut, CancellationToken cancellationToken)
|
||||
public async Task PauseAsync(int outputChannelIndex, int layerNo, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
await InvokeExclusiveAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
GetScenePlayerCore(outputChannelIndex).Pause(layerNo);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ResumeAsync(int outputChannelIndex, int layerNo, CancellationToken cancellationToken)
|
||||
{
|
||||
await InvokeExclusiveAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
GetScenePlayerCore(outputChannelIndex).Resume(layerNo);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task StopAsync(int outputChannelIndex, int layerNo, CancellationToken cancellationToken)
|
||||
{
|
||||
await InvokeExclusiveAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
GetScenePlayerCore(outputChannelIndex).Stop(layerNo);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task PlayOutAsync(int outputChannelIndex, int layerNo, bool cutOut, CancellationToken cancellationToken)
|
||||
{
|
||||
await InvokeExclusiveAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
@@ -393,6 +594,82 @@ public sealed class TornadoManager : IDisposable
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ClearNextPreviewAsync(int outputChannelIndex, int layerNo, CancellationToken cancellationToken)
|
||||
{
|
||||
await InvokeExclusiveAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
GetScenePlayerCore(outputChannelIndex).ClearNextPreview(layerNo);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task SaveMixedPreviewImageAsync(
|
||||
int outputChannelIndex,
|
||||
int layerNo,
|
||||
string fileName,
|
||||
int width,
|
||||
int height,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
throw new ArgumentException("Image file path is required.", nameof(fileName));
|
||||
}
|
||||
|
||||
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var eventHandler = GetEventHandlerCore();
|
||||
var completion = eventHandler.BeginSaveMixedPreviewImageWait();
|
||||
|
||||
try
|
||||
{
|
||||
await _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
|
||||
var directory = Path.GetDirectoryName(fileName);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
GetScenePlayerCore(outputChannelIndex).SaveMixedPreviewImage(fileName, width, height);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await WaitForKarismaCallbackAsync(
|
||||
completion,
|
||||
cancellationToken,
|
||||
$"SaveMixedPreviewImage output={outputChannelIndex} layer={layerNo}").ConfigureAwait(false);
|
||||
|
||||
if (result.Result != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"SaveMixedPreviewImage failed for output={outputChannelIndex} layer={layerNo}: {result.Result} ({(int)result.Result})");
|
||||
}
|
||||
|
||||
await WaitForFileAsync(fileName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
eventHandler.CancelPendingSaveMixedPreviewImage(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_asyncOperationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveSceneImageAsync(
|
||||
string sceneAlias,
|
||||
string fileName,
|
||||
@@ -411,6 +688,9 @@ public sealed class TornadoManager : IDisposable
|
||||
throw new ArgumentException("Image file path is required.", nameof(fileName));
|
||||
}
|
||||
|
||||
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
@@ -436,9 +716,10 @@ public sealed class TornadoManager : IDisposable
|
||||
GetSceneCore(sceneAlias).SaveSceneImage(fileName, width, height, frame);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
var result = await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
var result = await WaitForKarismaCallbackAsync(
|
||||
completion,
|
||||
cancellationToken,
|
||||
$"SaveSceneImage scene={sceneAlias}").ConfigureAwait(false);
|
||||
|
||||
if (result.Result != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
@@ -453,10 +734,15 @@ public sealed class TornadoManager : IDisposable
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task UnloadSceneAsync(string sceneAlias, CancellationToken cancellationToken)
|
||||
finally
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
_asyncOperationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UnloadSceneAsync(string sceneAlias, CancellationToken cancellationToken)
|
||||
{
|
||||
await InvokeExclusiveAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
@@ -471,9 +757,9 @@ public sealed class TornadoManager : IDisposable
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task TriggerAsync(int outputChannelIndex, int layerNo, string animationName, CancellationToken cancellationToken)
|
||||
public async Task TriggerAsync(int outputChannelIndex, int layerNo, string animationName, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
await InvokeExclusiveAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
@@ -674,7 +960,57 @@ public sealed class TornadoManager : IDisposable
|
||||
return _eventHandler ?? throw new InvalidOperationException("Karisma event handler is unavailable.");
|
||||
}
|
||||
|
||||
private async Task InvokeExclusiveAsync(Action action, CancellationToken cancellationToken)
|
||||
{
|
||||
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _dispatcher.InvokeAsync(action, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_asyncOperationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<eKResult> WaitForKarismaResultAsync(
|
||||
Task<eKResult> completion,
|
||||
CancellationToken cancellationToken,
|
||||
string operationName)
|
||||
{
|
||||
return WaitForKarismaCallbackAsync(completion, cancellationToken, operationName);
|
||||
}
|
||||
|
||||
private static async Task<T> WaitForKarismaCallbackAsync<T>(
|
||||
Task<T> completion,
|
||||
CancellationToken cancellationToken,
|
||||
string operationName)
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(AsyncOperationTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
return await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"{operationName} timed out after {AsyncOperationTimeout.TotalSeconds:0.#} seconds.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfKarismaFailed(eKResult result, string message)
|
||||
{
|
||||
if (result != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
throw new InvalidOperationException($"{message}: {result} ({(int)result})");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureConnectedInternalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _asyncOperationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
Task<int>? connectCompletion = null;
|
||||
try
|
||||
@@ -714,9 +1050,10 @@ public sealed class TornadoManager : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(AsyncOperationTimeout);
|
||||
var errorCode = await connectCompletion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
var errorCode = await WaitForKarismaCallbackAsync(
|
||||
connectCompletion,
|
||||
cancellationToken,
|
||||
"Connect").ConfigureAwait(false);
|
||||
if (errorCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Karisma Async Engine OnConnect failed: errorCode={errorCode}");
|
||||
@@ -744,6 +1081,11 @@ public sealed class TornadoManager : IDisposable
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_asyncOperationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForFileAsync(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -133,12 +133,12 @@
|
||||
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
|
||||
<GenerateAppInstallerFile>True</GenerateAppInstallerFile>
|
||||
<AppxPackageSigningEnabled>True</AppxPackageSigningEnabled>
|
||||
<PackageCertificateKeyFile>Tornado3_2026Election_TemporaryKey.pfx</PackageCertificateKeyFile>
|
||||
<PackageCertificateThumbprint>E691A33C64DF20A204FFD4F096B9C3EB4B95709C</PackageCertificateThumbprint>
|
||||
<AppxPackageSigningTimestampDigestAlgorithm>SHA256</AppxPackageSigningTimestampDigestAlgorithm>
|
||||
<AppxAutoIncrementPackageRevision>True</AppxAutoIncrementPackageRevision>
|
||||
<GenerateTestArtifacts>True</GenerateTestArtifacts>
|
||||
<AppxBundle>Never</AppxBundle>
|
||||
<AppInstallerUri>http://172.30.1.36/</AppInstallerUri>
|
||||
<AppInstallerUri>http://122.34.248.185/msix/</AppInstallerUri>
|
||||
<HoursBetweenUpdateChecks>0</HoursBetweenUpdateChecks>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Tornado3_2026Election.Common;
|
||||
using Tornado3_2026Election.Domain;
|
||||
@@ -6,6 +7,10 @@ namespace Tornado3_2026Election.ViewModels;
|
||||
|
||||
public sealed class CareerPromiseEditRowViewModel : ObservableObject
|
||||
{
|
||||
private string _districtName;
|
||||
private string _candidateCode;
|
||||
private string _candidateName;
|
||||
private string _party;
|
||||
private string _promise1;
|
||||
private string _promise2;
|
||||
private string _promise3;
|
||||
@@ -14,21 +19,45 @@ public sealed class CareerPromiseEditRowViewModel : ObservableObject
|
||||
string candidateCode,
|
||||
string candidateName,
|
||||
string party,
|
||||
IReadOnlyList<string>? promises = null)
|
||||
IReadOnlyList<string>? promises = null,
|
||||
string districtName = "",
|
||||
Action<CareerPromiseEditRowViewModel>? deleteAction = null)
|
||||
{
|
||||
CandidateCode = candidateCode ?? string.Empty;
|
||||
CandidateName = candidateName ?? string.Empty;
|
||||
Party = party ?? string.Empty;
|
||||
_candidateCode = candidateCode ?? string.Empty;
|
||||
_candidateName = candidateName ?? string.Empty;
|
||||
_party = party ?? string.Empty;
|
||||
_districtName = districtName ?? string.Empty;
|
||||
_promise1 = GetPromise(promises, 0);
|
||||
_promise2 = GetPromise(promises, 1);
|
||||
_promise3 = GetPromise(promises, 2);
|
||||
DeleteCommand = new RelayCommand(() => deleteAction?.Invoke(this), () => deleteAction is not null);
|
||||
}
|
||||
|
||||
public string CandidateCode { get; }
|
||||
public RelayCommand DeleteCommand { get; }
|
||||
|
||||
public string CandidateName { get; }
|
||||
public string DistrictName
|
||||
{
|
||||
get => _districtName;
|
||||
set => SetProperty(ref _districtName, value ?? string.Empty);
|
||||
}
|
||||
|
||||
public string Party { get; }
|
||||
public string CandidateCode
|
||||
{
|
||||
get => _candidateCode;
|
||||
set => SetProperty(ref _candidateCode, value ?? string.Empty);
|
||||
}
|
||||
|
||||
public string CandidateName
|
||||
{
|
||||
get => _candidateName;
|
||||
set => SetProperty(ref _candidateName, value ?? string.Empty);
|
||||
}
|
||||
|
||||
public string Party
|
||||
{
|
||||
get => _party;
|
||||
set => SetProperty(ref _party, value ?? string.Empty);
|
||||
}
|
||||
|
||||
public string Promise1
|
||||
{
|
||||
@@ -80,20 +109,28 @@ public sealed class CareerPromiseEditRowViewModel : ObservableObject
|
||||
string districtName)
|
||||
{
|
||||
var normalizedCandidateCode = CandidateCode.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedCandidateCode) || !HasAnyPromise)
|
||||
var normalizedCandidateName = CandidateName.Trim();
|
||||
var normalizedParty = Party.Trim();
|
||||
if (!HasAnyPromise ||
|
||||
string.IsNullOrWhiteSpace(normalizedCandidateName) ||
|
||||
string.IsNullOrWhiteSpace(normalizedParty))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedDistrictName = string.IsNullOrWhiteSpace(DistrictName)
|
||||
? districtName?.Trim() ?? string.Empty
|
||||
: DistrictName.Trim();
|
||||
|
||||
return new CareerPromiseEntry
|
||||
{
|
||||
StationId = stationId?.Trim() ?? string.Empty,
|
||||
ElectionType = electionType?.Trim() ?? string.Empty,
|
||||
DistrictCode = districtCode?.Trim() ?? string.Empty,
|
||||
DistrictName = districtName?.Trim() ?? string.Empty,
|
||||
DistrictName = normalizedDistrictName,
|
||||
CandidateCode = normalizedCandidateCode,
|
||||
CandidateName = CandidateName.Trim(),
|
||||
Party = Party.Trim(),
|
||||
CandidateName = normalizedCandidateName,
|
||||
Party = normalizedParty,
|
||||
Promises = new[] { Promise1.Trim(), Promise2.Trim(), Promise3.Trim() }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Tornado3_2026Election.Common;
|
||||
@@ -19,6 +20,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
|
||||
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
|
||||
private static readonly Brush PlaybackActiveIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 90, 84));
|
||||
private static readonly Brush PlaybackIdleIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 183, 197, 216));
|
||||
private readonly ChannelScheduleEngine _engine;
|
||||
private readonly ITornado3Adapter _adapter;
|
||||
private readonly CutDebugStateStore _cutDebugStateStore;
|
||||
@@ -33,6 +36,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
private ScheduleRegionOption? _selectedRegionOption;
|
||||
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
|
||||
private CancellationTokenSource? _directPlaybackCts;
|
||||
private ChannelScheduleItem? _preparedDirectItem;
|
||||
private string _preparedDirectFormatId = string.Empty;
|
||||
private string _preparedDirectRegionKey = string.Empty;
|
||||
private bool _loopEnabled;
|
||||
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
|
||||
private int _regionOptionsRevision;
|
||||
@@ -78,8 +84,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
];
|
||||
Queue = engine.Queue;
|
||||
|
||||
SchedulePrepareCommand = new AsyncRelayCommand(PrepareScheduleAsync);
|
||||
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
|
||||
StopCommand = new AsyncRelayCommand(StopAsync);
|
||||
DirectPrepareCommand = new AsyncRelayCommand(DirectPrepareAsync, CanDirectStart);
|
||||
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
|
||||
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
|
||||
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
|
||||
@@ -138,10 +146,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<ChannelScheduleItem> Queue { get; }
|
||||
|
||||
public AsyncRelayCommand SchedulePrepareCommand { get; }
|
||||
|
||||
public AsyncRelayCommand StartCommand { get; }
|
||||
|
||||
public AsyncRelayCommand StopCommand { get; }
|
||||
|
||||
public AsyncRelayCommand DirectPrepareCommand { get; }
|
||||
|
||||
public AsyncRelayCommand DirectStartCommand { get; }
|
||||
|
||||
public AsyncRelayCommand DirectStopCommand { get; }
|
||||
@@ -227,6 +239,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
SyncSelectedCutDebugTemplate();
|
||||
_ = RebuildRegionOptionsAsync();
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
DirectPrepareCommand.NotifyCanExecuteChanged();
|
||||
DirectStartCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
@@ -240,6 +253,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
if (SetProperty(ref _selectedRegionOption, value))
|
||||
{
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
DirectPrepareCommand.NotifyCanExecuteChanged();
|
||||
DirectStartCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
@@ -324,13 +338,41 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
public string AdapterStateText => $"Tornado 상태: {AdapterStateLabel}";
|
||||
|
||||
public string TransmissionLabel => Queue.Any(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)
|
||||
public bool IsPlaying => Queue.Any(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)
|
||||
|| _engine.ActivePlaybackItem is not null;
|
||||
|
||||
public Brush PlaybackIconBrush => IsPlaying ? PlaybackActiveIconBrush : PlaybackIdleIconBrush;
|
||||
|
||||
public string TransmissionLabel => _engine.ActivePlaybackItem?.State == ScheduleQueueItemState.Sending
|
||||
? "준비"
|
||||
: IsPlaying
|
||||
? "송출 중"
|
||||
: "대기";
|
||||
|
||||
public string CurrentItemName => Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.DisplayName ?? "대기 화면";
|
||||
public string CurrentItemName => CurrentPlaybackItem?.DisplayName ?? "대기 화면";
|
||||
|
||||
public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "다음 컷 없음";
|
||||
public string NextItemName => InternalNextPlaybackItem?.InternalNextPreviewDisplayName
|
||||
?? QueueNextPlaybackItem?.DisplayName
|
||||
?? "다음 컷 없음";
|
||||
|
||||
public ImageSource? CurrentPreviewSource => CurrentPlaybackItem?.PreviewSource;
|
||||
|
||||
public ImageSource? NextPreviewSource => InternalNextPlaybackItem?.InternalNextPreviewSource
|
||||
?? QueueNextPlaybackItem?.PreviewSource;
|
||||
|
||||
public string CurrentPreviewStatusLabel => CurrentPlaybackItem?.PreviewStatusLabel ?? "송출 중인 컷 없음";
|
||||
|
||||
public string NextPreviewStatusLabel => InternalNextPlaybackItem?.InternalNextPreviewStatusLabel
|
||||
?? QueueNextPlaybackItem?.PreviewStatusLabel
|
||||
?? "다음 컷 없음";
|
||||
|
||||
public double CurrentPreviewWidth => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Width;
|
||||
|
||||
public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height;
|
||||
|
||||
public double NextPreviewWidth => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Width;
|
||||
|
||||
public double NextPreviewHeight => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Height;
|
||||
|
||||
public int QueuedItemCount => Queue.Count(item => item.State == ScheduleQueueItemState.Queued);
|
||||
|
||||
@@ -340,8 +382,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
get
|
||||
{
|
||||
var current = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.DisplayName ?? "-";
|
||||
var next = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "-";
|
||||
var current = CurrentPlaybackItem?.DisplayName ?? "-";
|
||||
var next = InternalNextPlaybackItem?.InternalNextPreviewDisplayName
|
||||
?? QueueNextPlaybackItem?.DisplayName
|
||||
?? "-";
|
||||
return $"현재 {current} / 다음 {next} / 대기 {Queue.Count(item => item.State == ScheduleQueueItemState.Queued)}";
|
||||
}
|
||||
}
|
||||
@@ -428,6 +472,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
public double SelectedFormatThumbnailHeight => _selectedFormatThumbnailHeight;
|
||||
|
||||
private ChannelScheduleItem? CurrentPlaybackItem =>
|
||||
_engine.ActivePlaybackItem;
|
||||
|
||||
private ChannelScheduleItem? InternalNextPlaybackItem =>
|
||||
CurrentPlaybackItem is { HasInternalNextPreview: true } item ? item : null;
|
||||
|
||||
private ChannelScheduleItem? QueueNextPlaybackItem =>
|
||||
Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next);
|
||||
|
||||
private ChannelScheduleItem? NextPlaybackItem =>
|
||||
InternalNextPlaybackItem ?? QueueNextPlaybackItem;
|
||||
|
||||
public async Task RefreshRegionOptionsAsync()
|
||||
{
|
||||
await RebuildRegionOptionsAsync();
|
||||
@@ -466,6 +522,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
_videoWallLayoutPreset = videoWallLayoutPreset;
|
||||
UpdateSelectedFormatThumbnailMetrics();
|
||||
ApplyQueueThumbnailLayouts();
|
||||
NotifyPlaybackPreviewChanged();
|
||||
}
|
||||
|
||||
private async Task StartAsync()
|
||||
@@ -475,6 +532,21 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
_logService.Info($"[{Title}] 큐를 시작");
|
||||
}
|
||||
|
||||
private async Task PrepareScheduleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _engine.PrepareNextAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 스케줄 다음 컷 준비");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
RefreshSummary();
|
||||
_logService.Error($"[{Title}] Schedule prepare failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopAsync()
|
||||
{
|
||||
await _engine.StopAsync().ConfigureAwait(false);
|
||||
@@ -482,6 +554,57 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
_logService.Info($"[{Title}] 큐를 종료");
|
||||
}
|
||||
|
||||
private async Task DirectPrepareAsync()
|
||||
{
|
||||
var selectedFormat = SelectedFormat;
|
||||
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
|
||||
if (selectedFormat is null || regionOption is null)
|
||||
{
|
||||
_logService.Warning($"[{Title}] 바로 송출 준비할 컷과 지역을 먼저 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
|
||||
_directPlaybackCts?.Cancel();
|
||||
_directPlaybackCts?.Dispose();
|
||||
|
||||
var prepareCts = new CancellationTokenSource();
|
||||
_directPlaybackCts = prepareCts;
|
||||
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
|
||||
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
|
||||
selectedFormat,
|
||||
_videoWallLayoutPreset,
|
||||
ThumbnailDisplayContext.Queue));
|
||||
_preparedDirectItem = item;
|
||||
_preparedDirectFormatId = selectedFormat.Id;
|
||||
_preparedDirectRegionKey = BuildRegionOptionKey(regionOption);
|
||||
|
||||
try
|
||||
{
|
||||
_logService.Info($"[{Title}] 선택 컷 준비: {selectedFormat.Name} / {regionOption.Label}");
|
||||
await _engine.PrepareDirectAsync(item, selectedFormat, prepareCts.Token).ConfigureAwait(false);
|
||||
if (!prepareCts.IsCancellationRequested)
|
||||
{
|
||||
_logService.Info($"[{Title}] 선택 컷 준비 완료: {selectedFormat.Name}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
ClearPreparedDirectState(item);
|
||||
_logService.Error($"[{Title}] 선택 컷 준비 실패: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_directPlaybackCts, prepareCts))
|
||||
{
|
||||
_directPlaybackCts = null;
|
||||
}
|
||||
|
||||
prepareCts.Dispose();
|
||||
RefreshSummary();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DirectStartAsync()
|
||||
{
|
||||
var selectedFormat = SelectedFormat;
|
||||
@@ -492,22 +615,24 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
|
||||
var preparedItem = ResolvePreparedDirectItem(selectedFormat, regionOption);
|
||||
if (preparedItem is null)
|
||||
{
|
||||
_logService.Warning($"[{Title}] 현재 단계에서는 '{selectedFormat.Name}' 컷을 바로 송출할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
|
||||
_directPlaybackCts?.Cancel();
|
||||
_directPlaybackCts?.Dispose();
|
||||
}
|
||||
|
||||
var playbackCts = new CancellationTokenSource();
|
||||
_directPlaybackCts = playbackCts;
|
||||
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
|
||||
var item = preparedItem ?? ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
|
||||
if (preparedItem is null)
|
||||
{
|
||||
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
|
||||
selectedFormat,
|
||||
_videoWallLayoutPreset,
|
||||
ThumbnailDisplayContext.Queue));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -524,6 +649,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearPreparedDirectState(item);
|
||||
if (ReferenceEquals(_directPlaybackCts, playbackCts))
|
||||
{
|
||||
_directPlaybackCts = null;
|
||||
@@ -538,10 +664,49 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
_directPlaybackCts?.Cancel();
|
||||
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
|
||||
ClearPreparedDirectState(_preparedDirectItem);
|
||||
_engine.ClearDirectPlayback();
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 선택 컷 송출 정지");
|
||||
}
|
||||
|
||||
private ChannelScheduleItem? ResolvePreparedDirectItem(
|
||||
FormatTemplateDefinition selectedFormat,
|
||||
ScheduleRegionOption regionOption)
|
||||
{
|
||||
if (_preparedDirectItem is null ||
|
||||
!_engine.IsPreparedItem(_preparedDirectItem) ||
|
||||
!string.Equals(_preparedDirectFormatId, selectedFormat.Id, StringComparison.Ordinal) ||
|
||||
!string.Equals(_preparedDirectRegionKey, BuildRegionOptionKey(regionOption), StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _preparedDirectItem;
|
||||
}
|
||||
|
||||
private void ClearPreparedDirectState(ChannelScheduleItem? item)
|
||||
{
|
||||
if (item is not null && !ReferenceEquals(_preparedDirectItem, item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_preparedDirectItem = null;
|
||||
_preparedDirectFormatId = string.Empty;
|
||||
_preparedDirectRegionKey = string.Empty;
|
||||
}
|
||||
|
||||
private static string BuildRegionOptionKey(ScheduleRegionOption regionOption)
|
||||
{
|
||||
return string.Join(
|
||||
"\u001F",
|
||||
regionOption.Scope,
|
||||
regionOption.ElectionType ?? string.Empty,
|
||||
regionOption.Label ?? string.Empty,
|
||||
regionOption.DistrictCode ?? string.Empty);
|
||||
}
|
||||
|
||||
private async Task ForceNextAsync()
|
||||
{
|
||||
await _engine.ForceNextAsync().ConfigureAwait(false);
|
||||
@@ -563,12 +728,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
|
||||
{
|
||||
_logService.Warning($"[{Title}] 현재 단계에서는 '{SelectedFormat.Name}' 컷을 추가할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
|
||||
if (regionOption is null)
|
||||
{
|
||||
@@ -675,9 +834,19 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
nameof(AdapterState),
|
||||
nameof(AdapterStateText),
|
||||
nameof(AdapterStateLabel),
|
||||
nameof(IsPlaying),
|
||||
nameof(PlaybackIconBrush),
|
||||
nameof(TransmissionLabel),
|
||||
nameof(CurrentItemName),
|
||||
nameof(NextItemName),
|
||||
nameof(CurrentPreviewSource),
|
||||
nameof(NextPreviewSource),
|
||||
nameof(CurrentPreviewStatusLabel),
|
||||
nameof(NextPreviewStatusLabel),
|
||||
nameof(CurrentPreviewWidth),
|
||||
nameof(CurrentPreviewHeight),
|
||||
nameof(NextPreviewWidth),
|
||||
nameof(NextPreviewHeight),
|
||||
nameof(QueuedItemCount),
|
||||
nameof(QueueFootnote),
|
||||
nameof(QueueSummary),
|
||||
@@ -691,7 +860,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
private bool CanAddFormat()
|
||||
{
|
||||
return SelectedFormat is not null &&
|
||||
SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase) &&
|
||||
SelectedRegionOption is not null;
|
||||
}
|
||||
|
||||
@@ -716,7 +884,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
var selectedFormatId = SelectedFormat?.Id;
|
||||
var selectedCategory = SelectedFormatCategoryOption?.Value;
|
||||
var filteredFormats = _allFormats
|
||||
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
|
||||
.Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
|
||||
.ToArray();
|
||||
|
||||
@@ -738,6 +905,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
UpdateSelectedFormatThumbnailMetrics();
|
||||
SyncSelectedCutDebugTemplate();
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
DirectPrepareCommand.NotifyCanExecuteChanged();
|
||||
DirectStartCommand.NotifyCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(QueueFootnote));
|
||||
}
|
||||
@@ -745,10 +913,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
private void RebuildFormatCategoryOptions()
|
||||
{
|
||||
var selectedCategory = SelectedFormatCategoryOption?.Value;
|
||||
var formatsInCurrentPhase = _allFormats
|
||||
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
|
||||
.ToArray();
|
||||
var options = CreateFormatCategoryOptions(formatsInCurrentPhase);
|
||||
var options = CreateFormatCategoryOptions(_allFormats);
|
||||
|
||||
FormatCategoryOptions.Clear();
|
||||
foreach (var option in options)
|
||||
@@ -817,6 +982,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
SelectedRegionOption = null;
|
||||
_lastRegionOptionFormatId = string.Empty;
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
DirectPrepareCommand.NotifyCanExecuteChanged();
|
||||
DirectStartCommand.NotifyCanExecuteChanged();
|
||||
return;
|
||||
}
|
||||
@@ -844,6 +1010,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
|
||||
_lastRegionOptionFormatId = selectedFormat.Id;
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
DirectPrepareCommand.NotifyCanExecuteChanged();
|
||||
DirectStartCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
@@ -897,7 +1064,87 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.OldItems is not null)
|
||||
{
|
||||
foreach (var item in e.OldItems.OfType<ChannelScheduleItem>())
|
||||
{
|
||||
item.PropertyChanged -= QueueItem_PropertyChanged;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.NewItems is not null)
|
||||
{
|
||||
foreach (var item in e.NewItems.OfType<ChannelScheduleItem>())
|
||||
{
|
||||
item.PropertyChanged -= QueueItem_PropertyChanged;
|
||||
item.PropertyChanged += QueueItem_PropertyChanged;
|
||||
}
|
||||
}
|
||||
|
||||
ApplyQueueThumbnailLayouts();
|
||||
NotifyPlaybackStateChanged();
|
||||
NotifyPlaybackPreviewChanged();
|
||||
}
|
||||
|
||||
private void QueueItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(ChannelScheduleItem.State)
|
||||
or nameof(ChannelScheduleItem.DisplayName)
|
||||
or nameof(ChannelScheduleItem.CurrentRegionLabel)
|
||||
or nameof(ChannelScheduleItem.PreviewSource)
|
||||
or nameof(ChannelScheduleItem.PreviewStatusLabel)
|
||||
or nameof(ChannelScheduleItem.InternalNextPreviewSource)
|
||||
or nameof(ChannelScheduleItem.InternalNextPreviewStatusLabel)
|
||||
or nameof(ChannelScheduleItem.InternalNextPreviewDisplayName)
|
||||
or nameof(ChannelScheduleItem.HasInternalNextPreview)
|
||||
or nameof(ChannelScheduleItem.ThumbnailSource))
|
||||
{
|
||||
if (e.PropertyName is nameof(ChannelScheduleItem.State)
|
||||
or nameof(ChannelScheduleItem.DisplayName)
|
||||
or nameof(ChannelScheduleItem.CurrentRegionLabel))
|
||||
{
|
||||
NotifyPlaybackStateChanged();
|
||||
}
|
||||
|
||||
NotifyPlaybackPreviewChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyPlaybackStateChanged()
|
||||
{
|
||||
OnPropertyChanged(
|
||||
nameof(IsPlaying),
|
||||
nameof(PlaybackIconBrush),
|
||||
nameof(TransmissionLabel),
|
||||
nameof(CurrentItemName),
|
||||
nameof(NextItemName),
|
||||
nameof(QueuedItemCount),
|
||||
nameof(QueueFootnote),
|
||||
nameof(QueueSummary));
|
||||
}
|
||||
|
||||
private void NotifyPlaybackPreviewChanged()
|
||||
{
|
||||
OnPropertyChanged(
|
||||
nameof(CurrentPreviewSource),
|
||||
nameof(NextPreviewSource),
|
||||
nameof(CurrentPreviewStatusLabel),
|
||||
nameof(NextPreviewStatusLabel),
|
||||
nameof(CurrentPreviewWidth),
|
||||
nameof(CurrentPreviewHeight),
|
||||
nameof(NextPreviewWidth),
|
||||
nameof(NextPreviewHeight));
|
||||
}
|
||||
|
||||
private ThumbnailDisplayMetrics ResolvePlaybackPreviewMetrics(ChannelScheduleItem? item)
|
||||
{
|
||||
var template = item is null
|
||||
? null
|
||||
: _allFormats.FirstOrDefault(format => string.Equals(format.Id, item.FormatId, StringComparison.Ordinal));
|
||||
|
||||
return template is null
|
||||
? ThumbnailLayoutResolver.ResolveDisplayMetrics(Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.PlaybackPreview)
|
||||
: ThumbnailLayoutResolver.ResolveDisplayMetrics(template, _videoWallLayoutPreset, ThumbnailDisplayContext.PlaybackPreview);
|
||||
}
|
||||
|
||||
private static ScheduleRegionOption? ResolvePreferredRegionOption(
|
||||
@@ -948,11 +1195,35 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
private static bool UsesAllDefaultRegionScope(FormatTemplateDefinition format)
|
||||
{
|
||||
if (IsBottomTurnoutSidoFormat(format))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsBottomTurnoutDistrictFormat(format))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var source = $"{format.Name} {format.Id}";
|
||||
return source.Contains("광역단체장", StringComparison.Ordinal) ||
|
||||
source.Contains("교육감", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsBottomTurnoutSidoFormat(FormatTemplateDefinition format)
|
||||
{
|
||||
return format.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||
(string.Equals(format.Name, "사전투표율_시도", StringComparison.Ordinal) ||
|
||||
string.Equals(format.Name, "투표율_시도", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool IsBottomTurnoutDistrictFormat(FormatTemplateDefinition format)
|
||||
{
|
||||
return format.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||
(string.Equals(format.Name, "사전투표율_시군구", StringComparison.Ordinal) ||
|
||||
string.Equals(format.Name, "투표율_시군구", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
|
||||
{
|
||||
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
|
||||
|
||||
@@ -6,6 +6,8 @@ public sealed class DistrictOverviewCardViewModel
|
||||
{
|
||||
public required string DistrictViewName { get; init; }
|
||||
|
||||
public string DistrictCode { get; init; } = string.Empty;
|
||||
|
||||
public required string RegionName { get; init; }
|
||||
|
||||
public required string CountedRateDisplay { get; init; }
|
||||
|
||||
@@ -22,6 +22,8 @@ public sealed class MainViewModel : ObservableObject
|
||||
{
|
||||
private static readonly Brush ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen);
|
||||
private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed);
|
||||
private static readonly Brush DataReceivingNavigationBrush = new SolidColorBrush(Colors.LimeGreen);
|
||||
private static readonly Brush DataWaitingNavigationBrush = new SolidColorBrush(Colors.White);
|
||||
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
|
||||
private FormatCatalogService _formatCatalogService;
|
||||
private readonly AppStateStore _stateStore;
|
||||
@@ -35,7 +37,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
private bool _isSituationRoomExpanded;
|
||||
private bool _suppressAutomaticSave;
|
||||
private bool _isSyncingQueuedCutDurations;
|
||||
private CancellationTokenSource? _automaticSaveCts;
|
||||
private int _automaticSaveRevision;
|
||||
private int? _windowX;
|
||||
private int? _windowY;
|
||||
private int? _windowWidth;
|
||||
@@ -85,7 +87,11 @@ public sealed class MainViewModel : ObservableObject
|
||||
_cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled);
|
||||
Settings.PropertyChanged += Settings_PropertyChanged;
|
||||
Data.PropertyChanged += Data_PropertyChanged;
|
||||
_sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath, _cutDebugStateStore);
|
||||
_sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(
|
||||
_logService,
|
||||
() => Settings.ImageRootPath,
|
||||
_cutDebugStateStore,
|
||||
Settings.GetKarismaLayerNo);
|
||||
|
||||
NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀", _sharedTornadoAdapter);
|
||||
TopLeftChannel = CreateChannelViewModel(BroadcastChannel.TopLeft, "좌상단", _sharedTornadoAdapter);
|
||||
@@ -208,6 +214,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
nameof(VideoWallVisibility),
|
||||
nameof(PreElectionDataVisibility),
|
||||
nameof(DataVisibility),
|
||||
nameof(CareerPromiseDataVisibility),
|
||||
nameof(CutListVisibility),
|
||||
nameof(SettingsVisibility),
|
||||
nameof(LogVisibility),
|
||||
@@ -226,6 +233,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
AppPage.PreElectionData => "사전데이터",
|
||||
AppPage.TurnoutData => "투표데이터",
|
||||
AppPage.CountingData or AppPage.Data => "개표데이터",
|
||||
AppPage.CareerPromiseData => "공약데이터",
|
||||
AppPage.CutList => "컷리스트",
|
||||
AppPage.Settings => "설정",
|
||||
AppPage.Log => "로그",
|
||||
@@ -270,6 +278,8 @@ public sealed class MainViewModel : ObservableObject
|
||||
|
||||
public Visibility DataVisibility => IsLiveDataPage(CurrentPage) ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public Visibility CareerPromiseDataVisibility => CurrentPage == AppPage.CareerPromiseData ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public Visibility CutListVisibility => CurrentPage == AppPage.CutList ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public Visibility SettingsVisibility => CurrentPage == AppPage.Settings ? Visibility.Visible : Visibility.Collapsed;
|
||||
@@ -398,6 +408,10 @@ public sealed class MainViewModel : ObservableObject
|
||||
|
||||
public Brush CgIntegrationBrush => IsCgConnected ? ConnectedStatusBrush : DisconnectedStatusBrush;
|
||||
|
||||
public Brush DataNavigationIconBrush => Data.HasLiveDataSignal
|
||||
? DataReceivingNavigationBrush
|
||||
: DataWaitingNavigationBrush;
|
||||
|
||||
public string CgIntegrationDetail
|
||||
{
|
||||
get
|
||||
@@ -502,6 +516,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
"turnout-data" => AppPage.TurnoutData,
|
||||
"counting-data" => AppPage.CountingData,
|
||||
"data" => Data.IsPreElectionPhase ? AppPage.TurnoutData : AppPage.CountingData,
|
||||
"career-promises" => AppPage.CareerPromiseData,
|
||||
"cut-list" => AppPage.CutList,
|
||||
"settings" => AppPage.Settings,
|
||||
"log" => AppPage.Log,
|
||||
@@ -509,6 +524,11 @@ public sealed class MainViewModel : ObservableObject
|
||||
};
|
||||
|
||||
CurrentPage = targetPage;
|
||||
if (targetPage == AppPage.CareerPromiseData)
|
||||
{
|
||||
Data.EnsureCareerPromiseElectionType();
|
||||
}
|
||||
|
||||
SyncBroadcastPhaseForLiveDataPage(targetPage);
|
||||
}
|
||||
|
||||
@@ -532,7 +552,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
var targetPhase = page switch
|
||||
{
|
||||
AppPage.TurnoutData => BroadcastPhase.PreElection,
|
||||
AppPage.CountingData or AppPage.Data => BroadcastPhase.Counting,
|
||||
AppPage.CountingData or AppPage.Data or AppPage.CareerPromiseData => BroadcastPhase.Counting,
|
||||
_ => (BroadcastPhase?)null
|
||||
};
|
||||
|
||||
@@ -688,9 +708,14 @@ public sealed class MainViewModel : ObservableObject
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _thumbnailGeneratorService
|
||||
.GenerateAsync(
|
||||
_formatCatalogService.GetAll(),
|
||||
var templates = _formatCatalogService.GetAll();
|
||||
var result = _sharedTornadoAdapter is KarismaTornado3Adapter karismaAdapter
|
||||
? await karismaAdapter.GenerateThumbnailsAsync(
|
||||
templates,
|
||||
Settings.SelectedStationVideoWallLayoutPreset,
|
||||
CancellationToken.None)
|
||||
: await _thumbnailGeneratorService.GenerateAsync(
|
||||
templates,
|
||||
Settings.ImageRootPath,
|
||||
Settings.SelectedStationVideoWallLayoutPreset,
|
||||
CancellationToken.None);
|
||||
@@ -767,6 +792,11 @@ public sealed class MainViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HeaderStatus));
|
||||
}
|
||||
|
||||
if (args.PropertyName is nameof(DataViewModel.HasLiveDataSignal))
|
||||
{
|
||||
OnPropertyChanged(nameof(DataNavigationIconBrush));
|
||||
}
|
||||
|
||||
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
|
||||
or nameof(DataViewModel.BroadcastPhase)
|
||||
or nameof(DataViewModel.ElectionType)
|
||||
@@ -875,6 +905,10 @@ public sealed class MainViewModel : ObservableObject
|
||||
private void ApplyState(AppState state)
|
||||
{
|
||||
Settings.IsDebugFeaturesEnabled = state.IsDebugFeaturesEnabled;
|
||||
Settings.NormalLayerNo = state.NormalLayerNo;
|
||||
Settings.TopLeftLayerNo = state.TopLeftLayerNo;
|
||||
Settings.BottomLayerNo = state.BottomLayerNo;
|
||||
Settings.VideoWallLayerNo = state.VideoWallLayerNo;
|
||||
|
||||
if (RestoreSelection.RestoreStations)
|
||||
{
|
||||
@@ -956,54 +990,31 @@ public sealed class MainViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
CancelPendingAutomaticSave();
|
||||
|
||||
var automaticSaveCts = new CancellationTokenSource();
|
||||
_automaticSaveCts = automaticSaveCts;
|
||||
_ = RunAutomaticSaveAsync(automaticSaveCts);
|
||||
var revision = Interlocked.Increment(ref _automaticSaveRevision);
|
||||
_ = RunAutomaticSaveAsync(revision);
|
||||
}
|
||||
|
||||
private async Task RunAutomaticSaveAsync(CancellationTokenSource automaticSaveCts)
|
||||
private async Task RunAutomaticSaveAsync(int revision)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cancellationToken = automaticSaveCts.Token;
|
||||
await Task.Delay(AutomaticSaveDelay, cancellationToken).ConfigureAwait(false);
|
||||
if (_suppressAutomaticSave || cancellationToken.IsCancellationRequested)
|
||||
await Task.Delay(AutomaticSaveDelay).ConfigureAwait(false);
|
||||
if (_suppressAutomaticSave || revision != Volatile.Read(ref _automaticSaveRevision))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SaveStateCoreAsync(writeLog: false).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning($"자동 저장 실패: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_automaticSaveCts, automaticSaveCts))
|
||||
{
|
||||
_automaticSaveCts = null;
|
||||
}
|
||||
|
||||
automaticSaveCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelPendingAutomaticSave()
|
||||
{
|
||||
if (_automaticSaveCts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_automaticSaveCts.Cancel();
|
||||
_automaticSaveCts.Dispose();
|
||||
_automaticSaveCts = null;
|
||||
Interlocked.Increment(ref _automaticSaveRevision);
|
||||
}
|
||||
|
||||
private async Task SaveStateCoreAsync(bool writeLog)
|
||||
@@ -1024,6 +1035,10 @@ public sealed class MainViewModel : ObservableObject
|
||||
WindowHeight = _windowHeight ?? 0,
|
||||
IsWindowMaximized = _isWindowMaximized,
|
||||
IsDebugFeaturesEnabled = Settings.IsDebugFeaturesEnabled,
|
||||
NormalLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.Normal),
|
||||
TopLeftLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.TopLeft),
|
||||
BottomLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.Bottom),
|
||||
VideoWallLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.VideoWall),
|
||||
OperationMode = OperationMode.ToString(),
|
||||
BroadcastPhase = Data.BroadcastPhase.ToString(),
|
||||
IsPollingEnabled = Data.IsPollingEnabled,
|
||||
@@ -1094,6 +1109,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
Data,
|
||||
Settings.BuildSelectedStationProfile,
|
||||
() => Settings.ImageRootPath,
|
||||
() => Settings.SelectedStationVideoWallLayoutPreset,
|
||||
formatId => _formatCatalogService.FindById(formatId),
|
||||
_logService);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using Tornado3_2026Election.Common;
|
||||
@@ -11,12 +12,17 @@ public sealed class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private string _selectedStationId = "KNN";
|
||||
private string _imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
|
||||
private bool _isDebugFeaturesEnabled = true;
|
||||
private bool _isDebugFeaturesEnabled;
|
||||
private int _normalLayerNo;
|
||||
private int _topLeftLayerNo = 1;
|
||||
private int _bottomLayerNo = 2;
|
||||
private int _videoWallLayerNo;
|
||||
private readonly IReadOnlyList<SelectionOption<VideoWallLayoutPreset>> _videoWallLayoutOptions =
|
||||
[
|
||||
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Auto, "자동"),
|
||||
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Standard5760x1080, "5760 x 1080"),
|
||||
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.UltraWide11520x1080, "11520 x 1080")
|
||||
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Wall3840x810, "3840 x 810"),
|
||||
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Wall2880x1080, "2880 x 1080"),
|
||||
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.UltraWide8316x1080, "8316 x 1080")
|
||||
];
|
||||
|
||||
public SettingsViewModel(IEnumerable<BroadcastStationProfile> stations)
|
||||
@@ -84,6 +90,30 @@ public sealed class SettingsViewModel : ObservableObject
|
||||
set => SetProperty(ref _isDebugFeaturesEnabled, value);
|
||||
}
|
||||
|
||||
public double NormalLayerNo
|
||||
{
|
||||
get => _normalLayerNo;
|
||||
set => SetLayerNo(ref _normalLayerNo, value, nameof(NormalLayerNo));
|
||||
}
|
||||
|
||||
public double TopLeftLayerNo
|
||||
{
|
||||
get => _topLeftLayerNo;
|
||||
set => SetLayerNo(ref _topLeftLayerNo, value, nameof(TopLeftLayerNo));
|
||||
}
|
||||
|
||||
public double BottomLayerNo
|
||||
{
|
||||
get => _bottomLayerNo;
|
||||
set => SetLayerNo(ref _bottomLayerNo, value, nameof(BottomLayerNo));
|
||||
}
|
||||
|
||||
public double VideoWallLayerNo
|
||||
{
|
||||
get => _videoWallLayerNo;
|
||||
set => SetLayerNo(ref _videoWallLayerNo, value, nameof(VideoWallLayerNo));
|
||||
}
|
||||
|
||||
public StationFilterItemViewModel SelectedStation
|
||||
=> Stations.FirstOrDefault(station => station.Id == SelectedStationId) ?? Stations[0];
|
||||
|
||||
@@ -125,9 +155,35 @@ public sealed class SettingsViewModel : ObservableObject
|
||||
|
||||
public string SelectedStationVideoWallLayoutSummary => SelectedStation.VideoWallLayoutSummary;
|
||||
|
||||
public int GetKarismaLayerNo(BroadcastChannel channel)
|
||||
{
|
||||
return channel switch
|
||||
{
|
||||
BroadcastChannel.TopLeft => _topLeftLayerNo,
|
||||
BroadcastChannel.Bottom => _bottomLayerNo,
|
||||
BroadcastChannel.VideoWall => _videoWallLayerNo,
|
||||
_ => _normalLayerNo
|
||||
};
|
||||
}
|
||||
|
||||
public BroadcastStationProfile BuildSelectedStationProfile()
|
||||
{
|
||||
return SelectedStation.ToProfile();
|
||||
}
|
||||
|
||||
private void SetLayerNo(ref int field, double value, string propertyName)
|
||||
{
|
||||
SetProperty(ref field, NormalizeLayerNo(value), propertyName);
|
||||
}
|
||||
|
||||
private static int NormalizeLayerNo(double value)
|
||||
{
|
||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.Clamp((int)Math.Round(value, MidpointRounding.AwayFromZero), 0, 99);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,8 +118,9 @@ public sealed class StationFilterItemViewModel : ObservableObject
|
||||
|
||||
public string VideoWallLayoutSummary => VideoWallLayoutPreset switch
|
||||
{
|
||||
VideoWallLayoutPreset.Standard5760x1080 => "5760 x 1080 비디오월",
|
||||
VideoWallLayoutPreset.UltraWide11520x1080 => "11520 x 1080 비디오월",
|
||||
VideoWallLayoutPreset.Wall3840x810 => "3840 x 810 VideoWall",
|
||||
VideoWallLayoutPreset.Wall2880x1080 => "2880 x 1080 VideoWall",
|
||||
VideoWallLayoutPreset.UltraWide8316x1080 => "8316 x 1080 VideoWall",
|
||||
_ => "씬 기준 자동 감지"
|
||||
};
|
||||
|
||||
|
||||
BIN
outputs/color-audit/color_match_audit_list.xlsx
Normal file
886
tools/CreateDesignIssuePpt.py
Normal 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()
|
||||
1984
tools/KarismaTcpProbe/CutFileAudit.cs
Normal file
@@ -43,6 +43,7 @@
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaChartCellUpdate.cs" Link="AppSource\Services\KarismaChartCellUpdate.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCropKeyUpdate.cs" Link="AppSource\Services\KarismaCropKeyUpdate.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaPositionUpdate.cs" Link="AppSource\Services\KarismaPositionUpdate.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolutionReader.cs" Link="AppSource\Services\KarismaSceneResolutionReader.cs" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using KAsyncEngineLib;
|
||||
@@ -319,6 +321,12 @@ if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComp
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && string.Equals(args[0], "--audit-party-colors-live", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Environment.ExitCode = await LiveCutValidation.RunPartyColorAuditAsync(args[1..]).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && string.Equals(args[0], "--validate-current-api-cuts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Environment.ExitCode = await CurrentApiCutDiagnostics.RunAsync(args[1..]).ConfigureAwait(false);
|
||||
@@ -337,6 +345,12 @@ if (args.Length > 0 && string.Equals(args[0], "--report-cut-debug-coverage", Str
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && string.Equals(args[0], "--audit-cut-files", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Environment.ExitCode = await CutFileAudit.RunAsync(args[1..]).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var options = ProbeOptions.Parse(args);
|
||||
|
||||
Console.WriteLine($"Karisma TCP probe starting. target={options.Host}:{options.Port} timeout={options.Timeout.TotalSeconds:0}s");
|
||||
@@ -862,6 +876,33 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<SceneValidationOperation> operations = string.IsNullOrWhiteSpace(options.OperationsPath)
|
||||
? Array.Empty<SceneValidationOperation>()
|
||||
: LoadSceneOperations(options.ScenePath, options.OperationsPath);
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
var operationResult = ApplySceneOperation(handler, scene!, operation, options.Connection.Timeout);
|
||||
if (!string.Equals(operationResult.Result, eKResult.RESULT_SUCCESS.ToString(), StringComparison.Ordinal))
|
||||
{
|
||||
if (operation.ContinueOnFailure)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[SAVE-IMAGE] Optional operation {operationResult.Method} failed for '{operationResult.ObjectName}': " +
|
||||
$"{operationResult.Result} {operationResult.Detail}");
|
||||
continue;
|
||||
}
|
||||
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(
|
||||
true,
|
||||
"SUCCESS",
|
||||
operationResult.Result,
|
||||
options.OutputPath,
|
||||
$"Operation {operationResult.Method} failed for '{operationResult.ObjectName}': {operationResult.Detail}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.MaterialOpacity is not null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
@@ -976,31 +1017,38 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
||||
}
|
||||
}
|
||||
|
||||
var positionKeyUpdates = new List<PositionKeyUpdate>();
|
||||
if (options.PositionKey is not null)
|
||||
{
|
||||
positionKeyUpdates.Add(options.PositionKey);
|
||||
}
|
||||
|
||||
positionKeyUpdates.AddRange(options.PositionKeys);
|
||||
foreach (var positionKeyUpdate in positionKeyUpdates)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[SAVE-IMAGE] Setting position key object={options.PositionKey.ObjectName} index={options.PositionKey.KeyIndex} " +
|
||||
$"value=({options.PositionKey.X},{options.PositionKey.Y},{options.PositionKey.Z}) vector={options.PositionKey.VectorType}...");
|
||||
var sceneObject = scene.GetObject(options.PositionKey.ObjectName);
|
||||
$"[SAVE-IMAGE] Setting position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
|
||||
$"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
|
||||
var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.PositionKey.ObjectName}' was not found."));
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionKeyUpdate.ObjectName}' was not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
handler.ResetPositionKeyTask();
|
||||
sceneObject.SetPositionKey(
|
||||
options.PositionKey.KeyIndex,
|
||||
options.PositionKey.X,
|
||||
options.PositionKey.Y,
|
||||
options.PositionKey.Z,
|
||||
options.PositionKey.VectorType);
|
||||
positionKeyUpdate.KeyIndex,
|
||||
positionKeyUpdate.X,
|
||||
positionKeyUpdate.Y,
|
||||
positionKeyUpdate.Z,
|
||||
positionKeyUpdate.VectorType);
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{options.PositionKey.ObjectName}'." ));
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{positionKeyUpdate.ObjectName}'." ));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1008,7 +1056,7 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
||||
if (positionKeyResult != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={options.PositionKey.ObjectName}"));
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={positionKeyUpdate.ObjectName}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1154,24 +1202,118 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
||||
}
|
||||
}
|
||||
|
||||
var outputDirectory = Path.GetDirectoryName(options.OutputPath);
|
||||
foreach (var positionUpdate in options.PostPositions)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[SAVE-IMAGE] Setting post-chart position object={positionUpdate.ObjectName} " +
|
||||
$"value=({positionUpdate.X},{positionUpdate.Y},{positionUpdate.Z}) vector={positionUpdate.VectorType}...");
|
||||
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionUpdate.ObjectName}' was not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
handler.ResetPositionTask();
|
||||
sceneObject.SetPosition(
|
||||
positionUpdate.X,
|
||||
positionUpdate.Y,
|
||||
positionUpdate.Z,
|
||||
positionUpdate.VectorType);
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.PositionTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{positionUpdate.ObjectName}'." ));
|
||||
return;
|
||||
}
|
||||
|
||||
var positionResult = handler.PositionTask.Result;
|
||||
if (positionResult != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={positionUpdate.ObjectName}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var positionKeyUpdate in options.PostPositionKeys)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[SAVE-IMAGE] Setting post-chart position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
|
||||
$"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
|
||||
var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionKeyUpdate.ObjectName}' was not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
handler.ResetPositionKeyTask();
|
||||
sceneObject.SetPositionKey(
|
||||
positionKeyUpdate.KeyIndex,
|
||||
positionKeyUpdate.X,
|
||||
positionKeyUpdate.Y,
|
||||
positionKeyUpdate.Z,
|
||||
positionKeyUpdate.VectorType);
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{positionKeyUpdate.ObjectName}'." ));
|
||||
return;
|
||||
}
|
||||
|
||||
var positionKeyResult = handler.PositionKeyTask.Result;
|
||||
if (positionKeyResult != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={positionKeyUpdate.ObjectName}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var captures = new List<(string OutputPath, int Frame)>();
|
||||
if (options.Frames.Count > 0)
|
||||
{
|
||||
var captureDirectory = options.OutputDirectory ?? options.OutputPath;
|
||||
foreach (var captureFrame in options.Frames)
|
||||
{
|
||||
captures.Add((
|
||||
Path.GetFullPath(Path.Combine(
|
||||
captureDirectory,
|
||||
string.Format(CultureInfo.InvariantCulture, options.OutputPattern, captureFrame))),
|
||||
captureFrame));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
captures.Add((options.OutputPath, options.Frame));
|
||||
}
|
||||
|
||||
long totalBytes = 0;
|
||||
foreach (var capture in captures)
|
||||
{
|
||||
var outputDirectory = Path.GetDirectoryName(capture.OutputPath);
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
if (File.Exists(options.OutputPath))
|
||||
if (File.Exists(capture.OutputPath))
|
||||
{
|
||||
File.Delete(options.OutputPath);
|
||||
File.Delete(capture.OutputPath);
|
||||
}
|
||||
|
||||
Console.WriteLine("[SAVE-IMAGE] Calling SaveSceneImage()...");
|
||||
Console.WriteLine($"[SAVE-IMAGE] Calling SaveSceneImage() frame={capture.Frame} output={capture.OutputPath}...");
|
||||
handler.ResetSaveSceneImageTask();
|
||||
scene.SaveSceneImage(options.OutputPath, options.Width, options.Height, options.Frame);
|
||||
scene.SaveSceneImage(capture.OutputPath, options.Width, options.Height, capture.Frame);
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.SaveSceneImageTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, "OnSaveSceneImage timed out."));
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", capture.OutputPath, "OnSaveSceneImage timed out."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1179,28 +1321,42 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
||||
if (saveResult != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", saveResult.ToString(), options.OutputPath, $"OnSaveSceneImage result={saveResult}"));
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", saveResult.ToString(), capture.OutputPath, $"OnSaveSceneImage result={saveResult}"));
|
||||
return;
|
||||
}
|
||||
|
||||
var savedThisFrame = false;
|
||||
var fileWaitDeadline = DateTime.UtcNow + options.Connection.Timeout;
|
||||
while (DateTime.UtcNow < fileWaitDeadline)
|
||||
{
|
||||
if (File.Exists(options.OutputPath))
|
||||
if (File.Exists(capture.OutputPath))
|
||||
{
|
||||
var info = new FileInfo(options.OutputPath);
|
||||
var info = new FileInfo(capture.OutputPath);
|
||||
if (info.Length > 0)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", options.OutputPath, $"Saved {info.Length} bytes."));
|
||||
return;
|
||||
totalBytes += info.Length;
|
||||
savedThisFrame = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(50);
|
||||
}
|
||||
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, "Image file was not created."));
|
||||
if (!savedThisFrame)
|
||||
{
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", capture.OutputPath, "Image file was not created."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var resultOutput = options.Frames.Count > 0
|
||||
? options.OutputDirectory ?? options.OutputPath
|
||||
: options.OutputPath;
|
||||
var detail = captures.Count == 1
|
||||
? $"Saved {totalBytes} bytes."
|
||||
: $"Saved {captures.Count} frames ({totalBytes} bytes).";
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", resultOutput, detail));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1283,7 +1439,16 @@ static Task<SceneCatalogProbeResult> CatalogScenesAsync(SceneCatalogOptions opti
|
||||
.EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories)
|
||||
.Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) ||
|
||||
path.Contains(options.SceneFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
RelativePath = Path.GetRelativePath(options.RootPath, path)
|
||||
})
|
||||
.OrderBy(item => item.RelativePath.Count(character =>
|
||||
character == Path.DirectorySeparatorChar ||
|
||||
character == Path.AltDirectorySeparatorChar))
|
||||
.ThenBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(item => item.Path)
|
||||
.Take(options.MaxScenes ?? int.MaxValue)
|
||||
.ToArray();
|
||||
|
||||
@@ -2309,142 +2474,7 @@ static Task<SceneValidationProbeResult> ValidateSceneOperationsAsync(SceneValida
|
||||
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
Console.WriteLine($"[VALIDATE] {operation.Method} object={operation.ObjectName}");
|
||||
var sceneObject = scene.GetObject(operation.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"OBJECT_NOT_FOUND",
|
||||
"scene.GetObject returned null."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (sceneObject is not IKACounter counter)
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"NOT_A_COUNTER",
|
||||
"Object does not implement IKACounter."));
|
||||
continue;
|
||||
}
|
||||
|
||||
handler.ResetCounterNumberKeyTask();
|
||||
counter.SetCounterNumberKey(operation.KeyIndex, operation.Number);
|
||||
if (!WaitForTaskWithMessagePump(handler.CounterNumberKeyTask, options.Connection.Timeout))
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetCounterNumberKey timed out."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var callbackResult = handler.CounterNumberKeyTask.Result;
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
callbackResult.ToString(),
|
||||
string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
handler.ResetVisibleTask();
|
||||
sceneObject.SetVisible(operation.Visible ? 1 : 0);
|
||||
if (!WaitForTaskWithMessagePump(handler.VisibleTask, options.Connection.Timeout))
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetVisible timed out."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var callbackResult = handler.VisibleTask.Result;
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
callbackResult.ToString(),
|
||||
string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetStyleColor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (sceneObject is not IKAStyle style)
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"NOT_A_STYLE_OBJECT",
|
||||
"Object does not implement IKAStyle."));
|
||||
continue;
|
||||
}
|
||||
|
||||
handler.ResetStyleColorTask();
|
||||
style.SetStyleColor(
|
||||
ParseStyleType(operation.StyleType),
|
||||
operation.Order,
|
||||
operation.R,
|
||||
operation.G,
|
||||
operation.B,
|
||||
operation.A);
|
||||
if (!WaitForTaskWithMessagePump(handler.StyleColorTask, options.Connection.Timeout))
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetStyleColor timed out."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var callbackResult = handler.StyleColorTask.Result;
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
callbackResult.ToString(),
|
||||
string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
handler.ResetSetValueTask();
|
||||
sceneObject.SetValue(operation.Value ?? string.Empty);
|
||||
if (!WaitForTaskWithMessagePump(handler.SetValueTask, options.Connection.Timeout))
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetValue timed out."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var setValueResult = handler.SetValueTask.Result;
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
setValueResult.ToString(),
|
||||
string.Empty));
|
||||
results.Add(ApplySceneOperation(handler, scene, operation, options.Connection.Timeout));
|
||||
}
|
||||
|
||||
WriteSceneValidationMarkdown(options, results);
|
||||
@@ -2532,7 +2562,16 @@ static Task<FolderInspectionProbeResult> InspectTscnFolderAsync(FolderInspection
|
||||
.EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories)
|
||||
.Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) ||
|
||||
path.Contains(options.SceneFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
RelativePath = Path.GetRelativePath(options.RootPath, path)
|
||||
})
|
||||
.OrderBy(item => item.RelativePath.Count(character =>
|
||||
character == Path.DirectorySeparatorChar ||
|
||||
character == Path.AltDirectorySeparatorChar))
|
||||
.ThenBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(item => item.Path)
|
||||
.Take(options.MaxScenes ?? int.MaxValue)
|
||||
.ToArray();
|
||||
|
||||
@@ -2865,7 +2904,12 @@ static void WriteFolderInspectionMarkdown(
|
||||
|
||||
static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOptions options)
|
||||
{
|
||||
var json = File.ReadAllText(options.OperationsPath);
|
||||
return LoadSceneOperations(options.ScenePath, options.OperationsPath);
|
||||
}
|
||||
|
||||
static List<SceneValidationOperation> LoadSceneOperations(string scenePath, string operationsPath)
|
||||
{
|
||||
var json = File.ReadAllText(operationsPath);
|
||||
var operations = JsonSerializer.Deserialize<List<SceneValidationOperation>>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
@@ -2875,13 +2919,143 @@ static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOp
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(operation.Value))
|
||||
{
|
||||
operation.Value = operation.Value.Replace("${SCENE_DIR}", Path.GetDirectoryName(options.ScenePath) ?? string.Empty, StringComparison.Ordinal);
|
||||
operation.Value = operation.Value.Replace("${SCENE_DIR}", Path.GetDirectoryName(scenePath) ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
static SceneOperationValidationResult ApplySceneOperation(
|
||||
ProbeEventHandler handler,
|
||||
IKAScene scene,
|
||||
SceneValidationOperation operation,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
Console.WriteLine($"[VALIDATE] {operation.Method} object={operation.ObjectName}");
|
||||
var sceneObject = scene.GetObject(operation.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"OBJECT_NOT_FOUND",
|
||||
"scene.GetObject returned null.");
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (sceneObject is not IKACounter counter)
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"NOT_A_COUNTER",
|
||||
"Object does not implement IKACounter.");
|
||||
}
|
||||
|
||||
handler.ResetCounterNumberKeyTask();
|
||||
counter.SetCounterNumberKey(operation.KeyIndex, operation.Number);
|
||||
if (!WaitForTaskWithMessagePump(handler.CounterNumberKeyTask, timeout))
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetCounterNumberKey timed out.");
|
||||
}
|
||||
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
handler.CounterNumberKeyTask.Result.ToString(),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
handler.ResetVisibleTask();
|
||||
sceneObject.SetVisible(operation.Visible ? 1 : 0);
|
||||
if (!WaitForTaskWithMessagePump(handler.VisibleTask, timeout))
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetVisible timed out.");
|
||||
}
|
||||
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
handler.VisibleTask.Result.ToString(),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetStyleColor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (sceneObject is not IKAStyle style)
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"NOT_A_STYLE_OBJECT",
|
||||
"Object does not implement IKAStyle.");
|
||||
}
|
||||
|
||||
handler.ResetStyleColorTask();
|
||||
style.SetStyleColor(
|
||||
ParseStyleType(operation.StyleType),
|
||||
operation.Order,
|
||||
operation.R,
|
||||
operation.G,
|
||||
operation.B,
|
||||
operation.A);
|
||||
if (!WaitForTaskWithMessagePump(handler.StyleColorTask, timeout))
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetStyleColor timed out.");
|
||||
}
|
||||
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
handler.StyleColorTask.Result.ToString(),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
handler.ResetSetValueTask();
|
||||
sceneObject.SetValue(operation.Value ?? string.Empty);
|
||||
if (!WaitForTaskWithMessagePump(handler.SetValueTask, timeout))
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetValue timed out.");
|
||||
}
|
||||
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
handler.SetValueTask.Result.ToString(),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
static string DescribeOperationPayload(SceneValidationOperation operation)
|
||||
{
|
||||
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -3387,17 +3561,24 @@ internal sealed record SaveSceneImageOptions(
|
||||
int Width,
|
||||
int Height,
|
||||
int Frame,
|
||||
IReadOnlyList<int> Frames,
|
||||
string? OutputDirectory,
|
||||
string OutputPattern,
|
||||
string? SetObjectName,
|
||||
string? SetObjectValue,
|
||||
string? VisibleObjectName,
|
||||
bool? VisibleObjectValue,
|
||||
VariableNameUpdate? VariableName,
|
||||
CloneObjectUpdate? CloneObject,
|
||||
string? OperationsPath,
|
||||
MaterialOpacityUpdate? MaterialOpacity,
|
||||
SizeUpdate? Size,
|
||||
PositionUpdate? Position,
|
||||
IReadOnlyList<PositionUpdate> Positions,
|
||||
PositionKeyUpdate? PositionKey,
|
||||
IReadOnlyList<PositionKeyUpdate> PositionKeys,
|
||||
IReadOnlyList<PositionUpdate> PostPositions,
|
||||
IReadOnlyList<PositionKeyUpdate> PostPositionKeys,
|
||||
string? ChartObjectName,
|
||||
string? ChartCsvPath,
|
||||
IReadOnlyList<ChartCellUpdate> ChartCells,
|
||||
@@ -3411,6 +3592,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
string? scenePath = null;
|
||||
string? sceneAlias = null;
|
||||
string? outputPath = null;
|
||||
string? outputDirectory = null;
|
||||
string outputPattern = "frame_{0:D4}.png";
|
||||
IReadOnlyList<int> frames = Array.Empty<int>();
|
||||
string? setObjectName = null;
|
||||
string? setObjectValue = null;
|
||||
string? visibleObjectName = null;
|
||||
@@ -3419,6 +3603,7 @@ internal sealed record SaveSceneImageOptions(
|
||||
string? variableNameValue = null;
|
||||
string? cloneSourceObjectName = null;
|
||||
string? cloneVariableName = null;
|
||||
string? operationsPath = null;
|
||||
string? materialOpacityObjectName = null;
|
||||
float? materialOpacityValue = null;
|
||||
string? sizeObjectName = null;
|
||||
@@ -3429,6 +3614,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
string? positionKeyObjectName = null;
|
||||
int positionKeyIndex = 1;
|
||||
string? positionKeyRaw = null;
|
||||
string? positionKeysRaw = null;
|
||||
string? postPositionsRaw = null;
|
||||
string? postPositionKeysRaw = null;
|
||||
string? chartObjectName = null;
|
||||
string? chartCsvPath = null;
|
||||
string? chartCellsRaw = null;
|
||||
@@ -3452,6 +3640,12 @@ internal sealed record SaveSceneImageOptions(
|
||||
case "--output" when index + 1 < args.Length:
|
||||
outputPath = args[++index];
|
||||
break;
|
||||
case "--output-dir" when index + 1 < args.Length:
|
||||
outputDirectory = args[++index];
|
||||
break;
|
||||
case "--output-pattern" when index + 1 < args.Length:
|
||||
outputPattern = args[++index];
|
||||
break;
|
||||
case "--set-object" when index + 1 < args.Length:
|
||||
setObjectName = args[++index];
|
||||
break;
|
||||
@@ -3481,6 +3675,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
case "--clone-name" when index + 1 < args.Length:
|
||||
cloneVariableName = args[++index];
|
||||
break;
|
||||
case "--operations" when index + 1 < args.Length:
|
||||
operationsPath = args[++index];
|
||||
break;
|
||||
case "--material-opacity-object" when index + 1 < args.Length:
|
||||
materialOpacityObjectName = args[++index];
|
||||
break;
|
||||
@@ -3513,6 +3710,15 @@ internal sealed record SaveSceneImageOptions(
|
||||
case "--position-key" when index + 1 < args.Length:
|
||||
positionKeyRaw = args[++index];
|
||||
break;
|
||||
case "--position-keys" when index + 1 < args.Length:
|
||||
positionKeysRaw = args[++index];
|
||||
break;
|
||||
case "--post-positions" when index + 1 < args.Length:
|
||||
postPositionsRaw = args[++index];
|
||||
break;
|
||||
case "--post-position-keys" when index + 1 < args.Length:
|
||||
postPositionKeysRaw = args[++index];
|
||||
break;
|
||||
case "--chart-object" when index + 1 < args.Length:
|
||||
chartObjectName = args[++index];
|
||||
break;
|
||||
@@ -3543,6 +3749,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
frame = parsedFrame;
|
||||
index++;
|
||||
break;
|
||||
case "--frames" when index + 1 < args.Length:
|
||||
frames = ParseFrameSequence(args[++index]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3551,13 +3760,27 @@ internal sealed record SaveSceneImageOptions(
|
||||
throw new ArgumentException("--scene is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
if (frames.Count > 0)
|
||||
{
|
||||
outputDirectory ??= outputPath;
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
throw new ArgumentException("--output-dir is required when --frames is provided.");
|
||||
}
|
||||
|
||||
outputDirectory = Path.GetFullPath(outputDirectory);
|
||||
outputPath ??= outputDirectory;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
throw new ArgumentException("--output is required.");
|
||||
}
|
||||
|
||||
scenePath = Path.GetFullPath(scenePath);
|
||||
outputPath = Path.GetFullPath(outputPath);
|
||||
operationsPath = string.IsNullOrWhiteSpace(operationsPath)
|
||||
? null
|
||||
: Path.GetFullPath(operationsPath);
|
||||
sceneAlias ??= Path.GetFileNameWithoutExtension(scenePath);
|
||||
return new SaveSceneImageOptions(
|
||||
connection,
|
||||
@@ -3567,17 +3790,24 @@ internal sealed record SaveSceneImageOptions(
|
||||
width,
|
||||
height,
|
||||
frame,
|
||||
frames,
|
||||
outputDirectory,
|
||||
outputPattern,
|
||||
setObjectName,
|
||||
setObjectValue,
|
||||
visibleObjectName,
|
||||
visibleObjectValue,
|
||||
ParseVariableName(variableNameObjectName, variableNameValue),
|
||||
ParseCloneObject(cloneSourceObjectName, cloneVariableName),
|
||||
operationsPath,
|
||||
ParseMaterialOpacity(materialOpacityObjectName, materialOpacityValue),
|
||||
ParseSize(sizeObjectName, sizeRaw),
|
||||
ParsePosition(positionObjectName, positionRaw),
|
||||
ParsePositions(positionsRaw),
|
||||
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
|
||||
ParsePositionKeys(positionKeysRaw),
|
||||
ParsePositions(postPositionsRaw),
|
||||
ParsePositionKeys(postPositionKeysRaw),
|
||||
chartObjectName,
|
||||
chartCsvPath,
|
||||
ParseChartCells(chartCellsRaw),
|
||||
@@ -3586,6 +3816,53 @@ internal sealed record SaveSceneImageOptions(
|
||||
ParsePathModifications(modifyPathRaw));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<int> ParseFrameSequence(string value)
|
||||
{
|
||||
var frames = new List<int>();
|
||||
foreach (var token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var rangeMatch = Regex.Match(token, @"^(?<start>-?\d+)-(?<end>-?\d+)(?::(?<step>\d+))?$", RegexOptions.CultureInvariant);
|
||||
if (rangeMatch.Success)
|
||||
{
|
||||
var start = int.Parse(rangeMatch.Groups["start"].Value, CultureInfo.InvariantCulture);
|
||||
var end = int.Parse(rangeMatch.Groups["end"].Value, CultureInfo.InvariantCulture);
|
||||
var step = rangeMatch.Groups["step"].Success
|
||||
? int.Parse(rangeMatch.Groups["step"].Value, CultureInfo.InvariantCulture)
|
||||
: 1;
|
||||
if (step <= 0)
|
||||
{
|
||||
throw new ArgumentException("--frames range step must be greater than zero.");
|
||||
}
|
||||
|
||||
if (start <= end)
|
||||
{
|
||||
for (var frame = start; frame <= end; frame += step)
|
||||
{
|
||||
frames.Add(frame);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var frame = start; frame >= end; frame -= step)
|
||||
{
|
||||
frames.Add(frame);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var singleFrame))
|
||||
{
|
||||
throw new ArgumentException($"Invalid frame token: {token}");
|
||||
}
|
||||
|
||||
frames.Add(singleFrame);
|
||||
}
|
||||
|
||||
return frames.Distinct().ToArray();
|
||||
}
|
||||
|
||||
private static CloneObjectUpdate? ParseCloneObject(string? sourceObjectName, string? variableName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceObjectName) || string.IsNullOrWhiteSpace(variableName))
|
||||
@@ -3724,6 +4001,38 @@ internal sealed record SaveSceneImageOptions(
|
||||
return new PositionKeyUpdate(objectName, keyIndex, x, y, z, vectorType);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PositionKeyUpdate> ParsePositionKeys(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return Array.Empty<PositionKeyUpdate>();
|
||||
}
|
||||
|
||||
var updates = new List<PositionKeyUpdate>();
|
||||
foreach (var token in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var nameParts = token.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||
if (nameParts.Length != 2)
|
||||
{
|
||||
throw new ArgumentException($"Invalid position key update: {token}");
|
||||
}
|
||||
|
||||
var objectAndKey = nameParts[0].Split('#', 2, StringSplitOptions.TrimEntries);
|
||||
if (objectAndKey.Length != 2 || !int.TryParse(objectAndKey[1], out var keyIndex))
|
||||
{
|
||||
throw new ArgumentException($"Invalid position key object/index: {nameParts[0]}");
|
||||
}
|
||||
|
||||
var update = ParsePositionKey(objectAndKey[0], keyIndex, nameParts[1]);
|
||||
if (update is not null)
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ChartCellUpdate> ParseChartCells(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
@@ -3857,7 +4166,7 @@ internal sealed record SceneCatalogOptions(
|
||||
switch (args[index])
|
||||
{
|
||||
case "--root" when index + 1 < args.Length:
|
||||
index++;
|
||||
rootPath = args[++index];
|
||||
break;
|
||||
case "--output" when index + 1 < args.Length:
|
||||
outputPath = args[++index];
|
||||
@@ -4195,7 +4504,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
|
||||
switch (args[index])
|
||||
{
|
||||
case "--root" when index + 1 < args.Length:
|
||||
index++;
|
||||
rootPath = args[++index];
|
||||
break;
|
||||
case "--output" when index + 1 < args.Length:
|
||||
outputPath = args[++index];
|
||||
@@ -4358,6 +4667,8 @@ internal sealed class SceneValidationOperation
|
||||
public int A { get; set; } = 255;
|
||||
|
||||
public bool Visible { get; set; }
|
||||
|
||||
public bool ContinueOnFailure { get; set; }
|
||||
}
|
||||
|
||||
internal sealed record SceneOperationValidationResult(string ObjectName, string Method, string Payload, string Result, string Detail);
|
||||
|
||||
99
tools/msix/Install-ComtrophyMsixCertificate.ps1
Normal 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"
|
||||
}
|
||||
475
tools/msix/Publish-MsixToNas.ps1
Normal 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
@@ -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"
|
||||
```
|
||||