This commit is contained in:
2026-05-13 11:21:48 +09:00
parent 960163dad8
commit 8b5c92194f
66 changed files with 12393 additions and 939 deletions

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -337,6 +337,12 @@ if (args.Length > 0 && string.Equals(args[0], "--report-cut-debug-coverage", Str
return;
}
if (args.Length > 0 && string.Equals(args[0], "--audit-cut-files", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = await CutFileAudit.RunAsync(args[1..]).ConfigureAwait(false);
return;
}
var options = ProbeOptions.Parse(args);
Console.WriteLine($"Karisma TCP probe starting. target={options.Host}:{options.Port} timeout={options.Timeout.TotalSeconds:0}s");
@@ -862,6 +868,25 @@ 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))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(
true,
"SUCCESS",
operationResult.Result,
options.OutputPath,
$"Operation {operationResult.Method} failed for '{operationResult.ObjectName}': {operationResult.Detail}"));
return;
}
}
if (options.MaterialOpacity is not null)
{
Console.WriteLine(
@@ -1283,7 +1308,16 @@ static Task<SceneCatalogProbeResult> CatalogScenesAsync(SceneCatalogOptions opti
.EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories)
.Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) ||
path.Contains(options.SceneFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(path => new
{
Path = path,
RelativePath = Path.GetRelativePath(options.RootPath, path)
})
.OrderBy(item => item.RelativePath.Count(character =>
character == Path.DirectorySeparatorChar ||
character == Path.AltDirectorySeparatorChar))
.ThenBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase)
.Select(item => item.Path)
.Take(options.MaxScenes ?? int.MaxValue)
.ToArray();
@@ -2309,142 +2343,7 @@ static Task<SceneValidationProbeResult> ValidateSceneOperationsAsync(SceneValida
foreach (var operation in operations)
{
Console.WriteLine($"[VALIDATE] {operation.Method} object={operation.ObjectName}");
var sceneObject = scene.GetObject(operation.ObjectName);
if (sceneObject is null)
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"OBJECT_NOT_FOUND",
"scene.GetObject returned null."));
continue;
}
if (string.Equals(operation.Method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase))
{
if (sceneObject is not IKACounter counter)
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"NOT_A_COUNTER",
"Object does not implement IKACounter."));
continue;
}
handler.ResetCounterNumberKeyTask();
counter.SetCounterNumberKey(operation.KeyIndex, operation.Number);
if (!WaitForTaskWithMessagePump(handler.CounterNumberKeyTask, options.Connection.Timeout))
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetCounterNumberKey timed out."));
continue;
}
var callbackResult = handler.CounterNumberKeyTask.Result;
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
callbackResult.ToString(),
string.Empty));
continue;
}
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
{
handler.ResetVisibleTask();
sceneObject.SetVisible(operation.Visible ? 1 : 0);
if (!WaitForTaskWithMessagePump(handler.VisibleTask, options.Connection.Timeout))
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetVisible timed out."));
continue;
}
var callbackResult = handler.VisibleTask.Result;
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
callbackResult.ToString(),
string.Empty));
continue;
}
if (string.Equals(operation.Method, "SetStyleColor", StringComparison.OrdinalIgnoreCase))
{
if (sceneObject is not IKAStyle style)
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"NOT_A_STYLE_OBJECT",
"Object does not implement IKAStyle."));
continue;
}
handler.ResetStyleColorTask();
style.SetStyleColor(
ParseStyleType(operation.StyleType),
operation.Order,
operation.R,
operation.G,
operation.B,
operation.A);
if (!WaitForTaskWithMessagePump(handler.StyleColorTask, options.Connection.Timeout))
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetStyleColor timed out."));
continue;
}
var callbackResult = handler.StyleColorTask.Result;
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
callbackResult.ToString(),
string.Empty));
continue;
}
handler.ResetSetValueTask();
sceneObject.SetValue(operation.Value ?? string.Empty);
if (!WaitForTaskWithMessagePump(handler.SetValueTask, options.Connection.Timeout))
{
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetValue timed out."));
continue;
}
var setValueResult = handler.SetValueTask.Result;
results.Add(new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
setValueResult.ToString(),
string.Empty));
results.Add(ApplySceneOperation(handler, scene, operation, options.Connection.Timeout));
}
WriteSceneValidationMarkdown(options, results);
@@ -2532,7 +2431,16 @@ static Task<FolderInspectionProbeResult> InspectTscnFolderAsync(FolderInspection
.EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories)
.Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) ||
path.Contains(options.SceneFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(path => new
{
Path = path,
RelativePath = Path.GetRelativePath(options.RootPath, path)
})
.OrderBy(item => item.RelativePath.Count(character =>
character == Path.DirectorySeparatorChar ||
character == Path.AltDirectorySeparatorChar))
.ThenBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase)
.Select(item => item.Path)
.Take(options.MaxScenes ?? int.MaxValue)
.ToArray();
@@ -2865,7 +2773,12 @@ static void WriteFolderInspectionMarkdown(
static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOptions options)
{
var json = File.ReadAllText(options.OperationsPath);
return LoadSceneOperations(options.ScenePath, options.OperationsPath);
}
static List<SceneValidationOperation> LoadSceneOperations(string scenePath, string operationsPath)
{
var json = File.ReadAllText(operationsPath);
var operations = JsonSerializer.Deserialize<List<SceneValidationOperation>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
@@ -2875,13 +2788,143 @@ static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOp
{
if (!string.IsNullOrWhiteSpace(operation.Value))
{
operation.Value = operation.Value.Replace("${SCENE_DIR}", Path.GetDirectoryName(options.ScenePath) ?? string.Empty, StringComparison.Ordinal);
operation.Value = operation.Value.Replace("${SCENE_DIR}", Path.GetDirectoryName(scenePath) ?? string.Empty, StringComparison.Ordinal);
}
}
return operations;
}
static SceneOperationValidationResult ApplySceneOperation(
ProbeEventHandler handler,
IKAScene scene,
SceneValidationOperation operation,
TimeSpan timeout)
{
Console.WriteLine($"[VALIDATE] {operation.Method} object={operation.ObjectName}");
var sceneObject = scene.GetObject(operation.ObjectName);
if (sceneObject is null)
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"OBJECT_NOT_FOUND",
"scene.GetObject returned null.");
}
if (string.Equals(operation.Method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase))
{
if (sceneObject is not IKACounter counter)
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"NOT_A_COUNTER",
"Object does not implement IKACounter.");
}
handler.ResetCounterNumberKeyTask();
counter.SetCounterNumberKey(operation.KeyIndex, operation.Number);
if (!WaitForTaskWithMessagePump(handler.CounterNumberKeyTask, timeout))
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetCounterNumberKey timed out.");
}
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
handler.CounterNumberKeyTask.Result.ToString(),
string.Empty);
}
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
{
handler.ResetVisibleTask();
sceneObject.SetVisible(operation.Visible ? 1 : 0);
if (!WaitForTaskWithMessagePump(handler.VisibleTask, timeout))
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetVisible timed out.");
}
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
handler.VisibleTask.Result.ToString(),
string.Empty);
}
if (string.Equals(operation.Method, "SetStyleColor", StringComparison.OrdinalIgnoreCase))
{
if (sceneObject is not IKAStyle style)
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"NOT_A_STYLE_OBJECT",
"Object does not implement IKAStyle.");
}
handler.ResetStyleColorTask();
style.SetStyleColor(
ParseStyleType(operation.StyleType),
operation.Order,
operation.R,
operation.G,
operation.B,
operation.A);
if (!WaitForTaskWithMessagePump(handler.StyleColorTask, timeout))
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetStyleColor timed out.");
}
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
handler.StyleColorTask.Result.ToString(),
string.Empty);
}
handler.ResetSetValueTask();
sceneObject.SetValue(operation.Value ?? string.Empty);
if (!WaitForTaskWithMessagePump(handler.SetValueTask, timeout))
{
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
"TIMEOUT",
"OnSetValue timed out.");
}
return new SceneOperationValidationResult(
operation.ObjectName,
operation.Method,
DescribeOperationPayload(operation),
handler.SetValueTask.Result.ToString(),
string.Empty);
}
static string DescribeOperationPayload(SceneValidationOperation operation)
{
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
@@ -3393,6 +3436,7 @@ internal sealed record SaveSceneImageOptions(
bool? VisibleObjectValue,
VariableNameUpdate? VariableName,
CloneObjectUpdate? CloneObject,
string? OperationsPath,
MaterialOpacityUpdate? MaterialOpacity,
SizeUpdate? Size,
PositionUpdate? Position,
@@ -3419,6 +3463,7 @@ internal sealed record SaveSceneImageOptions(
string? variableNameValue = null;
string? cloneSourceObjectName = null;
string? cloneVariableName = null;
string? operationsPath = null;
string? materialOpacityObjectName = null;
float? materialOpacityValue = null;
string? sizeObjectName = null;
@@ -3481,6 +3526,9 @@ internal sealed record SaveSceneImageOptions(
case "--clone-name" when index + 1 < args.Length:
cloneVariableName = args[++index];
break;
case "--operations" when index + 1 < args.Length:
operationsPath = args[++index];
break;
case "--material-opacity-object" when index + 1 < args.Length:
materialOpacityObjectName = args[++index];
break;
@@ -3558,6 +3606,9 @@ internal sealed record SaveSceneImageOptions(
scenePath = Path.GetFullPath(scenePath);
outputPath = Path.GetFullPath(outputPath);
operationsPath = string.IsNullOrWhiteSpace(operationsPath)
? null
: Path.GetFullPath(operationsPath);
sceneAlias ??= Path.GetFileNameWithoutExtension(scenePath);
return new SaveSceneImageOptions(
connection,
@@ -3573,6 +3624,7 @@ internal sealed record SaveSceneImageOptions(
visibleObjectValue,
ParseVariableName(variableNameObjectName, variableNameValue),
ParseCloneObject(cloneSourceObjectName, cloneVariableName),
operationsPath,
ParseMaterialOpacity(materialOpacityObjectName, materialOpacityValue),
ParseSize(sizeObjectName, sizeRaw),
ParsePosition(positionObjectName, positionRaw),
@@ -3857,7 +3909,7 @@ internal sealed record SceneCatalogOptions(
switch (args[index])
{
case "--root" when index + 1 < args.Length:
index++;
rootPath = args[++index];
break;
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
@@ -4195,7 +4247,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
switch (args[index])
{
case "--root" when index + 1 < args.Length:
index++;
rootPath = args[++index];
break;
case "--output" when index + 1 < args.Length:
outputPath = args[++index];

View File

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

View File

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

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

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