컷별 데이터 연계

This commit is contained in:
2026-04-14 17:14:12 +09:00
parent 54c778c75d
commit e0c5f4dbfe
19 changed files with 7826 additions and 33 deletions

31
COUNTER_DEBUG.md Normal file
View File

@@ -0,0 +1,31 @@
# Counter Debug Notes
Date: 2026-04-14
## Summary
- Scene: `1-2위_ani_광역단체장`
- Counter objects: `득표율01`, `득표율02`
- API: `IKACounter.SetCounterNumberKey(KeyIndex, Number)`
- Key index used for this test: `1`
## Current test values
- `득표율01` -> `30`
- `득표율02` -> `20`
## Verification
- `tools/KarismaTcpProbe` supports `--test-counter`.
- Verified against `127.0.0.1:30001`.
- `OnSetCounterNumberKey` returned `RESULT_SUCCESS` for both `득표율01` and `득표율02`.
## Runtime Mapping Update
- `KarismaTornado3Adapter` now maps scene variables from `ElectionDataSnapshot` at send time.
- Candidate text keys such as `후보명NN`, `정당명NN`, `득표수NN`, `득표율NN`, `순위NN`, `표차NN`, `득표차NN` are populated automatically.
- Common scene keys such as `선거구명01`, `시도명01`, `개표율01`, `투표율01`, `전국투표율01` are populated automatically.
- Animated templates with `ani` in the template id or name also send `IKACounter.SetCounterNumberKey(1, voteRate)` for each `득표율NN`.
- `유확당NN` resolves to `유력.vrv`, `확정.vrv` or `확실.vrv`, and `당선.vrv`.
- `후보사진NN` falls back to `Images/Photo/sampleNEW.png` when a candidate-specific photo is not found.
- Party image keys such as `정당바NN`, `정당판NN`, `정당심볼NN`, `그룹NN` resolve from the `Images/Dang` asset folders when a matching party file exists.

View File

@@ -0,0 +1,31 @@
# Scene Variable Validation
- Generated: 2026-04-14 16:51:07
- Scene: `E:\김의연\지역민방\T3_Cut\Elect2026_Normal_민방\1-2위_ani_광역단체장.tscn`
- Operations: `C:\Users\MD\source\repos\Tornado3_2026Election\tools\KarismaTcpProbe\scene-ops\1-2위_ani_광역단체장_live.json`
- Success Count: 21
- Failure Count: 0
| Object | Method | Payload | Result | Detail |
| --- | --- | --- | --- | --- |
| 개표율01 | SetValue | 88.8 | RESULT_SUCCESS | |
| 시도명01 | SetValue | 서울특별시 | RESULT_SUCCESS | |
| 표차01 | SetValue | 25,000 | RESULT_SUCCESS | |
| 유확당01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Tag\\당선.vrv | RESULT_SUCCESS | |
| 순위01 | SetValue | 1 | RESULT_SUCCESS | |
| 정당명01 | SetValue | 더불어민주당 | RESULT_SUCCESS | |
| 후보명01 | SetValue | 김후보 | RESULT_SUCCESS | |
| 득표수01 | SetValue | 2,123,456 | RESULT_SUCCESS | |
| 정당바01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Dang\\Dang_Map\\더불어민주당.png | RESULT_SUCCESS | |
| 정당판01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Dang\\Dang_Round\\더불어민주당.png | RESULT_SUCCESS | |
| 후보사진01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Photo\\sampleNEW.png | RESULT_SUCCESS | |
| 득표율01 | SetCounterNumberKey | keyIndex=1, number=34.8 | RESULT_SUCCESS | |
| 유확당02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Tag\\유력.vrv | RESULT_SUCCESS | |
| 순위02 | SetValue | 2 | RESULT_SUCCESS | |
| 정당명02 | SetValue | 국민의힘 | RESULT_SUCCESS | |
| 후보명02 | SetValue | 이후보 | RESULT_SUCCESS | |
| 득표수02 | SetValue | 1,123,456 | RESULT_SUCCESS | |
| 정당바02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Dang\\Dang_Map\\국민의힘.png | RESULT_SUCCESS | |
| 정당판02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Dang\\Dang_Round\\국민의힘.png | RESULT_SUCCESS | |
| 후보사진02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Photo\\sampleNEW.png | RESULT_SUCCESS | |
| 득표율02 | SetCounterNumberKey | keyIndex=1, number=32 | RESULT_SUCCESS | |

View File

@@ -0,0 +1,27 @@
# Scene Variable Validation
- Generated: 2026-04-14 15:14:18
- Scene: `C:\Users\MD\Documents\Tornado3 Data\T3_Cut\T3_Cut\Elect2026_Normal_민방\1-2위_ani_광역단체장.tscn`
- Operations: `C:\Users\MD\source\repos\Tornado3_2026Election\tools\KarismaTcpProbe\scene-ops\1-2위_ani_광역단체장.json`
- Success Count: 14
- Failure Count: 3
| Object | Method | Payload | Result | Detail |
| --- | --- | --- | --- | --- |
| Image | SetValue | C:\\Users\\MD\\Documents\\Tornado3 Data\\T3_Cut\\T3_Cut\\Elect2026_Normal_민방\\Images\\Etc\\가이드.png | RESULT_ERROR_NO_VARIABLE_OBJECT | |
| 시도명 | SetValue | 서울특별시 | RESULT_ERROR_NO_VARIABLE_OBJECT | |
| 표차 | SetValue | 100,000표차 | RESULT_ERROR_NO_VARIABLE_OBJECT | |
| 유확당01 | SetValue | C:\\Users\\MD\\Documents\\Tornado3 Data\\T3_Cut\\T3_Cut\\Elect2026_Normal_민방\\Images\\Tag\\당선.vrv | RESULT_SUCCESS | |
| 순위01 | SetValue | 1 | RESULT_SUCCESS | |
| 정당명01 | SetValue | 더불어민주당 | RESULT_SUCCESS | |
| 후보명01 | SetValue | 김후보 | RESULT_SUCCESS | |
| 득표수01 | SetValue | 2,123,456 | RESULT_SUCCESS | |
| 후보사진01 | SetValue | C:\\Users\\MD\\Documents\\Tornado3 Data\\T3_Cut\\T3_Cut\\Elect2026_Normal_민방\\Images\\Photo\\sampleNEW.png | RESULT_SUCCESS | |
| 득표율01 | SetCounterNumberKey | keyIndex=1, number=30 | RESULT_SUCCESS | |
| 유확당02 | SetValue | C:\\Users\\MD\\Documents\\Tornado3 Data\\T3_Cut\\T3_Cut\\Elect2026_Normal_민방\\Images\\Tag\\당선.vrv | RESULT_SUCCESS | |
| 순위02 | SetValue | 2 | RESULT_SUCCESS | |
| 정당명02 | SetValue | 국민의힘 | RESULT_SUCCESS | |
| 후보명02 | SetValue | 이후보 | RESULT_SUCCESS | |
| 득표수02 | SetValue | 1,123,456 | RESULT_SUCCESS | |
| 후보사진02 | SetValue | C:\\Users\\MD\\Documents\\Tornado3 Data\\T3_Cut\\T3_Cut\\Elect2026_Normal_민방\\Images\\Photo\\sampleNEW.png | RESULT_SUCCESS | |
| 득표율02 | SetCounterNumberKey | keyIndex=1, number=20 | RESULT_SUCCESS | |

View File

