diff --git a/suites-experimental/media-performance/LICENSE.md b/suites-experimental/media-performance/LICENSE.md new file mode 100644 index 000000000..5efcbd04b --- /dev/null +++ b/suites-experimental/media-performance/LICENSE.md @@ -0,0 +1,38 @@ +# Media Asset Attributions + +## bigbuckbunny-video.webm and bigbuckbunny-audio.webm + +Excerpt from "Big Buck Bunny" (https://peach.blender.org/), © 2008 Blender +Foundation. Licensed under the Creative Commons Attribution 3.0 Unported +License (https://creativecommons.org/licenses/by/3.0/). + +The assets are bundled here solely as deterministic input for the +Media-Streaming workload. The original source file (`bbb_sunflower_1080p_60fps_normal.mp4.zip`) was obtained from the official [Blender Foundation repository](https://peach.blender.org/download/). It was re-encoded using FFmpeg 8.1.2 (specifically Lavc 62.28.102 / Lavf 62.12.102) into separate single-track WebM representations (VP9 video and Opus audio, DASH streaming architecture): + +```bash +# Pass 1: VP9 Video Stream +ffmpeg -y -i bbb_sunflower_1080p_60fps_normal.mp4 -c:v libvpx-vp9 \ + -b:v 4M -minrate 1.5M -maxrate 8.8M -crf 31 \ + -g 300 -keyint_min 0 \ + -tile-columns 2 -threads 8 -speed 4 \ + -auto-alt-ref 1 -arnr_max_frames 7 -arnr_strength 5 -arnr_type 3 \ + -rc_lookahead 24 -enable-tpl 1 \ + -pass 1 -an -f null /dev/null + +# Pass 2: VP9 Video Stream +ffmpeg -y -i bbb_sunflower_1080p_60fps_normal.mp4 -c:v libvpx-vp9 \ + -b:v 4M -minrate 1.5M -maxrate 8.8M -crf 31 \ + -g 300 -keyint_min 0 \ + -tile-columns 2 -threads 8 -speed 2 \ + -auto-alt-ref 1 -arnr_max_frames 7 -arnr_strength 5 -arnr_type 3 \ + -rc_lookahead 24 -enable-tpl 1 \ + -pass 2 -an \ + bigbuckbunny-video.webm + +# Extract Opus Audio Stream +ffmpeg -y -i bbb_sunflower_1080p_60fps_normal.mp4 -vn \ + -c:a libopus -b:a 128k -ac 2 \ + bigbuckbunny-audio.webm +``` + +This delivers a 2-pass VP9 video stream (1080p, 60fps) with alternate reference frames (`-auto-alt-ref 1`), a 5-second keyframe interval (`-g 300`), and ~4 Mbps bitrate for decoder stress testing, paired with a separate Opus audio stream (stereo, 128k) in dedicated WebM containers. diff --git a/suites-experimental/media-performance/README.md b/suites-experimental/media-performance/README.md new file mode 100644 index 000000000..77fee0f1e --- /dev/null +++ b/suites-experimental/media-performance/README.md @@ -0,0 +1,35 @@ +# Media Performance Workloads + +This benchmark suite measures the performance, responsiveness, and processing throughput of modern web media APIs. + +--- + +## 1. Media-Streaming (`streaming.html` / `streaming.js`) + +### InitialPlayback + +Tests standard video playback initialization latency via Media Source Extensions (MSE) to simulate typical streaming user journeys on platforms like YouTube or Vimeo. The time measured is from the `play` button clicked to the first video frame painted on the screen. We use `requestVideoFrameCallback` to ensure that the video is painted on the screen. + +### Seek + +Measures the latency of performing a quick skip-ahead action within an already loaded media stream. This simulates a common user interaction, such as scrubbing through a video timeline or double-tapping right to skip forward on a streaming platform. The time measured is from the `seek` button clicked to the first video frame painted on the screen after seeking. We use `requestVideoFrameCallback` to ensure that the video is painted on the screen after seeking. + +--- + +## 2. Media-Conferencing (`conferencing.html` / `conferencing.js`) + +### VideoChat + +Tests real-time video encoding and decoding pipelines using WebCodecs. While the UI simulates a Video Chat call, WebRTC is not tested; the focus is purely on measuring VideoEncoder and VideoDecoder throughput. The time measured is from the `video-benchmark` button clicked until all video frames have been encoded, decoded, and the session is torn down. + +### VoiceChat + +Tests real-time audio encoding, decoding using WebCodecs and audio routing and effects processing using WebAudio. It measures the browser's efficiency in processing audio streams and applying standard audio nodes. The two stages run sequentially in a simulated media pipeline: audio routing and effects processing complete via WebAudio, and the resulting rendered audio buffer is directly encoded and decoded via WebCodecs. The time measured is from the `voice-benchmark` button clicked until all audio frames have been encoded/decoded and the WebAudio offline rendering completes. + +--- + +## Notes & Troubleshooting + +### Safari & Low Power Mode + +When running these workloads in Safari on macOS or iOS, verify that **Low Power Mode** is disabled. Low Power Mode completely restricts programmatic media playback (`video.play()` and automated media pipelines) regardless of muted state, which will cause tests to fail with a `NotAllowedError`. diff --git a/suites-experimental/media-performance/bigbuckbunny-audio.webm b/suites-experimental/media-performance/bigbuckbunny-audio.webm new file mode 100644 index 000000000..e02cf4927 Binary files /dev/null and b/suites-experimental/media-performance/bigbuckbunny-audio.webm differ diff --git a/suites-experimental/media-performance/bigbuckbunny-video.webm b/suites-experimental/media-performance/bigbuckbunny-video.webm new file mode 100644 index 000000000..10e2f3c92 Binary files /dev/null and b/suites-experimental/media-performance/bigbuckbunny-video.webm differ diff --git a/suites-experimental/media-performance/conferencing.html b/suites-experimental/media-performance/conferencing.html new file mode 100644 index 000000000..3f0216d15 --- /dev/null +++ b/suites-experimental/media-performance/conferencing.html @@ -0,0 +1,30 @@ + + + + + Media Conferencing Workload + + + +
+ + +
+
+
+
+

