docs(deck): 경영진 보고 덱 + 영상 브리프, 소개서에 도판 11장 배치

받은 이미지 11장을 docs/images/ 로 최적화 배치(37.6MB -> 3.3MB: 사진은
JPEG q88, 평면 다이어그램은 PNG-8, 최대폭 1600). 원본은 image/ 에 그대로 둔다.

소개서: 매니페스트(docs/소개서-이미지.json)로 11장을 절에 배치해 HTML 재생성
(4.6MB 단일 파일). 표지 컷은 1장 앞에 before-heading 으로.

경영진 보고 덱(docs/deck/): 14장. 매뉴얼 생성기와 같은 구조 —
deck.json(내용) + build_deck.py(생성기) + clips/(3초 클립). 손으로 쓴 HTML 은
두 번째 수정부터 낡고, 낡은 덱은 회의실에서 틀린 숫자를 띄운다.

클립이 없으면 포스터 스틸로 자동 대체되므로 지금 상태로도 완성본이다.
비교 슬라이드는 클립이 하나도 없으면 이미 좌우 비교인 09 스틸 1장으로
바뀐다 — 좌우 패널에 쪼개 넣으면 같은 비교가 두 겹이 된다.

모든 클립·스틸에 '재현' 배지를 템플릿에 박았다. 이 영상들은 제품 동작
녹화가 아니라 연출된 재현이고, 실제 동작처럼 보여주면 허위 시연이 된다.
배지 문구는 매체를 따른다(영상/이미지) — 스틸에 "재현 영상"은 그 자체가
작은 거짓말이다. 실화면 캡처 8장에는 배지가 붙지 않는다.

실렌더로 확인하며 잡은 결함:
- 마지막 슬라이드가 백지였다. :nth-child(even) 이 section.closing 보다
  명시도가 높아 네이비 배경을 덮고, 흰 글자가 흰 배경에 얹혔다(대비 1:1).
- 한국어 제목이 어절 중간에서 끊겼다("설명할 수 있습/니다") — CJK 기본
  줄바꿈이 글자 단위다. word-break:keep-all.
- 슬라이드 이동이 전혀 동작하지 않았다. scroll-snap-type:y mandatory 인
  컨테이너에서 behavior:'smooth' 가 취소되고 원위치로 스냅된다
  (측정: smooth->0, auto->905). 즉시 전환으로 변경 — 덱에서는 컷 전환이
  오히려 슬라이드답다. 키보드가 항상 잡히는 것은 아니라 이전/다음 버튼도 추가.
- 도판이 커서 하단 주석이 화면 밖으로 밀렸다(max-height 하향).
- 표지 베일이 무거워 경광등 빨강이 죽었다 — 이 제품의 상징색이라 살렸다.
- 4번과 5번이 같은 도판을 썼다(포스터 교체).

접근성 게이트(측정): 대비 실패 0건. figcaption 4.35:1 미달로 --ink-3 을
#5C6474 로, 네이비 위 슬라이드 번호 2.0:1 을 #8FB2E2 로 교정. 레일 점은
7x7 이라 발표 중 못 맞춘다 — 점은 그대로 두고 히트영역 24x24(WCAG 2.5.8),
이전/다음 버튼 46x46.

영상 브리프(docs/영상-브리프.md): 3초 클립 6종. 스틸이 못 하는 것만 발주
했고, 클립마다 0.0~3.0초 타임라인·프레임 안에 반드시 있어야 할 것·피할 것을
적었다. 대시보드 녹화는 제외 — 실화면 캡처가 이미 있고 그건 진짜다.

생성물(경영진-보고.html)과 clips/ 는 gitignore — 사용설명서.html 과 같은 이유.
This commit is contained in:
유창욱 2026-08-25 11:03:50 +09:00
parent 643b428d0a
commit 9ee3dab378
30 changed files with 970 additions and 6 deletions

7
.gitignore vendored
View file

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

View file

@ -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회, 보존기간) ·

470
docs/deck/build_deck.py Normal file
View file

