"""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