Skip to content

Commit 8485686

Browse files
nodeeeeeeclaude
andcommitted
Redesign video-slide matching UI for smooth user experience
Electron app (the actual GUI users see): - Align page: new "Video ↔ Slide Matching" card replaces old auto-discover + manual cards - Scan button discovers all captions and slide files via IPC - Each video shown with title from manifest, × to remove, dropdown pre-filled with auto-suggested match - + button to add multiple slide files per video (multi-part) - − button to remove extra slide entries - Status indicators (● aligned, ○ pending) - Mapping saved persistently and loaded on re-scan - "Auto-align (skip matching)" button for fully automatic mode Backend (main.js): - New IPC handlers: align:scan (discover captions/slides/mapping), align:saveMapping (persist mapping JSON) - frame_extractor.py added to SCRIPTS dict and extraResources Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b265418 commit 8485686

5 files changed

Lines changed: 271 additions & 50 deletions

File tree

electron/main.js

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@ function scriptPath(name) {
5858
}
5959

6060
const SCRIPTS = {
61-
downloader: scriptPath('downloader.py'),
62-
transcribe: scriptPath('extract_caption.py'),
63-
align: scriptPath('semantic_alignment.py'),
64-
generate: scriptPath('note_generation.py'),
61+
downloader: scriptPath('downloader.py'),
62+
transcribe: scriptPath('extract_caption.py'),
63+
frame_extractor: scriptPath('frame_extractor.py'),
64+
align: scriptPath('semantic_alignment.py'),
65+
generate: scriptPath('note_generation.py'),
6566
};
6667

