Skip to content

Commit afa4e50

Browse files
nodeeeeeeclaude
andcommitted
Stream-aware audio pickup + screen detection for DV-fallback videos
Panopto sometimes packs audio only in the DV stream, not the SS/OBJECT screen stream we prefer. Two coordinated changes: downloader.py - _get_stream_candidates returns all stream variants in preference order - download_video probes each HLS master for audio, falls back to the next candidate when the preferred one is video-only, and raises (skipping the video) when no stream has audio. The manifest entry now records the stream_tag we downloaded plus available_tags and a has_screen_stream hint. frame_extractor.py - process_course skips the camera/screen classifier when the manifest says the recording had an SS/OBJECT stream available (via has_screen_stream), so DV-audio-fallback videos still get frame extraction. Also adds --force-screen as a manual override. pipeline_worker.py - --skip-frames flag so notes destined for --image-source slides don't burn vision tokens on per-frame descriptions that are never read. - GUI auto-enables skip-frames when image source = slides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 31dfbeb commit afa4e50

4 files changed

Lines changed: 177 additions & 32 deletions

File tree

downloader.py

Lines changed: 135 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,13 @@ def get_course_by_id(canvas: Canvas, course_id: int):
269269
# per-institution and sometimes per-course.
270270
_PANOPTO_TAB_TOOL_ID_FALLBACK = 128
271271

272+
# Stream-tag preference order. Can be overridden at runtime via
273+
# AUTONOTE_PREFER_STREAM or the --prefer-stream CLI flag. Default keeps the
274+
# existing screen-share-first behaviour, which is right for most lectures.
275+
_PREFER_STREAM_ORDER: tuple[str, ...] = tuple(
276+
os.environ.get("AUTONOTE_PREFER_STREAM", "SS,OBJECT,DV").split(",")
277+
)
278+
272279
# Per-course cache for resolved Panopto tool IDs.
273280
_PANOPTO_TOOL_ID_CACHE: dict[int, int] = {}
274281

@@ -781,33 +788,69 @@ def _download_authenticated(
781788
progress_cb(100)
782789

783790

791+
def _get_stream_candidates(
792+
session_id: str,
793+
cookies: list[dict],
794+
bearer_token: str | None = None,
795+
course_id: int | None = None,
796+
) -> list[tuple[str, dict, str]]:
797+
"""Return all HLS stream candidates for a Panopto session in preference
798+
order. Each item is (stream_url, auth_headers, stream_tag). Empty list on
799+
failure. download_video() probes each candidate for audio and uses the
800+
first one that has it."""
801+
singles = _get_stream_url(
802+
session_id, cookies,
803+
bearer_token=bearer_token, course_id=course_id,
804+
_return_all=True,
805+
)
806+
return singles or []
807+
808+
784809
def _get_stream_url(
785810
session_id: str,
786811
cookies: list[dict],
787812
bearer_token: str | None = None,
788813
course_id: int | None = None,
789-
) -> tuple[str, dict] | None:
814+
_return_all: bool = False,
815+
) -> tuple[str, dict] | list[tuple[str, dict, str]] | None:
790816
"""Get the HLS stream URL for a Panopto session.
791817
792818
Returns (stream_url, auth_headers, stream_tag) on success, None on failure.
819+
If _return_all=True, returns a list of all candidate tuples in preference
820+
order so callers can fall back (audio-less stream → try next).
821+
793822
auth_headers is always {} (HLS streams are CDN-public once the URL is known).
794823
stream_tag is the Panopto stream tag ("SS" for screen share, "DV" for camera, etc.).
795824
796825
Priority:
797826
1. DeliveryInfo.aspx POST with Bearer token → HLS master.m3u8
798827
2. Playwright LTI re-launch → navigate to Viewer.aspx → intercept DeliveryInfo
799828
"""
800-
def _extract_stream(body: dict) -> tuple[str, str] | None:
829+
def _extract_streams(body: dict) -> list[tuple[str, str]]:
830+
"""Return a list of (stream_url, tag) candidates in preference order.
831+
Caller can try each until one produces a video with an audio track —
832+
some Panopto recordings store audio only in the DV stream while SS /
833+
OBJECT are video-only for screen recordings."""
801834
streams = (body.get("Delivery") or {}).get("Streams") or []
802-
# Prefer screen content (SS > OBJECT) over camera (DV).
803-
# OBJECT streams contain the lecturer's screen recording (slides),
804-
# which is more valuable for note generation than the camera view.
805-
for tag in ("SS", "OBJECT", "DV", None):
835+
# Preference: SS (screen-share) > OBJECT (screen recording) > DV (camera).
836+
# We still yield *all* streams so the caller can fall back if the
837+
# preferred one lacks audio.
838+
order = _PREFER_STREAM_ORDER
839+
out: list[tuple[str, str]] = []
840+
seen_urls: set[str] = set()
841+
for tag in order + (None,):
806842
for s in streams:
807843
surl = s.get("StreamUrl", "")
808-
if surl and (tag is None or s.get("Tag") == tag):
809-
return surl, s.get("Tag", "unknown")
810-
return None
844+
if not surl or surl in seen_urls:
845+
continue
846+
if tag is None or s.get("Tag") == tag:
847+
seen_urls.add(surl)
848+
out.append((surl, s.get("Tag", "unknown")))
849+
return out
850+
851+
def _extract_stream(body: dict) -> tuple[str, str] | None:
852+
streams = _extract_streams(body)
853+
return streams[0] if streams else None
811854

