File size: 6,881 Bytes
f07812e
 
 
 
 
 
 
 
 
f6a6455
 
 
 
 
 
 
f07812e
f6a6455
f07812e
 
 
f6a6455
 
f07812e
f6a6455
 
 
 
 
f07812e
f6a6455
 
f07812e
 
 
f6a6455
 
 
 
 
f07812e
 
 
f6a6455
f07812e
 
 
f6a6455
 
 
 
 
 
f07812e
 
f6a6455
 
f07812e
 
 
 
 
f6a6455
 
 
f07812e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6a6455
f07812e
 
 
 
f6a6455
f07812e
 
 
 
 
 
 
f6a6455
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
"""Stage 5 + 6: real screenshots via yt-dlp stream URL + ffmpeg, at weighted timestamps.

No full download. We resolve a direct video stream URL with yt-dlp (metadata only), then
``ffmpeg -ss T -frames:v 1`` grabs a single frame per timestamp over HTTP range requests
(only a few seconds of data each). For each shot we grab 3 candidates around T and keep
the sharpest (Laplacian variance) to avoid landing on a blurry/transition frame.

The capture timestamp itself 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

from yt_dlp import YoutubeDL

# Format preference: muxed (single A/V url) <=720p first, then video-only, then anything.
_FORMAT = ("best[height<=720][vcodec!=none][acodec!=none]/"
           "best[height<=720][vcodec!=none]/bestvideo[height<=720]/best[vcodec!=none]/best")


# --------------------------------------------------------------------------- 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
    }


# ----------------------------------------------------------------------- stream + grab
def get_stream_url(video_id: str, cookiefile: str | None = None,
                   proxy: str | None = None) -> tuple[str, float]:
    """Resolve a direct video stream URL (no download); return ``(url, duration_seconds)``."""
    opts = {"quiet": True, "no_warnings": True, "skip_download": True, "format": _FORMAT}
    if cookiefile:
        opts["cookiefile"] = cookiefile
    if proxy:
        opts["proxy"] = proxy
    with YoutubeDL(opts) as ydl:
        info = ydl.extract_info(f"https://www.youtube.com/watch?v={video_id}", download=False)

    stream = info.get("url")
    if not stream:
        for f in info.get("requested_formats") or []:
            if f.get("vcodec") not in (None, "none") and f.get("url"):
                stream = f["url"]
                break
    if not stream:
        vids = [f for f in info.get("formats", [])
                if f.get("vcodec") not in (None, "none") and f.get("url")]
        if vids:
            stream = sorted(vids, key=lambda f: f.get("height") or 0)[-1]["url"]
    if not stream:
        raise RuntimeError("Could not resolve a video stream URL for frame capture.")
    return stream, float(info.get("duration") or 0.0)


def _grab(stream_url: str, t: float, out_path: str, proxy: str | None) -> bool:
    """Grab one frame at time ``t`` via ffmpeg input-seek; return True on success."""
    cmd = ["ffmpeg", "-y", "-loglevel", "error", "-ss", f"{max(0.0, t):.2f}",
           "-i", stream_url, "-frames:v", "1", "-q:v", "2", out_path]
    env = dict(os.environ)
    if proxy:  # stream URL is bound to the extraction IP; route ffmpeg through it too
        env["http_proxy"] = env["https_proxy"] = proxy
    try:
        subprocess.run(cmd, capture_output=True, text=True, timeout=90, env=env)
    except subprocess.TimeoutExpired:
        return False
    return os.path.exists(out_path) and os.path.getsize(out_path) > 0


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 grab_best_frame(stream_url: str, t: float, out_dir: str, idx: int,
                    proxy: str | None = None) -> str | None:
    """Grab candidates around ``t`` and keep the sharpest; return its path or None."""
    cands = []
    for k, dt in enumerate((-0.5, 0.0, 0.5)):
        p = os.path.join(out_dir, f"shot_{idx}_{k}.jpg")
        if _grab(stream_url, t + dt, p, proxy):
            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], stream_url: str, out_dir: str,
                  proxy: str | None = None, progress=None) -> dict[int, dict]:
    """Grab a frame for each ``{step_index: time}``; return ``{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_best_frame(stream_url, t, out_dir, idx, proxy)
        if path:
            out[idx] = {"time": t, "path": path}
    return out