feat: Add media preview support with video and image handling in LivePreview
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user