비디오의 경우 원하는 장면으로 썸네일을 변경할 수 있도록 개선

This commit is contained in:
static
2025-07-12 02:53:30 +09:00
parent eac81abe5a
commit fa7ba451c3
3 changed files with 74 additions and 22 deletions

View File

@@ -42,6 +42,30 @@ const generateImageThumbnail = (imageUrl: string) => {
});
};
export const captureVideoThumbnail = (video: HTMLVideoElement) => {
return new Promise<Blob>((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<Blob>((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;

View File

@@ -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}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={fileBlobUrl} controls></video>
<div class="flex flex-col space-y-2">
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={videoElement} src={fileBlobUrl} controls muted></video>
<IconEntryButton
icon={IconCamera}
onclick={() => updateThumbnail($info.dataKey!, $info.dataKeyVersion!)}
class="w-full"
>
이 장면을 썸네일로 설정하기
</IconEntryButton>
</div>
{:else}
{@render viewerLoading("비디오를 불러오고 있어요.")}
{/if}

View File

@@ -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<CategoryFileAddRequest>(`/api/category/${categoryId}/file/add`, {
file: fileId,