move_detect/docs/deck/prepare_clips.py
유창욱 dea12a2931 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 카메라 건드림) 자리는 계속 스틸로 대체된다.
2026-08-30 22:52:23 +09:00

149 lines
6.6 KiB
Python

#!/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()