@@ -239,11 +239,14 @@ IDLE → READY → SENDING → ON_AIR → NEXT
## 16. Karisma / Tornado3 연동 기준 ## 16. Karisma / Tornado3 연동 기준
- CG 연동 라이브러리는 `Interop.KAsyncEngineLib.dll`을 사용한다. - CG 연동 라이브러리는 `Interop.KAsyncEngineLib.dll`을 사용한다.
- `Interop.KAsyncEngineLib.dll``AMD64` 기준이므로 앱 실행 대상도 `x64`를 기준으로 운영한다.
- 기본 접속 대상은 `127.0.0.1:30001`이다. - 기본 접속 대상은 `127.0.0.1:30001`이다.
- `TORNADO_KARISMA_HOST`가 있으면 기본 호스트 대신 사용한다. - `TORNADO_KARISMA_HOST`가 있으면 기본 호스트 대신 사용한다.
- `TORNADO_KARISMA_PORT`가 있으면 기본 포트 대신 사용한다. - `TORNADO_KARISMA_PORT`가 있으면 기본 포트 대신 사용한다.
- 앱은 시작 시 공유 Karisma 어댑터 1개를 만들고 `127.0.0.1:30001` 연결을 즉시 시도한다.
- 노멀, 좌상단, 하단, 비디오월 채널은 같은 TCP 연결을 공유하고, 채널별 `output/layer` 바인딩만 다르게 사용한다.
- 앱 시작 시 `T3_Cut 경로`가 유효하지 않으면 실CG 대신 Mock Adapter로 폴백한다. - 앱 시작 시 `T3_Cut 경로`가 유효하지 않으면 실CG 대신 Mock Adapter로 폴백한다.
- 현재 구현 기준으로는 시작 시 Mock으로 결정된 경우, 설정 변경 실CG 재연결을 위해 앱 재시작이 필요할 수 있다. - 현재 구현 기준으로는 시작 시 Mock으로 결정된 경우, 설정 변경만으로 실CG 어댑터로 승격되지 않으므로 앱 재시작이 필요할 수 있다.
- 채널 기본 바인딩은 `노멀=0:0`, `좌상단=0:1`, `하단=0:2`, `비디오월=1:0`이다. - 채널 기본 바인딩은 `노멀=0:0`, `좌상단=0:1`, `하단=0:2`, `비디오월=1:0`이다.
- 환경변수 `TORNADO_KARISMA_BIND_NORMAL`, `TORNADO_KARISMA_BIND_TOPLEFT`, `TORNADO_KARISMA_BIND_BOTTOM`, `TORNADO_KARISMA_BIND_VIDEOWALL`로 채널 바인딩을 덮어쓸 수 있다. - 환경변수 `TORNADO_KARISMA_BIND_NORMAL`, `TORNADO_KARISMA_BIND_TOPLEFT`, `TORNADO_KARISMA_BIND_BOTTOM`, `TORNADO_KARISMA_BIND_VIDEOWALL`로 채널 바인딩을 덮어쓸 수 있다.
@@ -252,6 +255,8 @@ IDLE → READY → SENDING → ON_AIR → NEXT
- 사용자 설정 명칭은 `이미지 루트 경로`가 아니라 `T3_Cut 경로`로 표기한다. - 사용자 설정 명칭은 `이미지 루트 경로`가 아니라 `T3_Cut 경로`로 표기한다.
- 송출에 사용하는 컷 파일 확장자는 `.tscn`이다. - 송출에 사용하는 컷 파일 확장자는 `.tscn`이다.
- 컷 파일은 `T3_Cut` 루트 아래의 고정된 포맷 구조를 기준으로 사용한다. - 컷 파일은 `T3_Cut` 루트 아래의 고정된 포맷 구조를 기준으로 사용한다.
- 기본 `T3_Cut` 탐색 순서는 `TORNADO_T3CUT_PATH` 환경변수, `문서\Tornado3 Data\T3_Cut\T3_Cut`, `문서\Tornado3 Data\T3_Cut`, `다운로드\T3_Cut` 순서다.
- 사용자가 상위 폴더를 선택했더라도 그 아래의 `T3_Cut` 하위 폴더에서 `.tscn` 파일이 확인되면 해당 하위 폴더를 실제 송출 루트로 정규화한다.
- 포맷 목록은 폴더 스캔으로 동적 생성하지 않고 하드코딩된 목록으로 관리한다. - 포맷 목록은 폴더 스캔으로 동적 생성하지 않고 하드코딩된 목록으로 관리한다.
- 같은 컷 이름에 `_loop.tscn` 파일이 있으면 반복 송출 컷으로 사용한다. - 같은 컷 이름에 `_loop.tscn` 파일이 있으면 반복 송출 컷으로 사용한다.
- 최초 송출 시에는 기본 컷 파일을 사용한다. - 최초 송출 시에는 기본 컷 파일을 사용한다.
@@ -318,10 +323,18 @@ IDLE → READY → SENDING → ON_AIR → NEXT
- 같은 이름의 `_loop.tscn` 파일이 있으면, 이미 송출 중인 상태에서 재호출할 때 loop 컷을 우선 사용한다. - 같은 이름의 `_loop.tscn` 파일이 있으면, 이미 송출 중인 상태에서 재호출할 때 loop 컷을 우선 사용한다.
### CG 연결 상태 표시 규칙 ### CG 연결 상태 표시 규칙
- CG 상태는 Karisma 어댑터 존재 여부가 아니라 실제 TCP `30001` 연결 성공 여부를 기준으로 표시한다. - CG 상태는 Karisma 어댑터 존재 여부가 아니라 공유 TCP `30001` 연결 성공 여부를 기준으로 표시한다.
- `Connected / Disconnected` 표시는 `OnConnect``OnClose` 콜백 기준으로 갱신한다. - `Connected / Disconnected` 표시는 `OnConnect``OnClose` 콜백 기준으로 갱신한다.
- 공유 연결 상태는 해당 연결을 사용하는 모든 채널 패널에 동일하게 반영한다.
- TCP 연결이 끊기면 5초 간격으로 자동 재접속을 시도한다. - TCP 연결이 끊기면 5초 간격으로 자동 재접속을 시도한다.
## 2026-04-14 TCP / SetValue 디버깅 업데이트
- 앱 실행 직후 `30001`과의 TCP 연결을 바로 시도하고, 이후 각 채널은 그 단일 연결을 공유한다.
- Karisma SDK 콜백 수신을 위해 전용 STA 스레드에서 메시지 펌프를 유지한다.
- `SetValue` 검증을 위해 후보 이름 키를 기존 `Candidate1Name`, `Candidate2Name` 외에 `후보명01`, `후보명02`로도 함께 전달한다.
- 현재 테스트 빌드 기준 `후보명01=김후보`, `후보명02=이후보`를 함께 송신해 실제 장면 변수 반영 여부를 확인한다.
### 인코딩 확인 원칙 ### 인코딩 확인 원칙
- 터미널 출력이 깨져 보이는 것과 파일 자체 인코딩 손상을 구분해서 판단한다. - 터미널 출력이 깨져 보이는 것과 파일 자체 인코딩 손상을 구분해서 판단한다.
- 한글 문자열 상태 판단은 편집기 화면 또는 `UTF-8` 파일 직접 읽기 기준으로 확인한다. - 한글 문자열 상태 판단은 편집기 화면 또는 `UTF-8` 파일 직접 읽기 기준으로 확인한다.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
# TSCN Variable Discovery
- Generated: 2026-04-14 15:24:09
- Root: `E:\김의연\지역민방\T3_Cut`
- Scene Count: 1
- Discovered Variable Count: 21
- 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
### `Elect2026_Normal_민방\1-2위_ani_광역단체장.tscn`
- Candidate Count: 28
- Discovered Variables: 21
| 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 |
| 득표율02 | 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 | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
| 유확당02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
| 정당명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
| 정당명02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
| 정당바01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
| 정당바02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
| 정당판01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
| 정당판02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
| 표차01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
| 후보명01 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
| 후보명02 | SetValue | __TCP_VALIDATE__ | RESULT_SUCCESS |
| 후보사진01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |
| 후보사진02 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Bottom_민방\\Images\\Etc\\가이드.png | RESULT_SUCCESS |

View File

@@ -0,0 +1,38 @@
# TSCN Variable Discovery
- Generated: 2026-04-14 15:21:19
- Root: `E:\김의연\지역민방\T3_Cut`
- Scene Count: 3
- Discovered Variable Count: 0
- 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
### `Elect2026_Bottom_민방\1-2위_광역단체장.tscn`
- Candidate Count: 54
- Discovered Variables: 0
- No variables discovered with the current TCP validation heuristics.
### `Elect2026_Bottom_민방\1-2위_광역단체장_loop.tscn`
- Candidate Count: 53
- Discovered Variables: 0
- No variables discovered with the current TCP validation heuristics.
### `Elect2026_Bottom_민방\1-2위_기초단체장.tscn`
- Candidate Count: 53
- Discovered Variables: 0
- No variables discovered with the current TCP validation heuristics.

View File