6768
const ML_PACKAGES = [
@@ -781,6 +782,77 @@ function registerIpc() {
781782
ipcMain.handle('path:dataDir', () => DATA_DIR);
782783
ipcMain.handle('course:listLectures', (_, cid) => discoverLectures(cid));
783784

785+
// ── Align: scan captions + slides, load/save mapping ───────────────────
786+
ipcMain.handle('align:scan', (_, cid) => {
787+
const outDir = getOutputDir();
788+
const base = path.join(outDir, String(cid));
789+
const capDir = path.join(base, 'captions');
790+
const matDir = path.join(base, 'materials');
791+
const alignDir = path.join(base, 'alignment');
792+
const exts = new Set(['.pdf', '.pptx', '.ppt', '.docx', '.doc']);
793+
794+
// Captions
795+
let captions = [];
796+
if (fs.existsSync(capDir)) {
797+
captions = fs.readdirSync(capDir)
798+
.filter(f => f.endsWith('.json'))
799+
.sort()
800+
.map(f => {
801+
const stem = f.replace(/\.json$/, '');
802+
const aligned = fs.existsSync(path.join(alignDir, f));
803+
return { stem, filename: f, aligned };
804+
});
805+
}
806+
807+
// Slides (recursive)
808+
const slides = [];
809+
function walkDir(dir, rel) {
810+
if (!fs.existsSync(dir)) return;
811+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
812+
if (entry.isDirectory()) {
813+
walkDir(path.join(dir, entry.name), rel ? rel + '/' + entry.name : entry.name);
814+
} else if (exts.has(path.extname(entry.name).toLowerCase()) && !entry.name.includes('image_cache')) {
815+
const relPath = rel ? 'materials/' + rel + '/' + entry.name : 'materials/' + entry.name;
816+
slides.push({ name: entry.name, rel: relPath });
817+
}
818+
}
819+
}
820+
walkDir(matDir, '');
821+
822+
// Video titles from manifest
823+
const titles = {};
824+
const mf = path.join(DATA_DIR, 'manifest.json');
825+
if (fs.existsSync(mf)) {
826+
try {
827+
const manifest = JSON.parse(fs.readFileSync(mf, 'utf8'));
828+
for (const [, entry] of Object.entries(manifest)) {
829+
if (entry.status === 'done' && entry.title) {
830+
const sanitized = entry.title.replace(/[\\/*?:"<>|]/g, '_');
831+
titles[sanitized] = entry.title;
832+
}
833+
}
834+
} catch {}
835+
}
836+
837+
// Existing mapping
838+
let mapping = {};
839+
const mapFile = path.join(alignDir, 'video_slide_mapping.json');
840+
if (fs.existsSync(mapFile)) {
841+
try { mapping = JSON.parse(fs.readFileSync(mapFile, 'utf8')); } catch {}
842+
}
843+
844+
return { captions, slides, titles, mapping, base };
845+
});
846+
847+
ipcMain.handle('align:saveMapping', (_, { cid, mapping }) => {
848+
const outDir = getOutputDir();
849+
const alignDir = path.join(outDir, String(cid), 'alignment');
850+
if (!fs.existsSync(alignDir)) fs.mkdirSync(alignDir, { recursive: true });
851+
const mapFile = path.join(alignDir, 'video_slide_mapping.json');
852+
fs.writeFileSync(mapFile, JSON.stringify(mapping, null, 2));
853+
return mapFile;
854+
});
855+
784856
// ── Uninstaller ──────────────────────────────────────────────────────────
785857
ipcMain.handle('uninstall:sizes', async () => {
786858
const venvDir = path.join(DATA_DIR, 'venv');

electron/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "auto-note",
3-
"version": "0.9.13",
3+
"version": "0.9.18",
44
"description": "AutoNote — lecture notes generator from Canvas recordings",
55
"homepage": "https://github.com/nodeeeeee/Auto-Note",
66
"author": {
@@ -68,6 +68,10 @@
6868
"from": "../extract_caption.py",
6969
"to": "scripts/extract_caption.py"
7070
},
71+
{
72+
"from": "../frame_extractor.py",
73+
"to": "scripts/frame_extractor.py"
74+
},
7175
{
7276
"from": "../semantic_alignment.py",
7377
"to": "scripts/semantic_alignment.py"

electron/preload.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ contextBridge.exposeInMainWorld('api', {
5151
// ── Course helpers ────────────────────────────────────────────────────────
5252
listLectures: (cid) => ipcRenderer.invoke('course:listLectures', cid),
5353

54+
// ── Align: scan + mapping ───────────────────────────────────────────────
55+
alignScan: (cid) => ipcRenderer.invoke('align:scan', cid),
56+
alignSaveMapping:(cid, mapping) => ipcRenderer.invoke('align:saveMapping', { cid, mapping }),
57+
5458
// ── OS/Dialog helpers ─────────────────────────────────────────────────────
5559
openDirDialog: () => ipcRenderer.invoke('dialog:openDir'),
5660
getOutputDir: () => ipcRenderer.invoke('path:outputDir'),

electron/renderer/app.js

Lines changed: 176 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -631,49 +631,143 @@ async function fillTranscribeInfo() {
631631
}
632632

633633
// ── Page: Align ────────────────────────────────────────────────────────────────
634+
635+
// Align page state
636+
const AlignState = { rows: [], slideOptions: [], courseId: '' };
637+
634638
function buildAlign() {
635639
return `
636640
${sectionTitle('Align Transcripts to Slides', '')}
637641
<div id="align-info-card"></div>
638642
${mkCard(`
639-
<div class="card-title">Auto-discover (whole course)</div>
640-
<div class="card-sub">Pairs all unaligned captions with matching slide files.</div>
641-
<div class="row">
643+
<div class="card-title" style="display:flex;align-items:center;gap:8px">
644+
${I.link} Video ↔ Slide Matching
645+
</div>
646+
<div class="card-sub">
647+
Match each video to its lecture slides. Auto-suggested matches are pre-filled.
648+
Click <strong>+</strong> to add multiple slide files, or <strong>×</strong> to exclude a video.
649+
</div>
650+
<div class="row" style="margin-top:8px">
642651
<div class="col expand">
643652
<span class="label">Course</span>
644653
<select id="align-course" class="select-ctrl">${courseOptions()}</select>
645654
</div>
646-
<div class="col expand">
647-
<span class="label">Output directory (blank = auto)</span>
648-
<input id="align-outdir" class="input-text" type="text" placeholder="blank = [course]/alignment/">
649-
</div>
655+
<button class="btn-primary" id="align-scan-btn">${I.search || '🔍'} Scan videos &amp; slides</button>
650656
</div>
651-
<div class="row" style="margin-top:8px">
652-
<button class="btn-primary" id="align-auto-btn">${I.play} Run auto-align</button>
653-
</div>
654-
`)}
655-
${mkCard(`
656-
<div class="card-title">Manual (specific files)</div>
657-
<div class="card-sub">Align one caption JSON to one or more slide files.</div>
658-
<div class="field">
659-
<label class="label">Caption JSON path</label>
660-
<input id="align-caption" class="input-text" type="text">
661-
</div>
662-
<div class="field">
663-
<label class="label">Slide file(s) (space-separated for multi-part)</label>
664-
<input id="align-slides" class="input-text" type="text">
665-
</div>
666-
<div class="row end" style="margin-top:8px">
667-
<div class="col expand">
668-
<label class="label">Output directory (blank = auto)</label>
669-
<input id="align-manual-out" class="input-text" type="text">
670-
</div>
671-
<button class="btn-primary" id="align-manual-btn">${I.play} Align</button>
657+
<div id="align-match-status" style="font-size:11px;color:var(--c-white-45);margin:4px 0 6px 0"></div>
658+
<div id="align-match-rows"></div>
659+
<div class="row" style="margin-top:12px;gap:12px">
660+
<button class="btn-primary" id="align-run-btn">${I.play} Align with mapping</button>
661+
<button class="btn-outline" id="align-auto-btn">Auto-align (skip matching)</button>
672662
</div>
673663
`)}
674664
`;
675665
}
676666

667+
function _alignSlideOptionsHtml(selected = '(none)') {
668+
let html = '<option value="(none)">(none — auto-detect)</option>';
669+
for (const s of AlignState.slideOptions) {
670+
const sel = s.rel === selected ? ' selected' : '';
671+
html += `<option value="${esc(s.rel)}"${sel}>${esc(s.rel)}</option>`;
672+
}
673+
return html;
674+
}
675+
676+
function _alignAutoSuggest(capStem) {
677+
// Simple heuristic: week number → lecture number, or token overlap
678+
const capLower = capStem.toLowerCase().replace(/[-_]/g, ' ');
679+
const weekMatch = capLower.match(/week\s*(\d+)/);
680+
const lecMatch = capLower.match(/lec(?:ture)?\s*(\d+)/);
681+
const capNum = weekMatch ? weekMatch[1] : (lecMatch ? lecMatch[1] : null);
682+
683+
let bestScore = 0, bestRel = '';
684+
for (const s of AlignState.slideOptions) {
685+
const sl = s.name.toLowerCase().replace(/[-_]/g, ' ');
686+
// Lecture number match
687+
const slNum = sl.match(/l(?:ecture)?\s*(\d+)/i);
688+
if (capNum && slNum && capNum === slNum[1]) return s.rel;
689+
// Token overlap
690+
const capTokens = new Set(capLower.split(/\s+/));
691+
const slTokens = new Set(sl.split(/\s+/));
692+
const inter = [...capTokens].filter(t => slTokens.has(t)).length;
693+
const union = new Set([...capTokens, ...slTokens]).size;
694+
const score = union ? inter / union : 0;
695+
if (score > bestScore) { bestScore = score; bestRel = s.rel; }
696+
}
697+
return bestScore > 0.05 ? bestRel : '(none)';
698+
}
699+
700+
function _alignRebuildRows() {
701+
const container = document.getElementById('align-match-rows');
702+
if (!container) return;
703+
704+
if (!AlignState.rows.length) { container.innerHTML = ''; return; }
705+
706+
let html = `<div style="display:flex;gap:6px;padding:2px 0;opacity:0.4;font-size:10px;font-weight:600">
707+
<span style="width:28px"></span><span style="width:14px"></span>
708+
<span style="width:220px">Video</span><span style="width:16px"></span>
709+
<span style="flex:1">Lecture slides</span></div>`;
710+
711+
AlignState.rows.forEach((row, ri) => {
712+
const statusIcon = row.aligned
713+
? '<span style="color:var(--c-success)" title="Already aligned">●</span>'
714+
: '<span style="opacity:0.3" title="Not yet aligned">○</span>';
715+
716+
let slidesHtml = '';
717+
row.slides.forEach((sel, si) => {
718+
const actionBtn = si === 0
719+
? `<button class="icon-btn" title="Add slide file" data-action="add" data-row="${ri}">+</button>`
720+
: `<button class="icon-btn" title="Remove" data-action="remove-slide" data-row="${ri}" data-slide="${si}" style="color:var(--c-error)">−</button>`;
721+
slidesHtml += `<div style="display:flex;align-items:center;gap:2px;margin-bottom:2px">
722+
<select class="select-ctrl" style="flex:1;font-size:11px" data-row="${ri}" data-slide="${si}">${_alignSlideOptionsHtml(sel)}</select>
723+
${actionBtn}</div>`;
724+
});
725+
726+
html += `<div style="display:flex;align-items:flex-start;gap:6px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.06)">
727+
<button class="icon-btn" title="Remove video" data-action="remove" data-row="${ri}" style="color:var(--c-error);font-size:14px">×</button>
728+
<span style="width:14px;padding-top:4px">${statusIcon}</span>
729+
<span style="width:220px;padding-top:4px;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(row.title)}">${esc(row.title)}</span>
730+
<span style="padding-top:4px;opacity:0.3">→</span>
731+
<div style="flex:1">${slidesHtml}</div>
732+
</div>`;
733+
});
734+
735+
container.innerHTML = html;
736+
737+
// Update status
738+
const nMatched = AlignState.rows.filter(r => r.slides.some(s => s !== '(none)')).length;
739+
const statusEl = document.getElementById('align-match-status');
740+
if (statusEl) statusEl.textContent = `${AlignState.rows.length} video(s), ${AlignState.slideOptions.length} slide file(s). ${nMatched} matched.`;
741+
742+
// Bind events via delegation
743+
container.onclick = (e) => {
744+
const btn = e.target.closest('[data-action]');
745+
if (!btn) return;
746+
const action = btn.dataset.action;
747+
const ri = parseInt(btn.dataset.row);
748+
if (action === 'remove') {
749+
AlignState.rows.splice(ri, 1);
750+
_alignRebuildRows();
751+
} else if (action === 'add') {
752+
AlignState.rows[ri].slides.push('(none)');
753+
_alignRebuildRows();
754+
} else if (action === 'remove-slide') {
755+
const si = parseInt(btn.dataset.slide);
756+
if (AlignState.rows[ri].slides.length > 1) {
757+
AlignState.rows[ri].slides.splice(si, 1);
758+
_alignRebuildRows();
759+
}
760+
}
761+
};
762+
container.onchange = (e) => {
763+
const sel = e.target.closest('select[data-row]');
764+
if (!sel) return;
765+
const ri = parseInt(sel.dataset.row);
766+
const si = parseInt(sel.dataset.slide);
767+
AlignState.rows[ri].slides[si] = sel.value;
768+
};
769+
}
770+
677771
async function fillAlignInfo() {
678772
const [model, ctx] = await Promise.all([
679773
window.api.getConstant('align', 'EMBED_MODEL'),
@@ -682,8 +776,7 @@ async function fillAlignInfo() {
682776
const el = document.getElementById('align-info-card');
683777
if (el) {
684778
el.innerHTML = mkCard(`<div class="info-row">${I.info}
685-
<span>Embed model: <strong>${esc(model)}</strong> Context: ±<strong>${esc(ctx)}s</strong>
686-
— Three-strategy matching: lecture number → token overlap → content embedding.</span></div>`);
779+
<span>Embed: <strong>${esc(model)}</strong> Context: ±<strong>${esc(ctx)}s</strong></span></div>`);
687780
}
688781
}
689782

@@ -1356,26 +1449,64 @@ async function attachPageHandlers() {
13561449
// ── Align ─────────────────────────────────────────────────────────────────────
13571450
if (pg === 4) {
13581451
fillAlignInfo();
1452+
1453+
// Scan button — populate the matching rows
1454+
document.getElementById('align-scan-btn')?.addEventListener('click', async () => {
1455+
const cid = document.getElementById('align-course')?.value;
1456+
if (!cid) { snack('Select a course first.', false); return; }
1457+
AlignState.courseId = cid;
1458+
1459+
const data = await window.api.alignScan(cid);
1460+
AlignState.slideOptions = data.slides;
1461+
1462+
AlignState.rows = data.captions.map(cap => {
1463+
// Determine title
1464+
const title = data.titles[cap.stem] || cap.stem;
1465+
// Pick initial slides: existing mapping > auto-suggest
1466+
let initSlides;
1467+
if (data.mapping[cap.stem] && data.mapping[cap.stem].length) {
1468+
initSlides = data.mapping[cap.stem];
1469+
} else {
1470+
const suggested = _alignAutoSuggest(cap.stem);
1471+
initSlides = suggested !== '(none)' ? [suggested] : ['(none)'];
1472+
}
1473+
return { stem: cap.stem, title, aligned: cap.aligned, slides: initSlides };
1474+
});
1475+
1476+
_alignRebuildRows();
1477+
snack(`Found ${data.captions.length} video(s), ${data.slides.length} slide file(s).`);
1478+
});
1479+
1480+
// Align with mapping button
1481+
document.getElementById('align-run-btn')?.addEventListener('click', async () => {
1482+
const cid = AlignState.courseId || document.getElementById('align-course')?.value;
1483+
if (!cid) { snack('Select a course first.', false); return; }
1484+
if (!AlignState.rows.length) { snack('Click "Scan" first.', false); return; }
1485+
1486+
// Build mapping from current state
1487+
const mapping = {};
1488+
for (const row of AlignState.rows) {
1489+
const vals = row.slides.filter(s => s && s !== '(none)');
1490+
if (vals.length) mapping[row.stem] = vals;
1491+
}
1492+
1493+
// Save mapping
1494+
const mapFile = await window.api.alignSaveMapping(cid, mapping);
1495+
1496+
const python = await window.api.getPythonPath();
1497+
const paths = await window.api.getScriptsPaths();
1498+
const cmd = [python, paths.align, '--course', cid, '--mapping', mapFile];
1499+
runCmd(cmd, 'align --course ' + cid + ' --mapping …');
1500+
});
1501+
1502+
// Auto-align (skip matching) button
13591503
document.getElementById('align-auto-btn')?.addEventListener('click', async () => {
1360-
const cid = document.getElementById('align-course')?.value;
1504+
const cid = document.getElementById('align-course')?.value;
13611505
if (!cid) { snack('Select a course first.', false); return; }
13621506
const python = await window.api.getPythonPath();
13631507
const paths = await window.api.getScriptsPaths();
1364-
const outDir = document.getElementById('align-outdir')?.value.trim();
13651508
const cmd = [python, paths.align, '--course', cid];
1366-
if (outDir) cmd.push('--out', outDir);
1367-
runCmd(cmd, 'semantic_alignment.py --course ' + cid);
1368-
});
1369-
document.getElementById('align-manual-btn')?.addEventListener('click', async () => {
1370-
const caption = document.getElementById('align-caption')?.value.trim();
1371-
const slides = document.getElementById('align-slides')?.value.trim();
1372-
if (!caption || !slides) { snack('Caption and slide path(s) are required.', false); return; }
1373-
const python = await window.api.getPythonPath();
1374-
const paths = await window.api.getScriptsPaths();
1375-
const outDir = document.getElementById('align-manual-out')?.value.trim();
1376-
const cmd = [python, paths.align, '--caption', caption, '--slides', ...slides.split(/\s+/)];
1377-
if (outDir) cmd.push('--out', outDir);
1378-
runCmd(cmd, 'semantic_alignment.py --caption …');
1509+
runCmd(cmd, 'align --course ' + cid);
13791510
});
13801511
return;
13811512
}

0 commit comments

Comments
 (0)