Compare commits

...
2 Commits
17 changed files with 518 additions and 57 deletions
+10
View File
@@ -0,0 +1,10 @@
DOGMA Nodes 1.0.50
- One workflow and one node package for desktop/local and remote/cloud ComfyUI. No SimplePod-only fork, model sampling changes, model downloads or new runtime dependencies.
- Fix the local-only 403 on restitch preview, detail, job polling and apply. The loaded H3 catalog supplies an ephemeral browser proof, sent only in a custom header. Require it with JSON for state changes, reject cross-site/opaque requests, do not trust forwarded headers for privileges, do not put the proof into URLs or saved workflows. The catalog response is non-cacheable. This complements existing Comfy/proxy authentication; it is not a new public-server authentication system.
- Distinguish localhost desktop requests from public Host/proxy requests at runtime. Native Windows folder opening remains localhost-only. On remote or Linux servers the same Open saved folder button shows an internal browser rooted in the actual Comfy output directory with downloadable media files.
- Preserve existing desktop folder selection including drives and paths outside Comfy output. Remote folder selection starts in the server output directory, remembers selections within it and allows subfolders. Remote copies are bounded to output and cannot navigate through symlinks outside it. Sessions and other non-media files are not listed in the media browser.
- When importing desktop settings on a POSIX server, replace Windows output paths with the actual server output and let final-specific paths inherit it. Do not alter normal local paths or require a separate JSON. Existing sessions, saved videos and current workflow can continue after node update and page reload.
- Missing mask dependency now names the Manager package MaskVidExperiments as well as MVEx_MaskToLatentSpace. Respect HTML hidden controls in action styles, including view-only folder panels.
Validation: local and proxy/remote tests against production route registration and actual 97-frame CPU restitch, including preview/poll/apply, final copy, output-boundary rejection, stale/missing proof, request origin checks and local Windows OS-call dispatch (mocked to avoid opening Explorer). The same frontend was exercised in an isolated browser with a rewritten internal Host and non-loopback client address, then in localhost mode: server folder selection and media listing, all seven restitch previews without 403, Windows path adaptation and retained local drives/folder navigation. No real SimplePod instance or live local installation was modified during verification. No new H3 sampling was required or run.
+10
View File
@@ -0,0 +1,10 @@
DOGMA Nodes 1.0.51
- Integrate HDR finishing into the existing comfyui-dogma-nodes Manager package. No separate node pack, installer, Torch/CUDA update or model download.
- Add DOGMAHDRColorChunkedV1 for per-frame SDR-to-ACEScct conversion using the native Comfy colour transform and fp32 intermediates.
- Add DOGMAHDRSaveStreamingV1: consume raw decoded ACEScct one frame at a time, restore source proportions in UHD, save ACEScg RGB HALF EXRs with ZIP16 and AP1/D60 chromaticities, and optionally write ProRes 4444 HLG with original PCM24 audio. Preserve original frame count and rational frame rate; omit only LTX temporal padding. Return paths rather than additional complete IMAGE batches. Unique output folders, completion manifest and atomic final MOV name.
- Add DOGMAHDRCheckpointV1 before decode; its lazy recovery branch loads a saved latent without running the samplers again. Recovery requires the same source video and compatible VAE.
- Preserve previous node IDs, modules, frontend and H3 workflows. HDR registration is optional on older Comfy versions: failure to import the new HDR module does not remove existing DOGMA nodes. HDR requires ComfyUI >=0.45.0; no new declared runtime dependency.
- Updated HDR workflow keeps the existing BF16 model, HDR LoRA and scene embedding, fp32 VAE, tile settings and two-stage 8+3 sampling.
Validation: graph consistency, native colour equivalence, actual HALF EXR round-trip including negative/extended values, ACES metadata, actual ProRes HLG and PCM24 audio, exact rational timestamps, trim/padding, latent save/recovery, unique folders and output boundaries. The unchanged finishing function was additionally exercised on a synthetic shared-frame UHD 144-frame/6-second export, with 0.43 GiB additional peak process RAM. This measurement isolates finishing and is not a full LTX inference/decode or a SimplePod run. Live Comfy installations were not modified.
+19
View File
@@ -147,3 +147,22 @@ comfy node install comfyui-dogma-nodes
## Notes
The nodes use only PyTorch and ComfyUI's built-in sampler APIs. No extra Python dependencies are required.
## HDR finishing (DOGMA Nodes 1.0.51)
The HDR master workflow uses the same DOGMA Nodes Manager package on Windows and Linux.
`DOGMAHDRColorChunkedV1`, `DOGMAHDRCheckpointV1`, and `DOGMAHDRSaveStreamingV1`
require ComfyUI 0.45.0 or later. Update the existing package; no separate HDR pack
or installer is required. Existing H3 workflows and node identifiers are unchanged.
The master is a 3840x2160 ACEScg RGB HALF EXR sequence (ZIP lossless, AP1/D60).
The optional MOV is ProRes 4444 HLG / BT.2020 with 10-bit 4:4:4 encoder input
and original PCM24 audio; the EXR sequence is the 16-bit floating-point master.
Finishing is sequential per frame, but native VAE decode still returns a complete batch.
Output is under the actual Comfy output folder in `DOGMA_HDR_4K/master_XXXXX/`.
The checkpoint is saved before decode in `DOGMA_HDR_4K/checkpoints/`.
To recover an interrupted export, keep the same source loaded and enter the saved
`.latent` path (relative to Comfy output) in `resume_from`; leave it empty for a new generation.
Do not use `--fp16-intermediates` for the HDR master path.
+12
View File
@@ -194,3 +194,15 @@ try:
del _H3_134, _H3_134_NAMES
except Exception as _h3_134_error:
print("[DOGMA H3 Director V2] Unavailable; existing nodes preserved:", _h3_134_error)
# Optional HDR finishing. Older Comfy versions retain all previous DOGMA nodes.
try:
from .dogma_hdr_streaming import NODE_CLASS_MAPPINGS as _HDR151, NODE_DISPLAY_NAME_MAPPINGS as _HDR151_NAMES
if set(_HDR151).intersection(NODE_CLASS_MAPPINGS):
raise RuntimeError("DOGMA HDR: node ID collision")
NODE_CLASS_MAPPINGS.update(_HDR151)
NODE_DISPLAY_NAME_MAPPINGS.update(_HDR151_NAMES)
del _HDR151, _HDR151_NAMES
except Exception as _hdr151_error:
print("[DOGMA HDR] HDR nodes unavailable (requires ComfyUI >= 0.45.0); previous DOGMA nodes preserved:", _hdr151_error)
+61
View File
@@ -0,0 +1,61 @@
"""Browser request proof for H3 data operations; OS actions remain local-only.
The token complements the deployment's existing Comfy/proxy authentication.
It is not a replacement for authentication on a publicly exposed Comfy server.
Forwarded headers never grant access or widen the filesystem scope.
"""
import secrets
from pathlib import Path
from urllib.parse import urlsplit
from aiohttp import web
class H3Access:
def __init__(self):self.token=secrets.token_urlsafe(32)
@staticmethod
def fetch_site(request):
site=request.headers.get('Sec-Fetch-Site','').lower()
if site and site not in ('same-origin','none'):
raise web.HTTPForbidden(text='Richiesta da un altro sito non consentita.')
origin=request.headers.get('Origin')
if origin:
try:valid=origin!='null' and urlsplit(origin).scheme in ('http','https') and bool(urlsplit(origin).netloc)
except ValueError:valid=False
if not valid:raise web.HTTPForbidden(text='Origine non valida.')
def is_local(self,request):
if request.remote not in ('127.0.0.1','::1'):return False
try:
host=urlsplit('//'+request.host)
if host.hostname not in ('127.0.0.1','::1','localhost'):return False
origin=request.headers.get('Origin')
if origin and urlsplit(origin).netloc.lower()!=host.netloc.lower():return False
self.fetch_site(request)
except (ValueError,web.HTTPForbidden):return False
return True
def local(self,request):
if not self.is_local(request):
raise web.HTTPForbidden(text='Questa azione del sistema è disponibile solo da localhost sul computer Comfy.')
def browser(self,request):
self.fetch_site(request)
token=request.headers.get('X-Dogma-Access','')
if not token or not secrets.compare_digest(token.encode('utf8'),self.token.encode('ascii')):
raise web.HTTPForbidden(text='Accesso DOGMA scaduto o pannello precedente. Ricarica la pagina e riapri H3 Director.')
if request.method=='POST' and request.content_type!='application/json':
raise web.HTTPUnsupportedMediaType(text='Richiesta JSON necessaria.')
def catalog(self,request,output):
self.fetch_site(request)
import os
return {'token':self.token,'local_files':self.is_local(request),
'open_folder':self.is_local(request) and os.name=='nt',
'output_directory':str(Path(output).resolve()),'platform':os.name}
def output_path(self,request,path,root):
root=Path(root).expanduser().resolve()
destination=Path(path).expanduser().resolve() if path else root
if not self.is_local(request) and not destination.is_relative_to(root):
raise ValueError('Da remoto scegli una cartella dentro l’output di Comfy: '+str(root))
return destination
+31 -19
View File
@@ -612,17 +612,13 @@ def register_routes():
from .dogma_h3_video_edit import register_edit_routes
cleanup=register_edit_routes(routes,input_file)
PromptServer.instance.app.on_cleanup.append(cleanup)
def local_request(request):
from urllib.parse import urlparse
if request.remote not in ('127.0.0.1','::1'):
raise web.HTTPForbidden(text='Disponibile solo sul computer Comfy.')
origin=request.headers.get('Origin')
if origin and urlparse(origin).netloc!=request.host:
raise web.HTTPForbidden(text='Origine non valida.')
from .dogma_h3_access import H3Access
access=H3Access()
local_request=access.local
import sys
from .dogma_h3_restitch import register_routes as register_restitch
PromptServer.instance.app.on_cleanup.append(register_restitch(routes,sys.modules[__name__],local_request))
PromptServer.instance.app.on_cleanup.append(register_restitch(routes,sys.modules[__name__],access.browser))
@routes.post('/dogma/h3/v2/open-folder')
async def open_folder(request):
@@ -639,10 +635,12 @@ def register_routes():
@routes.post('/dogma/h3/v2/edit/export-copy')
async def export_edit_copy(request):
local_request(request)
access.browser(request)
import asyncio
data=await request.json()
try:return web.json_response(await asyncio.to_thread(export_saved_edit,data))
try:
data['output_dir']=str(access.output_path(request,data.get('output_dir','').strip(),folder_paths.get_output_directory()))
return web.json_response(await asyncio.to_thread(export_saved_edit,data))
except (ValueError,OSError,KeyError) as exc:raise web.HTTPBadRequest(text=str(exc))
@routes.post('/dogma/h3/v2/choose-folder')
@@ -652,26 +650,39 @@ def register_routes():
@routes.get('/dogma/h3/v2/folders')
async def folders(request):
local_request(request)
access.browser(request)
import asyncio
def browse():
directory=Path(request.query.get('path') or folder_paths.get_output_directory()).expanduser().resolve()
output=Path(folder_paths.get_output_directory()).resolve()
local=access.is_local(request)
directory=access.output_path(request,request.query.get('path'),output)
if not directory.is_dir(): raise ValueError('Cartella inesistente. Inserisci un percorso valido.')
directories=[]
directories=[];files=[]
media={'.mp4','.mov','.webm','.mkv','.png','.jpg','.jpeg','.webp','.gif','.wav','.mp3','.flac'}
with os.scandir(directory) as entries:
for entry in entries:
try:
if entry.is_dir(): directories.append({'name':entry.name,'path':entry.path})
if entry.is_dir() and (local or Path(entry.path).resolve().is_relative_to(output)):
directories.append({'name':entry.name,'path':entry.path})
elif request.query.get('files')=='1' and entry.is_file() and Path(entry.name).suffix.lower() in media:
resolved=Path(entry.path).resolve()
if resolved.is_relative_to(output):
rel=resolved.relative_to(output)
files.append({'name':entry.name,'size':entry.stat().st_size,
'video':{'filename':rel.name,'subfolder':rel.parent.as_posix(),'type':'output'}})
except OSError: continue
if len(directories)>500: break
if len(directories)+len(files)>500: break
directories.sort(key=lambda x:x['name'].casefold())
files.sort(key=lambda x:x['name'].casefold())
drives=[]
if os.name=='nt':
if local and os.name=='nt':
import ctypes
bits=ctypes.windll.kernel32.GetLogicalDrives()
drives=[{'name':chr(65+i)+':','path':chr(65+i)+':/'} for i in range(26) if bits & (1<<i)]
return {'path':str(directory),'parent':str(directory.parent) if directory.parent!=directory else None,
'drives':drives,'directories':directories[:500],'truncated':len(directories)>500}
parent=str(directory.parent) if directory.parent!=directory and (local or directory.parent.is_relative_to(output)) else None
return {'path':str(directory),'parent':parent,'restricted':not local,
'drives':drives,'directories':directories[:500],'files':files[:500],
'truncated':len(directories)+len(files)>500}
try: return web.json_response(await asyncio.to_thread(browse))
except (ValueError,OSError) as exc: raise web.HTTPBadRequest(text=str(exc))
@@ -689,10 +700,11 @@ def register_routes():
async def catalog(request):
import asyncio,time
nonlocal catalog_task,catalog_time
capabilities=access.catalog(request,folder_paths.get_output_directory())
if catalog_task is None or (catalog_task.done() and (catalog_task.exception() is not None or time.monotonic()-catalog_time>30)):
catalog_time=time.monotonic()
catalog_task=asyncio.create_task(asyncio.to_thread(build_catalog))
return web.json_response(await asyncio.shield(catalog_task))
return web.json_response({**await asyncio.shield(catalog_task),'access':capabilities},headers={'Cache-Control':'no-store'})
@routes.get('/dogma/h3/v2/session/{session}')
async def session(request):
+4 -4
View File
@@ -115,7 +115,7 @@ def apply_restitch(core,data):
return current
finally:temp.unlink(missing_ok=True)
def register_routes(routes,core,local_request):
def register_routes(routes,core,authorize_request):
from aiohttp import web
jobs={};slot=asyncio.Semaphore(1)
async def launch(kind,data):
@@ -132,12 +132,12 @@ def register_routes(routes,core,local_request):
except Exception as exc:job.update(status='error',message=str(exc))
job['task']=asyncio.create_task(run());return web.json_response({'id':job['id']})
@routes.post('/dogma/h3/v2/edit/restitch/preview')
async def preview(request):local_request(request);return await launch('preview',await request.json())
async def preview(request):authorize_request(request);return await launch('preview',await request.json())
@routes.post('/dogma/h3/v2/edit/restitch/apply')
async def apply(request):local_request(request);return await launch('apply',await request.json())
async def apply(request):authorize_request(request);return await launch('apply',await request.json())
@routes.get('/dogma/h3/v2/edit/restitch/job/{id}')
async def status(request):
local_request(request);job=jobs.get(request.match_info['id'])
authorize_request(request);job=jobs.get(request.match_info['id'])
if not job:raise web.HTTPNotFound(text='Lavorazione non disponibile.')
return web.json_response({k:v for k,v in job.items() if k!='task'})
async def cleanup(app):await asyncio.gather(*(j['task'] for j in jobs.values()),return_exceptions=True)
+314
View File
@@ -0,0 +1,314 @@
"""DOGMA Nodes: bounded-working-memory HDR finishing for ComfyUI >= 0.45.0.
No models, dependencies, monkey patches, or changes to ComfyUI are installed.
The native VAE still returns a complete batch; finishing consumes one frame
at a time and returns file paths, never another complete IMAGE batch.
"""
from __future__ import annotations
import json
import logging
import math
import os
from fractions import Fraction
from pathlib import Path
import av
import numpy as np
import torch
import safetensors.torch
import folder_paths
import nodes
import comfy.utils
from comfy.cli_args import args
from comfy_api.latest import io, ui
from comfy_api.latest._input_impl.video_types import (
BT2020_NCL, set_video_color_properties,
)
from comfy_extras import nodes_images as ni
VERSION = "1.0.51"
SPACES = ["sRGB", "HDR", "HDR ACEScct", "HDR LogC3", "HDR PQ", "linear", "linear ACEScg"]
log = logging.getLogger("DOGMA.HDR")
def require_fp32():
if args.fp16_intermediates:
raise ValueError("HDR 16-bit float: rimuovi --fp16-intermediates dall'avvio di Comfy. La conversione colore richiede intermedi fp32.")
def convert_frame(frame, source, destination):
require_fp32()
return ni.ImageColorSpace.execute(
frame.to(device="cpu", dtype=torch.float32), source, destination
).result[0].to(device="cpu", dtype=torch.float32)
class DogmaHDRColorChunked(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="DOGMAHDRColorChunkedV1", display_name="DOGMA HDR | Colore per fotogramma",
category="DOGMA/HDR",
description="Stessa trasformazione colore Comfy, con temporanei limitati a un fotogramma. Non riduce risoluzione o precisione.",
inputs=[io.Image.Input("image"),
io.Combo.Input("source", options=SPACES, default="sRGB"),
io.Combo.Input("destination", options=SPACES, default="HDR ACEScct")],
outputs=[io.Image.Output()],
)
@classmethod
def execute(cls, image, source, destination):
require_fp32()
# Per-frame tone mapping would differ from the native batch transform.
# This node is intentionally limited to the non-tone-mapping HDR path.
if destination == "sRGB" or (source == "HDR PQ" and destination == "HDR"):
raise ValueError("Questa conversione usa statistiche dell'intero batch: usa ImageColorSpace nativo per il tonemapping SDR/PQ-HLG.")
if source == destination:
return io.NodeOutput(image.to(device="cpu", dtype=torch.float32))
output = torch.empty(image.shape, device="cpu", dtype=torch.float32)
progress = comfy.utils.ProgressBar(len(image))
with torch.inference_mode():
for index in range(len(image)):
nodes.before_node_execution()
output[index:index + 1].copy_(convert_frame(image[index:index + 1], source, destination))
progress.update(1)
return io.NodeOutput(output)
def fit_frame(frame, source_width, source_height, target_width, target_height):
width = 2 * math.floor(min(target_width, target_height * source_width / source_height) / 2)
height = 2 * math.floor(min(target_height, target_width * source_height / source_width) / 2)
if min(width, height) < 2:
raise ValueError("Proporzioni del sorgente non valide.")
frame = nodes.ImageScale().upscale(frame, "bicubic", width, height, "disabled")[0]
return ni.ResizeAndPadImage.execute(frame, target_width, target_height, "black", "area").result[0]
def create_job(prefix, output_directory):
# Comfy validates that the requested prefix remains inside its output root.
directory, name, counter, _, _ = folder_paths.get_save_image_path(prefix, output_directory)
while True:
job = Path(directory) / f"{name}_{counter:05d}"
try:
job.mkdir()
return job
except FileExistsError:
counter += 1
def write_json(path, data):
temporary = path.with_suffix(path.suffix + ".partial")
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(temporary, path)
def encode_audio(container, audio, count, rate):
if audio is None:
return
sample_rate = int(audio["sample_rate"])
wave = audio["waveform"][0]
layouts = {1: "mono", 2: "stereo", 3: "2.1", 4: "quad", 6: "5.1", 8: "7.1"}
if wave.shape[0] not in layouts:
raise ValueError(f"Audio: {wave.shape[0]} canali non supportati; nessun downmix automatico.")
layout = layouts[wave.shape[0]]
stream = container.add_stream("pcm_s24le", rate=sample_rate, layout=layout)
n = min(wave.shape[-1], math.ceil(Fraction(sample_rate * count, 1) / rate))
# Audio is also chunked, avoiding an extra full-length contiguous copy.
for offset in range(0, n, 8192):
part = wave[:, offset:min(n, offset + 8192)].float().cpu().contiguous().numpy()
frame = av.AudioFrame.from_ndarray(part, format="fltp", layout=layout)
frame.sample_rate = sample_rate
frame.time_base = Fraction(1, sample_rate)
frame.pts = offset
container.mux(stream.encode(frame))
container.mux(stream.encode(None))
def export_frames(images, source_width, source_height, source_frames, fps,
filename_prefix="DOGMA_HDR_4K/master", save_mov=True, audio=None,
target_width=3840, target_height=2160, output_directory=None,
progress=None, metadata=None):
"""Sequential finishing; target size override is for isolated CPU QA only."""
require_fp32()
if images.ndim != 4 or images.shape[-1] != 3:
raise ValueError("Atteso IMAGE RGB [fotogrammi, altezza, larghezza, 3] in ACEScct.")
if source_width < 1 or source_height < 1 or source_frames < 1 or source_frames > len(images):
raise ValueError(f"Conteggio/proporzioni non validi: richiesti {source_frames} frame, decode disponibile {len(images)}.")
if not math.isfinite(float(fps)) or fps <= 0:
raise ValueError("FPS sorgente non valido.")
# Recover standard rational rates without rounding 24000/1001 to 23.976.
rate = Fraction(float(fps)).limit_denominator(1000000)
output_directory = output_directory or folder_paths.get_output_directory()
job = create_job(filename_prefix, output_directory)
exr_dir = job / "EXR"
exr_dir.mkdir()
mov_path = job / "DOGMA_4K_HDR_HLG.mov"
mov_temporary = job / "DOGMA_4K_HDR_HLG.partial.mov"
manifest = dict(version=VERSION, status="incomplete", completed_frames=0,
frames=source_frames, fps=str(rate), width=target_width, height=target_height,
master="ACEScg AP1/D60 RGB HALF EXR ZIP16", source_width=source_width,
source_height=source_height, decoded_frames=len(images),
mov="ProRes 4444 BT.2020 HLG; 10-bit encoding input; PCM24 original audio" if save_mov else None,
metadata=metadata or {})
write_json(job / "manifest.json", manifest)
container = None
try:
if save_mov:
container = av.open(str(mov_temporary), mode="w", format="mov", options={"movflags": "use_metadata_tags+faststart"})
stream = container.add_stream("prores_ks", rate=rate)
stream.width, stream.height = target_width, target_height
stream.pix_fmt = "yuv444p10le"
stream.options = {"profile": "4444", "alpha_bits": "16"}
stream.codec_context.thread_count = 2
stream.time_base = 1 / rate
set_video_color_properties(stream.codec_context, "HDR")
# Register audio before the muxer writes the MOV header.
# The PCM stream is created below by encode_audio, before video encode.
if audio is not None:
encode_audio(container, audio, source_frames, rate)
with torch.inference_mode():
for index in range(source_frames):
nodes.before_node_execution()
linear = convert_frame(images[index:index + 1], "HDR ACEScct", "linear ACEScg")
linear = fit_frame(linear, source_width, source_height, target_width, target_height)
if not torch.isfinite(linear).all():
raise ValueError(f"Frame {index}: valori HDR NaN/Inf. Il file non viene dichiarato completo.")
# HALF cannot represent values outside +-65504. Do not silently clip.
if linear.abs().max() > 65504:
raise ValueError(f"Frame {index}: range oltre HALF (+-65504). Serve EXR float32 per questi valori.")
encoded = ni._encode_image(linear[0], "exr", "16-bit float", "linear ACEScg")
encoded = ni.inject_exr_metadata(encoded, None, None, "linear ACEScg")
exr = exr_dir / f"frame_{index:06d}.exr"
temporary = exr.with_suffix(".exr.partial")
temporary.write_bytes(encoded)
os.replace(temporary, exr)
del encoded
if container is not None:
hlg = convert_frame(linear, "linear ACEScg", "HDR")[0]
pixels = (hlg * 65535).clamp(0, 65535).numpy().astype(np.uint16)
frame = av.VideoFrame.from_ndarray(pixels, format="rgb48le")
frame = frame.reformat(format="yuv444p10le", dst_colorspace=BT2020_NCL)
set_video_color_properties(frame, "HDR")
frame.time_base = 1 / rate
frame.pts = index
container.mux(stream.encode(frame))
del hlg, pixels, frame
del linear
manifest["completed_frames"] = index + 1
if (index + 1) % 12 == 0 or index + 1 == source_frames:
write_json(job / "manifest.json", manifest)
log.info("HDR export: %d/%d frame; %s", index + 1, source_frames, job)
if progress is not None:
progress.update(1)
if container is not None:
container.mux(stream.encode(None))
container.close()
container = None
os.replace(mov_temporary, mov_path)
manifest["status"] = "complete"
write_json(job / "manifest.json", manifest)
except BaseException as error:
manifest["error"] = str(error)
write_json(job / "manifest.json", manifest)
raise
finally:
if container is not None:
container.close()
return job, mov_path if save_mov else None
class DogmaHDRSaveStreaming(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="DOGMAHDRSaveStreamingV1", display_name="DOGMA HDR | Salva 4K EXR16 + MOV progressivo",
category="DOGMA/HDR", is_output_node=True,
description="Converte ACEScct, ripristina proporzioni, elimina la sola coda temporale e salva un frame alla volta. Nessun secondo batch IMAGE completo.",
inputs=[io.Image.Input("images"), io.Int.Input("source_width", force_input=True),
io.Int.Input("source_height", force_input=True), io.Int.Input("source_frames", force_input=True),
io.Float.Input("fps", force_input=True),
io.String.Input("filename_prefix", default="DOGMA_HDR_4K/master"),
io.Boolean.Input("save_mov", default=True), io.Audio.Input("audio", optional=True)],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
outputs=[io.String.Output("exr_directory"), io.String.Output("mov_path")],
)
@classmethod
def execute(cls, images, source_width, source_height, source_frames, fps,
filename_prefix="DOGMA_HDR_4K/master", save_mov=True, audio=None):
metadata = {}
if not args.disable_metadata and cls.hidden is not None:
if cls.hidden.extra_pnginfo:
metadata.update(cls.hidden.extra_pnginfo)
if cls.hidden.prompt:
metadata["prompt"] = cls.hidden.prompt
job, mov = export_frames(images, source_width, source_height, source_frames, fps,
filename_prefix, save_mov, audio,
progress=comfy.utils.ProgressBar(source_frames), metadata=metadata)
preview = None
if mov:
subfolder = str(job.relative_to(Path(folder_paths.get_output_directory()))).replace("\\", "/")
preview = ui.PreviewVideo([ui.SavedResult(mov.name, subfolder, io.FolderType.output)])
return io.NodeOutput(str(job / "EXR"), str(mov) if mov else "", ui=preview)
class DogmaHDRCheckpoint(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="DOGMAHDRCheckpointV1", display_name="DOGMA HDR | Checkpoint prima del decode",
category="DOGMA/HDR",
description="Salva il latente prima del decode. Per riprendere lo stesso video inserisci il percorso del checkpoint: i sampler vengono saltati.",
inputs=[io.Latent.Input("samples", lazy=True),
io.String.Input("filename_prefix", default="DOGMA_HDR_4K/checkpoints/HDR"),
io.String.Input("resume_from", default="", tooltip="Lascia vuoto per una nuova generazione. Percorso .latent relativo a output, oppure assoluto dentro input/output Comfy.")],
outputs=[io.Latent.Output(), io.String.Output("checkpoint_path")],
)
@classmethod
def check_lazy_status(cls, filename_prefix, resume_from, samples=None):
return [] if resume_from.strip() or samples is not None else ["samples"]
@classmethod
def execute(cls, filename_prefix, resume_from="", samples=None):
if resume_from.strip():
path = Path(resume_from.strip()).expanduser()
if not path.is_absolute():
path = Path(folder_paths.get_output_directory()) / path
path = path.resolve()
roots = [Path(folder_paths.get_output_directory()).resolve(), Path(folder_paths.get_input_directory()).resolve()]
if not any(path.is_relative_to(root) for root in roots) or path.suffix != ".latent":
raise ValueError("Il checkpoint deve essere un file .latent nella cartella input/output di Comfy.")
saved = safetensors.torch.load_file(str(path), device="cpu")
if "latent_format_version_0" not in saved or "latent_tensor" not in saved:
raise ValueError("Checkpoint non compatibile: atteso formato latente Comfy attuale.")
return io.NodeOutput({"samples": saved["latent_tensor"]}, str(path))
if samples is None or "samples" not in samples:
raise ValueError("Nessun latente ricevuto dal sampler.")
directory, name, counter, _, _ = folder_paths.get_save_image_path(filename_prefix, folder_paths.get_output_directory())
path = Path(directory) / f"{name}_{counter:05d}_.latent"
temporary = path.with_suffix(".latent.partial")
saved = {"latent_tensor": samples["samples"].detach().contiguous(),
"latent_format_version_0": torch.tensor([])}
comfy.utils.save_torch_file(saved, str(temporary), metadata={"dogma_hdr_streaming": VERSION})
os.replace(temporary, path)
log.info("Checkpoint HDR salvato prima del decode: %s", path)
return io.NodeOutput(samples, str(path))
# Registered through the existing DOGMA package, not a separate extension.
NODE_CLASS_MAPPINGS = {
"DOGMAHDRColorChunkedV1": DogmaHDRColorChunked,
"DOGMAHDRSaveStreamingV1": DogmaHDRSaveStreaming,
"DOGMAHDRCheckpointV1": DogmaHDRCheckpoint,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DOGMAHDRColorChunkedV1": "DOGMA HDR | Colore per fotogramma",
"DOGMAHDRSaveStreamingV1": "DOGMA HDR | Salva 4K EXR16 + MOV progressivo",
"DOGMAHDRCheckpointV1": "DOGMA HDR | Checkpoint prima del decode",
}
# Initialize V3 schema fields while retaining explicit, Registry-readable IDs.
for _node in NODE_CLASS_MAPPINGS.values():
_node.GET_SCHEMA()
del _node
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-dogma-nodes"
description = "Custom ComfyUI nodes for DOGMA AI video workflows."
version = "1.0.49"
version = "1.0.51"
license = { file = "LICENSE" }
dependencies = ["scipy>=1.10"]
+3
View File
@@ -67,4 +67,7 @@
.dg-studio .dg-upload-status{font-size:12px;margin:6px 0;overflow-wrap:anywhere;min-height:0}
.dg-studio .dg-file-drop[data-upload-state=error]>.dg-upload-status{color:#ffbb98}
.dg-studio .dg-file-drop[aria-busy=true]>.dg-drop-hint{color:#ffd2b8;border-style:solid}
.dg-studio .dg-folders a{display:flex;align-items:center;gap:8px;padding:10px 14px;margin:4px 6px 4px 0;border:1px solid #505256;border-radius:7px;background:#26282b;text-decoration:none;overflow-wrap:anywhere}
.dg-studio .dg-folders a:hover{border-color:var(--orange)}
.dg-studio [hidden]{display:none!important}
.dg-studio .dg-upload-toast{position:fixed;bottom:45px;left:50%;transform:translateX(-50%);z-index:20;max-width:80vw;padding:10px 18px;border:1px solid var(--orange);border-radius:7px;background:#30231d;box-shadow:0 4px 20px #0008;color:#fff}
+27 -14
View File
@@ -1,13 +1,13 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import {actionButton,decorateActions,setAction} from './dogma_h3_actions.js?v=studio-1-0-49';
import {assetField,referenceDrop,guardFileNavigation} from './dogma_h3_uploads.js?v=studio-1-0-49';
import { renderEditor, renderEditResults, renderEditTrials } from './dogma_h3_video_edit.js?v=studio-1-0-49';
import {actionButton,decorateActions,setAction} from './dogma_h3_actions.js?v=studio-1-0-50';
import {assetField,referenceDrop,guardFileNavigation} from './dogma_h3_uploads.js?v=studio-1-0-50';
import { renderEditor, renderEditResults, renderEditTrials } from './dogma_h3_video_edit.js?v=studio-1-0-50';
import { inputFile, viewURL, mediaPreview, releaseMedia } from './dogma_h3_media.js?v=studio-1-0-49';
import { inputFile, viewURL, mediaPreview, releaseMedia } from './dogma_h3_media.js?v=studio-1-0-50';
const ROOT=new URL('.',import.meta.url);
const css=document.createElement('link');css.rel='stylesheet';css.href=new URL('dogma_h3_director_v134.css?v=studio-1-0-49',ROOT);document.head.append(css);
const css=document.createElement('link');css.rel='stylesheet';css.href=new URL('dogma_h3_director_v134.css?v=studio-1-0-50',ROOT);document.head.append(css);
const clone=x=>JSON.parse(JSON.stringify(x));
const el=(tag,text='',cls='')=>{const e=document.createElement(tag);e.textContent=text;e.className=cls;return e;};
const button=actionButton;
@@ -40,6 +40,12 @@ export class DirectorStudio {
let stored;try{stored=JSON.parse(this.widget.value);}catch{stored={};}
this.config=merge(clone(this.catalog.defaults),stored);
this.config.session ||= crypto.randomUUID();
const access=this.catalog.access;
if(access?.local_files===false&&access.platform!=='nt'&&access.output_directory){
for(const [object,key]of [[this.config,'output_dir'],[this.config.edit,'final_output_dir']]){
if(/^(?:[a-z]:[\\/]|\\\\)/i.test(object[key]||''))object[key]=key==='final_output_dir'?'':access.output_directory;
}
}
}
family(task){return ['ref2v','edit'].includes(task)?'ref2v':'fl2v';}
turbo(task,steps){
@@ -97,10 +103,12 @@ export class DirectorStudio {
clearTimeout(this.saveTimer);this.saveTimer=setTimeout(()=>{try{app.graph?.change?.();}catch(e){console.warn('DOGMA: aggiornamento workflow',e);}},250);
}
async request(path,opts={}){
const headers=Object.fromEntries(new Headers(opts.headers||{}).entries());
if(path.startsWith('/dogma/h3/v2/')&&this.catalog?.access?.token)headers['X-Dogma-Access']=this.catalog.access.token;
const controller=new AbortController(),epoch=this.epoch;this.requests.add(controller);let expired=false;
const timeout=opts.body instanceof FormData?null:setTimeout(()=>{expired=true;controller.abort();},20000);
try{
const r=await api.fetchApi(path,{...opts,signal:opts.signal?AbortSignal.any([controller.signal,opts.signal]):controller.signal});
const r=await api.fetchApi(path,{...opts,headers,signal:opts.signal?AbortSignal.any([controller.signal,opts.signal]):controller.signal});
if(!r.ok)throw new Error(`Comfy: ${r.status} ${await r.text()}`);
const result=await r.json();
if(epoch!==this.epoch){const e=new Error('Pannello chiuso');e.name='AbortError';throw e;}
@@ -146,27 +154,32 @@ export class DirectorStudio {
this.save();
}
destroy(){this.close();clearTimeout(this.saveTimer);this.abort.abort();}
async browseFolders(scope='general'){
async browseFolders(scope='general',options={}){
if(this.folderPanel||!this.dialog)return;
const epoch=this.epoch,panel=el('section','','dg-folder-browser');this.folderPanel=panel;
panel.setAttribute('role','region');panel.setAttribute('aria-label','Scegli cartella output');
panel.setAttribute('role','region');panel.setAttribute('aria-label',options.viewOnly?'Cartella del video salvato':'Scegli cartella output');
const main=this.dialog.querySelector('main');main.prepend(panel);
const target=scope==='edit'?this.config.edit:this.config,key=scope==='edit'?'final_output_dir':'output_dir';
const input=el('input');input.type='text';input.value=target[key]||this.config.output_dir||'';input.setAttribute('aria-label','Percorso cartella');
const remote=this.catalog.access?.local_files===false;
const serverRoot=(this.catalog.access?.output_directory||'').replaceAll('\\','/').replace(/\/$/,'');
const preferred=(target[key]||this.config.output_dir||'').replaceAll('\\','/');
const initial=remote&&preferred!==serverRoot&&!preferred.startsWith(serverRoot+'/')?serverRoot:preferred;
const input=el('input');input.type='text';input.value=options.initialPath||initial;input.setAttribute('aria-label','Percorso cartella');
const info=el('p'),list=el('div','','dg-folders');let busy=false,current='';
const done=()=>{panel.remove();if(this.folderPanel===panel)this.folderPanel=null;};
const use=button('Usa questa cartella',async()=>{if(input.value!==current&&!await load(input.value))return;target[key]=current;this.save();done();this.render();},'dg-primary');use.disabled=true;
const use=button('Usa questa cartella',async()=>{if(input.value!==current&&!await load(input.value))return;target[key]=current;this.save();done();this.render();},'dg-primary');use.disabled=true;use.hidden=!!options.viewOnly;
const go=button('Vai',()=>load(input.value));
panel.append(el('h2','Cartella di salvataggio'),el('p','Naviga nelle cartelle del computer che esegue Comfy.'),input,go,button('Annulla',done),use,info,list);
panel.append(el('h2',options.viewOnly?'Cartella del video salvato':'Cartella di salvataggio'),el('p',remote?'Cartelle del server che esegue Comfy. La navigazione parte dalla sua cartella output.':'Naviga nelle cartelle del computer che esegue Comfy.'),input,go,button(options.viewOnly?'Chiudi':'Annulla',done),use,info,list);
const load=async path=>{
if(busy)return;busy=true;go.disabled=true;use.disabled=true;info.textContent='Caricamento cartelle…';list.replaceChildren();
try{
const r=await this.request('/dogma/h3/v2/folders?'+new URLSearchParams({path:path||''}));
const r=await this.request('/dogma/h3/v2/folders?'+new URLSearchParams({path:path||'',files:options.viewOnly?'1':'0'}));
if(epoch!==this.epoch||this.folderPanel!==panel)return;
current=r.path;input.value=current;info.textContent=r.truncated?'Mostrate le prime 500 cartelle. Puoi inserire il percorso completo.':'Seleziona una sottocartella oppure conferma questa cartella.';
current=r.path;input.value=current;info.textContent=r.truncated?'Mostrati i primi 500 elementi.':options.viewOnly?'File della cartella: clicca un nome per scaricarlo.':'Seleziona una sottocartella oppure conferma questa cartella.';
if(r.parent)list.append(button('↑ Cartella superiore',()=>load(r.parent)));
for(const d of r.drives||[])list.append(button(d.name,()=>load(d.path)));
for(const d of r.directories||[])list.append(button('📁 '+d.name,()=>load(d.path)));
for(const f of r.files||[]){const link=el('a');setAction(link,f.name+' · '+(f.size/1024/1024).toFixed(1)+' MB','download');link.href=viewURL(f.video);link.download=f.name;list.append(link);}
use.disabled=false;return true;
}catch(e){if(epoch===this.epoch&&this.folderPanel===panel&&e.name!=='AbortError')info.textContent=e.message;return false;}
finally{busy=false;go.disabled=false;}
@@ -254,7 +267,7 @@ export class DirectorStudio {
if(previewEnabled)required.push('ModelPreviewOverrideKJ');
if(action==='finish'&&this.config.task!=='edit'&&this.config.finish.stages!=='ultimate')required.push('MinimaxH3LatentUpscaler3D');
if(action==='finish'&&this.config.task!=='edit'&&this.config.finish.stages!=='latent')required.push('UltimateSDUpscaleNoUpscaleGuider');
const missing=required.filter(n=>!this.catalog.nodes.includes(n));if(missing.length)throw new Error('Installa dal Manager i nodi richiesti: '+missing.join(', '));
const missing=required.filter(n=>!this.catalog.nodes.includes(n));if(missing.length)throw new Error('Installa dal Manager: '+missing.map(n=>n==='MVEx_MaskToLatentSpace'?'MaskVidExperiments (nodo '+n+')':n).join(', '));
const q=await this.request('/queue');if(q.queue_running?.length||q.queue_pending?.length)throw new Error('Comfy sta già lavorando. Attendi la fine della coda.');
this.config.action=action;this.config.nonce++;this.save();
if(this.config.task==='edit'&&action==='draft'){delete this.data.edit_batch;this.promotedTrial=null;this.editFinishWaiting=null;}
+3 -3
View File
@@ -1,6 +1,6 @@
import {mediaPreview,inputFile} from './dogma_h3_media.js?v=studio-1-0-49';
import {frameIndex,frameTime,frameCount,stepTime} from './dogma_h3_timing.js?v=studio-1-0-49';
import {actionButton,setAction,loraLabel} from './dogma_h3_actions.js?v=studio-1-0-49';
import {mediaPreview,inputFile} from './dogma_h3_media.js?v=studio-1-0-50';
import {frameIndex,frameTime,frameCount,stepTime} from './dogma_h3_timing.js?v=studio-1-0-50';
import {actionButton,setAction,loraLabel} from './dogma_h3_actions.js?v=studio-1-0-50';
const el=(tag,text='')=>{const e=document.createElement(tag);e.textContent=text;return e;};
const button=actionButton;
const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
+1 -1
View File
@@ -1,4 +1,4 @@
import { mediaPreview } from './dogma_h3_media.js?v=studio-1-0-49';
import { mediaPreview } from './dogma_h3_media.js?v=studio-1-0-50';
const el=(tag,text='')=>{const e=document.createElement(tag);e.textContent=text;return e;};
export function maskKey(c){
+3 -3
View File
@@ -1,6 +1,6 @@
import {viewURL} from './dogma_h3_media.js?v=studio-1-0-49';
import {actionButton,setAction} from './dogma_h3_actions.js?v=studio-1-0-49';
import {RestitchHover} from './dogma_h3_restitch_hover.js?v=studio-1-0-49';
import {viewURL} from './dogma_h3_media.js?v=studio-1-0-50';
import {actionButton,setAction} from './dogma_h3_actions.js?v=studio-1-0-50';
import {RestitchHover} from './dogma_h3_restitch_hover.js?v=studio-1-0-50';
const el=(tag,text='',cls='')=>{const e=document.createElement(tag);e.textContent=text;e.className=cls;return e;};
const clamp=(x,a,b)=>Math.min(b,Math.max(a,x));
export class RestitchPanel{
+1 -1
View File
@@ -1,4 +1,4 @@
import {viewURL} from './dogma_h3_media.js?v=studio-1-0-49';
import {viewURL} from './dogma_h3_media.js?v=studio-1-0-50';
const clamp=(x,a,b)=>Math.min(b,Math.max(a,x));
const el=(tag,cls='')=>{const e=document.createElement(tag);e.className=cls;return e;};
export class RestitchHover{
+2 -2
View File
@@ -1,5 +1,5 @@
import {mediaPreview,inputFile} from './dogma_h3_media.js?v=studio-1-0-49';
import {actionButton,setAction} from './dogma_h3_actions.js?v=studio-1-0-49';
import {mediaPreview,inputFile} from './dogma_h3_media.js?v=studio-1-0-50';
import {actionButton,setAction} from './dogma_h3_actions.js?v=studio-1-0-50';
const el=(tag,text='',cls='')=>{const e=document.createElement(tag);e.textContent=text;e.className=cls;return e;};
const extensions={image:new Set(['png','jpg','jpeg','webp','gif','bmp','tif','tiff','avif','heic','heif']),video:new Set(['mp4','mov','m4v','mkv','webm','avi','mpeg','mpg','wmv','flv','ts','mts','m2ts','mxf','ogv','3gp'])};
export function mediaKind(file){const mime=String(file.type||'').toLowerCase();if(mime.startsWith('image/'))return 'image';if(mime.startsWith('video/'))return 'video';const ext=String(file.name||'').split('.').pop().toLowerCase();return Object.keys(extensions).find(kind=>extensions[kind].has(ext));}
+16 -9
View File
@@ -1,12 +1,12 @@
import { MaskEditor, compositeControls } from './dogma_h3_edit_mask.js?v=studio-1-0-49';
import { EditComparison } from './dogma_h3_edit_compare.js?v=studio-1-0-49';
import { MaskEditor, compositeControls } from './dogma_h3_edit_mask.js?v=studio-1-0-50';
import { EditComparison } from './dogma_h3_edit_compare.js?v=studio-1-0-50';
import { api } from '../../scripts/api.js';
import {actionButton,setAction,loraLabel} from './dogma_h3_actions.js?v=studio-1-0-49';
import {RestitchPanel} from './dogma_h3_restitch.js?v=studio-1-0-49';
import {assetField,referenceDrop} from './dogma_h3_uploads.js?v=studio-1-0-49';
import { inputFile, viewURL, mediaPreview } from './dogma_h3_media.js?v=studio-1-0-49';
import { frameIndex, frameTime, frameCount, snapSelection, stepTime, parseSelection } from './dogma_h3_timing.js?v=studio-1-0-49';
export { snapSelection } from './dogma_h3_timing.js?v=studio-1-0-49';
import {actionButton,setAction,loraLabel} from './dogma_h3_actions.js?v=studio-1-0-50';
import {RestitchPanel} from './dogma_h3_restitch.js?v=studio-1-0-50';
import {assetField,referenceDrop} from './dogma_h3_uploads.js?v=studio-1-0-50';
import { inputFile, viewURL, mediaPreview } from './dogma_h3_media.js?v=studio-1-0-50';
import { frameIndex, frameTime, frameCount, snapSelection, stepTime, parseSelection } from './dogma_h3_timing.js?v=studio-1-0-50';
export { snapSelection } from './dogma_h3_timing.js?v=studio-1-0-50';
const el=(tag,text='',cls='')=>{const e=document.createElement(tag);e.textContent=text;e.className=cls;return e;};
const button=actionButton;
@@ -131,7 +131,14 @@ export function renderEditResults(studio,body){
studio.editCompare=new EditComparison(studio,card,selected);
card.append(el('small',selected.saved_path));
if(selected.export_error)card.append(el('p','Il video completo è conservato nella sessione. La copia nella cartella scelta non è riuscita: '+selected.export_error));
card.append(button('Apri cartella del video salvato',async()=>{try{await studio.request('/dogma/h3/v2/open-folder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session:studio.config.session,id:selected.id})});}catch(e){studio.fail(e.message);}}));
card.append(button('Apri cartella del video salvato',async()=>{try{
if(studio.catalog.access?.open_folder===false){
const base=studio.catalog.access.output_directory.replaceAll('\\','/');
const saved=String(selected.saved_path||'').replaceAll('\\','/'),directory=saved.slice(0,saved.lastIndexOf('/'));
const initialPath=directory===base||directory.startsWith(base+'/')?directory:base+'/'+(selected.video.subfolder||'');
await studio.browseFolders('edit',{initialPath,viewOnly:true});
}else await studio.request('/dogma/h3/v2/open-folder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session:studio.config.session,id:selected.id})});
}catch(e){studio.fail(e.message);}}));
const copy=button('Copia questo finale nella cartella scelta',async()=>{if(copy.disabled)return;copy.disabled=true;try{const result=await studio.request('/dogma/h3/v2/edit/export-copy',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session:studio.config.session,id:selected.id,output_dir:studio.config.edit.final_output_dir||studio.config.output_dir})});for(const item of studio.data.edits||[])if(item.id===result.id){item.saved_path=result.saved_path;delete item.export_error;}studio.data.status='done';studio.data.message='Video completo copiato nella cartella scelta.';studio.render();}catch(e){studio.fail(e.message);}finally{copy.disabled=false;}});copy.disabled=studio.pending;card.append(copy);
card.append(el('small','La copia usa il video già completato, senza ricalcolarlo. Destinazione: '+(studio.config.edit.final_output_dir||studio.config.output_dir||'cartella output Comfy')));
card.append(button('Cambia cartella di destinazione…',()=>studio.browseFolders('edit')));