812855
sess = requests.Session()
813856
for ck in cookies:
@@ -835,6 +878,10 @@ def _extract_stream(body: dict) -> tuple[str, str] | None:
835878
if r.status_code == 200:
836879
body = r.json()
837880
if not body.get("ErrorCode"):
881+
if _return_all:
882+
all_streams = _extract_streams(body)
883+
if all_streams:
884+
return [(u, {}, t) for (u, t) in all_streams]
838885
result = _extract_stream(body)
839886
if result:
840887
surl, stag = result
@@ -874,10 +921,14 @@ def _on_response(resp) -> None:
874921
if "DeliveryInfo.aspx" in resp.url and not captured:
875922
try:
876923
body = resp.json()
877-
result = _extract_stream(body)
878-
if result:
879-
surl, stag = result
880-
captured.append((surl, {}, stag))
924+
if _return_all:
925+
for surl, stag in _extract_streams(body):
926+
captured.append((surl, {}, stag))
927+
else:
928+
result = _extract_stream(body)
929+
if result:
930+
surl, stag = result
931+
captured.append((surl, {}, stag))
881932
except Exception:
882933
pass
883934

@@ -892,6 +943,8 @@ def _on_response(resp) -> None:
892943
finally:
893944
browser.close()
894945

946+
if _return_all:
947+
return list(captured) if captured else []
895948
return captured[0] if captured else None
896949

897950

@@ -941,6 +994,34 @@ def _try_imageio() -> str | None:
941994
return _try_imageio()
942995

943996

