diff --git a/apps/web/src/components/replays/engine/replay-engine.ts b/apps/web/src/components/replays/engine/replay-engine.ts new file mode 100644 index 000000000..26b483606 --- /dev/null +++ b/apps/web/src/components/replays/engine/replay-engine.ts @@ -0,0 +1,88 @@ +// The replay engine seam +// +// The player used to construct `new Replayer(...)` inline, which made rrweb the +// only thing it could ever play. The iOS SDK records H.264 segments and wraps +// each one in rrweb-*shaped* events so the chunk pipeline carries them +// untouched — but there is no DOM to rebuild, so rrweb renders nothing. +// +// Everything above this interface (the provider's transport state, the trimmed +// timeline, markers, idle bands, the chunk loader) is format-agnostic and stays +// exactly as it was. Everything rrweb-specific lives in `rrweb-engine.ts`; +// everything video-specific in `video-engine.ts`. +// +// The contract is deliberately shaped like the rrweb surface the provider +// already depended on, so the refactor is behaviour-preserving: +// +// new Replayer(events, {root}) -> ReplayEngineFactory.create({mount, events}) +// getMetaData().totalTime -> totalTimeMs +// getCurrentTime() -> getCurrentTimeMs() +// play(o) / pause(o?) -> play(o) / pause(o?) +// setConfig({speed}) -> setSpeed(speed) +// addEvent(e) -> addEvent(e) +// destroy() -> destroy() +// on(Finish) / on(Resize) -> onFinish / onResize callbacks +// .iframe + .wrapper.style -> fit(container) + +/** The recorded viewport, used to letterbox the recording inside the surface. */ +export interface Viewport { + readonly width: number + readonly height: number +} + +export interface ReplayEngineCreateInput { + /** The surface's inner div. The engine owns its contents entirely. */ + readonly mount: HTMLElement + /** The seed events. Later events arrive through `addEvent`, forward-only. */ + readonly events: ReadonlyArray + /** + * Viewport to fall back to when the engine can't report its own — derived + * from the stream's `meta` events by `deriveMeta`. + */ + readonly fallbackViewport: Viewport + /** Playback reached the end of the recording. */ + onFinish(): void + /** The recorded viewport changed mid-session; the surface must re-fit. */ + onResize(): void +} + +export interface ReplayEngine { + /** Length of the loaded recording, in real ms. */ + readonly totalTimeMs: number + /** + * Playhead as a real-ms offset from session start, matching the clock the + * trimmed `Timeline` and backend span alignment are built against. + * + * Implementations must never return a negative or non-finite value — the + * provider feeds this straight back into `play()`. + */ + getCurrentTimeMs(): number + play(offsetMs: number): void + /** With no offset, hold at the current position. */ + pause(offsetMs?: number): void + setSpeed(speed: number): void + /** + * Append an event that arrived after construction. + * + * Forward-only by contract: every trailing event postdates the seed. A + * backward seek rebuilds the engine from the nearest checkpoint instead + * (see `requestSeek` in `use-replay-chunk-loader.ts`). + */ + addEvent(event: unknown): void + /** Fit the recording inside `container`, letterboxed and centred. */ + fit(container: HTMLElement): void + destroy(): void +} + +export interface ReplayEngineFactory { + create(input: ReplayEngineCreateInput): ReplayEngine +} + +/** + * Which engine plays a session's chunks. + * + * Carried by the `maple.session.replay_format` resource attribute so the player + * can pick an engine from session metadata alone, without downloading a chunk + * to find out. An absent attribute means `rrweb` — every session recorded + * before the marker existed is a browser recording. + */ +export type ReplayFormat = "rrweb" | "video" diff --git a/apps/web/src/components/replays/engine/rrweb-engine.ts b/apps/web/src/components/replays/engine/rrweb-engine.ts new file mode 100644 index 000000000..ac8a0cee8 --- /dev/null +++ b/apps/web/src/components/replays/engine/rrweb-engine.ts @@ -0,0 +1,107 @@ +import { Replayer } from "@rrweb/replay" +import { ReplayerEvents } from "@rrweb/types" +import type { ReplayEngine, ReplayEngineCreateInput, ReplayEngineFactory } from "./replay-engine" + +// The rrweb engine — browser recordings. +// +// This is the behaviour the player has always had, moved behind the engine +// interface unchanged. Every quirk documented here was a shipped bug once. + +class RrwebEngine implements ReplayEngine { + private readonly replayer: Replayer + private readonly fallbackViewport: ReplayEngineCreateInput["fallbackViewport"] + + constructor(input: ReplayEngineCreateInput) { + const accent = + getComputedStyle(document.documentElement).getPropertyValue("--primary").trim() || "#6366f1" + + this.fallbackViewport = input.fallbackViewport + this.replayer = new Replayer(input.events as never, { + root: input.mount, + speed: 1, + // We skip idle ourselves by jumping (see the provider's rAF loop) — + // rrweb's own skipInactive only fast-forwards, which is slow. Keep it off. + skipInactive: false, + mouseTail: { duration: 600, lineCap: "round", lineWidth: 3, strokeStyle: accent }, + showWarning: false, + showDebug: false, + liveMode: false, + }) + + // rrweb's own transport events are unreliable in @rrweb/replay (Start/Resume + // often don't fire); play/pause state is driven from the provider's handlers. + // We still honour Finish to flip back to the replay affordance at the end. + this.replayer.on(ReplayerEvents.Finish, () => input.onFinish()) + // The recorded viewport can change mid-session (responsive / window resize); + // rrweb resizes its iframe and emits Resize. Also fires for the initial snapshot. + this.replayer.on(ReplayerEvents.Resize, () => input.onResize()) + } + + get totalTimeMs(): number { + return this.replayer.getMetaData().totalTime + } + + /** + * Read the playhead, treating "not started yet" as 0. + * + * rrweb builds its player context with `baselineTime: 0`, and + * `getCurrentTime()` is `timer.timeOffset + (baselineTime - events[0].timestamp)` + * — so until the engine has been driven by a `play()` / `pause(offset)` (the only + * things that assign `baselineTime`), it reports `-events[0].timestamp`: a + * negative epoch, ~55 years. Feeding that back into `play()` re-bases the whole + * stream decades into the future and nothing ever casts, which is what left the + * player frozen at 0:00 until the first scrub re-based it for us. + */ + getCurrentTimeMs(): number { + const ms = this.replayer.getCurrentTime() + return Number.isFinite(ms) && ms > 0 ? ms : 0 + } + + play(offsetMs: number): void { + this.replayer.play(offsetMs) + } + + pause(offsetMs?: number): void { + this.replayer.pause(offsetMs) + } + + setSpeed(speed: number): void { + this.replayer.setConfig({ speed }) + } + + addEvent(event: unknown): void { + this.replayer.addEvent(event as never) + } + + /** + * Fit the recorded page *inside* the surface (contain + letterbox), centered on + * both axes. The surface keeps a constant box (CSS aspect-ratio / fullscreen + * flex), so the player height never jumps between recordings. + * + * Scale against the iframe rrweb actually built, not the statically-derived + * fallback — a session can carry several Meta events (viewport resizes), and + * `deriveMeta` keeps the last one, which may not match the current frame. The + * iframe's width/height *attributes* always reflect the current viewport, and + * the `Resize` listener re-runs this when they change mid-playback. + */ + fit(container: HTMLElement): void { + const vw = Number(this.replayer.iframe?.getAttribute("width")) || this.fallbackViewport.width + const vh = Number(this.replayer.iframe?.getAttribute("height")) || this.fallbackViewport.height + const availW = container.clientWidth + const availH = container.clientHeight + if (!availW || !availH || !vw || !vh) return + const scale = Math.min(availW / vw, availH / vh) + const offsetX = Math.max(0, (availW - vw * scale) / 2) + const offsetY = Math.max(0, (availH - vh * scale) / 2) + this.replayer.wrapper.style.transformOrigin = "top left" + this.replayer.wrapper.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})` + } + + destroy(): void { + this.replayer.destroy() + } +} + +export const rrwebEngineFactory: ReplayEngineFactory = { + create: (input) => new RrwebEngine(input), +} diff --git a/apps/web/src/components/replays/engine/video-engine.test.ts b/apps/web/src/components/replays/engine/video-engine.test.ts new file mode 100644 index 000000000..d3f91cbfc --- /dev/null +++ b/apps/web/src/components/replays/engine/video-engine.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest" +import { + extractVideoSegments, + resolveSegment, + segmentsTotalMs, + videoSegmentPayload, + type VideoSegment, +} from "./video-engine" + +// A mobile recording is a sequence of independent MP4s, each opening on an IDR +// keyframe. The segment math below is what turns a playhead offset into +// (which file, how far into it) — the whole reason seeking is exact here. +// +// The DOM side (