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 * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
import { Watermarker } from '@/utility/watermarker.js';
const $i = ensureSignin();
@ -153,7 +154,7 @@ const items = ref<{
aborted: boolean;
compressionLevel: 0 | 1 | 2 | 3;
compressedSize?: number | null;
compressedImage?: Blob | null;
preprocessedFile?: Blob | null;
file: File;
watermarkPresetId: string | null;
abort?: (() => void) | null;
@ -277,6 +278,7 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
text: i18n.ts.cropImage,
action: async () => {
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
URL.revokeObjectURL(item.thumbnail);
items.value.splice(items.value.indexOf(item), 1, {
...item,
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) {
function changeWatermarkPreset(presetId: string | null) {
item.watermarkPresetId = presetId;
preprocess(item).then(() => {
triggerRef(items);
});
}
menu.push({
icon: 'ti ti-copyright',
text: i18n.ts.watermark,
@ -295,8 +304,15 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
type: 'radioOption',
text: i18n.ts.none,
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,
active: computed(() => item.compressionLevel === 0 || item.compressionLevel == null),
action: () => changeCompressionLevel(0),
}, {
type: 'divider',
}, {
type: 'radioOption',
text: i18n.ts.low,
@ -384,7 +402,7 @@ async function upload() { // エラーハンドリングなどを考慮してシ
item.uploadFailed = false;
item.uploading = true;
const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, {
const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, {
name: item.uploadName ?? item.name,
folderId: props.folderId,
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> {
item.preprocessing = true;
const compressionSettings = getCompressionSettings(item.compressionLevel);
const shouldCompress = item.compressionLevel !== 0 && compressionSettings && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file));
let file: Blob | File = 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 = {
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
maxWidth: compressionSettings.maxWidth,
@ -456,24 +519,28 @@ async function preprocess(item: (typeof items)['value'][number]): Promise<void>
};
try {
const result = await readAndCompressImage(item.file, config);
if (result.size < item.file.size || item.file.type === 'image/webp') {
const result = await readAndCompressImage(file, config);
if (result.size < file.size || file.type === 'image/webp') {
// The compression may not always reduce the file size
// (and WebP is not browser safe yet)
item.compressedImage = markRaw(result);
file = result;
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) {
console.error('Failed to resize image', err);
}
} else {
item.compressedImage = null;
item.compressedSize = null;
item.uploadName = item.name;
}
URL.revokeObjectURL(item.thumbnail);
item.thumbnail = window.URL.createObjectURL(file);
item.preprocessedFile = markRaw(file);
item.preprocessing = false;
URL.revokeObjectURL(img.src);
}
function initializeFile(file: File) {

View File

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

View File

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

View File

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