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