From df25ac5fcd3ff7d26c813b820cbf7fee48445c86 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 18 Feb 2026 17:18:27 -0600 Subject: [PATCH 001/139] initial --- selfdrive/ui/tests/diff/diff.py | 113 ++++++++++++++++++--- selfdrive/ui/tests/diff/diff_template.html | 1 + 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 974edb42a367ee..ba4a444075331c 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -49,18 +49,97 @@ def find_differences(video1, video2) -> tuple[list[int], tuple[int, int]]: return different_frames, (len(hashes1), len(hashes2)) -def generate_html_report(videos: tuple[str, str], basedir: str, different_frames: list[int], frame_counts: tuple[int, int], diff_video_name): - chunks = [] - if different_frames: - current_chunk = [different_frames[0]] - for i in range(1, len(different_frames)): - if different_frames[i] == different_frames[i - 1] + 1: - current_chunk.append(different_frames[i]) - else: - chunks.append(current_chunk) - current_chunk = [different_frames[i]] - chunks.append(current_chunk) +def compute_chunks(different_frames: list[int]) -> list[list[int]]: + """Group consecutive frame indices into contiguous chunks.""" + if not different_frames: + return [] + chunks: list[list[int]] = [] + current_chunk = [different_frames[0]] + for i in range(1, len(different_frames)): + if different_frames[i] == different_frames[i - 1] + 1: + current_chunk.append(different_frames[i]) + else: + chunks.append(current_chunk) + current_chunk = [different_frames[i]] + chunks.append(current_chunk) + return chunks + + +def get_video_fps(video_path: str) -> float: + """Return the frame-rate of a video file.""" + cmd = ['ffprobe', '-v', 'error', '-select_streams', 'v:0', + '-show_entries', 'stream=r_frame_rate', '-of', 'csv=p=0', str(video_path)] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + num, den = result.stdout.strip().split('/') + return int(num) / int(den) + + +CLIP_PADDING = 30 # extra frames of context to include before/after each chunk + +def extract_clip(video_path: str, start_frame: int, end_frame: int, output_path: str, fps: float) -> None: + """Extract [start_frame, end_frame] (plus padding) from *video_path* into *output_path*.""" + padded_start = max(0, start_frame - CLIP_PADDING) + total_frames = (end_frame - padded_start + 1) + CLIP_PADDING + start_time = padded_start / fps + duration = total_frames / fps + cmd = ['ffmpeg', '-i', str(video_path), '-ss', str(start_time), '-t', str(duration), '-y', str(output_path)] + subprocess.run(cmd, capture_output=True, check=True) + + +def extract_chunk_clips( + video1: str, + video2: str, + diff_video: str, + chunks: list[list[int]], + fps: float, + output_dir: Path, +) -> list[dict]: + """For each chunk extract a short clip from video1, video2 and the diff video.""" + clip_sets: list[dict] = [] + for i, chunk in enumerate(chunks): + start_frame, end_frame = chunk[0], chunk[-1] + clips: dict[str, str] = {} + for name, src in [('video1', video1), ('video2', video2), ('diff', diff_video)]: + out_path = output_dir / f"chunk_{i:03d}_{name}.mp4" + print(f" Extracting chunk {i + 1}/{len(chunks)} ({name}) frames {start_frame}–{end_frame}…") + extract_clip(src, start_frame, end_frame, str(out_path), fps) + clips[name] = out_path.name + clip_sets.append({'start_frame': start_frame, 'end_frame': end_frame, + 'duration': end_frame - start_frame + 1, 'clips': clips}) + return clip_sets + + +def generate_chunks_html(clip_sets: list[dict], basedir: str) -> str: + if not clip_sets: + return "" + parts = ["

Different Sections

"] + for i, cs in enumerate(clip_sets): + parts.append( + f"

Section {i + 1}: frames {cs['start_frame']}\u2013{cs['end_frame']} " + f"({cs['duration']} frame{'s' if cs['duration'] != 1 else ''})

" + ) + parts.append("") + for label, key in [('Video 1', 'video1'), ('Video 2', 'video2'), ('Pixel Diff', 'diff')]: + src = os.path.join(basedir, cs['clips'][key]) + parts.append( + f"" + ) + parts.append("

{label}

" + f"
") + return "\n".join(parts) + + +def generate_html_report( + videos: tuple[str, str], + basedir: str, + different_frames: list[int], + frame_counts: tuple[int, int], + diff_video_name: str, + clip_sets: list[dict] | None = None, +) -> str: total_frames = max(frame_counts) frame_delta = frame_counts[1] - frame_counts[0] different_total = len(different_frames) + abs(frame_delta) @@ -72,6 +151,8 @@ def generate_html_report(videos: tuple[str, str], basedir: str, different_frames + (f" Video {'2' if frame_delta > 0 else '1'} is longer by {abs(frame_delta)} frames." if frame_delta != 0 else "") ) + chunks_html = generate_chunks_html(clip_sets or [], basedir) + # Load HTML template and replace placeholders html = HTML_TEMPLATE_PATH.read_text() placeholders = { @@ -79,6 +160,7 @@ def generate_html_report(videos: tuple[str, str], basedir: str, different_frames "VIDEO2_SRC": os.path.join(basedir, os.path.basename(videos[1])), "DIFF_SRC": os.path.join(basedir, diff_video_name), "RESULT_TEXT": result_text, + "CHUNKS_HTML": chunks_html, } for key, value in placeholders.items(): html = html.replace(f"${key}", value) @@ -119,9 +201,16 @@ def main(): if different_frames is None: sys.exit(1) + chunks = compute_chunks(different_frames) + clip_sets: list[dict] = [] + if chunks: + print(f"\nExtracting {len(chunks)} different section(s)...") + fps = get_video_fps(args.video1) + clip_sets = extract_chunk_clips(args.video1, args.video2, diff_video_path, chunks, fps, DIFF_OUT_DIR) + print() print("Generating HTML report...") - html = generate_html_report((args.video1, args.video2), args.basedir, different_frames, frame_counts, diff_video_name) + html = generate_html_report((args.video1, args.video2), args.basedir, different_frames, frame_counts, diff_video_name, clip_sets) with open(DIFF_OUT_DIR / args.output, 'w') as f: f.write(html) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 3f1de1051205fe..1dbf5f4005cd04 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -27,6 +27,7 @@

UI Diff


Results: $RESULT_TEXT

+ $CHUNKS_HTML From 51573130340e96e489dd71f67f7877b31c31744f Mon Sep 17 00:00:00 2001 From: David Date: Wed, 18 Feb 2026 19:35:33 -0600 Subject: [PATCH 005/139] enhance UI diff template with accordion sections for chunk display and lazy loading of videos --- selfdrive/ui/tests/diff/diff_template.html | 85 ++++++++++++++++------ 1 file changed, 62 insertions(+), 23 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 3e9483a1accedc..27611fcc944cb6 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -7,6 +7,12 @@ @@ -91,7 +91,7 @@

UI Diff

