File size: 9,988 Bytes
ba80657
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6a6455
 
 
 
 
 
 
ba80657
f07812e
f6a6455
 
f07812e
f6a6455
 
 
 
 
f07812e
f6a6455
 
f07812e
 
 
f6a6455
 
 
 
 
f07812e
 
 
f6a6455
f07812e
 
 
f6a6455
 
 
 
 
 
f07812e
 
f6a6455
 
f07812e
 
 
 
 
f6a6455
 
 
f07812e
 
 
 
 
 
6e9f89d
f07812e
 
 
 
 
 
 
 
 
 
 
 
 
ba80657
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e9f89d
f07812e
ba80657
f07812e
ba80657
 
6e9f89d
 
 
 
 
f07812e
 
 
 
 
 
 
 
 
 
 
 
f6a6455
6e9f89d
 
ba80657
 
 
 
 
f07812e
 
6e9f89d
 
ba80657
 
 
 
 
 
 
6e9f89d
 
f07812e
 
 
ba80657
f07812e
 
ba80657
 
 
 
f6a6455
d790783
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""Stage 5 + 6: real screenshots via a one-time low-res video download + ffmpeg, at
weighted timestamps.

Two proxies are used because of how the Space's egress is filtered:

* Resolution hits ``youtube.com`` and must go through ``YT_PROXY`` — an HTTPS/TLS-wrapped
  proxy that hides the target host from the egress DPI that otherwise resets YouTube.
