Spaces:
Sleeping
Sleeping
Commit ·
f6a6455
1
Parent(s): 2931418
Add YouTube -> tutorial .docx pipeline (Gradio Space)
Browse filesSearch 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 +14 -0
- README.md +40 -1
- app.py +213 -0
- packages.txt +1 -0
- pipeline/__init__.py +6 -0
- pipeline/captions.py +68 -0
- pipeline/docx_builder.py +61 -0
- pipeline/download.py +98 -0
- pipeline/frames.py +122 -0
- pipeline/search.py +77 -0
- pipeline/sentiment.py +109 -0
- pipeline/transcribe.py +48 -0
- pipeline/tutorial.py +129 -0
- requirements.txt +9 -0
.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|