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'
', + f'재현 {"영상" if live else "이미지"}'] + if live: + out.append( + f'') + else: + out.append('클립 대기') + out.append(f'{esc(alt)} (재현 이미지)') + out.append("
") + 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'

{esc(sl["eyebrow"])}

') + if sl.get("title"): + wide = " wide" if len(sl["title"]) > 34 else "" + o.append(f'

{sl["title"]}

') # 의도된 인라인 마크업 허용 + if sl.get("lead"): + 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'
' + f'
' + f'
' + f'

{esc(sl["eyebrow"])}

' + f'

{esc(sl["title"])}

' + f'

{esc(sl["lead"])}

' + f'{esc(sl["foot"])}' + f'
') + + if kind == "closing": + lines = "".join(f"
{ln}
" for ln in sl["lines"]) # 의도된 허용 + return (f'
' + f'{idx:02d}' + f'

{esc(sl["eyebrow"])}

' + f'

{esc(sl["title"])}

' + f'
{lines}
' + f'{esc(sl["foot"])}
') + + body.append(head(sl)) + + if kind == "statement": + cols = "".join(f'

{esc(c["head"])}

{esc(c["body"])}

' + for c in sl["columns"]) + body.append(f'
{cols}
') + + elif kind == "figure": + body.append(f'
') + + elif kind == "clip": + pts = "".join(f"
  • {esc(p)}
  • " for p in sl.get("points", [])) + right = f'' + if sl.get("kicker"): + right += f'

    {esc(sl["kicker"])}

    ' + body.append( + '
    ' + + media_block(sl.get("clip"), sl["poster"], sl.get("title", "")) + + (f'
    {esc(sl["caption"])}
    ' if sl.get("caption") else "") + + f'
    {right}
    ') + + 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('
    ' + '재현 이미지' + f'
    ') + else: + panes = [] + for side in ("left", "right"): + pane = sl[side] + panes.append( + f'
    {esc(pane["label"])}' + + media_block(pane.get("clip"), pane["poster"], pane["label"]) + + f'

    {esc(pane["body"])}

    ') + body.append(f'
    {"".join(panes)}
    ') + + elif kind == "metrics": + items = "".join(f'

    {esc(m["head"])}

    {esc(m["body"])}

    ' + for m in sl["items"]) + body.append(f'
    {items}
    ') + + elif kind == "status": + def box(cls: str, blk: dict) -> str: + li = "".join(f"
  • {esc(i)}
  • " for i in blk["items"]) + return f'

    {esc(blk["head"])}

    ' + body.append('
    ' + box("done", sl["done"]) + + box("pending", sl["pending"]) + "
    ") + + else: + raise SystemExit(f"ERROR: 알 수 없는 슬라이드 종류: {kind}") + + body.append(note(sl)) + return (f'
    {idx:02d}' + + "".join(body) + "
    ") + + +def render(doc: dict, version: str) -> str: + slides = doc["slides"] + dots = "".join(f'' for i in range(len(slides))) + body = "".join(render_slide(sl, i) for i, sl in enumerate(slides)) + today = datetime.date.today().isoformat() + return ( + '' + '' + f"{esc(doc['title'])} — {esc(doc['audience'])}" + f"" + f'' + f'
    {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는 이미 있습니다.
    그런데 왜 사고를 못 막습니까.", + "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": "효과는 저희가 주장하지 않습니다.
    시스템이 스스로 측정해 보여줍니다", + "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": [ + "쓰러진 자세가 3초 지속되면 확정하고,", + "감지한 노드가 즉시 경광등을 울리고,", + "1분 안에 담당자에게, 5분 안에 확인되지 않으면 위로 올립니다.", + "그리고 무슨 일이 있었는지 지울 수 없는 형태로 남깁니다." + ], + "foot": "감지 · 대응 · 증거" + } + ] +} \ No newline at end of file diff --git a/docs/images/01-현장-설치.jpg b/docs/images/01-현장-설치.jpg new file mode 100644 index 0000000..80a87cc Binary files /dev/null and b/docs/images/01-현장-설치.jpg differ diff --git a/docs/images/02-경광등.jpg b/docs/images/02-경광등.jpg new file mode 100644 index 0000000..b46dfc2 Binary files /dev/null and b/docs/images/02-경광등.jpg differ diff --git a/docs/images/03-엣지노드.jpg b/docs/images/03-엣지노드.jpg new file mode 100644 index 0000000..0b962f4 Binary files /dev/null and b/docs/images/03-엣지노드.jpg differ diff --git a/docs/images/04-위험구역.jpg b/docs/images/04-위험구역.jpg new file mode 100644 index 0000000..30452dc Binary files /dev/null and b/docs/images/04-위험구역.jpg differ diff --git a/docs/images/05-사슬.png b/docs/images/05-사슬.png new file mode 100644 index 0000000..4bf07d1 Binary files /dev/null and b/docs/images/05-사슬.png differ diff --git a/docs/images/06-구성도.png b/docs/images/06-구성도.png new file mode 100644 index 0000000..9a3688f Binary files /dev/null and b/docs/images/06-구성도.png differ diff --git a/docs/images/07-대응시간.png b/docs/images/07-대응시간.png new file mode 100644 index 0000000..23e52e7 Binary files /dev/null and b/docs/images/07-대응시간.png differ diff --git a/docs/images/08-넘어짐-감지-시퀀스.jpg b/docs/images/08-넘어짐-감지-시퀀스.jpg new file mode 100644 index 0000000..eb4be5d Binary files /dev/null and b/docs/images/08-넘어짐-감지-시퀀스.jpg differ diff --git a/docs/images/09-앉기-vs-넘어짐.jpg b/docs/images/09-앉기-vs-넘어짐.jpg new file mode 100644 index 0000000..9bca2e7 Binary files /dev/null and b/docs/images/09-앉기-vs-넘어짐.jpg differ diff --git a/docs/images/10-위험구역-판정.jpg b/docs/images/10-위험구역-판정.jpg new file mode 100644 index 0000000..81a6ea2 Binary files /dev/null and b/docs/images/10-위험구역-판정.jpg differ diff --git a/docs/images/11-2인1조.jpg b/docs/images/11-2인1조.jpg new file mode 100644 index 0000000..de7dc41 Binary files /dev/null and b/docs/images/11-2인1조.jpg differ diff --git a/docs/소개서-이미지.json b/docs/소개서-이미지.json new file mode 100644 index 0000000..c2814ce --- /dev/null +++ b/docs/소개서-이미지.json @@ -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": "설치 대상이 되는 전형적인 작업 공간." + } + ] +} \ No newline at end of file diff --git a/docs/영상-브리프.md b/docs/영상-브리프.md new file mode 100644 index 0000000..5affcb1 --- /dev/null +++ b/docs/영상-브리프.md @@ -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초입니다 | diff --git a/docs/프로젝트-소개서.html b/docs/프로젝트-소개서.html index 8b46d50..b337424 100644 --- a/docs/프로젝트-소개서.html +++ b/docs/프로젝트-소개서.html @@ -3,7 +3,7 @@ -move_detect — 작업장 안전 AI CCTV 프로젝트 소개서 +move_detect — 작업장 안전 AI CCTV