This commit is contained in:
syuilo 2025-05-28 11:15:57 +09:00
parent 7754ccb73f
commit de90b606c1
4 changed files with 96 additions and 21 deletions

View File

@ -96,6 +96,7 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile, UploadAbortedError } from '@/utility/drive.js'; import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
import { Watermarker } from '@/utility/watermarker.js';
const $i = ensureSignin(); const $i = ensureSignin();
@ -153,7 +154,7 @@ const items = ref<{
aborted: boolean; aborted: boolean;
compressionLevel: 0 | 1 | 2 | 3; compressionLevel: 0 | 1 | 2 | 3;
compressedSize?: number | null; compressedSize?: number | null;
compressedImage?: Blob | null; preprocessedFile?: Blob | null;
file: File; file: File;
watermarkPresetId: string | null; watermarkPresetId: string | null;
abort?: (() => void) | null; abort?: (() => void) | null;
@ -277,6 +278,7 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
text: i18n.ts.cropImage, text: i18n.ts.cropImage,
action: async () => { action: async () => {
const cropped = await os.cropImageFile(item.file, { aspectRatio: null }); const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
URL.revokeObjectURL(item.thumbnail);
items.value.splice(items.value.indexOf(item), 1, { items.value.splice(items.value.indexOf(item), 1, {
...item, ...item,
file: markRaw(cropped), file: markRaw(cropped),
@ -287,6 +289,13 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
} }
if (WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) { if (WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
function changeWatermarkPreset(presetId: string | null) {
item.watermarkPresetId = presetId;
preprocess(item).then(() => {
triggerRef(items);
});
}
menu.push({ menu.push({
icon: 'ti ti-copyright', icon: 'ti ti-copyright',
text: i18n.ts.watermark, text: i18n.ts.watermark,
@ -295,8 +304,15 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
type: 'radioOption', type: 'radioOption',
text: i18n.ts.none, text: i18n.ts.none,
active: computed(() => item.watermarkPresetId == null), active: computed(() => item.watermarkPresetId == null),
action: () => item.watermarkPresetId = null, action: () => changeWatermarkPreset(null),
}], }, {
type: 'divider',
}, ...prefer.s.watermarkPresets.map(preset => ({
type: 'radioOption',
text: preset.name,
active: computed(() => item.watermarkPresetId === preset.id),
action: () => changeWatermarkPreset(preset.id),
}))],
}); });
} }
@ -317,6 +333,8 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
text: i18n.ts.none, text: i18n.ts.none,
active: computed(() => item.compressionLevel === 0 || item.compressionLevel == null), active: computed(() => item.compressionLevel === 0 || item.compressionLevel == null),
action: () => changeCompressionLevel(0), action: () => changeCompressionLevel(0),
}, {
type: 'divider',
}, { }, {
type: 'radioOption', type: 'radioOption',
text: i18n.ts.low, text: i18n.ts.low,
@ -384,7 +402,7 @@ async function upload() { // エラーハンドリングなどを考慮してシ
item.uploadFailed = false; item.uploadFailed = false;
item.uploading = true; item.uploading = true;
const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, { const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, {
name: item.uploadName ?? item.name, name: item.uploadName ?? item.name,
folderId: props.folderId, folderId: props.folderId,
onProgress: (progress) => { onProgress: (progress) => {
@ -441,13 +459,58 @@ async function chooseFile(ev: MouseEvent) {
} }
} }
function getImageElement(file: Blob | File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve(img);
};
img.onerror = (error) => {
reject(error);
};
img.src = URL.createObjectURL(file);
});
}
async function preprocess(item: (typeof items)['value'][number]): Promise<void> { async function preprocess(item: (typeof items)['value'][number]): Promise<void> {
item.preprocessing = true; item.preprocessing = true;
const compressionSettings = getCompressionSettings(item.compressionLevel); let file: Blob | File = item.file;
const shouldCompress = item.compressionLevel !== 0 && compressionSettings && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file)); const img = await getImageElement(file);
if (shouldCompress) { const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(file.type);
const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
if (needsWatermark && preset != null) {
const canvas = window.document.createElement('canvas');
const renderer = new Watermarker({
canvas: canvas,
width: img.width,
height: img.height,
preset: preset,
originalImage: img,
});
await renderer.bakeTextures();
renderer.render();
file = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
if (blob == null) {
throw new Error('Failed to convert canvas to blob');
}
resolve(blob);
}, 'image/png');
});
}
const compressionSettings = getCompressionSettings(item.compressionLevel);
const needsCompress = item.compressionLevel !== 0 && compressionSettings && COMPRESSION_SUPPORTED_TYPES.includes(file.type) && !(await isAnimated(file));
if (needsCompress) {
const config = { const config = {
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
maxWidth: compressionSettings.maxWidth, maxWidth: compressionSettings.maxWidth,
@ -456,24 +519,28 @@ async function preprocess(item: (typeof items)['value'][number]): Promise<void>
}; };
try { try {
const result = await readAndCompressImage(item.file, config); const result = await readAndCompressImage(file, config);
if (result.size < item.file.size || item.file.type === 'image/webp') { if (result.size < file.size || file.type === 'image/webp') {
// The compression may not always reduce the file size // The compression may not always reduce the file size
// (and WebP is not browser safe yet) // (and WebP is not browser safe yet)
item.compressedImage = markRaw(result); file = result;
item.compressedSize = result.size; item.compressedSize = result.size;
item.uploadName = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; item.uploadName = file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
} }
} catch (err) { } catch (err) {
console.error('Failed to resize image', err); console.error('Failed to resize image', err);
} }
} else { } else {
item.compressedImage = null;
item.compressedSize = null; item.compressedSize = null;
item.uploadName = item.name; item.uploadName = item.name;
} }
URL.revokeObjectURL(item.thumbnail);
item.thumbnail = window.URL.createObjectURL(file);
item.preprocessedFile = markRaw(file);
item.preprocessing = false; item.preprocessing = false;
URL.revokeObjectURL(img.src);
} }
function initializeFile(file: File) { function initializeFile(file: File) {

View File

@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:min="0" :min="0"
:max="1" :max="1"
:step="0.01" :step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate continuousUpdate
> >
<template #label>{{ i18n.ts._watermarkEditor.scale }}</template> <template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
@ -33,6 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:min="0" :min="0"
:max="1" :max="1"
:step="0.01" :step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate continuousUpdate
> >
<template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
@ -58,6 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:min="0" :min="0"
:max="1" :max="1"
:step="0.01" :step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate continuousUpdate
> >
<template #label>{{ i18n.ts._watermarkEditor.scale }}</template> <template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
@ -68,6 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:min="0" :min="0"
:max="1" :max="1"
:step="0.01" :step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate continuousUpdate
> >
<template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>

View File

@ -96,6 +96,7 @@ onUnmounted(() => {
watch(() => props.preset, async () => { watch(() => props.preset, async () => {
if (renderer != null) { if (renderer != null) {
renderer.updatePreset(props.preset); renderer.updatePreset(props.preset);
await renderer.bakeTextures();
renderer.render(); renderer.render();
} }
}, { deep: true }); }, { deep: true });

View File

@ -84,12 +84,12 @@ export type WatermarkerLayer = WatermarkerTextLayer | WatermarkerImageLayer;
export class Watermarker { export class Watermarker {
private canvas: HTMLCanvasElement | null = null; private canvas: HTMLCanvasElement | null = null;
public gl: WebGL2RenderingContext | null = null; private gl: WebGL2RenderingContext | null = null;
public renderTextureProgram!: WebGLProgram; private renderTextureProgram!: WebGLProgram;
public renderInvertedTextureProgram!: WebGLProgram; private renderInvertedTextureProgram!: WebGLProgram;
public renderWidth!: number; private renderWidth!: number;
public renderHeight!: number; private renderHeight!: number;
public originalImage: HTMLImageElement; private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
private preset: WatermarkPreset; private preset: WatermarkPreset;
private originalImageTexture: WebGLTexture; private originalImageTexture: WebGLTexture;
private resultTexture: WebGLTexture; private resultTexture: WebGLTexture;
@ -101,7 +101,7 @@ export class Watermarker {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
width: number; width: number;
height: number; height: number;
originalImage: HTMLImageElement; originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
preset: WatermarkPreset; preset: WatermarkPreset;
}) { }) {
this.canvas = options.canvas; this.canvas = options.canvas;
@ -131,7 +131,7 @@ export class Watermarker {
this.originalImageTexture = this.createTexture(); this.originalImageTexture = this.createTexture();
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture); gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.originalImage.width, this.originalImage.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.originalImage); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, options.width, options.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.originalImage);
gl.bindTexture(gl.TEXTURE_2D, null); gl.bindTexture(gl.TEXTURE_2D, null);
this.resultTexture = this.createTexture(); this.resultTexture = this.createTexture();
@ -253,6 +253,7 @@ export class Watermarker {
const margin = Math.min(this.renderWidth, this.renderHeight) / 50; const margin = Math.min(this.renderWidth, this.renderHeight) / 50;
measureCtx.font = `bold ${fontSize}px sans-serif`; measureCtx.font = `bold ${fontSize}px sans-serif`;
const textMetrics = measureCtx.measureText(layer.text); const textMetrics = measureCtx.measureText(layer.text);
measureCtx.canvas.remove();
const RESOLUTION_FACTOR = 4; const RESOLUTION_FACTOR = 4;
@ -284,6 +285,8 @@ export class Watermarker {
width: textCtx.canvas.width, width: textCtx.canvas.width,
height: textCtx.canvas.height, height: textCtx.canvas.height,
}); });
textCtx.canvas.remove();
} }
} }
} }
@ -398,7 +401,7 @@ export class Watermarker {
} }
} }
public async render() { public render() {
const gl = this.gl; const gl = this.gl;
if (gl == null) { if (gl == null) {
throw new Error('gl is not initialized'); throw new Error('gl is not initialized');