From 97619b45fc4dd3239d01936720cd4c7ce9aed87b Mon Sep 17 00:00:00 2001 From: MajoorWaldi Date: Fri, 27 Mar 2026 10:54:16 +0100 Subject: [PATCH] feat: Add media preview support with video and image handling in LivePreview --- __init__.py | 1 + js/preview/host.js | 121 +++++++++++++++++++++++++++++++++++++++++ server.py | 118 ++++++++++++++++++++++++++++++++++++++++ src/preview/host.ts | 129 ++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 3 ++ 5 files changed, 372 insertions(+) create mode 100644 server.py diff --git a/__init__.py b/__init__.py index 2969d25..37bc8e6 100644 --- a/__init__.py +++ b/__init__.py @@ -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 diff --git a/js/preview/host.js b/js/preview/host.js index 9b5b59e..6b59e9f 100644 --- a/js/preview/host.js +++ b/js/preview/host.js @@ -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; diff --git a/server.py b/server.py new file mode 100644 index 0000000..52edfb7 --- /dev/null +++ b/server.py @@ -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 diff --git a/src/preview/host.ts b/src/preview/host.ts index cb6a81f..3a2cb8f 100644 --- a/src/preview/host.ts +++ b/src/preview/host.ts @@ -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; diff --git a/src/types.ts b/src/types.ts index 570bdfc..d036cb3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 | null; lastKey: string | null;