[fix] huge images, use downscale for image scaling (#2207)

This PR improves our method for handling images, which is especially
useful when using a local tldraw editor. Previously, we were only
downsample images that were above the browser's maximum size. We now
downsample all images. This will result in smaller images in almost all
cases. It will also prevent very large jpeg images from being converted
to png images, which could often lead to an increase in file size!

### Change Type

- [x] `minor` — New feature

### Test Plan

1. Add some images (jpegs or pngs) to the canvas.

### Release Notes

- Improved image rescaling.
This commit is contained in:
Steve Ruiz 2023-11-14 08:21:32 +00:00 committed by GitHub
parent 6f12eaefa2
commit 65bdafa0ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 79 additions and 46 deletions

View file

@ -673,7 +673,10 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
export function getEmbedInfo(inputUrl: string): TLEmbedResult; export function getEmbedInfo(inputUrl: string): TLEmbedResult;
// @public // @public
export function getResizedImageDataUrl(dataURLForImage: string, width: number, height: number): Promise<string>; export function getResizedImageDataUrl(dataURLForImage: string, width: number, height: number, opts?: {
type?: string | undefined;
quality?: number | undefined;
}): Promise<string>;
// @public (undocumented) // @public (undocumented)
function Group({ children, size, }: { function Group({ children, size, }: {

View file

@ -7236,7 +7236,7 @@
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "@tldraw/tldraw!getResizedImageDataUrl:function(1)", "canonicalReference": "@tldraw/tldraw!getResizedImageDataUrl:function(1)",
"docComment": "/**\n * Get the size of an image from its source.\n *\n * @param dataURLForImage - The image file as a string.\n *\n * @param width - The desired width.\n *\n * @param height - The desired height.\n *\n * @public\n */\n", "docComment": "/**\n * Get the size of an image from its source.\n *\n * @param dataURLForImage - The image file as a string.\n *\n * @param width - The desired width.\n *\n * @param height - The desired height.\n *\n * @param opts - Options for the image.\n *\n * @example\n * ```ts\n * const size = await getImageSize('https://example.com/image.jpg')\n * const dataUrl = await getResizedImageDataUrl('https://example.com/image.jpg', size.w, size.h, { type: \"image/jpeg\", quality: 0.92 })\n * ```\n *\n * @public\n */\n",
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",
@ -7262,6 +7262,14 @@
"kind": "Content", "kind": "Content",
"text": "number" "text": "number"
}, },
{
"kind": "Content",
"text": ", opts?: "
},
{
"kind": "Content",
"text": "{\n type?: string | undefined;\n quality?: number | undefined;\n}"
},
{ {
"kind": "Content", "kind": "Content",
"text": "): " "text": "): "
@ -7282,8 +7290,8 @@
], ],
"fileUrlPath": "packages/tldraw/src/lib/utils/assets.ts", "fileUrlPath": "packages/tldraw/src/lib/utils/assets.ts",
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 7, "startIndex": 9,
"endIndex": 9 "endIndex": 11
}, },
"releaseTag": "Public", "releaseTag": "Public",
"overloadIndex": 1, "overloadIndex": 1,
@ -7311,6 +7319,14 @@
"endIndex": 6 "endIndex": 6
}, },
"isOptional": false "isOptional": false
},
{
"parameterName": "opts",
"parameterTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"isOptional": true
} }
], ],
"name": "getResizedImageDataUrl" "name": "getResizedImageDataUrl"

View file

@ -55,6 +55,7 @@
"@tldraw/editor": "workspace:*", "@tldraw/editor": "workspace:*",
"canvas-size": "^1.2.6", "canvas-size": "^1.2.6",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"downscale": "^1.0.6",
"hotkeys-js": "^3.11.2", "hotkeys-js": "^3.11.2",
"lz-string": "^1.4.4" "lz-string": "^1.4.4"
}, },
@ -68,6 +69,7 @@
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@types/canvas-size": "^1.2.0", "@types/canvas-size": "^1.2.0",
"@types/classnames": "^2.3.1", "@types/classnames": "^2.3.1",
"@types/downscale": "^1.0.4",
"@types/lz-string": "^1.3.34", "@types/lz-string": "^1.3.34",
"chokidar-cli": "^3.0.0", "chokidar-cli": "^3.0.0",
"jest-canvas-mock": "^2.5.2", "jest-canvas-mock": "^2.5.2",

View file

@ -91,10 +91,16 @@ export function registerDefaultExternalContentHandlers(
if (isFinite(maxImageDimension)) { if (isFinite(maxImageDimension)) {
const resizedSize = containBoxSize(size, { w: maxImageDimension, h: maxImageDimension }) const resizedSize = containBoxSize(size, { w: maxImageDimension, h: maxImageDimension })
if (size !== resizedSize && (file.type === 'image/jpeg' || file.type === 'image/png')) { if (size !== resizedSize && (file.type === 'image/jpeg' || file.type === 'image/png')) {
// If we created a new size and the type is an image, rescale the image size = resizedSize
dataUrl = await getResizedImageDataUrl(dataUrl, size.w, size.h)
} }
size = resizedSize }
// Always rescale the image
if (file.type === 'image/jpeg' || file.type === 'image/png') {
dataUrl = await getResizedImageDataUrl(dataUrl, size.w, size.h, {
type: file.type,
quality: 0.92,
})
} }
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(dataUrl)) const assetId: TLAssetId = AssetRecordType.createId(getHashForString(dataUrl))

View file

