Compare commits

...
3 Commits
19 changed files with 747 additions and 98 deletions
+12
View File
@@ -0,0 +1,12 @@
DOGMA Nodes 1.0.49
- Add drag and drop to the source video, uploaded mask, first/last images and image/video reference slots. Retain picker buttons. File drops outside these targets cannot navigate away from the workflow.
- Accept ordered batches of mixed image/video references in the reference section, reusing empty matching slots. Validate type, nonempty content, single-slot counts and reference limits before upload. Nested slot drops do not also add references. Report partial batch failures; preserve completed uploads and previous selections on invalid drops. Ignore late responses after session changes or closure.
- Remove duplicated plus signs beside SVG icons. Use Aggiungi immagine / Aggiungi video, direct multi-file pickers, clear previous/next-frame and zoom labels.
- Restitch preset hover opens a large floating video preview, including the custom preset. Damped cursor following, viewport bounds, short flyby delay, smooth switching, pointer-transparent interaction, keyboard focus and Escape dismissal; honor reduced-motion settings. Close/panel navigation releases the detail decoder and renderer.
- Keep the existing 320px shared grid preparation. On first hover, prepare one additional shared 960px-long-edge temporary atlas (or source size if smaller), rather than magnifying only the thumbnail. Display the actual preview resolution and loading state. Every preset reuses the same detail data and renderer; no encoded video per preset or duplicate final. Detail stays synchronized with the grid, loop, seek and pause. Until detail loads, show the existing preview. Both preview resolutions approximate the full-resolution matte; the saved final still uses original-resolution restitch.
- Retain all 1.0.48 LoRA, timing, colour preservation, restitch and export behavior. No model sampling changes, new dependencies or node identifiers.
Validation: JavaScript/Python syntax; 13 browser integration checks for upload validation, picker parity, ordered mixed batches, nested drops, partial failures, busy state, stale responses, directories and navigation guards. Isolated real-media browser check of all seven synchronized previews, 960x720 detail, viewport-edge placement, pause, preset switching, Escape and cleanup. Real 97-frame preview atlas dimensions and timestamps verified at both resolutions. Live Comfy workflow, source media and saved final remain untouched. No H3 generation, installation or publication performed by preparation.
The first detailed preview requires a separate one-time preparation; it is not included in the under-five-second grid timing previously measured for the 4-second example. The detail atlas is cached once per edit in Comfy temporary storage (about 26.7 MB for the tested 4.04-second clip), not in the chosen export directory. Existing generated-wall quality limitations still apply.
+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):
+9 -8
View File
@@ -26,15 +26,16 @@ def inputs(core,session,item_id):
if not master.is_relative_to(root) or not master.is_file():raise ValueError('Master della sessione non disponibile.')
return st,item,root,core.input_file(c['edit']['source']),core.input_file(settings['source']),raw,master,sha
def preview_atlas(core,session,item_id):
def preview_atlas(core,session,item_id,edge=320):
from .dogma_h3_video_edit import cached_probe,plan_interval
import folder_paths
if edge not in (320,960):raise ValueError('Risoluzione anteprima non disponibile.')
st,item,root,source,mask,raw,master,sha=inputs(core,session,item_id)
meta=cached_probe(source);validate_alignment(meta,cached_probe(mask));e=item['config']['edit'];plan=plan_interval(meta,e['start'],e['end'])
marks=[(str(p),p.stat().st_size,p.stat().st_mtime_ns) for p in (source,mask,raw)]
key=hashlib.sha256(json.dumps([marks,plan,'atlas-v2-320'],sort_keys=True).encode()).hexdigest()
key=hashlib.sha256(json.dumps([marks,plan,'atlas-v2-'+str(edge)],sort_keys=True).encode()).hexdigest()
folder=Path(folder_paths.get_temp_directory())/'DOGMA_RESTITCH';folder.mkdir(parents=True,exist_ok=True)
path=folder/(key+'.mp4');ratio=min(1.,320/max(meta['width'],meta['height']))
path=folder/(key+'.mp4');ratio=min(1.,edge/max(meta['width'],meta['height']))
w,h=(max(2,round(meta[k]*ratio/2)*2) for k in ('width','height'))
field_range=256.;started=time.monotonic()
if not path.exists():
@@ -114,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):
@@ -126,17 +127,17 @@ def register_routes(routes,core,local_request):
async def run():
try:
async with slot:
result=await asyncio.to_thread(preview_atlas,core,data['session'],data['id']) if kind=='preview' else await asyncio.to_thread(apply_restitch,core,data)
result=await asyncio.to_thread(preview_atlas,core,data['session'],data['id'],960 if data.get('detail') is True else 320) if kind=='preview' else await asyncio.to_thread(apply_restitch,core,data)
job.update(status='done',result=result)
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.48"
version = "1.0.51"
license = { file = "LICENSE" }
dependencies = ["scipy>=1.10"]
+3 -2
View File
@@ -1,4 +1,5 @@
const paths={play:'M8 5l11 7-11 7z',pause:'M8 5v14M16 5v14',upload:'M12 16V3m-5 5 5-5 5 5M4 15v6h16v-6',download:'M12 3v13m-5-5 5 5 5-5M4 17v4h16v-4',folder:'M3 7V5h7l2 2h9v13H3z',plus:'M12 5v14M5 12h14',trash:'M3 6h18M9 6V3h6v3M6 6l1 15h10l1-15M10 10v7M14 10v7',stop:'M6 6h12v12H6z',check:'M4 12l5 5L20 6',compare:'M3 4h18v16H3zM12 4v16',settings:'M4 7h16M4 17h16M9 4v6M15 14v6',refresh:'M20 7v5h-5M4 17v-5h5M6 7a7 7 0 0 1 12-1l2 3M4 15l2 3a7 7 0 0 0 12-1',back:'M20 12H4m7-7-7 7 7 7',next:'M8 5l10 7-10 7M20 5v14',previous:'M16 5 6 12l10 7M4 5v14',film:'M3 4h18v16H3zM7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4',save:'M5 3h12l4 4v14H3V3zM7 3v6h10V3M7 21v-8h10v8',zoom:'M10 3a7 7 0 1 0 0 14 7 7 0 0 0 0-14M15 15l6 6M7 10h6',layers:'m12 3 10 6-10 6L2 9zM2 13l10 6 10-6M2 17l10 6 10-6'};
paths.zoomIn=paths.zoom+'M10 7v6';
export function inferIcon(text){
const s=text.toLowerCase();
if(/ripristina|ricarica|riprova|nuovi seed/.test(s))return 'refresh';
@@ -6,13 +7,13 @@ export function inferIcon(text){
if(/pausa/.test(s))return 'pause';if(/riproduci/.test(s))return 'play';if(/interrompi|annulla/.test(s))return 'stop';
if(/rimuovi|elimina|libera/.test(s))return 'trash';if(/scarica/.test(s))return 'download';if(/carica|cambia file|cambia video/.test(s))return 'upload';
if(/cartella/.test(s))return 'folder';if(/salva|applica/.test(s))return 'save';if(/scegli|usa|conferma/.test(s))return 'check';
if(/confront|affiancati/.test(s))return 'compare';if(/zoom/.test(s))return 'zoom';if(/−1 fotogramma/.test(s))return 'previous';if(/\+1 fotogramma/.test(s))return 'next';
if(/confront|affiancati/.test(s))return 'compare';if(/ingrandisci/.test(s))return 'zoomIn';if(/riduci/.test(s))return 'zoom';if(/zoom/.test(s))return s.includes('+')?'zoomIn':'zoom';if(/−1 fotogramma|fotogramma precedente/.test(s))return 'previous';if(/\+1 fotogramma|fotogramma successivo/.test(s))return 'next';
if(/lora/.test(s))return 'layers';if(/maschera|impostazioni/.test(s))return 'settings';
if(/torna|vai all/.test(s))return 'back';if(/ripristina|ricarica|riprova|nuovi seed/.test(s))return 'refresh';
if(/^\+|aggiungi|nuova sessione/.test(s))return 'plus';if(/genera|provini|video|final|prepara|svilupp/.test(s))return 'film';return null;
}
export function setAction(button,text,icon=inferIcon(text)){
const label=document.createElement('span');label.textContent=text;button.replaceChildren();button.classList.add('dg-action');
const label=document.createElement('span');label.textContent=icon==='plus'?text.replace(/^\+\s+/,''):text;button.replaceChildren();button.classList.add('dg-action');
if(icon&&paths[icon]){const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.classList.add('dg-icon');svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.8');svg.setAttribute('stroke-linecap','round');svg.setAttribute('stroke-linejoin','round');svg.setAttribute('aria-hidden','true');const p=document.createElementNS(svg.namespaceURI,'path');p.setAttribute('d',paths[icon]);svg.append(p);button.append(svg);}
button.append(label);return button;
}
+19
View File
@@ -48,7 +48,26 @@
.dg-studio .dg-restitch-choice canvas{display:block;width:100%;height:clamp(95px,17vh,160px);object-fit:contain;margin:7px 0;background:#050505;border-radius:4px}
.dg-studio .dg-restitch-choice strong{display:block;font-size:13px}.dg-studio .dg-restitch-choice small{font-size:11px}
.dg-studio .dg-restitch-source{position:absolute!important;width:1px!important;height:1px!important;opacity:.01;pointer-events:none}
.dg-studio .dg-restitch-hover{position:fixed;top:0;left:0;z-index:100;box-sizing:border-box;padding:12px;border:1px solid #ff641dcc;border-radius:12px;background:#1b1b1df5;box-shadow:0 18px 70px #000b;pointer-events:none;opacity:0;visibility:hidden;transition:opacity .12s,visibility .12s;will-change:transform;contain:layout paint}
.dg-studio .dg-restitch-hover[data-open=true]{opacity:1;visibility:visible}
.dg-studio .dg-restitch-hover strong{display:block;margin:0 0 8px;font-size:15px;color:#fff}
.dg-studio .dg-restitch-hover canvas{display:block;width:100%;height:auto;max-height:calc(76vh - 72px);object-fit:contain;background:#050505;border-radius:6px}
.dg-studio .dg-restitch-hover small{display:block;margin-top:8px;font-size:12px;color:#d4c9c3;white-space:nowrap}
.dg-studio .dg-restitch-choice{cursor:zoom-in}
@media(prefers-reduced-motion:reduce){.dg-studio .dg-restitch-hover{transition:none}}
.dg-studio .dg-restitch h3{font-size:15px;margin:18px 0 10px}.dg-studio .dg-restitch .dg-grid{max-width:700px}
@media(max-width:1100px){.dg-studio .dg-restitch-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:750px){.dg-studio .dg-restitch-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:420px){.dg-studio .dg-restitch-grid{grid-template-columns:1fr}}
.dg-studio .dg-file-drop{transition:outline-color .12s,background-color .12s;outline:1px dashed #57504a;outline-offset:-5px}
.dg-studio .dg-file-drop[data-dragging=true]{outline:2px solid var(--orange);outline-offset:-3px;background:#3b281e;box-shadow:inset 0 0 0 5px #ff5b1422}
.dg-studio .dg-drop-hint{display:flex;align-items:center;gap:9px;padding:10px 12px;margin:10px 0;border:1px dashed #665045;border-radius:6px;color:#d2b9aa;font-size:13px;background:#1b1918}
.dg-studio .dg-file-drop[data-dragging=true]>.dg-drop-hint{color:#fff;border-color:var(--orange);background:#4b2c1c}
.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}
+35 -25
View File
@@ -1,12 +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-48';
import { renderEditor, renderEditResults, renderEditTrials } from './dogma_h3_video_edit.js?v=studio-1-0-48';
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-48';
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-48',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;
@@ -39,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){
@@ -96,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;}
@@ -118,6 +127,7 @@ export class DirectorStudio {
if(this.dialog?.open)return;
active=this;const epoch=++this.epoch;this.loading=true;
const dialog=this.dialog=el('dialog','','dg-studio');dialog.setAttribute('aria-label','DOGMA H3 Director');
guardFileNavigation(dialog);
dialog.addEventListener('cancel',e=>{e.preventDefault();this.close();});
// Keep Comfy shortcuts away from text fields; never synthesize keyboard events.
for(const name of ['keydown','keyup'])dialog.addEventListener(name,e=>e.stopPropagation());
@@ -144,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;}
@@ -252,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;}
@@ -312,17 +327,11 @@ export class DirectorStudio {
const draw=()=>{
[...d.children].slice(1).forEach(e=>e.remove());d.firstChild.textContent=`${title} · ${stack.length}`;
stack.forEach((l,i)=>{const row=el('div','','dg-lora-row');this.field(row,'LoRA',l,'name',{options:['',...this.catalog.loras]});this.field(row,'Potenza',l,'strength',{type:'number',step:.01,min:-10,max:10});row.append(button('Rimuovi',()=>{stack.splice(i,1);this.save();draw();}));d.append(row);});
d.append(button('+ Aggiungi LoRA',()=>{stack.push({name:'',strength:1});this.save();draw();}));decorateActions(d);
d.append(button('Aggiungi LoRA',()=>{stack.push({name:'',strength:1});this.save();draw();}));decorateActions(d);
};draw();
}
asset(parent,title,obj,key,kind='image',remove,onUpload){
const p=this.section(parent,title);const name=obj[key];
if(name)p.append(mediaPreview(inputFile(name),{kind,className:'dg-asset'}).root,el('small',name));
const upload=el('input');upload.type='file';upload.accept=kind==='video'?'video/*':'image/*';upload.hidden=true;
upload.onchange=async()=>{const file=upload.files?.[0];if(!file)return;this.uploads=(this.uploads||0)+1;this.status();try{const data=new FormData();data.append('image',file);data.append('type','input');data.append('subfolder','DOGMA_DIRECTOR');const res=await this.request('/upload/image',{method:'POST',body:data});obj[key]=(res.subfolder?res.subfolder+'/':'')+res.name;onUpload?.();this.save();this.render();}catch(e){if(e.name!=='AbortError')this.fail(e.message);}finally{this.uploads--;this.status();}};
p.append(upload,button(name?'Cambia file':'Carica file',()=>upload.click()));
if(name||remove)p.append(button('Rimuovi',()=>{if(remove)remove();else obj[key]='';this.save();this.render();}));
return p;
return assetField(this,parent,title,obj,key,kind,{remove,onUploaded:onUpload});
}
render(){
if(!this.dialog||this.loading)return;
@@ -355,10 +364,11 @@ export class DirectorStudio {
const assets=el('div','','dg-grid');body.append(assets);
if(c.task==='i2v'){this.asset(assets,'Primo fotogramma · facoltativo',c,'first');this.asset(assets,'Ultimo fotogramma · facoltativo',c,'last');}
if(c.task==='ref2v'){
let image=0,video=0;c.refs.forEach((r,i)=>this.asset(assets,r.kind==='image'?`Picture ${++image}`:`Video ${++video}`,r,'name',r.kind,()=>c.refs.splice(i,1)));
const p=this.section(assets,'Reference');p.append(el('p','Nel prompt usa <Picture 1>, <Picture 2>, <Video 1>… Dei video viene usato l’inizio, per la durata richiesta (massimo 15 secondi), ricampionato a 24 fps. Il loro audio non viene usato come reference.'));
if(image<9)p.append(button('+ Immagine',()=>{c.refs.push({kind:'image',name:''});this.save();this.render();}));
if(video<3)p.append(button('+ Video',()=>{c.refs.push({kind:'video',name:''});this.save();this.render();}));
const drop=referenceDrop(this,p,{image:9,video:3}),grid=el('div','','dg-grid');p.append(grid);
let image=0,video=0;c.refs.forEach((r,i)=>this.asset(grid,r.kind==='image'?`Picture ${++image}`:`Video ${++video}`,r,'name',r.kind,()=>c.refs.splice(i,1)));
if(image<9)p.append(button('Aggiungi immagine',()=>drop.choose('image')));
if(video<3)p.append(button('Aggiungi video',()=>drop.choose('video')));
}
const prompt=this.section(body,'Esplora una direzione');this.field(prompt,'Prompt comune',c,'prompt',{multi:true,rows:7,hint:'Può contenere tutti i paragrafi necessari. In modalità Prompt diversi puoi lasciarlo vuoto e scrivere prompt completi nelle schede.'});
this.field(prompt,'Tipo di confronto',c,'exploration',{options:[['seeds','Seed diversi · stesso prompt'],['prompts','Prompt diversi · stesso seed']],change:()=>this.render()});
@@ -370,7 +380,7 @@ export class DirectorStudio {
for(let i=0;i<c.count;i++){c.trial_loras[String(i)] ||= [];this.loras(prompt,c.trial_loras[String(i)],`LoRA aggiuntive · provino ${i+1}`);}
}else{
c.variants.forEach((v,i)=>{const card=this.section(prompt,`Prompt ${i+1}`);this.field(card,'Testo completo',v,'text',{multi:true,rows:7});this.loras(card,v.loras ||= [],'LoRA aggiuntive');if(c.variants.length>1)card.append(button('Elimina questa variante',()=>{c.variants.splice(i,1);this.save();this.render();}));});
if(c.variants.length<10)prompt.append(button('+ Nuovo prompt',()=>{c.variants.push({text:'',loras:[]});this.save();this.render();const inputs=this.dialog.querySelectorAll('textarea');inputs[inputs.length-1]?.focus({preventScroll:true});}));
if(c.variants.length<10)prompt.append(button('Aggiungi prompt',()=>{c.variants.push({text:'',loras:[]});this.save();this.render();const inputs=this.dialog.querySelectorAll('textarea');inputs[inputs.length-1]?.focus({preventScroll:true});}));
}
const size=this.section(body,'Formato dei provini');const sizes=el('div','','dg-grid');size.append(sizes);
this.field(sizes,'Proporzioni automatiche dalla prima immagine disponibile',c,'auto_aspect',{type:'checkbox',change:()=>this.render()});
+4 -4
View File
@@ -1,6 +1,6 @@
import {mediaPreview,inputFile} from './dogma_h3_media.js?v=studio-1-0-48';
import {frameIndex,frameTime,frameCount,stepTime} from './dogma_h3_timing.js?v=studio-1-0-48';
import {actionButton,setAction,loraLabel} from './dogma_h3_actions.js?v=studio-1-0-48';
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));
@@ -46,7 +46,7 @@ export class EditComparison{
this.playButton=button('Riproduci edit',()=>this.toggle(true));this.fullButton=button('Riproduci tutto',()=>this.toggle(false));
this.fullButton.hidden=this.left.offset>0||this.right.offset>0||this.left.end!==Infinity&&this.left.end<item.duration||this.right.end<item.duration;
const loopLabel=el('label','Ripeti in loop'),loop=el('input');loop.type='checkbox';loop.checked=studio.editLoop!==false;loop.onchange=()=>{studio.editLoop=loop.checked;};loopLabel.prepend(loop);
actions.append(this.playButton,this.fullButton,button('Vai all’edit',()=>this.seek(item.selection.start)),button('−1 fotogramma',()=>this.step(-1)),button('+1 fotogramma',()=>this.step(1)),loopLabel);
actions.append(this.playButton,this.fullButton,button('Vai all’edit',()=>this.seek(item.selection.start)),button('Fotogramma precedente',()=>this.step(-1)),button('Fotogramma successivo',()=>this.step(1)),loopLabel);
this.slider=el('input');this.slider.type='range';this.slider.step='any';this.slider.setAttribute('aria-label','Posizione comune del confronto');this.slider.oninput=()=>this.seek(Number(this.slider.value));
this.position=el('p');this.notice=el('small');this.notice.setAttribute('role','status');
const sound=el('label','Audio del confronto');sound.className='dg-field';const audio=el('select');
+1 -1
View File
@@ -1,4 +1,4 @@
import { mediaPreview } from './dogma_h3_media.js?v=studio-1-0-48';
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){
+9 -8
View File
@@ -1,5 +1,6 @@
import {viewURL} from './dogma_h3_media.js?v=studio-1-0-48';
import {actionButton,setAction} from './dogma_h3_actions.js?v=studio-1-0-48';
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{
@@ -45,7 +46,7 @@ export class RestitchPanel{
const card=actionButton('',()=>this.choose(p));card.className='dg-restitch-choice';card.setAttribute('aria-label','Seleziona '+p.label);card.setAttribute('aria-pressed',String(p===this.selected));
const title=el('strong',p.label),canvas=el('canvas'),settings=el('small');canvas.width=info.width;canvas.height=info.height;canvas.setAttribute('aria-label','Anteprima '+p.label);this.canvases.push(canvas);this.cards.push({card,title,settings,p});card.replaceChildren(title,canvas,settings);grid.append(card);
}
this.body.append(grid);
this.body.append(grid);this.hover=new RestitchHover(this,Renderer);this.cards.forEach((c,n)=>this.hover.bind(c.card,c.p,this.canvases[n]));
const manual=el('div','','dg-grid');
for(const [key,label,min,max]of[['grow','Espansione / contrazione · px',-256,256],['feather','Feather interno · px',0,256]]){
const wrap=el('label','','dg-field'),input=el('input');wrap.append(el('span',label));input.type='number';input.min=min;input.max=max;input.step=1;input.value=this.custom[key];
@@ -53,14 +54,14 @@ export class RestitchPanel{
}
this.body.append(el('h3','Impostazioni personali'),manual);
this.applyButton=actionButton('Applica al finale',()=>this.apply(),'dg-primary');this.body.append(this.applyButton);
this.body.append(el('small',`Anteprime ${info.width} × ${info.height} px · il salvataggio usa i pixel e i tempi del sorgente. Una sola anteprima temporanea condivisa; nessun file per ogni preset. La sfumatura in anteprima è indicativa alla risoluzione ridotta.`));
this.body.append(el('small',`Passa il mouse su un riquadro per ingrandirlo. Griglia ${info.width} × ${info.height} px; il dettaglio a 960 px viene preparato al primo passaggio e condiviso tra i preset. Il salvataggio usa i pixel e i tempi del sorgente; nessun file finale per ogni prova.`));
this.body.append(this.notice);this.notice.textContent='';this.refresh();
this.video=el('video');this.video.muted=true;this.video.loop=true;this.video.playsInline=true;this.video.preload='auto';this.video.className='dg-restitch-source';this.video.setAttribute('aria-hidden','true');this.body.append(this.video);
this.video.addEventListener('loadeddata',()=>{if(this.closed||!this.root.open)return;try{this.renderer=new Renderer(this.info,this.canvases);this.play();}catch(e){this.notice.textContent=e.message;}},{signal:this.abort.signal});
this.video.addEventListener('error',()=>{this.notice.textContent='Anteprima condivisa non leggibile. Chiudi e riapri la sezione per riprovare.';},{signal:this.abort.signal});
this.video.addEventListener('seeked',()=>this.draw(),{signal:this.abort.signal});
this.video.addEventListener('play',()=>{setAction(this.playButton,'Pausa anteprime');this.schedule();},{signal:this.abort.signal});
this.video.addEventListener('pause',()=>{this.cancelFrame();setAction(this.playButton,'Riproduci anteprime');},{signal:this.abort.signal});
this.video.addEventListener('pause',()=>{this.cancelFrame();this.hover?.pause();setAction(this.playButton,'Riproduci anteprime');},{signal:this.abort.signal});
this.video.src=viewURL(info.file);this.video.load();
}
choose(p){this.selected=p;this.studio.restitchDrafts[this.item.id]={grow:this.custom.grow,feather:this.custom.feather,selected:p.id};this.refresh();if(this.video?.paused)this.draw();}
@@ -71,7 +72,7 @@ export class RestitchPanel{
play(){if(!this.renderer)return;this.studio.editCompare?.pause();this.video.play().catch(()=>{setAction(this.playButton,'Riproduci anteprime');this.notice.textContent='Premi Riproduci anteprime per avviare tutti i riquadri.';});}
schedule(){this.cancelFrame();if(this.closed||!this.video||this.video.paused)return;const next=()=>{if(this.closed||this.video?.paused)return;this.draw();this.schedule();};if(this.video.requestVideoFrameCallback)this.frame=this.video.requestVideoFrameCallback(next);else this.raf=requestAnimationFrame(next);}
cancelFrame(){if(this.frame!=null)this.video?.cancelVideoFrameCallback?.(this.frame);cancelAnimationFrame(this.raf);this.frame=null;}
draw(){if(!this.renderer||!this.video||this.video.readyState<2)return;this.renderer.draw(this.video,this.presets);if(!this.root.dataset.readyMs)this.root.dataset.readyMs=Math.round(performance.now()-this.openedAt);this.scrub.value=this.video.currentTime;this.position.textContent=this.video.currentTime.toFixed(2)+' s';for(const c of this.canvases)c.dataset.time=this.video.currentTime.toFixed(6);}
draw(){if(!this.renderer||!this.video||this.video.readyState<2)return;this.renderer.draw(this.video,this.presets);if(!this.root.dataset.readyMs)this.root.dataset.readyMs=Math.round(performance.now()-this.openedAt);this.scrub.value=this.video.currentTime;this.position.textContent=this.video.currentTime.toFixed(2)+' s';for(const c of this.canvases)c.dataset.time=this.video.currentTime.toFixed(6);this.hover?.draw();}
async apply(){
if(this.applyButton.disabled)return;this.applying=true;this.refresh();this.notice.textContent='Applicazione alla dimensione originale… Nessuna nuova generazione.';
try{
@@ -79,11 +80,11 @@ export class RestitchPanel{
if(this.closed)return;Object.assign(this.item,result);this.studio.restitchDrafts[this.item.id]={grow:this.selected.grow,feather:this.selected.feather,selected:'current'};this.studio.data.message=result.export_error||'Restitch applicato al finale.';this.applying=false;this.studio.render();
}catch(e){if(this.closed||e.name==='AbortError')return;this.applying=false;this.notice.textContent=e.message;this.refresh();}
}
release(){this.applying=false;this.loadEpoch=(this.loadEpoch||0)+1;this.cancelFrame();this.video?.pause();if(this.video){this.video.removeAttribute('src');this.video.load();}this.video=null;this.renderer?.destroy();this.renderer=null;clearTimeout(this.pollTimer);this.pollResolve?.();}
release(){this.applying=false;this.loadEpoch=(this.loadEpoch||0)+1;this.hover?.destroy();this.hover=null;this.cancelFrame();this.video?.pause();if(this.video){this.video.removeAttribute('src');this.video.load();}this.video=null;this.renderer?.destroy();this.renderer=null;clearTimeout(this.pollTimer);this.pollResolve?.();}
destroy(){if(this.closed)return;this.closed=true;this.release();this.abort.abort();}
}
class Renderer{
export class Renderer{
constructor(info,canvases){
this.info=info;this.canvases=canvases;this.contexts=canvases.map(c=>c.getContext('2d'));this.output=document.createElement('canvas');this.output.width=info.width*canvases.length;this.output.height=info.height;
const gl=this.gl=this.output.getContext('webgl',{alpha:false,antialias:false,preserveDrawingBuffer:true});
+81
View File
@@ -0,0 +1,81 @@
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{
constructor(panel,Renderer){
this.panel=panel;this.Renderer=Renderer;this.abort=new AbortController();this.closed=false;
this.root=el('div','dg-restitch-hover');this.root.setAttribute('aria-hidden','true');
this.title=el('strong');this.canvas=el('canvas');this.caption=el('small');this.root.append(this.title,this.canvas,this.caption);
// Keep the floating preview in the dialog's top layer; it never intercepts clicks.
panel.root.closest('dialog').append(this.root);
const signal=this.abort.signal;
window.addEventListener('keydown',e=>{if(e.key==='Escape'&&this.active){this.hide();e.preventDefault();e.stopPropagation();}},{capture:true,signal});
window.addEventListener('scroll',()=>this.hide(),{capture:true,passive:true,signal});
window.addEventListener('resize',()=>this.hide(),{signal});
window.addEventListener('blur',()=>this.hide(),{signal});
}
bind(card,preset,thumbnail){
const signal=this.abort.signal,show=e=>{
if(e.pointerType==='touch')return;clearTimeout(this.showTimer);clearTimeout(this.hideTimer);
this.pointer={x:e.clientX,y:e.clientY};
this.showTimer=setTimeout(()=>this.show(preset,thumbnail),this.active?0:140);
};
card.addEventListener('pointerenter',show,{signal});
card.addEventListener('pointermove',e=>{this.pointer={x:e.clientX,y:e.clientY};if(this.active)this.follow();},{signal});
card.addEventListener('pointerleave',()=>{clearTimeout(this.showTimer);this.hideTimer=setTimeout(()=>this.hide(),65);},{signal});
card.addEventListener('focus',()=>{if(!card.matches(':focus-visible'))return;const r=card.getBoundingClientRect();show({clientX:r.x+r.width/2,clientY:r.y+r.height/2});},{signal});
card.addEventListener('blur',()=>this.hide(),{signal});
}
show(preset,thumbnail){
if(this.closed||!this.panel.root.open)return;
this.active={preset,thumbnail};this.title.textContent=preset.label;this.root.dataset.open='true';
const ratio=this.panel.info.width/this.panel.info.height;
const viewport=window.visualViewport,w=viewport?.width||innerWidth,h=viewport?.height||innerHeight;
const width=Math.max(120,Math.min(1080,w*.62,(h*.76-72)*ratio,w-32));
this.root.style.width=width+'px';this.canvas.style.aspectRatio=ratio;
this.draw();this.follow(true);this.loadDetail();
}
follow(immediate=false){
if(!this.active)return;const viewport=window.visualViewport,ox=viewport?.offsetLeft||0,oy=viewport?.offsetTop||0;
const w=viewport?.width||innerWidth,h=viewport?.height||innerHeight,r=this.root.getBoundingClientRect(),p=this.pointer;
// Anchor opposite the pointer, then add a small, damped vertical follow.
const left=p.x<w/2?p.x+28:p.x-r.width-28;
this.target={x:clamp(left,ox+16,ox+w-r.width-16),y:clamp(p.y-r.height*.48,oy+16,oy+h-r.height-16)};
if(immediate||!this.position){this.position={...this.target};this.place();}
if(!this.motion)this.motion=requestAnimationFrame(()=>this.tick());
}
tick(){this.motion=null;if(!this.active)return;const p=this.position,t=this.target;
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches,k=reduced?1:.2;p.x+=(t.x-p.x)*k;p.y+=(t.y-p.y)*k;this.place();
if(Math.abs(t.x-p.x)+Math.abs(t.y-p.y)>.3)this.motion=requestAnimationFrame(()=>this.tick());
}
place(){this.root.style.transform=`translate3d(${this.position.x}px,${this.position.y}px,0)`;}
async loadDetail(){
if(this.loading||this.detailInfo||this.detailError||this.closed)return;this.loading=true;
try{
const info=await this.panel.job('/dogma/h3/v2/edit/restitch/preview',{session:this.panel.studio.config.session,id:this.panel.item.id,detail:true});
if(this.closed)return;this.detailInfo=info;this.root.dataset.preparedMs=Math.round(info.elapsed_seconds*1000);const video=this.video=el('video','dg-restitch-source');video.muted=true;video.loop=true;video.playsInline=true;video.preload='auto';this.panel.body.append(video);
video.addEventListener('loadeddata',()=>{if(this.closed)return;try{this.renderer=new this.Renderer(info,[this.canvas]);this.sync();this.draw();}catch(e){this.detailError=e.message;}},{signal:this.abort.signal});
video.addEventListener('seeked',()=>{if(!this.closed)this.draw();},{signal:this.abort.signal});
video.addEventListener('error',()=>{this.detailError='Dettaglio non disponibile';this.draw();},{signal:this.abort.signal});
video.src=viewURL(info.file);video.load();
}catch(e){if(!this.closed&&e.name!=='AbortError'){this.detailError=e.message;this.draw();}}
finally{this.loading=false;}
}
sync(){
const master=this.panel.video,video=this.video;if(!master||!video||!this.renderer||!this.active)return false;
if(video.seeking)return false;
if(Math.abs(video.currentTime-master.currentTime)>.075){video.currentTime=master.currentTime;return false;}
if(master.paused)video.pause();else if(video.paused)video.play().catch(()=>{});
return video.readyState>=2;
}
draw(){
if(!this.active||this.closed)return;const {preset,thumbnail}=this.active,detail=this.sync(),info=detail?this.detailInfo:this.panel.info;
if(this.canvas.width!==info.width||this.canvas.height!==info.height){this.canvas.width=info.width;this.canvas.height=info.height;}
if(detail)this.renderer.draw(this.video,[preset]);else this.canvas.getContext('2d').drawImage(thumbnail,0,0,this.canvas.width,this.canvas.height);
const t=this.panel.video?.currentTime||0;this.canvas.dataset.time=t.toFixed(6);this.root.dataset.quality=detail?'detail':'grid';
this.caption.textContent=`${preset.grow>0?'+':''}${preset.grow} px · feather ${preset.feather} px · ${info.width} × ${info.height} · ${t.toFixed(2)} s`+(!detail?(this.detailError?' · Dettaglio non disponibile':' · Preparazione dettaglio…'):'');
}
pause(){this.video?.pause();}
hide(){clearTimeout(this.showTimer);clearTimeout(this.hideTimer);this.active=null;delete this.root.dataset.open;this.pause();cancelAnimationFrame(this.motion);this.motion=null;}
destroy(){if(this.closed)return;this.closed=true;this.hide();this.abort.abort();this.renderer?.destroy();if(this.video){this.video.removeAttribute('src');this.video.load();this.video.remove();}this.root.remove();}
}
+92
View File
@@ -0,0 +1,92 @@
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));}
export function validateFiles(files,kind,{multiple=false}={}){
if(!files.length)throw Error('Nessun file da caricare.');
if(!multiple&&files.length!==1)throw Error('Questa area accetta un solo file alla volta.');
for(const f of files){const actual=mediaKind(f);if(!actual||kind!=='media'&&actual!==kind)throw Error(`«${f.name}»: qui puoi caricare ${kind==='image'?'un’immagine':kind==='video'?'un video':'immagini e video'}.`);if(!f.size)throw Error(`«${f.name}» è vuoto e non può essere caricato.`);}
return files;
}
export function referencePlan(refs,files,limits){
validateFiles(files,'media',{multiple:true});
for(const kind of ['image','video']){const present=refs.filter(r=>r.kind===kind&&r.name).length,incoming=files.filter(f=>mediaKind(f)===kind).length;if(present+incoming>limits[kind])throw Error(`Limite reference: massimo ${limits.image} immagini e ${limits.video} video aggiuntivi. Riduci i file selezionati.`);}
return files.map(file=>({file,kind:mediaKind(file)}));
}
const fileTransfer=e=>Array.from(e.dataTransfer?.types||[]).includes('Files')||!!e.dataTransfer?.files?.length;
export function dropTarget(root,kind,submit,notice,{multiple=false,label=''}={}){
root.classList.add('dg-file-drop');root.dataset.fileKind=kind;if(label)root.setAttribute('aria-label',label);
let depth=0;const reset=()=>{depth=0;delete root.dataset.dragging;};
root.addEventListener('dragenter',e=>{if(!fileTransfer(e))return;e.preventDefault();e.stopPropagation();depth++;root.dataset.dragging='true';});
root.addEventListener('dragover',e=>{if(!fileTransfer(e))return;e.preventDefault();e.stopPropagation();e.dataTransfer.dropEffect='copy';root.dataset.dragging='true';});
root.addEventListener('dragleave',e=>{if(!depth)return;e.stopPropagation();depth=Math.max(0,depth-1);if(!depth)delete root.dataset.dragging;});
root.addEventListener('drop',e=>{
if(!fileTransfer(e))return;e.preventDefault();e.stopPropagation();reset();
try{
if(Array.from(e.dataTransfer.items||[]).some(item=>item.webkitGetAsEntry?.()?.isDirectory))throw Error('Trascina i singoli file, senza cartelle.');
const files=Array.from(e.dataTransfer.files||[]);validateFiles(files,kind,{multiple});submit(files);
}catch(error){notice(error.message,true);}
});
return reset;
}
export function guardFileNavigation(dialog){
dialog.addEventListener('dragover',e=>{if(fileTransfer(e)){e.preventDefault();e.dataTransfer.dropEffect='none';}});
dialog.addEventListener('drop',e=>{if(!fileTransfer(e))return;e.preventDefault();const toast=el('div','Rilascia il file in un’area di caricamento.','dg-upload-toast');toast.setAttribute('role','status');dialog.append(toast);setTimeout(()=>toast.remove(),3500);});
}
function current(studio,config,session,epoch){return studio.config===config&&studio.config.session===session&&studio.epoch===epoch;}
async function upload(studio,file){const data=new FormData();data.append('image',file);data.append('type','input');data.append('subfolder','DOGMA_DIRECTOR');const response=await studio.request('/upload/image',{method:'POST',body:data});return (response.subfolder?response.subfolder+'/':'')+response.name;}
function hint(root,text){const h=el('div','','dg-drop-hint');setAction(h,text,'upload');root.append(h);return h;}
export function assetField(studio,parent,title,obj,key,kind='image',options={}){
const root=studio.section(parent,title),name=obj[key];let busy=false;
if(name&&options.preview!==false)root.append(mediaPreview(inputFile(name),{kind,className:'dg-asset'}).root);
if(name)root.append(el('small',name));
hint(root,kind==='video'?'Trascina qui un video':'Trascina qui un’immagine');
const picker=el('input');picker.type='file';picker.accept=kind==='video'?'video/*':'image/*';picker.hidden=true;
const notice=el('p','','dg-upload-status');notice.setAttribute('role','status');
const message=(text,error=false)=>{notice.textContent=text;root.dataset.uploadState=error?'error':busy?'uploading':'idle';};
const choose=actionButton(name?(options.changeLabel||'Cambia file'):(options.loadLabel||'Carica file'),()=>{if(studio.uploads>0)message('Attendi il caricamento già in corso.');else picker.click();});let remove;
if(options.allowRemove!==false&&(name||options.remove))remove=actionButton('Rimuovi',()=>{if(options.remove)options.remove();else obj[key]='';studio.save();studio.render();});
const send=async files=>{
if(busy||studio.uploads>0){message('Attendi il caricamento già in corso.');return;}
try{validateFiles(files,kind);}catch(error){message(error.message,true);return;}
busy=true;choose.disabled=true;if(remove)remove.disabled=true;root.setAttribute('aria-busy','true');studio.uploads=(studio.uploads||0)+1;studio.status();message('Caricamento di '+files[0].name+'…');
const config=studio.config,session=config.session,epoch=studio.epoch;
try{
const name=await upload(studio,files[0]);if(!current(studio,config,session,epoch))return;
obj[key]=name;options.onUploaded?.(name);studio.save();studio.render();
}catch(error){if(error.name!=='AbortError')message(error.message,true);}
finally{busy=false;root.setAttribute('aria-busy','false');choose.disabled=false;if(remove)remove.disabled=false;studio.uploads=Math.max(0,(studio.uploads||1)-1);studio.status();}
};
picker.onchange=()=>{const files=Array.from(picker.files||[]);picker.value='';if(files.length)send(files);};
root.append(picker,choose);if(remove)root.append(remove);root.append(notice);
dropTarget(root,kind,send,message,{label:title});return root;
}
export function referenceDrop(studio,root,{image=9,video=3}={}){
const limits={image,video};let busy=false;
hint(root,'Trascina qui immagini e video di riferimento, anche più file insieme');
const notice=el('p',studio.referenceUploadNotice||'','dg-upload-status');notice.setAttribute('role','status');root.append(notice);
const message=(text,error=false)=>{notice.textContent=text;root.dataset.uploadState=error?'error':busy?'uploading':'idle';};
const send=async files=>{
if(busy||studio.uploads>0){message('Attendi il caricamento già in corso.');return;}
let plan;try{plan=referencePlan(studio.config.refs,files,limits);}catch(error){message(error.message,true);return;}
busy=true;studio.uploads=(studio.uploads||0)+1;studio.status();root.setAttribute('aria-busy','true');
const config=studio.config,session=config.session,epoch=studio.epoch;let done=0;const errors=[];
try{
for(const [i,entry]of plan.entries()){
message(`Caricamento reference ${i+1}/${plan.length}: ${entry.file.name}…`);
try{
const name=await upload(studio,entry.file);if(!current(studio,config,session,epoch))throw new DOMException('Caricamento annullato','AbortError');
const empty=config.refs.find(r=>r.kind===entry.kind&&!r.name);
if(empty)empty.name=name;else config.refs.push({kind:entry.kind,name});done++;studio.save();
}catch(error){if(error.name==='AbortError')throw error;errors.push(entry.file.name+': '+error.message);}
}
studio.referenceUploadNotice=errors.length?`Caricati ${done} file. `+errors.join(' · '):`Caricati ${done} file di riferimento.`;
if(done)studio.render();else message(studio.referenceUploadNotice,true);
}catch(error){if(error.name!=='AbortError')message(error.message,true);}
finally{busy=false;root.setAttribute('aria-busy','false');studio.uploads=Math.max(0,(studio.uploads||1)-1);studio.status();}
};
dropTarget(root,'media',send,message,{multiple:true,label:'Reference: trascina immagini e video'});
const pickers={};for(const kind of ['image','video']){const picker=el('input');picker.type='file';picker.accept=kind+'/*';picker.multiple=true;picker.hidden=true;picker.onchange=()=>{const files=Array.from(picker.files||[]);picker.value='';if(files.length)send(files);};pickers[kind]=picker;root.append(picker);}
return {choose:kind=>{if(!busy&&!studio.uploads)pickers[kind].click();else message('Attendi il caricamento già in corso.');}};
}
+24 -30
View File
@@ -1,11 +1,12 @@
import { MaskEditor, compositeControls } from './dogma_h3_edit_mask.js?v=studio-1-0-48';
import { EditComparison } from './dogma_h3_edit_compare.js?v=studio-1-0-48';
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-48';
import {RestitchPanel} from './dogma_h3_restitch.js?v=studio-1-0-48';
import { inputFile, viewURL, mediaPreview } from './dogma_h3_media.js?v=studio-1-0-48';
import { frameIndex, frameTime, frameCount, snapSelection, stepTime, parseSelection } from './dogma_h3_timing.js?v=studio-1-0-48';
export { snapSelection } from './dogma_h3_timing.js?v=studio-1-0-48';
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;
@@ -15,34 +16,20 @@ export const clock=t=>{const m=Math.floor(t/60),s=t-m*60;return `${String(m).pad
export function renderEditor(studio,body){
const c=studio.config,e=c.edit;
const section=studio.section(body,'Video originale · scegli il tratto da modificare');
const upload=el('input');upload.type='file';upload.accept='video/*';upload.hidden=true;
const load=button(e.source?'Cambia video':'Carica video',()=>upload.click());
const uploadStatus=el('small');
upload.onchange=async()=>{
const file=upload.files?.[0];if(!file)return;
studio.uploads=(studio.uploads||0)+1;studio.status();load.disabled=true;uploadStatus.textContent='Caricamento del video sul server…';
try{
const data=new FormData();data.append('image',file);data.append('type','input');data.append('subfolder','DOGMA_DIRECTOR');
const r=await studio.request('/upload/image',{method:'POST',body:data});
e.source=(r.subfolder?r.subfolder+'/':'')+r.name;e.start=0;e.end=107/24;
studio.editMeta=null;studio.save();studio.render();
}catch(err){if(err.name!=='AbortError')studio.fail(err.message);}
finally{studio.uploads--;studio.status();load.disabled=false;uploadStatus.textContent='';}
};
section.append(upload,load,uploadStatus);
const section=assetField(studio,body,'Video originale · scegli il tratto da modificare',e,'source','video',{
preview:false,allowRemove:false,loadLabel:'Carica video',changeLabel:'Cambia video',onUploaded:()=>{e.start=0;e.end=107/24;studio.editMeta=null;}});
if(e.source){
section.append(el('small',e.source));
const timeline=el('div','','dg-edit-panel');section.append(timeline);
studio.editTimeline=new EditTimeline(studio,timeline,e);
}
studio.maskEditor=new MaskEditor(studio,body);
const refs=studio.section(body,'Reference per la modifica');
refs.append(el('p','Il tratto selezionato è <Video 1>. Le immagini aggiuntive sono <Picture 1>, <Picture 2>…; gli altri video sono <Video 2> e <Video 3>.'));
const drop=referenceDrop(studio,refs,{image:9,video:2});
const grid=el('div','','dg-grid');refs.append(grid);let pictures=0,videos=1;
c.refs.forEach((r,i)=>studio.asset(grid,r.kind==='image'?`Picture ${++pictures}`:`Video ${++videos}`,r,'name',r.kind,()=>c.refs.splice(i,1)));
if(pictures<9)refs.append(button('+ Immagine reference',()=>{c.refs.push({kind:'image',name:''});studio.save();studio.render();}));
if(videos<3)refs.append(button('+ Video reference',()=>{c.refs.push({kind:'video',name:''});studio.save();studio.render();}));
if(pictures<9)refs.append(button('Aggiungi immagine',()=>drop.choose('image')));
if(videos<3)refs.append(button('Aggiungi video',()=>drop.choose('video')));
refs.append(el('small','Massimo 9 immagini e 3 video incluso il sorgente. I video aggiuntivi devono durare 2–15 s; la somma, sorgente selezionato incluso, non può superare 15 s.'));
const prompt=studio.section(body,'Cosa vuoi cambiare?');
studio.field(prompt,'Istruzioni di edit',c,'prompt',{multi:true,rows:7,hint:'Descrivi la modifica e il ruolo delle reference. Il sorgente viene inserito automaticamente come Video 1.'});
@@ -67,7 +54,7 @@ export function renderEditor(studio,body){
variant.loras??=[];studio.loras(row,variant.loras,'LoRA della variante');
if(c.variants.length>1)row.append(button('Rimuovi variante',()=>{c.variants.splice(i,1);studio.save();studio.render();}));
});
if(c.variants.length<10)trials.append(button('+ Variante',()=>{c.variants.push({text:'',loras:[]});studio.save();studio.render();}));
if(c.variants.length<10)trials.append(button('Aggiungi variante',()=>{c.variants.push({text:'',loras:[]});studio.save();studio.render();}));
}
trials.append(el('p',`I provini elaborano solo la selezione fino a ${e.trial_edge} px. In modalità rapida il primo caricamento e la prima preparazione restano necessari; i seed successivi riusano la preparazione comune. Scegli un provino per rigenerarlo alla dimensione originale con lo stesso prompt, seed e maschera.`));
const go=button(`Genera provini edit a ${e.trial_edge} px`,()=>studio.queue('draft'));go.className='dg-primary';go.dataset.queue='';body.append(go);
@@ -144,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')));
@@ -184,9 +178,9 @@ class EditTimeline{
const controls=el('div','','dg-edit-controls');
const loopLabel=el('label','Ripeti in loop'),loop=el('input');loop.type='checkbox';loop.checked=studio.editLoop!==false;loop.onchange=()=>{studio.editLoop=loop.checked;v.loop=loop.checked;};loopLabel.prepend(loop);controls.append(loopLabel);
this.playButton=button('Riproduci selezione',()=>{if(!v.paused){v.pause();return;}if(!this.commitInputs())return;cancelAnimationFrame(this.seekRAF);this.seekRAF=null;if(v.currentTime<this.config.start||v.currentTime>=this.config.end)v.currentTime=this.config.start;this.playingSelection=true;v.play().catch(()=>{this.playingSelection=false;});});controls.append(this.playButton);
controls.append(button('−1 fotogramma',()=>this.seek(stepTime(m,v.currentTime,-1))),button('+1 fotogramma',()=>this.seek(stepTime(m,v.currentTime,1))));
controls.append(button('Fotogramma precedente',()=>this.seek(stepTime(m,v.currentTime,-1))),button('Fotogramma successivo',()=>this.seek(stepTime(m,v.currentTime,1))));
controls.append(button('Inizia la selezione da qui',()=>{const t=frameTime(m,Math.min(frameCount(m)-1,frameIndex(m,v.currentTime))),d=this.config.end-this.config.start;this.select(Math.min(t,m.duration-d),Math.min(t+d,m.duration),'end');}));
controls.append(button('− Zoom',()=>this.zoom(2)),button('+ Zoom',()=>this.zoom(.5)),button('Tutto il video',()=>{this.span=m.duration;this.viewStart=0;this.paint(true);}));
controls.append(button('Riduci',()=>this.zoom(2)),button('Ingrandisci',()=>this.zoom(.5)),button('Tutto il video',()=>{this.span=m.duration;this.viewStart=0;this.paint(true);}));
this.root.append(controls);
const overviewLabel=el('label','Scorri nel video completo','dg-field');
const overview=this.overview=el('input');overview.type='range';overview.min=0;overview.max=m.duration;overview.step=1/m.fps;