mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user