@@ -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+
10251065def _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')
0 commit comments