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`
${this.frames.map((frame) => { const fraction = this.#fraction(frame.timestamp) const active = frame.timestamp === activeFrame return html`