@ -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'<div class="media{"" if live else " still"}">',
f'<span class="badge-reenact">재현 {"영상" if live else "이미지"}</span>']
if live:
out.append(
f'<video src="clips/{esc(clip)}" poster="{data_uri(poster)}" muted playsinline '
f'preload="metadata" controls aria-label="{esc(alt)} (재현 영상)"></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 has_clip(name: str | None) -> bool:
return bool(name) and (CLIPS / name).is_file()
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)
(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()

0
docs/deck/clips/.gitkeep Normal file
View file

203
docs/deck/deck.json Normal file
View file

@ -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는 이미 있습니다.<br>그런데 왜 사고를 못 막습니까.",
"columns": [
{
"head": "아무도 안 보고 있다",
"body": "카메라 수십 대를 사람이 실시간으로 볼 수는 없습니다. 화면은 사고가 난 뒤에 되감기 위한 것이 됩니다."
},
{
"head": "알아도 알릴 방법이 없다",
"body": "설령 감지해도 화면에만 뜨면 그것은 기록 장치입니다. 새벽 3시에 넘어진 사람은 아침까지 그대로입니다."
},
{
"head": "나중에 설명할 수 없다",
"body": "사고 조사에서 필요한 것은 영상이 아니라 시각·조치·통보의 기록인데, 그 기록이 남지 않습니다."
}
]
},
{
"kind": "figure",
"eyebrow": "해결 구조",
"title": "하나의 사슬입니다",
"image": "05-사슬.png",
"note": "어느 한 칸이 비면 나머지가 무의미해집니다. 감지만 있으면 아무도 모르고, 대응만 있으면 나중에 설명할 수 없습니다."
},
{
"kind": "clip",
"eyebrow": "핵심 시연",
"title": "쓰러지면, 경광등이 켜집니다",
"clip": "v01-넘어짐-경광등.mp4",
"poster": "02-경광등.jpg",
"caption": "쓰러진 자세가 3초 지속되면 확정. 감지한 노드가 자기 GPIO로 직접 울립니다.",
"points": [
"서버도 인터넷도 거치지 않습니다 — 회선이 끊겨도, 서버가 죽어도 울립니다",
"10초 넘게 움직임이 없으면 최상위 위급으로 스스로 올라갑니다",
"1분 안에 담당자에게, 5분 안에 확인되지 않으면 상위 담당자에게"
]
},
{
"kind": "figure",
"eyebrow": "판정 근거",
"title": "언제 감지되는지 설명할 수 있습니다",
"image": "08-넘어짐-감지-시퀀스.jpg",
"note": "한 프레임 보고 놀라서 울리는 것이 아니라, 자세가 지속되는지를 초 단위로 확인하고 단계적으로 올립니다. 도중에 일어서면 알람 없이 니어미스로만 기록됩니다 — 사고 전 신호가 통계로 쌓입니다."
},
{
"kind": "compare",
"eyebrow": "가장 많이 받는 질문",
"title": "앉으면 오탐이 나지 않습니까",
"left": {
"label": "울리지 않음",
"tone": "ok",
"clip": "v02-앉기-정상.mp4",
"poster": "09-앉기-vs-넘어짐.jpg",
"body": "쭈그려 앉거나 허리를 굽힌 자세는 세로가 여전히 깁니다."
},
"right": {
"label": "울림",
"tone": "alarm",
"clip": "v01-넘어짐-경광등.mp4",
"poster": "08-넘어짐-감지-시퀀스.jpg",
"body": "가로가 세로보다 넓어진 상태가 3초 이상 지속될 때만 확정합니다."
},
"note": "그래도 오탐은 납니다. 화면에서 오탐 표시 한 번이면 통계에서 빠지고, 감도 조정의 근거가 됩니다. 오탐율은 화면에 그대로 표시됩니다.",
"fallback": "09-앉기-vs-넘어짐.jpg"
},
{
"kind": "clip",
"eyebrow": "구역 규칙",
"title": "위험구역은 화면 위에 직접 그립니다",
"clip": "v04-구역진입.mp4",
"poster": "10-위험구역-판정.jpg",
"caption": "판정 기준은 발 위치입니다. 구역 안에 0.75초 이상 머물면 이벤트가 남습니다.",
"points": [
"경고 · 제한 · 협착 3단계로 위험도를 나눠 지정합니다",
"2인 1조 구역에 혼자 남으면 30초 뒤 사건이 됩니다",
"정원 초과 · 장시간 체류도 같은 방식으로 규칙화됩니다"
]
},
{
"kind": "clip",
"eyebrow": "차별점",
"title": "감시 장비 자신의 고장도 신고합니다",
"clip": "v05-카메라-건드림.mp4",
"poster": "01-현장-설치.jpg",
"caption": "청소하다 카메라를 스치면 위험구역이 엉뚱한 바닥을 가리킵니다. 그런데 프레임은 계속 들어오고 화면은 정상으로 보입니다.",
"points": [
"화각 변경 · 렌즈 가림 · 조도 저하를 스스로 감지합니다",
"노드가 응답을 멈추면 서버가 하트비트의 부재로 판정합니다",
"\"아무도 확인하지 않았다\"는 사실 자체도 사건으로 기록됩니다"
],
"kicker": "이것이 이 제품의 조용한 실패 방어선입니다. 대부분의 감시 시스템은 자기가 멈춘 것을 모릅니다."
},
{
"kind": "figure",
"eyebrow": "기대효과",
"title": "달라지는 것은 발견까지 걸리는 시간입니다",
"image": "07-대응시간.png",
"note": "사고를 없애지는 못합니다. 발견과 대응이 빨라질 뿐입니다 — 그런데 쓰러진 사람에게는 그 차이가 결과를 바꿉니다."
},
{
"kind": "metrics",
"eyebrow": "기대효과",
"title": "효과는 저희가 주장하지 않습니다.<br>시스템이 스스로 측정해 보여줍니다",
"items": [
{
"head": "평균 대응시간",
"body": "감지에서 확인까지 걸린 시간. 개선 활동의 성패가 이 숫자 하나로 드러납니다."
},
{
"head": "니어미스 : 사고 비율",
"body": "넘어질 뻔한 횟수가 쌓입니다. 사고가 나기 전에 어디가 위험한지 보입니다."
},
{
"head": "조치 전후 감지 밀도",
"body": "안전 조치를 한 구역에서 이벤트가 실제로 줄었는지 히트맵으로 비교합니다."
},
{
"head": "오탐율",
"body": "숨기지 않고 화면에 표시합니다. 신뢰할 수 있는 시스템인지 스스로 증명하게 합니다."
}
],
"note": "공급자의 ROI 추정이 아니라 고객 화면의 숫자입니다."
},
{
"kind": "statement",
"eyebrow": "반드시 나올 질문",
"title": "\"근로자 감시 아닙니까\"",
"lead": "노조가 물을 것이고, 물어야 합니다. 답은 약속이 아니라 구조여야 합니다.",
"columns": [
{
"head": "영상을 녹화하지 않습니다",
"body": "실시간으로 들여다보는 화면 자체가 제품에 없습니다. 위험 상황이 감지된 순간에만 사진 3장이 남고, 7일 뒤 자동 삭제됩니다."
},
{
"head": "누구인지 모릅니다",
"body": "이름도 사번도 얼굴 인식도 없습니다. 얼굴은 카메라 쪽에서, 전송 전에 가려집니다."
},
{
"head": "근무태도 기능이 없습니다",
"body": "감지 17종 전부가 위험 상태입니다. 자리 비움·작업 속도는 만들 수 있는 기능 자체가 없습니다."
}
],
"note": "존재하지 않는 데이터는 열람될 수 없고, 유출될 수 없고, 목적 외로 쓰일 수 없습니다. 접근 통제는 되돌릴 수 있지만 미수집은 되돌릴 수 없습니다."
},
{
"kind": "figure",
"eyebrow": "구성",
"title": "현장에 노드, 사무실에 대시보드",
"image": "06-구성도.png",
"note": "노드에서 서버로 가는 것은 스냅샷 3장과 메타데이터뿐입니다. 폐쇄망에서도 동작하며, 회선이 끊긴 동안의 이벤트는 노드에 쌓였다가 복구되면 올라갑니다."
},
{
"kind": "status",
"eyebrow": "현재 상태",
"title": "지금 어디까지 와 있습니까",
"done": {
"head": "완료 — 1.0.0 납품 버전",
"items": [
"감지 17종 · 통보 · 에스컬레이션 · 사고조사 패키지",
"자동 테스트 548건 통과 (엣지 308 · 서버 240)",
"납품 점검 43건 + 재감사 12건 전량 해소 — 판정 적합",
"폐쇄망 설치 패키지 · 역할별 사용설명서"
]
},
"pending": {
"head": "남은 것 — 실장비 현장 검증",
"items": [
"경광등 실제 점등 · 카메라 감도 현장 튜닝",
"실제 영상으로 넘어짐 판정 타이밍 확인",
"보호구 미착용 · 중장비 근접 · 화재 감지는 모델 확보가 선행되어야 합니다"
]
},
"note": "감지율 100%는 없습니다. 초기에는 오탐이 반드시 납니다. 그 사실을 숨기지 않는 것이 이 시스템을 계속 쓰게 만드는 조건입니다."
},
{
"kind": "closing",
"eyebrow": "요약",
"title": "이 시스템이 하는 일",
"lines": [
"쓰러진 자세가 <b>3초</b> 지속되면 확정하고,",
"감지한 노드가 <b>즉시</b> 경광등을 울리고,",
"<b>1분</b> 안에 담당자에게, <b>5분</b> 안에 확인되지 않으면 위로 올립니다.",
"그리고 무슨 일이 있었는지 <b>지울 수 없는 형태로</b> 남깁니다."
],
"foot": "감지 · 대응 · 증거"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

BIN
docs/images/05-사슬.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

BIN
docs/images/11-2인1조.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View file

@ -0,0 +1,60 @@
{
"images": [
{
"file": "01-현장-설치.jpg",
"heading": "## 1. 왜 만들었나",
"placement": "before-heading",
"caption": "작업장에 설치된 카메라와 현장 경광등."
},
{
"file": "05-사슬.png",
"heading": "## 2. 무엇을 하는가",
"caption": "감지 → 대응 → 증거. 어느 한 칸이 비면 나머지가 무의미해집니다."
},
{
"file": "08-넘어짐-감지-시퀀스.jpg",
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
"caption": "넘어짐 감지 단계. 1초 위험 자세 → 3초 확정 → 10초 미동 없음. 도중에 일어서면 니어미스로만 기록됩니다."
},
{
"file": "09-앉기-vs-넘어짐.jpg",
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
"caption": "앉은 자세와 넘어진 자세는 형태와 지속 시간으로 구분합니다."
},
{
"file": "10-위험구역-판정.jpg",
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
"caption": "위험구역은 화면 위에 직접 그립니다. 판정 기준은 발 위치입니다."
},
{
"file": "11-2인1조.jpg",
"heading": "### 2-1. 감지 — 무엇을 알아채는가",
"caption": "자세뿐 아니라 구역 안의 인원 수도 안전 조건입니다."
},
{
"file": "02-경광등.jpg",
"heading": "### 2-2. 대응 — 어떻게 사람에게 닿는가",
"caption": "현장 경광등은 감지한 노드가 자기 GPIO로 직접 울립니다. 서버도 인터넷도 거치지 않습니다."
},
{
"file": "07-대응시간.png",
"heading": "### 3-1. 즉시 달라지는 것",
"caption": "발견까지 걸리는 시간의 변화."
},
{
"file": "06-구성도.png",
"heading": "## 4. 시스템 구성",
"caption": "카메라 → 엣지 노드 → 대시보드. 노드에서 서버로 가는 것은 스냅샷 3장과 메타데이터뿐입니다."
},
{
"file": "03-엣지노드.jpg",
"heading": "### 4-1. 엣지 노드",
"caption": "Raspberry Pi 5 + Hailo-8 가속기."
},
{
"file": "04-위험구역.jpg",
"heading": "## 6. 납품과 운영",
"caption": "설치 대상이 되는 전형적인 작업 공간."
}
]
}

196
docs/영상-브리프.md Normal file
View file

@ -0,0 +1,196 @@
# 경영진 보고용 3초 영상 브리프
> 대상: `docs/deck/` 경영진 보고 덱에 들어갈 짧은 클립.
> 이미 확보된 스틸 11장(`docs/images/`)은 그대로 씁니다 — 영상은 **스틸이 못 하는 것**만 발주합니다.
---
## 0. 먼저 읽을 것
### 0-1. ⚠ 모든 클립은 "재현 영상"입니다
이 클립들은 실제 제품이 동작하는 화면 녹화가 아니라 **연출된 재현**입니다.
경영진 보고에서 이를 실제 동작 영상처럼 보여주면 그건 허위 시연입니다.
**덱 템플릿이 모든 클립에 `재현` 배지를 자동으로 붙이도록 만들었습니다.** 지워지지 않게
코드에 박아 뒀습니다 — 발표자가 깜빡할 수 있는 종류의 것이라서요.
실제 제품 화면이 필요한 자리에는 **실화면 캡처 8장**(`server/deploy/manual/shots/`)이 이미
있습니다. 그건 진짜입니다. 재현 클립과 실화면을 덱에서 시각적으로 구분해 두었습니다.
### 0-2. 스틸로 충분한 것은 발주하지 않았습니다
판정 조건·구성도·타임라인은 정지 이미지가 더 잘 전달합니다(읽을 시간이 필요하므로).
아래 6종은 **움직임이 있어야만 전달되는 것**만 골랐습니다.
### 0-3. 공통 사양
| 항목 | 값 |
|---|---|
| 길이 | **3초** (±0.3초) |
| 컷 | **단일 컷, 편집 없음.** 카메라 고정 — CCTV 시점이어야 설득력이 있습니다 |
| 화면비 | 16:9, **1920×1080 이상** |
| 형식 | `.mp4` (H.264 + yuv420p). 브라우저 재생용 |
| 소리 | **없음.** 회의실에서 음소거로 재생합니다 |
| 재생 | 1회 재생 후 **마지막 프레임 정지**. 넘어지는 장면이 무한 반복되면 보기 불편합니다 |
| 얼굴 | **식별 불가**. 뒷모습·측면·헬멧·바닥을 향한 얼굴 |
| 조명 | 실제 공장 조도. 지나치게 영화적인 조명은 재현 티가 나서 오히려 신뢰를 깎습니다 |
| 색 | 스틸 11장과 같은 톤 — 네이비 작업복, 흰 헬멧, 회색 콘크리트 바닥, 노란 안전선 |
### 0-4. 카메라 시점을 고정하세요
**모든 클립이 같은 공장, 같은 높이(약 3~4m), 같은 앵글이면 가장 좋습니다.**
클립마다 장소가 바뀌면 "여러 곳에서 찍은 홍보 영상"처럼 보이고, 한 곳에서 이어지면
**"이 시스템이 이 공장을 보고 있다"** 로 읽힙니다. 후자가 훨씬 강합니다.
가능하면 `08-넘어짐-감지-시퀀스.png` 의 공장을 그대로 쓰세요.
---
## A. 발주 목록
### A-1. `v01-넘어짐-경광등.mp4` ★P0 — 이 덱에서 가장 중요한 3초
**이 클립이 말해야 하는 것**
> 사람이 쓰러지고, **경광등이 켜진다.** 그 사이에 아무도 없다.
**왜 필요한가** — 이 제품의 전부입니다. 감지가 대응으로 이어지는 순간을 정지 이미지로는
보여줄 수 없습니다. 경영진이 기억할 3초가 있다면 이것입니다.
**타임라인**
| 시각 | 화면 |
|---|---|
| 0.0s | 작업자가 기계 앞에 서 있음. 화면 한쪽 상단에 **꺼진 경광등**이 보임 |
| 0.5s | 무너지기 시작 |
| 1.2s | 바닥에 쓰러짐. 헬멧이 굴러감 |
| 2.0s | **경광등 점등** — 붉은 빛이 주변 바닥·기계에 반사 |
| 3.0s | 경광등 켜진 채 정지 |
**꼭** — **경광등이 처음부터 프레임 안에 있어야 합니다.** 꺼진 상태에서 켜지는 것이 보여야
하고, 컷을 나눠 경광등을 따로 보여주면 효과가 절반이 됩니다.
**피할 것** — 슬로모션, 극적인 음악용 편집, 카메라 흔들림(핸드헬드), 유혈.
---
### A-2. `v02-앉기-정상.mp4` ★P0 — 반드시 A-3과 한 쌍
**한 문장**
> 쭈그려 앉아 작업해도 **아무 일도 일어나지 않는다.**
**타임라인** — 작업자가 자연스럽게 쭈그려 앉아 바닥의 부품을 만짐. 3초 내내 경광등 꺼진 채.
**꼭** — **A-1과 완전히 같은 앵글·같은 장소.** 나란히 틀어서 "차이는 자세뿐"이 보여야 합니다.
경광등이 프레임 안에 있고 **끝까지 꺼져 있어야** 합니다.
**왜 필요한가** — "앉으면 오탐 나는 거 아니냐"가 도입 검토의 첫 질문입니다. 말로 답하는 것과
3초를 보여주는 것은 설득력이 다릅니다.
---
### A-3. `v03-허리굽힘-정상.mp4` ★P1
**한 문장**
> 허리를 굽혀 물건을 들어도 울리지 않는다.
A-2와 같은 목적, 다른 자세. 여유가 없으면 A-2만으로도 성립합니다.
---
### A-4. `v04-구역진입.mp4` ★P1
**한 문장**
> **발이 선을 넘는 순간** 판정된다.
**타임라인**
| 시각 | 화면 |
|---|---|
| 0.0s | 바닥에 노란 안전선. 작업자가 선 밖에서 걸어옴 |
| 1.5s | **발이 선을 넘음** |
| 2.5s | 경광등 점등 (또는 주의색) |
| 3.0s | 정지 |
**꼭** — **발과 선이 함께 프레임 안에** 있어야 합니다. 상반신만 잡으면 이 클립의 의미가 사라집니다.
카메라를 조금 낮추거나 각도를 눕혀 바닥이 넓게 보이게.
---
### A-5. `v05-카메라-건드림.mp4` ★P1 — 차별점
**한 문장**
> 카메라를 건드리면 **시스템이 그 사실을 스스로 신고한다.**
**타임라인**
| 시각 | 화면 |
|---|---|
| 0.0s | 정상적인 공장 화면 |
| 1.0s | 화면 전체가 **한 번 흔들리며 각도가 틀어짐** (누군가 청소하다 스친 것처럼) |
| 1.5s | 틀어진 채로 안정 — **화면은 여전히 멀쩡해 보임** |
| 3.0s | 정지 |
**꼭** — 카메라 **자체 시점**입니다(1인칭). 삼각대를 밖에서 찍는 게 아니라, 화면이 흔들려야 합니다.
틀어진 뒤에도 영상이 정상적으로 나오는 것이 핵심 — "고장난 게 아니라서 아무도 모른다"가
이 제품이 잡는 조용한 실패입니다.
**피할 것** — 화면이 까매지거나 노이즈가 끼면 안 됩니다. 그건 누구나 아는 고장입니다.
---
### A-6. `v06-2인1조.mp4` ★P2
**한 문장**
> 한 명이 나가면 **혼자 남은 것**이 판정된다.
**타임라인** — 0.0s 두 사람이 함께 작업 → 1.5s 한 명이 프레임 밖으로 걸어 나감 →
3.0s 남은 한 명 혼자. (실제로는 30초 지속 후 이벤트이므로 클립에 경광등은 켜지 **않습니다.**)
**꼭** — 경광등을 켜지 마세요. 30초 규칙이라 즉시 울리지 않습니다. 덱의 자막이 설명합니다.
---
## B. 우선순위
| | 파일 | 없으면 안 되는가 |
|---|---|---|
| **P0** | `v01-넘어짐-경광등.mp4` | **덱의 핵심.** 이것만 있어도 보고가 됩니다 |
| **P0** | `v02-앉기-정상.mp4` | v01과 쌍. 단독으로는 의미 없음 |
| P1 | `v04-구역진입.mp4` | 구역 기능 설명이 크게 살아납니다 |
| P1 | `v05-카메라-건드림.mp4` | 차별점 슬라이드용 |
| P1 | `v03-허리굽힘-정상.mp4` | v02의 보강 |
| P2 | `v06-2인1조.mp4` | 있으면 좋음 |
**P0 2개만 있어도 덱은 성립합니다.** 나머지 자리는 스틸 이미지로 자동 대체됩니다.
---
## C. 전달 방법
`docs/deck/clips/` 에 위 파일명 그대로 넣어주십시오.
덱 생성기(`docs/deck/build_deck.py`)가 **파일이 있으면 영상을, 없으면 해당 스틸 이미지를**
자동으로 넣습니다. 그래서 지금 P0 2개만 먼저 주셔도 되고, 나중에 하나씩 추가해도
생성기만 다시 돌리면 반영됩니다.
```
python docs/deck/build_deck.py
```
---
## D. 넣지 않기로 한 것
| 제외 | 이유 |
|---|---|
| 대시보드 화면 녹화 | **실화면 캡처 8장이 이미 있고 그건 진짜입니다.** 재현 영상으로 대체하면 진짜를 가짜로 바꾸는 셈 |
| 골격(스켈레톤) 오버레이 애니메이션 | 작업자를 추적한다는 인상을 키움 — 스틸 브리프와 같은 이유 |
| 드론·타임랩스·기업 홍보 톤 | 이건 안전 설비 보고이지 브랜드 필름이 아닙니다. 화려할수록 "실제로 되는 건가"라는 의심을 삽니다 |
| 3초를 넘는 서사형 영상 | 경영진 보고에서 한 장면에 쓸 수 있는 시간은 3초입니다 |

File diff suppressed because one or more lines are too long

BIN
image/01-현장-설치.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

BIN
image/02-경광등.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

BIN
image/03-엣지노드.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

BIN
image/04-위험구역.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

BIN
image/05-사슬.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
image/06-구성도.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

BIN
image/07-대응시간.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

BIN
image/11-2인1조.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB