From: Alexander Ebert Date: Thu, 25 Apr 2024 15:30:36 +0000 (+0200) Subject: Add an automated resizing for images exceeding the limits X-Git-Tag: 6.1.0_Alpha_1~85^2^2~42 X-Git-Url: https://git.stricted.de/?a=commitdiff_plain;h=252a03dda9125faed178dba9fb24c39aa0ab2999;p=GitHub%2FWoltLab%2FWCF.git Add an automated resizing for images exceeding the limits --- diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index fd2573fd1c..2e149ca931 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -3,6 +3,7 @@ import { upload as filesUpload } from "WoltLabSuite/Core/Api/Files/Upload"; import WoltlabCoreFileElement from "./woltlab-core-file"; import { Response as UploadChunkResponse, uploadChunk } from "WoltLabSuite/Core/Api/Files/Chunk/Chunk"; import { generateThumbnails } from "WoltLabSuite/Core/Api/Files/GenerateThumbnails"; +import ImageResizer from "WoltLabSuite/Core/Image/Resizer"; export type ThumbnailsGenerated = { data: GenerateThumbnailsResponse; @@ -16,6 +17,13 @@ type ThumbnailData = { type GenerateThumbnailsResponse = ThumbnailData[]; +type ResizeConfiguration = { + maxWidth: number; + maxHeight: number; + fileType: "image/jpeg" | "image/webp" | "keep"; + quality: number; +}; + async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise { const typeName = element.dataset.typeName!; @@ -85,16 +93,82 @@ async function getSha256Hash(data: BufferSource): Promise { .join(""); } +function clearPreviousErrors(element: WoltlabCoreFileUploadElement): void { + element.parentElement?.querySelectorAll(".innerError").forEach((x) => x.remove()); +} + +async function resizeImage(element: WoltlabCoreFileUploadElement, file: File): Promise { + switch (file.type) { + case "image/jpeg": + case "image/png": + case "image/webp": + // Potential candidate for a resize operation. + break; + + default: + // Not an image or an unsupported file type. + return file; + } + + const timeout = new Promise((resolve) => { + window.setTimeout(() => resolve(file), 10_000); + }); + + const resizeConfiguration = JSON.parse(element.dataset.resizeConfiguration!) as ResizeConfiguration; + + const resizer = new ImageResizer(); + const { image, exif } = await resizer.loadFile(file); + + const maxHeight = resizeConfiguration.maxHeight; + let maxWidth = resizeConfiguration.maxWidth; + if (window.devicePixelRatio >= 2) { + const actualWidth = window.screen.width * window.devicePixelRatio; + const actualHeight = window.screen.height * window.devicePixelRatio; + + // If the dimensions are equal then this is a screenshot from a HiDPI + // device, thus we downscale this to the “natural” dimensions. + if (actualWidth === image.width && actualHeight === image.height) { + maxWidth = Math.min(maxWidth, window.screen.width); + } + } + + const canvas = await resizer.resize(image, maxWidth, maxHeight, resizeConfiguration.quality, true, timeout); + if (canvas === undefined) { + // The resize operation failed, timed out or there was no need to perform + // any scaling whatsoever. + return file; + } + + let fileType: string = resizeConfiguration.fileType; + if (fileType === "image/jpeg" || fileType === "image/webp") { + fileType = "image/webp"; + } else { + fileType = file.type; + } + + const resizedFile = await resizer.saveFile( + { + exif, + image: canvas, + }, + file.name, + fileType, + resizeConfiguration.quality, + ); + + return resizedFile; +} + export function setup(): void { - wheneverFirstSeen("woltlab-core-file-upload", (element) => { + wheneverFirstSeen("woltlab-core-file-upload", (element: WoltlabCoreFileUploadElement) => { element.addEventListener("upload", (event: CustomEvent) => { - // TODO: Add some pipeline logic here. + const file = event.detail; - // TODO: Add a canvas based resize to the pipeline. + clearPreviousErrors(element); - // TODO: We need to pass around the file using some dedicated type of - // data structure because it can be modified somehow. - void upload(element, event.detail); + void resizeImage(element, file).then((resizedFile) => { + void upload(element, resizedFile); + }); }); }); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js index 752856b0be..a822e10cc8 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -1,7 +1,8 @@ -define(["require", "exports", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk", "WoltLabSuite/Core/Api/Files/GenerateThumbnails"], function (require, exports, Selector_1, Upload_1, Chunk_1, GenerateThumbnails_1) { +define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk", "WoltLabSuite/Core/Api/Files/GenerateThumbnails", "WoltLabSuite/Core/Image/Resizer"], function (require, exports, tslib_1, Selector_1, Upload_1, Chunk_1, GenerateThumbnails_1, Resizer_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; + Resizer_1 = tslib_1.__importDefault(Resizer_1); async function upload(element, file) { const typeName = element.dataset.typeName; const fileHash = await getSha256Hash(await file.arrayBuffer()); @@ -51,10 +52,64 @@ define(["require", "exports", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite .map((b) => b.toString(16).padStart(2, "0")) .join(""); } + function clearPreviousErrors(element) { + element.parentElement?.querySelectorAll(".innerError").forEach((x) => x.remove()); + } + async function resizeImage(element, file) { + switch (file.type) { + case "image/jpeg": + case "image/png": + case "image/webp": + // Potential candidate for a resize operation. + break; + default: + // Not an image or an unsupported file type. + return file; + } + const timeout = new Promise((resolve) => { + window.setTimeout(() => resolve(file), 10000); + }); + const resizeConfiguration = JSON.parse(element.dataset.resizeConfiguration); + const resizer = new Resizer_1.default(); + const { image, exif } = await resizer.loadFile(file); + const maxHeight = resizeConfiguration.maxHeight; + let maxWidth = resizeConfiguration.maxWidth; + if (window.devicePixelRatio >= 2) { + const actualWidth = window.screen.width * window.devicePixelRatio; + const actualHeight = window.screen.height * window.devicePixelRatio; + // If the dimensions are equal then this is a screenshot from a HiDPI + // device, thus we downscale this to the “natural” dimensions. + if (actualWidth === image.width && actualHeight === image.height) { + maxWidth = Math.min(maxWidth, window.screen.width); + } + } + const canvas = await resizer.resize(image, maxWidth, maxHeight, resizeConfiguration.quality, true, timeout); + if (canvas === undefined) { + // The resize operation failed, timed out or there was no need to perform + // any scaling whatsoever. + return file; + } + let fileType = resizeConfiguration.fileType; + if (fileType === "image/jpeg" || fileType === "image/webp") { + fileType = "image/webp"; + } + else { + fileType = file.type; + } + const resizedFile = await resizer.saveFile({ + exif, + image: canvas, + }, file.name, fileType, resizeConfiguration.quality); + return resizedFile; + } function setup() { (0, Selector_1.wheneverFirstSeen)("woltlab-core-file-upload", (element) => { element.addEventListener("upload", (event) => { - void upload(element, event.detail); + const file = event.detail; + clearPreviousErrors(element); + void resizeImage(element, file).then((resizedFile) => { + void upload(element, resizedFile); + }); }); }); } diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 598b212140..af6dfd8629 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -127,6 +127,21 @@ final class AttachmentFileProcessor implements IFileProcessor ); } + #[\Override] + public function getResizeConfiguration(): ResizeConfiguration + { + if (!\ATTACHMENT_IMAGE_AUTOSCALE) { + return ResizeConfiguration::unbounded(); + } + + return new ResizeConfiguration( + \ATTACHMENT_IMAGE_AUTOSCALE_MAX_WIDTH, + \ATTACHMENT_IMAGE_AUTOSCALE_MAX_HEIGHT, + ResizeFileType::fromString(\ATTACHMENT_IMAGE_AUTOSCALE_FILE_TYPE), + \ATTACHMENT_IMAGE_AUTOSCALE_QUALITY + ); + } + #[\Override] public function getThumbnailFormats(): array { diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php index 2ffb4e40cf..88141f1f43 100644 --- a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php @@ -62,11 +62,13 @@ final class FileProcessor extends SingletonFactory data-type-name="%s" data-context="%s" data-file-extensions="%s" + data-resize-configuration="%s" > HTML, StringUtil::encodeHTML($fileProcessor->getTypeName()), StringUtil::encodeHTML(JSON::encode($context)), StringUtil::encodeHTML($allowedFileExtensions), + StringUtil::encodeHTML(JSON::encode($fileProcessor->getResizeConfiguration())), ); } diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index d18a484598..56d49368ab 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -25,6 +25,8 @@ interface IFileProcessor public function getAllowedFileExtensions(array $context): array; + public function getResizeConfiguration(): ResizeConfiguration; + public function getTypeName(): string; public function getUploadResponse(File $file): array; diff --git a/wcfsetup/install/files/lib/system/file/processor/ResizeConfiguration.class.php b/wcfsetup/install/files/lib/system/file/processor/ResizeConfiguration.class.php new file mode 100644 index 0000000000..d5d3ee333a --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/ResizeConfiguration.class.php @@ -0,0 +1,38 @@ + 100) { + throw new \OutOfRangeException("The quality value must be larger than 0 and less than or equal to 100."); + } + } + + #[\Override] + public function jsonSerialize(): mixed + { + return [ + 'maxWidth' => $this->maxWidth, + 'maxHeight' => $this->maxHeight, + 'fileType' => $this->fileType->toString(), + 'quality' => $this->quality, + ]; + } + + public static function unbounded(): self + { + return new self( + -1, + -1, + ResizeFileType::Keep, + 100 + ); + } +} diff --git a/wcfsetup/install/files/lib/system/file/processor/ResizeFileType.class.php b/wcfsetup/install/files/lib/system/file/processor/ResizeFileType.class.php new file mode 100644 index 0000000000..943731cbfd --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/ResizeFileType.class.php @@ -0,0 +1,28 @@ + 'image/jpeg', + self::Keep => 'keep', + self::Webp => 'image/webp', + }; + } + + public static function fromString(string $fileType): self + { + return match ($fileType) { + 'image/jpeg' => self::Jpeg, + 'keep' => self::Keep, + 'image/webp' => self::Webp, + }; + } +}