From f97d5b19d9647ef0c73caeef00828e4713ffabda Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 11:37:36 +0530 Subject: [PATCH 01/18] Inline I did try and search both in git history and on the internet if caching the FileReader itself has any performance benefits, but I didn't find anything. --- web/apps/photos/src/services/exif.ts | 11 +++++++---- web/apps/photos/src/services/export/index.ts | 4 ---- web/apps/photos/src/utils/file/blob.ts | 15 --------------- web/apps/photos/src/utils/file/index.ts | 19 +++---------------- 4 files changed, 10 insertions(+), 39 deletions(-) delete mode 100644 web/apps/photos/src/utils/file/blob.ts diff --git a/web/apps/photos/src/services/exif.ts b/web/apps/photos/src/services/exif.ts index 9ccaa0f344..c606f72517 100644 --- a/web/apps/photos/src/services/exif.ts +++ b/web/apps/photos/src/services/exif.ts @@ -334,12 +334,11 @@ export function getEXIFTime(exifData: ParsedEXIFData): number { } export async function updateFileCreationDateInEXIF( - reader: FileReader, fileBlob: Blob, updatedDate: Date, ) { try { - let imageDataURL = await convertImageToDataURL(reader, fileBlob); + let imageDataURL = await convertImageToDataURL(fileBlob); imageDataURL = "data:image/jpeg;base64" + imageDataURL.slice(imageDataURL.indexOf(",")); @@ -349,7 +348,10 @@ export async function updateFileCreationDateInEXIF( } exifObj["Exif"][piexif.ExifIFD.DateTimeOriginal] = convertToExifDateFormat(updatedDate); - + log.debug(() => [ + "updateFileCreationDateInEXIF", + { updatedDate, exifObj }, + ]); const exifBytes = piexif.dump(exifObj); const exifInsertedFile = piexif.insert(exifBytes, imageDataURL); return dataURIToBlob(exifInsertedFile); @@ -359,7 +361,8 @@ export async function updateFileCreationDateInEXIF( } } -async function convertImageToDataURL(reader: FileReader, blob: Blob) { +async function convertImageToDataURL(blob: Blob) { + const reader = new FileReader(); const dataURL = await new Promise((resolve) => { reader.onload = () => resolve(reader.result as string); reader.readAsDataURL(blob); diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index 394dbf8805..e30402a316 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -970,11 +970,7 @@ class ExportService { try { const fileUID = getExportRecordFileUID(file); const originalFileStream = await downloadManager.getFile(file); - if (!this.fileReader) { - this.fileReader = new FileReader(); - } const updatedFileStream = await getUpdatedEXIFFileForDownload( - this.fileReader, file, originalFileStream, ); diff --git a/web/apps/photos/src/utils/file/blob.ts b/web/apps/photos/src/utils/file/blob.ts deleted file mode 100644 index cb2e8c7a22..0000000000 --- a/web/apps/photos/src/utils/file/blob.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const readAsDataURL = (blob) => - new Promise((resolve, reject) => { - const fileReader = new FileReader(); - fileReader.onload = () => resolve(fileReader.result as string); - fileReader.onerror = () => reject(fileReader.error); - fileReader.readAsDataURL(blob); - }); - -export const readAsText = (blob) => - new Promise((resolve, reject) => { - const fileReader = new FileReader(); - fileReader.onload = () => resolve(fileReader.result as string); - fileReader.onerror = () => reject(fileReader.error); - fileReader.readAsText(blob); - }); diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 363361307a..630859c751 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -50,7 +50,6 @@ export enum FILE_OPS_TYPE { } export async function getUpdatedEXIFFileForDownload( - fileReader: FileReader, file: EnteFile, fileStream: ReadableStream, ): Promise> { @@ -62,7 +61,6 @@ export async function getUpdatedEXIFFileForDownload( ) { const fileBlob = await new Response(fileStream).blob(); const updatedFileBlob = await updateFileCreationDateInEXIF( - fileReader, fileBlob, new Date(file.pubMagicMetadata.data.editedTime / 1000), ); @@ -74,7 +72,6 @@ export async function getUpdatedEXIFFileForDownload( export async function downloadFile(file: EnteFile) { try { - const fileReader = new FileReader(); let fileBlob = await new Response( await DownloadManager.getFile(file), ).blob(); @@ -98,11 +95,7 @@ export async function downloadFile(file: EnteFile) { new File([fileBlob], file.metadata.title), ); fileBlob = await new Response( - await getUpdatedEXIFFileForDownload( - fileReader, - file, - fileBlob.stream(), - ), + await getUpdatedEXIFFileForDownload(file, fileBlob.stream()), ).blob(); fileBlob = new Blob([fileBlob], { type: fileType.mimeType }); const tempURL = URL.createObjectURL(fileBlob); @@ -455,13 +448,12 @@ async function downloadFilesDesktop( }, downloadPath: string, ) { - const fileReader = new FileReader(); for (const file of files) { try { if (progressBarUpdater?.isCancelled()) { return; } - await downloadFileDesktop(electron, fileReader, file, downloadPath); + await downloadFileDesktop(electron, file, downloadPath); progressBarUpdater?.increaseSuccess(); } catch (e) { log.error("download fail for file", e); @@ -472,7 +464,6 @@ async function downloadFilesDesktop( async function downloadFileDesktop( electron: Electron, - fileReader: FileReader, file: EnteFile, downloadDir: string, ) { @@ -480,11 +471,7 @@ async function downloadFileDesktop( const stream = (await DownloadManager.getFile( file, )) as ReadableStream; - const updatedStream = await getUpdatedEXIFFileForDownload( - fileReader, - file, - stream, - ); + const updatedStream = await getUpdatedEXIFFileForDownload(file, stream); if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { const fileBlob = await new Response(updatedStream).blob(); From 5a79658e582b05ddbd0e41d32e45239cb1b680ff Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 11:39:59 +0530 Subject: [PATCH 02/18] Tweak --- web/apps/photos/src/utils/file/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 630859c751..1de404b14b 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -468,9 +468,8 @@ async function downloadFileDesktop( downloadDir: string, ) { const fs = electron.fs; - const stream = (await DownloadManager.getFile( - file, - )) as ReadableStream; + + const stream = await DownloadManager.getFile(file); const updatedStream = await getUpdatedEXIFFileForDownload(file, stream); if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { From 34e13caa773170e07bf16a8244961e16f269bb60 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 11:55:16 +0530 Subject: [PATCH 03/18] Doc --- web/apps/photos/src/services/export/index.ts | 4 +- web/apps/photos/src/utils/file/index.ts | 47 ++++++++++++++------ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index e30402a316..651526203b 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -36,7 +36,7 @@ import { getCollectionUserFacingName, getNonEmptyPersonalCollections, } from "utils/collection"; -import { getPersonalFiles, getUpdatedEXIFFileForDownload } from "utils/file"; +import { getPersonalFiles, streamWithUpdatedExif } from "utils/file"; import { getAllLocalCollections } from "../collectionService"; import { migrateExport } from "./migration"; @@ -970,7 +970,7 @@ class ExportService { try { const fileUID = getExportRecordFileUID(file); const originalFileStream = await downloadManager.getFile(file); - const updatedFileStream = await getUpdatedEXIFFileForDownload( + const updatedFileStream = await streamWithUpdatedExif( file, originalFileStream, ); diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 1de404b14b..e57e401b6c 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -49,26 +49,47 @@ export enum FILE_OPS_TYPE { DELETE_PERMANENTLY, } -export async function getUpdatedEXIFFileForDownload( - file: EnteFile, - fileStream: ReadableStream, -): Promise> { - const extension = lowercaseExtension(file.metadata.title); +/** + * Return a new stream after applying Exif updates if applicable to the given + * stream, otherwise return the original. + * + * This function is meant to provide a stream that can be used to download (or + * export) a file to the user's computer after applying any Exif updates to the + * original file's data. + * + * - This only updates JPEG files. + * + * - For JPEG files, the DateTimeOriginal Exif entry is updated to reflect the + * time that the user edited within Ente. + * + * @param enteFile The {@link EnteFile} whose data we want. + * + * @param stream A {@link ReadableStream} containing the original data for + * {@link enteFile}. + * + * @returns A new {@link ReadableStream} with updates if any updates were + * needed, otherwise return the original stream. + */ +export const streamWithUpdatedExif = async ( + enteFile: EnteFile, + stream: ReadableStream, +): Promise> => { + const extension = lowercaseExtension(enteFile.metadata.title); if ( - file.metadata.fileType === FILE_TYPE.IMAGE && - file.pubMagicMetadata?.data.editedTime && + enteFile.metadata.fileType === FILE_TYPE.IMAGE && + enteFile.pubMagicMetadata?.data.editedTime && (extension == "jpeg" || extension == "jpg") ) { - const fileBlob = await new Response(fileStream).blob(); + const fileBlob = await new Response(stream).blob(); const updatedFileBlob = await updateFileCreationDateInEXIF( fileBlob, - new Date(file.pubMagicMetadata.data.editedTime / 1000), + new Date(enteFile.pubMagicMetadata.data.editedTime / 1000), ); return updatedFileBlob.stream(); } else { - return fileStream; + return stream; } -} +}; export async function downloadFile(file: EnteFile) { try { @@ -95,7 +116,7 @@ export async function downloadFile(file: EnteFile) { new File([fileBlob], file.metadata.title), ); fileBlob = await new Response( - await getUpdatedEXIFFileForDownload(file, fileBlob.stream()), + await streamWithUpdatedExif(file, fileBlob.stream()), ).blob(); fileBlob = new Blob([fileBlob], { type: fileType.mimeType }); const tempURL = URL.createObjectURL(fileBlob); @@ -470,7 +491,7 @@ async function downloadFileDesktop( const fs = electron.fs; const stream = await DownloadManager.getFile(file); - const updatedStream = await getUpdatedEXIFFileForDownload(file, stream); + const updatedStream = await streamWithUpdatedExif(file, stream); if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { const fileBlob = await new Response(updatedStream).blob(); From 67df790d28e6779e972144e2a8389cbe06ba3721 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 12:09:50 +0530 Subject: [PATCH 04/18] Shorten We don't have a CSP yet (it is report only, and there we already allow data:) Ref: - https://stackoverflow.com/questions/12168909/blob-from-dataurl --- web/apps/photos/src/services/exif.ts | 31 +++++++--------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/web/apps/photos/src/services/exif.ts b/web/apps/photos/src/services/exif.ts index c606f72517..4023bc20fd 100644 --- a/web/apps/photos/src/services/exif.ts +++ b/web/apps/photos/src/services/exif.ts @@ -370,29 +370,14 @@ async function convertImageToDataURL(blob: Blob) { return dataURL; } -function dataURIToBlob(dataURI: string) { - // convert base64 to raw binary data held in a string - // doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this - const byteString = atob(dataURI.split(",")[1]); - - // separate out the mime component - const mimeString = dataURI.split(",")[0].split(":")[1].split(";")[0]; - - // write the bytes of the string to an ArrayBuffer - const ab = new ArrayBuffer(byteString.length); - - // create a view into the buffer - const ia = new Uint8Array(ab); - - // set the bytes of the buffer to the correct values - for (let i = 0; i < byteString.length; i++) { - ia[i] = byteString.charCodeAt(i); - } - - // write the ArrayBuffer to a blob, and you're done - const blob = new Blob([ab], { type: mimeString }); - return blob; -} +/** + * Convert a `data:` URI to a blob. + * + * Requires `connect-src data:` in the CSP (since it internally uses `fetch` to + * perform the conversion). + */ +const dataURIToBlob = (dataURI: string) => + fetch(dataURI).then((res) => res.blob()); function convertToExifDateFormat(date: Date) { return `${date.getFullYear()}:${ From c92e08c8d4e559052c47f75ad81f203f00638a08 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 13:43:47 +0530 Subject: [PATCH 05/18] Rewrite to the same result --- web/apps/photos/src/services/exif.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/web/apps/photos/src/services/exif.ts b/web/apps/photos/src/services/exif.ts index 4023bc20fd..dcc832ad7a 100644 --- a/web/apps/photos/src/services/exif.ts +++ b/web/apps/photos/src/services/exif.ts @@ -338,7 +338,7 @@ export async function updateFileCreationDateInEXIF( updatedDate: Date, ) { try { - let imageDataURL = await convertImageToDataURL(fileBlob); + let imageDataURL = await blobToDataURL(fileBlob); imageDataURL = "data:image/jpeg;base64" + imageDataURL.slice(imageDataURL.indexOf(",")); @@ -354,29 +354,36 @@ export async function updateFileCreationDateInEXIF( ]); const exifBytes = piexif.dump(exifObj); const exifInsertedFile = piexif.insert(exifBytes, imageDataURL); - return dataURIToBlob(exifInsertedFile); + return dataURLToBlob(exifInsertedFile); } catch (e) { log.error("updateFileModifyDateInEXIF failed", e); return fileBlob; } } -async function convertImageToDataURL(blob: Blob) { - const reader = new FileReader(); - const dataURL = await new Promise((resolve) => { +/** + * Convert a blob to a `data:` URL. + */ +const blobToDataURL = (blob: Blob) => + new Promise((resolve) => { + const reader = new FileReader(); + // We need to cast to a string here. This should be safe since MDN says: + // + // > the result attribute contains the data as a data: URL representing + // > the file's data as a base64 encoded string. + // > + // > https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL reader.onload = () => resolve(reader.result as string); reader.readAsDataURL(blob); }); - return dataURL; -} /** - * Convert a `data:` URI to a blob. + * Convert a `data:` URL to a blob. * * Requires `connect-src data:` in the CSP (since it internally uses `fetch` to * perform the conversion). */ -const dataURIToBlob = (dataURI: string) => +const dataURLToBlob = (dataURI: string) => fetch(dataURI).then((res) => res.blob()); function convertToExifDateFormat(date: Date) { From d59e50ff93c9196749313bbb57d4da8a04a9fcc6 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 13:52:54 +0530 Subject: [PATCH 06/18] Mention why --- web/apps/photos/src/services/exif.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/apps/photos/src/services/exif.ts b/web/apps/photos/src/services/exif.ts index dcc832ad7a..6efd587f4e 100644 --- a/web/apps/photos/src/services/exif.ts +++ b/web/apps/photos/src/services/exif.ts @@ -339,6 +339,9 @@ export async function updateFileCreationDateInEXIF( ) { try { let imageDataURL = await blobToDataURL(fileBlob); + // Since we pass a Blob without an associated type, we get back a + // generic data URL like "data:application/octet-stream;base64,...". + // Modify it to have a `image/jpeg` MIME type. imageDataURL = "data:image/jpeg;base64" + imageDataURL.slice(imageDataURL.indexOf(",")); From 67d1d6c597a8409ed6366b663412d887e9cfbc8c Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 13:57:59 +0530 Subject: [PATCH 07/18] Move --- web/apps/photos/src/services/exif.ts | 63 ------------------ web/apps/photos/src/utils/file/index.ts | 2 +- web/docs/dependencies.md | 3 +- .../new/photos/services/exif-update.ts | 64 +++++++++++++++++++ 4 files changed, 67 insertions(+), 65 deletions(-) create mode 100644 web/packages/new/photos/services/exif-update.ts diff --git a/web/apps/photos/src/services/exif.ts b/web/apps/photos/src/services/exif.ts index 6efd587f4e..8efc54c4b8 100644 --- a/web/apps/photos/src/services/exif.ts +++ b/web/apps/photos/src/services/exif.ts @@ -7,7 +7,6 @@ import type { } from "@/new/photos/types/metadata"; import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time"; import exifr from "exifr"; -import piexif from "piexifjs"; type ParsedEXIFData = Record & Partial<{ @@ -332,65 +331,3 @@ export function getEXIFTime(exifData: ParsedEXIFData): number { } return validateAndGetCreationUnixTimeInMicroSeconds(dateTime); } - -export async function updateFileCreationDateInEXIF( - fileBlob: Blob, - updatedDate: Date, -) { - try { - let imageDataURL = await blobToDataURL(fileBlob); - // Since we pass a Blob without an associated type, we get back a - // generic data URL like "data:application/octet-stream;base64,...". - // Modify it to have a `image/jpeg` MIME type. - imageDataURL = - "data:image/jpeg;base64" + - imageDataURL.slice(imageDataURL.indexOf(",")); - const exifObj = piexif.load(imageDataURL); - if (!exifObj["Exif"]) { - exifObj["Exif"] = {}; - } - exifObj["Exif"][piexif.ExifIFD.DateTimeOriginal] = - convertToExifDateFormat(updatedDate); - log.debug(() => [ - "updateFileCreationDateInEXIF", - { updatedDate, exifObj }, - ]); - const exifBytes = piexif.dump(exifObj); - const exifInsertedFile = piexif.insert(exifBytes, imageDataURL); - return dataURLToBlob(exifInsertedFile); - } catch (e) { - log.error("updateFileModifyDateInEXIF failed", e); - return fileBlob; - } -} - -/** - * Convert a blob to a `data:` URL. - */ -const blobToDataURL = (blob: Blob) => - new Promise((resolve) => { - const reader = new FileReader(); - // We need to cast to a string here. This should be safe since MDN says: - // - // > the result attribute contains the data as a data: URL representing - // > the file's data as a base64 encoded string. - // > - // > https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL - reader.onload = () => resolve(reader.result as string); - reader.readAsDataURL(blob); - }); - -/** - * Convert a `data:` URL to a blob. - * - * Requires `connect-src data:` in the CSP (since it internally uses `fetch` to - * perform the conversion). - */ -const dataURLToBlob = (dataURI: string) => - fetch(dataURI).then((res) => res.blob()); - -function convertToExifDateFormat(date: Date) { - return `${date.getFullYear()}:${ - date.getMonth() + 1 - }:${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`; -} diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index e57e401b6c..ff5ac45d2e 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -25,7 +25,7 @@ import type { User } from "@ente/shared/user/types"; import { downloadUsingAnchor } from "@ente/shared/utils"; import { t } from "i18next"; import { moveToHiddenCollection } from "services/collectionService"; -import { updateFileCreationDateInEXIF } from "services/exif"; +import { updateFileCreationDateInEXIF } from "@/new/photos/services/exif"; import { deleteFromTrash, trashFiles, diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index 31ab0e29e8..e78b2c9907 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -192,7 +192,8 @@ For more details, see [translations.md](translations.md). ## Media - [ExifReader](https://github.com/mattiasw/ExifReader) is used for Exif - parsing. + parsing. [piexifjs](https://github.com/hMatoba/piexifjs) is used for writing + back Exif (only supports JPEG). - [jszip](https://github.com/Stuk/jszip) is used for reading zip files in the web code (Live photos are zip files under the hood). Note that the desktop diff --git a/web/packages/new/photos/services/exif-update.ts b/web/packages/new/photos/services/exif-update.ts new file mode 100644 index 0000000000..09f8fd1a84 --- /dev/null +++ b/web/packages/new/photos/services/exif-update.ts @@ -0,0 +1,64 @@ +import log from "@/base/log"; +import piexif from "piexifjs"; + +export const updateFileCreationDateInEXIF = async ( + fileBlob: Blob, + updatedDate: Date, +) => { + try { + let imageDataURL = await blobToDataURL(fileBlob); + // Since we pass a Blob without an associated type, we get back a + // generic data URL like "data:application/octet-stream;base64,...". + // Modify it to have a `image/jpeg` MIME type. + imageDataURL = + "data:image/jpeg;base64" + + imageDataURL.slice(imageDataURL.indexOf(",")); + const exifObj = piexif.load(imageDataURL); + if (!exifObj["Exif"]) { + exifObj["Exif"] = {}; + } + exifObj["Exif"][piexif.ExifIFD.DateTimeOriginal] = + convertToExifDateFormat(updatedDate); + log.debug(() => [ + "updateFileCreationDateInEXIF", + { updatedDate, exifObj }, + ]); + const exifBytes = piexif.dump(exifObj); + const exifInsertedFile = piexif.insert(exifBytes, imageDataURL); + return dataURLToBlob(exifInsertedFile); + } catch (e) { + log.error("updateFileModifyDateInEXIF failed", e); + return fileBlob; + } +}; + +/** + * Convert a blob to a `data:` URL. + */ +const blobToDataURL = (blob: Blob) => + new Promise((resolve) => { + const reader = new FileReader(); + // We need to cast to a string here. This should be safe since MDN says: + // + // > the result attribute contains the data as a data: URL representing + // > the file's data as a base64 encoded string. + // > + // > https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + +/** + * Convert a `data:` URL to a blob. + * + * Requires `connect-src data:` in the CSP (since it internally uses `fetch` to + * perform the conversion). + */ +const dataURLToBlob = (dataURI: string) => + fetch(dataURI).then((res) => res.blob()); + +function convertToExifDateFormat(date: Date) { + return `${date.getFullYear()}:${ + date.getMonth() + 1 + }:${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`; +} From c918a796af8576f7014692c3bfe7bf4b6bd039b4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 14:13:28 +0530 Subject: [PATCH 08/18] Add types --- .../types/clip-bpe-js.d.ts} | 3 +- .../new/photos/services/exif-update.ts | 6 +-- web/packages/new/photos/types/piexifjs.d.ts | 42 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) rename desktop/src/{types/clip-bpe-js.ts => main/types/clip-bpe-js.d.ts} (69%) create mode 100644 web/packages/new/photos/types/piexifjs.d.ts diff --git a/desktop/src/types/clip-bpe-js.ts b/desktop/src/main/types/clip-bpe-js.d.ts similarity index 69% rename from desktop/src/types/clip-bpe-js.ts rename to desktop/src/main/types/clip-bpe-js.d.ts index 269ebf28fc..575b5fe127 100644 --- a/desktop/src/types/clip-bpe-js.ts +++ b/desktop/src/main/types/clip-bpe-js.d.ts @@ -1,9 +1,8 @@ /** - * @file Types for "clip-bpe-js" + * Types for [clip-bpe-js](https://github.com/josephrocca/clip-bpe-js). * * Non exhaustive, only the function we need. */ - declare module "clip-bpe-js" { class Tokenizer { encodeForCLIP(text: string): number[]; diff --git a/web/packages/new/photos/services/exif-update.ts b/web/packages/new/photos/services/exif-update.ts index 09f8fd1a84..c08d404d5c 100644 --- a/web/packages/new/photos/services/exif-update.ts +++ b/web/packages/new/photos/services/exif-update.ts @@ -14,10 +14,8 @@ export const updateFileCreationDateInEXIF = async ( "data:image/jpeg;base64" + imageDataURL.slice(imageDataURL.indexOf(",")); const exifObj = piexif.load(imageDataURL); - if (!exifObj["Exif"]) { - exifObj["Exif"] = {}; - } - exifObj["Exif"][piexif.ExifIFD.DateTimeOriginal] = + if (!exifObj.Exif) exifObj.Exif = {}; + exifObj.Exif[piexif.ExifIFD.DateTimeOriginal] = convertToExifDateFormat(updatedDate); log.debug(() => [ "updateFileCreationDateInEXIF", diff --git a/web/packages/new/photos/types/piexifjs.d.ts b/web/packages/new/photos/types/piexifjs.d.ts new file mode 100644 index 0000000000..211ee9754e --- /dev/null +++ b/web/packages/new/photos/types/piexifjs.d.ts @@ -0,0 +1,42 @@ +/** + * Types for [piexifjs](https://github.com/hMatoba/piexifjs). + * + * Non exhaustive, only the function we need. + */ +declare module "piexifjs" { + interface ExifObj { + Exif?: Record; + } + + interface Piexifjs { + /** + * Get exif data as object. + * + * @param jpegData a string that starts with "data:image/jpeg;base64," + * (a data URL), "\xff\xd8", or "Exif". + */ + load: (jpegData: string) => ExifObj; + /** + * Get exif as string to insert into JPEG. + * + * @param exifObj An object obtained using {@link load}. + */ + dump: (exifObj: ExifObj) => string; + /** + * Insert exif into JPEG. + * + * If {@link jpegData} is a data URL, returns the modified JPEG as a + * data URL. Else if {@link jpegData} is binary as string, returns JPEG + * as binary as string. + */ + insert: (exifStr: string, jpegData: string) => string; + /** + * Keys for the tags in {@link ExifObj}. + */ + ExifIFD: { + DateTimeOriginal: number; + }; + } + const piexifjs: Piexifjs; + export default piexifjs; +} From bd2e8bb7286451d7d71fb10e5615c7b258df9bc7 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 14:34:07 +0530 Subject: [PATCH 09/18] Rename --- web/apps/photos/src/utils/file/index.ts | 4 +-- .../new/photos/services/exif-update.ts | 30 +++++++++++++------ web/packages/new/photos/types/file.ts | 5 ++++ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index ff5ac45d2e..f40e791027 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -4,6 +4,7 @@ import { type Electron } from "@/base/types/ipc"; import { FILE_TYPE } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; import DownloadManager from "@/new/photos/services/download"; +import { setJPEGExifDateTimeOriginal } from "@/new/photos/services/exif-update"; import { EncryptedEnteFile, EnteFile, @@ -25,7 +26,6 @@ import type { User } from "@ente/shared/user/types"; import { downloadUsingAnchor } from "@ente/shared/utils"; import { t } from "i18next"; import { moveToHiddenCollection } from "services/collectionService"; -import { updateFileCreationDateInEXIF } from "@/new/photos/services/exif"; import { deleteFromTrash, trashFiles, @@ -81,7 +81,7 @@ export const streamWithUpdatedExif = async ( (extension == "jpeg" || extension == "jpg") ) { const fileBlob = await new Response(stream).blob(); - const updatedFileBlob = await updateFileCreationDateInEXIF( + const updatedFileBlob = await setJPEGExifDateTimeOriginal( fileBlob, new Date(enteFile.pubMagicMetadata.data.editedTime / 1000), ); diff --git a/web/packages/new/photos/services/exif-update.ts b/web/packages/new/photos/services/exif-update.ts index c08d404d5c..afc591510a 100644 --- a/web/packages/new/photos/services/exif-update.ts +++ b/web/packages/new/photos/services/exif-update.ts @@ -1,12 +1,23 @@ import log from "@/base/log"; import piexif from "piexifjs"; -export const updateFileCreationDateInEXIF = async ( - fileBlob: Blob, +/** + * Return a new blob with the "DateTimeOriginal" Exif tag set to the given + * {@link date}. + * + * @param jpegBlob A {@link Blob} containing JPEG data. + * + * @param date A {@link Date} to use as the value for the Exif + * "DateTimeOriginal" tag. + * + * @returns A new blob derived from {@link jpegBlob} but with the updated date. + */ +export const setJPEGExifDateTimeOriginal = async ( + jpegBlob: Blob, updatedDate: Date, ) => { try { - let imageDataURL = await blobToDataURL(fileBlob); + let imageDataURL = await blobToDataURL(jpegBlob); // Since we pass a Blob without an associated type, we get back a // generic data URL like "data:application/octet-stream;base64,...". // Modify it to have a `image/jpeg` MIME type. @@ -26,7 +37,7 @@ export const updateFileCreationDateInEXIF = async ( return dataURLToBlob(exifInsertedFile); } catch (e) { log.error("updateFileModifyDateInEXIF failed", e); - return fileBlob; + return jpegBlob; } }; @@ -55,8 +66,9 @@ const blobToDataURL = (blob: Blob) => const dataURLToBlob = (dataURI: string) => fetch(dataURI).then((res) => res.blob()); -function convertToExifDateFormat(date: Date) { - return `${date.getFullYear()}:${ - date.getMonth() + 1 - }:${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`; -} +/** + * Convert the given {@link Date} to a format that is expected by Exif for the + * DateTimeOriginal tag. + */ +const convertToExifDateFormat = (date: Date) => + `${date.getFullYear()}:${date.getMonth() + 1}:${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`; diff --git a/web/packages/new/photos/types/file.ts b/web/packages/new/photos/types/file.ts index 7cbbb8eb38..b24ea54a75 100644 --- a/web/packages/new/photos/types/file.ts +++ b/web/packages/new/photos/types/file.ts @@ -116,6 +116,11 @@ export interface FileMagicMetadataProps { export type FileMagicMetadata = MagicMetadataCore; export interface FilePublicMagicMetadataProps { + /** + * Modified value of the date time associated with an {@link EnteFile}. + * + * Epoch microseconds. + */ editedTime?: number; editedName?: string; caption?: string; From fda6f686886ed9f58b77ea374abdc1b5cc747210 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 14:43:11 +0530 Subject: [PATCH 10/18] The default type deduced by tsc from the JS works This file was apparently never in use (its extension was not .d.ts). --- desktop/src/main/types/clip-bpe-js.d.ts | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 desktop/src/main/types/clip-bpe-js.d.ts diff --git a/desktop/src/main/types/clip-bpe-js.d.ts b/desktop/src/main/types/clip-bpe-js.d.ts deleted file mode 100644 index 575b5fe127..0000000000 --- a/desktop/src/main/types/clip-bpe-js.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Types for [clip-bpe-js](https://github.com/josephrocca/clip-bpe-js). - * - * Non exhaustive, only the function we need. - */ -declare module "clip-bpe-js" { - class Tokenizer { - encodeForCLIP(text: string): number[]; - } -} From 9e81591c637121525a1e80d81ba5d267eae8d286 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 14:52:16 +0530 Subject: [PATCH 11/18] Rearrange --- web/apps/photos/src/utils/file/index.ts | 74 ++++++++++++------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index f40e791027..77144ade45 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -49,6 +49,43 @@ export enum FILE_OPS_TYPE { DELETE_PERMANENTLY, } +export async function downloadFile(file: EnteFile) { + try { + let fileBlob = await new Response( + await DownloadManager.getFile(file), + ).blob(); + if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { + const { imageFileName, imageData, videoFileName, videoData } = + await decodeLivePhoto(file.metadata.title, fileBlob); + const image = new File([imageData], imageFileName); + const imageType = await detectFileTypeInfo(image); + const tempImageURL = URL.createObjectURL( + new Blob([imageData], { type: imageType.mimeType }), + ); + const video = new File([videoData], videoFileName); + const videoType = await detectFileTypeInfo(video); + const tempVideoURL = URL.createObjectURL( + new Blob([videoData], { type: videoType.mimeType }), + ); + downloadUsingAnchor(tempImageURL, imageFileName); + downloadUsingAnchor(tempVideoURL, videoFileName); + } else { + const fileType = await detectFileTypeInfo( + new File([fileBlob], file.metadata.title), + ); + fileBlob = await new Response( + await streamWithUpdatedExif(file, fileBlob.stream()), + ).blob(); + fileBlob = new Blob([fileBlob], { type: fileType.mimeType }); + const tempURL = URL.createObjectURL(fileBlob); + downloadUsingAnchor(tempURL, file.metadata.title); + } + } catch (e) { + log.error("failed to download file", e); + throw e; + } +} + /** * Return a new stream after applying Exif updates if applicable to the given * stream, otherwise return the original. @@ -91,43 +128,6 @@ export const streamWithUpdatedExif = async ( } }; -export async function downloadFile(file: EnteFile) { - try { - let fileBlob = await new Response( - await DownloadManager.getFile(file), - ).blob(); - if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { - const { imageFileName, imageData, videoFileName, videoData } = - await decodeLivePhoto(file.metadata.title, fileBlob); - const image = new File([imageData], imageFileName); - const imageType = await detectFileTypeInfo(image); - const tempImageURL = URL.createObjectURL( - new Blob([imageData], { type: imageType.mimeType }), - ); - const video = new File([videoData], videoFileName); - const videoType = await detectFileTypeInfo(video); - const tempVideoURL = URL.createObjectURL( - new Blob([videoData], { type: videoType.mimeType }), - ); - downloadUsingAnchor(tempImageURL, imageFileName); - downloadUsingAnchor(tempVideoURL, videoFileName); - } else { - const fileType = await detectFileTypeInfo( - new File([fileBlob], file.metadata.title), - ); - fileBlob = await new Response( - await streamWithUpdatedExif(file, fileBlob.stream()), - ).blob(); - fileBlob = new Blob([fileBlob], { type: fileType.mimeType }); - const tempURL = URL.createObjectURL(fileBlob); - downloadUsingAnchor(tempURL, file.metadata.title); - } - } catch (e) { - log.error("failed to download file", e); - throw e; - } -} - /** Segment the given {@link files} into lists indexed by their collection ID */ export const groupFilesBasedOnCollectionID = (files: EnteFile[]) => { const result = new Map(); From 09036bb57fdbe86193ea73c5234c534226bf8c8e Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 15:02:36 +0530 Subject: [PATCH 12/18] Move the catch up --- web/apps/photos/src/services/export/index.ts | 4 +- web/apps/photos/src/utils/file/index.ts | 37 ++++++++++------- .../new/photos/services/exif-update.ts | 41 ++++++++----------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index 651526203b..4400471c31 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -36,7 +36,7 @@ import { getCollectionUserFacingName, getNonEmptyPersonalCollections, } from "utils/collection"; -import { getPersonalFiles, streamWithUpdatedExif } from "utils/file"; +import { getPersonalFiles, updateExifIfNeeded } from "utils/file"; import { getAllLocalCollections } from "../collectionService"; import { migrateExport } from "./migration"; @@ -970,7 +970,7 @@ class ExportService { try { const fileUID = getExportRecordFileUID(file); const originalFileStream = await downloadManager.getFile(file); - const updatedFileStream = await streamWithUpdatedExif( + const updatedFileStream = await updateExifIfNeeded( file, originalFileStream, ); diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 77144ade45..3b13c2febe 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -74,7 +74,7 @@ export async function downloadFile(file: EnteFile) { new File([fileBlob], file.metadata.title), ); fileBlob = await new Response( - await streamWithUpdatedExif(file, fileBlob.stream()), + await updateExifIfNeeded(file, fileBlob.stream()), ).blob(); fileBlob = new Blob([fileBlob], { type: fileType.mimeType }); const tempURL = URL.createObjectURL(fileBlob); @@ -107,23 +107,32 @@ export async function downloadFile(file: EnteFile) { * @returns A new {@link ReadableStream} with updates if any updates were * needed, otherwise return the original stream. */ -export const streamWithUpdatedExif = async ( +export const updateExifIfNeeded = async ( enteFile: EnteFile, stream: ReadableStream, ): Promise> => { - const extension = lowercaseExtension(enteFile.metadata.title); - if ( - enteFile.metadata.fileType === FILE_TYPE.IMAGE && - enteFile.pubMagicMetadata?.data.editedTime && - (extension == "jpeg" || extension == "jpg") - ) { - const fileBlob = await new Response(stream).blob(); - const updatedFileBlob = await setJPEGExifDateTimeOriginal( - fileBlob, + // Not an image. + if (enteFile.metadata.fileType != FILE_TYPE.IMAGE) return stream; + // Time was not edited. + if (!enteFile.pubMagicMetadata?.data.editedTime) return stream; + + const fileName = enteFile.metadata.title; + const extension = lowercaseExtension(fileName); + // Not a JPEG (likely). + if (extension != "jpeg" && extension != "jpg") return stream; + + try { + const updatedBlob = await setJPEGExifDateTimeOriginal( + await new Response(stream).blob(), new Date(enteFile.pubMagicMetadata.data.editedTime / 1000), ); - return updatedFileBlob.stream(); - } else { + return updatedBlob.stream(); + } catch (e) { + // We used the file's extension to determine if this was a JPEG, but + // this is not a guarantee. Misnamed files, while rare, do exist. So + // in case of errors, return the original back instead of causing the + // entire download or export to fail. + log.error(`Failed to modify Exif date for ${fileName}`, e); return stream; } }; @@ -491,7 +500,7 @@ async function downloadFileDesktop( const fs = electron.fs; const stream = await DownloadManager.getFile(file); - const updatedStream = await streamWithUpdatedExif(file, stream); + const updatedStream = await updateExifIfNeeded(file, stream); if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { const fileBlob = await new Response(updatedStream).blob(); diff --git a/web/packages/new/photos/services/exif-update.ts b/web/packages/new/photos/services/exif-update.ts index afc591510a..7440d7ce7a 100644 --- a/web/packages/new/photos/services/exif-update.ts +++ b/web/packages/new/photos/services/exif-update.ts @@ -1,4 +1,3 @@ -import log from "@/base/log"; import piexif from "piexifjs"; /** @@ -14,31 +13,23 @@ import piexif from "piexifjs"; */ export const setJPEGExifDateTimeOriginal = async ( jpegBlob: Blob, - updatedDate: Date, + date: Date, ) => { - try { - let imageDataURL = await blobToDataURL(jpegBlob); - // Since we pass a Blob without an associated type, we get back a - // generic data URL like "data:application/octet-stream;base64,...". - // Modify it to have a `image/jpeg` MIME type. - imageDataURL = - "data:image/jpeg;base64" + - imageDataURL.slice(imageDataURL.indexOf(",")); - const exifObj = piexif.load(imageDataURL); - if (!exifObj.Exif) exifObj.Exif = {}; - exifObj.Exif[piexif.ExifIFD.DateTimeOriginal] = - convertToExifDateFormat(updatedDate); - log.debug(() => [ - "updateFileCreationDateInEXIF", - { updatedDate, exifObj }, - ]); - const exifBytes = piexif.dump(exifObj); - const exifInsertedFile = piexif.insert(exifBytes, imageDataURL); - return dataURLToBlob(exifInsertedFile); - } catch (e) { - log.error("updateFileModifyDateInEXIF failed", e); - return jpegBlob; - } + let dataURL = await blobToDataURL(jpegBlob); + // Since we pass a Blob without an associated type, we get back a generic + // data URL of the form "data:application/octet-stream;base64,...". + // + // Modify it to have a `image/jpeg` MIME type. + dataURL = "data:image/jpeg;base64" + dataURL.slice(dataURL.indexOf(",")); + + const exifObj = piexif.load(dataURL); + if (!exifObj.Exif) exifObj.Exif = {}; + exifObj.Exif[piexif.ExifIFD.DateTimeOriginal] = + convertToExifDateFormat(date); + const exifBytes = piexif.dump(exifObj); + const exifInsertedFile = piexif.insert(exifBytes, dataURL); + + return dataURLToBlob(exifInsertedFile); }; /** From ca8ae8c6e7fd837049328f65d2fa6947dc45c68c Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 15:16:58 +0530 Subject: [PATCH 13/18] Fix the fallback --- web/apps/photos/src/utils/file/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 3b13c2febe..d0ee1f9f03 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -121,9 +121,10 @@ export const updateExifIfNeeded = async ( // Not a JPEG (likely). if (extension != "jpeg" && extension != "jpg") return stream; + const blob = await new Response(stream).blob(); try { const updatedBlob = await setJPEGExifDateTimeOriginal( - await new Response(stream).blob(), + blob, new Date(enteFile.pubMagicMetadata.data.editedTime / 1000), ); return updatedBlob.stream(); @@ -133,7 +134,7 @@ export const updateExifIfNeeded = async ( // in case of errors, return the original back instead of causing the // entire download or export to fail. log.error(`Failed to modify Exif date for ${fileName}`, e); - return stream; + return blob.stream(); } }; From 3b1fd78fbe02a1330dee446c07445878e3e1c6fe Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 15:21:35 +0530 Subject: [PATCH 14/18] Selective handling --- web/apps/photos/src/utils/file/index.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index d0ee1f9f03..394468294c 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -129,12 +129,18 @@ export const updateExifIfNeeded = async ( ); return updatedBlob.stream(); } catch (e) { - // We used the file's extension to determine if this was a JPEG, but - // this is not a guarantee. Misnamed files, while rare, do exist. So - // in case of errors, return the original back instead of causing the - // entire download or export to fail. log.error(`Failed to modify Exif date for ${fileName}`, e); - return blob.stream(); + // We used the file's extension to determine if this was a JPEG, but + // this is not a guarantee. Misnamed files, while rare, do exist. So in + // that is the error thrown by the underlying library, fallback to the + // original instead of causing the entire download or export to fail. + if ( + e instanceof Error && + e.message.endsWith("Given file is neither JPEG nor TIFF") + ) { + return blob.stream(); + } + throw e; } }; From cff6570ebbbe0e2a561b532c375eaa00a5c7d528 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 15:23:37 +0530 Subject: [PATCH 15/18] Move to a layer that should be dealing with the piexifjs internals --- web/apps/photos/src/services/export/index.ts | 5 +- web/apps/photos/src/utils/file/index.ts | 65 +----------------- .../new/photos/services/exif-update.ts | 67 +++++++++++++++++-- 3 files changed, 69 insertions(+), 68 deletions(-) diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index 4400471c31..7efb6556cc 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -4,6 +4,7 @@ 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 { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update"; import { exportMetadataDirectoryName, exportTrashDirectoryName, @@ -36,7 +37,7 @@ import { getCollectionUserFacingName, getNonEmptyPersonalCollections, } from "utils/collection"; -import { getPersonalFiles, updateExifIfNeeded } from "utils/file"; +import { getPersonalFiles } from "utils/file"; import { getAllLocalCollections } from "../collectionService"; import { migrateExport } from "./migration"; @@ -970,7 +971,7 @@ class ExportService { try { const fileUID = getExportRecordFileUID(file); const originalFileStream = await downloadManager.getFile(file); - const updatedFileStream = await updateExifIfNeeded( + const updatedFileStream = await updateExifIfNeededAndPossible( file, originalFileStream, ); diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 394468294c..fe4587e605 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -1,10 +1,9 @@ -import { lowercaseExtension } from "@/base/file"; import log from "@/base/log"; import { type Electron } from "@/base/types/ipc"; import { FILE_TYPE } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; import DownloadManager from "@/new/photos/services/download"; -import { setJPEGExifDateTimeOriginal } from "@/new/photos/services/exif-update"; +import { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update"; import { EncryptedEnteFile, EnteFile, @@ -74,7 +73,7 @@ export async function downloadFile(file: EnteFile) { new File([fileBlob], file.metadata.title), ); fileBlob = await new Response( - await updateExifIfNeeded(file, fileBlob.stream()), + await updateExifIfNeededAndPossible(file, fileBlob.stream()), ).blob(); fileBlob = new Blob([fileBlob], { type: fileType.mimeType }); const tempURL = URL.createObjectURL(fileBlob); @@ -86,64 +85,6 @@ export async function downloadFile(file: EnteFile) { } } -/** - * Return a new stream after applying Exif updates if applicable to the given - * stream, otherwise return the original. - * - * This function is meant to provide a stream that can be used to download (or - * export) a file to the user's computer after applying any Exif updates to the - * original file's data. - * - * - This only updates JPEG files. - * - * - For JPEG files, the DateTimeOriginal Exif entry is updated to reflect the - * time that the user edited within Ente. - * - * @param enteFile The {@link EnteFile} whose data we want. - * - * @param stream A {@link ReadableStream} containing the original data for - * {@link enteFile}. - * - * @returns A new {@link ReadableStream} with updates if any updates were - * needed, otherwise return the original stream. - */ -export const updateExifIfNeeded = async ( - enteFile: EnteFile, - stream: ReadableStream, -): Promise> => { - // Not an image. - if (enteFile.metadata.fileType != FILE_TYPE.IMAGE) return stream; - // Time was not edited. - if (!enteFile.pubMagicMetadata?.data.editedTime) return stream; - - const fileName = enteFile.metadata.title; - const extension = lowercaseExtension(fileName); - // Not a JPEG (likely). - if (extension != "jpeg" && extension != "jpg") return stream; - - const blob = await new Response(stream).blob(); - try { - const updatedBlob = await setJPEGExifDateTimeOriginal( - blob, - new Date(enteFile.pubMagicMetadata.data.editedTime / 1000), - ); - return updatedBlob.stream(); - } catch (e) { - log.error(`Failed to modify Exif date for ${fileName}`, e); - // We used the file's extension to determine if this was a JPEG, but - // this is not a guarantee. Misnamed files, while rare, do exist. So in - // that is the error thrown by the underlying library, fallback to the - // original instead of causing the entire download or export to fail. - if ( - e instanceof Error && - e.message.endsWith("Given file is neither JPEG nor TIFF") - ) { - return blob.stream(); - } - throw e; - } -}; - /** Segment the given {@link files} into lists indexed by their collection ID */ export const groupFilesBasedOnCollectionID = (files: EnteFile[]) => { const result = new Map(); @@ -507,7 +448,7 @@ async function downloadFileDesktop( const fs = electron.fs; const stream = await DownloadManager.getFile(file); - const updatedStream = await updateExifIfNeeded(file, stream); + const updatedStream = await updateExifIfNeededAndPossible(file, stream); if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { const fileBlob = await new Response(updatedStream).blob(); diff --git a/web/packages/new/photos/services/exif-update.ts b/web/packages/new/photos/services/exif-update.ts index 7440d7ce7a..4941cac04d 100644 --- a/web/packages/new/photos/services/exif-update.ts +++ b/web/packages/new/photos/services/exif-update.ts @@ -1,4 +1,66 @@ +import { lowercaseExtension } from "@/base/file"; +import log from "@/base/log"; +import { FILE_TYPE } from "@/media/file-type"; import piexif from "piexifjs"; +import type { EnteFile } from "../types/file"; + +/** + * Return a new stream after applying Exif updates if applicable to the given + * stream, otherwise return the original. + * + * This function is meant to provide a stream that can be used to download (or + * export) a file to the user's computer after applying any Exif updates to the + * original file's data. + * + * - This only updates JPEG files. + * + * - For JPEG files, the DateTimeOriginal Exif entry is updated to reflect the + * time that the user edited within Ente. + * + * @param enteFile The {@link EnteFile} whose data we want. + * + * @param stream A {@link ReadableStream} containing the original data for + * {@link enteFile}. + * + * @returns A new {@link ReadableStream} with updates if any updates were + * needed, otherwise return the original stream. + */ +export const updateExifIfNeededAndPossible = async ( + enteFile: EnteFile, + stream: ReadableStream, +): Promise> => { + // Not an image. + if (enteFile.metadata.fileType != FILE_TYPE.IMAGE) return stream; + // Time was not edited. + if (!enteFile.pubMagicMetadata?.data.editedTime) return stream; + + const fileName = enteFile.metadata.title; + const extension = lowercaseExtension(fileName); + // Not a JPEG (likely). + if (extension != "jpeg" && extension != "jpg") return stream; + + const blob = await new Response(stream).blob(); + try { + const updatedBlob = await setJPEGExifDateTimeOriginal( + blob, + new Date(enteFile.pubMagicMetadata.data.editedTime / 1000), + ); + return updatedBlob.stream(); + } catch (e) { + log.error(`Failed to modify Exif date for ${fileName}`, e); + // We used the file's extension to determine if this was a JPEG, but + // this is not a guarantee. Misnamed files, while rare, do exist. So in + // that is the error thrown by the underlying library, fallback to the + // original instead of causing the entire download or export to fail. + if ( + e instanceof Error && + e.message.endsWith("Given file is neither JPEG nor TIFF") + ) { + return blob.stream(); + } + throw e; + } +}; /** * Return a new blob with the "DateTimeOriginal" Exif tag set to the given @@ -11,10 +73,7 @@ import piexif from "piexifjs"; * * @returns A new blob derived from {@link jpegBlob} but with the updated date. */ -export const setJPEGExifDateTimeOriginal = async ( - jpegBlob: Blob, - date: Date, -) => { +const setJPEGExifDateTimeOriginal = async (jpegBlob: Blob, date: Date) => { let dataURL = await blobToDataURL(jpegBlob); // Since we pass a Blob without an associated type, we get back a generic // data URL of the form "data:application/octet-stream;base64,...". From 19e7c2d65c2af4d7d18fb22ade91048ee5905fb3 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 15:25:53 +0530 Subject: [PATCH 16/18] Fix --- web/packages/new/photos/services/exif-update.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/new/photos/services/exif-update.ts b/web/packages/new/photos/services/exif-update.ts index 4941cac04d..7393396ef9 100644 --- a/web/packages/new/photos/services/exif-update.ts +++ b/web/packages/new/photos/services/exif-update.ts @@ -54,7 +54,7 @@ export const updateExifIfNeededAndPossible = async ( // original instead of causing the entire download or export to fail. if ( e instanceof Error && - e.message.endsWith("Given file is neither JPEG nor TIFF") + e.message.endsWith("Given file is neither JPEG nor TIFF.") ) { return blob.stream(); } From 3ff38415653827514f97c8570c64873a9fdd2142 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 20:48:40 +0530 Subject: [PATCH 17/18] zero pad --- .../new/photos/services/exif-update.ts | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/web/packages/new/photos/services/exif-update.ts b/web/packages/new/photos/services/exif-update.ts index 7393396ef9..814eb71c68 100644 --- a/web/packages/new/photos/services/exif-update.ts +++ b/web/packages/new/photos/services/exif-update.ts @@ -119,6 +119,42 @@ const dataURLToBlob = (dataURI: string) => /** * Convert the given {@link Date} to a format that is expected by Exif for the * DateTimeOriginal tag. + * + * [Note: Exif dates] + * + * Common Exif date time tags, an in particular "DateTimeOriginal", are + * specified in the form: + * + * yyyy:MM:DD HH:mm:ss + * + * These values thus do not have an associated UTC offset or TZ. The common + * convention (based on my current understanding) is that these times are + * interpreted to be the local time where the photo was taken. + * + * Recently, there seems to be increasing support for the (newly standardized) + * "OffsetTimeOriginal" and related fields, which specifies time zone for + * "DateTimeOriginal" (and related fields). + * + * However, when the offset time tag is not present (a frequent occurrence, not + * just for older photos but also for screenshots generated by OSes as of 2024), + * we don't really know, and stick with the common convention: + * + * - When reading, assume that the Exif date is in the local TZ when deriving + * a UTC timestamp from it. + * + * - When writing, convert the UTC timestamp to local time. */ -const convertToExifDateFormat = (date: Date) => - `${date.getFullYear()}:${date.getMonth() + 1}:${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`; +const convertToExifDateFormat = (date: Date) => { + // TODO: Exif - Handle offsettime if present + const yyyy = date.getFullYear(); + const MM = zeroPad2(date.getMonth() + 1); + const dd = zeroPad2(date.getDate()); + const HH = zeroPad2(date.getHours()); + const mm = zeroPad2(date.getMinutes()); + const ss = zeroPad2(date.getSeconds()); + + return `${yyyy}:${MM}:${dd} ${HH}:${mm}:${ss}`; +}; + +/** Zero pad the given number to 2 digits. */ +const zeroPad2 = (n: number) => (n < 10 ? `0${n}` : `${n}`); From fc03d2196de8f2ef40b1fa1aa752657d3d0cd3f8 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 22 Jul 2024 21:01:36 +0530 Subject: [PATCH 18/18] Tell eslint that we want the ? --- web/packages/new/photos/services/exif-update.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/packages/new/photos/services/exif-update.ts b/web/packages/new/photos/services/exif-update.ts index 814eb71c68..77f1b3c212 100644 --- a/web/packages/new/photos/services/exif-update.ts +++ b/web/packages/new/photos/services/exif-update.ts @@ -29,14 +29,17 @@ export const updateExifIfNeededAndPossible = async ( enteFile: EnteFile, stream: ReadableStream, ): Promise> => { - // Not an image. + // Not needed: Not an image. if (enteFile.metadata.fileType != FILE_TYPE.IMAGE) return stream; - // Time was not edited. + + // Not needed: Time was not edited. + // TODO: Until the types reflect reality + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!enteFile.pubMagicMetadata?.data.editedTime) return stream; const fileName = enteFile.metadata.title; const extension = lowercaseExtension(fileName); - // Not a JPEG (likely). + // Not possible: Not a JPEG (likely). if (extension != "jpeg" && extension != "jpg") return stream; const blob = await new Response(stream).blob();