Skip to content

Commit 3ff40a9

Browse files
nodeeeeeeclaude
andcommitted
Add user video↔slide matching with auto-detect fallback
- semantic_alignment: new --mapping option accepts a JSON file mapping caption stems to slide file paths. User-mapped pairs are used first; unmapped captions fall back to auto name/content matching. - gui.py Align page: new "Video ↔ Slide Matching" card that: - Scans course folder for all captions and slide files - Shows each video with a dropdown to select its slide file - "(none)" = auto-detect fallback - Saves mapping JSON and passes it to the align script - Priority order: user mapping → lecture number match → filename similarity → content embedding fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 95755d5 commit 3ff40a9

2 files changed

Lines changed: 185 additions & 21 deletions

File tree

gui.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1201,6 +1201,106 @@ def build_align(page: ft.Page, console: OutputConsole) -> ft.Column:
12011201
manual_out_f = _text_field("Output directory",
12021202
hint="blank = auto-inferred")
12031203

1204+
# ── Video ↔ Slide Matching state ─────────────────────────────────────────
1205+
match_rows_col = ft.Column(controls=[], spacing=6)
1206+
match_course_dd = _course_dropdown(
1207+
value=course_val["v"],
1208+
on_select=lambda e: course_val.update({"v": e.data}),
1209+
)
1210+
# Each row: {caption_stem, dropdown_value}
1211+
_match_state: dict = {"rows": [], "captions": [], "slide_options": []}
1212+
1213+
def _scan_matching(_) -> None:
1214+
"""Scan course folder for captions and slide files, build matching UI."""
1215+
cid = course_val["v"]
1216+
base = _get_output_dir() / cid
1217+
cap_dir = base / "captions"
1218+
mat_dir = base / "materials"
1219+
1220+
captions = sorted(cap_dir.glob("*.json")) if cap_dir.exists() else []
1221+
exts = {".pdf", ".pptx", ".ppt", ".docx", ".doc"}
1222+
slides = sorted([
1223+
p for p in mat_dir.rglob("*")
1224+
if p.is_file() and p.suffix.lower() in exts
1225+
and "image_cache" not in p.name
1226+
]) if mat_dir.exists() else []
1227+
1228+
if not captions:
1229+
console.write("No captions found. Transcribe videos first.", color=C_WARN)
1230+
return
1231+
1232+
# Build dropdown options: "(none)" + all slide files
1233+
slide_opts = [ft.dropdown.Option("(none)", "(none — auto-detect)")]
1234+
for sp in slides:
1235+
rel = str(sp.relative_to(base))
1236+
slide_opts.append(ft.dropdown.Option(rel, sp.name))
1237+
1238+
_match_state["captions"] = captions
1239+
_match_state["slide_options"] = slides
1240+
_match_state["rows"] = []
1241+
match_rows_col.controls.clear()
1242+
1243+
for cap in captions:
1244+
dd = ft.Dropdown(
1245+
options=list(slide_opts), # copy
1246+
value="(none)",
1247+
dense=True,
1248+
bgcolor=C_OUTPUT_BG,
1249+
border_color=C_PRIMARY,
1250+
text_size=11,
1251+
expand=True,
1252+
)
1253+
_match_state["rows"].append({"stem": cap.stem, "dropdown": dd})
1254+
match_rows_col.controls.append(
1255+
ft.Row(controls=[
1256+
ft.Text(cap.stem, size=11, width=250,
1257+
color=ft.Colors.WHITE, overflow=ft.TextOverflow.ELLIPSIS),
1258+
ft.Icon(ft.Icons.ARROW_FORWARD, size=14, color=C_PRIMARY),
1259+
dd,
1260+
], spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER)
1261+
)
1262+
1263+
page.update()
1264+
console.write(f"Found {len(captions)} caption(s), {len(slides)} slide file(s). "
1265+
"Select matching slides for each video, then click 'Align with mapping'.",
1266+
color=C_SUCCESS)
1267+
1268+
def _run_with_mapping(_) -> None:
1269+
"""Save the mapping and run alignment with it."""
1270+
cid = course_val["v"]
1271+
base = _get_output_dir() / cid
1272+
1273+
mapping: dict[str, list[str]] = {}
1274+
for row in _match_state["rows"]:
1275+
val = row["dropdown"].value
1276+
if val and val != "(none)":
1277+
mapping[row["stem"]] = [val]
1278+
1279+
if not mapping and not _match_state["rows"]:
1280+
console.write("Scan for videos first.", color=C_WARN)
1281+
return
1282+
1283+
# Save mapping JSON
1284+
mapping_file = base / "alignment" / "video_slide_mapping.json"
1285+
mapping_file.parent.mkdir(parents=True, exist_ok=True)
1286+
with open(mapping_file, "w") as f:
1287+
json.dump(mapping, f, indent=2)
1288+
1289+
n_mapped = len(mapping)
1290+
n_total = len(_match_state["rows"])
1291+
console.write(
1292+
f"Mapping saved: {n_mapped}/{n_total} video(s) matched to slides. "
1293+
f"Remaining {n_total - n_mapped} will use auto-detect.",
1294+
color=C_PRIMARY,
1295+
)
1296+
1297+
cmd = [PYTHON, str(SCRIPTS["align"]),
1298+
"--course", cid,
1299+
"--mapping", str(mapping_file)]
1300+
if out_dir_f.value.strip():
1301+
cmd += ["--out", out_dir_f.value.strip()]
1302+
console.run(cmd)
1303+
12041304
def _run_course(_) -> None:
12051305
cmd = [PYTHON, str(SCRIPTS["align"]), "--course", course_val["v"]]
12061306
if out_dir_f.value.strip():
@@ -1227,16 +1327,39 @@ def _run_manual(_) -> None:
12271327
ft.Icon(ft.Icons.INFO_OUTLINE, color=C_PRIMARY, size=15),
12281328
ft.Text(
12291329
f"Embed: {embed_model} Context: ±{ctx_sec}s "
1230-
"Content-based fallback if names don't match.",
1330+
"Match videos to slides, or let auto-detect find them.",
12311331
size=12,
12321332
color=ft.Colors.with_opacity(0.7, ft.Colors.WHITE),
12331333
),
12341334
], spacing=8)),
12351335

