Rework images to support lazy loading and blurhash (web only)

This commit is contained in:
Zoe Roux 2023-08-31 01:51:12 +02:00
parent 22e136d9fd
commit 607b973dbd
No known key found for this signature in database
9 changed files with 568 additions and 222 deletions

View File

@ -12,6 +12,7 @@
"@gorhom/portal": "*",
"@material-symbols/svg-400": "*",
"@radix-ui/react-dropdown-menu": "*",
"blurhash": "*",
"expo-linear-gradient": "*",
"moti": "*",
"react": "*",
@ -28,6 +29,9 @@
"@radix-ui/react-dropdown-menu": {
"optional": true
},
"blurhash": {
"optional": true
},
"react-native-blurhash": {
"optional": true
},
@ -39,5 +43,8 @@
"@expo/html-elements": "^0.5.1",
"@tanstack/react-query": "^4.32.6",
"solito": "^4.0.1"
},
"optionalDependencies": {
"blurhash": "^2.0.5"
}
}

View File

@ -1,221 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { KyooImage } from "@kyoo/models";
import { ComponentType, ReactNode, useState } from "react";
import {
View,
ImageSourcePropType,
ImageStyle,
Platform,
ImageProps,
ViewProps,
ViewStyle,
} from "react-native";
import {Image as Img} from "expo-image"
import { percent, useYoshiki } from "yoshiki/native";
import { YoshikiStyle } from "yoshiki/dist/type";
import { Skeleton } from "./skeleton";
import { LinearGradient, LinearGradientProps } from "expo-linear-gradient";
import { alpha, ContrastArea } from "./themes";
type YoshikiEnhanced<Style> = Style extends any
? {
[key in keyof Style]: YoshikiStyle<Style[key]>;
}
: never;
type WithLoading<T> = (T & { isLoading?: boolean }) | (Partial<T> & { isLoading: true });
type Props = WithLoading<{
src?: KyooImage | null;
alt?: string;
quality: "low" | "medium" | "high"
}>;
type ImageLayout = YoshikiEnhanced<
| { width: ImageStyle["width"]; height: ImageStyle["height"] }
| { width: ImageStyle["width"]; aspectRatio: ImageStyle["aspectRatio"] }
| { height: ImageStyle["height"]; aspectRatio: ImageStyle["aspectRatio"] }
>;
export const Image = ({
src,
quality,
alt,
isLoading: forcedLoading = false,
layout,
...props
}: Props & { style?: ViewStyle } & { layout: ImageLayout }) => {
const { css } = useYoshiki();
return (
<Img
source={src?.[quality ?? "high"]}
placeholder={src?.blurhash}
accessibilityLabel={alt}
{...css([
layout,
// {
// // width: percent(100),
// // height: percent(100),
// // resizeMode: "cover",
// borderRadius: 6
// },
]) as any}
/>
);
// const [state, setState] = useState<"loading" | "errored" | "finished">(
// src ? "loading" : "errored",
// );
//
// // This could be done with a key but this makes the API easier to use.
// // This unsures that the state is resetted when the source change (useful for recycler lists.)
// const [oldSource, setOldSource] = useState(src);
// if (oldSource !== src) {
// setState("loading");
// setOldSource(src);
// }
//
// const border = { borderRadius: 6 } satisfies ViewStyle;
//
// if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
// if (!src || state === "errored")
// return <View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />;
//
// const nativeProps = Platform.select<Partial<ImageProps>>({
// web: {
// defaultSource: typeof src === "string" ? { uri: src } : Array.isArray(src) ? src[0] : src,
// },
// default: {},
// });
//
// return (
// <Skeleton variant="custom" show={state === "loading"} {...css([layout, border], props)}>
// <Img
// source={typeof src === "string" ? { uri: src } : src}
// accessibilityLabel={alt}
// onLoad={() => setState("finished")}
// onError={() => setState("errored")}
// {...nativeProps}
// {...css([
// {
// width: percent(100),
// height: percent(100),
// resizeMode: "cover",
// },
// ])}
// />
// </Skeleton>
// );
};
export const Poster = ({
alt,
isLoading = false,
layout,
...props
}: Props & { style?: ViewStyle } & {
layout: YoshikiEnhanced<{ width: ViewStyle["width"] } | { height: ViewStyle["height"] }>;
}) => (
<Image isLoading={isLoading} alt={alt} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />
);
export const ImageBackground = <AsProps = ViewProps,>({
src,
alt,
gradient = true,
as,
children,
containerStyle,
imageStyle,
isLoading,
...asProps
}: {
as?: ComponentType<AsProps>;
gradient?: Partial<LinearGradientProps> | boolean;
children: ReactNode;
containerStyle?: YoshikiEnhanced<ViewStyle>;
imageStyle?: YoshikiEnhanced<ImageStyle>;
} & AsProps &
Props) => {
const [isErrored, setErrored] = useState(false);
const nativeProps = Platform.select<Partial<ImageProps>>({
web: {
defaultSource: typeof src === "string" ? { uri: src! } : Array.isArray(src) ? src[0] : src!,
},
default: {},
});
const Container = as ?? View;
return (
<ContrastArea contrastText>
{({ css, theme }) => (
<Container {...(asProps as AsProps)}>
<View
{...css([
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: -1,
bg: (theme) => theme.background,
},
containerStyle,
])}
>
{src && !isErrored && (
<Img
source={typeof src === "string" ? { uri: src } : src}
accessibilityLabel={alt}
onError={() => setErrored(true)}
{...nativeProps}
{...css([
{ width: percent(100), height: percent(100), resizeMode: "cover" },
imageStyle,
])}
/>
)}
{gradient && (
<LinearGradient
start={{ x: 0, y: 0.25 }}
end={{ x: 0, y: 1 }}
colors={["transparent", alpha(theme.colors.black, 0.6)]}
{...css(
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
typeof gradient === "object" ? gradient : undefined,
)}
/>
)}
</View>
{children}
</Container>
)}
</ContrastArea>
);
};

