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