Skip to content

Commit 4489ab0

Browse files
nodeeeeeeclaude
andcommitted
Redesign video-slide matching UI for smooth user experience
Align page completely rewritten: - Single clean card with "Scan videos & slides" button - Auto-suggests matches: lecture number matching (Week3 → Lecture3), filename token overlap, with pre-filled dropdowns - Status indicators: green check for already-aligned videos, hollow circle for pending - Video titles from manifest (not raw caption filenames) - Loads and saves mapping persistently so users don't redo matches - Green border on matched dropdowns, muted border on unmatched - "Auto-align (skip matching)" button for fully automatic mode - Removed separate auto-discover and manual cards (merged into one) Pipeline page: - Auto-uses saved mapping.json if it exists when running alignment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fbe7dc1 commit 4489ab0

1 file changed

Lines changed: 161 additions & 104 deletions

File tree

gui.py

Lines changed: 161 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,8 +1029,12 @@ def _run(_):
10291029
[PYTHON, str(SCRIPTS["frame_extractor"]),
10301030
"--course", str(cid),
10311031
"--path", str(_get_output_dir())]))
1032-
cmds.append(("Align", [PYTHON, str(SCRIPTS["align"]),
1033-
"--course", str(cid)]))
1032+
# Use saved mapping if it exists
1033+
mapping_file = _get_output_dir() / str(cid) / "alignment" / "video_slide_mapping.json"
1034+
align_cmd = [PYTHON, str(SCRIPTS["align"]), "--course", str(cid)]
1035+
if mapping_file.exists():
1036+
align_cmd += ["--mapping", str(mapping_file)]
1037+
cmds.append(("Align", align_cmd))
10341038

