Files

279 lines
18 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.
/* Original hover-comparison UI, MIT licensed. */
import { T } from "./i18n.js";
import {loadPreviewPair} from './preview_images.js';
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
};
export class ImageCompareView {
constructor(options) {
this.options = options;
this.root = element("div", "dd-inpaint dd-comparer");
this.originals = []; this.results = []; this.index = 0;
this.split = .5;
this.version = 0; this.ready = false;
this.dimensionsLabel = element("span", "dd-dimensions");
const toolbar = element("div", "dd-row dd-compare-toolbar");
toolbar.append(element("span", "dd-compare-label", T("Result")));
this.resultSelect = element("select"); this.resultSelect.setAttribute("aria-label", T("Choose result"));
this.resultSelect.onchange = () => { this.index = Number(this.resultSelect.value); this.options.onSelect?.(this.index); this.loadSelected(); };
toolbar.append(this.resultSelect, this.dimensionsLabel);
const spacer = element("span", "dd-spacer"); toolbar.append(spacer);
this.reloadButton = element("button", "", "↻"); this.reloadButton.type = "button";
this.reloadButton.title = T("Reload comparison images"); this.reloadButton.setAttribute("aria-label", this.reloadButton.title);
this.reloadButton.onclick = async event => { event.preventDefault(); event.stopPropagation(); await this.options.recover?.(); this.loadSelected(); };
toolbar.append(this.reloadButton);
this.downloadButton = element("button", "", "↓"); this.downloadButton.type = "button";
this.downloadButton.title = T("Download the latest generated image");
this.downloadButton.setAttribute("aria-label", this.downloadButton.title);
this.downloadButton.onclick = event => { event.preventDefault(); event.stopPropagation(); this.downloadLatest(); };
toolbar.append(this.downloadButton);
const fit = element("button", "", T("Fit")); fit.type = "button";
fit.onclick = event => { event.preventDefault(); event.stopPropagation(); this.schedule(); }; toolbar.append(fit);
this.stage = element("div", "dd-stage dd-compare-stage");
this.canvas = document.createElement("canvas"); this.canvas.tabIndex = 0;
this.canvas.setAttribute("aria-label", T("Result left, original right; hover to compare"));
this.canvas.oncontextmenu = event => event.preventDefault();
this.canvas.addEventListener("pointerenter", event => { this.hovered = true; this.move(event); this.schedule(); });
this.canvas.addEventListener("pointerleave", () => { this.hovered = false; this.schedule(); });
this.divider = element("div");
Object.assign(this.divider.style, { position: "absolute", background: "#ffffffb3", pointerEvents: "none" });
this.divider.hidden = true;
this.stage.append(this.canvas, this.divider);
this.progressBox=element("div","dd-preview-progress");this.progressBox.hidden=true;
this.progressBox.setAttribute("role","status");
this.progressText=element("span");this.progressBar=document.createElement("progress");this.progressBar.max=1;
this.progressBar.setAttribute("aria-label",T("Comparison image loading progress"));
this.progressBox.append(this.progressText,this.progressBar);this.stage.append(this.progressBox);
this.retryOnOnline=()=>{if(!this.ready && !this.loadingActive && this.results.length)this.loadSelected();};
window.addEventListener("online",this.retryOnOnline);
this.empty = element("div", "dd-empty", T("Connect an original image and a result, then run the workflow")); this.stage.append(this.empty);
const adoption = element("div", "dd-row dd-adoption");
adoption.append(element("span", "dd-compare-label", T("Adopt into")));
this.targetSelect = element("select"); this.targetSelect.setAttribute("aria-label", T("Target inline editor"));
this.targetSelect.onfocus = () => this.refreshTargets();
this.targetSelect.onchange = () => { this.manualTarget = true; this.updateButton(); };
adoption.append(this.targetSelect);
this.adoptButton = element("button", "dd-primary", T("Adopt this result")); this.adoptButton.type = "button";
this.adoptButton.onclick = () => this.adopt(); adoption.append(this.adoptButton);
this.message = element("div", "dd-status", T("Result left, original right; hover to compare"));
this.message.hidden = true; this.message.setAttribute("role", "alert");
this.root.append(this.stage, toolbar, adoption, this.message);
this.canvas.addEventListener("pointermove", event => this.move(event));
this.canvas.addEventListener("pointerdown", event => {
if (!this.ready || event.button !== 0) return;
event.preventDefault(); event.stopPropagation(); this.canvas.focus({ preventScroll: true });
this.move(event);
});
this.canvas.addEventListener("wheel", event => this.wheel(event), { passive: false });
this.canvas.addEventListener("keydown", event => {
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault(); event.stopPropagation();
this.split = Math.max(0, Math.min(1, this.split + (event.key === "ArrowRight" ? .02 : -.02))); this.schedule();
}
});
this.observer = new ResizeObserver(() => this.schedule()); this.observer.observe(this.stage);
this.refreshTargets(); this.updateButton();
}
status(text, error = false) { this.message.textContent = text; this.message.classList.toggle("dd-error", error); this.message.hidden = !error; }
refreshTargets() {
const selected = this.targetSelect.value;
const { editors = [], preferred = "" } = this.options.getTargets?.() || {};
this.targetSelect.replaceChildren();
const placeholder = element("option", "", T("Select an inline mask")); placeholder.value = ""; this.targetSelect.append(placeholder);
for (const editor of editors) {
const option = element("option", "", editor.label); option.value = String(editor.id); this.targetSelect.append(option);
}
const valid = id => editors.some(editor => String(editor.id) === String(id));
this.targetSelect.value = this.manualTarget && valid(selected) ? selected : valid(preferred) ? String(preferred) : editors.length === 1 ? String(editors[0].id) : "";
this.updateButton();
}
updateButton() {
this.adoptButton.disabled = !this.ready || !this.targetSelect.value || !!this.busy || this.pendingOutputs>0;
this.adoptButton.textContent = this.busy ? T("Adopting…") : T("Adopt this result");
this.downloadButton.disabled = !this.latestResult || !!this.downloading;
}
setLatestResult(result) { this.latestResult = result || null; this.updateButton(); }
async downloadLatest() {
if (!this.latestResult || this.downloading) return;
const result = this.latestResult;
this.downloading = true; this.updateButton();
const controller=this.downloadController=new AbortController();
try {
const blob = await this.options.fetchResult(result,{signal:controller.signal});
if (this.destroyed) return;
const url = URL.createObjectURL(blob), link = document.createElement("a");
link.href = url; link.download = "adachi-inpaint-result.png";
document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
this.status("");
} catch (error) { if (!this.destroyed) this.status(error.message, true); }
finally {if(this.downloadController===controller)this.downloadController=null;this.downloading = false; if (!this.destroyed) this.updateButton();}
}
async setData(data, index = this.index) {
this.originals = Array.isArray(data?.originals) ? data.originals : [];
this.results = Array.isArray(data?.results) ? data.results : [];
this.index = Math.max(0, Math.min(this.results.length - 1, Number(index) || 0));
this.resultSelect.replaceChildren();
this.results.forEach((_, i) => {
const option = element("option", "", `${i + 1} / ${this.results.length}`); option.value = String(i); this.resultSelect.append(option);
});
this.resultSelect.value = String(this.index);
this.resultSelect.disabled = this.results.length < 2;
this.refreshTargets();
await this.loadSelected();
}
loading(text, states) {
this.progressBox.hidden=false;this.progressText.textContent=text;
if(states) {
const loaded=states.reduce((n,s)=>n+s.loaded,0),total=states.every(s=>s.total>0)?states.reduce((n,s)=>n+s.total,0):0;
const size=n=>n>=1048576?`${(n/1048576).toFixed(1)} MiB`:`${Math.round(n/1024)} KiB`;
this.progressText.textContent+=total?` · ${Math.min(100,Math.round(loaded/total*100))}% · ${size(loaded)} / ${size(total)}`:` · ${size(loaded)}`;
if(total)this.progressBar.value=Math.min(1,loaded/total);else this.progressBar.removeAttribute("value");
} else this.progressBar.removeAttribute("value");
}
async loadSelected({background=false}={}) {
if(this.destroyed)return;
clearTimeout(this.retryTimer);this.loadController?.abort();
const version=++this.version,controller=this.loadController=new AbortController();
this.reloadButton.disabled=false;this.loadingActive=true;
this.ready=false;this.updateButton();this.schedule();
if(!this.originals.length || !this.results.length){this.loadingActive=false;this.progressBox.hidden=true;return;}
const repetitions=this.results.length/this.originals.length;
const original=this.originals[Math.min(this.originals.length-1,Math.floor(this.index/repetitions))];
const current=()=>version===this.version && !this.destroyed && !controller.signal.aborted;
const blobKey=JSON.stringify([original,this.results[this.index]]);
if(this.blobKey!==blobKey){this.blobKey=blobKey;this.pendingBlobs=[null,null];}
const blobs=this.pendingBlobs||[null,null];
try {
let images;
for(let attempt=0;attempt<3;attempt++) {
const states=blobs.map(blob=>({loaded:blob?.size||0,total:blob?.size||0,complete:!!blob}));
this.loading(attempt?T("Retrying comparison images")+` (${attempt+1}/3)`:T("Loading comparison images"),states);
try {
images=await loadPreviewPair([original,this.results[this.index]],{loadPreview:(...args)=>this.options.loadPreview(...args),cached:blobs,signal:controller.signal,
onProgress:(i,state)=>{states[i]=state;if(current())this.loading(T("Loading comparison images"),states);},
onDecoding:()=>{if(current() && states.every(s=>s.complete))this.loading(T("Decoding comparison images…"));}});
break;
} catch(error) {
if(!current())return;
if(attempt===2 || [404,410].includes(error.status))throw error;
await new Promise(resolve=>{const done=()=>{clearTimeout(timer);controller.signal.removeEventListener('abort',done);resolve();};const timer=setTimeout(done,1000*(attempt+1));controller.signal.addEventListener('abort',done,{once:true});});
}
}
if(!current()){for(const item of images||[])URL.revokeObjectURL(item.url);return;}
for(const url of this.previewUrls||[])URL.revokeObjectURL(url);
this.previewUrls=images.map(item=>item.url);this.before=images[0].image;this.after=images[1].image;
this.ready=true;this.pendingBlobs=null;this.retryCount=0;this.empty.hidden=true;this.progressBox.hidden=true;
this.dimensionsLabel.textContent=`${this.before.width} × ${this.before.height}`;
this.status("");this.updateButton();this.schedule();
} catch(error) {
if(!current())return;
this.progressBox.title=error.message;
if([404,410].includes(error.status)){this.progressBox.hidden=true;this.pendingBlobs=null;this.status(T("Result preview expired; run the workflow again"),true);return;}
this.status(T("Image loading failed; retrying automatically. You can also click reload."),true);
const seconds=Math.min(60,5*2**Math.min(this.retryCount||0,4));this.retryCount=(this.retryCount||0)+1;
this.loading(T("Waiting to retry comparison images")+` · ${seconds}s`);
this.retryTimer=setTimeout(()=>this.loadSelected({background:true}),seconds*1000);
} finally {if(version===this.version)this.loadingActive=false;}
}
async adopt() {
this.refreshTargets();
if (this.busy) return;
if (!this.ready || this.pendingOutputs>0) { this.status(T("The result preview is still loading"), true); return; }
if (!this.targetSelect.value) { this.status(T("Select an inline mask to adopt into"), true); return; }
const target = this.targetSelect.value, result = this.results[this.index];
this.busy = true; this.updateButton(); this.status(T("Setting the result as the new source image…"));
const controller=this.adoptController=new AbortController();
try {
await this.options.onAdopt(target, result,{signal:controller.signal});
if (!this.destroyed) this.status(T("Adopted: back in the inline mask, keep painting to inpaint again"));
} catch (error) { if (!this.destroyed) this.status(error.message, true); }
finally {if(this.adoptController===controller)this.adoptController=null;this.busy = false; if (!this.destroyed) this.updateButton();}
}
transform() {
const rect = this.canvas.getBoundingClientRect();
const scale = this.before ? Math.min(rect.width / this.before.width, rect.height / this.before.height) : 1;
return { width: rect.width, height: rect.height, scale, x: (rect.width - (this.before?.width || 0) * scale) / 2, y: (rect.height - (this.before?.height || 0) * scale) / 2 };
}
position(event) { const rect = this.canvas.getBoundingClientRect(), t = this.transform(); return { x: (event.clientX - rect.left - t.x) / t.scale, y: (event.clientY - rect.top - t.y) / t.scale }; }
wheel(event) {
event.preventDefault(); event.stopPropagation();
this.options.onGraphWheel?.(event);
}
move(event) {
if (!this.ready) return;
this.hovered = true;
this.split = Math.max(0, Math.min(1, this.position(event).x / this.before.width));
this.schedule();
}
schedule() { if (!this.frame && !this.destroyed) this.frame = requestAnimationFrame(() => { this.frame = 0; this.render(); }); }
render() {
const t = this.transform(), ratio = Math.min(devicePixelRatio || 1, 2);
if (!t.width || !t.height) return;
// Graph zoom changes the screen rect, not the amount of source detail.
// Never allocate a huge upscaled bitmap just to magnify existing pixels.
const rasterRatio = Math.min(ratio, this.ready ? 1 / t.scale : ratio,
8192 / t.width, 8192 / t.height, Math.sqrt(16 * 1024 * 1024 / (t.width * t.height)));
const width = Math.max(1, Math.round(t.width * rasterRatio)), height = Math.max(1, Math.round(t.height * rasterRatio));
if (this.canvas.width !== width || this.canvas.height !== height) { this.canvas.width = width; this.canvas.height = height; }
const ctx = this.canvas.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0);
this.divider.hidden = !this.ready || !this.hovered;
if (!this.ready) { ctx.clearRect(0, 0, width, height); this.lastPaint = null; return; }
const split = this.split * this.before.width;
const lineX = Math.round((t.x + split * t.scale) * rasterRatio) + .5;
if (this.hovered) {
const screenScale = t.width / this.canvas.clientWidth || 1;
Object.assign(this.divider.style, {
left: `${(t.x + split * t.scale) / screenScale}px`, top: `${t.y / screenScale}px`,
height: `${this.before.height * t.scale / screenScale}px`, width: `${1 / screenScale}px`,
});
}
const key = [this.version, width, height, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio].join(":");
const previous = this.lastPaint;
ctx.save();
// While hovering, pixels outside the old/new divider positions are
// unchanged. Clip the redraw to that strip rather than repainting all.
if (previous?.key === key && previous.hovered && this.hovered) {
const left = Math.max(0, Math.floor(Math.min(previous.lineX, lineX)) - 2);
const right = Math.min(width, Math.ceil(Math.max(previous.lineX, lineX)) + 2);
ctx.beginPath(); ctx.rect(left, 0, right - left, height); ctx.clip();
}
ctx.clearRect(0, 0, width, height);
ctx.setTransform(t.scale * rasterRatio, 0, 0, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio);
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
this.lastPaint = { key, hovered: this.hovered, lineX };
if (!this.hovered) { ctx.restore(); return; }
ctx.save(); ctx.beginPath(); ctx.rect(split, 0, this.before.width - split, this.before.height); ctx.clip();
ctx.drawImage(this.before, 0, 0); ctx.restore();
ctx.restore();
}
destroy() {
this.destroyed = true; ++this.version; clearTimeout(this.retryTimer);this.loadController?.abort();
this.downloadController?.abort();this.adoptController?.abort();
window.removeEventListener("online",this.retryOnOnline);for(const url of this.previewUrls||[])URL.revokeObjectURL(url);
cancelAnimationFrame(this.frame); this.observer.disconnect();
this.pendingBlobs=null;this.before = this.after = null; this.canvas.width = this.canvas.height = 0; this.root.remove();
}
}