move_detect/docs/deck/build_deck.py
유창욱 dea12a2931 feat(deck): P0 클립 2종 배치 — 브라우저 재생 형식으로 변환 + 포스터 자동 추출
받은 v01(넘어짐·경광등)·v02(앉기·정상)는 내용상 브리프대로였다. 컨택트
시트로 확인: v01 0.79초 쓰러짐 → 1.58초 경광등 점등 → 유지, v02 4초 내내
경광등 꺼짐. 둘 다 CCTV 앵글, 얼굴 식별 불가.

다만 HEVC(H.265)로 인코딩돼 있어 브라우저가 디코딩하지 못했다. 같은 페이지
같은 브라우저에서 대조:

    HEVC  .mp4  -> readyState 0, videoWidth 0, duration NaN, error 이벤트 없음
    VP9   .webm -> readyState 1+, videoWidth 1600, duration 4.04

서버(Range 미지원 가능성)를 배제하려 blob 으로 통째로 올려도 HEVC 는 같았다.
오류를 내지 않는 실패라 리허설 없이는 회의실에서야 알게 되는 종류다.

docs/deck/prepare_clips.py 신설 — 원본을 VP9/WebM 으로 변환(ffmpeg·openh264
없어 H.264 인코딩 불가, avc1/H264/X264 전부 VideoWriter 열기 실패)하고 클립
자신의 마지막 프레임을 포스터로 뽑는다. 원본 mp4 도 clips/ 에 남겨 두고
build_deck 이 <source> 를 webm→mp4 순으로 내보내 환경에 맞는 쪽이 재생된다.

포스터를 클립에서 뽑는 이유: deck.json 의 포스터는 클립이 없을 때 쓰는 대체
도판이라 클립이 생기면 어긋난다 — 비교 슬라이드에서 좌우 비교 도판이 좌우
패널 각각에 들어가 비교가 두 겹이 됐다.

cv2.imwrite 는 Windows 에서 한글 경로에 쓰지 못한다(예외 없이 False). 이
프로젝트는 파일명이 전부 한글이라 포스터가 조용히 하나도 안 만들어졌다.
imencode + write_bytes 로 우회.

확인하지 못한 것: 재생 진행 여부. 자동화 브라우저 컨텍스트가 디코딩까지만
허용하고 currentTime 이 0 에서 움직이지 않는다(자기가 MediaRecorder 로 만든
WebM 도 동일). 발표 노트북에서 한 번 눌러 확인해야 한다. 재생이 안 되더라도
poster 가 남아 슬라이드가 비지는 않는다.

영상 브리프에 형식 요구(H.264, HEVC 금지)와 변환 절차를 반영. 남은 P1 2종
(v04 구역진입·v05 카메라 건드림) 자리는 계속 스틸로 대체된다.
2026-08-30 22:52:23 +09:00

509 lines
25 KiB
Python