10351039
if "generate" in steps:
10361040
c = [PYTHON, str(SCRIPTS["generate"]),
@@ -1213,33 +1217,47 @@ def _run(_) -> None:
12131217
def build_align(page: ft.Page, console: OutputConsole) -> ft.Column:
12141218
course_val = {"v": str(next(iter(COURSES), ""))}
12151219
course_dd = _course_dropdown(
1216-
value=course_val["v"],
1217-
# Bug fix: use e.data
1218-
on_select=lambda e: course_val.update({"v": e.data}),
1219-
)
1220-
out_dir_f = _text_field("Output directory",
1221-
hint="blank = [course]/alignment/")
1222-
caption_f = _text_field("Caption JSON path")
1223-
slides_f = _text_field("Slide file(s)",
1224-
hint="space-separated for multi-part")
1225-
manual_out_f = _text_field("Output directory",
1226-
hint="blank = auto-inferred")
1227-
1228-
# ── Video ↔ Slide Matching state ─────────────────────────────────────────
1229-
match_rows_col = ft.Column(controls=[], spacing=6)
1230-
match_course_dd = _course_dropdown(
12311220
value=course_val["v"],
12321221
on_select=lambda e: course_val.update({"v": e.data}),
12331222
)
1234-
# Each row: {caption_stem, dropdown_value}
1235-
_match_state: dict = {"rows": [], "captions": [], "slide_options": []}
1223+
1224+
# ── Video ↔ Slide Matching ───────────────────────────────────────────────
1225+
match_rows_col = ft.Column(controls=[], spacing=4)
1226+
match_status = ft.Text("", size=11, color=ft.Colors.with_opacity(0.6, ft.Colors.WHITE))
1227+
_match_state: dict = {"rows": [], "base": None}
1228+
1229+
def _auto_suggest(cap_stem: str, slides: list, base: Path) -> str:
1230+
"""Find the best auto-match for a caption using name similarity."""
1231+
import re as _re
1232+
cap_lower = cap_stem.lower().replace("-", " ").replace("_", " ")
1233+
cap_tokens = set(cap_lower.split())
1234+
best_score, best_rel = 0.0, "(none)"
1235+
for sp in slides:
1236+
sl = sp.stem.lower().replace("-", " ").replace("_", " ")
1237+
sl_tokens = set(sl.split())
1238+
if cap_tokens and sl_tokens:
1239+
score = len(cap_tokens & sl_tokens) / len(cap_tokens | sl_tokens)
1240+
if score > best_score:
1241+
best_score = score
1242+
best_rel = str(sp.relative_to(base))
1243+
# Also try lecture number matching
1244+
cap_num = _re.search(r"week\s*(\d+)|lec(?:ture)?\s*(\d+)|[Ll](\d+)", cap_stem)
1245+
sl_num = _re.search(r"[Ll](?:ecture)?\s*(\d+)", sp.stem)
1246+
if cap_num and sl_num:
1247+
cn = next(g for g in cap_num.groups() if g)
1248+
sn = sl_num.group(1)
1249+
if cn == sn:
1250+
best_rel = str(sp.relative_to(base))
1251+
best_score = 1.0
1252+
return best_rel if best_score > 0.05 else "(none)"
12361253

12371254
def _scan_matching(_) -> None:
12381255
"""Scan course folder for captions and slide files, build matching UI."""
12391256
cid = course_val["v"]
12401257
base = _get_output_dir() / cid
12411258
cap_dir = base / "captions"
12421259
mat_dir = base / "materials"
1260+
align_dir = base / "alignment"
12431261

12441262
captions = sorted(cap_dir.glob("*.json")) if cap_dir.exists() else []
12451263
exts = {".pdf", ".pptx", ".ppt", ".docx", ".doc"}
@@ -1253,93 +1271,139 @@ def _scan_matching(_) -> None:
12531271
console.write("No captions found. Transcribe videos first.", color=C_WARN)
12541272
return
12551273

1256-
# Build dropdown options: "(none)" + all slide files
1274+
# Load manifest for video titles
1275+
manifest: dict = {}
1276+
mf = DATA_DIR / "manifest.json"
1277+
if mf.exists():
1278+
try:
1279+
manifest = json.load(open(mf))
1280+
except Exception:
1281+
pass
1282+
1283+
# Load existing mapping if present
1284+
existing_mapping: dict = {}
1285+
mapping_file = align_dir / "video_slide_mapping.json"
1286+
if mapping_file.exists():
1287+
try:
1288+
existing_mapping = json.load(open(mapping_file))
1289+
except Exception:
1290+
pass
1291+
1292+
# Build dropdown options
12571293
slide_opts = [ft.dropdown.Option("(none)", "(none — auto-detect)")]
12581294
for sp in slides:
12591295
rel = str(sp.relative_to(base))
1260-
slide_opts.append(ft.dropdown.Option(rel, sp.name))
1296+
# Show relative path for clarity when there are subfolders
1297+
slide_opts.append(ft.dropdown.Option(rel, rel))
12611298

1262-
_match_state["captions"] = captions
1263-
_match_state["slide_options"] = slides
12641299
_match_state["rows"] = []
1300+
_match_state["base"] = base
12651301
match_rows_col.controls.clear()
12661302

1303+
# Header row
1304+
match_rows_col.controls.append(ft.Row(controls=[
1305+
ft.Text("Video", size=11, weight=ft.FontWeight.BOLD,
1306+
width=280, color=ft.Colors.with_opacity(0.5, ft.Colors.WHITE)),
1307+
ft.Container(width=14),
1308+
ft.Text("Lecture slides", size=11, weight=ft.FontWeight.BOLD,
1309+
color=ft.Colors.with_opacity(0.5, ft.Colors.WHITE), expand=True),
1310+
], spacing=8))
1311+
match_rows_col.controls.append(ft.Divider(
1312+
height=1, color=ft.Colors.with_opacity(0.1, ft.Colors.WHITE)))
1313+
12671314
for cap in captions:
1315+
# Determine video title from manifest
1316+
title = cap.stem
1317+
for entry in manifest.values():
1318+
if entry.get("status") == "done" and entry.get("title", "").replace("/", "_").replace(":", "_") == cap.stem:
1319+
title = entry["title"]
1320+
break
1321+
1322+
# Check alignment status
1323+
aligned = (align_dir / f"{cap.stem}.json").exists() if align_dir.exists() else False
1324+
1325+
# Pick initial value: existing mapping > auto-suggest > (none)
1326+
if cap.stem in existing_mapping:
1327+
initial = existing_mapping[cap.stem][0] if existing_mapping[cap.stem] else "(none)"
1328+
else:
1329+
initial = _auto_suggest(cap.stem, slides, base)
1330+
1331+
# Validate the initial value exists in options
1332+
valid_keys = {o.key for o in slide_opts}
1333+
if initial not in valid_keys:
1334+
initial = "(none)"
1335+
12681336
dd = ft.Dropdown(
1269-
options=list(slide_opts), # copy
1270-
value="(none)",
1337+
options=list(slide_opts),
1338+
value=initial,
12711339
dense=True,
12721340
bgcolor=C_OUTPUT_BG,
1273-
border_color=C_PRIMARY,
1341+
border_color=ft.Colors.GREEN_400 if initial != "(none)" else ft.Colors.with_opacity(0.3, ft.Colors.WHITE),
12741342
text_size=11,
12751343
expand=True,
1344+
content_padding=ft.Padding.symmetric(horizontal=8, vertical=2),
12761345
)
12771346
_match_state["rows"].append({"stem": cap.stem, "dropdown": dd})
1347+
1348+
# Status indicator
1349+
if aligned:
1350+
status_icon = ft.Icon(ft.Icons.CHECK_CIRCLE, size=14, color=ft.Colors.GREEN_400)
1351+
status_tip = ft.Tooltip(message="Already aligned", content=status_icon)
1352+
else:
1353+
status_icon = ft.Icon(ft.Icons.CIRCLE_OUTLINED, size=14,
1354+
color=ft.Colors.with_opacity(0.3, ft.Colors.WHITE))
1355+
status_tip = ft.Tooltip(message="Not yet aligned", content=status_icon)
1356+
12781357
match_rows_col.controls.append(
12791358
ft.Row(controls=[
1280-
ft.Text(cap.stem, size=11, width=250,
1359+
status_tip,
1360+
ft.Text(title, size=11, width=260,
12811361
color=ft.Colors.WHITE, overflow=ft.TextOverflow.ELLIPSIS),
1282-
ft.Icon(ft.Icons.ARROW_FORWARD, size=14, color=C_PRIMARY),
1362+
ft.Icon(ft.Icons.ARROW_FORWARD, size=12,
1363+
color=ft.Colors.with_opacity(0.4, ft.Colors.WHITE)),
12831364
dd,
1284-
], spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER)
1365+
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER)
12851366
)
12861367

1368+
n_auto = sum(1 for r in _match_state["rows"] if r["dropdown"].value != "(none)")
1369+
match_status.value = (
1370+
f"{len(captions)} video(s), {len(slides)} slide file(s). "
1371+
f"{n_auto} auto-matched."
1372+
)
12871373
page.update()
1288-
console.write(f"Found {len(captions)} caption(s), {len(slides)} slide file(s). "
1289-
"Select matching slides for each video, then click 'Align with mapping'.",
1290-
color=C_SUCCESS)
12911374

12921375
def _run_with_mapping(_) -> None:
1293-
"""Save the mapping and run alignment with it."""
1376+
"""Save the mapping and run alignment."""
12941377
cid = course_val["v"]
1295-
base = _get_output_dir() / cid
1378+
base = _match_state.get("base") or _get_output_dir() / cid
1379+
1380+
if not _match_state["rows"]:
1381+
console.write("Click 'Scan' first to discover videos and slides.", color=C_WARN)
1382+
return
12961383

12971384
mapping: dict[str, list[str]] = {}
12981385
for row in _match_state["rows"]:
12991386
val = row["dropdown"].value
13001387
if val and val != "(none)":
13011388
mapping[row["stem"]] = [val]
13021389

1303-
if not mapping and not _match_state["rows"]:
1304-
console.write("Scan for videos first.", color=C_WARN)
1305-
return
1306-
1307-
# Save mapping JSON
1390+
# Save mapping JSON (even if empty — records user's choice to auto-detect all)
13081391
mapping_file = base / "alignment" / "video_slide_mapping.json"
13091392
mapping_file.parent.mkdir(parents=True, exist_ok=True)
13101393
with open(mapping_file, "w") as f:
13111394
json.dump(mapping, f, indent=2)
13121395

13131396
n_mapped = len(mapping)
1314-
n_total = len(_match_state["rows"])
1315-
console.write(
1316-
f"Mapping saved: {n_mapped}/{n_total} video(s) matched to slides. "
1317-
f"Remaining {n_total - n_mapped} will use auto-detect.",
1318-
color=C_PRIMARY,
1319-
)
1397+
n_total = len(_match_state["rows"])
13201398

13211399
cmd = [PYTHON, str(SCRIPTS["align"]),
13221400
"--course", cid,
13231401
"--mapping", str(mapping_file)]
1324-
if out_dir_f.value.strip():
1325-
cmd += ["--out", out_dir_f.value.strip()]
13261402
console.run(cmd)
13271403

1328-
def _run_course(_) -> None:
1404+
def _run_auto(_) -> None:
1405+
"""Run auto-align without any user mapping."""
13291406
cmd = [PYTHON, str(SCRIPTS["align"]), "--course", course_val["v"]]
1330-
if out_dir_f.value.strip():
1331-
cmd += ["--out", out_dir_f.value.strip()]
1332-
console.run(cmd)
1333-
1334-
def _run_manual(_) -> None:
1335-
if not caption_f.value.strip() or not slides_f.value.strip():
1336-
console.write("Caption and slide path(s) are required.", color=C_WARN)
1337-
return
1338-
cmd = ([PYTHON, str(SCRIPTS["align"]),
1339-
"--caption", caption_f.value.strip(),
1340-
"--slides"] + slides_f.value.strip().split())
1341-
if manual_out_f.value.strip():
1342-
cmd += ["--out", manual_out_f.value.strip()]
13431407
console.run(cmd)
13441408

13451409
embed_model = _read_constant("align", "EMBED_MODEL")
@@ -1350,65 +1414,58 @@ def _run_manual(_) -> None:
13501414
_card(ft.Row(controls=[
13511415
ft.Icon(ft.Icons.INFO_OUTLINE, color=C_PRIMARY, size=15),
13521416
ft.Text(
1353-
f"Embed: {embed_model} Context: ±{ctx_sec}s "
1354-
"Match videos to slides, or let auto-detect find them.",
1417+
f"Embed: {embed_model} Context: ±{ctx_sec}s",
13551418
size=12,
13561419
color=ft.Colors.with_opacity(0.7, ft.Colors.WHITE),
13571420
),
13581421
], spacing=8)),
13591422

13601423
# ── Video ↔ Slide Matching card ──────────────────────────────────────
13611424
_card(ft.Column(controls=[
1362-
ft.Text("Video ↔ Slide Matching", size=13,
1363-
weight=ft.FontWeight.BOLD, color=ft.Colors.WHITE),
1364-
ft.Text("Match each video to its lecture slides. "
1365-
"Unmatched videos fall back to auto-detect.",
1366-
size=12, color=ft.Colors.with_opacity(0.6, ft.Colors.WHITE)),
1367-
ft.Container(height=6),
13681425
ft.Row(controls=[
1369-
ft.Column(controls=[_label("Course"), match_course_dd],
1370-
spacing=6, expand=True),
1371-
_run_btn("Scan", ft.Icons.SEARCH, _scan_matching),
1372-
], spacing=12, vertical_alignment=ft.CrossAxisAlignment.END),
1373-
ft.Container(height=4),
1374-
match_rows_col,
1426+
ft.Icon(ft.Icons.COMPARE_ARROWS, color=C_PRIMARY, size=18),
1427+
ft.Text("Video ↔ Slide Matching", size=14,
1428+
weight=ft.FontWeight.BOLD, color=ft.Colors.WHITE),
1429+
], spacing=8),
1430+
ft.Text("Match each video recording to its lecture slides. "
1431+
"Auto-suggested matches are pre-filled — adjust as needed. "
1432+
"Videos left on 'auto-detect' will be matched by content similarity.",
1433+
size=12, color=ft.Colors.with_opacity(0.6, ft.Colors.WHITE)),
13751434
ft.Container(height=4),
1376-
ft.Row(controls=[
1377-
_run_btn("Align with mapping", ft.Icons.LINK, _run_with_mapping),
1378-
]),
1379-
], spacing=8)),
13801435

