Skip to content

Commit f69220b

Browse files
authored
fix(images): use dual DPI baselines for correct pixelRatio on all platforms (tldraw#8163)
High-DPI PNGs (e.g. Retina screenshots) store their logical size in `asset.props.w`/`h`, but their actual pixel dimensions are larger. Without knowing the pixel ratio, the CDN image resize endpoints cap the requested width at the logical size, returning a downscaled image that looks blurry on retina displays. This PR adds an optional `pixelRatio` property to `TLImageAsset`, populated from the PNG pHYs chunk via `MediaHelpers.getImageSize()`, and uses it in both `multiplayerAssetStore` and `createDemoAssetStore` to request the correct pixel-level width from image transform endpoints. The pHYs DPI detection now tries both standard baselines — 96 DPI (Windows/web) and 72 DPI (macOS) — and picks whichever yields a clean integer ratio above 1. This correctly handles all common cases: | Image DPI | Source | pixelRatio | |-----------|--------|------------| | 72 | macOS 1x | 1 | | 96 | Windows 1x / web standard | 1 | | 144 | macOS 2x Retina | 2 | | 192 | Windows 2x HiDPI | 2 | | 216 | macOS 3x | 3 | | 288 | Windows 3x | 3 | The previous implementation used 72 DPI as the sole baseline, which was only correct for macOS. Any standard 96 DPI PNG (extremely common from Windows and tools like Photoshop/GIMP) got a pixelRatio of ~1.333, causing those images to appear ~25% smaller than intended. before+after (looking at her blue hair can show you the crispness of the lines) <img width="1171" height="1065" alt="Screenshot 2026-03-05 at 15 15 46" src="https://github.com/user-attachments/assets/83ccf063-27b4-48bc-9c72-0ca021087dcb" /> <img width="1158" height="1070" alt="Screenshot 2026-03-05 at 16 19 34" src="https://github.com/user-attachments/assets/715dd7d6-288f-4342-8929-173c5741808b" /> ### Change type - [x] `improvement` ### API changes - Added optional `pixelRatio` property to `TLImageAsset` - Added `pixelRatio: number` to `MediaHelpers.getImageSize()` return type - Added migration `AddPixelRatio` (version 6) for image assets ### Test plan 1. Drop a macOS 2x Retina screenshot (144 DPI pHYs) onto the canvas — should get `pixelRatio: 2` and display at half its pixel dimensions 2. Drop a standard Windows/web PNG (96 DPI pHYs) — should get no `pixelRatio` set (treated as 1) and display at full pixel size 3. Drop a Windows HiDPI screenshot (192 DPI) — should get `pixelRatio: 2` 4. Zoom in/out on a high-DPI image and confirm the resolved image URL requests width scaled by the pixel ratio - [x] Unit tests ### Release notes - Fix high-DPI image sizing to work correctly across macOS and Windows by detecting the source DPI baseline from the PNG metadata. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces a new optional `TLImageAsset.props.pixelRatio` and uses it to change how resized image URLs are computed, which can affect image rendering quality and bandwidth/costs across clients. Also updates schema validation/migrations and `MediaHelpers.getImageSize`’s return type, so downstream consumers may need to handle the new field. > > **Overview** > Fixes blurry/incorrectly scaled high-DPI PNG rendering by *recording the source `pixelRatio` on image assets* and using it when generating resize URLs. > > `MediaHelpers.getImageSize` now returns `{ w, h, pixelRatio }` and detects pixel ratio from PNG `pHYs` using dual 96/72 DPI baselines. `TLImageAsset` gains optional `pixelRatio` with validator + migration (`AddPixelRatio`), and both `multiplayerAssetStore` and the sync demo asset store scale requested transform width by `w * pixelRatio` to fetch true-resolution images. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 864fe31. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 975096a commit f69220b

8 files changed

Lines changed: 60 additions & 16 deletions

File tree

apps/dotcom/client/src/utils/multiplayerAssetStore.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,15 @@ export function multiplayerAssetStore(opts?: {
125125
const networkCompensation =
126126
!context.networkEffectiveType || context.networkEffectiveType === '4g' ? 1 : 0.5
127127

128+
const pixelRatio = asset.props.pixelRatio ?? 1
129+
const trueWidth = asset.props.w * pixelRatio
128130
const width = Math.ceil(
129131
Math.min(
130-
asset.props.w *
132+
trueWidth *
131133
clamp(context.steppedScreenScale, 1 / 32, 1) *
132134
networkCompensation *
133135
context.dpr,
134-
asset.props.w
136+
trueWidth
135137
)
136138
)
137139

packages/sync/src/useSyncDemo.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,15 @@ function createDemoAssetStore(host: string): TLAssetStore {
236236
const networkCompensation =
237237
!context.networkEffectiveType || context.networkEffectiveType === '4g' ? 1 : 0.5
238238

239+
const pixelRatio = asset.props.pixelRatio ?? 1
240+
const trueWidth = asset.props.w * pixelRatio
239241
const width = Math.ceil(
240242
Math.min(
241-
asset.props.w *
243+
trueWidth *
242244
clamp(context.steppedScreenScale, 1 / 32, 1) *
243245
networkCompensation *
244246
context.dpr,
245-
asset.props.w
247+
trueWidth
246248
)
247249
)
248250

packages/tldraw/src/lib/defaultExternalContentHandlers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,8 @@ export async function getMediaAssetInfoPartial(
666666

667667
const isAnimated = (await MediaHelpers.isAnimated(file)) || isVideoType
668668

669+
const pixelRatio = 'pixelRatio' in size && size.pixelRatio !== 1 ? size.pixelRatio : undefined
670+
669671
const assetInfo = {
670672
id: assetId,
671673
type: isImageType ? 'image' : 'video',
@@ -678,6 +680,7 @@ export async function getMediaAssetInfoPartial(
678680
fileSize: file.size,
679681
mimeType: fileType,
680682
isAnimated,
683+
...(isImageType && pixelRatio ? { pixelRatio } : undefined),
681684
},
682685
meta: {},
683686
} as TLImageAsset | TLVideoAsset

packages/tlschema/api-report.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,6 +1134,7 @@ export type TLImageAsset = TLBaseAsset<'image', {
11341134
isAnimated: boolean;
11351135
mimeType: null | string;
11361136
name: string;
1137+
pixelRatio?: number;
11371138
src: null | string;
11381139
w: number;
11391140
}>;

packages/tlschema/src/assets/TLImageAsset.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type TLImageAsset = TLBaseAsset<
1717
mimeType: string | null
1818
src: string | null
1919
fileSize?: number
20+
pixelRatio?: number
2021
}
2122
>
2223

@@ -60,6 +61,7 @@ export const imageAssetValidator: T.Validator<TLImageAsset> = createAssetValidat
6061
mimeType: T.string.nullable(),
6162
src: T.srcUrl.nullable(),
6263
fileSize: T.nonZeroNumber.optional(),
64+
pixelRatio: T.positiveNumber.optional(),
6365
})
6466
)
6567

@@ -69,6 +71,7 @@ const Versions = createMigrationIds('com.tldraw.asset.image', {
6971
MakeUrlsValid: 3,
7072
AddFileSize: 4,
7173
MakeFileSizeOptional: 5,
74+
AddPixelRatio: 6,
7275
} as const)
7376

7477
/**
@@ -165,5 +168,14 @@ export const imageAssetMigrations = createRecordMigrationSequence({
165168
}
166169
},
167170
},
171+
{
172+
id: Versions.AddPixelRatio,
173+
up: (_asset: any) => {
174+
// noop — pixelRatio is optional and undefined by default
175+
},
176+
down: (asset: any) => {
177+
delete asset.props.pixelRatio
178+
},
179+
},
168180
],
169181
})

packages/tlschema/src/migrations.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2102,6 +2102,21 @@ describe('Make image asset file size optional', () => {
21022102
})
21032103
})
21042104

2105+
describe('Add pixelRatio to image asset', () => {
2106+
const { up, down } = getTestMigration(imageAssetVersions.AddPixelRatio)
2107+
2108+
test('up works as expected', () => {
2109+
expect(up({ props: { w: 100, h: 100 } })).toEqual({ props: { w: 100, h: 100 } })
2110+
})
2111+
2112+
test('down works as expected', () => {
2113+
expect(down({ props: { w: 100, h: 100, pixelRatio: 2 } })).toEqual({
2114+
props: { w: 100, h: 100 },
2115+
})
2116+
expect(down({ props: { w: 100, h: 100 } })).toEqual({ props: { w: 100, h: 100 } })
2117+
})
2118+
})
2119+
21052120
describe('Add flipX, flipY to image shape', () => {
21062121
const { up, down } = getTestMigration(imageShapeVersions.AddFlipProps)
21072122

packages/utils/api-report.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ export class MediaHelpers {
300300
}>;
301301
static getImageSize(blob: Blob): Promise<{
302302
h: number;
303+
pixelRatio: number;
303304
w: number;
304305
}>;
305306
static getVideoFrameAsDataUrl(video: HTMLVideoElement, time?: number): Promise<string>;

packages/utils/src/lib/media/media.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ export class MediaHelpers {
309309
* ```
310310
* @public
311311
*/
312-
static async getImageSize(blob: Blob): Promise<{ w: number; h: number }> {
312+
static async getImageSize(blob: Blob): Promise<{ w: number; h: number; pixelRatio: number }> {
313313
const { w, h } = await MediaHelpers.usingObjectURL(blob, MediaHelpers.getImageAndDimensions)
314314

315315
try {
@@ -320,25 +320,33 @@ export class MediaHelpers {
320320
if (physChunk) {
321321
const physData = PngHelpers.parsePhys(view, physChunk.dataOffset)
322322
if (physData.unit === 1 && physData.ppux === physData.ppuy) {
323-
// Calculate pixels per meter:
324-
// - 1 inch = 0.0254 meters
325-
// - 72 DPI is 72 dots per inch
326-
// - pixels per meter = 72 / 0.0254
327-
const pixelsPerMeter = 72 / 0.0254
328-
const pixelRatio = Math.max(physData.ppux / pixelsPerMeter, 1)
329-
return {
330-
w: Math.round(w / pixelRatio),
331-
h: Math.round(h / pixelRatio),
323+
const dpi = Math.round(physData.ppux * 0.0254)
324+
// Try both standard baselines: Windows/web = 96, macOS = 72.
325+
// Pick whichever yields a clean integer ratio > 1.
326+
const r96 = dpi / 96
327+
const r72 = dpi / 72
328+
let pixelRatio = 1
329+
if (Number.isInteger(r96) && r96 > 1) {
330+
pixelRatio = r96
331+
} else if (Number.isInteger(r72) && r72 > 1) {
332+
pixelRatio = r72
333+
}
334+
if (pixelRatio > 1) {
335+
return {
336+
w: Math.ceil(w / pixelRatio),
337+
h: Math.ceil(h / pixelRatio),
338+
pixelRatio,
339+
}
332340
}
333341
}
334342
}
335343
}
336344
}
337345
} catch (err) {
338346
console.error(err)
339-
return { w, h }
347+
return { w, h, pixelRatio: 1 }
340348
}
341-
return { w, h }
349+
return { w, h, pixelRatio: 1 }
342350
}
343351

344352
/**

0 commit comments

Comments
 (0)