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:
parent
fdf3d8b72a
commit
dea12a2931
5 changed files with 201 additions and 8 deletions
|
|
@ -38,6 +38,32 @@ CLIPS = HERE / "clips"
|
|||
|
||||
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:
|
||||
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"}">',
|
||||
f'<span class="badge-reenact">재현 {"영상" if live else "이미지"}</span>']
|
||||
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(
|
||||
f'<video src="clips/{esc(clip)}" poster="{data_uri(poster)}" muted playsinline '
|
||||
f'preload="metadata" controls aria-label="{esc(alt)} (재현 영상)"></video>')
|
||||
f'<video poster="{uri}" muted playsinline preload="metadata" '
|
||||
f'controls aria-label="{esc(alt)} (재현 영상)">{tags}</video>')
|
||||
else:
|
||||
out.append('<span class="missing">클립 대기</span>')
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
return bool(name) and (CLIPS / name).is_file()
|
||||
return clip_sources(name) != []
|
||||
|
||||
|
||||
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)]:
|
||||
if c and c not in wanted:
|
||||
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]
|
||||
|
||||
print(f"{out} ({out.stat().st_size/1024:.0f} KB, 슬라이드 {len(doc['slides'])}장)", file=sys.stderr)
|
||||
|
|
|
|||
149
docs/deck/prepare_clips.py
Normal file
149
docs/deck/prepare_clips.py
Normal 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()
|
||||
|
|
@ -27,10 +27,10 @@
|
|||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 길이 | **3초** (±0.3초) |
|
||||
| 길이 | **3초** (±0.3초). 받은 v01·v02 는 4.04초 — 쓸 수는 있으나 슬라이드당 3초 전제로 짠 흐름보다 깁니다 |
|
||||
| 컷 | **단일 컷, 편집 없음.** 카메라 고정 — CCTV 시점이어야 설득력이 있습니다 |
|
||||
| 화면비 | 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회 재생 후 **마지막 프레임 정지**. 넘어지는 장면이 무한 반복되면 보기 불편합니다 |
|
||||
| 얼굴 | **식별 불가**. 뒷모습·측면·헬멧·바닥을 향한 얼굴 |
|
||||
|
|
@ -174,7 +174,12 @@ A-2와 같은 목적, 다른 자세. 여유가 없으면 A-2만으로도 성립
|
|||
|
||||
## 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`)가 **파일이 있으면 영상을, 없으면 해당 스틸 이미지를**
|
||||
자동으로 넣습니다. 그래서 지금 P0 2개만 먼저 주셔도 되고, 나중에 하나씩 추가해도
|
||||
|
|
|
|||
BIN
image/v01-넘어짐-경광등.mp4
Normal file
BIN
image/v01-넘어짐-경광등.mp4
Normal file
Binary file not shown.
BIN
image/v02-앉기-정상.mp4
Normal file
BIN
image/v02-앉기-정상.mp4
Normal file
Binary file not shown.
Loading…
Reference in a new issue