"""Bố cục FILE kết quả Analyzer đa cú (lát A1, 13/08/2026) — contract chung giữa route (app/main.py) và CV worker (scripts/cv_worker.py). Vì sao file chứ không Redis: kết quả job từng MẤT THẬT khi chỉ nằm Redis TTL 1h (BG29b, 13/08). Từ BG31 nếp bắt buộc: JSON per cú ghi ra ĐĨA ngay khi worker xong; Redis chỉ còn là kênh tiến độ. Module này chỉ stdlib (json/pathlib/os) — cả venv app lẫn venv CV import được, không kéo thêm dep. Bố cục một video (``//``, base mặc định ``data/analyzer`` — ``data/`` đã gitignore, video bản quyền không bao giờ vào git):: video.mp4|.mov upload gốc — XOÁ sau khi scan xong (không đọng) shots.json manifest: status + danh sách cú (ghi NGAY sau segmentation, trước khi phân tích cú nào) shot_01.json ... kết quả per cú: dict result analyze nguyên vẹn, hoặc {"error", "message"} khi cú phân tích lỗi thumb_01.jpg ... frame đầu cú (FE bảng §3.1) overlay_01.mp4 ... overlay per cú (lát A2): render trong job analyze TRƯỚC khi xoá clip — frame broadcast, chỉ local clips/shot_01.mp4 ... clip tạm per cú — worker XOÁ sau khi phân tích Manifest ``shots.json``:: {"status": "done" | "error", "message": , "video_id", "filename", "fps_nominal", "t_first_s", "t_last_s", "duration_s", "n_frames", "n_dup_frames", "warnings": [...], "shots": [{"idx", "t_start_s", "t_end_s", "t_onset_s", "t_settle_s", "status": "queued" | "error", "reason", "analyze_id", "clip", "thumb"}, ...]} Mốc thời gian trong manifest là giây TUYỆT ĐỐI theo PTS video nạp vào. """ from __future__ import annotations import json import os from pathlib import Path # Trần upload/thời lượng video đa cú — contract chung: route enforce lúc # POST (ffprobe), worker enforce lần hai trong segment_video (cv2). Video # đích là "một đoạn trận / một rack" (design §1) — 20 phút là dư cho một # rack 9-ball, đồng thời chặn nạp nguyên trận 6h (scan sẽ chạy hàng giờ). VIDEO_MAX_UPLOAD_B = 1 << 30 # 1 GB (~1h broadcast 1080p h264) VIDEO_MAX_DUR_S = 20 * 60.0 VIDEO_SUFFIXES = {".mp4", ".mov"} def analyzer_base_dir() -> Path: """Thư mục gốc kết quả Analyzer — ``data/analyzer`` trong repo (ngoài git) hoặc env ``POOLCOACH_ANALYZER_DIR``. App và worker cùng máy (tính năng local-only, nếp BG24) nên cùng đường dẫn.""" env = os.environ.get("POOLCOACH_ANALYZER_DIR") if env: return Path(env) return Path(__file__).resolve().parents[1] / "data" / "analyzer" def video_dir(video_id: str) -> Path: return analyzer_base_dir() / video_id def manifest_path(vdir: Path) -> Path: return vdir / "shots.json" def shot_json_path(vdir: Path, idx: int) -> Path: return vdir / f"shot_{idx:02d}.json" def thumb_path(vdir: Path, idx: int) -> Path: return vdir / f"thumb_{idx:02d}.jpg" def overlay_path(vdir: Path, idx: int) -> Path: """overlay.mp4 per cú (lát A2 phần 3) — render trong job analyze TRƯỚC khi xoá clip, nằm cạnh JSON. Chứa frame broadcast → chỉ local/demo, data/ đã gitignore (bản quyền, BRIEF A2).""" return vdir / f"overlay_{idx:02d}.mp4" def clip_path(vdir: Path, idx: int) -> Path: return vdir / "clips" / f"shot_{idx:02d}.mp4" def shot_analyze_id(video_id: str, idx: int) -> str: """analyze_id của job per cú — đoán được từ (video, idx) để route poll tiến độ mà không cần tra bảng nào.""" return f"{video_id}s{idx:02d}" def write_json(path: Path, data: dict) -> None: """Ghi JSON qua file tạm + replace — người đọc không bao giờ thấy file viết dở (route đọc song song với worker ghi).""" path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") os.replace(tmp, path) def read_json(path: Path) -> dict | None: """None khi file chưa có/đọc hỏng — caller coi là 'chưa có kết quả'.""" try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return None # ----------------------------------- video mẫu đã phân tích sẵn (14/08) # Mở app trên máy KHÔNG GPU (Space HF) phải thấy NGAY một danh sách cú thật # — chờ phân tích lại là mất người xem ngay phút đầu (BRIEF 14/08 việc B: # "trải nghiệm mở-link-thấy-ngay quan trọng hơn tốc độ phân tích video # mới"). Bộ mẫu là kết quả ĐÃ LƯU của run c9fda71c, chép nguyên vào repo — # KHÔNG chạy lại để "làm mới số" (pipeline analyze không tái lập bit qua # restart worker, đã đo 13-14/08). DEMO_SRC_DIR = Path(__file__).resolve().parent / "demo_analyzer" def demo_ids() -> list[str]: """Id các video mẫu đóng gói trong repo (thư mục con của DEMO_SRC_DIR).""" if not DEMO_SRC_DIR.is_dir(): return [] return sorted(p.name for p in DEMO_SRC_DIR.iterdir() if p.is_dir() and manifest_path(p).exists()) def seed_demo(base: Path | None = None) -> list[str]: """Chép video mẫu vào thư mục kết quả nếu chưa có. Trả id đã sẵn sàng. Chép chứ không trỏ thẳng: thư mục ảnh Docker chỉ-đọc dưới uid 1000, còn ``POOLCOACH_ANALYZER_DIR`` thì trỏ chỗ ghi được (/tmp trên HF) — một đường đọc duy nhất cho MỌI video, không có nhánh "nếu là video mẫu". Đã có thì KHÔNG đè: người dùng có thể đã xem dở. BEST-EFFORT: hỏng chỉ warning (nếp _log_recommend) — thiếu video mẫu là mất phần trình diễn, không phải mất app. """ import shutil base = base or analyzer_base_dir() ok: list[str] = [] for vid in demo_ids(): dst = base / vid try: if not manifest_path(dst).exists(): shutil.copytree(DEMO_SRC_DIR / vid, dst, dirs_exist_ok=True) ok.append(vid) except OSError as e: print(f"[poolcoach] WARNING: khong chep duoc video mau {vid}: " f"{type(e).__name__}: {e}", flush=True) return ok