271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
import { abortError, releaseImage } from "./loader.mjs";
|
||
|
||
const MAX_IMAGES = 256;
|
||
const MAX_PIXELS = 16_777_216;
|
||
const validBatch = data => data?.version === 1 && /^[a-f0-9]{32}$/.test(data.batch_id)
|
||
&& Number.isInteger(data.total) && data.total > 0 && data.total <= MAX_IMAGES;
|
||
const validItem = (item, total) => item && Number.isInteger(item.index) && item.index >= 0 && item.index < total
|
||
&& Number.isInteger(item.width) && item.width > 0 && Number.isInteger(item.height) && item.height > 0
|
||
&& item.width * item.height <= MAX_PIXELS;
|
||
|
||
export function readResults(results) {
|
||
if (!Array.isArray(results) || !results.length) throw new Error("预览结果无效");
|
||
const entries = [];
|
||
const batches = new Set();
|
||
for (const batch of results) {
|
||
if (!validBatch(batch) || batches.has(batch.batch_id) || batch.status !== "complete" || !Array.isArray(batch.items)) throw new Error("预览结果无效");
|
||
batches.add(batch.batch_id);
|
||
const items = new Map();
|
||
for (const item of batch.items) {
|
||
if (!validItem(item, batch.total) || items.has(item.index)) throw new Error("预览图片索引或尺寸无效");
|
||
items.set(item.index, item);
|
||
}
|
||
if (items.size !== batch.total) throw new Error("预览结果不完整,请重新运行节点");
|
||
for (let index = 0; index < batch.total; index++) entries.push({ ...items.get(index), batchId: batch.batch_id });
|
||
}
|
||
if (entries.length > MAX_IMAGES) throw new Error("一次最多预览 256 张图片,请缩小图片批次");
|
||
return { key: [...batches].join(":"), entries };
|
||
}
|
||
|
||
// ComfyUI owns the grid, selection, next/previous controls and keyboard handling.
|
||
export class NativePreview {
|
||
constructor(node, loader, updateStatus) {
|
||
this.node = node;
|
||
this.loader = loader;
|
||
this.updateStatus = updateStatus;
|
||
this.items = new Map();
|
||
this.thumbnails = new Map();
|
||
this.pending = new Map();
|
||
this.errors = new Map();
|
||
this.downloads = new Map();
|
||
this.visible = false;
|
||
this.disposed = false;
|
||
this.selected = null;
|
||
this.generation = 0;
|
||
loader.acquire();
|
||
this.placeholder = new Image();
|
||
this.placeholder.src = "data:image/svg+xml," + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="384" height="384"><rect width="384" height="384" fill="#282828"/><path d="M172 192h40" stroke="#666" stroke-width="3"/></svg>');
|
||
this.placeholderReady = this.placeholder.decode().then(() => {
|
||
if (!this.disposed) { this.ready = true; this.render(); }
|
||
}).catch(() => this.fail("无法创建预览占位图,请刷新页面"));
|
||
// Observe only this node's selection; no global image/store replacement.
|
||
this.indexDescriptor = Object.getOwnPropertyDescriptor(node, "imageIndex");
|
||
Object.defineProperty(node, "imageIndex", {
|
||
configurable: true, enumerable: true,
|
||
get: () => this.selected,
|
||
set: index => this.select(index),
|
||
});
|
||
}
|
||
|
||
status() {
|
||
if (this.disposed) return;
|
||
const error = this.failure || this.originalError || this.errors.values().next().value;
|
||
let text = error;
|
||
if (!text) {
|
||
text = !this.total ? "等待图片" : this.complete ? `${this.total} 张图片` : `已生成 ${this.items.size} / ${this.total}`;
|
||
if (this.originalController) text += " · 正在加载原图";
|
||
else if (this.original) text += ` · ${this.original.record.image.naturalWidth} × ${this.original.record.image.naturalHeight}`;
|
||
}
|
||
this.updateStatus(text, Boolean(error));
|
||
}
|
||
|
||
fail(message) { this.failure = message; this.status(); }
|
||
|
||
reset(key, total) {
|
||
this.generation++;
|
||
this.clearImages();
|
||
this.key = key;
|
||
this.total = total;
|
||
this.items.clear();
|
||
this.errors.clear();
|
||
this.failure = null;
|
||
this.complete = false;
|
||
this.selected = null;
|
||
this.render();
|
||
this.status();
|
||
}
|
||
|
||
begin(data) {
|
||
if (this.disposed) return false;
|
||
if (!validBatch(data)) { this.fail("预览描述信息无效"); return false; }
|
||
if (this.key !== data.batch_id) this.reset(data.batch_id, data.total);
|
||
else if (this.total !== data.total) { this.fail("预览图片数量不一致"); return false; }
|
||
return true;
|
||
}
|
||
|
||
receive(data) {
|
||
if (this.disposed) return;
|
||
if (data.event === "start") { this.begin(data); return; }
|
||
if (data.batch_id !== this.key) return;
|
||
if (data.event === "image") {
|
||
if (!validItem(data.item, this.total) || this.items.has(data.item.index)) { this.fail("预览图片索引或尺寸无效"); return; }
|
||
this.items.set(data.item.index, { ...data.item, batchId: data.batch_id });
|
||
this.reconcile();
|
||
} else if (data.event === "failed") {
|
||
this.fail(`第 ${Number(data.index) + 1} 张处理失败:${String(data.error || "执行中断")}`);
|
||
} else if (data.event === "complete") {
|
||
this.complete = true;
|
||
if (this.items.size !== this.total) this.fail("预览结果不完整,请重新运行节点");
|
||
}
|
||
this.status();
|
||
}
|
||
|
||
showResults(results) {
|
||
if (this.disposed) return;
|
||
try {
|
||
const { key, entries } = readResults(results);
|
||
if (this.key !== key) this.reset(key, entries.length);
|
||
else if (this.total !== entries.length) throw new Error("预览图片数量不一致");
|
||
entries.forEach((entry, index) => this.items.set(index, entry));
|
||
this.complete = true;
|
||
this.reconcile();
|
||
this.status();
|
||
} catch (error) { this.fail(error.message); }
|
||
}
|
||
|
||
setVisible(visible) {
|
||
if (this.disposed || visible === this.visible) return;
|
||
this.visible = visible;
|
||
if (visible) { this.render(); this.reconcile(); }
|
||
else this.clearImages();
|
||
}
|
||
|
||
select(index) {
|
||
if (this.disposed || this.selected === index) return;
|
||
if (index != null && (!Number.isInteger(index) || index < 0 || index >= (this.total || 0))) return;
|
||
this.selected = index ?? null;
|
||
this.clearOriginal();
|
||
for (const [pendingIndex, controller] of this.pending) {
|
||
if (index != null && pendingIndex !== index) { controller.abort(); this.pending.delete(pendingIndex); }
|
||
}
|
||
this.render();
|
||
this.reconcile();
|
||
}
|
||
|
||
reconcile() {
|
||
if (this.disposed || !this.visible) return;
|
||
for (const [index, item] of this.items) {
|
||
if (this.selected != null && this.selected !== index) continue;
|
||
if (!this.thumbnails.has(index) && !this.pending.has(index) && !this.errors.has(index)) this.loadThumbnail(index, item);
|
||
}
|
||
if (this.selected != null && this.items.has(this.selected) && !this.originalError) {
|
||
this.ensureOriginal(this.selected).catch(error => {
|
||
if (error.name !== "AbortError") { this.originalError = error.message; this.status(); }
|
||
});
|
||
}
|
||
}
|
||
|
||
async loadThumbnail(index, item) {
|
||
const controller = new AbortController();
|
||
this.pending.set(index, controller);
|
||
try {
|
||
const record = await this.loader.load(item.batchId, item.index, "thumbnail", controller.signal);
|
||
if (controller.signal.aborted || this.disposed) { releaseImage(record); return; }
|
||
if (Math.max(record.image.naturalWidth, record.image.naturalHeight) > 384) { releaseImage(record); throw new Error("缩略图尺寸无效"); }
|
||
this.thumbnails.set(index, record);
|
||
this.render();
|
||
} catch (error) {
|
||
if (error.name !== "AbortError") { this.errors.set(index, `第 ${index + 1} 张:${error.message}`); this.status(); }
|
||
} finally {
|
||
if (this.pending.get(index) === controller) this.pending.delete(index);
|
||
}
|
||
}
|
||
|
||
ensureOriginal(index) {
|
||
if (this.disposed) return Promise.reject(abortError());
|
||
if (this.original?.index === index) return Promise.resolve(this.original.record);
|
||
if (this.originalController && this.originalIndex === index) return this.originalPromise;
|
||
const item = this.items.get(index);
|
||
if (!item) return Promise.reject(new Error("这张图片尚未生成"));
|
||
this.clearOriginal();
|
||
const controller = new AbortController();
|
||
this.originalController = controller;
|
||
this.originalIndex = index;
|
||
this.status();
|
||
this.originalPromise = this.loader.load(item.batchId, item.index, "original", controller.signal).then(record => {
|
||
if (controller.signal.aborted || this.disposed) { releaseImage(record); throw abortError(); }
|
||
if (record.image.naturalWidth !== item.width || record.image.naturalHeight !== item.height) { releaseImage(record); throw new Error("原图尺寸不匹配"); }
|
||
this.original = { index, record };
|
||
this.render();
|
||
return record;
|
||
}).finally(() => {
|
||
if (this.originalController === controller) {
|
||
this.originalController = null;
|
||
this.originalPromise = null;
|
||
this.status();
|
||
}
|
||
});
|
||
return this.originalPromise;
|
||
}
|
||
|
||
render() {
|
||
if (this.disposed || !this.visible || !this.ready || !this.total) return;
|
||
this.node.imgs = Array.from({ length: this.total }, (_, index) =>
|
||
this.original?.index === index && this.selected === index ? this.original.record.image
|
||
: this.thumbnails.get(index)?.image || this.placeholder);
|
||
this.node.graph?.setDirtyCanvas(true, true);
|
||
}
|
||
|
||
clearOriginal() {
|
||
this.originalController?.abort();
|
||
this.originalController = null;
|
||
this.originalPromise = null;
|
||
this.originalError = null;
|
||
const old = this.original;
|
||
this.original = null;
|
||
this.render();
|
||
releaseImage(old?.record);
|
||
}
|
||
|
||
clearImages() {
|
||
this.node.imgs = [];
|
||
for (const controller of this.pending.values()) controller.abort();
|
||
this.pending.clear();
|
||
this.clearOriginal();
|
||
for (const record of this.thumbnails.values()) releaseImage(record);
|
||
this.thumbnails.clear();
|
||
this.node.imgs = [];
|
||
}
|
||
|
||
retry() { this.errors.clear(); this.originalError = null; this.reconcile(); this.status(); }
|
||
|
||
async createDownload(index) {
|
||
const generation = this.generation;
|
||
const record = await this.ensureOriginal(index);
|
||
if (this.disposed || generation !== this.generation) throw abortError();
|
||
const item = this.items.get(index);
|
||
const filename = `ComfyUI_${item.batchId.slice(0, 8)}_${String(item.index + 1).padStart(3, "0")}.png`;
|
||
const url = URL.createObjectURL(record.blob);
|
||
const timer = setTimeout(() => { URL.revokeObjectURL(url); this.downloads.delete(url); }, 60_000);
|
||
this.downloads.set(url, timer);
|
||
return { url, filename };
|
||
}
|
||
|
||
async save(index) {
|
||
try {
|
||
const { url, filename } = await this.createDownload(index);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = filename;
|
||
document.body.append(anchor);
|
||
try { anchor.click(); } finally { anchor.remove(); }
|
||
} catch (error) {
|
||
if (error.name !== "AbortError") { this.originalError = error.message; this.status(); }
|
||
}
|
||
}
|
||
|
||
dispose() {
|
||
if (this.disposed) return;
|
||
this.disposed = true;
|
||
this.generation++;
|
||
this.clearImages();
|
||
for (const [url, timer] of this.downloads) { clearTimeout(timer); URL.revokeObjectURL(url); }
|
||
this.downloads.clear();
|
||
this.placeholder.removeAttribute("src");
|
||
this.items.clear();
|
||
if (this.indexDescriptor) Object.defineProperty(this.node, "imageIndex", this.indexDescriptor);
|
||
else { delete this.node.imageIndex; this.node.imageIndex = null; }
|
||
this.loader.release();
|
||
}
|
||
}
|