View File

@ -0,0 +1,46 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { KyooImage } from "@kyoo/models";
import {
Image as Img,
ImageStyle,
} from "react-native";
import { YoshikiStyle } from "yoshiki/src/type";
export type YoshikiEnhanced<Style> = Style extends any
? {
[key in keyof Style]: YoshikiStyle<Style[key]>;
}
: never;
export type WithLoading<T> = (T & { isLoading?: boolean }) | (Partial<T> & { isLoading: true });
export type Props = WithLoading<{
src?: KyooImage | null;
alt: string;
quality: "low" | "medium" | "high";
}>;
export type ImageLayout = YoshikiEnhanced<
| { width: ImageStyle["width"]; height: ImageStyle["height"] }
| { width: ImageStyle["width"]; aspectRatio: ImageStyle["aspectRatio"] }
| { height: ImageStyle["height"]; aspectRatio: ImageStyle["aspectRatio"] }
>;

View File

@ -0,0 +1,188 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { decode } from "blurhash";
// The blurhashToUrl has been stolen from https://gist.github.com/mattiaz9/53cb67040fa135cb395b1d015a200aff
export function blurHashToDataURL(hash: string | undefined): string | undefined {
if (!hash) return undefined;
const pixels = decode(hash, 32, 32);
const dataURL = parsePixels(pixels, 32, 32);
return dataURL;
}
// thanks to https://github.com/wheany/js-png-encoder
function parsePixels(pixels: Uint8ClampedArray, width: number, height: number) {
const pixelsString = Array.from(pixels)
.map((byte) => String.fromCharCode(byte))
.join("");
const pngString = generatePng(width, height, pixelsString);
const dataURL =
typeof Buffer !== "undefined"
? Buffer.from(getPngArray(pngString)).toString("base64")
: btoa(pngString);
return "data:image/png;base64," + dataURL;
}
function getPngArray(pngString: string) {
const pngArray = new Uint8Array(pngString.length);
for (let i = 0; i < pngString.length; i++) {
pngArray[i] = pngString.charCodeAt(i);
}
return pngArray;
}
function generatePng(width: number, height: number, rgbaString: string) {
const DEFLATE_METHOD = String.fromCharCode(0x78, 0x01);
const CRC_TABLE: number[] = [];
const SIGNATURE = String.fromCharCode(137, 80, 78, 71, 13, 10, 26, 10);
const NO_FILTER = String.fromCharCode(0);
let n, c, k;
// make crc table
for (n = 0; n < 256; n++) {
c = n;
for (k = 0; k < 8; k++) {
if (c & 1) {
c = 0xedb88320 ^ (c >>> 1);
} else {
c = c >>> 1;
}
}
CRC_TABLE[n] = c;
}
// Functions
function inflateStore(data: string) {
const MAX_STORE_LENGTH = 65535;
let storeBuffer = "";
let remaining;
let blockType;
for (let i = 0; i < data.length; i += MAX_STORE_LENGTH) {
remaining = data.length - i;
blockType = "";
if (remaining <= MAX_STORE_LENGTH) {
blockType = String.fromCharCode(0x01);
} else {
remaining = MAX_STORE_LENGTH;
blockType = String.fromCharCode(0x00);
}
// little-endian
storeBuffer += blockType + String.fromCharCode(remaining & 0xff, (remaining & 0xff00) >>> 8);
storeBuffer += String.fromCharCode(~remaining & 0xff, (~remaining & 0xff00) >>> 8);
storeBuffer += data.substring(i, i + remaining);
}
return storeBuffer;
}
function adler32(data: string) {
let MOD_ADLER = 65521;
let a = 1;
let b = 0;
for (let i = 0; i < data.length; i++) {
a = (a + data.charCodeAt(i)) % MOD_ADLER;
b = (b + a) % MOD_ADLER;
}
return (b << 16) | a;
}
function updateCrc(crc: number, buf: string) {
let c = crc;
let b: number;
for (let n = 0; n < buf.length; n++) {
b = buf.charCodeAt(n);
c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
}
return c;
}
function crc(buf: string) {
return updateCrc(0xffffffff, buf) ^ 0xffffffff;
}
function dwordAsString(dword: number) {
return String.fromCharCode(
(dword & 0xff000000) >>> 24,
(dword & 0x00ff0000) >>> 16,
(dword & 0x0000ff00) >>> 8,
dword & 0x000000ff,
);
}
function createChunk(length: number, type: string, data: string) {
const CRC = crc(type + data);
return dwordAsString(length) + type + data + dwordAsString(CRC);
}
function createIHDR(width: number, height: number) {
const IHDRdata =
dwordAsString(width) +
dwordAsString(height) +
// bit depth
String.fromCharCode(8) +
// color type: 6=truecolor with alpha
String.fromCharCode(6) +
// compression method: 0=deflate, only allowed value
String.fromCharCode(0) +
// filtering: 0=adaptive, only allowed value
String.fromCharCode(0) +
// interlacing: 0=none
String.fromCharCode(0);
return createChunk(13, "IHDR", IHDRdata);
}
// PNG creations
const IEND = createChunk(0, "IEND", "");
const IHDR = createIHDR(width, height);
let scanlines = "";
let scanline;
for (let y = 0; y < rgbaString.length; y += width * 4) {
scanline = NO_FILTER;
if (Array.isArray(rgbaString)) {
for (let x = 0; x < width * 4; x++) {
scanline += String.fromCharCode(rgbaString[y + x] & 0xff);
}
} else {
scanline += rgbaString.substr(y, width * 4);
}
scanlines += scanline;
}
const compressedScanlines =
DEFLATE_METHOD + inflateStore(scanlines) + dwordAsString(adler32(scanlines));
const IDAT = createChunk(compressedScanlines.length, "IDAT", compressedScanlines);
const pngString = SIGNATURE + IHDR + IDAT + IEND;
return pngString;
}

