Spaces:
Sleeping
Sleeping
| """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>/<video_id>/``, 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": <khi error>, | |
| "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 | |