Files
MajoorWaldi-ComfyUI-Majoor-…/ui/features/viewer/videoProcessor.ts
T

817 lines
28 KiB
TypeScript

import { APP_CONFIG } from "../../app/config.js";
import { EVENTS } from "../../app/events.js";
import { clamp01 } from "./state.js";
import { computeProcessorScale } from "./processorUtils.js";
import { drawMediaError } from "./imageProcessor.js";
import { createWebGLVideoProcessor, isWebGLAvailable } from "./videoProcessorWebGL.js";
export function createVideoProcessor({
canvas,
videoEl,
disableWebGL,
pauseDuringExecution = null,
getGradeParams,
isDefaultGrade,
_tonemap,
maxProcPixelsVideo,
throttleFps,
safeAddListener,
safeCall,
onReady,
}: Record<string, any> = {}): Record<string, any> {
const shouldPauseDuringExecution =
pauseDuringExecution == null
? !!APP_CONFIG?.VIEWER_PAUSE_DURING_EXECUTION
: !!pauseDuringExecution;
// Attempt WebGL first
let glProc: any = null;
if (!disableWebGL && isWebGLAvailable()) {
try {
glProc = createWebGLVideoProcessor({
canvas,
videoEl,
getGradeParams,
isDefaultGrade,
maxProcPixelsVideo,
});
} catch (e: any) {
console.warn("WebGL Init failed, falling back to 2D", e);
glProc = null;
}
}
// alpha: true so the canvas stays transparent until a frame actually paints,
// instead of flashing/sticking opaque black if rendering stalls (e.g. the WebGL
// fallback path kicking in under context pressure from rapid open/close cycles).
const ctx = glProc
? null
: (() => {
try {
return canvas.getContext("2d", { willReadFrequently: true, alpha: true });
} catch {
return null;
}
})();
const srcCanvas = document.createElement("canvas");
const srcCtx = (() => {
try {
return srcCanvas.getContext("2d", { willReadFrequently: true, alpha: false });
} catch {
return null;
}
})();
const sampleCanvas = document.createElement("canvas");
sampleCanvas.width = 1;
sampleCanvas.height = 1;
const sampleCtx = (() => {
try {
return sampleCanvas.getContext("2d", { willReadFrequently: true, alpha: false });
} catch {
return null;
}
})();
const proc: Record<string, any> = {
ready: false,
_rendering: false,
_destroyed: false,
_rvfc: null,
_rafIdLoop: null,
_rafIdSchedule: null,
_seekRaf: null,
_lastHeavyRenderAt: 0,
_throttleTimer: null,
_connectRAF: null,
_connectTries: 0,
_buffer: null,
_lut: null,
_lutKey: "",
_lastFrameTime: -1,
_lastHeavySig: "",
_runtimePaused: false,
};
const unsubs: any[] = [];
const computeScale = (w: any, h: any) => computeProcessorScale(maxProcPixelsVideo, w, h);
const ensureSizeFromVideo = () => {
try {
const w0 = Number(videoEl?.videoWidth) || 0;
const h0 = Number(videoEl?.videoHeight) || 0;
if (!(w0 > 0 && h0 > 0)) return false;
proc.naturalW = w0;
proc.naturalH = h0;
proc.scale = computeScale(w0, h0);
const w = Math.max(1, Math.round(w0 * proc.scale));
const h = Math.max(1, Math.round(h0 * proc.scale));
if (srcCanvas.width !== w) srcCanvas.width = w;
if (srcCanvas.height !== h) srcCanvas.height = h;
// When WebGL is active it owns canvas dimensions (ensureSize in
// videoProcessorWebGL.js). Setting canvas.width here would clear
// the WebGL backbuffer and fight the GL viewport.
if (!glProc) {
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
}
canvas._mjrNaturalW = w0;
canvas._mjrNaturalH = h0;
canvas._mjrPixelScale = proc.scale;
proc.ready = true;
return true;
} catch {
return false;
}
};
const drawSource = () => {
if (!srcCtx) return false;
if (!proc.ready) return false;
try {
srcCtx.drawImage(videoEl, 0, 0, srcCanvas.width, srcCanvas.height);
return true;
} catch {
return false;
}
};
const renderProcessedFrame = () => {
if (!proc.ready) return;
const params = proc.lastParams || getGradeParams?.() || {};
if (glProc) {
glProc.update(params);
return;
}
if (!ctx || !srcCtx) return;
// Optimization: if no grading controls are active, bypass the expensive readback
// and draw the video element directly to the destination canvas.
if (isDefaultGrade?.(params)) {
try {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
} catch (e: any) {
console.debug?.(e);
}
return;
}
// Grading active: requires Source -> Buffer -> CPU Transform -> Dest
if (!drawSource()) return;
let src;
try {
src = srcCtx.getImageData(0, 0, srcCanvas.width, srcCanvas.height);
} catch {
try {
// Fallback if readback fails (e.g. tainted canvas)
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
} catch (e: any) {
console.debug?.(e);
}
return;
}
const w = srcCanvas.width;
const h = srcCanvas.height;
// Reuse buffer if possible to reduce GC churn
let dst = proc._buffer;
if (!dst || dst.width !== w || dst.height !== h) {
try {
dst = ctx.createImageData(w, h);
proc._buffer = dst;
} catch {
try {
dst = new ImageData(w, h);
proc._buffer = dst;
} catch {
return;
}
}
}
// Safety check if buffer creation failed
if (!dst) return;
const exposureEV = Number(params.exposureEV) || 0;
const gamma = Math.max(0.1, Math.min(3, Number(params.gamma) || 1));
const invGamma = 1 / gamma;
const channel = String(params.channel || "rgb");
const analysisMode = String(params.analysisMode || "none");
const zebraThreshold = clamp01(params.zebraThreshold ?? 0.95);
const exposureScale = Math.pow(2, exposureEV);
const s = src.data;
const d = dst.data;
const canUseLut = analysisMode !== "zebra" && channel === "rgb";
let lut: any = null;
if (canUseLut) {
const lutKey = `${exposureScale.toFixed(6)}|${invGamma.toFixed(6)}`;
if (!proc._lut || proc._lutKey !== lutKey) {
const nextLut = new Uint8ClampedArray(256);
for (let j = 0; j < 256; j += 1) {
const v = j / 255;
nextLut[j] = Math.round(Math.pow(clamp01(v * exposureScale), invGamma) * 255);
}
proc._lut = nextLut;
proc._lutKey = lutKey;
}
lut = proc._lut;
}
if (lut) {
for (let i = 0; i < d.length; i += 4) {
d[i] = lut[s[i] ?? 0];
d[i + 1] = lut[s[i + 1] ?? 0];
d[i + 2] = lut[s[i + 2] ?? 0];
d[i + 3] = 255;
}
} else {
for (let i = 0; i < d.length; i += 4) {
const r0 = (s[i] ?? 0) / 255;
const g0 = (s[i + 1] ?? 0) / 255;
const b0 = (s[i + 2] ?? 0) / 255;
const a0 = (s[i + 3] ?? 255) / 255;
let rr = r0 * exposureScale;
let gg = g0 * exposureScale;
let bb = b0 * exposureScale;
const lum = 0.2126 * rr + 0.7152 * gg + 0.0722 * bb;
if (analysisMode === "zebra") {
// Input is 8-bit video; avoid tonemapping (it alters baseline contrast/colors).
const over = clamp01(lum) >= zebraThreshold;
if (over) {
const stripe = (((Math.floor(i / 4) % w) + Math.floor(i / 4 / w)) & 7) < 3;
rr = stripe ? 1 : 0;
gg = stripe ? 1 : 0;
bb = stripe ? 1 : 0;
} else {
rr = Math.pow(clamp01(rr), invGamma);
gg = Math.pow(clamp01(gg), invGamma);
bb = Math.pow(clamp01(bb), invGamma);
}
} else {
rr = Math.pow(clamp01(rr), invGamma);
gg = Math.pow(clamp01(gg), invGamma);
bb = Math.pow(clamp01(bb), invGamma);
}
if (channel === "r") {
gg = rr;
bb = rr;
} else if (channel === "g") {
rr = gg;
bb = gg;
} else if (channel === "b") {
rr = bb;
gg = bb;
} else if (channel === "a") {
rr = a0;
gg = a0;
bb = a0;
} else if (channel === "l") {
const l = clamp01(lum);
const lg = Math.pow(l, invGamma);
rr = lg;
gg = lg;
bb = lg;
}
d[i] = Math.round(clamp01(rr) * 255);
d[i + 1] = Math.round(clamp01(gg) * 255);
d[i + 2] = Math.round(clamp01(bb) * 255);
d[i + 3] = 255;
}
}
try {
ctx.putImageData(dst, 0, 0);
} catch (e: any) {
console.debug?.(e);
}
};
const renderOnce = () => {
if (proc._destroyed) return;
if (!canvas?.isConnected) return;
if (!proc.ready) ensureSizeFromVideo();
if (!proc.ready) return;
// Skip duplicate heavy renders when neither time nor grading params changed.
try {
const params = proc.lastParams || getGradeParams?.() || {};
const heavy = !isDefaultGrade?.(params);
if (heavy) {
const t = Number(videoEl?.currentTime) || 0;
const sig = `${Number(params.exposureEV) || 0}|${Number(params.gamma) || 1}|${String(params.channel || "rgb")}|${String(params.analysisMode || "none")}|${Number(params.zebraThreshold ?? 0.95)}`;
if (
Math.abs(t - (Number(proc._lastFrameTime) || 0)) < 1e-6 &&
sig === String(proc._lastHeavySig || "")
) {
return;
}
proc._lastFrameTime = t;
proc._lastHeavySig = sig;
}
} catch (e: any) {
console.debug?.(e);
}
renderProcessedFrame();
};
const scheduleWhenConnected = () => {
if (proc._destroyed) return;
try {
if (canvas?.isConnected) {
proc._connectRAF = null;
proc._connectTries = 0;
scheduleRender();
return;
}
} catch (e: any) {
console.debug?.(e);
}
if (proc._connectRAF != null) return;
proc._connectTries = (Number(proc._connectTries) || 0) + 1;
if (proc._connectTries > 20) {
proc._connectRAF = null;
proc._connectTries = 0;
return;
}
try {
proc._connectRAF = requestAnimationFrame(() => {
proc._connectRAF = null;
scheduleWhenConnected();
});
} catch {
proc._connectRAF = null;
}
};
const _getThrottleMs = () => {
try {
const fps = Number(throttleFps);
if (!Number.isFinite(fps) || fps <= 0) return 0;
return Math.max(0, Math.floor(1000 / Math.max(1, fps)));
} catch {
return 0;
}
};
const scheduleRender = () => {
if (proc._destroyed) return;
if (proc._runtimePaused) return;
if (proc._rendering) return;
if (!canvas?.isConnected) {
scheduleWhenConnected();
return;
}
const params = proc.lastParams || getGradeParams?.() || {};
const heavy = !isDefaultGrade?.(params);
const throttleMs = heavy && !videoEl?.paused ? _getThrottleMs() : 0;
if (throttleMs > 0) {
const now = Date.now();
const nextOkAt = (Number(proc._lastHeavyRenderAt) || 0) + throttleMs;
if (now < nextOkAt) {
try {
if (proc._throttleTimer) clearTimeout(proc._throttleTimer);
} catch (e: any) {
console.debug?.(e);
}
try {
proc._throttleTimer = setTimeout(
() => {
try {
proc._throttleTimer = null;
} catch (e: any) {
console.debug?.(e);
}
scheduleRender();
},
Math.min(250, Math.max(0, nextOkAt - now)),
);
} catch (e: any) {
console.debug?.(e);
}
return;
}
}
proc._rendering = true;
try {
proc._rafIdSchedule = requestAnimationFrame(() => {
proc._rafIdSchedule = null;
proc._rendering = false;
renderOnce();
try {
if (heavy) proc._lastHeavyRenderAt = Date.now();
} catch (e: any) {
console.debug?.(e);
}
});
} catch {
proc._rendering = false;
}
};
const startFrameLoop = () => {
if (proc._destroyed) return;
if (proc._runtimePaused) return;
// Cancel any previously-registered frame loops before starting new ones.
// Without this, every pause->play cycle (including seeks that temporarily
// pause the video) accumulates an extra rVFC/RAF callback. After N cycles
// N callbacks fire every frame, each enqueuing a redundant scheduleRender().
try {
if (proc._rvfc != null && typeof videoEl?.cancelVideoFrameCallback === "function") {
videoEl.cancelVideoFrameCallback(proc._rvfc);
proc._rvfc = null;
}
} catch (e: any) {
console.debug?.(e);
}
try {
if (proc._rafIdLoop != null) {
cancelAnimationFrame(proc._rafIdLoop);
proc._rafIdLoop = null;
}
} catch (e: any) {
console.debug?.(e);
}
try {
if (typeof videoEl?.requestVideoFrameCallback === "function") {
const cb = () => {
if (proc._destroyed) return;
if (!canvas?.isConnected) return;
scheduleRender();
if (!videoEl.paused) {
try {
proc._rvfc = videoEl.requestVideoFrameCallback(cb);
} catch (e: any) {
console.debug?.(e);
}
}
};
try {
proc._rvfc = videoEl.requestVideoFrameCallback(cb);
} catch (e: any) {
console.debug?.(e);
}
return;
}
} catch (e: any) {
console.debug?.(e);
}
const loop = () => {
if (proc._destroyed) return;
if (!canvas?.isConnected) return;
scheduleRender();
if (!videoEl.paused) {
try {
proc._rafIdLoop = requestAnimationFrame(loop);
} catch (e: any) {
console.debug?.(e);
}
}
};
try {
proc._rafIdLoop = requestAnimationFrame(loop);
} catch (e: any) {
console.debug?.(e);
}
};
const setParams = (params: any) => {
proc.lastParams = params || proc.lastParams || getGradeParams?.();
scheduleRender();
};
const pauseProcessing = () => {
proc._runtimePaused = true;
try {
if (proc._throttleTimer) clearTimeout(proc._throttleTimer);
} catch (e: any) {
console.debug?.(e);
}
proc._throttleTimer = null;
try {
if (proc._rvfc != null && typeof videoEl?.cancelVideoFrameCallback === "function") {
videoEl.cancelVideoFrameCallback(proc._rvfc);
}
} catch (e: any) {
console.debug?.(e);
}
proc._rvfc = null;
try {
if (proc._rafIdLoop != null) cancelAnimationFrame(proc._rafIdLoop);
} catch (e: any) {
console.debug?.(e);
}
proc._rafIdLoop = null;
try {
if (proc._rafIdSchedule != null) cancelAnimationFrame(proc._rafIdSchedule);
} catch (e: any) {
console.debug?.(e);
}
proc._rafIdSchedule = null;
try {
if (proc._seekRaf != null) cancelAnimationFrame(proc._seekRaf);
} catch (e: any) {
console.debug?.(e);
}
proc._seekRaf = null;
proc._rendering = false;
};
const resumeProcessing = () => {
proc._runtimePaused = false;
if (videoEl?.paused) {
scheduleRender();
return;
}
startFrameLoop();
scheduleRender();
};
const sampleAtOriginal = (x: any, y: any) => {
try {
if (!proc.ready) ensureSizeFromVideo();
if (!proc.ready) return null;
drawSource();
const scale = proc.scale || 1;
const sx = Math.max(
0,
Math.min(srcCanvas.width - 1, Math.floor((Number(x) || 0) * scale)),
);
const sy = Math.max(
0,
Math.min(srcCanvas.height - 1, Math.floor((Number(y) || 0) * scale)),
);
if (!sampleCtx) return null;
sampleCtx.clearRect(0, 0, 1, 1);
sampleCtx.drawImage(srcCanvas, sx, sy, 1, 1, 0, 0, 1, 1);
const data = sampleCtx.getImageData(0, 0, 1, 1)?.data;
if (!data || data.length < 4) return null;
const r = data[0] ?? 0;
const g = data[1] ?? 0;
const b = data[2] ?? 0;
const a = data[3] ?? 255;
const raw = [r / 255, g / 255, b / 255, a / 255];
const ev = Number(proc.lastParams?.exposureEV) || 0;
const exposureScale = Math.pow(2, ev);
const lin = [
raw[0] * exposureScale,
raw[1] * exposureScale,
raw[2] * exposureScale,
raw[3],
];
return { r, g, b, a, raw, lin, scale: proc.scale };
} catch {
return null;
}
};
const onMeta = () => {
ensureSizeFromVideo();
scheduleRender();
try {
onReady?.({ naturalW: proc.naturalW, naturalH: proc.naturalH, pixelScale: proc.scale });
} catch (e: any) {
console.debug?.(e);
}
};
const onRuntimeStatus = (event: any) => {
if (!shouldPauseDuringExecution) return;
const activePromptId = String(event?.detail?.active_prompt_id || "").trim();
if (activePromptId) {
pauseProcessing();
return;
}
resumeProcessing();
};
try {
const onPlay = () => {
if (proc._runtimePaused) return;
startFrameLoop();
};
// Live scrub: while the element is actively seeking (e.g. the user is
// dragging the timeline while paused), the browser coalesces rapid
// currentTime changes and only fires a single `seeked` once it settles.
// Without this, the canvas stays frozen on the last fully-seeked frame
// and the previewed image does not follow the playhead during the drag.
// Drive repaints with a short rAF loop so each newly-decoded frame is
// drawn to the canvas as the seek progresses.
const stopSeekLoop = () => {
try {
if (proc._seekRaf != null) cancelAnimationFrame(proc._seekRaf);
} catch (e: any) {
console.debug?.(e);
}
proc._seekRaf = null;
};
const onSeeking = () => {
if (proc._destroyed || proc._runtimePaused) return;
// While playing, the normal frame loop (rVFC/rAF) already repaints.
try {
if (!videoEl?.paused) {
scheduleRender();
return;
}
} catch (e: any) {
console.debug?.(e);
}
if (proc._seekRaf != null) return;
const loop = () => {
proc._seekRaf = null;
if (proc._destroyed) return;
if (!canvas?.isConnected) return;
scheduleRender();
let stillSeeking = false;
try {
stillSeeking = !!videoEl?.seeking;
} catch (e: any) {
console.debug?.(e);
}
if (stillSeeking) {
try {
proc._seekRaf = requestAnimationFrame(loop);
} catch (e: any) {
console.debug?.(e);
}
}
};
try {
proc._seekRaf = requestAnimationFrame(loop);
} catch (e: any) {
console.debug?.(e);
}
};
const onSeeked = () => {
stopSeekLoop();
scheduleRender();
};
const onError = () => {
proc.ready = false;
try {
const errCode = videoEl?.error?.code;
const errMsg = videoEl?.error?.message || "";
// MediaError codes: 1=aborted, 2=network, 3=decode, 4=src_not_supported
let msg, hint;
if (errCode === 2) {
msg = "Failed to load video (network / path error)";
hint = "Check file permissions / path, or try re-indexing.";
} else if (errCode === 3) {
msg = "Failed to load video (decode error - unsupported codec?)";
hint =
"Browser may not support this codec (e.g. H.265/HEVC). Try converting to H.264/MP4.";
} else if (errCode === 4) {
msg = "Failed to load video (unsupported format or codec)";
hint =
"Browser cannot decode this file (e.g. H.265/HEVC). Try converting to H.264/MP4.";
} else {
msg = "Failed to load video";
hint = errMsg || "Check file permissions / path, or try re-indexing.";
}
console.warn("[MJR] Video load error", {
code: errCode,
message: errMsg,
src: videoEl?.src,
});
drawMediaError(canvas, msg, hint);
} catch (e: any) {
console.debug?.(e);
}
};
unsubs.push(
safeAddListener?.(videoEl, "loadedmetadata", onMeta, { once: true }) || (() => {}),
);
unsubs.push(
safeAddListener?.(videoEl, "seeking", onSeeking, { passive: true }) || (() => {}),
);
unsubs.push(
safeAddListener?.(videoEl, "seeked", onSeeked, { passive: true }) || (() => {}),
);
unsubs.push(
safeAddListener?.(videoEl, "pause", scheduleRender, { passive: true }) || (() => {}),
);
unsubs.push(safeAddListener?.(videoEl, "play", onPlay, { passive: true }) || (() => {}));
unsubs.push(
safeAddListener?.(
videoEl,
"timeupdate",
() => {
try {
if (
!videoEl?.paused &&
typeof videoEl?.requestVideoFrameCallback === "function"
) {
return;
}
} catch (e: any) {
console.debug?.(e);
}
scheduleRender();
},
{ passive: true },
) || (() => {}),
);
unsubs.push(safeAddListener?.(videoEl, "error", onError, { passive: true }) || (() => {}));
if (shouldPauseDuringExecution) {
window.addEventListener(EVENTS.RUNTIME_STATUS, onRuntimeStatus);
if (String((window as any)?.__MJR_EXECUTION_RUNTIME__?.active_prompt_id || "").trim()) {
pauseProcessing();
}
}
} catch (e: any) {
console.debug?.(e);
}
return {
setParams,
sampleAtOriginal,
getInfo: () => ({ ...proc, renderer: glProc ? "webgl" : "2d" }),
pause: pauseProcessing,
resume: resumeProcessing,
destroy: () => {
if (glProc) glProc.destroy();
proc._destroyed = true;
try {
window.removeEventListener(EVENTS.RUNTIME_STATUS, onRuntimeStatus);
} catch (e: any) {
console.debug?.(e);
}
try {
if (proc._throttleTimer) clearTimeout(proc._throttleTimer);
} catch (e: any) {
console.debug?.(e);
}
proc._throttleTimer = null;
try {
if (proc._connectRAF != null) cancelAnimationFrame(proc._connectRAF);
} catch (e: any) {
console.debug?.(e);
}
proc._connectRAF = null;
proc._connectTries = 0;
try {
if (proc._rvfc != null && typeof videoEl?.cancelVideoFrameCallback === "function") {
videoEl.cancelVideoFrameCallback(proc._rvfc);
}
} catch (e: any) {
console.debug?.(e);
}
try {
if (proc._rafIdLoop != null) cancelAnimationFrame(proc._rafIdLoop);
} catch (e: any) {
console.debug?.(e);
}
try {
if (proc._rafIdSchedule != null) cancelAnimationFrame(proc._rafIdSchedule);
} catch (e: any) {
console.debug?.(e);
}
try {
if (proc._seekRaf != null) cancelAnimationFrame(proc._seekRaf);
} catch (e: any) {
console.debug?.(e);
}
try {
for (const u of unsubs) safeCall?.(u);
} catch (e: any) {
console.debug?.(e);
}
try {
srcCanvas.width = 0;
srcCanvas.height = 0;
} catch (e: any) {
console.debug?.(e);
}
try {
canvas.width = 0;
canvas.height = 0;
} catch (e: any) {
console.debug?.(e);
}
proc._buffer = null;
},
};
}