1336+
# ── Video ↔ Slide Matching card ──────────────────────────────────────
1337+
_card(ft.Column(controls=[
1338+
ft.Text("Video ↔ Slide Matching", size=13,
1339+
weight=ft.FontWeight.BOLD, color=ft.Colors.WHITE),
1340+
ft.Text("Match each video to its lecture slides. "
1341+
"Unmatched videos fall back to auto-detect.",
1342+
size=12, color=ft.Colors.with_opacity(0.6, ft.Colors.WHITE)),
1343+
ft.Container(height=6),
1344+
ft.Row(controls=[
1345+
ft.Column(controls=[_label("Course"), match_course_dd],
1346+
spacing=6, expand=True),
1347+
_run_btn("Scan", ft.Icons.SEARCH, _scan_matching),
1348+
], spacing=12, vertical_alignment=ft.CrossAxisAlignment.END),
1349+
ft.Container(height=4),
1350+
match_rows_col,
1351+
ft.Container(height=4),
1352+
ft.Row(controls=[
1353+
_run_btn("Align with mapping", ft.Icons.LINK, _run_with_mapping),
1354+
]),
1355+
], spacing=8)),
1356+
1357+
# ── Auto-discover card ───────────────────────────────────────────────
12361358
_card(ft.Column(controls=[
12371359
ft.Text("Auto-discover (whole course)", size=13,
12381360
weight=ft.FontWeight.BOLD, color=ft.Colors.WHITE),
1239-
ft.Text("Pairs all unaligned captions with matching slide files.",
1361+
ft.Text("Auto-pairs all captions with slides by name/content. "
1362+
"No user matching needed.",
12401363
size=12, color=ft.Colors.with_opacity(0.6, ft.Colors.WHITE)),
12411364
ft.Container(height=6),
12421365
ft.Row(controls=[

semantic_alignment.py

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,7 +1380,33 @@ def _content_match_slide_group(
13801380
return [best]
13811381

13821382

1383-
def process_course(course_id: int | str, use_jina: bool = False) -> None:
1383+
def _load_mapping(mapping_path: Path, course_dir: Path) -> dict[str, list[Path]]:
1384+
"""Load a user-supplied video↔slide mapping JSON.
1385+
1386+
Format: {"caption_stem": ["path/to/slide.pdf", ...], ...}
1387+
Paths are relative to course_dir or absolute.
1388+
Returns {caption_stem: [resolved_Path, ...]}.
1389+
"""
1390+
with open(mapping_path, encoding="utf-8") as f:
1391+
raw = json.load(f)
1392+
mapping: dict[str, list[Path]] = {}
1393+
for cap_stem, slide_list in raw.items():
1394+
resolved = []
1395+
for sp in slide_list:
1396+
p = Path(sp)
1397+
if not p.is_absolute():
1398+
p = course_dir / p
1399+
if p.exists():
1400+
resolved.append(p)
1401+
else:
1402+
print(f" [warn] Mapped slide not found: {sp}")
1403+
if resolved:
1404+
mapping[cap_stem] = resolved
1405+
return mapping
1406+
1407+
1408+
def process_course(course_id: int | str, use_jina: bool = False,
1409+
mapping_path: Path | None = None) -> None:
13841410
course_dir = COURSE_DATA_DIR / str(course_id)
13851411
captions_dir = course_dir / "captions"
13861412
print(f"Course dir : {course_dir}", flush=True)
@@ -1401,6 +1427,14 @@ def process_course(course_id: int | str, use_jina: bool = False) -> None:
14011427

14021428
print(f"Found {len(captions)} caption(s), {len(all_slides)} slide file(s).", flush=True)
14031429

1430+
# ── Load user-supplied mapping if provided ────────────────────────────────
1431+
user_mapping: dict[str, list[Path]] = {}
1432+
if mapping_path and mapping_path.exists():
1433+
user_mapping = _load_mapping(mapping_path, course_dir)
1434+
print(f" User mapping: {len(user_mapping)} video→slide pair(s) loaded")
1435+
for cap_stem, slides in user_mapping.items():
1436+
print(f" {cap_stem}{[s.name for s in slides]}")
1437+
14041438
# Check Jina API availability if requested
14051439
if use_jina:
14061440
jina_key = _get_jina_key()
@@ -1417,7 +1451,7 @@ def process_course(course_id: int | str, use_jina: bool = False) -> None:
14171451
if num is not None:
14181452
slides_by_num[num].append(sp)
14191453

1420-
embedder = None if use_jina else get_embedder()
1454+
embedder = None if use_jina else None # lazy-load only when needed
14211455

14221456
for cap in captions:
14231457
# Skip captions flagged as low-quality (wrong/empty recordings)
@@ -1430,20 +1464,24 @@ def process_course(course_id: int | str, use_jina: bool = False) -> None:
14301464
except Exception:
14311465
pass # can't read → proceed and let align() handle it
14321466

1433-
# Find matching slides
1434-
if embedder is None and not use_jina:
1435-
embedder = get_embedder()
1436-
1437-
slide_group = _find_best_slide_group(cap, slides_by_num, all_slides)
1438-
if not slide_group:
1439-
if embedder is None:
1440-
embedder = get_embedder()
1441-
slide_group = _content_match_slide_group(
1442-
cap, slides_by_num, all_slides, embedder
1443-
)
1444-
if not slide_group:
1445-
print(f" [warn] No matching slides for {cap.name} — skipping")
1446-
continue
1467+
# ── Priority 1: user-supplied mapping ────────────────────────────────
1468+
slide_group: list[Path] = []
1469+
if cap.stem in user_mapping:
1470+
slide_group = user_mapping[cap.stem]
1471+
print(f" [mapping] {cap.name}{[s.name for s in slide_group]}")
1472+
else:
1473+
# ── Priority 2: automatic name/number matching ───────────────────
1474+
slide_group = _find_best_slide_group(cap, slides_by_num, all_slides)
1475+
if not slide_group:
1476+
# ── Priority 3: content embedding fallback ───────────────────
1477+
if embedder is None:
1478+
embedder = get_embedder()
1479+
slide_group = _content_match_slide_group(
1480+
cap, slides_by_num, all_slides, embedder
1481+
)
1482+
if not slide_group:
1483+
print(f" [warn] No matching slides for {cap.name} — skipping")
1484+
continue
14471485

14481486
# Check if all output files for this group already exist
14491487
if len(slide_group) == 1:
@@ -1463,8 +1501,6 @@ def process_course(course_id: int | str, use_jina: bool = False) -> None:
14631501
continue
14641502
# Fall back to text-based on failure
14651503
print(" [jina] Falling back to text-based alignment")
1466-
if embedder is None:
1467-
embedder = get_embedder()
14681504

14691505
if embedder is None:
14701506
embedder = get_embedder()
@@ -1486,10 +1522,15 @@ def main() -> None:
14861522
help="Output directory (default: [course]/alignment)")
14871523
parser.add_argument("--jina", action="store_true",
14881524
help="Use Jina Embeddings v4 for multimodal (image+text) alignment")
1525+
parser.add_argument("--mapping", metavar="JSON",
1526+
help="User-supplied video↔slide mapping JSON file. "
1527+
"Format: {\"caption_stem\": [\"slide_path\", ...]}. "
1528+
"Unmapped captions fall back to auto-discovery.")
14891529
args = parser.parse_args()
14901530

14911531
if args.course:
1492-
process_course(args.course, use_jina=args.jina)
1532+
mapping = Path(args.mapping) if args.mapping else None
1533+
process_course(args.course, use_jina=args.jina, mapping_path=mapping)
14931534
return
14941535

14951536
if not args.caption or not args.slides:

0 commit comments

Comments
 (0)