From 023a912e9bc951e656486119f0275d073c78c0d9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ashraf Date: Thu, 3 Aug 2023 12:56:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20validate=20manifest=20sc?= =?UTF-8?q?hema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/dotlottie-js/package.json | 3 +- .../src/common/dotlottie-utils.ts | 235 +++++++++++++----- packages/dotlottie-js/src/common/manifest.ts | 111 +++------ pnpm-lock.yaml | 6 + 4 files changed, 223 insertions(+), 132 deletions(-) diff --git a/packages/dotlottie-js/package.json b/packages/dotlottie-js/package.json index bb0a445..b47cec9 100644 --- a/packages/dotlottie-js/package.json +++ b/packages/dotlottie-js/package.json @@ -60,7 +60,8 @@ "fflate": "0.7.4", "filenamify": "6.0.0", "sharp": "0.32.0", - "sharp-phash": "2.1.0" + "sharp-phash": "2.1.0", + "zod": "^3.21.4" }, "devDependencies": { "@types/jasmine": "4.3.2", diff --git a/packages/dotlottie-js/src/common/dotlottie-utils.ts b/packages/dotlottie-js/src/common/dotlottie-utils.ts index 0af2605..c49865c 100644 --- a/packages/dotlottie-js/src/common/dotlottie-utils.ts +++ b/packages/dotlottie-js/src/common/dotlottie-utils.ts @@ -2,43 +2,62 @@ * Copyright 2023 Design Barn Inc. */ -/* eslint-disable no-warning-comments */ +/* eslint-disable guard-for-in */ -import type { Animation as AnimationData } from '@lottiefiles/lottie-types'; +import type { Animation as AnimationData, Asset } from '@lottiefiles/lottie-types'; import type { UnzipFileFilter, Unzipped } from 'fflate'; import { unzip, strFromU8 } from 'fflate'; import type { Manifest } from './manifest'; +import { ManifestSchema } from './manifest'; import { isValidURL } from './utils'; -function dataUrlFromU8(byte: Uint8Array, mimetype: string): string { - if (typeof window === 'undefined') { - const base64 = Buffer.from(byte).toString('base64'); +/** + * Create a data URL from Uint8Array. + * @param byte - The Uint8Array byte. + * @param mimetype - The mimetype of the data. + * @returns The data URL string. + */ +export function dataUrlFromU8(byte: Uint8Array, mimetype: string): string { + const base64 = + typeof window === 'undefined' ? Buffer.from(byte).toString('base64') : window.btoa(new TextDecoder().decode(byte)); - return `data:${mimetype};base64,${base64}`; - } else { - const textDecoder = new TextDecoder(); - const str = textDecoder.decode(byte); + return `data:${mimetype};base64,${base64}`; +} - return `data:${mimetype};base64,${window.btoa(str)}`; - } +/** + * Check if an asset is an image asset. + * @param asset - The asset to check. + * @returns `true` if it's an image asset, `false` otherwise. + */ +export function isImageAsset(asset: Asset.Value): asset is Asset.Image { + return 'w' in asset && 'h' in asset && !('xt' in asset) && 'p' in asset; } export class DotLottieUtils { private _dotLottie: Uint8Array | undefined; - private constructor() { - // - } - + /** + * Get the dotLottie data. + * @returns The dotLottie data. + */ public get dotLottie(): Uint8Array | undefined { return this._dotLottie; } + /** + * Set the dotLottie data. + * @param dotLottie - The dotLottie data to set. + */ public set dotLottie(dotLottie: Uint8Array | undefined) { this._dotLottie = dotLottie; } + /** + * Load .lottie file from URL. + * @param src - The URL source. + * @returns Promise that resolves with an instance of DotLottieUtils. + */ public static async loadFromURL(src: string): Promise { if (!isValidURL(src)) { throw new Error('Invalid URL provided'); @@ -58,19 +77,39 @@ export class DotLottieUtils { instance.dotLottie = new Uint8Array(data); + if (!(await instance.isValidDotLottie())) { + throw new Error('Invalid dotLottie'); + } + return instance; } + /** + * Load .lottie file from ArrayBuffer. + * @param arrayBuffer - The ArrayBuffer. + * @returns Promise that resolves with an instance of DotLottieUtils. + */ public static async loadFromArrayBuffer(arrayBuffer: ArrayBuffer): Promise { const instance = new DotLottieUtils(); instance.dotLottie = new Uint8Array(arrayBuffer); + const { error, success } = await instance.validateDotLottie(); + + if (!success) { + throw new Error(error); + } + return instance; } + /** + * Unzip the .lottie file. + * @param filter - The filter function to apply to the files. + * @returns Promise that resolves with the unzipped data. + */ public async unzip(filter: UnzipFileFilter = (): boolean => true): Promise { - const unzipped = await new Promise((resolve, reject) => { + const unzippedFile = await new Promise((resolve, reject) => { if (typeof this._dotLottie === 'undefined') { reject(new Error('.lottie is not loaded.')); @@ -86,9 +125,14 @@ export class DotLottieUtils { }); }); - return unzipped; + return unzippedFile; } + /** + * Unzip a specific file from the .lottie file. + * @param filepath - The filepath to unzip. + * @returns Promise that resolves with the unzipped data. + */ private async _unzipFile(filepath: string): Promise { const unzipped = await this.unzip((file) => file.name === filepath); @@ -101,6 +145,28 @@ export class DotLottieUtils { return data; } + public async validateDotLottie(): Promise<{ error?: string; success: boolean }> { + if (!this._dotLottie) { + return { success: false, error: 'DotLottie not found' }; + } + + const manifest = this.getManifest(); + + const manifestValidationResult = ManifestSchema.safeParse(manifest); + + if (!manifestValidationResult.success) { + const error = manifestValidationResult.error.toString(); + + return { success: false, error }; + } + + return { success: true }; + } + + /** + * Get the manifest data from the .lottie file. + * @returns Promise that resolves with the manifest data. + */ public async getManifest(): Promise { const manifestFileName = 'manifest.json'; @@ -109,6 +175,11 @@ export class DotLottieUtils { return JSON.parse(strFromU8(unzippedManifest, false)) as Manifest; } + /** + * Get an image from the .lottie file. + * @param filename - The filename of the image to get. + * @returns Promise that resolves with the image data. + */ public async getImage(filename: string): Promise { const imageFileName = `images/${filename}`; @@ -117,25 +188,37 @@ export class DotLottieUtils { return strFromU8(unzippedImage, false); } + /** + * Get all images from the .lottie file. + * @param filter - The filter function to apply to the files. + * @returns Promise that resolves with the images data. + */ public async getImages(filter: UnzipFileFilter = (): boolean => true): Promise> { const unzippedImages = await this.unzip((file) => file.name.startsWith('images/') && filter(file)); const images: Record = {}; - // eslint-disable-next-line guard-for-in for (const imagePath in unzippedImages) { const data = unzippedImages[imagePath]; if (data instanceof Uint8Array) { const imageId = imagePath.replace('images/', ''); - images[imageId] = strFromU8(data, false); + const imageExtension = imagePath.split('.').pop() || 'png'; + + images[imageId] = dataUrlFromU8(data, `image/${imageExtension}`); } } return images; } + /** + * Get an animation from the .lottie file. + * @param animationId - The animation ID to get. + * @param inlineAssets - Options for inlining assets. + * @returns Promise that resolves with the animation data. + */ public async getAnimation( animationId: string, { inlineAssets }: { inlineAssets?: boolean } = {}, @@ -150,61 +233,53 @@ export class DotLottieUtils { return animationData; } - const imagesFilenames: string[] = []; - - for (const asset of animationData.assets || []) { - if ('w' in asset && 'h' in asset && !('xt' in asset) && 'p' in asset) { - imagesFilenames.push(`images/${asset.p}`); - } - } - - const unzippedImageAssets = await this.unzip((file) => imagesFilenames.includes(file.name)); - - for (const asset of animationData.assets || []) { - if ('w' in asset && 'h' in asset && !('xt' in asset) && 'p' in asset) { - const imageData = unzippedImageAssets[`images/${asset.p}`]; - - if (imageData instanceof Uint8Array) { - const imageExtension = asset.p.split('.').pop() || 'png'; - const imageDataURL = dataUrlFromU8(imageData, `image/${imageExtension}`); - - asset.p = imageDataURL; - asset.u = ''; - asset.e = 1; - } - } - } + await this.inlineImageAssets({ animationId: animationData }); return animationData; } - public async getAnimations(filter: UnzipFileFilter = (): boolean => true): Promise> { - const animations: Record = {}; + /** + * Gets multiple animations from the .lottie file, optionally filtered by a provided function. + * Allows for optionally inlining assets within the animations. + * + * @param filter - Optional filter function to apply when retrieving animations + * @param options - An object containing an optional `inlineAssets` boolean. If true, assets are inlined within the animations + * @returns A record containing the animations data, keyed by animation ID + */ + public async getAnimations( + filter: UnzipFileFilter = (): boolean => true, + { inlineAssets }: { inlineAssets?: boolean } = {}, + ): Promise> { + const animationsMap: Record = {}; const unzippedAnimations = await this.unzip((file) => file.name.startsWith('animations/') && filter(file)); - // eslint-disable-next-line guard-for-in for (const animationPath in unzippedAnimations) { const data = unzippedAnimations[animationPath]; if (data instanceof Uint8Array) { const animationId = animationPath.replace('animations/', '').replace('.json', ''); - animations[animationId] = JSON.parse(strFromU8(data, false)) as AnimationData; + const animationData = JSON.parse(strFromU8(data, false)) as AnimationData; + + animationsMap[animationId] = animationData; } } - /* - TODO: inlining assets - TODO: - - search for image assets - - decompress image assets - - inline decompressed image assets - */ + if (!inlineAssets) { + return animationsMap; + } + + await this.inlineImageAssets(animationsMap); - return animations; + return animationsMap; } + /** + * Gets a specific theme from the .lottie file by its ID + * @param themeId - The ID of the theme to get + * @returns The theme data as a string + */ public async getTheme(themeId: string): Promise { const themeFilename = `themes/${themeId}.lss`; @@ -213,22 +288,64 @@ export class DotLottieUtils { return strFromU8(unzippedTheme, false); } + /** + * Gets multiple themes from the .lottie file, optionally filtered by a provided function + * @param filter - Optional filter function to apply when retrieving themes + * @returns A record containing the theme data, keyed by theme ID + */ public async getThemes(filter: UnzipFileFilter = (): boolean => true): Promise> { - const themes: Record = {}; + const themesMap: Record = {}; const unzippedThemes = await this.unzip((file) => file.name.startsWith('themes/') && filter(file)); - // eslint-disable-next-line guard-for-in for (const themePath in unzippedThemes) { const data = unzippedThemes[themePath]; if (data instanceof Uint8Array) { const themeId = themePath.replace('themes/', '').replace('.lss', ''); - themes[themeId] = strFromU8(data, false); + themesMap[themeId] = strFromU8(data, false); + } + } + + return themesMap; + } + + public async inlineImageAssets(animations: Record): Promise { + const imagesMap = new Map>(); + + for (const [animationId, animationData] of Object.entries(animations)) { + for (const asset of animationData.assets || []) { + if (isImageAsset(asset)) { + const imageId = asset.p; + + if (!imagesMap.has(imageId)) { + imagesMap.set(imageId, new Set()); + } + + imagesMap.get(imageId)?.add(animationId); + } } } - return themes; + const unzippedImages = await this.getImages((file) => imagesMap.has(file.name)); + + for (const [imageId, animationIdsSet] of imagesMap) { + const imageDataURL = unzippedImages[`images/${imageId}`]; + + if (imageDataURL) { + for (const animationId of animationIdsSet) { + const animationData = animations[animationId]; + + for (const asset of animationData?.assets || []) { + if (isImageAsset(asset) && asset.p === imageId) { + asset.p = imageDataURL; + asset.u = ''; + asset.e = 1; + } + } + } + } + } } } diff --git a/packages/dotlottie-js/src/common/manifest.ts b/packages/dotlottie-js/src/common/manifest.ts index 3c6d992..7a0447c 100644 --- a/packages/dotlottie-js/src/common/manifest.ts +++ b/packages/dotlottie-js/src/common/manifest.ts @@ -2,75 +2,42 @@ * Copyright 2023 Design Barn Inc. */ -export enum PlayMode { - Bounce = 'bounce', - Normal = 'normal', -} - -export interface ManifestAnimation { - autoplay?: boolean; - - // default theme id - defaultTheme?: string; - - // Define playback direction 1 forward, -1 backward - direction?: number; - - // Play on hover - hover?: boolean; - - id: string; - - // Time to wait between loops in milliseconds - intermission?: number; - - loop?: boolean | number; - - // Choice between 'bounce' and 'normal' - playMode?: PlayMode; - - // Desired playback speed, default 1.0 - speed?: number; - - // Theme color - themeColor?: string; -} - -export interface ManifestTheme { - // scoped animations ids - animations: string[]; - - id: string; -} - -export interface Manifest { - // Default animation to play - activeAnimationId?: string; - - // List of animations - animations: ManifestAnimation[]; - - // Name of the author - author?: string | undefined; - - // Custom data to be made available to the player and animations - custom?: Record; - - // Description of the animation - description?: string | undefined; - - // Name and version of the software that created the dotLottie - generator?: string | undefined; - - // Description of the animation - keywords?: string | undefined; - - // Revision version number of the dotLottie - revision?: number | undefined; - - // List of themes - themes?: ManifestTheme[]; - - // Target dotLottie version - version?: string | undefined; -} +import { z } from 'zod'; + +export const PlayModeSchema = z.union([z.literal('bounce'), z.literal('normal')]); + +export type PlayMode = z.infer; + +export const ManifestAnimationSchema = z.object({ + autoplay: z.boolean().optional(), + defaultTheme: z.string().optional(), + direction: z.number().optional(), + hover: z.boolean().optional(), + id: z.string(), + intermission: z.number().optional(), + loop: z.union([z.boolean(), z.number()]).optional(), + playMode: PlayModeSchema.optional(), + speed: z.number().optional(), + themeColor: z.string().optional(), +}); +export type ManifestAnimation = z.infer; + +export const ManifestThemeSchema = z.object({ + animations: z.array(z.string()), + id: z.string(), +}); +export type ManifestTheme = z.infer; + +export const ManifestSchema = z.object({ + activeAnimationId: z.string().optional(), + animations: z.array(ManifestAnimationSchema), + author: z.string().optional(), + custom: z.record(z.unknown()).optional(), + description: z.string().optional(), + generator: z.string().optional(), + keywords: z.string().optional(), + revision: z.number().optional(), + themes: z.array(ManifestThemeSchema).optional(), + version: z.string().optional(), +}); +export type Manifest = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0435ff3..e0f46ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,7 @@ importers: sharp-phash: 2.1.0 tsup: 6.1.3 typescript: 4.7.4 + zod: ^3.21.4 dependencies: '@lottiefiles/lottie-types': 1.1.0 browser-image-hash: 0.0.5 @@ -168,6 +169,7 @@ importers: filenamify: 6.0.0 sharp: 0.32.0 sharp-phash: 2.1.0_sharp@0.32.0 + zod: 3.21.4 devDependencies: '@types/jasmine': 4.3.2 '@types/node': 18.0.6 @@ -17649,6 +17651,10 @@ packages: resolution: {integrity: sha512-LZRucWt4j/ru5azOkJxCfpR87IyFDn8h2UODdqvXzZLb3K7bb9chUrUIGTy3BPsr8XnbQYfQ5Md5Hu2OYIo1mg==} dev: true + /zod/3.21.4: + resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} + dev: false + /zwitch/2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: true