feat(frontend): selectable compression kind (misskey-dev/misskey#11760)

This commit is contained in:
NoriDev 2023-09-08 16:41:08 +09:00
commit 5753ff88d4
8 changed files with 135 additions and 14 deletions

View file

@ -45,6 +45,9 @@
- 새로운 신고가 있는 경우, 네비게이션 바의 제어판 아이콘과 제어판 페이지의 신고 섹션에 점을 표시
- 스크롤 시 요소 표시 기능을 Friendly 이외의 UI에도 대응
- 기본 다크 테마를 'Rosé Pine Moon'으로 변경
- 이미지 압축 방식 선택 가능
- 사이즈 변경 여부를 선택할 수 있음
- 이미지를 업로드할 때 손실 압축으로 변경할 수 있음
- Spec: 사용자 정의 이모티콘 라이센스를 여러 항목으로 추가할 수 있도록 (MisskeyIO/misskey#130)
- Enhance: '제어판 - 신고' 페이지의 버튼 가독성 향상
- Enhance: '모달에 흐림 효과 사용' 옵션이 비활성화된 경우, 이미지를 탭하여 표시할 때 표시되는 배경을 어둡게 조정

View file

@ -2329,3 +2329,10 @@ _abuse:
reportContentPattern: "Patterns of Reported Contents"
list: "List"
resolver: "Resolver"
_imageCompressionMode:
title: "Image compression format"
description: "If you do not want to keep the original image, you can select the compression format of the image for web publishing. If the image is to be reduced in size, it will be reduced to be smaller than 2048x2048. If lossy compression is not specified, either lossy or lossy compression will be automatically selected depending on the original image."
resizeCompress: "Resize and compression"
noResizeCompress: "Compression without resize"
resizeCompressLossy: "Resize and lossy compression"
noResizeCompressLossy: "Lossy compression without resize"

8
locales/index.d.ts vendored
View file

@ -2490,6 +2490,14 @@ export interface Locale {
"list": string;
"resolver": string;
};
"_imageCompressionMode": {
"title": string;
"description": string;
"resizeCompress": string;
"noResizeCompress": string;
"resizeCompressLossy": string;
"noResizeCompressLossy": string;
};
}
declare const locales: {
[lang: string]: Locale;

View file

@ -2400,3 +2400,11 @@ _abuse:
reportContentPattern: "通報内容のパターン"
list: "一覧"
resolver: "リソルバー"
_imageCompressionMode:
title: "画像の圧縮形式"
description: "オリジナル画像を保持しない場合に、Web公開用画像の圧縮形式を選択できます。縮小する場合は2048x2048より小さくなるように縮小されます。非可逆圧縮を指定しない場合は、元画像に応じて非可逆圧縮か可逆圧縮かが自動的に選択されます。"
resizeCompress: "縮小して再圧縮する"
noResizeCompress: "縮小せず再圧縮する"
resizeCompressLossy: "縮小して非可逆圧縮する"
noResizeCompressLossy: "縮小せず非可逆圧縮する"

View file

@ -2327,3 +2327,10 @@ _abuse:
reportContentPattern: "신고 내용 패턴"
list: "목록"
resolver: "리졸버"
_imageCompressionMode:
title: "이미지 압축 형식"
description: "웹 공개용 이미지의 압축 형식을 선택할 수 있어요. 축소할 경우 2048x2048보다 작게 축소돼요. 압축 방식을 지정하지 않으면 원본 이미지의 형식에 따라 무손실 압축 또는 손실 압축이 자동으로 선택돼요."
resizeCompress: "해상도 축소 및 압축"
noResizeCompress: "해상도를 축소하지 않고 압축"
resizeCompressLossy: "해상도 축소 및 손실 압축"
noResizeCompressLossy: "해상도를 축소하지 않고 손실 압축"

View file

@ -44,6 +44,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
</MkSwitch>
<MkSelect v-model="imageCompressionMode">
<template #label>{{ i18n.ts._imageCompressionMode.title }}</template>
<option value="resizeCompress">{{ i18n.ts._imageCompressionMode.resizeCompress }}</option>
<option value="noResizeCompress">{{ i18n.ts._imageCompressionMode.noResizeCompress }}</option>
<option value="resizeCompressLossy">{{ i18n.ts._imageCompressionMode.resizeCompressLossy }}</option>
<option value="noResizeCompressLossy">{{ i18n.ts._imageCompressionMode.noResizeCompressLossy }}</option>
<template #caption>{{ i18n.ts._imageCompressionMode.description }}</template>
</MkSelect>
<MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()">
<template #label>{{ i18n.ts.alwaysMarkSensitive }}</template>
</MkSwitch>
@ -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<any>(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;

View file

@ -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<boolean> {
// 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<BrowserImageResizerConfig | undefined> {
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,
};
}

View file

@ -112,6 +112,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: false,
},
imageCompressionMode: {
where: 'account',
default: 'resizeCompressLossy' as 'resizeCompress' | 'noResizeCompress' | 'resizeCompressLossy' | 'noResizeCompressLossy' | null,
},
memo: {
where: 'account',
default: null,