feat(canvas): 画布资源 - 缩略图字段与缓存加载支持
This commit is contained in:
@@ -104,7 +104,7 @@ const resourceCache = new Map<string, RemoteResource>();
|
||||
const resourceRequests = new Map<string, Promise<RemoteResource>>();
|
||||
const missingResourceIds = new Set<string>();
|
||||
export type ResourceAccessPurpose = "display" | "copy" | "download" | "browser-process" | "provider-input";
|
||||
export type ResourceAccessVariant = "original" | "playback";
|
||||
export type ResourceAccessVariant = "original" | "playback" | "thumbnail";
|
||||
export type ResourceAccess = {
|
||||
resourceId: string;
|
||||
requestedVariant: ResourceAccessVariant;
|
||||
@@ -116,6 +116,8 @@ export type ResourceAccess = {
|
||||
refreshAt: string;
|
||||
revision: string;
|
||||
fallbackReason?: string;
|
||||
originalWidth?: number;
|
||||
originalHeight?: number;
|
||||
};
|
||||
|
||||
type ResourceAccessBatchItem = { resourceId: string; access?: ResourceAccess; error?: { msg?: string } };
|
||||
|
||||
91
web/src/services/canvas-image-loader.ts
Normal file
91
web/src/services/canvas-image-loader.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const MAX_ACTIVE_IMAGE_PREPARATIONS = 4;
|
||||
const IMAGE_PREPARATION_TIMEOUT_MS = 60_000;
|
||||
|
||||
type ImagePreparationJob = {
|
||||
src: string;
|
||||
signal: AbortSignal;
|
||||
resolve: (image: HTMLImageElement) => void;
|
||||
reject: (error: unknown) => void;
|
||||
started: boolean;
|
||||
settled: boolean;
|
||||
image?: HTMLImageElement;
|
||||
timeout?: ReturnType<typeof setTimeout>;
|
||||
onAbort: () => void;
|
||||
};
|
||||
|
||||
const queue: ImagePreparationJob[] = [];
|
||||
let activePreparations = 0;
|
||||
|
||||
/** Load and decode one image under a shared concurrency budget. Queued work is cancellable. */
|
||||
export function prepareCanvasImage(src: string, signal: AbortSignal, timeoutMs = IMAGE_PREPARATION_TIMEOUT_MS): Promise<HTMLImageElement> {
|
||||
if (signal.aborted) return Promise.reject(abortError());
|
||||
return new Promise((resolve, reject) => {
|
||||
const job: ImagePreparationJob = { src, signal, resolve, reject, started: false, settled: false, onAbort: () => {} };
|
||||
job.onAbort = () => {
|
||||
if (job.settled) return;
|
||||
if (!job.started) {
|
||||
const index = queue.indexOf(job);
|
||||
if (index >= 0) queue.splice(index, 1);
|
||||
settle(job, undefined, abortError());
|
||||
return;
|
||||
}
|
||||
job.image?.removeAttribute("src");
|
||||
settle(job, undefined, abortError());
|
||||
};
|
||||
signal.addEventListener("abort", job.onAbort, { once: true });
|
||||
queue.push(job);
|
||||
pump(timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
export function canvasImagePreparationStats() {
|
||||
return { active: activePreparations, queued: queue.length, limit: MAX_ACTIVE_IMAGE_PREPARATIONS };
|
||||
}
|
||||
|
||||
function pump(timeoutMs: number) {
|
||||
while (activePreparations < MAX_ACTIVE_IMAGE_PREPARATIONS && queue.length) {
|
||||
const job = queue.shift()!;
|
||||
if (job.settled || job.signal.aborted) {
|
||||
settle(job, undefined, abortError());
|
||||
continue;
|
||||
}
|
||||
job.started = true;
|
||||
activePreparations += 1;
|
||||
job.timeout = setTimeout(() => {
|
||||
job.image?.removeAttribute("src");
|
||||
settle(job, undefined, new Error("图片加载或解码超时"));
|
||||
}, timeoutMs);
|
||||
|
||||
const image = new Image();
|
||||
job.image = image;
|
||||
image.decoding = "async";
|
||||
image.onload = () => {
|
||||
if (job.settled || job.signal.aborted) return;
|
||||
void image.decode().then(
|
||||
() => settle(job, image),
|
||||
(error) => settle(job, undefined, error),
|
||||
);
|
||||
};
|
||||
image.onerror = () => settle(job, undefined, new Error("图片加载失败"));
|
||||
image.src = job.src;
|
||||
if (image.complete && image.naturalWidth > 0) image.onload?.call(image, new Event("load"));
|
||||
}
|
||||
}
|
||||
|
||||
function settle(job: ImagePreparationJob, image?: HTMLImageElement, error?: unknown) {
|
||||
if (job.settled) return;
|
||||
job.settled = true;
|
||||
job.signal.removeEventListener("abort", job.onAbort);
|
||||
if (job.timeout) clearTimeout(job.timeout);
|
||||
if (job.started) {
|
||||
activePreparations = Math.max(0, activePreparations - 1);
|
||||
pump(IMAGE_PREPARATION_TIMEOUT_MS);
|
||||
}
|
||||
if (error !== undefined) job.reject(error);
|
||||
else if (image) job.resolve(image);
|
||||
else job.reject(new Error("图片准备未完成"));
|
||||
}
|
||||
|
||||
function abortError() {
|
||||
return new DOMException("图片准备已取消", "AbortError");
|
||||
}
|
||||
Reference in New Issue
Block a user