|
@@ -1,14 +1,23 @@
|
|
|
import { FILE_TYPE } from "@/media/file-type";
|
|
|
-import { isNonWebImageFileExtension } from "@/media/formats";
|
|
|
+import { isHEICExtension, isNonWebImageFileExtension } from "@/media/formats";
|
|
|
import { decodeLivePhoto } from "@/media/live-photo";
|
|
|
+import { createHEICConvertComlinkWorker } from "@/media/worker/heic-convert";
|
|
|
+import type { DedicatedHEICConvertWorker } from "@/media/worker/heic-convert.worker";
|
|
|
import { nameAndExtension } from "@/next/file";
|
|
|
import log from "@/next/log";
|
|
|
+import type { ComlinkWorker } from "@/next/worker/comlink-worker";
|
|
|
import { shuffled } from "@/utils/array";
|
|
|
-import { ensure, ensureString } from "@/utils/ensure";
|
|
|
+import { wait } from "@/utils/promise";
|
|
|
import ComlinkCryptoWorker from "@ente/shared/crypto";
|
|
|
+import { ApiError } from "@ente/shared/error";
|
|
|
import HTTPService from "@ente/shared/network/HTTPService";
|
|
|
-import { getCastFileURL, getEndpoint } from "@ente/shared/network/api";
|
|
|
-import { wait } from "@ente/shared/utils";
|
|
|
+import {
|
|
|
+ getCastFileURL,
|
|
|
+ getCastThumbnailURL,
|
|
|
+ getEndpoint,
|
|
|
+} from "@ente/shared/network/api";
|
|
|
+import type { AxiosResponse } from "axios";
|
|
|
+import type { CastData } from "services/cast-data";
|
|
|
import { detectMediaMIMEType } from "services/detect-type";
|
|
|
import {
|
|
|
EncryptedEnteFile,
|
|
@@ -16,53 +25,20 @@ import {
|
|
|
FileMagicMetadata,
|
|
|
FilePublicMagicMetadata,
|
|
|
} from "types/file";
|
|
|
+import { isChromecast } from "./chromecast";
|
|
|
|
|
|
/**
|
|
|
- * Save the data received after pairing with a sender into local storage.
|
|
|
- *
|
|
|
- * We will read in back when we start the slideshow.
|
|
|
+ * If we're using HEIC conversion, then this variable caches the comlink web
|
|
|
+ * worker we're using to perform the actual conversion.
|
|
|
*/
|
|
|
-export const storeCastData = (payload: unknown) => {
|
|
|
- if (!payload || typeof payload != "object")
|
|
|
- throw new Error("Unexpected cast data");
|
|
|
-
|
|
|
- // Iterate through all the keys of the payload object and save them to
|
|
|
- // localStorage. We don't validate here, we'll validate when we read these
|
|
|
- // values back in `readCastData`.
|
|
|
- for (const key in payload) {
|
|
|
- window.localStorage.setItem(key, payload[key]);
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-interface CastData {
|
|
|
- /** A key to decrypt the collection we are casting. */
|
|
|
- collectionKey: string;
|
|
|
- /** A credential to use for fetching media files for this cast session. */
|
|
|
- castToken: string;
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Read back the cast data we got after pairing.
|
|
|
- *
|
|
|
- * Sibling of {@link storeCastData}. It throws an error if the expected data is
|
|
|
- * not present in localStorage.
|
|
|
- */
|
|
|
-export const readCastData = (): CastData => {
|
|
|
- const collectionKey = ensureString(localStorage.getItem("collectionKey"));
|
|
|
- const castToken = ensureString(localStorage.getItem("castToken"));
|
|
|
- return { collectionKey, castToken };
|
|
|
-};
|
|
|
-
|
|
|
-type RenderableImageURLPair = [url: string, nextURL: string];
|
|
|
+let heicWorker: ComlinkWorker<typeof DedicatedHEICConvertWorker> | undefined;
|
|
|
|
|
|
/**
|
|
|
* An async generator function that loops through all the files in the
|
|
|
- * collection, returning renderable URLs to each that can be displayed in a
|
|
|
- * slideshow.
|
|
|
+ * collection, returning renderable image URLs to each that can be displayed in
|
|
|
+ * a slideshow.
|
|
|
*
|
|
|
- * Each time it resolves with a pair of URLs (a {@link RenderableImageURLPair}),
|
|
|
- * one for the next slideshow image, and one for the slideshow image that will
|
|
|
- * be displayed after that. It also pre-fetches the next to next URL each time.
|
|
|
+ * Each time it resolves with a (data) URL for the slideshow image to show next.
|
|
|
*
|
|
|
* If there are no renderable image in the collection, the sequence ends by
|
|
|
* yielding `{done: true}`.
|
|
@@ -73,14 +49,18 @@ type RenderableImageURLPair = [url: string, nextURL: string];
|
|
|
*
|
|
|
* The generator ignores errors in the fetching and decoding of individual
|
|
|
* images in the collection, skipping the erroneous ones and moving onward to
|
|
|
- * the next one. It will however throw if there are errors when getting the
|
|
|
- * collection itself. This can happen both the first time, or when we are about
|
|
|
- * to loop around to the start of the collection.
|
|
|
+ * the next one.
|
|
|
+ *
|
|
|
+ * - It will however throw if there are errors when getting the collection
|
|
|
+ * itself. This can happen both the first time, or when we are about to loop
|
|
|
+ * around to the start of the collection.
|
|
|
+ *
|
|
|
+ * - It will also throw if three consecutive image fail.
|
|
|
*
|
|
|
* @param castData The collection to show and credentials to fetch the files
|
|
|
* within it.
|
|
|
*/
|
|
|
-export const renderableImageURLs = async function* (castData: CastData) {
|
|
|
+export const imageURLGenerator = async function* (castData: CastData) {
|
|
|
const { collectionKey, castToken } = castData;
|
|
|
|
|
|
/**
|
|
@@ -89,11 +69,8 @@ export const renderableImageURLs = async function* (castData: CastData) {
|
|
|
*/
|
|
|
const previousURLs: string[] = [];
|
|
|
|
|
|
- /** The URL pair that we will yield */
|
|
|
- const urls: string[] = [];
|
|
|
-
|
|
|
/** Number of milliseconds to keep the slide on the screen. */
|
|
|
- const slideDuration = 10000; /* 10 s */
|
|
|
+ const slideDuration = 12000; /* 12 s */
|
|
|
|
|
|
/**
|
|
|
* Time when we last yielded.
|
|
@@ -108,6 +85,14 @@ export const renderableImageURLs = async function* (castData: CastData) {
|
|
|
// bit, for the user to see the checkmark animation as reassurance).
|
|
|
lastYieldTime -= slideDuration - 2500; /* wait at most 2.5 s */
|
|
|
|
|
|
+ /**
|
|
|
+ * Number of time we have caught an exception while trying to generate an
|
|
|
+ * image URL for individual files.
|
|
|
+ *
|
|
|
+ * When this happens three times consecutively, we throw.
|
|
|
+ */
|
|
|
+ let consecutiveFailures = 0;
|
|
|
+
|
|
|
while (true) {
|
|
|
const encryptedFiles = shuffled(
|
|
|
await getEncryptedCollectionFiles(castToken),
|
|
@@ -118,30 +103,34 @@ export const renderableImageURLs = async function* (castData: CastData) {
|
|
|
for (const encryptedFile of encryptedFiles) {
|
|
|
const file = await decryptEnteFile(encryptedFile, collectionKey);
|
|
|
|
|
|
- if (!isFileEligibleForCast(file)) continue;
|
|
|
+ if (!isFileEligible(file)) continue;
|
|
|
|
|
|
- console.log("will start createRenderableURL", new Date());
|
|
|
+ let url: string;
|
|
|
try {
|
|
|
- urls.push(await createRenderableURL(castToken, file));
|
|
|
+ url = await createRenderableURL(castToken, file);
|
|
|
+ consecutiveFailures = 0;
|
|
|
haveEligibleFiles = true;
|
|
|
} catch (e) {
|
|
|
+ consecutiveFailures += 1;
|
|
|
+ // 1, 2, bang!
|
|
|
+ if (consecutiveFailures == 3) throw e;
|
|
|
+
|
|
|
+ if (e instanceof ApiError && e.httpStatusCode == 401) {
|
|
|
+ // The token has expired. This can happen, e.g., if the user
|
|
|
+ // opens the dialog to cast again, causing the client to
|
|
|
+ // invalidate existing tokens.
|
|
|
+ //
|
|
|
+ // Rethrow the error, which will bring us back to the
|
|
|
+ // pairing page.
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+
|
|
|
+ // On all other errors (including temporary network issues),
|
|
|
log.error("Skipping unrenderable file", e);
|
|
|
+ await wait(100); /* Breathe */
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
- console.log("did end createRenderableURL", new Date());
|
|
|
-
|
|
|
- // Need at least a pair.
|
|
|
- //
|
|
|
- // There are two scenarios:
|
|
|
- //
|
|
|
- // - First run: urls will initially be empty, so gobble two.
|
|
|
- //
|
|
|
- // - Subsequently, urls will have the "next" / "preloaded" URL left
|
|
|
- // over from the last time. We'll promote that to being the one
|
|
|
- // that'll get displayed, and preload another one.
|
|
|
- // if (urls.length < 2) continue;
|
|
|
-
|
|
|
// The last element of previousURLs is the URL that is currently
|
|
|
// being shown on screen.
|
|
|
//
|
|
@@ -150,23 +139,14 @@ export const renderableImageURLs = async function* (castData: CastData) {
|
|
|
if (previousURLs.length > 1)
|
|
|
URL.revokeObjectURL(previousURLs.shift());
|
|
|
|
|
|
- // The URL that'll now get displayed on screen.
|
|
|
- const url = ensure(urls.shift());
|
|
|
- // The URL that we're preloading for next time around.
|
|
|
- const nextURL = ""; //ensure(urls[0]);
|
|
|
-
|
|
|
previousURLs.push(url);
|
|
|
|
|
|
- const urlPair: RenderableImageURLPair = [url, nextURL];
|
|
|
-
|
|
|
const elapsedTime = Date.now() - lastYieldTime;
|
|
|
- if (elapsedTime > 0 && elapsedTime < slideDuration) {
|
|
|
- console.log("waiting", slideDuration - elapsedTime);
|
|
|
+ if (elapsedTime > 0 && elapsedTime < slideDuration)
|
|
|
await wait(slideDuration - elapsedTime);
|
|
|
- }
|
|
|
|
|
|
lastYieldTime = Date.now();
|
|
|
- yield urlPair;
|
|
|
+ yield url;
|
|
|
}
|
|
|
|
|
|
// This collection does not have any files that we can show.
|
|
@@ -185,7 +165,7 @@ const getEncryptedCollectionFiles = async (
|
|
|
): Promise<EncryptedEnteFile[]> => {
|
|
|
let files: EncryptedEnteFile[] = [];
|
|
|
let sinceTime = 0;
|
|
|
- let resp;
|
|
|
+ let resp: AxiosResponse;
|
|
|
do {
|
|
|
resp = await HTTPService.get(
|
|
|
`${getEndpoint()}/cast/diff`,
|
|
@@ -269,12 +249,19 @@ const decryptEnteFile = async (
|
|
|
return file;
|
|
|
};
|
|
|
|
|
|
-const isFileEligibleForCast = (file: EnteFile) => {
|
|
|
+const isFileEligible = (file: EnteFile) => {
|
|
|
if (!isImageOrLivePhoto(file)) return false;
|
|
|
if (file.info.fileSize > 100 * 1024 * 1024) return false;
|
|
|
|
|
|
+ // This check is fast but potentially incorrect because in practice we do
|
|
|
+ // encounter files that are incorrectly named and have a misleading
|
|
|
+ // extension. To detect the actual type, we need to sniff the MIME type, but
|
|
|
+ // that requires downloading and decrypting the file first.
|
|
|
const [, extension] = nameAndExtension(file.metadata.title);
|
|
|
- if (isNonWebImageFileExtension(extension)) return false;
|
|
|
+ if (isNonWebImageFileExtension(extension)) {
|
|
|
+ // Of the known non-web types, we support HEIC.
|
|
|
+ return isHEICExtension(extension);
|
|
|
+ }
|
|
|
|
|
|
return true;
|
|
|
};
|
|
@@ -284,6 +271,12 @@ const isImageOrLivePhoto = (file: EnteFile) => {
|
|
|
return fileType == FILE_TYPE.IMAGE || fileType == FILE_TYPE.LIVE_PHOTO;
|
|
|
};
|
|
|
|
|
|
+export const heicToJPEG = async (heicBlob: Blob) => {
|
|
|
+ let worker = heicWorker;
|
|
|
+ if (!worker) heicWorker = worker = createHEICConvertComlinkWorker();
|
|
|
+ return await (await worker.remote).heicToJPEG(heicBlob);
|
|
|
+};
|
|
|
+
|
|
|
/**
|
|
|
* Create and return a new data URL that can be used to show the given
|
|
|
* {@link file} in our slideshow image viewer.
|
|
@@ -291,29 +284,50 @@ const isImageOrLivePhoto = (file: EnteFile) => {
|
|
|
* Once we're done showing the file, the URL should be revoked using
|
|
|
* {@link URL.revokeObjectURL} to free up browser resources.
|
|
|
*/
|
|
|
-const createRenderableURL = async (castToken: string, file: EnteFile) =>
|
|
|
- URL.createObjectURL(await renderableImageBlob(castToken, file));
|
|
|
+const createRenderableURL = async (castToken: string, file: EnteFile) => {
|
|
|
+ const imageBlob = await renderableImageBlob(castToken, file);
|
|
|
+ return URL.createObjectURL(imageBlob);
|
|
|
+};
|
|
|
|
|
|
const renderableImageBlob = async (castToken: string, file: EnteFile) => {
|
|
|
- const fileName = file.metadata.title;
|
|
|
- let blob = await downloadFile(castToken, file);
|
|
|
- if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
|
|
|
- const { imageData } = await decodeLivePhoto(fileName, blob);
|
|
|
+ const shouldUseThumbnail = isChromecast();
|
|
|
+
|
|
|
+ let blob = await downloadFile(castToken, file, shouldUseThumbnail);
|
|
|
+
|
|
|
+ let fileName = file.metadata.title;
|
|
|
+ if (!shouldUseThumbnail && file.metadata.fileType == FILE_TYPE.LIVE_PHOTO) {
|
|
|
+ const { imageData, imageFileName } = await decodeLivePhoto(
|
|
|
+ fileName,
|
|
|
+ blob,
|
|
|
+ );
|
|
|
+ fileName = imageFileName;
|
|
|
blob = new Blob([imageData]);
|
|
|
}
|
|
|
+
|
|
|
+ // We cannot rely on the file's extension to detect the file type, some
|
|
|
+ // files are incorrectly named. So use a MIME type sniffer first, but if
|
|
|
+ // that fails than fallback to the extension.
|
|
|
const mimeType = await detectMediaMIMEType(new File([blob], fileName));
|
|
|
if (!mimeType)
|
|
|
throw new Error(`Could not detect MIME type for file ${fileName}`);
|
|
|
+
|
|
|
+ if (mimeType == "image/heif" || mimeType == "image/heic")
|
|
|
+ blob = await heicToJPEG(blob);
|
|
|
+
|
|
|
return new Blob([blob], { type: mimeType });
|
|
|
};
|
|
|
|
|
|
-const downloadFile = async (castToken: string, file: EnteFile) => {
|
|
|
+const downloadFile = async (
|
|
|
+ castToken: string,
|
|
|
+ file: EnteFile,
|
|
|
+ shouldUseThumbnail: boolean,
|
|
|
+) => {
|
|
|
if (!isImageOrLivePhoto(file))
|
|
|
throw new Error("Can only cast images and live photos");
|
|
|
|
|
|
- const url = getCastFileURL(file.id);
|
|
|
- // TODO(MR): Remove if usused eventually
|
|
|
- // const url = getCastThumbnailURL(file.id);
|
|
|
+ const url = shouldUseThumbnail
|
|
|
+ ? getCastThumbnailURL(file.id)
|
|
|
+ : getCastFileURL(file.id);
|
|
|
const resp = await HTTPService.get(
|
|
|
url,
|
|
|
null,
|
|
@@ -327,9 +341,11 @@ const downloadFile = async (castToken: string, file: EnteFile) => {
|
|
|
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
|
|
const decrypted = await cryptoWorker.decryptFile(
|
|
|
new Uint8Array(resp.data),
|
|
|
- await cryptoWorker.fromB64(file.file.decryptionHeader),
|
|
|
- // TODO(MR): Remove if usused eventually
|
|
|
- // await cryptoWorker.fromB64(file.thumbnail.decryptionHeader),
|
|
|
+ await cryptoWorker.fromB64(
|
|
|
+ shouldUseThumbnail
|
|
|
+ ? file.thumbnail.decryptionHeader
|
|
|
+ : file.file.decryptionHeader,
|
|
|
+ ),
|
|
|
file.key,
|
|
|
);
|
|
|
return new Response(decrypted).blob();
|