Files

522 lines
23 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 { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
function imageDataToUrl(data) {
return api.apiURL(
`/view?filename=${encodeURIComponent(data.filename)}&type=${data.type}&subfolder=${data.subfolder}` + app.getPreviewFormatParam()
);
}
app.registerExtension({
name: "Matoo.CompareFrames",
async nodeCreated(node) {
if (node.comfyClass !== "CompareFrames") return;
// ── State ─────────────────────────────────────────────────────────────
let framesA = [];
let framesB = [];
let fps = 12;
let speed = 1.0;
let loop = true;
let isPlaying = false;
let playFrame = 0;
let rafId = null;
let lastTime = null;
let accumulated = 0;
let layoutMode = "slider"; // "slider" | "sidebyside" | "overlay"
let overlayAlpha = 0.5;
let sliderRatio = 0.5;
let isDragging = false;
// ── Helpers ───────────────────────────────────────────────────────────
function mkBtn(label, title) {
const b = document.createElement("button");
b.textContent = label;
b.title = title;
b.style.cssText = [
"background:#2a2a2a;color:#ccc;border:1px solid #444;border-radius:4px;",
"padding:2px 8px;font-size:11px;cursor:pointer;white-space:nowrap;",
"transition:background .15s,color .15s,border-color .15s;"
].join("");
b.addEventListener("mouseenter", () => { if (!b._active) b.style.background = "#3a3a3a"; });
b.addEventListener("mouseleave", () => { if (!b._active) b.style.background = "#2a2a2a"; });
return b;
}
function setActive(b, on) {
b._active = on;
b.style.background = on ? "#00b894" : "#2a2a2a";
b.style.color = on ? "#fff" : "#ccc";
b.style.borderColor = on ? "#00b894" : "#444";
}
function mkSep() {
const s = document.createElement("span");
s.style.cssText = "width:1px;height:16px;background:#333;margin:0 2px;flex-shrink:0;";
return s;
}
// ── Build UI elements (all created up-front) ──────────────────────────
// Toolbar
const toolbar = document.createElement("div");
toolbar.style.cssText = [
"display:flex;align-items:center;gap:5px;padding:4px 6px;",
"background:#1a1a1a;border-bottom:1px solid #333;flex-wrap:wrap;",
"user-select:none;flex-shrink:0;"
].join("");
// Playback
const btnPlay = mkBtn("▶ Play", "Play / Pause");
const btnPrev = mkBtn("◀", "Previous frame");
const btnNext = mkBtn("▶", "Next frame");
const btnLoop = mkBtn("↺ Loop", "Toggle loop");
setActive(btnLoop, true);
const speedSel = document.createElement("select");
speedSel.style.cssText = "background:#2a2a2a;color:#ccc;border:1px solid #444;border-radius:4px;font-size:11px;padding:1px 4px;cursor:pointer;";
[["0.25×","0.25"],["0.5×","0.5"],["1×","1"],["1.5×","1.5"],["2×","2"],["4×","4"]].forEach(([txt,val]) => {
const o = document.createElement("option");
o.textContent = txt; o.value = val;
if (val === "1") o.selected = true;
speedSel.appendChild(o);
});
// Layout buttons
const btnSlider = mkBtn("⧪ Slider", "Slider compare mode");
const btnSide = mkBtn("⬛⬛ Side by Side", "Side by side mode");
const btnOverlay = mkBtn("◈ Overlay", "Overlay transparency");
setActive(btnSlider, true);
const infoLabel = document.createElement("span");
infoLabel.style.cssText = "font-size:10px;color:#666;margin-left:auto;white-space:nowrap;";
infoLabel.textContent = "No frames loaded";
toolbar.append(btnPlay, btnPrev, btnNext, btnLoop, speedSel, mkSep(), btnSlider, btnSide, btnOverlay, infoLabel);
// Alpha row (hidden by default)
const alphaRow = document.createElement("div");
alphaRow.style.cssText = [
"display:none;align-items:center;gap:6px;padding:3px 6px;",
"background:#181818;border-bottom:1px solid #333;",
"user-select:none;flex-shrink:0;"
].join("");
const alphaLabelA = document.createElement("span");
alphaLabelA.style.cssText = "font-size:10px;color:#888;";
alphaLabelA.textContent = "A";
const alphaSlider = document.createElement("input");
alphaSlider.type = "range";
alphaSlider.min = 0; alphaSlider.max = 100; alphaSlider.value = 50;
alphaSlider.style.cssText = "flex:1;accent-color:#00b894;cursor:pointer;height:14px;";
const alphaLabelB = document.createElement("span");
alphaLabelB.style.cssText = "font-size:10px;color:#888;";
alphaLabelB.textContent = "B";
const alphaValLabel = document.createElement("span");
alphaValLabel.style.cssText = "font-size:10px;color:#00b894;min-width:32px;text-align:right;white-space:nowrap;";
alphaValLabel.textContent = "50%";
alphaRow.append(alphaLabelA, alphaSlider, alphaLabelB, alphaValLabel);
// Scrub bar
const scrubBar = document.createElement("div");
scrubBar.style.cssText = [
"display:flex;align-items:center;gap:6px;padding:3px 6px;",
"background:#181818;border-bottom:1px solid #333;flex-shrink:0;"
].join("");
const scrubLabel = document.createElement("span");
scrubLabel.style.cssText = "font-size:10px;color:#666;min-width:48px;";
scrubLabel.textContent = "0 / 0";
const scrubber = document.createElement("input");
scrubber.type = "range";
scrubber.min = 0; scrubber.max = 0; scrubber.value = 0;
scrubber.style.cssText = "flex:1;accent-color:#00b894;cursor:pointer;height:14px;";
scrubBar.append(scrubLabel, scrubber);
// Canvas — explicit pixel height; we'll resize it via ResizeObserver
const canvas = document.createElement("canvas");
canvas.style.cssText = "width:100%;display:block;touch-action:none;flex:1;min-height:0;";
const ctx = canvas.getContext("2d");
// Root wrapper
const root = document.createElement("div");
root.style.cssText = "width:100%;display:flex;flex-direction:column;background:#111;font-family:sans-serif;overflow:hidden;";
root.append(toolbar, alphaRow, scrubBar, canvas);
// Register widget
node.addDOMWidget("framepreview", "preview", root, {
serialize: false,
hideOnZoom: false,
});
// Keep canvas pixel dimensions in sync with its CSS layout size
const ro = new ResizeObserver(() => requestAnimationFrame(drawFrame));
ro.observe(canvas);
// ── Event wiring ──────────────────────────────────────────────────────
btnPlay.addEventListener("click", () => {
isPlaying = !isPlaying;
btnPlay.textContent = isPlaying ? "⏸ Pause" : "▶ Play";
setActive(btnPlay, isPlaying);
if (isPlaying) {
playFrame = getWidgetValues().compFrame;
lastTime = null;
accumulated = 0;
rafId = requestAnimationFrame(animLoop);
} else {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
});
btnPrev.addEventListener("click", () => {
const total = Math.max(framesA.length, framesB.length);
if (!total) return;
playFrame = ((isPlaying ? playFrame : getWidgetValues().compFrame) - 1 + total) % total;
setCompFrameWidget(playFrame);
updateScrubber();
drawFrame();
});
btnNext.addEventListener("click", () => {
const total = Math.max(framesA.length, framesB.length);
if (!total) return;
playFrame = ((isPlaying ? playFrame : getWidgetValues().compFrame) + 1) % total;
setCompFrameWidget(playFrame);
updateScrubber();
drawFrame();
});
btnLoop.addEventListener("click", () => {
loop = !loop;
setActive(btnLoop, loop);
});
speedSel.addEventListener("change", () => { speed = parseFloat(speedSel.value); });
// Layout mode
function setLayout(mode) {
layoutMode = mode;
setActive(btnSlider, mode === "slider");
setActive(btnSide, mode === "sidebyside");
setActive(btnOverlay, mode === "overlay");
alphaRow.style.display = mode === "overlay" ? "flex" : "none";
canvas.style.cursor = "default";
drawFrame();
}
btnSlider .addEventListener("click", () => setLayout("slider"));
btnSide .addEventListener("click", () => setLayout("sidebyside"));
btnOverlay.addEventListener("click", () => setLayout("overlay"));
alphaSlider.addEventListener("input", () => {
overlayAlpha = parseInt(alphaSlider.value, 10) / 100;
alphaValLabel.textContent = `${alphaSlider.value}%`;
drawFrame();
});
scrubber.addEventListener("input", () => {
playFrame = parseInt(scrubber.value, 10);
setCompFrameWidget(playFrame);
updateScrubberLabel();
drawFrame();
});
// ── Widget helpers ────────────────────────────────────────────────────
function getWidgetValues() {
let compFrame = 0, skipA = 0, skipB = 0;
if (node.widgets) {
const wC = node.widgets.find(w => w.name === "compare_frame");
const wA = node.widgets.find(w => w.name === "skip_a");
const wB = node.widgets.find(w => w.name === "skip_b");
if (wC) compFrame = wC.value || 0;
if (wA) skipA = wA.value || 0;
if (wB) skipB = wB.value || 0;
}
return { compFrame, skipA, skipB };
}
function setCompFrameWidget(val) {
if (!node.widgets) return;
const w = node.widgets.find(w => w.name === "compare_frame");
if (w) w.value = val;
}
function updateScrubber() {
const total = Math.max(framesA.length, framesB.length);
scrubber.max = Math.max(0, total - 1);
scrubber.value = isPlaying ? playFrame : getWidgetValues().compFrame;
updateScrubberLabel();
}
function updateScrubberLabel() {
const total = Math.max(framesA.length, framesB.length);
scrubLabel.textContent = `${scrubber.value} / ${Math.max(0, total - 1)}`;
}
const hookWidgets = () => {
if (!node.widgets) return;
for (const w of node.widgets) {
if (["compare_frame","skip_a","skip_b"].includes(w.name) && !w._cf_hooked) {
const orig = w.callback;
w.callback = function () {
if (orig) orig.apply(this, arguments);
updateScrubber();
requestAnimationFrame(drawFrame);
};
w._cf_hooked = true;
}
}
};
hookWidgets();
setTimeout(hookWidgets, 200);
// ── Draw ──────────────────────────────────────────────────────────────
function getImg(arr, idx) {
if (!arr || !arr.length) return null;
const img = arr[Math.min(idx, arr.length - 1)];
return (img && img.complete && img.naturalWidth > 0) ? img : null;
}
function fitInRegion(img, rx, ry, rw, rh, alpha) {
if (!img) return;
const savedAlpha = ctx.globalAlpha;
if (alpha !== undefined) ctx.globalAlpha = alpha;
ctx.save();
ctx.beginPath(); ctx.rect(rx, ry, rw, rh); ctx.clip();
const r = img.naturalWidth / img.naturalHeight;
let dw, dh, dx, dy;
if (r > rw / rh) { dw = rw; dh = rw / r; dx = rx; dy = ry + (rh - dh) / 2; }
else { dh = rh; dw = rh * r; dx = rx + (rw - dw) / 2; dy = ry; }
ctx.drawImage(img, dx, dy, dw, dh);
ctx.restore();
ctx.globalAlpha = savedAlpha;
}
function fitFull(img, clipX, clipW, w, h, alpha) {
// Fit image to full canvas size, clip to a column — used for slider wipe
if (!img) return;
const savedAlpha = ctx.globalAlpha;
if (alpha !== undefined) ctx.globalAlpha = alpha;
ctx.save();
ctx.beginPath(); ctx.rect(clipX, 0, clipW, h); ctx.clip();
const r = img.naturalWidth / img.naturalHeight;
let dw, dh, dx, dy;
if (r > w / h) { dw = w; dh = w / r; dx = 0; dy = (h - dh) / 2; }
else { dh = h; dw = h * r; dx = (w - dw) / 2; dy = 0; }
ctx.drawImage(img, dx, dy, dw, dh);
ctx.restore();
ctx.globalAlpha = savedAlpha;
}
function drawFrame() {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
canvas.width = Math.round(rect.width);
canvas.height = Math.round(rect.height);
const w = canvas.width, h = canvas.height;
ctx.fillStyle = "#111";
ctx.fillRect(0, 0, w, h);
const { compFrame, skipA, skipB } = getWidgetValues();
const base = isPlaying ? playFrame : compFrame;
const targetA = Math.max(0, base + skipA);
const targetB = Math.max(0, base + skipB);
const imgA = getImg(framesA, targetA);
const imgB = getImg(framesB, targetB);
// Nothing loaded yet
if (!imgA && !imgB) {
ctx.fillStyle = "#444"; ctx.font = "13px sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText("Connect images_a and/or images_b, then run the node", w / 2, h / 2);
return;
}
// Single sequence — show full width
if (!(framesA.length > 0 && framesB.length > 0)) {
const img = imgA || imgB;
const label = imgA ? `A: ${targetA}` : `B: ${targetB}`;
fitInRegion(img, 0, 0, w, h);
drawBanner(w);
ctx.fillStyle = "#fff"; ctx.textAlign = "left"; ctx.fillText(label, 8, 12);
ctx.fillStyle = "#00b894"; ctx.textAlign = "right"; ctx.fillText(`Frame ${base}`, w - 8, 12);
return;
}
// Two sequences — use layout mode
if (layoutMode === "sidebyside") {
const half = Math.floor(w / 2);
fitInRegion(imgA, 0, 0, half, h);
fitInRegion(imgB, half, 0, half, h);
ctx.strokeStyle = "#00b894"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(half, 0); ctx.lineTo(half, h); ctx.stroke();
drawBanner(w);
ctx.fillStyle = "#fff"; ctx.textAlign = "center";
ctx.fillText(`A: ${targetA}`, w * 0.25, 12);
ctx.fillStyle = "#00b894";
ctx.fillText(`Frame ${base}`, w * 0.5, 12);
ctx.fillStyle = "#fff";
ctx.fillText(`B: ${targetB}`, w * 0.75, 12);
} else if (layoutMode === "overlay") {
fitInRegion(imgA, 0, 0, w, h, 1);
if (imgB) fitInRegion(imgB, 0, 0, w, h, overlayAlpha);
drawBanner(w);
ctx.fillStyle = "#fff"; ctx.textAlign = "left";
ctx.fillText(`A: ${targetA}`, 8, 12);
ctx.fillStyle = "#00b894"; ctx.textAlign = "center";
ctx.fillText(`Frame ${base} • B ${Math.round(overlayAlpha * 100)}%`, w / 2, 12);
ctx.fillStyle = "#fff"; ctx.textAlign = "right";
ctx.fillText(`B: ${targetB}`, w - 8, 12);
} else {
// Slider
const sx = w * sliderRatio;
fitFull(imgA, 0, sx, w, h);
fitFull(imgB, sx, w - sx, w, h);
ctx.strokeStyle = "#00b894"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sx, 24); ctx.lineTo(sx, h); ctx.stroke();
ctx.fillStyle = "#fff"; ctx.shadowColor = "rgba(0,0,0,.5)"; ctx.shadowBlur = 4;
ctx.beginPath(); ctx.arc(sx, h / 2, 12, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0; ctx.strokeStyle = "#222"; ctx.lineWidth = 1.5; ctx.stroke();
ctx.fillStyle = "#222"; ctx.font = "9px Arial"; ctx.textAlign = "center";
ctx.fillText("◀▶", sx, h / 2);
drawBanner(w);
ctx.fillStyle = "#fff"; ctx.textAlign = "left"; ctx.fillText(`A: ${targetA}`, 8, 12);
ctx.fillStyle = "#00b894"; ctx.textAlign = "center"; ctx.fillText(`Frame ${base}`, w / 2, 12);
ctx.fillStyle = "#fff"; ctx.textAlign = "right"; ctx.fillText(`B: ${targetB}`, w - 8, 12);
}
}
function drawBanner(w) {
ctx.fillStyle = "rgba(0,0,0,.65)";
ctx.fillRect(0, 0, w, 24);
ctx.font = "12px sans-serif"; ctx.textBaseline = "middle";
}
// ── Animation loop ────────────────────────────────────────────────────
function animLoop(ts) {
if (!isPlaying) return;
if (lastTime === null) lastTime = ts;
accumulated += ts - lastTime;
lastTime = ts;
const dur = 1000 / (fps * speed);
const total = Math.max(framesA.length, framesB.length);
while (accumulated >= dur && total > 0) {
accumulated -= dur;
playFrame++;
if (playFrame >= total) {
if (loop) { playFrame = 0; }
else {
playFrame = total - 1;
isPlaying = false;
btnPlay.textContent = "▶ Play";
setActive(btnPlay, false);
setCompFrameWidget(playFrame);
updateScrubber();
drawFrame();
return;
}
}
scrubber.value = playFrame;
updateScrubberLabel();
setCompFrameWidget(playFrame);
}
drawFrame();
rafId = requestAnimationFrame(animLoop);
}
// ── Slider pointer events ─────────────────────────────────────────────
canvas.addEventListener("pointerdown", (e) => {
if (layoutMode !== "slider") return;
const rect = canvas.getBoundingClientRect();
if (Math.abs((e.clientX - rect.left) - rect.width * sliderRatio) < 20) {
isDragging = true;
canvas.setPointerCapture(e.pointerId);
e.preventDefault(); e.stopPropagation();
}
});
canvas.addEventListener("pointermove", (e) => {
if (layoutMode === "slider") {
const rect = canvas.getBoundingClientRect();
const near = Math.abs((e.clientX - rect.left) - rect.width * sliderRatio) < 20;
canvas.style.cursor = near ? "col-resize" : "default";
} else {
canvas.style.cursor = "default";
}
if (!isDragging) return;
const rect = canvas.getBoundingClientRect();
sliderRatio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
drawFrame();
e.preventDefault(); e.stopPropagation();
});
const stopDrag = (e) => {
if (!isDragging) return;
isDragging = false;
canvas.releasePointerCapture(e.pointerId);
e.preventDefault(); e.stopPropagation();
};
canvas.addEventListener("pointerup", stopDrag);
canvas.addEventListener("pointercancel", stopDrag);
const origOnResize = node.onResize;
node.onResize = function () {
if (origOnResize) origOnResize.apply(this, arguments);
requestAnimationFrame(drawFrame);
};
// ── Load on execution ─────────────────────────────────────────────────
node.onExecuted = async function (message) {
isPlaying = false;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
btnPlay.textContent = "▶ Play"; setActive(btnPlay, false);
if (message.fps != null)
fps = Array.isArray(message.fps) ? message.fps[0] : message.fps;
framesA = []; framesB = [];
playFrame = getWidgetValues().compFrame;
const load = (list, target) => (list || []).map(d => new Promise(res => {
const img = new Image();
img.onload = img.onerror = res;
img.src = imageDataToUrl(d);
target.push(img);
}));
await Promise.all([...load(message.a_images, framesA), ...load(message.b_images, framesB)]);
const total = Math.max(framesA.length, framesB.length);
scrubber.max = Math.max(0, total - 1);
scrubber.value = playFrame;
updateScrubberLabel();
const parts = [];
if (framesA.length) parts.push(`A: ${framesA.length}`);
if (framesB.length) parts.push(`B: ${framesB.length}`);
infoLabel.textContent = parts.join(" | ") + ` frames • ${fps} fps`;
requestAnimationFrame(drawFrame);
};
// Initial draw attempt after layout settles
setTimeout(() => requestAnimationFrame(drawFrame), 150);
},
});