@@ -86,7 +86,7 @@ public sealed class CandidateEntry : ObservableObject
public string EffectiveJudgementLabel => EffectiveJudgement switch public string EffectiveJudgementLabel => EffectiveJudgement switch
{ {
CandidateJudgement.Leading => "유력", CandidateJudgement.Leading => "유력",
CandidateJudgement.Confirmed => "확", CandidateJudgement.Confirmed => "확",
CandidateJudgement.Elected => "당선", CandidateJudgement.Elected => "당선",
_ => "-" _ => "-"
}; };

View File

@@ -0,0 +1,3 @@
namespace Tornado3_2026Election.Services;
public readonly record struct KarismaCounterNumberKeyUpdate(string ObjectName, int KeyIndex, double Number);

View File

@@ -143,7 +143,7 @@ public class KarismaEventHandler : KAEventHandler
virtual public void OnQueryChartDataTable(eKResult Result, string SceneName, string ObjectName, KAChartDataTable Table) { } virtual public void OnQueryChartDataTable(eKResult Result, string SceneName, string ObjectName, KAChartDataTable Table) { }
virtual public void OnQuerySize(eKResult Result, string SceneName, string ObjectName, float Width, float Height) { } virtual public void OnQuerySize(eKResult Result, string SceneName, string ObjectName, float Width, float Height) { }
virtual public void OnSetSize(eKResult Result, string SceneName, string ObjectName) { } virtual public void OnSetSize(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetCounterNumberKey(eKResult Result, string SceneName, string ObjectName) { } public void OnSetCounterNumberKey(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetCounterNumberKey), Result, $"scene={SceneName} object={ObjectName}");
virtual public void OnSetPositionKey(eKResult Result, string SceneName, string ObjectName) { } virtual public void OnSetPositionKey(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetRotationKey(eKResult Result, string SceneName, string ObjectName) { } virtual public void OnSetRotationKey(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetScaleKey(eKResult Result, string SceneName, string ObjectName) { } virtual public void OnSetScaleKey(eKResult Result, string SceneName, string ObjectName) { }

View File

@@ -0,0 +1,229 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace Tornado3_2026Election.Services;
public sealed class KarismaSceneVariableCatalog
{
private static readonly IReadOnlyDictionary<string, KarismaSceneVariableDefinition> EmptySceneVariables =
new Dictionary<string, KarismaSceneVariableDefinition>(StringComparer.OrdinalIgnoreCase);
private readonly IReadOnlyDictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>> _scenes;
private KarismaSceneVariableCatalog(
IReadOnlyDictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>> scenes)
{
_scenes = scenes;
}
public static KarismaSceneVariableCatalog Load(LogService logService)
{
var reportPath = FindDiscoveryReportPath();
if (string.IsNullOrWhiteSpace(reportPath) || !File.Exists(reportPath))
{
logService.Warning("Karisma scene variable catalog report was not found. Falling back to runtime value heuristics.");
return new KarismaSceneVariableCatalog(
new Dictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>>(StringComparer.OrdinalIgnoreCase));
}
try
{
var scenes = ParseReport(reportPath);
logService.Info($"Karisma scene variable catalog loaded: scenes={scenes.Count} source='{reportPath}'.");
return new KarismaSceneVariableCatalog(scenes);
}
catch (Exception ex)
{
logService.Warning($"Failed to load Karisma scene variable catalog: {ex.Message}");
return new KarismaSceneVariableCatalog(
new Dictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>>(StringComparer.OrdinalIgnoreCase));
}
}
public IReadOnlyDictionary<string, KarismaSceneVariableDefinition> GetSceneVariables(string t3CutPath, string scenePath)
{
if (_scenes.Count == 0 ||
string.IsNullOrWhiteSpace(t3CutPath) ||
string.IsNullOrWhiteSpace(scenePath))
{
return EmptySceneVariables;
}
var relativePath = NormalizeRelativePath(Path.GetRelativePath(t3CutPath, scenePath));
return _scenes.TryGetValue(relativePath, out var variables)
? variables
: EmptySceneVariables;
}
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>> ParseReport(string reportPath)
{
var scenes = new Dictionary<string, Dictionary<string, KarismaSceneVariableDefinition>>(StringComparer.OrdinalIgnoreCase);
string? currentScene = null;
foreach (var rawLine in File.ReadLines(reportPath, Encoding.UTF8))
{
var line = rawLine.Trim();
if (TryParseSceneHeader(line, out var sceneRelativePath))
{
currentScene = NormalizeRelativePath(sceneRelativePath);
if (!scenes.ContainsKey(currentScene))
{
scenes[currentScene] = new Dictionary<string, KarismaSceneVariableDefinition>(StringComparer.OrdinalIgnoreCase);
}
continue;
}
if (string.IsNullOrWhiteSpace(currentScene) || !line.StartsWith('|'))
{
continue;
}
var cells = SplitMarkdownRow(line);
if (cells.Count < 4 ||
string.Equals(cells[0], "Variable", StringComparison.OrdinalIgnoreCase) ||
string.Equals(cells[0], "---", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var variableName = cells[0];
if (string.IsNullOrWhiteSpace(variableName))
{
continue;
}
var method = cells[1];
var payload = cells[2];
var result = cells[3];
if (!string.Equals(result, "RESULT_SUCCESS", StringComparison.OrdinalIgnoreCase))
{
continue;
}
scenes[currentScene][variableName] = new KarismaSceneVariableDefinition(
variableName,
ResolveKind(variableName, method, payload),
method,
payload);
}
return scenes.ToDictionary(
pair => pair.Key,
pair => (IReadOnlyDictionary<string, KarismaSceneVariableDefinition>)pair.Value,
StringComparer.OrdinalIgnoreCase);
}
private static bool TryParseSceneHeader(string line, out string sceneRelativePath)
{
sceneRelativePath = string.Empty;
if (!line.StartsWith("### `", StringComparison.Ordinal) || !line.EndsWith('`'))
{
return false;
}
sceneRelativePath = line.Substring(5, line.Length - 6);
return !string.IsNullOrWhiteSpace(sceneRelativePath);
}
private static List<string> SplitMarkdownRow(string line)
{
var cells = line.Split('|');
if (cells.Length <= 2)
{
return [];
}
return cells
.Skip(1)
.Take(cells.Length - 2)
.Select(cell => cell.Trim())
.ToList();
}
private static KarismaSceneVariableKind ResolveKind(string variableName, string method, string payload)
{
if (string.Equals(method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase))
{
return KarismaSceneVariableKind.Counter;
}
if (variableName.StartsWith("\uC720\uD655\uB2F9", StringComparison.OrdinalIgnoreCase))
{
return KarismaSceneVariableKind.VideoResource;
}
if (payload.EndsWith(".vrv", StringComparison.OrdinalIgnoreCase))
{
return KarismaSceneVariableKind.VideoResource;
}
if (payload.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
payload.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
payload.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
payload.EndsWith(".webp", StringComparison.OrdinalIgnoreCase))
{
return KarismaSceneVariableKind.Image;
}
return KarismaSceneVariableKind.Text;
}
private static string? FindDiscoveryReportPath()
{
foreach (var startPath in EnumerateSearchRoots())
{
var current = startPath;
for (var depth = 0; depth < 8 && !string.IsNullOrWhiteSpace(current); depth++)
{
var candidate = Path.Combine(current, "TSCN_VARIABLE_DISCOVERY_E_DRIVE.md");
if (File.Exists(candidate))
{
return candidate;
}
current = Path.GetDirectoryName(current);
}
}
return null;
}
private static IEnumerable<string> EnumerateSearchRoots()
{
var roots = new List<string> { AppContext.BaseDirectory };
try
{
roots.Add(Directory.GetCurrentDirectory());
}
catch
{
}
return roots;
}
private static string NormalizeRelativePath(string relativePath)
{
return relativePath
.Replace('/', '\\')
.Trim();
}
}
public sealed record KarismaSceneVariableDefinition(
string Name,
KarismaSceneVariableKind Kind,
string Method,
string Payload);
public enum KarismaSceneVariableKind
{
Text,
Image,
VideoResource,
Counter
}

View File

@@ -16,6 +16,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private readonly TornadoManager _manager; private readonly TornadoManager _manager;
private readonly LogService _logService; private readonly LogService _logService;
private readonly Func<string> _t3CutPathProvider; private readonly Func<string> _t3CutPathProvider;
private readonly KarismaSceneVariableCatalog _sceneVariableCatalog;
private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings; private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings;
private readonly string _connectionTarget; private readonly string _connectionTarget;
private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new(); private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new();
@@ -27,12 +28,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
TornadoManager manager, TornadoManager manager,
LogService logService, LogService logService,
Func<string> t3CutPathProvider, Func<string> t3CutPathProvider,
KarismaSceneVariableCatalog sceneVariableCatalog,
string connectionTarget, string connectionTarget,
IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> bindings) IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> bindings)
{ {
_manager = manager; _manager = manager;
_logService = logService; _logService = logService;
_t3CutPathProvider = t3CutPathProvider; _t3CutPathProvider = t3CutPathProvider;
_sceneVariableCatalog = sceneVariableCatalog;
_connectionTarget = connectionTarget; _connectionTarget = connectionTarget;
_bindings = bindings; _bindings = bindings;
_manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty); _manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty);
@@ -105,10 +108,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
} }
var manager = new TornadoManager(host, port, logService); var manager = new TornadoManager(host, port, logService);
var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
adapter = new KarismaTornado3Adapter( adapter = new KarismaTornado3Adapter(
manager, manager,
logService, logService,
t3CutPathProvider, t3CutPathProvider,
sceneVariableCatalog,
$"{host}:{port}", $"{host}:{port}",
BuildBindings()); BuildBindings());
return true; return true;
@@ -141,12 +146,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
var binding = ResolveBinding(channel); var binding = ResolveBinding(channel);
var t3CutPath = ResolveT3CutPath(); var t3CutPath = ResolveT3CutPath();
var resolvedScene = ResolveScene(template, t3CutPath, IsChannelOnAir(channel)); var resolvedScene = ResolveScene(template, t3CutPath, IsChannelOnAir(channel));
var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath); var sceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path);
var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath, sceneVariables);
var counterNumberKeys = BuildCounterNumberKeyUpdates(template, snapshot, sceneVariables);
State = TornadoConnectionState.Sending; State = TornadoConnectionState.Sending;
await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false); await _manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false);
await _manager.ApplyValuesAsync(resolvedScene.Alias, values, cancellationToken).ConfigureAwait(false); await _manager.ApplyValuesAsync(resolvedScene.Alias, values, counterNumberKeys, cancellationToken).ConfigureAwait(false);
_pendingScenes[channel] = resolvedScene.Alias; _pendingScenes[channel] = resolvedScene.Alias;
_logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}"); _logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}");
@@ -318,8 +325,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
FormatCutDefinition cut, FormatCutDefinition cut,
ElectionDataSnapshot snapshot, ElectionDataSnapshot snapshot,
BroadcastStationProfile station, BroadcastStationProfile station,
string t3CutPath) string t3CutPath,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{ {
var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
var countedRateDisplay = FormatRate(CalculateCountedRate(snapshot));
var turnoutRateDisplay = FormatRate(snapshot.TurnoutRate);
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
["TemplateId"] = template.Id, ["TemplateId"] = template.Id,
@@ -348,6 +359,10 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
["Timestamp"] = snapshot.ReceivedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) ["Timestamp"] = snapshot.ReceivedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
}; };
SetAliases(values, snapshot.DistrictName, "선거구명", "선거구명01", "시도명", "시도명01");
SetAliases(values, countedRateDisplay, "개표율", "개표율01");
SetAliases(values, turnoutRateDisplay, "투표율", "투표율01", "전국투표율", "전국투표율01");
var orderedCandidates = snapshot.Candidates var orderedCandidates = snapshot.Candidates
.OrderByDescending(candidate => candidate.VoteCount) .OrderByDescending(candidate => candidate.VoteCount)
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal) .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
@@ -357,19 +372,48 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
{ {
var candidate = orderedCandidates[index]; var candidate = orderedCandidates[index];
var slot = index + 1; var slot = index + 1;
var voteCountDisplay = FormatCount(candidate.VoteCount);
var voteRateDisplay = FormatRate(candidate.VoteRate);
var voteGapDisplay = FormatCount(CalculateVoteGap(orderedCandidates, index));
var rankDisplay = slot.ToString(CultureInfo.InvariantCulture);
var rankImagePath = ResolveRankAssetPath(t3CutPath, templateFolderPath, slot);
var judgementPath = ResolveJudgementAssetPath(t3CutPath, templateFolderPath, candidate.EffectiveJudgement);
var candidateImagePath = ResolveCandidateImagePath(t3CutPath, templateFolderPath, candidate);
var partyBarPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Bar);
var partyPlatePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Plate);
var partySymbolPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Symbol);
var groupPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Group);
values[$"Candidate{slot}Code"] = candidate.CandidateCode; values[$"Candidate{slot}Code"] = candidate.CandidateCode;
values[$"Candidate{slot}Name"] = candidate.Name; values[$"Candidate{slot}Name"] = candidate.Name;
values[$"후보명{slot:00}"] = candidate.Name;
values[$"Candidate{slot}Party"] = candidate.Party; values[$"Candidate{slot}Party"] = candidate.Party;
values[$"Candidate{slot}VoteCount"] = candidate.VoteCount.ToString(CultureInfo.InvariantCulture); values[$"Candidate{slot}VoteCount"] = candidate.VoteCount.ToString(CultureInfo.InvariantCulture);
values[$"Candidate{slot}VoteCountDisplay"] = candidate.VoteCount.ToString("N0", CultureInfo.InvariantCulture); values[$"Candidate{slot}VoteCountDisplay"] = voteCountDisplay;
values[$"Candidate{slot}VoteRate"] = candidate.VoteRate.ToString("0.0", CultureInfo.InvariantCulture); values[$"Candidate{slot}VoteRate"] = voteRateDisplay;
values[$"Candidate{slot}Judgement"] = candidate.EffectiveJudgementLabel; values[$"Candidate{slot}Judgement"] = candidate.EffectiveJudgementLabel;
values[$"Candidate{slot}ImagePath"] = ResolveCandidateImagePath(t3CutPath, candidate); values[$"Candidate{slot}ImagePath"] = candidateImagePath;
}
values["후보명01"] = "김후보"; SetRankAliases(values, sceneVariables, rankDisplay, rankImagePath, $"순위{slot:00}", $"순위{slot}");
values["후보명02"] = "이후보"; SetAliases(values, candidate.Name, $"후보명{slot:00}", $"후보명{slot}");
SetAliases(values, candidate.Party, $"정당명{slot:00}", $"정당명{slot}");
SetAliases(values, voteCountDisplay, $"득표수{slot:00}", $"득표수{slot}");
SetAliases(values, voteRateDisplay, $"득표율{slot:00}", $"득표율{slot}");
SetAliases(values, voteGapDisplay, $"표차{slot:00}", $"표차{slot}", $"득표차{slot:00}", $"득표차{slot}");
SetAliases(
values,
snapshot.DistrictName,
$"선거구명{slot:00}",
$"선거구명{slot}",
$"시도명{slot:00}",
$"시도명{slot}");
SetAliases(values, countedRateDisplay, $"개표율{slot:00}", $"개표율{slot}");
SetOptionalAliases(values, judgementPath, $"유확당{slot:00}", $"유확당{slot}");
SetOptionalAliases(values, candidateImagePath, $"후보사진{slot:00}", $"후보사진{slot}");
SetOptionalAliases(values, partyBarPath, $"정당바{slot:00}", $"정당바{slot}");
SetOptionalAliases(values, partyPlatePath, $"정당판{slot:00}", $"정당판{slot}");
SetOptionalAliases(values, partySymbolPath, $"정당심볼{slot:00}", $"정당심볼{slot}");
SetOptionalAliases(values, groupPath, $"그룹{slot:00}", $"그룹{slot}");
}
if (orderedCandidates.FirstOrDefault() is { } leader) if (orderedCandidates.FirstOrDefault() is { } leader)
{ {
@@ -377,34 +421,366 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
values["LeaderName"] = leader.Name; values["LeaderName"] = leader.Name;
values["LeaderParty"] = leader.Party; values["LeaderParty"] = leader.Party;
values["LeaderVoteCount"] = leader.VoteCount.ToString(CultureInfo.InvariantCulture); values["LeaderVoteCount"] = leader.VoteCount.ToString(CultureInfo.InvariantCulture);
values["LeaderVoteCountDisplay"] = leader.VoteCount.ToString("N0", CultureInfo.InvariantCulture); values["LeaderVoteCountDisplay"] = FormatCount(leader.VoteCount);
values["LeaderVoteRate"] = leader.VoteRate.ToString("0.0", CultureInfo.InvariantCulture); values["LeaderVoteRate"] = FormatRate(leader.VoteRate);
values["LeaderJudgement"] = leader.EffectiveJudgementLabel; values["LeaderJudgement"] = leader.EffectiveJudgementLabel;
values["LeaderImagePath"] = ResolveCandidateImagePath(t3CutPath, leader); values["LeaderImagePath"] = ResolveCandidateImagePath(t3CutPath, templateFolderPath, leader);
} }
return FilterValuesForScene(values, sceneVariables);
}
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildCounterNumberKeyUpdates(
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
if (!IsAnimatedTemplate(template))
{
return Array.Empty<KarismaCounterNumberKeyUpdate>();
}
var orderedCandidates = snapshot.Candidates
.OrderByDescending(candidate => candidate.VoteCount)
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
.ToArray();
if (orderedCandidates.Length == 0)
{
return Array.Empty<KarismaCounterNumberKeyUpdate>();
}
var updates = new List<KarismaCounterNumberKeyUpdate>(orderedCandidates.Length);
for (var index = 0; index < orderedCandidates.Length; index++)
{
var slot = index + 1;
var variableName = $"득표율{slot:00}";
if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
{
continue;
}
updates.Add(new KarismaCounterNumberKeyUpdate(
variableName,
1,
Math.Round(orderedCandidates[index].VoteRate, 1, MidpointRounding.AwayFromZero)));
}
return updates;
}
private static string ResolveCandidateImagePath(string t3CutPath, string templateFolderPath, CandidateEntry candidate)
{
if (!candidate.HasImage || string.IsNullOrWhiteSpace(t3CutPath))
{
return string.Empty;
}
var relativePaths = new List<string>();
if (!string.IsNullOrWhiteSpace(candidate.CandidateCode))
{
foreach (var extension in new[] { ".png", ".jpg", ".jpeg", ".webp" })
{
relativePaths.Add(Path.Combine("Images", "Photo", candidate.CandidateCode + extension));
relativePaths.Add(candidate.CandidateCode + extension);
}
}
foreach (var sampleFileName in new[] { "sampleNEW.png", "sampleNEW.jpg", "sampleNEW.jpeg", "sample.png" })
{
relativePaths.Add(Path.Combine("Images", "Photo", sampleFileName));
relativePaths.Add(sampleFileName);
}
return ResolveAssetAcrossRoots(t3CutPath, templateFolderPath, relativePaths);
}
private static string ResolveRankAssetPath(string t3CutPath, string templateFolderPath, int rank)
{
if (rank <= 0)
{
return string.Empty;
}
return ResolveAssetAcrossRoots(
t3CutPath,
templateFolderPath,
new[] { Path.Combine("Images", "Rank", $"{rank}위.png") });
}
private static string ResolveJudgementAssetPath(string t3CutPath, string templateFolderPath, CandidateJudgement judgement)
{
string[] fileNames = judgement switch
{
CandidateJudgement.Leading => new[] { "유력.vrv" },
CandidateJudgement.Confirmed => new[] { "확정.vrv", "확실.vrv" },
CandidateJudgement.Elected => new[] { "당선.vrv" },
_ => Array.Empty<string>()
};
if (fileNames.Length == 0)
{
return string.Empty;
}
return ResolveAssetAcrossRoots(
t3CutPath,
templateFolderPath,
fileNames.Select(fileName => Path.Combine("Images", "Tag", fileName)));
}
private static string ResolvePartyAssetPath(
string t3CutPath,
string templateFolderPath,
string partyName,
PartyAssetKind assetKind)
{
if (string.IsNullOrWhiteSpace(partyName))
{
return string.Empty;
}
var relativePaths = new List<string>();
foreach (var candidateFileName in GetPartyFileNameCandidates(partyName))
{
switch (assetKind)
{
case PartyAssetKind.Bar:
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
break;
case PartyAssetKind.Plate:
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
break;
case PartyAssetKind.Symbol:
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Symbol", candidateFileName + ".png"));
break;
case PartyAssetKind.Group:
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Symbol", candidateFileName + ".png"));
break;
}
}
return ResolveAssetAcrossRoots(t3CutPath, templateFolderPath, relativePaths);
}
private static string ResolveTemplateFolderPath(string t3CutPath, FormatTemplateDefinition template)
{
var normalizedTemplateId = template.Id
.Replace('\\', Path.DirectorySeparatorChar)
.Replace('/', Path.DirectorySeparatorChar);
var relativeDirectory = Path.GetDirectoryName(normalizedTemplateId);
return string.IsNullOrWhiteSpace(relativeDirectory)
? t3CutPath
: Path.Combine(t3CutPath, relativeDirectory);
}
private static double CalculateCountedRate(ElectionDataSnapshot snapshot)
{
var denominator = snapshot.TurnoutVotes > 0
? snapshot.TurnoutVotes
: snapshot.TotalExpectedVotes;
return denominator <= 0
? 0d
: Math.Round(snapshot.CountedVotes * 100d / denominator, 1, MidpointRounding.AwayFromZero);
}
private static int CalculateVoteGap(IReadOnlyList<CandidateEntry> orderedCandidates, int index)
{
if (orderedCandidates.Count <= 1 || index < 0 || index >= orderedCandidates.Count)
{
return 0;
}
var comparisonIndex = index < orderedCandidates.Count - 1
? index + 1
: index - 1;
return Math.Abs(orderedCandidates[index].VoteCount - orderedCandidates[comparisonIndex].VoteCount);
}
private static bool IsAnimatedTemplate(FormatTemplateDefinition template)
{
return template.Id.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
template.Name.Contains("ani", StringComparison.OrdinalIgnoreCase);
}
private static string FormatCount(int value)
{
return value.ToString("N0", CultureInfo.InvariantCulture);
}
private static string FormatRate(double value)
{
return Math.Round(value, 1, MidpointRounding.AwayFromZero).ToString("0.0", CultureInfo.InvariantCulture);
}
private static void SetAliases(IDictionary<string, string> values, string value, params string[] keys)
{
foreach (var key in keys)
{
if (!string.IsNullOrWhiteSpace(key))
{
values[key] = value;
}
}
}
private static void SetOptionalAliases(IDictionary<string, string> values, string? value, params string[] keys)
{
if (!string.IsNullOrWhiteSpace(value))
{
SetAliases(values, value, keys);
}
}
private static void SetRankAliases(
IDictionary<string, string> values,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
string rankDisplay,
string rankImagePath,
params string[] keys)
{
foreach (var key in keys)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
if (sceneVariables.Count > 0 &&
sceneVariables.TryGetValue(key, out var variable) &&
variable.Kind == KarismaSceneVariableKind.Image &&
!string.IsNullOrWhiteSpace(rankImagePath))
{
values[key] = rankImagePath;
continue;
}
values[key] = rankDisplay;
}
}
private static Dictionary<string, string> FilterValuesForScene(
Dictionary<string, string> values,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
if (sceneVariables.Count == 0)
{
return values; return values;
} }
private static string ResolveCandidateImagePath(string t3CutPath, CandidateEntry candidate) return values
.Where(pair => sceneVariables.ContainsKey(pair.Key))
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
}
private static string ResolveAssetAcrossRoots(string t3CutPath, string templateFolderPath, IEnumerable<string> relativePaths)
{ {
if (!candidate.HasImage || string.IsNullOrWhiteSpace(t3CutPath) || string.IsNullOrWhiteSpace(candidate.CandidateCode)) var candidates = relativePaths
.Where(path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (candidates.Length == 0)
{ {
return string.Empty; return string.Empty;
} }
foreach (var extension in new[] { ".png", ".jpg", ".jpeg", ".webp" }) foreach (var root in BuildAssetRoots(t3CutPath, templateFolderPath))
{ {
var path = Path.Combine(t3CutPath, candidate.CandidateCode + extension); foreach (var relativePath in candidates)
if (File.Exists(path))
{ {
return path; var fullPath = Path.Combine(root, relativePath);
if (File.Exists(fullPath))
{
return fullPath;
}
} }
} }
return string.Empty; return string.Empty;
} }
private static IReadOnlyList<string> BuildAssetRoots(string t3CutPath, string templateFolderPath)
{
var roots = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddRoot(templateFolderPath);
var current = templateFolderPath;
while (!string.IsNullOrWhiteSpace(current))
{
var parent = Path.GetDirectoryName(current);
if (string.IsNullOrWhiteSpace(parent) ||
!parent.StartsWith(t3CutPath, StringComparison.OrdinalIgnoreCase))
{
break;
}
AddRoot(parent);
current = parent;
}
if (Directory.Exists(t3CutPath))
{
foreach (var directory in Directory.GetDirectories(t3CutPath))
{
AddRoot(directory);
}
}
return roots;
void AddRoot(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
{
return;
}
var normalizedPath = Path.GetFullPath(path);
if (seen.Add(normalizedPath))
{
roots.Add(normalizedPath);
}
}
}
private static IEnumerable<string> GetPartyFileNameCandidates(string partyName)
{
var trimmed = partyName.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
yield break;
}
yield return trimmed;
var noSpaces = trimmed.Replace(" ", string.Empty);
if (!string.Equals(noSpaces, trimmed, StringComparison.Ordinal))
{
yield return noSpaces;
}
if (string.Equals(trimmed, "무소속", StringComparison.Ordinal))
{
yield return "무소속기타";
}
}
private enum PartyAssetKind
{
Bar,
Plate,
Symbol,
Group
}
private readonly record struct KarismaChannelBinding(int OutputChannelIndex, int LayerNo); private readonly record struct KarismaChannelBinding(int OutputChannelIndex, int LayerNo);
private readonly record struct ResolvedScene(string Path, string Alias); private readonly record struct ResolvedScene(string Path, string Alias);