* The media itself lives on ``googlevideo.com`` (not DPI-filtered) and is many MB, which
  the TLS proxy can't sustain — so it's downloaded through ``YT_MEDIA_PROXY``, a plain
  HTTP proxy, in chunked Range requests (dodging YouTube's single-stream throttling).

We download one capped-resolution copy, then ``ffmpeg`` reads the *local* file to grab 3
candidates around each timestamp and keeps the sharpest (Laplacian variance). The capture
timestamp comes from the weighted indicator: a blend of the LLM's suggested timestamp and
the transcript segment timing of the text the step quotes.
"""
from __future__ import annotations

import difflib
import os
import subprocess

import requests
from yt_dlp import YoutubeDL


# --------------------------------------------------------------------------- weighting
def _clamp(x: float, lo: float, hi: float) -> float:
    return max(lo, min(hi, x))


def best_match_segment(quote: str, t_llm: float, segs: list[dict]) -> dict:
    """The transcript segment a step refers to: best text match, else nearest to t_llm."""
    quote = (quote or "").strip().lower()
    if quote:
        ratio, best = max(
            ((difflib.SequenceMatcher(None, quote, s["text"].lower()).ratio(), s) for s in segs),
            key=lambda t: t[0])
        if ratio >= 0.3:
            return best
    return min(segs, key=lambda s: abs((s["start"] + s["end"]) / 2 - t_llm))


def pick_time(step: dict, segs: list[dict], *, w_llm: float = 0.4, w_whisper: float = 0.6,
              lead: float = 1.0, max_drift: float = 20.0) -> float:
    """Weighted capture time (seconds). No snapping — we grab on demand at any T.

    Transcript timing is ground truth for *when* the text is spoken; the LLM time is its
    guess of *what* matters. Blend them, clamping a far-off LLM time back into the matched
    segment so a hallucinated timestamp can't drag the shot off-topic.
    """
    seg = best_match_segment(step.get("quote", ""), float(step.get("t_llm", 0.0)), segs)
    t_whisper = (seg["start"] + seg["end"]) / 2 + lead
    t_llm = float(step.get("t_llm", t_whisper))
    if abs(t_llm - t_whisper) > max_drift:
        t_llm = _clamp(t_llm, seg["start"], seg["end"])
    total = (w_llm + w_whisper) or 1.0
    return max(0.0, (w_llm * t_llm + w_whisper * t_whisper) / total)


def compute_shot_times(steps: list[dict], segs: list[dict], *, w_llm: float = 0.4,
                       w_whisper: float = 0.6, lead: float = 1.0,
                       max_shots: int = 8) -> dict[int, float]:
    """Return ``{step_index: capture_time}`` for the top-``max_shots`` steps by importance."""
    if not segs:
        return {}
    order = sorted(range(len(steps)), key=lambda i: -float(steps[i].get("importance", 0.5)))
    chosen = set(order[:max_shots])
    return {
        i: pick_time(steps[i], segs, w_llm=w_llm, w_whisper=w_whisper, lead=lead)
        for i in range(len(steps)) if i in chosen
    }


# -------------------------------------------------------------------- download + grab
def _sharpness(path: str) -> float:
    """Variance of the Laplacian (higher = sharper). Used to reject blurry frames."""
    import numpy as np
    from PIL import Image

    im = np.asarray(Image.open(path).convert("L"), dtype=np.float64)
    if im.shape[0] < 3 or im.shape[1] < 3:
        return 0.0
    lap = (-4 * im[1:-1, 1:-1] + im[:-2, 1:-1] + im[2:, 1:-1]
           + im[1:-1, :-2] + im[1:-1, 2:])
    return float(lap.var())


def _resolve_media_url(video_id: str, max_height: int, cookiefile: str | None,
                       proxy: str | None) -> str | None:
    """Resolve a single-file (progressive/DASH) video URL <= ``max_height`` via the
    HTTPS ``proxy``. Returns the ``googlevideo.com`` media URL, or None."""
    opts = {
        "quiet": True, "no_warnings": True, "skip_download": True,
        "format": (f"bv*[height<={max_height}][ext=mp4]/bv*[height<={max_height}]/"
                   f"best[height<={max_height}]/best"),
    }
    if cookiefile:
        opts["cookiefile"] = cookiefile
    if proxy:
        opts["proxy"] = proxy
        # yt-dlp validates against certifi only and ignores SSL_CERT_FILE; switch it to
        # the default cert path (which honors our combined bundle) so the self-signed
        # HTTPS-proxy cert validates. See app._install_proxy_ca.
        if os.environ.get("SSL_CERT_FILE"):
            opts["compat_opts"] = ["no-certifi"]
    with YoutubeDL(opts) as ydl:
        info = ydl.extract_info(f"https://www.youtube.com/watch?v={video_id}", download=False)
    if info.get("url"):
        return info["url"]
    vids = [f for f in info.get("formats", [])
            if f.get("vcodec") not in (None, "none") and f.get("url")
            and (f.get("height") or 0) <= max_height
            and str(f.get("protocol", "")).startswith("http")]
    if vids:
        return sorted(vids, key=lambda f: f.get("height") or 0)[-1]["url"]
    return None


def _download_media(url: str, dest: str, media_proxy: str | None,
                    chunk: int = 1 << 20, timeout: int = 30) -> None:
    """Download ``url`` to ``dest`` through the plain ``media_proxy`` using chunked Range
    requests — small ranges dodge YouTube's single-stream throttling and stay within the
    plain proxy's throughput (the TLS proxy can't sustain large transfers)."""
    proxies = {"http": media_proxy, "https": media_proxy} if media_proxy else None
    probe = requests.get(url, proxies=proxies, headers={"Range": "bytes=0-0"}, timeout=timeout)
    probe.raise_for_status()
    cr = probe.headers.get("Content-Range", "")
    total = int(cr.split("/")[-1]) if "/" in cr and cr.split("/")[-1].isdigit() else 0
    with open(dest, "wb") as fh:
        if not total:  # server ignored Range — fall back to a single stream
            with requests.get(url, proxies=proxies, stream=True, timeout=timeout) as r:
                r.raise_for_status()
                for c in r.iter_content(chunk):
                    if c:
                        fh.write(c)
            return
        start = 0
        while start < total:
            end = min(start + chunk - 1, total - 1)
            for attempt in range(3):
                try:
                    rr = requests.get(url, proxies=proxies, timeout=timeout,
                                      headers={"Range": f"bytes={start}-{end}"})
                    rr.raise_for_status()
                    fh.write(rr.content)
                    break
                except requests.RequestException:
                    if attempt == 2:
                        raise
            start = end + 1


def _grab_local(media_path: str, t: float, out_dir: str, idx: int) -> str | None:
    """Grab 3 candidates around ``t`` from the *local* ``media_path`` and keep the
    sharpest. ffmpeg reads a local file here, so no proxy/network is involved."""
    cands = []
    for k, dt in enumerate((-0.5, 0.0, 0.5)):
        p = os.path.join(out_dir, f"shot_{idx}_{k}.jpg")
        cmd = ["ffmpeg", "-y", "-loglevel", "error", "-ss", f"{max(0.0, t + dt):.2f}",
               "-i", media_path, "-frames:v", "1", "-q:v", "2", p]
        try:
            subprocess.run(cmd, capture_output=True, text=True, timeout=60)
        except subprocess.TimeoutExpired:
            continue
        if os.path.exists(p) and os.path.getsize(p) > 0:
            cands.append(p)
    if not cands:
        return None
    best = max(cands, key=_sharpness)
    for p in cands:
        if p != best:
            try:
                os.remove(p)
            except OSError:
                pass
    return best


def capture_shots(times: dict[int, float], video_id: str, out_dir: str,
                  cookiefile: str | None = None, proxy: str | None = None,
                  media_proxy: str | None = None, progress=None, *,
                  max_height: int = 360) -> dict[int, dict]:
    """Download one capped-resolution copy of the video (resolve via ``proxy``, fetch
    media via ``media_proxy``) and keep the sharpest frame per ``{step_index: time}``;
    return ``{idx: {"time", "path"}}``."""
    os.makedirs(out_dir, exist_ok=True)
    items = list(times.items())
    if not items:
        return {}
    url = _resolve_media_url(video_id, max_height, cookiefile, proxy)
    if not url:
        raise RuntimeError("Could not resolve a downloadable video stream URL.")
    media = os.path.join(out_dir, "_video.bin")
    if progress:
        progress(0.0, desc="Downloading video for screenshots")
    _download_media(url, media, media_proxy or proxy)

    out: dict[int, dict] = {}
    for n, (idx, t) in enumerate(items):
        if progress:
            progress((n + 1) / max(1, len(items)), desc=f"Screenshot {n + 1}/{len(items)}")
        path = _grab_local(media, t, out_dir, idx)
        if path:
            out[idx] = {"time": t, "path": path}
    try:
        os.remove(media)
    except OSError:
        pass
    return out


def capture_from_file(times: dict[int, float], video_path: str, out_dir: str,
                      progress=None) -> dict[int, dict]:
    """Extract the sharpest frame per ``{step_index: time}`` from an already-downloaded
    local ``video_path`` (no network/proxy). Returns ``{idx: {"time", "path"}}``."""
    os.makedirs(out_dir, exist_ok=True)
    out: dict[int, dict] = {}
    items = list(times.items())
    for n, (idx, t) in enumerate(items):
        if progress:
            progress((n + 1) / max(1, len(items)), desc=f"Screenshot {n + 1}/{len(items)}")
        path = _grab_local(video_path, t, out_dir, idx)
        if path:
            out[idx] = {"time": t, "path": path}
    return out