feat: Add media preview support with video and image handling in LivePreview

This commit is contained in:
MajoorWaldi
2026-03-27 10:54:16 +01:00
parent 83fe6ef4b9
commit 97619b45fc
5 changed files with 372 additions and 0 deletions
+1
View File
@@ -49,6 +49,7 @@ _ensure_pkg(_PKG, BASE_DIR, BASE_DIR / "__init__.py")
_ensure_pkg(f"{_PKG}.nodes", BASE_DIR / "nodes", BASE_DIR / "nodes" / "__init__.py")
_nodes_dir = BASE_DIR / "nodes"
_load_module(f"{_PKG}.server", BASE_DIR / "server.py")
ImageOpsBlur = _load_module(f"{_PKG}.nodes.blur", _nodes_dir / "blur.py").ImageOpsBlur
ImageOpsChannel = _load_module(f"{_PKG}.nodes.channel", _nodes_dir / "channel.py").ImageOpsChannel
+121
View File
@@ -47,6 +47,9 @@ function ensureState(node) {
info: null,
progressWrap: null,
progressBar: null,
mediaWrap: null,
mediaVideo: null,
mediaImage: null,
rafId: null,
debounceTimer: null,
lastKey: null,
@@ -208,6 +211,33 @@ function ensurePreviewWidget(node, progress, canvasSize) {
canvas.style.background = "rgba(0,0,0,0.35)";
canvas.style.border = "1px solid rgba(255,255,255,0.08)";
canvas.style.touchAction = "none";
const mediaWrap = document.createElement("div");
mediaWrap.style.width = "100%";
mediaWrap.style.display = "none";
mediaWrap.style.borderRadius = "8px";
mediaWrap.style.overflow = "hidden";
mediaWrap.style.background = "rgba(0,0,0,0.35)";
mediaWrap.style.border = "1px solid rgba(255,255,255,0.08)";
const mediaVideo = document.createElement("video");
mediaVideo.controls = false;
mediaVideo.loop = true;
mediaVideo.muted = true;
mediaVideo.playsInline = true;
mediaVideo.autoplay = true;
mediaVideo.preload = "metadata";
mediaVideo.style.width = "100%";
mediaVideo.style.height = "auto";
mediaVideo.style.display = "block";
mediaVideo.style.background = "transparent";
mediaVideo.hidden = true;
const mediaImage = document.createElement("img");
mediaImage.style.width = "100%";
mediaImage.style.height = "auto";
mediaImage.style.display = "block";
mediaImage.style.background = "transparent";
mediaImage.hidden = true;
mediaWrap.appendChild(mediaVideo);
mediaWrap.appendChild(mediaImage);
const metaRow = document.createElement("div");
metaRow.style.marginTop = "6px";
metaRow.style.display = "flex";
@@ -442,6 +472,7 @@ function ensurePreviewWidget(node, progress, canvasSize) {
progressBar.style.borderRadius = "999px";
progressBar.style.background = "rgba(255,255,255,0.55)";
progressWrap.appendChild(progressBar);
root.appendChild(mediaWrap);
root.appendChild(canvas);
metaRow.appendChild(info);
if (cropResetButton) metaRow.appendChild(cropResetButton);
@@ -468,6 +499,9 @@ function ensurePreviewWidget(node, progress, canvasSize) {
st.info = info;
st.progressWrap = progressWrap;
st.progressBar = progressBar;
st.mediaWrap = mediaWrap;
st.mediaVideo = mediaVideo;
st.mediaImage = mediaImage;
st.cropResetButton = cropResetButton;
st.drawBrushButton = drawBrushButton;
st.drawEraserButton = drawEraserButton;
@@ -574,6 +608,88 @@ function getNativePreviewImage(node) {
const candidate = imgs[Math.max(0, Math.min(imgs.length - 1, index))] ?? imgs[imgs.length - 1] ?? null;
return candidate instanceof HTMLImageElement ? candidate : null;
}
function hideNativeMediaPreview(st) {
if (st.mediaVideo) {
st.mediaVideo.pause();
st.mediaVideo.hidden = true;
st.mediaVideo.removeAttribute("src");
st.mediaVideo.load();
}
if (st.mediaImage) {
st.mediaImage.hidden = true;
st.mediaImage.removeAttribute("src");
}
if (st.mediaWrap) st.mediaWrap.style.display = "none";
if (st.canvas) st.canvas.style.display = "block";
}
function parsePreviewSourceUrl(src) {
try {
return new URL(src, window.location.href);
} catch {
return null;
}
}
function nativePreviewFilename(node) {
const img = getNativePreviewImage(node);
const parsed = parsePreviewSourceUrl(img?.src ?? "");
return parsed?.searchParams.get("filename") ?? null;
}
function buildMediaPreviewUrl(node, canvasSize) {
const img = getNativePreviewImage(node);
const parsed = parsePreviewSourceUrl(img?.src ?? "");
if (!parsed) return null;
const filename = parsed.searchParams.get("filename");
const type = parsed.searchParams.get("type") ?? "temp";
const subfolder = parsed.searchParams.get("subfolder") ?? "";
if (!filename) return null;
const params = new URLSearchParams({
filename,
type,
subfolder,
force_size: `${Math.max(128, canvasSize * 2)}x?`,
deadline: "realtime"
});
return api.apiURL(`/imageops/viewmedia?${params.toString()}`);
}
function showNativeMediaPreview(node, st, canvasSize) {
if (!st.nativeAnimated || !st.mediaWrap) return false;
const img = getNativePreviewImage(node);
if (!img) return false;
const filename = String(nativePreviewFilename(node) ?? "").toLowerCase();
const isAnimatedImage = filename.endsWith(".webp") || filename.endsWith(".gif");
if (isAnimatedImage && st.mediaImage) {
if (st.mediaVideo) {
st.mediaVideo.pause();
st.mediaVideo.hidden = true;
st.mediaVideo.removeAttribute("src");
st.mediaVideo.load();
}
st.mediaWrap.style.display = "block";
st.mediaImage.hidden = false;
if (st.mediaImage.src !== img.src) {
st.mediaImage.src = img.src;
}
if (st.canvas) st.canvas.style.display = "none";
return true;
}
if (!st.mediaVideo) return false;
const mediaUrl = buildMediaPreviewUrl(node, canvasSize);
if (!mediaUrl) return false;
if (st.mediaImage) {
st.mediaImage.hidden = true;
st.mediaImage.removeAttribute("src");
}
st.mediaWrap.style.display = "block";
st.mediaVideo.hidden = false;
if (st.mediaVideo.src !== mediaUrl) {
st.mediaVideo.src = mediaUrl;
}
st.mediaVideo.muted = true;
void st.mediaVideo.play().catch(() => {
});
if (st.canvas) st.canvas.style.display = "none";
return true;
}
function findWidget(node, name) {
return node?.widgets?.find((w) => w?.name === name) ?? null;
}
@@ -991,6 +1107,10 @@ function drawCompBounds(node, ctx, width, height, sourceWidth, sourceHeight, lay
function tryRenderNativePreview(node, st, canvasSize) {
if (isCropNode(node) || isCompNode(node) || isDrawNode(node)) return false;
if (st.nativeDirty) return false;
if (showNativeMediaPreview(node, st, canvasSize)) {
st.info.textContent = "Node preview (media)";
return true;
}
const img = getNativePreviewImage(node);
if (!img) return false;
if (!img.complete) {
@@ -1006,6 +1126,7 @@ function tryRenderNativePreview(node, st, canvasSize) {
}
function blit(node, st, source, canvasSize, sourceWidth, sourceHeight) {
if (!st.canvas) return;
hideNativeMediaPreview(st);
const ctx = st.canvas.getContext("2d");
if (!ctx) return;
if (st.canvas.width !== canvasSize) st.canvas.width = canvasSize;
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
import asyncio
import os
import shutil
import subprocess
from pathlib import Path
import folder_paths
import server
web = server.web
def _ffmpeg_path() -> str | None:
forced = os.getenv("IMAGEOPS_FORCE_FFMPEG_PATH")
if forced and os.path.isfile(forced):
return forced
try:
from imageio_ffmpeg import get_ffmpeg_exe
candidate = get_ffmpeg_exe()
if candidate and os.path.isfile(candidate):
return candidate
except Exception:
pass
return shutil.which("ffmpeg")
def _base_dir(kind: str) -> str:
normalized = str(kind or "temp").strip().lower()
if normalized == "input":
return folder_paths.get_input_directory()
if normalized == "output":
return folder_paths.get_output_directory()
return folder_paths.get_temp_directory()
def _resolve_preview_path(query) -> tuple[str, str]:
filename = str(query.get("filename") or "").strip()
subfolder = str(query.get("subfolder") or "").strip().replace("\\", "/")
kind = str(query.get("type") or "temp").strip().lower()
if not filename:
raise web.HTTPBadRequest(text="Missing filename")
root = Path(_base_dir(kind)).resolve()
candidate = (root / subfolder / filename).resolve()
try:
common = os.path.commonpath([str(root), str(candidate)])
except ValueError:
common = ""
if common != str(root):
raise web.HTTPForbidden(text="Invalid preview path")
if not candidate.is_file():
raise web.HTTPNotFound(text="Preview file not found")
return str(candidate), candidate.name
def _ext(path: str) -> str:
return Path(path).suffix.lower()
def _force_size_filter(force_size: str) -> str | None:
raw = str(force_size or "").strip()
if not raw or raw.lower() == "disabled":
return None
if "x" not in raw:
return None
width, height = raw.split("x", 1)
width = width.strip() or "?"
height = height.strip() or "?"
width_expr = "-2" if width == "?" else f"min({int(float(width))},iw)"
height_expr = "-2" if height == "?" else f"min({int(float(height))},ih)"
return f"scale={width_expr}:{height_expr}:flags=lanczos"
@server.PromptServer.instance.routes.get("/imageops/viewmedia")
async def imageops_viewmedia(request):
path, filename = _resolve_preview_path(request.rel_url.query)
ext = _ext(path)
ffmpeg = _ffmpeg_path()
force_filter = _force_size_filter(request.rel_url.query.get("force_size", ""))
animated_like = ext in {".gif", ".webp", ".mp4", ".mov", ".webm", ".mkv", ".avi"}
if ffmpeg is None or not animated_like:
return web.FileResponse(path=path)
args = [ffmpeg, "-v", "error", "-i", path]
if force_filter:
args += ["-vf", force_filter]
deadline = "good" if str(request.rel_url.query.get("deadline", "realtime")).lower() == "good" else "realtime"
args += ["-an", "-c:v", "libvpx-vp9", "-deadline", deadline, "-cpu-used", "8", "-f", "webm", "-"]
proc = await asyncio.create_subprocess_exec(
*args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
)
response = web.StreamResponse()
response.content_type = "video/webm"
response.headers["Content-Disposition"] = f'filename="{Path(filename).stem}.webm"'
await response.prepare(request)
try:
while proc.stdout is not None:
chunk = await proc.stdout.read(2**20)
if not chunk:
break
await response.write(chunk)
await proc.wait()
except (ConnectionResetError, ConnectionError, asyncio.CancelledError):
proc.kill()
finally:
if proc.returncode is None:
proc.kill()
return response
+129
View File
@@ -68,6 +68,9 @@ function ensureState(node: ComfyNode): NodeState {
info: null,
progressWrap: null,
progressBar: null,
mediaWrap: null,
mediaVideo: null,
mediaImage: null,
rafId: null,
debounceTimer: null,
lastKey: null,
@@ -249,6 +252,37 @@ function ensurePreviewWidget(node: ComfyNode, progress: ProgressBus, canvasSize:
canvas.style.border = "1px solid rgba(255,255,255,0.08)";
canvas.style.touchAction = "none";
const mediaWrap = document.createElement("div");
mediaWrap.style.width = "100%";
mediaWrap.style.display = "none";
mediaWrap.style.borderRadius = "8px";
mediaWrap.style.overflow = "hidden";
mediaWrap.style.background = "rgba(0,0,0,0.35)";
mediaWrap.style.border = "1px solid rgba(255,255,255,0.08)";
const mediaVideo = document.createElement("video");
mediaVideo.controls = false;
mediaVideo.loop = true;
mediaVideo.muted = true;
mediaVideo.playsInline = true;
mediaVideo.autoplay = true;
mediaVideo.preload = "metadata";
mediaVideo.style.width = "100%";
mediaVideo.style.height = "auto";
mediaVideo.style.display = "block";
mediaVideo.style.background = "transparent";
mediaVideo.hidden = true;
const mediaImage = document.createElement("img");
mediaImage.style.width = "100%";
mediaImage.style.height = "auto";
mediaImage.style.display = "block";
mediaImage.style.background = "transparent";
mediaImage.hidden = true;
mediaWrap.appendChild(mediaVideo);
mediaWrap.appendChild(mediaImage);
const metaRow = document.createElement("div");
metaRow.style.marginTop = "6px";
metaRow.style.display = "flex";
@@ -520,6 +554,7 @@ function ensurePreviewWidget(node: ComfyNode, progress: ProgressBus, canvasSize:
progressBar.style.background = "rgba(255,255,255,0.55)";
progressWrap.appendChild(progressBar);
root.appendChild(mediaWrap);
root.appendChild(canvas);
metaRow.appendChild(info);
if (cropResetButton) metaRow.appendChild(cropResetButton);
@@ -549,6 +584,9 @@ function ensurePreviewWidget(node: ComfyNode, progress: ProgressBus, canvasSize:
st.info = info;
st.progressWrap = progressWrap;
st.progressBar = progressBar;
st.mediaWrap = mediaWrap;
st.mediaVideo = mediaVideo;
st.mediaImage = mediaImage;
st.cropResetButton = cropResetButton;
st.drawBrushButton = drawBrushButton;
st.drawEraserButton = drawEraserButton;
@@ -675,6 +713,92 @@ function getNativePreviewImage(node: ComfyNode): HTMLImageElement | null {
return candidate instanceof HTMLImageElement ? candidate : null;
}
function hideNativeMediaPreview(st: NodeState): void {
if (st.mediaVideo) {
st.mediaVideo.pause();
st.mediaVideo.hidden = true;
st.mediaVideo.removeAttribute("src");
st.mediaVideo.load();
}
if (st.mediaImage) {
st.mediaImage.hidden = true;
st.mediaImage.removeAttribute("src");
}
if (st.mediaWrap) st.mediaWrap.style.display = "none";
if (st.canvas) st.canvas.style.display = "block";
}
function parsePreviewSourceUrl(src: string): URL | null {
try {
return new URL(src, window.location.href);
} catch {
return null;
}
}
function nativePreviewFilename(node: ComfyNode): string | null {
const img = getNativePreviewImage(node);
const parsed = parsePreviewSourceUrl(img?.src ?? "");
return parsed?.searchParams.get("filename") ?? null;
}
function buildMediaPreviewUrl(node: ComfyNode, canvasSize: number): string | null {
const img = getNativePreviewImage(node);
const parsed = parsePreviewSourceUrl(img?.src ?? "");
if (!parsed) return null;
const filename = parsed.searchParams.get("filename");
const type = parsed.searchParams.get("type") ?? "temp";
const subfolder = parsed.searchParams.get("subfolder") ?? "";
if (!filename) return null;
const params = new URLSearchParams({
filename,
type,
subfolder,
force_size: `${Math.max(128, canvasSize * 2)}x?`,
deadline: "realtime",
});
return api.apiURL(`/imageops/viewmedia?${params.toString()}`);
}
function showNativeMediaPreview(node: ComfyNode, st: NodeState, canvasSize: number): boolean {
if (!st.nativeAnimated || !st.mediaWrap) return false;
const img = getNativePreviewImage(node);
if (!img) return false;
const filename = String(nativePreviewFilename(node) ?? "").toLowerCase();
const isAnimatedImage = filename.endsWith(".webp") || filename.endsWith(".gif");
if (isAnimatedImage && st.mediaImage) {
if (st.mediaVideo) {
st.mediaVideo.pause();
st.mediaVideo.hidden = true;
st.mediaVideo.removeAttribute("src");
st.mediaVideo.load();
}
st.mediaWrap.style.display = "block";
st.mediaImage.hidden = false;
if (st.mediaImage.src !== img.src) {
st.mediaImage.src = img.src;
}
if (st.canvas) st.canvas.style.display = "none";
return true;
}
if (!st.mediaVideo) return false;
const mediaUrl = buildMediaPreviewUrl(node, canvasSize);
if (!mediaUrl) return false;
if (st.mediaImage) {
st.mediaImage.hidden = true;
st.mediaImage.removeAttribute("src");
}
st.mediaWrap.style.display = "block";
st.mediaVideo.hidden = false;
if (st.mediaVideo.src !== mediaUrl) {
st.mediaVideo.src = mediaUrl;
}
st.mediaVideo.muted = true;
void st.mediaVideo.play().catch(() => {});
if (st.canvas) st.canvas.style.display = "none";
return true;
}
function findWidget(node: ComfyNode, name: string) {
return node?.widgets?.find((w) => w?.name === name) ?? null;
}
@@ -1180,6 +1304,10 @@ function drawCompBounds(
function tryRenderNativePreview(node: ComfyNode, st: NodeState, canvasSize: number): boolean {
if (isCropNode(node) || isCompNode(node) || isDrawNode(node)) return false;
if (st.nativeDirty) return false;
if (showNativeMediaPreview(node, st, canvasSize)) {
st.info!.textContent = "Node preview (media)";
return true;
}
const img = getNativePreviewImage(node);
if (!img) return false;
if (!img.complete) {
@@ -1203,6 +1331,7 @@ function blit(
sourceHeight?: number,
): void {
if (!st.canvas) return;
hideNativeMediaPreview(st);
const ctx = st.canvas.getContext("2d");
if (!ctx) return;
if (st.canvas.width !== canvasSize) st.canvas.width = canvasSize;
+3
View File
@@ -149,6 +149,9 @@ export interface NodeState {
info: HTMLDivElement | null;
progressWrap: HTMLDivElement | null;
progressBar: HTMLDivElement | null;
mediaWrap: HTMLDivElement | null;
mediaVideo: HTMLVideoElement | null;
mediaImage: HTMLImageElement | null;
rafId: number | null;
debounceTimer: ReturnType<typeof setTimeout> | null;
lastKey: string | null;