power video update
This commit is contained in:
@@ -10,6 +10,7 @@ import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import folder_paths
|
||||
|
||||
|
||||
@@ -43,6 +44,18 @@ def _to_float(val, default):
|
||||
return default
|
||||
|
||||
|
||||
def _to_int(val, default):
|
||||
"""Coerce a value to int, handling dict and invalid types."""
|
||||
if isinstance(val, dict) or isinstance(val, bool):
|
||||
return default
|
||||
if isinstance(val, (int, float)):
|
||||
return int(val)
|
||||
try:
|
||||
return int(val) if val else default
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def extract_audio(file_path, start_time=0, duration=0):
|
||||
"""Extract audio from a video file using ffmpeg.
|
||||
|
||||
@@ -93,8 +106,8 @@ class PowerLoadVideo:
|
||||
Outputs:
|
||||
- IMAGE: Tensor of shape [frame_count, height, width, 3]
|
||||
- AUDIO: Audio waveform dict {"waveform", "sample_rate"}
|
||||
- FPS: Vide real FPS
|
||||
- METADATA: Dict containing frame boundaries, fps settings, and crop info
|
||||
- frame_count (INT): count of frames in the output
|
||||
- METADATA: Dict containing frame boundaries, fps settings, crop info, and final output dimensions
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@@ -119,17 +132,20 @@ class PowerLoadVideo:
|
||||
"crop_y": ("FLOAT", {"default": 0.5, "min": 0, "max": 1, "step": 0.01}),
|
||||
"crop_w": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 1, "step": 0.01}),
|
||||
"crop_h": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 1, "step": 0.01}),
|
||||
"width": ("INT", {"default": 0," min": 128, "step": 32, "forceInput": True}),
|
||||
"height": ("INT", {"default": 0," min": 128, "step": 32, "forceInput": True}),
|
||||
"metadata": ("METADATA",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "AUDIO", "FPS", "METADATA")
|
||||
RETURN_TYPES = ("IMAGE", "AUDIO", "INT", "METADATA")
|
||||
RETURN_NAMES = ("image", "audio", "frame_count", "metadata")
|
||||
FUNCTION = "load_video"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "Power/Video"
|
||||
DESCRIPTION = "Load a video file via drag-and-drop. Outputs frames as IMAGE tensor, audio, FPS, and metadata."
|
||||
DESCRIPTION = "Load a video file via drag-and-drop. Outputs frames as IMAGE tensor, audio, frame count, and metadata."
|
||||
|
||||
def load_video(self, video=None, start_frame=1, end_frame=-1, force_fps=0, max_fps=0, crop_enabled=False, crop_x=0.5, crop_y=0.5, crop_w=1.0, crop_h=1.0, metadata=None):
|
||||
def load_video(self, video=None, start_frame=1, end_frame=-1, force_fps=0, max_fps=0, crop_enabled=False, crop_x=0.5, crop_y=0.5, crop_w=1.0, crop_h=1.0, width=None, height=None, metadata=None):
|
||||
"""
|
||||
Load video frames and audio from uploaded video file.
|
||||
|
||||
@@ -140,11 +156,19 @@ class PowerLoadVideo:
|
||||
force_fps: Force output FPS (0 = native). Same logic as VHS force_rate.
|
||||
max_fps: Maximum output frames (0 = disabled). Calculates required source frames
|
||||
based on FPS conversion ratio. Ignores end_frame trim when set.
|
||||
width: Optional target output width (0/None = disabled). Applied after crop.
|
||||
If only one of width/height is set, the other is computed proportionally.
|
||||
Final size is snapped to the closest dimensions divisible by 32.
|
||||
Frames are LANCZOS-scaled to cover the target (no stretching) and
|
||||
center-cropped to it.
|
||||
height: Optional target output height (0/None = disabled). Same rules as width.
|
||||
metadata: Optional METADATA dict from another PowerLoadVideo or ChainEditVideo node.
|
||||
If provided and contains crop info, will apply the same crop to this video.
|
||||
If the source was resized via width/height inputs (resized=True),
|
||||
applies the same resize so outputs match dimensions.
|
||||
|
||||
Returns:
|
||||
tuple: (IMAGE tensor, AUDIO dict, fps, metadata_dict)
|
||||
tuple: (IMAGE tensor, AUDIO dict, frame_count INT, metadata_dict)
|
||||
"""
|
||||
|
||||
# Extract crop settings from metadata if provided
|
||||
@@ -157,6 +181,11 @@ class PowerLoadVideo:
|
||||
crop_h = metadata.get("crop_h", 1.0)
|
||||
crop_enabled = True
|
||||
|
||||
# Apply the same resize as the source node if it was resized via width/height inputs
|
||||
if metadata.get("resized", False):
|
||||
width = metadata.get("width") or 0
|
||||
height = metadata.get("height") or 0
|
||||
|
||||
# Apply start_offset from metadata (adds to starting trim frame number)
|
||||
meta_start_offset = metadata.get("start_offset", 0)
|
||||
if meta_start_offset != 0:
|
||||
@@ -320,6 +349,35 @@ class PowerLoadVideo:
|
||||
bottom = top + snapped_h
|
||||
image_tensor = image_tensor[:, top:bottom, left:right, :]
|
||||
|
||||
# Apply width/height resize if provided (after crop, so both can work together)
|
||||
target_w = _to_int(width, 0)
|
||||
target_h = _to_int(height, 0)
|
||||
resized = False
|
||||
if target_w > 0 or target_h > 0:
|
||||
resized = True
|
||||
cur_h, cur_w = image_tensor.shape[1], image_tensor.shape[2]
|
||||
if target_w > 0 and target_h > 0:
|
||||
pass # use both as-is
|
||||
elif target_w > 0:
|
||||
target_h = int(round(target_w * cur_h / cur_w))
|
||||
else:
|
||||
target_w = int(round(target_h * cur_w / cur_h))
|
||||
# Snap to closest dimensions divisible by 32 (min 32)
|
||||
target_w = max(32, int(round(target_w / 32)) * 32)
|
||||
target_h = max(32, int(round(target_h / 32)) * 32)
|
||||
if (target_w, target_h) != (cur_w, cur_h):
|
||||
# No stretching: LANCZOS-scale to COVER the target size (aspect ratio
|
||||
# preserved), then center-crop to the exact target dimensions.
|
||||
scale = max(target_w / cur_w, target_h / cur_h)
|
||||
new_w = int(round(cur_w * scale))
|
||||
new_h = int(round(cur_h * scale))
|
||||
t = image_tensor.permute(0, 3, 1, 2)
|
||||
t = F.interpolate(t, size=(new_h, new_w), mode="lanczos", antialias=True)
|
||||
t = t.permute(0, 2, 3, 1).contiguous()
|
||||
left = (new_w - target_w) // 2
|
||||
top = (new_h - target_h) // 2
|
||||
image_tensor = t[:, top:top + target_h, left:left + target_w, :]
|
||||
|
||||
# Extract audio (skip trimming if full video)
|
||||
audio = None
|
||||
if full_video:
|
||||
@@ -343,9 +401,15 @@ class PowerLoadVideo:
|
||||
"crop_y": crop_y,
|
||||
"crop_w": crop_w,
|
||||
"crop_h": crop_h,
|
||||
# Final output frame dimensions (after crop + optional resize)
|
||||
"width": image_tensor.shape[2],
|
||||
"height": image_tensor.shape[1],
|
||||
# True if this node was resized via width/height inputs - chained nodes
|
||||
# receiving this metadata will apply the same resize
|
||||
"resized": resized,
|
||||
}
|
||||
|
||||
return (image_tensor, audio, target_fps, video_metadata)
|
||||
return (image_tensor, audio, image_tensor.shape[0], video_metadata)
|
||||
|
||||
def pil_totensor(self, images):
|
||||
"""Convert list of PIL Images to PyTorch tensor [N, H, W, C] in [0, 1]."""
|
||||
@@ -357,7 +421,7 @@ class PowerLoadVideo:
|
||||
return torch.from_numpy(stacked)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(s, video=None, start_frame=1, end_frame=-1, force_fps=0, max_fps=0, crop_enabled=False, crop_x=0.5, crop_y=0.5, crop_w=1.0, crop_h=1.0, metadata=None):
|
||||
def IS_CHANGED(s, video=None, start_frame=1, end_frame=-1, force_fps=0, max_fps=0, crop_enabled=False, crop_x=0.5, crop_y=0.5, crop_w=1.0, crop_h=1.0, width=None, height=None, metadata=None):
|
||||
if not video:
|
||||
return 0
|
||||
try:
|
||||
@@ -395,6 +459,9 @@ class PowerLoadVideo:
|
||||
m.update(f"{crop_y:.4f}".encode())
|
||||
m.update(f"{crop_w:.4f}".encode())
|
||||
m.update(f"{crop_h:.4f}".encode())
|
||||
# Include width/height in hash so changing them triggers re-execution
|
||||
m.update(str(_to_int(width, 0)).encode())
|
||||
m.update(str(_to_int(height, 0)).encode())
|
||||
# Include metadata hash if provided
|
||||
if metadata is not None and isinstance(metadata, dict):
|
||||
m.update(str(tuple(sorted(metadata.items()))).encode())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { TYPE_LABELS, TYPE_COLORS, COLOR_IMAGE, TEXT, getWidget, isReferenceMode } from "./h3_text_multiline.js";
|
||||
import { NODE_CLASS, TYPE_LABELS, TYPE_COLORS, COLOR_IMAGE, TEXT, getWidget, isReferenceMode } from "./h3_text_multiline.js";
|
||||
import { normalizeLinks } from "./links.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -63,6 +63,53 @@ const AUDIO_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24
|
||||
const CHIP_CLASS = "h3qq-mention-chip";
|
||||
const TOKEN_RE = /@(Picture|Video|Audio)\s+(\d+)/g;
|
||||
|
||||
// Self-heal state: when the contentEditable widget is detached (e.g. after a
|
||||
// tab/workspace switch that rebuilds node DOM) its text appears lost even
|
||||
// though the hidden "text" widget still holds it. Snapshot on every sync and
|
||||
// restore+rebuild if the editor element ever ends up outside the document.
|
||||
const HEAL_INTERVAL_MS = 3000;
|
||||
let healTimerId = null;
|
||||
|
||||
function getWidgetValueLocal(node) {
|
||||
return String(getWidget(node, "text")?.value ?? "");
|
||||
}
|
||||
|
||||
function snapshotNodeText(node) {
|
||||
const value = getWidgetValueLocal(node);
|
||||
node.properties ||= {};
|
||||
if (node.properties.__h3qqLastSyncedText !== value) node.properties.__h3qqLastSyncedText = value;
|
||||
}
|
||||
|
||||
function installSelfHeal() {
|
||||
if (typeof document === "undefined" || healTimerId != null) return;
|
||||
const onVisibility = () => { if (!document.hidden) check(); };
|
||||
const stop = () => { clearInterval(healTimerId); healTimerId = null; document.removeEventListener("visibilitychange", onVisibility); };
|
||||
const check = () => {
|
||||
let hasH3Node = false;
|
||||
for (const node of app.graph?._nodes || []) {
|
||||
if (String(node?.comfyClass || node?.type) !== NODE_CLASS) continue;
|
||||
hasH3Node = true;
|
||||
if (!node.__h3qqEditor || !document.contains(node.__h3qqEditor)) continue;
|
||||
const text = getWidgetValueLocal(node);
|
||||
const snapshot = String(node.properties?.__h3qqLastSyncedText ?? "");
|
||||
// The DOM is gone while the value lives on: restore it (only when
|
||||
// a real snapshot exists so we never clobber fresh content), then
|
||||
// rebuild the editor so text is visible again.
|
||||
if (text === "" && snapshot !== "") {
|
||||
const widget = getWidget(node, "text");
|
||||
if (widget) widget.value = snapshot;
|
||||
}
|
||||
node.__h3qqEditor = null;
|
||||
node.__h3qqEditorWrap = null;
|
||||
installPromptEditorFactory(node);
|
||||
snapshotNodeText(node);
|
||||
}
|
||||
if (!hasH3Node) stop();
|
||||
};
|
||||
healTimerId = setInterval(check, HEAL_INTERVAL_MS);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
|
||||
function setWidgetOption(widget, key, value) {
|
||||
if (!widget) return;
|
||||
widget.options ||= {};
|
||||
@@ -221,6 +268,7 @@ export function renderEditorFromNode(node, force = false) {
|
||||
if (token.type === "mention") editor.append(makeMentionChip(token.option));
|
||||
else appendTextWithBreaks(editor, token.value);
|
||||
}
|
||||
snapshotNodeText(node);
|
||||
}
|
||||
|
||||
function serializeEditorText(editor) {
|
||||
@@ -251,6 +299,7 @@ function syncEditorFromNode(node) {
|
||||
if (!editor || !widget) return;
|
||||
const text = serializeEditorText(editor);
|
||||
if (String(widget.value ?? "") !== text) widget.value = text;
|
||||
snapshotNodeText(node);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
app.graph?.change?.();
|
||||
}
|
||||
@@ -701,6 +750,10 @@ function installPromptEditorFactory(node) {
|
||||
domWidget.serialize = false;
|
||||
setWidgetOption(domWidget, "serialize", false);
|
||||
setWidgetOption(domWidget, "canvasOnly", false);
|
||||
// A healthy editor means any earlier retry delay has no business carrying
|
||||
// over: the next detach must recover fast again, not with a 2 s backoff.
|
||||
node.__h3qqHookAttempts = 0;
|
||||
installSelfHeal();
|
||||
// Keep the editor row directly under the (now hidden) text widget.
|
||||
const domIndex = node.widgets?.indexOf(domWidget) ?? -1;
|
||||
const textIndex = node.widgets?.indexOf(widget) ?? -1;
|
||||
|
||||
@@ -132,11 +132,40 @@ function installNode(nodeType) {
|
||||
return result;
|
||||
};
|
||||
|
||||
// Rebuild the editor within one frame if its DOM was detached (e.g. a
|
||||
// workspace-tab switch that rebuilds node widget DOM). Mirrors Easy's
|
||||
// onDrawForeground recovery so text never visibly blanks out. Must null the
|
||||
// stale refs first: installPromptEditorFactory early-returns when
|
||||
// __h3qqEditor is set, even if that element has left the document.
|
||||
const originalDraw = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function onDrawForegroundH3QQ(ctx) {
|
||||
const result = originalDraw?.apply(this, arguments);
|
||||
// Only recover an editor that WAS installed and then detached. A null
|
||||
// editor means the initial-install retry path (onNodeCreated/onConfigure)
|
||||
// still owns reinstallation; acting here would race it per frame.
|
||||
const editor = this.__h3qqEditor;
|
||||
if (!editor || document.contains(editor)) return result;
|
||||
if (this.__h3qqHealPending || this.__h3qqHookRetry) return result; // already recovering
|
||||
this.__h3qqHealPending = true;
|
||||
const self = this;
|
||||
const run = () => {
|
||||
self.__h3qqHealPending = false;
|
||||
if (!app.graph?.getNodeById?.(self.id)) return; // node removed while pending
|
||||
if (self.__h3qqEditor && document.contains(self.__h3qqEditor)) return; // healed meanwhile
|
||||
self.__h3qqEditor = null;
|
||||
self.__h3qqEditorWrap = null;
|
||||
installPromptEditorSoon(self);
|
||||
};
|
||||
(typeof requestAnimationFrame === "function" ? requestAnimationFrame : setTimeout)(run, 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
const originalRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function onRemovedH3QQ() {
|
||||
closeMentionMenu(this);
|
||||
if (this.__h3qqHookRetry) clearTimeout(this.__h3qqHookRetry);
|
||||
this.__h3qqHookRetry = null;
|
||||
this.__h3qqHealPending = false;
|
||||
this.__h3qqHookAttempts = 0;
|
||||
this.__h3qqEditor = null;
|
||||
this.__h3qqEditorWrap = null;
|
||||
|
||||
Reference in New Issue
Block a user