// Build video player table document.getElementById('player').innerHTML = ` - ${LABELS.map((label, i) => `
+ ${LABELS.map((label, i) => `

${label}

From a56a2fb71d57e68034fa99cc1e945d1e9d2a552a Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 17:04:57 -0600 Subject: [PATCH 080/139] fix --- selfdrive/ui/tests/diff/diff_template.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index a9c957215423f3..d94dff9f98db78 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -8,7 +8,6 @@ body { font-family: sans-serif; margin: 0.75em; } h1, h2, h3, h4 { margin: 0.5em 0 0.25em; } table.videos { width: 100%; } - table.videos td { width: 100%; } .no-video { display: none; width: 100%; min-height: 180px; border: 1px dashed #aaa; background: #fafafa; color: #666; align-items: center; justify-content: center; text-align: center; } #nav { display: flex; flex-wrap: wrap; gap: 0.6em; margin: 1em 0;} #nav button { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0; border: 2px solid #aaa; border-radius: 6px; cursor: pointer; background: #f5f5f5; width: 200px; position: relative; } @@ -91,7 +90,7 @@

UI Diff

// Build video player table document.getElementById('player').innerHTML = ` - ${LABELS.map((label, i) => `
+ ${LABELS.map((label, i) => `

${label}

From dd0f331d717f76650f1725a202c2a908d75ac04c Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 17:08:42 -0600 Subject: [PATCH 081/139] fix mobile --- selfdrive/ui/tests/diff/diff_template.html | 1 + 1 file changed, 1 insertion(+) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index d94dff9f98db78..4f55b39855347a 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -24,6 +24,7 @@ .nav-desc { font-size: 0.60rem; padding: 0.12rem 0.25rem 0.35rem; text-align: center; } @media (max-width: 800px) { table.videos tr { display: flex; flex-direction: column; } + table.videos td { width: 100%; } #nav { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.5em; } #nav button { width: 100%; } } From 8891fd68d76512c90ad27986afe615820eb4e50b Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 17:09:26 -0600 Subject: [PATCH 082/139] remove nav-row --- selfdrive/ui/tests/diff/diff_template.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 4f55b39855347a..589062825fdafb 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -17,7 +17,6 @@ #nav button.type-insert.active { border-color: #1a7a1a; background: #e6f7e6; } #nav button.type-delete { border-color: #c0392b; } #nav button.type-delete.active { border-color: #922b21; background: #fde8e6; } - .nav-row { display: flex; align-items: center; gap: 8px; width: 100%; } .nav-index { position: absolute; left: 6px; bottom: 4px; font-weight: 600; color: #777; } .nav-index.hidden { display: none; } .nav-action { flex: 1; text-align: center; font-weight: 600; } @@ -184,7 +183,6 @@

${label}

btn.appendChild(img); } const header = document.createElement('div'); - header.className = 'nav-row'; const idxEl = document.createElement('span'); idxEl.className = 'nav-index'; if (scene.idx) { From f5f1a771fe84b1d19c4ceaf4a8458ea7cacd9181 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 17:21:46 -0600 Subject: [PATCH 083/139] cleanup --- selfdrive/ui/tests/diff/diff_template.html | 74 ++++++++-------------- 1 file changed, 26 insertions(+), 48 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 589062825fdafb..ee4fbd93320249 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -17,10 +17,10 @@ #nav button.type-insert.active { border-color: #1a7a1a; background: #e6f7e6; } #nav button.type-delete { border-color: #c0392b; } #nav button.type-delete.active { border-color: #922b21; background: #fde8e6; } + .nav-title { flex: 1; text-align: center; font-weight: 600; } + .nav-desc { font-size: 0.60rem; padding: 0.12rem 0.25rem 0.35rem; text-align: center; } .nav-index { position: absolute; left: 6px; bottom: 4px; font-weight: 600; color: #777; } .nav-index.hidden { display: none; } - .nav-action { flex: 1; text-align: center; font-weight: 600; } - .nav-desc { font-size: 0.60rem; padding: 0.12rem 0.25rem 0.35rem; text-align: center; } @media (max-width: 800px) { table.videos tr { display: flex; flex-direction: column; } table.videos td { width: 100%; } @@ -47,16 +47,16 @@

UI Diff

const scenes = [ { title: 'Full Video', desc: '', type: '', srcs: ['$VIDEO1_SRC', '$VIDEO2_SRC', '$DIFF_SRC'], loop: true }, ...chunks.map(({ v1_start, v1_end, v2_start, v2_end, v1_count, v2_count, type, clips, thumb }, i) => { - // Build action text based on type and frame counts - let actionText; + // Build title based on type and frame counts + let title; if (type === 'insert') { - actionText = `Added ${frames_label(v2_count)}`; + title = `Added ${frames_label(v2_count)}`; } else if (type === 'delete') { - actionText = `Removed ${frames_label(v1_count)}`; + title = `Removed ${frames_label(v1_count)}`; } else if (type === 'replace') { - actionText = (v1_count === v2_count) ? `Changed ${frames_label(v1_count)}` : `Changed ${v1_count}→${v2_count} frames`; + title = (v1_count === v2_count) ? `Changed ${frames_label(v1_count)}` : `Changed ${v1_count}→${v2_count} frames`; } else { - actionText = frames_label(Math.max(v1_count || 0, v2_count || 0)); + title = frames_label(Math.max(v1_count || 0, v2_count || 0)); } // Build description with frame ranges @@ -77,13 +77,8 @@

UI Diff

} return { - idx: i + 1, - title: actionText || `Group ${i + 1}`, - desc: desc, - type: type || '', + idx: i + 1, title, desc, type, thumb, loop: true, srcs: [clips.video1, clips.video2, clips.diff], - thumb: thumb || '', - loop: true, }; }), ]; @@ -171,42 +166,25 @@

${label}

// Build nav buttons (hide the nav entirely when there are no diff segments). const navEl = document.getElementById('nav'); - if (scenes.length > 1) { - scenes.forEach((scene, i) => { - const btn = document.createElement('button'); - const navClass = TYPE_NAV_CLASSES[scene.type]; - if (navClass) btn.classList.add(navClass); - if (scene.thumb) { - const img = document.createElement('img'); - img.src = scene.thumb; - img.alt = scene.title; - btn.appendChild(img); - } - const header = document.createElement('div'); - const idxEl = document.createElement('span'); - idxEl.className = 'nav-index'; - if (scene.idx) { - idxEl.textContent = scene.idx + '.'; - } else { - idxEl.classList.add('hidden'); - } - const actionEl = document.createElement('span'); - actionEl.className = 'nav-action'; - const icon = TYPE_ICON[scene.type] || ''; - actionEl.textContent = (icon ? icon + ' ' : '') + scene.title; - header.appendChild(actionEl); - header.appendChild(idxEl); - const desc = document.createElement('span'); - desc.className = 'nav-desc'; - desc.textContent = scene.desc || ''; - btn.title = ((scene.title || '') + (scene.desc ? ' — ' + scene.desc : '')).trim(); - btn.appendChild(header); - btn.appendChild(desc); + if (scenes.length === 0) { + navEl.style.display = 'none'; + } else { + let navHtml = ''; + scenes.forEach(({ idx, type, title, desc, thumb }, i) => { + const navClass = TYPE_NAV_CLASSES[type] ? TYPE_NAV_CLASSES[type] : ''; + const icon = TYPE_ICON[type] || ''; + const thumbHtml = thumb ? `${title}` : ''; + const descHtml = `${desc || ''}`; + const titleHtml = `${icon ? icon + ' ' : ''}${title}`; + const idxHtml = `${idx ? idx + '.' : ''}`; + const headerHtml = `
${titleHtml}${idxHtml}
`; + const btnTitle = ((title || '') + (desc ? ' — ' + desc : '')).trim(); + navHtml += ``; + }); + navEl.innerHTML = navHtml; + Array.from(navEl.children).forEach((btn, i) => { btn.onclick = () => switchTo(i); - navEl.appendChild(btn); }); - } else { - navEl.style.display = 'none'; } // On load: if ?selected=N is present, select that scene; otherwise default to 0. From 08d593b2f61c6d1cf0cfd5f513c87fa341298c28 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 17:26:11 -0600 Subject: [PATCH 084/139] clean --- selfdrive/ui/tests/diff/diff_template.html | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index ee4fbd93320249..591f3578359609 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -164,22 +164,21 @@

${label}

const TYPE_ICON = { replace: '✏️', insert: '➕', delete: '➖' }; const TYPE_NAV_CLASSES = { insert: 'type-insert', delete: 'type-delete' }; - // Build nav buttons (hide the nav entirely when there are no diff segments). + // Build nav buttons for each scene/chunk const navEl = document.getElementById('nav'); if (scenes.length === 0) { - navEl.style.display = 'none'; + navEl.style.display = 'none'; // hide the nav container if there are no diff chunks } else { let navHtml = ''; scenes.forEach(({ idx, type, title, desc, thumb }, i) => { - const navClass = TYPE_NAV_CLASSES[type] ? TYPE_NAV_CLASSES[type] : ''; - const icon = TYPE_ICON[type] || ''; const thumbHtml = thumb ? `${title}` : ''; - const descHtml = `${desc || ''}`; + const icon = TYPE_ICON[type] || ''; const titleHtml = `${icon ? icon + ' ' : ''}${title}`; - const idxHtml = `${idx ? idx + '.' : ''}`; + const descHtml = `${desc || ''}`; + const idxHtml = `${idx ? idx + '.' : ''}`; const headerHtml = `
${titleHtml}${idxHtml}
`; const btnTitle = ((title || '') + (desc ? ' — ' + desc : '')).trim(); - navHtml += ``; + navHtml += ``; }); navEl.innerHTML = navHtml; Array.from(navEl.children).forEach((btn, i) => { From ff4a9a5f8c4f0538be5af22c4e6c10435af6fa14 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 18:13:23 -0600 Subject: [PATCH 085/139] more improvements and cleanup --- selfdrive/ui/tests/diff/diff_template.html | 26 ++++++++++++---------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 591f3578359609..8fe779f1577d86 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -10,9 +10,10 @@ table.videos { width: 100%; } .no-video { display: none; width: 100%; min-height: 180px; border: 1px dashed #aaa; background: #fafafa; color: #666; align-items: center; justify-content: center; text-align: center; } #nav { display: flex; flex-wrap: wrap; gap: 0.6em; margin: 1em 0;} - #nav button { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0; border: 2px solid #aaa; border-radius: 6px; cursor: pointer; background: #f5f5f5; width: 200px; position: relative; } + #nav button { width: 200px; display: flex; flex-direction: column; align-items: center; padding: 0; border: 2px solid #aaa; border-radius: 6px; cursor: pointer; background: #f5f5f5; position: relative; } #nav button img { width: 100%; } #nav button.active { border-color: #0078d4; background: #e5f2fc; border-width: 3px; } + #nav button.type-full { justify-content: center; } #nav button.type-insert { border-color: #2a9d2a; } #nav button.type-insert.active { border-color: #1a7a1a; background: #e6f7e6; } #nav button.type-delete { border-color: #c0392b; } @@ -42,7 +43,7 @@

UI Diff

const frames_label = (n) => `${n} frame${n !== 1 ? 's' : ''}`; const frames_range_label = (start, end) => start === end ? `frame ${start + 1}` : `frames ${start + 1}–${end + 1}`; - const video_frames_range_label = (v, start, end) => `video ${v}: ${frames_range_label(start, end)}`; + const video_frames_range_label = (v, start, end) => `${frames_range_label(start, end)} (video ${v})`; const scenes = [ { title: 'Full Video', desc: '', type: '', srcs: ['$VIDEO1_SRC', '$VIDEO2_SRC', '$DIFF_SRC'], loop: true }, @@ -50,11 +51,16 @@

UI Diff

// Build title based on type and frame counts let title; if (type === 'insert') { - title = `Added ${frames_label(v2_count)}`; + title = `➕ Added ${frames_label(v2_count)}`; } else if (type === 'delete') { - title = `Removed ${frames_label(v1_count)}`; + title = `➖ Removed ${frames_label(v1_count)}`; } else if (type === 'replace') { - title = (v1_count === v2_count) ? `Changed ${frames_label(v1_count)}` : `Changed ${v1_count}→${v2_count} frames`; + if (v1_count === v2_count) { + title = `✏️ Changed ${frames_label(v1_count)}`; + } else { + const changeType = v2_count > v1_count ? '➕' : '➖'; + title = `✏️${changeType} Changed ${v1_count}→${v2_count} frames`; + } } else { title = frames_label(Math.max(v1_count || 0, v2_count || 0)); } @@ -66,9 +72,7 @@

UI Diff

} else if (type === 'delete') { desc = video_frames_range_label(1, v1_start, v1_end); } else if (type === 'replace') { - const a = video_frames_range_label(1, v1_start, v1_end); - const b = video_frames_range_label(2, v2_start, v2_end); - desc = `${a} → ${b}`; + desc = `${video_frames_range_label(1, v1_start, v1_end)} → ${video_frames_range_label(2, v2_start, v2_end)}`; } else { const parts = []; if (v1_count) parts.push(video_frames_range_label(1, v1_start, v1_end)); @@ -161,7 +165,6 @@

${label}

history.replaceState(null, '', newUrl); } - const TYPE_ICON = { replace: '✏️', insert: '➕', delete: '➖' }; const TYPE_NAV_CLASSES = { insert: 'type-insert', delete: 'type-delete' }; // Build nav buttons for each scene/chunk @@ -172,13 +175,12 @@

${label}

let navHtml = ''; scenes.forEach(({ idx, type, title, desc, thumb }, i) => { const thumbHtml = thumb ? `${title}` : ''; - const icon = TYPE_ICON[type] || ''; - const titleHtml = `${icon ? icon + ' ' : ''}${title}`; + const titleHtml = `${title}`; const descHtml = `${desc || ''}`; const idxHtml = `${idx ? idx + '.' : ''}`; const headerHtml = `
${titleHtml}${idxHtml}
`; const btnTitle = ((title || '') + (desc ? ' — ' + desc : '')).trim(); - navHtml += ``; + navHtml += ``; }); navEl.innerHTML = navHtml; Array.from(navEl.children).forEach((btn, i) => { From c900e5e8161dc5c431ecc387f08bae6a732b1650 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 18:22:27 -0600 Subject: [PATCH 086/139] align top --- selfdrive/ui/tests/diff/diff_template.html | 1 + 1 file changed, 1 insertion(+) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 8fe779f1577d86..51de615d30a6c8 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -8,6 +8,7 @@ body { font-family: sans-serif; margin: 0.75em; } h1, h2, h3, h4 { margin: 0.5em 0 0.25em; } table.videos { width: 100%; } + table.videos td { vertical-align: top; } .no-video { display: none; width: 100%; min-height: 180px; border: 1px dashed #aaa; background: #fafafa; color: #666; align-items: center; justify-content: center; text-align: center; } #nav { display: flex; flex-wrap: wrap; gap: 0.6em; margin: 1em 0;} #nav button { width: 200px; display: flex; flex-direction: column; align-items: center; padding: 0; border: 2px solid #aaa; border-radius: 6px; cursor: pointer; background: #f5f5f5; position: relative; } From e46415d424b898be03e7c0ebee2f736593e67aa3 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 18:58:23 -0600 Subject: [PATCH 087/139] fix no video placeholder size --- selfdrive/ui/tests/diff/diff_template.html | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 51de615d30a6c8..8e460aea622e2d 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -9,7 +9,7 @@ h1, h2, h3, h4 { margin: 0.5em 0 0.25em; } table.videos { width: 100%; } table.videos td { vertical-align: top; } - .no-video { display: none; width: 100%; min-height: 180px; border: 1px dashed #aaa; background: #fafafa; color: #666; align-items: center; justify-content: center; text-align: center; } + .no-video { display: none; width: 100%; align-items: center; justify-content: center; text-align: center; border: 1px dashed #aaa; box-sizing: border-box; background: #fafafa; color: #666; } #nav { display: flex; flex-wrap: wrap; gap: 0.6em; margin: 1em 0;} #nav button { width: 200px; display: flex; flex-direction: column; align-items: center; padding: 0; border: 2px solid #aaa; border-radius: 6px; cursor: pointer; background: #f5f5f5; position: relative; } #nav button img { width: 100%; } @@ -107,8 +107,23 @@

${label}

const isEnded = (v, i) => !active[i] || v.ended || (Number.isFinite(v.duration) && v.currentTime >= v.duration - 0.05); + const applySceneAspectRatio = (ratio) => { + noVideoEls.forEach((el) => el.style.aspectRatio = ratio); + }; + + // Need to sync aspect ratio across videos so the no video placeholders will have the correct size + const syncAspectRatioFromAnyActiveVideo = () => { + const reference = videos.find((v, i) => active[i] && v.videoWidth > 0 && v.videoHeight > 0); + if (!reference) return; + applySceneAspectRatio(`${reference.videoWidth} / ${reference.videoHeight}`); + }; + // Sync: keep playing videos at the same playhead videos.forEach((v, vi) => { + v.addEventListener('loadedmetadata', () => { + if (active[vi]) syncAspectRatioFromAnyActiveVideo(); + }); + v.addEventListener('timeupdate', () => { if (restarting || !active[vi]) return; videos.forEach((o, oi) => { @@ -141,7 +156,7 @@

${label}

function switchTo(idx) { restarting = false; videos.forEach(v => { v.pause(); v.loop = false; }); // never use native loop - const { srcs, loop, type } = scenes[idx]; + const { srcs, loop } = scenes[idx]; loopEnabled = loop; videos.forEach((v, vi) => { From 49a089ee94258a3a9bb42d36dc7953aca0a25165 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 19:17:08 -0600 Subject: [PATCH 088/139] fix flashing --- selfdrive/ui/tests/diff/diff_template.html | 36 ++++++++++------------ 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 8e460aea622e2d..c8fb01ea72a664 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -9,7 +9,10 @@ h1, h2, h3, h4 { margin: 0.5em 0 0.25em; } table.videos { width: 100%; } table.videos td { vertical-align: top; } - .no-video { display: none; width: 100%; align-items: center; justify-content: center; text-align: center; border: 1px dashed #aaa; box-sizing: border-box; background: #fafafa; color: #666; } + .video-box { width: 100%; position: relative; aspect-ratio: var(--scene-aspect, 16/9); } + .video-box video, .video-box .no-video { position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; } + .video-box video { object-fit: contain; } + .no-video { display: none; align-items: center; justify-content: center; text-align: center; border: 1px dashed #aaa; box-sizing: border-box; background: #fafafa; color: #666; } #nav { display: flex; flex-wrap: wrap; gap: 0.6em; margin: 1em 0;} #nav button { width: 200px; display: flex; flex-direction: column; align-items: center; padding: 0; border: 2px solid #aaa; border-radius: 6px; cursor: pointer; background: #f5f5f5; position: relative; } #nav button img { width: 100%; } @@ -92,13 +95,14 @@

UI Diff

document.getElementById('player').innerHTML = ` ${LABELS.map((label, i) => ``).join('')}

${label}

-
- +
+
No video
`; const videos = [0, 1, 2].map(i => document.getElementById(`v${i}`)); + const videoBoxes = Array.from(document.querySelectorAll('.video-box')); const noVideoEls = [0, 1, 2].map(i => document.getElementById(`nv${i}`)); let active = [true, true, true]; // Whether each video is active/exists for the current scene @@ -108,20 +112,17 @@

${label}

const isEnded = (v, i) => !active[i] || v.ended || (Number.isFinite(v.duration) && v.currentTime >= v.duration - 0.05); const applySceneAspectRatio = (ratio) => { - noVideoEls.forEach((el) => el.style.aspectRatio = ratio); - }; - - // Need to sync aspect ratio across videos so the no video placeholders will have the correct size - const syncAspectRatioFromAnyActiveVideo = () => { - const reference = videos.find((v, i) => active[i] && v.videoWidth > 0 && v.videoHeight > 0); - if (!reference) return; - applySceneAspectRatio(`${reference.videoWidth} / ${reference.videoHeight}`); + videoBoxes.forEach(box => box.style.setProperty('--scene-aspect', ratio)); }; // Sync: keep playing videos at the same playhead videos.forEach((v, vi) => { v.addEventListener('loadedmetadata', () => { - if (active[vi]) syncAspectRatioFromAnyActiveVideo(); + // Once metadata is available for this video, update scene ratio from this video and hide its placeholder + if (!active[vi]) return + if (v.videoWidth > 0 && v.videoHeight > 0) applySceneAspectRatio(`${v.videoWidth}/${v.videoHeight}`); + noVideoEls[vi].style.display = 'none'; + v.style.display = ''; }); v.addEventListener('timeupdate', () => { @@ -165,14 +166,9 @@

${label}

const source = v.querySelector('source'); source.src = src || ''; v.load(); - if (active[vi]) { - v.style.display = ''; - noVideoEls[vi].style.display = 'none'; - v.play().catch(() => {}); - } else { - v.style.display = 'none'; - noVideoEls[vi].style.display = 'flex'; - } + v.style.display = src ? '' : 'none'; + if (src) v.play().catch(() => {}); + noVideoEls[vi].style.display = 'flex'; // Show placeholder until metadata loads }); document.querySelectorAll('#nav button').forEach((b, j) => b.classList.toggle('active', j === idx)); From f93b464b1f386ac8fe79b6dbcc0f8f22e79180ab Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 19:25:19 -0600 Subject: [PATCH 089/139] show loading --- selfdrive/ui/tests/diff/diff_template.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index c8fb01ea72a664..3a0d7d37d2d1d8 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -168,7 +168,8 @@

${label}

v.load(); v.style.display = src ? '' : 'none'; if (src) v.play().catch(() => {}); - noVideoEls[vi].style.display = 'flex'; // Show placeholder until metadata loads + noVideoEls[vi].style.display = 'flex'; // Show placeholder until video is ready + noVideoEls[vi].innerHTML = src ? 'Loading…' : 'No video'; }); document.querySelectorAll('#nav button').forEach((b, j) => b.classList.toggle('active', j === idx)); From eca7c6085559bbe747cf3adbdc278b3ff2bc77ee Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 19:27:26 -0600 Subject: [PATCH 090/139] simplify --- selfdrive/ui/tests/diff/diff_template.html | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 3a0d7d37d2d1d8..93828fcfd0e19a 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -202,12 +202,10 @@

${label}

} // On load: if ?selected=N is present, select that scene; otherwise default to 0. - (function selectInitialFromQuery() { - const params = new URLSearchParams(window.location.search); - const v = parseInt(params.get('selected') || '0', 10); - const initial = (!Number.isNaN(v) && v >= 0 && v < scenes.length) ? v : 0; - switchTo(initial); - })(); + const params = new URLSearchParams(window.location.search); + const v = parseInt(params.get('selected') || '0', 10); + const initial = (!Number.isNaN(v) && v >= 0 && v < scenes.length) ? v : 0; + switchTo(initial); From c112bd5c081092ac495eb743a9b6b7aef4805962 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 19:37:21 -0600 Subject: [PATCH 091/139] enhance video loading experience with placeholders and smooth scrolling --- selfdrive/ui/tests/diff/diff_template.html | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 93828fcfd0e19a..3588cb23357d59 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -160,6 +160,7 @@

${label}

const { srcs, loop } = scenes[idx]; loopEnabled = loop; + // Update videos with new sources, show/hide as needed, and show placeholders until ready videos.forEach((v, vi) => { const src = srcs[vi]; active[vi] = !!src; @@ -168,14 +169,21 @@

${label}

v.load(); v.style.display = src ? '' : 'none'; if (src) v.play().catch(() => {}); - noVideoEls[vi].style.display = 'flex'; // Show placeholder until video is ready - noVideoEls[vi].innerHTML = src ? 'Loading…' : 'No video'; + // Show placeholder until video is ready + noVideoEls[vi].style.display = 'flex'; + noVideoEls[vi].innerText = src ? 'Loading…' : 'No video'; }); + // Update active nav button document.querySelectorAll('#nav button').forEach((b, j) => b.classList.toggle('active', j === idx)); + + // Update selected query param const newUrl = new URL(window.location); idx > 0 ? newUrl.searchParams.set('selected', idx) : newUrl.searchParams.delete('selected'); history.replaceState(null, '', newUrl); + + // Scroll to top (nicer on mobile) + window.scrollTo({ top: 0, behavior: 'smooth' }); } const TYPE_NAV_CLASSES = { insert: 'type-insert', delete: 'type-delete' }; @@ -201,7 +209,7 @@

${label}

}); } - // On load: if ?selected=N is present, select that scene; otherwise default to 0. + // If 'selected' query param is present, use that scene; otherwise default to 0. const params = new URLSearchParams(window.location.search); const v = parseInt(params.get('selected') || '0', 10); const initial = (!Number.isNaN(v) && v >= 0 && v < scenes.length) ? v : 0; From e88183867690e97c0d884585aeeabf1e43bdb320 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 20:51:11 -0600 Subject: [PATCH 092/139] copy diff chunks in workflow --- .github/workflows/ui_preview.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ui_preview.yaml b/.github/workflows/ui_preview.yaml index 72ced4985228d1..3af90d160f3ba8 100644 --- a/.github/workflows/ui_preview.yaml +++ b/.github/workflows/ui_preview.yaml @@ -122,6 +122,7 @@ jobs: cp "${{ github.workspace }}/selfdrive/ui/tests/diff/report/${diff_name}.html" "${{ github.workspace }}/pr_ui/" cp "${{ github.workspace }}/selfdrive/ui/tests/diff/report/${diff_name}.mp4" "${{ github.workspace }}/pr_ui/" + cp -r "${{ github.workspace }}/selfdrive/ui/tests/diff/report/${diff_name}-chunks" "${{ github.workspace }}/pr_ui/" REPORT_URL="https://commaai.github.io/ci-artifacts/${diff_name}_pr_${{ github.event.number }}.html" if [ $diff_exit_code -eq 0 ]; then From f7e319768fdad9b8994ab887b869f7ad8785b81d Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 20:56:12 -0600 Subject: [PATCH 093/139] fix bug --- selfdrive/ui/tests/diff/diff_template.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 3588cb23357d59..f2787a6375aaf6 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -190,8 +190,8 @@

${label}

// Build nav buttons for each scene/chunk const navEl = document.getElementById('nav'); - if (scenes.length === 0) { - navEl.style.display = 'none'; // hide the nav container if there are no diff chunks + if (scenes.length <= 1) { + navEl.style.display = 'none'; // hide the nav container if there are no diff chunks (only the full video scene) } else { let navHtml = ''; scenes.forEach(({ idx, type, title, desc, thumb }, i) => { From 5e4fea13fd0eac02aa4bf820ddb3c00655ad5131 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 21:06:13 -0600 Subject: [PATCH 094/139] cleanup and refactor pass --- selfdrive/ui/tests/diff/diff.py | 50 +++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 15413d0ae3b2ad..7d52a577db7066 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -121,14 +121,14 @@ def _rel_path(p: Path) -> str: # --- video1 clip --- v1_clip = output_dir / f"{i:03d}_video1.mp4" if chunk_type != 'insert': - print(f" Chunk {i + 1}/{n} (v1/{chunk_type}) frames {v1_start}-{v1_end}") + print(f" [{i + 1}/{n}] video1 ({chunk_type}): frames {v1_start}-{v1_end}") extract_clip(video1, v1_start, v1_end, v1_clip, fps) clips['video1'] = _rel_path(v1_clip) # --- video2 clip --- v2_clip = output_dir / f"{i:03d}_video2.mp4" if chunk_type != 'delete': - print(f" Chunk {i + 1}/{n} (v2/{chunk_type}) frames {v2_start}-{v2_end}") + print(f" [{i + 1}/{n}] video2 ({chunk_type}): frames {v2_start}-{v2_end}") extract_clip(video2, v2_start, v2_end, v2_clip, fps) clips['video2'] = _rel_path(v2_clip) @@ -145,7 +145,7 @@ def _rel_path(p: Path) -> str: thumb_ext = 'png' if chunk_type == 'replace' else 'jpg' # Use PNG for the diff thumbnails for clarity; JPG is smaller for the other thumbnails thumb_path = output_dir / f"{i:03d}_thumb.{thumb_ext}" thumb_source = diff_clip if chunk_type == 'replace' else (v1_clip if chunk_type == 'delete' else v2_clip) - print(f" Chunk {i + 1}/{n} (thumb) frame {thumb_frame}") + print(f" [{i + 1}/{n}] thumbnail: frame {thumb_frame}") generate_thumbnail(thumb_source, thumb_frame, thumb_path, fps) clip_sets.append({ @@ -159,7 +159,7 @@ def _rel_path(p: Path) -> str: def generate_html_report( - videos: tuple[str, str], basedir: str, diff_frame_count: int, frame_counts: tuple[int, int], diff_video_name: str, clip_sets: list[dict] + videos: tuple[Path, Path], basedir: str, diff_frame_count: int, frame_counts: tuple[int, int], diff_video_name: str, clip_sets: list[dict] ) -> str: total_frames = max(frame_counts) frame_delta = frame_counts[1] - frame_counts[0] @@ -174,8 +174,8 @@ def generate_html_report( # Load HTML template and replace placeholders html = HTML_TEMPLATE_PATH.read_text() placeholders = { - "VIDEO1_SRC": os.path.join(basedir, os.path.basename(videos[0])), - "VIDEO2_SRC": os.path.join(basedir, os.path.basename(videos[1])), + "VIDEO1_SRC": os.path.join(basedir, videos[0].name), + "VIDEO2_SRC": os.path.join(basedir, videos[1].name), "DIFF_SRC": os.path.join(basedir, diff_video_name), "RESULT_TEXT": result_text, "CHUNKS_JSON": json.dumps(clip_sets), @@ -199,6 +199,12 @@ def main(): if not args.output.lower().endswith('.html'): args.output += '.html' + video1 = Path(args.video1) + video2 = Path(args.video2) + missing = [str(p) for p in (video1, video2) if not p.exists()] + if missing: + parser.error(f"Video file(s) not found: {', '.join(missing)}") + output_stem = Path(args.output).stem diff_video_name = f"{output_stem}.mp4" chunks_folder_name = f"{output_stem}-chunks" @@ -206,19 +212,20 @@ def main(): os.makedirs(DIFF_OUT_DIR, exist_ok=True) print("=" * 60) - print("VIDEO DIFF - HTML REPORT") + print("UI VIDEO DIFF REPORT") print("=" * 60) - print(f"Video 1: {args.video1}") - print(f"Video 2: {args.video2}") - print(f"Output: {args.output}") - print(f"Diff video: {diff_video_name}") + print(f"Video 1 : {video1}") + print(f"Video 2 : {video2}") + print(f"HTML output: {args.output}") + print(f"Diff video : {diff_video_name}") print(f"Diff chunks: {chunks_folder_name}") print() - print("Creating diff video...") - create_diff_video(args.video1, args.video2, str(DIFF_OUT_DIR / diff_video_name)) + print("[1/4] Creating full diff video...") + create_diff_video(video1, video2, DIFF_OUT_DIR / diff_video_name) - hashes1, hashes2 = get_video_frame_hashes(args.video1, args.video2) + print("[2/4] Hashing frames...") + hashes1, hashes2 = get_video_frame_hashes(str(video1), str(video2)) frame_counts = (len(hashes1), len(hashes2)) chunks = compute_diff_chunks(hashes1, hashes2) @@ -226,19 +233,20 @@ def main(): clip_sets = [] if chunks: - print(f"\nExtracting {len(chunks)} diff chunks(s)...") - fps = get_video_fps(args.video1) - clip_sets = extract_chunk_clips(args.video1, args.video2, chunks, fps, args.basedir, chunks_folder_name) + print(f"[3/4] Extracting {len(chunks)} diff chunk(s)...") + fps = get_video_fps(video1) + clip_sets = extract_chunk_clips(video1, video2, chunks, fps, args.basedir, chunks_folder_name) + else: + print("[3/4] No per-chunk differences found.") - print() - print("Generating HTML report...") - html = generate_html_report((args.video1, args.video2), args.basedir, diff_frame_count, frame_counts, diff_video_name, clip_sets) + print("[4/4] Generating HTML report...") + html = generate_html_report((video1, video2), args.basedir, diff_frame_count, frame_counts, diff_video_name, clip_sets) output_path = DIFF_OUT_DIR / args.output with open(output_path, 'w') as f: f.write(html) - print(f"Report generated at '{output_path}'") + print(f"Report generated at: {output_path}") # Open in browser by default if not args.no_open: From 9221870fca0b11040b9ea04de36d1c6fcc61aace Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 21:31:43 -0600 Subject: [PATCH 095/139] parallelize video frame hashing --- selfdrive/ui/tests/diff/diff.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 7d52a577db7066..6d38dd8a592e41 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -6,6 +6,7 @@ import subprocess import webbrowser import argparse +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from typing import Literal from pathlib import Path @@ -33,12 +34,15 @@ def extract_framehashes(video_path: str) -> list[str]: def get_video_frame_hashes(video1: str, video2: str) -> tuple[list[str], list[str]]: - """Hash every frame of both videos and return the two hash lists.""" - print("Hashing frames from video 1...") - hashes1 = extract_framehashes(video1) + """Hash every frame of both videos in parallel and return the two hash lists.""" + with ThreadPoolExecutor(max_workers=2) as executor: + print("Generating frame hashes for both videos...") + future1 = executor.submit(extract_framehashes, video1) + future2 = executor.submit(extract_framehashes, video2) + hashes1 = future1.result() + hashes2 = future2.result() + print(f" Found {len(hashes1)} frames in video 1.") - print("Hashing frames from video 2...") - hashes2 = extract_framehashes(video2) print(f" Found {len(hashes2)} frames in video 2.") return hashes1, hashes2 From 6a29d05574c5234befdb559536d2fdda4526ab73 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 23:20:28 -0600 Subject: [PATCH 096/139] update styles --- selfdrive/ui/tests/diff/diff_template.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index f2787a6375aaf6..f7002572e3fb0d 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -8,11 +8,11 @@ body { font-family: sans-serif; margin: 0.75em; } h1, h2, h3, h4 { margin: 0.5em 0 0.25em; } table.videos { width: 100%; } - table.videos td { vertical-align: top; } + /* Video box is used to maintain aspect ratio for video placeholder size and to prevent layout shifts when switching scenes */ .video-box { width: 100%; position: relative; aspect-ratio: var(--scene-aspect, 16/9); } .video-box video, .video-box .no-video { position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; } .video-box video { object-fit: contain; } - .no-video { display: none; align-items: center; justify-content: center; text-align: center; border: 1px dashed #aaa; box-sizing: border-box; background: #fafafa; color: #666; } + .video-box .no-video { display: none; align-items: center; justify-content: center; text-align: center; border: 1px dashed #aaa; box-sizing: border-box; background: #fafafa; color: #666; } #nav { display: flex; flex-wrap: wrap; gap: 0.6em; margin: 1em 0;} #nav button { width: 200px; display: flex; flex-direction: column; align-items: center; padding: 0; border: 2px solid #aaa; border-radius: 6px; cursor: pointer; background: #f5f5f5; position: relative; } #nav button img { width: 100%; } From 5c7eeb0fdd759b4829f9225accc5721491e9586e Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 23:31:01 -0600 Subject: [PATCH 097/139] use Path instead --- selfdrive/ui/tests/diff/diff.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 6d38dd8a592e41..46885303baa5f2 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -19,8 +19,8 @@ CLIP_PADDING_AFTER = 0 # extra frames of context to include after each chunk -def extract_framehashes(video_path: str) -> list[str]: - cmd = ['ffmpeg', '-i', video_path, '-map', '0:v:0', '-vsync', '0', '-f', 'framehash', '-hash', 'md5', '-'] +def extract_framehashes(video_path: Path) -> list[str]: + cmd = ['ffmpeg', '-i', str(video_path), '-map', '0:v:0', '-vsync', '0', '-f', 'framehash', '-hash', 'md5', '-'] result = subprocess.run(cmd, capture_output=True, text=True, check=True) hashes = [] for line in result.stdout.splitlines(): @@ -33,7 +33,7 @@ def extract_framehashes(video_path: str) -> list[str]: return hashes -def get_video_frame_hashes(video1: str, video2: str) -> tuple[list[str], list[str]]: +def get_video_frame_hashes(video1: Path, video2: Path) -> tuple[list[str], list[str]]: """Hash every frame of both videos in parallel and return the two hash lists.""" with ThreadPoolExecutor(max_workers=2) as executor: print("Generating frame hashes for both videos...") @@ -229,7 +229,7 @@ def main(): create_diff_video(video1, video2, DIFF_OUT_DIR / diff_video_name) print("[2/4] Hashing frames...") - hashes1, hashes2 = get_video_frame_hashes(str(video1), str(video2)) + hashes1, hashes2 = get_video_frame_hashes(video1, video2) frame_counts = (len(hashes1), len(hashes2)) chunks = compute_diff_chunks(hashes1, hashes2) From a5add3cc1fb07ff6a871ae304503e1fca3da3104 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 23:33:25 -0600 Subject: [PATCH 098/139] do diff in parallel --- selfdrive/ui/tests/diff/diff.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 46885303baa5f2..b0f6ee2effc0cd 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -6,6 +6,7 @@ import subprocess import webbrowser import argparse +import threading from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from typing import Literal @@ -74,7 +75,7 @@ def compute_diff_chunks(hashes1: list[str], hashes2: list[str]) -> list[Chunk]: def create_diff_video(video1: Path, video2: Path, output: Path) -> None: """Create a diff video of two clips using ffmpeg blend filter with difference mode.""" - cmd = ['ffmpeg', '-i', str(video1), '-i', str(video2), '-filter_complex', 'blend=all_mode=difference', '-vsync', '0', '-y', str(output)] + cmd = ['ffmpeg', '-nostdin', '-i', str(video1), '-i', str(video2), '-filter_complex', 'blend=all_mode=difference', '-vsync', '0', '-y', str(output)] subprocess.run(cmd, capture_output=True, check=True) @@ -112,6 +113,8 @@ def extract_chunk_clips(video1: Path, video2: Path, chunks: list[Chunk], fps: fl os.makedirs(output_dir, exist_ok=True) n = len(chunks) + # TODO: We should definitely try to do this in parallel, but it makes it more complex, so leaving for now + for i, chunk in enumerate(chunks): chunk_type = chunk.type v1_start, v1_end, v1_count = chunk.v1_start, chunk.v1_end, chunk.v1_count @@ -225,8 +228,9 @@ def main(): print(f"Diff chunks: {chunks_folder_name}") print() - print("[1/4] Creating full diff video...") - create_diff_video(video1, video2, DIFF_OUT_DIR / diff_video_name) + print("[1/4] Starting full diff video in background...") + diff_thread = threading.Thread(target=create_diff_video, args=(video1, video2, DIFF_OUT_DIR / diff_video_name)) + diff_thread.start() print("[2/4] Hashing frames...") hashes1, hashes2 = get_video_frame_hashes(video1, video2) @@ -257,6 +261,9 @@ def main(): print(f"Opening {args.output} in browser...") webbrowser.open(f'file://{os.path.abspath(output_path)}') + print("Waiting for full diff video to finish...") + diff_thread.join() + return 0 if diff_frame_count == 0 else 1 From 0c31cd33f865769b4c4865d1895a3d69809ee3ae Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Feb 2026 21:26:24 -0600 Subject: [PATCH 099/139] process chunks in parallel --- selfdrive/ui/tests/diff/diff.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index b0f6ee2effc0cd..84c0db741f0463 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -21,7 +21,7 @@ def extract_framehashes(video_path: Path) -> list[str]: - cmd = ['ffmpeg', '-i', str(video_path), '-map', '0:v:0', '-vsync', '0', '-f', 'framehash', '-hash', 'md5', '-'] + cmd = ['ffmpeg', '-nostdin', '-i', str(video_path), '-map', '0:v:0', '-vsync', '0', '-f', 'framehash', '-hash', 'md5', '-'] result = subprocess.run(cmd, capture_output=True, text=True, check=True) hashes = [] for line in result.stdout.splitlines(): @@ -94,7 +94,7 @@ def extract_clip(video_path: Path, start_frame: int, end_frame: int, output_path padding_before = start_frame - padded_start total_frames = (end_frame - start_frame + 1) + padding_before + CLIP_PADDING_AFTER start_time = padded_start / fps - cmd = ['ffmpeg', '-i', str(video_path), '-ss', f"{start_time:.6f}", '-frames:v', str(total_frames), '-vsync', '0', '-y', str(output_path)] + cmd = ['ffmpeg', '-nostdin', '-i', str(video_path), '-ss', f"{start_time:.6f}", '-frames:v', str(total_frames), '-vsync', '0', '-y', str(output_path)] subprocess.run(cmd, capture_output=True, check=True) return total_frames @@ -102,7 +102,7 @@ def extract_clip(video_path: Path, start_frame: int, end_frame: int, output_path def generate_thumbnail(video_path: Path, frame: int, out_path: Path, fps: float) -> None: """Create a single-frame PNG thumbnail at the given frame index.""" t = frame / fps - cmd = ['ffmpeg', '-i', str(video_path), '-ss', f"{t:.6f}", '-frames:v', '1', '-vsync', '0', '-y', str(out_path)] + cmd = ['ffmpeg', '-nostdin', '-i', str(video_path), '-ss', f"{t:.6f}", '-frames:v', '1', '-vsync', '0', '-y', str(out_path)] subprocess.run(cmd, capture_output=True, check=True) @@ -113,9 +113,7 @@ def extract_chunk_clips(video1: Path, video2: Path, chunks: list[Chunk], fps: fl os.makedirs(output_dir, exist_ok=True) n = len(chunks) - # TODO: We should definitely try to do this in parallel, but it makes it more complex, so leaving for now - - for i, chunk in enumerate(chunks): + def process_chunk(i: int, chunk: Chunk) -> dict: chunk_type = chunk.type v1_start, v1_end, v1_count = chunk.v1_start, chunk.v1_end, chunk.v1_count v2_start, v2_end, v2_count = chunk.v2_start, chunk.v2_end, chunk.v2_count @@ -128,20 +126,21 @@ def _rel_path(p: Path) -> str: # --- video1 clip --- v1_clip = output_dir / f"{i:03d}_video1.mp4" if chunk_type != 'insert': - print(f" [{i + 1}/{n}] video1 ({chunk_type}): frames {v1_start}-{v1_end}") + # print(f" [{i + 1}/{n}] video1 ({chunk_type}): frames {v1_start}-{v1_end}") extract_clip(video1, v1_start, v1_end, v1_clip, fps) clips['video1'] = _rel_path(v1_clip) # --- video2 clip --- v2_clip = output_dir / f"{i:03d}_video2.mp4" if chunk_type != 'delete': - print(f" [{i + 1}/{n}] video2 ({chunk_type}): frames {v2_start}-{v2_end}") + # print(f" [{i + 1}/{n}] video2 ({chunk_type}): frames {v2_start}-{v2_end}") extract_clip(video2, v2_start, v2_end, v2_clip, fps) clips['video2'] = _rel_path(v2_clip) # --- diff clip --- diff_clip = output_dir / f"{i:03d}_diff.mp4" if chunk_type == 'replace': + # print(f" [{i + 1}/{n}] diff: frames {v1_start}-{v1_end} vs {v2_start}-{v2_end}") create_diff_video(v1_clip, v2_clip, diff_clip) clips['diff'] = _rel_path(diff_clip) @@ -152,15 +151,22 @@ def _rel_path(p: Path) -> str: thumb_ext = 'png' if chunk_type == 'replace' else 'jpg' # Use PNG for the diff thumbnails for clarity; JPG is smaller for the other thumbnails thumb_path = output_dir / f"{i:03d}_thumb.{thumb_ext}" thumb_source = diff_clip if chunk_type == 'replace' else (v1_clip if chunk_type == 'delete' else v2_clip) - print(f" [{i + 1}/{n}] thumbnail: frame {thumb_frame}") + # print(f" [{i + 1}/{n}] thumbnail: frame {thumb_frame}") generate_thumbnail(thumb_source, thumb_frame, thumb_path, fps) - clip_sets.append({ - 'type': chunk_type, + return { + 'type': chunk_type, 'clips': clips, 'thumb': _rel_path(thumb_path), 'v1_start': v1_start, 'v1_end': v1_end, 'v1_count': v1_count, 'v2_start': v2_start, 'v2_end': v2_end, 'v2_count': v2_count, - 'clips': clips, 'thumb': _rel_path(thumb_path), - }) + } + + max_workers = min(8, len(chunks)) + print(f" Running with up to {max_workers} threads...") + with ThreadPoolExecutor(max_workers) as executor: + futures = [executor.submit(process_chunk, i, chunk) for i, chunk in enumerate(chunks)] + for future in futures: + print(f" Processed chunk {futures.index(future) + 1}/{n}") + clip_sets.append(future.result()) return clip_sets From 8eb625daf1562940862c080780a29be73f086e16 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 00:07:30 -0600 Subject: [PATCH 100/139] more --- selfdrive/ui/tests/diff/diff.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 84c0db741f0463..2588bec15e6a9a 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -16,8 +16,9 @@ DIFF_OUT_DIR = Path(BASEDIR) / "selfdrive" / "ui" / "tests" / "diff" / "report" HTML_TEMPLATE_PATH = Path(__file__).with_name("diff_template.html") -CLIP_PADDING_BEFORE = 0 # extra frames of context to include before each chunk -CLIP_PADDING_AFTER = 0 # extra frames of context to include after each chunk +# extra frames of context to include before/after each diff chunk +CLIP_PADDING_BEFORE = 0 +CLIP_PADDING_AFTER = 0 def extract_framehashes(video_path: Path) -> list[str]: @@ -123,6 +124,8 @@ def _rel_path(p: Path) -> str: """ Return path relative to the basedir.""" return os.path.join(basedir, folder_name, p.name) + # TODO: We could further parallelize by doing some of these calls in parallel within each chunk + # --- video1 clip --- v1_clip = output_dir / f"{i:03d}_video1.mp4" if chunk_type != 'insert': @@ -160,6 +163,7 @@ def _rel_path(p: Path) -> str: 'v2_start': v2_start, 'v2_end': v2_end, 'v2_count': v2_count, } + # Process chunks in parallel with a thread pool max_workers = min(8, len(chunks)) print(f" Running with up to {max_workers} threads...") with ThreadPoolExecutor(max_workers) as executor: @@ -234,7 +238,7 @@ def main(): print(f"Diff chunks: {chunks_folder_name}") print() - print("[1/4] Starting full diff video in background...") + print("[1/4] Creating full diff video in background...") diff_thread = threading.Thread(target=create_diff_video, args=(video1, video2, DIFF_OUT_DIR / diff_video_name)) diff_thread.start() @@ -251,7 +255,7 @@ def main(): fps = get_video_fps(video1) clip_sets = extract_chunk_clips(video1, video2, chunks, fps, args.basedir, chunks_folder_name) else: - print("[3/4] No per-chunk differences found.") + print("[3/4] No diff chunks found, skipping clip extraction.") print("[4/4] Generating HTML report...") html = generate_html_report((video1, video2), args.basedir, diff_frame_count, frame_counts, diff_video_name, clip_sets) @@ -267,7 +271,8 @@ def main(): print(f"Opening {args.output} in browser...") webbrowser.open(f'file://{os.path.abspath(output_path)}') - print("Waiting for full diff video to finish...") + if (diff_thread.is_alive()): + print("Waiting for diff video generation to finish...") diff_thread.join() return 0 if diff_frame_count == 0 else 1 From b539b24056de5593a27ff86ce5f398bb7458c2fc Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 00:17:21 -0600 Subject: [PATCH 101/139] parallelize within chunks --- selfdrive/ui/tests/diff/diff.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 2588bec15e6a9a..56b23368f56d70 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -124,21 +124,22 @@ def _rel_path(p: Path) -> str: """ Return path relative to the basedir.""" return os.path.join(basedir, folder_name, p.name) - # TODO: We could further parallelize by doing some of these calls in parallel within each chunk - - # --- video1 clip --- v1_clip = output_dir / f"{i:03d}_video1.mp4" - if chunk_type != 'insert': - # print(f" [{i + 1}/{n}] video1 ({chunk_type}): frames {v1_start}-{v1_end}") - extract_clip(video1, v1_start, v1_end, v1_clip, fps) - clips['video1'] = _rel_path(v1_clip) - - # --- video2 clip --- v2_clip = output_dir / f"{i:03d}_video2.mp4" - if chunk_type != 'delete': - # print(f" [{i + 1}/{n}] video2 ({chunk_type}): frames {v2_start}-{v2_end}") - extract_clip(video2, v2_start, v2_end, v2_clip, fps) - clips['video2'] = _rel_path(v2_clip) + + # Parallelize clip extractions within each chunk + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [] + if chunk_type != 'insert': + clips['video1'] = _rel_path(v1_clip) + # print(f" [{i + 1}/{n}] video 1: frames {v1_start}-{v1_end}") + futures.append(executor.submit(extract_clip, video1, v1_start, v1_end, v1_clip, fps)) + if chunk_type != 'delete': + clips['video2'] = _rel_path(v2_clip) + # print(f" [{i + 1}/{n}] video 2: frames {v2_start}-{v2_end}") + futures.append(executor.submit(extract_clip, video2, v2_start, v2_end, v2_clip, fps)) + for future in futures: + future.result() # --- diff clip --- diff_clip = output_dir / f"{i:03d}_diff.mp4" From b1f4f3ac04cb22ec7e712a9d2f6b336775ba1ad6 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 00:18:02 -0600 Subject: [PATCH 102/139] should have done this way --- selfdrive/ui/tests/diff/diff.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 56b23368f56d70..5ad42d8c9616b3 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -131,13 +131,13 @@ def _rel_path(p: Path) -> str: with ThreadPoolExecutor(max_workers=2) as executor: futures = [] if chunk_type != 'insert': - clips['video1'] = _rel_path(v1_clip) # print(f" [{i + 1}/{n}] video 1: frames {v1_start}-{v1_end}") futures.append(executor.submit(extract_clip, video1, v1_start, v1_end, v1_clip, fps)) + clips['video1'] = _rel_path(v1_clip) if chunk_type != 'delete': - clips['video2'] = _rel_path(v2_clip) # print(f" [{i + 1}/{n}] video 2: frames {v2_start}-{v2_end}") futures.append(executor.submit(extract_clip, video2, v2_start, v2_end, v2_clip, fps)) + clips['video2'] = _rel_path(v2_clip) for future in futures: future.result() From 3cf5dd7f812773e2946a2e421cb73a32bef09c7e Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 00:35:19 -0600 Subject: [PATCH 103/139] comment --- selfdrive/ui/tests/diff/diff.py | 1 + 1 file changed, 1 insertion(+) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 5ad42d8c9616b3..00a4f1b2010629 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -51,6 +51,7 @@ def get_video_frame_hashes(video1: Path, video2: Path) -> tuple[list[str], list[ @dataclass class Chunk: + """Represents a contiguous chunk of differences between the two videos. Ranges (start-end) are inclusive.""" type: Literal['replace', 'insert', 'delete'] v1_start: int v1_end: int From a8b947d41c05da6b75dcc891663f78e5ff746bfb Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 00:35:45 -0600 Subject: [PATCH 104/139] rename Chunk to DiffChunk --- selfdrive/ui/tests/diff/diff.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 00a4f1b2010629..4075e0610b0542 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -50,7 +50,7 @@ def get_video_frame_hashes(video1: Path, video2: Path) -> tuple[list[str], list[ @dataclass -class Chunk: +class DiffChunk: """Represents a contiguous chunk of differences between the two videos. Ranges (start-end) are inclusive.""" type: Literal['replace', 'insert', 'delete'] v1_start: int @@ -61,13 +61,13 @@ class Chunk: v2_count: int -def compute_diff_chunks(hashes1: list[str], hashes2: list[str]) -> list[Chunk]: - """Use difflib to compute diff chunks from the two hash lists. Returns a list of Chunk objects.""" +def compute_diff_chunks(hashes1: list[str], hashes2: list[str]) -> list[DiffChunk]: + """Use difflib to compute diff chunks from the two hash lists. Returns a list of DiffChunk objects.""" matcher = difflib.SequenceMatcher(a=hashes1, b=hashes2, autojunk=False) diff_ops: list[list] = [list(op) for op in matcher.get_opcodes() if op[0] != 'equal'] # filter out equal chunks - chunks: list[Chunk] = [] + chunks: list[DiffChunk] = [] for tag, i1, i2, j1, j2 in diff_ops: - chunks.append(Chunk( + chunks.append(DiffChunk( type=tag, v1_start=i1, v1_end=i2 - 1, v1_count=i2 - i1, v2_start=j1, v2_end=j2 - 1, v2_count=j2 - j1, @@ -108,14 +108,14 @@ def generate_thumbnail(video_path: Path, frame: int, out_path: Path, fps: float) subprocess.run(cmd, capture_output=True, check=True) -def extract_chunk_clips(video1: Path, video2: Path, chunks: list[Chunk], fps: float, basedir: str, folder_name: str) -> list[dict]: +def extract_chunk_clips(video1: Path, video2: Path, chunks: list[DiffChunk], fps: float, basedir: str, folder_name: str) -> list[dict]: """For each diff chunk extract clips from video1, video2, and a diff/highlight video.""" clip_sets: list[dict] = [] output_dir = DIFF_OUT_DIR / folder_name os.makedirs(output_dir, exist_ok=True) n = len(chunks) - def process_chunk(i: int, chunk: Chunk) -> dict: + def process_chunk(i: int, chunk: DiffChunk) -> dict: chunk_type = chunk.type v1_start, v1_end, v1_count = chunk.v1_start, chunk.v1_end, chunk.v1_count v2_start, v2_end, v2_count = chunk.v2_start, chunk.v2_end, chunk.v2_count From ffa9e300837f3eaff23da242e6cff03f702a3f8e Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 00:37:41 -0600 Subject: [PATCH 105/139] update comment --- selfdrive/ui/tests/diff/diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 4075e0610b0542..95488d9ab560f7 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -102,7 +102,7 @@ def extract_clip(video_path: Path, start_frame: int, end_frame: int, output_path def generate_thumbnail(video_path: Path, frame: int, out_path: Path, fps: float) -> None: - """Create a single-frame PNG thumbnail at the given frame index.""" + """Create a single-frame thumbnail at the given frame index. File format is determined by the output extension (e.g. .jpg or .png).""" t = frame / fps cmd = ['ffmpeg', '-nostdin', '-i', str(video_path), '-ss', f"{t:.6f}", '-frames:v', '1', '-vsync', '0', '-y', str(out_path)] subprocess.run(cmd, capture_output=True, check=True) From 0f81204b4322794300b5a37c403851e35dc684d6 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 00:50:25 -0600 Subject: [PATCH 106/139] cleanup --- selfdrive/ui/tests/diff/diff.py | 41 +++++++++++++++++---------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 95488d9ab560f7..bf4a8c00e6a2fd 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -43,9 +43,6 @@ def get_video_frame_hashes(video1: Path, video2: Path) -> tuple[list[str], list[ future2 = executor.submit(extract_framehashes, video2) hashes1 = future1.result() hashes2 = future2.result() - - print(f" Found {len(hashes1)} frames in video 1.") - print(f" Found {len(hashes2)} frames in video 2.") return hashes1, hashes2 @@ -109,11 +106,15 @@ def generate_thumbnail(video_path: Path, frame: int, out_path: Path, fps: float) def extract_chunk_clips(video1: Path, video2: Path, chunks: list[DiffChunk], fps: float, basedir: str, folder_name: str) -> list[dict]: - """For each diff chunk extract clips from video1, video2, and a diff/highlight video.""" + """For each diff chunk, extract clips from video1, video2, a diff video (if both are available), and a thumbnail image.""" clip_sets: list[dict] = [] output_dir = DIFF_OUT_DIR / folder_name os.makedirs(output_dir, exist_ok=True) - n = len(chunks) + n: int = len(chunks) + + def get_rel_path(p: Path) -> str: + """ Return path relative to the basedir.""" + return os.path.join(basedir, folder_name, p.name) def process_chunk(i: int, chunk: DiffChunk) -> dict: chunk_type = chunk.type @@ -121,24 +122,22 @@ def process_chunk(i: int, chunk: DiffChunk) -> dict: v2_start, v2_end, v2_count = chunk.v2_start, chunk.v2_end, chunk.v2_count clips: dict[str, str | None] = {'video1': None, 'video2': None, 'diff': None} - def _rel_path(p: Path) -> str: - """ Return path relative to the basedir.""" - return os.path.join(basedir, folder_name, p.name) - v1_clip = output_dir / f"{i:03d}_video1.mp4" v2_clip = output_dir / f"{i:03d}_video2.mp4" - # Parallelize clip extractions within each chunk + # Parallelize video1/video2 clip extractions within each chunk with ThreadPoolExecutor(max_workers=2) as executor: futures = [] if chunk_type != 'insert': + # --- video 1 clip --- # print(f" [{i + 1}/{n}] video 1: frames {v1_start}-{v1_end}") futures.append(executor.submit(extract_clip, video1, v1_start, v1_end, v1_clip, fps)) - clips['video1'] = _rel_path(v1_clip) + clips['video1'] = get_rel_path(v1_clip) if chunk_type != 'delete': + # --- video 2 clip --- # print(f" [{i + 1}/{n}] video 2: frames {v2_start}-{v2_end}") futures.append(executor.submit(extract_clip, video2, v2_start, v2_end, v2_clip, fps)) - clips['video2'] = _rel_path(v2_clip) + clips['video2'] = get_rel_path(v2_clip) for future in futures: future.result() @@ -147,7 +146,7 @@ def _rel_path(p: Path) -> str: if chunk_type == 'replace': # print(f" [{i + 1}/{n}] diff: frames {v1_start}-{v1_end} vs {v2_start}-{v2_end}") create_diff_video(v1_clip, v2_clip, diff_clip) - clips['diff'] = _rel_path(diff_clip) + clips['diff'] = get_rel_path(diff_clip) # --- thumbnail (middle frame of the diff content inside the clip) --- padding_used = min((v1_start if chunk_type != 'insert' else v2_start), CLIP_PADDING_BEFORE) @@ -160,7 +159,7 @@ def _rel_path(p: Path) -> str: generate_thumbnail(thumb_source, thumb_frame, thumb_path, fps) return { - 'type': chunk_type, 'clips': clips, 'thumb': _rel_path(thumb_path), + 'type': chunk_type, 'clips': clips, 'thumb': get_rel_path(thumb_path), 'v1_start': v1_start, 'v1_end': v1_end, 'v1_count': v1_count, 'v2_start': v2_start, 'v2_end': v2_end, 'v2_count': v2_count, } @@ -240,33 +239,35 @@ def main(): print(f"Diff chunks: {chunks_folder_name}") print() - print("[1/4] Creating full diff video in background...") + print("[1/5] Starting diff video generation in background thread...") diff_thread = threading.Thread(target=create_diff_video, args=(video1, video2, DIFF_OUT_DIR / diff_video_name)) diff_thread.start() - print("[2/4] Hashing frames...") + print("[2/5] Hashing frames...") hashes1, hashes2 = get_video_frame_hashes(video1, video2) frame_counts = (len(hashes1), len(hashes2)) + print(f" Found {frame_counts[0]} frames in video 1 and {frame_counts[1]} frames in video 2.") + print("[3/5] Computing diff chunks...") chunks = compute_diff_chunks(hashes1, hashes2) diff_frame_count = sum(max(c.v1_count, c.v2_count) for c in chunks) clip_sets = [] if chunks: - print(f"[3/4] Extracting {len(chunks)} diff chunk(s)...") + print(f"[4/5] Extracting {len(chunks)} diff chunk(s)...") fps = get_video_fps(video1) clip_sets = extract_chunk_clips(video1, video2, chunks, fps, args.basedir, chunks_folder_name) else: - print("[3/4] No diff chunks found, skipping clip extraction.") + print("[4/5] No diff chunks found, skipping clip extraction.") - print("[4/4] Generating HTML report...") + print("[5/5] Generating HTML report...") html = generate_html_report((video1, video2), args.basedir, diff_frame_count, frame_counts, diff_video_name, clip_sets) output_path = DIFF_OUT_DIR / args.output with open(output_path, 'w') as f: f.write(html) - print(f"Report generated at: {output_path}") + print(f" Report generated at: {output_path}") # Open in browser by default if not args.no_open: From e9449c8e350d255595abfc2d5557d955dd0698c9 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 00:55:33 -0600 Subject: [PATCH 107/139] clean --- selfdrive/ui/tests/diff/diff_template.html | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index f7002572e3fb0d..c8b701bd7527e3 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -53,7 +53,7 @@

UI Diff

{ title: 'Full Video', desc: '', type: '', srcs: ['$VIDEO1_SRC', '$VIDEO2_SRC', '$DIFF_SRC'], loop: true }, ...chunks.map(({ v1_start, v1_end, v2_start, v2_end, v1_count, v2_count, type, clips, thumb }, i) => { // Build title based on type and frame counts - let title; + let title = ''; if (type === 'insert') { title = `➕ Added ${frames_label(v2_count)}`; } else if (type === 'delete') { @@ -66,7 +66,7 @@

UI Diff

title = `✏️${changeType} Changed ${v1_count}→${v2_count} frames`; } } else { - title = frames_label(Math.max(v1_count || 0, v2_count || 0)); + title = frames_label(v1_count || 0, v2_count || 0); } // Build description with frame ranges @@ -77,11 +77,6 @@

UI Diff

desc = video_frames_range_label(1, v1_start, v1_end); } else if (type === 'replace') { desc = `${video_frames_range_label(1, v1_start, v1_end)} → ${video_frames_range_label(2, v2_start, v2_end)}`; - } else { - const parts = []; - if (v1_count) parts.push(video_frames_range_label(1, v1_start, v1_end)); - if (v2_count) parts.push(video_frames_range_label(2, v2_start, v2_end)); - desc = parts.join(' | '); } return { From cdb00c767c3a83f719f65cf105cd36b0cf274e30 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 01:05:03 -0600 Subject: [PATCH 108/139] make selected nav index black --- selfdrive/ui/tests/diff/diff_template.html | 1 + 1 file changed, 1 insertion(+) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index c8b701bd7527e3..7a4534e5a37f18 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -17,6 +17,7 @@ #nav button { width: 200px; display: flex; flex-direction: column; align-items: center; padding: 0; border: 2px solid #aaa; border-radius: 6px; cursor: pointer; background: #f5f5f5; position: relative; } #nav button img { width: 100%; } #nav button.active { border-color: #0078d4; background: #e5f2fc; border-width: 3px; } + #nav button.active .nav-index { color: black; } #nav button.type-full { justify-content: center; } #nav button.type-insert { border-color: #2a9d2a; } #nav button.type-insert.active { border-color: #1a7a1a; background: #e6f7e6; } From 0ddcf3d32ceac1aabf4fad44aa62957d9cde9983 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 15:38:45 -0600 Subject: [PATCH 109/139] rename --- selfdrive/ui/tests/diff/diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index bf4a8c00e6a2fd..ab1cc81dbc7d9c 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -236,7 +236,7 @@ def main(): print(f"Video 2 : {video2}") print(f"HTML output: {args.output}") print(f"Diff video : {diff_video_name}") - print(f"Diff chunks: {chunks_folder_name}") + print(f"Chunks dir : {chunks_folder_name}") print() print("[1/5] Starting diff video generation in background thread...") From f9ebe5341b5303c92747d000c357fe8368bc32a8 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 15:39:14 -0600 Subject: [PATCH 110/139] align --- selfdrive/ui/tests/diff/diff.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index ab1cc81dbc7d9c..0d0937b38400af 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -232,11 +232,11 @@ def main(): print("=" * 60) print("UI VIDEO DIFF REPORT") print("=" * 60) - print(f"Video 1 : {video1}") - print(f"Video 2 : {video2}") - print(f"HTML output: {args.output}") - print(f"Diff video : {diff_video_name}") - print(f"Chunks dir : {chunks_folder_name}") + print(f"Video 1: {video1}") + print(f"Video 2: {video2}") + print(f"HTML output: {args.output}") + print(f"Diff video: {diff_video_name}") + print(f"Chunks dir: {chunks_folder_name}") print() print("[1/5] Starting diff video generation in background thread...") From bb58c1254f690268041def3d29047f040372395e Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 15:40:48 -0600 Subject: [PATCH 111/139] log found chunks count --- selfdrive/ui/tests/diff/diff.py | 1 + 1 file changed, 1 insertion(+) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 0d0937b38400af..9ae7091e52321d 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -251,6 +251,7 @@ def main(): print("[3/5] Computing diff chunks...") chunks = compute_diff_chunks(hashes1, hashes2) diff_frame_count = sum(max(c.v1_count, c.v2_count) for c in chunks) + print(f" Found {len(chunks)} diff chunk(s) with a total of {diff_frame_count} different frames.") clip_sets = [] if chunks: From 1db3049834396dd52f5523813842572d2175e618 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 15:51:09 -0600 Subject: [PATCH 112/139] log more --- selfdrive/ui/tests/diff/diff.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 9ae7091e52321d..a59e924434c481 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -166,7 +166,7 @@ def process_chunk(i: int, chunk: DiffChunk) -> dict: # Process chunks in parallel with a thread pool max_workers = min(8, len(chunks)) - print(f" Running with up to {max_workers} threads...") + print(f" Processing {len(chunks)} chunks with {max_workers} threads...") with ThreadPoolExecutor(max_workers) as executor: futures = [executor.submit(process_chunk, i, chunk) for i, chunk in enumerate(chunks)] for future in futures: @@ -256,7 +256,9 @@ def main(): clip_sets = [] if chunks: print(f"[4/5] Extracting {len(chunks)} diff chunk(s)...") + print(" Getting video fps...", end=' ') fps = get_video_fps(video1) + print(f"{fps:.2f} fps") clip_sets = extract_chunk_clips(video1, video2, chunks, fps, args.basedir, chunks_folder_name) else: print("[4/5] No diff chunks found, skipping clip extraction.") From 10ef9f48cb43e0331e36aec8ab4f7fbd388848ab Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 15:53:26 -0600 Subject: [PATCH 113/139] add chunks progress bar --- selfdrive/ui/tests/diff/diff.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index a59e924434c481..97983f4f380ba1 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -8,9 +8,10 @@ import argparse import threading from concurrent.futures import ThreadPoolExecutor +from tqdm import tqdm +from pathlib import Path from dataclasses import dataclass from typing import Literal -from pathlib import Path from openpilot.common.basedir import BASEDIR DIFF_OUT_DIR = Path(BASEDIR) / "selfdrive" / "ui" / "tests" / "diff" / "report" @@ -169,8 +170,7 @@ def process_chunk(i: int, chunk: DiffChunk) -> dict: print(f" Processing {len(chunks)} chunks with {max_workers} threads...") with ThreadPoolExecutor(max_workers) as executor: futures = [executor.submit(process_chunk, i, chunk) for i, chunk in enumerate(chunks)] - for future in futures: - print(f" Processed chunk {futures.index(future) + 1}/{n}") + for future in tqdm(futures, desc="Processing chunks"): clip_sets.append(future.result()) return clip_sets From aec975f4750f2322fb6a05b3b6ed2baddd9f6232 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 16:11:23 -0600 Subject: [PATCH 114/139] cleanup and better progress --- selfdrive/ui/tests/diff/diff.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/selfdrive/ui/tests/diff/diff.py b/selfdrive/ui/tests/diff/diff.py index 97983f4f380ba1..5654eabd7d3c62 100755 --- a/selfdrive/ui/tests/diff/diff.py +++ b/selfdrive/ui/tests/diff/diff.py @@ -7,7 +7,7 @@ import webbrowser import argparse import threading -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, as_completed from tqdm import tqdm from pathlib import Path from dataclasses import dataclass @@ -108,10 +108,8 @@ def generate_thumbnail(video_path: Path, frame: int, out_path: Path, fps: float) def extract_chunk_clips(video1: Path, video2: Path, chunks: list[DiffChunk], fps: float, basedir: str, folder_name: str) -> list[dict]: """For each diff chunk, extract clips from video1, video2, a diff video (if both are available), and a thumbnail image.""" - clip_sets: list[dict] = [] output_dir = DIFF_OUT_DIR / folder_name os.makedirs(output_dir, exist_ok=True) - n: int = len(chunks) def get_rel_path(p: Path) -> str: """ Return path relative to the basedir.""" @@ -130,33 +128,29 @@ def process_chunk(i: int, chunk: DiffChunk) -> dict: with ThreadPoolExecutor(max_workers=2) as executor: futures = [] if chunk_type != 'insert': - # --- video 1 clip --- - # print(f" [{i + 1}/{n}] video 1: frames {v1_start}-{v1_end}") + # video 1 clip futures.append(executor.submit(extract_clip, video1, v1_start, v1_end, v1_clip, fps)) clips['video1'] = get_rel_path(v1_clip) if chunk_type != 'delete': - # --- video 2 clip --- - # print(f" [{i + 1}/{n}] video 2: frames {v2_start}-{v2_end}") + # video 2 clip futures.append(executor.submit(extract_clip, video2, v2_start, v2_end, v2_clip, fps)) clips['video2'] = get_rel_path(v2_clip) for future in futures: future.result() - # --- diff clip --- + # diff clip diff_clip = output_dir / f"{i:03d}_diff.mp4" if chunk_type == 'replace': - # print(f" [{i + 1}/{n}] diff: frames {v1_start}-{v1_end} vs {v2_start}-{v2_end}") create_diff_video(v1_clip, v2_clip, diff_clip) clips['diff'] = get_rel_path(diff_clip) - # --- thumbnail (middle frame of the diff content inside the clip) --- + # thumbnail (middle frame of the diff content inside the clip) padding_used = min((v1_start if chunk_type != 'insert' else v2_start), CLIP_PADDING_BEFORE) content_count = v1_count if chunk_type != 'insert' else v2_count thumb_frame = padding_used + content_count // 2 thumb_ext = 'png' if chunk_type == 'replace' else 'jpg' # Use PNG for the diff thumbnails for clarity; JPG is smaller for the other thumbnails thumb_path = output_dir / f"{i:03d}_thumb.{thumb_ext}" thumb_source = diff_clip if chunk_type == 'replace' else (v1_clip if chunk_type == 'delete' else v2_clip) - # print(f" [{i + 1}/{n}] thumbnail: frame {thumb_frame}") generate_thumbnail(thumb_source, thumb_frame, thumb_path, fps) return { @@ -168,10 +162,13 @@ def process_chunk(i: int, chunk: DiffChunk) -> dict: # Process chunks in parallel with a thread pool max_workers = min(8, len(chunks)) print(f" Processing {len(chunks)} chunks with {max_workers} threads...") + results = [] with ThreadPoolExecutor(max_workers) as executor: futures = [executor.submit(process_chunk, i, chunk) for i, chunk in enumerate(chunks)] - for future in tqdm(futures, desc="Processing chunks"): - clip_sets.append(future.result()) + for future in tqdm(as_completed(futures), total=len(futures), desc="Processing chunks"): + results.append(future.result()) + # results will be out of order due to parallel processing, so sort them back to the original order based on v1_start frame index + clip_sets = sorted(results, key=lambda x: x['v1_start']) return clip_sets @@ -239,7 +236,7 @@ def main(): print(f"Chunks dir: {chunks_folder_name}") print() - print("[1/5] Starting diff video generation in background thread...") + print("[1/5] Starting full video diff generation in background thread...") diff_thread = threading.Thread(target=create_diff_video, args=(video1, video2, DIFF_OUT_DIR / diff_video_name)) diff_thread.start() From 385ac67670907c0031506d8b6965f7f2d2c0d947 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 22 Feb 2026 17:38:12 -0600 Subject: [PATCH 115/139] move --- selfdrive/ui/tests/diff/diff_template.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/tests/diff/diff_template.html b/selfdrive/ui/tests/diff/diff_template.html index 7a4534e5a37f18..873f89207dbb3f 100644 --- a/selfdrive/ui/tests/diff/diff_template.html +++ b/selfdrive/ui/tests/diff/diff_template.html @@ -37,9 +37,9 @@

UI Diff

-

Results: $RESULT_TEXT


+

Results: $RESULT_TEXT