View File

@ -0,0 +1,85 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { useState } from "react";
import { ImageProps, ImageStyle, Platform, View, ViewStyle } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { YoshikiEnhanced, WithLoading, Props, ImageLayout } from "./base-image";
import { Skeleton } from "../skeleton";
export const Image = ({
src,
quality,
alt,
isLoading: forcedLoading = false,
layout,
...props
}: Props & { style?: ImageStyle } & { layout: ImageLayout }) => {
const { css } = useYoshiki();
const [state, setState] = useState<"loading" | "errored" | "finished">(
src ? "loading" : "errored",
);
// This could be done with a key but this makes the API easier to use.
// This unsures that the state is resetted when the source change (useful for recycler lists.)
const [oldSource, setOldSource] = useState(src);
if (oldSource !== src) {
setState("loading");
setOldSource(src);
}
const border = { borderRadius: 6 } satisfies ViewStyle;
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
if (!src || state === "errored")
return <View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />;
const nativeProps = Platform.select<Partial<ImageProps>>({
web: {
defaultSource: typeof src === "string" ? { uri: src } : Array.isArray(src) ? src[0] : src,
},
default: {},
});
return (
<View {...css(layout)}>
<Blurhash src={src.high} blurhash={src.blurhash} />
</View>
);
// return (
// <Skeleton variant="custom" show={state === "loading"} {...css([layout, border], props)}>
// <Img
// source={{ uri: src[quality || "high"] }}
// accessibilityLabel={alt}
// onLoad={() => setState("finished")}
// onError={() => setState("errored")}
// {...nativeProps}
// {...css([
// {
// width: percent(100),
// height: percent(100),
// resizeMode: "cover",
// },
// ])}
// />
// </Skeleton>
// );
};

