vivekchakraverty Claude Opus 4.8 commited on
Commit
f6a6455
·
1 Parent(s): 2931418

Add YouTube -> tutorial .docx pipeline (Gradio Space)

Browse files

Search top videos, rank by comment sentiment, transcribe the best with
faster-whisper, generate tutorial text with DeepSeek-V3, pick screenshots via a
weighted LLM/Whisper-timing indicator, caption them with a vision model, and
assemble a .docx. Downloaded video is auto-deleted after frame extraction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ *.docx
6
+ *.mp4
7
+ *.webm
8
+ *.wav
9
+ *.jpg
10
+ *.jpeg
11
+ *.png
12
+ runs/
13
+ .gradio/
14
+ .DS_Store
README.md CHANGED
@@ -12,4 +12,43 @@ license: mit
12
  short_description: Make Tutorials from YouTube Videos
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  short_description: Make Tutorials from YouTube Videos
13
  ---
14
 
15
+ # YouTube Tutorial Post Generator
16
+
17
+ Give a **topic** and your **Hugging Face token**, and this Space builds a downloadable
18
+ **`.docx` tutorial** (text + AI-captioned screenshots) from the single best YouTube video
19
+ on that topic.
20
+
21
+ ## Pipeline
22
+
23
+ 1. **Search** — top 5 videos via the [`adarshajay/youtube-search`](https://huggingface.co/spaces/adarshajay/youtube-search) Space.
24
+ 2. **Sentiment rank** — fetch top comments per video with `yt-dlp` and score them with the
25
+ BERT classifier [`OmarMedhat7/youtube-sentiment-analysis-model`](https://huggingface.co/OmarMedhat7/youtube-sentiment-analysis-model);
26
+ the video with the highest positive share wins.
27
+ 3. **Download** the winner with `yt-dlp` and extract audio with `ffmpeg`.
28
+ 4. **Transcribe** locally with `faster-whisper` (segment timestamps).
29
+ 5. **Candidate frames** are extracted densely from the video, then the **video is
30
+ auto-deleted** — only small JPEGs and the transcript remain.
31
+ 6. **Tutorial text** — `deepseek-ai/DeepSeek-V3` (HF Inference Providers, billed to your
32
+ token) turns the transcript into structured steps.
33
+ 7. **Screenshot selection** — a weighted indicator blends each step's LLM-suggested
34
+ timestamp with Whisper's actual speech timing.
35
+ 8. **Captions** — a vision model (default `Qwen/Qwen2.5-VL-72B-Instruct`, billed to your
36
+ token) captions each screenshot.
37
+ 9. **Assemble** the `.docx` for download.
38
+
39
+ ## Notes
40
+
41
+ - **Your HF token is used only for the LLM and vision-model calls** and is billed to your
42
+ account. Create a fine-grained token with *"Make calls to Inference Providers"* at
43
+ <https://huggingface.co/settings/tokens>.
44
+ - **Free CPU tier:** Whisper runs on CPU, so transcription is slow — keep videos short
45
+ (default cap ~20 min).
46
+ - **YouTube may block the Space's IP.** If downloads/comments fail, add a Netscape-format
47
+ cookie file as a Space secret named `YT_COOKIES` (the file's contents).
48
+
49
+ ## Local run
50
+
51
+ ```bash
52
+ pip install -r requirements.txt # needs ffmpeg on PATH
53
+ python app.py
54
+ ```
app.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio Space: YouTube topic -> captioned .docx tutorial.
2
+
3
+ Orchestrates the pipeline stages and streams progress/status to the UI. Heavy ML
4
+ imports (torch/transformers/faster-whisper) are lazy inside the pipeline modules, so app
5
+ startup stays fast.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import shutil
12
+ import tempfile
13
+
14
+ import gradio as gr
15
+
16
+ from pipeline import (
17
+ captions as captions_mod,
18
+ docx_builder,
19
+ download as download_mod,
20
+ frames as frames_mod,
21
+ search as search_mod,
22
+ sentiment as sentiment_mod,
23
+ transcribe as transcribe_mod,
24
+ tutorial as tutorial_mod,
25
+ )
26
+
27
+ LLM_CHOICES = [
28
+ "deepseek-ai/DeepSeek-V3",
29
+ "meta-llama/Llama-3.3-70B-Instruct",
30
+ "openai/gpt-oss-120b",
31
+ ]
32
+ VLM_CHOICES = [
33
+ "Qwen/Qwen2.5-VL-72B-Instruct",
34
+ "Qwen/Qwen2.5-VL-7B-Instruct",
35
+ "meta-llama/Llama-3.2-90B-Vision-Instruct",
36
+ ]
37
+
38
+
39
+ def _cookiefile(workdir: str) -> str | None:
40
+ """Materialize the YT_COOKIES secret (Netscape cookie file contents) to disk."""
41
+ data = os.environ.get("YT_COOKIES")
42
+ if not data:
43
+ return None
44
+ path = os.path.join(workdir, "cookies.txt")
45
+ with open(path, "w", encoding="utf-8") as fh:
46
+ fh.write(data)
47
+ return path
48
+
49
+
50
+ def _ranking_rows(scored: list[dict]) -> list[list]:
51
+ rows = []
52
+ for rank, v in enumerate(scored, start=1):
53
+ rows.append([
54
+ rank,
55
+ v.get("title", v["video_id"]),
56
+ f"{v['positive_share'] * 100:.0f}%",
57
+ v.get("n_comments", 0),
58
+ v.get("note", "") or "ok",
59
+ v["url"],
60
+ ])
61
+ return rows
62
+
63
+
64
+ def _safe_name(text: str) -> str:
65
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:60] or "tutorial"
66
+
67
+
68
+ def run_pipeline(topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead,
69
+ max_minutes, max_shots, progress=gr.Progress()):
70
+ """Generator that yields (status_md, ranking_df, transcript, docx_file)."""
71
+ log: list[str] = []
72
+
73
+ def status(msg: str):
74
+ log.append(msg)
75
+ return "\n\n".join(log)
76
+
77
+ topic = (topic or "").strip()
78
+ if not topic:
79
+ raise gr.Error("Please enter a topic.")
80
+ if not (hf_token or "").strip():
81
+ raise gr.Error("Please paste your Hugging Face token (used for the LLM + vision model).")
82
+
83
+ workdir = tempfile.mkdtemp(prefix="ytt_")
84
+ frames_dir = os.path.join(workdir, "frames")
85
+ video_path = None
86
+ try:
87
+ cookiefile = _cookiefile(workdir)
88
+
89
+ # 1. Search ------------------------------------------------------------------
90
+ progress(0.02, desc="Searching")
91
+ yield status(f"🔍 Searching top videos for **{topic}**…"), gr.update(), gr.update(), gr.update()
92
+ videos = search_mod.search_top5(topic)
93
+ yield status(f"Found {len(videos)} candidate videos."), gr.update(), gr.update(), gr.update()
94
+
95
+ # 2. Sentiment ranking -------------------------------------------------------
96
+ yield status("💬 Fetching comments and scoring sentiment…"), gr.update(), gr.update(), gr.update()
97
+ best, scored = sentiment_mod.rank_by_sentiment(videos, cookiefile, progress)
98
+ ranking = gr.update(value=_ranking_rows(scored))
99
+ yield (status(f"🏆 Picked **{best.get('title', best['video_id'])}** "
100
+ f"({best['positive_share'] * 100:.0f}% positive)."),
101
+ ranking, gr.update(), gr.update())
102
+
103
+ # 3. Download + audio --------------------------------------------------------
104
+ progress(0.25, desc="Downloading")
105
+ yield status("⬇️ Downloading the chosen video…"), ranking, gr.update(), gr.update()
106
+ video_path, duration = download_mod.download_video(
107
+ best["url"], workdir, cookiefile, int(max_minutes))
108
+ wav = download_mod.extract_audio(video_path, workdir)
109
+
110
+ # 4. Transcribe --------------------------------------------------------------
111
+ progress(0.4, desc="Transcribing")
112
+ yield status("📝 Transcribing with Whisper (this is the slow part on CPU)…"), ranking, gr.update(), gr.update()
113
+ segs = transcribe_mod.transcribe(wav, progress)
114
+ transcript = transcribe_mod.transcript_text(segs)
115
+ yield (status(f"Transcript ready ({len(segs)} segments)."),
116
+ ranking, gr.update(value=transcript), gr.update())
117
+
118
+ # 5. Candidate frames, then DELETE the video --------------------------------
119
+ progress(0.6, desc="Extracting frames")
120
+ candidates = frames_mod.extract_candidates(video_path, frames_dir, duration)
121
+ frames_mod.delete_video(video_path)
122
+ video_path = None
123
+ yield (status(f"🎞️ Extracted {len(candidates)} candidate frames and "
124
+ f"**deleted the downloaded video**."),
125
+ ranking, gr.update(value=transcript), gr.update())
126
+
127
+ # 6. Tutorial text -----------------------------------------------------------
128
+ progress(0.72, desc="Writing tutorial")
129
+ yield status(f"🤖 Generating tutorial with `{llm_model}`…"), ranking, gr.update(value=transcript), gr.update()
130
+ tut = tutorial_mod.generate_tutorial(transcript, hf_token.strip(), llm_model)
131
+
132
+ # 7. Weighted screenshot selection ------------------------------------------
133
+ selected = frames_mod.select_screenshots(
134
+ tut["steps"], segs, candidates,
135
+ w_llm=float(w_llm), w_whisper=float(w_whisper), lead=float(lead),
136
+ max_shots=int(max_shots),
137
+ )
138
+ yield (status(f"🖼️ Selected {len(selected)} screenshots via the weighted indicator."),
139
+ ranking, gr.update(value=transcript), gr.update())
140
+
141
+ # 8. Captions ----------------------------------------------------------------
142
+ progress(0.85, desc="Captioning")
143
+ yield status(f"✍️ Captioning screenshots with `{vlm_model}`…"), ranking, gr.update(value=transcript), gr.update()
144
+ caps = captions_mod.caption_frames(selected, tut["steps"], hf_token.strip(), vlm_model, progress)
145
+
146
+ # 9. DOCX --------------------------------------------------------------------
147
+ progress(0.95, desc="Building document")
148
+ out_path = os.path.join(workdir, f"{_safe_name(tut['title'])}.docx")
149
+ docx_builder.build_docx(tut, selected, caps, out_path, source_url=best["url"])
150
+
151
+ progress(1.0, desc="Done")
152
+ yield (status("✅ Done! Download your tutorial below."),
153
+ ranking, gr.update(value=transcript), gr.update(value=out_path))
154
+
155
+ except gr.Error:
156
+ raise
157
+ except (download_mod.DownloadError, RuntimeError, ValueError) as exc:
158
+ raise gr.Error(str(exc))
159
+ finally:
160
+ # Always remove the video if it somehow survived; keep frames/docx until the
161
+ # response is sent (Gradio copies the returned file out).
162
+ if video_path:
163
+ frames_mod.delete_video(video_path)
164
+
165
+
166
+ def build_ui():
167
+ with gr.Blocks(title="YouTube → Tutorial Post") as demo:
168
+ gr.Markdown(
169
+ "# 📝 YouTube → Tutorial Post Generator\n"
170
+ "Enter a topic and your Hugging Face token. The Space picks the best video, "
171
+ "transcribes it, and builds a **captioned `.docx` tutorial**. Your token is "
172
+ "used only for the LLM + vision-model calls and **billed to your account**."
173
+ )
174
+ with gr.Row():
175
+ with gr.Column(scale=2):
176
+ topic = gr.Textbox(label="Topic", placeholder="e.g. Excel pivot tables for beginners")
177
+ hf_token = gr.Textbox(label="Hugging Face token", type="password",
178
+ placeholder="hf_… (Inference Providers permission)")
179
+ with gr.Column(scale=1):
180
+ llm_model = gr.Dropdown(LLM_CHOICES, value=LLM_CHOICES[0],
181
+ label="Tutorial LLM", allow_custom_value=True)
182
+ vlm_model = gr.Dropdown(VLM_CHOICES, value=VLM_CHOICES[0],
183
+ label="Vision model (captions)", allow_custom_value=True)
184
+
185
+ with gr.Accordion("Advanced settings", open=False):
186
+ with gr.Row():
187
+ w_llm = gr.Slider(0.0, 1.0, value=0.4, step=0.05, label="Weight: LLM timestamp")
188
+ w_whisper = gr.Slider(0.0, 1.0, value=0.6, step=0.05, label="Weight: Whisper timing")
189
+ lead = gr.Slider(0.0, 5.0, value=1.0, step=0.5, label="Lead offset (s)")
190
+ with gr.Row():
191
+ max_minutes = gr.Slider(2, 60, value=20, step=1, label="Max video length (min)")
192
+ max_shots = gr.Slider(1, 15, value=8, step=1, label="Max screenshots")
193
+
194
+ run_btn = gr.Button("Generate tutorial", variant="primary")
195
+
196
+ status_md = gr.Markdown(label="Status")
197
+ ranking_df = gr.Dataframe(
198
+ headers=["#", "Title", "Positive", "Comments", "Note", "URL"],
199
+ label="Sentiment ranking", interactive=False, wrap=True,
200
+ )
201
+ transcript_box = gr.Textbox(label="Transcript preview", lines=10, max_lines=20, show_copy_button=True)
202
+ docx_file = gr.File(label="Download tutorial (.docx)")
203
+
204
+ run_btn.click(
205
+ run_pipeline,
206
+ inputs=[topic, hf_token, llm_model, vlm_model, w_llm, w_whisper, lead, max_minutes, max_shots],
207
+ outputs=[status_md, ranking_df, transcript_box, docx_file],
208
+ )
209
+ return demo
210
+
211
+
212
+ if __name__ == "__main__":
213
+ build_ui().queue().launch()
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
pipeline/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Pipeline package: YouTube topic -> captioned .docx tutorial.
2
+
3
+ Stages (see app.run_pipeline for orchestration):
4
+ search -> sentiment -> download -> transcribe -> frames (extract + delete video)
5
+ -> tutorial -> frames.select (weighted) -> captions -> docx_builder
6
+ """
pipeline/captions.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 8: caption each selected screenshot with an HF vision model.
2
+
3
+ Calls a vision-language chat model (default Qwen2.5-VL-72B) via HF Inference Providers,
4
+ billed to the user's token. The image is sent inline as a base64 ``data:`` URL and the
5
+ step heading is given as context so captions are relevant, not just literal. Failures
6
+ degrade gracefully to the step heading so the .docx is always produced.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+
12
+ from huggingface_hub import InferenceClient
13
+
14
+ DEFAULT_VLM = "Qwen/Qwen2.5-VL-72B-Instruct"
15
+ FALLBACK_VLM = "Qwen/Qwen2.5-VL-7B-Instruct"
16
+
17
+ _PROMPT = (
18
+ "Write a single concise caption (one sentence, no preamble) describing what this "
19
+ "tutorial screenshot shows. Context for this step: {heading}."
20
+ )
21
+
22
+
23
+ def _data_url(path: str) -> str:
24
+ with open(path, "rb") as fh:
25
+ b64 = base64.b64encode(fh.read()).decode()
26
+ return f"data:image/jpeg;base64,{b64}"
27
+
28
+
29
+ def _caption_one(client: InferenceClient, model: str, path: str, heading: str) -> str:
30
+ messages = [{
31
+ "role": "user",
32
+ "content": [
33
+ {"type": "text", "text": _PROMPT.format(heading=heading or "tutorial step")},
34
+ {"type": "image_url", "image_url": {"url": _data_url(path)}},
35
+ ],
36
+ }]
37
+ resp = client.chat.completions.create(model=model, messages=messages, max_tokens=120)
38
+ return (resp.choices[0].message.content or "").strip()
39
+
40
+
41
+ def caption_frames(selected: dict[int, dict], steps: list[dict], hf_token: str,
42
+ model: str = DEFAULT_VLM, progress=None) -> dict[int, str]:
43
+ """Return ``{step_index: caption}`` for each selected screenshot.
44
+
45
+ Tries ``model`` first, then ``FALLBACK_VLM``, then the step heading.
46
+ """
47
+ if not selected:
48
+ return {}
49
+ if not hf_token:
50
+ raise ValueError("An HF token is required for image captioning (billed to your key).")
51
+
52
+ client = InferenceClient(token=hf_token)
53
+ captions: dict[int, str] = {}
54
+ items = list(selected.items())
55
+ for n, (idx, sel) in enumerate(items):
56
+ heading = steps[idx].get("heading", "")
57
+ if progress:
58
+ progress((n + 1) / len(items), desc=f"Captioning {n + 1}/{len(items)}")
59
+ text = ""
60
+ for m in (model, FALLBACK_VLM):
61
+ try:
62
+ text = _caption_one(client, m, sel["path"], heading)
63
+ if text:
64
+ break
65
+ except Exception:
66
+ continue
67
+ captions[idx] = text or heading or "Screenshot"
68
+ return captions
pipeline/docx_builder.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 9: assemble the .docx tutorial (text + captioned screenshots)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+ from docx import Document
7
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
8
+ from docx.shared import Inches, Pt, RGBColor
9
+
10
+
11
+ def _fmt_ts(seconds: float) -> str:
12
+ m, s = divmod(int(seconds), 60)
13
+ h, m = divmod(m, 60)
14
+ return f"{h:d}:{m:02d}:{s:02d}" if h else f"{m:d}:{s:02d}"
15
+
16
+
17
+ def build_docx(tutorial: dict, selected: dict[int, dict], captions: dict[int, str],
18
+ out_path: str, source_url: str = "") -> str:
19
+ """Write the tutorial to ``out_path`` and return it.
20
+
21
+ ``tutorial`` = {title, intro, steps}; ``selected`` = {step_idx: {time, path}};
22
+ ``captions`` = {step_idx: caption}.
23
+ """
24
+ doc = Document()
25
+ doc.add_heading(tutorial["title"], level=0)
26
+
27
+ if tutorial.get("intro"):
28
+ doc.add_paragraph(tutorial["intro"])
29
+
30
+ if source_url:
31
+ src = doc.add_paragraph()
32
+ run = src.add_run(f"Source video: {source_url}")
33
+ run.italic = True
34
+ run.font.size = Pt(9)
35
+ run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
36
+
37
+ for i, step in enumerate(tutorial["steps"], start=0):
38
+ doc.add_heading(f"{i + 1}. {step['heading']}", level=1)
39
+ if step.get("body"):
40
+ doc.add_paragraph(step["body"])
41
+
42
+ sel = selected.get(i)
43
+ if sel and os.path.exists(sel["path"]):
44
+ pic_par = doc.add_paragraph()
45
+ pic_par.alignment = WD_ALIGN_PARAGRAPH.CENTER
46
+ pic_par.add_run().add_picture(sel["path"], width=Inches(6))
47
+
48
+ cap_par = doc.add_paragraph()
49
+ cap_par.alignment = WD_ALIGN_PARAGRAPH.CENTER
50
+ caption = captions.get(i, "")
51
+ cap_run = cap_par.add_run(f"{caption} ")
52
+ cap_run.italic = True
53
+ cap_run.font.size = Pt(9)
54
+ ts_run = cap_par.add_run(f"(@ {_fmt_ts(sel['time'])})")
55
+ ts_run.italic = True
56
+ ts_run.font.size = Pt(9)
57
+ ts_run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
58
+
59
+ os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
60
+ doc.save(out_path)
61
+ return out_path
pipeline/download.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 3: download the chosen video and extract audio for Whisper.
2
+
3
+ Downloads a <=720p mp4 with yt-dlp, then uses ffmpeg to produce a 16 kHz mono wav.
4
+ Enforces a duration cap up front so a long video can't stall the free-CPU Space.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import subprocess
10
+
11
+ from yt_dlp import YoutubeDL
12
+
13
+
14
+ class DownloadError(RuntimeError):
15
+ """Raised for user-actionable download failures (blocked IP, too long, etc.)."""
16
+
17
+
18
+ def probe_duration(url: str, cookiefile: str | None = None) -> float:
19
+ """Return the video duration in seconds without downloading."""
20
+ opts = {"skip_download": True, "quiet": True, "no_warnings": True}
21
+ if cookiefile:
22
+ opts["cookiefile"] = cookiefile
23
+ try:
24
+ with YoutubeDL(opts) as ydl:
25
+ info = ydl.extract_info(url, download=False)
26
+ except Exception as exc:
27
+ raise DownloadError(_blocked_hint(exc)) from exc
28
+ return float(info.get("duration") or 0.0)
29
+
30
+
31
+ def download_video(url: str, out_dir: str, cookiefile: str | None = None,
32
+ max_minutes: int = 20) -> tuple[str, float]:
33
+ """Download the video to ``out_dir``; return ``(mp4_path, duration_seconds)``."""
34
+ os.makedirs(out_dir, exist_ok=True)
35
+
36
+ duration = probe_duration(url, cookiefile)
37
+ if duration and duration > max_minutes * 60:
38
+ raise DownloadError(
39
+ f"Video is {duration / 60:.0f} min long, over the {max_minutes} min cap for "
40
+ "this free-CPU Space. Pick a shorter video or raise the cap."
41
+ )
42
+
43
+ out_tmpl = os.path.join(out_dir, "video.%(ext)s")
44
+ opts = {
45
+ "outtmpl": out_tmpl,
46
+ "format": "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]/best",
47
+ "merge_output_format": "mp4",
48
+ "quiet": True,
49
+ "no_warnings": True,
50
+ "noplaylist": True,
51
+ }
52
+ if cookiefile:
53
+ opts["cookiefile"] = cookiefile
54
+
55
+ try:
56
+ with YoutubeDL(opts) as ydl:
57
+ info = ydl.extract_info(url, download=True)
58
+ path = ydl.prepare_filename(info)
59
+ except Exception as exc:
60
+ raise DownloadError(_blocked_hint(exc)) from exc
61
+
62
+ duration = float(info.get("duration") or duration or 0.0)
63
+
64
+ # merge_output_format may have rewritten the extension to .mp4
65
+ if not os.path.exists(path):
66
+ base, _ = os.path.splitext(path)
67
+ path = base + ".mp4"
68
+ if not os.path.exists(path):
69
+ raise DownloadError("yt-dlp reported success but no output file was found.")
70
+ return path, duration
71
+
72
+
73
+ def extract_audio(video_path: str, out_dir: str) -> str:
74
+ """Extract 16 kHz mono wav from ``video_path`` via ffmpeg; return the wav path."""
75
+ wav_path = os.path.join(out_dir, "audio.wav")
76
+ cmd = [
77
+ "ffmpeg", "-y", "-i", video_path,
78
+ "-vn", "-ac", "1", "-ar", "16000", "-f", "wav", wav_path,
79
+ ]
80
+ proc = subprocess.run(cmd, capture_output=True, text=True)
81
+ if proc.returncode != 0 or not os.path.exists(wav_path):
82
+ raise DownloadError(
83
+ "ffmpeg failed to extract audio. Is ffmpeg installed (packages.txt)?\n"
84
+ + proc.stderr[-600:]
85
+ )
86
+ return wav_path
87
+
88
+
89
+ def _blocked_hint(exc: Exception) -> str:
90
+ msg = str(exc)
91
+ lowered = msg.lower()
92
+ if any(k in lowered for k in ("sign in", "bot", "403", "429", "confirm you", "cookies")):
93
+ return (
94
+ "YouTube blocked this request (common from datacenter IPs like HF Spaces). "
95
+ "Add a Netscape cookie file as the Space secret YT_COOKIES and retry.\n"
96
+ f"Original error: {msg[:300]}"
97
+ )
98
+ return f"Download failed: {msg[:400]}"
pipeline/frames.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 5 + 6: candidate frames and the weighted screenshot indicator.
2
+
3
+ Flow:
4
+ * ``extract_candidates`` pulls a dense, downscaled grid of frames from the video in a
5
+ single ffmpeg pass, *before* the video is deleted.
6
+ * ``delete_video`` removes the source video (auto-delete once the transcript + frames
7
+ exist).
8
+ * ``select_screenshots`` runs *after* the LLM. For each step it computes a capture time
9
+ that blends the LLM's suggested timestamp with the Whisper segment timing of the text
10
+ the step quotes, then snaps that target to the nearest pre-extracted candidate frame.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import difflib
15
+ import glob
16
+ import os
17
+ import subprocess
18
+
19
+ # A 1 fps grid gives <=0.5 s snapping error. Capped so a long video can't explode disk.
20
+ GRID_FPS = 1.0
21
+ MAX_CANDIDATES = 1500
22
+
23
+
24
+ def extract_candidates(video_path: str, out_dir: str, duration: float) -> list[tuple[float, str]]:
25
+ """Extract a downscaled candidate-frame grid; return sorted ``[(time_s, path), ...]``."""
26
+ os.makedirs(out_dir, exist_ok=True)
27
+ eff_fps = GRID_FPS
28
+ if duration and duration * GRID_FPS > MAX_CANDIDATES:
29
+ eff_fps = MAX_CANDIDATES / duration # thin the grid for very long videos
30
+
31
+ pattern = os.path.join(out_dir, "cand_%06d.jpg")
32
+ cmd = [
33
+ "ffmpeg", "-y", "-i", video_path,
34
+ "-vf", f"fps={eff_fps:.6f},scale=-2:720",
35
+ "-q:v", "4", pattern,
36
+ ]
37
+ proc = subprocess.run(cmd, capture_output=True, text=True)
38
+ files = sorted(glob.glob(os.path.join(out_dir, "cand_*.jpg")))
39
+ if not files:
40
+ raise RuntimeError(
41
+ "ffmpeg extracted no candidate frames.\n" + proc.stderr[-600:]
42
+ )
43
+ # fps filter emits frame i (1-based) at ~ (i-1)/eff_fps seconds.
44
+ return [((i) / eff_fps, f) for i, f in enumerate(files)]
45
+
46
+
47
+ def delete_video(video_path: str) -> None:
48
+ """Delete the downloaded video (and any sibling temp media) if present."""
49
+ for p in (video_path,):
50
+ try:
51
+ if p and os.path.exists(p):
52
+ os.remove(p)
53
+ except OSError:
54
+ pass
55
+
56
+
57
+ def _clamp(x: float, lo: float, hi: float) -> float:
58
+ return max(lo, min(hi, x))
59
+
60
+
61
+ def best_match_segment(quote: str, t_llm: float, segs: list[dict]) -> dict:
62
+ """Find the Whisper segment a step refers to.
63
+
64
+ Prefer the segment whose text best matches ``quote``; if there's no decent textual
65
+ match, fall back to the segment nearest ``t_llm``.
66
+ """
67
+ quote = (quote or "").strip().lower()
68
+ if quote:
69
+ scored = [
70
+ (difflib.SequenceMatcher(None, quote, s["text"].lower()).ratio(), s)
71
+ for s in segs
72
+ ]
73
+ ratio, best = max(scored, key=lambda t: t[0])
74
+ if ratio >= 0.3:
75
+ return best
76
+ return min(segs, key=lambda s: abs((s["start"] + s["end"]) / 2 - t_llm))
77
+
78
+
79
+ def pick_time(step: dict, segs: list[dict], cand_times: list[float], *,
80
+ w_llm: float = 0.4, w_whisper: float = 0.6, lead: float = 1.0,
81
+ max_drift: float = 20.0) -> float:
82
+ """Weighted capture time, snapped to the nearest candidate frame.
83
+
84
+ ``t_whisper`` (segment mid + lead) is ground truth for *when* the referenced text is
85
+ spoken; ``t_llm`` is the model's guess of *what* matters. We blend them, but if the
86
+ LLM time drifts far from the matched segment we clamp it back into that segment so a
87
+ hallucinated timestamp can't drag the shot off-topic.
88
+ """
89
+ seg = best_match_segment(step.get("quote", ""), float(step.get("t_llm", 0.0)), segs)
90
+ t_whisper = (seg["start"] + seg["end"]) / 2 + lead
91
+ t_llm = float(step.get("t_llm", t_whisper))
92
+ if abs(t_llm - t_whisper) > max_drift:
93
+ t_llm = _clamp(t_llm, seg["start"], seg["end"])
94
+
95
+ total = w_llm + w_whisper or 1.0
96
+ t_target = (w_llm * t_llm + w_whisper * t_whisper) / total
97
+ return min(cand_times, key=lambda c: abs(c - t_target))
98
+
99
+
100
+ def select_screenshots(steps: list[dict], segs: list[dict],
101
+ candidates: list[tuple[float, str]], *,
102
+ w_llm: float = 0.4, w_whisper: float = 0.6, lead: float = 1.0,
103
+ max_shots: int = 8) -> dict[int, dict]:
104
+ """Return ``{step_index: {"time", "path"}}`` for the steps that get a screenshot.
105
+
106
+ When there are more steps than ``max_shots``, the highest-``importance`` steps win.
107
+ """
108
+ if not candidates:
109
+ return {}
110
+ cand_times = [t for t, _ in candidates]
111
+ time_to_path = dict(candidates)
112
+
113
+ order = sorted(range(len(steps)), key=lambda i: -float(steps[i].get("importance", 0.5)))
114
+ chosen = set(order[:max_shots])
115
+
116
+ out: dict[int, dict] = {}
117
+ for i, step in enumerate(steps):
118
+ if i not in chosen:
119
+ continue
120
+ t = pick_time(step, segs, cand_times, w_llm=w_llm, w_whisper=w_whisper, lead=lead)
121
+ out[i] = {"time": t, "path": time_to_path[t]}
122
+ return out
pipeline/search.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 1: search the top videos for a topic via the adarshajay/youtube-search Space.
2
+
3
+ The upstream Space exposes ``api_name="/youtube_search"``. It takes a single query
4
+ string and returns a single plain-text blob listing the top 5 results (title, URL and
5
+ video id only). We parse the ids/titles out of that text.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from gradio_client import Client
12
+
13
+ SEARCH_SPACE = "adarshajay/youtube-search"
14
+ SEARCH_API = "/youtube_search"
15
+
16
+ # 11-char YouTube ids as they appear in watch?v=... or youtu.be/... URLs.
17
+ _ID_RE = re.compile(r"(?:v=|youtu\.be/|/watch/|/embed/)([A-Za-z0-9_-]{11})")
18
+
19
+
20
+ def _parse_titles(text: str, ids: list[str]) -> dict[str, str]:
21
+ """Best-effort: map each id to a nearby title line in the result blob.
22
+
23
+ The upstream format is not guaranteed, so this is intentionally forgiving: for each
24
+ id we take the longest non-URL line that appears on or just before the line holding
25
+ the id. Anything we can't resolve falls back to the id itself in ``search_top5``.
26
+ """
27
+ lines = text.splitlines()
28
+ titles: dict[str, str] = {}
29
+ for vid in ids:
30
+ idx = next((i for i, ln in enumerate(lines) if vid in ln), None)
31
+ if idx is None:
32
+ continue
33
+ window = lines[max(0, idx - 1): idx + 1]
34
+ cands = [ln.strip(" -*\t") for ln in window if "http" not in ln and vid not in ln]
35
+ cands = [c for c in cands if c]
36
+ if cands:
37
+ titles[vid] = max(cands, key=len)
38
+ return titles
39
+
40
+
41
+ def search_top5(topic: str, max_results: int = 5) -> list[dict]:
42
+ """Return up to ``max_results`` unique videos for ``topic``.
43
+
44
+ Each item: ``{"video_id", "url", "title"}``. Raises RuntimeError if the upstream
45
+ Space is unreachable or returns no parseable video ids.
46
+ """
47
+ topic = (topic or "").strip()
48
+ if not topic:
49
+ raise ValueError("Please enter a topic to search for.")
50
+
51
+ try:
52
+ client = Client(SEARCH_SPACE)
53
+ raw = client.predict(topic, api_name=SEARCH_API)
54
+ except Exception as exc: # network / Space down / API renamed
55
+ raise RuntimeError(
56
+ f"Could not reach the search Space '{SEARCH_SPACE}'. It may be sleeping or "
57
+ f"its API changed. Details: {exc}"
58
+ ) from exc
59
+
60
+ text = raw if isinstance(raw, str) else str(raw)
61
+ ids = list(dict.fromkeys(_ID_RE.findall(text))) # preserve order, dedupe
62
+ if not ids:
63
+ raise RuntimeError(
64
+ "The search Space returned no recognizable YouTube video ids. Raw output:\n"
65
+ + text[:500]
66
+ )
67
+
68
+ ids = ids[:max_results]
69
+ titles = _parse_titles(text, ids)
70
+ return [
71
+ {
72
+ "video_id": vid,
73
+ "url": f"https://www.youtube.com/watch?v={vid}",
74
+ "title": titles.get(vid, vid),
75
+ }
76
+ for vid in ids
77
+ ]
pipeline/sentiment.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 2: rank candidate videos by the sentiment of their top comments.
2
+
3
+ We fetch comments with yt-dlp (no video download) and score them with the BERT text
4
+ classifier ``OmarMedhat7/youtube-sentiment-analysis-model`` running inside the Space.
5
+ The video with the highest positive share wins. Comment scraping is flaky, so any
6
+ single video that fails simply scores 0 and we never abort the whole ranking.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from functools import lru_cache
12
+
13
+ from yt_dlp import YoutubeDL
14
+
15
+ SENTIMENT_MODEL = "OmarMedhat7/youtube-sentiment-analysis-model"
16
+ MAX_COMMENTS = 30
17
+
18
+ # Label strings vary by model; treat anything that looks positive/negative accordingly.
19
+ _POSITIVE = {"positive", "pos", "label_2", "label_1"}
20
+ _NEGATIVE = {"negative", "neg", "label_0"}
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def _classifier():
25
+ """Lazily build and cache the text-classification pipeline (heavy import)."""
26
+ from transformers import pipeline
27
+
28
+ return pipeline(
29
+ "text-classification",
30
+ model=SENTIMENT_MODEL,
31
+ truncation=True,
32
+ max_length=256,
33
+ top_k=None, # return all label scores so we can read the dominant one
34
+ )
35
+
36
+
37
+ def _polarity(scores) -> float:
38
+ """Map one classifier output to a polarity in [-1, 1].
39
+
40
+ ``scores`` is a list of ``{"label", "score"}`` (top_k=None). Positive labels add,
41
+ negative labels subtract; neutral/unknown contribute nothing.
42
+ """
43
+ val = 0.0
44
+ for s in scores:
45
+ label = str(s["label"]).strip().lower()
46
+ if label in _POSITIVE:
47
+ val += s["score"]
48
+ elif label in _NEGATIVE:
49
+ val -= s["score"]
50
+ return val
51
+
52
+
53
+ def _fetch_comments(url: str, cookiefile: str | None) -> list[str]:
54
+ opts = {
55
+ "skip_download": True,
56
+ "getcomments": True,
57
+ "quiet": True,
58
+ "no_warnings": True,
59
+ "extractor_args": {"youtube": {"max_comments": [str(MAX_COMMENTS)], "comment_sort": ["top"]}},
60
+ }
61
+ if cookiefile:
62
+ opts["cookiefile"] = cookiefile
63
+ with YoutubeDL(opts) as ydl:
64
+ info = ydl.extract_info(url, download=False)
65
+ comments = info.get("comments") or []
66
+ texts = [c.get("text", "").strip() for c in comments if c.get("text")]
67
+ return texts[:MAX_COMMENTS]
68
+
69
+
70
+ def rank_by_sentiment(videos: list[dict], cookiefile: str | None = None,
71
+ progress=None) -> tuple[dict, list[dict]]:
72
+ """Score each video and return ``(best_video, scored)``.
73
+
74
+ ``scored`` mirrors ``videos`` with added keys: ``positive_share`` (0..1),
75
+ ``n_comments``, ``note``. Ordering of ``scored`` is by positive_share desc, with the
76
+ original search order as the tie-break / fallback.
77
+ """
78
+ if not videos:
79
+ raise ValueError("No videos to rank.")
80
+
81
+ clf = _classifier()
82
+ scored: list[dict] = []
83
+ for i, v in enumerate(videos):
84
+ if progress:
85
+ progress((i + 1) / len(videos), desc=f"Sentiment {i + 1}/{len(videos)}")
86
+ item = dict(v)
87
+ try:
88
+ comments = _fetch_comments(v["url"], cookiefile)
89
+ if comments:
90
+ results = clf(comments) # list aligned with comments (each = list of labels)
91
+ pols = [_polarity(r) for r in results]
92
+ positives = sum(1 for p in pols if p > 0)
93
+ item["positive_share"] = positives / len(pols)
94
+ item["n_comments"] = len(comments)
95
+ item["note"] = ""
96
+ else:
97
+ item["positive_share"] = 0.0
98
+ item["n_comments"] = 0
99
+ item["note"] = "no comments fetched"
100
+ except Exception as exc:
101
+ item["positive_share"] = 0.0
102
+ item["n_comments"] = 0
103
+ item["note"] = f"comment fetch failed: {type(exc).__name__}"
104
+ item["search_rank"] = i
105
+ scored.append(item)
106
+
107
+ # Best = highest positive share; ties and all-zero fall back to original search order.
108
+ scored.sort(key=lambda d: (-d["positive_share"], d["search_rank"]))
109
+ return scored[0], scored
pipeline/transcribe.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 4: timestamped transcription with faster-whisper (CPU/int8).
2
+
3
+ Returns a list of segments ``{"start", "end", "text"}`` in seconds. The model is cached
4
+ at module level so repeated runs in the same Space process don't reload weights.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from functools import lru_cache
10
+
11
+ # Small model keeps CPU transcription tractable on the free tier. Override with
12
+ # WHISPER_MODEL (e.g. "small", "tiny") via env if needed.
13
+ WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base")
14
+
15
+
16
+ @lru_cache(maxsize=1)
17
+ def _model():
18
+ from faster_whisper import WhisperModel
19
+
20
+ return WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
21
+
22
+
23
+ def transcribe(wav_path: str, progress=None) -> list[dict]:
24
+ """Transcribe ``wav_path`` and return timestamped segments."""
25
+ model = _model()
26
+ segments, info = model.transcribe(wav_path, vad_filter=True)
27
+
28
+ total = float(getattr(info, "duration", 0.0)) or None
29
+ segs: list[dict] = []
30
+ for s in segments: # generator -> streamed, so we can report progress
31
+ text = s.text.strip()
32
+ if text:
33
+ segs.append({"start": float(s.start), "end": float(s.end), "text": text})
34
+ if progress and total:
35
+ progress(min(s.end / total, 1.0), desc="Transcribing")
36
+
37
+ if not segs:
38
+ raise RuntimeError("Whisper produced an empty transcript (no speech detected).")
39
+ return segs
40
+
41
+
42
+ def transcript_text(segs: list[dict]) -> str:
43
+ """Render segments as ``[mm:ss] text`` lines for the LLM and the UI preview."""
44
+ lines = []
45
+ for s in segs:
46
+ m, sec = divmod(int(s["start"]), 60)
47
+ lines.append(f"[{m:02d}:{sec:02d}] {s['text']}")
48
+ return "\n".join(lines)
pipeline/tutorial.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 7: transform the timestamped transcript into a structured tutorial.
2
+
3
+ Uses an HF Inference Providers chat model (default DeepSeek-V3) billed to the user's
4
+ token. The model is asked for strict JSON so the downstream weighted indicator and the
5
+ .docx builder have stable fields to work with.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+
12
+ from huggingface_hub import InferenceClient
13
+
14
+ DEFAULT_LLM = "deepseek-ai/DeepSeek-V3"
15
+
16
+ # Rough char budget to stay clear of context limits on the free path. Long transcripts
17
+ # are truncated (with a marker); good enough for a tutorial summary.
18
+ MAX_TRANSCRIPT_CHARS = 24000
19
+
20
+ _SYSTEM = (
21
+ "You are a technical writer. You convert a timestamped video transcript into a clear, "
22
+ "step-by-step written tutorial. You ALWAYS respond with a single JSON object and no "
23
+ "prose outside it."
24
+ )
25
+
26
+ _INSTRUCTIONS = """\
27
+ Turn the transcript below into a tutorial. Return ONLY a JSON object with this schema:
28
+
29
+ {
30
+ "title": "string - concise tutorial title",
31
+ "intro": "string - 2-4 sentence overview",
32
+ "steps": [
33
+ {
34
+ "heading": "string - short step title",
35
+ "body": "string - 1-3 paragraphs explaining this step in your own words",
36
+ "quote": "string - a short, near-verbatim snippet (<=120 chars) copied from the "
37
+ "transcript line this step is based on, used to locate the moment",
38
+ "t_llm": number, // best timestamp IN SECONDS for an illustrative screenshot
39
+ "importance": number // 0..1, how worth screenshotting this step is
40
+ }
41
+ ]
42
+ }
43
+
44
+ Rules:
45
+ - 4 to 10 steps. Keep quotes copied from the transcript so they can be matched back.
46
+ - t_llm must be within the transcript's time range.
47
+ - Output valid JSON only. No markdown, no comments in the actual output.
48
+
49
+ Transcript (each line is "[mm:ss] text"):
50
+ ---
51
+ {transcript}
52
+ ---
53
+ """
54
+
55
+
56
+ def _extract_json(text: str) -> dict:
57
+ """Parse the model output into a dict, tolerating code fences / stray prose."""
58
+ text = text.strip()
59
+ if text.startswith("```"):
60
+ text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.DOTALL).strip()
61
+ try:
62
+ return json.loads(text)
63
+ except json.JSONDecodeError:
64
+ start, end = text.find("{"), text.rfind("}")
65
+ if start != -1 and end != -1 and end > start:
66
+ return json.loads(text[start:end + 1])
67
+ raise
68
+
69
+
70
+ def _normalize(data: dict) -> dict:
71
+ """Coerce/clean fields so downstream stages never crash on missing keys."""
72
+ steps = []
73
+ for s in data.get("steps", []) or []:
74
+ try:
75
+ t = float(s.get("t_llm", 0) or 0)
76
+ except (TypeError, ValueError):
77
+ t = 0.0
78
+ try:
79
+ imp = float(s.get("importance", 0.5) or 0.5)
80
+ except (TypeError, ValueError):
81
+ imp = 0.5
82
+ steps.append({
83
+ "heading": str(s.get("heading", "Step")).strip() or "Step",
84
+ "body": str(s.get("body", "")).strip(),
85
+ "quote": str(s.get("quote", "")).strip(),
86
+ "t_llm": max(0.0, t),
87
+ "importance": min(1.0, max(0.0, imp)),
88
+ })
89
+ if not steps:
90
+ raise RuntimeError("The LLM returned no usable steps.")
91
+ return {
92
+ "title": str(data.get("title", "Tutorial")).strip() or "Tutorial",
93
+ "intro": str(data.get("intro", "")).strip(),
94
+ "steps": steps,
95
+ }
96
+
97
+
98
+ def generate_tutorial(transcript: str, hf_token: str, model: str = DEFAULT_LLM) -> dict:
99
+ """Call the chat model and return a normalized ``{title, intro, steps}`` dict."""
100
+ if not hf_token:
101
+ raise ValueError("An HF token is required for the tutorial LLM (billed to your key).")
102
+
103
+ truncated = transcript[:MAX_TRANSCRIPT_CHARS]
104
+ if len(transcript) > MAX_TRANSCRIPT_CHARS:
105
+ truncated += "\n[... transcript truncated for length ...]"
106
+ prompt = _INSTRUCTIONS.replace("{transcript}", truncated)
107
+
108
+ client = InferenceClient(token=hf_token)
109
+ try:
110
+ resp = client.chat.completions.create(
111
+ model=model,
112
+ messages=[
113
+ {"role": "system", "content": _SYSTEM},
114
+ {"role": "user", "content": prompt},
115
+ ],
116
+ temperature=0.3,
117
+ max_tokens=4000,
118
+ )
119
+ except Exception as exc:
120
+ raise RuntimeError(f"Tutorial LLM call failed ({model}): {exc}") from exc
121
+
122
+ content = resp.choices[0].message.content or ""
123
+ try:
124
+ data = _extract_json(content)
125
+ except Exception as exc:
126
+ raise RuntimeError(
127
+ f"Could not parse JSON from the LLM. First 400 chars:\n{content[:400]}"
128
+ ) from exc
129
+ return _normalize(data)
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.44
2
+ gradio_client>=1.3
3
+ huggingface_hub>=0.27
4
+ yt-dlp>=2024.12.13
5
+ faster-whisper>=1.1.0
6
+ transformers>=4.46
7
+ torch>=2.2
8
+ python-docx>=1.1.2
9
+ Pillow>=10.4