UNPKG

@tldraw/utils

Version:

tldraw infinite canvas SDK (private utilities).

258 lines (233 loc) • 7.2 kB
import { promiseWithResolve } from '../control' import { Image } from '../network' import { isApngAnimated } from './apng' import { isAvifAnimated } from './avif' import { isGifAnimated } from './gif' import { PngHelpers } from './png' import { isWebpAnimated } from './webp' /** @public */ export const DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES = Object.freeze(['image/svg+xml' as const]) /** @public */ export const DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES = Object.freeze([ 'image/jpeg' as const, 'image/png' as const, 'image/webp' as const, ]) /** @public */ export const DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES = Object.freeze([ 'image/gif' as const, 'image/apng' as const, 'image/avif' as const, ]) /** @public */ export const DEFAULT_SUPPORTED_IMAGE_TYPES = Object.freeze([ ...DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES, ...DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES, ...DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES, ]) /** @public */ export const DEFAULT_SUPPORT_VIDEO_TYPES = Object.freeze([ 'video/mp4' as const, 'video/webm' as const, 'video/quicktime' as const, ]) /** @public */ export const DEFAULT_SUPPORTED_MEDIA_TYPES = Object.freeze([ ...DEFAULT_SUPPORTED_IMAGE_TYPES, ...DEFAULT_SUPPORT_VIDEO_TYPES, ]) /** @public */ export const DEFAULT_SUPPORTED_MEDIA_TYPE_LIST = DEFAULT_SUPPORTED_MEDIA_TYPES.join(',') /** * Helpers for media * * @public */ export class MediaHelpers { /** * Load a video from a url. * @public */ static loadVideo(src: string): Promise<HTMLVideoElement> { return new Promise((resolve, reject) => { const video = document.createElement('video') video.onloadeddata = () => resolve(video) video.onerror = (e) => { console.error(e) reject(new Error('Could not load video')) } video.crossOrigin = 'anonymous' video.src = src }) } static async getVideoFrameAsDataUrl(video: HTMLVideoElement, time = 0): Promise<string> { const promise = promiseWithResolve<string>() let didSetTime = false const onReadyStateChanged = () => { if (!didSetTime) { if (video.readyState >= video.HAVE_METADATA) { didSetTime = true video.currentTime = time } else { return } } if (video.readyState >= video.HAVE_CURRENT_DATA) { const canvas = document.createElement('canvas') canvas.width = video.videoWidth canvas.height = video.videoHeight const ctx = canvas.getContext('2d') if (!ctx) { throw new Error('Could not get 2d context') } ctx.drawImage(video, 0, 0) promise.resolve(canvas.toDataURL()) } } const onError = (e: Event) => { console.error(e) promise.reject(new Error('Could not get video frame')) } video.addEventListener('loadedmetadata', onReadyStateChanged) video.addEventListener('loadeddata', onReadyStateChanged) video.addEventListener('canplay', onReadyStateChanged) video.addEventListener('seeked', onReadyStateChanged) video.addEventListener('error', onError) video.addEventListener('stalled', onError) onReadyStateChanged() try { return await promise } finally { video.removeEventListener('loadedmetadata', onReadyStateChanged) video.removeEventListener('loadeddata', onReadyStateChanged) video.removeEventListener('canplay', onReadyStateChanged) video.removeEventListener('seeked', onReadyStateChanged) video.removeEventListener('error', onError) video.removeEventListener('stalled', onError) } } /** * Load an image from a url. * @public */ static getImageAndDimensions( src: string ): Promise<{ w: number; h: number; image: HTMLImageElement }> { return new Promise((resolve, reject) => { const img = Image() img.onload = () => { let dimensions if (img.naturalWidth) { dimensions = { w: img.naturalWidth, h: img.naturalHeight, } } else { // Sigh, Firefox doesn't have naturalWidth or naturalHeight for SVGs. :-/ // We have to attach to dom and use clientWidth/clientHeight. document.body.appendChild(img) dimensions = { w: img.clientWidth, h: img.clientHeight, } document.body.removeChild(img) } resolve({ ...dimensions, image: img }) } img.onerror = (e) => { console.error(e) reject(new Error('Could not load image')) } img.crossOrigin = 'anonymous' img.referrerPolicy = 'strict-origin-when-cross-origin' img.style.visibility = 'hidden' img.style.position = 'absolute' img.style.opacity = '0' img.style.zIndex = '-9999' img.src = src }) } /** * Get the size of a video blob * * @param blob - 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 blob - A Blob containing the image. * @public */ static async getImageSize(blob: Blob): Promise<{ w: number; h: number }> { const { w, h } = await MediaHelpers.usingObjectURL(blob, MediaHelpers.getImageAndDimensions) 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) { // Calculate pixels per meter: // - 1 inch = 0.0254 meters // - 72 DPI is 72 dots per inch // - pixels per meter = 72 / 0.0254 const pixelsPerMeter = 72 / 0.0254 const pixelRatio = Math.max(physData.ppux / pixelsPerMeter, 1) return { w: Math.round(w / pixelRatio), h: Math.round(h / pixelRatio), } } } } } } catch (err) { console.error(err) return { w, h } } return { w, h } } static async isAnimated(file: Blob): Promise<boolean> { if (file.type === 'image/gif') { return isGifAnimated(await file.arrayBuffer()) } if (file.type === 'image/avif') { return isAvifAnimated(await file.arrayBuffer()) } if (file.type === 'image/webp') { return isWebpAnimated(await file.arrayBuffer()) } if (file.type === 'image/apng') { return isApngAnimated(await file.arrayBuffer()) } return false } static isAnimatedImageType(mimeType: string | null): boolean { return DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES.includes((mimeType as any) || '') } static isStaticImageType(mimeType: string | null): boolean { return DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES.includes((mimeType as any) || '') } static isVectorImageType(mimeType: string | null): boolean { return DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES.includes((mimeType as any) || '') } static isImageType(mimeType: string): boolean { return DEFAULT_SUPPORTED_IMAGE_TYPES.includes((mimeType as any) || '') } 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) } } }