mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-15 22:38:47 +00:00
썸네일을 메모리와 OPFS에 캐시하도록 개선
This commit is contained in:
@@ -37,6 +37,7 @@
|
|||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"kysely-ctl": "^0.10.1",
|
"kysely-ctl": "^0.10.1",
|
||||||
|
"lru-cache": "^11.1.0",
|
||||||
"mime": "^4.0.6",
|
"mime": "^4.0.6",
|
||||||
"p-limit": "^6.2.0",
|
"p-limit": "^6.2.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -96,6 +96,9 @@ importers:
|
|||||||
kysely-ctl:
|
kysely-ctl:
|
||||||
specifier: ^0.10.1
|
specifier: ^0.10.1
|
||||||
version: 0.10.1(kysely@0.27.5)
|
version: 0.10.1(kysely@0.27.5)
|
||||||
|
lru-cache:
|
||||||
|
specifier: ^11.1.0
|
||||||
|
version: 11.1.0
|
||||||
mime:
|
mime:
|
||||||
specifier: ^4.0.6
|
specifier: ^4.0.6
|
||||||
version: 4.0.6
|
version: 4.0.6
|
||||||
@@ -1414,6 +1417,10 @@ packages:
|
|||||||
lru-cache@10.4.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
|
lru-cache@11.1.0:
|
||||||
|
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
|
||||||
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
luxon@3.5.0:
|
luxon@3.5.0:
|
||||||
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
|
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3369,6 +3376,8 @@ snapshots:
|
|||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
|
lru-cache@11.1.0: {}
|
||||||
|
|
||||||
luxon@3.5.0: {}
|
luxon@3.5.0: {}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
name: string;
|
name: string;
|
||||||
subtext?: string;
|
subtext?: string;
|
||||||
textClass?: ClassValue;
|
textClass?: ClassValue;
|
||||||
thumbnail?: ArrayBuffer;
|
thumbnail?: string;
|
||||||
type: "directory" | "file";
|
type: "directory" | "file";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,19 +22,12 @@
|
|||||||
thumbnail,
|
thumbnail,
|
||||||
type,
|
type,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let thumbnailUrl: string | undefined = $state();
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
thumbnailUrl = thumbnail && URL.createObjectURL(new Blob([thumbnail]));
|
|
||||||
return () => thumbnailUrl && URL.revokeObjectURL(thumbnailUrl);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet iconSnippet()}
|
{#snippet iconSnippet()}
|
||||||
<div class="flex h-10 w-10 items-center justify-center overflow-y-hidden text-xl">
|
<div class="flex h-10 w-10 items-center justify-center overflow-y-hidden text-xl">
|
||||||
{#if thumbnailUrl}
|
{#if thumbnail}
|
||||||
<img src={thumbnailUrl} alt={name} loading="lazy" />
|
<img src={thumbnail} alt={name} loading="lazy" />
|
||||||
{:else if type === "directory"}
|
{:else if type === "directory"}
|
||||||
<IconFolder />
|
<IconFolder />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./cache";
|
export * from "./cache";
|
||||||
export * from "./download";
|
export * from "./download";
|
||||||
|
export * from "./thumbnail";
|
||||||
export * from "./upload";
|
export * from "./upload";
|
||||||
|
|||||||
29
src/lib/modules/file/thumbnail.ts
Normal file
29
src/lib/modules/file/thumbnail.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { LRUCache } from "lru-cache";
|
||||||
|
import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
|
||||||
|
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
||||||
|
|
||||||
|
const loadedThumbnails = new LRUCache<number, string>({ max: 100 });
|
||||||
|
|
||||||
|
export const getFileThumbnail = async (fileId: number) => {
|
||||||
|
const thumbnail = loadedThumbnails.get(fileId);
|
||||||
|
if (thumbnail) {
|
||||||
|
return thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailBuffer = await readFile(`/thumbnails/${fileId}`);
|
||||||
|
if (!thumbnailBuffer) return null;
|
||||||
|
|
||||||
|
const thumbnailUrl = getThumbnailUrl(thumbnailBuffer);
|
||||||
|
loadedThumbnails.set(fileId, thumbnailUrl);
|
||||||
|
return thumbnailUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeFileThumbnail = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
|
||||||
|
await writeFile(`/thumbnails/${fileId}`, thumbnailBuffer);
|
||||||
|
loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFileThumbnail = async (fileId: number) => {
|
||||||
|
loadedThumbnails.delete(fileId);
|
||||||
|
await deleteFile(`/thumbnails/${fileId}`);
|
||||||
|
};
|
||||||
@@ -130,9 +130,8 @@ const encryptFile = limitFunction(
|
|||||||
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
|
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
|
||||||
|
|
||||||
const thumbnail = await generateThumbnail(file, fileType);
|
const thumbnail = await generateThumbnail(file, fileType);
|
||||||
const thumbnailEncrypted = thumbnail
|
const thumbnailBuffer = await thumbnail?.arrayBuffer();
|
||||||
? await encryptData(await thumbnail.arrayBuffer(), dataKey)
|
const thumbnailEncrypted = thumbnailBuffer ? await encryptData(thumbnailBuffer, dataKey) : null;
|
||||||
: null;
|
|
||||||
|
|
||||||
status.update((value) => {
|
status.update((value) => {
|
||||||
value.status = "upload-pending";
|
value.status = "upload-pending";
|
||||||
@@ -148,7 +147,8 @@ const encryptFile = limitFunction(
|
|||||||
nameEncrypted,
|
nameEncrypted,
|
||||||
createdAtEncrypted,
|
createdAtEncrypted,
|
||||||
lastModifiedAtEncrypted,
|
lastModifiedAtEncrypted,
|
||||||
thumbnailEncrypted,
|
thumbnail: thumbnail &&
|
||||||
|
thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ concurrency: 4 },
|
{ concurrency: 4 },
|
||||||
@@ -198,7 +198,9 @@ export const uploadFile = async (
|
|||||||
hmacSecret: HmacSecret,
|
hmacSecret: HmacSecret,
|
||||||
masterKey: MasterKey,
|
masterKey: MasterKey,
|
||||||
onDuplicate: () => Promise<boolean>,
|
onDuplicate: () => Promise<boolean>,
|
||||||
): Promise<{ fileId: number; fileBuffer: ArrayBuffer } | undefined> => {
|
): Promise<
|
||||||
|
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
|
||||||
|
> => {
|
||||||
const status = writable<FileUploadStatus>({
|
const status = writable<FileUploadStatus>({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
parentId,
|
parentId,
|
||||||
@@ -236,7 +238,7 @@ export const uploadFile = async (
|
|||||||
nameEncrypted,
|
nameEncrypted,
|
||||||
createdAtEncrypted,
|
createdAtEncrypted,
|
||||||
lastModifiedAtEncrypted,
|
lastModifiedAtEncrypted,
|
||||||
thumbnailEncrypted,
|
thumbnail,
|
||||||
} = await encryptFile(status, file, fileBuffer, masterKey);
|
} = await encryptFile(status, file, fileBuffer, masterKey);
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -263,20 +265,20 @@ export const uploadFile = async (
|
|||||||
form.set("checksum", fileEncryptedHash);
|
form.set("checksum", fileEncryptedHash);
|
||||||
|
|
||||||
let thumbnailForm = null;
|
let thumbnailForm = null;
|
||||||
if (thumbnailEncrypted) {
|
if (thumbnail) {
|
||||||
thumbnailForm = new FormData();
|
thumbnailForm = new FormData();
|
||||||
thumbnailForm.set(
|
thumbnailForm.set(
|
||||||
"metadata",
|
"metadata",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
dekVersion: dataKeyVersion.toISOString(),
|
dekVersion: dataKeyVersion.toISOString(),
|
||||||
contentIv: thumbnailEncrypted.iv,
|
contentIv: thumbnail.iv,
|
||||||
} satisfies FileThumbnailUploadRequest),
|
} satisfies FileThumbnailUploadRequest),
|
||||||
);
|
);
|
||||||
thumbnailForm.set("content", new Blob([thumbnailEncrypted.ciphertext]));
|
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
|
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
|
||||||
return { fileId, fileBuffer };
|
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.update((value) => {
|
status.update((value) => {
|
||||||
value.status = "error";
|
value.status = "error";
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { encodeToBase64 } from "$lib/modules/crypto";
|
||||||
|
|
||||||
const scaleSize = (width: number, height: number, targetSize: number) => {
|
const scaleSize = (width: number, height: number, targetSize: number) => {
|
||||||
if (width <= targetSize || height <= targetSize) {
|
if (width <= targetSize || height <= targetSize) {
|
||||||
return { width, height };
|
return { width, height };
|
||||||
@@ -74,3 +76,7 @@ export const generateVideoThumbnail = (videoUrl: string, time = 0) => {
|
|||||||
video.src = videoUrl;
|
video.src = videoUrl;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
|
||||||
|
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { limitFunction } from "p-limit";
|
import { limitFunction } from "p-limit";
|
||||||
import { encryptData } from "$lib/modules/crypto";
|
import { encryptData } from "$lib/modules/crypto";
|
||||||
import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file";
|
import { getFileCache, storeFileCache, downloadFile, storeFileThumbnail } from "$lib/modules/file";
|
||||||
import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail";
|
import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail";
|
||||||
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
|
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
|
||||||
|
|
||||||
@@ -55,7 +55,10 @@ export const requestThumbnailUpload = limitFunction(
|
|||||||
form.set("content", new Blob([thumbnailEncrypted.ciphertext]));
|
form.set("content", new Blob([thumbnailEncrypted.ciphertext]));
|
||||||
|
|
||||||
const res = await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
|
const res = await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
|
||||||
return res.ok;
|
if (!res.ok) return false;
|
||||||
|
|
||||||
|
storeFileThumbnail(fileId, thumbnail); // Intended
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
{ concurrency: 1 },
|
{ concurrency: 4 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { ActionEntryButton } from "$lib/components/atoms";
|
import { ActionEntryButton } from "$lib/components/atoms";
|
||||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||||
|
import { getFileThumbnail } from "$lib/modules/file";
|
||||||
import type { FileInfo } from "$lib/modules/filesystem";
|
import type { FileInfo } from "$lib/modules/filesystem";
|
||||||
import { formatDateTime } from "$lib/modules/util";
|
import { formatDateTime } from "$lib/modules/util";
|
||||||
import { getFileThumbnail } from "./service";
|
import { requestFileThumbnailDownload } from "./service";
|
||||||
import type { SelectedEntry } from "../service.svelte";
|
import type { SelectedEntry } from "../service.svelte";
|
||||||
|
|
||||||
import IconMoreVert from "~icons/material-symbols/more-vert";
|
import IconMoreVert from "~icons/material-symbols/more-vert";
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
|
|
||||||
let { info, onclick, onOpenMenuClick }: Props = $props();
|
let { info, onclick, onOpenMenuClick }: Props = $props();
|
||||||
|
|
||||||
let thumbnail: ArrayBuffer | undefined = $state();
|
let thumbnail: string | undefined = $state();
|
||||||
|
|
||||||
const openFile = () => {
|
const openFile = () => {
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info!;
|
const { id, dataKey, dataKeyVersion, name } = $info!;
|
||||||
@@ -35,12 +36,15 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($info?.dataKey) {
|
if ($info?.dataKey) {
|
||||||
getFileThumbnail($info.id, $info.dataKey)
|
getFileThumbnail($info.id)
|
||||||
.then((thumbnailData) => {
|
.then(
|
||||||
thumbnail = thumbnailData ?? undefined;
|
(thumbnailUrl) => thumbnailUrl || requestFileThumbnailDownload($info.id, $info.dataKey!),
|
||||||
|
)
|
||||||
|
.then((thumbnailUrl) => {
|
||||||
|
thumbnail = thumbnailUrl ?? undefined;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// TODO: Error handling
|
// TODO: Error Handling
|
||||||
thumbnail = undefined;
|
thumbnail = undefined;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { callGetApi } from "$lib/hooks";
|
import { callGetApi } from "$lib/hooks";
|
||||||
import { decryptData } from "$lib/modules/crypto";
|
import { decryptData } from "$lib/modules/crypto";
|
||||||
|
import { storeFileThumbnail } from "$lib/modules/file";
|
||||||
|
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
||||||
import type { FileThumbnailInfoResponse } from "$lib/server/schemas";
|
import type { FileThumbnailInfoResponse } from "$lib/server/schemas";
|
||||||
|
|
||||||
export const getFileThumbnail = async (fileId: number, dataKey: CryptoKey) => {
|
export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => {
|
||||||
let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
|
let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
|
|
||||||
@@ -13,5 +15,7 @@ export const getFileThumbnail = async (fileId: number, dataKey: CryptoKey) => {
|
|||||||
|
|
||||||
const thumbnailEncrypted = await res.arrayBuffer();
|
const thumbnailEncrypted = await res.arrayBuffer();
|
||||||
const thumbnail = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey);
|
const thumbnail = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey);
|
||||||
return thumbnail;
|
|
||||||
|
storeFileThumbnail(fileId, thumbnail); // Intended
|
||||||
|
return getThumbnailUrl(thumbnail);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getContext, setContext } from "svelte";
|
|||||||
import { callGetApi, callPostApi } from "$lib/hooks";
|
import { callGetApi, callPostApi } from "$lib/hooks";
|
||||||
import { storeHmacSecrets } from "$lib/indexedDB";
|
import { storeHmacSecrets } from "$lib/indexedDB";
|
||||||
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
|
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
|
||||||
import { storeFileCache, deleteFileCache, uploadFile } from "$lib/modules/file";
|
import { storeFileCache, deleteFileCache, storeFileThumbnail, uploadFile } from "$lib/modules/file";
|
||||||
import type {
|
import type {
|
||||||
DirectoryRenameRequest,
|
DirectoryRenameRequest,
|
||||||
DirectoryCreateRequest,
|
DirectoryCreateRequest,
|
||||||
@@ -81,6 +81,10 @@ export const requestFileUpload = async (
|
|||||||
if (!res) return false;
|
if (!res) return false;
|
||||||
|
|
||||||
storeFileCache(res.fileId, res.fileBuffer); // Intended
|
storeFileCache(res.fileId, res.fileBuffer); // Intended
|
||||||
|
if (res.thumbnailBuffer) {
|
||||||
|
storeFileThumbnail(res.fileId, res.thumbnailBuffer); // Intended
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user