@@ -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:
12131217def 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