View File

@ -0,0 +1,105 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { useLayoutEffect, useState } from "react";
import { ImageStyle, View, ViewStyle } from "react-native";
import { StyleList, processStyleList } from "yoshiki/src/type";
import { useYoshiki } from "yoshiki/native";
import { Props, ImageLayout } from "./base-image";
import { blurHashToDataURL } from "./blurhash-web";
import { Skeleton } from "../skeleton";
import NextImage from "next/image";
// Extract classnames from leftover props using yoshiki's internal.
const extractClassNames = <Style,>(props: {
style?: StyleList<{ $$css?: true; yoshiki?: string } | Style>;
}) => {
const inline = processStyleList(props.style);
return "$$css" in inline && inline.$$css ? inline.yoshiki : undefined;
};
export const Image = ({
src,
quality,
alt,
isLoading: forcedLoading = false,
layout,
...props
}: Props & { style?: ImageStyle } & { layout: ImageLayout }) => {
const { css } = useYoshiki();
const [state, setState] = useState<"loading" | "errored" | "finished">(
src ? "finished" : "errored",
);
useLayoutEffect(() => {
setState("loading");
}, []);
const border = { borderRadius: 6 } satisfies ViewStyle;
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
if (!src || state === "errored")
return <View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />;
const blurhash = blurHashToDataURL(src.blurhash);
return (
<div
style={{
// To reproduce view's behavior
position: "relative",
boxSizing: "border-box",
borderStyle: "solid",
overflow: "hidden",
// Use a blurhash here to nicely fade the NextImage when it is loaded completly
// (this prevents loading the image line by line which is ugly and buggy on firefox)
backgroundImage: `url(${blurhash})`,
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
backgroundPosition: "50% 50%",
// Use the layout's size and display the rounded border
width: (layout as any).width,
height: (layout as any).height,
aspectRatio: (layout as any).aspectRatio,
...border,
}}
// Gather classnames from props (to support parent's hover for example).
className={extractClassNames(props)}
>
<NextImage
src={src[quality ?? "high"]}
alt={alt!}
fill={true}
style={{
objectFit: "cover",
opacity: state === "loading" ? 0 : 1,
transition: "opacity .2s ease-out",
}}
blurDataURL={blurhash}
placeholder="blur"
// Don't use next's server to reprocess images, they are already optimized by kyoo.
unoptimized={true}
onLoadingComplete={() => setState("finished")}
onError={() => setState("errored")}
/>
</div>
);
};

View File

