Spaces:
Sleeping
Sleeping
File size: 6,595 Bytes
78738de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | """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
|