diff --git a/.changeset/player-fits-capture-by-both-axes.md b/.changeset/player-fits-capture-by-both-axes.md
new file mode 100644
index 00000000..474d653d
--- /dev/null
+++ b/.changeset/player-fits-capture-by-both-axes.md
@@ -0,0 +1,7 @@
+---
+"@wdio/devtools-app": patch
+---
+
+Fit a capture with no DOM by both axes. The player's screenshot branch — reached by every trace that carries no mutation stream, so by every native mobile one — was bounded on the width alone inside a wrapper that hides its overflow, and the filmstrip drew each frame in a fixed 16:9 box with `object-cover`. A portrait capture was therefore scaled up to the pane width, overflowed its height, and had the remainder cut off, while its thumbnails were cropped to a horizontal band through the middle of the screen. Measured on a 1206x2622 iPhone 17 capture in a 1240x457 pane: the main pane showed 17% of the device screen at 5.9x magnification, and because that band is empty page on a phone app screen, the whole filmstrip rendered as blank white rectangles. The captured bytes were always correct — dragging the image out of the player showed the whole screen.
+
+This is not mobile-specific, it was only unmissable there: the same width-only fit cut the bottom 15% off a 1280x800 desktop capture in a 400px-tall pane. Both places now fit by the capture's own pixels, read from its PNG or JPEG header — the metadata viewport cannot serve, because it disagrees with the screenshot on both mobile platforms (Android reports the window without the navigation bar, iOS reports points rather than pixels) and a DOM-less trace carries no viewport at all. The main pane fills the pane and contains inside it, matching the screencast branch; a filmstrip thumbnail takes the capture's own aspect ratio and contains rather than covers, keeping the 16:9 box only for bytes that name no size.
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/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/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`