diff --git a/CHANGELOG.md b/CHANGELOG.md index e57a2c4fd3..685a15f4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ - Enhance: AiScriptで`LOCALE`として現在の設定言語を取得できるように - `$[rainbow ]`記法が、動きのあるMFMが無効になっていても使用できるようになりました - Playの操作を行うAPI TokenをAPIコンソールから発行できるように +- 画像の圧縮方法を選択可能にしました + - サイズ変更を行うかを選択可能にしました + - 強制的に非可逆圧縮できるようになりました - Fix: サーバー情報画面(`/instance-info/{domain}`)でブロックができないのを修正 - Fix: 未読のお知らせの「わかった」をクリック・タップしてもその場で「わかった」が消えない問題を修正 - Fix: iOSで画面を回転させるとテキストサイズが変わる問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index 1f25edd0ef..5e996bf2bd 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2202,6 +2202,14 @@ export interface Locale { "mention": string; }; }; + "_imageCompressionMode": { + "title": string; + "description": string; + "resizeCompress": string; + "noResizeCompress": string; + "resizeCompressLossy": string; + "noResizeCompressLossy": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2e0e64bbef..c66353fcf6 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2115,3 +2115,11 @@ _webhookSettings: renote: "Renoteされたとき" reaction: "リアクションがあったとき" mention: "メンションされたとき" + +_imageCompressionMode: + title: "画像の圧縮形式" + description: "オリジナル画像を保持しない場合に、Web公開用画像の圧縮形式を選択できます。縮小する場合は2048x2048より小さくなるように縮小されます。非可逆圧縮を指定しない場合は、元画像に応じて非可逆圧縮か可逆圧縮かが自動的に選択されます。" + resizeCompress: "縮小して再圧縮する" + noResizeCompress: "縮小せず再圧縮する" + resizeCompressLossy: "縮小して非可逆圧縮する" + noResizeCompressLossy: "縮小せず非可逆圧縮する" diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index de502e8511..a3ce52232c 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -44,6 +44,14 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + @@ -71,6 +79,7 @@ import MkChart from '@/components/MkChart.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { $i } from '@/account'; +import MkSelect from '@/components/MkSelect.vue'; const fetching = ref(true); const usage = ref(null); @@ -91,6 +100,7 @@ const meterStyle = computed(() => { }); const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading')); +const imageCompressionMode = computed(defaultStore.makeGetterSetter('imageCompressionMode')); os.api('drive').then(info => { capacity.value = info.capacity; diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts index 8fe64c8b76..a22c259d27 100644 --- a/packages/frontend/src/scripts/upload/compress-config.ts +++ b/packages/frontend/src/scripts/upload/compress-config.ts @@ -6,31 +6,105 @@ import isAnimated from 'is-file-animated'; import { isWebpSupported } from './isWebpSupported'; import type { BrowserImageResizerConfig } from 'browser-image-resizer'; +import { defaultStore } from '@/store'; const compressTypeMap = { - 'image/jpeg': { quality: 0.90, mimeType: 'image/webp' }, - 'image/png': { quality: 1, mimeType: 'image/webp' }, - 'image/webp': { quality: 0.90, mimeType: 'image/webp' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/webp' }, + 'lossy': { quality: 0.90, mimeType: 'image/webp' }, + 'lossless': { quality: 1, mimeType: 'image/webp' }, } as const; const compressTypeMapFallback = { - 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/png': { quality: 1, mimeType: 'image/png' }, - 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, + 'lossy': { quality: 0.85, mimeType: 'image/jpeg' }, + 'lossless': { quality: 1, mimeType: 'image/png' }, } as const; +const inputCompressKindMap = { + 'image/jpeg': 'lossy', + 'image/png': 'lossless', + 'image/webp': 'lossy', + 'image/svg+xml': 'lossless', +} as const; + +const resizeSizeConfig = { maxWidth: 2048, maxHeight: 2048 } as const; +const noResizeSizeConfig = { maxWidth: Number.MAX_SAFE_INTEGER, maxHeight: Number.MAX_SAFE_INTEGER } as const; + +async function isLosslessWebp(file: Blob): Promise { + // file header + // 'RIFF': u32 @ 0x00 + // file size: u32 @ 0x04 + // 'WEBP': u32 @ 0x08 + // for simple lossless + // 'VP8L': u32 @ 0x0C + // so read 16 bytes and check those three magic numbers + const buffer = new Uint8Array(await file.slice(0, 16).arrayBuffer()); + + const header = 'RIFF\x00\x00\x00\x00WEBPVP8L'; + for (let i = 0; i < header.length; i++) { + const code = header.charCodeAt(i); + if (code === 0) continue; + if (buffer[i] !== code) return false; + } + return true; +} + +async function inputImageKind(file: File): Promise<'lossy' | 'lossless' | undefined> { + let compressKind: 'lossy' | 'lossless' | undefined = inputCompressKindMap[file.type]; + if (!compressKind) return undefined; // unknown image format + if (await isAnimated(file)) return undefined; // animated image format + // WEBPs can be lossless + if (await isLosslessWebp(file)) compressKind = 'lossless'; + return compressKind; +} + export async function getCompressionConfig(file: File): Promise { - const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type]; - if (!imgConfig || await isAnimated(file)) { - return; + const inputCompressKind = await inputImageKind(file); + if (!inputCompressKind) return undefined; + + let compressKind: 'lossy' | 'lossless'; + let resize: boolean; + + switch (defaultStore.state.imageCompressionMode) { + case 'resizeCompress': + case null: + default: + resize = true; + compressKind = inputCompressKind; + break; + case 'noResizeCompress': + resize = false; + compressKind = inputCompressKind; + break; + case 'resizeCompressLossy': + resize = true; + compressKind = 'lossy'; + break; + case 'noResizeCompressLossy': + resize = false; + compressKind = 'lossy'; + break; + } + + const webpSupported = isWebpSupported(); + + const imgFormatConfig = (webpSupported ? compressTypeMap : compressTypeMapFallback)[compressKind]; + const sizeConfig = resize ? resizeSizeConfig : noResizeSizeConfig; + + if (!resize) { + // we don't resize images so we may omit recompression + if (imgFormatConfig.mimeType === file.type && inputCompressKind === compressKind) { + // we don't have to recompress already compressed to preferred image format. + return undefined; + } + + if (!webpSupported && file.type === 'image/webp' && compressKind === 'lossless') { + // lossless webp -> png recompression likely to increase image size so don't recompress + return undefined; + } } return { - maxWidth: 2048, - maxHeight: 2048, debug: true, - ...imgConfig, + ...imgFormatConfig, + ...sizeConfig, }; } diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index e9f672384e..bf8cc560cd 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -87,6 +87,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: false, }, + imageCompressionMode: { + where: 'account', + default: 'resizeCompress' as 'resizeCompress' | 'noResizeCompress' | 'resizeCompressLossy' | 'noResizeCompressLossy' | null, + }, memo: { where: 'account', default: null,