Spaces:
Sleeping
Sleeping
Screenshots: 2-phase (HTTPS-proxy resolve + plain media-proxy chunked download + local ffmpeg)
Browse files- pipeline/frames.py +103 -56
pipeline/frames.py
CHANGED
|
@@ -1,16 +1,18 @@
|
|
| 1 |
-
"""Stage 5 + 6: real screenshots via
|
| 2 |
-
timestamps.
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
|
|
@@ -18,8 +20,8 @@ import difflib
|
|
| 18 |
import os
|
| 19 |
import subprocess
|
| 20 |
|
|
|
|
| 21 |
from yt_dlp import YoutubeDL
|
| 22 |
-
from yt_dlp.utils import download_range_func
|
| 23 |
|
| 24 |
|
| 25 |
# --------------------------------------------------------------------------- weighting
|
|
@@ -84,15 +86,79 @@ def _sharpness(path: str) -> float:
|
|
| 84 |
return float(lap.var())
|
| 85 |
|
| 86 |
|
| 87 |
-
def
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
sharpest. ffmpeg reads a local file here, so no proxy/network is involved."""
|
| 90 |
cands = []
|
| 91 |
-
for k, dt in enumerate((-0.
|
| 92 |
p = os.path.join(out_dir, f"shot_{idx}_{k}.jpg")
|
| 93 |
-
|
| 94 |
-
|
| 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:
|
|
@@ -113,51 +179,32 @@ def _best_frame_from_clip(clip: str, out_dir: str, idx: int, target: float) -> s
|
|
| 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 |
-
|
| 117 |
-
max_height: int =
|
| 118 |
-
"""Download
|
| 119 |
-
|
|
|
|
| 120 |
os.makedirs(out_dir, exist_ok=True)
|
| 121 |
items = list(times.items())
|
| 122 |
if not items:
|
| 123 |
return {}
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 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 |
-
|
| 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
|
|
|
|
| 1 |
+
"""Stage 5 + 6: real screenshots via a one-time low-res video download + ffmpeg, at
|
| 2 |
+
weighted timestamps.
|
| 3 |
+
|
| 4 |
+
Two proxies are used because of how the Space's egress is filtered:
|
| 5 |
+
|
| 6 |
+
* Resolution hits ``youtube.com`` and must go through ``YT_PROXY`` — an HTTPS/TLS-wrapped
|
| 7 |
+
proxy that hides the target host from the egress DPI that otherwise resets YouTube.
|
| 8 |
+
* The media itself lives on ``googlevideo.com`` (not DPI-filtered) and is many MB, which
|
| 9 |
+
the TLS proxy can't sustain — so it's downloaded through ``YT_MEDIA_PROXY``, a plain
|
| 10 |
+
HTTP proxy, in chunked Range requests (dodging YouTube's single-stream throttling).
|
| 11 |
+
|
| 12 |
+
We download one capped-resolution copy, then ``ffmpeg`` reads the *local* file to grab 3
|
| 13 |
+
candidates around each timestamp and keeps the sharpest (Laplacian variance). The capture
|
| 14 |
+
timestamp comes from the weighted indicator: a blend of the LLM's suggested timestamp and
|
| 15 |
+
the transcript segment timing of the text the step quotes.
|
| 16 |
"""
|
| 17 |
from __future__ import annotations
|
| 18 |
|
|
|
|
| 20 |
import os
|
| 21 |
import subprocess
|
| 22 |
|
| 23 |
+
import requests
|
| 24 |
from yt_dlp import YoutubeDL
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
# --------------------------------------------------------------------------- weighting
|
|
|
|
| 86 |
return float(lap.var())
|
| 87 |
|
| 88 |
|
| 89 |
+
def _resolve_media_url(video_id: str, max_height: int, cookiefile: str | None,
|
| 90 |
+
proxy: str | None) -> str | None:
|
| 91 |
+
"""Resolve a single-file (progressive/DASH) video URL <= ``max_height`` via the
|
| 92 |
+
HTTPS ``proxy``. Returns the ``googlevideo.com`` media URL, or None."""
|
| 93 |
+
opts = {
|
| 94 |
+
"quiet": True, "no_warnings": True, "skip_download": True,
|
| 95 |
+
"format": (f"bv*[height<={max_height}][ext=mp4]/bv*[height<={max_height}]/"
|
| 96 |
+
f"best[height<={max_height}]/best"),
|
| 97 |
+
}
|
| 98 |
+
if cookiefile:
|
| 99 |
+
opts["cookiefile"] = cookiefile
|
| 100 |
+
if proxy:
|
| 101 |
+
opts["proxy"] = proxy
|
| 102 |
+
# yt-dlp validates against certifi only and ignores SSL_CERT_FILE; switch it to
|
| 103 |
+
# the default cert path (which honors our combined bundle) so the self-signed
|
| 104 |
+
# HTTPS-proxy cert validates. See app._install_proxy_ca.
|
| 105 |
+
if os.environ.get("SSL_CERT_FILE"):
|
| 106 |
+
opts["compat_opts"] = ["no-certifi"]
|
| 107 |
+
with YoutubeDL(opts) as ydl:
|
| 108 |
+
info = ydl.extract_info(f"https://www.youtube.com/watch?v={video_id}", download=False)
|
| 109 |
+
if info.get("url"):
|
| 110 |
+
return info["url"]
|
| 111 |
+
vids = [f for f in info.get("formats", [])
|
| 112 |
+
if f.get("vcodec") not in (None, "none") and f.get("url")
|
| 113 |
+
and (f.get("height") or 0) <= max_height
|
| 114 |
+
and str(f.get("protocol", "")).startswith("http")]
|
| 115 |
+
if vids:
|
| 116 |
+
return sorted(vids, key=lambda f: f.get("height") or 0)[-1]["url"]
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _download_media(url: str, dest: str, media_proxy: str | None,
|
| 121 |
+
chunk: int = 1 << 20, timeout: int = 30) -> None:
|
| 122 |
+
"""Download ``url`` to ``dest`` through the plain ``media_proxy`` using chunked Range
|
| 123 |
+
requests — small ranges dodge YouTube's single-stream throttling and stay within the
|
| 124 |
+
plain proxy's throughput (the TLS proxy can't sustain large transfers)."""
|
| 125 |
+
proxies = {"http": media_proxy, "https": media_proxy} if media_proxy else None
|
| 126 |
+
probe = requests.get(url, proxies=proxies, headers={"Range": "bytes=0-0"}, timeout=timeout)
|
| 127 |
+
probe.raise_for_status()
|
| 128 |
+
cr = probe.headers.get("Content-Range", "")
|
| 129 |
+
total = int(cr.split("/")[-1]) if "/" in cr and cr.split("/")[-1].isdigit() else 0
|
| 130 |
+
with open(dest, "wb") as fh:
|
| 131 |
+
if not total: # server ignored Range — fall back to a single stream
|
| 132 |
+
with requests.get(url, proxies=proxies, stream=True, timeout=timeout) as r:
|
| 133 |
+
r.raise_for_status()
|
| 134 |
+
for c in r.iter_content(chunk):
|
| 135 |
+
if c:
|
| 136 |
+
fh.write(c)
|
| 137 |
+
return
|
| 138 |
+
start = 0
|
| 139 |
+
while start < total:
|
| 140 |
+
end = min(start + chunk - 1, total - 1)
|
| 141 |
+
for attempt in range(3):
|
| 142 |
+
try:
|
| 143 |
+
rr = requests.get(url, proxies=proxies, timeout=timeout,
|
| 144 |
+
headers={"Range": f"bytes={start}-{end}"})
|
| 145 |
+
rr.raise_for_status()
|
| 146 |
+
fh.write(rr.content)
|
| 147 |
+
break
|
| 148 |
+
except requests.RequestException:
|
| 149 |
+
if attempt == 2:
|
| 150 |
+
raise
|
| 151 |
+
start = end + 1
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _grab_local(media_path: str, t: float, out_dir: str, idx: int) -> str | None:
|
| 155 |
+
"""Grab 3 candidates around ``t`` from the *local* ``media_path`` and keep the
|
| 156 |
sharpest. ffmpeg reads a local file here, so no proxy/network is involved."""
|
| 157 |
cands = []
|
| 158 |
+
for k, dt in enumerate((-0.5, 0.0, 0.5)):
|
| 159 |
p = os.path.join(out_dir, f"shot_{idx}_{k}.jpg")
|
| 160 |
+
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-ss", f"{max(0.0, t + dt):.2f}",
|
| 161 |
+
"-i", media_path, "-frames:v", "1", "-q:v", "2", p]
|
|
|
|
| 162 |
try:
|
| 163 |
subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
| 164 |
except subprocess.TimeoutExpired:
|
|
|
|
| 179 |
|
| 180 |
def capture_shots(times: dict[int, float], video_id: str, out_dir: str,
|
| 181 |
cookiefile: str | None = None, proxy: str | None = None,
|
| 182 |
+
media_proxy: str | None = None, progress=None, *,
|
| 183 |
+
max_height: int = 360) -> dict[int, dict]:
|
| 184 |
+
"""Download one capped-resolution copy of the video (resolve via ``proxy``, fetch
|
| 185 |
+
media via ``media_proxy``) and keep the sharpest frame per ``{step_index: time}``;
|
| 186 |
+
return ``{idx: {"time", "path"}}``."""
|
| 187 |
os.makedirs(out_dir, exist_ok=True)
|
| 188 |
items = list(times.items())
|
| 189 |
if not items:
|
| 190 |
return {}
|
| 191 |
+
url = _resolve_media_url(video_id, max_height, cookiefile, proxy)
|
| 192 |
+
if not url:
|
| 193 |
+
raise RuntimeError("Could not resolve a downloadable video stream URL.")
|
| 194 |
+
media = os.path.join(out_dir, "_video.bin")
|
| 195 |
+
if progress:
|
| 196 |
+
progress(0.0, desc="Downloading video for screenshots")
|
| 197 |
+
_download_media(url, media, media_proxy or proxy)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
|
|
|
| 199 |
out: dict[int, dict] = {}
|
| 200 |
for n, (idx, t) in enumerate(items):
|
| 201 |
if progress:
|
| 202 |
progress((n + 1) / max(1, len(items)), desc=f"Screenshot {n + 1}/{len(items)}")
|
| 203 |
+
path = _grab_local(media, t, out_dir, idx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
if path:
|
| 205 |
out[idx] = {"time": t, "path": path}
|
| 206 |
+
try:
|
| 207 |
+
os.remove(media)
|
| 208 |
+
except OSError:
|
| 209 |
+
pass
|
| 210 |
return out
|