by drpwchen
Lecture recordings → structured grounded notes + a synced HTML viewer: video, timestamped transcript and curated summary on one page. Local GPU pipeline (Whisper ASR · slide extraction · OCR · VLM signals · capture-time alignment). Claude Code skill + plain CLI.
# Add to your Claude Code skills
git clone https://github.com/drpwchen/lecture-to-notesGuides for using cli tools skills like lecture-to-notes.
lecture-to-notes is an open-source cli tools skill for AI coding assistants such as Claude Code, Codex CLI, and ChatGPT, built by drpwchen. Lecture recordings → structured grounded notes + a synced HTML viewer: video, timestamped transcript and curated summary on one page. Local GPU pipeline (Whisper ASR · slide extraction · OCR · VLM signals · capture-time alignment). Claude Code skill + plain CLI. It has 55 GitHub stars.
lecture-to-notes's catalog security scan is still queued. You can run an instant dependency and prompt-injection check now with the "Scan for vulnerabilities" button above.
Clone the repository with "git clone https://github.com/drpwchen/lecture-to-notes" and add it to your Claude Code skills directory (see the Installation section above). lecture-to-notes ships a SKILL.md manifest, so compatible agents can discover and load it automatically.
lecture-to-notes is primarily written in Python. It is open-source under drpwchen on GitHub, so you can review or fork the full source.
Yes. SkillsLLM lists many other CLI Tools skills you can browse and compare side by side. Open the CLI Tools category from the badge at the top of this page, or use the Related Skills and comparison links further down to weigh lecture-to-notes against similar tools.
No comments yet. Be the first to share your thoughts!
Top skills in this category by stars
Unlocks once the catalog security scan passes (runs nightly).
The deep catalog scan for this skill is still queued. Run an instant dependency check now instead.
Turn a lecture recording (video or audio-only) into structured notes. Every heavy
stage runs locally at 0 Claude tokens; Claude only does the final synthesis. This
page is the map; detail lives in reference/, one topic per file.
| File | What is in it |
|---|---|
reference/pipeline.md |
Per-stage flags, thresholds, JSON schemas, timeouts, observability |
reference/note-spec.md |
Note quality spec, tier scoring, width table, synthesis prompt requirements |
reference/segmented-mode.md |
Multi-talk workshop folders → per-segment L2/L3 + Hub + web viewer |
reference/multi-camera.md |
One long recording + many phone clips/photos → one timeline |
reference/decisions.md |
Post-mortems, benchmarks, wrong turns, VRAM measurements |
transcribe_video.py exits without --lang. A wrong guess makes Whisper
hallucinate Chinese from accented English and the transcript is unusable.reference/decisions.md#asr-auto-correction. Do not add an auto-apply mode.transcribe_video.py auto-runs
retranscribe_segment.py --auto on detected token-collapse. If collapses
survive that, escalate (wider beam + --no-repeat-ngram-size + a glossary),
never skip.quick_text, Stage B2 clean_text, or pdf_text.json.
ocr.vlm_text is an always-empty compatibility field.--batch-size 4 with --beam-size 10==, and
never combine --beam-size 15 with sequential mode — that crashes
(0xC0000005). The measured sweet spot is --batch-size 3 --beam-size 10.slides_grounded.json exists. A single subagent bounces during the long
GPU waits and burns 30+ min of wall time per lecture.--engine groq. When unsure, ask; default to local.==Start here. route_inputs.py is the front door== — it classifies a folder and
prints the ordered commands plus the questions a human must answer. It is
plan-only: it never runs anything and never writes a file.
python <skill-dir>/scripts/route_inputs.py <material_dir> [--recursive] [--out-dir DIR] [--json]
| What is in the folder | Slide source | Route |
|---|---|---|
| Video, no deck | frames from the video | Path A — Steps 5–7 |
| Audio/video + PDF deck (==preferred==) | PDF text + page renders | Path B — build_slides_from_pdf.py |
| Audio/video + loose slide images (≥3) | the images themselves | Path B-images — build_slides_from_images.py |
| Audio only, no deck | none | Path C — transcript-only note |
| N-up handout PDF | cropped tiles | Path B-multi — crop_multiup_pdf.py first |
| Multi-talk workshop folder | per segment | reference/segmented-mode.md |
| One long recording + many phone clips/photos | per source | reference/multi-camera.md |
.pptx / .docx / .key |
— | convert to PDF yourself first; there is no conversion step here |
==Multi-source contract==: when two or more independent sources are present, run
media_capture_index.py --emit-alignment alignment.json first.
==Capture timestamps are HYPOTHESES; transcript cross-correlation
(xcorr_media_offsets.py) is EVIDENCE.== A source whose reliable flag is false
got its start from mtime or has none — it must not be aligned on. Nothing is ever
auto-corrected: a claimed-vs-measured disagreement >5 s is flagged
"conflict": true for a human to judge. Details in reference/multi-camera.md.
One command plus its purpose per step; flags, thresholds and outputs are in
reference/pipeline.md.
English / Mandarin / bilingual? Accented speakers? Code-switching mid-sentence? Use AskUserQuestion if the user has not said. HARD RULE 1.
One directory per lecture holds every intermediate; name it
{date}_{speaker}_{topic}, the shape finalize_to_vault.py parses.
python <skill-dir>/scripts/gpu_check.py --out-dir "$OUT_DIR" --min-free-mb 6000
Gate before transcription and again before Stage D. Exit 0 proceed, 1 warn
and proceed, 2 blocked — surface it, ==do not retry in a loop==.
→ reference/pipeline.md#gpu-check
python <skill-dir>/scripts/transcribe_video.py "<media>" \
--output-dir "$OUT_DIR" --lang <zh|en|bilingual|auto> \
--batch-size 3 --beam-size 10
Local faster-whisper by default; --engine groq is an optional offload (HARD
RULE 9). Default model alias is breeze25 (needs a local model dir); on a
machine without one, pass --model large-v3, which faster-whisper downloads.
Recordings over ~30 min go through the chunked runner instead.
→ reference/pipeline.md#transcription
python <skill-dir>/scripts/extract_slides.py "<video>" --output-dir "$OUT_DIR" --interval 15
Writes slides/frame_NNNN.jpg + slides/timestamps.json, phash-deduping adjacent
near-identical frames. Path B/B-images skip this. → reference/pipeline.md#stage-a
python <skill-dir>/scripts/quick_ocr.py "$OUT_DIR"
RapidOCR on every frame → slides_raw.json. ==Required==: without it every slide
looks decorative to the Stage D gate. → reference/pipeline.md#stage-b
python <skill-dir>/scripts/build_slides_from_pdf.py "$OUT_DIR" [--audio-duration-sec N]
python <skill-dir>/scripts/build_slides_from_images.py "<img_dir>" -o "$OUT_DIR" [--audio-duration-sec N]
Either bridge emits slides_raw.json + slides_dedup.json directly, replacing
Steps 5–7. → reference/pipeline.md#path-b
python <skill-dir>/scripts/dedup_semantic.py "$OUT_DIR"
Merges adjacent frames by text-subset or layout similarity, marks
dedup.is_canonical. Output slides_dedup.json.
→ reference/pipeline.md#stage-c
python <skill-dir>/scripts/ocr_surya.py "$OUT_DIR" [--resume]
Surya in its own venv on canonical text-bearing slides, RapidOCR as the shallow
fallback. Adds ocr.clean_text / ocr_engine / ocr_confidence. ==Updates
slides_dedup.json in place== (one-time backup slides_dedup.pre_b2.json) and
writes slides_ocr.json. Path B skips it — pdf_text is already clean. Without
a Surya venv it warns and routes everything to RapidOCR rather than failing.
→ reference/pipeline.md#stage-b2
python <skill-dir>/scripts/vlm_signals.py "$OUT_DIR" --model minicpm-v:8b --num-ctx 4096
Semantic signals per canonical slide, behind a 4-condition pre-skip gate for
decorative frames. Re-check the GPU first (Step 3). Output slides_vlm.json.
scripts/ocr_slides.py is a deprecated shim forwarding here, same argv and
outputs. → reference/pipeline.md#stage-d
python <skill-dir>/scripts/ground_slides.py "$OUT_DIR"
Pure Python, 0 LLM calls. Ties each canonical slide to the words spoken over it.
Output slides_grounded.json — the input to synthesis.
→ reference/pipeline.md#stage-e
python <skill-dir>/scripts/flag_asr_suspects.py --dir "$OUT_DIR"
Runs HERE, after Stage E: the slide glossary it needs comes from
slides_grounded.json. Writes asr_suspects.txt; ==the transcript is left
byte-identical==. Treat each line as a question, never a substitution.
→ reference/pipeline.md#asr-suspects
Over ~30 min / 25 k tokens of transcript, offload chunk summaries to a Sonnet
subagent instead of reading the whole transcript into main context. Coverage
guards ([CHUNK_END], [CONTINUE_NEEDED], expected-chunk count) are mandatory.
→ reference/pipeline.md#chunked-summarization
Two passes for batches and long lectures — ==Tier-pass then Write-pass==:
slides_grounded.json + transcript.txt +
pdf_text.json, applies the tier scoring rules, writes only
slides_final.json (integer tier, attachment_name, embed_width,
section_suggestion). This file is the frozen tier authority.slides_final.json + transcript +
slide text, writes note_draft.md with [[EMBED sN]] placeholders only — no
paths, widths or callouts.One pass is fine for one short lecture; splitting them stops the writer from
simplifying structure to make its own embed audit pass. → reference/note-spec.md
(mandatory: quality spec, tier rules, prompt requirements)
python <skill-dir>/scripts/render_embeds.py "$OUT_DIR" --note note_draft.md --in-place
python <skill-dir>/scripts/finalize_to_vault.py "$OUT_DIR" [--vault-root PATH]
python <skill-dir>/scripts/audit_note.py "<note path>" --mode lecture --grounding "$OUT_DIR"
render_embeds.py expands placeholders to col-0 callouts with path + width and
audits Tier-1/2 coverage; finalize_to_vault.py copies cited slides + the note
into the vault; the auditor is the gate. ==Always pass --grounding== — without
it the caption↔frame check only warns. → reference/note-spec.md
Draft review exemption: this output is machine-transcribed and synthesized —
write to the inbox without showing a draft; the user reviews in Obsidian.
python <skill-dir>/scripts/build_single_talk_web.py "$OUT_DIR" --plan seg_plan.json
# … Stage F writes the L3/ files … then:
python <skill-dir>/scripts/build_single_talk_web.py "$OUT_DIR" --plan seg_plan.json --export
A single talk gets the synced HTML viewer by default, not just workshops.
==Stage F authors the segment plan itself== (JSON list:
seg/start/end/slug/title_zh, derived from the transcript + slide topics — no
human segmentation needed); ==content sections = more segments, not more
headings==, the viewer builds exactly two chapters per segment. The script assembles manifest + segments.json + L2 slices + HUB
skeleton from transcript.json, and ==auto-appends an L3-only 全場總整理
overview segment== (sorted first — many segments still need one place listing
everything; the HUB never renders in the viewer). Stage F then writes one
L3_segNN_<slug>.md per segment (==image embeds by basename only, timecodes
`(V1 MM:SS)`==) plus the overview note — content shape picked by ==one
question: is this a single body of knowledge?== (single talk → pearls +
per-segment one-liners; same-lecturer arc → thematic reorganization with
sources; multi-speaker workshop → ==catalog only, never forced synthesis== —
spec in reference/pipeline.md#web-export); --export refuses while any L3
is missing.
Compression default is ==H.265 CRF 24== (self-use; --codec h264 when sharing
to machines you can't verify). Multi-talk workshops keep their own flow →
reference/segmented-mode.md. → reference/pipeline.md#web-export
# 逐段筆記 instead of
# 逐投影片筆記.crop_multiup_pdf.py <pdf> <out_dir> --expected-rows R --expected-cols C.
Pages that are genuinely 1-up (title pages) are handled per page, not forced
into the consensus grid.audio.wav.Everything below is ==this machine's setup, not a requirement== — nothing in the pipeline depends on any of it, and the generic alternative is inline.
| Used for | Generic alternative |
|---|---|
job_runner.py wrapping long GPU jobs (tree-kills children on timeout) |
plain timeout <n> <cmd>, or run in the foreground |
gpu_lease.py / a pause-flag file between concurrent batches |
run GPU stages one at a time; leave paths.pause_flag empty in config |
vault-search / OpenEvidence / Zotero lookups during synthesis |
skip; cite only what the lecture itself provided |
ntfy completion pings |
skip |
external batch control plane (run_queue, rerun_batch, clip_order, dashboard) |
course-specific, not shipped — see reference/segmented-mode.md |
Vault paths (99Attachment/lecture_{slug}, the inbox folder) are ==a private vault
convention== and configurable: render_embeds.py --attach-root / --attach-dir,
finalize_to_vault.py --vault-root.
ollama pull minicpm-v:8b # ~5.5 GB Q4_K_M, Stage D
pip install rapidfuzz rapidocr-onnxruntime scikit-image pyyaml json_repair pillow numpy
ffmpeg + ffprobe on PATH. Surya (Stage B2) lives in its own venv; point
ocr_engine.surya_python at it in config.yaml, or leave it blank to fall back
to RapidOCR. Copy config.example.yaml → config.yaml on a new machine; every
machine-specific value there is blank by default and env-overridable.
==Optional dependencies degrade loudly, not silently== — a missing scikit-image or
RapidOCR is reported and gated, because a silent degrade produced wrong output
rather than less output (reference/decisions.md#optional-dependency-degradation).
<skill-dir>/
├── SKILL.md, config.yaml, config.example.yaml
├── reference/ pipeline.md · note-spec.md · segmented-mode.md · multi-camera.md · decisions.md
├── data/ real_words.txt, real_acronyms.txt (regenerated, not committed)
├── ocr_bench/ engine benchmark harness (bring your own fixtures)
└── scripts/
├── route_inputs.py front door — classifies material, prints the plan
├── transcribe_video.py retranscribe_segment.py gpu_check.py groq_asr.py
├── extract_slides.py quick_ocr.py dedup_semantic.py ocr_surya.py
├── vlm_signals.py (ocr_slides.py = deprecated shim → here)
├── build_slides_from_pdf.py build_slides_from_images.py crop_multiup_pdf.py
├── ground_slides.py flag_asr_suspects.py make_glossary.py build_real_words.py
├── render_embeds.py finalize_to_vault.py audit_note.py export_web.py
├── media_capture_index.py xcorr_media_offsets.py query_near_field.py
├── adapters/ surya_adapter.py (production OCR adapters)
├── batch/ build_L1 · split_segments · split_L1_by_segment · add_dhash ·
│ vlm_cache · detect_language · detect_language_audio ·
│ phi_mask · process_slide_deck (generic batch layer)
├── layout2/ viewer.css, viewer.js (web viewer assets, edited verbatim)
└── _common.py _log.py _paths.py
Per-lecture output directory:
{lecture}/
├── metadata.json run_id + media fingerprint + per-stage status
├── transcript.json/.txt timestamped segments; .txt is [MM:SS] text, H:MM:SS past an hour
├── asr_suspects.txt flagged tokens — flags only, never a rewrite
├── alignment.json multi-source capture-start hypotheses (when applicable)
├── slides/ frame_NNNN.jpg | page_NN.jpg | original photo names
├── slides_raw.json Stage B quick text + density + entropy
├── slides_dedup.json Stage C canonical markers (Stage B2 updates in place)
├── slides_dedup.pre_b2.json one-time pre-Stage-B2 snapshot
├── slides_ocr.json Stage B2 Surya result, for inspection
├── slides_vlm.json Stage D VLM signals + vlm_skip + skip_metrics
├── slides_grounded.json Stage E transcript grounding + retrieval fields
├── slides_final.json Stage F tier + score + attachment_name + width
└── logs/progress_*.jsonl per-stage event streams (not every stage emits one)
Each stage output is a superset of the previous, so you can re-run one stage
without redoing transcription or frame extraction. runs.jsonl one level up
carries one summary line per stage run, joined by run_id. Keep the intermediates
— they are how tier decisions get debugged.
English · 繁體中文

Turn a lecture or conference recording into structured, slide-illustrated notes — and into a synced HTML viewer where the video, the timestamped transcript, and the curated summary sit on one page: the video highlights the matching note as it plays, and clicking any note timestamp seeks the video.
Every expensive stage runs locally: Whisper ASR on your GPU, frame extraction, OCR, and a local vision model for slide semantics. An LLM is used only at the end, to write prose from evidence the pipeline already assembled.
The design goal is not "summarize a video". It is traceability: every claim in the finished note should be attachable to a moment in the transcript and to the slide that was on screen at that moment. Most of the machinery here exists to make that link trustworthy rather than plausible.
Rehabilitation-medicine courses are notoriously hard to take notes on. Manual therapy, ultrasound scanning — the knowledge is in the motion, so at every course you see a forest of tripods: everyone records everything, planning to rewatch over dinner. Nobody actually rewatches hours of video.
With AI the first instinct was to teach it precise screenshotting, to squeeze the video back into a traditional text-plus-figures note. The turn was realizing a note doesn't have to be that shape at all: build a webpage that ties the video to the transcript, and you can jump straight to the moment you care about. What you actually want to know is how the maneuver is performed — and that only lives in motion. So the summary is for studying, the transcript is for verifying, and the video is one click away from both.
Real courses never produce one tidy recording. Some talks you filmed, some you
only audio-recorded, for some you photographed the slides with your phone, and
sometimes the organizer hands out a PDF deck afterwards. This pipeline takes
the folder as-is: route_inputs.py figures out what role each file plays,
and the alignment stage stitches every source onto one shared timeline —
so the finished note cites the right slide at the right moment even when that
slide never appeared inside the video.
flowchart TD
IN["material folder<br/>(video / audio / PDF deck / slide images)"]
IN --> R["route_inputs.py<br/><i>classifies the folder, prints the plan</i>"]
R --> T["transcribe_video.py<br/>faster-whisper, local GPU"]
R --> S{"slide source"}
S -->|video only| A["extract_slides.py<br/>frames + perceptual dedup"]
S -->|PDF deck| P["build_slides_from_pdf.py<br/>page render + embedded text"]
S -->|loose images| I["build_slides_from_images.py"]
A --> B["quick_ocr.py<br/>Stage B — cheap OCR triage"]
B --> C["dedup_semantic.py<br/>Stage C — canonical slides"]
C --> B2["ocr_surya.py<br/>Stage B2 — high-quality OCR"]
P --> D
I --> D
B2 --> D["vlm_signals.py<br/>Stage D — slide semantics (local VLM)"]
D --> E["ground_slides.py<br/>Stage E — tie slides to spoken words"]
T --> E
E --> F["Stage F — synthesis (LLM)<br/>tier pass, then write pass"]
F --> O["render_embeds.py → finalize_to_vault.py → audit_note.py"]
F --> W["export_web.py<br/><b>synced HTML viewer</b> — video + transcript + summary on one page"]
Stages A–E are plain Python and cost zero LLM tokens. They produce
slides_grounded.json: for each canonical slide, its OCR text, its semantic
signals, and the transcript segments spoken while it was on screen. That file is
the input to synthesis, and it is also readable on its own — if you never run
Stage F you still have a transcript, a deduplicated slide set, and the mapping
between them.
The signature output. export_web.py builds a single self-contained HTML
page per course where the video, the timestamped transcript, and the curated
summary notes are presented together and kept in sync both ways:
(Vn MM:SS) timestamp — click it
and the video seeks to that moment..html plus one support folder (browser-ready
clips, friendly-named slides, markdown + PDF copies). No server, no build
step for the reader — send the folder, they double-click the page.
--compress produces a smaller H.264 set for handing around.Real output from a cervical-ultrasound workshop (dates and speaker names blurred):

| Course hub — every segment with duration and a one-line hook | Summary view — per-video source table and key pearls |
|---|---|
![]() |
![]() |
The viewer UI lives in scripts/layout2/ (viewer.css, viewer.js);
export_web.py only generates the per-course timeline manifest and the synced
note HTML, so a UI tweak is an asset edit, not a generator change.
The HTML viewer is the reading surface; plain markdown is the storage format, so everything also lands in your note vault:
finalize_to_vault.py ships the finished note plus its cited slide images
into an Obsidian-style vault (attachment folder and inbox are configurable
flags — any folder of markdown works).markdown/ copy of every transcript
and summary, wikilink-rewritten so the folder itself opens directly as an
Obsidian vault — same content as the webpage, readable and searchable in
your normal note workflow, plus PDF copies for people who want neither.So you get three durable forms of the same lecture: the synced webpage for studying, markdown for your knowledge base, PDF for handing to anyone.
The part worth stealing, if you take nothing else.
When a talk is captured by more than one device — a room recording plus phone clips plus photos — you need a shared timeline. The obvious approach is to trust each file's capture timestamp. That approach is wrong often enough to matter: phone clocks drift, some files only have an mtime, and a recording that was stopped and restarted lies about its own start.
So this pipeline separates the two:
media_capture_index.py reads capture times and emits them as claims,
each with a reliable flag. A source whose start came from mtime, or has no
start at all, is marked unreliable and must not be aligned on.xcorr_media_offsets.py measures the actual offset by cross-correlating
transcripts of the overlapping audio. That is evidence."conflict": true and stops. It never auto-corrects.The failure this prevents is a 44-minute misalignment that looks completely normal in the output, because every downstream stage faithfully processes the wrong pairing. A pipeline that silently reconciles conflicting evidence produces confident garbage; one that flags the conflict produces a question.
Honest version:
scripts/groq_asr.py
sends compressed audio to Groq's whisper-large-v3-turbo (free tier
works; the 25 MB request cap is handled by chunking) and returns the same
segment format as the local path. Read its docstring first: hosted Whisper
has no anti-collapse knobs for heavily code-switched audio, and anything
you send leaves your machine — don't route confidential recordings there.ffmpeg and ffprobe on PATH — not optional.minicpm-v:8b for Stage D slide
semantics; a separate venv with Surya
for high-quality OCR; pandoc for the web/PDF export.On an 8 GB card, GPU stages must be serialized — Whisper and the VLM cannot run
concurrently, and frame extraction must not run during transcription. The
measured sweet spot for transcription is --batch-size 3 --beam-size 10;
--beam-size 15 with sequential mode crashes.
git clone https://github.com/drpwchen/lecture-to-notes
cd lecture-to-notes
pip install -r requirements.txt
pip install -r requirements-optional.txt # recommended
cp config.example.yaml config.yaml # every value is