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