faster image processing in default asset handler (#2441)
![Kapture 2024-01-10 at 13 42 06](https://github.com/tldraw/tldraw/assets/1489520/616bcda7-c05b-46f1-b985-3a36bb5c9476) (gif is with 6x CPU throttling to make the effect more visible) This is the first of a few diffs I'm working on to make dropping images onto the canvas feel a lot faster. There are three main changes here: 1. We operate on `Blob`s and `File`s rather than data urls. This saves a fair bit on converting to/from base64 all the time. I've updated our `MediaHelper` APIs to encourage the same in consumers. 2. We only check the max canvas size (slow) if images are above a certain dimension that we consider "safe" (8k x 8k) 3. Switching from the `downscale` npm library to canvas native downscaling. that library claims to give better results than the browser, but hasn't been updated in ~7 years. in modern browsers, we can opt-in to native high-quality image smoothing to achieve similar results much faster than with an algorithm implemented in pure JS. I want to follow this up with a system to show image placeholders whilst we're waiting for long-running operations like resizing etc but i'm going to split that out into its own diff as it'll involve some fairly complex changes to the history management API. ### Change Type - [x] `major` — Breaking change [^1]: publishes a `patch` release, for devDependencies use `internal` [^2]: will not publish a new version ### Test Plan 1. Tested manually, unit tests & end-to-end tests pass
This commit is contained in:
parent
7902dc65c3
commit
3c1aee492a
15 changed files with 674 additions and 403 deletions
|
@ -39,12 +39,12 @@ export default function HostedImagesExample() {
|
||||||
|
|
||||||
if (['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'].includes(file.type)) {
|
if (['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'].includes(file.type)) {
|
||||||
shapeType = 'image'
|
shapeType = 'image'
|
||||||
size = await MediaHelpers.getImageSizeFromSrc(url)
|
size = await MediaHelpers.getImageSize(file)
|
||||||
isAnimated = file.type === 'image/gif' && (await isGifAnimated(file))
|
isAnimated = file.type === 'image/gif' && (await isGifAnimated(file))
|
||||||
} else {
|
} else {
|
||||||
shapeType = 'video'
|
shapeType = 'video'
|
||||||
isAnimated = true
|
isAnimated = true
|
||||||
size = await MediaHelpers.getVideoSizeFromSrc(url)
|
size = await MediaHelpers.getVideoSize(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
const asset: TLAsset = AssetRecordType.create({
|
const asset: TLAsset = AssetRecordType.create({
|
||||||
|
|
|
@ -320,6 +320,12 @@ declare namespace Dialog {
|
||||||
}
|
}
|
||||||
export { Dialog }
|
export { Dialog }
|
||||||
|
|
||||||
|
// @public
|
||||||
|
export function downsizeImage(blob: Blob, width: number, height: number, opts?: {
|
||||||
|
type?: string | undefined;
|
||||||
|
quality?: number | undefined;
|
||||||
|
}): Promise<Blob>;
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export class DrawShapeTool extends StateNode {
|
export class DrawShapeTool extends StateNode {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
@ -692,12 +698,6 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
|
||||||
// @public
|
// @public
|
||||||
export function getEmbedInfo(inputUrl: string): TLEmbedResult;
|
export function getEmbedInfo(inputUrl: string): TLEmbedResult;
|
||||||
|
|
||||||
// @public
|
|
||||||
export function getResizedImageDataUrl(dataURLForImage: string, width: number, height: number, opts?: {
|
|
||||||
type?: string | undefined;
|
|
||||||
quality?: number | undefined;
|
|
||||||
}): Promise<string>;
|
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export function getSvgAsImage(svg: SVGElement, isSafari: boolean, options: {
|
export function getSvgAsImage(svg: SVGElement, isSafari: boolean, options: {
|
||||||
type: 'jpeg' | 'png' | 'webp';
|
type: 'jpeg' | 'png' | 'webp';
|
||||||
|
@ -834,7 +834,7 @@ function Indicator(): JSX.Element;
|
||||||
export const Input: React_3.ForwardRefExoticComponent<TLUiInputProps & React_3.RefAttributes<HTMLInputElement>>;
|
export const Input: React_3.ForwardRefExoticComponent<TLUiInputProps & React_3.RefAttributes<HTMLInputElement>>;
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export function isGifAnimated(file: File): Promise<boolean>;
|
export function isGifAnimated(file: Blob): Promise<boolean>;
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
function Item({ noClose, ...props }: DropdownMenuItemProps): JSX.Element;
|
function Item({ noClose, ...props }: DropdownMenuItemProps): JSX.Element;
|
||||||
|
|
|
@ -2827,6 +2827,114 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"kind": "Function",
|
||||||
|
"canonicalReference": "@tldraw/tldraw!downsizeImage:function(1)",
|
||||||
|
"docComment": "/**\n * Resize an image Blob to be smaller than it is currently.\n *\n * @param image - The image Blob.\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 image = await (await fetch('/image.jpg')).blob()\n * const size = await getImageSize(image)\n * const resizedImage = await downsizeImage(image, size.w / 2, size.h / 2, { type: \"image/jpeg\", quality: 0.92 })\n * ```\n *\n * @public\n */\n",
|
||||||
|
"excerptTokens": [
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "export declare function downsizeImage(blob: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Blob",
|
||||||
|
"canonicalReference": "!Blob:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ", width: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ", height: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ", opts?: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "{\n type?: string | undefined;\n quality?: number | undefined;\n}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "): "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Promise",
|
||||||
|
"canonicalReference": "!Promise:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "<"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Blob",
|
||||||
|
"canonicalReference": "!Blob:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ">"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ";"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fileUrlPath": "packages/tldraw/src/lib/utils/assets/assets.ts",
|
||||||
|
"returnTypeTokenRange": {
|
||||||
|
"startIndex": 9,
|
||||||
|
"endIndex": 13
|
||||||
|
},
|
||||||
|
"releaseTag": "Public",
|
||||||
|
"overloadIndex": 1,
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterName": "blob",
|
||||||
|
"parameterTypeTokenRange": {
|
||||||
|
"startIndex": 1,
|
||||||
|
"endIndex": 2
|
||||||
|
},
|
||||||
|
"isOptional": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameterName": "width",
|
||||||
|
"parameterTypeTokenRange": {
|
||||||
|
"startIndex": 3,
|
||||||
|
"endIndex": 4
|
||||||
|
},
|
||||||
|
"isOptional": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameterName": "height",
|
||||||
|
"parameterTypeTokenRange": {
|
||||||
|
"startIndex": 5,
|
||||||
|
"endIndex": 6
|
||||||
|
},
|
||||||
|
"isOptional": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameterName": "opts",
|
||||||
|
"parameterTypeTokenRange": {
|
||||||
|
"startIndex": 7,
|
||||||
|
"endIndex": 8
|
||||||
|
},
|
||||||
|
"isOptional": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "downsizeImage"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"kind": "Class",
|
"kind": "Class",
|
||||||
"canonicalReference": "@tldraw/tldraw!DrawShapeTool:class",
|
"canonicalReference": "@tldraw/tldraw!DrawShapeTool:class",
|
||||||
|
@ -7673,104 +7781,6 @@
|
||||||
],
|
],
|
||||||
"name": "getEmbedInfo"
|
"name": "getEmbedInfo"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"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 * @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",
|
|
||||||
"text": "export declare function getResizedImageDataUrl(dataURLForImage: "
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": ", width: "
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": ", height: "
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": ", opts?: "
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": "{\n type?: string | undefined;\n quality?: number | undefined;\n}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": "): "
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Reference",
|
|
||||||
"text": "Promise",
|
|
||||||
"canonicalReference": "!Promise:interface"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": "<string>"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Content",
|
|
||||||
"text": ";"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"fileUrlPath": "packages/tldraw/src/lib/utils/assets/assets.ts",
|
|
||||||
"returnTypeTokenRange": {
|
|
||||||
"startIndex": 9,
|
|
||||||
"endIndex": 11
|
|
||||||
},
|
|
||||||
"releaseTag": "Public",
|
|
||||||
"overloadIndex": 1,
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"parameterName": "dataURLForImage",
|
|
||||||
"parameterTypeTokenRange": {
|
|
||||||
"startIndex": 1,
|
|
||||||
"endIndex": 2
|
|
||||||
},
|
|
||||||
"isOptional": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameterName": "width",
|
|
||||||
"parameterTypeTokenRange": {
|
|
||||||
"startIndex": 3,
|
|
||||||
"endIndex": 4
|
|
||||||
},
|
|
||||||
"isOptional": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameterName": "height",
|
|
||||||
"parameterTypeTokenRange": {
|
|
||||||
"startIndex": 5,
|
|
||||||
"endIndex": 6
|
|
||||||
},
|
|
||||||
"isOptional": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameterName": "opts",
|
|
||||||
"parameterTypeTokenRange": {
|
|
||||||
"startIndex": 7,
|
|
||||||
"endIndex": 8
|
|
||||||
},
|
|
||||||
"isOptional": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "getResizedImageDataUrl"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"kind": "Function",
|
"kind": "Function",
|
||||||
"canonicalReference": "@tldraw/tldraw!getSvgAsImage:function(1)",
|
"canonicalReference": "@tldraw/tldraw!getSvgAsImage:function(1)",
|
||||||
|
@ -9679,8 +9689,8 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Reference",
|
"kind": "Reference",
|
||||||
"text": "File",
|
"text": "Blob",
|
||||||
"canonicalReference": "!File:interface"
|
"canonicalReference": "!Blob:interface"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
|
|
|
@ -55,7 +55,6 @@
|
||||||
"@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"
|
||||||
},
|
},
|
||||||
|
@ -69,7 +68,6 @@
|
||||||
"@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",
|
||||||
|
|
|
@ -147,7 +147,7 @@ export {
|
||||||
DEFAULT_ACCEPTED_IMG_TYPE,
|
DEFAULT_ACCEPTED_IMG_TYPE,
|
||||||
DEFAULT_ACCEPTED_VID_TYPE,
|
DEFAULT_ACCEPTED_VID_TYPE,
|
||||||
containBoxSize,
|
containBoxSize,
|
||||||
getResizedImageDataUrl,
|
downsizeImage,
|
||||||
isGifAnimated,
|
isGifAnimated,
|
||||||
} from './lib/utils/assets/assets'
|
} from './lib/utils/assets/assets'
|
||||||
export { getEmbedInfo } from './lib/utils/embeds/embeds'
|
export { getEmbedInfo } from './lib/utils/embeds/embeds'
|
||||||
|
|
|
@ -12,12 +12,14 @@ import {
|
||||||
TLTextShapeProps,
|
TLTextShapeProps,
|
||||||
Vec,
|
Vec,
|
||||||
VecLike,
|
VecLike,
|
||||||
|
assert,
|
||||||
compact,
|
compact,
|
||||||
createShapeId,
|
createShapeId,
|
||||||
|
getHashForBuffer,
|
||||||
getHashForString,
|
getHashForString,
|
||||||
} from '@tldraw/editor'
|
} from '@tldraw/editor'
|
||||||
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from './shapes/shared/default-shape-constants'
|
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from './shapes/shared/default-shape-constants'
|
||||||
import { containBoxSize, getResizedImageDataUrl, isGifAnimated } from './utils/assets/assets'
|
import { containBoxSize, downsizeImage, isGifAnimated } from './utils/assets/assets'
|
||||||
import { getEmbedInfo } from './utils/embeds/embeds'
|
import { getEmbedInfo } from './utils/embeds/embeds'
|
||||||
import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text'
|
import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text'
|
||||||
|
|
||||||
|
@ -43,87 +45,63 @@ export function registerDefaultExternalContentHandlers(
|
||||||
}: TLExternalContentProps
|
}: TLExternalContentProps
|
||||||
) {
|
) {
|
||||||
// files -> asset
|
// files -> asset
|
||||||
editor.registerExternalAssetHandler('file', async ({ file }) => {
|
editor.registerExternalAssetHandler('file', async ({ file: _file }) => {
|
||||||
return await new Promise((resolve, reject) => {
|
const name = _file.name
|
||||||
if (
|
let file: Blob = _file
|
||||||
!acceptedImageMimeTypes.includes(file.type) &&
|
const isImageType = acceptedImageMimeTypes.includes(file.type)
|
||||||
!acceptedVideoMimeTypes.includes(file.type)
|
const isVideoType = acceptedVideoMimeTypes.includes(file.type)
|
||||||
) {
|
|
||||||
console.warn(`File type not allowed: ${file.type}`)
|
assert(isImageType || isVideoType, `File type not allowed: ${file.type}`)
|
||||||
reject()
|
assert(
|
||||||
|
file.size <= maxAssetSize,
|
||||||
|
`File size too big: ${(file.size / 1024).toFixed()}kb > ${(maxAssetSize / 1024).toFixed()}kb`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (file.type === 'video/quicktime') {
|
||||||
|
// hack to make .mov videos work
|
||||||
|
file = new Blob([file], { type: 'video/mp4' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = isImageType
|
||||||
|
? await MediaHelpers.getImageSize(file)
|
||||||
|
: await MediaHelpers.getVideoSize(file)
|
||||||
|
|
||||||
|
const isAnimated = file.type === 'image/gif' ? await isGifAnimated(file) : isVideoType
|
||||||
|
|
||||||
|
const hash = await getHashForBuffer(await file.arrayBuffer())
|
||||||
|
|
||||||
|
if (isFinite(maxImageDimension)) {
|
||||||
|
const resizedSize = containBoxSize(size, { w: maxImageDimension, h: maxImageDimension })
|
||||||
|
if (size !== resizedSize && (file.type === 'image/jpeg' || file.type === 'image/png')) {
|
||||||
|
size = resizedSize
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (file.size > maxAssetSize) {
|
// Always rescale the image
|
||||||
console.warn(
|
if (file.type === 'image/jpeg' || file.type === 'image/png') {
|
||||||
`File size too big: ${(file.size / 1024).toFixed()}kb > ${(
|
file = await downsizeImage(file, size.w, size.h, {
|
||||||
maxAssetSize / 1024
|
type: file.type,
|
||||||
).toFixed()}kb`
|
quality: 0.92,
|
||||||
)
|
})
|
||||||
reject()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader()
|
const assetId: TLAssetId = AssetRecordType.createId(hash)
|
||||||
reader.onerror = () => reject(reader.error)
|
|
||||||
reader.onload = async () => {
|
|
||||||
let dataUrl = reader.result as string
|
|
||||||
|
|
||||||
// Hack to make .mov videos work via dataURL.
|
const asset = AssetRecordType.create({
|
||||||
if (file.type === 'video/quicktime' && dataUrl.includes('video/quicktime')) {
|
id: assetId,
|
||||||
dataUrl = dataUrl.replace('video/quicktime', 'video/mp4')
|
type: isImageType ? 'image' : 'video',
|
||||||
}
|
typeName: 'asset',
|
||||||
|
props: {
|
||||||
const isImageType = acceptedImageMimeTypes.includes(file.type)
|
name,
|
||||||
|
src: await MediaHelpers.blobToDataUrl(file),
|
||||||
let size: {
|
w: size.w,
|
||||||
w: number
|
h: size.h,
|
||||||
h: number
|
mimeType: file.type,
|
||||||
}
|
isAnimated,
|
||||||
let isAnimated: boolean
|
},
|
||||||
|
|
||||||
if (isImageType) {
|
|
||||||
size = await MediaHelpers.getImageSizeFromSrc(dataUrl)
|
|
||||||
isAnimated = file.type === 'image/gif' && (await isGifAnimated(file))
|
|
||||||
} else {
|
|
||||||
isAnimated = true
|
|
||||||
size = await MediaHelpers.getVideoSizeFromSrc(dataUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFinite(maxImageDimension)) {
|
|
||||||
const resizedSize = containBoxSize(size, { w: maxImageDimension, h: maxImageDimension })
|
|
||||||
if (size !== resizedSize && (file.type === 'image/jpeg' || file.type === 'image/png')) {
|
|
||||||
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 asset = AssetRecordType.create({
|
|
||||||
id: assetId,
|
|
||||||
type: isImageType ? 'image' : 'video',
|
|
||||||
typeName: 'asset',
|
|
||||||
props: {
|
|
||||||
name: file.name,
|
|
||||||
src: dataUrl,
|
|
||||||
w: size.w,
|
|
||||||
h: size.h,
|
|
||||||
mimeType: file.type,
|
|
||||||
isAnimated,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
resolve(asset)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return asset
|
||||||
})
|
})
|
||||||
|
|
||||||
// urls -> bookmark asset
|
// urls -> bookmark asset
|
||||||
|
|
|
@ -26,3 +26,5 @@ async function calculateBrowserCanvasMaxSize(): Promise<CanvasMaxSize> {
|
||||||
maxArea: maxArea.width * maxArea.height,
|
maxArea: maxArea.width * maxArea.height,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MAX_SAFE_CANVAS_DIMENSION = 8192
|
||||||
|
|
|
@ -1,103 +0,0 @@
|
||||||
import downscale from 'downscale'
|
|
||||||
import { getBrowserCanvasMaxSize } from '../shapes/shared/getBrowserCanvasMaxSize'
|
|
||||||
import { isAnimated } from './assets/is-gif-animated'
|
|
||||||
|
|
||||||
type BoxWidthHeight = {
|
|
||||||
w: number
|
|
||||||
h: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contains the size within the given box size
|
|
||||||
*
|
|
||||||
* @param originalSize - The size of the asset
|
|
||||||
* @param containBoxSize - The container size
|
|
||||||
* @returns Adjusted size
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
export function containBoxSize(
|
|
||||||
originalSize: BoxWidthHeight,
|
|
||||||
containBoxSize: BoxWidthHeight
|
|
||||||
): BoxWidthHeight {
|
|
||||||
const overByXScale = originalSize.w / containBoxSize.w
|
|
||||||
const overByYScale = originalSize.h / containBoxSize.h
|
|
||||||
|
|
||||||
if (overByXScale <= 1 && overByYScale <= 1) {
|
|
||||||
return originalSize
|
|
||||||
} else if (overByXScale > overByYScale) {
|
|
||||||
return {
|
|
||||||
w: originalSize.w / overByXScale,
|
|
||||||
h: originalSize.h / overByXScale,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
w: originalSize.w / overByYScale,
|
|
||||||
h: originalSize.h / overByYScale,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
opts = {} as { type?: string; quality?: number }
|
|
||||||
): Promise<string> {
|
|
||||||
let desiredWidth = width * 2
|
|
||||||
let desiredHeight = height * 2
|
|
||||||
const { type = 'image/jpeg', quality = 0.92 } = opts
|
|
||||||
|
|
||||||
const canvasSizes = await getBrowserCanvasMaxSize()
|
|
||||||
|
|
||||||
const aspectRatio = width / height
|
|
||||||
|
|
||||||
if (desiredWidth > canvasSizes.maxWidth) {
|
|
||||||
desiredWidth = canvasSizes.maxWidth
|
|
||||||
desiredHeight = desiredWidth / 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
|
|
||||||
}
|
|
||||||
|
|
||||||
return await downscale(dataURLForImage, desiredWidth, desiredHeight, { imageType: type, quality })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export const DEFAULT_ACCEPTED_IMG_TYPE = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']
|
|
||||||
/** @public */
|
|
||||||
export const DEFAULT_ACCEPTED_VID_TYPE = ['video/mp4', 'video/quicktime']
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export async function isGifAnimated(file: File): Promise<boolean> {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onerror = () => reject(reader.error)
|
|
||||||
reader.onload = () => {
|
|
||||||
resolve(reader.result ? isAnimated(reader.result as ArrayBuffer) : false)
|
|
||||||
}
|
|
||||||
reader.readAsArrayBuffer(file)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,5 +1,8 @@
|
||||||
import downscale from 'downscale'
|
import { MediaHelpers, assertExists } from '@tldraw/editor'
|
||||||
import { getBrowserCanvasMaxSize } from '../../shapes/shared/getBrowserCanvasMaxSize'
|
import {
|
||||||
|
MAX_SAFE_CANVAS_DIMENSION,
|
||||||
|
getBrowserCanvasMaxSize,
|
||||||
|
} from '../../shapes/shared/getBrowserCanvasMaxSize'
|
||||||
import { isAnimated } from './is-gif-animated'
|
import { isAnimated } from './is-gif-animated'
|
||||||
|
|
||||||
type BoxWidthHeight = {
|
type BoxWidthHeight = {
|
||||||
|
@ -38,54 +41,77 @@ export function containBoxSize(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the size of an image from its source.
|
* Resize an image Blob to be smaller than it is currently.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const size = await getImageSize('https://example.com/image.jpg')
|
* const image = await (await fetch('/image.jpg')).blob()
|
||||||
* const dataUrl = await getResizedImageDataUrl('https://example.com/image.jpg', size.w, size.h, { type: "image/jpeg", quality: 0.92 })
|
* const size = await getImageSize(image)
|
||||||
|
* const resizedImage = await downsizeImage(image, size.w / 2, size.h / 2, { type: "image/jpeg", quality: 0.92 })
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param dataURLForImage - The image file as a string.
|
* @param image - The image Blob.
|
||||||
* @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.
|
* @param opts - Options for the image.
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export async function getResizedImageDataUrl(
|
export async function downsizeImage(
|
||||||
dataURLForImage: string,
|
blob: Blob,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
opts = {} as { type?: string; quality?: number }
|
opts = {} as { type?: string; quality?: number }
|
||||||
): Promise<string> {
|
): Promise<Blob> {
|
||||||
let desiredWidth = width * 2
|
const image = await MediaHelpers.usingObjectURL(blob, MediaHelpers.loadImage)
|
||||||
let desiredHeight = height * 2
|
let desiredWidth = Math.min(width * 2, image.naturalWidth)
|
||||||
const { type = 'image/jpeg', quality = 0.92 } = opts
|
let desiredHeight = Math.min(height * 2, image.naturalHeight)
|
||||||
|
const { type = blob.type, quality = 0.92 } = opts
|
||||||
|
|
||||||
const canvasSizes = await getBrowserCanvasMaxSize()
|
if (desiredWidth > MAX_SAFE_CANVAS_DIMENSION || desiredHeight > MAX_SAFE_CANVAS_DIMENSION) {
|
||||||
|
const canvasSizes = await getBrowserCanvasMaxSize()
|
||||||
|
|
||||||
const aspectRatio = width / height
|
const aspectRatio = width / height
|
||||||
|
|
||||||
if (desiredWidth > canvasSizes.maxWidth) {
|
if (desiredWidth > canvasSizes.maxWidth) {
|
||||||
desiredWidth = canvasSizes.maxWidth
|
desiredWidth = canvasSizes.maxWidth
|
||||||
desiredHeight = desiredWidth / aspectRatio
|
desiredHeight = desiredWidth / 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 (desiredHeight > canvasSizes.maxHeight) {
|
const canvas = document.createElement('canvas')
|
||||||
desiredHeight = canvasSizes.maxHeight
|
canvas.width = desiredWidth
|
||||||
desiredWidth = desiredHeight * aspectRatio
|
canvas.height = desiredHeight
|
||||||
}
|
const ctx = assertExists(
|
||||||
|
canvas.getContext('2d', { willReadFrequently: true }),
|
||||||
|
'Could not get canvas context'
|
||||||
|
)
|
||||||
|
ctx.imageSmoothingEnabled = true
|
||||||
|
ctx.imageSmoothingQuality = 'high'
|
||||||
|
ctx.drawImage(image, 0, 0, desiredWidth, desiredHeight)
|
||||||
|
|
||||||
if (desiredWidth * desiredHeight > canvasSizes.maxArea) {
|
return new Promise((resolve, reject) => {
|
||||||
const ratio = Math.sqrt(canvasSizes.maxArea / (desiredWidth * desiredHeight))
|
canvas.toBlob(
|
||||||
desiredWidth *= ratio
|
(blob) => {
|
||||||
desiredHeight *= ratio
|
if (blob) {
|
||||||
}
|
resolve(blob)
|
||||||
|
} else {
|
||||||
return await downscale(dataURLForImage, desiredWidth, desiredHeight, {
|
reject(new Error('Could not resize image'))
|
||||||
// downscale expects the type without the `image/` prefix
|
}
|
||||||
imageType: type.replace('image/', ''),
|
},
|
||||||
quality,
|
type,
|
||||||
|
quality
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,13 +121,6 @@ export const DEFAULT_ACCEPTED_IMG_TYPE = ['image/jpeg', 'image/png', 'image/gif'
|
||||||
export const DEFAULT_ACCEPTED_VID_TYPE = ['video/mp4', 'video/quicktime']
|
export const DEFAULT_ACCEPTED_VID_TYPE = ['video/mp4', 'video/quicktime']
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export async function isGifAnimated(file: File): Promise<boolean> {
|
export async function isGifAnimated(file: Blob): Promise<boolean> {
|
||||||
return await new Promise((resolve, reject) => {
|
return isAnimated(await file.arrayBuffer())
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onerror = () => reject(reader.error)
|
|
||||||
reader.onload = () => {
|
|
||||||
resolve(reader.result ? isAnimated(reader.result as ArrayBuffer) : false)
|
|
||||||
}
|
|
||||||
reader.readAsArrayBuffer(file)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,6 +68,9 @@ export function getErrorAnnotations(error: Error): ErrorAnnotations;
|
||||||
// @public
|
// @public
|
||||||
export function getFirstFromIterable<T = unknown>(set: Map<any, T> | Set<T>): T;
|
export function getFirstFromIterable<T = unknown>(set: Map<any, T> | Set<T>): T;
|
||||||
|
|
||||||
|
// @public
|
||||||
|
export function getHashForBuffer(buffer: ArrayBuffer): string;
|
||||||
|
|
||||||
// @public
|
// @public
|
||||||
export function getHashForObject(obj: any): string;
|
export function getHashForObject(obj: any): string;
|
||||||
|
|
||||||
|
@ -124,14 +127,19 @@ export function mapObjectMapValues<Key extends string, ValueBefore, ValueAfter>(
|
||||||
|
|
||||||
// @public
|
// @public
|
||||||
export class MediaHelpers {
|
export class MediaHelpers {
|
||||||
static getImageSizeFromSrc(dataURL: string): Promise<{
|
static blobToDataUrl(blob: Blob): Promise<string>;
|
||||||
|
static getImageSize(blob: Blob): Promise<{
|
||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
}>;
|
}>;
|
||||||
static getVideoSizeFromSrc(src: string): Promise<{
|
static getVideoSize(blob: Blob): Promise<{
|
||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
}>;
|
}>;
|
||||||
|
static loadImage(src: string): Promise<HTMLImageElement>;
|
||||||
|
static loadVideo(src: string): Promise<HTMLVideoElement>;
|
||||||
|
// (undocumented)
|
||||||
|
static usingObjectURL<T>(blob: Blob, fn: (url: string) => Promise<T>): Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @internal (undocumented)
|
// @internal (undocumented)
|
||||||
|
|
|
@ -654,6 +654,52 @@
|
||||||
],
|
],
|
||||||
"name": "getFirstFromIterable"
|
"name": "getFirstFromIterable"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"kind": "Function",
|
||||||
|
"canonicalReference": "@tldraw/utils!getHashForBuffer:function(1)",
|
||||||
|
"docComment": "/**\n * Hash an ArrayBuffer using the FNV-1a algorithm.\n *\n * @public\n */\n",
|
||||||
|
"excerptTokens": [
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "export declare function getHashForBuffer(buffer: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "ArrayBuffer",
|
||||||
|
"canonicalReference": "!ArrayBuffer:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "): "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ";"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fileUrlPath": "packages/utils/src/lib/hash.ts",
|
||||||
|
"returnTypeTokenRange": {
|
||||||
|
"startIndex": 3,
|
||||||
|
"endIndex": 4
|
||||||
|
},
|
||||||
|
"releaseTag": "Public",
|
||||||
|
"overloadIndex": 1,
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterName": "buffer",
|
||||||
|
"parameterTypeTokenRange": {
|
||||||
|
"startIndex": 1,
|
||||||
|
"endIndex": 2
|
||||||
|
},
|
||||||
|
"isOptional": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "getHashForBuffer"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"kind": "Function",
|
"kind": "Function",
|
||||||
"canonicalReference": "@tldraw/utils!getHashForObject:function(1)",
|
"canonicalReference": "@tldraw/utils!getHashForObject:function(1)",
|
||||||
|
@ -1246,16 +1292,71 @@
|
||||||
"members": [
|
"members": [
|
||||||
{
|
{
|
||||||
"kind": "Method",
|
"kind": "Method",
|
||||||
"canonicalReference": "@tldraw/utils!MediaHelpers.getImageSizeFromSrc:member(1)",
|
"canonicalReference": "@tldraw/utils!MediaHelpers.blobToDataUrl:member(1)",
|
||||||
"docComment": "/**\n * Get the size of an image from its source.\n *\n * @param dataURL - The file as a string.\n *\n * @public\n */\n",
|
"docComment": "/**\n * Read a blob into a data url\n *\n * @public\n */\n",
|
||||||
"excerptTokens": [
|
"excerptTokens": [
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
"text": "static getImageSizeFromSrc(dataURL: "
|
"text": "static blobToDataUrl(blob: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Blob",
|
||||||
|
"canonicalReference": "!Blob:interface"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
"text": "string"
|
"text": "): "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Promise",
|
||||||
|
"canonicalReference": "!Promise:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "<string>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ";"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isStatic": true,
|
||||||
|
"returnTypeTokenRange": {
|
||||||
|
"startIndex": 3,
|
||||||
|
"endIndex": 5
|
||||||
|
},
|
||||||
|
"releaseTag": "Public",
|
||||||
|
"isProtected": false,
|
||||||
|
"overloadIndex": 1,
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterName": "blob",
|
||||||
|
"parameterTypeTokenRange": {
|
||||||
|
"startIndex": 1,
|
||||||
|
"endIndex": 2
|
||||||
|
},
|
||||||
|
"isOptional": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isOptional": false,
|
||||||
|
"isAbstract": false,
|
||||||
|
"name": "blobToDataUrl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Method",
|
||||||
|
"canonicalReference": "@tldraw/utils!MediaHelpers.getImageSize:member(1)",
|
||||||
|
"docComment": "/**\n * Get the size of an image blob\n *\n * @param dataURL - A Blob containing the image.\n *\n * @public\n */\n",
|
||||||
|
"excerptTokens": [
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "static getImageSize(blob: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Blob",
|
||||||
|
"canonicalReference": "!Blob:interface"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
|
@ -1285,7 +1386,7 @@
|
||||||
"overloadIndex": 1,
|
"overloadIndex": 1,
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"parameterName": "dataURL",
|
"parameterName": "blob",
|
||||||
"parameterTypeTokenRange": {
|
"parameterTypeTokenRange": {
|
||||||
"startIndex": 1,
|
"startIndex": 1,
|
||||||
"endIndex": 2
|
"endIndex": 2
|
||||||
|
@ -1295,20 +1396,21 @@
|
||||||
],
|
],
|
||||||
"isOptional": false,
|
"isOptional": false,
|
||||||
"isAbstract": false,
|
"isAbstract": false,
|
||||||
"name": "getImageSizeFromSrc"
|
"name": "getImageSize"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Method",
|
"kind": "Method",
|
||||||
"canonicalReference": "@tldraw/utils!MediaHelpers.getVideoSizeFromSrc:member(1)",
|
"canonicalReference": "@tldraw/utils!MediaHelpers.getVideoSize:member(1)",
|
||||||
"docComment": "/**\n * Get the size of a video from its source.\n *\n * @param src - The source of the video.\n *\n * @public\n */\n",
|
"docComment": "/**\n * Get the size of a video blob\n *\n * @param src - A SharedBlob containing the video\n *\n * @public\n */\n",
|
||||||
"excerptTokens": [
|
"excerptTokens": [
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
"text": "static getVideoSizeFromSrc(src: "
|
"text": "static getVideoSize(blob: "
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Reference",
|
||||||
"text": "string"
|
"text": "Blob",
|
||||||
|
"canonicalReference": "!Blob:interface"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
|
@ -1336,6 +1438,68 @@
|
||||||
"releaseTag": "Public",
|
"releaseTag": "Public",
|
||||||
"isProtected": false,
|
"isProtected": false,
|
||||||
"overloadIndex": 1,
|
"overloadIndex": 1,
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterName": "blob",
|
||||||
|
"parameterTypeTokenRange": {
|
||||||
|
"startIndex": 1,
|
||||||
|
"endIndex": 2
|
||||||
|
},
|
||||||
|
"isOptional": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isOptional": false,
|
||||||
|
"isAbstract": false,
|
||||||
|
"name": "getVideoSize"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Method",
|
||||||
|
"canonicalReference": "@tldraw/utils!MediaHelpers.loadImage:member(1)",
|
||||||
|
"docComment": "/**\n * Load an image from a url.\n *\n * @public\n */\n",
|
||||||
|
"excerptTokens": [
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "static loadImage(src: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "): "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Promise",
|
||||||
|
"canonicalReference": "!Promise:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "<"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "HTMLImageElement",
|
||||||
|
"canonicalReference": "!HTMLImageElement:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ">"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ";"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isStatic": true,
|
||||||
|
"returnTypeTokenRange": {
|
||||||
|
"startIndex": 3,
|
||||||
|
"endIndex": 7
|
||||||
|
},
|
||||||
|
"releaseTag": "Public",
|
||||||
|
"isProtected": false,
|
||||||
|
"overloadIndex": 1,
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"parameterName": "src",
|
"parameterName": "src",
|
||||||
|
@ -1348,7 +1512,161 @@
|
||||||
],
|
],
|
||||||
"isOptional": false,
|
"isOptional": false,
|
||||||
"isAbstract": false,
|
"isAbstract": false,
|
||||||
"name": "getVideoSizeFromSrc"
|
"name": "loadImage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Method",
|
||||||
|
"canonicalReference": "@tldraw/utils!MediaHelpers.loadVideo:member(1)",
|
||||||
|
"docComment": "/**\n * Load a video from a url.\n *\n * @public\n */\n",
|
||||||
|
"excerptTokens": [
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "static loadVideo(src: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "): "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Promise",
|
||||||
|
"canonicalReference": "!Promise:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "<"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "HTMLVideoElement",
|
||||||
|
"canonicalReference": "!HTMLVideoElement:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ">"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ";"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isStatic": true,
|
||||||
|
"returnTypeTokenRange": {
|
||||||
|
"startIndex": 3,
|
||||||
|
"endIndex": 7
|
||||||
|
},
|
||||||
|
"releaseTag": "Public",
|
||||||
|
"isProtected": false,
|
||||||
|
"overloadIndex": 1,
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterName": "src",
|
||||||
|
"parameterTypeTokenRange": {
|
||||||
|
"startIndex": 1,
|
||||||
|
"endIndex": 2
|
||||||
|
},
|
||||||
|
"isOptional": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isOptional": false,
|
||||||
|
"isAbstract": false,
|
||||||
|
"name": "loadVideo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Method",
|
||||||
|
"canonicalReference": "@tldraw/utils!MediaHelpers.usingObjectURL:member(1)",
|
||||||
|
"docComment": "",
|
||||||
|
"excerptTokens": [
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "static usingObjectURL<T>(blob: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Blob",
|
||||||
|
"canonicalReference": "!Blob:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ", fn: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "(url: string) => "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Promise",
|
||||||
|
"canonicalReference": "!Promise:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "<T>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "): "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "Promise",
|
||||||
|
"canonicalReference": "!Promise:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "<T>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ";"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typeParameters": [
|
||||||
|
{
|
||||||
|
"typeParameterName": "T",
|
||||||
|
"constraintTokenRange": {
|
||||||
|
"startIndex": 0,
|
||||||
|
"endIndex": 0
|
||||||
|
},
|
||||||
|
"defaultTypeTokenRange": {
|
||||||
|
"startIndex": 0,
|
||||||
|
"endIndex": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isStatic": true,
|
||||||
|
"returnTypeTokenRange": {
|
||||||
|
"startIndex": 7,
|
||||||
|
"endIndex": 9
|
||||||
|
},
|
||||||
|
"releaseTag": "Public",
|
||||||
|
"isProtected": false,
|
||||||
|
"overloadIndex": 1,
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterName": "blob",
|
||||||
|
"parameterTypeTokenRange": {
|
||||||
|
"startIndex": 1,
|
||||||
|
"endIndex": 2
|
||||||
|
},
|
||||||
|
"isOptional": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameterName": "fn",
|
||||||
|
"parameterTypeTokenRange": {
|
||||||
|
"startIndex": 3,
|
||||||
|
"endIndex": 6
|
||||||
|
},
|
||||||
|
"isOptional": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isOptional": false,
|
||||||
|
"isAbstract": false,
|
||||||
|
"name": "usingObjectURL"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"implementsTokenRanges": []
|
"implementsTokenRanges": []
|
||||||
|
|
|
@ -20,7 +20,7 @@ export { debounce } from './lib/debounce'
|
||||||
export { annotateError, getErrorAnnotations } from './lib/error'
|
export { annotateError, getErrorAnnotations } from './lib/error'
|
||||||
export { FileHelpers } from './lib/file'
|
export { FileHelpers } from './lib/file'
|
||||||
export { noop, omitFromStackTrace, throttle } from './lib/function'
|
export { noop, omitFromStackTrace, throttle } from './lib/function'
|
||||||
export { getHashForObject, getHashForString, lns } from './lib/hash'
|
export { getHashForBuffer, getHashForObject, getHashForString, lns } from './lib/hash'
|
||||||
export { getFirstFromIterable } from './lib/iterable'
|
export { getFirstFromIterable } from './lib/iterable'
|
||||||
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
|
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
|
||||||
export { MediaHelpers } from './lib/media'
|
export { MediaHelpers } from './lib/media'
|
||||||
|
|
|
@ -21,6 +21,21 @@ export function getHashForObject(obj: any) {
|
||||||
return getHashForString(JSON.stringify(obj))
|
return getHashForString(JSON.stringify(obj))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash an ArrayBuffer using the FNV-1a algorithm.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function getHashForBuffer(buffer: ArrayBuffer) {
|
||||||
|
const view = new DataView(buffer)
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < view.byteLength; i++) {
|
||||||
|
hash = (hash << 5) - hash + view.getUint8(i)
|
||||||
|
hash |= 0 // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
return hash + ''
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export function lns(str: string) {
|
export function lns(str: string) {
|
||||||
const result = str.split('')
|
const result = str.split('')
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { FileHelpers } from './file'
|
|
||||||
import { PngHelpers } from './png'
|
import { PngHelpers } from './png'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -8,18 +7,16 @@ import { PngHelpers } from './png'
|
||||||
*/
|
*/
|
||||||
export class MediaHelpers {
|
export class MediaHelpers {
|
||||||
/**
|
/**
|
||||||
* Get the size of a video from its source.
|
* Load a video from a url.
|
||||||
*
|
|
||||||
* @param src - The source of the video.
|
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
static async getVideoSizeFromSrc(src: string): Promise<{ w: number; h: number }> {
|
static loadVideo(src: string): Promise<HTMLVideoElement> {
|
||||||
return await new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const video = document.createElement('video')
|
const video = document.createElement('video')
|
||||||
video.onloadeddata = () => resolve({ w: video.videoWidth, h: video.videoHeight })
|
video.onloadeddata = () => resolve(video)
|
||||||
video.onerror = (e) => {
|
video.onerror = (e) => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
reject(new Error('Could not get video size'))
|
reject(new Error('Could not load video'))
|
||||||
}
|
}
|
||||||
video.crossOrigin = 'anonymous'
|
video.crossOrigin = 'anonymous'
|
||||||
video.src = src
|
video.src = src
|
||||||
|
@ -27,45 +24,90 @@ export class MediaHelpers {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the size of an image from its source.
|
* Load an image from a url.
|
||||||
*
|
|
||||||
* @param dataURL - The file as a string.
|
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
static async getImageSizeFromSrc(dataURL: string): Promise<{ w: number; h: number }> {
|
static loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
return await new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.onload = async () => {
|
img.onload = () => resolve(img)
|
||||||
try {
|
img.onerror = (e) => {
|
||||||
const blob = await FileHelpers.base64ToFile(dataURL)
|
console.error(e)
|
||||||
const view = new DataView(blob)
|
reject(new Error('Could not load image'))
|
||||||
if (PngHelpers.isPng(view, 0)) {
|
}
|
||||||
const physChunk = PngHelpers.findChunk(view, 'pHYs')
|
img.crossOrigin = 'anonymous'
|
||||||
if (physChunk) {
|
img.src = src
|
||||||
const physData = PngHelpers.parsePhys(view, physChunk.dataOffset)
|
})
|
||||||
if (physData.unit === 0 && physData.ppux === physData.ppuy) {
|
}
|
||||||
const pixelRatio = Math.max(physData.ppux / 2834.5, 1)
|
|
||||||
resolve({
|
/**
|
||||||
w: Math.round(img.width / pixelRatio),
|
* Read a blob into a data url
|
||||||
h: Math.round(img.height / pixelRatio),
|
* @public
|
||||||
})
|
*/
|
||||||
return
|
static blobToDataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = (e) => {
|
||||||
|
console.error(e)
|
||||||
|
reject(new Error('Could not read blob'))
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the size of a video blob
|
||||||
|
*
|
||||||
|
* @param src - A SharedBlob containing the video
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
static async getVideoSize(blob: Blob): Promise<{ w: number; h: number }> {
|
||||||
|
return MediaHelpers.usingObjectURL(blob, async (url) => {
|
||||||
|
const video = await MediaHelpers.loadVideo(url)
|
||||||
|
return { w: video.videoWidth, h: video.videoHeight }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the size of an image blob
|
||||||
|
*
|
||||||
|
* @param dataURL - A Blob containing the image.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
static async getImageSize(blob: Blob): Promise<{ w: number; h: number }> {
|
||||||
|
const image = await MediaHelpers.usingObjectURL(blob, MediaHelpers.loadImage)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (blob.type === 'image/png') {
|
||||||
|
const view = new DataView(await blob.arrayBuffer())
|
||||||
|
if (PngHelpers.isPng(view, 0)) {
|
||||||
|
const physChunk = PngHelpers.findChunk(view, 'pHYs')
|
||||||
|
if (physChunk) {
|
||||||
|
const physData = PngHelpers.parsePhys(view, physChunk.dataOffset)
|
||||||
|
if (physData.unit === 0 && physData.ppux === physData.ppuy) {
|
||||||
|
const pixelRatio = Math.max(physData.ppux / 2834.5, 1)
|
||||||
|
return {
|
||||||
|
w: Math.round(image.naturalWidth / pixelRatio),
|
||||||
|
h: Math.round(image.naturalHeight / pixelRatio),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({ w: img.width, h: img.height })
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
resolve({ w: img.width, h: img.height })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
img.onerror = (err) => {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
reject(new Error('Could not get image size'))
|
return { w: image.naturalWidth, h: image.naturalHeight }
|
||||||
}
|
}
|
||||||
img.crossOrigin = 'anonymous'
|
return { w: image.naturalWidth, h: image.naturalHeight }
|
||||||
img.src = dataURL
|
}
|
||||||
})
|
|
||||||
|
static async usingObjectURL<T>(blob: Blob, fn: (url: string) => Promise<T>): Promise<T> {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
try {
|
||||||
|
return await fn(url)
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4562,12 +4562,10 @@ __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
|
||||||
|
@ -4831,13 +4829,6 @@ __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"
|
||||||
|
@ -8015,13 +8006,6 @@ __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"
|
||||||
|
|
Loading…
Reference in a new issue