diff --git a/desktop/src/main/services/ml-clip.ts b/desktop/src/main/services/ml-clip.ts index 6719e191c4..92c56ccc80 100644 --- a/desktop/src/main/services/ml-clip.ts +++ b/desktop/src/main/services/ml-clip.ts @@ -49,73 +49,203 @@ const clipImageEmbedding_ = async (jpegFilePath: string) => { return normalizeEmbedding(imageEmbedding); }; -const getRGBData = async (jpegFilePath: string): Promise => { +const getRGBData = async ( + jpegFilePath: string, +): Promise => { const jpegData = await fs.readFile(jpegFilePath); const rawImageData = jpeg.decode(jpegData, { useTArray: true, formatAsRGBA: false, - }); + }); // TODO: manav: make sure this works on all images, not just jpeg + const pixelData = rawImageData.data; - const nx = rawImageData.width; - const ny = rawImageData.height; - const inputImage = rawImageData.data; + const requiredWidth = 224; + const requiredHeight = 224; + const requiredSize = 3 * requiredWidth * requiredHeight; + const mean: number[] = [0.48145466, 0.4578275, 0.40821073]; + const std: number[] = [0.26862954, 0.26130258, 0.27577711]; - const nx2 = 224; - const ny2 = 224; - const totalSize = 3 * nx2 * ny2; + const scale = Math.max( + requiredWidth / rawImageData.width, + requiredHeight / rawImageData.height, + ); + const scaledWidth = Math.round(rawImageData.width * scale); + const scaledHeight = Math.round(rawImageData.height * scale); + const widthOffset = Math.max(0, scaledWidth - requiredWidth) / 2; + const heightOffset = Math.max(0, scaledHeight - requiredHeight) / 2; - const result = Array(totalSize).fill(0); - const scale = Math.max(nx, ny) / 224; + const processedImage = new Float32Array(requiredSize); - const nx3 = Math.round(nx / scale); - const ny3 = Math.round(ny / scale); + // Populate the Float32Array with normalized pixel values. + let pi = 0; + const cOffsetG = requiredHeight * requiredWidth; // ChannelOffsetGreen + const cOffsetB = 2 * requiredHeight * requiredWidth; // ChannelOffsetBlue + for (let h = 0 + heightOffset; h < scaledHeight - heightOffset; h++) { + for (let w = 0 + widthOffset; w < scaledWidth - widthOffset; w++) { + const { r, g, b } = pixelRGBBicubic( + w / scale, + h / scale, + pixelData, + rawImageData.width, + rawImageData.height, + ); + processedImage[pi] = (r / 255.0 - mean[0]!) / std[0]!; + processedImage[pi + cOffsetG] = (g / 255.0 - mean[1]!) / std[1]!; + processedImage[pi + cOffsetB] = (b / 255.0 - mean[2]!) / std[2]!; + pi++; + } + } + return processedImage; +}; - const mean: number[] = [0.48145466, 0.4578275, 0.40821073]; - const std: number[] = [0.26862954, 0.26130258, 0.27577711]; +// NOTE: exact duplicate of the function in web/apps/photos/src/services/face/image.ts +const pixelRGBBicubic = ( + fx: number, + fy: number, + imageData: Uint8Array, + imageWidth: number, + imageHeight: number, +) => { + // Clamp to image boundaries. + fx = clamp(fx, 0, imageWidth - 1); + fy = clamp(fy, 0, imageHeight - 1); - for (let y = 0; y < ny3; y++) { - for (let x = 0; x < nx3; x++) { - for (let c = 0; c < 3; c++) { - // Linear interpolation - const sx = (x + 0.5) * scale - 0.5; - const sy = (y + 0.5) * scale - 0.5; + const x = Math.trunc(fx) - (fx >= 0.0 ? 0 : 1); + const px = x - 1; + const nx = x + 1; + const ax = x + 2; + const y = Math.trunc(fy) - (fy >= 0.0 ? 0 : 1); + const py = y - 1; + const ny = y + 1; + const ay = y + 2; + const dx = fx - x; + const dy = fy - y; - const x0 = Math.max(0, Math.floor(sx)); - const y0 = Math.max(0, Math.floor(sy)); + const cubic = ( + dx: number, + ipp: number, + icp: number, + inp: number, + iap: number, + ) => + icp + + 0.5 * + (dx * (-ipp + inp) + + dx * dx * (2 * ipp - 5 * icp + 4 * inp - iap) + + dx * dx * dx * (-ipp + 3 * icp - 3 * inp + iap)); - const x1 = Math.min(x0 + 1, nx - 1); - const y1 = Math.min(y0 + 1, ny - 1); + const icc = pixelRGBA(imageData, imageWidth, imageHeight, x, y); - const dx = sx - x0; - const dy = sy - y0; + const ipp = + px < 0 || py < 0 + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, px, py); + const icp = + px < 0 ? icc : pixelRGBA(imageData, imageWidth, imageHeight, x, py); + const inp = + py < 0 || nx >= imageWidth + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, nx, py); + const iap = + ax >= imageWidth || py < 0 + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, ax, py); - const j00 = 3 * (y0 * nx + x0) + c; - const j01 = 3 * (y0 * nx + x1) + c; - const j10 = 3 * (y1 * nx + x0) + c; - const j11 = 3 * (y1 * nx + x1) + c; + const ip0 = cubic(dx, ipp.r!, icp.r!, inp.r!, iap.r!); + const ip1 = cubic(dx, ipp.g!, icp.g!, inp.g!, iap.g!); + const ip2 = cubic(dx, ipp.b!, icp.b!, inp.b!, iap.b!); + // const ip3 = cubic(dx, ipp.a, icp.a, inp.a, iap.a); - const v00 = inputImage[j00] ?? 0; - const v01 = inputImage[j01] ?? 0; - const v10 = inputImage[j10] ?? 0; - const v11 = inputImage[j11] ?? 0; + const ipc = + px < 0 ? icc : pixelRGBA(imageData, imageWidth, imageHeight, px, y); + const inc = + nx >= imageWidth + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, nx, y); + const iac = + ax >= imageWidth + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, ax, y); - const v0 = v00 * (1 - dx) + v01 * dx; - const v1 = v10 * (1 - dx) + v11 * dx; + const ic0 = cubic(dx, ipc.r!, icc.r!, inc.r!, iac.r!); + const ic1 = cubic(dx, ipc.g!, icc.g!, inc.g!, iac.g!); + const ic2 = cubic(dx, ipc.b!, icc.b!, inc.b!, iac.b!); + // const ic3 = cubic(dx, ipc.a, icc.a, inc.a, iac.a); - const v = v0 * (1 - dy) + v1 * dy; + const ipn = + px < 0 || ny >= imageHeight + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, px, ny); + const icn = + ny >= imageHeight + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, x, ny); + const inn = + nx >= imageWidth || ny >= imageHeight + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, nx, ny); + const ian = + ax >= imageWidth || ny >= imageHeight + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, ax, ny); - const v2 = Math.min(Math.max(Math.round(v), 0), 255); + const in0 = cubic(dx, ipn.r!, icn.r!, inn.r!, ian.r!); + const in1 = cubic(dx, ipn.g!, icn.g!, inn.g!, ian.g!); + const in2 = cubic(dx, ipn.b!, icn.b!, inn.b!, ian.b!); + // const in3 = cubic(dx, ipn.a, icn.a, inn.a, ian.a); - // createTensorWithDataList is dumb compared to reshape and - // hence has to be given with one channel after another - const i = y * nx3 + x + (c % 3) * 224 * 224; + const ipa = + px < 0 || ay >= imageHeight + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, px, ay); + const ica = + ay >= imageHeight + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, x, ay); + const ina = + nx >= imageWidth || ay >= imageHeight + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, nx, ay); + const iaa = + ax >= imageWidth || ay >= imageHeight + ? icc + : pixelRGBA(imageData, imageWidth, imageHeight, ax, ay); - result[i] = (v2 / 255 - (mean[c] ?? 0)) / (std[c] ?? 1); - } - } - } + const ia0 = cubic(dx, ipa.r!, ica.r!, ina.r!, iaa.r!); + const ia1 = cubic(dx, ipa.g!, ica.g!, ina.g!, iaa.g!); + const ia2 = cubic(dx, ipa.b!, ica.b!, ina.b!, iaa.b!); + // const ia3 = cubic(dx, ipa.a, ica.a, ina.a, iaa.a); + + const c0 = Math.trunc(clamp(cubic(dy, ip0, ic0, in0, ia0), 0, 255)); + const c1 = Math.trunc(clamp(cubic(dy, ip1, ic1, in1, ia1), 0, 255)); + const c2 = Math.trunc(clamp(cubic(dy, ip2, ic2, in2, ia2), 0, 255)); + // const c3 = cubic(dy, ip3, ic3, in3, ia3); + + return { r: c0, g: c1, b: c2 }; +}; + +// NOTE: exact duplicate of the function in web/apps/photos/src/services/face/image.ts +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); - return result; +// NOTE: exact duplicate of the function in web/apps/photos/src/services/face/image.ts +const pixelRGBA = ( + imageData: Uint8Array, + width: number, + height: number, + x: number, + y: number, +) => { + if (x < 0 || x >= width || y < 0 || y >= height) { + return { r: 0, g: 0, b: 0, a: 0 }; + } + const index = (y * width + x) * 4; + return { + r: imageData[index], + g: imageData[index + 1], + b: imageData[index + 2], + a: imageData[index + 3], + }; }; const normalizeEmbedding = (embedding: Float32Array) => { diff --git a/web/apps/cast/src/services/render.ts b/web/apps/cast/src/services/render.ts index 5e86b3b8bc..acacdc88d2 100644 --- a/web/apps/cast/src/services/render.ts +++ b/web/apps/cast/src/services/render.ts @@ -243,12 +243,14 @@ const decryptEnteFile = async ( file.metadata.title = file.pubMagicMetadata.data.editedName; } // @ts-expect-error TODO: The core types need to be updated to allow the - // possibility of missing metadata fiels. + // possibility of missing metadata fields. return file; }; const isFileEligible = (file: EnteFile) => { if (!isImageOrLivePhoto(file)) return false; + // @ts-expect-error TODO: The core types need to be updated to allow the + // possibility of missing info fields (or do they?) if (file.info.fileSize > 100 * 1024 * 1024) return false; // This check is fast but potentially incorrect because in practice we do diff --git a/web/apps/photos/src/components/Collections/CollectionCard.tsx b/web/apps/photos/src/components/Collections/CollectionCard.tsx index 5bf247020e..7d757561ba 100644 --- a/web/apps/photos/src/components/Collections/CollectionCard.tsx +++ b/web/apps/photos/src/components/Collections/CollectionCard.tsx @@ -1,10 +1,10 @@ +import downloadManager from "@/new/photos/services/download"; import { EnteFile } from "@/new/photos/types/file"; import { LoadingThumbnail, StaticThumbnail, } from "components/PlaceholderThumbnails"; import { useEffect, useState } from "react"; -import downloadManager from "services/download"; export default function CollectionCard(props: { children?: any; diff --git a/web/apps/photos/src/components/PhotoFrame.tsx b/web/apps/photos/src/components/PhotoFrame.tsx index 6095a31765..a6f8606a08 100644 --- a/web/apps/photos/src/components/PhotoFrame.tsx +++ b/web/apps/photos/src/components/PhotoFrame.tsx @@ -1,4 +1,5 @@ import { FILE_TYPE } from "@/media/file-type"; +import DownloadManager from "@/new/photos/services/download"; import type { LivePhotoSourceURL, SourceURLs } from "@/new/photos/types/file"; import { EnteFile } from "@/new/photos/types/file"; import log from "@/next/log"; @@ -14,7 +15,6 @@ import PhotoSwipe from "photoswipe"; import { useContext, useEffect, useState } from "react"; import AutoSizer from "react-virtualized-auto-sizer"; import { Duplicate } from "services/deduplicationService"; -import DownloadManager from "services/download"; import { SelectedState, SetFilesDownloadProgressAttributesCreator, diff --git a/web/apps/photos/src/components/PhotoList/dedupe.tsx b/web/apps/photos/src/components/PhotoList/dedupe.tsx index 56a9638e3f..1652ff176f 100644 --- a/web/apps/photos/src/components/PhotoList/dedupe.tsx +++ b/web/apps/photos/src/components/PhotoList/dedupe.tsx @@ -1,4 +1,5 @@ import { EnteFile } from "@/new/photos/types/file"; +import { formattedByteSize } from "@/new/photos/utils/units"; import { FlexWrapper } from "@ente/shared/components/Container"; import { Box, styled } from "@mui/material"; import { @@ -19,7 +20,6 @@ import { areEqual, } from "react-window"; import { Duplicate } from "services/deduplicationService"; -import { formattedByteSize } from "utils/units"; export enum ITEM_TYPE { TIME = "TIME", diff --git a/web/apps/photos/src/components/PhotoList/index.tsx b/web/apps/photos/src/components/PhotoList/index.tsx index 85c6db57cb..1f4b0bfb06 100644 --- a/web/apps/photos/src/components/PhotoList/index.tsx +++ b/web/apps/photos/src/components/PhotoList/index.tsx @@ -1,4 +1,5 @@ import { EnteFile } from "@/new/photos/types/file"; +import { formattedByteSize } from "@/new/photos/utils/units"; import { FlexWrapper } from "@ente/shared/components/Container"; import { formatDate } from "@ente/shared/time/format"; import { Box, Checkbox, Link, Typography, styled } from "@mui/material"; @@ -24,7 +25,6 @@ import { } from "react-window"; import { handleSelectCreator } from "utils/photoFrame"; import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery"; -import { formattedByteSize } from "utils/units"; const FOOTER_HEIGHT = 90; const ALBUM_FOOTER_HEIGHT = 75; diff --git a/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx index caa9be2de4..b4a932b9aa 100644 --- a/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx +++ b/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx @@ -1,5 +1,6 @@ import { FILE_TYPE } from "@/media/file-type"; import { EnteFile } from "@/new/photos/types/file"; +import { formattedByteSize } from "@/new/photos/utils/units"; import { nameAndExtension } from "@/next/file"; import log from "@/next/log"; import { FlexWrapper } from "@ente/shared/components/Container"; @@ -8,7 +9,6 @@ import VideocamOutlined from "@mui/icons-material/VideocamOutlined"; import Box from "@mui/material/Box"; import { useEffect, useState } from "react"; import { changeFileName, updateExistingFilePubMetadata } from "utils/file"; -import { formattedByteSize } from "utils/units"; import { FileNameEditDialog } from "./FileNameEditDialog"; import InfoItem from "./InfoItem"; diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx index 0d41aba55d..3bb010d708 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx @@ -1,3 +1,4 @@ +import downloadManager from "@/new/photos/services/download"; import { EnteFile } from "@/new/photos/types/file"; import { nameAndExtension } from "@/next/file"; import log from "@/next/log"; @@ -35,7 +36,6 @@ import { AppContext } from "pages/_app"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { createContext, useContext, useEffect, useRef, useState } from "react"; import { getLocalCollections } from "services/collectionService"; -import downloadManager from "services/download"; import uploadManager from "services/upload/uploadManager"; import { getEditorCloseConfirmationMessage } from "utils/ui"; import ColoursMenu from "./ColoursMenu"; diff --git a/web/apps/photos/src/components/PhotoViewer/index.tsx b/web/apps/photos/src/components/PhotoViewer/index.tsx index db3d11b5c2..56ad5475ec 100644 --- a/web/apps/photos/src/components/PhotoViewer/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/index.tsx @@ -11,12 +11,14 @@ import { copyFileToClipboard, downloadSingleFile, getFileFromURL, - isSupportedRawFormat, } from "utils/file"; import { FILE_TYPE } from "@/media/file-type"; import { isNonWebImageFileExtension } from "@/media/formats"; +import downloadManager from "@/new/photos/services/download"; import type { LoadedLivePhotoSourceURL } from "@/new/photos/types/file"; +import { detectFileTypeInfo } from "@/new/photos/utils/detect-type"; +import { isNativeConvertibleToJPEG } from "@/new/photos/utils/file"; import { lowercaseExtension } from "@/next/file"; import { FlexWrapper } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; @@ -44,8 +46,6 @@ import { t } from "i18next"; import isElectron from "is-electron"; import { AppContext } from "pages/_app"; import { GalleryContext } from "pages/gallery"; -import { detectFileTypeInfo } from "services/detect-type"; -import downloadManager from "services/download"; import { getParsedExifData } from "services/exif"; import { trashFiles } from "services/fileService"; import { SetFilesDownloadProgressAttributesCreator } from "types/gallery"; @@ -352,7 +352,9 @@ function PhotoViewer(props: Iprops) { const extension = lowercaseExtension(file.metadata.title); const isSupported = !isNonWebImageFileExtension(extension) || - isSupportedRawFormat(extension); + // TODO: This condition doesn't sound correct when running in the + // web app? + isNativeConvertibleToJPEG(extension); setShowEditButton( file.metadata.fileType === FILE_TYPE.IMAGE && isSupported, ); diff --git a/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/individual/usageSection.tsx b/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/individual/usageSection.tsx index 8975941ad5..251a58d823 100644 --- a/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/individual/usageSection.tsx +++ b/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/individual/usageSection.tsx @@ -1,7 +1,7 @@ +import { formattedStorageByteSize } from "@/new/photos/utils/units"; import { SpaceBetweenFlex } from "@ente/shared/components/Container"; import { Box, Typography } from "@mui/material"; import { t } from "i18next"; -import { formattedStorageByteSize } from "utils/units"; import { Progressbar } from "../../styledComponents"; diff --git a/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/storageSection.tsx b/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/storageSection.tsx index 7f2712f738..4ad0ed2149 100644 --- a/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/storageSection.tsx +++ b/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/storageSection.tsx @@ -1,6 +1,6 @@ -import { Box, styled, Typography } from "@mui/material"; +import { bytesInGB, formattedStorageByteSize } from "@/new/photos/utils/units"; +import { Box, Typography, styled } from "@mui/material"; import { t } from "i18next"; -import { bytesInGB, formattedStorageByteSize } from "utils/units"; const MobileSmallBox = styled(Box)` display: none; diff --git a/web/apps/photos/src/components/Upload/UploadProgress/dialog.tsx b/web/apps/photos/src/components/Upload/UploadProgress/dialog.tsx index 1367b57ad5..ded9e2c17f 100644 --- a/web/apps/photos/src/components/Upload/UploadProgress/dialog.tsx +++ b/web/apps/photos/src/components/Upload/UploadProgress/dialog.tsx @@ -1,8 +1,11 @@ import { Dialog, DialogContent, Link } from "@mui/material"; import { t } from "i18next"; +import { + UPLOAD_RESULT, + UPLOAD_STAGES, +} from "@/new/photos/services/upload/types"; import { dialogCloseHandler } from "@ente/shared/components/DialogBox/TitleWithCloseButton"; -import { UPLOAD_RESULT, UPLOAD_STAGES } from "constants/upload"; import UploadProgressContext from "contexts/uploadProgress"; import { useContext, useEffect, useState } from "react"; import { Trans } from "react-i18next"; diff --git a/web/apps/photos/src/components/Upload/UploadProgress/footer.tsx b/web/apps/photos/src/components/Upload/UploadProgress/footer.tsx index 5a5bebd201..7372392032 100644 --- a/web/apps/photos/src/components/Upload/UploadProgress/footer.tsx +++ b/web/apps/photos/src/components/Upload/UploadProgress/footer.tsx @@ -1,5 +1,8 @@ +import { + UPLOAD_RESULT, + UPLOAD_STAGES, +} from "@/new/photos/services/upload/types"; import { Button, DialogActions } from "@mui/material"; -import { UPLOAD_RESULT, UPLOAD_STAGES } from "constants/upload"; import { t } from "i18next"; import { useContext } from "react"; diff --git a/web/apps/photos/src/components/Upload/UploadProgress/inProgressSection.tsx b/web/apps/photos/src/components/Upload/UploadProgress/inProgressSection.tsx index 128e280abb..327cc43723 100644 --- a/web/apps/photos/src/components/Upload/UploadProgress/inProgressSection.tsx +++ b/web/apps/photos/src/components/Upload/UploadProgress/inProgressSection.tsx @@ -11,8 +11,8 @@ import { } from "./section"; import { InProgressItemContainer } from "./styledComponents"; +import { UPLOAD_STAGES } from "@/new/photos/services/upload/types"; import { CaptionedText } from "components/CaptionedText"; -import { UPLOAD_STAGES } from "constants/upload"; export const InProgressSection = () => { const { inProgressUploads, hasLivePhotos, uploadFileNames, uploadStage } = diff --git a/web/apps/photos/src/components/Upload/UploadProgress/index.tsx b/web/apps/photos/src/components/Upload/UploadProgress/index.tsx index 1acffd561e..3dc9d6cea8 100644 --- a/web/apps/photos/src/components/Upload/UploadProgress/index.tsx +++ b/web/apps/photos/src/components/Upload/UploadProgress/index.tsx @@ -1,4 +1,4 @@ -import { UPLOAD_STAGES } from "constants/upload"; +import { UPLOAD_STAGES } from "@/new/photos/services/upload/types"; import UploadProgressContext from "contexts/uploadProgress"; import { t } from "i18next"; import { AppContext } from "pages/_app"; diff --git a/web/apps/photos/src/components/Upload/UploadProgress/progressBar.tsx b/web/apps/photos/src/components/Upload/UploadProgress/progressBar.tsx index 6173829d7e..a18d9d7aa9 100644 --- a/web/apps/photos/src/components/Upload/UploadProgress/progressBar.tsx +++ b/web/apps/photos/src/components/Upload/UploadProgress/progressBar.tsx @@ -1,5 +1,5 @@ +import { UPLOAD_STAGES } from "@/new/photos/services/upload/types"; import { Box, Divider, LinearProgress } from "@mui/material"; -import { UPLOAD_STAGES } from "constants/upload"; import UploadProgressContext from "contexts/uploadProgress"; import { useContext } from "react"; diff --git a/web/apps/photos/src/components/Upload/UploadProgress/resultSection.tsx b/web/apps/photos/src/components/Upload/UploadProgress/resultSection.tsx index 1be6ca8317..6c483bf49c 100644 --- a/web/apps/photos/src/components/Upload/UploadProgress/resultSection.tsx +++ b/web/apps/photos/src/components/Upload/UploadProgress/resultSection.tsx @@ -1,7 +1,7 @@ +import { UPLOAD_RESULT } from "@/new/photos/services/upload/types"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { CaptionedText } from "components/CaptionedText"; import ItemList from "components/ItemList"; -import { UPLOAD_RESULT } from "constants/upload"; import UploadProgressContext from "contexts/uploadProgress"; import { useContext } from "react"; import { diff --git a/web/apps/photos/src/components/Upload/UploadProgress/title.tsx b/web/apps/photos/src/components/Upload/UploadProgress/title.tsx index 1b97b9b437..332ef57483 100644 --- a/web/apps/photos/src/components/Upload/UploadProgress/title.tsx +++ b/web/apps/photos/src/components/Upload/UploadProgress/title.tsx @@ -1,10 +1,10 @@ +import { UPLOAD_STAGES } from "@/new/photos/services/upload/types"; import { IconButtonWithBG, SpaceBetweenFlex, } from "@ente/shared/components/Container"; import Close from "@mui/icons-material/Close"; import { Box, DialogTitle, Stack, Typography } from "@mui/material"; -import { UPLOAD_STAGES } from "constants/upload"; import { t } from "i18next"; import { useContext } from "react"; diff --git a/web/apps/photos/src/components/Upload/Uploader.tsx b/web/apps/photos/src/components/Upload/Uploader.tsx index 85e660672e..767b32c35d 100644 --- a/web/apps/photos/src/components/Upload/Uploader.tsx +++ b/web/apps/photos/src/components/Upload/Uploader.tsx @@ -1,3 +1,9 @@ +import { exportMetadataDirectoryName } from "@/new/photos/services/export"; +import type { + FileAndPath, + UploadItem, +} from "@/new/photos/services/upload/types"; +import { UPLOAD_STAGES } from "@/new/photos/services/upload/types"; import { basename } from "@/next/file"; import log from "@/next/log"; import type { CollectionMapping, Electron, ZipItem } from "@/next/types/ipc"; @@ -7,7 +13,6 @@ import { CustomError } from "@ente/shared/error"; import { isPromise } from "@ente/shared/utils"; import DiscFullIcon from "@mui/icons-material/DiscFull"; import UserNameInputDialog from "components/UserNameInputDialog"; -import { UPLOAD_STAGES } from "constants/upload"; import { t } from "i18next"; import isElectron from "is-electron"; import { AppContext } from "pages/_app"; @@ -15,13 +20,11 @@ import { GalleryContext } from "pages/gallery"; import { useContext, useEffect, useRef, useState } from "react"; import billingService from "services/billingService"; import { getLatestCollections } from "services/collectionService"; -import { exportMetadataDirectoryName } from "services/export"; import { getPublicCollectionUID, getPublicCollectionUploaderName, savePublicCollectionUploaderName, } from "services/publicCollectionService"; -import type { FileAndPath, UploadItem } from "services/upload/types"; import type { InProgressUpload, SegregatedFinishedUploads, diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/card.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/card.tsx index 55e93979de..17f0b6e6e7 100644 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/card.tsx +++ b/web/apps/photos/src/components/pages/gallery/PlanSelector/card.tsx @@ -1,3 +1,4 @@ +import { bytesInGB } from "@/new/photos/utils/units"; import log from "@/next/log"; import { SpaceBetweenFlex } from "@ente/shared/components/Container"; import { SUPPORT_EMAIL } from "@ente/shared/constants/urls"; @@ -27,7 +28,6 @@ import { planForSubscription, updateSubscription, } from "utils/billing"; -import { bytesInGB } from "utils/units"; import { getLocalUserDetails } from "utils/user"; import { getTotalFamilyUsage, isPartOfFamily } from "utils/user/family"; import { ManageSubscription } from "./manageSubscription"; diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx index 5f7e13deb8..7b375f5d73 100644 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx +++ b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx @@ -1,8 +1,8 @@ import { SpaceBetweenFlex } from "@ente/shared/components/Container"; import { Box, styled, Typography } from "@mui/material"; +import { formattedStorageByteSize } from "@/new/photos/utils/units"; import { Trans } from "react-i18next"; -import { formattedStorageByteSize } from "utils/units"; const RowContainer = styled(SpaceBetweenFlex)(({ theme }) => ({ // gap: theme.spacing(1.5), diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/index.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/index.tsx index 31e97c68e6..e83ca781ee 100644 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/index.tsx +++ b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/index.tsx @@ -1,3 +1,4 @@ +import { formattedStorageByteSize } from "@/new/photos/utils/units"; import { SpaceBetweenFlex } from "@ente/shared/components/Container"; import ArrowForward from "@mui/icons-material/ArrowForward"; import { Box, IconButton, Stack, Typography, styled } from "@mui/material"; @@ -12,7 +13,6 @@ import { isPopularPlan, isUserSubscribedPlan, } from "utils/billing"; -import { formattedStorageByteSize } from "utils/units"; import { PlanRow } from "./planRow"; interface Iprops { diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/planRow.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/planRow.tsx index 9f1351b120..9701baf01a 100644 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/planRow.tsx +++ b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/planRow.tsx @@ -1,3 +1,4 @@ +import { bytesInGB } from "@/new/photos/utils/units"; import { FlexWrapper, FluidContainer } from "@ente/shared/components/Container"; import ArrowForward from "@mui/icons-material/ArrowForward"; import Done from "@mui/icons-material/Done"; @@ -7,7 +8,6 @@ import { PLAN_PERIOD } from "constants/gallery"; import { t } from "i18next"; import { Plan, Subscription } from "types/billing"; import { hasPaidSubscription, isUserSubscribedPlan } from "utils/billing"; -import { bytesInGB } from "utils/units"; interface Iprops { plan: Plan; diff --git a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx index 0b4c386c4b..6a5e8128bb 100644 --- a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx +++ b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx @@ -1,4 +1,5 @@ import { FILE_TYPE } from "@/media/file-type"; +import DownloadManager from "@/new/photos/services/download"; import { EnteFile } from "@/new/photos/types/file"; import log from "@/next/log"; import { Overlay } from "@ente/shared/components/Container"; @@ -17,7 +18,6 @@ import i18n from "i18next"; import { DeduplicateContext } from "pages/deduplicate"; import { GalleryContext } from "pages/gallery"; import React, { useContext, useEffect, useRef, useState } from "react"; -import DownloadManager from "services/download"; import { shouldShowAvatar } from "utils/file"; import Avatar from "./Avatar"; diff --git a/web/apps/photos/src/constants/upload.ts b/web/apps/photos/src/constants/upload.ts deleted file mode 100644 index a0103cb6e6..0000000000 --- a/web/apps/photos/src/constants/upload.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Location } from "types/metadata"; - -export const RANDOM_PERCENTAGE_PROGRESS_FOR_PUT = () => 90 + 10 * Math.random(); - -export const NULL_LOCATION: Location = { latitude: null, longitude: null }; - -export enum UPLOAD_STAGES { - START, - READING_GOOGLE_METADATA_FILES, - EXTRACTING_METADATA, - UPLOADING, - CANCELLING, - FINISH, -} - -export enum UPLOAD_RESULT { - FAILED, - ALREADY_UPLOADED, - UNSUPPORTED, - BLOCKED, - TOO_LARGE, - LARGER_THAN_AVAILABLE_STORAGE, - UPLOADED, - UPLOADED_WITH_STATIC_THUMBNAIL, - ADDED_SYMLINK, -} diff --git a/web/apps/photos/src/contexts/uploadProgress.tsx b/web/apps/photos/src/contexts/uploadProgress.tsx index b25df7d65b..1c98569b03 100644 --- a/web/apps/photos/src/contexts/uploadProgress.tsx +++ b/web/apps/photos/src/contexts/uploadProgress.tsx @@ -1,4 +1,4 @@ -import { UPLOAD_STAGES } from "constants/upload"; +import { UPLOAD_STAGES } from "@/new/photos/services/upload/types"; import { createContext } from "react"; import type { InProgressUpload, diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 98af7f9d67..a74c2b3ab1 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -1,3 +1,4 @@ +import DownloadManager from "@/new/photos/services/download"; import { clientPackageName, staticAppTitle } from "@/next/app"; import { CustomHead } from "@/next/components/Head"; import { setupI18n } from "@/next/i18n"; @@ -48,7 +49,6 @@ import { useRouter } from "next/router"; import "photoswipe/dist/photoswipe.css"; import { createContext, useContext, useEffect, useRef, useState } from "react"; import LoadingBar from "react-top-loading-bar"; -import DownloadManager from "services/download"; import { resumeExportsIfNeeded } from "services/export"; import { isFaceIndexingEnabled, diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery/index.tsx index 8bac954cbb..42f3aed2c4 100644 --- a/web/apps/photos/src/pages/gallery/index.tsx +++ b/web/apps/photos/src/pages/gallery/index.tsx @@ -1,5 +1,6 @@ import { WhatsNew } from "@/new/photos/components/WhatsNew"; import { shouldShowWhatsNew } from "@/new/photos/services/changelog"; +import downloadManager from "@/new/photos/services/download"; import { getLocalFiles, getLocalTrashedFiles, @@ -92,7 +93,6 @@ import { getHiddenItemsSummary, getSectionSummaries, } from "services/collectionService"; -import downloadManager from "services/download"; import { syncFiles } from "services/fileService"; import locationSearchService from "services/locationSearchService"; import { sync } from "services/sync"; diff --git a/web/apps/photos/src/pages/shared-albums/index.tsx b/web/apps/photos/src/pages/shared-albums/index.tsx index 4e479457b1..b4786e1798 100644 --- a/web/apps/photos/src/pages/shared-albums/index.tsx +++ b/web/apps/photos/src/pages/shared-albums/index.tsx @@ -1,3 +1,4 @@ +import downloadManager from "@/new/photos/services/download"; import { EnteFile } from "@/new/photos/types/file"; import { mergeMetadata } from "@/new/photos/utils/file"; import log from "@/next/log"; @@ -45,7 +46,6 @@ import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useDropzone } from "react-dropzone"; -import downloadManager from "services/download"; import { getLocalPublicCollection, getLocalPublicCollectionPassword, diff --git a/web/apps/photos/src/services/clip-service.ts b/web/apps/photos/src/services/clip-service.ts index be59a2aeda..4b9900aed5 100644 --- a/web/apps/photos/src/services/clip-service.ts +++ b/web/apps/photos/src/services/clip-service.ts @@ -1,4 +1,5 @@ import { FILE_TYPE } from "@/media/file-type"; +import downloadManager from "@/new/photos/services/download"; import { getAllLocalFiles, getLocalFiles } from "@/new/photos/services/files"; import { EnteFile } from "@/new/photos/types/file"; import { ensureElectron } from "@/next/electron"; @@ -10,7 +11,6 @@ import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; import PQueue from "p-queue"; import { Embedding } from "types/embedding"; import { getPersonalFiles } from "utils/file"; -import downloadManager from "./download"; import { localCLIPEmbeddings, putEmbedding } from "./embeddingService"; /** Status of CLIP indexing on the images in the user's local library. */ diff --git a/web/apps/photos/src/services/download/clients/photos.ts b/web/apps/photos/src/services/download/clients/photos.ts deleted file mode 100644 index 110c51a093..0000000000 --- a/web/apps/photos/src/services/download/clients/photos.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { EnteFile } from "@/new/photos/types/file"; -import { customAPIOrigin } from "@/next/origins"; -import { CustomError } from "@ente/shared/error"; -import HTTPService from "@ente/shared/network/HTTPService"; -import { retryAsyncFunction } from "@ente/shared/utils"; -import { DownloadClient } from "services/download"; - -export class PhotosDownloadClient implements DownloadClient { - constructor( - private token: string, - private timeout: number, - ) {} - - updateTokens(token: string) { - this.token = token; - } - - async downloadThumbnail(file: EnteFile): Promise { - const token = this.token; - if (!token) throw Error(CustomError.TOKEN_MISSING); - - const customOrigin = await customAPIOrigin(); - - // See: [Note: Passing credentials for self-hosted file fetches] - const getThumbnail = () => { - const opts = { responseType: "arraybuffer", timeout: this.timeout }; - if (customOrigin) { - const params = new URLSearchParams({ token }); - return HTTPService.get( - `${customOrigin}/files/preview/${file.id}?${params.toString()}`, - undefined, - undefined, - opts, - ); - } else { - return HTTPService.get( - `https://thumbnails.ente.io/?fileID=${file.id}`, - undefined, - { "X-Auth-Token": token }, - opts, - ); - } - }; - - const resp = await retryAsyncFunction(getThumbnail); - if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); - return new Uint8Array(resp.data); - } - - async downloadFile( - file: EnteFile, - onDownloadProgress: (event: { loaded: number; total: number }) => void, - ): Promise { - const token = this.token; - if (!token) throw Error(CustomError.TOKEN_MISSING); - - const customOrigin = await customAPIOrigin(); - - // See: [Note: Passing credentials for self-hosted file fetches] - const getFile = () => { - const opts = { - responseType: "arraybuffer", - timeout: this.timeout, - onDownloadProgress, - }; - - if (customOrigin) { - const params = new URLSearchParams({ token }); - return HTTPService.get( - `${customOrigin}/files/download/${file.id}?${params.toString()}`, - undefined, - undefined, - opts, - ); - } else { - return HTTPService.get( - `https://files.ente.io/?fileID=${file.id}`, - undefined, - { "X-Auth-Token": token }, - opts, - ); - } - }; - - const resp = await retryAsyncFunction(getFile); - if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); - return new Uint8Array(resp.data); - } - - async downloadFileStream(file: EnteFile): Promise { - const token = this.token; - if (!token) throw Error(CustomError.TOKEN_MISSING); - - const customOrigin = await customAPIOrigin(); - - // [Note: Passing credentials for self-hosted file fetches] - // - // Fetching files (or thumbnails) in the default self-hosted Ente - // configuration involves a redirection: - // - // 1. The browser makes a HTTP GET to a museum with credentials. Museum - // inspects the credentials, in this case the auth token, and if - // they're valid, returns a HTTP 307 redirect to the pre-signed S3 - // URL that to the file in the configured S3 bucket. - // - // 2. The browser follows the redirect to get the actual file. The URL - // is pre-signed, i.e. already has all credentials needed to prove to - // the S3 object storage that it should serve this response. - // - // For the first step normally we'd pass the auth the token via the - // "X-Auth-Token" HTTP header. In this case though, that would be - // problematic because the browser preserves the request headers when it - // follows the HTTP 307 redirect, and the "X-Auth-Token" header also - // gets sent to the redirected S3 request made in second step. - // - // To avoid this, we pass the token as a query parameter. Generally this - // is not a good idea, but in this case (a) the URL is not a user - // visible one and (b) even if it gets logged, it'll be in the - // self-hosters own service. - // - // Note that Ente's own servers don't have these concerns because we use - // a slightly different flow involving a proxy instead of directly - // connecting to the S3 storage. - // - // 1. The web browser makes a HTTP GET request to a proxy passing it the - // credentials in the "X-Auth-Token". - // - // 2. The proxy then does both the original steps: (a). Use the - // credentials to get the pre signed URL, and (b) fetch that pre - // signed URL and stream back the response. - - const getFile = () => { - if (customOrigin) { - const params = new URLSearchParams({ token }); - return fetch( - `${customOrigin}/files/download/${file.id}?${params.toString()}`, - ); - } else { - return fetch(`https://files.ente.io/?fileID=${file.id}`, { - headers: { - "X-Auth-Token": token, - }, - }); - } - }; - - return retryAsyncFunction(getFile); - } -} diff --git a/web/apps/photos/src/services/download/clients/publicAlbums.ts b/web/apps/photos/src/services/download/clients/publicAlbums.ts deleted file mode 100644 index d471591e63..0000000000 --- a/web/apps/photos/src/services/download/clients/publicAlbums.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { EnteFile } from "@/new/photos/types/file"; -import { customAPIOrigin } from "@/next/origins"; -import { CustomError } from "@ente/shared/error"; -import HTTPService from "@ente/shared/network/HTTPService"; -import { retryAsyncFunction } from "@ente/shared/utils"; -import { DownloadClient } from "services/download"; - -export class PublicAlbumsDownloadClient implements DownloadClient { - private token: string; - private passwordToken: string; - - constructor(private timeout: number) {} - - updateTokens(token: string, passwordToken: string) { - this.token = token; - this.passwordToken = passwordToken; - } - - downloadThumbnail = async (file: EnteFile) => { - const accessToken = this.token; - const accessTokenJWT = this.passwordToken; - if (!accessToken) throw Error(CustomError.TOKEN_MISSING); - const customOrigin = await customAPIOrigin(); - - // See: [Note: Passing credentials for self-hosted file fetches] - const getThumbnail = () => { - const opts = { - responseType: "arraybuffer", - }; - - if (customOrigin) { - const params = new URLSearchParams({ - accessToken, - ...(accessTokenJWT && { accessTokenJWT }), - }); - return HTTPService.get( - `${customOrigin}/public-collection/files/preview/${file.id}?${params.toString()}`, - undefined, - undefined, - opts, - ); - } else { - return HTTPService.get( - `https://public-albums.ente.io/preview/?fileID=${file.id}`, - undefined, - { - "X-Auth-Access-Token": accessToken, - ...(accessTokenJWT && { - "X-Auth-Access-Token-JWT": accessTokenJWT, - }), - }, - opts, - ); - } - }; - - const resp = await getThumbnail(); - if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); - return new Uint8Array(resp.data); - }; - - downloadFile = async ( - file: EnteFile, - onDownloadProgress: (event: { loaded: number; total: number }) => void, - ) => { - const accessToken = this.token; - const accessTokenJWT = this.passwordToken; - if (!accessToken) throw Error(CustomError.TOKEN_MISSING); - - const customOrigin = await customAPIOrigin(); - - // See: [Note: Passing credentials for self-hosted file fetches] - const getFile = () => { - const opts = { - responseType: "arraybuffer", - timeout: this.timeout, - onDownloadProgress, - }; - - if (customOrigin) { - const params = new URLSearchParams({ - accessToken, - ...(accessTokenJWT && { accessTokenJWT }), - }); - return HTTPService.get( - `${customOrigin}/public-collection/files/download/${file.id}?${params.toString()}`, - undefined, - undefined, - opts, - ); - } else { - return HTTPService.get( - `https://public-albums.ente.io/download/?fileID=${file.id}`, - undefined, - { - "X-Auth-Access-Token": accessToken, - ...(accessTokenJWT && { - "X-Auth-Access-Token-JWT": accessTokenJWT, - }), - }, - opts, - ); - } - }; - - const resp = await retryAsyncFunction(getFile); - if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); - return new Uint8Array(resp.data); - }; - - async downloadFileStream(file: EnteFile): Promise { - const accessToken = this.token; - const accessTokenJWT = this.passwordToken; - if (!accessToken) throw Error(CustomError.TOKEN_MISSING); - - const customOrigin = await customAPIOrigin(); - - // See: [Note: Passing credentials for self-hosted file fetches] - const getFile = () => { - if (customOrigin) { - const params = new URLSearchParams({ - accessToken, - ...(accessTokenJWT && { accessTokenJWT }), - }); - return fetch( - `${customOrigin}/public-collection/files/download/${file.id}?${params.toString()}`, - ); - } else { - return fetch( - `https://public-albums.ente.io/download/?fileID=${file.id}`, - { - headers: { - "X-Auth-Access-Token": accessToken, - ...(accessTokenJWT && { - "X-Auth-Access-Token-JWT": accessTokenJWT, - }), - }, - }, - ); - } - }; - - return retryAsyncFunction(getFile); - } -} diff --git a/web/apps/photos/src/services/exif.ts b/web/apps/photos/src/services/exif.ts index 073a695f75..d483dec745 100644 --- a/web/apps/photos/src/services/exif.ts +++ b/web/apps/photos/src/services/exif.ts @@ -1,10 +1,13 @@ import { type FileTypeInfo } from "@/media/file-type"; +import { NULL_LOCATION } from "@/new/photos/services/upload/types"; +import type { + Location, + ParsedExtractedMetadata, +} from "@/new/photos/types/metadata"; import log from "@/next/log"; import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time"; -import { NULL_LOCATION } from "constants/upload"; import exifr from "exifr"; import piexif from "piexifjs"; -import type { Location, ParsedExtractedMetadata } from "types/metadata"; type ParsedEXIFData = Record & Partial<{ diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index df7d23eddf..80f866312e 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -1,9 +1,16 @@ import { FILE_TYPE } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; import type { Metadata } from "@/media/types/file"; +import downloadManager from "@/new/photos/services/download"; +import { + exportMetadataDirectoryName, + exportTrashDirectoryName, +} from "@/new/photos/services/export"; import { getAllLocalFiles } from "@/new/photos/services/files"; import { EnteFile } from "@/new/photos/types/file"; import { mergeMetadata } from "@/new/photos/utils/file"; +import { safeDirectoryName, safeFileName } from "@/new/photos/utils/native-fs"; +import { writeStream } from "@/new/photos/utils/native-stream"; import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; import { wait } from "@/utils/promise"; @@ -31,10 +38,7 @@ import { getNonEmptyPersonalCollections, } from "utils/collection"; import { getPersonalFiles, getUpdatedEXIFFileForDownload } from "utils/file"; -import { safeDirectoryName, safeFileName } from "utils/native-fs"; -import { writeStream } from "utils/native-stream"; import { getAllLocalCollections } from "../collectionService"; -import downloadManager from "../download"; import { migrateExport } from "./migration"; /** Name of the JSON file in which we keep the state of the export. */ @@ -46,18 +50,6 @@ const exportRecordFileName = "export_status.json"; */ const exportDirectoryName = "Ente Photos"; -/** - * Name of the directory in which we put our metadata when exporting to the file - * system. - */ -export const exportMetadataDirectoryName = "metadata"; - -/** - * Name of the directory in which we keep trash items when deleting files that - * have been exported to the local disk previously. - */ -export const exportTrashDirectoryName = "Trash"; - export enum ExportStage { INIT = 0, MIGRATION = 1, diff --git a/web/apps/photos/src/services/export/migration.ts b/web/apps/photos/src/services/export/migration.ts index 75ab0e2f11..1dfbe49d60 100644 --- a/web/apps/photos/src/services/export/migration.ts +++ b/web/apps/photos/src/services/export/migration.ts @@ -1,8 +1,15 @@ import { FILE_TYPE } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; +import downloadManager from "@/new/photos/services/download"; +import { exportMetadataDirectoryName } from "@/new/photos/services/export"; import { getAllLocalFiles } from "@/new/photos/services/files"; import { EnteFile } from "@/new/photos/types/file"; import { mergeMetadata } from "@/new/photos/utils/file"; +import { + safeDirectoryName, + safeFileName, + sanitizeFilename, +} from "@/new/photos/utils/native-fs"; import { ensureElectron } from "@/next/electron"; import { nameAndExtension } from "@/next/file"; import log from "@/next/log"; @@ -10,7 +17,6 @@ import { wait } from "@/utils/promise"; import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; import type { User } from "@ente/shared/user/types"; import { getLocalCollections } from "services/collectionService"; -import downloadManager from "services/download"; import { Collection } from "types/collection"; import { CollectionExportNames, @@ -25,12 +31,6 @@ import { import { getNonEmptyPersonalCollections } from "utils/collection"; import { getIDBasedSortedFiles, getPersonalFiles } from "utils/file"; import { - safeDirectoryName, - safeFileName, - sanitizeFilename, -} from "utils/native-fs"; -import { - exportMetadataDirectoryName, getCollectionIDFromFileUID, getExportRecordFileUID, getLivePhotoExportName, diff --git a/web/apps/photos/src/services/face/f-index.ts b/web/apps/photos/src/services/face/f-index.ts index cf41623e94..48b37acb52 100644 --- a/web/apps/photos/src/services/face/f-index.ts +++ b/web/apps/photos/src/services/face/f-index.ts @@ -1,5 +1,6 @@ import { FILE_TYPE } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; +import DownloadManager from "@/new/photos/services/download"; import type { Box, Dimensions, @@ -8,10 +9,10 @@ import type { } from "@/new/photos/services/face/types"; import { faceIndexingVersion } from "@/new/photos/services/face/types"; import type { EnteFile } from "@/new/photos/types/file"; +import { getRenderableImage } from "@/new/photos/utils/file"; import log from "@/next/log"; import { workerBridge } from "@/next/worker/worker-bridge"; import { Matrix } from "ml-matrix"; -import DownloadManager from "services/download"; import { getSimilarityTransformation } from "similarity-transformation"; import { Matrix as TransformationMatrix, @@ -20,7 +21,6 @@ import { scale, translate, } from "transformation-matrix"; -import { getRenderableImage } from "utils/file"; import { saveFaceCrop } from "./crop"; import { clamp, diff --git a/web/apps/photos/src/services/face/face.worker.ts b/web/apps/photos/src/services/face/face.worker.ts index d74c235cc0..f5cc4b8b6f 100644 --- a/web/apps/photos/src/services/face/face.worker.ts +++ b/web/apps/photos/src/services/face/face.worker.ts @@ -1,6 +1,6 @@ +import downloadManager from "@/new/photos/services/download"; import { EnteFile } from "@/new/photos/types/file"; import { expose } from "comlink"; -import downloadManager from "services/download"; import mlService from "services/machineLearning/machineLearningService"; export class DedicatedMLWorker { diff --git a/web/apps/photos/src/services/fix-exif.ts b/web/apps/photos/src/services/fix-exif.ts index a695f8d3a0..fc504ea622 100644 --- a/web/apps/photos/src/services/fix-exif.ts +++ b/web/apps/photos/src/services/fix-exif.ts @@ -1,14 +1,14 @@ import { FILE_TYPE } from "@/media/file-type"; +import downloadManager from "@/new/photos/services/download"; import { EnteFile } from "@/new/photos/types/file"; +import { detectFileTypeInfo } from "@/new/photos/utils/detect-type"; import log from "@/next/log"; import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time"; import type { FixOption } from "components/FixCreationTime"; -import { detectFileTypeInfo } from "services/detect-type"; import { changeFileCreationTime, updateExistingFilePubMetadata, } from "utils/file"; -import downloadManager from "./download"; import { getParsedExifData } from "./exif"; const EXIF_TIME_TAGS = [ diff --git a/web/apps/photos/src/services/locationSearchService.ts b/web/apps/photos/src/services/locationSearchService.ts index 354c87a712..07c805bac0 100644 --- a/web/apps/photos/src/services/locationSearchService.ts +++ b/web/apps/photos/src/services/locationSearchService.ts @@ -1,6 +1,6 @@ +import type { Location } from "@/new/photos/types/metadata"; import log from "@/next/log"; -import { LocationTagData } from "types/entity"; -import { Location } from "types/metadata"; +import type { LocationTagData } from "types/entity"; export interface City { city: string; diff --git a/web/apps/photos/src/services/logout.ts b/web/apps/photos/src/services/logout.ts index 266247ca98..fbf8249df2 100644 --- a/web/apps/photos/src/services/logout.ts +++ b/web/apps/photos/src/services/logout.ts @@ -1,10 +1,10 @@ +import DownloadManager from "@/new/photos/services/download"; import { terminateFaceWorker } from "@/new/photos/services/face"; import { clearFaceData } from "@/new/photos/services/face/db"; import { clearFeatureFlagSessionState } from "@/new/photos/services/feature-flags"; import log from "@/next/log"; import { accountLogout } from "@ente/accounts/services/logout"; import { clipService } from "services/clip-service"; -import DownloadManager from "./download"; import exportService from "./export"; import mlWorkManager from "./face/mlWorkManager"; @@ -31,7 +31,7 @@ export const photosLogout = async () => { } try { - await DownloadManager.logout(); + DownloadManager.logout(); } catch (e) { ignoreError("download", e); } diff --git a/web/apps/photos/src/services/upload/takeout.ts b/web/apps/photos/src/services/upload/takeout.ts index 24c0a9d267..e0b1307ed9 100644 --- a/web/apps/photos/src/services/upload/takeout.ts +++ b/web/apps/photos/src/services/upload/takeout.ts @@ -1,12 +1,12 @@ /** @file Dealing with the JSON metadata in Google Takeouts */ +import type { UploadItem } from "@/new/photos/services/upload/types"; +import { NULL_LOCATION } from "@/new/photos/services/upload/types"; +import type { Location } from "@/new/photos/types/metadata"; +import { readStream } from "@/new/photos/utils/native-stream"; import { ensureElectron } from "@/next/electron"; import { nameAndExtension } from "@/next/file"; import log from "@/next/log"; -import { NULL_LOCATION } from "constants/upload"; -import type { Location } from "types/metadata"; -import { readStream } from "utils/native-stream"; -import type { UploadItem } from "./types"; export interface ParsedMetadataJSON { creationTime: number; diff --git a/web/apps/photos/src/services/upload/thumbnail.ts b/web/apps/photos/src/services/upload/thumbnail.ts index 91260131d3..255bc68ca3 100644 --- a/web/apps/photos/src/services/upload/thumbnail.ts +++ b/web/apps/photos/src/services/upload/thumbnail.ts @@ -1,12 +1,15 @@ import { FILE_TYPE, type FileTypeInfo } from "@/media/file-type"; import { heicToJPEG } from "@/media/heic-convert"; import { scaledImageDimensions } from "@/media/image"; +import * as ffmpeg from "@/new/photos/services/ffmpeg"; +import { + toDataOrPathOrZipEntry, + type DesktopUploadItem, +} from "@/new/photos/services/upload/types"; 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 { toDataOrPathOrZipEntry, type DesktopUploadItem } from "./types"; /** Maximum width or height of the generated thumbnail */ const maxThumbnailDimension = 720; diff --git a/web/apps/photos/src/services/upload/uploadManager.ts b/web/apps/photos/src/services/upload/uploadManager.ts index 869bbbad27..b66b2ebf8f 100644 --- a/web/apps/photos/src/services/upload/uploadManager.ts +++ b/web/apps/photos/src/services/upload/uploadManager.ts @@ -1,6 +1,12 @@ import { FILE_TYPE } from "@/media/file-type"; import { potentialFileTypeFromExtension } from "@/media/live-photo"; import { getLocalFiles } from "@/new/photos/services/files"; +import type { UploadItem } from "@/new/photos/services/upload/types"; +import { + RANDOM_PERCENTAGE_PROGRESS_FOR_PUT, + UPLOAD_RESULT, + UPLOAD_STAGES, +} from "@/new/photos/services/upload/types"; import { EncryptedEnteFile, EnteFile } from "@/new/photos/types/file"; import { ensureElectron } from "@/next/electron"; import { lowercaseExtension, nameAndExtension } from "@/next/file"; @@ -15,11 +21,6 @@ import { CustomError } from "@ente/shared/error"; import { Events, eventBus } from "@ente/shared/events"; import { Canceler } from "axios"; import type { Remote } from "comlink"; -import { - RANDOM_PERCENTAGE_PROGRESS_FOR_PUT, - UPLOAD_RESULT, - UPLOAD_STAGES, -} from "constants/upload"; import isElectron from "is-electron"; import { getLocalPublicFiles, @@ -35,7 +36,6 @@ import { tryParseTakeoutMetadataJSON, type ParsedMetadataJSON, } from "./takeout"; -import type { UploadItem } from "./types"; import UploadService, { uploadItemFileName, uploader } from "./uploadService"; export type FileID = number; diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index c50407eb94..846c2d6f59 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -2,6 +2,13 @@ import { hasFileHash } from "@/media/file"; import { FILE_TYPE, type FileTypeInfo } from "@/media/file-type"; import { encodeLivePhoto } from "@/media/live-photo"; import type { Metadata } from "@/media/types/file"; +import * as ffmpeg from "@/new/photos/services/ffmpeg"; +import type { UploadItem } from "@/new/photos/services/upload/types"; +import { + NULL_LOCATION, + RANDOM_PERCENTAGE_PROGRESS_FOR_PUT, + UPLOAD_RESULT, +} from "@/new/photos/services/upload/types"; import { EnteFile, MetadataFileAttributes, @@ -11,6 +18,9 @@ import { type FilePublicMagicMetadataProps, } from "@/new/photos/types/file"; import { EncryptedMagicMetadata } from "@/new/photos/types/magicMetadata"; +import type { ParsedExtractedMetadata } from "@/new/photos/types/metadata"; +import { detectFileTypeInfoFromChunk } from "@/new/photos/utils/detect-type"; +import { readStream } from "@/new/photos/utils/native-stream"; import { ensureElectron } from "@/next/electron"; import { basename } from "@/next/file"; import log from "@/next/log"; @@ -21,26 +31,17 @@ import type { B64EncryptionResult } from "@ente/shared/crypto/internal/libsodium import { ENCRYPTION_CHUNK_SIZE } from "@ente/shared/crypto/internal/libsodium"; import { CustomError, handleUploadError } from "@ente/shared/error"; import type { Remote } from "comlink"; -import { - NULL_LOCATION, - RANDOM_PERCENTAGE_PROGRESS_FOR_PUT, - UPLOAD_RESULT, -} from "constants/upload"; import { addToCollection } from "services/collectionService"; import { parseImageMetadata } from "services/exif"; -import * as ffmpeg from "services/ffmpeg"; import { PublicUploadProps, type LivePhotoAssets, } from "services/upload/uploadManager"; -import type { ParsedExtractedMetadata } from "types/metadata"; import { getNonEmptyMagicMetadataProps, updateMagicMetadata, } from "utils/magicMetadata"; -import { readStream } from "utils/native-stream"; import * as convert from "xml-js"; -import { detectFileTypeInfoFromChunk } from "../detect-type"; import { tryParseEpochMicrosecondsFromFileName } from "./date"; import publicUploadHttpClient from "./publicUploadHttpClient"; import type { ParsedMetadataJSON } from "./takeout"; @@ -50,7 +51,6 @@ import { generateThumbnailNative, generateThumbnailWeb, } from "./thumbnail"; -import type { UploadItem } from "./types"; import UploadHttpClient from "./uploadHttpClient"; import type { UploadableUploadItem } from "./uploadManager"; diff --git a/web/apps/photos/src/services/watch.ts b/web/apps/photos/src/services/watch.ts index 597bbe29cc..8c367ee71c 100644 --- a/web/apps/photos/src/services/watch.ts +++ b/web/apps/photos/src/services/watch.ts @@ -4,6 +4,7 @@ */ import { getLocalFiles } from "@/new/photos/services/files"; +import { UPLOAD_RESULT } from "@/new/photos/services/upload/types"; import { EncryptedEnteFile } from "@/new/photos/types/file"; import { ensureElectron } from "@/next/electron"; import { basename, dirname } from "@/next/file"; @@ -14,7 +15,6 @@ import type { FolderWatchSyncedFile, } from "@/next/types/ipc"; import { ensureString } from "@/utils/ensure"; -import { UPLOAD_RESULT } from "constants/upload"; import debounce from "debounce"; import uploadManager, { type UploadItemWithCollection, diff --git a/web/apps/photos/src/types/entity.ts b/web/apps/photos/src/types/entity.ts index 60844ce466..0f22973d21 100644 --- a/web/apps/photos/src/types/entity.ts +++ b/web/apps/photos/src/types/entity.ts @@ -1,4 +1,4 @@ -import { Location } from "types/metadata"; +import { Location } from "@/new/photos/types/metadata"; export enum EntityType { LOCATION_TAG = "location", diff --git a/web/apps/photos/src/types/metadata.ts b/web/apps/photos/src/types/metadata.ts deleted file mode 100644 index 7994e62479..0000000000 --- a/web/apps/photos/src/types/metadata.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface Location { - latitude: number; - longitude: number; -} - -export interface ParsedExtractedMetadata { - location: Location; - creationTime: number; - width: number; - height: number; -} diff --git a/web/apps/photos/src/utils/collection/index.ts b/web/apps/photos/src/utils/collection/index.ts index 12fffe6bfa..d6081567f3 100644 --- a/web/apps/photos/src/utils/collection/index.ts +++ b/web/apps/photos/src/utils/collection/index.ts @@ -1,6 +1,7 @@ import { getAllLocalFiles, getLocalFiles } from "@/new/photos/services/files"; import { EnteFile } from "@/new/photos/types/file"; import { SUB_TYPE, VISIBILITY_STATE } from "@/new/photos/types/magicMetadata"; +import { safeDirectoryName } from "@/new/photos/utils/native-fs"; import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; import { CustomError } from "@ente/shared/error"; @@ -43,7 +44,6 @@ import { import { SetFilesDownloadProgressAttributes } from "types/gallery"; import { downloadFilesWithProgress } from "utils/file"; import { isArchivedCollection, updateMagicMetadata } from "utils/magicMetadata"; -import { safeDirectoryName } from "utils/native-fs"; export enum COLLECTION_OPS_TYPE { ADD, diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index f9bb04b94c..6f2b3b89fb 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -1,7 +1,6 @@ import { FILE_TYPE } from "@/media/file-type"; -import { isNonWebImageFileExtension } from "@/media/formats"; -import { heicToJPEG } from "@/media/heic-convert"; import { decodeLivePhoto } from "@/media/live-photo"; +import DownloadManager from "@/new/photos/services/download"; import { EncryptedEnteFile, EnteFile, @@ -12,21 +11,20 @@ import { FileWithUpdatedMagicMetadata, } from "@/new/photos/types/file"; import { VISIBILITY_STATE } from "@/new/photos/types/magicMetadata"; +import { detectFileTypeInfo } from "@/new/photos/utils/detect-type"; import { mergeMetadata } from "@/new/photos/utils/file"; +import { safeFileName } from "@/new/photos/utils/native-fs"; +import { writeStream } from "@/new/photos/utils/native-stream"; import { lowercaseExtension } from "@/next/file"; import log from "@/next/log"; -import { CustomErrorMessage, type Electron } from "@/next/types/ipc"; -import { workerBridge } from "@/next/worker/worker-bridge"; +import { type Electron } from "@/next/types/ipc"; import { withTimeout } from "@/utils/promise"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; import type { User } from "@ente/shared/user/types"; import { downloadUsingAnchor } from "@ente/shared/utils"; import { t } from "i18next"; -import isElectron from "is-electron"; import { moveToHiddenCollection } from "services/collectionService"; -import { detectFileTypeInfo } from "services/detect-type"; -import DownloadManager from "services/download"; import { updateFileCreationDateInEXIF } from "services/exif"; import { deleteFromTrash, @@ -40,21 +38,6 @@ import { SetFilesDownloadProgressAttributesCreator, } from "types/gallery"; import { isArchivedFile, updateMagicMetadata } from "utils/magicMetadata"; -import { safeFileName } from "utils/native-fs"; -import { writeStream } from "utils/native-stream"; - -const SUPPORTED_RAW_FORMATS = [ - "heic", - "rw2", - "tiff", - "arw", - "cr3", - "cr2", - "nef", - "psd", - "dng", - "tif", -]; export enum FILE_OPS_TYPE { DOWNLOAD, @@ -66,22 +49,6 @@ export enum FILE_OPS_TYPE { DELETE_PERMANENTLY, } -class ModuleState { - /** - * This will be set to true if we get an error from the Node.js side of our - * desktop app telling us that native JPEG conversion is not available for - * the current OS/arch combination. - * - * That way, we can stop pestering it again and again (saving an IPC - * round-trip). - * - * Note the double negative when it is used. - */ - isNativeJPEGConversionNotAvailable = false; -} - -const moduleState = new ModuleState(); - /** * @returns a string to use as an identifier when logging information about the * given {@link enteFile}. The returned string contains the file name (for ease @@ -257,80 +224,6 @@ export async function decryptFile( } } -/** - * The returned blob.type is filled in, whenever possible, with the MIME type of - * the data that we're dealing with. - */ -export const getRenderableImage = async (fileName: string, imageBlob: Blob) => { - try { - const tempFile = new File([imageBlob], fileName); - const fileTypeInfo = await detectFileTypeInfo(tempFile); - log.debug( - () => - `Need renderable image for ${JSON.stringify({ fileName, ...fileTypeInfo })}`, - ); - const { extension } = fileTypeInfo; - - if (!isNonWebImageFileExtension(extension)) { - // Either it is something that the browser already knows how to - // render, or something we don't even about yet. - const mimeType = fileTypeInfo.mimeType; - if (!mimeType) { - log.info( - "Trying to render a file without a MIME type", - fileName, - ); - return imageBlob; - } else { - return new Blob([imageBlob], { type: mimeType }); - } - } - - const available = !moduleState.isNativeJPEGConversionNotAvailable; - if (isElectron() && available && isSupportedRawFormat(extension)) { - // If we're running in our desktop app, see if our Node.js layer can - // convert this into a JPEG using native tools for us. - try { - return await nativeConvertToJPEG(imageBlob); - } catch (e) { - if (e.message.endsWith(CustomErrorMessage.NotAvailable)) { - moduleState.isNativeJPEGConversionNotAvailable = true; - } else { - log.error("Native conversion to JPEG failed", e); - } - } - } - - if (extension == "heic" || extension == "heif") { - // For HEIC/HEIF files we can use our web HEIC converter. - return await heicToJPEG(imageBlob); - } - - return undefined; - } catch (e) { - log.error(`Failed to get renderable image for ${fileName}`, e); - return undefined; - } -}; - -const nativeConvertToJPEG = async (imageBlob: Blob) => { - const startTime = Date.now(); - const imageData = new Uint8Array(await imageBlob.arrayBuffer()); - const electron = globalThis.electron; - // If we're running in a worker, we need to reroute the request back to - // the main thread since workers don't have access to the `window` (and - // thus, to the `window.electron`) object. - const jpegData = electron - ? await electron.convertToJPEG(imageData) - : await workerBridge.convertToJPEG(imageData); - log.debug(() => `Native JPEG conversion took ${Date.now() - startTime} ms`); - return new Blob([jpegData], { type: "image/jpeg" }); -}; - -export function isSupportedRawFormat(exactType: string) { - return SUPPORTED_RAW_FORMATS.includes(exactType.toLowerCase()); -} - export async function changeFilesVisibility( files: EnteFile[], visibility: VISIBILITY_STATE, diff --git a/web/packages/eslint-config/package.json b/web/packages/eslint-config/package.json index 699a1ed4d3..fe1006ca94 100644 --- a/web/packages/eslint-config/package.json +++ b/web/packages/eslint-config/package.json @@ -1,6 +1,7 @@ { "name": "@ente/eslint-config", - "version": "1.0.0", + "version": "0.0.0", + "private": "true", "main": "index.js", "dependencies": {}, "devDependencies": { diff --git a/web/packages/new/.eslintrc.js b/web/packages/new/.eslintrc.js index 53a0075961..37111028d5 100644 --- a/web/packages/new/.eslintrc.js +++ b/web/packages/new/.eslintrc.js @@ -1,3 +1,8 @@ module.exports = { extends: ["@/build-config/eslintrc-react"], + // TODO: These can be removed when we start using ffmpeg upstream. For an reason + // I haven't investigated much, when we run eslint on our CI, it seems to behave + // differently than locally and give a lot of warnings that possibly arise from + // it not being able to locate ffmpeg-wasm. + ignorePatterns: ["**/ffmpeg/worker.ts"], }; diff --git a/web/apps/photos/src/services/download/index.ts b/web/packages/new/photos/services/download.ts similarity index 52% rename from web/apps/photos/src/services/download/index.ts rename to web/packages/new/photos/services/download.ts index 179aa7cbc8..0a26c47502 100644 --- a/web/apps/photos/src/services/download/index.ts +++ b/web/packages/new/photos/services/download.ts @@ -1,29 +1,34 @@ +// TODO: Remove this override +/* eslint-disable @typescript-eslint/no-empty-function */ + import { FILE_TYPE } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; -import { +import * as ffmpeg from "@/new/photos/services/ffmpeg"; +import type { EnteFile, - type LivePhotoSourceURL, - type SourceURLs, + LivePhotoSourceURL, + SourceURLs, } from "@/new/photos/types/file"; +import { getRenderableImage } from "@/new/photos/utils/file"; +import { isDesktop } from "@/next/app"; import { blobCache, type BlobCache } from "@/next/blob-cache"; import log from "@/next/log"; +import { customAPIOrigin } from "@/next/origins"; +import { ensure } from "@/utils/ensure"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker"; import { CustomError } from "@ente/shared/error"; import { isPlaybackPossible } from "@ente/shared/media/video-playback"; +import HTTPService from "@ente/shared/network/HTTPService"; +import { retryAsyncFunction } from "@ente/shared/utils"; import type { Remote } from "comlink"; -import isElectron from "is-electron"; -import * as ffmpeg from "services/ffmpeg"; -import { getRenderableImage } from "utils/file"; -import { PhotosDownloadClient } from "./clients/photos"; -import { PublicAlbumsDownloadClient } from "./clients/publicAlbums"; export type OnDownloadProgress = (event: { loaded: number; total: number; }) => void; -export interface DownloadClient { +interface DownloadClient { updateTokens: (token: string, passwordToken?: string) => void; downloadThumbnail: ( file: EnteFile, @@ -37,8 +42,8 @@ export interface DownloadClient { } class DownloadManagerImpl { - private ready: boolean = false; - private downloadClient: DownloadClient; + private ready = false; + private downloadClient: DownloadClient | undefined; /** Local cache for thumbnails. Might not be available. */ private thumbnailCache?: BlobCache; /** @@ -47,11 +52,14 @@ class DownloadManagerImpl { * Only available when we're running in the desktop app. */ private fileCache?: BlobCache; - private cryptoWorker: Remote; + private cryptoWorker: Remote | undefined; private fileObjectURLPromises = new Map>(); private fileConversionPromises = new Map>(); - private thumbnailObjectURLPromises = new Map>(); + private thumbnailObjectURLPromises = new Map< + number, + Promise + >(); private fileDownloadProgress = new Map(); @@ -86,12 +94,17 @@ class DownloadManagerImpl { throw new Error( "Attempting to use an uninitialized download manager", ); + + return { + downloadClient: ensure(this.downloadClient), + cryptoWorker: ensure(this.cryptoWorker), + }; } - async logout() { + logout() { this.ready = false; - this.cryptoWorker = null; - this.downloadClient = null; + this.cryptoWorker = undefined; + this.downloadClient = undefined; this.fileObjectURLPromises.clear(); this.fileConversionPromises.clear(); this.thumbnailObjectURLPromises.clear(); @@ -100,11 +113,8 @@ class DownloadManagerImpl { } updateToken(token: string, passwordToken?: string) { - this.downloadClient.updateTokens(token, passwordToken); - } - - updateCryptoWorker(cryptoWorker: Remote) { - this.cryptoWorker = cryptoWorker; + const { downloadClient } = this.ensureInitialized(); + downloadClient.updateTokens(token, passwordToken); } setProgressUpdater(progressUpdater: (value: Map) => void) { @@ -112,10 +122,12 @@ class DownloadManagerImpl { } private downloadThumb = async (file: EnteFile) => { - const encrypted = await this.downloadClient.downloadThumbnail(file); - const decrypted = await this.cryptoWorker.decryptThumbnail( + const { downloadClient, cryptoWorker } = this.ensureInitialized(); + + const encrypted = await downloadClient.downloadThumbnail(file); + const decrypted = await cryptoWorker.decryptThumbnail( encrypted, - await this.cryptoWorker.fromB64(file.thumbnail.decryptionHeader), + await cryptoWorker.fromB64(file.thumbnail.decryptionHeader), file.key, ); return decrypted; @@ -127,14 +139,17 @@ class DownloadManagerImpl { const key = file.id.toString(); const cached = await this.thumbnailCache?.get(key); if (cached) return new Uint8Array(await cached.arrayBuffer()); - if (localOnly) return null; + if (localOnly) return undefined; const thumb = await this.downloadThumb(file); - this.thumbnailCache?.put(key, new Blob([thumb])); + await this.thumbnailCache?.put(key, new Blob([thumb])); return thumb; } - async getThumbnailForPreview(file: EnteFile, localOnly = false) { + async getThumbnailForPreview( + file: EnteFile, + localOnly = false, + ): Promise { this.ensureInitialized(); try { if (!this.thumbnailObjectURLPromises.has(file.id)) { @@ -160,15 +175,19 @@ class DownloadManagerImpl { getFileForPreview = async ( file: EnteFile, forceConvert = false, - ): Promise => { + ): Promise => { this.ensureInitialized(); try { const getFileForPreviewPromise = async () => { const fileBlob = await new Response( await this.getFile(file, true), ).blob(); - const { url: originalFileURL } = - await this.fileObjectURLPromises.get(file.id); + // TODO: Is this ensure valid? + // The existing code was already dereferencing, so it shouldn't + // affect behaviour. + const { url: originalFileURL } = ensure( + await this.fileObjectURLPromises.get(file.id), + ); const converted = await getRenderableFileURL( file, @@ -196,7 +215,7 @@ class DownloadManagerImpl { async getFile( file: EnteFile, cacheInMemory = false, - ): Promise> { + ): Promise | null> { this.ensureInitialized(); try { const getFilePromise = async (): Promise => { @@ -215,7 +234,12 @@ class DownloadManagerImpl { } this.fileObjectURLPromises.set(file.id, getFilePromise()); } - const fileURLs = await this.fileObjectURLPromises.get(file.id); + // TODO: Is this ensure valid? + // The existing code was already dereferencing, so it shouldn't + // affect behaviour. + const fileURLs = ensure( + await this.fileObjectURLPromises.get(file.id), + ); if (fileURLs.isOriginal) { const fileStream = (await fetch(fileURLs.url as string)).body; return fileStream; @@ -231,12 +255,15 @@ class DownloadManagerImpl { private async downloadFile( file: EnteFile, - ): Promise> { + ): Promise | null> { + const { downloadClient, cryptoWorker } = this.ensureInitialized(); + log.info(`download attempted for file id ${file.id}`); const onDownloadProgress = this.trackDownloadProgress( file.id, - file.info?.fileSize, + // TODO: Is info supposed to be optional though? + file.info?.fileSize ?? 0, ); const cacheKey = file.id.toString(); @@ -248,23 +275,29 @@ class DownloadManagerImpl { const cachedBlob = await this.fileCache?.get(cacheKey); let encryptedArrayBuffer = await cachedBlob?.arrayBuffer(); if (!encryptedArrayBuffer) { - const array = await this.downloadClient.downloadFile( + const array = await downloadClient.downloadFile( file, onDownloadProgress, ); encryptedArrayBuffer = array.buffer; - this.fileCache?.put(cacheKey, new Blob([encryptedArrayBuffer])); + await this.fileCache?.put( + cacheKey, + new Blob([encryptedArrayBuffer]), + ); } this.clearDownloadProgress(file.id); try { - const decrypted = await this.cryptoWorker.decryptFile( + const decrypted = await cryptoWorker.decryptFile( new Uint8Array(encryptedArrayBuffer), - await this.cryptoWorker.fromB64(file.file.decryptionHeader), + await cryptoWorker.fromB64(file.file.decryptionHeader), file.key, ); return new Response(decrypted).body; } catch (e) { - if (e.message === CustomError.PROCESSING_FAILED) { + if ( + e instanceof Error && + e.message == CustomError.PROCESSING_FAILED + ) { log.error( `Failed to process file with fileID:${file.id}, localID: ${file.metadata.localID}, version: ${file.metadata.version}, deviceFolder:${file.metadata.deviceFolder}`, e, @@ -278,7 +311,7 @@ class DownloadManagerImpl { let res: Response; if (cachedBlob) res = new Response(cachedBlob); else { - res = await this.downloadClient.downloadFileStream(file); + res = await downloadClient.downloadFileStream(file); // We don't have a files cache currently, so this was already a // no-op. But even if we had a cache, this seems sus, because // res.blob() will read the stream and I'd think then trying to do @@ -286,20 +319,20 @@ class DownloadManagerImpl { // this.fileCache?.put(cacheKey, await res.blob()); } - const reader = res.body.getReader(); + const body = res.body; + if (!body) return null; + const reader = body.getReader(); - const contentLength = +res.headers.get("Content-Length") ?? 0; + const contentLength = + parseInt(res.headers.get("Content-Length") ?? "") || 0; let downloadedBytes = 0; - const decryptionHeader = await this.cryptoWorker.fromB64( + const decryptionHeader = await cryptoWorker.fromB64( file.file.decryptionHeader, ); - const fileKey = await this.cryptoWorker.fromB64(file.key); + const fileKey = await cryptoWorker.fromB64(file.key); const { pullState, decryptionChunkSize } = - await this.cryptoWorker.initChunkDecryption( - decryptionHeader, - fileKey, - ); + await cryptoWorker.initChunkDecryption(decryptionHeader, fileKey); let leftoverBytes = new Uint8Array(); @@ -333,7 +366,7 @@ class DownloadManagerImpl { // and we might need multiple iterations to drain it all. while (data.length >= decryptionChunkSize) { const { decryptedData } = - await this.cryptoWorker.decryptFileChunk( + await cryptoWorker.decryptFileChunk( data.slice(0, decryptionChunkSize), pullState, ); @@ -347,7 +380,7 @@ class DownloadManagerImpl { // full chunk, no more bytes are going to come. if (data.length) { const { decryptedData } = - await this.cryptoWorker.decryptFileChunk( + await cryptoWorker.decryptFileChunk( data, pullState, ); @@ -395,7 +428,7 @@ const DownloadManager = new DownloadManagerImpl(); export default DownloadManager; -const createDownloadClient = (token: string): DownloadClient => { +const createDownloadClient = (token: string | undefined): DownloadClient => { const timeout = 300000; // 5 minute if (token) { return new PhotosDownloadClient(token, timeout); @@ -410,14 +443,14 @@ async function getRenderableFileURL( originalFileURL: string, forceConvert: boolean, ): Promise { - const existingOrNewObjectURL = (convertedBlob: Blob) => + const existingOrNewObjectURL = (convertedBlob: Blob | null | undefined) => convertedBlob ? convertedBlob === fileBlob ? originalFileURL : URL.createObjectURL(convertedBlob) : undefined; - let url: SourceURLs["url"]; + let url: SourceURLs["url"] | undefined; let isOriginal: boolean; let isRenderable: boolean; let type: SourceURLs["type"] = "normal"; @@ -464,14 +497,15 @@ async function getRenderableFileURL( } } - return { url, isOriginal, isRenderable, type, mimeType }; + // TODO: Can we remove this ensure and reflect it in the types? + return { url: ensure(url), isOriginal, isRenderable, type, mimeType }; } async function getRenderableLivePhotoURL( file: EnteFile, fileBlob: Blob, forceConvert: boolean, -): Promise { +): Promise { const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob); const getRenderableLivePhotoImageURL = async () => { @@ -481,11 +515,12 @@ async function getRenderableLivePhotoURL( livePhoto.imageFileName, imageBlob, ); + if (!convertedImageBlob) return undefined; return URL.createObjectURL(convertedImageBlob); } catch (e) { //ignore and return null - return null; + return undefined; } }; @@ -498,10 +533,11 @@ async function getRenderableLivePhotoURL( forceConvert, true, ); + if (!convertedVideoBlob) return undefined; return URL.createObjectURL(convertedVideoBlob); } catch (e) { //ignore and return null - return null; + return undefined; } }; @@ -524,7 +560,7 @@ async function getPlayableVideo( if (isPlayable && !forceConvert) { return videoBlob; } else { - if (!forceConvert && !runOnWeb && !isElectron()) { + if (!forceConvert && !runOnWeb && !isDesktop) { return null; } log.info(`Converting video ${videoNameTitle} to mp4`); @@ -536,3 +572,293 @@ async function getPlayableVideo( return null; } } + +class PhotosDownloadClient implements DownloadClient { + constructor( + private token: string, + private timeout: number, + ) {} + + updateTokens(token: string) { + this.token = token; + } + + async downloadThumbnail(file: EnteFile): Promise { + const token = this.token; + if (!token) throw Error(CustomError.TOKEN_MISSING); + + const customOrigin = await customAPIOrigin(); + + // See: [Note: Passing credentials for self-hosted file fetches] + const getThumbnail = () => { + const opts = { responseType: "arraybuffer", timeout: this.timeout }; + if (customOrigin) { + const params = new URLSearchParams({ token }); + return HTTPService.get( + `${customOrigin}/files/preview/${file.id}?${params.toString()}`, + undefined, + undefined, + opts, + ); + } else { + return HTTPService.get( + `https://thumbnails.ente.io/?fileID=${file.id}`, + undefined, + { "X-Auth-Token": token }, + opts, + ); + } + }; + + const resp = await retryAsyncFunction(getThumbnail); + if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); + // TODO: Remove this cast (it won't be needed when we migrate this from + // axios to fetch). + return new Uint8Array(resp.data as ArrayBuffer); + } + + async downloadFile( + file: EnteFile, + onDownloadProgress: (event: { loaded: number; total: number }) => void, + ): Promise { + const token = this.token; + if (!token) throw Error(CustomError.TOKEN_MISSING); + + const customOrigin = await customAPIOrigin(); + + // See: [Note: Passing credentials for self-hosted file fetches] + const getFile = () => { + const opts = { + responseType: "arraybuffer", + timeout: this.timeout, + onDownloadProgress, + }; + + if (customOrigin) { + const params = new URLSearchParams({ token }); + return HTTPService.get( + `${customOrigin}/files/download/${file.id}?${params.toString()}`, + undefined, + undefined, + opts, + ); + } else { + return HTTPService.get( + `https://files.ente.io/?fileID=${file.id}`, + undefined, + { "X-Auth-Token": token }, + opts, + ); + } + }; + + const resp = await retryAsyncFunction(getFile); + if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); + // TODO: Remove this cast (it won't be needed when we migrate this from + // axios to fetch). + return new Uint8Array(resp.data as ArrayBuffer); + } + + async downloadFileStream(file: EnteFile): Promise { + const token = this.token; + if (!token) throw Error(CustomError.TOKEN_MISSING); + + const customOrigin = await customAPIOrigin(); + + // [Note: Passing credentials for self-hosted file fetches] + // + // Fetching files (or thumbnails) in the default self-hosted Ente + // configuration involves a redirection: + // + // 1. The browser makes a HTTP GET to a museum with credentials. Museum + // inspects the credentials, in this case the auth token, and if + // they're valid, returns a HTTP 307 redirect to the pre-signed S3 + // URL that to the file in the configured S3 bucket. + // + // 2. The browser follows the redirect to get the actual file. The URL + // is pre-signed, i.e. already has all credentials needed to prove to + // the S3 object storage that it should serve this response. + // + // For the first step normally we'd pass the auth the token via the + // "X-Auth-Token" HTTP header. In this case though, that would be + // problematic because the browser preserves the request headers when it + // follows the HTTP 307 redirect, and the "X-Auth-Token" header also + // gets sent to the redirected S3 request made in second step. + // + // To avoid this, we pass the token as a query parameter. Generally this + // is not a good idea, but in this case (a) the URL is not a user + // visible one and (b) even if it gets logged, it'll be in the + // self-hosters own service. + // + // Note that Ente's own servers don't have these concerns because we use + // a slightly different flow involving a proxy instead of directly + // connecting to the S3 storage. + // + // 1. The web browser makes a HTTP GET request to a proxy passing it the + // credentials in the "X-Auth-Token". + // + // 2. The proxy then does both the original steps: (a). Use the + // credentials to get the pre signed URL, and (b) fetch that pre + // signed URL and stream back the response. + + const getFile = () => { + if (customOrigin) { + const params = new URLSearchParams({ token }); + return fetch( + `${customOrigin}/files/download/${file.id}?${params.toString()}`, + ); + } else { + return fetch(`https://files.ente.io/?fileID=${file.id}`, { + headers: { + "X-Auth-Token": token, + }, + }); + } + }; + + return retryAsyncFunction(getFile); + } +} + +class PublicAlbumsDownloadClient implements DownloadClient { + private token: string | undefined; + private passwordToken: string | undefined; + + constructor(private timeout: number) {} + + updateTokens(token: string, passwordToken?: string) { + this.token = token; + this.passwordToken = passwordToken; + } + + downloadThumbnail = async (file: EnteFile) => { + const accessToken = this.token; + const accessTokenJWT = this.passwordToken; + if (!accessToken) throw Error(CustomError.TOKEN_MISSING); + const customOrigin = await customAPIOrigin(); + + // See: [Note: Passing credentials for self-hosted file fetches] + const getThumbnail = () => { + const opts = { + responseType: "arraybuffer", + }; + + if (customOrigin) { + const params = new URLSearchParams({ + accessToken, + ...(accessTokenJWT && { accessTokenJWT }), + }); + return HTTPService.get( + `${customOrigin}/public-collection/files/preview/${file.id}?${params.toString()}`, + undefined, + undefined, + opts, + ); + } else { + return HTTPService.get( + `https://public-albums.ente.io/preview/?fileID=${file.id}`, + undefined, + { + "X-Auth-Access-Token": accessToken, + ...(accessTokenJWT && { + "X-Auth-Access-Token-JWT": accessTokenJWT, + }), + }, + opts, + ); + } + }; + + const resp = await getThumbnail(); + if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); + // TODO: Remove this cast (it won't be needed when we migrate this from + // axios to fetch). + return new Uint8Array(resp.data as ArrayBuffer); + }; + + downloadFile = async ( + file: EnteFile, + onDownloadProgress: (event: { loaded: number; total: number }) => void, + ) => { + const accessToken = this.token; + const accessTokenJWT = this.passwordToken; + if (!accessToken) throw Error(CustomError.TOKEN_MISSING); + + const customOrigin = await customAPIOrigin(); + + // See: [Note: Passing credentials for self-hosted file fetches] + const getFile = () => { + const opts = { + responseType: "arraybuffer", + timeout: this.timeout, + onDownloadProgress, + }; + + if (customOrigin) { + const params = new URLSearchParams({ + accessToken, + ...(accessTokenJWT && { accessTokenJWT }), + }); + return HTTPService.get( + `${customOrigin}/public-collection/files/download/${file.id}?${params.toString()}`, + undefined, + undefined, + opts, + ); + } else { + return HTTPService.get( + `https://public-albums.ente.io/download/?fileID=${file.id}`, + undefined, + { + "X-Auth-Access-Token": accessToken, + ...(accessTokenJWT && { + "X-Auth-Access-Token-JWT": accessTokenJWT, + }), + }, + opts, + ); + } + }; + + const resp = await retryAsyncFunction(getFile); + if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED); + // TODO: Remove this cast (it won't be needed when we migrate this from + // axios to fetch). + return new Uint8Array(resp.data as ArrayBuffer); + }; + + async downloadFileStream(file: EnteFile): Promise { + const accessToken = this.token; + const accessTokenJWT = this.passwordToken; + if (!accessToken) throw Error(CustomError.TOKEN_MISSING); + + const customOrigin = await customAPIOrigin(); + + // See: [Note: Passing credentials for self-hosted file fetches] + const getFile = () => { + if (customOrigin) { + const params = new URLSearchParams({ + accessToken, + ...(accessTokenJWT && { accessTokenJWT }), + }); + return fetch( + `${customOrigin}/public-collection/files/download/${file.id}?${params.toString()}`, + ); + } else { + return fetch( + `https://public-albums.ente.io/download/?fileID=${file.id}`, + { + headers: { + "X-Auth-Access-Token": accessToken, + ...(accessTokenJWT && { + "X-Auth-Access-Token-JWT": accessTokenJWT, + }), + }, + }, + ); + } + }; + + return retryAsyncFunction(getFile); + } +} diff --git a/web/packages/new/photos/services/export.ts b/web/packages/new/photos/services/export.ts new file mode 100644 index 0000000000..40229741ed --- /dev/null +++ b/web/packages/new/photos/services/export.ts @@ -0,0 +1,11 @@ +/** + * Name of the directory in which we put our metadata when exporting to the file + * system. + */ +export const exportMetadataDirectoryName = "metadata"; + +/** + * Name of the directory in which we keep trash items when deleting files that + * have been exported to the local disk previously. + */ +export const exportTrashDirectoryName = "Trash"; diff --git a/web/apps/photos/src/constants/ffmpeg.ts b/web/packages/new/photos/services/ffmpeg/constants.ts similarity index 100% rename from web/apps/photos/src/constants/ffmpeg.ts rename to web/packages/new/photos/services/ffmpeg/constants.ts diff --git a/web/apps/photos/src/services/ffmpeg.ts b/web/packages/new/photos/services/ffmpeg/index.ts similarity index 89% rename from web/apps/photos/src/services/ffmpeg.ts rename to web/packages/new/photos/services/ffmpeg/index.ts index 800df1e953..02913ed797 100644 --- a/web/apps/photos/src/services/ffmpeg.ts +++ b/web/packages/new/photos/services/ffmpeg/index.ts @@ -1,3 +1,16 @@ +import { + NULL_LOCATION, + toDataOrPathOrZipEntry, + type DesktopUploadItem, + type UploadItem, +} from "@/new/photos/services/upload/types"; +import type { ParsedExtractedMetadata } from "@/new/photos/types/metadata"; +import { + readConvertToMP4Done, + readConvertToMP4Stream, + writeConvertToMP4Stream, +} from "@/new/photos/utils/native-stream"; +import { ensureElectron } from "@/next/electron"; import type { Electron } from "@/next/types/ipc"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time"; @@ -6,20 +19,8 @@ import { ffmpegPathPlaceholder, inputPathPlaceholder, outputPathPlaceholder, -} from "constants/ffmpeg"; -import { NULL_LOCATION } from "constants/upload"; -import type { ParsedExtractedMetadata } from "types/metadata"; -import { - readConvertToMP4Done, - readConvertToMP4Stream, - writeConvertToMP4Stream, -} from "utils/native-stream"; -import type { DedicatedFFmpegWorker } from "worker/ffmpeg.worker"; -import { - toDataOrPathOrZipEntry, - type DesktopUploadItem, - type UploadItem, -} from "./upload/types"; +} from "./constants"; +import type { DedicatedFFmpegWorker } from "./worker"; /** * Generate a thumbnail for the given video using a wasm FFmpeg running in a web @@ -112,7 +113,7 @@ export const extractVideoMetadata = async ( const outputData = uploadItem instanceof File ? await ffmpegExecWeb(command, uploadItem, "txt") - : await electron.ffmpegExec( + : await ensureElectron().ffmpegExec( command, toDataOrPathOrZipEntry(uploadItem), "txt", @@ -164,8 +165,8 @@ function parseFFmpegExtractedMetadata(encodedMetadata: Uint8Array) { property.split("="), ); const validKeyValuePairs = metadataKeyValueArray.filter( - (keyValueArray) => keyValueArray.length === 2, - ) as Array<[string, string]>; + (keyValueArray) => keyValueArray.length == 2, + ) as [string, string][]; const metadataMap = Object.fromEntries(validKeyValuePairs); @@ -190,19 +191,19 @@ function parseFFmpegExtractedMetadata(encodedMetadata: Uint8Array) { return parsedMetadata; } -function parseAppleISOLocation(isoLocation: string) { +const parseAppleISOLocation = (isoLocation: string | undefined) => { let location = { ...NULL_LOCATION }; if (isoLocation) { - const [latitude, longitude] = isoLocation + const m = isoLocation .match(/(\+|-)\d+\.*\d+/g) - .map((x) => parseFloat(x)); + ?.map((x) => parseFloat(x)); - location = { latitude, longitude }; + location = { latitude: m?.at(0) ?? null, longitude: m?.at(1) ?? null }; } return location; -} +}; -function parseCreationTime(creationTime: string) { +const parseCreationTime = (creationTime: string | undefined) => { let dateTime = null; if (creationTime) { dateTime = validateAndGetCreationUnixTimeInMicroSeconds( @@ -210,7 +211,7 @@ function parseCreationTime(creationTime: string) { ); } return dateTime; -} +}; /** * Run the given FFmpeg command using a wasm FFmpeg running in a web worker. @@ -258,18 +259,18 @@ export const convertToMP4 = async (blob: Blob): Promise => { const convertToMP4Native = async (electron: Electron, blob: Blob) => { const token = await writeConvertToMP4Stream(electron, blob); const mp4Blob = await readConvertToMP4Stream(electron, token); - readConvertToMP4Done(electron, token); + await readConvertToMP4Done(electron, token); return mp4Blob; }; /** Lazily create a singleton instance of our worker */ class WorkerFactory { - private instance: Promise>; + private instance: Promise> | undefined; private createComlinkWorker = () => new ComlinkWorker( "ffmpeg-worker", - new Worker(new URL("worker/ffmpeg.worker.ts", import.meta.url)), + new Worker(new URL("worker.ts", import.meta.url)), ); async lazy() { diff --git a/web/apps/photos/src/worker/ffmpeg.worker.ts b/web/packages/new/photos/services/ffmpeg/worker.ts similarity index 89% rename from web/apps/photos/src/worker/ffmpeg.worker.ts rename to web/packages/new/photos/services/ffmpeg/worker.ts index 06ba05be9e..e293b9aed7 100644 --- a/web/apps/photos/src/worker/ffmpeg.worker.ts +++ b/web/packages/new/photos/services/ffmpeg/worker.ts @@ -1,11 +1,12 @@ import log from "@/next/log"; +import { ensure } from "@/utils/ensure"; import QueueProcessor from "@ente/shared/utils/queueProcessor"; import { expose } from "comlink"; import { ffmpegPathPlaceholder, inputPathPlaceholder, outputPathPlaceholder, -} from "constants/ffmpeg"; +} from "./constants"; // When we run tsc on CI, the line below errors out // @@ -22,9 +23,9 @@ import { // Note that we can't use @ts-expect-error since it doesn't error out when // actually building! // -// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error // @ts-ignore -import { FFmpeg, createFFmpeg } from "ffmpeg-wasm"; +import { createFFmpeg, type FFmpeg } from "ffmpeg-wasm"; export class DedicatedFFmpegWorker { private ffmpeg: FFmpeg; @@ -106,7 +107,7 @@ const randomPrefix = () => { let result = ""; for (let i = 0; i < 10; i++) - result += alphabet[Math.floor(Math.random() * alphabet.length)]; + result += ensure(alphabet[Math.floor(Math.random() * alphabet.length)]); return result; }; @@ -127,4 +128,5 @@ const substitutePlaceholders = ( return segment; } }) - .filter((c) => !!c); + // TODO: The type guard should automatically get deduced with TS 5.5 + .filter((s): s is string => !!s); diff --git a/web/apps/photos/src/services/upload/types.ts b/web/packages/new/photos/services/upload/types.ts similarity index 81% rename from web/apps/photos/src/services/upload/types.ts rename to web/packages/new/photos/services/upload/types.ts index 25e2ab408a..f11ba90961 100644 --- a/web/apps/photos/src/services/upload/types.ts +++ b/web/packages/new/photos/services/upload/types.ts @@ -1,4 +1,5 @@ import type { ZipItem } from "@/next/types/ipc"; +import type { Location } from "../../types/metadata"; /** * An item to upload is one of the following: @@ -55,3 +56,28 @@ export const toDataOrPathOrZipEntry = (desktopUploadItem: DesktopUploadItem) => typeof desktopUploadItem == "string" || Array.isArray(desktopUploadItem) ? desktopUploadItem : desktopUploadItem.path; + +export const RANDOM_PERCENTAGE_PROGRESS_FOR_PUT = () => 90 + 10 * Math.random(); + +export const NULL_LOCATION: Location = { latitude: null, longitude: null }; + +export enum UPLOAD_STAGES { + START, + READING_GOOGLE_METADATA_FILES, + EXTRACTING_METADATA, + UPLOADING, + CANCELLING, + FINISH, +} + +export enum UPLOAD_RESULT { + FAILED, + ALREADY_UPLOADED, + UNSUPPORTED, + BLOCKED, + TOO_LARGE, + LARGER_THAN_AVAILABLE_STORAGE, + UPLOADED, + UPLOADED_WITH_STATIC_THUMBNAIL, + ADDED_SYMLINK, +} diff --git a/web/packages/new/photos/types/file.ts b/web/packages/new/photos/types/file.ts index e76e527393..7cbbb8eb38 100644 --- a/web/packages/new/photos/types/file.ts +++ b/web/packages/new/photos/types/file.ts @@ -26,7 +26,7 @@ export interface EncryptedEnteFile { file: S3FileAttributes; thumbnail: S3FileAttributes; metadata: MetadataFileAttributes; - info: FileInfo; + info: FileInfo | undefined; magicMetadata: EncryptedMagicMetadata; pubMagicMetadata: EncryptedMagicMetadata; encryptedKey: string; @@ -63,8 +63,8 @@ export interface EnteFile } export interface LivePhotoSourceURL { - image: () => Promise; - video: () => Promise; + image: () => Promise; + video: () => Promise; } export interface LoadedLivePhotoSourceURL { diff --git a/web/packages/new/photos/types/metadata.ts b/web/packages/new/photos/types/metadata.ts new file mode 100644 index 0000000000..8c7ee8088e --- /dev/null +++ b/web/packages/new/photos/types/metadata.ts @@ -0,0 +1,11 @@ +export interface Location { + latitude: number | null; + longitude: number | null; +} + +export interface ParsedExtractedMetadata { + location: Location; + creationTime: number | null; + width: number | null; + height: number | null; +} diff --git a/web/apps/photos/src/services/detect-type.ts b/web/packages/new/photos/utils/detect-type.ts similarity index 97% rename from web/apps/photos/src/services/detect-type.ts rename to web/packages/new/photos/utils/detect-type.ts index e92e10bf82..e7447587d6 100644 --- a/web/apps/photos/src/services/detect-type.ts +++ b/web/packages/new/photos/utils/detect-type.ts @@ -78,7 +78,7 @@ export const detectFileTypeInfoFromChunk = async ( const known = KnownFileTypeInfos.find((f) => f.extension == extension); if (known) return known; - if (KnownNonMediaFileExtensions.includes(extension)) + if (extension && KnownNonMediaFileExtensions.includes(extension)) throw Error(CustomError.UNSUPPORTED_FILE_FORMAT); throw e; diff --git a/web/packages/new/photos/utils/file.ts b/web/packages/new/photos/utils/file.ts index d5d2892254..92fb5dfff0 100644 --- a/web/packages/new/photos/utils/file.ts +++ b/web/packages/new/photos/utils/file.ts @@ -1,4 +1,27 @@ +import { isNonWebImageFileExtension } from "@/media/formats"; +import { heicToJPEG } from "@/media/heic-convert"; +import { isDesktop } from "@/next/app"; +import log from "@/next/log"; +import { CustomErrorMessage } from "@/next/types/ipc"; +import { workerBridge } from "@/next/worker/worker-bridge"; import type { EnteFile } from "../types/file"; +import { detectFileTypeInfo } from "./detect-type"; + +class ModuleState { + /** + * This will be set to true if we get an error from the Node.js side of our + * desktop app telling us that native JPEG conversion is not available for + * the current OS/arch combination. + * + * That way, we can stop pestering it again and again (saving an IPC + * round-trip). + * + * Note the double negative when it is used. + */ + isNativeJPEGConversionNotAvailable = false; +} + +const moduleState = new ModuleState(); /** * [Note: File name for local EnteFile objects] @@ -28,3 +51,100 @@ export function mergeMetadata(files: EnteFile[]): EnteFile[] { return file; }); } + +/** + * The returned blob.type is filled in, whenever possible, with the MIME type of + * the data that we're dealing with. + */ +export const getRenderableImage = async (fileName: string, imageBlob: Blob) => { + try { + const tempFile = new File([imageBlob], fileName); + const fileTypeInfo = await detectFileTypeInfo(tempFile); + log.debug( + () => + `Need renderable image for ${JSON.stringify({ fileName, ...fileTypeInfo })}`, + ); + const { extension } = fileTypeInfo; + + if (!isNonWebImageFileExtension(extension)) { + // Either it is something that the browser already knows how to + // render, or something we don't even about yet. + const mimeType = fileTypeInfo.mimeType; + if (!mimeType) { + log.info( + "Trying to render a file without a MIME type", + fileName, + ); + return imageBlob; + } else { + return new Blob([imageBlob], { type: mimeType }); + } + } + + const available = !moduleState.isNativeJPEGConversionNotAvailable; + if (isDesktop && available && isNativeConvertibleToJPEG(extension)) { + // If we're running in our desktop app, see if our Node.js layer can + // convert this into a JPEG using native tools for us. + try { + return await nativeConvertToJPEG(imageBlob); + } catch (e) { + if ( + e instanceof Error && + e.message.endsWith(CustomErrorMessage.NotAvailable) + ) { + moduleState.isNativeJPEGConversionNotAvailable = true; + } else { + log.error("Native conversion to JPEG failed", e); + } + } + } + + if (extension == "heic" || extension == "heif") { + // For HEIC/HEIF files we can use our web HEIC converter. + return await heicToJPEG(imageBlob); + } + + return undefined; + } catch (e) { + log.error(`Failed to get renderable image for ${fileName}`, e); + return undefined; + } +}; + +/** + * File extensions which our native JPEG conversion code should be able to + * convert to a renderable image. + */ +const convertibleToJPEGExtensions = [ + "heic", + "rw2", + "tiff", + "arw", + "cr3", + "cr2", + "nef", + "psd", + "dng", + "tif", +]; + +/** + * Return true if {@link extension} is amongst the file extensions which we + * expect our native JPEG conversion to be able to process. + */ +export const isNativeConvertibleToJPEG = (extension: string) => + convertibleToJPEGExtensions.includes(extension.toLowerCase()); + +const nativeConvertToJPEG = async (imageBlob: Blob) => { + const startTime = Date.now(); + const imageData = new Uint8Array(await imageBlob.arrayBuffer()); + const electron = globalThis.electron; + // If we're running in a worker, we need to reroute the request back to + // the main thread since workers don't have access to the `window` (and + // thus, to the `window.electron`) object. + const jpegData = electron + ? await electron.convertToJPEG(imageData) + : await workerBridge.convertToJPEG(imageData); + log.debug(() => `Native JPEG conversion took ${Date.now() - startTime} ms`); + return new Blob([jpegData], { type: "image/jpeg" }); +}; diff --git a/web/apps/photos/src/utils/native-fs.ts b/web/packages/new/photos/utils/native-fs.ts similarity index 98% rename from web/apps/photos/src/utils/native-fs.ts rename to web/packages/new/photos/utils/native-fs.ts index 27ebdd1c12..666efc4fca 100644 --- a/web/apps/photos/src/utils/native-fs.ts +++ b/web/packages/new/photos/utils/native-fs.ts @@ -5,12 +5,12 @@ * written for use by the code that runs in our desktop app. */ -import { nameAndExtension } from "@/next/file"; -import sanitize from "sanitize-filename"; import { exportMetadataDirectoryName, exportTrashDirectoryName, -} from "services/export"; +} from "@/new/photos/services/export"; +import { nameAndExtension } from "@/next/file"; +import sanitize from "sanitize-filename"; /** * Sanitize string for use as file or directory name. diff --git a/web/apps/photos/src/utils/native-stream.ts b/web/packages/new/photos/utils/native-stream.ts similarity index 93% rename from web/apps/photos/src/utils/native-stream.ts rename to web/packages/new/photos/utils/native-stream.ts index e922c26219..9c38897367 100644 --- a/web/apps/photos/src/utils/native-stream.ts +++ b/web/packages/new/photos/utils/native-stream.ts @@ -52,7 +52,7 @@ export const readStream = async ( const res = await fetch(req); if (!res.ok) throw new Error( - `Failed to read stream from ${url}: HTTP ${res.status}`, + `Failed to read stream from ${url.href}: HTTP ${res.status}`, ); const size = readNumericHeader(res, "Content-Length"); @@ -63,7 +63,7 @@ export const readStream = async ( const readNumericHeader = (res: Response, key: string) => { const valueText = res.headers.get(key); - const value = +valueText; + const value = valueText === null ? NaN : +valueText; if (isNaN(value)) throw new Error( `Expected a numeric ${key} when reading a stream response, instead got ${valueText}`, @@ -111,7 +111,9 @@ export const writeStream = async ( const res = await fetch(req); if (!res.ok) - throw new Error(`Failed to write stream to ${url}: HTTP ${res.status}`); + throw new Error( + `Failed to write stream to ${url.href}: HTTP ${res.status}`, + ); }; /** @@ -161,7 +163,7 @@ export const readConvertToMP4Stream = async ( const res = await fetch(req); if (!res.ok) throw new Error( - `Failed to read stream from ${url}: HTTP ${res.status}`, + `Failed to read stream from ${url.href}: HTTP ${res.status}`, ); return res.blob(); @@ -185,5 +187,7 @@ export const readConvertToMP4Done = async ( const req = new Request(url, { method: "GET" }); const res = await fetch(req); if (!res.ok) - throw new Error(`Failed to close stream at ${url}: HTTP ${res.status}`); + throw new Error( + `Failed to close stream at ${url.href}: HTTP ${res.status}`, + ); }; diff --git a/web/apps/photos/src/utils/units.ts b/web/packages/new/photos/utils/units.ts similarity index 100% rename from web/apps/photos/src/utils/units.ts rename to web/packages/new/photos/utils/units.ts diff --git a/web/packages/shared/utils/index.ts b/web/packages/shared/utils/index.ts index b71808d466..101e6eb27f 100644 --- a/web/packages/shared/utils/index.ts +++ b/web/packages/shared/utils/index.ts @@ -1,3 +1,4 @@ +import { ensure } from "@/utils/ensure"; import { wait } from "@/utils/promise"; export function downloadAsFile(filename: string, content: string) { @@ -47,7 +48,7 @@ export async function retryAsyncFunction( if (attemptNumber === waitTimeBeforeNextTry.length) { throw e; } - await wait(waitTimeBeforeNextTry[attemptNumber]); + await wait(ensure(waitTimeBeforeNextTry[attemptNumber])); } } } diff --git a/web/packages/shared/utils/queueProcessor.ts b/web/packages/shared/utils/queueProcessor.ts index b185281994..d490794469 100644 --- a/web/packages/shared/utils/queueProcessor.ts +++ b/web/packages/shared/utils/queueProcessor.ts @@ -1,9 +1,10 @@ +import { ensure } from "@/utils/ensure"; import { CustomError } from "@ente/shared/error"; interface RequestQueueItem { request: (canceller?: RequestCanceller) => Promise; successCallback: (response: any) => void; - failureCallback: (error: Error) => void; + failureCallback: (error: unknown) => void; isCanceled: { status: boolean }; canceller: { exec: () => void }; } @@ -50,7 +51,7 @@ export default class QueueProcessor { this.isProcessingRequest = true; while (this.requestQueue.length > 0) { - const queueItem = this.requestQueue.shift(); + const queueItem = ensure(this.requestQueue.shift()); let response = null; if (queueItem.isCanceled.status) {