1381-
# ── Auto-discover card ───────────────────────────────────────────────
1382-
_card(ft.Column(controls=[
1383-
ft.Text("Auto-discover (whole course)", size=13,
1384-
weight=ft.FontWeight.BOLD, color=ft.Colors.WHITE),
1385-
ft.Text("Auto-pairs all captions with slides by name/content. "
1386-
"No user matching needed.",
1387-
size=12, color=ft.Colors.with_opacity(0.6, ft.Colors.WHITE)),
1388-
ft.Container(height=6),
1436+
# Course selector + Scan button
13891437
ft.Row(controls=[
13901438
ft.Column(controls=[_label("Course"), course_dd],
13911439
spacing=6, expand=True),
1392-
ft.Column(controls=[_label("Output dir"), out_dir_f],
1393-
spacing=6, expand=True),
1394-
], spacing=16),
1440+
_run_btn("Scan videos & slides", ft.Icons.SEARCH, _scan_matching),
1441+
], spacing=12, vertical_alignment=ft.CrossAxisAlignment.END),
1442+
ft.Container(height=2),
1443+
match_status,
13951444
ft.Container(height=4),
1396-
_run_btn("Run auto-align", ft.Icons.AUTO_FIX_HIGH, _run_course),
1397-
], spacing=8)),
13981445

