feat(deck): P0 클립 2종 배치 — 브라우저 재생 형식으로 변환 + 포스터 자동 추출

받은 v01(넘어짐·경광등)·v02(앉기·정상)는 내용상 브리프대로였다. 컨택트
시트로 확인: v01 0.79초 쓰러짐 → 1.58초 경광등 점등 → 유지, v02 4초 내내
경광등 꺼짐. 둘 다 CCTV 앵글, 얼굴 식별 불가.

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

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

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

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

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

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

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

영상 브리프에 형식 요구(H.264, HEVC 금지)와 변환 절차를 반영. 남은 P1 2종
(v04 구역진입·v05 카메라 건드림) 자리는 계속 스틸로 대체된다.
This commit is contained in:
유창욱 2026-08-30 22:52:23 +09:00
parent fdf3d8b72a
commit dea12a2931
5 changed files with 201 additions and 8 deletions

View file

@ -38,6 +38,32 @@ CLIPS = HERE / "clips"
MIME = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"} 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: def data_uri(name: str) -> str:
p = IMAGES / name p = IMAGES / name
@ -295,13 +321,17 @@ def media_block(clip: str | None, poster: str, alt: str) -> str:
배지 문구는 실제 매체를 따른다 — 정지 이미지에 "재현 영상"이라고 붙이는 것 자체가 배지 문구는 실제 매체를 따른다 — 정지 이미지에 "재현 영상"이라고 붙이는 것 자체가
작은 거짓말이고, 이 덱의 존재 이유가 그 반대다. 작은 거짓말이고, 이 덱의 존재 이유가 그 반대다.
""" """
live = bool(clip) and (CLIPS / clip).is_file() sources = clip_sources(clip)
live = sources != []
out = [f'<div class="media{"" if live else " still"}">', out = [f'<div class="media{"" if live else " still"}">',
f'<span class="badge-reenact">재현 {"영상" if live else "이미지"}</span>'] f'<span class="badge-reenact">재현 {"영상" if live else "이미지"}</span>']
if live: 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( out.append(
f'<video src="clips/{esc(clip)}" poster="{data_uri(poster)}" muted playsinline ' f'<video poster="{uri}" muted playsinline preload="metadata" '
f'preload="metadata" controls aria-label="{esc(alt)} (재현 영상)"></video>') f'controls aria-label="{esc(alt)} (재현 영상)">{tags}</video>')
else: else:
out.append('<span class="missing">클립 대기</span>') out.append('<span class="missing">클립 대기</span>')
out.append(f'<img src="{data_uri(poster)}" alt="{esc(alt)} (재현 이미지)">') out.append(f'<img src="{data_uri(poster)}" alt="{esc(alt)} (재현 이미지)">')
@ -309,8 +339,16 @@ def media_block(clip: str | None, poster: str, alt: str) -> str:
return "".join(out) 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: def has_clip(name: str | None) -> bool:
return bool(name) and (CLIPS / name).is_file() return clip_sources(name) != []
def head(sl: dict) -> str: def head(sl: dict) -> str:
@ -455,7 +493,8 @@ def main() -> None:
[sl[s].get("clip") for s in ("left", "right") if isinstance(sl.get(s), dict)]: [sl[s].get("clip") for s in ("left", "right") if isinstance(sl.get(s), dict)]:
if c and c not in wanted: if c and c not in wanted:
wanted.append(c) wanted.append(c)
(have if (CLIPS / c).is_file() else []).append(c) if has_clip(c):
have.append(c)
missing = [c for c in wanted if c not in have] 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) print(f"{out} ({out.stat().st_size/1024:.0f} KB, 슬라이드 {len(doc['slides'])}장)", file=sys.stderr)

149
docs/deck/prepare_clips.py Normal file
View file

