Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion public/js/compare/start.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 => {
Expand Down
42 changes: 36 additions & 6 deletions public/js/compare/timeline.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions test/timeline.test.js
Original file line number Diff line number Diff line change
@@ -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']);
Expand Down Expand Up @@ -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');
});
Loading