Skip to content

Commit 13dd1e7

Browse files
committed
Match Panopto stream tags case-insensitively + filter test patterns
Two production failures from the EE2022 run: 1. Six Wednesday lectures downloaded the lecture-hall webcam instead of the slide-recording. Root cause: Panopto's DeliveryInfo API returns stream Tag in either upper-case ("OBJECT") or lower-case ("object") depending on the session's recording profile. The downloader's preference order tuple ("SS","OBJECT","DV") was matched case-sensitively, so when tags were lower-case the OBJECT branch never fired and the code fell through to "first stream" — which is `dv` (the camera). Fix: normalise both sides to upper-case before comparing, in both _extract_streams (the source picker) and the later video/audio classification loop. 2. For some lectures the OBJECT stream itself contained a TV-style "No Signal" test pattern — the screen-capture HDMI input was disconnected at recording time, so the slide-projector content was never captured. Added the obvious test-pattern phrasings to _JUNK_DESC_RE so those frames get dropped during the vision-API filter pass instead of being embedded in notes as colour bars. 7 new regression tests: - 5 cover the tag-case normalisation (lower, upper, mixed, dv-only, untagged-fallthrough) - 2 cover the junk-regex extensions (matches no-signal patterns, leaves real slide descriptions alone)
1 parent 3ac2400 commit 13dd1e7

4 files changed

Lines changed: 157 additions & 6 deletions

File tree