Local Preview

+ +
+
+

Remote Preview

+ +
+
+
+ Idle +
+
+ + + diff --git a/suites-experimental/media-performance/conferencing.js b/suites-experimental/media-performance/conferencing.js new file mode 100644 index 000000000..2d17b8cc1 --- /dev/null +++ b/suites-experimental/media-performance/conferencing.js @@ -0,0 +1,341 @@ +/* eslint-disable no-empty */ +/* global VideoDecoder, VideoEncoder, AudioDecoder, AudioEncoder, VideoFrame, AudioData */ +/** + * WebCodecs and WebAudio performance benchmark. + * Simulates a video/audio conferencing session (video chat and voice chat). + * Measures the performance of encoding/decoding video frames with WebCodecs, + * rendering them on local and remote HTML5 Canvases, and processing audio + * round trips via WebCodecs and Web Audio offline graph rendering. + */ +(function () { + const localCanvas = document.getElementById("local-canvas"); + const remoteCanvas = document.getElementById("remote-canvas"); + const localCtx = localCanvas.getContext("2d"); + const remoteCtx = remoteCanvas.getContext("2d"); + const statusEl = document.getElementById("status"); + + // Video Workload Constants: Chosen to simulate a standard 1080p @ 30fps VP9 video conferencing stream. + // 1080p (1920x1080) reflects typical HD video chat resolutions. + // VIDEO_FRAME_COUNT = 10 is chosen to provide a deterministic, fast-running workload per iteration. + // VIDEO_BITRATE = 1_000_000 (1 Mbps) reflects target WebCodecs VP9 encoding bitrates for 1080p streams. + const FRAME_WIDTH = 1920; + const FRAME_HEIGHT = 1080; + const VIDEO_FRAME_COUNT = 10; + const VIDEO_FRAME_DURATION_US = 33333; // ~30fps in microseconds + const VIDEO_BITRATE = 1_000_000; + const VIDEO_FRAMERATE = 30; + + // WebCodecs Audio Workload Constants: Opus audio encoding and decoding configuration. + // AUDIO_SAMPLE_RATE = 48000 (48 kHz) is chosen as the standard high-fidelity sample rate for Opus audio. + // AUDIO_FRAME_COUNT = 100 & AUDIO_FRAME_SIZE = 1024 are chosen to match WebCodecs AudioData chunk buffers (~21.3ms per frame). + const AUDIO_FRAME_COUNT = 100; + const AUDIO_SAMPLE_RATE = 48000; + const AUDIO_FRAME_SIZE = 1024; + const AUDIO_DECODER_BUFFER_SIZE = 8192; + const AUDIO_BITRATE = 96_000; + const AUDIO_CHANNELS = 1; + const STEREO_CHANNELS = 2; + + // WebAudio Processing Constants: OfflineAudioContext synthesis graph benchmark. + // AUDIO_TONE_HZ = 440 is chosen as the standard Concert A sine wave test signal for audio processing. + const MICROSECONDS_PER_SECOND = 1_000_000; + const AUDIO_OFFLINE_DURATION_SECONDS = 30; + const AUDIO_TONE_HZ = 440; + + const WEBAUDIO_SAMPLE_COUNT = AUDIO_SAMPLE_RATE * AUDIO_OFFLINE_DURATION_SECONDS; + + // Pre-generate the audio samples before we start the timer. + const PRE_GENERATED_AUDIO_SAMPLES = (function () { + const data = new Float32Array(WEBAUDIO_SAMPLE_COUNT); + const omega = (2 * Math.PI * AUDIO_TONE_HZ) / AUDIO_SAMPLE_RATE; + for (let i = 0; i < WEBAUDIO_SAMPLE_COUNT; i++) + data[i] = Math.sin(omega * i); + return data; + })(); + + const VIDEO_CODEC = "vp09.00.10.08"; + + const webCodecsSupported + = typeof globalThis.VideoEncoder === "function" + && typeof globalThis.VideoDecoder === "function" + && typeof globalThis.AudioEncoder === "function" + && typeof globalThis.AudioDecoder === "function" + && typeof globalThis.VideoFrame === "function" + && typeof globalThis.AudioData === "function"; + + const session = { + videoEncoder: null, + videoDecoder: null, + audioEncoder: null, + audioDecoder: null, + codec: null, + framesDecoded: 0, + }; + + function setStatus(text) { + statusEl.textContent = text; + } + + function markCompleted(buttonId) { + const el = document.getElementById(buttonId); + if (el) + el.classList.add("completed"); + } + + /** + * Renders a solid background, a moving colored box to simulate motion, and text showing the current frame index. + */ + function drawLocalFrame(i) { + localCtx.fillStyle = "#1e1e1e"; + localCtx.fillRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT); + + const boxSize = 200; + const x = (i * 15) % (FRAME_WIDTH - boxSize); + const y = Math.abs(Math.sin(i * 0.1)) * (FRAME_HEIGHT - boxSize); + + const hue = (i * 2) % 360; + localCtx.fillStyle = `hsl(${hue}, 80%, 60%)`; + localCtx.fillRect(x, y, boxSize, boxSize); + + localCtx.fillStyle = "#ffffff"; + localCtx.font = "40px sans-serif"; + localCtx.fillText(`Frame ${i}`, 20, 50); + } + + async function initializeVideoSession() { + if (!webCodecsSupported) + throw new Error("WebCodecs not supported"); + + try { + const support = await VideoEncoder.isConfigSupported({ + codec: VIDEO_CODEC, + width: FRAME_WIDTH, + height: FRAME_HEIGHT, + bitrate: VIDEO_BITRATE, + framerate: VIDEO_FRAMERATE, + }); + if (!support || !support.supported) + throw new Error(`Video codec ${VIDEO_CODEC} not supported`); + + session.videoDecoder = new VideoDecoder({ + output(frame) { + remoteCtx.drawImage(frame, 0, 0, FRAME_WIDTH, FRAME_HEIGHT); + frame.close(); + session.framesDecoded++; + }, + error(e) { + throw e; + }, + }); + + session.videoEncoder = new VideoEncoder({ + output(chunk, metadata) { + if (metadata && metadata.decoderConfig && session.videoDecoder.state !== "configured") + session.videoDecoder.configure(metadata.decoderConfig); + if (session.videoDecoder.state === "configured") + session.videoDecoder.decode(chunk); + }, + error(e) { + throw e; + }, + }); + + session.videoEncoder.configure({ + codec: VIDEO_CODEC, + width: FRAME_WIDTH, + height: FRAME_HEIGHT, + bitrate: VIDEO_BITRATE, + framerate: VIDEO_FRAMERATE, + }); + + setStatus(`Video session joined (codec=${VIDEO_CODEC})`); + } catch (e) { + setStatus(`Video codec init failed: ${e.message}`); + throw e; + } + } + + async function initializeAudioSession() { + if (!webCodecsSupported) + throw new Error("WebCodecs not supported"); + + try { + const audioOutputBuffer = new Float32Array(AUDIO_DECODER_BUFFER_SIZE); + session.audioDecoder = new AudioDecoder({ + output(data) { + const sampleCount = data.numberOfFrames * data.numberOfChannels; + let targetBuffer; + if (sampleCount <= audioOutputBuffer.length) + targetBuffer = audioOutputBuffer.subarray(0, sampleCount); + else + targetBuffer = new Float32Array(sampleCount); + // Copy the decoded audio into JavaScript memory, simulating how a real app reads audio to play through speakers. + // This ensures the browser actually performs the work of reading audio out of the decoder instead of taking shortcuts. + data.copyTo(targetBuffer, { planeIndex: 0 }); + data.close(); + }, + error(e) { + throw e; + }, + }); + + session.audioEncoder = new AudioEncoder({ + output(chunk, metadata) { + if (metadata && metadata.decoderConfig && session.audioDecoder.state !== "configured") + session.audioDecoder.configure(metadata.decoderConfig); + if (session.audioDecoder.state === "configured") + session.audioDecoder.decode(chunk); + }, + error(e) { + throw e; + }, + }); + + session.audioEncoder.configure({ + codec: "opus", + sampleRate: AUDIO_SAMPLE_RATE, + numberOfChannels: AUDIO_CHANNELS, + bitrate: AUDIO_BITRATE, + }); + + setStatus("Audio session joined"); + } catch (e) { + setStatus(`Audio codec init failed: ${e.message}`); + throw e; + } + } + + async function simulateVideoCall() { + if (!session.videoEncoder) + throw new Error("VideoEncoder not initialized"); + for (let i = 0; i < VIDEO_FRAME_COUNT; i++) { + drawLocalFrame(i); + + const frame = new VideoFrame(localCanvas, { timestamp: i * VIDEO_FRAME_DURATION_US }); + session.videoEncoder.encode(frame); + frame.close(); + } + + await session.videoEncoder.flush(); + await session.videoDecoder.flush(); + + if (session.framesDecoded !== VIDEO_FRAME_COUNT) + throw new Error(`Expected ${VIDEO_FRAME_COUNT} frames decoded, got ${session.framesDecoded}`); + setStatus(`Frames decoded: ${session.framesDecoded}`); + } + + async function simulateVoiceCall(processedSamples) { + if (!session.audioEncoder) + throw new Error("Benchmark error: AudioEncoder not initialized"); + const sourceSamples = processedSamples || PRE_GENERATED_AUDIO_SAMPLES; + const frameDurationUs = (AUDIO_FRAME_SIZE * MICROSECONDS_PER_SECOND) / AUDIO_SAMPLE_RATE; + for (let i = 0; i < AUDIO_FRAME_COUNT; i++) { + const audioBuffer = sourceSamples.subarray(i * AUDIO_FRAME_SIZE, (i + 1) * AUDIO_FRAME_SIZE); + const audioData = new AudioData({ + format: "f32", + sampleRate: AUDIO_SAMPLE_RATE, + numberOfFrames: AUDIO_FRAME_SIZE, + numberOfChannels: AUDIO_CHANNELS, + timestamp: i * frameDurationUs, + data: audioBuffer, + }); + session.audioEncoder.encode(audioData); + audioData.close(); + } + await session.audioEncoder.flush(); + await session.audioDecoder.flush(); + } + + /** + * Simulates a realistic voice-chat audio processing pipeline. + * We run an OfflineAudioContext in stereo to test: + * - A 5-node graph (Source -> Filter -> Compressor -> Gain -> StereoPanner). + * - Parameter automations (panning sweeps, volume changes, and filter sweeps) + * to stress-test sample-accurate calculations on the audio rendering thread. + */ + async function simulateAudioEffects() { + const length = AUDIO_SAMPLE_RATE * AUDIO_OFFLINE_DURATION_SECONDS; + // Run stereo to support panning + const offline = new OfflineAudioContext(STEREO_CHANNELS, length, AUDIO_SAMPLE_RATE); + const buffer = offline.createBuffer(AUDIO_CHANNELS, length, AUDIO_SAMPLE_RATE); + const channel = buffer.getChannelData(0); + channel.set(PRE_GENERATED_AUDIO_SAMPLES); + + const source = offline.createBufferSource(); + source.buffer = buffer; + + const highpass = offline.createBiquadFilter(); + highpass.type = "highpass"; + // Automate highpass filter cutoff frequency + highpass.frequency.setValueAtTime(100, 0); + highpass.frequency.linearRampToValueAtTime(150, AUDIO_OFFLINE_DURATION_SECONDS / 2); + highpass.frequency.linearRampToValueAtTime(100, AUDIO_OFFLINE_DURATION_SECONDS); + + const compressor = offline.createDynamicsCompressor(); + + const gain = offline.createGain(); + // Automate gain to simulate voice level fluctuations + gain.gain.setValueAtTime(0.8, 0); + gain.gain.linearRampToValueAtTime(0.5, AUDIO_OFFLINE_DURATION_SECONDS / 2); + gain.gain.linearRampToValueAtTime(0.8, AUDIO_OFFLINE_DURATION_SECONDS); + + const panner = offline.createStereoPanner(); + // Automate panning to simulate user positioning/movement in stereo space + panner.pan.setValueAtTime(-1.0, 0); + panner.pan.linearRampToValueAtTime(1.0, AUDIO_OFFLINE_DURATION_SECONDS); + + // Connect the graph + source.connect(highpass).connect(compressor).connect(gain).connect(panner).connect(offline.destination); + + source.start(0); + const renderedBuffer = await offline.startRendering(); + return renderedBuffer.getChannelData(0); + } + + function teardownSession() { + session.videoEncoder?.close(); + session.videoDecoder?.close(); + session.audioEncoder?.close(); + session.audioDecoder?.close(); + session.videoEncoder = null; + session.videoDecoder = null; + session.audioEncoder = null; + session.audioDecoder = null; + + localCtx.clearRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT); + remoteCtx.clearRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT); + + setStatus("Left call"); + } + + async function runVideoBenchmark() { + try { + await initializeVideoSession(); + await simulateVideoCall(); + teardownSession(); + markCompleted("video-benchmark"); + } catch (e) { + setStatus(`VideoChat failed: ${e.message}`); + throw e; + } + } + + async function runVoiceBenchmark() { + try { + await initializeAudioSession(); + // WebAudio + const processedAudio = await simulateAudioEffects(); + // WebCodecs + await simulateVoiceCall(processedAudio); + setStatus("Audio processed"); + teardownSession(); + markCompleted("voice-benchmark"); + } catch (e) { + setStatus(`VoiceChat failed: ${e.message}`); + throw e; + } + } + + document.getElementById("video-benchmark").addEventListener("click", runVideoBenchmark); + document.getElementById("voice-benchmark").addEventListener("click", runVoiceBenchmark); +})(); diff --git a/suites-experimental/media-performance/streaming.html b/suites-experimental/media-performance/streaming.html new file mode 100644 index 000000000..845abd16c --- /dev/null +++ b/suites-experimental/media-performance/streaming.html @@ -0,0 +1,21 @@ + + + + + Media Streaming Workload + + + +
+ + +
+
+ +
+ Idle +
+
+ + + diff --git a/suites-experimental/media-performance/streaming.js b/suites-experimental/media-performance/streaming.js new file mode 100644 index 000000000..af3a8c619 --- /dev/null +++ b/suites-experimental/media-performance/streaming.js @@ -0,0 +1,182 @@ +/** + * Media Source Extensions (MSE) playback benchmark. + * Simulates a video streaming user journey: prefetching video chunks, + * initializing a MediaSource, loading video data, beginning playback, + * measuring paint latency (via requestVideoFrameCallback), and seeking. + */ +(function () { + const video = document.getElementById("player"); + video.muted = true; + video.playsInline = true; + const statusEl = document.getElementById("status"); + + // We use a VP9 video track and an Opus audio track in separate WebM containers. + const VIDEO_URL = "bigbuckbunny-video.webm"; + const VIDEO_MIME = 'video/webm; codecs="vp9"'; + const AUDIO_URL = "bigbuckbunny-audio.webm"; + const AUDIO_MIME = 'audio/webm; codecs="opus"'; + // We choose a value which is intentionally not on a key-frame, but several frames after one. + // This ensures that the seek requires decoding a sequence of inter-frames (P-frames), + // rather than just jumping to a key-frame, which measures more realistic decoding latency. + // In bigbuckbunny.mp4, key-frames are at 0.00s and 8.33s. 1.6s is 48 frames after the + // first key-frame (at 30fps), forcing the decoder to process all preceding P-frames. + const SEEK_DELTA_SECONDS = 1.5; + const INITIAL_PAINT_MIN_TIME_SECONDS = 0.001; + const SEEK_TOLERANCE_SECONDS = 0.5; + const SEEK_END_MARGIN_SECONDS = 0.1; + + const session = { + videoBuffer: null, + audioBuffer: null, + sourceUrl: null, + loaded: false, + mediaSource: null, + }; + + function setStatus(text) { + statusEl.textContent = text; + } + + function markCompleted(buttonId) { + document.getElementById(buttonId).classList.add("completed"); + } + + function waitForPaintedFrame(minMediaTime = 0) { + return new Promise((resolve, reject) => { + if (typeof video.requestVideoFrameCallback !== "function") { + reject(new Error("requestVideoFrameCallback not supported")); + return; + } + const checkFrame = (now, metadata) => { + // Ensure the frame painted matches or exceeds our target media position. + if (metadata.mediaTime >= minMediaTime) + resolve(); + else + video.requestVideoFrameCallback(checkFrame); + }; + video.requestVideoFrameCallback(checkFrame); + }); + } + + async function prefetchVideo() { + const [vRes, aRes] = await Promise.all([fetch(VIDEO_URL), fetch(AUDIO_URL)]); + if (!vRes.ok || !aRes.ok) + throw new Error("Fetch failed"); + + [session.videoBuffer, session.audioBuffer] = await Promise.all([vRes.arrayBuffer(), aRes.arrayBuffer()]); + + const MediaSourceAPI = window.ManagedMediaSource || window.MediaSource; + if (typeof MediaSourceAPI !== "function" || !MediaSourceAPI.isTypeSupported(VIDEO_MIME) || !MediaSourceAPI.isTypeSupported(AUDIO_MIME)) + throw new Error("MediaSource or MIME type not supported"); + + session.mediaSource = new MediaSourceAPI(); + session.sourceUrl = URL.createObjectURL(session.mediaSource); + video.src = session.sourceUrl; + + await new Promise((resolve) => { + if (session.mediaSource.readyState === "open") + resolve(); + else + session.mediaSource.addEventListener("sourceopen", resolve, { once: true }); + }); + document.body.dataset.prefetchReady = "1"; + } + + function appendBufferAsync(sourceBuffer, buffer) { + return new Promise((resolve, reject) => { + const onAppendError = () => { + sourceBuffer.removeEventListener("updateend", onUpdateEnd); + reject(new Error("SourceBuffer append error")); + }; + const onUpdateEnd = () => { + sourceBuffer.removeEventListener("error", onAppendError); + resolve(); + }; + sourceBuffer.addEventListener("error", onAppendError, { once: true }); + sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true }); + sourceBuffer.appendBuffer(buffer); + }); + } + + async function initialPlayback() { + try { + if (!session.loaded && (!session.mediaSource || session.mediaSource.readyState !== "open" || !session.videoBuffer || !session.audioBuffer)) + throw new Error("Benchmark error: Prefetch step must complete before starting initial playback."); + + // Wait for a frame with mediaTime > 0 to ensure playback has actually progressed + // past the automatically pre-rendered first frame. + const painted = waitForPaintedFrame(INITIAL_PAINT_MIN_TIME_SECONDS); + + // Step 1: Add separate SourceBuffers for Video and Audio (DASH pattern) + const videoSourceBuffer = session.mediaSource.addSourceBuffer(VIDEO_MIME); + const audioSourceBuffer = session.mediaSource.addSourceBuffer(AUDIO_MIME); + await Promise.all([appendBufferAsync(videoSourceBuffer, session.videoBuffer), appendBufferAsync(audioSourceBuffer, session.audioBuffer)]); + + if (session.mediaSource.readyState === "open") + session.mediaSource.endOfStream(); + + session.loaded = true; + + // Step 2: Start playback and await the painted frame. + await Promise.all([video.play(), painted]); + setStatus(`Loaded (duration=${video.duration.toFixed(2)}s)`); + markCompleted("initial-playback"); + } catch (e) { + if (e.name === "NotAllowedError") + setStatus(`Playback failed: ${e.message} (Verify Low Power Mode is off and autoplay allowed)`); + else + setStatus(`Playback failed: ${e.message}`); + throw e; + } + } + + function waitForSeeked(targetTime, tolerance = SEEK_TOLERANCE_SECONDS) { + return new Promise((resolve, reject) => { + const cleanup = () => { + video.removeEventListener("seeked", handleSeeked); + video.removeEventListener("error", handleError); + }; + + const handleSeeked = () => { + const diff = Math.abs(video.currentTime - targetTime); + + if (diff <= tolerance) { + cleanup(); + resolve(); + } else { + cleanup(); + reject(new Error(`Seek target mismatch. Expected ~${targetTime}s, but got ${video.currentTime}s`)); + } + }; + + const handleError = () => { + cleanup(); + reject(new Error("Video error during seek")); + }; + + video.addEventListener("seeked", handleSeeked); + video.addEventListener("error", handleError); + }); + } + + async function seek() { + try { + if (!session.loaded || !isFinite(video.duration)) + throw new Error("Benchmark error: Seek started before initial playback completed successfully."); + const target = Math.min(video.currentTime + SEEK_DELTA_SECONDS, Math.max(0, video.duration - SEEK_END_MARGIN_SECONDS)); + const seeked = waitForSeeked(target); + const painted = waitForPaintedFrame(target); + video.currentTime = target; + await Promise.all([seeked, painted]); + setStatus(`Seeked to ${video.currentTime.toFixed(2)}s`); + markCompleted("seek"); + } catch (e) { + setStatus(`Seek failed: ${e.message}`); + throw e; + } + } + + window.prefetchVideo = prefetchVideo; + document.getElementById("initial-playback").addEventListener("click", initialPlayback); + document.getElementById("seek").addEventListener("click", seek); +})(); diff --git a/suites-experimental/media-performance/style.css b/suites-experimental/media-performance/style.css new file mode 100644 index 000000000..2a073b2d3 --- /dev/null +++ b/suites-experimental/media-performance/style.css @@ -0,0 +1,61 @@ +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background-color: #ffffff; + color: #1a1a1a; + margin: 16px; +} + +.controls { + display: flex; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +button { + background-color: #f4f4f4; + color: #1a1a1a; + border: 1px solid #cccccc; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-family: inherit; +} + +button.completed { + background-color: #d8f3dc; + border-color: #2d6a4f; +} + +.stage { + border: 1px solid #cccccc; + padding: 12px; + border-radius: 4px; +} + +.canvas-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.canvas-cell h4 { + margin: 0 0 8px; + font-size: 13px; +} + +canvas, +video { + background: #000000; + display: block; + width: 100%; + height: auto; +} + +.status { + margin-top: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: #555555; +} diff --git a/suites-experimental/suites.mjs b/suites-experimental/suites.mjs index 2f740e4bd..38b21a78d 100644 --- a/suites-experimental/suites.mjs +++ b/suites-experimental/suites.mjs @@ -292,4 +292,44 @@ export const ExperimentalSuites = freezeSuites([ }), ], }, + { + name: "Media-Conferencing", + url: "suites-experimental/media-performance/conferencing.html", + tags: ["experimental", "media"], + type: "async", + async prepare(page) { + await page.waitForElement("#video-benchmark"); + }, + tests: [ + new BenchmarkTestStep("VideoChat", async (page) => { + page.querySelector("#video-benchmark").click(); + await page.waitForElement("#video-benchmark.completed"); + }), + new BenchmarkTestStep("VoiceChat", async (page) => { + page.querySelector("#voice-benchmark").click(); + await page.waitForElement("#voice-benchmark.completed"); + }), + ], + }, + { + name: "Media-Streaming", + url: "suites-experimental/media-performance/streaming.html", + tags: ["experimental", "media"], + type: "async", + async prepare(page) { + await page.waitForElement("#initial-playback"); + page.call("prefetchVideo"); + await page.waitForElement("body[data-prefetch-ready='1']"); + }, + tests: [ + new BenchmarkTestStep("InitialPlayback", async (page) => { + page.querySelector("#initial-playback").click(); + await page.waitForElement("#initial-playback.completed"); + }), + new BenchmarkTestStep("Seek", async (page) => { + page.querySelector("#seek").click(); + await page.waitForElement("#seek.completed"); + }), + ], + }, ]);