mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-03 23:56:53 +00:00
썸네일 로딩 로직 최적화
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { getFileThumbnail } from "$lib/modules/file";
|
||||||
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
||||||
import { requestFileThumbnailDownload } from "$lib/services/file";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
info: SummarizedFileInfo;
|
info: SummarizedFileInfo;
|
||||||
@@ -10,25 +9,16 @@
|
|||||||
|
|
||||||
let { info, onclick }: Props = $props();
|
let { info, onclick }: Props = $props();
|
||||||
|
|
||||||
let showThumbnail = $derived(
|
let thumbnail = $derived(getFileThumbnail(info));
|
||||||
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
|
|
||||||
);
|
|
||||||
let thumbnailPromise = $derived(
|
|
||||||
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
|
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
|
||||||
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
|
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
|
||||||
>
|
>
|
||||||
{#await thumbnailPromise}
|
{#if $thumbnail}
|
||||||
|
<img src={$thumbnail} alt={info.name} class="h-full w-full object-cover" />
|
||||||
|
{:else}
|
||||||
<div class="h-full w-full bg-gray-100"></div>
|
<div class="h-full w-full bg-gray-100"></div>
|
||||||
{:then thumbnail}
|
{/if}
|
||||||
{#if thumbnail}
|
|
||||||
<img src={thumbnail} alt={info.name} class="h-full w-full object-cover" />
|
|
||||||
{:else}
|
|
||||||
<div class="h-full w-full bg-gray-100"></div>
|
|
||||||
{/if}
|
|
||||||
{/await}
|
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
|
||||||
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 { CategoryFileInfo } from "$lib/modules/filesystem";
|
import type { CategoryFileInfo } from "$lib/modules/filesystem";
|
||||||
import { requestFileThumbnailDownload } from "$lib/services/file";
|
|
||||||
import type { SelectedFile } from "./service";
|
import type { SelectedFile } from "./service";
|
||||||
|
|
||||||
import IconClose from "~icons/material-symbols/close";
|
import IconClose from "~icons/material-symbols/close";
|
||||||
@@ -16,12 +15,7 @@
|
|||||||
|
|
||||||
let { info, onclick, onRemoveClick }: Props = $props();
|
let { info, onclick, onRemoveClick }: Props = $props();
|
||||||
|
|
||||||
let showThumbnail = $derived(
|
let thumbnail = $derived(getFileThumbnail(info));
|
||||||
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
|
|
||||||
);
|
|
||||||
let thumbnailPromise = $derived(
|
|
||||||
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ActionEntryButton
|
<ActionEntryButton
|
||||||
@@ -30,9 +24,5 @@
|
|||||||
actionButtonIcon={onRemoveClick && IconClose}
|
actionButtonIcon={onRemoveClick && IconClose}
|
||||||
onActionButtonClick={() => onRemoveClick?.(info)}
|
onActionButtonClick={() => onRemoveClick?.(info)}
|
||||||
>
|
>
|
||||||
{#await thumbnailPromise}
|
<DirectoryEntryLabel type="file" thumbnail={$thumbnail} name={info.name} />
|
||||||
<DirectoryEntryLabel type="file" name={info.name} />
|
|
||||||
{:then thumbnail}
|
|
||||||
<DirectoryEntryLabel type="file" thumbnail={thumbnail ?? undefined} name={info.name} />
|
|
||||||
{/await}
|
|
||||||
</ActionEntryButton>
|
</ActionEntryButton>
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { LRUCache } from "lru-cache";
|
|
||||||
import {
|
import {
|
||||||
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
|
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
|
||||||
storeFileCacheIndex,
|
storeFileCacheIndex,
|
||||||
deleteFileCacheIndex,
|
deleteFileCacheIndex,
|
||||||
type FileCacheIndex,
|
type FileCacheIndex,
|
||||||
} from "$lib/indexedDB";
|
} from "$lib/indexedDB";
|
||||||
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
|
import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
|
||||||
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
|
||||||
|
|
||||||
const fileCacheIndex = new Map<number, FileCacheIndex>();
|
const fileCacheIndex = new Map<number, FileCacheIndex>();
|
||||||
const loadedThumbnails = new LRUCache<number, string>({ max: 100 });
|
|
||||||
|
|
||||||
export const prepareFileCache = async () => {
|
export const prepareFileCache = async () => {
|
||||||
for (const cache of await getFileCacheIndexFromIndexedDB()) {
|
for (const cache of await getFileCacheIndexFromIndexedDB()) {
|
||||||
@@ -51,30 +48,3 @@ export const deleteFileCache = async (fileId: number) => {
|
|||||||
await deleteFile(`/cache/${fileId}`);
|
await deleteFile(`/cache/${fileId}`);
|
||||||
await deleteFileCacheIndex(fileId);
|
await deleteFileCacheIndex(fileId);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFileThumbnailCache = async (fileId: number) => {
|
|
||||||
const thumbnail = loadedThumbnails.get(fileId);
|
|
||||||
if (thumbnail) return thumbnail;
|
|
||||||
|
|
||||||
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
|
|
||||||
if (!thumbnailBuffer) return null;
|
|
||||||
|
|
||||||
const thumbnailUrl = getThumbnailUrl(thumbnailBuffer);
|
|
||||||
loadedThumbnails.set(fileId, thumbnailUrl);
|
|
||||||
return thumbnailUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
|
|
||||||
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
|
||||||
loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFileThumbnailCache = async (fileId: number) => {
|
|
||||||
loadedThumbnails.delete(fileId);
|
|
||||||
await deleteFile(`/thumbnail/file/${fileId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteAllFileThumbnailCaches = async () => {
|
|
||||||
loadedThumbnails.clear();
|
|
||||||
await deleteDirectory("/thumbnail/file");
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./cache";
|
export * from "./cache";
|
||||||
export * from "./download.svelte";
|
export * from "./download.svelte";
|
||||||
|
export * from "./thumbnail";
|
||||||
export * from "./upload.svelte";
|
export * from "./upload.svelte";
|
||||||
|
|||||||
90
src/lib/modules/file/thumbnail.ts
Normal file
90
src/lib/modules/file/thumbnail.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { LRUCache } from "lru-cache";
|
||||||
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { decryptData } from "$lib/modules/crypto";
|
||||||
|
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
||||||
|
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
|
||||||
|
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
||||||
|
import { isTRPCClientError, trpc } from "$trpc/client";
|
||||||
|
|
||||||
|
const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 });
|
||||||
|
const loadingThumbnails = new Map<number, Writable<string | undefined>>();
|
||||||
|
|
||||||
|
const fetchFromOpfs = async (fileId: number) => {
|
||||||
|
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
|
||||||
|
if (thumbnailBuffer) {
|
||||||
|
return getThumbnailUrl(thumbnailBuffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
|
||||||
|
try {
|
||||||
|
const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([
|
||||||
|
fetch(`/api/file/${fileId}/thumbnail/download`),
|
||||||
|
trpc().file.thumbnail.query({ id: fileId }),
|
||||||
|
]);
|
||||||
|
const thumbnailBuffer = await decryptData(
|
||||||
|
await thumbnailEncrypted.arrayBuffer(),
|
||||||
|
thumbnailEncryptedIv,
|
||||||
|
dataKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
||||||
|
return getThumbnailUrl(thumbnailBuffer);
|
||||||
|
} catch (e) {
|
||||||
|
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileThumbnail = (file: SummarizedFileInfo) => {
|
||||||
|
if (
|
||||||
|
!browser ||
|
||||||
|
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnail = loadedThumbnails.get(file.id);
|
||||||
|
if (thumbnail) return thumbnail;
|
||||||
|
|
||||||
|
let loadingThumbnail = loadingThumbnails.get(file.id);
|
||||||
|
if (loadingThumbnail) return loadingThumbnail;
|
||||||
|
|
||||||
|
loadingThumbnail = writable(undefined);
|
||||||
|
loadingThumbnails.set(file.id, loadingThumbnail);
|
||||||
|
|
||||||
|
fetchFromOpfs(file.id)
|
||||||
|
.then((thumbnail) => thumbnail ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key)))
|
||||||
|
.then((thumbnail) => {
|
||||||
|
if (thumbnail) {
|
||||||
|
loadingThumbnail.set(thumbnail);
|
||||||
|
loadedThumbnails.set(file.id, loadingThumbnail as Writable<string>);
|
||||||
|
}
|
||||||
|
loadingThumbnails.delete(file.id);
|
||||||
|
});
|
||||||
|
return loadingThumbnail;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
|
||||||
|
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
||||||
|
|
||||||
|
const oldThumbnail = loadedThumbnails.get(fileId);
|
||||||
|
if (oldThumbnail) {
|
||||||
|
oldThumbnail.set(getThumbnailUrl(thumbnailBuffer));
|
||||||
|
} else {
|
||||||
|
loadedThumbnails.set(fileId, writable(getThumbnailUrl(thumbnailBuffer)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFileThumbnailCache = async (fileId: number) => {
|
||||||
|
loadedThumbnails.delete(fileId);
|
||||||
|
await deleteFile(`/thumbnail/file/${fileId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAllFileThumbnailCaches = async () => {
|
||||||
|
loadedThumbnails.clear();
|
||||||
|
await deleteDirectory(`/thumbnail/file`);
|
||||||
|
};
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import { getAllFileInfos } from "$lib/indexedDB/filesystem";
|
import { getAllFileInfos } from "$lib/indexedDB/filesystem";
|
||||||
import { decryptData } from "$lib/modules/crypto";
|
|
||||||
import {
|
import {
|
||||||
getFileCache,
|
getFileCache,
|
||||||
storeFileCache,
|
storeFileCache,
|
||||||
deleteFileCache,
|
deleteFileCache,
|
||||||
getFileThumbnailCache,
|
|
||||||
storeFileThumbnailCache,
|
|
||||||
deleteFileThumbnailCache,
|
|
||||||
downloadFile,
|
downloadFile,
|
||||||
|
deleteFileThumbnailCache,
|
||||||
} from "$lib/modules/file";
|
} from "$lib/modules/file";
|
||||||
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
|
||||||
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
|
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
|
||||||
import { trpc } from "$trpc/client";
|
import { trpc } from "$trpc/client";
|
||||||
|
|
||||||
@@ -44,29 +40,6 @@ export const requestFileThumbnailUpload = async (
|
|||||||
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
|
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => {
|
|
||||||
const cache = await getFileThumbnailCache(fileId);
|
|
||||||
if (cache || !dataKey) return cache;
|
|
||||||
|
|
||||||
let thumbnailInfo;
|
|
||||||
try {
|
|
||||||
thumbnailInfo = await trpc().file.thumbnail.query({ id: fileId });
|
|
||||||
} catch {
|
|
||||||
// TODO: Error Handling
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const { contentIv: thumbnailEncryptedIv } = thumbnailInfo;
|
|
||||||
|
|
||||||
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
|
|
||||||
const thumbnailEncrypted = await res.arrayBuffer();
|
|
||||||
const thumbnailBuffer = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey);
|
|
||||||
|
|
||||||
storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended
|
|
||||||
return getThumbnailUrl(thumbnailBuffer);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requestDeletedFilesCleanup = async () => {
|
export const requestDeletedFilesCleanup = async () => {
|
||||||
let liveFiles;
|
let liveFiles;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
|
||||||
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 { SummarizedFileInfo } from "$lib/modules/filesystem";
|
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
||||||
import { requestFileThumbnailDownload } from "$lib/services/file";
|
|
||||||
import { formatDateTime } from "$lib/utils";
|
import { formatDateTime } from "$lib/utils";
|
||||||
import type { SelectedEntry } from "../service.svelte";
|
import type { SelectedEntry } from "../service.svelte";
|
||||||
|
|
||||||
@@ -17,12 +16,7 @@
|
|||||||
|
|
||||||
let { info, onclick, onOpenMenuClick }: Props = $props();
|
let { info, onclick, onOpenMenuClick }: Props = $props();
|
||||||
|
|
||||||
let showThumbnail = $derived(
|
let thumbnail = $derived(getFileThumbnail(info));
|
||||||
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
|
|
||||||
);
|
|
||||||
let thumbnailPromise = $derived(
|
|
||||||
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const action = (callback: typeof onclick) => {
|
const action = (callback: typeof onclick) => {
|
||||||
callback({ type: "file", id: info.id, dataKey: info.dataKey, name: info.name });
|
callback({ type: "file", id: info.id, dataKey: info.dataKey, name: info.name });
|
||||||
@@ -35,18 +29,10 @@
|
|||||||
actionButtonIcon={IconMoreVert}
|
actionButtonIcon={IconMoreVert}
|
||||||
onActionButtonClick={() => action(onOpenMenuClick)}
|
onActionButtonClick={() => action(onOpenMenuClick)}
|
||||||
>
|
>
|
||||||
{#await thumbnailPromise}
|
<DirectoryEntryLabel
|
||||||
<DirectoryEntryLabel
|
type="file"
|
||||||
type="file"
|
thumbnail={$thumbnail}
|
||||||
name={info.name}
|
name={info.name}
|
||||||
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
||||||
/>
|
/>
|
||||||
{:then thumbnail}
|
|
||||||
<DirectoryEntryLabel
|
|
||||||
type="file"
|
|
||||||
thumbnail={thumbnail ?? undefined}
|
|
||||||
name={info.name}
|
|
||||||
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
|
||||||
/>
|
|
||||||
{/await}
|
|
||||||
</ActionEntryButton>
|
</ActionEntryButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user