Skip to content

Commit

Permalink
feat: 🎸 validate manifest schema
Browse files Browse the repository at this point in the history
  • Loading branch information
theashraf committed Aug 3, 2023
1 parent 027f090 commit 023a912
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 132 deletions.
3 changes: 2 additions & 1 deletion packages/dotlottie-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
235 changes: 176 additions & 59 deletions packages/dotlottie-js/src/common/dotlottie-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DotLottieUtils> {
if (!isValidURL(src)) {
throw new Error('Invalid URL provided');
Expand All @@ -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<DotLottieUtils> {
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<Unzipped> {
const unzipped = await new Promise<Unzipped>((resolve, reject) => {
const unzippedFile = await new Promise<Unzipped>((resolve, reject) => {
if (typeof this._dotLottie === 'undefined') {
reject(new Error('.lottie is not loaded.'));

Expand All @@ -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<Uint8Array> {
const unzipped = await this.unzip((file) => file.name === filepath);

Expand All @@ -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<Manifest> {
const manifestFileName = 'manifest.json';

Expand All @@ -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<string> {
const imageFileName = `images/${filename}`;

Expand All @@ -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<Record<string, string>> {
const unzippedImages = await this.unzip((file) => file.name.startsWith('images/') && filter(file));

const images: Record<string, string> = {};

// 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 } = {},
Expand All @@ -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<Record<string, AnimationData>> {
const animations: Record<string, AnimationData> = {};
/**
* 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<Record<string, AnimationData>> {
const animationsMap: Record<string, AnimationData> = {};

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<string> {
const themeFilename = `themes/${themeId}.lss`;

Expand All @@ -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<Record<string, string>> {
const themes: Record<string, string> = {};
const themesMap: Record<string, string> = {};

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<string, AnimationData>): Promise<void> {
const imagesMap = new Map<string, Set<string>>();

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;
}
}
}
}
}
}
}
Loading

0 comments on commit 023a912

Please sign in to comment.