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

As I started working on image LOD stuff and wrapping my head around the
codebase, this was bothering me.
- there are missing popular types, especially WebP
- there are places where we're copy/pasting the same list of types but
they can get out-of-date with each other (also, one place described
supporting webm but we didn't actually do that)

This adds animated apng/avif detection as well (alongside our animated
gif detection). Furthermore, it moves the gif logic to be alongside the
png logic (they were in separate packages unnecessarily)

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Release Notes

- Images: unify list of acceptable types and expand to include webp,
webm, apng, avif
This commit is contained in:
Mime Čuvalo 2024-05-13 09:29:43 +01:00 committed by GitHub
parent 142c27053b
commit d2d3e582e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 327 additions and 71 deletions

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { useState } from 'react' 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 anakin from './assets/anakin.jpeg'
import distractedBf from './assets/distracted-bf.jpeg' import distractedBf from './assets/distracted-bf.jpeg'
import expandingBrain from './assets/expanding-brain.png' import expandingBrain from './assets/expanding-brain.png'
@ -13,7 +13,7 @@ export function ImagePicker({
function onClickChooseImage() { function onClickChooseImage() {
const input = window.document.createElement('input') const input = window.document.createElement('input')
input.type = 'file' 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) => { input.addEventListener('change', async (e) => {
const fileList = (e.target as HTMLInputElement).files const fileList = (e.target as HTMLInputElement).files
if (!fileList || fileList.length === 0) return if (!fileList || fileList.length === 0) return

View file

@ -328,12 +328,6 @@ export function CutMenuItem(): JSX_2.Element;
// @public (undocumented) // @public (undocumented)
export function DebugFlags(): JSX_2.Element | null; 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) // @public (undocumented)
export const DefaultActionsMenu: NamedExoticComponent<TLUiActionsMenuProps>; export const DefaultActionsMenu: NamedExoticComponent<TLUiActionsMenuProps>;
@ -586,7 +580,7 @@ export function ExportFileContentSubMenu(): JSX_2.Element;
// @public // @public
export function exportToBlob({ editor, ids, format, opts, }: { export function exportToBlob({ editor, ids, format, opts, }: {
editor: Editor; editor: Editor;
format: 'jpeg' | 'json' | 'png' | 'svg' | 'webp'; format: TLExportType;
ids: TLShapeId[]; ids: TLShapeId[];
opts?: Partial<TLSvgOptions>; opts?: Partial<TLSvgOptions>;
}): Promise<Blob>; }): Promise<Blob>;
@ -951,6 +945,8 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
// (undocumented) // (undocumented)
indicator(shape: TLImageShape): JSX_2.Element | null; indicator(shape: TLImageShape): JSX_2.Element | null;
// (undocumented) // (undocumented)
isAnimated(shape: TLImageShape): boolean;
// (undocumented)
isAspectRatioLocked: () => boolean; isAspectRatioLocked: () => boolean;
// (undocumented) // (undocumented)
static migrations: TLPropsMigrations; static migrations: TLPropsMigrations;
@ -976,9 +972,6 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
static type: "image"; static type: "image";
} }
// @public (undocumented)
export function isGifAnimated(file: Blob): Promise<boolean>;
// @public (undocumented) // @public (undocumented)
export function KeyboardShortcutsMenuItem(): JSX_2.Element | null; export function KeyboardShortcutsMenuItem(): JSX_2.Element | null;

View file

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

View file

@ -1,4 +1,6 @@
import { import {
DEFAULT_SUPPORTED_IMAGE_TYPES,
DEFAULT_SUPPORT_VIDEO_TYPES,
Editor, Editor,
ErrorScreen, ErrorScreen,
Expand, 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. // We put these hooks into a component here so that they can run inside of the context provided by TldrawEditor and TldrawUi.
function InsideOfEditorAndUiContext({ function InsideOfEditorAndUiContext({
maxImageDimension = 1000, maxImageDimension = 1000,
maxAssetSize = 10 * 1024 * 1024, // 10mb maxAssetSize = 10 * 1024 * 1024, // 10mb
acceptedImageMimeTypes = defaultAcceptedImageMimeTypes, acceptedImageMimeTypes = DEFAULT_SUPPORTED_IMAGE_TYPES,
acceptedVideoMimeTypes = defaultAcceptedVideoMimeTypes, acceptedVideoMimeTypes = DEFAULT_SUPPORT_VIDEO_TYPES,
onMount, onMount,
}: Partial<TLExternalContentProps & { onMount: TLOnMountHandler }>) { }: Partial<TLExternalContentProps & { onMount: TLOnMountHandler }>) {
const editor = useEditor() const editor = useEditor()

View file

@ -22,7 +22,7 @@ import {
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 { TLUiToastsContextType } from './ui/context/toasts' import { TLUiToastsContextType } from './ui/context/toasts'
import { useTranslation } from './ui/hooks/useTranslation/useTranslation' 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 { getEmbedInfo } from './utils/embeds/embeds'
import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text' import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text'
@ -32,9 +32,9 @@ export type TLExternalContentProps = {
maxImageDimension: number maxImageDimension: number
// The maximum size (in bytes) of an asset. Assets larger than this will be rejected. Defaults to 10mb (10 * 1024 * 1024). // The maximum size (in bytes) of an asset. Assets larger than this will be rejected. Defaults to 10mb (10 * 1024 * 1024).
maxAssetSize: number 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[] 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[] acceptedVideoMimeTypes: readonly string[]
} }
@ -70,19 +70,19 @@ export function registerDefaultExternalContentHandlers(
? await MediaHelpers.getImageSize(file) ? await MediaHelpers.getImageSize(file)
: await MediaHelpers.getVideoSize(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()) const hash = await getHashForBuffer(await file.arrayBuffer())
if (isFinite(maxImageDimension)) { if (isFinite(maxImageDimension)) {
const resizedSize = containBoxSize(size, { w: maxImageDimension, h: 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 size = resizedSize
} }
} }
// Always rescale the image // Always rescale the image
if (file.type === 'image/jpeg' || file.type === 'image/png') { if (!isAnimated && MediaHelpers.isStaticImageType(file.type)) {
file = await downsizeImage(file, size.w, size.h, { file = await downsizeImage(file, size.w, size.h, {
type: file.type, type: file.type,
quality: 0.92, quality: 0.92,

View file

@ -3,6 +3,7 @@ import {
BaseBoxShapeUtil, BaseBoxShapeUtil,
FileHelpers, FileHelpers,
HTMLContainer, HTMLContainer,
MediaHelpers,
TLImageShape, TLImageShape,
TLOnDoubleClickHandler, TLOnDoubleClickHandler,
TLShapePartial, TLShapePartial,
@ -43,6 +44,17 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
} }
} }
isAnimated(shape: TLImageShape) {
const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : undefined
if (!asset) return false
return (
('mimeType' in asset.props && MediaHelpers.isAnimatedImageType(asset?.props.mimeType)) ||
('isAnimated' in asset.props && asset.props.isAnimated)
)
}
component(shape: TLImageShape) { component(shape: TLImageShape) {
const isCropping = this.editor.getCroppingShapeId() === shape.id const isCropping = this.editor.getCroppingShapeId() === shape.id
const prefersReducedMotion = usePrefersReducedMotion() const prefersReducedMotion = usePrefersReducedMotion()
@ -53,7 +65,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
const isSelected = shape.id === this.editor.getOnlySelectedShapeId() const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
useEffect(() => { useEffect(() => {
if (asset?.props.src && 'mimeType' in asset.props && asset?.props.mimeType === 'image/gif') { if (asset?.props.src && this.isAnimated(shape)) {
let cancelled = false let cancelled = false
const url = asset.props.src const url = asset.props.src
if (!url) return if (!url) return
@ -79,7 +91,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
cancelled = true cancelled = true
} }
} }
}, [prefersReducedMotion, asset?.props]) }, [prefersReducedMotion, asset?.props, shape])
if (asset?.type === 'bookmark') { if (asset?.type === 'bookmark') {
throw Error("Bookmark assets can't be rendered as images") throw Error("Bookmark assets can't be rendered as images")
@ -92,8 +104,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
// We only want to reduce motion for mimeTypes that have motion // We only want to reduce motion for mimeTypes that have motion
const reduceMotion = const reduceMotion =
prefersReducedMotion && prefersReducedMotion && (asset?.props.mimeType?.includes('video') || this.isAnimated(shape))
(asset?.props.mimeType?.includes('video') || asset?.props.mimeType?.includes('gif'))
const containerStyle = getCroppedContainerStyle(shape) const containerStyle = getCroppedContainerStyle(shape)
@ -151,7 +162,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
}} }}
draggable={false} draggable={false}
/> />
{asset.props.isAnimated && !shape.props.playing && ( {this.isAnimated(shape) && !shape.props.playing && (
<div className="tl-image__tg">GIF</div> <div className="tl-image__tg">GIF</div>
)} )}
</div> </div>
@ -218,8 +229,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
if (!asset) return if (!asset) return
const canPlay = const canPlay = asset.props.src && this.isAnimated(shape)
asset.props.src && 'mimeType' in asset.props && asset.props.mimeType === 'image/gif'
if (!canPlay) return if (!canPlay) return

View file

@ -133,7 +133,13 @@ export class Idle extends StateNode {
if (info.target === 'selection') { if (info.target === 'selection') {
util.onDoubleClickEdge?.(shape) util.onDoubleClickEdge?.(shape)
return
} }
// If the user double clicks the canvas, we want to cancel cropping,
// especially if it's an animated image, we want the image to continue playing.
this.cancel()
this.editor.root.handleEvent(info)
} }
override onKeyDown: TLEventHandlers['onKeyDown'] = () => { override onKeyDown: TLEventHandlers['onKeyDown'] = () => {

View file

@ -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' import { useCallback, useEffect, useRef } from 'react'
export function useInsertMedia() { export function useInsertMedia() {
@ -8,7 +8,7 @@ export function useInsertMedia() {
useEffect(() => { useEffect(() => {
const input = window.document.createElement('input') const input = window.document.createElement('input')
input.type = 'file' 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 input.multiple = true
inputRef.current = input inputRef.current = input
async function onchange(e: Event) { async function onchange(e: Event) {

View file

@ -1,6 +1,5 @@
import { MediaHelpers, assertExists } from '@tldraw/editor' import { MediaHelpers, assertExists } from '@tldraw/editor'
import { clampToBrowserMaxCanvasSize } from '../../shapes/shared/getBrowserCanvasMaxSize' import { clampToBrowserMaxCanvasSize } from '../../shapes/shared/getBrowserCanvasMaxSize'
import { isAnimated } from './is-gif-animated'
type BoxWidthHeight = { type BoxWidthHeight = {
w: number 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())
}

View file

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

View file

@ -37,6 +37,15 @@ export function debounce<T extends unknown[], U>(callback: (...args: T) => Promi
// @public // @public
export function dedupe<T>(input: T[], equals?: (a: any, b: any) => boolean): T[]; 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 // @internal
export function deleteFromLocalStorage(key: string): void; export function deleteFromLocalStorage(key: string): void;
@ -195,6 +204,14 @@ export class MediaHelpers {
h: number; h: number;
w: 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 loadImage(src: string): Promise<HTMLImageElement>;
static loadVideo(src: string): Promise<HTMLVideoElement>; static loadVideo(src: string): Promise<HTMLVideoElement>;
// (undocumented) // (undocumented)

View file

@ -25,7 +25,13 @@ export { noop, omitFromStackTrace, throttle } from './lib/function'
export { getHashForBuffer, 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 {
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 { invLerp, lerp, modulate, rng } from './lib/number'
export { export {
areObjectsShallowEqual, areObjectsShallowEqual,
@ -39,7 +45,6 @@ export {
objectMapValues, objectMapValues,
} from './lib/object' } from './lib/object'
export { measureAverageDuration, measureCbDuration, measureDuration } from './lib/perf' export { measureAverageDuration, measureCbDuration, measureDuration } from './lib/perf'
export { PngHelpers } from './lib/png'
export { type IndexKey } from './lib/reordering/IndexKey' export { type IndexKey } from './lib/reordering/IndexKey'
export { export {
ZERO_INDEX_KEY, ZERO_INDEX_KEY,

View file

@ -0,0 +1,150 @@
/*!
* MIT License: https://github.com/vHeemstra/is-apng/blob/main/license
* Copyright (c) Philip van Heemstra
*/
export function isApngAnimated(buffer: ArrayBuffer): boolean {
const view = new Uint8Array(buffer)
if (
!view ||
!((typeof Buffer !== 'undefined' && Buffer.isBuffer(view)) || view instanceof Uint8Array) ||
view.length < 16
) {
return false
}
const isPNG =
view[0] === 0x89 &&
view[1] === 0x50 &&
view[2] === 0x4e &&
view[3] === 0x47 &&
view[4] === 0x0d &&
view[5] === 0x0a &&
view[6] === 0x1a &&
view[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 idatIdx = indexOfSubstring(view, 'IDAT', 12)
if (idatIdx >= 12) {
const actlIdx = indexOfSubstring(view, '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')

View file

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

View file

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

View file

@ -1,4 +1,40 @@
import { isApngAnimated } from './apng'
import { isAvifAnimated } from './avif'
import { isGifAnimated } from './gif'
import { PngHelpers } from './png' import { PngHelpers } from './png'
import { isWebpAnimated } from './webp'
/** @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 * Helpers for media
@ -86,6 +122,38 @@ export class MediaHelpers {
return { w: image.naturalWidth, h: image.naturalHeight } 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/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 || '')
}
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> { static async usingObjectURL<T>(blob: Blob, fn: (url: string) => Promise<T>): Promise<T> {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
try { try {

View file

@ -43,7 +43,11 @@ if (typeof Int32Array !== 'undefined') {
TABLE = new Int32Array(TABLE) 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) => { const crc: CRCCalculator<Uint8Array> = (current, previous) => {
let crc = previous === 0 ? 0 : ~~previous! ^ -1 let crc = previous === 0 ? 0 : ~~previous! ^ -1

View file

@ -0,0 +1,25 @@
/*!
* MIT License: https://github.com/sindresorhus/is-webp/blob/main/license
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
*/
function isWebp(view: Uint8Array) {
if (!view || view.length < 12) {
return false
}
return view[8] === 87 && view[9] === 69 && view[10] === 66 && view[11] === 80
}
export function isWebpAnimated(buffer: ArrayBuffer) {
const view = new Uint8Array(buffer)
if (!isWebp(view)) {
return false
}
if (!view || view.length < 21) {
return false
}
return ((view[20] >> 1) & 1) === 1
}