blob: 04fd204715c8b2f63bb202e305760dd2a648a10a [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import type {Trace} from '../../public/trace';
import {BLOB, LONG, NUM, STR_NULL} from '../../trace_processor/query_result';
// Max in-flight decoder inputs before the feed loop awaits, to bound the
// decoder's queue and held-frame memory.
const DECODER_BACKPRESSURE_LIMIT = 32;
// How many AUs the feed loop fetches in one SQL round trip. Keeps peak JS
// memory bounded to O(batch) encoded bytes rather than O(stream).
const FETCH_BATCH = 32;
// One displayable access unit. Encoded bytes are not held here; they're
// fetched on demand via __INTRINSIC_VIDEO_FRAME_AU_DATA(id).
export interface FrameInfo {
id: number;
ts: bigint; // perfetto boot-time ns of the on-screen change
frameNumber: number;
isKey: boolean;
ptsUs: number;
}
// Decoder setup, cached once per stream.
interface Setup {
codecString: string; // RFC 6381, e.g. "avc1.42c029"
config: Uint8Array; // Annex-B SPS/PPS, prepended to the first decoded chunk
}
// A decoded frame queued for paint, tagged with its index into `frames`.
interface QueuedFrame {
idx: number;
frame: VideoFrame;
}
// The live play session: decoder + queue of decoded frames awaiting paint.
// Owned by play() and torn down by stop().
interface PlaySession {
decoder: VideoDecoder;
queue: QueuedFrame[];
}
// Per-stream player. The details panel mounts a <canvas> and registers it
// via attachCanvas(); decoded VideoFrames are drawn onto it with
// ctx.drawImage(). play() pipes AUs through one long-lived VideoDecoder
// backpressured against decodeQueueSize; a requestAnimationFrame loop paints
// whichever decoded frame is due against wall-clock, using the captured `ts`
// deltas so playback matches the device's own cadence.
export class VideoFramePlayer {
// Stream identity.
readonly trace: Trace;
readonly trackUri: string;
private readonly displayId: number;
// Stream metadata, filled by ensureFramesLoaded.
frames: FrameInfo[] = [];
currentIdx = 0;
// Playback speed multiplier (1 = real time). Read live by the playback
// loop, so changes take effect on the next frame.
playbackRate = 1;
// android_video_* stats entries that fired for this stream; surfaced in
// the details panel. Empty = healthy.
errors: string[] = [];
private framesLoaded = false;
private configId?: number;
private codecString?: string;
private setup?: Setup;
// Render target, mounted/unmounted by the details panel.
private canvas?: HTMLCanvasElement;
// Bumped on every stop/seek so in-flight async ops can detect cancellation.
private token = 0;
// Present iff currently playing.
private playSession?: PlaySession;
constructor(trace: Trace, trackUri: string, displayId: number) {
this.trace = trace;
this.trackUri = trackUri;
this.displayId = displayId;
}
// Whether this browser/context can decode frames. WebCodecs is absent in
// older browsers and in non-secure contexts; when false the preview/play
// controls can't do anything, so the panel shows a hint instead.
get webCodecsAvailable(): boolean {
return typeof VideoDecoder !== 'undefined';
}
get playing(): boolean {
return this.playSession !== undefined;
}
get currentFrame(): FrameInfo | undefined {
return this.frames[this.currentIdx];
}
// The details panel calls this on canvas mount. Re-paints the current
// frame so navigating away and back doesn't leave a blank box.
attachCanvas(c: HTMLCanvasElement): void {
this.canvas = c;
if (this.framesLoaded && !this.playing) {
void this.seekToIndex(this.currentIdx);
}
}
detachCanvas(): void {
this.canvas = undefined;
this.stop();
}
async ensureFramesLoaded(): Promise<void> {
if (this.framesLoaded) return;
const res = await this.trace.engine.query(`
SELECT id, ts, frame_number AS frameNumber,
COALESCE(is_key_frame, 0) AS isKey,
COALESCE(pts_us, 0) AS ptsUs,
COALESCE(is_config, 0) AS isConfig,
codec_string AS codecString
FROM __intrinsic_video_frames
WHERE display_id = ${this.displayId}
ORDER BY ts
`);
const it = res.iter({
id: NUM,
ts: LONG,
frameNumber: NUM,
isKey: NUM,
ptsUs: NUM,
isConfig: NUM,
codecString: STR_NULL,
});
for (; it.valid(); it.next()) {
if (it.codecString !== null) this.codecString = it.codecString;
if (it.isConfig) {
this.configId = it.id;
continue;
}
this.frames.push({
id: it.id,
ts: it.ts,
frameNumber: it.frameNumber,
isKey: it.isKey !== 0,
ptsUs: it.ptsUs,
});
}
// Per-stream failures: import-log rows for this display_id; name = reason.
const errRes = await this.trace.engine.query(`
SELECT DISTINCT l.name AS name
FROM _trace_import_logs l
JOIN args a USING (arg_set_id)
WHERE l.name LIKE 'android_video_%'
AND a.key = 'display_id'
AND a.int_value = ${this.displayId}
ORDER BY l.name
`);
const errIt = errRes.iter({name: STR_NULL});
for (; errIt.valid(); errIt.next()) {
if (errIt.name !== null) this.errors.push(errIt.name);
}
this.framesLoaded = true;
}
async seek(eventId: number): Promise<void> {
const idx = this.frames.findIndex((f) => f.id === eventId);
if (idx >= 0) await this.seekToIndex(idx);
}
prev(): void {
if (this.currentIdx > 0) this.selectAndSeek(this.currentIdx - 1);
}
next(): void {
if (this.currentIdx < this.frames.length - 1) {
this.selectAndSeek(this.currentIdx + 1);
}
}
// Seek and move the timeline cursor together. The details panel's load()
// skips re-decoding when the selection is already the seeked frame.
private selectAndSeek(idx: number): void {
void this.seekToIndex(idx);
this.trace.selection.selectTrackEvent(this.trackUri, this.frames[idx].id);
}
togglePlay(): void {
if (this.playing) this.stop();
else this.play();
}
setPlaybackRate(rate: number): void {
this.playbackRate = rate;
}
play(): void {
this.stop();
void this.runPlayLoop(++this.token, this.currentIdx);
}
stop(): void {
this.token++;
const s = this.playSession;
if (s === undefined) return;
this.playSession = undefined;
if (s.decoder.state !== 'closed') s.decoder.close();
for (const f of s.queue) f.frame.close();
}
// Decode nearestKey(idx)..idx with a one-shot decoder, returning the
// frames and the key-frame index k. The caller owns closing them. Pure:
// touches no playback state, so seek and the tooltip path can call it
// concurrently. Cost is O(distance to the preceding key frame).
private async decodeUpTo(
idx: number,
): Promise<{outs: VideoFrame[]; k: number} | undefined> {
if (typeof VideoDecoder === 'undefined') return undefined;
const setup = await this.loadSetup();
if (setup === undefined) return undefined;
const k = this.nearestKey(idx);
const range = this.frames.slice(k, idx + 1);
const datas = await this.fetchAuData(range.map((f) => f.id));
const outs: VideoFrame[] = [];
const decoder = new VideoDecoder({
output: (f) => outs.push(f),
error: (e) => console.warn('VideoFrames:', e),
});
decoder.configure(decoderConfig(setup));
for (let i = 0; i < range.length; i++) {
decoder.decode(chunkFor(setup, range[i], datas[i], i === 0));
}
await decoder.flush().catch(() => undefined);
if (decoder.state !== 'closed') decoder.close();
return {outs, k};
}
// Seek to a frame by index and paint it onto the live preview canvas.
private async seekToIndex(idx: number): Promise<void> {
if (idx < 0 || idx >= this.frames.length) return;
// Capture the post-stop() token so we can drop a stale decode if a
// newer seek/play started while ours was in flight.
this.stop();
const token = this.token;
this.currentIdx = idx;
const decoded = await this.decodeUpTo(idx);
if (decoded === undefined) return;
const {outs, k} = decoded;
if (token !== this.token) {
for (const f of outs) f.close();
return;
}
const target = outs[idx - k] ?? outs[outs.length - 1];
if (target !== undefined) this.drawFrame(target);
for (const f of outs) f.close();
m.redraw();
}
// Decode one frame (by event id) to a PNG data URL for the hover
// tooltip. Read-only: decodes onto a throwaway canvas, never touching
// the live preview or playback state.
async decodeFrameImage(eventId: number): Promise<string | undefined> {
await this.ensureFramesLoaded();
const idx = this.frames.findIndex((f) => f.id === eventId);
if (idx < 0) return undefined;
const decoded = await this.decodeUpTo(idx);
if (decoded === undefined) return undefined;
const {outs, k} = decoded;
try {
const frame = outs[idx - k] ?? outs[outs.length - 1];
if (frame === undefined) return undefined;
const w = frame.displayWidth || frame.codedWidth;
const h = frame.displayHeight || frame.codedHeight;
if (w <= 0 || h <= 0) return undefined;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d')?.drawImage(frame, 0, 0);
return canvas.toDataURL('image/png');
} finally {
for (const f of outs) f.close();
}
}
// openPlaySession() sets up the decoder, startPlaybackLoop() paints from
// the queue at wall-clock cadence, feedDecoder() decodes AUs with
// backpressure. All gated by `token`; teardown is in stop().
private async runPlayLoop(token: number, fromIdx: number): Promise<void> {
// Decode from the nearest key frame (needed to seed the decoder) but
// paint from fromIdx, so play() starts on the selected frame. Frames in
// [k, fromIdx) are decoded only to prime the decoder, not painted.
const k = this.nearestKey(fromIdx);
const session = await this.openPlaySession(token, k);
if (session === undefined) return;
this.startPlaybackLoop(token, session, fromIdx);
await this.feedDecoder(token, session, k);
}
private async openPlaySession(
token: number,
k: number,
): Promise<PlaySession | undefined> {
if (typeof VideoDecoder === 'undefined' || this.canvas === undefined) {
return undefined;
}
const setup = await this.loadSetup();
if (setup === undefined || token !== this.token) return undefined;
const queue: QueuedFrame[] = [];
// n-th output is frame k+n. Counted independently of queue.length,
// which the playback loop drains concurrently.
let decoded = 0;
const decoder = new VideoDecoder({
output: (f) => {
if (token !== this.token) {
f.close();
return;
}
queue.push({idx: k + decoded++, frame: f});
},
error: (e) => console.warn('VideoFrames:', e),
});
decoder.configure(decoderConfig(setup));
const session: PlaySession = {decoder, queue};
this.playSession = session;
return session;
}
private startPlaybackLoop(
token: number,
session: PlaySession,
fromIdx: number,
): void {
// Virtual playback clock in trace-ns, anchored to the first painted
// frame (so startup decode latency doesn't skip frames) and advanced
// each frame by the wall-clock delta scaled by playbackRate.
let lastWallMs = 0;
let playbackTs = 0n;
let started = false;
const step = () => {
if (token !== this.token) return;
// Drop seed frames decoded before fromIdx without painting (they
// exist only to prime the decoder's reference list).
while (session.queue.length > 0 && session.queue[0].idx < fromIdx) {
session.queue.shift()!.frame.close();
}
let due: QueuedFrame | undefined;
if (!started) {
// Paint the first frame immediately and anchor the clock to it.
if (session.queue.length > 0) {
due = session.queue.shift();
started = true;
lastWallMs = performance.now();
playbackTs = this.frames[due!.idx].ts;
}
} else {
const nowMs = performance.now();
playbackTs += BigInt(
Math.floor((nowMs - lastWallMs) * 1e6 * this.playbackRate),
);
lastWallMs = nowMs;
// Drain all frames whose ts is now due; paint only the latest.
while (
session.queue.length > 0 &&
this.frames[session.queue[0].idx].ts <= playbackTs
) {
if (due !== undefined) due.frame.close();
due = session.queue.shift();
}
}
if (due !== undefined) {
this.drawFrame(due.frame);
due.frame.close();
if (due.idx !== this.currentIdx) {
this.currentIdx = due.idx;
// Move the timeline cursor with playback. The details panel skips
// re-seeking while playing, so the canvas doesn't flicker.
this.trace.selection.selectTrackEvent(
this.trackUri,
this.frames[due.idx].id,
);
m.redraw();
}
if (due.idx >= this.frames.length - 1) {
this.stop();
m.redraw();
return;
}
}
requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
private async feedDecoder(
token: number,
session: PlaySession,
k: number,
): Promise<void> {
const setup = this.setup;
if (setup === undefined) return;
// Fetch a batch at a time so peak JS memory is O(batch), not O(stream).
for (let base = k; base < this.frames.length; base += FETCH_BATCH) {
if (token !== this.token) break;
const end = Math.min(base + FETCH_BATCH, this.frames.length);
const batch = this.frames.slice(base, end);
const datas = await this.fetchAuData(batch.map((f) => f.id));
for (let i = 0; i < batch.length; i++) {
if (!(await this.waitForDecoderCapacity(token, session.decoder))) {
return;
}
session.decoder.decode(
chunkFor(setup, batch[i], datas[i], base + i === k),
);
}
}
// flush() rejects if the decoder was already closed (end-of-stream or
// stop()); the session is being torn down either way.
await session.decoder.flush().catch(() => undefined);
}
// Returns true once the decoder can accept another chunk, false if `token`
// was invalidated while we waited (caller should bail).
private async waitForDecoderCapacity(
token: number,
decoder: VideoDecoder,
): Promise<boolean> {
while (decoder.decodeQueueSize > DECODER_BACKPRESSURE_LIMIT) {
if (token !== this.token) return false;
await new Promise<void>((r) => requestAnimationFrame(() => r()));
}
return token === this.token;
}
// Index of the nearest key frame at or before idx. Decoders need a key
// frame to seed the reference list before any non-key frame.
private nearestKey(idx: number): number {
let k = idx;
while (k > 0 && !this.frames[k].isKey) k--;
return k;
}
private async loadSetup(): Promise<Setup | undefined> {
if (this.setup !== undefined) return this.setup;
if (this.configId === undefined || this.codecString === undefined) return;
const [config] = await this.fetchAuData([this.configId]);
if (config.length === 0) return;
this.setup = {codecString: this.codecString, config};
return this.setup;
}
private async fetchAuData(ids: ReadonlyArray<number>): Promise<Uint8Array[]> {
if (ids.length === 0) return [];
const res = await this.trace.engine.query(`
SELECT id, __intrinsic_video_frame_au_data(id) AS data
FROM __intrinsic_video_frames
WHERE id IN (${ids.join(',')})
`);
const it = res.iter({id: NUM, data: BLOB});
const byId = new Map<number, Uint8Array>();
for (; it.valid(); it.next()) byId.set(it.id, it.data);
return ids.map((id) => byId.get(id) ?? new Uint8Array(0));
}
private drawFrame(frame: VideoFrame): void {
const c = this.canvas;
if (c === undefined) return;
const w = frame.displayWidth || frame.codedWidth;
const h = frame.displayHeight || frame.codedHeight;
if (w <= 0 || h <= 0) return;
if (c.width !== w) c.width = w;
if (c.height !== h) c.height = h;
c.getContext('2d')?.drawImage(frame, 0, 0);
}
}
// The first chunk per stream carries the codec_config (SPS/PPS) prepended,
// since those NALs were emitted out-of-band.
function chunkFor(
setup: Setup,
frame: FrameInfo,
bytes: Uint8Array,
isFirst: boolean,
): EncodedVideoChunk {
return new EncodedVideoChunk({
type: frame.isKey ? 'key' : 'delta',
timestamp: frame.ptsUs,
data: isFirst ? concat(setup.config, bytes) : bytes,
});
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}
function decoderConfig(setup: Setup): VideoDecoderConfig {
return {
codec: setup.codecString,
optimizeForLatency: true,
hardwareAcceleration: 'no-preference',
};
}