1399-
_card(ft.Column(controls=[
1400-
ft.Text("Manual (specific files)", size=13,
1401-
weight=ft.FontWeight.BOLD, color=ft.Colors.WHITE),
1402-
ft.Text("Align one caption to one or more slide files.",
1403-
size=12, color=ft.Colors.with_opacity(0.6, ft.Colors.WHITE)),
1404-
ft.Container(height=6),
1405-
caption_f,
1406-
slides_f,
1446+
# Matching rows (populated by _scan_matching)
1447+
match_rows_col,
1448+
1449+
# Action buttons
1450+
ft.Container(height=8),
14071451
ft.Row(controls=[
1408-
ft.Column(controls=[manual_out_f], expand=True),
1409-
_run_btn("Align", ft.Icons.LINK, _run_manual),
1410-
], spacing=12, vertical_alignment=ft.CrossAxisAlignment.END),
1411-
], spacing=8)),
1452+
ft.FilledButton(
1453+
"Align with mapping",
1454+
icon=ft.Icons.LINK,
1455+
on_click=_run_with_mapping,
1456+
style=ft.ButtonStyle(bgcolor=C_PRIMARY, color=ft.Colors.BLACK),
1457+
),
1458+
ft.OutlinedButton(
1459+
"Auto-align (skip matching)",
1460+
icon=ft.Icons.AUTO_FIX_HIGH,
1461+
on_click=_run_auto,
1462+
style=ft.ButtonStyle(
1463+
side=ft.BorderSide(1, ft.Colors.with_opacity(0.3, ft.Colors.WHITE)),
1464+
color=ft.Colors.with_opacity(0.7, ft.Colors.WHITE),
1465+
),
1466+
),
1467+
], spacing=12),
1468+
], spacing=6)),
14121469
]
14131470
return _page_layout(scroll_content)
14141471

0 commit comments

Comments
 (0)