받은 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 카메라 건드림) 자리는 계속 스틸로 대체된다.
149 lines
6.6 KiB
Python
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()
|