downloader.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -830,20 +830,29 @@ def _extract_streams(body: dict) -> list[tuple[str, str]]:
830830
"""Return a list of (stream_url, tag) candidates in preference order.
831831
Caller can try each until one produces a video with an audio track —
832832
some Panopto recordings store audio only in the DV stream while SS /
833-
OBJECT are video-only for screen recordings."""
833+
OBJECT are video-only for screen recordings.
834+
835+
Tag matching is case-insensitive: Panopto returns either upper-case
836+
("OBJECT", "DV") or lower-case ("object", "dv") tags depending on
837+
the session's recording setup. A case-sensitive match would fall
838+
through to "any stream first" — which is DV (the camera), so the
839+
downloaded video would be a lecture-hall camera shot instead of
840+
the screen recording.
841+
"""
834842
streams = (body.get("Delivery") or {}).get("Streams") or []
835843
# Preference: SS (screen-share) > OBJECT (screen recording) > DV (camera).
836844
# We still yield *all* streams so the caller can fall back if the
837845
# preferred one lacks audio.
838-
order = _PREFER_STREAM_ORDER
846+
order = tuple(t.upper() for t in _PREFER_STREAM_ORDER)
839847
out: list[tuple[str, str]] = []
840848
seen_urls: set[str] = set()
841849
for tag in order + (None,):
842850
for s in streams:
843851
surl = s.get("StreamUrl", "")
844852
if not surl or surl in seen_urls:
845853
continue
846-
if tag is None or s.get("Tag") == tag:
854+
stream_tag = (s.get("Tag", "") or "").upper()
855+
if tag is None or stream_tag == tag:
847856
seen_urls.add(surl)
848857
out.append((surl, s.get("Tag", "unknown")))
849858
return out
@@ -1201,7 +1210,10 @@ def download_video(video: dict, manifest: dict, base_dir: Path) -> bool | None:
12011210
# recording from a split-stream case where the audio is in DV but the
12021211
# screen video is in OBJECT/SS.
12031212
available_tags = [ct for (_, _, ct) in candidates]
1204-
has_screen_stream = any(t in ("SS", "OBJECT") for t in available_tags)
1213+
# Tags from Panopto are sometimes lower-case ("object"/"dv"). Normalise
1214+
# before matching so the "screen-stream-first" preference still fires.
1215+
has_screen_stream = any((t or "").upper() in ("SS", "OBJECT")
1216+
for t in available_tags)
12051217

12061218
# Classify each candidate as "video-with-audio" or "video-only" up front.
12071219
# Panopto screen recordings often store SS/OBJECT (screen content, no
@@ -1214,9 +1226,10 @@ def download_video(video: dict, manifest: dict, base_dir: Path) -> bool | None:
12141226
tried.append(ct)
12151227
is_m3u8 = "master.m3u8" in cu
12161228
has_audio = (not is_m3u8) or _hls_has_audio(cu)
1229+
ct_upper = (ct or "").upper()
12171230
# Preferred screen stream (first one we see that is SS/OBJECT) becomes
12181231
# the chosen VIDEO source — regardless of whether it has audio.
1219-
if video_cand is None and ct in ("SS", "OBJECT"):
1232+
if video_cand is None and ct_upper in ("SS", "OBJECT"):
12201233
video_cand = (cu, ch, ct, is_m3u8, has_audio)
12211234
# First stream with audio becomes the audio source.
12221235
if audio_cand is None and has_audio:

electron/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "auto-note",
3-
"version": "1.0.7",
3+
"version": "1.0.8",
44
"description": "AutoNote — lecture notes generator from Canvas recordings",
55
"homepage": "https://github.com/nodeeeeee/Auto-Note",
66
"author": {

frame_extractor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ def _parse_ffmpeg_duration(stderr: str | None) -> float | None:
165165
r"cannot (describe|view|generate|provide|access)|"
166166
r"i'?m sorry|\bsorry\b|"
167167
r"abstract blue wave|swirling blue|"
168+
# TV-style "no signal" screens: when Panopto's screen-capture input
169+
# was disconnected at recording time, the OBJECT stream is just the
170+
# broadcast color-bar test pattern. These have no lecture content.
171+
r"(test (pattern|screen|card)|color bars|colour bars|"
172+
r"vertical (stripes|bars) (in (various|standard) colou?rs|of various colou?rs)|"
173+
r"(static|broadcast) (screen|signal)|no signal|no input)|"
168174
r"(humorous|funny|joke) (meme|image|comic|cartoon|scene|depiction|strip|illustration|individuals|panel)|"
169175
r"(meme|comic) (format|image|strip|panel)|"
170176
r"xkcd|four-panel comic|meme structure)\b",

test/test_panopto_stream_case.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""
2+
Regression tests for the v1.0.8 Panopto-stream case-sensitivity fix.
3+
4+
Some Panopto sessions return stream tags in upper-case (`OBJECT`, `DV`)
5+
while others use lower-case (`object`, `dv`). The downloader's preference
6+
order is `("SS","OBJECT","DV")`. Before v1.0.8 the comparison was
7+
case-sensitive, so when Panopto returned lower-case tags the OBJECT
8+
branch never matched and the code fell through to "first stream" — which
9+
is `dv` (the camera). Result: a lecture-hall webcam shot got downloaded
10+
instead of the slide-recording, and downstream notes embedded camera
11+
frames of the lecturer instead of slides.
12+
13+
Six EE2022 Wednesday lectures were affected in production:
14+
01/04, 04/03, 08/04, 11/03, 18/03, 25/03 — all dated 2026.
15+
"""
16+
from __future__ import annotations
17+
18+
import sys
19+
from pathlib import Path
20+
21+
PROJECT_DIR = Path(__file__).parent.parent
22+
sys.path.insert(0, str(PROJECT_DIR))
23+
24+
25+
def _build_extract(prefer_order=("SS", "OBJECT", "DV")):
26+
"""Reimplement downloader._extract_streams with the v1.0.8 fix so we
27+
can test it without a real Panopto delivery payload."""
28+
def _extract_streams(body):
29+
streams = (body.get("Delivery") or {}).get("Streams") or []
30+
order = tuple(t.upper() for t in prefer_order)
31+
out = []
32+
seen = set()
33+
for tag in order + (None,):
34+
for s in streams:
35+
surl = s.get("StreamUrl", "")
36+
if not surl or surl in seen:
37+
continue
38+
stream_tag = (s.get("Tag", "") or "").upper()
39+
if tag is None or stream_tag == tag:
40+
seen.add(surl)
41+
out.append((surl, s.get("Tag", "unknown")))
42+
return out
43+
return _extract_streams
44+
45+
46+
class TestStreamTagCaseInsensitive:
47+
def test_lowercase_object_picked_over_lowercase_dv(self):
48+
# The bug case: tags are lowercase. Before the fix this fell
49+
# through to "first stream" = dv (camera). After: OBJECT wins.
50+
body = {"Delivery": {"Streams": [
51+
{"Tag": "dv", "StreamUrl": "https://cdn/dv.m3u8"},
52+
{"Tag": "object", "StreamUrl": "https://cdn/obj.m3u8"},
53+
]}}
54+
result = _build_extract()(body)
55+
assert result[0] == ("https://cdn/obj.m3u8", "object")
56+
assert result[1] == ("https://cdn/dv.m3u8", "dv")
57+
58+
def test_uppercase_object_still_works(self):
59+
# Backward-compat: existing Panopto sessions returning upper-case
60+
# tags must continue to be matched. v1.0.8 normalises both sides
61+
# to upper-case before comparing.
62+
body = {"Delivery": {"Streams": [
63+
{"Tag": "DV", "StreamUrl": "https://cdn/dv.m3u8"},
64+
{"Tag": "OBJECT", "StreamUrl": "https://cdn/obj.m3u8"},
65+
]}}
66+
result = _build_extract()(body)
67+
assert result[0] == ("https://cdn/obj.m3u8", "OBJECT")
68+
69+
def test_mixed_case_doesnt_break_anything(self):
70+
body = {"Delivery": {"Streams": [
71+
{"Tag": "Dv", "StreamUrl": "https://cdn/dv.m3u8"},
72+
{"Tag": "Object", "StreamUrl": "https://cdn/obj.m3u8"},
73+
{"Tag": "Ss", "StreamUrl": "https://cdn/ss.m3u8"},
74+
]}}
75+
result = _build_extract()(body)
76+
# SS first (highest preference), then OBJECT, then DV
77+
assert result[0][1] == "Ss"
78+
assert result[1][1] == "Object"
79+
assert result[2][1] == "Dv"
80+
81+
def test_dv_only_camera_only_recording(self):
82+
# Some Panopto sessions only have a DV stream (no screen capture).
83+
# In that case DV must be returned as the only candidate.
84+
body = {"Delivery": {"Streams": [
85+
{"Tag": "dv", "StreamUrl": "https://cdn/dv.m3u8"},
86+
]}}
87+
result = _build_extract()(body)
88+
assert len(result) == 1
89+
assert result[0][1] == "dv"
90+
91+
def test_empty_tag_falls_through_to_any(self):
92+
# If Panopto returns a stream with no Tag at all, it should still
93+
# be returned (after preferred tags are exhausted).
94+
body = {"Delivery": {"Streams": [
95+
{"StreamUrl": "https://cdn/u1.m3u8"},
96+
{"Tag": "OBJECT", "StreamUrl": "https://cdn/obj.m3u8"},
97+
]}}
98+
result = _build_extract()(body)
99+
# OBJECT should still come first
100+
assert result[0][1] == "OBJECT"
101+
# Untagged stream still appears in the list
102+
assert any(t == "unknown" for _, t in result)
103+
104+
105+
class TestJunkDescRegexExtensions:
106+
def test_no_signal_test_pattern_caught(self):
107+
from frame_extractor import _JUNK_DESC_RE
108+
# The exact pathology from EE2022 06/03 Fri — Panopto recorded
109+
# a TV-style "No Signal" screen because the slide projector wasn't
110+
# plugged in at recording time.
111+
descs = [
112+
'The image displays a test pattern commonly used to indicate a "No Signal" status on television screens.',
113+
"The image is a classic test pattern used for television broadcasts, featuring vertical stripes of various colors.",
114+
"The image displays a static screen with vertical color bars in various shades.",
115+
"The image displays a test screen typically used for television signals, consisting of vertical stripes in various colors.",
116+
]
117+
for d in descs:
118+
assert _JUNK_DESC_RE.search(d), f"Should be junk: {d}"
119+
120+
def test_legitimate_slide_descriptions_not_caught(self):
121+
from frame_extractor import _JUNK_DESC_RE
122+
# These are real lecture slide descriptions that must NOT be
123+
# filtered — false positives would silently drop slides.
124+
legit = [
125+
'The slide features the title "Renewable Energy Integration".',
126+
"The slide shows a network configuration diagram featuring a laptop and a TV.",
127+
"The image shows a circuit diagram for a synchronous generator.",
128+
"Architecture drawing of a power transmission system with multiple transformers.",
129+
'The slide titled "Tutorial: 3-Phase Balanced Power" shows a table summarising key concepts.',
130+
]
131+
for d in legit:
132+
assert not _JUNK_DESC_RE.search(d), f"Wrongly junked: {d}"

0 commit comments

Comments
 (0)