From 92ac83388806e1fa4691f66f20ddef22288389db Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Wed, 21 Oct 2026 15:22:08 +0200 Subject: [PATCH] Show the nearest frame when a filmstrip image is missing Some sitespeed.io runs list visual progress times that have no image on the server, so the filmstrip showed broken images. A frame that does not load is now replaced by the nearest one that does. The nearest, not the previous one, because the frame before a missing first visual change is often still blank. Assisted-by: Claude Opus 5.5 noreply@anthropic.com --- public/js/compare/start.js | 7 +++++- public/js/compare/timeline.js | 42 ++++++++++++++++++++++++++++++----- test/timeline.test.js | 41 ++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/public/js/compare/start.js b/public/js/compare/start.js index e4ff4c4..0629cd3 100644 --- a/public/js/compare/start.js +++ b/public/js/compare/start.js @@ -1,4 +1,4 @@ -/* global createMainDropZone, showLoading, loadHARsFromConfig, loadFilesFromGist, loadFilesFromConfig, loadFromPaste, compareFromForm, errorMessage, showUpload, toggleWaterfallLayout, blendWaterfalls, regenerate, setCompareMode, pickRun, setMetricsQuery, toggleAllMetrics, toggleMetricSection, setRequestFilter, setRequestSort, setRequestMin, toggleTimelinePlay, scrubTimeline, showMetric, linkMetric, setTimelineSpeed, stepTimelineFrame, jumpTimeline, setDrawTool, undoDrawing, clearDrawings, saveDrawingPng */ +/* global createMainDropZone, showLoading, loadHARsFromConfig, loadFilesFromGist, loadFilesFromConfig, loadFromPaste, compareFromForm, errorMessage, showUpload, toggleWaterfallLayout, blendWaterfalls, regenerate, setCompareMode, pickRun, setMetricsQuery, toggleAllMetrics, toggleMetricSection, setRequestFilter, setRequestSort, setRequestMin, toggleTimelinePlay, scrubTimeline, showMetric, linkMetric, setTimelineSpeed, stepTimelineFrame, jumpTimeline, setDrawTool, undoDrawing, clearDrawings, saveDrawingPng, replaceMissingFrame */ // Start-up code for the page. It lives in a file, not in index.html, // because the Content-Security-Policy blocks inline scripts. @@ -128,6 +128,11 @@ for (const [type, selector] of Object.entries(actionElements)) { }); } +// Image errors do not bubble, so listen in the capture phase. +document.addEventListener('error', e => { + if (e.target instanceof HTMLImageElement) replaceMissingFrame(e.target); +}, true); + // Pointing at (or tabbing to) a metric highlights it everywhere. for (const [type, on] of [['mouseover', true], ['mouseout', false], ['focusin', true], ['focusout', false]]) { document.addEventListener(type, e => { diff --git a/public/js/compare/timeline.js b/public/js/compare/timeline.js index 1091693..72ddb40 100644 --- a/public/js/compare/timeline.js +++ b/public/js/compare/timeline.js @@ -1,5 +1,5 @@ /* global generateVisualProgress, prefersReducedMotion, parseTemplate */ -/* exported initTimeline, setTimelineTime, scrubTimeline, toggleTimelinePlay, stopTimeline, addTimelineWaterfall, divergenceBands, setTimelineSpeed, stepTimelineFrame, jumpTimeline */ +/* exported initTimeline, setTimelineTime, scrubTimeline, toggleTimelinePlay, stopTimeline, addTimelineWaterfall, divergenceBands, setTimelineSpeed, stepTimelineFrame, jumpTimeline, replaceMissingFrame */ // The shared time cursor. The visual progress chart, the two videos (or // filmstrip frames), the milestone timeline, the filmstrip and the @@ -15,7 +15,9 @@ const timeline = { sides: {}, columns: [], current: null, - waterfalls: {} + waterfalls: {}, + // Frame images that did not load. + missingFrames: new Set() }; // Time ranges where the visual progress of the two runs differs by @@ -40,6 +42,36 @@ function divergenceBands(vp1, vp2, maxTime, threshold = 20) { return bands; } +// The image of the frame at ms. When that image did not load, the frame +// nearest in time that did (the earlier one on a tie): the frame before +// a missing first visual change is often still blank. +function frameAt(key, ms) { + const side = timeline.sides[key]; + const frames = (side && side.frames) || []; + if (!frames.length) return null; + const wanted = lastAtOrBefore(frames, ms, f => f.ms); + if (!timeline.missingFrames.has(wanted.img)) return wanted.img; + const timeOf = f => (f.sourceMs === undefined ? f.ms : f.sourceMs); + let nearest = null; + for (const f of frames) { + if (timeline.missingFrames.has(f.img)) continue; + if (!nearest || Math.abs(timeOf(f) - timeOf(wanted)) < Math.abs(timeOf(nearest) - timeOf(wanted))) nearest = f; + } + return nearest && nearest.img; +} + +// A frame image did not load. Some sitespeed.io runs have VisualProgress +// times with no image on disk. Show the nearest frame instead. +function replaceMissingFrame(img) { + const cell = img.closest('.filmstrip-cell'); + if (!img.classList.contains('timeline-frame') && !cell) return; + timeline.missingFrames.add(img.getAttribute('src')); + const key = cell ? (cell.classList.contains('filmstrip-cell--har2') ? '2' : '1') : img.dataset.side; + const column = img.closest('.filmstrip-column'); + const replacement = frameAt(key, column ? Number(column.dataset.ms) : timeline.t); + if (replacement && replacement !== img.getAttribute('src')) img.setAttribute('src', replacement); +} + function lastAtOrBefore(list, t, getTime) { let found = list[0]; for (const item of list) { @@ -154,10 +186,8 @@ function setTimelineTime(t, seekVideos) { progress.textContent = lastAtOrBefore(vp, ms, v => v.time).value + '% rendered'; } const frame = document.querySelector('.timeline-frame[data-side="' + key + '"]'); - if (frame && side.frames && side.frames.length) { - const img = lastAtOrBefore(side.frames, ms, f => f.ms).img; - if (frame.getAttribute('src') !== img) frame.setAttribute('src', img); - } + const img = frameAt(key, ms); + if (frame && img && frame.getAttribute('src') !== img) frame.setAttribute('src', img); const video = document.querySelector('.timeline-video[data-side="' + key + '"]'); if (video && seekVideos && Math.abs(video.currentTime - ms / 1000) > 0.04) { video.currentTime = ms / 1000; diff --git a/test/timeline.test.js b/test/timeline.test.js index e15aeba..ab90fd0 100644 --- a/test/timeline.test.js +++ b/test/timeline.test.js @@ -1,5 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import vm from 'node:vm'; import { loadScripts, readHar } from './helpers.js'; const { divergenceBands } = loadScripts(['timeline']); @@ -35,3 +36,43 @@ test('divergenceBands on the 2026 fixtures: the outlier run renders late', () => // stays at 61-62% (A is at 99%) until it reaches 95% at 2266 ms. assert.deepEqual(bands(a[4], b[2], 3500)[0], [1200, 2266]); }); + +// A frame image with just what replaceMissingFrame reads. +function frameImage(src, { side, ms }) { + const attrs = { src }; + const isCell = ms !== undefined; + const cell = isCell && { classList: { contains: c => c === 'filmstrip-cell--har' + side } }; + const column = isCell && { dataset: { ms: String(ms) } }; + return { + classList: { contains: c => c === 'timeline-frame' && !isCell }, + dataset: { side: String(side) }, + closest: sel => (sel === '.filmstrip-cell' ? cell : sel === '.filmstrip-column' ? column : null) || null, + getAttribute: name => attrs[name], + setAttribute: (name, value) => { attrs[name] = value; } + }; +} + +test('a frame that does not load is replaced by the nearest frame that does', () => { + const context = loadScripts(['timeline']); + const state = vm.runInContext('timeline', context); + // Run 1 of the Sweden test: VisualProgress has 1100 ms, the server has no image. + state.sides = { + 1: { frames: [{ ms: 0, img: 'ms_000000' }, { ms: 1100, img: 'ms_001100' }, { ms: 1133, img: 'ms_001133' }] } + }; + const cell = frameImage('ms_001100', { side: 1, ms: 1100 }); + context.replaceMissingFrame(cell); + // 1133 ms is nearer than the blank frame at 0 ms. + assert.equal(cell.getAttribute('src'), 'ms_001133'); + + // The timeline frame skips the missing image too. + state.t = 1110; + const frame = frameImage('ms_001100', { side: 1 }); + context.replaceMissingFrame(frame); + assert.equal(frame.getAttribute('src'), 'ms_001133'); + + // On a tie, the earlier frame. + state.sides[1].frames = [{ ms: 0, img: 'ms_000000' }, { ms: 1100, img: 'ms_001100' }, { ms: 2200, img: 'ms_002200' }]; + const tie = frameImage('ms_001100', { side: 1, ms: 1100 }); + context.replaceMissingFrame(tie); + assert.equal(tie.getAttribute('src'), 'ms_000000'); +});