dzmdd's picture
Upload folder using huggingface_hub
e488ce7 verified
Raw History Blame Contribute Delete
49.5 kB
// MAI Video Compare — browser-native synchronized viewer for the MAIVideoCompare
// node. The node writes small preview files; everything here is <video>
// elements: no tensors, no VAE, no VRAM. Ported from the flipbook's player.js
// (one live set of videos, buffered start-together, audio bound to one source).
//
// The house review page is the reference implementation for the timeline, the
// gear settings and the precise export; this file is that player fitted to a
// node widget. State the page keeps in localStorage keyed by document title is
// kept here keyed by node id: the DOM widget is serialize:false, and adding a
// real serialized widget would shift every older workflow's widget order.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
console.log("[MAIVideoCompare] widget build clock-3");
const viewURL = (f) => api.apiURL(`/view?filename=${encodeURIComponent(f.filename)}` +
`&type=${f.type}&subfolder=${encodeURIComponent(f.subfolder)}&t=${Date.now()}`);
// The two muxers are vendored next to this file (web/vendor, MIT, see its
// README) and are UMD globals, not ES modules: inject them as classic scripts
// from this extension's own path. Precise export degrades to MediaRecorder if
// either the injection or WebCodecs is unavailable.
let MUXERS = null;
function loadMuxers() {
if (MUXERS) return MUXERS;
const base = new URL("./vendor/", import.meta.url);
MUXERS = Promise.all(["mp4-muxer.min.js", "webm-muxer.min.js"].map((f) => new Promise((res) => {
const s = document.createElement("script");
s.src = new URL(f, base).href;
s.onload = () => res(true);
s.onerror = () => res(false);
document.head.appendChild(s);
})));
return MUXERS;
}
function el(tag, style, parent, text) {
const e = document.createElement(tag);
Object.assign(e.style, style || {});
if (text !== undefined) e.textContent = text;
parent?.appendChild(e);
return e;
}
function whole(v) {
try { const b = v.buffered; return v.duration > 0 && b.length > 0 && b.end(b.length - 1) >= v.duration - 0.25; }
catch (e) { return false; }
}
class CompareWidget {
constructor(node) {
this.node = node;
this.items = []; // manifest items
this.vids = []; // {item, video, pane, star}
this.mode = "side"; // side | flip | wipe | grid
this.pair = [0, 1]; // indexes into this.vids for pair modes
this.audio = null; // index whose audio is on (locked); hover overrides
this.hover = null;
this.showB = false; // flip
this.flicker = null; // interval
this.playing = false;
this.spans = []; // regenerated-window frame spans from the manifest
this.curves = null; // {name: [per-frame floats]} from the manifest
this.loopA = null; // loop brackets, in seconds; always present
this.loopB = null;
this.wf = null; // {L, R} peak buckets of the A-side audio
this.waveGen = 0;
this.lastT = 0;
this.flashUntil = 0;
this.flashColor = "#3fa66a";
this.set = this.loadSet();
this.build();
}
// ---------- per-node settings (localStorage keyed by node id) ----------
setKey() { return "mai.compare.set." + (this.node?.id ?? "n"); }
loadSet() {
const d = { fc: false, blip: true, markblip: true, spanA: null, spanB: null, marks: "" };
try { return Object.assign(d, JSON.parse(localStorage.getItem(this.setKey()) || "{}")); }
catch (e) { return d; }
}
saveSet() { try { localStorage.setItem(this.setKey(), JSON.stringify(this.set)); } catch (e) {} }
bands() {
if (this.set.spanA != null && this.set.spanB != null) return [[this.set.spanA, this.set.spanB]];
return this.spans || [];
}
markList() {
return String(this.set.marks || "").split(",").map((x) => parseFloat(x)).filter((x) => isFinite(x));
}
build() {
// The root fills the box the node gives the widget; the stage takes what
// is left after the two bars and never grows past it (videos fit inside).
const root = el("div", { fontFamily: "system-ui, sans-serif", fontSize: "12px", color: "#ddd",
background: "#1b1b1b", padding: "6px", borderRadius: "6px", userSelect: "none",
boxSizing: "border-box", width: "100%", height: "100%", minHeight: "360px",
display: "flex", flexDirection: "column", overflow: "hidden", position: "relative" });
this.bar = el("div", { display: "flex", gap: "6px", flexWrap: "wrap", alignItems: "center", marginBottom: "6px", flex: "0 0 auto" }, root);
this.setpanel = el("div", { display: "none", background: "#222", border: "1px solid #444",
borderRadius: "6px", padding: "6px", marginBottom: "6px", flex: "0 0 auto", lineHeight: "1.8" }, root);
this.stage = el("div", { position: "relative", width: "100%", background: "#000", flex: "1 1 auto", minHeight: "0", overflow: "hidden" }, root);
this.tlrow = el("div", { display: "flex", gap: "6px", alignItems: "center", marginTop: "6px", flex: "0 0 auto" }, root);
this.crow = el("div", { display: "flex", gap: "6px", alignItems: "center", marginTop: "6px", flex: "0 0 auto" }, root);
this.hint = el("div", { color: "#888", marginTop: "4px", flex: "0 0 auto", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, root,
"queue once to load previews. hover = hear, click = lock audio, ★ = winner (next queue passes it through), space play, ←/→ step, F flicker, 1-6 pick A then B, i/o loop in/out");
const modes = { side: "Side by side", flip: "Flip A/B", wipe: "Wipe", grid: "Grid" };
this.modeBtns = {};
for (const m in modes) this.modeBtns[m] = this.btn(this.bar, modes[m], () => this.setMode(m));
this.gearBtn = this.btn(this.bar, "⚙ settings", () => this.toggleSettings());
this.exportBtn = this.btn(this.bar, "⤓ export", () => this.openExport());
this.playBtn = this.btn(this.crow, "Play", () => this.setPlay(!this.playing));
this.syncMode = "clock";
this.syncBtn = this.btn(this.crow, "sync: clock", () => {
this.syncMode = this.syncMode === "clock" ? "glide" : "clock";
this.syncBtn.textContent = "sync: " + this.syncMode;
if (this.playing) this.alignPlay();
});
this.scrub = el("input", { flex: "1" }, this.crow); this.scrub.type = "range"; this.scrub.min = 0; this.scrub.max = 1000; this.scrub.value = 0;
this.scrub.oninput = () => this.seekAll(this.scrub.value / 1000 * this.dur());
this.scrub.addEventListener("pointerdown", () => {
this._resumeScrub = this.playing && !(this.clockLead()?.paused ?? true);
for (const x of this.vids) x.video.pause();
});
this.scrub.addEventListener("pointerup", () => {
if (this._resumeScrub) { this._resumeScrub = false; this.alignPlay(); }
});
this.fnum = el("span", { minWidth: "80px", color: "#9c9" }, this.crow, "frame 0");
this.wipe = el("input", { width: "120px", display: "none" }, this.crow); this.wipe.type = "range"; this.wipe.min = 0; this.wipe.max = 100; this.wipe.value = 50;
this.wipe.oninput = () => this.stage.style.setProperty("--w", this.wipe.value + "%");
this.buildOverlay();
this.buildSettings();
this.buildTimeline();
this.buildExportDialog(root);
root.tabIndex = 0;
root.addEventListener("keydown", (e) => this.key(e));
// interacting with the widget must never start a canvas gesture (node
// drag/resize) - that is what collapsed the width mid-wipe-drag
root.addEventListener("pointerdown", (e) => e.stopPropagation());
root.style.minWidth = "520px";
const w = this.node.addDOMWidget("mai_compare_ui", "div", root, { serialize: false });
if (w) {
// no computeSize: a DOM widget without it is FLEXIBLE - the frontend's
// layout gives it the node's free space via computeLayoutSize, which
// reads these two hooks. (A computeSize override pins the height and,
// if it tracks the node, ratchets: the node can grow but never shrink.)
w.options.getMinHeight = () => 470;
w.options.getMaxHeight = () => 100000;
}
const sz = this.node.size; this.node.setSize([Math.max(sz[0], 760), Math.max(sz[1], 640)]);
this.root = root;
this.setMode("side");
loadMuxers();
this.raf();
}
btn(parent, label, cb) {
const b = el("button", { font: "12px system-ui", padding: "3px 8px", border: "1px solid #555",
background: "#333", color: "#ddd", borderRadius: "4px", cursor: "pointer" }, parent, label);
b.onclick = (e) => { e.stopPropagation(); cb(); };
return b;
}
input(parent, type, width, cb) {
const i = el("input", { width, background: "#111", color: "#ddd", border: "1px solid #555",
borderRadius: "3px", font: "12px system-ui", padding: "1px 4px" }, parent);
i.type = type;
if (type === "checkbox") Object.assign(i.style, { width: "auto", padding: "0" });
i.oninput = () => cb(i);
i.onchange = () => cb(i);
i.onkeydown = (e) => e.stopPropagation(); // typing must not reach the player keys
return i;
}
label(parent, text) {
return el("label", { marginRight: "10px", color: "#bbb" }, parent, text);
}
dur() { const v = this.vids[this.pair[0]]?.video; return v?.duration || 0; }
fps() { return this.items[0]?.fps || 24; }
lead() { return this.vids[this.pair[0]]?.video || null; }
other() { return this.vids[this.pair[1]]?.video || null; }
heardIndex() { const on = this.hover ?? this.audio; return on == null ? 0 : on; }
// ---------- data from the node ----------
onExecuted(msg) {
const man = msg?.mai_compare?.[0];
if (!man) return;
this.teardown();
this.items = man.items || [];
this.winner = man.winner;
this.spans = Array.isArray(man.spans) ? man.spans : [];
this.curves = man.curves || null;
this.pair = [0, Math.min(1, this.items.length - 1)];
for (const it of this.items) {
const v = document.createElement("video");
v.src = viewURL(it); v.preload = "auto"; v.loop = true; v.muted = true; v.playsInline = true;
Object.assign(v.style, { width: "100%", height: "100%", objectFit: "contain", display: "block", background: "#000" });
v.preload = "auto";
this.vids.push({ item: it, video: v });
}
this.audio = this.vids.findIndex((x) => x.item.index === this.winner);
if (this.audio < 0) this.audio = 0;
this.loopA = null; this.loopB = null;
this.layout();
this.setPlay(true);
this.loadWave();
}
teardown() {
this.setPlay(false);
for (const x of this.vids) { try { x.video.pause(); x.video.removeAttribute("src"); x.video.load(); } catch (e) {} }
this.vids = []; this.stage.innerHTML = "";
this.wf = null; this.waveGen++;
if (this.flicker) { clearInterval(this.flicker); this.flicker = null; }
}
destroy() { this.dead = true; this.teardown(); }
// ---------- layout ----------
setMode(m) {
this.mode = m;
for (const k in this.modeBtns) this.modeBtns[k].style.background = k === m ? "#5a5" : "#333";
this.wipe.style.display = m === "wipe" ? "" : "none";
this.layout();
}
pane(x, idx, style) {
const p = el("div", Object.assign({ position: "relative", overflow: "hidden", background: "#000", height: "100%" }, style), this.stage);
p.appendChild(x.video);
const tag = el("span", { position: "absolute", left: "6px", top: "4px", padding: "1px 6px", background: "rgba(0,0,0,.6)",
borderRadius: "3px", fontSize: "11px", pointerEvents: "none" }, p,
`${idx + 1} · ${x.item.label}` + (x.item.seed !== undefined && !String(x.item.label).includes("seed") ? ` · seed ${x.item.seed}` : ""));
const star = el("button", { position: "absolute", right: "6px", top: "4px", font: "14px system-ui", background: "rgba(0,0,0,.6)",
color: x.item.index === this.winner ? "#fc3" : "#888", border: "none", cursor: "pointer", borderRadius: "3px" }, p, "★");
star.title = "winner: the next queue passes this source through";
star.onclick = (e) => { e.stopPropagation(); this.setWinner(x.item.index); };
x.pane = p; x.star = star; x.tag = tag;
p.addEventListener("pointerenter", () => { this.hover = this.vids.indexOf(x); this.applyAudio(); });
p.addEventListener("pointerleave", () => { this.hover = null; this.applyAudio(); });
p.addEventListener("click", () => { this.audio = this.vids.indexOf(x); this.applyAudio(); });
return p;
}
layout() {
this.stage.innerHTML = "";
for (const x of this.vids) x.pane = null;
if (!this.vids.length) { this.stage.appendChild(this.ovl); return; }
const n = this.vids.length;
this.stage.style.aspectRatio = "";
if (this.mode === "grid" || n === 1) {
const cols = n <= 2 ? n : n <= 4 ? 2 : 3, rows = Math.ceil(n / cols);
Object.assign(this.stage.style, { display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`, gap: "3px" });
this.vids.forEach((x, i) => this.pane(x, i, { minHeight: "0", minWidth: "0" }));
} else {
const [a, b] = this.pair.map((i) => this.vids[i]);
Object.assign(this.stage.style, { display: this.mode === "side" ? "grid" : "block",
gridTemplateColumns: this.mode === "side" ? "1fr 1fr" : "", gridTemplateRows: "1fr", gap: "3px" });
const pa = this.pane(a, this.pair[0], this.mode === "side" ? { minHeight: "0", minWidth: "0" } : { position: "absolute", inset: "0" });
const pb = this.pane(b, this.pair[1], this.mode === "side" ? { minHeight: "0", minWidth: "0" } : { position: "absolute", inset: "0" });
if (this.mode === "flip") { pb.style.display = this.showB ? "" : "none"; pa.style.display = this.showB ? "none" : ""; }
if (this.mode === "wipe") {
this.stage.style.setProperty("--w", this.wipe.value + "%");
pb.style.clipPath = "inset(0 0 0 var(--w))";
this.stage.onpointerdown = (ev) => {
const move = (e) => { const r = this.stage.getBoundingClientRect();
const pct = Math.max(0, Math.min(100, (e.clientX - r.left) / r.width * 100));
this.stage.style.setProperty("--w", pct.toFixed(1) + "%"); this.wipe.value = Math.round(pct); };
move(ev); this.stage.onpointermove = move; this.stage.onpointerup = () => { this.stage.onpointermove = null; };
};
} else this.stage.onpointerdown = null;
}
this.stage.appendChild(this.ovl); // absolute, out of flow: survives the rebuild
this.applyAudio();
}
setWinner(index) {
this.winner = index;
const w = this.node.widgets?.find((w) => w.name === "winner");
if (w) { w.value = index; this.node.setDirtyCanvas?.(true, true); }
for (const x of this.vids) if (x.star) x.star.style.color = x.item.index === index ? "#fc3" : "#888";
this.hint.textContent = `winner: source ${index}. queue again to pass it through winner_video / finalize it`;
}
// ---------- overlay: frame counter + region ball ----------
buildOverlay() {
this.ovl = el("div", { position: "absolute", top: "6px", right: "8px", zIndex: "6", display: "flex",
gap: "8px", alignItems: "center", font: "13px ui-monospace, monospace", color: "#fff",
textShadow: "0 1px 4px #000", pointerEvents: "none" });
this.blipEl = el("span", { width: "14px", height: "14px", borderRadius: "50%", opacity: "0",
transition: "opacity .12s", boxShadow: "0 0 8px 2px rgba(0,0,0,.5)" }, this.ovl);
this.fcEl = el("span", { display: "none" }, this.ovl, "frame 0");
this.stage.appendChild(this.ovl);
}
flash(c) { this.flashColor = c; this.flashUntil = performance.now() + 350; }
tickCross() {
const v = this.lead(); if (!v) return;
const t = v.currentTime || 0, fps = this.fps();
if (t < this.lastT - 0.2) { this.lastT = t; return; } // wrapped
for (const sp of this.bands()) {
const a = sp[0] / fps, b = sp[1] / fps;
if (this.set.blip) {
if (this.lastT < a && t >= a) this.flash("#3fa66a"); // region enter
if (this.lastT < b && t >= b) this.flash("#d7732a"); // region exit
}
}
if (this.set.markblip) for (const m of this.markList()) {
const tm = m / fps; if (this.lastT < tm && t >= tm) { this.flash("#3f93d6"); break; }
}
this.lastT = t;
}
drawOvl() {
const v = this.lead(), fps = this.fps();
const t = v ? (v.currentTime || 0) : 0;
this.fcEl.style.display = this.set.fc ? "" : "none";
if (this.set.fc) this.fcEl.textContent = "frame " + Math.round(t * fps);
const inR = this.set.blip && this.bands().some((sp) => t >= sp[0] / fps && t <= sp[1] / fps);
if (performance.now() < this.flashUntil) { this.blipEl.style.background = this.flashColor; this.blipEl.style.opacity = "1"; }
else if (inR) { this.blipEl.style.background = "#3fa66a"; this.blipEl.style.opacity = "0.55"; }
else this.blipEl.style.opacity = "0";
}
// ---------- gear settings ----------
buildSettings() {
const p = this.setpanel;
const l1 = this.label(p, "region frames ");
this.spanAIn = this.input(l1, "number", "70px", (i) => {
const sp = this.bands()[0] || [0, 0];
this.set.spanA = i.value === "" ? null : +i.value;
if (this.set.spanA != null && this.set.spanB == null) this.set.spanB = sp[1];
this.saveSet();
});
el("span", {}, l1, " to ");
this.spanBIn = this.input(l1, "number", "70px", (i) => {
const sp = this.bands()[0] || [0, 0];
this.set.spanB = i.value === "" ? null : +i.value;
if (this.set.spanB != null && this.set.spanA == null) this.set.spanA = sp[0];
this.saveSet();
});
this.btn(p, "reset to node", () => {
this.set.spanA = this.set.spanB = null; this.saveSet();
const sp = this.bands()[0] || [0, 0];
this.spanAIn.value = sp[0]; this.spanBIn.value = sp[1];
});
const l2 = this.label(p, "");
this.fcIn = this.input(l2, "checkbox", "", (i) => { this.set.fc = i.checked; this.saveSet(); });
el("span", {}, l2, " frame counter");
const l3 = this.label(p, "");
this.blipIn = this.input(l3, "checkbox", "", (i) => { this.set.blip = i.checked; this.saveSet(); });
el("span", {}, l3, " region enter/exit blips");
el("div", { height: "2px" }, p);
const l4 = this.label(p, "marks (frames, comma-sep) ");
this.marksIn = this.input(l4, "text", "220px", (i) => { this.set.marks = i.value; this.saveSet(); });
this.marksIn.placeholder = "e.g. 24, 60, 118";
const l5 = this.label(p, "");
this.markBlipIn = this.input(l5, "checkbox", "", (i) => { this.set.markblip = i.checked; this.saveSet(); });
el("span", {}, l5, " mark blips");
}
toggleSettings() {
const open = this.setpanel.style.display === "none";
this.setpanel.style.display = open ? "" : "none";
this.gearBtn.style.background = open ? "#5a5" : "#333";
if (!open) return;
const sp = this.bands()[0] || [0, 0];
this.spanAIn.value = this.set.spanA != null ? this.set.spanA : sp[0];
this.spanBIn.value = this.set.spanB != null ? this.set.spanB : sp[1];
this.fcIn.checked = this.set.fc; this.blipIn.checked = this.set.blip;
this.markBlipIn.checked = this.set.markblip; this.marksIn.value = this.set.marks;
}
// ---------- pro timeline ----------
buildTimeline() {
const box = el("div", { flex: "1 1 auto", minWidth: "200px", height: "84px", background: "#1f232a",
border: "1px solid #444", borderRadius: "6px", overflow: "hidden" }, this.tlrow);
this.tl = el("canvas", { width: "100%", height: "100%", display: "block", cursor: "crosshair" }, box);
this.tnum = el("span", { minWidth: "165px", color: "#9c9", fontVariantNumeric: "tabular-nums" }, this.tlrow, "0.00 s · frame 0");
this.btn(this.tlrow, "⌊ in", () => this.markIn());
this.btn(this.tlrow, "out ⌋", () => this.markOut());
this.btn(this.tlrow, "↺ full", () => { this.loopA = 0; this.loopB = this.dur() || null; });
this.tlDrag = null;
this.tl.addEventListener("pointerdown", (e) => {
const x = e.offsetX, y = e.offsetY, H = this.tl.clientHeight;
const tab = y < 14 || y > H - 14; // top/bottom grab tabs
const near = (t, tol) => t != null && Math.abs(this.tlX(t) - x) < tol;
this.tlDrag = near(this.loopA, tab ? 14 : 5) ? "in" : near(this.loopB, tab ? 14 : 5) ? "out" : "seek";
this.tl.setPointerCapture(e.pointerId);
this._resumeDrag = this.playing && !(this.clockLead()?.paused ?? true);
for (const x of this.vids) x.video.pause();
if (this.tlDrag === "seek") this.tlMove(e);
e.stopPropagation();
});
this.tl.addEventListener("pointermove", (e) => this.tlMove(e));
this.tl.addEventListener("pointerup", () => {
this.tlDrag = null;
if (this._resumeDrag) { this._resumeDrag = false; this.alignPlay(); }
});
this.tl.addEventListener("dblclick", () => { this.loopA = 0; this.loopB = this.dur() || null; });
window.addEventListener("resize", () => this.tlResize());
setTimeout(() => this.tlResize(), 50);
}
markIn() { const v = this.lead(); if (!v) return; this.loopA = v.currentTime; if (this.loopB != null && this.loopB <= this.loopA) this.loopB = null; }
markOut() { const v = this.lead(); if (!v) return; this.loopB = v.currentTime; if (this.loopA != null && this.loopA >= this.loopB) this.loopA = null; }
tlX(t) { const d = this.dur(); return d ? t / d * this.tl.clientWidth : 0; }
tlT(x) { const d = this.dur(); return d ? Math.max(0, Math.min(d, x / this.tl.clientWidth * d)) : 0; }
tlResize() {
const r = window.devicePixelRatio || 1;
this.tl.width = Math.max(1, Math.round(this.tl.clientWidth * r));
this.tl.height = Math.max(1, Math.round(this.tl.clientHeight * r));
this.tl.getContext("2d").setTransform(r, 0, 0, r, 0, 0);
}
tlMove(e) {
if (!this.tlDrag) return;
const t = this.tlT(e.offsetX), v = this.lead();
if (this.tlDrag === "seek") this.seekAll(t);
else if (this.tlDrag === "in") {
this.loopA = this.loopB != null ? Math.min(t, this.loopB - 0.05) : t;
if (v && v.currentTime < this.loopA) this.seekAll(this.loopA); // stranded playhead
} else {
this.loopB = this.loopA != null ? Math.max(t, this.loopA + 0.05) : t;
if (v && v.currentTime > this.loopB) this.seekAll(Math.max(this.loopA || 0, this.loopB - 0.05));
}
}
loopTick() {
const v = this.lead(); if (!v) return;
if (this.loopA == null || this.loopB == null) return;
const t = v.currentTime;
if (t > this.loopB + 0.02 || t < this.loopA - 0.35) this.seekAll(this.loopA);
}
async loadWave() {
this.wf = null;
const gen = ++this.waveGen, x = this.vids[this.pair[0]];
if (!x) return;
try {
const ab = await (await fetch(x.video.currentSrc || x.video.src)).arrayBuffer();
const AC = window.AudioContext || window.webkitAudioContext;
const ac = new AC();
const buf = await ac.decodeAudioData(ab);
ac.close();
if (gen !== this.waveGen) return;
const N = 1000, c0 = buf.getChannelData(0), c1 = buf.numberOfChannels > 1 ? buf.getChannelData(1) : c0;
const bucket = (c) => {
const out = [], step = Math.max(1, Math.floor(c.length / N));
for (let i = 0; i < N; i++) {
let m = 0; const st = i * step, e = Math.min(c.length, st + step);
for (let j = st; j < e; j += 2) { const v = Math.abs(c[j]); if (v > m) m = v; }
out.push(m);
}
return out;
};
this.wf = { L: bucket(c0), R: bucket(c1) };
} catch (e) { this.wf = null; } // a silent preview simply has no wave
}
drawTL() {
const cx = this.tl.getContext("2d"), W = this.tl.clientWidth, H = this.tl.clientHeight;
const d = this.dur(), fps = this.fps(), mid = H / 2;
cx.clearRect(0, 0, W, H);
cx.fillStyle = "#14171c"; cx.fillRect(0, 0, W, H);
if (d) {
if (this.loopA == null) this.loopA = 0;
if (this.loopB == null || this.loopB > d + 0.001) this.loopB = d;
if (this.loopA > d) this.loopA = 0;
cx.fillStyle = "rgba(63,147,214,.16)";
for (const sp of this.bands()) cx.fillRect(this.tlX(sp[0] / fps), 0, Math.max(2, this.tlX((sp[1] - sp[0]) / fps)), H);
for (const m of this.markList()) {
const x = this.tlX(m / fps);
cx.fillStyle = "#3f93d6"; cx.beginPath(); cx.moveTo(x - 4, 0); cx.lineTo(x + 4, 0); cx.lineTo(x, 6); cx.closePath(); cx.fill();
cx.fillStyle = "rgba(63,147,214,.16)";
}
}
if (this.wf) {
const n = this.wf.L.length;
cx.fillStyle = "rgba(154,163,173,.85)";
for (let i = 0; i < n; i++) {
const x = i / n * W, w = Math.max(1, W / n);
const l = this.wf.L[i] * (mid - 3), r = this.wf.R[i] * (mid - 3);
cx.fillRect(x, mid - 2 - l, w, l); // L above the centre line
cx.fillRect(x, mid + 2, w, r); // R below it
}
cx.fillStyle = "rgba(255,255,255,.25)"; cx.fillRect(0, mid - 1, W, 2);
} else { cx.fillStyle = "rgba(255,255,255,.15)"; cx.fillRect(0, mid - 1, W, 2); }
if (this.curves) {
const names = Object.keys(this.curves), cols = ["#3f93d6", "#c2456b", "#3fa66a", "#f2c94c"];
names.forEach((nm, k) => {
const c = this.curves[nm];
if (!Array.isArray(c) || c.length < 2) return;
const hi = Math.max(...c, 1e-6);
cx.strokeStyle = cols[k % cols.length]; cx.lineWidth = 1.2; cx.beginPath();
c.forEach((v, i) => { const x = i / (c.length - 1) * W, y = H - 4 - (H - 8) * v / hi; i ? cx.lineTo(x, y) : cx.moveTo(x, y); });
cx.stroke();
});
}
if (this.loopA != null && this.loopB != null) {
const xa = this.tlX(this.loopA), xb = this.tlX(this.loopB);
cx.fillStyle = "rgba(242,201,76,.16)"; cx.fillRect(xa, 0, xb - xa, H);
cx.strokeStyle = "#f2c94c"; cx.lineWidth = 2; cx.fillStyle = "#f2c94c";
const brk = (x, dir) => {
cx.beginPath(); cx.moveTo(x + 7 * dir, 1); cx.lineTo(x, 1); cx.lineTo(x, H - 1); cx.lineTo(x + 7 * dir, H - 1); cx.stroke();
cx.fillRect(dir > 0 ? x : x - 12, 0, 12, 10); cx.fillRect(dir > 0 ? x : x - 12, H - 10, 12, 10); // grab tabs
};
brk(xa, 1); brk(xb, -1);
}
const v = this.lead();
if (d && v) {
const x = this.tlX(v.currentTime);
cx.fillStyle = "#fff"; cx.fillRect(x - 0.5, 0, 1.5, H);
cx.beginPath(); cx.moveTo(x - 5, 0); cx.lineTo(x + 5, 0); cx.lineTo(x, 7); cx.closePath();
cx.fillStyle = "#f2c94c"; cx.fill();
}
const t = v ? (v.currentTime || 0) : 0;
this.tnum.textContent = t.toFixed(2) + " s · frame " + Math.round(t * fps) +
((this.loopA != null && this.loopB != null) ? " · loop " + Math.round(this.loopA * fps) + "-" + Math.round(this.loopB * fps) : "");
}
raf() {
if (this.dead) return;
try {
const want = Math.max(1, Math.round(this.tl.clientWidth * (window.devicePixelRatio || 1)));
if (this.tl.clientWidth && this.tl.width !== want) this.tlResize();
this.tickCross(); this.loopTick();
// paint at 10 Hz: repainting the waveform canvas per animation frame
// on the transformed node surface is what stuttered the videos
const pnow = performance.now();
if (pnow - (this._lastPaint || 0) > 150) { this._lastPaint = pnow; this.drawTL(); this.drawOvl(); }
} catch (e) {}
requestAnimationFrame(() => this.raf());
}
// ---------- playback ----------
applyAudio() {
const on = this.hover ?? this.audio;
this.vids.forEach((x, i) => { x.video.muted = i !== on; });
}
setPlay(on) {
this.playing = on;
this.playBtn.textContent = on ? "Pause" : "Play";
if (!on) { for (const x of this.vids) x.video.pause(); return; }
if (!this.vids.length) return;
if (this.started) { this.alignPlay(); return; }
this.started = true; this.playBtn.textContent = "Loading";
const t0 = Date.now();
const wait = setInterval(() => {
if (!this.playing) { clearInterval(wait); return; }
if (this.vids.every((x) => whole(x.video)) || Date.now() - t0 > 6000) {
clearInterval(wait); this.playBtn.textContent = "Pause";
for (const x of this.vids) { try { x.video.currentTime = 0; } catch (e) {} }
this.alignPlay();
this.tick();
}
}, 100);
}
clockLead() {
// the heard side owns the clock; otherwise the A side
const hearIdx = this.audio != null ? this.audio : this.pair[0];
return (this.vids[hearIdx] || this.vids[this.pair[0]] || this.vids[0])?.video || null;
}
alignPlay() {
// seek every source to the lead's clock, wait until all can play, then
// start them in the same tick - the stagger fix
const lead = this.vids[this.pair[0]]?.video || this.vids[0]?.video;
if (!lead) return;
const t = lead.currentTime || 0;
for (const x of this.vids) { try { x.video.currentTime = t; } catch (e) {} }
const ready = (v) => new Promise((res) => {
if (whole(v) || v.readyState >= 4) return res();
const f = () => { v.removeEventListener("canplaythrough", f); res(); };
v.addEventListener("canplaythrough", f); setTimeout(res, 4000);
});
Promise.all(this.vids.map((x) => ready(x.video))).then(() => {
if (!this.playing) return;
// re-seek after the wait (buffering can move currentTime), then start
for (const x of this.vids) { try { x.video.currentTime = t; } catch (e) {} }
const leadC = this._lead = this.clockLead();
for (const x of this.vids) {
if (this.syncMode === "clock" && x.video !== leadC) { x.video.pause(); continue; }
x.video.play().catch(() => { x.video.muted = true; x.video.play().catch(() => {}); });
}
// decoders spin up at different speeds, so a same-tick play() still
// staggers: once EVERY source has actually started rolling, snap the
// followers onto the lead's clock in one shot; the glide holds it after
const lead2 = this.vids[this.pair[0]]?.video || this.vids[0]?.video;
let pending = this.vids.length;
const onRolling = () => {
if (--pending > 0) return;
for (const x of this.vids) {
if (x.video === lead2) continue;
try { x.video.currentTime = lead2.currentTime; } catch (e) {}
}
};
for (const x of this.vids) {
const v2 = x.video;
const f = () => { v2.removeEventListener("playing", f); onRolling(); };
v2.addEventListener("playing", f); setTimeout(f, 1500);
}
});
}
tick() {
if (!this.playing) return;
const v = this.vids[this.pair[0]]?.video;
if (v && v.duration) {
const tnow = performance.now();
if (tnow - (this._lastDom || 0) > 100) {
this._lastDom = tnow;
this.scrub.value = Math.round(v.currentTime / v.duration * 1000);
this.fnum.textContent = "frame " + Math.round(v.currentTime * this.fps());
}
if (this.syncMode === "clock") {
// master clock: followers are PAUSED and pinned to the lead's time
// every frame - there is no second free-running player to drift
const lc = this._lead || this.clockLead();
if (lc && !lc.paused) for (const x of this.vids) {
const sv = x.video;
if (sv === lc) continue;
if (!sv.paused) sv.pause();
if (sv.seeking) continue; // let the decoder finish; self-pacing
if (Math.abs(sv.currentTime - lc.currentTime) > 0.03) {
try { sv.currentTime = lc.currentTime; } catch (e) {}
}
}
this.lastSync = v.currentTime;
requestAnimationFrame(() => this.tick());
return;
}
// continuous drift control, THROTTLED: adjusting playbackRate every
// animation frame makes Firefox playback shudder, so corrections run
// at most 4x a second, rates are quantized, and hard seeks are rare
const now = performance.now();
if (now - (this.lastGlide || 0) > 250) {
this.lastGlide = now;
for (const x of this.vids) {
const sv = x.video;
if (sv === v || sv.readyState < 3) continue;
const d = v.currentTime - sv.currentTime, ad = Math.abs(d);
if (ad > 0.5 && now - (this.lastSeekFix || 0) > 600) {
this.lastSeekFix = now; sv.currentTime = v.currentTime; sv.playbackRate = 1;
} else if (ad > 0.06) {
const r = Math.round(Math.max(0.9, Math.min(1.1, 1 + d * 0.5)) * 50) / 50;
if (sv.playbackRate !== r) sv.playbackRate = r;
} else if (sv.playbackRate !== 1) sv.playbackRate = 1;
}
}
this.lastSync = v.currentTime;
}
requestAnimationFrame(() => this.tick());
}
seekAll(t) {
for (const x of this.vids) { try { x.video.currentTime = t; } catch (e) {} }
this.fnum.textContent = "frame " + Math.round(t * this.fps());
}
step(df) {
const v = this.vids[this.pair[0]]?.video; if (!v) return;
this.setPlay(false);
this.seekAll(Math.max(0, v.currentTime + df / this.fps()));
}
key(e) {
if (e.key === " ") { e.preventDefault(); this.setPlay(!this.playing); }
else if (e.key === "ArrowLeft") { e.preventDefault(); this.step(e.shiftKey ? -12 : -1); }
else if (e.key === "ArrowRight") { e.preventDefault(); this.step(e.shiftKey ? 12 : 1); }
else if (e.key === "f" || e.key === "F") {
if (this.flicker) { clearInterval(this.flicker); this.flicker = null; return; }
if (this.mode !== "flip") this.setMode("flip");
this.flicker = setInterval(() => { this.showB = !this.showB; this.layout(); }, 250);
}
else if (e.key === "i") { this.markIn(); }
else if (e.key === "o") { this.markOut(); }
else if (/^[1-6]$/.test(e.key)) {
const i = parseInt(e.key) - 1; if (i >= this.vids.length) return;
this.pair = [this.pair[1], i]; this.layout(); this.loadWave();
}
else if (e.key === "Enter") { const x = this.vids[this.pair[1]]; if (x) this.setWinner(x.item.index); }
}
// ---------- export ----------
buildExportDialog(root) {
this.xdlg = el("div", { display: "none", position: "absolute", inset: "0", background: "rgba(0,0,0,.65)",
zIndex: "20", padding: "18px", overflow: "auto" }, root);
const box = el("div", { maxWidth: "440px", margin: "0 auto", background: "#1f232a", border: "1px solid #444",
borderRadius: "8px", padding: "12px", lineHeight: "1.9" }, this.xdlg);
el("div", { fontWeight: "600", marginBottom: "4px" }, box, "Export the current view");
this.xinfo = el("div", { color: "#9aa3ad", marginBottom: "6px" }, box, "");
const l1 = this.label(box, "");
this.xaudio = this.input(l1, "checkbox", "", () => {});
this.xaudio.checked = true;
el("span", {}, l1, " include audio (the side you hear)");
el("div", { height: "2px" }, box);
const l2 = this.label(box, "");
this.xprecise = this.input(l2, "checkbox", "", () => {});
this.xprecise.checked = true;
el("span", {}, l2, " precise export (WebCodecs: frame-exact, faster than real time)");
el("div", { height: "2px" }, box);
const l3 = this.label(box, "codec ");
this.xcodec = el("select", { background: "#111", color: "#ddd", border: "1px solid #555", borderRadius: "3px" }, l3);
for (const [v, t] of [["vp9", "best quality (VP9, .webm)"], ["av1", "smallest (AV1, .webm, slow)"],
["mp4", "compatibility (H.264 .mp4, the browser encoder is call-grade)"]]) {
const o = document.createElement("option"); o.value = v; o.textContent = t; this.xcodec.append(o);
}
const l4 = this.label(box, "bitrate ");
this.xrate = el("select", { background: "#111", color: "#ddd", border: "1px solid #555", borderRadius: "3px" }, l4);
for (const r of [24, 16, 40, 60, 100]) { const o = document.createElement("option"); o.value = r; o.textContent = r + " Mbps"; this.xrate.append(o); }
const row = el("div", { display: "flex", gap: "8px", alignItems: "center", marginTop: "8px" }, box);
this.btn(row, "record & save", () => this.runExport());
this.btn(row, "cancel", () => { this.xdlg.style.display = "none"; });
this.xstat = el("span", { color: "#9aa3ad" }, row, "");
}
pickMime() {
return ["video/mp4;codecs=avc1.640028", "video/mp4;codecs=avc1.42E01E", "video/mp4;codecs=avc1", "video/mp4",
"video/webm;codecs=vp9", "video/webm"].find((m) => window.MediaRecorder && MediaRecorder.isTypeSupported(m));
}
async openExport() {
const d = this.dur();
if (!d) { this.hint.textContent = "queue once and let a pair load before exporting"; return; }
await loadMuxers();
const a = this.loopA != null ? this.loopA : 0, b = this.loopB != null ? this.loopB : d;
const A = this.vids[this.pair[0]]?.item, B = this.vids[this.pair[1]]?.item;
this.xinfo.textContent = `${A?.label || "A"} vs ${B?.label || "B"} · ${this.mode === "wipe" ? "wipe (current slider)" : "side by side"} · ` +
`${a.toFixed(2)}s to ${b.toFixed(2)}s (${(b - a).toFixed(2)}s) + 2s end card`;
const cap = ("VideoEncoder" in window) && (typeof Mp4Muxer !== "undefined" || typeof WebMMuxer !== "undefined");
this.xprecise.checked = cap; this.xprecise.disabled = !cap;
this.xstat.textContent = cap ? "" : "WebCodecs or the muxers are unavailable: real-time capture only";
this.xdlg.style.display = "";
}
exportName(t0, t1, ext) {
const A = this.vids[this.pair[0]]?.item, B = this.vids[this.pair[1]]?.item, fps = this.fps();
const nm = (x) => String(x?.label || "src").replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 40);
return `${nm(A)}_vs_${nm(B)}_f${Math.round(t0 * fps)}-${Math.round(t1 * fps)}.${ext}`;
}
endCardLines() {
const A = this.vids[this.pair[0]]?.item, B = this.vids[this.pair[1]]?.item;
const f = (x) => x ? [x.label, x.seed !== undefined ? `seed ${x.seed}` : "",
`${x.frames}f @ ${Number(x.fps).toFixed(2)} fps`, `${x.width}x${x.height}`].filter(Boolean).join(" · ") : "";
return [`A ${f(A)}`, `B ${f(B)}`];
}
drawKit() {
// one canvas plus painters, shared by both export paths
const va = this.lead(), vb = this.other() || this.lead();
const W = va.videoWidth || 864, H = va.videoHeight || 480;
const wipe = this.mode === "wipe";
const cw = wipe ? W : 2 * W + 4, ch = H;
const cv = document.createElement("canvas"); cv.width = cw; cv.height = ch;
const cx = cv.getContext("2d");
const A = this.vids[this.pair[0]]?.item, B = this.vids[this.pair[1]]?.item;
const top = `A ${A?.label || ""} vs B ${B?.label || ""}`;
const fps = this.fps(), self = this;
const drawAB = () => {
cx.fillStyle = "#000"; cx.fillRect(0, 0, cw, ch);
if (wipe) {
cx.drawImage(va, 0, 0, W, H);
const w = (self.wipe.value / 100) * W;
cx.save(); cx.beginPath(); cx.rect(w, 0, W - w, H); cx.clip(); cx.drawImage(vb, 0, 0, W, H); cx.restore();
cx.fillStyle = "#f2c94c"; cx.fillRect(w - 1, 0, 2, H);
} else {
cx.drawImage(va, 0, 0, W, H); cx.drawImage(vb, W + 4, 0, W, H);
cx.font = "20px system-ui, sans-serif"; cx.textBaseline = "top";
cx.fillStyle = "rgba(0,0,0,.55)"; cx.fillRect(0, ch - 34, 90, 34); cx.fillRect(W + 4, ch - 34, 90, 34);
cx.fillStyle = "#fff"; cx.fillText("A", 12, ch - 28); cx.fillText("B", W + 16, ch - 28);
}
cx.fillStyle = "rgba(0,0,0,.55)"; cx.fillRect(0, 0, cw, 40);
cx.fillStyle = "#fff"; cx.font = "20px system-ui, sans-serif"; cx.textBaseline = "top"; cx.fillText(top, 12, 10);
// bake the frame counter and the region ball in when they are on
const tt = va.currentTime || 0; let bx = cw - 16;
if (self.set.fc) {
cx.font = "20px ui-monospace, monospace";
const txt = "frame " + Math.round(tt * fps), w = cx.measureText(txt).width;
cx.fillStyle = "rgba(0,0,0,.55)"; cx.fillRect(cw - w - 28, 46, w + 20, 30);
cx.fillStyle = "#fff"; cx.fillText(txt, cw - w - 18, 52); bx = cw - w - 44;
}
const inR = self.set.blip && self.bands().some((sp) => tt >= sp[0] / fps && tt <= sp[1] / fps);
let col = null, op = 0;
if (performance.now() < self.flashUntil) { col = self.flashColor; op = 1; }
else if (inR) { col = "#3fa66a"; op = 0.55; }
if (col) { cx.globalAlpha = op; cx.fillStyle = col; cx.beginPath(); cx.arc(bx - 8, 61, 8, 0, 7); cx.fill(); cx.globalAlpha = 1; }
};
const drawEnd = () => {
cx.fillStyle = "#000"; cx.fillRect(0, 0, cw, ch);
cx.fillStyle = "#fff"; cx.textBaseline = "top";
cx.font = "26px system-ui, sans-serif"; cx.fillText(top, 40, 40);
cx.font = "20px system-ui, sans-serif";
let y = 96;
for (const line of self.endCardLines()) { cx.fillText(line, 40, y); y += 30; if (y > ch - 40) break; }
const bd = self.bands()[0];
if (bd && y <= ch - 40) cx.fillText(`region f${bd[0]}-f${bd[1]}`, 40, y);
};
return { va, vb, cv, cx, cw, ch, drawAB, drawEnd };
}
async runExport() {
const d = this.dur(); if (!d) return;
const t0 = this.loopA != null ? this.loopA : 0, t1 = this.loopB != null ? this.loopB : d;
const kit = this.drawKit();
const { va, vb } = kit;
const wasA = va.loop, wasB = vb.loop, wasPlaying = this.playing;
this.xdlg.style.display = "none";
this.exportBtn.disabled = true;
va.loop = false; vb.loop = false;
this.setPlay(false);
try {
if (this.xprecise.checked && ("VideoEncoder" in window) && (typeof Mp4Muxer !== "undefined" || typeof WebMMuxer !== "undefined"))
await this.exportPrecise(kit, t0, t1);
else
await this.exportRealtime(kit, t0, t1);
} catch (err) {
console.error("MAIVideoCompare export failed", err);
this.hint.textContent = "export failed: " + err;
}
va.loop = wasA; vb.loop = wasB;
this.exportBtn.disabled = false; this.exportBtn.textContent = "⤓ export";
if (wasPlaying) this.setPlay(true);
}
saveBlob(blob, name) {
const u = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href = u; a.download = name; a.click();
setTimeout(() => URL.revokeObjectURL(u), 10000);
}
async exportRealtime(kit, t0, t1) {
const { va, vb, cv, drawAB, drawEnd } = kit;
const mime = this.pickMime();
if (!mime) throw new Error("no MediaRecorder codec in this browser");
const stream = cv.captureStream(this.fps());
if (this.xaudio.checked) {
const hv = this.vids[this.heardIndex()]?.video || va;
try { const at = hv.captureStream().getAudioTracks()[0]; if (at) stream.addTrack(at); } catch (e) {}
}
const rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: (parseInt(this.xrate.value) || 24) * 1e6 });
const chunks = []; rec.ondataavailable = (e) => chunks.push(e.data);
const done = new Promise((r) => { rec.onstop = r; });
this.exportBtn.textContent = "recording…";
this.seekAll(t0);
await new Promise((r) => setTimeout(r, 300));
rec.start(250); va.play(); vb.play();
let phase = "main", endAt = 0, raf;
await new Promise((res) => {
const step = () => {
if (phase === "main") {
drawAB();
if (va.currentTime >= t1 - 0.02 || va.ended) { phase = "end"; endAt = performance.now() + 2000; va.pause(); vb.pause(); }
} else { drawEnd(); if (performance.now() > endAt) { cancelAnimationFrame(raf); return res(); } }
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
});
rec.stop(); await done;
const ext = mime.startsWith("video/mp4") ? "mp4" : "webm";
this.saveBlob(new Blob(chunks, { type: mime }), this.exportName(t0, t1, ext));
}
async exportPrecise(kit, t0, t1) {
const { va, vb, cv, cw, ch, drawAB, drawEnd } = kit;
const FPS = this.fps();
const RATE = (parseInt(this.xrate.value) || 24) * 1e6;
const want = this.xcodec.value;
const ok = async (c) => (await VideoEncoder.isConfigSupported({ codec: c, width: cw, height: ch, bitrate: RATE, framerate: FPS })).supported;
let vcodec = null, mp4ok = false;
if (want === "mp4" && typeof Mp4Muxer !== "undefined" && await ok("avc1.640028")) { vcodec = "avc1.640028"; mp4ok = true; }
else if (want === "av1" && typeof WebMMuxer !== "undefined" && await ok("av01.0.08M.08")) { vcodec = "av01.0.08M.08"; }
if (!vcodec && typeof WebMMuxer !== "undefined" && await ok("vp09.00.10.08")) vcodec = "vp09.00.10.08";
if (!vcodec && typeof Mp4Muxer !== "undefined" && await ok("avc1.640028")) { vcodec = "avc1.640028"; mp4ok = true; }
if (!vcodec) throw new Error("no supported encoder+muxer");
let abuf = null;
if (this.xaudio.checked && ("AudioEncoder" in window)) {
try {
const hv = this.vids[this.heardIndex()]?.video || va;
const ab = await (await fetch(hv.currentSrc || hv.src)).arrayBuffer();
const AC = window.AudioContext || window.webkitAudioContext;
const ac = new AC();
abuf = await ac.decodeAudioData(ab);
ac.close();
if (!(await AudioEncoder.isConfigSupported({ codec: "opus", sampleRate: abuf.sampleRate,
numberOfChannels: Math.min(2, abuf.numberOfChannels), bitrate: 192000 })).supported) abuf = null;
} catch (e) { abuf = null; }
}
const sr = abuf ? abuf.sampleRate : 48000, nch = abuf ? Math.min(2, abuf.numberOfChannels) : 2;
let muxer, target;
if (mp4ok) {
target = new Mp4Muxer.ArrayBufferTarget();
muxer = new Mp4Muxer.Muxer({ target, fastStart: "in-memory", video: { codec: "avc", width: cw, height: ch },
audio: abuf ? { codec: "opus", sampleRate: sr, numberOfChannels: nch } : undefined });
} else {
target = new WebMMuxer.ArrayBufferTarget();
muxer = new WebMMuxer.Muxer({ target, video: { codec: vcodec.startsWith("av01") ? "V_AV1" : "V_VP9", width: cw, height: ch },
audio: abuf ? { codec: "A_OPUS", sampleRate: sr, numberOfChannels: nch } : undefined });
}
const venc = new VideoEncoder({ output: (c, m) => muxer.addVideoChunk(c, m), error: (e) => { throw e; } });
const vcfg = { codec: vcodec, width: cw, height: ch, bitrate: RATE, framerate: FPS, latencyMode: "quality" };
if (mp4ok) vcfg.avc = { format: "avc" };
venc.configure(vcfg);
const seek = (v, t) => new Promise((r) => {
let done = false;
const f = () => { if (done) return; done = true; v.removeEventListener("seeked", f); r(); };
v.addEventListener("seeked", f);
v.currentTime = Math.min(t, (v.duration || t + 1) - 0.0001);
setTimeout(f, 800);
});
va.pause(); vb.pause();
const n = Math.max(1, Math.round((t1 - t0) * FPS)), us = (f) => Math.round(f * 1e6 / FPS);
for (let i = 0; i < n; i++) {
const t = t0 + i / FPS;
await seek(va, t); if (vb !== va) await seek(vb, t);
drawAB();
const vf = new VideoFrame(cv, { timestamp: us(i), duration: us(1) });
venc.encode(vf, { keyFrame: i % (FPS * 2) === 0 }); vf.close();
if (i % 6 === 0) { this.exportBtn.textContent = "export " + Math.round(100 * i / n) + "%"; await new Promise((r) => setTimeout(r, 0)); }
while (venc.encodeQueueSize > 4) await new Promise((r) => setTimeout(r, 2));
}
drawEnd();
for (let i = 0; i < 2 * FPS; i++) {
const vf = new VideoFrame(cv, { timestamp: us(n + i), duration: us(1) });
venc.encode(vf, { keyFrame: i === 0 }); vf.close();
}
this.exportBtn.textContent = "finishing…";
await venc.flush(); venc.close();
if (abuf) {
const aenc = new AudioEncoder({ output: (c, m) => muxer.addAudioChunk(c, m), error: (e) => { throw e; } });
aenc.configure({ codec: "opus", sampleRate: sr, numberOfChannels: nch, bitrate: 192000 });
const s0 = Math.floor(t0 * sr), s1 = Math.min(abuf.length, Math.ceil(t1 * sr));
const CH = [];
for (let c = 0; c < nch; c++) CH.push(abuf.getChannelData(Math.min(c, abuf.numberOfChannels - 1)));
const STEP = Math.round(sr / 50);
for (let sp = s0; sp < s1; sp += STEP) {
const len = Math.min(STEP, s1 - sp);
const data = new Float32Array(len * nch);
for (let c = 0; c < nch; c++) data.set(CH[c].subarray(sp, sp + len), c * len);
const ad = new AudioData({ format: "f32-planar", sampleRate: sr, numberOfFrames: len, numberOfChannels: nch,
timestamp: Math.round((sp - s0) / sr * 1e6), data });
aenc.encode(ad); ad.close();
if (aenc.encodeQueueSize > 8) await new Promise((r) => setTimeout(r, 2));
}
await aenc.flush(); aenc.close();
}
muxer.finalize();
const type = mp4ok ? "video/mp4" : "video/webm", ext = mp4ok ? "mp4" : "webm";
this.saveBlob(new Blob([target.buffer], { type }), this.exportName(t0, t1, ext));
}
}
app.registerExtension({
name: "MAINodes.VideoCompare",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "MAIVideoCompare" && nodeData.name !== "MAISeedHunter") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
try { this.maiCompare = new CompareWidget(this); } catch (e) { console.error("MAIVideoCompare widget failed:", e); }
return r;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
this.maiCompare?.onExecuted(message);
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () { this.maiCompare?.destroy(); onRemoved?.apply(this, arguments); };
},
});