fix(player): an embed without a height shows its film, and heavy films are measured in Safari (#5932)

* test(player): measure heavy films in real Safari and Chrome on macOS

* test(player): count runtime media seeks and rate writes during the Safari bench

* test(player): compare the Safari bench against native playback of the same media

* test(player): split Safari control playback into per-frame media writes and per-frame transforms

* perf(core): write media volume and mute only when they change

* test(player): presented frame rate and compositor-animation controls in the Safari bench

* perf(core): keep the media volume guard without a new comment line

* test(player): derive presented frames from playback counters and snapshot video state

* test(player): control page plays only listed media, and anchor the Safari process match

* test(player): send the bench as code, and write only validated numbers from the browser

* fix(player): perf host page gives the player a real box, so films are on screen when measured

* fix(player): a player with no height set takes its film's shape instead of collapsing to nothing

The player box had no intrinsic height, so an embed that set only a width (or nothing) was 0 px tall
and showed no picture. An in-flow sizer now carries the composition's aspect ratio; a height the
embed sets still wins, and a height-only embed keeps its full width.

* test(player): label each embed on the player box page
This commit is contained in:
Miguel Ángel
2026-10-20 15:05:46 +00:00
committed by GitHub
parent 6d357ff0d8
commit 46cb5ca446
27 changed files with 1893 additions and 3 deletions
+7
View File
@@ -140,6 +140,13 @@ jobs:
PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
run: bun run test:browser-raster
- name: Verify the player takes the box its embed asks for (load shard only)
if: matrix.shard == 'load'
working-directory: packages/player
env:
PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
run: bun run test:browser-box
- name: Run player perf — ${{ matrix.shard }} (measure mode)
working-directory: packages/player
env:
+63
View File
@@ -0,0 +1,63 @@
name: Player Safari perf
permissions:
contents: read
env:
HYPERFRAMES_NO_TELEMETRY: "1"
on:
pull_request:
paths:
- "packages/player/**"
- "packages/core/src/runtime/**"
- ".github/workflows/player-safari.yml"
schedule:
- cron: "23 7 * * *"
workflow_dispatch:
concurrency:
group: player-safari-${{ github.ref }}
cancel-in-progress: true
jobs:
heavy-films:
name: "Heavy films: Safari vs Chrome"
runs-on: macos-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- run: bun install --frozen-lockfile
# The bench serves packages/player/dist and packages/core/dist, as the Linux perf shards do.
- run: bun run build
- name: Generate heavy media
run: |
brew install ffmpeg
bash packages/player/tests/perf/browsers/make-media.sh
- name: Enable safaridriver
run: sudo safaridriver --enable
- name: Run heavy films in Safari and Chrome
working-directory: packages/player
run: |
sw_vers
CHROMEDRIVER="$CHROMEWEBDRIVER/chromedriver" bun run tests/perf/browsers/run.ts --browsers safari,chrome --runs 2
- name: Upload results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: player-safari-heavy-films
path: packages/player/tests/perf/results/
if-no-files-found: warn
retention-days: 30
+1
View File
@@ -46,6 +46,7 @@ packages/producer/tests/parity/fixtures/hyperframe.runtime.iife.js
# Player perf test results (generated each run, attached as CI artifact)
packages/player/tests/perf/results/
packages/player/tests/perf/fixtures/_media/
# Rendered output (not test fixtures — those use git LFS)
output/
+40
View File
@@ -1387,6 +1387,46 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.volume).toBe(0.5);
});
it("writes volume and muted only when they change, not on every tick", () => {
const clip = createMockClip({ start: 0, end: 10, volume: 0.7 });
const writes = { volume: 0, muted: 0 };
for (const prop of ["volume", "muted"] as const) {
const native = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, prop)!;
Object.defineProperty(clip.el, prop, {
configurable: true,
get() {
return native.get!.call(this);
},
set(value) {
writes[prop]++;
native.set!.call(this, value);
},
});
}
for (const timeSeconds of [1, 1.04, 1.08, 1.12]) {
syncRuntimeMedia({
clips: [clip],
timeSeconds,
playing: false,
playbackRate: 1,
outputMuted: true,
});
}
expect(clip.el.volume).toBe(0.7);
expect(clip.el.muted).toBe(true);
expect(writes).toEqual({ volume: 0, muted: 1 });
clip.el.muted = false;
syncRuntimeMedia({
clips: [clip],
timeSeconds: 1.16,
playing: false,
playbackRate: 1,
outputMuted: true,
});
expect(clip.el.muted).toBe(true);
});
it("reports effective and author-only volume to external audio transports", () => {
const clip = createMockClip({ start: 0, end: 10, volume: 0 });
const onElementVolume = vi.fn();
+2 -2
View File
@@ -518,12 +518,12 @@ export function syncRuntimeMedia(params: {
const silencedByHidden =
el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el);
const effectiveVolume = silencedByHidden ? 0 : clampVolume(authorVolume * userVol);
el.volume = effectiveVolume;
if (Math.abs(el.volume - effectiveVolume) > 1e-6) el.volume = effectiveVolume;
lastRuntimeAppliedVolume.set(el, effectiveVolume);
params.onElementVolume?.(el, effectiveVolume, authorVolume);
// Mute only when force-muted or the transport owns this element; an unclaimed
// track stays audible via the HTMLMedia fallback.
if (forceMuteAll || params.isWebAudioOwned?.(el)) el.muted = true;
if ((forceMuteAll || params.isWebAudioOwned?.(el)) && !el.muted) el.muted = true;
// Ensure full preload for every active media element. Streaming
// formats (MP3) may arrive with preload="metadata", which only
// buffers the first few seconds and causes seeks to silently fail
+1
View File
@@ -33,6 +33,7 @@
"test": "vitest run",
"test:browser-security": "bun run tests/browser/sandbox-origin.ts",
"test:browser-raster": "bun run tests/browser/preview-raster-size.ts",
"test:browser-box": "bun run tests/browser/player-box.ts",
"perf": "bun run tests/perf/index.ts"
},
"dependencies": {
@@ -120,6 +120,7 @@ class HyperframesPlayer extends HTMLElement {
private shadow: ShadowRoot;
private container: HTMLDivElement;
private readonly sizer = document.createElement("div");
private iframe: HTMLIFrameElement;
private posterEl: HTMLImageElement | null = null;
private controlsApi: ReturnType<typeof setupControls> | null = null;
@@ -169,6 +170,8 @@ class HyperframesPlayer extends HTMLElement {
adoptShadowStyles(this.shadow, PLAYER_STYLES);
({ container: this.container, iframe: this.iframe } = createCompositionIframe());
this.sizer.className = "hfp-sizer";
this.shadow.appendChild(this.sizer);
this.shadow.appendChild(this.container);
const loaderElements = createShaderLoader();
@@ -1486,6 +1489,7 @@ class HyperframesPlayer extends HTMLElement {
const changed = width !== this._compositionWidth || height !== this._compositionHeight;
this._compositionWidth = width;
this._compositionHeight = height;
this.sizer.style.aspectRatio = `${width} / ${height}`;
this._rescale();
if (!changed) return;
const detail = { compositionWidth: width, compositionHeight: height };
+5
View File
@@ -7,6 +7,11 @@ export const PLAYER_STYLES = /* css */ `
contain: layout style;
}
/* The only in-flow child: with no height set, the player takes the composition's shape. */
.hfp-sizer {
aspect-ratio: 16 / 9;
}
.hfp-container {
position: absolute;
inset: 0;
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { launchBrowser } from "../perf/runner.js";
import { startServer } from "../perf/server.js";
// An embed that sets no height, only a height, only a width, or both, gets the box it asks for.
const server = startServer({ noCache: true });
const browser = await launchBrowser({ width: 1280, height: 1200 });
type BoxCase = { name: string; width: number; height: number; ok: boolean };
try {
const page = await browser.newPage();
await page.goto(`${server.origin}/fixtures/_control/player-box.html`);
await page.waitForFunction(() => (window as { __box?: unknown }).__box, { timeout: 30_000 });
const { cases, ratioHeightOnlyWidth } = (await page.evaluate(
() => (window as { __box?: unknown }).__box,
)) as { cases: BoxCase[]; ratioHeightOnlyWidth: number };
console.log(
`a plain block with aspect-ratio and only a height is ${ratioHeightOnlyWidth}px wide`,
);
for (const c of cases) {
assert(c.ok, `${c.name}: player box is ${c.width}x${c.height}`);
}
console.log(`player takes the box its embed asks for (${cases.length} embeds): PASS`);
} finally {
await browser.close();
await server.stop();
}
+1
View File
@@ -0,0 +1 @@
export function bench(cfg: { playMs: number; seekFractions: number[] }): Promise<unknown>;
@@ -0,0 +1,196 @@
// Runs inside the perf server's host page in any browser; run.ts sends this function's source over WebDriver.
// Resolves with raw per-frame samples so every browser is scored by the same code in run.ts.
export async function bench(cfg) {
const now = () => performance.now();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const nextFrame = () => new Promise((r) => requestAnimationFrame(r));
async function until(pred, timeoutMs, what) {
const end = now() + timeoutMs;
while (!pred()) {
if (now() > end) throw new Error(`timed out after ${timeoutMs} ms waiting for ${what}`);
await sleep(10);
}
}
await until(() => window.__playerReady || window.__playerError, 60_000, "player ready");
if (window.__playerError) throw new Error(`player error: ${window.__playerError}`);
await until(() => window.__playerAssetsReady, 60_000, "player assetsready");
const host = document.getElementById("player");
const iframe = host.shadowRoot.querySelector("iframe");
const win = iframe.contentWindow;
const runtime = win.__player;
const videos = () => Array.from(iframe.contentDocument.querySelectorAll("video"));
const shown = (v) => {
const s = win.getComputedStyle(v);
return s.display !== "none" && s.visibility !== "hidden" && Number(s.opacity) > 0;
};
const decoded = () =>
videos().reduce(
(acc, v) => {
const q = v.getVideoPlaybackQuality ? v.getVideoPlaybackQuality() : null;
if (q) {
acc.dropped += q.droppedVideoFrames;
acc.total += q.totalVideoFrames;
}
return acc;
},
{ dropped: 0, total: 0 },
);
const mediaErrors = () =>
videos()
.filter((v) => v.error)
.map((v) =>
`${v.currentSrc.split("/").pop()}: ${v.error.code} ${v.error.message || ""}`.trim(),
);
// The composition's own rAF callbacks (the runtime tick) are timed, so work splits into script and rendering.
let compositionScriptMs = 0;
const compositionRaf = win.requestAnimationFrame.bind(win);
win.requestAnimationFrame = (callback) =>
compositionRaf((ts) => {
const start = now();
try {
callback(ts);
} finally {
compositionScriptMs += now() - start;
}
});
// A message posted from a rAF callback is delivered after that frame's style, layout and paint,
// so (delivery - callback start) is the frame's main-thread work, runtime tick included.
function recordFrames(durationMs) {
const frames = [];
const channel = new MessageChannel();
let pending = null;
channel.port1.onmessage = () => {
if (pending) {
pending.work = now() - pending.start;
pending.script = compositionScriptMs;
}
compositionScriptMs = 0;
pending = null;
};
return new Promise((resolve) => {
const end = now() + durationMs;
function tick(ts) {
const frame = { ts, start: now(), work: null, script: null, comp: runtime.getTime() };
frames.push(frame);
pending = frame;
channel.port2.postMessage(0);
if (now() < end) requestAnimationFrame(tick);
else setTimeout(() => resolve(frames), 50);
}
requestAnimationFrame(tick);
});
}
// What the runtime does to its media during playback; a seek or rate change resets a decoder.
const mediaOps = { seek: 0, rate: 0, play: 0, pause: 0, load: 0 };
let countOps = false;
const rateLog = [];
const proto = win.HTMLMediaElement.prototype;
for (const [prop, key] of [
["currentTime", "seek"],
["playbackRate", "rate"],
]) {
const d = Object.getOwnPropertyDescriptor(proto, prop);
Object.defineProperty(proto, prop, {
...d,
set(value) {
if (countOps) mediaOps[key]++;
if (countOps && key === "rate" && rateLog.length < 40) {
rateLog.push({ at: Math.round(now()), from: this.playbackRate, to: value });
}
d.set.call(this, value);
},
});
}
for (const method of ["play", "pause", "load"]) {
const original = proto[method];
proto[method] = function (...args) {
if (countOps) mediaOps[method]++;
return original.apply(this, args);
};
}
const idle = await recordFrames(1000);
host.play();
await until(() => runtime.isPlaying(), 5_000, "isPlaying");
countOps = true;
const decodedBefore = decoded();
const droppedTimeline = [];
const sampler = setInterval(
() => droppedTimeline.push(decoded().dropped - decodedBefore.dropped),
500,
);
const startTimes = new Map(videos().map((v) => [v, v.currentTime]));
const play = await recordFrames(cfg.playMs);
const videoStates = videos().map((v) => ({
src: v.currentSrc.split("/").pop(),
readyState: v.readyState,
networkState: v.networkState,
paused: v.paused,
advanced: Math.round((v.currentTime - (startTimes.get(v) ?? 0)) * 100) / 100,
bufferedEnd: v.buffered.length
? Math.round(v.buffered.end(v.buffered.length - 1) * 10) / 10
: 0,
shown: shown(v),
}));
clearInterval(sampler);
const decodedAfter = decoded();
countOps = false;
host.pause();
await until(() => !runtime.isPlaying(), 5_000, "pause");
// Seek to paint: the timeline reports the target, then every shown video has presented
// a frame for it (requestVideoFrameCallback, or settled after two frames), then one paint.
const duration = runtime.getDuration();
const seeks = [];
for (const fraction of cfg.seekFractions) {
const target = Math.round(duration * fraction * 30) / 30;
const presented = new Set();
for (const v of videos()) {
if (v.requestVideoFrameCallback) v.requestVideoFrameCallback(() => presented.add(v));
}
const t0 = now();
host.seek(target);
let timedOut = false;
try {
await until(() => Math.abs(runtime.getTime() - target) < 0.05, 5_000, `time ${target}`);
await nextFrame();
await nextFrame();
await until(
() =>
videos()
.filter((v) => shown(v) && !v.error)
.every((v) => presented.has(v) || (!v.seeking && v.readyState >= 2)),
5_000,
`video frames at ${target}`,
);
} catch {
timedOut = true;
}
await nextFrame();
seeks.push({ target, ms: now() - t0, timedOut });
await sleep(100);
}
return {
userAgent: navigator.userAgent,
devicePixelRatio: window.devicePixelRatio,
videoCount: videos().length,
mediaErrors: mediaErrors(),
idle: idle.map((f) => f.ts),
frames: play.map((f) => ({ ts: f.ts, work: f.work, script: f.script, comp: f.comp })),
decoded: {
dropped: decodedAfter.dropped - decodedBefore.dropped,
total: decodedAfter.total - decodedBefore.total,
},
droppedTimeline,
videoStates,
mediaOps,
rateLog,
seeks,
};
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Generates the heavy fixtures' media into fixtures/_media (gitignored): too large to commit, cheap to rebuild.
set -euo pipefail
out="$(cd "$(dirname "$0")/.." && pwd)/fixtures/_media"
mkdir -p "$out"
cd "$out"
ff() { ffmpeg -hide_banner -loglevel error -y "$@"; }
src() { echo "testsrc2=size=$1:rate=30:duration=12"; }
alpha_vf="format=yuva420p,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(lt(hypot(X-240,Y-135),110+20*sin(T*3)),255,0)'"
[ -s h264-1080.mp4 ] || ff -f lavfi -i "$(src 1920x1080)" -c:v libx264 -preset veryfast -pix_fmt yuv420p -b:v 8M -g 60 -movflags +faststart h264-1080.mp4
[ -s h264-2160.mp4 ] || ff -f lavfi -i "$(src 3840x2160)" -c:v libx264 -preset veryfast -pix_fmt yuv420p -b:v 25M -g 60 -movflags +faststart h264-2160.mp4
if [ ! -s hevc-2160.mp4 ]; then
if [ "$(uname)" = Darwin ]; then
ff -f lavfi -i "$(src 3840x2160)" -c:v hevc_videotoolbox -b:v 20M -g 60 -tag:v hvc1 -movflags +faststart hevc-2160.mp4
else
ff -f lavfi -i "$(src 3840x2160)" -c:v libx265 -preset ultrafast -b:v 20M -g 60 -tag:v hvc1 -movflags +faststart hevc-2160.mp4
fi
fi
[ -s alpha-vp9.webm ] || ff -f lavfi -i "$(src 480x270)" -vf "$alpha_vf" -c:v libvpx-vp9 -pix_fmt yuva420p -b:v 1M -deadline realtime -cpu-used 8 -auto-alt-ref 0 alpha-vp9.webm
# HEVC with alpha only encodes through VideoToolbox; Linux iteration runs without it.
if [ "$(uname)" = Darwin ] && [ ! -s alpha-hevc.mov ]; then
ff -f lavfi -i "$(src 480x270)" -vf "$alpha_vf,format=bgra" -c:v hevc_videotoolbox -alpha_quality 0.75 -b:v 2M -tag:v hvc1 alpha-hevc.mov
fi
[ -s tone.m4a ] || ff -f lavfi -i "sine=frequency=440:duration=12" -c:a aac -b:a 128k tone.m4a
ls -l "$out"
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env bun
// Heavy-film bench for real Safari and Chrome over plain WebDriver; both run bench-page.js on one machine.
// Needs safaridriver (`sudo safaridriver --enable`) or chromedriver, and the media from make-media.sh.
// bun run tests/perf/browsers/run.ts --browsers safari,chrome --runs 2 [--fixtures a,b]
import { spawn, execFileSync, type ChildProcess } from "node:child_process";
import { mkdirSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { percentile } from "../runner.ts";
import { bench } from "./bench-page.js";
import { startServer } from "../server.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const RESULTS_DIR = resolve(HERE, "../results");
const PLAY_MS = 8_000;
const SEEK_FRACTIONS = [0.1, 0.7, 0.2, 0.8, 0.3, 0.9, 0.4, 0.6, 0.5, 0.05];
const WORK_BUDGET_MS = 8;
type Browser = "safari" | "chrome";
const DRIVERS: Record<
Browser,
{ cmd: string; args: (port: number) => string[]; caps: object; procs: RegExp }
> = {
safari: {
cmd: "safaridriver",
args: (port) => ["-p", String(port)],
caps: { browserName: "safari" },
procs: /\/(?:com\.apple\.WebKit\.\w+|Safari)$/,
},
chrome: {
cmd: process.env.CHROMEDRIVER ?? "chromedriver",
args: (port) => [`--port=${port}`],
caps: {
browserName: "chrome",
"goog:chromeOptions": {
args: [
"--autoplay-policy=no-user-gesture-required",
"--no-first-run",
"--no-default-browser-check",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-background-timer-throttling",
],
},
},
procs: /Google Chrome|Chromium/,
},
};
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i > 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
async function wd(base: string, method: string, path: string, body?: unknown): Promise<any> {
const res = await fetch(base + path, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = (await res.json()) as { value: any };
if (!res.ok || json.value?.error) {
throw new Error(`${method} ${path}: ${JSON.stringify(json.value).slice(0, 600)}`);
}
return json.value;
}
async function startDriver(browser: Browser, port: number): Promise<ChildProcess> {
const spec = DRIVERS[browser];
const proc = spawn(spec.cmd, spec.args(port), { stdio: "inherit" });
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
try {
if ((await wd(`http://127.0.0.1:${port}`, "GET", "/status")).ready) return proc;
} catch {}
await Bun.sleep(200);
}
proc.kill();
throw new Error(`${spec.cmd} did not become ready on port ${port}`);
}
/** Resident memory of the browser's own processes, in MB (WebDriver has no memory API Safari answers). */
function browserRssMb(procs: RegExp): number {
const out = execFileSync("ps", ["-axo", "rss=,comm="], { encoding: "utf-8" });
let kb = 0;
for (const line of out.split("\n")) {
const m = line.trim().match(/^(\d+)\s+(.*)$/);
if (m && procs.test(m[2]!) && !/driver/i.test(m[2]!)) kb += Number(m[1]);
}
return kb / 1024;
}
type PageResult = {
userAgent: string;
devicePixelRatio: number;
videoCount: number;
mediaErrors: string[];
idle: number[];
frames: Array<{ ts: number; work: number | null; script: number | null; comp: number }>;
decoded: { dropped: number; total: number };
droppedTimeline: number[];
videoStates: Array<Record<string, unknown>>;
mediaOps: Record<"seek" | "rate" | "play" | "pause" | "load", number>;
seeks: Array<{ target: number; ms: number; timedOut: boolean }>;
};
// Only finite numbers and explicit booleans from the browser reach disk; strings it reports go to the log.
const num = (v: unknown) =>
typeof v === "number" && Number.isFinite(v) ? Math.round(v * 1000) / 1000 : null;
function pageDetail(page: PageResult) {
return {
videos: num(page.videoCount),
mediaErrorCount: num(page.mediaErrors.length),
droppedTimeline: page.droppedTimeline.map(num),
mediaOps: {
seek: num(page.mediaOps.seek),
rate: num(page.mediaOps.rate),
play: num(page.mediaOps.play),
pause: num(page.mediaOps.pause),
load: num(page.mediaOps.load),
},
videoStates: page.videoStates.map((v) => ({
readyState: num(v.readyState),
networkState: num(v.networkState),
paused: v.paused === true,
advanced: num(v.advanced),
bufferedEnd: num(v.bufferedEnd),
shown: v.shown === true,
})),
seeks: page.seeks.map((x) => ({
target: num(x.target),
ms: num(x.ms),
timedOut: x.timedOut === true,
})),
};
}
function score(page: PageResult) {
const idleIntervals = page.idle.slice(1).map((t, i) => t - page.idle[i]!);
const vsync = percentile(idleIntervals, 50);
const intervals = page.frames.slice(1).map((f, i) => f.ts - page.frames[i]!.ts);
const dropped = intervals.reduce(
(n, d) => n + (d > vsync * 1.5 ? Math.round(d / vsync) - 1 : 0),
0,
);
const work = page.frames.map((f) => f.work).filter((w): w is number => w !== null);
const first = page.frames[0]!;
const last = page.frames[page.frames.length - 1]!;
const seekMs = page.seeks.map((s) => s.ms);
const round = (n: number) => num(Math.round(n * 10) / 10);
return {
vsyncMs: round(vsync),
frames: num(page.frames.length),
droppedFrames: num(dropped),
longestFrameMs: round(Math.max(...intervals)),
p95FrameMs: round(percentile(intervals, 95)),
maxWorkMs: round(Math.max(...work)),
p95WorkMs: round(percentile(work, 95)),
framesOverBudget: num(work.filter((w) => w > WORK_BUDGET_MS).length),
p95ScriptMs: round(
percentile(
page.frames.map((f) => f.script ?? 0),
95,
),
),
clockRatio: num(
Math.round((((last.comp - first.comp) * 1000) / (last.ts - first.ts)) * 1000) / 1000,
),
decoderDropped: num(page.decoded.dropped),
decoderFrames: num(page.decoded.total),
mediaSeeks: num(page.mediaOps.seek),
rateWrites: num(page.mediaOps.rate),
presentedFps: page.videoCount
? round((page.decoded.total - page.decoded.dropped) / page.videoCount / (PLAY_MS / 1000))
: null,
seekP50Ms: round(percentile(seekMs, 50)),
seekP95Ms: round(percentile(seekMs, 95)),
seekTimeouts: num(page.seeks.filter((s) => s.timedOut === true).length),
};
}
// The same media played natively, without the player: separates browser decoder drops from runtime-caused ones.
const CONTROLS = [
{ name: "grid-sample", media: "grid-sample", n: 10, rate: 1 },
{ name: "grid-sample-rate-1.03", media: "grid-sample", n: 10, rate: 1.03 },
{
name: "grid-sample-tick",
media: "grid-sample",
n: 10,
rate: 1,
extra: "&tick=1",
},
{
name: "grid-sample-animate",
media: "grid-sample",
n: 10,
rate: 1,
extra: "&animate=scale",
},
{
name: "grid-sample-translate",
media: "grid-sample",
n: 10,
rate: 1,
extra: "&animate=translate",
},
{
name: "grid-sample-css",
media: "grid-sample",
n: 10,
rate: 1,
extra: "&animate=css",
},
{ name: "16x1080", media: "h264-1080", n: 16, rate: 1 },
{ name: "alpha-webm", media: "alpha-vp9", n: 6, rate: 1 },
];
// Opens a page and returns the value it publishes on window[global] once it has one.
async function readPage<T>(
base: string,
browser: Browser,
url: string,
global: string,
): Promise<T> {
const session = await wd(base, "POST", "/session", {
capabilities: { alwaysMatch: DRIVERS[browser].caps },
});
const s = `/session/${session.sessionId}`;
try {
await wd(base, "POST", `${s}/url`, { url });
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const r = await wd(base, "POST", `${s}/execute/sync`, {
script: `return window.${global}`,
args: [],
});
if (r?.error) throw new Error(r.error);
if (r) return r as T;
await Bun.sleep(500);
}
throw new Error(`${url} did not publish ${global} within 60 s`);
} finally {
await wd(base, "DELETE", s).catch(() => {});
}
}
async function runFilm(base: string, browser: Browser, origin: string, fixture: string) {
const session = await wd(base, "POST", "/session", {
capabilities: { alwaysMatch: DRIVERS[browser].caps },
});
const s = `/session/${session.sessionId}`;
let peakRssMb = 0;
try {
await wd(base, "POST", `${s}/window/rect`, { x: 0, y: 0, width: 1024, height: 768 });
await wd(base, "POST", `${s}/url`, {
url: `${origin}/host.html?fixture=${fixture}&width=960&height=540`,
});
await wd(base, "POST", `${s}/execute/sync`, {
script: `window.__hfBench = ${bench.toString()};
window.__bench = null;
window.__hfBench(arguments[0]).then(
(r) => { window.__bench = { ok: r }; },
(e) => { window.__bench = { error: String(e && e.stack || e) }; });`,
args: [{ playMs: PLAY_MS, seekFractions: SEEK_FRACTIONS }],
});
const deadline = Date.now() + 180_000;
while (Date.now() < deadline) {
peakRssMb = Math.max(peakRssMb, browserRssMb(DRIVERS[browser].procs));
const r = await wd(base, "POST", `${s}/execute/sync`, {
script: "return window.__bench",
args: [],
});
if (r?.error) throw new Error(r.error);
if (r?.ok) return { page: r.ok as PageResult, peakRssMb: Math.round(peakRssMb) };
await Bun.sleep(500);
}
throw new Error("bench did not finish within 180 s");
} finally {
await wd(base, "DELETE", s).catch(() => {});
}
}
const browsers = arg("browsers", "safari,chrome").split(",") as Browser[];
const runs = Number(arg("runs", "2"));
const fixtures = arg(
"fixtures",
readdirSync(resolve(HERE, "../fixtures"))
.filter((f) => f.startsWith("heavy-") || f === "10-video-grid" || f === "gsap-heavy")
.join(","),
).split(",");
mkdirSync(RESULTS_DIR, { recursive: true });
const server = startServer();
const rows: Array<Record<string, unknown>> = [];
let failed = false;
try {
for (const browser of browsers) {
const port = 4444 + browsers.indexOf(browser);
const driver = await startDriver(browser, port);
try {
try {
const box = await readPage<{
cases: Array<{ name: string; width: number; height: number; ok: boolean }>;
ratioHeightOnlyWidth: number;
}>(
`http://127.0.0.1:${port}`,
browser,
`${server.origin}/fixtures/_control/player-box.html`,
"__box",
);
const wrong = box.cases.filter((c) => !c.ok);
console.log(
`[browsers] ${browser} aspect-ratio block with only a height: ${box.ratioHeightOnlyWidth}px wide`,
);
for (const c of box.cases)
console.log(`[browsers] ${browser} player box ${c.name}: ${c.width}x${c.height}`);
if (wrong.length) throw new Error(`${wrong.length} embeds got the wrong box`);
rows.push({ browser, fixture: "player-box", run: 1 });
} catch (e) {
failed = true;
rows.push({ browser, fixture: "player-box", run: 1, error: "failed, see the job log" });
console.error(`[browsers] ${browser} player box failed: ${e}`);
}
for (const fixture of fixtures) {
for (let run = 1; run <= runs; run++) {
try {
const { page, peakRssMb } = await runFilm(
`http://127.0.0.1:${port}`,
browser,
server.origin,
fixture,
);
const row = {
browser,
fixture,
run,
...score(page),
peakRssMb,
detail: pageDetail(page),
};
rows.push(row);
console.log(JSON.stringify({ ...row, detail: undefined }));
console.log(
`[browsers] ${browser} ${page.userAgent}; media errors: ${page.mediaErrors.join(", ") || "none"}`,
);
} catch (e) {
failed = true;
rows.push({ browser, fixture, run, error: "failed, see the job log" });
console.error(`[browsers] ${browser} ${fixture} run ${run} failed: ${e}`);
}
}
}
for (const c of CONTROLS) {
try {
const r = await readPage<{ dropped: number; total: number; presentedFps: number }>(
`http://127.0.0.1:${port}`,
browser,
`${server.origin}/fixtures/_control/index.html?media=${c.media}&n=${c.n}&rate=${c.rate}${c.extra ?? ""}`,
"__control",
);
rows.push({
browser,
fixture: `control:${c.name}`,
run: 1,
decoderDropped: num(r.dropped),
presentedFps: num(r.presentedFps),
decoderFrames: num(r.total),
});
console.log(JSON.stringify(rows[rows.length - 1]));
} catch (e) {
rows.push({
browser,
fixture: `control:${c.name}`,
run: 1,
error: "failed, see the job log",
});
console.error(`[browsers] ${browser} control ${c.name} failed: ${e}`);
}
}
} finally {
driver.kill();
}
}
} finally {
await server.stop();
}
writeFileSync(resolve(RESULTS_DIR, "browsers.json"), JSON.stringify(rows, null, 2) + "\n");
const cols = [
"browser",
"fixture",
"run",
"droppedFrames",
"longestFrameMs",
"p95FrameMs",
"maxWorkMs",
"p95WorkMs",
"framesOverBudget",
"p95ScriptMs",
"clockRatio",
"decoderDropped",
"decoderFrames",
"presentedFps",
"mediaSeeks",
"rateWrites",
"seekP50Ms",
"seekP95Ms",
"seekTimeouts",
"peakRssMb",
"error",
];
const md = [
`| ${cols.join(" | ")} |`,
`|${cols.map(() => "---").join("|")}|`,
...rows.map(
(r) =>
`| ${cols.map((c) => (r[c] === undefined ? "" : String(r[c]).slice(0, 80))).join(" | ")} |`,
),
].join("\n");
writeFileSync(resolve(RESULTS_DIR, "browsers.md"), md + "\n");
console.log(md);
if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`## Heavy films: ${browsers.join(" vs ")}\n\n${md}\n`,
);
process.exit(failed ? 1 : 0);
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>control: native video playback without the player</title>
<style>
html,
body {
margin: 0;
background: #000;
}
video {
width: 320px;
height: 180px;
object-fit: cover;
}
</style>
</head>
<body>
<script>
// ?media=<key>&n=&rate=, &tick=1 (per-frame media writes),
// &animate=scale|translate|css (ancestor transform every frame; css runs on the compositor).
(async function () {
var q = new URLSearchParams(location.search);
var MEDIA = {
"grid-sample": "../10-video-grid/sample.mp4",
"h264-1080": "../_media/h264-1080.mp4",
"alpha-vp9": "../_media/alpha-vp9.webm",
};
var src = MEDIA[q.get("media")];
if (!src) throw new Error("unknown media " + q.get("media"));
var videos = [];
for (var i = 0; i < Number(q.get("n") || 1); i++) {
var v = document.createElement("video");
v.src = src;
v.muted = true;
v.playsInline = true;
v.preload = "auto";
document.body.appendChild(v);
videos.push(v);
}
await Promise.all(
videos.map(
(v) => new Promise((r) => v.addEventListener("canplaythrough", r, { once: true })),
),
);
var decoded = () =>
videos.reduce(
(a, v) => {
var p = v.getVideoPlaybackQuality();
return {
dropped: a.dropped + p.droppedVideoFrames,
total: a.total + p.totalVideoFrames,
};
},
{ dropped: 0, total: 0 },
);
await Promise.all(videos.map((v) => v.play()));
videos.forEach((v) => (v.playbackRate = Number(q.get("rate") || 1)));
if (q.get("tick")) {
(function tick() {
videos.forEach((v) => {
v.volume = 1;
v.muted = true;
void v.currentTime;
});
requestAnimationFrame(tick);
})();
}
var animate = q.get("animate");
if (animate === "css") {
document.body.animate([{ transform: "scale(0.85)" }, { transform: "scale(0.95)" }], {
duration: 1000,
iterations: Infinity,
direction: "alternate",
});
} else if (animate) {
(function frame(t) {
var k = Math.sin(t / 500);
document.body.style.transform =
animate === "translate"
? "translateX(" + 40 * k + "px)"
: "scale(" + (0.9 + 0.05 * k) + ")";
requestAnimationFrame(frame);
})(0);
}
var before = decoded();
await new Promise((r) => setTimeout(r, 8000));
var after = decoded();
window.__control = {
dropped: after.dropped - before.dropped,
total: after.total - before.total,
presentedFps:
Math.round(
((after.total - before.total - (after.dropped - before.dropped)) /
videos.length /
8) *
10,
) / 10,
};
})().catch((e) => (window.__control = { error: String(e) }));
</script>
</body>
</html>
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>control: the box a player takes from its embed</title>
<style>
body {
margin: 0;
}
.wrap {
width: 800px;
}
p {
margin: 12px 0 4px;
font:
14px/1.2 system-ui,
sans-serif;
}
.column {
display: flex;
flex-direction: column;
width: 800px;
height: 600px;
}
.ratio {
aspect-ratio: 16 / 9;
height: 300px;
background: #333;
}
</style>
</head>
<body>
<div class="wrap">
<p>no height or width set: expect 800x450</p>
<hyperframes-player id="plain" src="../gsap-heavy/index.html"></hyperframes-player>
<p>height: 300px only: expect 800x300</p>
<hyperframes-player
id="heightOnly"
style="height: 300px"
src="../gsap-heavy/index.html"
></hyperframes-player>
<p>width: 400px only: expect 400x225</p>
<hyperframes-player
id="widthOnly"
style="width: 400px"
src="../gsap-heavy/index.html"
></hyperframes-player>
<p>width: 200px and height: 100px: expect 200x100</p>
<hyperframes-player
id="both"
style="width: 200px; height: 100px"
src="../gsap-heavy/index.html"
></hyperframes-player>
<p>not a player: a plain div with aspect-ratio 16/9 and only height: 300px</p>
<div id="ratioHeightOnly" class="ratio"></div>
</div>
<p>flex: 1 in an 800x600 column: expect 800x600</p>
<div class="column">
<hyperframes-player
id="flexFill"
style="flex: 1"
src="../gsap-heavy/index.html"
></hyperframes-player>
</div>
<script src="/player/hyperframes-player.global.js"></script>
<script>
// Each player's box once every film is ready, against the box its embed asks for.
(async function () {
var EXPECTED = {
plain: [800, 450],
heightOnly: [800, 300],
widthOnly: [400, 225],
both: [200, 100],
flexFill: [800, 600],
};
var players = Object.keys(EXPECTED).map((id) => document.getElementById(id));
await Promise.all(
players.map(
(p) =>
new Promise((resolve) => {
p.addEventListener("ready", resolve, { once: true });
setTimeout(resolve, 15000);
}),
),
);
var cases = players.map((p) => {
var w = Math.round(p.getBoundingClientRect().width);
var h = Math.round(p.getBoundingClientRect().height);
var want = EXPECTED[p.id];
return {
name: p.id,
width: w,
height: h,
ok: Math.abs(w - want[0]) <= 1 && Math.abs(h - want[1]) <= 1,
};
});
window.__box = {
cases: cases,
ratioHeightOnlyWidth: Math.round(
document.getElementById("ratioHeightOnly").getBoundingClientRect().width,
),
};
})();
</script>
</body>
</html>
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>perf fixture: heavy-16x1080</title>
<style>
html,
body {
margin: 0;
background: #050714;
overflow: hidden;
font-family: system-ui, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
video {
position: absolute;
object-fit: cover;
}
</style>
<script src="/vendor/gsap.min.js"></script>
<script data-hyperframes-runtime="1" src="/vendor/hyperframe.runtime.iife.js"></script>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-width="1920"
data-height="1080"
data-duration="10"
data-fps="30"
></div>
<script>
(function () {
var root = document.getElementById("root");
var tl = gsap.timeline({ paused: true });
function video(src, track, start, duration, css) {
var v = document.createElement("video");
v.setAttribute("data-start", String(start));
v.setAttribute("data-duration", String(duration));
v.setAttribute("data-track-index", String(track));
v.setAttribute("src", "../_media/" + src);
v.setAttribute("preload", "auto");
v.setAttribute("playsinline", "");
v.muted = true;
v.style.cssText = css;
root.appendChild(v);
return v;
}
// 16 simultaneous 1080p H.264 layers, each transformed every frame.
for (var i = 0; i < 16; i++) {
var x = (i % 4) * 480,
y = Math.floor(i / 4) * 270;
var v = video(
"h264-1080.mp4",
i,
0,
10,
"left:" + x + "px;top:" + y + "px;width:480px;height:270px;",
);
tl.fromTo(
v,
{ rotation: -2, scale: 0.94 },
{ rotation: 2, scale: 1, duration: 10, ease: "sine.inOut" },
0,
);
}
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>perf fixture: heavy-4k</title>
<style>
html,
body {
margin: 0;
background: #050714;
overflow: hidden;
font-family: system-ui, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
video {
position: absolute;
object-fit: cover;
}
</style>
<script src="/vendor/gsap.min.js"></script>
<script data-hyperframes-runtime="1" src="/vendor/hyperframe.runtime.iife.js"></script>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-width="1920"
data-height="1080"
data-duration="10"
data-fps="30"
></div>
<script>
(function () {
var root = document.getElementById("root");
var tl = gsap.timeline({ paused: true });
function video(src, track, start, duration, css) {
var v = document.createElement("video");
v.setAttribute("data-start", String(start));
v.setAttribute("data-duration", String(duration));
v.setAttribute("data-track-index", String(track));
v.setAttribute("src", "../_media/" + src);
v.setAttribute("preload", "auto");
v.setAttribute("playsinline", "");
v.muted = true;
v.style.cssText = css;
root.appendChild(v);
return v;
}
// Three 4K decodes at once: one full frame and two picture-in-picture.
video("hevc-2160.mp4", 0, 0, 10, "inset:0;width:100%;height:100%;");
var a = video(
"h264-2160.mp4",
1,
0,
10,
"left:80px;top:80px;width:640px;height:360px;border-radius:16px;",
);
var b = video(
"hevc-2160.mp4",
2,
0,
10,
"right:80px;bottom:80px;width:640px;height:360px;border-radius:16px;",
);
tl.to(a, { x: 400, y: 200, duration: 10, ease: "none" }, 0);
tl.to(b, { x: -400, y: -200, duration: 10, ease: "none" }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>perf fixture: heavy-alpha-hevc</title>
<style>
html,
body {
margin: 0;
background: #050714;
overflow: hidden;
font-family: system-ui, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
video {
position: absolute;
object-fit: cover;
}
</style>
<script src="/vendor/gsap.min.js"></script>
<script data-hyperframes-runtime="1" src="/vendor/hyperframe.runtime.iife.js"></script>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-width="1920"
data-height="1080"
data-duration="10"
data-fps="30"
></div>
<script>
(function () {
var root = document.getElementById("root");
var tl = gsap.timeline({ paused: true });
function video(src, track, start, duration, css) {
var v = document.createElement("video");
v.setAttribute("data-start", String(start));
v.setAttribute("data-duration", String(duration));
v.setAttribute("data-track-index", String(track));
v.setAttribute("src", "../_media/" + src);
v.setAttribute("preload", "auto");
v.setAttribute("playsinline", "");
v.muted = true;
v.style.cssText = css;
root.appendChild(v);
return v;
}
// Transparent overlays over a 1080p background; Safari and Chrome decode these alpha formats differently.
video("h264-1080.mp4", 0, 0, 10, "inset:0;width:100%;height:100%;");
for (var i = 0; i < 6; i++) {
var v = video(
"alpha-hevc.mov",
i + 1,
0,
10,
"left:" + i * 280 + "px;top:" + (i % 2 ? 560 : 120) + "px;width:640px;height:360px;",
);
tl.to(v, { x: 300 * (i % 2 ? -1 : 1), rotation: 8, duration: 10, ease: "sine.inOut" }, 0);
}
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>perf fixture: heavy-alpha-webm</title>
<style>
html,
body {
margin: 0;
background: #050714;
overflow: hidden;
font-family: system-ui, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
video {
position: absolute;
object-fit: cover;
}
</style>
<script src="/vendor/gsap.min.js"></script>
<script data-hyperframes-runtime="1" src="/vendor/hyperframe.runtime.iife.js"></script>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-width="1920"
data-height="1080"
data-duration="10"
data-fps="30"
></div>
<script>
(function () {
var root = document.getElementById("root");
var tl = gsap.timeline({ paused: true });
function video(src, track, start, duration, css) {
var v = document.createElement("video");
v.setAttribute("data-start", String(start));
v.setAttribute("data-duration", String(duration));
v.setAttribute("data-track-index", String(track));
v.setAttribute("src", "../_media/" + src);
v.setAttribute("preload", "auto");
v.setAttribute("playsinline", "");
v.muted = true;
v.style.cssText = css;
root.appendChild(v);
return v;
}
// Transparent overlays over a 1080p background; Safari and Chrome decode these alpha formats differently.
video("h264-1080.mp4", 0, 0, 10, "inset:0;width:100%;height:100%;");
for (var i = 0; i < 6; i++) {
var v = video(
"alpha-vp9.webm",
i + 1,
0,
10,
"left:" + i * 280 + "px;top:" + (i % 2 ? 560 : 120) + "px;width:640px;height:360px;",
);
tl.to(v, { x: 300 * (i % 2 ? -1 : 1), rotation: 8, duration: 10, ease: "sine.inOut" }, 0);
}
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,161 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>perf fixture: heavy-fx</title>
<style>
html,
body {
margin: 0;
background: #050714;
overflow: hidden;
font-family: system-ui, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
video {
position: absolute;
object-fit: cover;
}
.glass {
position: absolute;
top: 120px;
width: 360px;
height: 240px;
border-radius: 24px;
backdrop-filter: blur(18px) saturate(1.8);
-webkit-backdrop-filter: blur(18px) saturate(1.8);
background: rgba(255, 255, 255, 0.12);
}
.shader {
position: absolute;
left: 480px;
top: 270px;
width: 960px;
height: 540px;
mix-blend-mode: overlay;
}
</style>
<script src="/vendor/gsap.min.js"></script>
<script data-hyperframes-runtime="1" src="/vendor/hyperframe.runtime.iife.js"></script>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-width="1920"
data-height="1080"
data-duration="10"
data-fps="30"
></div>
<script>
(function () {
var root = document.getElementById("root");
var tl = gsap.timeline({ paused: true });
function video(src, track, start, duration, css) {
var v = document.createElement("video");
v.setAttribute("data-start", String(start));
v.setAttribute("data-duration", String(duration));
v.setAttribute("data-track-index", String(track));
v.setAttribute("src", "../_media/" + src);
v.setAttribute("preload", "auto");
v.setAttribute("playsinline", "");
v.muted = true;
v.style.cssText = css;
root.appendChild(v);
return v;
}
// Filters, blend modes, backdrop blur and a WebGL shader redrawn on every timeline update.
var fx = [
"blur(6px)",
"hue-rotate(90deg) saturate(2)",
"contrast(1.6) brightness(0.8)",
"grayscale(1)",
"sepia(1)",
"drop-shadow(0 0 24px #0ff)",
];
for (var i = 0; i < 6; i++) {
var v = video(
"h264-1080.mp4",
i,
0,
10,
"left:" +
(i % 3) * 640 +
"px;top:" +
Math.floor(i / 3) * 540 +
"px;width:640px;height:540px;filter:" +
fx[i] +
";mix-blend-mode:" +
(i % 2 ? "screen" : "multiply") +
";",
);
tl.fromTo(v, { scale: 1 }, { scale: 1.15, duration: 10, ease: "none" }, 0);
}
for (var j = 0; j < 4; j++) {
var glass = document.createElement("div");
glass.className = "glass";
glass.style.left = 100 + j * 440 + "px";
root.appendChild(glass);
tl.fromTo(glass, { y: 0 }, { y: 600, duration: 10, ease: "sine.inOut" }, 0);
}
var canvas = document.createElement("canvas");
canvas.width = 960;
canvas.height = 540;
canvas.className = "shader";
root.appendChild(canvas);
var gl = canvas.getContext("webgl");
function sh(type, text) {
var s = gl.createShader(type);
gl.shaderSource(s, text);
gl.compileShader(s);
return s;
}
var prog = gl.createProgram();
gl.attachShader(
prog,
sh(gl.VERTEX_SHADER, "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}"),
);
gl.attachShader(
prog,
sh(
gl.FRAGMENT_SHADER,
"precision mediump float;uniform float t;void main(){vec2 u=gl_FragCoord.xy/vec2(960.,540.);float v=0.;for(int i=0;i<24;i++){float f=float(i);v+=sin(u.x*(8.+f)+t*(1.+f*.1))*cos(u.y*(6.+f)-t);}gl_FragColor=vec4(.5+.5*sin(v),.5+.5*cos(v*1.3),.5+.5*sin(v*.7+t),.55);}",
),
);
gl.linkProgram(prog);
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
);
var loc = gl.getAttribLocation(prog, "p");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
var tLoc = gl.getUniformLocation(prog, "t");
var state = { t: 0 };
tl.to(
state,
{
t: 10,
duration: 10,
ease: "none",
onUpdate: function () {
gl.uniform1f(tLoc, state.t);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
},
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>perf fixture: heavy-long-gsap</title>
<style>
html,
body {
margin: 0;
background: #050714;
overflow: hidden;
font-family: system-ui, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
video {
position: absolute;
object-fit: cover;
}
.dot {
position: absolute;
width: 56px;
height: 56px;
border-radius: 12px;
background: linear-gradient(135deg, #6366f1, #ec4899);
color: #fff;
font: 600 14px/56px system-ui;
text-align: center;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
</style>
<script src="/vendor/gsap.min.js"></script>
<script data-hyperframes-runtime="1" src="/vendor/hyperframe.runtime.iife.js"></script>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-width="1920"
data-height="1080"
data-duration="120"
data-fps="30"
></div>
<script>
(function () {
var root = document.getElementById("root");
var tl = gsap.timeline({ paused: true });
function video(src, track, start, duration, css) {
var v = document.createElement("video");
v.setAttribute("data-start", String(start));
v.setAttribute("data-duration", String(duration));
v.setAttribute("data-track-index", String(track));
v.setAttribute("src", "../_media/" + src);
v.setAttribute("preload", "auto");
v.setAttribute("playsinline", "");
v.muted = true;
v.style.cssText = css;
root.appendChild(v);
return v;
}
// A two-minute timeline: 400 elements, about 4,000 tweens, no media.
for (var i = 0; i < 400; i++) {
var el = document.createElement("div");
el.className = "dot";
el.textContent = String(i);
el.style.left = (i % 25) * 76 + "px";
el.style.top = Math.floor(i / 25) * 67 + "px";
root.appendChild(el);
for (var k = 0; k < 10; k++) {
tl.to(
el,
{
x: Math.sin(i + k) * 120,
y: Math.cos(i * k) * 80,
rotation: (k % 2 ? 1 : -1) * 90,
opacity: 0.4 + (k % 3) * 0.3,
duration: 6,
ease: "power2.inOut",
},
(i % 20) * 0.3 + k * 11,
);
}
}
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,110 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>perf fixture: heavy-subcomp-audio</title>
<style>
html,
body {
margin: 0;
background: #050714;
overflow: hidden;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
.card {
position: absolute;
width: 960px;
height: 540px;
}
</style>
<script src="/vendor/gsap.min.js"></script>
<script data-hyperframes-runtime="1" src="/vendor/hyperframe.runtime.iife.js"></script>
</head>
<body>
<!-- Four sub-compositions, each with its own video and timeline, under three overlapping audio tracks. -->
<div
id="root"
data-composition-id="main"
data-width="1920"
data-height="1080"
data-duration="10"
data-fps="30"
>
<div
class="card"
style="left: 0; top: 0"
data-composition-id="card-0"
data-composition-src="scenes/card-0.html"
data-start="0"
data-duration="10"
data-track-index="0"
></div>
<div
class="card"
style="left: 960px; top: 0"
data-composition-id="card-1"
data-composition-src="scenes/card-1.html"
data-start="0"
data-duration="10"
data-track-index="1"
></div>
<div
class="card"
style="left: 0; top: 540px"
data-composition-id="card-2"
data-composition-src="scenes/card-2.html"
data-start="0"
data-duration="10"
data-track-index="2"
></div>
<div
class="card"
style="left: 960px; top: 540px"
data-composition-id="card-3"
data-composition-src="scenes/card-3.html"
data-start="0"
data-duration="10"
data-track-index="3"
></div>
<audio
src="../_media/tone.m4a"
data-start="0"
data-duration="10"
data-track-index="4"
data-volume="0.5"
></audio>
<audio
src="../_media/tone.m4a"
data-start="2"
data-duration="8"
data-track-index="5"
data-volume="0.5"
></audio>
<audio
src="../_media/tone.m4a"
data-start="4"
data-duration="6"
data-track-index="6"
data-volume="0.5"
></audio>
</div>
<script>
(function () {
var tl = gsap.timeline({ paused: true });
tl.fromTo(
".card",
{ scale: 0.9 },
{ scale: 1, duration: 10, ease: "sine.inOut", stagger: 0.25 },
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,53 @@
<template id="card-0-template">
<div data-composition-id="card-0" data-width="960" data-height="540" data-duration="10">
<video
src="../../_media/h264-1080.mp4"
data-start="0"
data-duration="10"
data-track-index="0"
muted
playsinline
></video>
<div class="title">scene 0</div>
<style>
[data-composition-id="card-0"] {
position: relative;
width: 960px;
height: 540px;
overflow: hidden;
}
[data-composition-id="card-0"] video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
[data-composition-id="card-0"] .title {
position: absolute;
left: 40px;
bottom: 40px;
color: #fff;
font: 700 56px system-ui;
}
</style>
<script>
(function () {
var tl = gsap.timeline({ paused: true });
tl.fromTo(
'[data-composition-id="card-0"] .title',
{ x: -200, opacity: 0 },
{ x: 0, opacity: 1, duration: 2, ease: "power3.out" },
0 * 0.5,
);
tl.to(
'[data-composition-id="card-0"] video',
{ scale: 1.2, duration: 10, ease: "none" },
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["card-0"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,53 @@
<template id="card-1-template">
<div data-composition-id="card-1" data-width="960" data-height="540" data-duration="10">
<video
src="../../_media/h264-1080.mp4"
data-start="0"
data-duration="10"
data-track-index="0"
muted
playsinline
></video>
<div class="title">scene 1</div>
<style>
[data-composition-id="card-1"] {
position: relative;
width: 960px;
height: 540px;
overflow: hidden;
}
[data-composition-id="card-1"] video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
[data-composition-id="card-1"] .title {
position: absolute;
left: 40px;
bottom: 40px;
color: #fff;
font: 700 56px system-ui;
}
</style>
<script>
(function () {
var tl = gsap.timeline({ paused: true });
tl.fromTo(
'[data-composition-id="card-1"] .title',
{ x: -200, opacity: 0 },
{ x: 0, opacity: 1, duration: 2, ease: "power3.out" },
1 * 0.5,
);
tl.to(
'[data-composition-id="card-1"] video',
{ scale: 1.2, duration: 10, ease: "none" },
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["card-1"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,53 @@
<template id="card-2-template">
<div data-composition-id="card-2" data-width="960" data-height="540" data-duration="10">
<video
src="../../_media/h264-1080.mp4"
data-start="0"
data-duration="10"
data-track-index="0"
muted
playsinline
></video>
<div class="title">scene 2</div>
<style>
[data-composition-id="card-2"] {
position: relative;
width: 960px;
height: 540px;
overflow: hidden;
}
[data-composition-id="card-2"] video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
[data-composition-id="card-2"] .title {
position: absolute;
left: 40px;
bottom: 40px;
color: #fff;
font: 700 56px system-ui;
}
</style>
<script>
(function () {
var tl = gsap.timeline({ paused: true });
tl.fromTo(
'[data-composition-id="card-2"] .title',
{ x: -200, opacity: 0 },
{ x: 0, opacity: 1, duration: 2, ease: "power3.out" },
2 * 0.5,
);
tl.to(
'[data-composition-id="card-2"] video',
{ scale: 1.2, duration: 10, ease: "none" },
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["card-2"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,53 @@
<template id="card-3-template">
<div data-composition-id="card-3" data-width="960" data-height="540" data-duration="10">
<video
src="../../_media/h264-1080.mp4"
data-start="0"
data-duration="10"
data-track-index="0"
muted
playsinline
></video>
<div class="title">scene 3</div>
<style>
[data-composition-id="card-3"] {
position: relative;
width: 960px;
height: 540px;
overflow: hidden;
}
[data-composition-id="card-3"] video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
[data-composition-id="card-3"] .title {
position: absolute;
left: 40px;
bottom: 40px;
color: #fff;
font: 700 56px system-ui;
}
</style>
<script>
(function () {
var tl = gsap.timeline({ paused: true });
tl.fromTo(
'[data-composition-id="card-3"] .title',
{ x: -200, opacity: 0 },
{ x: 0, opacity: 1, duration: 2, ease: "power3.out" },
3 * 0.5,
);
tl.to(
'[data-composition-id="card-3"] video',
{ scale: 1.2, duration: 10, ease: "none" },
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["card-3"] = tl;
})();
</script>
</div>
</template>
+7 -1
View File
@@ -68,6 +68,8 @@ const MIME_TYPES: Record<string, string> = {
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
};
@@ -87,7 +89,7 @@ function buildHostHtml(fixtureName: string, width: number, height: number): stri
<title>player perf host: ${fixtureName}</title>
<style>
html, body { margin: 0; padding: 0; background: #000; }
hyperframes-player { display: block; }
hyperframes-player { width: ${width}px; }
</style>
</head>
<body>
@@ -111,6 +113,10 @@ function buildHostHtml(fixtureName: string, width: number, height: number): stri
});
const player = document.getElementById("player");
player.addEventListener("ready", function (event) {
// A player with an empty box shows nothing, and Safari counts frames it never shows as dropped.
if (!player.offsetWidth || !player.offsetHeight) {
window.__playerError = "player box is empty, so the composition is not on screen";
}
window.__playerReady = true;
window.__playerReadyAt = performance.timeOrigin + performance.now();
window.__playerDuration = (event.detail && event.detail.duration) || 0;