@ -1,3 +1,4 @@
import downscale from 'downscale'
import { getBrowserCanvasMaxSize } from '../shapes/shared/getBrowserCanvasMaxSize' import { getBrowserCanvasMaxSize } from '../shapes/shared/getBrowserCanvasMaxSize'
import { isAnimated } from './is-gif-animated' import { isAnimated } from './is-gif-animated'
@ -39,60 +40,49 @@ export function containBoxSize(
/** /**
* Get the size of an image from its source. * Get the size of an image from its source.
* *
* @example
* ```ts
* const size = await getImageSize('https://example.com/image.jpg')
* const dataUrl = await getResizedImageDataUrl('https://example.com/image.jpg', size.w, size.h, { type: "image/jpeg", quality: 0.92 })
* ```
*
* @param dataURLForImage - The image file as a string. * @param dataURLForImage - The image file as a string.
* @param width - The desired width. * @param width - The desired width.
* @param height - The desired height. * @param height - The desired height.
* @param opts - Options for the image.
* @public * @public
*/ */
export async function getResizedImageDataUrl( export async function getResizedImageDataUrl(
dataURLForImage: string, dataURLForImage: string,
width: number, width: number,
height: number height: number,
opts = {} as { type?: string; quality?: number }
): Promise<string> { ): Promise<string> {
return await new Promise((resolve) => { let desiredWidth = width * 2
const img = new Image() let desiredHeight = height * 2
img.onload = async () => { const { type = 'image/jpeg', quality = 0.92 } = opts
// Initialize the canvas and it's size
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return const canvasSizes = await getBrowserCanvasMaxSize()
const canvasSizes = await getBrowserCanvasMaxSize() const aspectRatio = width / height
let desiredWidth = width * 2 if (desiredWidth > canvasSizes.maxWidth) {
let desiredHeight = height * 2 desiredWidth = canvasSizes.maxWidth
const aspectRatio = img.width / img.height desiredHeight = desiredWidth / aspectRatio
}
if (desiredWidth > canvasSizes.maxWidth) { if (desiredHeight > canvasSizes.maxHeight) {
desiredWidth = canvasSizes.maxWidth desiredHeight = canvasSizes.maxHeight
desiredHeight = desiredWidth / aspectRatio desiredWidth = desiredHeight * aspectRatio
} }
if (desiredHeight > canvasSizes.maxHeight) { if (desiredWidth * desiredHeight > canvasSizes.maxArea) {
desiredHeight = canvasSizes.maxHeight const ratio = Math.sqrt(canvasSizes.maxArea / (desiredWidth * desiredHeight))
desiredWidth = desiredHeight * aspectRatio desiredWidth *= ratio
} desiredHeight *= ratio
}
if (desiredWidth * desiredHeight > canvasSizes.maxArea) { return await downscale(dataURLForImage, desiredWidth, desiredHeight, { imageType: type, quality })
const ratio = Math.sqrt(canvasSizes.maxArea / (desiredWidth * desiredHeight))
desiredWidth *= ratio
desiredHeight *= ratio
}
canvas.width = desiredWidth
canvas.height = desiredHeight
// Draw image and export to a data-uri
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
const newDataURL = canvas.toDataURL()
// Do something with the result, like overwrite original
resolve(newDataURL)
}
img.crossOrigin = 'anonymous'
img.src = dataURLForImage
})
} }
/** @public */ /** @public */

View file

@ -4608,10 +4608,12 @@ __metadata:
"@tldraw/editor": "workspace:*" "@tldraw/editor": "workspace:*"
"@types/canvas-size": ^1.2.0 "@types/canvas-size": ^1.2.0
"@types/classnames": ^2.3.1 "@types/classnames": ^2.3.1
"@types/downscale": ^1.0.4
"@types/lz-string": ^1.3.34 "@types/lz-string": ^1.3.34
canvas-size: ^1.2.6 canvas-size: ^1.2.6
chokidar-cli: ^3.0.0 chokidar-cli: ^3.0.0
classnames: ^2.3.2 classnames: ^2.3.2
downscale: ^1.0.6
hotkeys-js: ^3.11.2 hotkeys-js: ^3.11.2
jest-canvas-mock: ^2.5.2 jest-canvas-mock: ^2.5.2
jest-environment-jsdom: ^28.1.2 jest-environment-jsdom: ^28.1.2
@ -4875,6 +4877,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/downscale@npm:^1.0.4":
version: 1.0.4
resolution: "@types/downscale@npm:1.0.4"
checksum: b29ca279b616e06896c01795effa5c483e243e85a137462f98eb43dec3598acab3f72b0637225a4087080e0326cf4bde89ba21d0473a9254a7661c5fd29f98d9
languageName: node
linkType: hard
"@types/estree-jsx@npm:^0.0.1": "@types/estree-jsx@npm:^0.0.1":
version: 0.0.1 version: 0.0.1
resolution: "@types/estree-jsx@npm:0.0.1" resolution: "@types/estree-jsx@npm:0.0.1"
@ -7927,6 +7936,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"downscale@npm:^1.0.6":
version: 1.0.6
resolution: "downscale@npm:1.0.6"
checksum: f69beb8fe7711964b259ef3d3502fd9f5dd0c4472aa279537dc58d69dcff8a619c77b0209db65841aab014930b97e82f92d43558acc1e569407b8fbc63fb04ee
languageName: node
linkType: hard
"duplexify@npm:^3.5.0, duplexify@npm:^3.6.0": "duplexify@npm:^3.5.0, duplexify@npm:^3.6.0":
version: 3.7.1 version: 3.7.1
resolution: "duplexify@npm:3.7.1" resolution: "duplexify@npm:3.7.1"