139 lines
5.3 KiB
JavaScript
139 lines
5.3 KiB
JavaScript
import { MAX_PACKET_BYTES } from "./codec.mjs";
|
|
|
|
export const abortError = () => new DOMException("Preview cancelled", "AbortError");
|
|
|
|
export function releaseImage(record) {
|
|
if (!record) return;
|
|
record.image.removeAttribute("src");
|
|
URL.revokeObjectURL(record.url);
|
|
}
|
|
|
|
// Shared slots bound HTTP requests, workers AND image decodes across nodes.
|
|
export class PreviewLoader {
|
|
constructor(fetchApi) {
|
|
this.fetchApi = fetchApi;
|
|
this.queue = [];
|
|
this.slots = Array.from({ length: 2 }, () => ({ busy: false, worker: null }));
|
|
this.users = 0;
|
|
}
|
|
|
|
acquire() { this.users++; }
|
|
|
|
release() {
|
|
if (this.users > 0) this.users--;
|
|
if (!this.users) {
|
|
for (const slot of this.slots) {
|
|
if (!slot.busy) { slot.worker?.terminate(); slot.worker = null; }
|
|
}
|
|
}
|
|
}
|
|
|
|
load(batchId, index, kind, signal) {
|
|
return new Promise((resolve, reject) => {
|
|
if (signal.aborted) { reject(abortError()); return; }
|
|
const job = { batchId, index, kind, signal, resolve, reject };
|
|
job.cancel = () => {
|
|
const position = this.queue.indexOf(job);
|
|
if (position !== -1) this.queue.splice(position, 1);
|
|
reject(abortError());
|
|
};
|
|
signal.addEventListener("abort", job.cancel, { once: true });
|
|
if (kind === "original") this.queue.unshift(job);
|
|
else this.queue.push(job);
|
|
this.pump();
|
|
});
|
|
}
|
|
|
|
pump() {
|
|
for (const slot of this.slots) {
|
|
if (slot.busy || !this.queue.length) continue;
|
|
const job = this.queue.shift();
|
|
job.signal.removeEventListener("abort", job.cancel);
|
|
slot.busy = true;
|
|
this.run(slot, job).then(job.resolve, job.reject).finally(() => {
|
|
slot.busy = false;
|
|
if (!this.users) { slot.worker?.terminate(); slot.worker = null; }
|
|
this.pump();
|
|
});
|
|
}
|
|
}
|
|
|
|
async run(slot, job) {
|
|
const controller = new AbortController();
|
|
const cancel = () => controller.abort();
|
|
job.signal.addEventListener("abort", cancel, { once: true });
|
|
if (job.signal.aborted) controller.abort();
|
|
const timer = setTimeout(cancel, 30_000);
|
|
let record;
|
|
try {
|
|
const response = await this.fetchApi(`/encrypt-preview/v1/${job.batchId}/${job.index}/${job.kind}`, {
|
|
signal: controller.signal, cache: "no-store",
|
|
});
|
|
if (response.status === 410 || response.status === 404) throw new Error("预览已过期,请重新运行节点");
|
|
if (!response.ok) throw new Error(`读取预览失败 (${response.status})`);
|
|
if (Number(response.headers.get("Content-Length")) > MAX_PACKET_BYTES) throw new Error("预览图片过大");
|
|
const buffer = await response.arrayBuffer();
|
|
if (controller.signal.aborted) throw abortError();
|
|
if (buffer.byteLength > MAX_PACKET_BYTES) throw new Error("预览图片过大");
|
|
const decoded = await this.restore(slot, buffer, controller.signal);
|
|
if (controller.signal.aborted) throw abortError();
|
|
const image = new Image();
|
|
image.decoding = "async";
|
|
record = { image, url: URL.createObjectURL(decoded.blob), blob: decoded.blob };
|
|
const stopDecode = () => image.removeAttribute("src");
|
|
controller.signal.addEventListener("abort", stopDecode, { once: true });
|
|
try {
|
|
image.src = record.url;
|
|
// The native widget needs HTMLImageElement. Decode once, asynchronously.
|
|
await image.decode();
|
|
} finally {
|
|
controller.signal.removeEventListener("abort", stopDecode);
|
|
}
|
|
if (controller.signal.aborted) throw abortError();
|
|
if (image.naturalWidth !== decoded.width || image.naturalHeight !== decoded.height) throw new Error("预览图片尺寸不匹配");
|
|
return record;
|
|
} catch (error) {
|
|
releaseImage(record);
|
|
if (controller.signal.aborted) {
|
|
if (job.signal.aborted) throw abortError();
|
|
throw new Error("读取预览超时,请重试");
|
|
}
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
job.signal.removeEventListener("abort", cancel);
|
|
}
|
|
}
|
|
|
|
restore(slot, buffer, signal) {
|
|
return new Promise((resolve, reject) => {
|
|
let worker;
|
|
let settled = false;
|
|
const finish = (error, result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
signal.removeEventListener("abort", stop);
|
|
if (worker) { worker.onmessage = null; worker.onerror = null; worker.onmessageerror = null; }
|
|
if (error) {
|
|
slot.worker?.terminate(); slot.worker = null;
|
|
reject(error);
|
|
} else resolve(result);
|
|
};
|
|
const stop = () => finish(abortError());
|
|
try {
|
|
if (signal.aborted) { stop(); return; }
|
|
worker = slot.worker ??= new Worker(new URL("./decode.worker.mjs", import.meta.url), { type: "module" });
|
|
worker.onmessage = ({ data }) => {
|
|
if (data?.error) finish(new Error(data.error));
|
|
else if (!(data?.blob instanceof Blob)) finish(new Error("预览 Worker 返回了无效数据"));
|
|
else finish(null, data);
|
|
};
|
|
worker.onerror = event => { event.preventDefault(); finish(new Error(event.message || "预览 Worker 启动失败")); };
|
|
worker.onmessageerror = () => finish(new Error("预览 Worker 返回了无效数据"));
|
|
signal.addEventListener("abort", stop, { once: true });
|
|
worker.postMessage({ buffer }, [buffer]);
|
|
} catch (error) { finish(error); }
|
|
});
|
|
}
|
|
}
|