받은 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 카메라 건드림) 자리는 계속 스틸로 대체된다.
509 lines
25 KiB
Python
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="이전 슬라이드">↑</button>'
|
|
'<button class="nav-next" type="button" aria-label="다음 슬라이드">↓</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()
|