View File

@@ -89,7 +89,11 @@ public sealed class TornadoManager : IDisposable
}, cancellationToken); }, cancellationToken);
} }
public Task ApplyValuesAsync(string sceneAlias, IReadOnlyDictionary<string, string> values, CancellationToken cancellationToken) public Task ApplyValuesAsync(
string sceneAlias,
IReadOnlyDictionary<string, string> values,
IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
CancellationToken cancellationToken)
{ {
return _dispatcher.InvokeAsync(() => return _dispatcher.InvokeAsync(() =>
{ {
@@ -122,6 +126,32 @@ public sealed class TornadoManager : IDisposable
_logService.Warning($"Karisma object update skipped: scene={sceneAlias} object={pair.Key} reason={ex.Message}"); _logService.Warning($"Karisma object update skipped: scene={sceneAlias} object={pair.Key} reason={ex.Message}");
} }
} }
foreach (var counterNumberKey in counterNumberKeys)
{
if (string.IsNullOrWhiteSpace(counterNumberKey.ObjectName))
{
continue;
}
try
{
var sceneObject = scene.GetObject(counterNumberKey.ObjectName);
if (sceneObject is not IKACounter counter)
{
_logService.Warning(
$"Karisma counter update skipped: scene={sceneAlias} object={counterNumberKey.ObjectName} reason=object is not a counter");
continue;
}
counter.SetCounterNumberKey(counterNumberKey.KeyIndex, counterNumberKey.Number);
}
catch (Exception ex)
{
_logService.Warning(
$"Karisma counter update skipped: scene={sceneAlias} object={counterNumberKey.ObjectName} keyIndex={counterNumberKey.KeyIndex} reason={ex.Message}");
}
}
} }
finally finally
{ {

View File

@@ -92,9 +92,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
Candidates = Candidates =
[ [
new CandidateEntry { CandidateCode = "A01", Name = "김후보", Party = "미래연합", VoteCount = 312000, VoteRate = 34.8, HasImage = true }, new CandidateEntry { CandidateCode = "A01", Name = "김후보", Party = "더불어민주당", VoteCount = 312000, VoteRate = 34.8, HasImage = true },
new CandidateEntry { CandidateCode = "A02", Name = "이후보", Party = "국민실행", VoteCount = 287000, VoteRate = 32.0, HasImage = true }, new CandidateEntry { CandidateCode = "A02", Name = "이후보", Party = "국민의힘", VoteCount = 287000, VoteRate = 32.0, HasImage = true },
new CandidateEntry { CandidateCode = "A03", Name = "이서윤", Party = "정의미래", VoteCount = 168000, VoteRate = 18.7, HasImage = false }, new CandidateEntry { CandidateCode = "A03", Name = "이서윤", Party = "개혁신당", VoteCount = 168000, VoteRate = 18.7, HasImage = false },
new CandidateEntry { CandidateCode = "A04", Name = "정민석", Party = "무소속", VoteCount = 129000, VoteRate = 14.5, HasImage = true } new CandidateEntry { CandidateCode = "A04", Name = "정민석", Party = "무소속", VoteCount = 129000, VoteRate = 14.5, HasImage = true }
]; ];

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<PropertyGroup>
<KarismaSdkDir Condition="'$(KarismaSdkDir)'==''">C:\Karisma SDK</KarismaSdkDir>
</PropertyGroup>
<ItemGroup>
<Reference Include="Interop.KAsyncEngineLib">
<HintPath>$(KarismaSdkDir)\Bin\C#\Interop.KAsyncEngineLib.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
<Private>true</Private>
</Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,597 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using KAsyncEngineLib;
namespace KarismaSceneCatalogNet48;
internal static class Program
{
[STAThread]
private static int Main(string[] args)
{
try
{
var options = CatalogOptions.Parse(args);
var session = new CatalogSession(options);
return session.Run();
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
return 1;
}
}
}
internal sealed class CatalogSession
{
private readonly CatalogOptions _options;
private readonly CatalogEventHandler _handler;
private readonly List<SceneCatalogEntry> _entries = new List<SceneCatalogEntry>();
private readonly List<SceneCatalogFailure> _failures = new List<SceneCatalogFailure>();
private IKAEngine _engine;
private IKAScene _sceneToQueryOnLoad;
private bool _connectReceived;
private int _connectErrorCode;
private bool _closeReceived;
private bool _loadReceived;
private eKResult _loadResult;
private bool _objectInfosReceived;
private ObjectInfosResult _objectInfosResult;
private bool _unloadReceived;
public CatalogSession(CatalogOptions options)
{
_options = options;
_handler = new CatalogEventHandler(this);
_engine = (IKAEngine)new KAEngineClass();
}
public int Run()
{
Console.WriteLine(
"Karisma scene catalog starting. target={0}:{1} root={2} output={3}",
_options.Host,
_options.Port,
_options.RootPath,
_options.OutputPath);
if (!ProbeRawTcp())
{
return 1;
}
Console.WriteLine("[CATALOG48] Calling Connect()...");
var connectRequested = _engine.Connect(_options.Host, _options.Port, _handler);
Console.WriteLine("[CATALOG48] Connect() returned {0} raw={1}", connectRequested != 0 ? "TRUE" : "FALSE", connectRequested);
if (connectRequested == 0)
{
return 1;
}
if (!WaitWithMessagePump(() => _connectReceived, _options.Timeout))
{
Console.WriteLine("[CATALOG48] OnConnect timed out.");
return 1;
}
if (_connectErrorCode != 0)
{
Console.WriteLine("[CATALOG48] OnConnect errorCode={0}", _connectErrorCode);
return 1;
}
var scenePaths = Directory
.EnumerateFiles(_options.RootPath, "*.tscn", SearchOption.AllDirectories)
.Where(path => string.IsNullOrWhiteSpace(_options.SceneFilter) ||
path.IndexOf(_options.SceneFilter, StringComparison.OrdinalIgnoreCase) >= 0)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Take(_options.MaxScenes > 0 ? _options.MaxScenes : int.MaxValue)
.ToArray();
Console.WriteLine("[CATALOG48] Scene count={0}", scenePaths.Length);
for (var index = 0; index < scenePaths.Length; index++)
{
var scenePath = scenePaths[index];
var sceneAlias = string.Format("catalog48_{0:D4}", index);
Console.WriteLine("[CATALOG48] ({0}/{1}) {2}", index + 1, scenePaths.Length, scenePath);
ResetSceneState();
IKAScene scene = null;
try
{
scene = _engine.LoadScene(scenePath, sceneAlias);
if (scene == null)
{
_failures.Add(new SceneCatalogFailure(scenePath, "LoadScene returned null."));
continue;
}
_sceneToQueryOnLoad = scene;
if (!WaitWithMessagePump(() => _loadReceived, _options.Timeout))
{
_failures.Add(new SceneCatalogFailure(scenePath, "OnLoadScene timed out."));
continue;
}
if (_loadResult != eKResult.RESULT_SUCCESS)
{
_failures.Add(new SceneCatalogFailure(scenePath, "OnLoadScene result=" + _loadResult));
TryUnload(scene);
continue;
}
if (!WaitWithMessagePump(() => _objectInfosReceived, _options.Timeout))
{
_failures.Add(new SceneCatalogFailure(scenePath, "OnQueryObjectInfos timed out."));
TryUnload(scene);
continue;
}
if (_objectInfosResult.Result != eKResult.RESULT_SUCCESS)
{
_failures.Add(new SceneCatalogFailure(
scenePath,
string.IsNullOrWhiteSpace(_objectInfosResult.Detail)
? "OnQueryObjectInfos result=" + _objectInfosResult.Result
: "OnQueryObjectInfos result=" + _objectInfosResult.Result + " detail=" + _objectInfosResult.Detail));
TryUnload(scene);
continue;
}
_entries.Add(new SceneCatalogEntry(
scenePath,
Path.GetRelativePath(_options.RootPath, scenePath),
_objectInfosResult.Objects));
TryUnload(scene);
}
catch (Exception ex)
{
_failures.Add(new SceneCatalogFailure(scenePath, ex.Message));
if (scene != null)
{
TryUnload(scene);
}
}
}
WriteMarkdown();
try
{
_engine.Disconnect();
WaitWithMessagePump(() => _closeReceived, TimeSpan.FromSeconds(2));
}
catch
{
}
Console.WriteLine();
Console.WriteLine("Summary");
Console.WriteLine("- Scenes Found: {0}", scenePaths.Length);
Console.WriteLine("- Scenes Cataloged: {0}", _entries.Count);
Console.WriteLine("- Failures: {0}", _failures.Count);
Console.WriteLine("- Output: {0}", _options.OutputPath);
return _failures.Count == 0 ? 0 : 1;
}
public void HandleConnect(int errorCode)
{
_connectReceived = true;
_connectErrorCode = errorCode;
Console.WriteLine("[SDK] OnConnect errorCode={0}", errorCode);
}
public void HandleClose(int errorCode)
{
_closeReceived = true;
Console.WriteLine("[SDK] OnClose errorCode={0}", errorCode);
}
public void HandleLoadScene(eKResult result, string sceneName)
{
_loadReceived = true;
_loadResult = result;
Console.WriteLine("[SDK] OnLoadScene result={0} scene={1}", result, sceneName);
if (result != eKResult.RESULT_SUCCESS || _sceneToQueryOnLoad == null)
{
return;
}
try
{
_sceneToQueryOnLoad.QueryObjectInfos();
}
catch (Exception ex)
{
_objectInfosReceived = true;
_objectInfosResult = new ObjectInfosResult(eKResult.RESULT_FAILURE, sceneName, new List<SceneObjectInfo>(), ex.Message);
}
finally
{
_sceneToQueryOnLoad = null;
}
}
public void HandleQueryObjectInfos(eKResult result, string sceneName, KAObjectInfos objectInfos)
{
var objects = new List<SceneObjectInfo>();
try
{
if (result == eKResult.RESULT_SUCCESS)
{
var count = objectInfos.GetCount();
for (var index = 0; index < count; index++)
{
var info = objectInfos.GetObjectInfo(index);
objects.Add(new SceneObjectInfo(
info.Name ?? string.Empty,
info.ObjectType,
info.Value ?? string.Empty,
info.bVisible != 0));
}
}
Console.WriteLine("[SDK] OnQueryObjectInfos result={0} scene={1} count={2}", result, sceneName, objects.Count);
_objectInfosResult = new ObjectInfosResult(result, sceneName, objects, string.Empty);
}
catch (Exception ex)
{
_objectInfosResult = new ObjectInfosResult(eKResult.RESULT_FAILURE, sceneName, objects, ex.Message);
}
finally
{
_objectInfosReceived = true;
}
}
public void HandleUnloadScene(eKResult result, string sceneName)
{
_unloadReceived = true;
Console.WriteLine("[SDK] OnUnloadScene result={0} scene={1}", result, sceneName);
}
private bool ProbeRawTcp()
{
try
{
using (var client = new TcpClient())
{
var asyncResult = client.BeginConnect(_options.Host, _options.Port, null, null);
if (!asyncResult.AsyncWaitHandle.WaitOne(_options.Timeout))
{
Console.WriteLine("[RAW] Failed: timeout");
return false;
}
client.EndConnect(asyncResult);
Console.WriteLine("[RAW] Connected local={0} remote={1}", client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
return true;
}
}
catch (Exception ex)
{
Console.WriteLine("[RAW] Failed: {0}", ex.Message);
return false;
}
}
private void ResetSceneState()
{
_sceneToQueryOnLoad = null;
_loadReceived = false;
_loadResult = eKResult.RESULT_FAILURE;
_objectInfosReceived = false;
_objectInfosResult = new ObjectInfosResult(eKResult.RESULT_FAILURE, string.Empty, new List<SceneObjectInfo>(), string.Empty);
_unloadReceived = false;
}
private void TryUnload(IKAScene scene)
{
try
{
_unloadReceived = false;
scene.UnloadScene();
WaitWithMessagePump(() => _unloadReceived, TimeSpan.FromSeconds(2));
}
catch
{
}
}
private void WriteMarkdown()
{
var outputDirectory = Path.GetDirectoryName(_options.OutputPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
using (var writer = new StreamWriter(_options.OutputPath, false, new UTF8Encoding(false)))
{
writer.WriteLine("# Scene Object Catalog");
writer.WriteLine();
writer.WriteLine("- Generated: {0:yyyy-MM-dd HH:mm:ss}", DateTime.Now);
writer.WriteLine("- Root: `{0}`", _options.RootPath);
writer.WriteLine("- Scene Count: {0}", _entries.Count);
writer.WriteLine("- Failure Count: {0}", _failures.Count);
writer.WriteLine();
if (_failures.Count > 0)
{
writer.WriteLine("## Failures");
writer.WriteLine();
foreach (var failure in _failures)
{
writer.WriteLine("- `{0}`: {1}", failure.ScenePath, EscapeCell(failure.Reason));
}
writer.WriteLine();
}
writer.WriteLine("## Scenes");
writer.WriteLine();
foreach (var entry in _entries)
{
writer.WriteLine("### `{0}`", entry.RelativePath);
writer.WriteLine();
writer.WriteLine("- Object Count: {0}", entry.Objects.Count);
writer.WriteLine();
writer.WriteLine("| Name | Type | Value | Visible |");
writer.WriteLine("| --- | --- | --- | --- |");
foreach (var obj in entry.Objects)
{
writer.WriteLine(
"| {0} | {1} | {2} | {3} |",
EscapeCell(obj.Name),
obj.ObjectType,
EscapeCell(obj.Value),
obj.Visible ? "true" : "false");
}
writer.WriteLine();
}
}
}
private static bool WaitWithMessagePump(Func<bool> condition, TimeSpan timeout)
{
User32.PeekMessage(out _, IntPtr.Zero, 0, 0, 0);
var deadline = DateTime.UtcNow + timeout;
while (!condition())
{
while (User32.PeekMessage(out var message, IntPtr.Zero, 0, 0, 1))
{
User32.TranslateMessage(ref message);
User32.DispatchMessage(ref message);
}
if (DateTime.UtcNow >= deadline)
{
return false;
}
Thread.Sleep(10);
}
return true;
}
private static string EscapeCell(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value.Replace("\\", "\\\\")
.Replace("|", "\\|")
.Replace("\r", " ")
.Replace("\n", "<br/>");
}
}
internal sealed class CatalogEventHandler : KAEventHandler
{
private readonly CatalogSession _owner;
public CatalogEventHandler(CatalogSession owner)
{
_owner = owner;
}
public void OnConnect(int errorCode) => _owner.HandleConnect(errorCode);
public void OnClose(int errorCode) => _owner.HandleClose(errorCode);
public void OnLoadScene(eKResult result, string sceneName) => _owner.HandleLoadScene(result, sceneName);
public void OnUnloadScene(eKResult result, string sceneName) => _owner.HandleUnloadScene(result, sceneName);
public void OnQueryObjectInfos(eKResult result, string sceneName, KAObjectInfos objectInfos) =>
_owner.HandleQueryObjectInfos(result, sceneName, objectInfos);
}
internal sealed class CatalogOptions
{
public string Host { get; private set; }
public int Port { get; private set; }
public TimeSpan Timeout { get; private set; }
public string RootPath { get; private set; }
public string OutputPath { get; private set; }
public string SceneFilter { get; private set; }
public int MaxScenes { get; private set; }
private CatalogOptions()
{
Host = "127.0.0.1";
Port = 30001;
Timeout = TimeSpan.FromSeconds(5);
RootPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"Tornado3 Data",
"T3_Cut",
"T3_Cut");
OutputPath = Path.Combine(Environment.CurrentDirectory, "SCENE_OBJECT_CATALOG.md");
SceneFilter = string.Empty;
MaxScenes = 0;
}
public static CatalogOptions Parse(string[] args)
{
var options = new CatalogOptions();
for (var index = 0; index < args.Length; index++)
{
switch (args[index])
{
case "--host" when index + 1 < args.Length:
options.Host = args[++index];
break;
case "--port" when index + 1 < args.Length && int.TryParse(args[index + 1], out var port):
options.Port = port;
index++;
break;
case "--timeout" when index + 1 < args.Length && double.TryParse(args[index + 1], out var timeoutSeconds):
options.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
index++;
break;
case "--root" when index + 1 < args.Length:
options.RootPath = Path.GetFullPath(args[++index]);
break;
case "--output" when index + 1 < args.Length:
options.OutputPath = Path.GetFullPath(args[++index]);
break;
case "--filter" when index + 1 < args.Length:
options.SceneFilter = args[++index];
break;
case "--max-scenes" when index + 1 < args.Length && int.TryParse(args[index + 1], out var maxScenes):
options.MaxScenes = maxScenes;
index++;
break;
}
}
if (!Directory.Exists(options.RootPath))
{
throw new DirectoryNotFoundException("Catalog root path does not exist: " + options.RootPath);
}
return options;
}
}
internal sealed class SceneCatalogEntry
{
public SceneCatalogEntry(string scenePath, string relativePath, IReadOnlyList<SceneObjectInfo> objects)
{
ScenePath = scenePath;
RelativePath = relativePath;
Objects = objects;
}
public string ScenePath { get; }
public string RelativePath { get; }
public IReadOnlyList<SceneObjectInfo> Objects { get; }
}
internal sealed class SceneCatalogFailure
{
public SceneCatalogFailure(string scenePath, string reason)
{
ScenePath = scenePath;
Reason = reason;
}
public string ScenePath { get; }
public string Reason { get; }
}
internal sealed class SceneObjectInfo
{
public SceneObjectInfo(string name, eKObjectType objectType, string value, bool visible)
{
Name = name;
ObjectType = objectType;
Value = value;
Visible = visible;
}
public string Name { get; }
public eKObjectType ObjectType { get; }
public string Value { get; }
public bool Visible { get; }
}
internal sealed class ObjectInfosResult
{
public ObjectInfosResult(eKResult result, string sceneName, IReadOnlyList<SceneObjectInfo> objects, string detail)
{
Result = result;
SceneName = sceneName;
Objects = objects;
Detail = detail;
}
public eKResult Result { get; }
public string SceneName { get; }
public IReadOnlyList<SceneObjectInfo> Objects { get; }
public string Detail { get; }
}
[StructLayout(LayoutKind.Sequential)]
internal struct NativeMessage
{
public IntPtr hwnd;
public uint message;
public UIntPtr wParam;
public IntPtr lParam;
public uint time;
public NativePoint pt;
public uint lPrivate;
}
[StructLayout(LayoutKind.Sequential)]
internal struct NativePoint
{
public int x;
public int y;
}
internal static class User32
{
[DllImport("user32.dll")]
internal static extern bool PeekMessage(out NativeMessage lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
[DllImport("user32.dll")]
internal static extern bool TranslateMessage([In] ref NativeMessage lpMsg);
[DllImport("user32.dll")]
internal static extern IntPtr DispatchMessage([In] ref NativeMessage lpmsg);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
[
{ "objectName": "Image", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Etc\\가이드.png" },
{ "objectName": "시도명", "method": "SetValue", "value": "서울특별시" },
{ "objectName": "표차", "method": "SetValue", "value": "100,000표차" },
{ "objectName": "유확당01", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Tag\\당선.vrv" },
{ "objectName": "순위01", "method": "SetValue", "value": "1" },
{ "objectName": "정당명01", "method": "SetValue", "value": "더불어민주당" },
{ "objectName": "후보명01", "method": "SetValue", "value": "김후보" },
{ "objectName": "득표수01", "method": "SetValue", "value": "2,123,456" },
{ "objectName": "후보사진01", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Photo\\sampleNEW.png" },
{ "objectName": "득표율01", "method": "SetCounterNumberKey", "keyIndex": 1, "number": 30 },
{ "objectName": "유확당02", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Tag\\당선.vrv" },
{ "objectName": "순위02", "method": "SetValue", "value": "2" },
{ "objectName": "정당명02", "method": "SetValue", "value": "국민의힘" },
{ "objectName": "후보명02", "method": "SetValue", "value": "이후보" },
{ "objectName": "득표수02", "method": "SetValue", "value": "1,123,456" },
{ "objectName": "후보사진02", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Photo\\sampleNEW.png" },
{ "objectName": "득표율02", "method": "SetCounterNumberKey", "keyIndex": 1, "number": 20 }
]

View File

@@ -0,0 +1,25 @@
[
{ "objectName": "\uAC1C\uD45C\uC72801", "method": "SetValue", "value": "88.8" },
{ "objectName": "\uC2DC\uB3C4\uBA8501", "method": "SetValue", "value": "\uC11C\uC6B8\uD2B9\uBCC4\uC2DC" },
{ "objectName": "\uD45C\uCC2801", "method": "SetValue", "value": "25,000" },
{ "objectName": "\uC720\uD655\uB2F901", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Tag\\\uB2F9\uC120.vrv" },
{ "objectName": "\uC21C\uC70401", "method": "SetValue", "value": "1" },
{ "objectName": "\uC815\uB2F9\uBA8501", "method": "SetValue", "value": "\uB354\uBD88\uC5B4\uBBFC\uC8FC\uB2F9" },
{ "objectName": "\uD6C4\uBCF4\uBA8501", "method": "SetValue", "value": "\uAE40\uD6C4\uBCF4" },
{ "objectName": "\uB4DD\uD45C\uC21801", "method": "SetValue", "value": "2,123,456" },
{ "objectName": "\uC815\uB2F9\uBC1401", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Dang\\Dang_Map\\\uB354\uBD88\uC5B4\uBBFC\uC8FC\uB2F9.png" },
{ "objectName": "\uC815\uB2F9\uD31001", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Dang\\Dang_Round\\\uB354\uBD88\uC5B4\uBBFC\uC8FC\uB2F9.png" },
{ "objectName": "\uD6C4\uBCF4\uC0AC\uC9C401", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Photo\\sampleNEW.png" },
{ "objectName": "\uB4DD\uD45C\uC72801", "method": "SetCounterNumberKey", "keyIndex": 1, "number": 34.8 },
{ "objectName": "\uC720\uD655\uB2F902", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Tag\\\uC720\uB825.vrv" },
{ "objectName": "\uC21C\uC70402", "method": "SetValue", "value": "2" },
{ "objectName": "\uC815\uB2F9\uBA8502", "method": "SetValue", "value": "\uAD6D\uBBFC\uC758\uD798" },
{ "objectName": "\uD6C4\uBCF4\uBA8502", "method": "SetValue", "value": "\uC774\uD6C4\uBCF4" },
{ "objectName": "\uB4DD\uD45C\uC21802", "method": "SetValue", "value": "1,123,456" },
{ "objectName": "\uC815\uB2F9\uBC1402", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Dang\\Dang_Map\\\uAD6D\uBBFC\uC758\uD798.png" },
{ "objectName": "\uC815\uB2F9\uD31002", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Dang\\Dang_Round\\\uAD6D\uBBFC\uC758\uD798.png" },
{ "objectName": "\uD6C4\uBCF4\uC0AC\uC9C402", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Photo\\sampleNEW.png" },
{ "objectName": "\uB4DD\uD45C\uC72802", "method": "SetCounterNumberKey", "keyIndex": 1, "number": 32.0 }
]