* fix: sync timeline, outputs, and UI state for Frame Selector V2 * chore: improve timeline perf and cleanup * feat: add pause workflow handshake for frame selector * chore: update example workflow * Add image/audio inputs and stabilize frame selector * Refine frame selector media handling * Fix missing graph_id_value in V4 * Fix pause init frame values * Ensure widget values update after setValue * Suppress progress after pause * Reset out_point on new media * Clamp frame widgets to total frames * Docs: note processing overlay * Docs: remove unrelated enhanced groups section * Rename Frame Selector V3 * Remove image from Frame Selector node usage Removed image from the Frame Selector node section. * Fix image sequence aspect sizing * Fix total_frames for image batch * Prefer counted frames from ffprobe * Always refresh video frame count * Count frames via cv2 when ffprobe missing * Clear processing overlay on other nodes * Decouple processing overlay between nodes * Show waiting status for other paused nodes * Accept pause/progress events for active graph * Resolve pause events by uid lookup * Fallback to single node for pause events * Fix processing overlay host node reference * Show waiting message on other nodes * Add queued and waiting overlay states * Align queued/waiting overlay states * Fix frame selector node detection * Cache image batch media between runs * Suppress overlays when no changes * Only show queue for dirty nodes * Suppress overlays when signature unchanged * Only show waiting for queued nodes * Skip queue overlay when signature unchanged * fix: avoid dirty flag on programmatic updates * fix: align queued state with executed signature * feat: sync audio playback with timeline * feat: use video audio when no input * fix: clear audio source on disconnect * fix: load video audio when no inputs * feat: higher-quality video audio preview * feat: audio envelope visualizer * feat: scrub timeline from audio envelope * fix: keep controls visible and add background * fix: stabilize preview height on web * ui: tighten timeline-to-audio spacing * ui: remove gap between timeline and waveform * ui: override widget row gap for timeline+audio * ui: scope timeline/audio gap fix * ui: remove audio envelope margin override * Revert "ui: remove audio envelope margin override" This reverts commit e3feec1ca6cade5c6af80aa266d65b297ab81563. * fix: clear queued overlays on execution end * fix: clear queued overlay when queue drains * fix: avoid queued state on cached runs * Bump version to 1.1.0 and update repository info * fix: tolerate ffmpeg no-audio errors * feat: fps override for image batch * ui: force fps as input socket * perf: fast 512px preview for image batch * fix: handle audio mapping extraction errors * ui: keep audio viewer visible without envelope * fix: show audio envelope in node v2 off * ui: center player control icons * ui: tighten timeline + audio envelope spacing * fix: fix et update new comfy * fix: fix et update new comfy v2 --------- Co-authored-by: Maxime Mouflard <maxime.mouflard@maxtronics.fr>
260 lines
8.8 KiB
Python
260 lines
8.8 KiB
Python
import server
|
|
web = server.web
|
|
|
|
import time
|
|
import json
|
|
import re
|
|
from uuid import uuid4
|
|
from typing import Any
|
|
|
|
from .utils import lnl_fix_path
|
|
from .video_utils import get_video_info
|
|
from .group_utils import group_extension_folder_path, setup_version_data
|
|
import os
|
|
|
|
GROUP_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
|
|
|
|
def _error(message: str, status: int = 400):
|
|
return web.json_response({"error": message}, status=status)
|
|
|
|
|
|
def _safe_load_json(file_path: str) -> Any | None:
|
|
try:
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
|
|
|
|
def _safe_dump_json(file_path: str, data: dict):
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=4)
|
|
|
|
|
|
def _is_valid_group_id(value: Any) -> bool:
|
|
if not isinstance(value, str):
|
|
return False
|
|
return GROUP_ID_PATTERN.fullmatch(value) is not None
|
|
|
|
|
|
def _group_file_path(group_id: str) -> str:
|
|
return os.path.join(group_extension_folder_path, f"{group_id}.json")
|
|
|
|
|
|
def _sanitize_object_name(value: Any) -> str:
|
|
if not isinstance(value, str):
|
|
return "Unnamed group"
|
|
cleaned = value.strip()
|
|
if not cleaned:
|
|
return "Unnamed group"
|
|
return cleaned[:200]
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/process_video_entry")
|
|
async def process_video_entry_route(request):
|
|
try:
|
|
json_data = await request.json()
|
|
except Exception:
|
|
return _error("Invalid JSON body", 400)
|
|
|
|
video_path = json_data.get("path") if isinstance(json_data, dict) else None
|
|
if not isinstance(video_path, str) or not video_path.strip():
|
|
return _error("Missing or invalid 'path'", 400)
|
|
|
|
try:
|
|
fixed_video_path = lnl_fix_path(video_path)
|
|
frame_rate, total_frames, duration = get_video_info(fixed_video_path)
|
|
except Exception as e:
|
|
return _error(f"Failed to read video info: {e}", 400)
|
|
|
|
return web.json_response({"frame_rate": frame_rate, "total_frames": total_frames, "duration": duration})
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/fetch_groups_data")
|
|
async def fetch_groups_data_route(request):
|
|
try:
|
|
json_files = [file for file in os.listdir(group_extension_folder_path) if file.endswith(".json")]
|
|
except OSError:
|
|
return _error("Unable to read groups directory", 500)
|
|
|
|
group_data = []
|
|
for file in json_files:
|
|
file_path = os.path.join(group_extension_folder_path, file)
|
|
data = _safe_load_json(file_path)
|
|
if not isinstance(data, dict):
|
|
continue
|
|
versions = data.get("versions")
|
|
if not isinstance(versions, list):
|
|
continue
|
|
normalized_versions = []
|
|
for version in sorted(versions, key=lambda x: x.get("id", 0), reverse=True):
|
|
if not isinstance(version, dict):
|
|
continue
|
|
version_id = version.get("id")
|
|
timestamp = version.get("last_change_timestamp")
|
|
if isinstance(version_id, int):
|
|
normalized_versions.append({"id": version_id, "timestamp": timestamp})
|
|
group_data.append({
|
|
"id": data.get("id"),
|
|
"name": data.get("name", os.path.splitext(file)[0]),
|
|
"versions": normalized_versions,
|
|
})
|
|
group_data = sorted(group_data, key=lambda x: str(x.get("name", "")))
|
|
|
|
return web.json_response(group_data)
|
|
|
|
|
|
@server.PromptServer.instance.routes.get("/fetch_group_data")
|
|
async def fetch_group_data_route(request):
|
|
group_id = request.query.get("groupId")
|
|
if not _is_valid_group_id(group_id):
|
|
return _error("Invalid groupId", 400)
|
|
|
|
group_file = _group_file_path(group_id)
|
|
if not os.path.exists(group_file):
|
|
return _error("Group data not found", 404)
|
|
|
|
data = _safe_load_json(group_file)
|
|
if not isinstance(data, dict):
|
|
return _error("Corrupted group data", 500)
|
|
|
|
versions = data.get("versions")
|
|
if isinstance(versions, list):
|
|
data["versions"] = sorted(
|
|
[v for v in versions if isinstance(v, dict)],
|
|
key=lambda x: x.get("id", 0),
|
|
reverse=True,
|
|
)
|
|
else:
|
|
data["versions"] = []
|
|
|
|
return web.json_response(data)
|
|
|
|
|
|
@server.PromptServer.instance.routes.post("/save_group_data")
|
|
async def save_group_data_route(request):
|
|
save_as_new = request.query.get("saveAsNew") == "true"
|
|
try:
|
|
json_data = await request.json()
|
|
except Exception:
|
|
return _error("Invalid JSON body", 400)
|
|
|
|
if not isinstance(json_data, dict) or "group_data" not in json_data:
|
|
return _error("Invalid data: missing 'group_data'", 400)
|
|
|
|
group_data = json_data.get("group_data")
|
|
if not isinstance(group_data, dict):
|
|
return _error("Invalid data: 'group_data' must be an object", 400)
|
|
|
|
versioning_data = group_data.get("versioning_data")
|
|
if versioning_data is None:
|
|
versioning_data = {}
|
|
group_data["versioning_data"] = versioning_data
|
|
if not isinstance(versioning_data, dict):
|
|
return _error("Invalid data: 'versioning_data' must be an object", 400)
|
|
|
|
object_id = versioning_data.get("object_id")
|
|
if object_id is None:
|
|
object_id = str(uuid4())
|
|
versioning_data["object_id"] = object_id
|
|
if not _is_valid_group_id(object_id):
|
|
return _error("Invalid object_id", 400)
|
|
|
|
object_name = _sanitize_object_name(versioning_data.get("object_name"))
|
|
versioning_data["object_name"] = object_name
|
|
|
|
storage_version_data = setup_version_data(group_data)
|
|
node_data = storage_version_data.get("node_data") if isinstance(storage_version_data, dict) else None
|
|
if not isinstance(node_data, dict):
|
|
return _error("Invalid group_data payload", 400)
|
|
group_node_data = node_data.get("group")
|
|
if not isinstance(group_node_data, dict):
|
|
return _error("Invalid group_data: missing group serialization", 400)
|
|
|
|
last_change_timestamp = int(time.time() * 1000)
|
|
group_file = _group_file_path(object_id)
|
|
|
|
if not os.path.exists(group_file):
|
|
object_version = 1
|
|
versioning_data = {
|
|
"object_id": object_id,
|
|
"object_name": object_name,
|
|
"object_version": object_version,
|
|
}
|
|
group_node_data["versioning_data"] = versioning_data
|
|
fresh_file_data = {
|
|
"id": object_id,
|
|
"name": object_name,
|
|
"versions": [
|
|
{
|
|
"id": object_version,
|
|
"last_change_timestamp": last_change_timestamp,
|
|
"node_data": node_data,
|
|
}
|
|
],
|
|
}
|
|
try:
|
|
_safe_dump_json(group_file, fresh_file_data)
|
|
except OSError:
|
|
return _error("Failed to write group data", 500)
|
|
return web.json_response(fresh_file_data)
|
|
|
|
data = _safe_load_json(group_file)
|
|
if not isinstance(data, dict):
|
|
return _error("Corrupted group data", 500)
|
|
versions = data.get("versions")
|
|
if not isinstance(versions, list):
|
|
versions = []
|
|
versions = [v for v in versions if isinstance(v, dict)]
|
|
|
|
if save_as_new:
|
|
versions = sorted(versions, key=lambda x: x.get("id", 0), reverse=True)
|
|
latest_id = versions[0].get("id", 0) if versions else 0
|
|
new_version_id = int(latest_id) + 1
|
|
node_data.setdefault("group", {})
|
|
node_data["group"]["versioning_data"] = {
|
|
"object_id": object_id,
|
|
"object_name": object_name,
|
|
"object_version": new_version_id,
|
|
}
|
|
new_version_data = {
|
|
"id": new_version_id,
|
|
"last_change_timestamp": last_change_timestamp,
|
|
"node_data": node_data,
|
|
}
|
|
versions.insert(0, new_version_data)
|
|
data["versions"] = versions
|
|
data["id"] = object_id
|
|
data["name"] = object_name
|
|
try:
|
|
_safe_dump_json(group_file, data)
|
|
except OSError:
|
|
return _error("Failed to write group data", 500)
|
|
return web.json_response(data)
|
|
|
|
object_version = versioning_data.get("object_version")
|
|
if not isinstance(object_version, int):
|
|
return _error("Invalid object_version", 400)
|
|
index = next((i for i, version in enumerate(versions) if version.get("id") == object_version), -1)
|
|
if index == -1:
|
|
return _error("Version not found", 404)
|
|
|
|
node_data.setdefault("group", {})
|
|
node_data["group"]["versioning_data"] = {
|
|
"object_id": object_id,
|
|
"object_name": object_name,
|
|
"object_version": object_version,
|
|
}
|
|
versions[index]["node_data"] = node_data
|
|
versions[index]["last_change_timestamp"] = last_change_timestamp
|
|
data["versions"] = versions
|
|
data["id"] = object_id
|
|
data["name"] = object_name
|
|
try:
|
|
_safe_dump_json(group_file, data)
|
|
except OSError:
|
|
return _error("Failed to write group data", 500)
|
|
return web.json_response(data)
|
|
|