From 81627d05f9d277bc676f869337c665b680b8b1ac Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 7 Sep 2026 18:21:18 +0530 Subject: [PATCH 1/5] fix(app): contain a DOM-less capture in the player pane --- .../src/components/browser/snapshot-styles.ts | 10 ++- .../app/test-ui/workbench/player/fixtures.ts | 33 ++++++++ .../test-ui/workbench/player/snapshot.test.ts | 84 +++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/browser/snapshot-styles.ts b/packages/app/src/components/browser/snapshot-styles.ts index 4a8d2cbd..c08f4650 100644 --- a/packages/app/src/components/browser/snapshot-styles.ts +++ b/packages/app/src/components/browser/snapshot-styles.ts @@ -74,9 +74,15 @@ export const snapshotStyles = css` overflow: hidden; } + /* The capture fills the pane and contains inside it — the same fit as the + screencast branch. Bounding the width alone scaled a portrait capture up to + the pane width, overflowed its height, and the wrapper's overflow:hidden + clipped the remainder: a 1206x2622 phone screen showed 17% of itself at + 5.9x in a 1240x457 pane. */ .screenshot-overlay img { - max-width: 100%; - height: auto; + width: 100%; + height: 100%; + object-fit: contain; display: block; } diff --git a/packages/app/test-ui/workbench/player/fixtures.ts b/packages/app/test-ui/workbench/player/fixtures.ts index 49bf003c..1cda87df 100644 --- a/packages/app/test-ui/workbench/player/fixtures.ts +++ b/packages/app/test-ui/workbench/player/fixtures.ts @@ -42,6 +42,20 @@ export const SECURE_SHOT = export const FRAME_SHOT = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mP48OEDAAWkAtFkkTHCAAAAAElFTkSuQmCC' +/** A capture taller than it is wide — the shape every phone screenshot has, and + * the one a width-only fit blew up past the pane. Its own pixels are what the + * player fits by, so the specs read this ratio back off the layout. Both of + * these are larger than the panes the specs give them, or a fit that ignores + * an axis still leaves them inside it and nothing is proved. */ +export const PORTRAIT_SHOT = + 'iVBORw0KGgoAAAANSUhEUgAAAHgAAAEECAIAAABskWeLAAABX0lEQVR42u3QMQ0AAAgDsMlBE4qRhYtdTaqgmT0KokC0aESLFm1BtGhEixZtQbRoRIsWjWjRiBYtGtGiES1aNKJFI1q0aESLRrRo0YgWjWjRohEtGtGiRSNaNKJFi0a0aESLFo1o0YgWLRrRohEtWjSiRSNatGhEi0a0aNGIFo1o0aIRLRrRokUjWjSiRYtGtGhEixaNaNGIFi0a0aIRLVo0okUjWrRoRItGtGjRiBaNaNGiES0a0aJFI1o0okWLRrRoRIsWjWjRiBYtGtGiES1aNKJFI1q0aESLRrRo0YgWjWjRohEtGtGiRSNaNKJFi0a0aESLFo1o0YgWLRrRohEtWjSiRSNatGhEi0a0aNGIFo1o0aIRLRrRokUjWjSiRYtGtGhEixaNaNGIFi0a0aIRLVo0okUjWrRoRItGtGjRiBaNaNGiES0a0aJFI1o0okWLRrRoBR1PvtEIvcLLfg8AAAAASUVORK5CYII=' +export const PORTRAIT_CAPTURE = { width: 120, height: 260 } + +/** A capture wider than the pane — the axis a width-only fit already bounded, + * so the regression guard for the other direction. 320x200. */ +export const LANDSCAPE_SHOT = + 'iVBORw0KGgoAAAANSUhEUgAAAUAAAADICAIAAAAWZq/8AAABvUlEQVR42u3TQQkAAAgEwYtjJhMbyxC+hIFJsLCZLuCpSAAGBgwMGBgMDBgYMDBgYDAwYGDAwGBgwMCAgQEDg4EBAwMGBgwMBgYMDBgYDAwYGDAwYGAwMGBgwMCAgcHAgIEBA4OBAQMDBgYMDAYGDAwYGAysAhgYMDBgYDAwYGDAwICBwcCAgQEDg4EBAwMGBgwMBgYMDBgYMDAYGDAwYGAwMGBgwMCAgcHAgIEBAwMGBgMDBgYMDAYGDAwYGDAwGBgwMGBgMDBgYMDAgIHBwICBAQMDBgYDAwYGDAwGBgwMGBgwMBgYMDBgYMDAYGDAwICBwcCAgQEDAwYGAwMGBgwMGBgMDBgYMDAYGDAwYGDAwGBgwMCAgcHAgIEBAwMGBgMDBgYMDBgYDAwYGDAwGBgwMGBgwMBgYMDAgIEBA4OBAQMDBgYDAwYGDAwYGAwMGBgwMBhYBTAwYGDAwGBgwMCAgQEDg4EBAwMGBgMDBgYMDBgYDAwYGDAwYGAwMGBgwMBgYMDAgIEBA4OBAQMDBgYMDAYGDAwYGAwMGBgwMGBgMDBgYMDAYGDAwICBAQODgQEDAwYGDAwGBgwMXCzP6VbflJe/hgAAAABJRU5ErkJggg==' + /** The two streams one player mount consumes. */ export interface TraceScenario { commands: CommandLog[] @@ -474,6 +488,25 @@ export const domlessTrace: DomlessTrace = { assertFlash } +/** The DOM-less branch carrying one capture — a native mobile trace, and any + * tall capture: no mutation stream to replay, so the screenshot is all the + * player has to fit. */ +const capturedTrace = (screenshot: string): TraceScenario => ({ + commands: [ + commandLog({ + command: 'url', + args: [LOGIN_URL], + screenshot, + startTime: RUN_START, + timestamp: RUN_START + 400 + }) + ], + mutations: [] +}) + +export const portraitTrace = capturedTrace(PORTRAIT_SHOT) +export const landscapeTrace = capturedTrace(LANDSCAPE_SHOT) + /** * A document anchor is a childList of one added node with no target; this one * carries no url, which is what makes the player fall back for the address bar. diff --git a/packages/app/test-ui/workbench/player/snapshot.test.ts b/packages/app/test-ui/workbench/player/snapshot.test.ts index a4a3b95d..4caff460 100644 --- a/packages/app/test-ui/workbench/player/snapshot.test.ts +++ b/packages/app/test-ui/workbench/player/snapshot.test.ts @@ -45,6 +45,7 @@ import { import { CAPTURED_VIEWPORT, domlessTrace, + landscapeTrace, LOGIN_SHOT, LOGIN_URL, loginTrace, @@ -53,6 +54,7 @@ import { metadataForViewport, orphanTrace, overlayLabelTrace, + portraitTrace, preCaptureTrace, RECORDING, recordedSessionMetadata, @@ -72,6 +74,8 @@ import { const TAG = 'wdio-devtools-browser' const ADDRESS_BAR = 'header .truncate' const SCREENSHOT = '.screenshot-overlay img' +/** Box the capture is fitted into, and the one that clips whatever overflows. */ +const SCREENSHOT_PANE = '.screenshot-overlay' const PLACEHOLDER = 'wdio-devtools-placeholder' const SCREENCAST = 'wdio-devtools-screencast-player' const VIEW_BUTTON = '.view-toggle button' @@ -1453,6 +1457,86 @@ describe('wdio-devtools-browser', () => { expect(iframe.style.height).toBe('800px') expect(scaleOf(iframe)).toBeCloseTo(0.5, 5) }) + + /** + * The screenshot branch — every DOM-less trace, so every native mobile one — + * is fitted by CSS rather than by the sizing pass above, and it was bounded + * on the width alone. A portrait capture was scaled up to the pane's width, + * overflowed its height, and the overflow:hidden wrapper clipped the rest: a + * 1206x2622 phone screen showed 17% of itself at 5.9x in a 1240x457 pane. + */ + describe('fitting a capture with no DOM to replay', () => { + /** Sizes the pane and waits for the capture to take a box inside it. No + * transform to wait on here — the fit is the stylesheet's, not the + * player's — so the wait is for the image to have laid out. */ + async function resizeScreenshotPane( + el: Browser, + width: number, + height: number + ): Promise { + const host = el.parentElement + if (!host) { + throw new Error('the mounted player has no pane to size') + } + host.style.width = `${width}px` + host.style.height = `${height}px` + const img = shadow(el, SCREENSHOT) + if (!img) { + throw new Error('the player rendered no screenshot') + } + await waitUntil( + () => img.complete && img.getBoundingClientRect().height > 0, + 'the capture to be laid out in the pane' + ) + return img + } + + /** The pane the capture is fitted into, and the box that clips it. */ + const clipRect = (el: Browser) => + shadow(el, SCREENSHOT_PANE)!.getBoundingClientRect() + + it('holds a portrait capture inside both axes of the pane', async () => { + const el = await mountBrowser(portraitTrace) + await settle(el) + + const img = await resizeScreenshotPane( + el, + ...paneFor(el, { w: 400, h: 200 }) + ) + + // Read against the pane rather than the numbers above, which the + // player's own chrome and padding eat into. On the width alone this + // 120x260 capture took the full pane width and more than four times its + // height, and everything past the fold was cut off. + const pane = clipRect(el) + const box = img.getBoundingClientRect() + expect(box.height).toBeLessThanOrEqual(pane.height + 1) + expect(box.width).toBeLessThanOrEqual(pane.width + 1) + expect(box.height).toBeCloseTo(pane.height, 0) + // The painted rect has no DOM box of its own to measure; `contain` is + // what letterboxes the capture inside the box asserted above, at its own + // shape, rather than cropping it to fill. + expect(getComputedStyle(img).objectFit).toBe('contain') + }) + + it('holds a capture wider than the pane inside it too', async () => { + const el = await mountBrowser(landscapeTrace) + await settle(el) + + // The axis the old rule did bound: a fit that swapped to the height + // alone draws this 320x200 capture 640px wide in a 400px pane. + const img = await resizeScreenshotPane( + el, + ...paneFor(el, { w: 400, h: 400 }) + ) + + const pane = clipRect(el) + const box = img.getBoundingClientRect() + expect(box.width).toBeLessThanOrEqual(pane.width + 1) + expect(box.height).toBeLessThanOrEqual(pane.height + 1) + expect(box.width).toBeCloseTo(pane.width, 0) + }) + }) }) describe('address-bar fallbacks', () => { From f83a58fe45ab939c700e0df9947a0ad1829d2230 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 8 Sep 2026 01:13:37 +0530 Subject: [PATCH 2/5] feat(shared): read a capture's mime and pixel size from its bytes --- .../app/src/components/browser/snapshot.ts | 3 +- packages/shared/src/image.ts | 130 ++++++++++++++++++ packages/shared/src/index.ts | 1 + packages/shared/tests/image.test.ts | 95 +++++++++++++ packages/trace/src/trace-frame-snapshots.ts | 10 +- 5 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 packages/shared/src/image.ts create mode 100644 packages/shared/tests/image.test.ts diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts index e0cbb54e..19c80c54 100644 --- a/packages/app/src/components/browser/snapshot.ts +++ b/packages/app/src/components/browser/snapshot.ts @@ -11,7 +11,6 @@ import { } from './element-overlay.js' import { commandPageUrl } from './url-at-timestamp.js' import { mutationForCommand } from './mutation-at-command.js' -import { imageMime } from './trace-timeline-utils.js' import { booleanAttributeOn, isBooleanAttribute } from './boolean-attribute.js' import { type ComponentChildren, h, render, type VNode } from 'preact' @@ -22,7 +21,7 @@ import type { SimplifiedVNode } from '@wdio/devtools-script/types' // characterData wire shape (parent ref + child index), so the replay reads it // from the same declaration that produces it. import type { TextMutation } from '@wdio/devtools-script/mutations.js' -import type { CommandLog } from '@wdio/devtools-shared' +import { imageMime, type CommandLog } from '@wdio/devtools-shared' import { mutationContext, diff --git a/packages/shared/src/image.ts b/packages/shared/src/image.ts new file mode 100644 index 00000000..3425b075 --- /dev/null +++ b/packages/shared/src/image.ts @@ -0,0 +1,130 @@ +/** Base64 magic of a PNG signature — the 8-byte header encodes deterministically. */ +const PNG_BASE64_MAGIC = 'iVBOR' +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] +const PNG_IHDR_WIDTH_OFFSET = 16 +const PNG_IHDR_END = 24 +const JPEG_SOI = [0xff, 0xd8] +/** A frame header names the image's size; DHT/JPG/DAC share the 0xC0-0xCF range. */ +const JPEG_SOF_EXCLUDED = [0xc4, 0xc8, 0xcc] +/** Start of scan — the entropy-coded data begins, so no header follows. */ +const JPEG_SOS = 0xda +/** Prefixes decoded in turn, in bytes. A capture is megabytes and only its + * header is ever read, so the small window serves every PNG (its size sits at + * a fixed offset 16 bytes in) and almost every JPEG. The large one is for a + * JPEG carrying metadata ahead of its frame header — EXIF and an embedded + * thumbnail put it 35 KB in on a device-farm capture, and each APPn segment + * may be 64 KB. */ +const HEADER_WINDOWS = [4096, 1_048_576] + +export interface ImageSize { + width: number + height: number +} + +/** Detect image mime from a base64 string's magic bytes — trace screenshots may + * be PNG (polling capture) or JPEG (CDP), and the zip names both `.jpeg`. */ +export function imageMime(base64: string): string { + return base64.startsWith(PNG_BASE64_MAGIC) ? 'image/png' : 'image/jpeg' +} + +/** Intrinsic pixel size read out of a base64 PNG or JPEG header, or null when + * the bytes name none. A capture is fitted by this rather than by the metadata + * viewport: the two disagree on both mobile platforms — Android reports the + * window without the nav bar, iOS reports points, not pixels. */ +export function imageDimensions(base64: string): ImageSize | null { + for (const window of HEADER_WINDOWS) { + const bytes = headerBytes(base64, window) + const size = pngSize(bytes) ?? jpegSize(bytes) + // A zero from a corrupt or truncated header is not a size — a caller + // deriving a ratio from it gets a 0/0 that collapses whatever it shapes. + if (size && size.width > 0 && size.height > 0) { + return size + } + // A prefix short of the window it asked for is the whole image: a wider + // one would decode the same bytes again. + if (bytes.length < window) { + break + } + } + return null +} + +/** The first `limit` bytes of a base64 string, sliced to a whole number of + * base64 groups so the decode never straddles one. Input that is not base64 at + * all (a `data:` url, a truncated frame) names no size — the null contract. */ +function headerBytes(base64: string, limit: number): Uint8Array { + const prefix = base64.slice(0, Math.ceil(limit / 3) * 4) + const groups = prefix.slice(0, prefix.length - (prefix.length % 4)) + let binary = '' + try { + binary = atob(groups) + } catch { + return new Uint8Array() + } + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +function pngSize(bytes: Uint8Array): ImageSize | null { + if ( + bytes.length < PNG_IHDR_END || + PNG_SIGNATURE.some((byte, i) => bytes[i] !== byte) + ) { + return null + } + return { + width: u32(bytes, PNG_IHDR_WIDTH_OFFSET), + height: u32(bytes, PNG_IHDR_WIDTH_OFFSET + 4) + } +} + +/** Walk the segment chain to the frame header. JPEG puts the size there rather + * than at a fixed offset, and any number of metadata segments may precede it. */ +function jpegSize(bytes: Uint8Array): ImageSize | null { + if (JPEG_SOI.some((byte, i) => bytes[i] !== byte)) { + return null + } + let at = JPEG_SOI.length + while (at + 8 < bytes.length) { + // 0xFF repeats as segment padding, so advance to the marker byte itself. + if (bytes[at] !== 0xff) { + return null + } + const marker = bytes[at + 1] + if (marker === 0xff) { + at++ + continue + } + if (isSofMarker(marker)) { + return { height: u16(bytes, at + 5), width: u16(bytes, at + 7) } + } + if (marker === JPEG_SOS) { + return null + } + // Every marker reachable here carries a length: the walk starts past SOI + // and stops at SOS, so the length-less ones (SOI, EOI, RST) are behind it. + at += 2 + u16(bytes, at + 2) + } + return null +} + +function isSofMarker(marker: number): boolean { + return marker >= 0xc0 && marker <= 0xcf && !JPEG_SOF_EXCLUDED.includes(marker) +} + +function u16(bytes: Uint8Array, at: number): number { + return (bytes[at] << 8) | bytes[at + 1] +} + +function u32(bytes: Uint8Array, at: number): number { + return ( + ((bytes[at] << 24) | + (bytes[at + 1] << 16) | + (bytes[at + 2] << 8) | + bytes[at + 3]) >>> + 0 + ) +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2d121ad7..6fcdfd15 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from './console.js' export * from './element-scripts.js' export * from './collector.js' export * from './files.js' +export * from './image.js' export * from './locator-dialect.js' export * from './routes.js' export * from './runner.js' diff --git a/packages/shared/tests/image.test.ts b/packages/shared/tests/image.test.ts new file mode 100644 index 00000000..18960bac --- /dev/null +++ b/packages/shared/tests/image.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' + +import { imageDimensions, imageMime } from '../src/image.js' + +/** 12x26 and 32x20 RGB PNGs — asymmetric so a swapped axis fails the test. */ +const PORTRAIT_PNG = + 'iVBORw0KGgoAAAANSUhEUgAAAAwAAAAaCAIAAAAMmCo2AAAAGElEQVR42mPQiDpBEDGMKhpVNKpocCoCABkvkkCF7UyEAAAAAElFTkSuQmCC' +const LANDSCAPE_PNG = + 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAUCAIAAABj86gYAAAAIElEQVR42mM4EaVBU8QwasGoBaMWjFowasGoBaMWEIMASNw5LplhiRIAAAAASUVORK5CYII=' +/** 12x26 JPEG carrying a JFIF segment ahead of its frame header, so the size is + * only reachable by walking the segment chain. */ +const PORTRAIT_JPEG = + '/9j/4AAQSkZJRgABAQAAAQABAAD/wAALCAAaAAwBAREA/9oACAEBAAA/AP/Z' + +/** The same JPEG with a metadata segment padded out ahead of its frame header — + * the shape a capture carrying EXIF and an embedded thumbnail has, which put + * the frame header 35 KB into a device-farm frame. */ +function behindMetadata(jpeg: string, padding: number): string { + const source = Uint8Array.from(atob(jpeg), (char) => char.charCodeAt(0)) + const segment = new Uint8Array(padding + 4) + segment[0] = 0xff + segment[1] = 0xe1 + segment[2] = ((padding + 2) >> 8) & 0xff + segment[3] = (padding + 2) & 0xff + + const out = new Uint8Array(source.length + segment.length) + out.set(source.subarray(0, 2)) + out.set(segment, 2) + out.set(source.subarray(2), 2 + segment.length) + + let binary = '' + for (const byte of out) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +describe('imageMime', () => { + it('names a PNG from its signature', () => { + expect(imageMime(PORTRAIT_PNG)).toBe('image/png') + }) + + it('names anything else JPEG — the only other capture format', () => { + expect(imageMime(PORTRAIT_JPEG)).toBe('image/jpeg') + expect(imageMime('AAAA')).toBe('image/jpeg') + }) +}) + +describe('imageDimensions', () => { + it("reads a PNG's IHDR", () => { + expect(imageDimensions(PORTRAIT_PNG)).toEqual({ width: 12, height: 26 }) + expect(imageDimensions(LANDSCAPE_PNG)).toEqual({ width: 32, height: 20 }) + }) + + it("walks a JPEG's segments to its frame header", () => { + expect(imageDimensions(PORTRAIT_JPEG)).toEqual({ width: 12, height: 26 }) + }) + + it('steps over a DHT segment rather than reading it as the frame header', () => { + // DHT is 0xC4 — inside the 0xC0-0xCF frame-header range. Read as one, this + // 12x26 JPEG measures 8755x4386, so the exclusion is what makes it right. + expect( + imageDimensions('/9j/xAAMEREiIjMzRERVVf/AAAsIABoADAEBEQD/2Q==') + ).toEqual({ width: 12, height: 26 }) + }) + + it('keeps walking past metadata larger than the first header window', () => { + expect(imageDimensions(behindMetadata(PORTRAIT_JPEG, 5_000))).toEqual({ + width: 12, + height: 26 + }) + }) + + it('is null when the bytes name no size', () => { + expect(imageDimensions('')).toBeNull() + expect(imageDimensions('AAAA')).toBeNull() + // A `data:` prefix is not base64, so the decode itself fails. + expect(imageDimensions(`data:image/png;base64,${PORTRAIT_PNG}`)).toBeNull() + // PNG signature, IHDR truncated away. + expect(imageDimensions(PORTRAIT_PNG.slice(0, 8))).toBeNull() + }) + + it('is null for a JPEG whose scan starts before any frame header', () => { + expect(imageDimensions('/9j/2gAIAQEAAD8A/9k=')).toBeNull() + }) + + it('is null when a header names a zero axis', () => { + // Same PNG with its IHDR width zeroed — a ratio taken off it collapses. + expect( + imageDimensions( + 'iVBORw0KGgoAAAANSUhEUgAAAAAAAAAaCAIAAAAMmCo2AAAAGElEQVR42mPQiDpBEDGMKhpVNKpocCoCABkvkkCF7UyEAAAAAElFTkSuQmCC' + ) + ).toBeNull() + }) +}) diff --git a/packages/trace/src/trace-frame-snapshots.ts b/packages/trace/src/trace-frame-snapshots.ts index 2d9f589b..af0b9745 100644 --- a/packages/trace/src/trace-frame-snapshots.ts +++ b/packages/trace/src/trace-frame-snapshots.ts @@ -2,7 +2,7 @@ // minimal DOM document so standard trace viewers render the action pane. // Compatibility shim until real DOM snapshots are captured. -import type { ActionSnapshot } from '@wdio/devtools-shared' +import { imageMime, type ActionSnapshot } from '@wdio/devtools-shared' const SNAPSHOT_DOCTYPE = 'html' const FALLBACK_FRAME_URL = 'about:blank' @@ -133,12 +133,6 @@ function frameIdForPage(pageId: string): string { return `frame@${suffix}` } -// Captures come from WebDriver screenshots (PNG) or CDP screencasts (JPEG), -// so the mime is sniffed from the base64 magic rather than assumed. -function imageMimeType(base64: string): string { - return base64.startsWith('iVBOR') ? 'image/png' : 'image/jpeg' -} - function imageDocument(snap: ActionSnapshot): FrameSnapshotNode { const screenshot = snap.screenshot ?? '' return [ @@ -151,7 +145,7 @@ function imageDocument(snap: ActionSnapshot): FrameSnapshotNode { [ 'IMG', { - src: `data:${imageMimeType(screenshot)};base64,${screenshot}`, + src: `data:${imageMime(screenshot)};base64,${screenshot}`, style: IMAGE_STYLE } ] From 9747bec470be364e6374c0c7ef8963cad1679739 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 8 Sep 2026 01:14:01 +0530 Subject: [PATCH 3/5] fix(app): give a filmstrip thumbnail the capture's own shape --- .../browser/trace-timeline-utils.ts | 6 -- .../src/components/browser/trace-timeline.ts | 41 +++++++++-- .../workbench/player/trace-timeline.test.ts | 69 ++++++++++++++++++- 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/browser/trace-timeline-utils.ts b/packages/app/src/components/browser/trace-timeline-utils.ts index ef99a363..214fbcd9 100644 --- a/packages/app/src/components/browser/trace-timeline-utils.ts +++ b/packages/app/src/components/browser/trace-timeline-utils.ts @@ -3,12 +3,6 @@ import { TICK_TARGET_DIVISIONS } from './trace-timeline-constants.js' -/** Detect image mime from a base64 string's magic bytes — trace screenshots - * may be PNG (polling capture) or JPEG (CDP), and the zip names both `.jpeg`. */ -export function imageMime(base64: string): string { - return base64.startsWith('/9j/') ? 'image/jpeg' : 'image/png' -} - export function tickStep( durationMs: number, targetTicks = TICK_TARGET_DIVISIONS diff --git a/packages/app/src/components/browser/trace-timeline.ts b/packages/app/src/components/browser/trace-timeline.ts index 7c463004..f0d00976 100644 --- a/packages/app/src/components/browser/trace-timeline.ts +++ b/packages/app/src/components/browser/trace-timeline.ts @@ -3,7 +3,11 @@ import { html, type TemplateResult } from 'lit' import { customElement, state, query } from 'lit/decorators.js' import { consume } from '@lit/context' import type { CommandLog, TracePlayerFrame } from '@wdio/devtools-shared' -import { isKeyboardCommand } from '@wdio/devtools-shared' +import { + imageDimensions, + imageMime, + isKeyboardCommand +} from '@wdio/devtools-shared' import { commandContext, framesContext } from '../../controller/context.js' import { elapsedSince } from '../../utils/elapsed.js' @@ -19,7 +23,6 @@ import { import { formatTickLabel, formatTimecode, - imageMime, tickStep } from './trace-timeline-utils.js' import { timelineStyles } from './trace-timeline-styles.js' @@ -50,6 +53,7 @@ export class TraceTimeline extends Element { @query('[data-scrub]') scrubEl?: HTMLElement #dragging = false + #thumbAspectMemo?: { screenshot: string; aspect: string | null } static styles = [...Element.styles, timelineStyles] @@ -338,6 +342,30 @@ export class TraceTimeline extends Element { ` } + /** Shape of a filmstrip thumbnail, as a CSS `aspect-ratio`, or null when the + * capture's bytes name no size. Read from the capture's own pixels rather + * than the metadata viewport, which disagrees with the screenshot on both + * mobile platforms. A fixed 16:9 box cropped (object-cover) a portrait + * capture to a horizontal band through its middle — usually empty page, so + * the whole strip rendered as blank rectangles. One ratio for the strip: + * every frame of a run shares one capture surface, and a mid-run rotation + * letterboxes inside the box instead of being cropped away. Memoized on the + * frame it was read from, because this runs on every playback tick. */ + get #thumbAspect(): string | null { + const screenshot = this.frames[0]?.screenshot + if (!screenshot) { + return null + } + if (this.#thumbAspectMemo?.screenshot !== screenshot) { + const size = imageDimensions(screenshot) + this.#thumbAspectMemo = { + screenshot, + aspect: size ? `${size.width} / ${size.height}` : null + } + } + return this.#thumbAspectMemo.aspect + } + // Thumbnails sit at their wall-clock position along the axis. #renderThumbTrack(): TemplateResult { if (!this.frames.length) { @@ -348,13 +376,16 @@ export class TraceTimeline extends Element { ` } const activeFrame = this.#activeFrameTimestamp + const aspect = this.#thumbAspect return html`
${this.frames.map((frame) => { const fraction = this.#fraction(frame.timestamp) const active = frame.timestamp === activeFrame return html`