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
{{ i18n.ts.keepOriginalUploading }}
{{ i18n.ts.keepOriginalUploadingDescription }}
+
+ {{ i18n.ts._imageCompressionMode.title }}
+
+
+
+
+ {{ i18n.ts._imageCompressionMode.description }}
+
{{ i18n.ts.alwaysMarkSensitive }}
@@ -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,