[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:
parent
6f12eaefa2
commit
65bdafa0ba
6 changed files with 79 additions and 46 deletions
|
@ -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, }: {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue