diff --git a/web/apps/cast/src/services/pair.ts b/web/apps/cast/src/services/pair.ts index 6262ed5ec..36b54cf75 100644 --- a/web/apps/cast/src/services/pair.ts +++ b/web/apps/cast/src/services/pair.ts @@ -1,7 +1,7 @@ import log from "@/next/log"; +import { wait } from "@/utils/promise"; import { boxSealOpen, toB64 } from "@ente/shared/crypto/internal/libsodium"; import castGateway from "@ente/shared/network/cast"; -import { wait } from "@ente/shared/utils"; import _sodium from "libsodium-wrappers"; export interface Registration { diff --git a/web/apps/cast/src/services/render.ts b/web/apps/cast/src/services/render.ts index 419b9cd49..c9ba05d69 100644 --- a/web/apps/cast/src/services/render.ts +++ b/web/apps/cast/src/services/render.ts @@ -5,10 +5,10 @@ import { nameAndExtension } from "@/next/file"; import log from "@/next/log"; import { shuffled } from "@/utils/array"; import { ensure } from "@/utils/ensure"; +import { wait } from "@/utils/promise"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import HTTPService from "@ente/shared/network/HTTPService"; import { getCastFileURL, getEndpoint } from "@ente/shared/network/api"; -import { wait } from "@ente/shared/utils"; import type { CastData } from "services/cast-data"; import { detectMediaMIMEType } from "services/detect-type"; import { diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index 786932ff8..3a68837e7 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -3,12 +3,12 @@ import { decodeLivePhoto } from "@/media/live-photo"; import type { Metadata } from "@/media/types/file"; import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; +import { wait } from "@/utils/promise"; import { CustomError } from "@ente/shared/error"; import { Events, eventBus } from "@ente/shared/events"; import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; import { formatDateTimeShort } from "@ente/shared/time/format"; import { User } from "@ente/shared/user/types"; -import { wait } from "@ente/shared/utils"; import QueueProcessor, { CancellationStatus, RequestCanceller, diff --git a/web/apps/photos/src/services/export/migration.ts b/web/apps/photos/src/services/export/migration.ts index 9404ddde5..0c8de03e6 100644 --- a/web/apps/photos/src/services/export/migration.ts +++ b/web/apps/photos/src/services/export/migration.ts @@ -3,9 +3,9 @@ import { decodeLivePhoto } from "@/media/live-photo"; import { ensureElectron } from "@/next/electron"; import { nameAndExtension } from "@/next/file"; import log from "@/next/log"; +import { wait } from "@/utils/promise"; import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; import { User } from "@ente/shared/user/types"; -import { wait } from "@ente/shared/utils"; import { getLocalCollections } from "services/collectionService"; import downloadManager from "services/download"; import { getAllLocalFiles } from "services/fileService"; diff --git a/web/apps/photos/src/services/upload/thumbnail.ts b/web/apps/photos/src/services/upload/thumbnail.ts index 1dd448376..70ba450ec 100644 --- a/web/apps/photos/src/services/upload/thumbnail.ts +++ b/web/apps/photos/src/services/upload/thumbnail.ts @@ -1,7 +1,10 @@ import { FILE_TYPE, type FileTypeInfo } from "@/media/file-type"; +import { + generateImageThumbnailUsingCanvas, + generateVideoThumbnailUsingCanvas, +} from "@/media/image"; import log from "@/next/log"; import { type Electron } from "@/next/types/ipc"; -import { withTimeout } from "@ente/shared/utils"; import * as ffmpeg from "services/ffmpeg"; import { heicToJPEG } from "services/heic-convert"; import { toDataOrPathOrZipEntry, type DesktopUploadItem } from "./types"; @@ -30,10 +33,10 @@ export const generateThumbnailWeb = async ( fileTypeInfo: FileTypeInfo, ): Promise => fileTypeInfo.fileType === FILE_TYPE.IMAGE - ? await generateImageThumbnailUsingCanvas(blob, fileTypeInfo) + ? await generateImageThumbnailWeb(blob, fileTypeInfo) : await generateVideoThumbnailWeb(blob); -const generateImageThumbnailUsingCanvas = async ( +const generateImageThumbnailWeb = async ( blob: Blob, { extension }: FileTypeInfo, ) => { @@ -42,35 +45,7 @@ const generateImageThumbnailUsingCanvas = async ( blob = await heicToJPEG(blob); } - const canvas = document.createElement("canvas"); - const canvasCtx = canvas.getContext("2d"); - - const imageURL = URL.createObjectURL(blob); - await withTimeout( - new Promise((resolve, reject) => { - const image = new Image(); - image.setAttribute("src", imageURL); - image.onload = () => { - try { - URL.revokeObjectURL(imageURL); - const { width, height } = scaledThumbnailDimensions( - image.width, - image.height, - maxThumbnailDimension, - ); - canvas.width = width; - canvas.height = height; - canvasCtx.drawImage(image, 0, 0, width, height); - resolve(undefined); - } catch (e) { - reject(e); - } - }; - }), - 30 * 1000, - ); - - return await compressedJPEGData(canvas); + return generateImageThumbnailUsingCanvas(blob); }; const generateVideoThumbnailWeb = async (blob: Blob) => { @@ -85,92 +60,6 @@ const generateVideoThumbnailWeb = async (blob: Blob) => { } }; -const generateVideoThumbnailUsingCanvas = async (blob: Blob) => { - const canvas = document.createElement("canvas"); - const canvasCtx = canvas.getContext("2d"); - - const videoURL = URL.createObjectURL(blob); - await withTimeout( - new Promise((resolve, reject) => { - const video = document.createElement("video"); - video.preload = "metadata"; - video.src = videoURL; - video.addEventListener("loadeddata", () => { - try { - URL.revokeObjectURL(videoURL); - const { width, height } = scaledThumbnailDimensions( - video.videoWidth, - video.videoHeight, - maxThumbnailDimension, - ); - canvas.width = width; - canvas.height = height; - canvasCtx.drawImage(video, 0, 0, width, height); - resolve(undefined); - } catch (e) { - reject(e); - } - }); - }), - 30 * 1000, - ); - - return await compressedJPEGData(canvas); -}; - -/** - * Compute the size of the thumbnail to create for an image with the given - * {@link width} and {@link height}. - * - * This function calculates a new size of an image for limiting it to maximum - * width and height (both specified by {@link maxDimension}), while maintaining - * aspect ratio. - * - * It returns `{0, 0}` for invalid inputs. - */ -const scaledThumbnailDimensions = ( - width: number, - height: number, - maxDimension: number, -): { width: number; height: number } => { - if (width === 0 || height === 0) return { width: 0, height: 0 }; - const widthScaleFactor = maxDimension / width; - const heightScaleFactor = maxDimension / height; - const scaleFactor = Math.min(widthScaleFactor, heightScaleFactor); - const thumbnailDimensions = { - width: Math.round(width * scaleFactor), - height: Math.round(height * scaleFactor), - }; - if (thumbnailDimensions.width === 0 || thumbnailDimensions.height === 0) - return { width: 0, height: 0 }; - return thumbnailDimensions; -}; - -const compressedJPEGData = async (canvas: HTMLCanvasElement) => { - let blob: Blob; - let prevSize = Number.MAX_SAFE_INTEGER; - let quality = 0.7; - - do { - if (blob) prevSize = blob.size; - blob = await new Promise((resolve) => { - canvas.toBlob((blob) => resolve(blob), "image/jpeg", quality); - }); - quality -= 0.1; - } while ( - quality >= 0.5 && - blob.size > maxThumbnailSize && - percentageSizeDiff(blob.size, prevSize) >= 10 - ); - - return new Uint8Array(await blob.arrayBuffer()); -}; - -const percentageSizeDiff = ( - newThumbnailSize: number, - oldThumbnailSize: number, -) => ((oldThumbnailSize - newThumbnailSize) * 100) / oldThumbnailSize; - /** * Generate a JPEG thumbnail for the given file or path using native tools. * diff --git a/web/apps/photos/src/services/upload/uploadHttpClient.ts b/web/apps/photos/src/services/upload/uploadHttpClient.ts index e8ae6de97..c23a58b52 100644 --- a/web/apps/photos/src/services/upload/uploadHttpClient.ts +++ b/web/apps/photos/src/services/upload/uploadHttpClient.ts @@ -1,9 +1,9 @@ import log from "@/next/log"; +import { wait } from "@/utils/promise"; import { CustomError, handleUploadError } from "@ente/shared/error"; import HTTPService from "@ente/shared/network/HTTPService"; import { getEndpoint, getUploadEndpoint } from "@ente/shared/network/api"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; -import { wait } from "@ente/shared/utils"; import { EnteFile } from "types/file"; import { MultipartUploadURLs, UploadFile, UploadURL } from "./uploadService"; diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 38fd7037b..0ab9ecff0 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -6,11 +6,11 @@ import log from "@/next/log"; import type { Electron } from "@/next/types/ipc"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { ensure } from "@/utils/ensure"; +import { wait } from "@/utils/promise"; import { getDedicatedCryptoWorker } from "@ente/shared/crypto"; import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker"; import { CustomError } from "@ente/shared/error"; import { Events, eventBus } from "@ente/shared/events"; -import { wait } from "@ente/shared/utils"; import { Canceler } from "axios"; import { Remote } from "comlink"; import { diff --git a/web/packages/accounts/components/ChangeEmail.tsx b/web/packages/accounts/components/ChangeEmail.tsx index ec647e671..0b175344b 100644 --- a/web/packages/accounts/components/ChangeEmail.tsx +++ b/web/packages/accounts/components/ChangeEmail.tsx @@ -1,3 +1,4 @@ +import { wait } from "@/utils/promise"; import { changeEmail, sendOTTForEmailChange } from "@ente/accounts/api/user"; import { APP_HOMES } from "@ente/shared/apps/constants"; import { PageProps } from "@ente/shared/apps/types"; @@ -6,7 +7,6 @@ import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer"; import LinkButton from "@ente/shared/components/LinkButton"; import SubmitButton from "@ente/shared/components/SubmitButton"; import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; -import { wait } from "@ente/shared/utils"; import { Alert, Box, TextField } from "@mui/material"; import { Formik, FormikHelpers } from "formik"; import { t } from "i18next"; diff --git a/web/packages/accounts/components/two-factor/VerifyForm.tsx b/web/packages/accounts/components/two-factor/VerifyForm.tsx index b7f7fc278..76fd87ba0 100644 --- a/web/packages/accounts/components/two-factor/VerifyForm.tsx +++ b/web/packages/accounts/components/two-factor/VerifyForm.tsx @@ -1,16 +1,15 @@ -import { Formik, FormikHelpers } from "formik"; -import { t } from "i18next"; -import { useRef, useState } from "react"; -import OtpInput from "react-otp-input"; - +import { wait } from "@/utils/promise"; import InvalidInputMessage from "@ente/accounts/components/two-factor/InvalidInputMessage"; import { CenteredFlex, VerticallyCentered, } from "@ente/shared/components/Container"; import SubmitButton from "@ente/shared/components/SubmitButton"; -import { wait } from "@ente/shared/utils"; import { Box, Typography } from "@mui/material"; +import { Formik, FormikHelpers } from "formik"; +import { t } from "i18next"; +import { useRef, useState } from "react"; +import OtpInput from "react-otp-input"; interface formValues { otp: string; diff --git a/web/packages/media/image.ts b/web/packages/media/image.ts new file mode 100644 index 000000000..8be284bc9 --- /dev/null +++ b/web/packages/media/image.ts @@ -0,0 +1,128 @@ +import { ensure } from "@/utils/ensure"; +import { withTimeout } from "@/utils/promise"; + +/** Maximum width or height of the generated thumbnail */ +const maxThumbnailDimension = 720; +/** Maximum size (in bytes) of the generated thumbnail */ +const maxThumbnailSize = 100 * 1024; // 100 KB + +export const generateImageThumbnailUsingCanvas = async (blob: Blob) => { + const canvas = document.createElement("canvas"); + const canvasCtx = ensure(canvas.getContext("2d")); + + const imageURL = URL.createObjectURL(blob); + await withTimeout( + new Promise((resolve, reject) => { + const image = new Image(); + image.setAttribute("src", imageURL); + image.onload = () => { + try { + URL.revokeObjectURL(imageURL); + const { width, height } = scaledThumbnailDimensions( + image.width, + image.height, + maxThumbnailDimension, + ); + canvas.width = width; + canvas.height = height; + canvasCtx.drawImage(image, 0, 0, width, height); + resolve(undefined); + } catch (e: unknown) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(e); + } + }; + }), + 30 * 1000, + ); + + return await compressedJPEGData(canvas); +}; + +export const generateVideoThumbnailUsingCanvas = async (blob: Blob) => { + const canvas = document.createElement("canvas"); + const canvasCtx = ensure(canvas.getContext("2d")); + + const videoURL = URL.createObjectURL(blob); + await withTimeout( + new Promise((resolve, reject) => { + const video = document.createElement("video"); + video.preload = "metadata"; + video.src = videoURL; + video.addEventListener("loadeddata", () => { + try { + URL.revokeObjectURL(videoURL); + const { width, height } = scaledThumbnailDimensions( + video.videoWidth, + video.videoHeight, + maxThumbnailDimension, + ); + canvas.width = width; + canvas.height = height; + canvasCtx.drawImage(video, 0, 0, width, height); + resolve(undefined); + } catch (e) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(e); + } + }); + }), + 30 * 1000, + ); + + return await compressedJPEGData(canvas); +}; + +/** + * Compute the size of the thumbnail to create for an image with the given + * {@link width} and {@link height}. + * + * This function calculates a new size of an image for limiting it to maximum + * width and height (both specified by {@link maxDimension}), while maintaining + * aspect ratio. + * + * It returns `{0, 0}` for invalid inputs. + */ +const scaledThumbnailDimensions = ( + width: number, + height: number, + maxDimension: number, +): { width: number; height: number } => { + if (width === 0 || height === 0) return { width: 0, height: 0 }; + const widthScaleFactor = maxDimension / width; + const heightScaleFactor = maxDimension / height; + const scaleFactor = Math.min(widthScaleFactor, heightScaleFactor); + const thumbnailDimensions = { + width: Math.round(width * scaleFactor), + height: Math.round(height * scaleFactor), + }; + if (thumbnailDimensions.width === 0 || thumbnailDimensions.height === 0) + return { width: 0, height: 0 }; + return thumbnailDimensions; +}; + +const compressedJPEGData = async (canvas: HTMLCanvasElement) => { + let blob: Blob | undefined | null; + let prevSize = Number.MAX_SAFE_INTEGER; + let quality = 0.7; + + do { + if (blob) prevSize = blob.size; + blob = await new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), "image/jpeg", quality); + }); + quality -= 0.1; + } while ( + quality >= 0.5 && + blob && + blob.size > maxThumbnailSize && + percentageSizeDiff(blob.size, prevSize) >= 10 + ); + + return new Uint8Array(await ensure(blob).arrayBuffer()); +}; + +const percentageSizeDiff = ( + newThumbnailSize: number, + oldThumbnailSize: number, +) => ((oldThumbnailSize - newThumbnailSize) * 100) / oldThumbnailSize; diff --git a/web/packages/shared/utils/index.ts b/web/packages/shared/utils/index.ts index 568ec5cc4..8b46f6267 100644 --- a/web/packages/shared/utils/index.ts +++ b/web/packages/shared/utils/index.ts @@ -1,11 +1,4 @@ -/** - * Wait for {@link ms} milliseconds - * - * This function is a promisified `setTimeout`. It returns a promise that - * resolves after {@link ms} milliseconds. - */ -export const wait = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); +import { wait } from "@/utils/promise"; export function downloadAsFile(filename: string, content: string) { const file = new Blob([content], { @@ -52,23 +45,3 @@ export async function retryAsyncFunction( } } } - -/** - * Await the given {@link promise} for {@link timeoutMS} milliseconds. If it - * does not resolve within {@link timeoutMS}, then reject with a timeout error. - */ -export const withTimeout = async (promise: Promise, ms: number) => { - let timeoutId: ReturnType; - const rejectOnTimeout = new Promise((_, reject) => { - timeoutId = setTimeout( - () => reject(new Error("Operation timed out")), - ms, - ); - }); - const promiseAndCancelTimeout = async () => { - const result = await promise; - clearTimeout(timeoutId); - return result; - }; - return Promise.race([promiseAndCancelTimeout(), rejectOnTimeout]); -}; diff --git a/web/packages/utils/promise.ts b/web/packages/utils/promise.ts new file mode 100644 index 000000000..4cb7648fd --- /dev/null +++ b/web/packages/utils/promise.ts @@ -0,0 +1,28 @@ +/** + * Wait for {@link ms} milliseconds + * + * This function is a promisified `setTimeout`. It returns a promise that + * resolves after {@link ms} milliseconds. + */ +export const wait = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Await the given {@link promise} for {@link timeoutMS} milliseconds. If it + * does not resolve within {@link timeoutMS}, then reject with a timeout error. + */ +export const withTimeout = async (promise: Promise, ms: number) => { + let timeoutId: ReturnType; + const rejectOnTimeout = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error("Operation timed out")), + ms, + ); + }); + const promiseAndCancelTimeout = async () => { + const result = await promise; + clearTimeout(timeoutId); + return result; + }; + return Promise.race([promiseAndCancelTimeout(), rejectOnTimeout]); +};