[web] Fix edited file name (#1684)

This commit is contained in:
Manav Rathi 2024-05-11 09:45:02 +05:30 committed by GitHub
commit ef7ce642d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 126 additions and 81 deletions

View file

@ -28,7 +28,6 @@
"leaflet-defaulticon-compatibility": "^0.1.1",
"localforage": "^1.9.0",
"memoize-one": "^6.0.0",
"mime-types": "^2.1.35",
"ml-matrix": "^6.10.4",
"otpauth": "^9.0.2",
"p-debounce": "^4.0.0",

View file

@ -1,24 +1,6 @@
import { nameAndExtension } from "@/next/file";
import log from "@/next/log";
import {
Backdrop,
Box,
CircularProgress,
IconButton,
Tab,
Tabs,
Typography,
} from "@mui/material";
import {
Dispatch,
MutableRefObject,
SetStateAction,
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { ensure } from "@/utils/ensure";
import {
CenteredFlex,
HorizontalFlex,
@ -32,6 +14,15 @@ import CropIcon from "@mui/icons-material/Crop";
import CropOriginalIcon from "@mui/icons-material/CropOriginal";
import DownloadIcon from "@mui/icons-material/Download";
import MenuIcon from "@mui/icons-material/Menu";
import {
Backdrop,
Box,
CircularProgress,
IconButton,
Tab,
Tabs,
Typography,
} from "@mui/material";
import { EnteDrawer } from "components/EnteDrawer";
import { EnteMenuItem } from "components/Menu/EnteMenuItem";
import MenuItemDivider from "components/Menu/MenuItemDivider";
@ -39,10 +30,18 @@ import { MenuItemGroup } from "components/Menu/MenuItemGroup";
import MenuSectionTitle from "components/Menu/MenuSectionTitle";
import { CORNER_THRESHOLD, FILTER_DEFAULT_VALUES } from "constants/photoEditor";
import { t } from "i18next";
import mime from "mime-types";
import { AppContext } from "pages/_app";
import {
Dispatch,
MutableRefObject,
SetStateAction,
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { getLocalCollections } from "services/collectionService";
import { detectFileTypeInfo } from "services/detect-type";
import downloadManager from "services/download";
import uploadManager from "services/upload/uploadManager";
import { EnteFile } from "types/file";
@ -72,13 +71,6 @@ export const ImageEditorOverlayContext = createContext(
type OperationTab = "crop" | "transform" | "colours";
const getEditedFileName = (fileName: string) => {
const fileNameParts = fileName.split(".");
const extension = fileNameParts.pop();
const editedFileName = `${fileNameParts.join(".")}-edited.${extension}`;
return editedFileName;
};
export interface CropBoxProps {
x: number;
y: number;
@ -94,6 +86,10 @@ const ImageEditorOverlay = (props: IProps) => {
const parentRef = useRef<HTMLDivElement | null>(null);
const [fileURL, setFileURL] = useState<string>("");
// The MIME type of the original file that we are editing.
//
// It _should_ generally be present, but it is not guaranteed to be.
const [mimeType, setMIMEType] = useState<string | undefined>();
const [currentRotationAngle, setCurrentRotationAngle] = useState(0);
@ -372,6 +368,10 @@ const ImageEditorOverlay = (props: IProps) => {
);
img.src = srcURLs.url as string;
setFileURL(srcURLs.url as string);
// We're casting the srcURLs.url to string above, i.e. this code
// is not meant to run for the live photos scenario. For images,
// we usually will have the mime type.
setMIMEType(srcURLs.mimeType);
} else {
img.src = fileURL;
}
@ -430,37 +430,6 @@ const ImageEditorOverlay = (props: IProps) => {
loadCanvas();
}, [props.show, props.file]);
const exportCanvasToBlob = (): Promise<Blob> => {
try {
const canvas = originalSizeCanvasRef.current;
if (!canvas) return;
const mimeType = mime.lookup(props.file.metadata.title);
const image = new Image();
image.src = canvas.toDataURL();
const context = canvas.getContext("2d");
if (!context) return;
return new Promise((resolve) => {
canvas.toBlob(resolve, mimeType);
});
} catch (e) {
log.error("Error exporting canvas to blob", e);
throw e;
}
};
const getEditedFile = async () => {
const blob = await exportCanvasToBlob();
if (!blob) {
throw Error("no blob");
}
const editedFileName = getEditedFileName(props.file.metadata.title);
const editedFile = new File([blob], editedFileName);
return editedFile;
};
const handleClose = () => {
setFileURL(null);
props.onClose();
@ -480,25 +449,23 @@ const ImageEditorOverlay = (props: IProps) => {
return <></>;
}
const downloadEditedPhoto = async () => {
try {
if (!canvasRef.current) return;
const getEditedFile = async () => {
const originalSizeCanvas = ensure(originalSizeCanvasRef.current);
const originalFileName = props.file.metadata.title;
return canvasToFile(originalSizeCanvas, originalFileName, mimeType);
};
const editedFile = await getEditedFile();
const fileType = await detectFileTypeInfo(editedFile);
const tempImgURL = URL.createObjectURL(
new Blob([editedFile], { type: fileType.mimeType }),
);
downloadUsingAnchor(tempImgURL, editedFile.name);
} catch (e) {
log.error("Error downloading edited photo", e);
}
const downloadEditedPhoto = async () => {
if (!canvasRef.current) return;
const f = await getEditedFile();
// Revokes the URL after downloading.
downloadUsingAnchor(URL.createObjectURL(f), f.name);
};
const saveCopyToEnte = async () => {
if (!canvasRef.current) return;
try {
if (!canvasRef.current) return;
const collections = await getLocalCollections();
const collection = collections.find(
@ -768,3 +735,55 @@ const ImageEditorOverlay = (props: IProps) => {
};
export default ImageEditorOverlay;
/**
* Create a new {@link File} with the contents of the given canvas.
*
* @param canvas A {@link HTMLCanvasElement} whose contents we want to download
* as a file.
*
* @param originalFileName The name of the original file which was used to seed
* the canvas. This will be used as a base name for the generated file (with an
* "-edited" suffix).
*
* @param originalMIMEType The MIME type of the original file which was used to
* seed the canvas. When possible, we try to download a file in the same format,
* but this is not guaranteed and depends on browser support. If the original
* MIME type can not be preserved, a PNG file will be downloaded.
*/
const canvasToFile = async (
canvas: HTMLCanvasElement,
originalFileName: string,
originalMIMEType?: string,
): Promise<File> => {
const image = new Image();
image.src = canvas.toDataURL();
// Browsers are required to support "image/png". They may also support
// "image/jpeg" and "image/webp". Potentially they may even support more
// formats, but to keep this scoped we limit to these three.
let [mimeType, extension] = ["image/png", "png"];
switch (originalMIMEType) {
case "image/jpeg":
mimeType = originalMIMEType;
extension = "jpeg";
break;
case "image/webp":
mimeType = originalMIMEType;
extension = "webp";
break;
default:
break;
}
const blob = ensure(
await new Promise<Blob>((resolve) => canvas.toBlob(resolve, mimeType)),
);
const [originalName] = nameAndExtension(originalFileName);
const fileName = `${originalName}-edited.${extension}`;
log.debug(() => ({ a: "canvas => file", blob, type: blob.type, mimeType }));
return new File([blob], fileName);
};

View file

@ -31,6 +31,16 @@ export type SourceURLs = {
isOriginal: boolean;
isRenderable: boolean;
type: "normal" | "livePhoto";
/**
* Best effort attempt at obtaining the MIME type.
*
* Known cases where it is missing:
*
* - Live photos (these have a different code path for obtaining the URL).
* - A video that is passes the isPlayable test in the browser.
*
*/
mimeType?: string;
};
export type OnDownloadProgress = (event: {
@ -476,6 +486,7 @@ async function getRenderableFileURL(
forceConvert: boolean,
): Promise<SourceURLs> {
let srcURLs: SourceURLs["url"];
let mimeType: string | undefined;
switch (file.metadata.fileType) {
case FILE_TYPE.IMAGE: {
const convertedBlob = await getRenderableImage(
@ -488,6 +499,7 @@ async function getRenderableFileURL(
convertedBlob,
);
srcURLs = convertedURL;
mimeType = convertedBlob.type;
break;
}
case FILE_TYPE.LIVE_PHOTO: {
@ -510,6 +522,7 @@ async function getRenderableFileURL(
convertedBlob,
);
srcURLs = convertedURL;
mimeType = convertedBlob.type;
break;
}
default: {
@ -534,6 +547,7 @@ async function getRenderableFileURL(
file.metadata.fileType === FILE_TYPE.LIVE_PHOTO
? "livePhoto"
: "normal",
mimeType,
};
}
@ -613,7 +627,7 @@ async function getPlayableVideo(
// TODO(MR): This might not work for very large (~ GB) videos. Test.
log.info(`Converting video ${videoNameTitle} to mp4`);
const convertedVideoData = await ffmpeg.convertToMP4(videoBlob);
return new Blob([convertedVideoData]);
return new Blob([convertedVideoData], { type: "video/mp4" });
}
} catch (e) {
log.error("Video conversion failed", e);

View file

@ -271,6 +271,10 @@ export function generateStreamFromArrayBuffer(data: Uint8Array) {
});
}
/**
* The returned blob.type is filled in, whenever possible, with the MIME type of
* the data that we're dealing with.
*/
export const getRenderableImage = async (fileName: string, imageBlob: Blob) => {
try {
const tempFile = new File([imageBlob], fileName);
@ -284,7 +288,16 @@ export const getRenderableImage = async (fileName: string, imageBlob: Blob) => {
if (!isNonWebImageFileExtension(extension)) {
// Either it is something that the browser already knows how to
// render, or something we don't even about yet.
return imageBlob;
const mimeType = fileTypeInfo.mimeType;
if (!mimeType) {
log.info(
"Trying to render a file without a MIME type",
fileName,
);
return imageBlob;
} else {
return new Blob([imageBlob], { type: mimeType });
}
}
const available = !moduleState.isNativeJPEGConversionNotAvailable;
@ -325,7 +338,7 @@ const nativeConvertToJPEG = async (imageBlob: Blob) => {
? await electron.convertToJPEG(imageData)
: await workerBridge.convertToJPEG(imageData);
log.debug(() => `Native JPEG conversion took ${Date.now() - startTime} ms`);
return new Blob([jpegData]);
return new Blob([jpegData], { type: "image/jpeg" });
};
export function isSupportedRawFormat(exactType: string) {

View file

@ -18,5 +18,5 @@ export const heicToJPEG = async (heicBlob: Blob): Promise<Blob> => {
const buffer = new Uint8Array(await heicBlob.arrayBuffer());
const result = await HeicConvert({ buffer, format: "JPEG" });
const convertedData = new Uint8Array(result);
return new Blob([convertedData]);
return new Blob([convertedData], { type: "image/jpeg" });
};

View file

@ -3,7 +3,7 @@
*/
export const ensure = <T>(v: T | null | undefined): T => {
if (v === null) throw new Error("Required value was null");
if (v === undefined) throw new Error("Required value was not found");
if (v === undefined) throw new Error("Required value was undefined");
return v;
};

View file

@ -3438,7 +3438,7 @@ mime-db@1.52.0:
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.12, mime-types@^2.1.35:
mime-types@^2.1.12:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==