997+
def _hls_has_audio(stream_url: str, ff_bin: str | None = None) -> bool:
998+
"""Return True when an HLS master playlist references an audio track.
999+
Called before download so we can skip audio-less variants (some Panopto
1000+
recordings only pack audio in the DV stream, not OBJECT/SS)."""
1001+
if ff_bin is None:
1002+
ff_bin = _resolve_ffmpeg()
1003+
if not ff_bin:
1004+
return True # can't check — assume yes and let caller try
1005+
ffprobe = ff_bin.replace("/ffmpeg", "/ffprobe")
1006+
if not Path(ffprobe).exists():
1007+
# imageio-ffmpeg bundles only ffmpeg; probe via ffmpeg -i
1008+
probe = subprocess.run(
1009+
[ff_bin, "-hide_banner", "-i", stream_url],
1010+
capture_output=True, text=True, timeout=30,
1011+
)
1012+
return "Audio:" in (probe.stderr or "")
1013+
try:
1014+
r = subprocess.run(
1015+
[ffprobe, "-v", "error", "-select_streams", "a",
1016+
"-show_entries", "stream=codec_name",
1017+
"-of", "default=nw=1:nk=1", stream_url],
1018+
capture_output=True, text=True, timeout=30,
1019+
)
1020+
return bool((r.stdout or "").strip())
1021+
except Exception:
1022+
return True # don't block download on probe failure
1023+
1024+
9441025
def _run_ffmpeg_hls(stream_url: str, out_path: Path, progress_cb) -> None:
9451026
"""Download an HLS master.m3u8 stream to *out_path* via ffmpeg.
9461027
@@ -1066,16 +1147,46 @@ def download_video(video: dict, manifest: dict, base_dir: Path) -> bool | None:
10661147
manifest[key] = {"status": "error", "title": title}
10671148
return False
10681149

1069-
result = _get_stream_url(
1150+
candidates = _get_stream_candidates(
10701151
session_id, cookies,
10711152
bearer_token=bearer_token,
10721153
course_id=course_id if viewer_url else None,
10731154
)
1074-
if not result:
1155+
if not candidates:
10751156
tqdm.write(f" [error] Could not get stream URL")
10761157
manifest[key] = {"status": "error", "title": title}
10771158
return False
1078-
stream_url, dl_headers, stream_tag = result
1159+
1160+
# 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.
1163+
available_tags = [ct for (_, _, ct) in candidates]
1164+
has_screen_stream = any(t in ("SS", "OBJECT") for t in available_tags)
1165+
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
1171+
tried = []
1172+
for (cu, ch, ct) in candidates:
1173+
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:
1181+
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.")
1185+
manifest[key] = {
1186+
"status": "error", "title": title,
1187+
"error": "no-audio-track",
1188+
}
1189+
raise RuntimeError(f"No audio track in any Panopto stream for '{title}'")
10791190
tqdm.write(f" Stream type: {stream_tag}")
10801191

10811192
bar = tqdm(total=100, desc=f" {title[:50]}", unit="%",
@@ -1107,6 +1218,8 @@ def progress_cb(pct: float) -> None:
11071218
"status": "done", "path": str(out_path),
11081219
"title": title, "session_id": session_id,
11091220
"stream_tag": stream_tag,
1221+
"has_screen_stream": has_screen_stream,
1222+
"available_tags": available_tags,
11101223
}
11111224
return True
11121225
except Exception as e:
@@ -1588,7 +1701,12 @@ def main() -> None:
15881701

15891702
for i, video in enumerate(pending):
15901703
print(f"\n[{i+1}/{len(pending)}] {video['title']}")
1591-
result = download_video(video, manifest, base_dir)
1704+
try:
1705+
result = download_video(video, manifest, base_dir)
1706+
except RuntimeError as _exc:
1707+
tqdm.write(f" [skip] {_exc}")
1708+
_save_json(MANIFEST_FILE, manifest)
1709+
continue
15921710
_save_json(MANIFEST_FILE, manifest)
15931711
if args.transcribe and result is True:
15941712
_spawn_transcribe(manifest[str(video["item_id"])]["path"])

frame_extractor.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -976,7 +976,8 @@ def extract_and_align(
976976

977977
# ── Course-level auto-discovery ──────────────────────────────────────────────
978978

979-
def process_course(course_id: str, base_dir: Path) -> int:
979+
def process_course(course_id: str, base_dir: Path,
980+
force_screen: bool = False) -> int:
980981
"""Auto-discover videos for a course, classify them, and extract frames.
981982
982983
Reads the manifest to find all downloaded videos for the course.
@@ -1037,9 +1038,16 @@ def process_course(course_id: str, base_dir: Path) -> int:
10371038
print(f" [skip] No caption for: {video_stem} (transcribe first)")
10381039
continue
10391040

1040-
# For SS/OBJECT-tagged streams, skip classification (known screen content)
1041+
# Skip the camera/screen classifier when we know the source Panopto
1042+
# recording includes screen content:
1043+
# - stream_tag is SS or OBJECT (direct screen stream)
1044+
# - has_screen_stream is True (DV was picked only because OBJECT
1045+
# lacked an audio track; the screen content is still in the DV
1046+
# mix because Panopto PIP-composites it for DV playback).
10411047
tag = entry.get("stream_tag", "").upper()
1042-
skip_classify = (tag in ("SS", "OBJECT"))
1048+
has_screen = tag in ("SS", "OBJECT") or bool(
1049+
entry.get("has_screen_stream", False))
1050+
skip_classify = force_screen or has_screen
10431051
result = extract_and_align(video_path, caption_path, course_dir,
10441052
skip_classify=skip_classify,
10451053
stream_tag=tag)
@@ -1072,13 +1080,18 @@ def main() -> None:
10721080
parser.add_argument("--path", help="Base output directory (for --course mode)")
10731081
parser.add_argument("--threshold", type=float, default=SCENE_THRESHOLD,
10741082
help=f"Scene detection threshold (default: {SCENE_THRESHOLD})")
1083+
parser.add_argument("--force-screen", action="store_true",
1084+
help="Skip the camera/screen classifier and always "
1085+
"extract frames. Use when the classifier "
1086+
"mis-identifies a screen recording as camera "
1087+
"(e.g. mixed DV streams with picture-in-picture).")
10751088
args = parser.parse_args()
10761089

10771090
_update_threshold(args.threshold)
10781091

10791092
if args.course:
10801093
base_dir = Path(args.path) if args.path else COURSE_DATA_DIR
1081-
process_course(args.course, base_dir)
1094+
process_course(args.course, base_dir, force_screen=args.force_screen)
10821095
else:
10831096
video_path = Path(args.video)
10841097
if not video_path.exists():
@@ -1092,7 +1105,8 @@ def main() -> None:
10921105
else:
10931106
course_dir = video_path.parent.parent
10941107

