diff --git a/.gitignore b/.gitignore index 7cba83c..f13c0f5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,10 @@ deploy/device.env # 납품 패키저가 빌드할 때마다 다시 만든다. 커밋된 사본을 두면 화면이 바뀐 뒤에도 그것이 # 최신처럼 보이는데, 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다. server/deploy/manual/사용설명서.html + +# 경영진 보고 덱 — 생성물. 매뉴얼(사용설명서.html)과 같은 이유로 커밋하지 않는다: +# 낡은 사본이 최신인 척하는 것이 가장 위험하다. build_deck.py 로 다시 뽑을 것. +docs/deck/경영진-보고.html +# 3초 클립은 바이너리다. 저장소가 아니라 발표 노트북에 둔다. +docs/deck/clips/* +!docs/deck/clips/.gitkeep diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 63e8534..e255155 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -39,6 +39,11 @@ `unacked_escalation` 이벤트 발행, event_uuid 로 멱등) · `DeviceHealthWatch`(하트비트 부재 → `device_fault`, 지속 성능저하 → `monitoring_degraded`; 엣지가 낼 수 없는 두 유형을 서버가 발행. uuid 앵커 = 장애 구간 시작이라 장애당 1건) +- `docs/deck/`: 경영진 보고 덱. `deck.json`(내용) + `build_deck.py`(생성기) + `clips/`(3초 재현 + 영상, 없으면 스틸로 자동 대체). 출력 `경영진-보고.html` 은 gitignore — 낡은 사본 방지. + 발주 명세는 `docs/영상-브리프.md`. +- `docs/images/`: 소개서·덱 공용 도판 11장(원본은 `image/`, 최적화본이 여기). + 배치 매니페스트 `docs/소개서-이미지.json`. - `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명. 목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게) - `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) · diff --git a/docs/deck/build_deck.py b/docs/deck/build_deck.py new file mode 100644 index 0000000..92fef16 --- /dev/null +++ b/docs/deck/build_deck.py @@ -0,0 +1,470 @@ +#!/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"} + + +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, 없으면 포스터 스틸. 어느 쪽이든 재현 배지를 단다. + + 배지 문구는 실제 매체를 따른다 — 정지 이미지에 "재현 영상"이라고 붙이는 것 자체가 + 작은 거짓말이고, 이 덱의 존재 이유가 그 반대다. + """ + live = bool(clip) and (CLIPS / clip).is_file() + out = [f'
{esc(sl["eyebrow"])}
') + if sl.get("title"): + wide = " wide" if len(sl["title"]) > 34 else "" + o.append(f'{esc(sl["lead"])}
') + return "".join(o) + + +def note(sl: dict) -> str: + return f'{esc(sl["note"])}
' if sl.get("note") else "" + + +def render_slide(sl: dict, idx: int) -> str: + kind = sl["kind"] + body = [] + + if kind == "cover": + return (f'') + + if kind == "closing": + lines = "".join(f"{esc(c["body"])}
{esc(sl["kicker"])}
' + body.append( + '{esc(pane["body"])}
{esc(m["body"])}
← → 또는 버튼으로 이동 · F11 전체화면
' + f"") + + +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) + (have if (CLIPS / c).is_file() else []).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() diff --git a/docs/deck/clips/.gitkeep b/docs/deck/clips/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/deck/deck.json b/docs/deck/deck.json new file mode 100644 index 0000000..02d3c90 --- /dev/null +++ b/docs/deck/deck.json @@ -0,0 +1,203 @@ +{ + "title": "작업장 안전 AI CCTV", + "subtitle": "쓰러진 사람을 3초 안에 알아채고, 경광등을 울리고, 증거를 남깁니다", + "audience": "경영진 보고", + "slides": [ + { + "kind": "cover", + "image": "01-현장-설치.jpg", + "eyebrow": "제품 보고", + "title": "작업장 안전 AI CCTV", + "lead": "쓰러진 사람을 3초 안에 알아채고, 경광등을 울리고, 증거를 남깁니다.", + "foot": "감지 · 대응 · 증거 — 셋이 한 사슬로 묶인 시스템" + }, + { + "kind": "statement", + "eyebrow": "문제", + "title": "CCTV는 이미 있습니다.