From f97952298dbce2a4e0950a18c8d41120071d6de1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 30 Oct 2024 10:20:45 +0530 Subject: [PATCH] [web] [desktop] Retain JPEG originals even on date modifications --- desktop/CHANGELOG.md | 1 + docs/docs/photos/faq/photo-dates.md | 39 ++++- web/apps/photos/package.json | 1 - web/apps/photos/src/services/export/index.ts | 9 +- web/apps/photos/src/utils/file/index.ts | 9 +- web/docs/dependencies.md | 3 +- .../new/photos/services/exif-update.ts | 147 ------------------ web/packages/new/photos/types/piexifjs.d.ts | 42 ----- web/yarn.lock | 5 - 9 files changed, 38 insertions(+), 218 deletions(-) delete mode 100644 web/packages/new/photos/services/exif-update.ts delete mode 100644 web/packages/new/photos/types/piexifjs.d.ts diff --git a/desktop/CHANGELOG.md b/desktop/CHANGELOG.md index 2b0ed2975c..e544b644b1 100644 --- a/desktop/CHANGELOG.md +++ b/desktop/CHANGELOG.md @@ -2,6 +2,7 @@ ## v1.7.7 (Unreleased) +- Retain JPEG originals even on date modifications. - . ## v1.7.6 diff --git a/docs/docs/photos/faq/photo-dates.md b/docs/docs/photos/faq/photo-dates.md index 10710caa80..1918093dc7 100644 --- a/docs/docs/photos/faq/photo-dates.md +++ b/docs/docs/photos/faq/photo-dates.md @@ -64,12 +64,14 @@ videos that you imported. The modifications (e.g. date changes) you make within Ente will be written into a separate metadata JSON file during export so as to not modify the original. -> There is one exception to this. For JPEG files, the Exif DateTimeOriginal is -> changed during export from web or desktop apps. This was done on a customer -> request, but in hindsight this has been an incorrect move. We are going to -> deprecate this behavior, and will instead provide separate tools (or -> functionality within the app itself) for customers who intentionally wish to -> modify their originals to reflect the associated metadata JSON. +> [!WARNING] +> +> There used to be one exception to this - for JPEG files, the Exif +> DateTimeOriginal was changed during export from web or desktop apps. This was +> done on a customer request, but in hindsight this was an incorrect change. +> +> We have deprecated this behaviour, and the desktop version 1.7.6 is going to +> be the last version with this exception. As an example: suppose you have `flower.png`. When you export your library, you will end up with: @@ -81,13 +83,36 @@ metadata/flower.png.json Ente writes this JSON in the same format as Google Takeout so that if a tool supports Google Takeout import, it should be able to read the JSON written by -Ente too +Ente too. > One small difference is that, to avoid clutter, Ente puts the JSON in the > `metadata/` subfolder, while Google puts it next to the file.
> >
Ente itself will read it from either place. +Here is a sample of how the JSON would look: + +```json +{ + "description": "This will be imported as the caption", + "creationTime": { + "timestamp": "1613532136", + "formatted": "17 Feb 2021, 03:22:16 UTC" + }, + "modificationTime": { + "timestamp": "1640225957", + "formatted": "23 Dec 2021, 02:19:17 UTC" + }, + "geoData": { + "latitude": 12.004170700000001, + "longitude": 79.8013945 + } +} +``` + +`photoTakenTime` will be considered as an alias for `creationTime`, and +`geoDataExif` will be considered as a fallback for `geoData`. + ### File creation time. The photo's data will be preserved verbatim, however when it is written out to diff --git a/web/apps/photos/package.json b/web/apps/photos/package.json index e2b6b828e3..1bfcec9104 100644 --- a/web/apps/photos/package.json +++ b/web/apps/photos/package.json @@ -24,7 +24,6 @@ "ml-matrix": "^6.11", "p-debounce": "^4.0.0", "photoswipe": "file:./thirdparty/photoswipe", - "piexifjs": "^1.0.6", "pure-react-carousel": "^1.30.1", "react-dropzone": "^14.2", "react-select": "^5.8.0", diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index e31e5d0494..53192409a7 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -14,7 +14,6 @@ import { getCollectionUserFacingName, } from "@/new/photos/services/collection"; import downloadManager from "@/new/photos/services/download"; -import { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update"; import { exportMetadataDirectoryName, exportTrashDirectoryName, @@ -939,16 +938,12 @@ class ExportService { try { const fileUID = getExportRecordFileUID(file); const originalFileStream = await downloadManager.getFile(file); - const updatedFileStream = await updateExifIfNeededAndPossible( - file, - originalFileStream, - ); if (file.metadata.fileType === FileType.livePhoto) { await this.exportLivePhoto( exportDir, fileUID, collectionExportPath, - updatedFileStream, + originalFileStream, file, ); } else { @@ -965,7 +960,7 @@ class ExportService { await writeStream( electron, `${collectionExportPath}/${fileExportName}`, - updatedFileStream, + originalFileStream, ); await this.addFileExportedRecord( exportDir, diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index c9f2280c7c..79588f5170 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -16,7 +16,6 @@ import { ItemVisibility } from "@/media/file-metadata"; import { FileType } from "@/media/file-type"; import { decodeLivePhoto } from "@/media/live-photo"; import DownloadManager from "@/new/photos/services/download"; -import { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update"; import { isArchivedFile, updateMagicMetadata, @@ -79,9 +78,6 @@ export async function downloadFile(file: EnteFile) { const fileType = await detectFileTypeInfo( new File([fileBlob], file.metadata.title), ); - fileBlob = await new Response( - await updateExifIfNeededAndPossible(file, fileBlob.stream()), - ).blob(); fileBlob = new Blob([fileBlob], { type: fileType.mimeType }); const tempURL = URL.createObjectURL(fileBlob); downloadAndRevokeObjectURL(tempURL, file.metadata.title); @@ -397,10 +393,9 @@ async function downloadFileDesktop( const fs = electron.fs; const stream = await DownloadManager.getFile(file); - const updatedStream = await updateExifIfNeededAndPossible(file, stream); if (file.metadata.fileType === FileType.livePhoto) { - const fileBlob = await new Response(updatedStream).blob(); + const fileBlob = await new Response(stream).blob(); const { imageFileName, imageData, videoFileName, videoData } = await decodeLivePhoto(file.metadata.title, fileBlob); const imageExportName = await safeFileName( @@ -439,7 +434,7 @@ async function downloadFileDesktop( await writeStream( electron, `${downloadDir}/${fileExportName}`, - updatedStream, + stream, ); } } diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index 5c43b11966..ba6d2f677b 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -167,8 +167,7 @@ For more details, see [translations.md](translations.md). ## Media - [ExifReader](https://github.com/mattiasw/ExifReader) is used for Exif - parsing. [piexifjs](https://github.com/hMatoba/piexifjs) is used for writing - back Exif (only supports JPEG). + parsing. - [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 deleted file mode 100644 index 64a343ad35..0000000000 --- a/web/packages/new/photos/services/exif-update.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { lowercaseExtension } from "@/base/file"; -import log from "@/base/log"; -import type { EnteFile } from "@/media/file"; -import { FileType } from "@/media/file-type"; -import piexif from "piexifjs"; - -/** - * 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 file The {@link EnteFile} whose data we want. - * - * @param stream A {@link ReadableStream} containing the original data for - * {@link file}. - * - * @returns A new {@link ReadableStream} with updates if any updates were - * needed, otherwise return the original stream. - */ -export const updateExifIfNeededAndPossible = async ( - file: EnteFile, - stream: ReadableStream, -): Promise> => { - // Not needed: Not an image. - if (file.metadata.fileType != FileType.image) return stream; - - // Not needed: Time was not edited. - if (!file.pubMagicMetadata?.data.editedTime) return stream; - - const fileName = file.metadata.title; - const extension = lowercaseExtension(fileName); - // Not possible: 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(file.pubMagicMetadata.data.editedTime / 1000), - ); - return updatedBlob.stream(); - } catch (e) { - log.error(`Failed to modify Exif date for ${fileName}`, e); - // Ignore errors and use the original - we don't want to block the whole - // download or export for an errant file. TODO: This is not always going - // to be the correct choice, but instead trying further hack around with - // the Exif modifications (and all the caveats that come with it), a - // more principled approach is to put our metadata in a sidecar and - // never touch the original. We can then and provide additional tools to - // update the original if the user so wishes from the sidecar. - return blob.stream(); - } -}; - -/** - * 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. - */ -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,...". - // - // 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); -}; - -/** - * 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()); - -/** - * Convert the given {@link Date} to a format that is expected by Exif for the - * DateTimeOriginal tag. - * - * See: [Note: Exif dates] - * - * --- - * - * TODO: This functionality is deprecated. The library we use here is - * unmaintained and there are no comprehensive other JS libs. - * - * Instead of doing this in this selective way, we should provide a CLI tool - * with better format support and more comprehensive handling of Exif and other - * metadata fields (like captions) that can be used by the user to modify their - * original from the Ente sidecar if they so wish. - */ -const convertToExifDateFormat = (date: Date) => { - const YYYY = zeroPad(date.getFullYear(), 4); - // JavaScript getMonth is zero-indexed, we want one-indexed. - const MM = zeroPad(date.getMonth() + 1, 2); - // JavaScript getDate is NOT zero-indexed, it is already one-indexed. - const DD = zeroPad(date.getDate(), 2); - const HH = zeroPad(date.getHours(), 2); - const mm = zeroPad(date.getMinutes(), 2); - const ss = zeroPad(date.getSeconds(), 2); - - return `${YYYY}:${MM}:${DD} ${HH}:${mm}:${ss}`; -}; - -/** Zero pad the given number to {@link d} digits. */ -const zeroPad = (n: number, d: number) => n.toString().padStart(d, "0"); diff --git a/web/packages/new/photos/types/piexifjs.d.ts b/web/packages/new/photos/types/piexifjs.d.ts deleted file mode 100644 index 211ee9754e..0000000000 --- a/web/packages/new/photos/types/piexifjs.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 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; -} diff --git a/web/yarn.lock b/web/yarn.lock index a1dc11d308..ab0c8a1aa9 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -3602,11 +3602,6 @@ picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -piexifjs@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/piexifjs/-/piexifjs-1.0.6.tgz#883811d73f447218d0d06e9ed7866d04533e59e0" - integrity sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag== - pngjs@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821"