assets: rework mime-type detection to be consistent/centralized; add support for webp/webm, apng, avif

pull/3730/head
Mime Čuvalo 2024-05-09 14:08:14 +01:00
rodzic da35f2bd75
commit 31b4e7ded0
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: BA84499022AC984D
19 zmienionych plików z 280 dodań i 66 usunięć

Wyświetl plik

@ -1,7 +1,6 @@
import { useCallback } from 'react'
import {
AssetRecordType,
DEFAULT_ACCEPTED_IMG_TYPE,
MediaHelpers,
TLAsset,
TLAssetId,
@ -25,7 +24,7 @@ export function useMultiplayerAssets(assetUploaderUrl: string) {
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
const isImageType = DEFAULT_ACCEPTED_IMG_TYPE.includes(file.type)
const isImageType = MediaHelpers.isImageType(file.type)
let size: {
w: number
@ -35,7 +34,7 @@ export function useMultiplayerAssets(assetUploaderUrl: string) {
if (isImageType) {
size = await MediaHelpers.getImageSize(file)
if (file.type === 'image/gif') {
if (MediaHelpers.isAnimatedImageType(file.type)) {
isAnimated = true // await getIsGifAnimated(file) todo export me from editor
} else {
isAnimated = false

Wyświetl plik

@ -1,6 +1,5 @@
import {
AssetRecordType,
DEFAULT_ACCEPTED_IMG_TYPE,
MediaHelpers,
TLAsset,
TLAssetId,
@ -23,7 +22,7 @@ export async function createAssetFromFile({ file }: { type: 'file'; file: File }
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
const isImageType = DEFAULT_ACCEPTED_IMG_TYPE.includes(file.type)
const isImageType = MediaHelpers.isImageType(file.type)
let size: {
w: number
@ -33,7 +32,7 @@ export async function createAssetFromFile({ file }: { type: 'file'; file: File }
if (isImageType) {
size = await MediaHelpers.getImageSize(file)
if (file.type === 'image/gif') {
if (MediaHelpers.isAnimatedImageType(file.type)) {
isAnimated = true // await getIsGifAnimated(file) todo export me from editor
} else {
isAnimated = false

Wyświetl plik

@ -7,7 +7,6 @@ import {
TLAssetId,
Tldraw,
getHashForString,
isGifAnimated,
uniqueId,
} from 'tldraw'
import 'tldraw/tldraw.css'
@ -40,10 +39,10 @@ export default function HostedImagesExample() {
let shapeType: 'image' | 'video'
//[c]
if (['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'].includes(file.type)) {
if (MediaHelpers.isImageType(file.type)) {
shapeType = 'image'
size = await MediaHelpers.getImageSize(file)
isAnimated = file.type === 'image/gif' && (await isGifAnimated(file))
isAnimated = await MediaHelpers.isAnimated(file)
} else {
shapeType = 'video'
isAnimated = true

Wyświetl plik

@ -1,5 +1,5 @@
import { useState } from 'react'
import { FileHelpers, MediaHelpers } from 'tldraw'
import { DEFAULT_SUPPORTED_MEDIA_TYPE_LIST, FileHelpers, MediaHelpers } from 'tldraw'
import anakin from './assets/anakin.jpeg'
import distractedBf from './assets/distracted-bf.jpeg'
import expandingBrain from './assets/expanding-brain.png'
@ -13,7 +13,7 @@ export function ImagePicker({
function onClickChooseImage() {
const input = window.document.createElement('input')
input.type = 'file'
input.accept = 'image/jpeg,image/png,image/gif,image/svg+xml,video/mp4,video/quicktime'
input.accept = DEFAULT_SUPPORTED_MEDIA_TYPE_LIST
input.addEventListener('change', async (e) => {
const fileList = (e.target as HTMLInputElement).files
if (!fileList || fileList.length === 0) return

Wyświetl plik

@ -326,12 +326,6 @@ export function CutMenuItem(): JSX_2.Element;
// @public (undocumented)
export function DebugFlags(): JSX_2.Element | null;
// @public (undocumented)
export const DEFAULT_ACCEPTED_IMG_TYPE: string[];
// @public (undocumented)
export const DEFAULT_ACCEPTED_VID_TYPE: string[];
// @public (undocumented)
export const DefaultActionsMenu: NamedExoticComponent<TLUiActionsMenuProps>;
@ -584,7 +578,7 @@ export function ExportFileContentSubMenu(): JSX_2.Element;
// @public
export function exportToBlob({ editor, ids, format, opts, }: {
editor: Editor;
format: 'jpeg' | 'json' | 'png' | 'svg' | 'webp';
format: TLExportType;
ids: TLShapeId[];
opts?: Partial<TLSvgOptions>;
}): Promise<Blob>;
@ -965,9 +959,6 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
static type: "image";
}
// @public (undocumented)
export function isGifAnimated(file: Blob): Promise<boolean>;
// @public (undocumented)
export function KeyboardShortcutsMenuItem(): JSX_2.Element | null;

Wyświetl plik

@ -109,13 +109,7 @@ export {
} from './lib/ui/hooks/useTranslation/useTranslation'
export { type TLUiIconType } from './lib/ui/icon-types'
export { useDefaultHelpers, type TLUiOverrides } from './lib/ui/overrides'
export {
DEFAULT_ACCEPTED_IMG_TYPE,
DEFAULT_ACCEPTED_VID_TYPE,
containBoxSize,
downsizeImage,
isGifAnimated,
} from './lib/utils/assets/assets'
export { containBoxSize, downsizeImage } from './lib/utils/assets/assets'
export { getEmbedInfo } from './lib/utils/embeds/embeds'
export { copyAs } from './lib/utils/export/copyAs'
export { exportToBlob, getSvgAsImage } from './lib/utils/export/export'

Wyświetl plik

@ -1,4 +1,6 @@
import {
DEFAULT_SUPPORTED_IMAGE_TYPES,
DEFAULT_SUPPORT_VIDEO_TYPES,
Editor,
ErrorScreen,
Expand,
@ -148,21 +150,12 @@ export function Tldraw(props: TldrawProps) {
)
}
const defaultAcceptedImageMimeTypes = Object.freeze([
'image/jpeg',
'image/png',
'image/gif',
'image/svg+xml',
])
const defaultAcceptedVideoMimeTypes = Object.freeze(['video/mp4', 'video/quicktime'])
// We put these hooks into a component here so that they can run inside of the context provided by TldrawEditor and TldrawUi.
function InsideOfEditorAndUiContext({
maxImageDimension = 1000,
maxAssetSize = 10 * 1024 * 1024, // 10mb
acceptedImageMimeTypes = defaultAcceptedImageMimeTypes,
acceptedVideoMimeTypes = defaultAcceptedVideoMimeTypes,
acceptedImageMimeTypes = DEFAULT_SUPPORTED_IMAGE_TYPES,
acceptedVideoMimeTypes = DEFAULT_SUPPORT_VIDEO_TYPES,
onMount,
}: Partial<TLExternalContentProps & { onMount: TLOnMountHandler }>) {
const editor = useEditor()

Wyświetl plik

@ -22,7 +22,7 @@ import {
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from './shapes/shared/default-shape-constants'
import { TLUiToastsContextType } from './ui/context/toasts'
import { useTranslation } from './ui/hooks/useTranslation/useTranslation'
import { containBoxSize, downsizeImage, isGifAnimated } from './utils/assets/assets'
import { containBoxSize, downsizeImage } from './utils/assets/assets'
import { getEmbedInfo } from './utils/embeds/embeds'
import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text'
@ -32,9 +32,9 @@ export type TLExternalContentProps = {
maxImageDimension: number
// The maximum size (in bytes) of an asset. Assets larger than this will be rejected. Defaults to 10mb (10 * 1024 * 1024).
maxAssetSize: number
// The mime types of images that are allowed to be handled. Defaults to ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'].
// The mime types of images that are allowed to be handled. Defaults to DEFAULT_SUPPORTED_IMAGE_TYPES.
acceptedImageMimeTypes: readonly string[]
// The mime types of videos that are allowed to be handled. Defaults to ['video/mp4', 'video/webm', 'video/quicktime'].
// The mime types of videos that are allowed to be handled. Defaults to DEFAULT_SUPPORT_VIDEO_TYPES.
acceptedVideoMimeTypes: readonly string[]
}
@ -70,19 +70,19 @@ export function registerDefaultExternalContentHandlers(
? await MediaHelpers.getImageSize(file)
: await MediaHelpers.getVideoSize(file)
const isAnimated = file.type === 'image/gif' ? await isGifAnimated(file) : isVideoType
const isAnimated = (await MediaHelpers.isAnimated(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')) {
if (size !== resizedSize && MediaHelpers.isStaticImageType(file.type)) {
size = resizedSize
}
}
// Always rescale the image
if (file.type === 'image/jpeg' || file.type === 'image/png') {
if (MediaHelpers.isStaticImageType(file.type)) {
file = await downsizeImage(file, size.w, size.h, {
type: file.type,
quality: 0.92,

Wyświetl plik

@ -3,6 +3,7 @@ import {
BaseBoxShapeUtil,
FileHelpers,
HTMLContainer,
MediaHelpers,
TLImageShape,
TLOnDoubleClickHandler,
TLShapePartial,
@ -53,7 +54,11 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
useEffect(() => {
if (asset?.props.src && 'mimeType' in asset.props && asset?.props.mimeType === 'image/gif') {
if (
asset?.props.src &&
'mimeType' in asset.props &&
MediaHelpers.isAnimatedImageType(asset?.props.mimeType)
) {
let cancelled = false
const url = asset.props.src
if (!url) return
@ -219,7 +224,9 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
if (!asset) return
const canPlay =
asset.props.src && 'mimeType' in asset.props && asset.props.mimeType === 'image/gif'
asset.props.src &&
'mimeType' in asset.props &&
MediaHelpers.isAnimatedImageType(asset.props.mimeType)
if (!canPlay) return

Wyświetl plik

@ -1,4 +1,4 @@
import { useEditor } from '@tldraw/editor'
import { DEFAULT_SUPPORTED_MEDIA_TYPE_LIST, useEditor } from '@tldraw/editor'
import { useCallback, useEffect, useRef } from 'react'
export function useInsertMedia() {
@ -8,7 +8,7 @@ export function useInsertMedia() {
useEffect(() => {
const input = window.document.createElement('input')
input.type = 'file'
input.accept = 'image/jpeg,image/png,image/gif,image/svg+xml,video/mp4,video/quicktime'
input.accept = DEFAULT_SUPPORTED_MEDIA_TYPE_LIST
input.multiple = true
inputRef.current = input
async function onchange(e: Event) {

Wyświetl plik

@ -1,6 +1,5 @@
import { MediaHelpers, assertExists } from '@tldraw/editor'
import { clampToBrowserMaxCanvasSize } from '../../shapes/shared/getBrowserCanvasMaxSize'
import { isAnimated } from './is-gif-animated'
type BoxWidthHeight = {
w: number
@ -91,13 +90,3 @@ export async function downsizeImage(
)
})
}
/** @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: Blob): Promise<boolean> {
return isAnimated(await file.arrayBuffer())
}

Wyświetl plik

@ -7,6 +7,7 @@ import {
exhaustiveSwitchError,
} from '@tldraw/editor'
import { clampToBrowserMaxCanvasSize } from '../../shapes/shared/getBrowserCanvasMaxSize'
import { TLExportType } from './exportAs'
/** @public */
export async function getSvgAsImage(
@ -143,7 +144,7 @@ export async function exportToBlob({
}: {
editor: Editor
ids: TLShapeId[]
format: 'svg' | 'png' | 'jpeg' | 'webp' | 'json'
format: TLExportType
opts?: Partial<TLSvgOptions>
}): Promise<Blob> {
switch (format) {
@ -185,7 +186,7 @@ const mimeTypeByFormat = {
export function exportToBlobPromise(
editor: Editor,
ids: TLShapeId[],
format: 'svg' | 'png' | 'jpeg' | 'webp' | 'json',
format: TLExportType,
opts = {} as Partial<TLSvgOptions>
): { blobPromise: Promise<Blob>; mimeType: string } {
return {

Wyświetl plik

@ -37,6 +37,15 @@ export function debounce<T extends unknown[], U>(callback: (...args: T) => Promi
// @public
export function dedupe<T>(input: T[], equals?: (a: any, b: any) => boolean): T[];
// @public (undocumented)
export const DEFAULT_SUPPORT_VIDEO_TYPES: readonly string[];
// @public (undocumented)
export const DEFAULT_SUPPORTED_IMAGE_TYPES: readonly string[];
// @public (undocumented)
export const DEFAULT_SUPPORTED_MEDIA_TYPE_LIST: string;
// @internal
export function deleteFromLocalStorage(key: string): void;
@ -195,6 +204,14 @@ export class MediaHelpers {
h: number;
w: number;
}>;
// (undocumented)
static isAnimated(file: Blob): Promise<boolean>;
// (undocumented)
static isAnimatedImageType(mimeType: null | string): boolean;
// (undocumented)
static isImageType(mimeType: string): boolean;
// (undocumented)
static isStaticImageType(mimeType: null | string): boolean;
static loadImage(src: string): Promise<HTMLImageElement>;
static loadVideo(src: string): Promise<HTMLVideoElement>;
// (undocumented)

Wyświetl plik

@ -24,7 +24,13 @@ export { noop, omitFromStackTrace, throttle } from './lib/function'
export { getHashForBuffer, getHashForObject, getHashForString, lns } from './lib/hash'
export { getFirstFromIterable } from './lib/iterable'
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
export { MediaHelpers } from './lib/media'
export {
DEFAULT_SUPPORTED_IMAGE_TYPES,
DEFAULT_SUPPORTED_MEDIA_TYPE_LIST,
DEFAULT_SUPPORT_VIDEO_TYPES,
MediaHelpers,
} from './lib/media/media'
export { PngHelpers } from './lib/media/png'
export { invLerp, lerp, modulate, rng } from './lib/number'
export {
areObjectsShallowEqual,
@ -38,7 +44,6 @@ export {
objectMapValues,
} from './lib/object'
export { measureAverageDuration, measureCbDuration, measureDuration } from './lib/perf'
export { PngHelpers } from './lib/png'
export { type IndexKey } from './lib/reordering/IndexKey'
export {
ZERO_INDEX_KEY,

Wyświetl plik

@ -0,0 +1,149 @@
/*!
* MIT License: https://github.com/vHeemstra/is-apng/blob/main/license
* Copyright (c) Philip van Heemstra
*/
export function isApng(buffer: Uint8Array): boolean {
if (
!buffer ||
!((typeof Buffer !== 'undefined' && Buffer.isBuffer(buffer)) || buffer instanceof Uint8Array) ||
buffer.length < 16
) {
return false
}
const isPNG =
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a
if (!isPNG) {
return false
}
/**
* Returns the index of the first occurrence of a sequence in an typed array, or -1 if it is not present.
*
* Works similar to `Array.prototype.indexOf()`, but it searches for a sequence of array values (bytes).
* The bytes in the `haystack` array are decoded (UTF-8) and then used to search for `needle`.
*
* @param haystack `Uint8Array`
* Array to search in.
*
* @param needle `string | RegExp`
* The value to locate in the array.
*
* @param fromIndex `number`
* The array index at which to begin the search.
*
* @param upToIndex `number`
* The array index up to which to search.
* If omitted, search until the end.
*
* @param chunksize `number`
* Size of the chunks used when searching (default 1024).
*
* @returns boolean
* Whether the array holds Animated PNG data.
*/
function indexOfSubstring(
haystack: Uint8Array,
needle: string | RegExp,
fromIndex: number,
upToIndex?: number,
chunksize = 1024 /* Bytes */
) {
/**
* Adopted from: https://stackoverflow.com/a/67771214/2142071
*/
if (!needle) {
return -1
}
needle = new RegExp(needle, 'g')
// The needle could get split over two chunks.
// So, at every chunk we prepend the last few characters
// of the last chunk.
const needle_length = needle.source.length
const decoder = new TextDecoder()
// Handle search offset in line with
// `Array.prototype.indexOf()` and `TypedArray.prototype.subarray()`.
const full_haystack_length = haystack.length
if (typeof upToIndex === 'undefined') {
upToIndex = full_haystack_length
}
if (fromIndex >= full_haystack_length || upToIndex <= 0 || fromIndex >= upToIndex) {
return -1
}
haystack = haystack.subarray(fromIndex, upToIndex)
let position = -1
let current_index = 0
let full_length = 0
let needle_buffer = ''
outer: while (current_index < haystack.length) {
const next_index = current_index + chunksize
// subarray doesn't copy
const chunk = haystack.subarray(current_index, next_index)
const decoded = decoder.decode(chunk, { stream: true })
const text = needle_buffer + decoded
let match: RegExpExecArray | null
let last_index = -1
while ((match = needle.exec(text)) !== null) {
last_index = match.index - needle_buffer.length
position = full_length + last_index
break outer
}
current_index = next_index
full_length += decoded.length
// Check that the buffer doesn't itself include the needle
// this would cause duplicate finds (we could also use a Set to avoid that).
const needle_index =
last_index > -1 ? last_index + needle_length : decoded.length - needle_length
needle_buffer = decoded.slice(needle_index)
}
// Correct for search offset.
if (position >= 0) {
position += fromIndex >= 0 ? fromIndex : full_haystack_length + fromIndex
}
return position
}
// APNGs have an animation control chunk ('acTL') preceding the IDATs.
// See: https://en.wikipedia.org/wiki/APNG#File_format
const arr = new Uint8Array(buffer)
const idatIdx = indexOfSubstring(arr, 'IDAT', 12)
if (idatIdx >= 12) {
const actlIdx = indexOfSubstring(arr, 'acTL', 8, idatIdx)
return actlIdx >= 8
}
return false
}
// globalThis.isApng = isApng
// (new TextEncoder()).encode('IDAT')
// Decimal: [73, 68, 65, 84]
// Hex: [0x49, 0x44, 0x41, 0x54]
// (new TextEncoder()).encode('acTL')
// Decimal: [97, 99, 84, 76]
// Hex: [0x61, 0x63, 0x54, 0x4C]
// const idatIdx = buffer.indexOf('IDAT')
// const actlIdx = buffer.indexOf('acTL')

Wyświetl plik

@ -0,0 +1,4 @@
export const isAvifAnimated = (buffer: ArrayBuffer) => {
const view = new Uint8Array(buffer)
return view[3] === 44
}

Wyświetl plik

@ -31,7 +31,7 @@ export function isGIF(buffer: ArrayBuffer): boolean {
*
* @public
*/
export function isAnimated(buffer: ArrayBuffer): boolean {
export function isGifAnimated(buffer: ArrayBuffer): boolean {
const view = new Uint8Array(buffer)
let hasColorTable, colorTableSize
let offset = 0

Wyświetl plik

@ -1,5 +1,40 @@
import { isApng } from './apng'
import { isAvifAnimated } from './avif'
import { isGifAnimated } from './gif'
import { PngHelpers } from './png'
/** @public */
export const DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES = Object.freeze(['image/svg+xml'])
/** @public */
export const DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES = Object.freeze([
'image/jpeg',
'image/png',
'image/webp',
])
/** @public */
export const DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES = Object.freeze([
'image/gif',
'image/apng',
'image/avif',
])
/** @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',
'video/webm',
'video/quicktime',
])
/** @public */
export const DEFAULT_SUPPORTED_MEDIA_TYPE_LIST = [
...DEFAULT_SUPPORTED_IMAGE_TYPES,
...DEFAULT_SUPPORT_VIDEO_TYPES,
].join(',')
/**
* Helpers for media
*
@ -86,6 +121,34 @@ export class MediaHelpers {
return { w: image.naturalWidth, h: image.naturalHeight }
}
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/apng') {
return isApng(new Uint8Array(await file.arrayBuffer()))
}
return false
}
static isAnimatedImageType(mimeType: string | null): boolean {
return DEFAULT_SUPPORTED_ANIMATED_IMAGE_TYPES.includes(mimeType || '')
}
static isStaticImageType(mimeType: string | null): boolean {
return DEFAULT_SUPPORTED_STATIC_IMAGE_TYPES.includes(mimeType || '')
}
static isImageType(mimeType: string): boolean {
return DEFAULT_SUPPORTED_IMAGE_TYPES.includes(mimeType)
}
static async usingObjectURL<T>(blob: Blob, fn: (url: string) => Promise<T>): Promise<T> {
const url = URL.createObjectURL(blob)
try {

Wyświetl plik

@ -43,7 +43,11 @@ if (typeof Int32Array !== 'undefined') {
TABLE = new Int32Array(TABLE)
}
// crc32, https://github.com/alexgorbatchev/crc/blob/master/src/calculators/crc32.ts
/*!
* MIT License: https://github.com/alexgorbatchev/crc/blob/master/LICENSE
* Copyright: 2014 Alex Gorbatchev
* Code: crc32, https://github.com/alexgorbatchev/crc/blob/master/src/calculators/crc32.ts
*/
const crc: CRCCalculator<Uint8Array> = (current, previous) => {
let crc = previous === 0 ? 0 : ~~previous! ^ -1