personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Stabilize transcript frame decoding

Serialize per-video thumbnail/full-res decoding, prefetch basic frames on expand, and limit overlays to modal renders for a lighter, more stable transcripts viewer.

+106 -101
+106 -101
apps/transcripts/workspace.html
··· 856 856 width: 0, 857 857 height: 0, 858 858 promise, 859 - thumbs: new Map() 859 + thumbs: new Map(), 860 + queue: Promise.resolve() 860 861 }); 861 862 return promise; 862 863 } 863 864 865 + async _withVideoQueue(videoUrl, task) { 866 + const entry = await this.loadVideo(videoUrl); 867 + const run = entry.queue.then(task, task); 868 + entry.queue = run.catch(() => {}); 869 + return run; 870 + } 871 + 864 872 async _seekTo(video, timestamp) { 865 873 return new Promise((resolve, reject) => { 866 874 const onSeeked = () => { ··· 891 899 return this.pendingThumbs.get(cacheKey); 892 900 } 893 901 894 - const pending = (async () => { 902 + const pending = this._withVideoQueue(videoUrl, async () => { 895 903 const video = entry.video; 896 904 const timestamp = Math.max(0, frameId - 1); 897 905 await this._seekTo(video, timestamp); ··· 921 929 } 922 930 entry.thumbs.set(frameId, canvas); 923 931 return canvas; 924 - })(); 932 + }); 925 933 926 934 this.pendingThumbs.set(cacheKey, pending); 927 935 try { ··· 932 940 } 933 941 934 942 async captureFullFrame(videoUrl, frameId) { 935 - const entry = await this.loadVideo(videoUrl); 936 - const video = entry.video; 937 - const timestamp = Math.max(0, frameId - 1); 938 - await this._seekTo(video, timestamp); 939 - return createImageBitmap(video); 943 + return this._withVideoQueue(videoUrl, async () => { 944 + const entry = await this.loadVideo(videoUrl); 945 + const video = entry.video; 946 + const timestamp = Math.max(0, frameId - 1); 947 + await this._seekTo(video, timestamp); 948 + return createImageBitmap(video); 949 + }); 940 950 } 941 951 942 952 async prefetchThumbnails(videoUrl, frameIds, onProgress = null) { ··· 965 975 966 976 // Draw thumbnail to canvas with optional annotations 967 977 async drawThumbnail(canvas, videoUrl, frameId, options = {}) { 968 - const { boxCoords, participants, width, height } = options; 978 + const { width, height } = options; 969 979 970 980 try { 971 981 const thumb = await this.captureThumbnail(videoUrl, frameId, width, height); ··· 980 990 const ctx = canvas.getContext('2d'); 981 991 ctx.drawImage(thumb, 0, 0, canvas.width, canvas.height); 982 992 983 - if (boxCoords && boxCoords.length === 4) { 984 - const [xMin, yMin, xMax, yMax] = boxCoords; 985 - const scaleX = canvas.width / thumb.width; 986 - const scaleY = canvas.height / thumb.height; 987 - 988 - ctx.strokeStyle = '#ef4444'; 989 - ctx.lineWidth = 3; 990 - ctx.strokeRect( 991 - xMin * scaleX, 992 - yMin * scaleY, 993 - (xMax - xMin) * scaleX, 994 - (yMax - yMin) * scaleY 995 - ); 996 - } 997 - 998 - if (participants && participants.length > 0) { 999 - for (const p of participants) { 1000 - const x = (p.left / 100) * canvas.width; 1001 - const y = (p.top / 100) * canvas.height; 1002 - const w = (p.width / 100) * canvas.width; 1003 - const h = (p.height / 100) * canvas.height; 1004 - 1005 - const colors = { 1006 - speaking: '#fbbf24', 1007 - active: '#4ade80', 1008 - muted: '#f87171', 1009 - presenting: '#60a5fa', 1010 - unknown: '#9ca3af' 1011 - }; 1012 - ctx.strokeStyle = colors[p.status] || colors.unknown; 1013 - ctx.lineWidth = 2; 1014 - ctx.setLineDash([5, 5]); 1015 - ctx.strokeRect(x, y, w, h); 1016 - ctx.setLineDash([]); 1017 - 1018 - const labelY = y + h + 16; 1019 - ctx.font = '11px system-ui, sans-serif'; 1020 - const textWidth = ctx.measureText(p.name).width; 1021 - ctx.fillStyle = colors[p.status] || colors.unknown; 1022 - ctx.fillRect(x, y + h + 2, textWidth + 8, 16); 1023 - 1024 - ctx.fillStyle = '#000'; 1025 - ctx.fillText(p.name, x + 4, labelY - 3); 1026 - } 1027 - } 1028 - 1029 993 canvas.classList.remove('loading'); 1030 994 return true; 1031 995 } catch (err) { ··· 1052 1016 const ctx = canvas.getContext('2d'); 1053 1017 ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); 1054 1018 1055 - if (boxCoords && boxCoords.length === 4) { 1056 - const [xMin, yMin, xMax, yMax] = boxCoords; 1057 - const scaleX = canvas.width / bitmap.width; 1058 - const scaleY = canvas.height / bitmap.height; 1059 - 1060 - ctx.strokeStyle = '#ef4444'; 1061 - ctx.lineWidth = 3; 1062 - ctx.strokeRect( 1063 - xMin * scaleX, 1064 - yMin * scaleY, 1065 - (xMax - xMin) * scaleX, 1066 - (yMax - yMin) * scaleY 1067 - ); 1068 - } 1069 - 1070 - if (participants && participants.length > 0) { 1071 - for (const p of participants) { 1072 - const x = (p.left / 100) * canvas.width; 1073 - const y = (p.top / 100) * canvas.height; 1074 - const w = (p.width / 100) * canvas.width; 1075 - const h = (p.height / 100) * canvas.height; 1076 - 1077 - const colors = { 1078 - speaking: '#fbbf24', 1079 - active: '#4ade80', 1080 - muted: '#f87171', 1081 - presenting: '#60a5fa', 1082 - unknown: '#9ca3af' 1083 - }; 1084 - ctx.strokeStyle = colors[p.status] || colors.unknown; 1085 - ctx.lineWidth = 2; 1086 - ctx.setLineDash([5, 5]); 1087 - ctx.strokeRect(x, y, w, h); 1088 - ctx.setLineDash([]); 1089 - 1090 - const labelY = y + h + 16; 1091 - ctx.font = '11px system-ui, sans-serif'; 1092 - const textWidth = ctx.measureText(p.name).width; 1093 - ctx.fillStyle = colors[p.status] || colors.unknown; 1094 - ctx.fillRect(x, y + h + 2, textWidth + 8, 16); 1095 - 1096 - ctx.fillStyle = '#000'; 1097 - ctx.fillText(p.name, x + 4, labelY - 3); 1098 - } 1099 - } 1019 + this._applyOverlays(ctx, canvas, bitmap.width, bitmap.height, boxCoords, participants); 1100 1020 1101 1021 if (bitmap && typeof bitmap.close === 'function') { 1102 1022 bitmap.close(); ··· 1109 1029 return false; 1110 1030 } 1111 1031 } 1032 + 1033 + _applyOverlays(ctx, canvas, sourceWidth, sourceHeight, boxCoords, participants) { 1034 + if (boxCoords && boxCoords.length === 4) { 1035 + const [xMin, yMin, xMax, yMax] = boxCoords; 1036 + const scaleX = canvas.width / sourceWidth; 1037 + const scaleY = canvas.height / sourceHeight; 1038 + 1039 + ctx.strokeStyle = '#ef4444'; 1040 + ctx.lineWidth = 3; 1041 + ctx.strokeRect( 1042 + xMin * scaleX, 1043 + yMin * scaleY, 1044 + (xMax - xMin) * scaleX, 1045 + (yMax - yMin) * scaleY 1046 + ); 1047 + } 1048 + 1049 + if (participants && participants.length > 0) { 1050 + for (const p of participants) { 1051 + const x = (p.left / 100) * canvas.width; 1052 + const y = (p.top / 100) * canvas.height; 1053 + const w = (p.width / 100) * canvas.width; 1054 + const h = (p.height / 100) * canvas.height; 1055 + 1056 + const colors = { 1057 + speaking: '#fbbf24', 1058 + active: '#4ade80', 1059 + muted: '#f87171', 1060 + presenting: '#60a5fa', 1061 + unknown: '#9ca3af' 1062 + }; 1063 + ctx.strokeStyle = colors[p.status] || colors.unknown; 1064 + ctx.lineWidth = 2; 1065 + ctx.setLineDash([5, 5]); 1066 + ctx.strokeRect(x, y, w, h); 1067 + ctx.setLineDash([]); 1068 + 1069 + const labelY = y + h + 16; 1070 + ctx.font = '11px system-ui, sans-serif'; 1071 + const textWidth = ctx.measureText(p.name).width; 1072 + ctx.fillStyle = colors[p.status] || colors.unknown; 1073 + ctx.fillRect(x, y + h + 2, textWidth + 8, 16); 1074 + 1075 + ctx.fillStyle = '#000'; 1076 + ctx.fillText(p.name, x + 4, labelY - 3); 1077 + } 1078 + } 1079 + } 1112 1080 } 1113 1081 1114 1082 // Global frame capture instance ··· 1284 1252 let segmentData = null; 1285 1253 let segmentAudioEl = null; 1286 1254 let currentVideoFiles = {}; // filename -> video URL mapping 1255 + let groupEntriesByIdx = new Map(); 1287 1256 1288 1257 function loadSegmentContent(seg) { 1289 1258 const showAudio = audioCheck.checked; ··· 1403 1372 1404 1373 // Group sequential basic screen frames together 1405 1374 const displayItems = groupBasicScreenFrames(chunks); 1375 + groupEntriesByIdx = new Map(); 1406 1376 1407 1377 let html = '<div class="tr-unified">'; 1408 1378 ··· 1418 1388 // Render items (chunks or groups) 1419 1389 displayItems.forEach((item, idx) => { 1420 1390 if (item.type === 'screen-group') { 1391 + groupEntriesByIdx.set(idx, item.entries || []); 1421 1392 // Render collapsed group of basic frames 1422 1393 html += renderScreenGroup(item, idx); 1423 1394 } else if (item.type === 'audio') { ··· 1468 1439 // Add click handlers for group headers to expand/collapse 1469 1440 panel.querySelectorAll('.tr-group-header').forEach(header => { 1470 1441 header.addEventListener('click', () => { 1471 - header.parentElement.classList.toggle('expanded'); 1442 + const groupEl = header.parentElement; 1443 + const isExpanded = groupEl.classList.toggle('expanded'); 1444 + if (!isExpanded) return; 1445 + if (groupEl.dataset.prefetched === 'true') return; 1446 + const groupIdx = parseInt(groupEl.dataset.idx, 10); 1447 + if (isNaN(groupIdx)) return; 1448 + const entries = groupEntriesByIdx.get(groupIdx) || []; 1449 + prefetchGroupThumbnails(entries, groupEl); 1472 1450 }); 1473 1451 }); 1474 1452 ··· 1482 1460 1483 1461 // Set up lazy loading for canvas thumbnails using IntersectionObserver 1484 1462 setupLazyCanvasLoading(); 1463 + } 1464 + 1465 + function prefetchGroupThumbnails(entries, groupEl) { 1466 + const frameIdsByVideo = new Map(); 1467 + for (const entry of entries) { 1468 + const filename = entry.source_ref?.filename; 1469 + const frameId = entry.source_ref?.frame_id; 1470 + if (!filename || !frameId) continue; 1471 + if (!frameIdsByVideo.has(filename)) { 1472 + frameIdsByVideo.set(filename, new Set()); 1473 + } 1474 + frameIdsByVideo.get(filename).add(frameId); 1475 + } 1476 + 1477 + const jobs = []; 1478 + for (const [filename, frameIds] of frameIdsByVideo.entries()) { 1479 + const url = currentVideoFiles[filename]; 1480 + if (!url) continue; 1481 + jobs.push(frameCapture.prefetchThumbnails(url, Array.from(frameIds))); 1482 + } 1483 + 1484 + if (jobs.length > 0) { 1485 + groupEl.dataset.prefetched = 'true'; 1486 + Promise.all(jobs).finally(() => { 1487 + setupLazyCanvasLoading(); 1488 + }); 1489 + } 1485 1490 } 1486 1491 1487 1492 // Lazy load canvas thumbnails when they become visible