From fa7ba451c3c7182056535eb8d75b6687faed2fa3 Mon Sep 17 00:00:00 2001 From: static Date: Sat, 12 Jul 2025 02:53:30 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B9=84=EB=94=94=EC=98=A4=EC=9D=98=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EC=9B=90=ED=95=98=EB=8A=94=20=EC=9E=A5?= =?UTF-8?q?=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=8D=B8=EB=84=A4=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EB=B3=80=EA=B2=BD=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/thumbnail.ts | 44 +++++++++++-------- .../(fullscreen)/file/[id]/+page.svelte | 22 +++++++++- src/routes/(fullscreen)/file/[id]/service.ts | 30 ++++++++++++- 3 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/lib/modules/thumbnail.ts b/src/lib/modules/thumbnail.ts index 1a24b5d..873772e 100644 --- a/src/lib/modules/thumbnail.ts +++ b/src/lib/modules/thumbnail.ts @@ -42,6 +42,30 @@ const generateImageThumbnail = (imageUrl: string) => { }); }; +export const captureVideoThumbnail = (video: HTMLVideoElement) => { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + const { width, height } = scaleSize(video.videoWidth, video.videoHeight, 250); + + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext("2d"); + if (!context) { + return reject(new Error("Failed to generate thumbnail")); + } + + context.drawImage(video, 0, 0, width, height); + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Failed to generate thumbnail")); + } + }, "image/webp"); + }); +}; + const generateVideoThumbnail = (videoUrl: string, time = 0) => { return new Promise((resolve, reject) => { const video = document.createElement("video"); @@ -49,25 +73,7 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => { video.currentTime = time; }; video.onseeked = () => { - const canvas = document.createElement("canvas"); - const { width, height } = scaleSize(video.videoWidth, video.videoHeight, 250); - - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext("2d"); - if (!context) { - return reject(new Error("Failed to generate thumbnail")); - } - - context.drawImage(video, 0, 0, width, height); - canvas.toBlob((blob) => { - if (blob) { - resolve(blob); - } else { - reject(new Error("Failed to generate thumbnail")); - } - }, "image/webp"); + captureVideoThumbnail(video).then(resolve).catch(reject); }; video.onerror = reject; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 325122f..61465bd 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -11,15 +11,18 @@ type FileInfo, type CategoryInfo, } from "$lib/modules/filesystem"; + import { captureVideoThumbnail } from "$lib/modules/thumbnail"; import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; import DownloadStatus from "./DownloadStatus.svelte"; import { requestFileRemovalFromCategory, requestFileDownload, + requestThumbnailUpload, requestFileAdditionToCategory, } from "./service"; + import IconCamera from "~icons/material-symbols/camera"; import IconClose from "~icons/material-symbols/close"; import IconAddCircle from "~icons/material-symbols/add-circle"; @@ -40,6 +43,7 @@ let isDownloadRequested = $state(false); let viewerType: "image" | "video" | undefined = $state(); let fileBlobUrl: string | undefined = $state(); + let videoElement: HTMLVideoElement | undefined = $state(); const updateViewer = async (buffer: ArrayBuffer, contentType: string) => { const fileBlob = new Blob([buffer], { type: contentType }); @@ -55,6 +59,11 @@ return fileBlob; }; + const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => { + const thumbnail = await captureVideoThumbnail(videoElement!); + await requestThumbnailUpload(data.id, thumbnail, dataKey, dataKeyVersion); + }; + const addToCategory = async (categoryId: number) => { await requestFileAdditionToCategory(data.id, categoryId); isAddToCategoryBottomSheetOpen = false; @@ -133,8 +142,17 @@ {/if} {:else if viewerType === "video"} {#if fileBlobUrl} - - +
+ + + updateThumbnail($info.dataKey!, $info.dataKeyVersion!)} + class="w-full" + > + 이 장면을 썸네일로 설정하기 + +
{:else} {@render viewerLoading("비디오를 불러오고 있어요.")} {/if} diff --git a/src/routes/(fullscreen)/file/[id]/service.ts b/src/routes/(fullscreen)/file/[id]/service.ts index ccedcd1..83a57c6 100644 --- a/src/routes/(fullscreen)/file/[id]/service.ts +++ b/src/routes/(fullscreen)/file/[id]/service.ts @@ -1,9 +1,37 @@ import { callPostApi } from "$lib/hooks"; -import type { CategoryFileAddRequest } from "$lib/server/schemas"; +import { encryptData } from "$lib/modules/crypto"; +import { storeFileThumbnailCache } from "$lib/modules/file"; +import type { CategoryFileAddRequest, FileThumbnailUploadRequest } from "$lib/server/schemas"; export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category"; export { requestFileDownload } from "$lib/services/file"; +export const requestThumbnailUpload = async ( + fileId: number, + thumbnail: Blob, + dataKey: CryptoKey, + dataKeyVersion: Date, +) => { + const thumbnailBuffer = await thumbnail.arrayBuffer(); + const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); + + const form = new FormData(); + form.set( + "metadata", + JSON.stringify({ + dekVersion: dataKeyVersion.toISOString(), + contentIv: thumbnailEncrypted.iv, + } satisfies FileThumbnailUploadRequest), + ); + form.set("content", new Blob([thumbnailEncrypted.ciphertext])); + + const res = await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); + if (!res.ok) return false; + + storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended + return true; +}; + export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => { const res = await callPostApi(`/api/category/${categoryId}/file/add`, { file: fileId,