@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""덱에 넣을 클립을 브라우저가 실제로 재생하는 형식으로 변환한다.
WHY THIS EXISTS
받은 클립은 HEVC(H.265) 였고, 그대로는 브라우저에서 디코딩되지 않았다. 같은 브라우저
같은 페이지에서 대조한 결과:
HEVC .mp4 -> readyState 0, videoWidth 0, duration NaN (error 이벤트조차 없음)
VP9 .webm -> readyState 1+, videoWidth 1600, duration 4.04
서버를 배제하고 blob 으로 통째로 올려도 HEVC 는 같았으므로 전송이 아니라 형식 문제다.
HEVC 는 재생 가능 여부가 OS·GPU·브라우저 조합을 타고(파이어폭스는 사실상 불가), 실패해도
error 없이 검은 상자로 남는다 — 리허설 없이는 모르고 회의실에서 처음 알게 되는 고장이라,
변환을 사람 손이 아니라 스크립트에 둔다.
확인하지 못한 것도 적어 둔다: **재생이 실제로 진행되는지**는 검증하지 못했다. 이 자동화
브라우저 컨텍스트에서는 디코딩까지만 되고 currentTime 이 0 에서 움직이지 않는다.
발표 노트북에서 한 번 눌러 보는 것으로 대체해야 한다. 재생이 안 되더라도 poster 가
남으므로 슬라이드가 비지는 않는다.
WHY VP9/WEBM AND NOT H.264
이 머신에는 ffmpeg 도, OpenCV 가 쓸 openh264 DLL 도 없어 H.264 인코딩이 불가능하다
(avc1/H264/X264 전부 VideoWriter 열기 실패). VP9/WebM 은 OpenCV 가 그대로 쓰고,
크로미움 계열과 파이어폭스가 코덱 확장 없이 재생한다.
H.264 가 필요해지면(구형 사파리 등) ffmpeg 를 설치하고 아래 CODEC 만 바꾸면 된다.
사용법:
python docs/deck/prepare_clips.py # image/ -> docs/deck/clips/
python docs/deck/prepare_clips.py --src <디렉터리>
"""
from __future__ import annotations
import argparse
import pathlib
import sys
HERE = pathlib.Path(__file__).resolve().parent
REPO = HERE.parents[1]
CLIPS = HERE / "clips"
# 덱에서 클립이 실제로 그려지는 폭은 1000px 안팎이다. 1920 을 그대로 두면 파일만 커지고
# 화면에서 달라지는 것은 없다.
MAX_W = 1600
CODEC = "VP90"
SUFFIX = ".webm"
SOURCE_EXT = {".mp4", ".mov", ".m4v", ".mkv"}
def transcode(src: pathlib.Path, dst: pathlib.Path) -> dict:
import cv2
cap = cv2.VideoCapture(str(src))
if not cap.isOpened():
raise SystemExit(f"ERROR: 읽을 수 없습니다(코덱 미지원): {src}")
fps = cap.get(cv2.CAP_PROP_FPS) or 24.0
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
if w <= 0 or h <= 0:
raise SystemExit(f"ERROR: 해상도를 읽지 못했습니다: {src}")
ow = min(w, MAX_W)
oh = int(round(h * ow / w))
oh -= oh % 2 # 짝수 높이 — 홀수면 인코더가 거부하는 경우가 있다
writer = cv2.VideoWriter(str(dst), cv2.VideoWriter_fourcc(*CODEC), fps, (ow, oh))
if not writer.isOpened():
raise SystemExit(f"ERROR: {CODEC} 인코더를 열지 못했습니다. OpenCV 빌드를 확인하십시오.")
n = 0
while True:
ok, frame = cap.read()
if not ok:
break
if (ow, oh) != (w, h):
frame = cv2.resize(frame, (ow, oh), interpolation=cv2.INTER_AREA)
writer.write(frame)
n += 1
writer.release()
cap.release()
if n == 0:
dst.unlink(missing_ok=True)
raise SystemExit(f"ERROR: 프레임을 하나도 읽지 못했습니다: {src}")
# 클립 자신의 마지막 프레임을 포스터로 뽑는다.
#
# deck.json 에 적힌 포스터는 클립이 없을 때 쓰는 대체 도판이라, 클립이 생기면 오히려
# 어긋난다 — 비교 슬라이드에서는 좌우 비교 도판이 좌우 패널 각각에 들어가 비교가 두 겹이
# 됐다. 클립에서 뽑으면 그 영상이 무엇인지 정확히 보여주고, 새 클립이 와도 자동으로 맞는다.
# 마지막 프레임을 쓰는 이유: 이 클립들의 요지는 결과(쓰러짐·경광등 점등·정상 유지)다.
cap = cv2.VideoCapture(str(src))
last = None
while True:
ok, f = cap.read()
if not ok:
break
last = f
cap.release()
poster = dst.with_suffix(".poster.jpg")
if last is not None:
if (ow, oh) != (w, h):
last = cv2.resize(last, (ow, oh), interpolation=cv2.INTER_AREA)
# cv2.imwrite 는 Windows 에서 한글이 든 경로에 쓰지 못한다 — 예외도 없이 False 만
# 돌려주고 조용히 넘어간다(파일명이 전부 한글인 이 프로젝트에서는 전부 실패한다).
# 메모리로 인코딩한 뒤 파이썬이 쓰면 경로 인코딩과 무관해진다.
ok, buf = cv2.imencode(".jpg", last, [cv2.IMWRITE_JPEG_QUALITY, 88])
if not ok:
raise SystemExit(f"ERROR: 포스터 인코딩 실패: {src}")
poster.write_bytes(buf.tobytes())
return {"frames": n, "fps": fps, "size": f"{ow}x{oh}", "poster": poster.name,
"seconds": round(n / fps, 2), "bytes": dst.stat().st_size}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--src", default=str(REPO / "image"))
ap.add_argument("--force", action="store_true", help="이미 변환된 것도 다시 만든다")
args = ap.parse_args()
src_dir = pathlib.Path(args.src)
if not src_dir.is_dir():
raise SystemExit(f"ERROR: 원본 디렉터리가 없습니다: {src_dir}")
CLIPS.mkdir(parents=True, exist_ok=True)
found = sorted(p for p in src_dir.iterdir()
if p.suffix.lower() in SOURCE_EXT and p.stem.startswith("v"))
if not found:
print(f"변환할 클립이 없습니다: {src_dir} (파일명은 v01-... 형식)", file=sys.stderr)
return
for src in found:
dst = CLIPS / (src.stem + SUFFIX)
if dst.exists() and not args.force and dst.stat().st_mtime >= src.stat().st_mtime:
print(f"{dst.name}: 최신 — 건너뜀", file=sys.stderr)
continue
info = transcode(src, dst)
print(f"{dst.name}: {info['size']} {info['seconds']}초 "
f"{info['frames']}프레임 {info['bytes']/1048576:.2f}MB", file=sys.stderr)
if info["seconds"] > 3.6:
# 브리프는 3초(±0.3)를 요구했다. 길다고 못 쓰는 것은 아니지만, 슬라이드당 3초를
# 전제로 짠 발표 흐름이므로 알고는 있어야 한다.
print(f" 주의: {info['seconds']}초 — 브리프 기준(3초±0.3)보다 깁니다.", file=sys.stderr)
if __name__ == "__main__":
main()

View file

@ -27,10 +27,10 @@
| 항목 | 값 | | 항목 | 값 |
|---|---| |---|---|
| 길이 | **3초** (±0.3초) | | 길이 | **3초** (±0.3초). 받은 v01·v02 는 4.04초 — 쓸 수는 있으나 슬라이드당 3초 전제로 짠 흐름보다 깁니다 |
| 컷 | **단일 컷, 편집 없음.** 카메라 고정 — CCTV 시점이어야 설득력이 있습니다 | | 컷 | **단일 컷, 편집 없음.** 카메라 고정 — CCTV 시점이어야 설득력이 있습니다 |
| 화면비 | 16:9, **1920×1080 이상** | | 화면비 | 16:9, **1920×1080 이상** |
| 형식 | `.mp4` (H.264 + yuv420p). 브라우저 재생용 | | 형식 | **`.mp4` (H.264 + yuv420p)** — HEVC/H.265 로 뽑지 마십시오. 브라우저가 디코딩하지 못하면서 오류도 내지 않아, 회의실에서야 검은 화면을 보게 됩니다. (받은 v01·v02 가 HEVC 였고 `prepare_clips.py` 로 VP9/WebM 변환해 넣었습니다 — H.264 로 주시면 변환 없이 그대로 씁니다.) |
| 소리 | **없음.** 회의실에서 음소거로 재생합니다 | | 소리 | **없음.** 회의실에서 음소거로 재생합니다 |
| 재생 | 1회 재생 후 **마지막 프레임 정지**. 넘어지는 장면이 무한 반복되면 보기 불편합니다 | | 재생 | 1회 재생 후 **마지막 프레임 정지**. 넘어지는 장면이 무한 반복되면 보기 불편합니다 |
| 얼굴 | **식별 불가**. 뒷모습·측면·헬멧·바닥을 향한 얼굴 | | 얼굴 | **식별 불가**. 뒷모습·측면·헬멧·바닥을 향한 얼굴 |
@ -174,7 +174,12 @@ A-2와 같은 목적, 다른 자세. 여유가 없으면 A-2만으로도 성립
## C. 전달 방법 ## C. 전달 방법
`docs/deck/clips/` 에 위 파일명 그대로 넣어주십시오. `image/` 또는 `docs/deck/clips/` 에 위 파일명 그대로 넣어주십시오.
```
python docs/deck/prepare_clips.py # 브라우저용 변환 + 포스터 추출
python docs/deck/build_deck.py # 덱 재생성
```
덱 생성기(`docs/deck/build_deck.py`)가 **파일이 있으면 영상을, 없으면 해당 스틸 이미지를** 덱 생성기(`docs/deck/build_deck.py`)가 **파일이 있으면 영상을, 없으면 해당 스틸 이미지를**
자동으로 넣습니다. 그래서 지금 P0 2개만 먼저 주셔도 되고, 나중에 하나씩 추가해도 자동으로 넣습니다. 그래서 지금 P0 2개만 먼저 주셔도 되고, 나중에 하나씩 추가해도

Binary file not shown.

BIN
image/v02-앉기-정상.mp4 Normal file

Binary file not shown.