diff --git a/web/apps/photos/src/services/upload/thumbnail.ts b/web/apps/photos/src/services/upload/thumbnail.ts index 70ba450ec..10da88a65 100644 --- a/web/apps/photos/src/services/upload/thumbnail.ts +++ b/web/apps/photos/src/services/upload/thumbnail.ts @@ -1,10 +1,9 @@ import { FILE_TYPE, type FileTypeInfo } from "@/media/file-type"; -import { - generateImageThumbnailUsingCanvas, - generateVideoThumbnailUsingCanvas, -} from "@/media/image"; +import { scaledImageDimensions } from "@/media/image"; import log from "@/next/log"; import { type Electron } from "@/next/types/ipc"; +import { ensure } from "@/utils/ensure"; +import { withTimeout } from "@/utils/promise"; import * as ffmpeg from "services/ffmpeg"; import { heicToJPEG } from "services/heic-convert"; import { toDataOrPathOrZipEntry, type DesktopUploadItem } from "./types"; @@ -48,6 +47,64 @@ const generateImageThumbnailWeb = async ( return generateImageThumbnailUsingCanvas(blob); }; +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 } = scaledImageDimensions( + image.width, + image.height, + maxThumbnailDimension, + ); + canvas.width = width; + canvas.height = height; + canvasCtx.drawImage(image, 0, 0, width, height); + resolve(undefined); + } catch (e: unknown) { + reject(e); + } + }; + }), + 30 * 1000, + ); + + return await compressedJPEGData(canvas); +}; + +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; + const generateVideoThumbnailWeb = async (blob: Blob) => { try { return await ffmpeg.generateVideoThumbnailWeb(blob); @@ -60,6 +117,39 @@ const generateVideoThumbnailWeb = async (blob: Blob) => { } }; +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 } = scaledImageDimensions( + 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); +}; + /** * Generate a JPEG thumbnail for the given file or path using native tools. * diff --git a/web/packages/media/image.ts b/web/packages/media/image.ts index 8be284bc9..2912af02a 100644 --- a/web/packages/media/image.ts +++ b/web/packages/media/image.ts @@ -1,128 +1,33 @@ -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}. + * Compute optimal dimensions for a resized version of an image while + * maintaining aspect ratio of the source image. * - * 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. + * @param width The width of the source image. + * + * @param height The height of the source image. + * + * @param maxDimension The maximum width of height of the resized image. + * + * This function returns a new size limiting it to maximum width and height + * (both specified by {@link maxDimension}), while maintaining aspect ratio of + * the source {@link width} and {@link height}. * * It returns `{0, 0}` for invalid inputs. */ -const scaledThumbnailDimensions = ( +export const scaledImageDimensions = ( width: number, height: number, maxDimension: number, ): { width: number; height: number } => { - if (width === 0 || height === 0) return { width: 0, height: 0 }; + 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 = { + const resizedDimensions = { width: Math.round(width * scaleFactor), height: Math.round(height * scaleFactor), }; - if (thumbnailDimensions.width === 0 || thumbnailDimensions.height === 0) + if (resizedDimensions.width == 0 || resizedDimensions.height == 0) return { width: 0, height: 0 }; - return thumbnailDimensions; + return resizedDimensions; }; - -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;