@ -0,0 +1,122 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { ImageProps, ImageStyle, Platform, View, ViewProps, ViewStyle } from "react-native";
import { Props, ImageLayout, YoshikiEnhanced } from "./base-image";
import { Image } from "./image";
import { ComponentType, ReactNode, useState } from "react";
import { LinearGradient, LinearGradientProps } from "expo-linear-gradient";
import { ContrastArea, alpha } from "../themes";
import { percent } from "yoshiki/native";
export { Image };
export const Poster = ({
alt,
isLoading = false,
layout,
...props
}: Props & { style?: ImageStyle } & {
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}) => (
<Image isLoading={isLoading} alt={alt} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />
);
export const ImageBackground = <AsProps = ViewProps,>({
src,
alt,
gradient = true,
as,
children,
containerStyle,
imageStyle,
isLoading,
...asProps
}: {
as?: ComponentType<AsProps>;
gradient?: Partial<LinearGradientProps> | boolean;
children: ReactNode;
containerStyle?: YoshikiEnhanced<ViewStyle>;
imageStyle?: YoshikiEnhanced<ImageStyle>;
} & AsProps &
Props) => {
const [isErrored, setErrored] = useState(false);
const nativeProps = Platform.select<Partial<ImageProps>>({
web: {
defaultSource: typeof src === "string" ? { uri: src! } : Array.isArray(src) ? src[0] : src!,
},
default: {},
});
const Container = as ?? View;
return (
<ContrastArea contrastText>
{({ css, theme }) => (
<Container {...(asProps as AsProps)}>
<View
{...css([
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: -1,
bg: (theme) => theme.background,
},
containerStyle,
])}
>
{src && !isErrored && (
<Image
source={typeof src === "string" ? { uri: src } : src}
accessibilityLabel={alt}
onError={() => setErrored(true)}
{...nativeProps}
{...css([
{ width: percent(100), height: percent(100), resizeMode: "cover" },
imageStyle,
])}
/>
)}
{gradient && (
<LinearGradient
start={{ x: 0, y: 0.25 }}
end={{ x: 0, y: 1 }}
colors={["transparent", alpha(theme.colors.black, 0.6)]}
{...css(
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
typeof gradient === "object" ? gradient : undefined,
)}
/>
)}
</View>
{children}
</Container>
)}
</ContrastArea>
);
};

View File

@ -73,7 +73,7 @@ const TitleLine = ({
isLoading: boolean;
slug: string;
name?: string;
tagline?: string;
tagline?: string | null;
date?: string | null;
poster?: KyooImage | null;
studio?: Studio | null;

View File

@ -2525,12 +2525,14 @@ __metadata:
"@gorhom/portal": ^1.0.14
"@tanstack/react-query": ^4.32.6
"@types/react": 18.2.0
blurhash: ^2.0.5
solito: ^4.0.1
typescript: ^5.1.6
peerDependencies:
"@gorhom/portal": "*"
"@material-symbols/svg-400": "*"
"@radix-ui/react-dropdown-menu": "*"
blurhash: "*"
expo-linear-gradient: "*"
moti: "*"
react: "*"
@ -2539,11 +2541,16 @@ __metadata:
react-native-reanimated: "*"
react-native-svg: "*"
yoshiki: "*"
dependenciesMeta:
blurhash:
optional: true
peerDependenciesMeta:
"@gorhom/portal":
optional: true
"@radix-ui/react-dropdown-menu":
optional: true
blurhash:
optional: true
react-native-blurhash:
optional: true
react-native-web:
@ -5123,6 +5130,13 @@ __metadata:
languageName: node
linkType: hard
"blurhash@npm:^2.0.5":
version: 2.0.5
resolution: "blurhash@npm:2.0.5"
checksum: aa4d6855bbaae116065b118a7b1e889648c15047e72048c28bab3db426a042ce1dc032a30c55a52da6140c314534841b984ab11cc68303668dde446d6ca53bc6
languageName: node
linkType: hard
"body-parser@npm:^1.20.1":
version: 1.20.2
resolution: "body-parser@npm:1.20.2"