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,