vivekchakraverty commited on
Commit
6e9f89d
·
verified ·
1 Parent(s): 144ae92

Screenshots via yt-dlp clip download over HTTPS proxy (ffmpeg local); trust proxy CA for yt-dlp

Browse files
Files changed (1) hide show
  1. pipeline/frames.py +65 -61
pipeline/frames.py CHANGED
@@ -1,9 +1,13 @@
1
- """Stage 5 + 6: real screenshots via yt-dlp stream URL + ffmpeg, at weighted timestamps.
 
2
 
3
- No full download. We resolve a direct video stream URL with yt-dlp (metadata only), then
4
- ``ffmpeg -ss T -frames:v 1`` grabs a single frame per timestamp over HTTP range requests
5
- (only a few seconds of data each). For each shot we grab 3 candidates around T and keep
6
- the sharpest (Laplacian variance) to avoid landing on a blurry/transition frame.
 
 
 
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
- # ----------------------------------------------------------------------- stream + grab
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 grab_best_frame(stream_url: str, t: float, out_dir: str, idx: int,
128
- proxy: str | None = None) -> str | None:
129
- """Grab candidates around ``t`` and keep the sharpest; return its path or None."""
130
  cands = []
131
- for k, dt in enumerate((-0.5, 0.0, 0.5)):
132
  p = os.path.join(out_dir, f"shot_{idx}_{k}.jpg")
133
- if _grab(stream_url, t + dt, p, proxy):
 
 
 
 
 
 
 
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], stream_url: str, out_dir: str,
148
- proxy: str | None = None, progress=None) -> dict[int, dict]:
149
- """Grab a frame for each ``{step_index: time}``; return ``{idx: {"time", "path"}}``."""
 
 
 
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
- path = grab_best_frame(stream_url, t, out_dir, idx, proxy)
 
 
 
 
 
 
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