Files

271 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}