diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index dc7d40c70..0bf355d8d 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -994,6 +994,7 @@ class ExportService { file, ); await writeStream( + electron, `${collectionExportPath}/${fileExportName}`, updatedFileStream, ); @@ -1047,6 +1048,7 @@ class ExportService { file, ); await writeStream( + electron, `${collectionExportPath}/${imageExportName}`, imageStream, ); @@ -1061,6 +1063,7 @@ class ExportService { ); try { await writeStream( + electron, `${collectionExportPath}/${videoExportName}`, videoStream, ); diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 72e8ed1f3..6ec2d370f 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -430,10 +430,122 @@ const moduleState = new ModuleState(); const readFileOrPath = async ( fileOrPath: File | string, fileTypeInfo: FileTypeInfo, -): Promise => - fileOrPath instanceof File - ? _readFile(fileOrPath, fileTypeInfo) - : _readPath(fileOrPath, fileTypeInfo); +): Promise => { + let file: File | undefined; + let dataOrStream: Uint8Array | DataStream; + let fileSize: number; + + if (fileOrPath instanceof File) { + file = fileOrPath; + fileSize = file.size; + dataOrStream = + fileSize > MULTIPART_PART_SIZE + ? getFileStream(file, FILE_READER_CHUNK_SIZE) + : new Uint8Array(await file.arrayBuffer()); + } else { + const path = fileOrPath; + const { response, size } = await readStream(ensureElectron(), path); + fileSize = size; + if (size > MULTIPART_PART_SIZE) { + const chunkCount = Math.ceil(size / FILE_READER_CHUNK_SIZE); + dataOrStream = { stream: response.body, chunkCount }; + } else { + dataOrStream = new Uint8Array(await response.arrayBuffer()); + } + } + + let thumbnail: Uint8Array | undefined; + let hasStaticThumbnail = false; + + const electron = globalThis.electron; + if (electron) { + // On Windows native thumbnail creation for images is not yet implemented. + const notAvailable = + fileTypeInfo.fileType == FILE_TYPE.IMAGE && + moduleState.isNativeImageThumbnailCreationNotAvailable; + + try { + if (!notAvailable) { + if (fileOrPath instanceof File) { + if (dataOrStream instanceof Uint8Array) { + thumbnail = await generateThumbnailNative( + electron, + dataOrStream, + fileTypeInfo, + ); + } + } else { + thumbnail = await generateThumbnailNative( + electron, + fileOrPath, + fileTypeInfo, + ); + } + } + } catch (e) { + if (e.message == CustomErrorMessage.NotAvailable) { + moduleState.isNativeImageThumbnailCreationNotAvailable = true; + } else { + log.error("Native thumbnail creation failed", e); + } + } + } + + // If needed, fallback to browser based thumbnail generation. First, see if + // we already have a file (which is also a blob). + if (!thumbnail && file) { + try { + thumbnail = await generateThumbnailWeb(file, fileTypeInfo); + } catch (e) { + log.error( + `Failed to generate ${fileTypeInfo.exactType} thumbnail`, + e, + ); + } + } + + // Otherwise see if the data is small enough to read in memory. + if (!thumbnail) { + let data: Uint8Array | undefined; + if (dataOrStream instanceof Uint8Array) { + data = dataOrStream; + } else { + // Read the stream into memory, since the our web based thumbnail + // generation methods need the entire file in memory. Don't try this + // fallback for huge files though lest we run out of memory. + if (fileSize < 100 * 1024 * 1024 /* 100 MB */) { + data = new Uint8Array( + await new Response(dataOrStream.stream).arrayBuffer(), + ); + // The Readable stream cannot be read twice, so also overwrite + // the stream with the data we read. + dataOrStream = data; + } + } + if (data) { + const blob = new Blob([data]); + try { + thumbnail = await generateThumbnailWeb(blob, fileTypeInfo); + } catch (e) { + log.error( + `Failed to generate ${fileTypeInfo.exactType} thumbnail`, + e, + ); + } + } + } + + if (!thumbnail) { + thumbnail = fallbackThumbnail(); + hasStaticThumbnail = true; + } + + return { + filedata: dataOrStream, + thumbnail, + hasStaticThumbnail, + }; +}; const _readFile = async (file: File, fileTypeInfo: FileTypeInfo) => { const dataOrStream = diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index c37644b70..03ef36982 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -646,7 +646,11 @@ async function downloadFileDesktop( fs.exists, ); const imageStream = generateStreamFromArrayBuffer(imageData); - await writeStream(`${downloadDir}/${imageExportName}`, imageStream); + await writeStream( + electron, + `${downloadDir}/${imageExportName}`, + imageStream, + ); try { const videoExportName = await safeFileName( downloadDir, @@ -654,7 +658,11 @@ async function downloadFileDesktop( fs.exists, ); const videoStream = generateStreamFromArrayBuffer(videoData); - await writeStream(`${downloadDir}/${videoExportName}`, videoStream); + await writeStream( + electron, + `${downloadDir}/${videoExportName}`, + videoStream, + ); } catch (e) { await fs.rm(`${downloadDir}/${imageExportName}`); throw e; @@ -665,7 +673,11 @@ async function downloadFileDesktop( file.metadata.title, fs.exists, ); - await writeStream(`${downloadDir}/${fileExportName}`, updatedStream); + await writeStream( + electron, + `${downloadDir}/${fileExportName}`, + updatedStream, + ); } } diff --git a/web/apps/photos/src/utils/native-stream.ts b/web/apps/photos/src/utils/native-stream.ts index 52da84f99..a9a76b41b 100644 --- a/web/apps/photos/src/utils/native-stream.ts +++ b/web/apps/photos/src/utils/native-stream.ts @@ -4,13 +4,18 @@ * NOTE: These functions only work when we're running in our desktop app. */ +import type { Electron } from "@/next/types/ipc"; + /** * Stream the given file from the user's local filesystem. * - * **This only works when we're running in our desktop app**. It uses the + * This only works when we're running in our desktop app since it uses the * "stream://" protocol handler exposed by our custom code in the Node.js layer. * See: [Note: IPC streams]. * + * To avoid accidentally invoking it in a non-desktop app context, it requires + * the {@link Electron} object as a parameter (even though it doesn't use it). + * * @param path The path on the file on the user's local filesystem whose * contents we want to stream. * @@ -23,6 +28,7 @@ * * The size is the size of the file that we'll be reading from disk. */ export const readStream = async ( + _: Electron, path: string, ): Promise<{ response: Response; size: number }> => { const req = new Request(`stream://read${path}`, { @@ -47,14 +53,22 @@ export const readStream = async ( /** * Write the given stream to a file on the local machine. * - * **This only works when we're running in our desktop app**. It uses the + * This only works when we're running in our desktop app since it uses the * "stream://" protocol handler exposed by our custom code in the Node.js layer. * See: [Note: IPC streams]. * + * To avoid accidentally invoking it in a non-desktop app context, it requires + * the {@link Electron} object as a parameter (even though it doesn't use it). + * * @param path The path on the local machine where to write the file to. + * * @param stream The stream which should be written into the file. - * */ -export const writeStream = async (path: string, stream: ReadableStream) => { + */ +export const writeStream = async ( + _: Electron, + path: string, + stream: ReadableStream, +) => { // TODO(MR): This doesn't currently work. // // Not sure what I'm doing wrong here; I've opened an issue upstream