#!/usr/bin/env python3
"""경영진 보고 덱 생성기 — 이미지를 base64 로 박아 넣은 단일 HTML.
WHY A GENERATOR AND NOT HAND-WRITTEN HTML
이 덱은 내용이 계속 바뀐다(수치·현재 상태·클립 추가). 손으로 쓴 HTML 은 두 번째 수정부터
낡기 시작하고, 낡은 덱은 경영진 앞에서 틀린 숫자를 띄운다. 내용은 deck.json, 도판은
docs/images/, 클립은 clips/ 에 두고 이 스크립트로 다시 뽑는다 — server/deploy/manual/
build_manual.py 와 같은 구조다.
WHY CLIPS ARE REFERENCED, NOT EMBEDDED
이미지는 3MB 라 통째로 넣어도 되지만 3초 클립 6개는 10MB 를 넘어 base64 로는 감당이 안 된다.
clips/ 를 상대경로로 참조하므로 **HTML 과 clips/ 폴더는 같이 옮겨야 한다.** 클립이 없는
자리는 포스터 스틸로 자동 대체되므로, 클립을 하나도 안 넣어도 덱은 완성된 상태로 나온다.
WHY EVERY CLIP CARRIES A "재현" BADGE
이 클립들은 제품 동작 녹화가 아니라 연출된 재현이다. 실제 동작처럼 보여주면 허위 시연이
된다. 발표자가 깜빡할 수 있는 종류의 것이라 배지를 템플릿에 박아 지울 수 없게 했다.
실화면 캡처(server/deploy/manual/shots/)에는 이 배지가 붙지 않는다 — 그건 진짜다.
사용법:
python docs/deck/build_deck.py # -> docs/deck/경영진-보고.html
python docs/deck/build_deck.py --out X.html
"""
from __future__ import annotations
import argparse
import base64
import datetime
import html
import json
import pathlib
import sys
HERE = pathlib.Path(__file__).resolve().parent
REPO = HERE.parents[1]
IMAGES = REPO / "docs" / "images"
CLIPS = HERE / "clips"
MIME = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}
# deck.json 은 클립을 논리 이름(v01-....mp4)으로 적는다. 실제 파일은 prepare_clips.py 가
# 변환한 결과라 확장자가 다를 수 있으므로, stem 이 같은 재생 가능한 파일을 전부 찾아
# <source> 로 늘어놓고 브라우저가 고르게 한다.
#
# webm 을 먼저 두는 이유: 받은 원본은 HEVC(H.265) 였고 브라우저에서 디코딩되지 않았다
# (같은 페이지 대조: HEVC videoWidth 0 / VP9 videoWidth 1600). 원본 mp4 도 뒤에 남겨 둬서
# VP9 를 못 쓰는 환경이면 그쪽으로 떨어지게 한다. 자세한 근거는 prepare_clips.py 머리말.
#
# 어느 쪽이든 재생이 안 되더라도 poster 가 남으므로 슬라이드가 비지는 않는다.
CLIP_TYPES = [(".webm", "video/webm"), (".m4v", "video/mp4"), (".mp4", "video/mp4")]
def file_uri(p: pathlib.Path) -> str:
mime = MIME.get(p.suffix.lower())
if mime is None:
raise SystemExit(f"ERROR: 지원하지 않는 이미지 형식: {p}")
return f"data:{mime};base64," + base64.b64encode(p.read_bytes()).decode("ascii")
def clip_poster(clip: str | None) -> pathlib.Path | None:
"""클립에서 뽑아 둔 포스터(prepare_clips.py 생성). 있으면 deck.json 의 포스터보다 우선한다."""
if not clip:
return None
p = CLIPS / (pathlib.Path(clip).stem + ".poster.jpg")
return p if p.is_file() else None
def data_uri(name: str) -> str:
p = IMAGES / name
if not p.is_file():
# 조용히 넘기면 도판 빠진 덱이 회의실에서 발견된다.
raise SystemExit(f"ERROR: 이미지가 없습니다: {p}")
mime = MIME.get(p.suffix.lower())
if mime is None:
raise SystemExit(f"ERROR: 지원하지 않는 이미지 형식: {p}")
return f"data:{mime};base64," + base64.b64encode(p.read_bytes()).decode("ascii")
CSS = """
:root{
--ink:#131A26; --ink-2:#3A4354; --ink-3:#5C6474;
--line:#DCE2EA; --line-2:#EDF1F5; --paper:#FFFFFF; --wash:#F4F6F9;
--navy:#003478; --navy-2:#0A4C9E; --navy-wash:#EAF0F9;
--alarm:#C1121F; --alarm-wash:#FBEDEE;
--caution:#8A6200; --caution-wash:#FBF4E4;
--ok:#14614A; --ok-wash:#E9F3EF;
--rail:64px;
--font:"Pretendard","Noto Sans KR","Malgun Gothic","Apple SD Gothic Neo",system-ui,sans-serif;
--mono:ui-monospace,"Cascadia Mono",Consolas,"D2Coding",monospace;
}
*{box-sizing:border-box}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
body{margin:0;background:var(--wash);color:var(--ink);font-family:var(--font);
line-height:1.6;-webkit-font-smoothing:antialiased;
/* CJK 기본 줄바꿈은 글자 단위라 "설명할 수 있습/니다" 처럼 어절이 잘린다. */
word-break:keep-all;overflow-wrap:break-word}
/* 좌측 레일 — 모든 슬라이드에 같은 자리에 있어 덱의 정체성이 된다. 가운데정렬 카드 나열을
피하려는 의도적 선택: 시선이 항상 왼쪽 같은 지점에서 출발한다. */
.rail{position:fixed;left:0;top:0;bottom:0;width:var(--rail);background:var(--navy);z-index:20;
display:flex;flex-direction:column;align-items:center;justify-content:space-between;
padding:1.1rem 0;color:#fff}
.rail .mark{font-size:.62rem;font-weight:700;letter-spacing:.18em;writing-mode:vertical-rl;
text-orientation:mixed;opacity:.92}
.rail .dots{display:flex;flex-direction:column}
.rail .dots a{width:24px;height:24px;display:flex;align-items:center;justify-content:center}
.rail .dots a::before{content:"";width:7px;height:7px;border-radius:50%;
background:rgba(255,255,255,.34);transition:background .15s}
.rail .dots a:hover::before,.rail .dots a:focus-visible::before{background:#fff}
.rail .dots a[aria-current="true"]::before{background:#fff}
.rail a:focus-visible{outline:2px solid #fff;outline-offset:3px}
.deck{margin-left:var(--rail);scroll-snap-type:y mandatory;overflow-y:auto;height:100vh}
section.slide{min-height:100vh;scroll-snap-align:start;display:flex;flex-direction:column;
justify-content:center;padding:4.2vh 5vw 6vh;position:relative;background:var(--paper);
border-bottom:1px solid var(--line)}
/* 표지·마무리는 자기 배경을 가진다. :nth-child(even) 는 명시도가 더 높아 그대로 두면
네이비 배경을 덮어쓰고, 그 위의 흰 글자가 흰 배경에 얹혀 슬라이드가 통째로 백지가 된다. */
section.slide:not(.cover):not(.closing):nth-child(even){background:var(--wash)}
.num{position:absolute;right:2.2vw;top:3.4vh;font-family:var(--mono);font-size:.72rem;
color:var(--ink-3);letter-spacing:.06em}
.eyebrow{margin:0 0 .55rem;font-size:.72rem;font-weight:700;letter-spacing:.16em;
color:var(--navy);text-transform:none}
.eyebrow::before{content:"";display:inline-block;width:22px;height:2px;background:var(--navy);
vertical-align:middle;margin-right:.6rem}
h2.title{margin:0 0 1.1rem;font-size:clamp(1.7rem,3.3vw,2.9rem);line-height:1.24;
letter-spacing:-.025em;font-weight:800;max-width:22ch}
h2.title.wide{max-width:34ch}
.lead{margin:0 0 1.4rem;font-size:clamp(1rem,1.35vw,1.18rem);color:var(--ink-2);max-width:56ch}
.note{margin:1.15rem 0 0;padding-left:.95rem;border-left:3px solid var(--navy);
font-size:.94rem;color:var(--ink-2);max-width:74ch}
/* 표지 */
section.cover{padding:0;background:var(--navy);color:#fff;justify-content:flex-end;overflow:hidden}
section.cover .shot{position:absolute;inset:0}
section.cover .shot img{width:100%;height:100%;object-fit:cover;opacity:.52}
section.cover .veil{position:absolute;inset:0;
background:linear-gradient(180deg,rgba(0,26,60,.22) 0%,rgba(0,26,60,.62) 55%,rgba(0,20,46,.94) 100%)}
section.cover .inner{position:relative;padding:0 5vw 8vh}
section.cover .eyebrow{color:#9DC0EE}
section.cover .eyebrow::before{background:#9DC0EE}
section.cover h1{margin:.2rem 0 .8rem;font-size:clamp(2.2rem,5.4vw,4.4rem);line-height:1.1;
letter-spacing:-.035em;font-weight:800}
section.cover .lead{color:#DCE7F6;font-size:clamp(1.02rem,1.7vw,1.4rem);max-width:44ch;margin-bottom:1.6rem}
section.cover .foot{font-family:var(--mono);font-size:.76rem;color:#8FB2E2;letter-spacing:.04em;
border-top:1px solid rgba(255,255,255,.22);padding-top:.9rem;display:inline-block}
/* 3열 진술 */
.cols{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0;margin-top:.5rem;
border-top:2px solid var(--ink)}
.cols .col{padding:1.15rem 1.5rem 0 0;min-width:0}
.cols .col + .col{padding-left:1.5rem;border-left:1px solid var(--line)}
.cols h3{margin:0 0 .5rem;font-size:1.02rem;font-weight:700;letter-spacing:-.01em}
.cols p{margin:0;font-size:.92rem;color:var(--ink-2)}
/* 도판 */
.figure{margin:.3rem 0 0;display:grid;grid-template-columns:minmax(0,1fr);gap:.7rem}
.figure img{width:100%;height:auto;display:block;border:1px solid var(--line);
background:#fff;max-height:50vh;object-fit:contain}
.media{position:relative;border:1px solid var(--line);background:#0B1220;overflow:hidden;
display:flex;align-items:center;justify-content:center}
.media video,.media img{width:auto;max-width:100%;height:auto;display:block;max-height:46vh}
/* 스틸 대체 컷은 도판 자체가 흰 바탕이라, 영상용 검은 레터박스를 두르면 무겁고 지저분하다. */
.media.still{background:#fff}
.media.still.tall img{max-height:54vh}
/* 재현 배지 — 지울 수 없게 템플릿에 박아 둔다. 파일 D-0 참조. */
.badge-reenact{position:absolute;left:.7rem;top:.7rem;z-index:2;background:var(--alarm);color:#fff;
font-size:.68rem;font-weight:700;letter-spacing:.1em;padding:.28rem .6rem;
border-radius:2px}
.missing{position:absolute;right:.7rem;top:.7rem;z-index:2;background:rgba(255,255,255,.86);
color:var(--ink-3);font-size:.64rem;padding:.28rem .6rem;border-radius:2px;
font-family:var(--mono);border:1px solid var(--line)}
figcaption{font-size:.86rem;color:var(--ink-3)}
.split{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:2rem;align-items:start}
@media (max-width:900px){.split{grid-template-columns:minmax(0,1fr)}}
ul.points{margin:.2rem 0 0;padding:0;list-style:none}
ul.points li{position:relative;padding:.6rem 0 .6rem 1.15rem;border-bottom:1px solid var(--line-2);
font-size:.95rem;color:var(--ink-2)}
ul.points li::before{content:"";position:absolute;left:0;top:1.05rem;width:5px;height:5px;
background:var(--navy);border-radius:50%}
.kicker{margin:1rem 0 0;padding:.75rem .9rem;background:var(--navy-wash);
border-left:3px solid var(--navy);font-size:.9rem;color:var(--ink-2)}
/* 좌우 비교 */
.compare{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1.4rem}
@media (max-width:900px){.compare{grid-template-columns:minmax(0,1fr)}}
.pane{min-width:0}
.pane .tag{display:inline-block;font-size:.74rem;font-weight:700;letter-spacing:.06em;
padding:.3rem .7rem;border-radius:2px;margin-bottom:.55rem}
.pane.ok .tag{background:var(--ok-wash);color:var(--ok);border:1px solid #BFD9CF}
.pane.alarm .tag{background:var(--alarm-wash);color:var(--alarm);border:1px solid #EFC9CC}
.pane p{margin:.55rem 0 0;font-size:.92rem;color:var(--ink-2)}
/* 지표 */
.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0;border-top:2px solid var(--ink)}
@media (max-width:800px){.metrics{grid-template-columns:minmax(0,1fr)}}
.metrics .m{padding:1.1rem 1.6rem 1.1rem 0;border-bottom:1px solid var(--line);min-width:0}
.metrics .m:nth-child(even){padding-left:1.6rem;border-left:1px solid var(--line)}
.metrics h3{margin:0 0 .35rem;font-size:1.02rem;color:var(--navy);font-weight:700}
.metrics p{margin:0;font-size:.92rem;color:var(--ink-2)}
/* 현재 상태 */
.status{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1.6rem}
@media (max-width:900px){.status{grid-template-columns:minmax(0,1fr)}}
.status .box{padding:1.05rem 1.2rem;border:1px solid var(--line);background:#fff;min-width:0}
.status .box.done{border-top:3px solid var(--ok)}
.status .box.pending{border-top:3px solid var(--caution)}
.status h3{margin:0 0 .6rem;font-size:.98rem;font-weight:700}
.status .box.done h3{color:var(--ok)}
.status .box.pending h3{color:var(--caution)}
.status ul{margin:0;padding-left:1.05rem}
.status li{font-size:.91rem;color:var(--ink-2);margin:.32rem 0}
/* 마무리 */
section.closing{background:var(--navy);color:#fff}
section.closing .eyebrow{color:#9DC0EE}
section.closing .eyebrow::before{background:#9DC0EE}
section.closing h2.title{color:#fff}
section.closing .lines{margin:.6rem 0 0;font-size:clamp(1.05rem,2vw,1.6rem);line-height:1.7;
max-width:38ch;color:#DCE7F6}
section.closing .lines b{color:#fff;font-weight:800}
section.closing .num{color:#8FB2E2}
section.closing .foot{margin-top:2rem;font-family:var(--mono);font-size:.78rem;color:#8FB2E2;
letter-spacing:.22em;border-top:1px solid rgba(255,255,255,.22);
padding-top:.9rem;display:inline-block}
.nav{position:fixed;right:1.1rem;bottom:3.2rem;z-index:21;display:flex;gap:.5rem}
.nav button{width:46px;height:46px;border-radius:3px;border:1px solid var(--line);
background:rgba(255,255,255,.94);color:var(--navy);font-size:1.1rem;line-height:1;
cursor:pointer;display:flex;align-items:center;justify-content:center}
.nav button:hover{background:var(--navy);color:#fff;border-color:var(--navy)}
.nav button:focus-visible{outline:2px solid var(--navy);outline-offset:2px}
.deck:focus{outline:none}
.help{position:fixed;right:1rem;bottom:1rem;z-index:20;font-family:var(--mono);font-size:.68rem;
color:var(--ink-3);background:rgba(255,255,255,.9);border:1px solid var(--line);
padding:.35rem .6rem;border-radius:3px}
@media print{
.rail,.help{display:none}
.deck{margin-left:0;height:auto;overflow:visible}
section.slide{min-height:auto;break-after:page;border-bottom:none;padding:1.2cm}
section.cover,section.closing{-webkit-print-color-adjust:exact;print-color-adjust:exact}
.media video,.media img,.figure img{max-height:12cm}
}
"""
JS = """
(function(){
var slides = Array.prototype.slice.call(document.querySelectorAll('section.slide'));
var dots = Array.prototype.slice.call(document.querySelectorAll('.rail .dots a'));
var idx = 0;
function show(i){
dots.forEach(function(d, k){ d.setAttribute('aria-current', k === i ? 'true' : 'false'); });
slides.forEach(function(s, k){
s.querySelectorAll('video').forEach(function(v){
// 슬라이드에 들어올 때만 처음부터 재생. 자동 반복은 하지 않는다 — 넘어지는 장면이
// 무한 루프로 도는 것은 회의실에서 보기 불편하다.
if (k === i) { try { v.currentTime = 0; v.play(); } catch (err) {} }
else { v.pause(); }
});
});
}
if (window.IntersectionObserver) {
var obs = new IntersectionObserver(function(entries){
entries.forEach(function(e){
if (!e.isIntersecting) return;
idx = slides.indexOf(e.target); // 스크롤·클릭으로 옮겨간 경우도 따라잡는다
show(idx);
});
}, { threshold: 0.55 });
slides.forEach(function(s){ obs.observe(s); });
}
// idx 를 즉시 올리고 이동시킨다. 연타해도 누른 횟수만큼 넘어간다.
function go(delta){
idx = Math.max(0, Math.min(slides.length - 1, idx + delta));
slides[idx].scrollIntoView({ block: 'start' }); // 'smooth' 는 스냅 컨테이너에서 취소된다
show(idx);
}
var NEXT = { ArrowDown:1, ArrowRight:1, PageDown:1, ' ':1, Enter:1 };
var PREV = { ArrowUp:1, ArrowLeft:1, PageUp:1 };
document.addEventListener('keydown', function(e){
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (NEXT[e.key]) { e.preventDefault(); go(1); }
else if (PREV[e.key]) { e.preventDefault(); go(-1); }
else if (e.key === 'Home') { e.preventDefault(); idx = 1; go(-1); }
else if (e.key === 'End') { e.preventDefault(); idx = slides.length - 2; go(1); }
});
dots.forEach(function(d, k){
d.addEventListener('click', function(){ idx = k; });
});
var prevBtn = document.querySelector('.nav-prev');
var nextBtn = document.querySelector('.nav-next');
if (prevBtn) prevBtn.addEventListener('click', function(){ go(-1); });
if (nextBtn) nextBtn.addEventListener('click', function(){ go(1); });
// .deck 이 스크롤 컨테이너인데 포커스를 못 받으면 방향키가 문서 스크롤러로 가버려
// 아무 일도 일어나지 않는다. 포커스 가능하게 만들고 처음에 한 번 준다.
var deck = document.querySelector('.deck');
if (deck) { deck.setAttribute('tabindex', '-1'); try { deck.focus({preventScroll:true}); } catch (e) {} }
show(0);
})();
"""
def esc(s: str) -> str:
return html.escape(str(s), quote=True)
def media_block(clip: str | None, poster: str, alt: str) -> str:
"""클립이 있으면 video, 없으면 포스터 스틸. 어느 쪽이든 재현 배지를 단다.
배지 문구는 실제 매체를 따른다 — 정지 이미지에 "재현 영상"이라고 붙이는 것 자체가
작은 거짓말이고, 이 덱의 존재 이유가 그 반대다.
"""
sources = clip_sources(clip)
live = sources != []
out = [f'<div class="media{"" if live else " still"}">',
f'<span class="badge-reenact">재현 {"영상" if live else "이미지"}</span>']
if live:
tags = "".join(f'<source src="clips/{esc(f)}" type="{m}">' for f, m in sources)
shot = clip_poster(clip)
uri = file_uri(shot) if shot else data_uri(poster)
out.append(
f'<video poster="{uri}" muted playsinline preload="metadata" '
f'controls aria-label="{esc(alt)} (재현 영상)">{tags}</video>')
else:
out.append('<span class="missing">클립 대기</span>')
out.append(f'<img src="{data_uri(poster)}" alt="{esc(alt)} (재현 이미지)">')
out.append("</div>")
return "".join(out)
def clip_sources(name: str | None) -> list[tuple[str, str]]:
"""@return [(파일명, MIME), ...] — 재생 가능한 형식만, 선호 순서대로. 없으면 빈 목록."""
if not name:
return []
stem = pathlib.Path(name).stem
return [(stem + ext, mime) for ext, mime in CLIP_TYPES if (CLIPS / (stem + ext)).is_file()]
def has_clip(name: str | None) -> bool:
return clip_sources(name) != []
def head(sl: dict) -> str:
o = []
if sl.get("eyebrow"):
o.append(f'<p class="eyebrow">{esc(sl["eyebrow"])}</p>')
if sl.get("title"):
wide = " wide" if len(sl["title"]) > 34 else ""
o.append(f'<h2 class="title{wide}">{sl["title"]}</h2>') # 의도된 인라인 마크업 허용
if sl.get("lead"):
o.append(f'<p class="lead">{esc(sl["lead"])}</p>')
return "".join(o)
def note(sl: dict) -> str:
return f'<p class="note">{esc(sl["note"])}</p>' if sl.get("note") else ""
def render_slide(sl: dict, idx: int) -> str:
kind = sl["kind"]
body = []
if kind == "cover":
return (f'<section class="slide cover" id="s{idx}">'
f'<div class="shot"><img src="{data_uri(sl["image"])}" alt=""></div>'
f'<div class="veil"></div><div class="inner">'
f'<p class="eyebrow">{esc(sl["eyebrow"])}</p>'
f'<h1>{esc(sl["title"])}</h1>'
f'<p class="lead">{esc(sl["lead"])}</p>'
f'<span class="foot">{esc(sl["foot"])}</span>'
f'</div></section>')
if kind == "closing":
lines = "".join(f"<div>{ln}</div>" for ln in sl["lines"]) # 의도된 <b> 허용
return (f'<section class="slide closing" id="s{idx}">'
f'<span class="num">{idx:02d}</span>'
f'<p class="eyebrow">{esc(sl["eyebrow"])}</p>'
f'<h2 class="title">{esc(sl["title"])}</h2>'
f'<div class="lines">{lines}</div>'
f'<span class="foot">{esc(sl["foot"])}</span></section>')
body.append(head(sl))
if kind == "statement":
cols = "".join(f'<div class="col"><h3>{esc(c["head"])}</h3><p>{esc(c["body"])}</p></div>'
for c in sl["columns"])
body.append(f'<div class="cols">{cols}</div>')
elif kind == "figure":
body.append(f'<figure class="figure"><img src="{data_uri(sl["image"])}" '
f'alt="{esc(sl.get("title", ""))}"></figure>')
elif kind == "clip":
pts = "".join(f"<li>{esc(p)}</li>" for p in sl.get("points", []))
right = f'<ul class="points">{pts}</ul>'
if sl.get("kicker"):
right += f'<p class="kicker">{esc(sl["kicker"])}</p>'
body.append(
'<div class="split"><figure class="figure">'
+ media_block(sl.get("clip"), sl["poster"], sl.get("title", ""))
+ (f'<figcaption>{esc(sl["caption"])}</figcaption>' if sl.get("caption") else "")
+ f'</figure><div>{right}</div></div>')
elif kind == "compare":
# 09 번 스틸은 그 자체가 이미 좌우 비교 그림이다. 클립이 없는 상태에서 좌우 패널로
# 쪼개 각 칸에 넣으면 같은 비교가 두 겹으로 들어가 오히려 읽히지 않는다.
if sl.get("fallback") and not (has_clip(sl["left"].get("clip"))
or has_clip(sl["right"].get("clip"))):
body.append('<figure class="figure"><div class="media still tall">'
'<span class="badge-reenact">재현 이미지</span>'
f'<img src="{data_uri(sl["fallback"])}" '
f'alt="{esc(sl.get("title", ""))} (재현 이미지)"></div></figure>')
else:
panes = []
for side in ("left", "right"):
pane = sl[side]
panes.append(
f'<div class="pane {esc(pane["tone"])}"><span class="tag">{esc(pane["label"])}</span>'
+ media_block(pane.get("clip"), pane["poster"], pane["label"])
+ f'<p>{esc(pane["body"])}</p></div>')
body.append(f'<div class="compare">{"".join(panes)}</div>')
elif kind == "metrics":
items = "".join(f'<div class="m"><h3>{esc(m["head"])}</h3><p>{esc(m["body"])}</p></div>'
for m in sl["items"])
body.append(f'<div class="metrics">{items}</div>')
elif kind == "status":
def box(cls: str, blk: dict) -> str:
li = "".join(f"<li>{esc(i)}</li>" for i in blk["items"])
return f'<div class="box {cls}"><h3>{esc(blk["head"])}</h3><ul>{li}</ul></div>'
body.append('<div class="status">' + box("done", sl["done"])
+ box("pending", sl["pending"]) + "</div>")
else:
raise SystemExit(f"ERROR: 알 수 없는 슬라이드 종류: {kind}")
body.append(note(sl))
return (f'<section class="slide" id="s{idx}"><span class="num">{idx:02d}</span>'
+ "".join(body) + "</section>")
def render(doc: dict, version: str) -> str:
slides = doc["slides"]
dots = "".join(f'<a href="#s{i}" aria-label="슬라이드 {i}"></a>' for i in range(len(slides)))
body = "".join(render_slide(sl, i) for i, sl in enumerate(slides))
today = datetime.date.today().isoformat()
return (
'<!doctype html><html lang="ko"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
f"<title>{esc(doc['title'])} — {esc(doc['audience'])}</title>"
f"<style>{CSS}</style></head><body>"
f'<nav class="rail"><span class="mark">{esc(doc["title"])}</span>'
f'<span class="dots">{dots}</span>'
f'<span class="mark">v{esc(version)} · {today}</span></nav>'
f'<main class="deck">{body}</main>'
'<div class="nav">'
'<button class="nav-prev" type="button" aria-label="이전 슬라이드">&#8593;</button>'
'<button class="nav-next" type="button" aria-label="다음 슬라이드">&#8595;</button>'
'</div>'
'<p class="help">← → 또는 버튼으로 이동 · F11 전체화면</p>'
f"<script>{JS}</script></body></html>")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=str(HERE / "경영진-보고.html"))
args = ap.parse_args()
vf = REPO / "VERSION"
if not vf.is_file():
raise SystemExit("ERROR: VERSION 파일이 없습니다. 버전 없는 보고 자료는 만들지 않습니다.")
version = vf.read_text(encoding="utf-8").strip()
doc = json.loads((HERE / "deck.json").read_text(encoding="utf-8"))
out = pathlib.Path(args.out)
out.write_text(render(doc, version), encoding="utf-8")
wanted, have = [], []
for sl in doc["slides"]:
for c in ([sl.get("clip")] if sl.get("clip") else []) + \
[sl[s].get("clip") for s in ("left", "right") if isinstance(sl.get(s), dict)]:
if c and c not in wanted:
wanted.append(c)
if has_clip(c):
have.append(c)
missing = [c for c in wanted if c not in have]
print(f"{out} ({out.stat().st_size/1024:.0f} KB, 슬라이드 {len(doc['slides'])}장)", file=sys.stderr)
if missing:
print(f"클립 대기 {len(missing)}/{len(wanted)} — 해당 자리는 스틸로 대체됨: "
+ ", ".join(missing), file=sys.stderr)
else:
print("클립 전량 배치됨. HTML 과 clips/ 폴더를 같이 옮기십시오.", file=sys.stderr)
if __name__ == "__main__":
main()