From de90b606c1af7ee80e6c408acc9e5635fd65de50 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 28 May 2025 11:15:57 +0900 Subject: [PATCH] wip --- .../src/components/MkUploaderDialog.vue | 91 ++++++++++++++++--- .../MkWatermarkEditorDialog.Layer.vue | 4 + .../pages/settings/drive.WatermarkItem.vue | 1 + packages/frontend/src/utility/watermarker.ts | 21 +++-- 4 files changed, 96 insertions(+), 21 deletions(-) diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue index e23e4acfd3..4b980ef19b 100644 --- a/packages/frontend/src/components/MkUploaderDialog.vue +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -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 { + 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 { 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((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 }; 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) { diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue index 5013480fc5..47a3abf690 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue @@ -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 > @@ -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 > @@ -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 > @@ -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 > diff --git a/packages/frontend/src/pages/settings/drive.WatermarkItem.vue b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue index e5884204e2..8d4a775317 100644 --- a/packages/frontend/src/pages/settings/drive.WatermarkItem.vue +++ b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue @@ -96,6 +96,7 @@ onUnmounted(() => { watch(() => props.preset, async () => { if (renderer != null) { renderer.updatePreset(props.preset); + await renderer.bakeTextures(); renderer.render(); } }, { deep: true }); diff --git a/packages/frontend/src/utility/watermarker.ts b/packages/frontend/src/utility/watermarker.ts index e4307a1c16..f0d4789e95 100644 --- a/packages/frontend/src/utility/watermarker.ts +++ b/packages/frontend/src/utility/watermarker.ts @@ -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');