[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;
// @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)
function Group({ children, size, }: {

View file

@ -7236,7 +7236,7 @@
{
"kind": "Function",
"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": [
{
"kind": "Content",
@ -7262,6 +7262,14 @@
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ", opts?: "
},
{
"kind": "Content",
"text": "{\n type?: string | undefined;\n quality?: number | undefined;\n}"
},
{
"kind": "Content",
"text": "): "
@ -7282,8 +7290,8 @@
],
"fileUrlPath": "packages/tldraw/src/lib/utils/assets.ts",
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 9
"startIndex": 9,
"endIndex": 11
},
"releaseTag": "Public",
"overloadIndex": 1,
@ -7311,6 +7319,14 @@
"endIndex": 6
},
"isOptional": false
},
{
"parameterName": "opts",
"parameterTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"isOptional": true
}
],
"name": "getResizedImageDataUrl"

View file

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

View file

@ -91,10 +91,16 @@ export function registerDefaultExternalContentHandlers(
if (isFinite(maxImageDimension)) {
const resizedSize = containBoxSize(size, { w: maxImageDimension, h: maxImageDimension })
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
dataUrl = await getResizedImageDataUrl(dataUrl, size.w, size.h)
size = resizedSize
}
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))

View file

@ -1,3 +1,4 @@
import downscale from 'downscale'
import { getBrowserCanvasMaxSize } from '../shapes/shared/getBrowserCanvasMaxSize'
import { isAnimated } from './is-gif-animated'
@ -39,60 +40,49 @@ export function containBoxSize(
/**
* 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 width - The desired width.
* @param height - The desired height.
* @param opts - Options for the image.
* @public
*/
export async function getResizedImageDataUrl(
dataURLForImage: string,
width: number,
height: number
height: number,
opts = {} as { type?: string; quality?: number }
): Promise<string> {
return await new Promise((resolve) => {
const img = new Image()
img.onload = async () => {
// Initialize the canvas and it's size
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
let desiredWidth = width * 2
let desiredHeight = height * 2
const { type = 'image/jpeg', quality = 0.92 } = opts
if (!ctx) return
const canvasSizes = await getBrowserCanvasMaxSize()
const canvasSizes = await getBrowserCanvasMaxSize()
const aspectRatio = width / height
let desiredWidth = width * 2
let desiredHeight = height * 2
const aspectRatio = img.width / img.height
if (desiredWidth > canvasSizes.maxWidth) {
desiredWidth = canvasSizes.maxWidth
desiredHeight = desiredWidth / aspectRatio
}
if (desiredWidth > canvasSizes.maxWidth) {
desiredWidth = canvasSizes.maxWidth
desiredHeight = desiredWidth / aspectRatio
}
if (desiredHeight > canvasSizes.maxHeight) {
desiredHeight = canvasSizes.maxHeight
desiredWidth = desiredHeight * aspectRatio
}
if (desiredHeight > canvasSizes.maxHeight) {
desiredHeight = canvasSizes.maxHeight
desiredWidth = desiredHeight * aspectRatio
}
if (desiredWidth * desiredHeight > canvasSizes.maxArea) {
const ratio = Math.sqrt(canvasSizes.maxArea / (desiredWidth * desiredHeight))
desiredWidth *= ratio
desiredHeight *= ratio
}
if (desiredWidth * desiredHeight > canvasSizes.maxArea) {
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
})
return await downscale(dataURLForImage, desiredWidth, desiredHeight, { imageType: type, quality })
}
/** @public */

View file

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