diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-11-06 20:25:17 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-06 20:25:17 +0900 |
| commit | 4ba18690d7abd7eea086bb59e6cbcc8ead9e121a (patch) | |
| tree | 7d25ec47d8711d945b08e3903642f2e982f40048 /packages/frontend/src/composables/use-uploader.ts | |
| parent | fix(frontend): improve startViewTransition handling (diff) | |
| download | misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.tar.gz misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.tar.bz2 misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.zip | |
feat(frontend): EXIFフレーム機能 (#16725)
* wip
* wip
* Update ImageEffector.ts
* Update image-label-renderer.ts
* Update image-label-renderer.ts
* wip
* Update image-label-renderer.ts
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update use-uploader.ts
* Update watermark.ts
* wip
* wu
* wip
* Update image-frame-renderer.ts
* wip
* wip
* Update image-frame-renderer.ts
* Create ImageCompositor.ts
* Update ImageCompositor.ts
* wip
* wip
* Update ImageEffector.ts
* wip
* Update use-uploader.ts
* wip
* wip
* wip
* wip
* Update fxs.ts
* wip
* wip
* wip
* Update CHANGELOG.md
* wip
* wip
* Update MkImageEffectorDialog.vue
* Update MkImageEffectorDialog.vue
* Update MkImageFrameEditorDialog.vue
* Update use-uploader.ts
* improve error handling
* Update use-uploader.ts
* 🎨
* wip
* wip
* lazy load
* lazy load
* wip
* wip
* wip
Diffstat (limited to 'packages/frontend/src/composables/use-uploader.ts')
| -rw-r--r-- | packages/frontend/src/composables/use-uploader.ts | 176 |
1 files changed, 132 insertions, 44 deletions
diff --git a/packages/frontend/src/composables/use-uploader.ts b/packages/frontend/src/composables/use-uploader.ts index e4aa1fda53..8ffb1e656b 100644 --- a/packages/frontend/src/composables/use-uploader.ts +++ b/packages/frontend/src/composables/use-uploader.ts @@ -9,6 +9,8 @@ import isAnimated from 'is-file-animated'; import { EventEmitter } from 'eventemitter3'; import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue'; import type { MenuItem } from '@/types/menu.js'; +import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js'; +import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js'; import { genId } from '@/utility/id.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; @@ -16,7 +18,6 @@ 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 { WatermarkRenderer } from '@/utility/watermark.js'; export type UploaderFeatures = { imageEditing?: boolean; @@ -28,13 +29,7 @@ const THUMBNAIL_SUPPORTED_TYPES = [ 'image/png', 'image/webp', 'image/svg+xml', -]; - -const IMAGE_COMPRESSION_SUPPORTED_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/webp', - 'image/svg+xml', + 'image/gif', ]; const IMAGE_EDITING_SUPPORTED_TYPES = [ @@ -49,11 +44,7 @@ const VIDEO_COMPRESSION_SUPPORTED_TYPES = [ // TODO 'video/x-matroska', ]; -const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES; - const IMAGE_PREPROCESS_NEEDED_TYPES = [ - ...WATERMARK_SUPPORTED_TYPES, - ...IMAGE_COMPRESSION_SUPPORTED_TYPES, ...IMAGE_EDITING_SUPPORTED_TYPES, ]; @@ -83,7 +74,9 @@ export type UploaderItem = { compressedSize?: number | null; preprocessedFile?: Blob | null; file: File; - watermarkPresetId: string | null; + watermarkPreset: WatermarkPreset | null; + watermarkLayers: WatermarkLayers | null; + imageFrameParams: ImageFrameParams | null; isSensitive?: boolean; caption?: string | null; abort?: (() => void) | null; @@ -135,6 +128,7 @@ export function useUploader(options: { const id = genId(); const filename = file.name ?? 'untitled'; const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; + const watermarkPreset = uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? (prefer.s.watermarkPresets.find(p => p.id === prefer.s.defaultWatermarkPresetId) ?? null) : null; items.value.push({ id, name: prefer.s.keepOriginalFilename ? filename : id + extension, @@ -146,8 +140,10 @@ export function useUploader(options: { aborted: false, uploaded: null, uploadFailed: false, - compressionLevel: IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0, - watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null, + compressionLevel: IMAGE_EDITING_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0, + watermarkPreset, + watermarkLayers: watermarkPreset?.layers ?? null, + imageFrameParams: null, file: markRaw(file), }); const reactiveItem = items.value.at(-1)!; @@ -253,7 +249,7 @@ export function useUploader(options: { }, },*/ { icon: 'ti ti-sparkles', - text: i18n.ts._imageEffector.title + ' (BETA)', + text: i18n.ts._imageEffector.title, action: async () => { const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), { image: item.file, @@ -280,13 +276,14 @@ export function useUploader(options: { if ( uploaderFeatures.value.watermark && $i.policies.watermarkAvailable && - WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && + IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded ) { - function changeWatermarkPreset(presetId: string | null) { - item.watermarkPresetId = presetId; + function change(layers: WatermarkLayers | null, preset?: WatermarkPreset | null) { + item.watermarkPreset = preset ?? null; + item.watermarkLayers = layers; preprocess(item).then(() => { triggerRef(items); }); @@ -295,43 +292,109 @@ export function useUploader(options: { menu.push({ icon: 'ti ti-copyright', text: i18n.ts.watermark, - caption: computed(() => item.watermarkPresetId == null ? null : prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId)?.name), + caption: computed(() => item.watermarkPreset != null ? item.watermarkPreset.name : item.watermarkLayers != null ? i18n.ts.custom : null), type: 'parent', children: [{ - type: 'radioOption', - text: i18n.ts.none, - active: computed(() => item.watermarkPresetId == null), - action: () => changeWatermarkPreset(null), + type: 'button' as const, + icon: 'ti ti-pencil', + text: i18n.ts.edit, + action: async () => { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), { + layers: item.watermarkLayers, + image: item.file, + }, { + ok: (layers) => { + change(layers); + }, + closed: () => dispose(), + }); + }, + }, { + type: 'button' as const, + icon: 'ti ti-x', + text: i18n.ts.remove, + action: () => change(null), }, { type: 'divider', + }, { + type: 'label', + text: i18n.ts.presets, }, ...prefer.s.watermarkPresets.map(preset => ({ type: 'radioOption' as const, text: preset.name, - active: computed(() => item.watermarkPresetId === preset.id), - action: () => changeWatermarkPreset(preset.id), - })), ...(prefer.s.watermarkPresets.length > 0 ? [{ - type: 'divider' as const, + active: computed(() => item.watermarkPreset?.id === preset.id), + action: () => change(preset.layers, preset), + }))], + }); + } + + if ( + uploaderFeatures.value.imageEditing && + IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && + !item.preprocessing && + !item.uploading && + !item.uploaded + ) { + function change(params: ImageFrameParams | null) { + item.imageFrameParams = params; + preprocess(item).then(() => { + triggerRef(items); + }); + } + + menu.push({ + icon: 'ti ti-device-ipad-horizontal', + text: i18n.ts.frame, + type: 'parent' as const, + children: [{ + type: 'button' as const, + icon: 'ti ti-pencil', + text: i18n.ts.edit, + action: async () => { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), { + params: item.imageFrameParams, + image: item.file, + imageCaption: item.caption ?? null, + imageFilename: item.name, + }, { + ok: (params) => { + change(params); + }, + closed: () => dispose(), + }); + }, + }, ...(item.imageFrameParams != null ? [{ + type: 'button' as const, + icon: 'ti ti-x', + text: i18n.ts.remove, + action: () => change(null), }] : []), { - type: 'button', - icon: 'ti ti-plus', - text: i18n.ts.add, + type: 'divider' as const, + }, { + type: 'label' as const, + text: i18n.ts.presets, + }, ...prefer.s.imageFramePresets.map(preset => ({ + type: 'button' as const, + text: preset.name, action: async () => { - const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), { + params: preset.params, image: item.file, + imageCaption: item.caption ?? null, + imageFilename: item.name, }, { - ok: (preset) => { - prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]); - changeWatermarkPreset(preset.id); + ok: (params) => { + change(params); }, closed: () => dispose(), }); }, - }], + }))], }); } if ( - (IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) && + (IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) && !item.preprocessing && !item.uploading && !item.uploaded @@ -545,10 +608,10 @@ export function useUploader(options: { let preprocessedFile: Blob | File = item.file; - const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable; - const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId); - if (needsWatermark && preset != null) { + const needsWatermark = item.watermarkLayers != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable; + if (needsWatermark && item.watermarkLayers != null) { const canvas = window.document.createElement('canvas'); + const WatermarkRenderer = await import('@/utility/watermark/WatermarkRenderer.js').then(x => x.WatermarkRenderer); const renderer = new WatermarkRenderer({ canvas: canvas, renderWidth: imageBitmap.width, @@ -556,9 +619,7 @@ export function useUploader(options: { image: imageBitmap, }); - await renderer.setLayers(preset.layers); - - renderer.render(); + await renderer.render(item.watermarkLayers); preprocessedFile = await new Promise<Blob>((resolve) => { canvas.toBlob((blob) => { @@ -571,8 +632,35 @@ export function useUploader(options: { }); } + const needsImageFrame = item.imageFrameParams != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type); + if (needsImageFrame && item.imageFrameParams != null) { + const canvas = window.document.createElement('canvas'); + const ExifReader = await import('exifreader'); + const exif = await ExifReader.load(await item.file.arrayBuffer()); + const ImageFrameRenderer = await import('@/utility/image-frame-renderer/ImageFrameRenderer.js').then(x => x.ImageFrameRenderer); + const frameRenderer = new ImageFrameRenderer({ + canvas: canvas, + image: await window.createImageBitmap(preprocessedFile), + exif, + caption: item.caption ?? null, + filename: item.name, + }); + + await frameRenderer.render(item.imageFrameParams); + + preprocessedFile = await new Promise<Blob>((resolve) => { + canvas.toBlob((blob) => { + if (blob == null) { + throw new Error('Failed to convert canvas to blob'); + } + resolve(blob); + frameRenderer.destroy(); + }, 'image/png'); + }); + } + const compressionSettings = getCompressionSettings(item.compressionLevel); - const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile)); + const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile)); if (needsCompress) { const config = { |