mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(app): crop animated images on web with libwebp (#2947)
This commit is contained in:
1 parent
e0843ac4f5
commit
bbfe809bef
104 files changed
+10884
-7316
No files matched your search
@@ -116,7 +116,6 @@ export const GuildCreateForm = observer(() => {
|
||||
<AssetCropModal
|
||||
assetType={AssetType.GUILD_ICON}
|
||||
imageUrl={base64}
|
||||
sourceMimeType={svg ? 'image/svg+xml' : file.type}
|
||||
onCropComplete={(croppedBlob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
|
||||
@@ -297,7 +297,6 @@ export const TemplateImportForm = observer(() => {
|
||||
<AssetCropModal
|
||||
assetType={AssetType.GUILD_ICON}
|
||||
imageUrl={base64}
|
||||
sourceMimeType={svg ? 'image/svg+xml' : file.type}
|
||||
onCropComplete={(croppedBlob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
|
||||
+17
-81
@@ -1,26 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Gif} from '@app/features/expressions/commands/GifCommands';
|
||||
import {
|
||||
AssetCropModal,
|
||||
type AssetType,
|
||||
canSkipOriginalAssetImage,
|
||||
} from '@app/features/expressions/components/modals/AssetCropModal';
|
||||
import {AssetCropModal, type AssetType} from '@app/features/expressions/components/modals/AssetCropModal';
|
||||
import {openAssetSourceModal} from '@app/features/expressions/components/modals/AssetSourceModal';
|
||||
import {showAnimatedAvifUnsupportedModal} from '@app/features/expressions/utils/AnimatedAvifModalUtils';
|
||||
import {
|
||||
getAnimatedFormatLabel,
|
||||
isAnimatedFile,
|
||||
shouldHandleAnimatedNonGifUpload,
|
||||
} from '@app/features/expressions/utils/AnimatedImageUtils';
|
||||
import {inspectImageFile} from '@app/features/expressions/utils/AnimatedImageUtils';
|
||||
import {downloadGifAsImageFile} from '@app/features/expressions/utils/GifFileDownload';
|
||||
import {isSvgFile, readImageFileAsUploadDataUrl} from '@app/features/expressions/utils/ImageUploadFileUtils';
|
||||
import {showGuildErrorModal} from '@app/features/guild/components/alerts/GuildErrorModalUtils';
|
||||
import {
|
||||
blobToDataUrl,
|
||||
getImageDimensionsFromDataUrl,
|
||||
getSafeImageMimeType,
|
||||
isGif,
|
||||
MAX_IMAGE_BYTES,
|
||||
revokeObjectUrl,
|
||||
} from '@app/features/guild/components/modals/guild_tabs/guild_overview_tab/utils/ImageAsset';
|
||||
@@ -31,9 +20,9 @@ import {
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils';
|
||||
import {formatFileSize} from '@app/features/messaging/utils/FileUtils';
|
||||
import {canDecodeAnimatedAvif} from '@app/features/platform/utils/ImageDecoderInterop';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -55,26 +44,11 @@ const ANIMATED_IMAGES_ARE_NOT_SUPPORTED_FOR_THIS_ASSET_DESCRIPTOR = msg({
|
||||
message: 'Animated images are not supported for this asset.',
|
||||
comment: 'Confirmation modal body shown before using a non-croppable animated community image.',
|
||||
});
|
||||
const CROPPING_ANIMATED_FILES_ISN_T_SUPPORTED_YET_THE_DESCRIPTOR = msg({
|
||||
message: "Cropping animated {formatLabel} files isn't supported yet. The original image will be used.",
|
||||
comment:
|
||||
'Confirmation modal body shown before using an animated community image without cropping. {formatLabel} is the image format name.',
|
||||
});
|
||||
const CROPPING_ANIMATED_FILES_WITHOUT_FORMAT_ISN_T_SUPPORTED_YET_THE_DESCRIPTOR = msg({
|
||||
message: "Cropping animated files isn't supported yet. The original image will be used.",
|
||||
comment: 'Confirmation modal body shown before using an animated community image with an unknown format.',
|
||||
});
|
||||
const CROPPED_IMAGE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR = msg({
|
||||
message: 'Cropped image is too large. Choose a smaller area or a smaller file (max {maxSizeLabel}).',
|
||||
comment:
|
||||
'Error modal body shown when a cropped community image still exceeds the size limit. {maxSizeLabel} is a formatted file size.',
|
||||
});
|
||||
const IMAGE_DIMENSIONS_DO_NOT_MATCH_THIS_ASSET_DESCRIPTOR = msg({
|
||||
message:
|
||||
'{label} dimensions do not match this asset. Choose a different image or upload a static image you can crop.',
|
||||
comment:
|
||||
'Error modal body shown when a non-croppable community image has dimensions that do not fit the target asset. {label} names the image field.',
|
||||
});
|
||||
const IMAGE_COULDN_T_BE_USED_DESCRIPTOR = msg({
|
||||
message: "Image couldn't be used",
|
||||
comment: 'Error modal title shown when a community image upload cannot be accepted or processed.',
|
||||
@@ -197,22 +171,6 @@ export function useGuildImageAssetField({
|
||||
},
|
||||
[applyAspectRatio, aspectRatio, setFieldValue, setHasCleared, setPreviewUrl],
|
||||
);
|
||||
const canApplyOriginalDataUrl = useCallback(
|
||||
async (dataUrl: string) => {
|
||||
try {
|
||||
const dimensions = await getImageDimensionsFromDataUrl(dataUrl);
|
||||
if (canSkipOriginalAssetImage(assetType, dimensions)) {
|
||||
return true;
|
||||
}
|
||||
showErrorModal(i18n._(IMAGE_DIMENSIONS_DO_NOT_MATCH_THIS_ASSET_DESCRIPTOR, {label}));
|
||||
return false;
|
||||
} catch {
|
||||
showErrorModal(i18n._(INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[assetType, i18n, label, showErrorModal],
|
||||
);
|
||||
const handleFile = useCallback(
|
||||
async (file: File | null) => {
|
||||
if (!file) return;
|
||||
@@ -224,7 +182,7 @@ export function useGuildImageAssetField({
|
||||
return;
|
||||
}
|
||||
const svg = isSvgFile(file);
|
||||
const animated = svg ? false : await isAnimatedFile(file);
|
||||
const {format, animated} = svg ? {format: 'unknown', animated: false} : await inspectImageFile(file);
|
||||
if (animated) {
|
||||
const policy = gif?.mode ?? 'disallow';
|
||||
if (policy === 'disallow') {
|
||||
@@ -246,6 +204,11 @@ export function useGuildImageAssetField({
|
||||
}
|
||||
}
|
||||
}
|
||||
const isAnimatedAvif = animated && format === 'avif';
|
||||
if (isAnimatedAvif && !(await canDecodeAnimatedAvif())) {
|
||||
showAnimatedAvifUnsupportedModal({i18n});
|
||||
return;
|
||||
}
|
||||
setIsProcessing(true);
|
||||
let sourceBase64: string;
|
||||
try {
|
||||
@@ -259,42 +222,12 @@ export function useGuildImageAssetField({
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
const animatedHandled = shouldHandleAnimatedNonGifUpload({
|
||||
file,
|
||||
isGif: isGif(file),
|
||||
animated,
|
||||
onAnimatedAvif: () => {
|
||||
setIsProcessing(false);
|
||||
showAnimatedAvifUnsupportedModal({i18n});
|
||||
},
|
||||
onOtherAnimated: async () => {
|
||||
if (!(await canApplyOriginalDataUrl(sourceBase64))) {
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
const formatLabel = getAnimatedFormatLabel(file);
|
||||
ToastCommands.createToast({
|
||||
type: 'info',
|
||||
children:
|
||||
formatLabel == null
|
||||
? i18n._(CROPPING_ANIMATED_FILES_WITHOUT_FORMAT_ISN_T_SUPPORTED_YET_THE_DESCRIPTOR)
|
||||
: i18n._(CROPPING_ANIMATED_FILES_ISN_T_SUPPORTED_YET_THE_DESCRIPTOR, {formatLabel}),
|
||||
});
|
||||
await applyDataUrl(sourceBase64, file, requestId);
|
||||
setIsProcessing(false);
|
||||
},
|
||||
});
|
||||
if (animatedHandled) {
|
||||
return;
|
||||
}
|
||||
setIsProcessing(false);
|
||||
const sourceMimeType = getSafeImageMimeType(file);
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<AssetCropModal
|
||||
assetType={assetType}
|
||||
imageUrl={sourceBase64}
|
||||
sourceMimeType={sourceMimeType}
|
||||
onCropComplete={async (croppedBlob) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setIsProcessing(true);
|
||||
@@ -314,10 +247,14 @@ export function useGuildImageAssetField({
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSkip={async () => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
await applyDataUrl(sourceBase64, file, requestId);
|
||||
}}
|
||||
onSkip={
|
||||
isAnimatedAvif
|
||||
? undefined
|
||||
: async () => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
await applyDataUrl(sourceBase64, file, requestId);
|
||||
}
|
||||
}
|
||||
data-flx="guild.guild-tabs.guild-overview-tab.use-guild-image-asset-field.handle-file.asset-crop-modal"
|
||||
/>
|
||||
)),
|
||||
@@ -327,7 +264,6 @@ export function useGuildImageAssetField({
|
||||
applyAspectRatio,
|
||||
applyDataUrl,
|
||||
assetType,
|
||||
canApplyOriginalDataUrl,
|
||||
canManage,
|
||||
gif,
|
||||
label,
|
||||
|
||||
-38
@@ -1,17 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ImageDimensions} from '@app/features/expressions/utils/AssetImageGeometry';
|
||||
import {getImageUploadMimeType} from '@app/features/expressions/utils/ImageUploadFileUtils';
|
||||
|
||||
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function isGif(file: File): boolean {
|
||||
const type = (file.type || '').toLowerCase();
|
||||
if (type === 'image/gif') return true;
|
||||
const name = (file.name || '').toLowerCase();
|
||||
return name.endsWith('.gif');
|
||||
}
|
||||
|
||||
export function revokeObjectUrl(url: string | null | undefined): void {
|
||||
if (!url) return;
|
||||
if (!url.startsWith('blob:')) return;
|
||||
@@ -28,31 +18,3 @@ export function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function getSafeImageMimeType(file: File): string {
|
||||
return getImageUploadMimeType(file);
|
||||
}
|
||||
|
||||
export function getImageDimensionsFromDataUrl(dataUrl: string): Promise<ImageDimensions> {
|
||||
if (typeof Image === 'undefined') {
|
||||
return Promise.reject(new Error('Image API is unavailable'));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
resolve({width: img.naturalWidth, height: img.naturalHeight});
|
||||
} else {
|
||||
reject(new Error('Invalid image dimensions'));
|
||||
}
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load image'));
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user