Skip to content

Commit 0837a8a

Browse files
nodeeeeeeclaude
andcommitted
Merge OBJECT/SS screen video with DV audio for Panopto split-stream recordings
Some Panopto sessions store the screen-recording (OBJECT/SS) without an audio track and put the lecturer's microphone in a separate DV stream. Previously we'd reject the audio-less screen stream and download the DV camera video, which destroyed slide content for the frame extractor. We now classify each candidate as video-bearing or audio-bearing, prefer the screen stream as the video source, and merge the two streams via ffmpeg when they differ. Includes the test fixes for the dir_key-based section/image-cache API and a new regression suite covering the split-stream selector + the merge helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 088b974 commit 0837a8a

5 files changed

Lines changed: 240 additions & 21 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ anthropic_key.txt
1515
85397/
1616
85427/
1717
manifest.json
18+
# Stray notes generated when AUTONOTE_DATA_DIR isn't set land at the repo root
19+
*_notes.md
1820

1921
# ── Python ────────────────────────────────────────────────────────────────────
2022
__pycache__/

downloader.py

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,46 @@ def _hls_has_audio(stream_url: str, ff_bin: str | None = None) -> bool:
10221022
return True # don't block download on probe failure
10231023

10241024

1025+
def _run_ffmpeg_hls_merge(video_url: str, audio_url: str,
1026+
out_path: Path, progress_cb) -> None:
1027+
"""Merge a video-only HLS stream with an audio-only HLS stream.
1028+
1029+
Used when the screen-recording stream (OBJECT/SS) has no audio track and
1030+
the audio lives in a separate DV/AUDIO stream. Produces one MP4 with the
1031+
screen video and the lecturer's audio.
1032+
"""
1033+
ff_bin = _resolve_ffmpeg()
1034+
if not ff_bin:
1035+
raise RuntimeError(
1036+
"ffmpeg not found. Install ffmpeg or `pip install imageio-ffmpeg`."
1037+
)
1038+
cmd = [
1039+
ff_bin, "-y", "-loglevel", "error",
1040+
"-i", video_url,
1041+
"-i", audio_url,
1042+
"-map", "0:v:0", "-map", "1:a:0",
1043+
"-c", "copy",
1044+
"-shortest",
1045+
str(out_path),
1046+
]
1047+
try:
1048+
from ffmpeg_progress_yield import FfmpegProgress
1049+
ff = FfmpegProgress(cmd)
1050+
for pct in ff.run_command_with_progress():
1051+
progress_cb(pct)
1052+
if not out_path.exists() or out_path.stat().st_size < 1024:
1053+
raise RuntimeError(
1054+
f"ffmpeg produced no output for {out_path.name} — "
1055+
f"video/audio merge failed."
1056+
)
1057+
except ImportError:
1058+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
1059+
if result.returncode != 0:
1060+
tail = (result.stderr or "").strip().splitlines()[-10:]
1061+
raise RuntimeError(f"ffmpeg merge failed:\n" + "\n".join(tail))
1062+
progress_cb(100)
1063+
1064+
10251065
def _run_ffmpeg_hls(stream_url: str, out_path: Path, progress_cb) -> None:
10261066
"""Download an HLS master.m3u8 stream to *out_path* via ffmpeg.
10271067
@@ -1158,36 +1198,69 @@ def download_video(video: dict, manifest: dict, base_dir: Path) -> bool | None:
11581198
return False
11591199

11601200
# Record which tags existed so the frame extractor can tell a true camera
1161-
# recording from a DV-with-audio fallback whose OBJECT/SS screen stream
1162-
# was only rejected for lacking an audio track.
1201+
# recording from a split-stream case where the audio is in DV but the
1202+
# screen video is in OBJECT/SS.
11631203
available_tags = [ct for (_, _, ct) in candidates]
11641204
has_screen_stream = any(t in ("SS", "OBJECT") for t in available_tags)
11651205

1166-
# Probe each candidate for an audio track. Panopto screen-recording
1167-
# streams (SS/OBJECT) sometimes pack audio only in the DV variant, so
1168-
# we fall back through all available streams rather than trusting the
1169-
# first by tag.
1170-
stream_url = dl_headers = stream_tag = None
1206+
# Classify each candidate as "video-with-audio" or "video-only" up front.
1207+
# Panopto screen recordings often store SS/OBJECT (screen content, no
1208+
# audio) alongside DV (camera + audio). When both exist, we want the
1209+
# screen video AND the DV audio — merged by ffmpeg — so notes see slides
1210+
# as frames AND the transcript carries lecture audio.
1211+
video_cand = audio_cand = None # (url, headers, tag, is_m3u8)
11711212
tried = []
11721213
for (cu, ch, ct) in candidates:
11731214
tried.append(ct)
1174-
# Only HLS master playlists are audio-probable from here. Direct
1175-
# authenticated URLs (non-m3u8) go straight through.
1176-
if "master.m3u8" not in cu or _hls_has_audio(cu):
1177-
stream_url, dl_headers, stream_tag = cu, ch, ct
1178-
break
1179-
tqdm.write(f" Stream '{ct}' has no audio — trying next candidate")
1180-
if stream_url is None:
1215+
is_m3u8 = "master.m3u8" in cu
1216+
has_audio = (not is_m3u8) or _hls_has_audio(cu)
1217+
# Preferred screen stream (first one we see that is SS/OBJECT) becomes
1218+
# the chosen VIDEO source — regardless of whether it has audio.
1219+
if video_cand is None and ct in ("SS", "OBJECT"):
1220+
video_cand = (cu, ch, ct, is_m3u8, has_audio)
1221+
# First stream with audio becomes the audio source.
1222+
if audio_cand is None and has_audio:
1223+
audio_cand = (cu, ch, ct, is_m3u8, has_audio)
1224+
1225+
# If we never saw a screen stream, fall back to the audio-bearing stream
1226+
# (treat its video as the video source too).
1227+
if video_cand is None:
1228+
if audio_cand is None:
1229+
tqdm.write(
1230+
f" [error] No stream with audio found for {title} "
1231+
f"(tried: {', '.join(tried)}) — skipping; the recording "
1232+
f"itself appears to be video-only.")
1233+
manifest[key] = {
1234+
"status": "error", "title": title,
1235+
"error": "no-audio-track",
1236+
}
1237+
raise RuntimeError(f"No audio track in any Panopto stream for '{title}'")
1238+
video_cand = audio_cand
1239+
if audio_cand is None:
1240+
# Screen stream exists but nothing has audio — still skip.
11811241
tqdm.write(
1182-
f" [error] No stream with audio found for {title} "
1183-
f"(tried: {', '.join(tried)}) — skipping; the recording itself "
1184-
f"appears to be video-only.")
1242+
f" [error] Video streams exist but none has audio for {title} "
1243+
f"(tried: {', '.join(tried)}) — skipping.")
11851244
manifest[key] = {
11861245
"status": "error", "title": title,
11871246
"error": "no-audio-track",
11881247
}
11891248
raise RuntimeError(f"No audio track in any Panopto stream for '{title}'")
1190-
tqdm.write(f" Stream type: {stream_tag}")
1249+
1250+
merge_needed = (
1251+
video_cand[0] != audio_cand[0] # different stream URLs
1252+
and video_cand[3] and audio_cand[3] # both HLS master playlists
1253+
)
1254+
stream_url = video_cand[0]
1255+
dl_headers = video_cand[1]
1256+
stream_tag = video_cand[2]
1257+
audio_url = audio_cand[0] if merge_needed else None
1258+
audio_tag = audio_cand[2] if merge_needed else None
1259+
if merge_needed:
1260+
tqdm.write(
1261+
f" Stream type: {stream_tag} video + {audio_tag} audio (merging)")
1262+
else:
1263+
tqdm.write(f" Stream type: {stream_tag}")
11911264

11921265
bar = tqdm(total=100, desc=f" {title[:50]}", unit="%",
11931266
bar_format="{desc} |{bar}| {n:3d}/{total}%", leave=True)
@@ -1200,6 +1273,9 @@ def progress_cb(pct: float) -> None:
12001273
if dl_headers:
12011274
# Direct authenticated download (e.g. REST API DownloadUrl)
12021275
_download_authenticated(stream_url, out_path, dl_headers, progress_cb)
1276+
elif audio_url:
1277+
# Split-stream case: screen video (no audio) + separate audio stream.
1278+
_run_ffmpeg_hls_merge(stream_url, audio_url, out_path, progress_cb)
12031279
elif "master.m3u8" in stream_url:
12041280
# HLS stream — always use ffmpeg. PanoptoDownloader's HLS path
12051281
# is broken for URLs with query strings (its endswith('master.m3u8')

test/test_language_and_skip.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -365,18 +365,22 @@ class TestNoteGenerationSkipLogic:
365365

366366
def test_section_cache_respected(self, tmp_path):
367367
"""generate_section should return cached content without --force."""
368-
from note_generation import _section_path
368+
from note_generation import _section_path, LectureData
369369

370370
sections_dir = tmp_path / "sections"
371371
sections_dir.mkdir()
372372

373-
# Create a cached section file (must be > 500 bytes to pass cache check)
374-
sec_file = _section_path(sections_dir, 1, 1, 1)
373+
slide_path = tmp_path / "lec1.pdf"
374+
slide_path.write_bytes(b"%PDF-1.4 dummy")
375+
ld = LectureData(num=1, slide_path=slide_path, alignment_path=None)
376+
377+
sec_file = _section_path(sections_dir, ld, 1)
375378
content = "### 1.1 Cached Content\n\n" + ("This was previously generated. " * 30)
376379
sec_file.write_text(content)
377380

378381
assert sec_file.exists()
379382
assert sec_file.stat().st_size > 50
383+
assert ld.dir_key in sec_file.name
380384

381385
def test_help_shows_force_flag(self):
382386
r = _run("note_generation.py", "--help")

test/test_note_generation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ def _make_lecture_data(self, slides_with_text: list[tuple[int, str, str]]):
176176
ld = MagicMock()
177177
ld.num = 1
178178
ld.file_idx = 1
179+
ld.dir_key = "L01"
180+
ld.source = "slides"
179181
ld.img_cache = {}
180182
ld.slides = [
181183
ng.SlideInfo(idx, label, text)

test/test_v0_13_split_stream.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""
2+
Regression tests for the v0.13.0 Panopto split-stream download path.
3+
4+
When a Panopto recording stores screen video in OBJECT/SS (no audio) and the
5+
microphone audio in a separate DV stream, downloader.py must:
6+
• Prefer the screen-recording stream as the VIDEO source
7+
• Pick the first audio-bearing stream as the AUDIO source
8+
• Use ffmpeg to merge them into one MP4 (so the frame extractor sees slides
9+
AND the transcript sees lecture audio)
10+
"""
11+
from __future__ import annotations
12+
13+
import sys
14+
from pathlib import Path
15+
from unittest.mock import MagicMock, patch
16+
17+
import pytest
18+
19+
PROJECT_DIR = Path(__file__).parent.parent
20+
sys.path.insert(0, str(PROJECT_DIR))
21+
22+
23+
def _select_split(candidates, hls_audio_lookup):
24+
"""Reimplement downloader.py's video/audio classification.
25+
26+
Mirrors the logic in download_video so tests don't need to monkeypatch
27+
Panopto auth, ffprobe, or the progress bar. ``candidates`` is a list of
28+
(url, headers, tag) and ``hls_audio_lookup`` maps url → bool (has audio).
29+
Returns (video_cand, audio_cand, merge_needed).
30+
"""
31+
video_cand = audio_cand = None
32+
for cu, ch, ct in candidates:
33+
is_m3u8 = "master.m3u8" in cu
34+
has_audio = (not is_m3u8) or hls_audio_lookup.get(cu, True)
35+
if video_cand is None and ct in ("SS", "OBJECT"):
36+
video_cand = (cu, ch, ct, is_m3u8, has_audio)
37+
if audio_cand is None and has_audio:
38+
audio_cand = (cu, ch, ct, is_m3u8, has_audio)
39+
if video_cand is None and audio_cand is not None:
40+
video_cand = audio_cand
41+
merge = (
42+
video_cand is not None and audio_cand is not None
43+
and video_cand[0] != audio_cand[0]
44+
and video_cand[3] and audio_cand[3]
45+
)
46+
return video_cand, audio_cand, merge
47+
48+
49+
class TestSplitStreamClassification:
50+
"""Verify the OBJECT-video + DV-audio split-stream selector."""
51+
52+
def test_object_video_dv_audio_merges(self):
53+
cands = [
54+
("https://cdn/obj.master.m3u8", None, "OBJECT"),
55+
("https://cdn/dv.master.m3u8", None, "DV"),
56+
]
57+
audio = {
58+
"https://cdn/obj.master.m3u8": False, # screen has no audio
59+
"https://cdn/dv.master.m3u8": True, # camera carries audio
60+
}
61+
v, a, merge = _select_split(cands, audio)
62+
assert v[2] == "OBJECT"
63+
assert a[2] == "DV"
64+
assert merge is True
65+
66+
def test_ss_with_audio_no_merge(self):
67+
"""If the screen stream already has audio, no merge is needed."""
68+
cands = [
69+
("https://cdn/ss.master.m3u8", None, "SS"),
70+
("https://cdn/dv.master.m3u8", None, "DV"),
71+
]
72+
audio = {
73+
"https://cdn/ss.master.m3u8": True,
74+
"https://cdn/dv.master.m3u8": True,
75+
}
76+
v, a, merge = _select_split(cands, audio)
77+
assert v[2] == "SS"
78+
assert a[2] == "SS" # same stream → audio source is video source
79+
assert merge is False
80+
81+
def test_dv_only_camera_recording(self):
82+
"""No screen stream — fall back to DV as both video and audio."""
83+
cands = [("https://cdn/dv.master.m3u8", None, "DV")]
84+
audio = {"https://cdn/dv.master.m3u8": True}
85+
v, a, merge = _select_split(cands, audio)
86+
assert v[2] == "DV"
87+
assert a[2] == "DV"
88+
assert merge is False
89+
90+
def test_screen_without_audio_anywhere_returns_no_audio(self):
91+
"""OBJECT exists but no candidate has audio — caller should error."""
92+
cands = [
93+
("https://cdn/obj.master.m3u8", None, "OBJECT"),
94+
("https://cdn/dv.master.m3u8", None, "DV"),
95+
]
96+
audio = {
97+
"https://cdn/obj.master.m3u8": False,
98+
"https://cdn/dv.master.m3u8": False,
99+
}
100+
v, a, merge = _select_split(cands, audio)
101+
assert v is not None
102+
assert a is None
103+
assert merge is False
104+
105+
def test_direct_url_treated_as_audio_bearing(self):
106+
"""Non-HLS authenticated URLs skip the audio probe (always assumed audio)."""
107+
cands = [("https://cdn/dv.mp4?token=abc", {"Auth": "x"}, "DV")]
108+
v, a, merge = _select_split(cands, {})
109+
assert v[2] == "DV"
110+
assert merge is False
111+
assert v[4] is True # has_audio
112+
113+
114+
class TestMergeFunctionExists:
115+
"""The new merge helper must be importable with the right signature."""
116+
117+
def test_run_ffmpeg_hls_merge_signature(self):
118+
from downloader import _run_ffmpeg_hls_merge
119+
import inspect
120+
121+
sig = inspect.signature(_run_ffmpeg_hls_merge)
122+
params = list(sig.parameters.keys())
123+
assert params == ["video_url", "audio_url", "out_path", "progress_cb"]
124+
125+
def test_merge_raises_when_ffmpeg_missing(self, tmp_path):
126+
from downloader import _run_ffmpeg_hls_merge
127+
128+
with patch("downloader._resolve_ffmpeg", return_value=None):
129+
with pytest.raises(RuntimeError, match="ffmpeg not found"):
130+
_run_ffmpeg_hls_merge(
131+
"https://cdn/v.m3u8",
132+
"https://cdn/a.m3u8",
133+
tmp_path / "out.mp4",
134+
lambda pct: None,
135+
)

0 commit comments

Comments
 (0)