Spaces:
Sleeping
Sleeping
Screenshots via yt-dlp clip download over HTTPS proxy (ffmpeg local); trust proxy CA for yt-dlp
Browse files- pipeline/frames.py +65 -61
pipeline/frames.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
| 1 |
-
"""Stage 5 + 6: real screenshots via yt-dlp
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
The capture timestamp itself comes from the weighted indicator: a blend of the LLM's
|
| 9 |
suggested timestamp and the transcript segment timing of the text the step quotes.
|
|
@@ -15,10 +19,7 @@ import os
|
|
| 15 |
import subprocess
|
| 16 |
|
| 17 |
from yt_dlp import YoutubeDL
|
| 18 |
-
|
| 19 |
-
# Format preference: muxed (single A/V url) <=720p first, then video-only, then anything.
|
| 20 |
-
_FORMAT = ("best[height<=720][vcodec!=none][acodec!=none]/"
|
| 21 |
-
"best[height<=720][vcodec!=none]/bestvideo[height<=720]/best[vcodec!=none]/best")
|
| 22 |
|
| 23 |
|
| 24 |
# --------------------------------------------------------------------------- weighting
|
|
@@ -69,48 +70,7 @@ def compute_shot_times(steps: list[dict], segs: list[dict], *, w_llm: float = 0.
|
|
| 69 |
}
|
| 70 |
|
| 71 |
|
| 72 |
-
# --------------------------------------------------------------------
|
| 73 |
-
def get_stream_url(video_id: str, cookiefile: str | None = None,
|
| 74 |
-
proxy: str | None = None) -> tuple[str, float]:
|
| 75 |
-
"""Resolve a direct video stream URL (no download); return ``(url, duration_seconds)``."""
|
| 76 |
-
opts = {"quiet": True, "no_warnings": True, "skip_download": True, "format": _FORMAT}
|
| 77 |
-
if cookiefile:
|
| 78 |
-
opts["cookiefile"] = cookiefile
|
| 79 |
-
if proxy:
|
| 80 |
-
opts["proxy"] = proxy
|
| 81 |
-
with YoutubeDL(opts) as ydl:
|
| 82 |
-
info = ydl.extract_info(f"https://www.youtube.com/watch?v={video_id}", download=False)
|
| 83 |
-
|
| 84 |
-
stream = info.get("url")
|
| 85 |
-
if not stream:
|
| 86 |
-
for f in info.get("requested_formats") or []:
|
| 87 |
-
if f.get("vcodec") not in (None, "none") and f.get("url"):
|
| 88 |
-
stream = f["url"]
|
| 89 |
-
break
|
| 90 |
-
if not stream:
|
| 91 |
-
vids = [f for f in info.get("formats", [])
|
| 92 |
-
if f.get("vcodec") not in (None, "none") and f.get("url")]
|
| 93 |
-
if vids:
|
| 94 |
-
stream = sorted(vids, key=lambda f: f.get("height") or 0)[-1]["url"]
|
| 95 |
-
if not stream:
|
| 96 |
-
raise RuntimeError("Could not resolve a video stream URL for frame capture.")
|
| 97 |
-
return stream, float(info.get("duration") or 0.0)
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def _grab(stream_url: str, t: float, out_path: str, proxy: str | None) -> bool:
|
| 101 |
-
"""Grab one frame at time ``t`` via ffmpeg input-seek; return True on success."""
|
| 102 |
-
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-ss", f"{max(0.0, t):.2f}",
|
| 103 |
-
"-i", stream_url, "-frames:v", "1", "-q:v", "2", out_path]
|
| 104 |
-
env = dict(os.environ)
|
| 105 |
-
if proxy: # stream URL is bound to the extraction IP; route ffmpeg through it too
|
| 106 |
-
env["http_proxy"] = env["https_proxy"] = proxy
|
| 107 |
-
try:
|
| 108 |
-
subprocess.run(cmd, capture_output=True, text=True, timeout=90, env=env)
|
| 109 |
-
except subprocess.TimeoutExpired:
|
| 110 |
-
return False
|
| 111 |
-
return os.path.exists(out_path) and os.path.getsize(out_path) > 0
|
| 112 |
-
|
| 113 |
-
|
| 114 |
def _sharpness(path: str) -> float:
|
| 115 |
"""Variance of the Laplacian (higher = sharper). Used to reject blurry frames."""
|
| 116 |
import numpy as np
|
|
@@ -124,13 +84,20 @@ def _sharpness(path: str) -> float:
|
|
| 124 |
return float(lap.var())
|
| 125 |
|
| 126 |
|
| 127 |
-
def
|
| 128 |
-
|
| 129 |
-
|
| 130 |
cands = []
|
| 131 |
-
for k, dt in enumerate((-0.
|
| 132 |
p = os.path.join(out_dir, f"shot_{idx}_{k}.jpg")
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
cands.append(p)
|
| 135 |
if not cands:
|
| 136 |
return None
|
|
@@ -144,16 +111,53 @@ def grab_best_frame(stream_url: str, t: float, out_dir: str, idx: int,
|
|
| 144 |
return best
|
| 145 |
|
| 146 |
|
| 147 |
-
def capture_shots(times: dict[int, float],
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
| 150 |
os.makedirs(out_dir, exist_ok=True)
|
| 151 |
-
out: dict[int, dict] = {}
|
| 152 |
items = list(times.items())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
for n, (idx, t) in enumerate(items):
|
| 154 |
if progress:
|
| 155 |
progress((n + 1) / max(1, len(items)), desc=f"Screenshot {n + 1}/{len(items)}")
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
if path:
|
| 158 |
out[idx] = {"time": t, "path": path}
|
| 159 |
return out
|
|
|
|
| 1 |
+
"""Stage 5 + 6: real screenshots via short yt-dlp clip downloads + ffmpeg, at weighted
|
| 2 |
+
timestamps.
|
| 3 |
|
| 4 |
+
For each timestamp we download a ~1.5 s clip with yt-dlp's ``download_ranges`` (a couple
|
| 5 |
+
hundred KB) and let ``ffmpeg`` extract frames from that *local* file. We grab 3 candidates
|
| 6 |
+
around the target and keep the sharpest (Laplacian variance) to avoid a blurry/transition
|
| 7 |
+
frame. Downloading (rather than ffmpeg-seeking the remote stream) is required because the
|
| 8 |
+
stream lives on ``googlevideo.com`` and must go through the residential proxy — and when
|
| 9 |
+
``YT_PROXY`` is an HTTPS/TLS-wrapped proxy (needed to hide the target host from egress DPI)
|
| 10 |
+
ffmpeg can't talk to it, but yt-dlp can.
|
| 11 |
|
| 12 |
The capture timestamp itself comes from the weighted indicator: a blend of the LLM's
|
| 13 |
suggested timestamp and the transcript segment timing of the text the step quotes.
|
|
|
|
| 19 |
import subprocess
|
| 20 |
|
| 21 |
from yt_dlp import YoutubeDL
|
| 22 |
+
from yt_dlp.utils import download_range_func
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
# --------------------------------------------------------------------------- weighting
|
|
|
|
| 70 |
}
|
| 71 |
|
| 72 |
|
| 73 |
+
# -------------------------------------------------------------------- download + grab
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _sharpness(path: str) -> float:
|
| 75 |
"""Variance of the Laplacian (higher = sharper). Used to reject blurry frames."""
|
| 76 |
import numpy as np
|
|
|
|
| 84 |
return float(lap.var())
|
| 85 |
|
| 86 |
|
| 87 |
+
def _best_frame_from_clip(clip: str, out_dir: str, idx: int, target: float) -> str | None:
|
| 88 |
+
"""Extract candidates near ``target`` seconds into a *local* ``clip`` and keep the
|
| 89 |
+
sharpest. ffmpeg reads a local file here, so no proxy/network is involved."""
|
| 90 |
cands = []
|
| 91 |
+
for k, dt in enumerate((-0.4, 0.0, 0.4)):
|
| 92 |
p = os.path.join(out_dir, f"shot_{idx}_{k}.jpg")
|
| 93 |
+
ss = max(0.0, target + dt)
|
| 94 |
+
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-ss", f"{ss:.2f}",
|
| 95 |
+
"-i", clip, "-frames:v", "1", "-q:v", "2", p]
|
| 96 |
+
try:
|
| 97 |
+
subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
| 98 |
+
except subprocess.TimeoutExpired:
|
| 99 |
+
continue
|
| 100 |
+
if os.path.exists(p) and os.path.getsize(p) > 0:
|
| 101 |
cands.append(p)
|
| 102 |
if not cands:
|
| 103 |
return None
|
|
|
|
| 111 |
return best
|
| 112 |
|
| 113 |
|
| 114 |
+
def capture_shots(times: dict[int, float], video_id: str, out_dir: str,
|
| 115 |
+
cookiefile: str | None = None, proxy: str | None = None,
|
| 116 |
+
progress=None, *, half: float = 0.75,
|
| 117 |
+
max_height: int = 480) -> dict[int, dict]:
|
| 118 |
+
"""Download a short clip around each ``{step_index: time}`` and keep the sharpest
|
| 119 |
+
frame; return ``{idx: {"time", "path"}}``. One yt-dlp call fetches every section."""
|
| 120 |
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
| 121 |
items = list(times.items())
|
| 122 |
+
if not items:
|
| 123 |
+
return {}
|
| 124 |
+
clips_dir = os.path.join(out_dir, "_clips")
|
| 125 |
+
os.makedirs(clips_dir, exist_ok=True)
|
| 126 |
+
ranges = [(max(0.0, t - half), t + half) for _, t in items]
|
| 127 |
+
opts = {
|
| 128 |
+
"quiet": True, "no_warnings": True,
|
| 129 |
+
"format": (f"bv*[height<={max_height}][vcodec!=none]/"
|
| 130 |
+
f"best[height<={max_height}]/best[vcodec!=none]/best"),
|
| 131 |
+
"paths": {"home": clips_dir},
|
| 132 |
+
"outtmpl": {"default": "sec_%(section_start)0.2f.%(ext)s"},
|
| 133 |
+
"download_ranges": download_range_func(None, ranges),
|
| 134 |
+
"force_keyframes_at_cuts": True,
|
| 135 |
+
}
|
| 136 |
+
if cookiefile:
|
| 137 |
+
opts["cookiefile"] = cookiefile
|
| 138 |
+
if proxy:
|
| 139 |
+
opts["proxy"] = proxy
|
| 140 |
+
# yt-dlp trusts only certifi's own bundle by default and ignores SSL_CERT_FILE.
|
| 141 |
+
# When an HTTPS-proxy CA has been installed into a combined bundle (SSL_CERT_FILE,
|
| 142 |
+
# set by app._install_proxy_ca), switch yt-dlp to the default cert path so it
|
| 143 |
+
# honors that file — this is what lets it validate the self-signed proxy cert.
|
| 144 |
+
if os.environ.get("SSL_CERT_FILE"):
|
| 145 |
+
opts["compat_opts"] = ["no-certifi"]
|
| 146 |
+
with YoutubeDL(opts) as ydl:
|
| 147 |
+
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
|
| 148 |
+
|
| 149 |
+
import glob
|
| 150 |
+
out: dict[int, dict] = {}
|
| 151 |
for n, (idx, t) in enumerate(items):
|
| 152 |
if progress:
|
| 153 |
progress((n + 1) / max(1, len(items)), desc=f"Screenshot {n + 1}/{len(items)}")
|
| 154 |
+
start = max(0.0, t - half)
|
| 155 |
+
matches = [m for m in glob.glob(os.path.join(clips_dir, f"sec_{start:.2f}.*"))
|
| 156 |
+
if not m.endswith(".part")]
|
| 157 |
+
if not matches:
|
| 158 |
+
continue
|
| 159 |
+
# t sits ``t - start`` seconds into its clip (== half, except when clamped at 0)
|
| 160 |
+
path = _best_frame_from_clip(matches[0], out_dir, idx, t - start)
|
| 161 |
if path:
|
| 162 |
out[idx] = {"time": t, "path": path}
|
| 163 |
return out
|