1095-
extract_and_align(video_path, caption_path, course_dir)
1108+
extract_and_align(video_path, caption_path, course_dir,
1109+
skip_classify=args.force_screen)
10961110

10971111

10981112
def _update_threshold(val: float) -> None:

gui.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,12 +1038,15 @@ def _run(_):
10381038
if "transcribe" in steps:
10391039
cmds.append(("Transcribe", [PYTHON, str(SCRIPTS["transcribe"])]))
10401040

1041-
# Frame extraction for screen share videos (runs between transcribe and align)
1041+
# Frame extraction for screen share videos (runs between transcribe and align).
1042+
# Skip entirely when the user is planning to generate notes from slide
1043+
# PDFs — the frames and per-frame vision descriptions are never read.
10421044
if "align" in steps:
1043-
cmds.append(("Extract frames (screen share)",
1044-
[PYTHON, str(SCRIPTS["frame_extractor"]),
1045-
"--course", str(cid),
1046-
"--path", str(_get_output_dir())]))
1045+
if image_src_val["v"] != "slides":
1046+
cmds.append(("Extract frames (screen share)",
1047+
[PYTHON, str(SCRIPTS["frame_extractor"]),
1048+
"--course", str(cid),
1049+
"--path", str(_get_output_dir())]))
10471050
# Use saved mapping if it exists
10481051
mapping_file = _get_output_dir() / str(cid) / "alignment" / "video_slide_mapping.json"
10491052
align_cmd = [PYTHON, str(SCRIPTS["align"]), "--course", str(cid)]

pipeline_worker.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,15 +173,17 @@ def align_one(video: dict, force: bool) -> bool:
173173
return _run(cmd, f"Align: {video['stem']}")
174174

175175

176-
def pipeline_sequential(videos: list[dict], force: bool) -> None:
176+
def pipeline_sequential(videos: list[dict], force: bool,
177+
skip_frames: bool = False) -> None:
177178
"""Simple sequential pipeline: transcribe → frames → align per video."""
178179
for i, video in enumerate(videos, 1):
179180
print(f"\n{'═' * 60}")
180181
print(f" Video {i}/{len(videos)}: {video['stem']}")
181182
print(f"{'═' * 60}", flush=True)
182183

183184
transcribe_one(video, force)
184-
extract_frames_one(video)
185+
if not skip_frames:
186+
extract_frames_one(video)
185187

186188
# Alignment is best done course-level (batch BGE-M3 matching)
187189
if videos:
@@ -193,7 +195,8 @@ def pipeline_sequential(videos: list[dict], force: bool) -> None:
193195
_run(cmd, "Align all transcripts")
194196

195197

196-
def pipeline_threaded(videos: list[dict], force: bool) -> None:
198+
def pipeline_threaded(videos: list[dict], force: bool,
199+
skip_frames: bool = False) -> None:
197200
"""Threaded pipeline: transcribe and frame-extract/align overlap.
198201
199202
Thread 1 (transcriber): transcribes videos one at a time, pushes
@@ -223,6 +226,8 @@ def frame_processor():
223226
video = queue.get()
224227
if video is None:
225228
break
229+
if skip_frames:
230+
continue # frames disabled — drain the queue only
226231
print(f"\n Processing frames: {video['stem']}", flush=True)
227232
extract_frames_one(video)
228233

@@ -258,6 +263,11 @@ def main() -> None:
258263
help="Re-process even if output files exist")
259264
parser.add_argument("--sequential", action="store_true",
260265
help="Disable threading (debug mode)")
266+
parser.add_argument("--skip-frames", action="store_true",
267+
help="Skip frame extraction + per-frame vision "
268+
"description. Use this when you plan to generate "
269+
"notes with --image-source slides — the frames "
270+
"and their descriptions are never read.")
261271
args = parser.parse_args()
262272

263273
base_dir = Path(args.path) if args.path else COURSE_DATA_DIR
@@ -271,9 +281,9 @@ def main() -> None:
271281
print(f"Found {len(videos)} video(s) for course {args.course}.")
272282

273283
if args.sequential:
274-
pipeline_sequential(videos, args.force)
284+
pipeline_sequential(videos, args.force, skip_frames=args.skip_frames)
275285
else:
276-
pipeline_threaded(videos, args.force)
286+
pipeline_threaded(videos, args.force, skip_frames=args.skip_frames)
277287

278288
print(f"\n✓ Pipeline complete: {len(videos)} video(s) processed.")
279289

0 commit comments

Comments
 (0)