[web] Exif write-back improvements (#2515)
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* @file Types for "clip-bpe-js"
|
||||
*
|
||||
* Non exhaustive, only the function we need.
|
||||
*/
|
||||
|
||||
declare module "clip-bpe-js" {
|
||||
class Tokenizer {
|
||||
encodeForCLIP(text: string): number[];
|
||||
}
|
||||
}
|
||||
@@ -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<string, any> &
|
||||
Partial<{
|
||||
@@ -332,67 +331,3 @@ export function getEXIFTime(exifData: ParsedEXIFData): number {
|
||||
}
|
||||
return validateAndGetCreationUnixTimeInMicroSeconds(dateTime);
|
||||
}
|
||||
|
||||
export async function updateFileCreationDateInEXIF(
|
||||
reader: FileReader,
|
||||
fileBlob: Blob,
|
||||
updatedDate: Date,
|
||||
) {
|
||||
try {
|
||||
let imageDataURL = await convertImageToDataURL(reader, fileBlob);
|
||||
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);
|
||||
|
||||
const exifBytes = piexif.dump(exifObj);
|
||||
const exifInsertedFile = piexif.insert(exifBytes, imageDataURL);
|
||||
return dataURIToBlob(exifInsertedFile);
|
||||
} catch (e) {
|
||||
log.error("updateFileModifyDateInEXIF failed", e);
|
||||
return fileBlob;
|
||||
}
|
||||
}
|
||||
|
||||
async function convertImageToDataURL(reader: FileReader, blob: Blob) {
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(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;
|
||||
}
|
||||
|
||||
function convertToExifDateFormat(date: Date) {
|
||||
return `${date.getFullYear()}:${
|
||||
date.getMonth() + 1
|
||||
}:${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
|
||||
}
|
||||
|
||||
@@ -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, getUpdatedEXIFFileForDownload } from "utils/file";
|
||||
import { getPersonalFiles } from "utils/file";
|
||||
import { getAllLocalCollections } from "../collectionService";
|
||||
import { migrateExport } from "./migration";
|
||||
|
||||
@@ -970,11 +971,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,
|
||||
const updatedFileStream = await updateExifIfNeededAndPossible(
|
||||
file,
|
||||
originalFileStream,
|
||||
);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export const readAsDataURL = (blob) =>
|
||||
new Promise<string>((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<string>((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = () => resolve(fileReader.result as string);
|
||||
fileReader.onerror = () => reject(fileReader.error);
|
||||
fileReader.readAsText(blob);
|
||||
});
|
||||
@@ -1,9 +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 { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update";
|
||||
import {
|
||||
EncryptedEnteFile,
|
||||
EnteFile,
|
||||
@@ -25,7 +25,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 "services/exif";
|
||||
import {
|
||||
deleteFromTrash,
|
||||
trashFiles,
|
||||
@@ -49,32 +48,8 @@ export enum FILE_OPS_TYPE {
|
||||
DELETE_PERMANENTLY,
|
||||
}
|
||||
|
||||
export async function getUpdatedEXIFFileForDownload(
|
||||
fileReader: FileReader,
|
||||
file: EnteFile,
|
||||
fileStream: ReadableStream<Uint8Array>,
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
const extension = lowercaseExtension(file.metadata.title);
|
||||
if (
|
||||
file.metadata.fileType === FILE_TYPE.IMAGE &&
|
||||
file.pubMagicMetadata?.data.editedTime &&
|
||||
(extension == "jpeg" || extension == "jpg")
|
||||
) {
|
||||
const fileBlob = await new Response(fileStream).blob();
|
||||
const updatedFileBlob = await updateFileCreationDateInEXIF(
|
||||
fileReader,
|
||||
fileBlob,
|
||||
new Date(file.pubMagicMetadata.data.editedTime / 1000),
|
||||
);
|
||||
return updatedFileBlob.stream();
|
||||
} else {
|
||||
return fileStream;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadFile(file: EnteFile) {
|
||||
try {
|
||||
const fileReader = new FileReader();
|
||||
let fileBlob = await new Response(
|
||||
await DownloadManager.getFile(file),
|
||||
).blob();
|
||||
@@ -98,11 +73,7 @@ export async function downloadFile(file: EnteFile) {
|
||||
new File([fileBlob], file.metadata.title),
|
||||
);
|
||||
fileBlob = await new Response(
|
||||
await getUpdatedEXIFFileForDownload(
|
||||
fileReader,
|
||||
file,
|
||||
fileBlob.stream(),
|
||||
),
|
||||
await updateExifIfNeededAndPossible(file, fileBlob.stream()),
|
||||
).blob();
|
||||
fileBlob = new Blob([fileBlob], { type: fileType.mimeType });
|
||||
const tempURL = URL.createObjectURL(fileBlob);
|
||||
@@ -455,13 +426,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,19 +442,13 @@ async function downloadFilesDesktop(
|
||||
|
||||
async function downloadFileDesktop(
|
||||
electron: Electron,
|
||||
fileReader: FileReader,
|
||||
file: EnteFile,
|
||||
downloadDir: string,
|
||||
) {
|
||||
const fs = electron.fs;
|
||||
const stream = (await DownloadManager.getFile(
|
||||
file,
|
||||
)) as ReadableStream<Uint8Array>;
|
||||
const updatedStream = await getUpdatedEXIFFileForDownload(
|
||||
fileReader,
|
||||
file,
|
||||
stream,
|
||||
);
|
||||
|
||||
const stream = await DownloadManager.getFile(file);
|
||||
const updatedStream = await updateExifIfNeededAndPossible(file, stream);
|
||||
|
||||
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
|
||||
const fileBlob = await new Response(updatedStream).blob();
|
||||
|
||||
@@ -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
|
||||
|
||||
163
web/packages/new/photos/services/exif-update.ts
Normal file
163
web/packages/new/photos/services/exif-update.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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<Uint8Array>,
|
||||
): Promise<ReadableStream<Uint8Array>> => {
|
||||
// Not needed: Not an image.
|
||||
if (enteFile.metadata.fileType != FILE_TYPE.IMAGE) return stream;
|
||||
|
||||
// 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 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(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
|
||||
* {@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<string>((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.
|
||||
*
|
||||
* [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) => {
|
||||
// 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}`);
|
||||
@@ -116,6 +116,11 @@ export interface FileMagicMetadataProps {
|
||||
export type FileMagicMetadata = MagicMetadataCore<FileMagicMetadataProps>;
|
||||
|
||||
export interface FilePublicMagicMetadataProps {
|
||||
/**
|
||||
* Modified value of the date time associated with an {@link EnteFile}.
|
||||
*
|
||||
* Epoch microseconds.
|
||||
*/
|
||||
editedTime?: number;
|
||||
editedName?: string;
|
||||
caption?: string;
|
||||
|
||||
42
web/packages/new/photos/types/piexifjs.d.ts
vendored
Normal file
42
web/packages/new/photos/types/piexifjs.d.ts
vendored
Normal file
@@ -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<number, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user