diff --git a/docs/deck/build_deck.py b/docs/deck/build_deck.py index 92fef16..c47ad2f 100644 --- a/docs/deck/build_deck.py +++ b/docs/deck/build_deck.py @@ -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 이 같은 재생 가능한 파일을 전부 찾아 +# 로 늘어놓고 브라우저가 고르게 한다. +# +# 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'
', f'재현 {"영상" if live else "이미지"}'] if live: + tags = "".join(f'' for f, m in sources) + shot = clip_poster(clip) + uri = file_uri(shot) if shot else data_uri(poster) out.append( - f'') + f'') else: out.append('클립 대기') out.append(f'{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) diff --git a/docs/deck/prepare_clips.py b/docs/deck/prepare_clips.py new file mode 100644 index 0000000..e49eec7 --- /dev/null +++ b/docs/deck/prepare_clips.py @@ -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() diff --git a/docs/영상-브리프.md b/docs/영상-브리프.md index 5affcb1..3116dfb 100644 --- a/docs/영상-브리프.md +++ b/docs/영상-브리프.md @@ -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개만 먼저 주셔도 되고, 나중에 하나씩 추가해도 diff --git a/image/v01-넘어짐-경광등.mp4 b/image/v01-넘어짐-경광등.mp4 new file mode 100644 index 0000000..e5a21ad Binary files /dev/null and b/image/v01-넘어짐-경광등.mp4 differ diff --git a/image/v02-앉기-정상.mp4 b/image/v02-앉기-정상.mp4 new file mode 100644 index 0000000..b115891 Binary files /dev/null and b/image/v02-앉기-정상.mp4 differ