Compare commits

..
14 Commits
Author SHA1 Message Date
axior 42a976ae63 DOGMA 1.0.51: Add bounded-memory HDR finishing and recoverable export [skip ci] 2026-11-14 00:07:54 +01:00
axior 9d3241a272 DOGMA 1.0.50: Fix proxy restitch access and unify desktop and server folder controls [skip ci] 2026-11-13 17:50:59 +01:00
axior 463878c802 DOGMA 1.0.49: Add media drag and drop and synchronized floating restitch detail [skip ci] 2026-11-13 16:28:10 +01:00
axior 91e692ced7 DOGMA 1.0.48: Add synchronized restitch previews, custom mattes, action icons and edit LoRA variants [skip ci] 2026-11-13 15:58:55 +01:00
axior 70641ca408 DOGMA 1.0.47: Fix frame cadence, preserve source colour and separate generation and final mattes [skip ci] 2026-11-13 14:50:23 +01:00
axior ad83bb4917 DOGMA 1.0.46: Develop approved edits, validate masks and save finals to the chosen folder [skip ci] 2026-11-13 13:30:27 +01:00
axior 59ac90acc5 DOGMA 1.0.45: Add fast edit trials, immediate promotion and looping AB comparison [skip ci] 2026-11-12 21:16:32 +01:00
axior e694bead16 DOGMA 1.0.43: Add native masked video edits, mask overlay preview and 512px edit trials [skip ci] 2026-11-12 20:07:11 +01:00
axior 7b4b2ebe65 DOGMA 1.0.41: Fix media preview paths and free edit intervals [skip ci] 2026-11-12 18:51:49 +01:00
axior a3fda9f153 DOGMA 1.0.40: H3 Director Video Edit, visual timeline, source-size compositing and prompt scroll fix [skip ci] 2026-11-12 17:08:32 +01:00
axior 9814f66aa7 DOGMA 1.0.39: H3 Director V2 fullscreen studio, persistent trials and staged finishing [skip ci] 2026-10-31 15:43:03 +01:00
axior 0abaad9969 DOGMA 1.0.38: H3 Director V2 fullscreen studio, persistent trials and staged finishing [skip ci] 2026-10-28 20:40:20 +01:00
axior 6d6a45ee21 DOGMA 1.0.37: H3 Director V2 fullscreen studio, persistent trials and staged finishing [skip ci] 2026-10-28 19:44:13 +01:00
axior 1357b86bfd DOGMA 1.0.36: H3 Director V2 fullscreen studio, persistent trials and staged finishing [skip ci] 2026-10-28 18:41:12 +01:00
34 changed files with 3424 additions and 145 deletions
+18
View File
@@ -0,0 +1,18 @@
# DOGMA Nodes 1.0.40 — H3 Director Video Edit
Adds a fourth Director mode for a bounded edit of a long source clip. The editor
offers draggable timeline handles, thumbnails, live seek, zoom and additional
references. H3 Ref2VA generates only the selection at the original canvas size
(temporary padding is cropped), without upscale, RIFE or staged finishing.
The edited selection is composited into a full-length export with original audio.
The source video is re-encoded; it is not a bit-exact stream copy. Native H3 is
24 fps; source timestamps are retained without motion interpolation. Selection
lengths follow the supported frame grid and available reference budget.
Adding test prompts preserves scroll and focuses the new prompt. Existing
Director modes and node IDs remain available. No additional custom-node pack.
Requires FFmpeg on PATH or imageio-ffmpeg, and the existing H3 dependencies.
Validation: nine media/integration tests, 291 timeline constraints, browser
interaction checks. Full H3 GPU inference and visual seam quality remain untested.
Use DOGMA_H3_DIRECTOR_V4.json. Restart ComfyUI and reload the browser after updating.
+3
View File
@@ -0,0 +1,3 @@
DOGMA Nodes 1.0.41
Fix previews of uploaded media in subfolders. Allow manually entered edit times with comma or dot decimals. Select intervals down to one source frame; pad H3 temporal inputs and trim the generated tail during composition.
+14
View File
@@ -0,0 +1,14 @@
DOGMA Nodes 1.0.43
Includes the preview/timing/comparison fixes prepared for 1.0.42.
- Optional user-uploaded B/W video mask, matching the full source. White regenerates, black preserves.
- Source-pixel grow/contract and Gaussian feather, shared by mask preparation and final composition.
- Native H3 masked generation: source-seeded AV latent plus nested noise mask, with Ref2VA conditioning retained. Uses MaskVidExperiments mask geometry; no global model patches.
- Asynchronous CPU overlay video preview, cache invalidation after mask, source, selection or settings changes.
- Edit trials up to 512 px, multiple seeds or prompt variants; final original-size regeneration from the selected trial's saved configuration.
- Original audio retained in final output; trials and mask previews are silent.
Requires current native H3 masking support in ComfyUI and MVEx_MaskToLatentSpace from MaskVidExperiments. This node is present in the user's running ComfyUI.
Validation: real mask processing and export tests, installed MaskVidExperiments CPU temporal/spatial mapping, source/reference/noise-mask wiring with a deterministic H3 stand-in, frozen trial settings, 512x384 trial to 640x480 final, isolated browser tests. No new GPU H3 generation was run.
+13
View File
@@ -0,0 +1,13 @@
DOGMA Nodes 1.0.45
Includes the full-duration rounding and visible overlay-validation fixes from 1.0.44.
- Fast edit trials use installed Ref2Video Turbo 4-step sampling; final quality settings remain separate and frozen in the selected trial.
- Trial sizes 256/384/512 px; optional live previews are disabled by default for trials.
- Source, references, text conditioning and native mask preparation are reused for identical prompts across seed trials. Reference images/video are reduced for draft preparation only.
- Selecting a completed trial during a batch abandons the unfinished trial at the next local checkpoint, skips the remaining trials and immediately regenerates the selected trial at original size. No global Comfy interrupt is used.
- Source and comparison playback loop by default; one click restarts playback after a non-looping finish.
- Independent A/B selectors compare original, trials and completed versions from the same source with synchronized source-time offsets. A or B can be selected for the final render.
- A newly completed trial no longer replaces a trial the user is already inspecting.
Validation uses real file processing and deterministic CPU H3 stand-ins, including interruption during sampling, exact frozen selection, separate 4/20-step settings, preparation reuse and browser loop/promotion/A-B tests. No new GPU H3 performance benchmark was run.
+19
View File
@@ -0,0 +1,19 @@
DOGMA Nodes 1.0.46
- Explicit original-size edit sampling: match selected trial (default), Turbo Ref2Video 4-step, or standard without Turbo with a chosen step count. Existing saved trials are supported.
- Final mode reaches both the normal finish action and immediate promotion during an active trial batch, while source/prompt/seed/references/mask stay frozen to the chosen trial.
- Save complete effective sampling settings with new trials and recover the known Turbo preset for 1.0.45 trial records.
- Validate the native joint AV edit mask before sampling: grid shape, finite range, nonempty video edit area and zero audio mask. Record the sampled mask coverage in the output metadata.
- Preserve an existing video mask when audio locking is requested. This fixes a fragile generic sampler branch; current edit calls already keep audio locking off and use the native audio mask.
- Show each saved final's originating trial, seed and sampling mode; distinguish historical outputs while a new final is still running.
- Develop the approved edit using its saved generated AV latents, a learned latent upscale and partial denoise under the original native edit mask. Older trials explicitly encode their approved video; missing new checkpoints stop without falling back to the source.
- Use the approved trial as Video 1 in guided development, at its existing reference resolution, instead of reintroducing the source wall or inflating reference tokens.
- Default to preserve mode: resize the approved edited video inside its saved mask without new inference or applying feather/fades twice. Keep H3 development and source-based regeneration as explicit options.
- Save new trial checkpoints and file fingerprints. Retain full prompt/seed/source/reference/mask provenance on each final.
- Always copy completed edit finals to the selected destination (the Comfy output root when empty). Show the path and allow opening the folder or copying a completed final elsewhere without regenerating it.
- Commit manually typed folder paths in one confirmation and propagate the latest destination when promoting a trial during an active batch.
- Include edit results in the open-folder lookup; update displayed paths when an existing final is copied.
Validation: native mask/export regressions, explicit final-mode switching, method/destination propagation during active-batch promotion, production sample() handoff with audio locking off/on and missing-mask rejection, isolated browser Standard and preserve finals and copy controls. A real 1664x1248 H3 Turbo 4-step guided render from the user's legacy approved trial completed; the native mask was present. Both preserve and guided exports retain all 97 source timestamps and unchanged audio packets. GPU guided development took 213.7 seconds in the isolated verification, including preparation and export. The guided result still introduced wall artifacts: it is optional, not the default. CPU neural stand-ins were used for the wider regression matrix; Standard sampling was not separately rerun on the real GPU.
Guided refinement can change generated details. Preserve mode keeps the approved appearance by resizing pixels; it does not synthesize new high-resolution detail.
+13
View File
@@ -0,0 +1,13 @@
DOGMA Nodes 1.0.47
- Fix frame content, not just timestamps: exact-timestamp NUT intermediates and nearest-frame matching replace millisecond Matroska / floor matching, which repeated and skipped every third frame at 24 fps.
- Restitch by decoding each original frame exactly once. Preserve its timestamp, time base and protected native colour samples; replace only the selected matte area. Export H264 losslessly, retaining source audio packets where MP4 supports their codec. Files are larger than the previous CRF16 export.
- Keep raw generated frames for new trials. Recover 1.0.46 trials from their saved generated latents using VAE decode only; do not rerun H3 or use a preview that already lost frames. Update the recovered preview pointer. Older trials without saved latents stop explicitly instead of falling back to the original.
- Separate H3 generation-mask grow/feather from the final compositing matte. Default final matte uses the uploaded mask with 0-pixel grow and 8-pixel inward feather, preserving black window bars and carriage geometry even when H3 needs a larger generation area. Original shared-mask behaviour remains an explicit option.
- Preview either the H3 generation area or the exact final compositing area. Carry final matte settings through ordinary and early trial selection, without altering the frozen source/prompt/seed/reference/generation mask. Preserve mode can change final matte settings without new H3 sampling.
- Add independent H3 processing resolution: native 768-short-edge / about 1 MP (1024x768 at 4:3), chosen-trial resolution, or original resolution explicitly marked experimental. The exported master always retains the source geometry and timing. Size references consistently with the processing canvas.
- Record processing/export sizes, final matte settings, lossless encoding and file fingerprints. Clearly label older finals that lack these export fixes.
Validation: production-path CPU edit regressions (including masked/unmasked edit, saved-trial promotion, Turbo/standard selection, raw-latent recovery and no-sampling preservation); frame-identity tests for all 97 frames, VFR and fractional rates; 10-bit YUV and grayscale sources with nonzero timestamp origins; expanded generation mask with protected frame geometry; real user-trial recovery and restitch of all 97 original frames, zero changed protected RGB pixels, exact protected YUV samples and unchanged audio packets. Both mask preview areas were exercised in an isolated browser with the real uploaded mask. No live installation or publication is performed by preparing this release.
Visual limits: the earlier diagnostic export still showed the expanded matte over the carriage. The new separate matte addresses that compositing defect; the final edge remains adjustable. A real 1024x768 Turbo 4-step source-based edit completed with the native mask present but still retained wall/grid artifacts. Native resolution is not a guaranteed cure for instruction adherence. Preserve mode retains the selected generation's appearance, including any flaws and low-resolution detail. H3 development/regeneration can change or fail the requested edit. No test result here is a claim of artistic approval of the wall replacement.
+14
View File
@@ -0,0 +1,14 @@
DOGMA Nodes 1.0.48
- Optional collapsed restitch refinement below completed masked edits: six named presets plus one user-defined variant with grow/contract and inward-feather controls.
- All seven live views share a single browser video decode and frame clock. They loop and scrub together. Edits stay in the preview until Apply is selected. The grid is released when closed.
- Build one small temporary preview atlas, not seven encoded videos. Live grow/feather changes require no server render. Retain one raw generated segment per new H3 edit; preserve finals reuse the trial segment. Cache is shared across variants and repeated openings.
- Apply the selected contour at full source resolution without H3 inference. Replace the same completed master and its unchanged exported copy atomically, retaining source timestamps, protected samples and original audio. No per-preset final copies or new gallery versions. Reject stale requests, invalid settings and external file modifications.
- Match live contour offsets with distance-based full-resolution restitch. Preview sampling is deliberately reduced to 320-pixel-long-edge images; exact edges and encoding are evaluated in the original-size final.
- Play buttons toggle pause and resume in the source selection and synchronized comparison. Remove the redundant separate pause button; preserve restart/loop and cancellation behavior.
- Add consistent orange SVG action icons throughout the workflow: loading, generation, selection, comparison, frame stepping, folders, save/download, removal, recovery, LoRA stacks and restitch refinement. Maintain text labels and primary-action hierarchy.
- Expose additional LoRA stacks and weights on every edit variant. Add LoRA comparison with the same common prompt and seed; optional text can carry character trigger words. Common LoRAs remain available. The chosen combined stack is frozen with the trial and retained for the final even after later UI changes. A blank variant can use the common prompt as a baseline.
Validation: full CPU production-path edit regressions; distinct same-seed LoRA variants and selected-stack promotion; exact frame identity/VFR checks; real 97-frame masked source restitch with custom settings, unchanged protected colour planes, source timestamps and coherent master/export hashes; stale/invalid request rejection and no duplicate finals. Real cold preparation measured 2.92 seconds on the user's 4.04-second clip. The browser displayed all seven synchronized views in 3.24 seconds, exercised custom controls, applied a choice, reopened the cache and verified play/pause in selection/comparison. One temporary atlas was about 3.4 MB. This timing is measured for that clip and machine, not a universal limit for longer videos.
Older regenerated/guided finals whose raw generated segment was deleted by earlier versions must be prepared again before this refinement is available. Preserve finals can reuse an existing saved trial segment. Refinement changes the restitch; it does not repair flaws already present in the generated wall. Native model quality and training-resolution findings from 1.0.47 still apply. No new H3 sampling or character-weight downloads were performed for this UX release.
+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
+249 -64
View File
@@ -14,30 +14,36 @@ import shutil
_STATE_LOCK=threading.RLock()
from pathlib import Path
from .dogma_h3_video_edit import edit_defaults, run_edit
def defaults():
return dict(version=2, session=str(uuid.uuid4()), action="draft", selected="", nonce=0,
generate_audio=True, output_dir="", job="DOGMA", task="i2v", exploration="seeds", count=2, seed=64,
prompt="", variants=[{"text":"", "loras":[]}], trial_loras={},
first="", last="", refs=[], common_loras=[], seconds=5,
auto_aspect=True, long_edge=672, width=672, height=384, sound=True,
first="", last="", refs=[], common_loras=[], seconds=5, edit=edit_defaults(),
auto_aspect=True, long_edge=512, width=512, height=288, sound=True,
models={"i2v":"minimax_h3_fl2va_pruned_int8_convrot.safetensors",
"t2v":"minimax_h3_fl2va_pruned_int8_convrot.safetensors", "ref2v":"minimax_h3_ref2va_pruned_int8_convrot.safetensors", "clip":"qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"vae":"minimax_h3_video_vae_fp16.safetensors", "audio":"minimax_h3_audio_vae_fp32.safetensors"},
draft={"turbo":"minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors",
"turbo_strength":1., "steps":4, "sampler":"euler", "scheduler":"simple",
"shift_video":12., "shift_audio":3., "lying":0., "lying_start":.1,"lying_end":.9},
finish={"stages":"both", "resolution":"2k", "scale":2., "turbo":"minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors",
develop={"mode":"guided", "long_edge":960, "turbo":"", "turbo_strength":1.,
"steps":20, "sampler":"res_multistep", "scheduler":"simple", "denoise":.65,
"shift_video":12., "shift_audio":3., "lying":-.01, "lying_start":.1, "lying_end":.9},
selected_development="",
finish={"stages":"both", "resolution":"2k", "scale":2., "turbo":"",
"turbo_strength":1., "sampler":"er_sde", "scheduler":"simple", "steps":8,
"sigmas":"", "denoise":.18,
"sigmas":"0.9035, 0.8000, 0.6316, 0.3158, 0.0000", "denoise":.35,
"shift_video":12.,"shift_audio":3.,"lying":0.,"lying_start":.1,"lying_end":.9,
"upscaler":"minimax_h3_latent_upscaler_3d_bf16.safetensors",
"ultimate_scale":1., "tile_width":768,"tile_height":512,"tile_padding":64,
"mask_blur":16,"ultimate_steps":8,"ultimate_denoise":.14,
"ultimate_model":"minimax_h3_fl2va_pruned_int8_convrot.safetensors",
"ultimate_model":"minimax_h3_ref2va_pruned_int8_convrot.safetensors", "ultimate_sampler":"res_multistep", "ultimate_seed":42, "refine_seed":444,
"ultimate_turbo":"minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors",
"ultimate_strength":1.},
rife={"enabled":True,"fps":48,"model":"flownet.pkl","scale":1.,"batch_size":1,"fp16":True},
preview={"enabled":True,"tiny_vae":"taeh3.safetensors","max_resolution":512,"frames":12,"fps":6})
@@ -52,16 +58,14 @@ def parse_config(raw):
data=json.loads(raw) if isinstance(raw,str) else copy.deepcopy(raw)
if not isinstance(data,dict): raise ValueError("Configurazione Director non valida.")
c=merge(defaults(),data)
# Migrate the shipped high-noise refinement preset without overriding custom settings.
if 'resolution' not in data.get('finish',{}):
if c['finish']['sigmas']=='0.9035, 0.8, 0.6316, 0.3158, 0':
c['finish'].update(sigmas='',denoise=.18)
# Stored sampling settings always win. Restoring a preset is an explicit UI action.
if c['finish']['resolution'] not in ('1k','2k','3k','4k'):
raise ValueError('Risoluzione: scegli 1K, 2K, 3K o 4K.')
c['session']=str(uuid.UUID(c['session']))
if c['action'] not in ('draft','finish'): raise ValueError('Azione non valida.')
if c['task'] not in ('i2v','t2v','ref2v'): raise ValueError('Scegli I2V, T2V o Ref2Video.')
if c['exploration'] not in ('seeds','prompts'): raise ValueError('Modalità provini non valida.')
if c['action'] not in ('draft','develop','finish'): raise ValueError('Azione non valida.')
if c['task'] not in ('i2v','t2v','ref2v','edit'): raise ValueError('Scegli I2V, T2V, Ref2Video o Video Edit.')
if c['task']=='edit' and c['action'] not in ('draft','finish'): raise ValueError('Video Edit usa provini e rigenerazione originale.')
if c['exploration'] not in ('seeds','prompts','loras'): raise ValueError('Modalità provini non valida.')
if not 1<=int(c['count'])<=10: raise ValueError('Scegli da 1 a 10 provini.')
if not 0.2<=float(c['seconds'])<=15: raise ValueError('Durata: da 0,2 a 15 secondi.')
if not 256<=int(c['long_edge'])<=1536: raise ValueError('Lato lungo provini: 256–1536 px.')
@@ -70,8 +74,14 @@ def parse_config(raw):
if not 1<=float(c['finish']['scale'])<=4 or not 1<=float(c['finish']['ultimate_scale'])<=4:
raise ValueError('Fattore upscale: da 1 a 4.')
if c['finish']['stages'] not in ('both','latent','ultimate'): raise ValueError('Finalizzazione non valida.')
if int(c['rife']['fps']) not in (48,60): raise ValueError('RIFE: scegli 48 o 60 fps.')
if not 1<=int(c['rife']['batch_size'])<=32: raise ValueError('RIFE batch: da 1 a 32.')
if float(c['rife']['scale']) not in (.25,.5,1.,2.,4.): raise ValueError('Scala RIFE non valida.')
if not 0<=int(c['seed'])<2**53: raise ValueError('Seed fuori intervallo.')
for phase in ('draft','finish'):
if c['develop']['mode'] not in ('guided','native'): raise ValueError('Modalità sviluppo non valida.')
if not 512<=int(c['develop']['long_edge'])<=1536: raise ValueError('Sviluppo: lato lungo 512–1536 px.')
if not 0<float(c['develop']['denoise'])<1: raise ValueError('Sviluppo guidato: denoise maggiore di 0 e minore di 1.')
for phase in ('draft','develop','finish'):
s=c[phase]
if not 0<=float(s['lying_start'])<=float(s['lying_end'])<=1: raise ValueError('Intervallo Lying Sigma non valido.')
if not 1<=int(s['steps'])<=100: raise ValueError('Step: da 1 a 100.')
@@ -87,11 +97,12 @@ def parse_config(raw):
def make_plan(c):
common=c['prompt'].strip()
if c['exploration']=='prompts':
if c['exploration'] in ('prompts','loras'):
variants=c['variants']
if not 1<=len(variants)<=10: raise ValueError('Inserisci da 1 a 10 schede prompt.')
if any(not v.get('text','').strip() for v in variants): raise ValueError('Ogni scheda prompt deve contenere testo.')
return [dict(prompt='\n\n'.join(x for x in (common,v['text'].strip()) if x),
if c['exploration']=='prompts' and not common and any(not v.get('text','').strip() for v in variants): raise ValueError('Ogni scheda prompt deve contenere testo oppure usare le istruzioni comuni.')
if c['exploration']=='loras' and not common:raise ValueError('Scrivi le istruzioni di edit comuni prima di confrontare le LoRA.')
return [dict(prompt='\n\n'.join(x for x in (common,v.get('text','').strip()) if x),
seed=int(c['seed']),loras=c['common_loras']+v.get('loras',[])) for v in variants]
if not common: raise ValueError('Scrivi il prompt prima di generare.')
return [dict(prompt=common,seed=(int(c['seed'])+i)%(2**53-1),
@@ -153,10 +164,34 @@ def export_file(path,c):
if not directory.is_absolute(): raise ValueError('La cartella output deve avere un percorso assoluto.')
directory.mkdir(parents=True,exist_ok=True)
dest=directory/(path.stem+'_'+uuid.uuid4().hex[:8]+path.suffix)
shutil.copy2(path,dest)
temp=dest.with_suffix(dest.suffix+'.tmp')
try:
shutil.copy2(path,temp)
if temp.stat().st_size!=Path(path).stat().st_size:raise OSError('Copia del video finale incompleta.')
temp.replace(dest)
finally:temp.unlink(missing_ok=True)
return str(dest.resolve())
def export_saved_edit(data):
import folder_paths
with _STATE_LOCK:
st=state(data['session'])
if st.get('status')=='running':raise ValueError('Attendi la fine della lavorazione prima di ricopiare un finale.')
item=next((x for x in st.get('edits',[]) if x.get('id')==data.get('id') and x.get('complete')),None)
if not item:raise ValueError('Finale completo non disponibile.')
f=item['video'];source=(Path(folder_paths.get_output_directory())/f.get('subfolder','')/f['filename']).resolve()
if not source.is_relative_to(manifest_root(data['session']).resolve()) or not source.is_file():
raise ValueError('File finale non disponibile. Non verrà esportato il sorgente al suo posto.')
destination=data.get('output_dir','').strip() or folder_paths.get_output_directory()
saved=export_file(source,dict(output_dir=destination))
item['saved_path']=saved;item.pop('export_error',None);item.setdefault('exports',[]).append(saved)
if st.get('message','').startswith('Video pronto e conservato nella sessione; copia'):
st.update(status='done',message='Video completo copiato nella cartella scelta.')
write_json(manifest_root(data['session'])/'session.json',st)
return dict(saved_path=saved,id=item['id'])
def call(name,uid='dogma',**kwargs):
import nodes
cls=nodes.NODE_CLASS_MAPPINGS.get(name)
@@ -230,7 +265,7 @@ def condition(c,a,res,prompt,w,h,n):
def load_resources(c):
import nodes
m=c['models']; name=m[c['task']]
m=c['models']; name=m['ref2v' if c['task']=='edit' else c['task']]
if not name: raise ValueError(f'Seleziona il modello {c["task"]} nelle impostazioni avanzate.')
return {'model':nodes.UNETLoader().load_unet(name,'default')[0],
'clip':nodes.CLIPLoader().load_clip(m['clip'],'minimax','default')[0],
@@ -247,7 +282,8 @@ def prepare_model(res,c,trial,phase,uid):
for l in stack:
if l.get('name') and l.get('enabled',True) and float(l.get('strength',1))!=0:
model=nodes.LoraLoaderModelOnly().load_lora_model_only(model,l['name'],float(l.get('strength',1)))[0]
model=MiniMaxH3SigmaShift.execute(model,float(s['shift_video']),float(s['shift_audio']))[0]
if not s.get('native_shift',False):
model=MiniMaxH3SigmaShift.execute(model,float(s['shift_video']),float(s['shift_audio']))[0]
p=c['preview']
if p['enabled']:
model=call('ModelPreviewOverrideKJ',uid,model=model,max_resolution=int(p['max_resolution']),
@@ -268,6 +304,9 @@ def sampler_for(s):
def sample(model,cond,latent,s,seed,manual=False,progress=None):
import torch
if s.get('require_edit_mask'):
from .dogma_h3_edit_mask import inspect_native_mask
inspect_native_mask(latent)
from comfy_extras.nodes_custom_sampler import BasicScheduler,Guider_Basic,Noise_RandomNoise,SamplerCustomAdvanced
guider=Guider_Basic(model); guider.set_conds(cond)
if manual and s.get('sigmas','').strip():
@@ -285,9 +324,22 @@ def sample(model,cond,latent,s,seed,manual=False,progress=None):
def callback(step,x0,x,total):
if preview: preview(step,x0,x,total)
progress(step+1,total)
result=guider.sample(Noise_RandomNoise(seed).generate_noise(latent),tensor,sampler_for(s),sigmas,
noise=Noise_RandomNoise(seed).generate_noise(latent)
if s.get('lock_audio'):
from comfy.nested_tensor import NestedTensor
video,audio=tensor.unbind(); nv,na=noise.unbind()
noise=NestedTensor([nv,torch.zeros_like(na)])
existing_mask=latent.get('noise_mask')
video_mask=existing_mask.unbind()[0] if existing_mask is not None else torch.ones_like(video)
latent['noise_mask']=NestedTensor([video_mask,torch.zeros_like(audio)])
if s.get('require_edit_mask'):
from .dogma_h3_edit_mask import inspect_native_mask
inspect_native_mask(latent)
result=guider.sample(noise,tensor,sampler_for(s),sigmas,
denoise_mask=latent.get('noise_mask'),callback=callback,
disable_pbar=not comfy.utils.PROGRESS_BAR_ENABLED,seed=seed)
if s.get('lock_audio'):
result=NestedTensor([result.unbind()[0],audio.to(result.unbind()[1].device)])
latent['samples']=result.to(comfy.model_management.intermediate_device())
latent.pop('downscale_ratio_spacial',None);latent.pop('downscale_ratio_temporal',None)
return latent
@@ -299,11 +351,24 @@ def decode(res,latent,generate_audio=True):
return nodes.VAEDecode().decode(res['vae'],latent)[0],(VAEDecodeAudio.execute(res['audio'],latent)[0] if generate_audio else None)
def save_video(images,audio,path,metadata):
def rife_preflight(settings):
"""Fail before inference, rather than let the third-party node download weights."""
import nodes, folder_paths, inspect
cls=nodes.NODE_CLASS_MAPPINGS.get('RIFEInterpolation')
if cls is None: raise ValueError('RIFE richiede ComfyUI-VFI. Installalo dal Manager oppure disattiva RIFE in Finalizza.')
name=settings['model']
if Path(name).name!=name or name!='flownet.pkl': raise ValueError('Modello RIFE non valido: usa flownet.pkl.')
base=Path(inspect.getfile(cls)).parent
paths=[base/'rife'/'train_log'/name,Path(folder_paths.models_dir)/'rife'/name,base/'models'/name]
if not any(p.is_file() for p in paths):
raise ValueError('Pesi RIFE flownet.pkl assenti. Configura ComfyUI-VFI o disattiva RIFE; nessun download automatico è stato avviato.')
def save_video(images,audio,path,metadata,fps=24):
from comfy_extras.nodes_video import CreateVideo
from comfy_api.latest import Types
path.parent.mkdir(parents=True,exist_ok=True)
video=CreateVideo.execute(images,24.,audio)[0]
video=CreateVideo.execute(images,float(fps),audio)[0]
video.save_to(str(path),format=Types.VideoContainer('mp4'),codec=Types.VideoCodec('h264'),metadata={'dogma':metadata},crf=20)
@@ -340,10 +405,11 @@ class DOGMAH3DirectorEngineV2:
import torch
from .dogma_h3_director_v133 import pack,unpack
c=parse_config(sessione); root=manifest_root(c['session']); root.mkdir(parents=True,exist_ok=True)
st=state(c['session']); st['engine']=str(unique_id); st['session']=c['session']
st=state(c['session']); st['engine']=str(unique_id); st['session']=c['session'];st['action']=c['action']
def update(status,message,**extra):
from server import PromptServer
st.update(status=status,message=message,action=c['action'],progress=None,**extra)
values=dict(status=status,message=message,action=st.get('action',c['action']),progress=None)
values.update(extra);st.update(values)
write_json(root/'session.json',st)
PromptServer.instance.send_sync('dogma.h3.v2',st)
def progress(step,total):
@@ -352,13 +418,15 @@ class DOGMAH3DirectorEngineV2:
PromptServer.instance.send_sync('dogma.h3.v2.progress',{'session':c['session'],'engine':str(unique_id),'step':step,'total':total})
try:
update('running','Preparazione risorse…')
if c['action']=='draft':
if c['task']=='edit':
run_edit(c,root,st,update,progress,unique_id)
elif c['action']=='draft':
plan=make_plan(c); a=assets(c)
first=next((x for x in [a['first'],a['last'],*a['images'].values(),*a['videos'].values()] if x is not None),None)
w,h=dimensions(c,(first.shape[2],first.shape[1]) if first is not None else None)
n=frames(c['seconds']); res=load_resources(c)
run=str(uuid.uuid4()); dest=root/run; dest.mkdir()
update('running','Preparazione prompt…',candidates=[],finals=[],run=run,width=w,height=h,frames=n,total_trials=len(plan))
update('running','Preparazione prompt…',candidates=[],developments=[],finals=[],run=run,width=w,height=h,frames=n,total_trials=len(plan))
# Encode once for shared prompts; different prompt cards preserve all paragraphs.
encoded={}
for i,p in enumerate(plan):
@@ -381,10 +449,57 @@ class DOGMAH3DirectorEngineV2:
st['candidates'].append(candidate)
update('running',f'Provino {i+1}/{len(plan)} pronto')
del result,images,audio,model
update('ready','Provini pronti. Scegli quello da finalizzare.',active=None)
else:
update('ready','Bozze pronte. Scegli quale sviluppare in qualità.',active=None)
elif c['action']=='develop':
candidate=next((p for p in st.get('candidates',[]) if p['id']==c['selected']),None)
if candidate is None: raise ValueError('Seleziona un provino disponibile nella galleria.')
if candidate is None: raise ValueError('Scegli una bozza da sviluppare.')
frozen=parse_config(candidate['config']); frozen['develop']=copy.deepcopy(c['develop']); frozen['preview']=c['preview']
# No Turbo from the draft is inherited. Only the chosen additional LoRAs persist.
d=frozen['develop']; frozen['long_edge']=int(d['long_edge'])
res=load_resources(frozen); a=assets(frozen)
w,h=dimensions(dict(auto_aspect=True,long_edge=d['long_edge']), (candidate['width'],candidate['height']))
n=candidate['frames']; folder=root/'developments'/str(uuid.uuid4()); folder.mkdir(parents=True)
update('running','Sviluppo · preparazione alla risoluzione di qualità')
cond,empty=condition(frozen,a,res,candidate['prompt'],w,h,n)
settings=copy.deepcopy(d)
if d['mode']=='guided':
from comfy.nested_tensor import NestedTensor
path=(root/candidate['latent']).resolve()
if not path.is_relative_to(root.resolve()): raise ValueError('Percorso latenti non valido.')
saved=unpack(torch.load(path,map_location='cpu',weights_only=True))
video,audio_lat=saved['samples'].unbind()
update('running','Sviluppo · trasferimento dei latenti della bozza scelta')
up=call('MinimaxH3LatentUpscaler3D',latent={'samples':video},model_name=c['finish']['upscaler'],
mode={'mode':'target dimensions','width':w,'height':h},align=32,keep_proportion=False,
device='cuda',precision='fp16',offload_after_upscale=True)[0]
v=up['samples']; h,w=v.shape[-2]*16,v.shape[-1]*16
if tuple(v.shape)!=tuple(empty['samples'].unbind()[0].shape):
cond,empty=condition(frozen,a,res,candidate['prompt'],w,h,n)
expected_video,expected_audio=empty['samples'].unbind()
if tuple(v.shape)!=tuple(expected_video.shape) or tuple(audio_lat.shape)!=tuple(expected_audio.shape):
raise ValueError('Dimensioni dei latenti incompatibili con lo sviluppo. Rigenera la bozza con la stessa durata e modalità.')
latent={'samples':NestedTensor([v,audio_lat])}; settings['lock_audio']=True
else:
latent=empty; settings['denoise']=1.
model=prepare_model(res,frozen,candidate,'develop',unique_id)
update('running','Sviluppo · campionamento di qualità',active='develop')
started=time.monotonic()
result=sample(model,cond,latent,settings,candidate['seed'],progress=progress)
torch.save(pack(result),folder/'developed.pt')
update('running','Sviluppo · decodifica e salvataggio')
images,audio=decode(res,result,c['generate_audio'])
job=re.sub(r'[^\w.-]+','_',c['job'])[:80] or 'DOGMA'
movie=folder/(job+'_H3_SVILUPPO.mp4'); save_video(images,audio,movie,candidate)
item=dict(id=uuid.uuid4().hex,candidate=candidate['id'],label=candidate['label']+' · sviluppo',
video=view_file(movie),saved_path=export_file(movie,c),latent=str((folder/'developed.pt').relative_to(root)),
width=images.shape[2],height=images.shape[1],frames=n,config=frozen,mode=d['mode'],
elapsed_seconds=round(time.monotonic()-started,2),prompt=candidate['prompt'],seed=candidate['seed'],loras=candidate['loras'])
st.setdefault('developments',[]).append(item)
update('developed','Sviluppo pronto. Controllalo e scegli quale finalizzare.',active=None)
else:
candidate=next((p for p in st.get('developments',[]) if p['id']==c.get('selected_development') and p['candidate']==c['selected']),None)
if candidate is None: raise ValueError('Sviluppa prima la bozza e seleziona uno sviluppo disponibile.')
if c['rife']['enabled']: rife_preflight(c['rife'])
# Freeze source/prompt/model/LoRAs to selected trial; only finishing controls are editable.
frozen=parse_config(candidate['config']); frozen['finish']=c['finish']; frozen['preview']=c['preview']
res=load_resources(frozen); a=assets(frozen)
@@ -411,7 +526,7 @@ class DOGMAH3DirectorEngineV2:
cond=condition(frozen,a,res,candidate['prompt'],w,h,n)[0]
model=prepare_model(res,frozen,candidate,'finish',unique_id)
update('running','Finalizzazione · raffinamento dei latenti')
latent=sample(model,cond,latent,fin,candidate['seed'],True,progress=progress)
latent=sample(model,cond,latent,dict(fin,lock_audio=True),int(fin['refine_seed']),True,progress=progress)
torch.save(pack(latent),folder/'latent_refined.pt')
update('running','Finalizzazione · decodifica')
images,audio=decode(res,latent,c['generate_audio'])
@@ -420,8 +535,18 @@ class DOGMAH3DirectorEngineV2:
images=torch.cat([F.interpolate(chunk.movedim(-1,1),size=(target_h,target_w),mode='bicubic',align_corners=False).movedim(1,-1).clamp(0,1) for chunk in images.split(8)])
intermediate=folder/(job+'_H3_LATENT.mp4' if stages!='ultimate' else job+'_H3_SCELTO.mp4')
save_video(images,audio,intermediate,candidate)
st.setdefault('finals',[]).append({'label':'Latent' if stages!='ultimate' else 'Provino scelto','candidate':candidate['id'],'video':view_file(intermediate),'saved_path':export_file(intermediate,c),'id':uuid.uuid4().hex})
update('running' if stages!='latent' else 'done','Passaggio salvato')
st.setdefault('finals',[]).append({'label':'Intermedio · Latent' if stages!='ultimate' else 'Intermedio · Provino scelto','candidate':candidate['id'],'video':view_file(intermediate),'saved_path':export_file(intermediate,c),'id':uuid.uuid4().hex,'fps':24,'frames':len(images),'width':images.shape[2],'height':images.shape[1]})
update('running','Passaggio a 24 fps salvato')
fps=24
if c['rife']['enabled']:
r=c['rife']; fps=int(r['fps'])
update('running',f'Finalizzazione · RIFE da 24 a {fps} fps, prima di Ultimate')
images=call('RIFEInterpolation',unique_id,images=images,source_fps=24,target_fps=fps,
scale=float(r['scale']),model_name=r['model'],batch_size=int(r['batch_size']),use_fp16=bool(r['fp16']))[0]
out=folder/(job+f'_H3_RIFE_{fps}fps.mp4')
save_video(images,audio,out,candidate,fps=fps)
st['finals'].append({'label':f'Intermedio · RIFE · {fps} fps','candidate':candidate['id'],'video':view_file(out),
'saved_path':export_file(out,c),'id':uuid.uuid4().hex,'fps':fps,'frames':len(images),'width':images.shape[2],'height':images.shape[1]})
if stages in ('both','ultimate'):
import torch.nn.functional as F
from comfy_extras.nodes_custom_sampler import Guider_Basic,BasicScheduler
@@ -432,7 +557,9 @@ class DOGMAH3DirectorEngineV2:
# Original Ultimate H3 workflow appends a temporal context tail, then trims it.
count=len(images); padded_n=frames((count+17)/24)
padded=torch.cat([images,images[-1:].repeat(padded_n-count,1,1,1)])
# Ultimate uses FL2V conditioning on the selected decoded video, including for Ref2V/T2V trials.
# Ultimate encodes its own local video tiles. Full-frame keyframe
# tokens cannot be passed to a tile-sized latent (H3 shape mismatch).
# Match the original Ultimate graph: FL2V text conditioning only.
import nodes
ultimate_res=dict(res)
if fin['ultimate_model']!=frozen['models'][frozen['task']]:
@@ -440,17 +567,21 @@ class DOGMAH3DirectorEngineV2:
ultimate_config=copy.deepcopy(frozen)
ultimate_config['finish']['turbo']=fin['ultimate_turbo']
ultimate_config['finish']['turbo_strength']=fin['ultimate_strength']
model=prepare_model(ultimate_res,ultimate_config,candidate,'finish',unique_id)
ultimate_config['finish']['native_shift']=True
model=prepare_model(ultimate_res,ultimate_config,dict(candidate,loras=[]),'finish',unique_id)
from comfy_extras.nodes_minimax_h3 import MiniMaxH3ImageToVideo
cond=MiniMaxH3ImageToVideo.execute(res['clip'],res['vae'],candidate['prompt'],w,h,padded_n,images[:1],images[-1:])[0]
cond=MiniMaxH3ImageToVideo.execute(res['clip'],res['vae'],candidate['prompt'],w,h,padded_n,None,None)[0]
guider=Guider_Basic(model); guider.set_conds(cond)
sigmas=BasicScheduler.execute(model,fin['scheduler'],int(fin['ultimate_steps']),float(fin['ultimate_denoise']))[0]
images=call('UltimateSDUpscaleNoUpscaleGuider',unique_id,upscaled_image=padded,guider=guider,sampler=sampler_for(fin),sigmas=sigmas,
vae=res['vae'],seed=candidate['seed'],mode_type='Chess',tile_width=int(fin['tile_width']),tile_height=int(fin['tile_height']),
images=call('UltimateSDUpscaleNoUpscaleGuider',unique_id,upscaled_image=padded,guider=guider,sampler=sampler_for(dict(fin,sampler=fin['ultimate_sampler'],lying=0)),sigmas=sigmas,
vae=res['vae'],seed=int(fin['ultimate_seed']),mode_type='Chess',tile_width=int(fin['tile_width']),tile_height=int(fin['tile_height']),
mask_blur=int(fin['mask_blur']),tile_padding=int(fin['tile_padding']),seam_fix_mode='None',seam_fix_denoise=.15,
seam_fix_mask_blur=8,seam_fix_width=64,seam_fix_padding=16,tile_overlap_mode='Reprocess Overlap',tiled_decode=True,batch_size=1,anchor_context=False)[0][:count]
out=folder/(job+'_H3_ULTIMATE.mp4'); save_video(images,audio,out,candidate)
st['finals'].append({'label':'Ultimate','candidate':candidate['id'],'video':view_file(out),'saved_path':export_file(out,c),'id':uuid.uuid4().hex})
out=folder/(job+f'_H3_ULTIMATE_{fps}fps.mp4')
save_video(images,audio,out,candidate,fps=fps)
st['finals'].append({'label':f'Ultimate · {fps} fps','candidate':candidate['id'],'video':view_file(out),'saved_path':export_file(out,c),'id':uuid.uuid4().hex,'fps':fps,'frames':len(images),'width':images.shape[2],'height':images.shape[1]})
st['finals'][-1]['label']='Finale · '+st['finals'][-1]['label'].removeprefix('Intermedio · ')
st['finals'][-1]['complete']=True
update('done','Finalizzazione completata. I passaggi sono salvati.',active=None)
return {'ui':{'dogma_v2':[st]},'result':(st['message'],)}
except Exception as exc:
@@ -478,19 +609,23 @@ def register_routes():
from aiohttp import web
import folder_paths, comfy.samplers, nodes
routes=PromptServer.instance.routes
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_video_edit import register_edit_routes
cleanup=register_edit_routes(routes,input_file)
PromptServer.instance.app.on_cleanup.append(cleanup)
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__],access.browser))
@routes.post('/dogma/h3/v2/open-folder')
async def open_folder(request):
local_request(request)
data=await request.json(); st=state(data['session'])
item=next((x for x in st.get('finals',[])+st.get('candidates',[]) if x.get('id')==data.get('id')),None)
import asyncio
data=await request.json(); st=await asyncio.to_thread(state,data['session'])
item=next((x for x in st.get('edits',[])+st.get('finals',[])+st.get('developments',[])+st.get('candidates',[]) if x.get('id')==data.get('id')),None)
if not item or not item.get('saved_path'): raise web.HTTPNotFound(text='Risultato non disponibile.')
directory=Path(item['saved_path']).parent
if not directory.is_dir(): raise web.HTTPNotFound(text='Cartella non disponibile.')
@@ -498,33 +633,83 @@ def register_routes():
os.startfile(str(directory))
return web.json_response({'ok':True})
@routes.post('/dogma/h3/v2/edit/export-copy')
async def export_edit_copy(request):
access.browser(request)
import asyncio
data=await request.json()
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')
async def choose_folder(request):
local_request(request)
import asyncio,subprocess
if os.name!='nt': raise web.HTTPBadRequest(text='Inserisci il percorso della cartella.')
script="""[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
Add-Type -AssemblyName System.Windows.Forms
$picker = New-Object System.Windows.Forms.FolderBrowserDialog
$picker.Description = 'DOGMA - Cartella output'
if ($picker.ShowDialog() -eq 'OK') { [Console]::Write($picker.SelectedPath) }
"""
result=await asyncio.to_thread(subprocess.run,['powershell','-NoProfile','-STA','-Command',script],capture_output=True,text=True,encoding='utf-8',timeout=120,creationflags=subprocess.CREATE_NO_WINDOW)
if result.returncode: raise web.HTTPBadRequest(text='Selettore non disponibile. Inserisci il percorso.')
return web.json_response({'path':result.stdout.strip()})
raise web.HTTPGone(text='Aggiorna il pannello DOGMA: la scelta cartella ora avviene nel browser.')
@routes.get('/dogma/h3/v2/catalog')
async def catalog(request):
@routes.get('/dogma/h3/v2/folders')
async def folders(request):
access.browser(request)
import asyncio
def browse():
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=[];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() 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)+len(files)>500: break
directories.sort(key=lambda x:x['name'].casefold())
files.sort(key=lambda x:x['name'].casefold())
drives=[]
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)]
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))
def build_catalog():
def files(k):
try: return folder_paths.get_filename_list(k)
except KeyError: return []
return web.json_response({'defaults':defaults(),'models':files('diffusion_models'),'clip':files('text_encoders'),
return {'defaults':defaults(),'models':files('diffusion_models'),'clip':files('text_encoders'),
'vae':files('vae'),'tiny_vae':['none']+files('vae_approx'),'loras':files('loras'),'upscalers':files('latent_upscale_models'),
'samplers':comfy.samplers.KSampler.SAMPLERS,'schedulers':comfy.samplers.KSampler.SCHEDULERS,
'nodes':list(nodes.NODE_CLASS_MAPPINGS)})
'nodes':list(nodes.NODE_CLASS_MAPPINGS)}
catalog_task=None
catalog_time=0
@routes.get('/dogma/h3/v2/catalog')
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),'access':capabilities},headers={'Cache-Control':'no-store'})
@routes.get('/dogma/h3/v2/session/{session}')
async def session(request):
try: return web.json_response(state(request.match_info['session']))
import asyncio
try: return web.json_response(await asyncio.to_thread(state,request.match_info['session']))
except (ValueError,OSError): return web.json_response({'error':'Sessione non disponibile'},status=400)
+72
View File
@@ -0,0 +1,72 @@
"""Per-session edit promotion, without interrupting Comfy's other jobs."""
import threading
import uuid
from .dogma_h3_edit_sampling import validate_final_options
_LOCK=threading.RLock()
_ACTIVE={}
class PromoteEdit(Exception):
pass
class EditBatch:
def __init__(self,session,total,available):
self.session=session;self.id=uuid.uuid4().hex;self.total=total
self.available=set(available);self.chosen=None;self.phase='draft';self.current=0;self.completed=0;self.final_options=None
def snapshot(self):
with _LOCK:
return dict(id=self.id,phase=self.phase,total=self.total,current=self.current,completed=self.completed,chosen=self.chosen,final_options=self.final_options)
def checkpoint(self):
with _LOCK:
if self.phase=='draft' and self.chosen:raise PromoteEdit()
def add(self,trial):
with _LOCK:self.available.add(trial);self.completed+=1
def request(self,trial,batch_id=None,options=None):
options=validate_final_options(options) if options is not None else None
with _LOCK:
if batch_id and batch_id!=self.id:raise ValueError('Il gruppo di provini è cambiato. Riapri la scelta.')
if trial not in self.available:raise ValueError('Il provino deve essere già completo prima di sceglierlo.')
if self.chosen and options!=self.final_options:raise ValueError('La modalità finale è già stata confermata. Attendi questa lavorazione.')
if self.phase=='finish':
if trial==self.chosen:return self.snapshot()
raise ValueError('È già in corso la rigenerazione del provino scelto.')
if self.phase!='draft':return None
if self.chosen and self.chosen!=trial:raise ValueError('È già stato scelto un provino per la rigenerazione.')
self.chosen=trial;self.final_options=options
return self.snapshot()
def seal(self):
with _LOCK:
self.phase='finish' if self.chosen else 'ready'
return self.chosen
def close(self):
with _LOCK:
if _ACTIVE.get(self.session) is self:_ACTIVE.pop(self.session)
def begin_batch(session,total,available):
session=str(uuid.UUID(session))
with _LOCK:
if session in _ACTIVE:raise ValueError('Questa sessione ha già un gruppo di provini in esecuzione.')
batch=EditBatch(session,total,available);_ACTIVE[session]=batch
return batch
def request_promotion(session,trial,batch_id=None,options=None):
session=str(uuid.UUID(session))
with _LOCK:
batch=_ACTIVE.get(session)
return batch.request(trial,batch_id,options) if batch else None
def register_promotion_route(routes):
from aiohttp import web
@routes.post('/dogma/h3/v2/edit/promote')
async def promote(request):
try:
data=await request.json()
result=request_promotion(data['session'],data['trial'],data.get('batch'),data.get('options'))
return web.json_response({'status':'accepted' if result else 'idle','batch':result})
except (ValueError,KeyError) as exc:raise web.HTTPBadRequest(text=str(exc))
+79
View File
@@ -0,0 +1,79 @@
"""Develop the approved edit, rather than silently regenerating its source."""
from pathlib import Path
import hashlib
def pack(value):
import torch
from comfy.nested_tensor import NestedTensor
if isinstance(value,NestedTensor):return {'__dogma_nested__':[pack(x) for x in value.unbind()]}
if isinstance(value,torch.Tensor):return value.detach().cpu().contiguous()
if isinstance(value,dict):return {k:pack(v) for k,v in value.items()}
if isinstance(value,(tuple,list)):return [pack(v) for v in value]
if value is None or isinstance(value,(str,int,float,bool)):return value
raise TypeError('Metadati latenti non supportati.')
def unpack(value):
from comfy.nested_tensor import NestedTensor
if isinstance(value,dict):
if set(value)=={'__dogma_nested__'}:return NestedTensor([unpack(x) for x in value['__dogma_nested__']])
return {k:unpack(v) for k,v in value.items()}
if isinstance(value,list):return [unpack(x) for x in value]
return value
def fingerprint(path):
digest=hashlib.sha256()
with Path(path).open('rb') as stream:
for chunk in iter(lambda:stream.read(1024*1024),b''):digest.update(chunk)
return digest.hexdigest()
def approved_movie(root,trial):
path=Path(trial['saved_path']).resolve()
if not path.is_relative_to(Path(root).resolve()) or not path.is_file():
raise ValueError('Video del provino scelto assente. Il sorgente non verrà usato al suo posto.')
if trial.get('video_sha256') and fingerprint(path)!=trial['video_sha256']:
raise ValueError('Il file del provino scelto è cambiato: finale annullato.')
return path
def reference_movie(root,trial):
path=trial.get('corrected_path')
if not path:return approved_movie(root,trial)
path=Path(path).resolve()
if not path.is_relative_to(Path(root).resolve()) or not path.is_file():raise ValueError('Anteprima del provino recuperato non disponibile.')
if trial.get('corrected_video_sha256') and fingerprint(path)!=trial['corrected_video_sha256']:raise ValueError('Anteprima recuperata modificata.')
return path
def guide_latent(director,root,trial,vae,plate,width,height,read_trial,interrupt):
import torch
from comfy.nested_tensor import NestedTensor
movie=approved_movie(root,trial)
video_path=trial.get('latent')
if video_path:
path=(Path(root)/video_path).resolve()
if not path.is_relative_to(Path(root).resolve()) or not path.is_file():
raise ValueError('Latenti del provino assenti. Rigenera il provino oppure scegli Mantieni edit.')
if trial.get('latent_sha256') and fingerprint(path)!=trial['latent_sha256']:
raise ValueError('Latenti del provino modificati: finale annullato.')
saved=unpack(torch.load(path,map_location='cpu',weights_only=True))
video=saved['samples'].unbind()[0]
origin='saved_latent'
else:
# Older trials have only their video. Encode that approved result explicitly.
frames=read_trial(reference_movie(root,trial))
video=vae.encode(frames[:,:,:,:3]);del frames
origin='encoded_legacy_trial'
interrupt()
if video.shape[-2:]!=(height//16,width//16):
up=director.call('MinimaxH3LatentUpscaler3D',latent={'samples':video},
model_name=trial['config']['finish']['upscaler'],
mode={'mode':'target dimensions','width':width,'height':height},align=32,keep_proportion=False,
device='cuda',precision='fp16',offload_after_upscale=True)[0]
video=up['samples']
target,audio=plate['samples'].unbind()
if tuple(video.shape)!=tuple(target.shape):
raise ValueError('Il provino scelto e il finale hanno griglie diverse. Nessuna rigenerazione dal sorgente eseguita.')
out=plate.copy();video=video.to(device=target.device,dtype=target.dtype)
if plate.get('noise_mask') is not None:
vm,_=plate['noise_mask'].unbind();vm=vm.to(video)
video=video*vm+target*(1-vm)
out['samples']=NestedTensor((video,audio))
return out,dict(method='guided',trial=trial['id'],input=origin,video_sha256=fingerprint(movie))
+99
View File
@@ -0,0 +1,99 @@
"""Frame-exact restitch and lossless export in the source colour planes."""
from pathlib import Path
from fractions import Fraction
import subprocess,tempfile,time,itertools
import av
import numpy as np
from .dogma_h3_edit_mask import FrameReader,composite_mask,composite_settings,resize_rgb,resize_alpha,fade_weight
YUV_FORMATS={'yuv420p','yuv422p','yuv444p','yuvj420p','yuvj422p','yuvj444p','yuv420p10le','yuv422p10le','yuv444p10le','gray','gray10le'}
def output_format(frame):
name=frame.format.name
name={'nv12':'yuv420p','nv21':'yuv420p','nv16':'yuv422p','nv20le':'yuv422p10le','gray8':'gray'}.get(name,name)
if name in YUV_FORMATS:return name,'libx264'
if name in ('rgb24','bgr24','rgba','bgra','bgr0','rgb0','0rgb','0bgr'):return 'rgb24','libx264rgb'
raise ValueError('Export senza perdita non disponibile per '+frame.format.name+'. Usa un sorgente SDR YUV a 8/10 bit o RGB a 8 bit.')
def plane_array(plane,fmt):
dtype=np.dtype('<u2') if '10le' in fmt else np.dtype('u1')
channels=3 if fmt=='rgb24' else 1
return np.ndarray((plane.height,plane.line_size//dtype.itemsize),dtype=dtype,buffer=plane)[:,:plane.width*channels]
def plane_mask(alpha,width,height):
if alpha.shape==(height,width):return alpha
sy=(alpha.shape[0]+height-1)//height;sx=(alpha.shape[1]+width-1)//width
padded=np.pad(alpha,((0,height*sy-alpha.shape[0]),(0,width*sx-alpha.shape[1])),mode='edge')
result=padded.reshape(height,sy,width,sx).min(axis=(1,3))
# Decoder chroma interpolation also reads adjacent samples. Keep that small
# shared neighbourhood original so protected RGB pixels remain untouched.
from scipy.ndimage import minimum_filter
return minimum_filter(result,size=(3 if sy>1 else 1,3 if sx>1 else 1),mode='nearest')
def blend_frame(original,replacement,alpha,fmt):
base=original if original.format.name==fmt else original.reformat(format=fmt)
if not np.any(alpha):return base
rgb=av.VideoFrame.from_ndarray(resize_rgb(replacement,(original.width,original.height)),format='rgb24')
if fmt=='rgb24':patch=rgb
else:
matrix=original.colorspace if original.colorspace not in (0,2) else 5
full_range=original.color_range==2 or (fmt.startswith('gray') and original.color_range==0)
patch=rgb.reformat(format=fmt,dst_colorspace=matrix,dst_color_range='JPEG' if full_range else 'MPEG')
out=av.VideoFrame(original.width,original.height,fmt)
for source,generated,dest in zip(base.planes,patch.planes,out.planes):
a=plane_array(source,fmt);b=plane_array(generated,fmt);d=plane_array(dest,fmt)
weight=plane_mask(alpha,source.width,source.height)
if fmt=='rgb24':weight=np.repeat(weight,3,axis=1)
d[:]=np.rint(a.astype(np.float32)*(1-weight)+b.astype(np.float32)*weight).astype(d.dtype)
out.colorspace=original.colorspace;out.color_range=2 if fmt.startswith('gray') and original.color_range==0 else original.color_range
return out
def compose_lossless(source,patch,dest,meta,plan,fade,ffmpeg,interrupt=lambda:None,mask_path=None,settings=None):
"""Decode each original frame once; never framesync a rounded overlay."""
intermediate=Path(dest).with_suffix('.video.nut');readers=[]
try:
gen=FrameReader(patch);readers.append(gen)
mask=FrameReader(mask_path,plan['start']) if mask_path else None
if mask:readers.append(mask)
with av.open(str(source)) as src,av.open(str(intermediate),'w',format='nut') as out:
s=src.streams.video[0];decoded=src.decode(s);first=next(decoded)
fmt,codec=output_format(first)
stream=out.add_stream(codec,rate=Fraction(meta['fps_ratio']));stream.width=s.width;stream.height=s.height;stream.pix_fmt=fmt
stream.time_base=s.time_base;stream.codec_context.time_base=s.time_base;stream.options={'qp':'0','preset':'medium','bf':'0'}
for key in ('color_range','colorspace','color_trc','color_primaries'):setattr(stream.codec_context,key,getattr(s.codec_context,key))
# FFV1 gray has implicit full-range luma. x264 emits a neutral-chroma
# YUV stream; signal that range or protected greys become darker.
if fmt.startswith('gray') and stream.codec_context.color_range==0:stream.codec_context.color_range=2
for i,frame in enumerate(itertools.chain((first,),decoded)):
interrupt();stamp=frame.pts;tb=frame.time_base;second=float(frame.time)-meta['video_start']
final=frame if frame.format.name==fmt else frame.reformat(format=fmt)
if plan['start_frame']<=i<plan['end_frame']:
alpha=composite_mask(mask.at(second,nearest=True),settings) if mask else np.ones((s.height,s.width),np.float32)
alpha=resize_alpha(alpha,(s.width,s.height))
times=meta['frame_times'];next_time=times[i+1] if i+1<len(times) else meta['duration']
alpha*=fade_weight(second,plan,fade,next_time-second)
if np.any(alpha):final=blend_frame(frame,gen.at(second-plan['start'],nearest=True),alpha,fmt)
final.pts=stamp;final.time_base=tb
for packet in stream.encode(final):out.mux(packet)
for packet in stream.encode():out.mux(packet)
copy_audio=all(x in ('aac','mp3','alac','ac3','eac3') for x in meta['audio_codecs'])
args=[ffmpeg(),'-hide_banner','-loglevel','error','-nostdin','-y','-copyts','-i',str(source),'-i',str(intermediate),
'-map','1:v:0','-map','0:a?','-map_metadata','0','-map_chapters','0','-c:v','copy','-c:a','copy' if copy_audio else 'aac']
if not copy_audio:args+=['-b:a','320k']
args+=['-video_track_timescale',str(Fraction(meta.get('time_base','1/1000000')).denominator),'-movflags','+faststart',str(dest)]
with tempfile.TemporaryFile() as log:
process=subprocess.Popen(args,stdout=subprocess.DEVNULL,stderr=log)
try:
while process.poll() is None:interrupt();time.sleep(.1)
if process.returncode:
log.seek(0);raise ValueError('Salvataggio finale fallito: '+log.read().decode(errors='replace')[-2500:])
except BaseException:
if process.poll() is None:process.kill();process.wait()
raise
return dict(audio_copied=copy_audio,fps=meta['fps'],width=meta['width'],height=meta['height'],
export_encoding='lossless_h264',source_planes_preserved=True,timing_version=2,
composite_mask_version=1,composite_mask_settings=composite_settings(settings) if mask else None)
except BaseException:Path(dest).unlink(missing_ok=True);raise
finally:
intermediate.unlink(missing_ok=True)
for reader in readers:reader.close()
+329
View File
@@ -0,0 +1,329 @@
"""User-supplied video masks, spatial adjustments and bounded edit compositing."""
import asyncio
import hashlib
import inspect
import json
import math
from pathlib import Path
import threading
import uuid
from fractions import Fraction
import av
import numpy as np
from scipy.ndimage import maximum_filter, minimum_filter, gaussian_filter
def mask_defaults():
return dict(enabled=False, source='', grow=0, feather=0., opacity=.55, view='overlay',
composite_mode='source', composite_grow=0, composite_feather=8., preview_area='composite')
def mask_settings(value):
settings = {**mask_defaults(), **(value or {})}
if not settings['enabled']:
return settings
grow, feather, opacity, cg, cf = (float(settings[k]) for k in ('grow', 'feather', 'opacity', 'composite_grow', 'composite_feather'))
if not all(math.isfinite(x) for x in (grow, feather, opacity, cg, cf)) or min(feather,cf) < 0 or not 0 <= opacity <= 1:
raise ValueError('Regolazioni maschera non valide: sfumatura positiva e opacità da 0 a 1.')
settings.update(grow=round(grow), feather=feather, opacity=opacity,composite_grow=round(cg),composite_feather=cf)
if settings['composite_mode'] not in ('source','generation') or settings['preview_area'] not in ('composite','generation'):
raise ValueError('Area maschera non valida.')
if settings['view'] not in ('overlay', 'mask', 'source'):
raise ValueError('Vista maschera non valida.')
return settings
def composite_settings(settings):
"""Generation context must not silently replace protected frame geometry."""
if settings.get('composite_mode','source') == 'generation':return settings
return {**settings,'grow':settings.get('composite_grow',0),'feather':settings.get('composite_feather',8.)}
def composite_mask(rgb,settings):
effective=composite_settings(settings)
if settings.get('composite_mode','source')=='generation':return processed_mask(rgb,effective)
if settings.get('composite_metric')=='distance':
# Match the continuously offset contour used by the live restitch grid.
# Old exports retain their square morphology unless explicitly restitched.
from scipy.ndimage import distance_transform_edt
base=processed_mask(rgb,{**effective,'grow':0,'feather':0.})
support=base>0
if not support.any():return base
distance=distance_transform_edt(np.pad(support,1))[1:-1,1:-1]-distance_transform_edt(~support)
distance+=effective['grow']
radius=float(effective['feather'])
if radius:
x=np.clip(distance/radius,0,1).astype(np.float32);weight=x*x*(3-2*x)
else:weight=(distance>=0).astype(np.float32)
opacity=maximum_filter(base,size=2*effective['grow']+1,mode='constant',cval=0) if effective['grow']>0 else base
return opacity*weight
alpha=processed_mask(rgb,{**effective,'feather':0.})
radius=float(effective['feather'])
if radius:
# Feather inward: never extend replacement over protected window bars
# or carriage walls merely to soften the moving matte edge.
from scipy.ndimage import distance_transform_edt
distance=distance_transform_edt(np.pad(alpha>0,1))[1:-1,1:-1]
x=np.clip(distance/radius,0,1).astype(np.float32)
alpha*=x*x*(3-2*x)
return alpha
def validate_alignment(source, mask):
if (source['width'], source['height']) != (mask['width'], mask['height']):
raise ValueError('La maschera deve avere le stesse dimensioni del video originale.')
a, b = source['frame_times'], mask['frame_times']
tolerance = max(.001, .25/source['fps'])
if len(a) != len(b) or abs(source['duration']-mask['duration']) > tolerance or any(abs(x-y) > tolerance for x,y in zip(a,b)):
raise ValueError('La maschera deve coprire tutto il sorgente con gli stessi fotogrammi e tempi.')
def processed_mask(rgb, settings):
alpha = (rgb.astype(np.float32) @ np.array([.2126, .7152, .0722], np.float32))/255.
alpha[alpha < 1/255] = 0
alpha[alpha > 254/255] = 1
grow = settings['grow']
if grow:
fn = maximum_filter if grow > 0 else minimum_filter
alpha = fn(alpha, size=2*abs(grow)+1, mode='constant', cval=0)
if settings['feather']:
alpha = gaussian_filter(alpha, sigma=settings['feather'], mode='constant', cval=0)
return np.clip(alpha, 0, 1)
def fade_weight(second, plan, fade, frame_duration):
fade = min(float(fade), plan['duration']/2)
if fade <= 0 or plan['source_frames'] <= 1:
return 1.
return max(0., min(1., (second-plan['start']+frame_duration)/fade, (plan['end']-second)/fade))
class FrameReader:
def __init__(self, path, start=0):
self.container = av.open(str(path))
self.stream = self.container.streams.video[0]
self.origin = float(self.stream.start_time*self.stream.time_base) if self.stream.start_time is not None else 0.
self.container.seek(int((self.origin+start)/float(self.stream.time_base)), stream=self.stream, backward=True)
self.frames = self.container.decode(self.stream)
self.current = next(self.frames, None)
self.following = next(self.frames, None)
if self.current is None:
self.close()
raise ValueError('Video privo di fotogrammi leggibili.')
def at(self, second, nearest=False, size=None):
while self.following is not None and float(self.following.time)-self.origin <= second+1e-6:
self.current = self.following
self.following = next(self.frames, None)
selected=self.current
if nearest and self.following is not None and abs(float(self.following.time)-self.origin-second)<abs(float(self.current.time)-self.origin-second):selected=self.following
if size is not None:return selected.reformat(width=size[0],height=size[1],format='rgb24').to_ndarray()
return selected.to_ndarray(format='rgb24')
def close(self):
self.container.close()
def resize_rgb(rgb, size):
if (rgb.shape[1], rgb.shape[0]) == size:
return rgb
return av.VideoFrame.from_ndarray(rgb, format='rgb24').reformat(width=size[0], height=size[1]).to_ndarray(format='rgb24')
def resize_alpha(alpha, size):
if (alpha.shape[1], alpha.shape[0]) == size:
return alpha
from PIL import Image
return np.asarray(Image.fromarray(alpha).resize(size, Image.Resampling.BILINEAR), dtype=np.float32)
def prepare_masked_latent(director, latent, vae, selected, mask_path, settings, plan, content_size, interrupt):
import torch
import nodes
from comfy.nested_tensor import NestedTensor
# Native per-row masking must be available; do not patch the global model.
try:
from comfy.ldm.minimax.model import mask_row_values
except ImportError:
raise ValueError('Aggiorna ComfyUI: il masking nativo H3 richiesto non è disponibile.') from None
node = nodes.NODE_CLASS_MAPPINGS.get('MVEx_MaskToLatentSpace')
if node is None:
raise ValueError('La maschera H3 richiede MaskVidExperiments aggiornato dal Manager.')
empty_video, audio = latent['samples'].unbind()
width, height = selected.shape[2], selected.shape[1]
masks = torch.empty((len(selected),height,width),dtype=torch.float32)
reader = FrameReader(mask_path,plan['start'])
try:
for i in range(len(selected)):
interrupt()
second = min(plan['start']+i/24,plan['last_source_time'])
alpha = resize_alpha(processed_mask(reader.at(second,nearest=True),settings),content_size)
alpha = np.pad(alpha,((0,height-alpha.shape[0]),(0,width-alpha.shape[1])),mode='constant')
masks[i] = torch.from_numpy(alpha)
finally:
reader.close()
kwargs = dict(masks=masks,compression={'compression':'auto'},spatial_method='max',temporal_method='max',
grow_spatial=0,grow_temporal=0,vae=vae)
# This input was added by MaskVidExperiments after the version in this install.
if 'grow_temp_mode' in inspect.signature(node.execute).parameters:
kwargs['grow_temp_mode'] = 'both'
reduced = director.call('MVEx_MaskToLatentSpace',**kwargs)[0]
plate = vae.encode(selected[:,:,:,:3])
if tuple(plate.shape) != tuple(empty_video.shape) or tuple(reduced.shape) != tuple(plate.shape[-3:]):
raise ValueError('Video, maschera e griglia temporale H3 non coincidono. Aggiorna ComfyUI e MaskVidExperiments.')
result = latent.copy()
result['samples'] = NestedTensor((plate,audio))
result['noise_mask'] = NestedTensor((reduced[None,None].to(dtype=plate.dtype),torch.zeros_like(audio)))
return result
def inspect_native_mask(latent):
"""Validate the actual AV mask handed to sampling, not only the preview."""
import torch
mask=latent.get('noise_mask')
if mask is None or not getattr(mask,'is_nested',hasattr(mask,'unbind')):
raise ValueError('Maschera edit assente dai latenti: campionamento annullato.')
try:
video,audio=latent['samples'].unbind();vm,am=mask.unbind()
except (ValueError,TypeError,AttributeError):
raise ValueError('Maschera edit AV non valida.') from None
if vm.ndim!=video.ndim or vm.shape[0]!=video.shape[0] or vm.shape[1] not in (1,video.shape[1]) or tuple(vm.shape[2:])!=tuple(video.shape[2:]):
raise ValueError('La maschera non coincide con la griglia video del campionamento.')
if tuple(am.shape)!=tuple(audio.shape) or not torch.isfinite(am).all() or torch.count_nonzero(am):
raise ValueError('La maschera audio deve restare a zero durante l’edit.')
if not torch.isfinite(vm).all() or vm.min()<0 or vm.max()>1:
raise ValueError('La maschera video contiene valori non validi.')
if not torch.count_nonzero(vm):raise ValueError('La selezione non contiene aree bianche nella maschera: nessun edit da applicare.')
return dict(enabled=True,shape=list(vm.shape),coverage=round(float(vm.float().mean()),6),audio_locked=True)
def write_segment(source, generated, dest, meta, plan, *, mask_path=None, settings=None,
size=None, fade=0., preview=False, precomposited=False, interrupt=lambda:None, progress=lambda a,b:None):
"""Only the selected interval is decoded; mask adjustments use source pixels."""
size = size or (meta['width'], meta['height'])
readers = []
try:
src = FrameReader(source, plan['start']); readers.append(src)
gen = FrameReader(generated) if generated else None
if gen: readers.append(gen)
mask = FrameReader(mask_path, plan['start']) if mask_path else None
if mask: readers.append(mask)
times = meta['frame_times'][plan['start_frame']:plan['end_frame']]
with av.open(str(dest), 'w') as out:
codec = 'libx264' if preview else 'ffv1'
stream = out.add_stream(codec, rate=Fraction(meta['fps_ratio']))
stream.width, stream.height = size
stream.pix_fmt = 'yuv420p' if preview else 'bgr0'
stream.time_base = Fraction(meta.get('time_base','1/1000000'))
if preview: stream.options = {'crf':'18', 'preset':'veryfast'}
for i, second in enumerate(times):
interrupt()
original = resize_rgb(src.at(second,nearest=True), size)
mask_fn=processed_mask if preview and settings.get('preview_area')=='generation' else composite_mask
alpha = mask_fn(mask.at(second,nearest=True), settings) if mask else np.ones((size[1],size[0]),np.float32)
next_time = times[i+1] if i+1 < len(times) else plan['end']
alpha = resize_alpha(alpha, size)*fade_weight(second, plan, fade, next_time-second)
# An approved trial already includes feather and temporal fades.
# Applying their alpha twice would bring the old wall back.
if precomposited:alpha=(alpha>0).astype(np.float32)
if preview:
if settings['view'] == 'mask':
rgb = np.repeat(np.rint(alpha[...,None]*255).astype(np.uint8), 3, axis=2)
elif settings['view'] == 'source':
rgb = original
else:
mix = alpha[...,None]*settings['opacity']
rgb = np.rint(original*(1-mix)+np.array([255,80,35])*mix).astype(np.uint8)
else:
replacement = resize_rgb(gen.at(second-plan['start'],nearest=True), size)
mix = alpha[...,None]
rgb = np.rint(original*(1-mix)+replacement*mix).clip(0,255).astype(np.uint8)
frame = av.VideoFrame.from_ndarray(rgb, format='rgb24')
frame.time_base = Fraction(meta.get('time_base','1/1000000'))
frame.pts = round((second-plan['start'])/frame.time_base)
for packet in stream.encode(frame): out.mux(packet)
progress(i+1,len(times))
for packet in stream.encode(): out.mux(packet)
except BaseException:
Path(dest).unlink(missing_ok=True)
raise
finally:
for reader in readers: reader.close()
def register_mask_routes(routes, input_file, probe, plan_interval):
from aiohttp import web
import folder_paths
jobs = {}
slot = asyncio.Semaphore(1)
def describe(data):
settings = mask_settings(data.get('mask'))
source, mask = input_file(data['source']), input_file(settings['source'])
meta, mask_meta = probe(source), probe(mask)
validate_alignment(meta, mask_meta)
plan = plan_interval(meta,data['start'],data['end'])
fade = float(data.get('fade',0))
if not math.isfinite(fade) or not 0 <= fade <= .5:
raise ValueError('Raccordo maschera non valido.')
stamps = [(str(p),p.stat().st_size,p.stat().st_mtime_ns) for p in (source,mask)]
key = hashlib.sha256(json.dumps([stamps,settings,plan,fade],sort_keys=True).encode()).hexdigest()
return source,mask,meta,plan,settings,fade,key
async def work(job, data):
try:
async with slot:
if job['cancel'].is_set(): job['status']='cancelled';return
source,mask,meta,plan,settings,fade,key = await asyncio.to_thread(describe,data)
directory = Path(folder_paths.get_temp_directory())/'DOGMA_MASK_PREVIEW'
directory.mkdir(parents=True,exist_ok=True)
dest = directory/(key+'.mp4')
job['status'] = 'running'
if not dest.is_file():
scale = min(1.,960/max(meta['width'],meta['height']))
size = tuple(max(2,round(x*scale/2)*2) for x in (meta['width'],meta['height']))
def interrupt():
if job['cancel'].is_set(): raise InterruptedError('Anteprima annullata.')
def progress(done,total): job.update(done=done,total=total)
temp = directory/(key+'.'+job['id']+'.mp4')
await asyncio.to_thread(write_segment,source,None,temp,meta,plan,mask_path=mask,settings=settings,
size=size,fade=fade,preview=True,interrupt=interrupt,progress=progress)
temp.replace(dest)
job.update(status='done',file={'filename':dest.name,'subfolder':'DOGMA_MASK_PREVIEW','type':'temp'},
selection=plan,settings=settings)
except InterruptedError:
job['status'] = 'cancelled'
except Exception as exc:
job.update(status='error',message=str(exc))
@routes.post('/dogma/h3/v2/edit/mask-preview')
async def start_preview(request):
data = await request.json()
# Bound completed job records; video files live in Comfy's temporary folder.
for key in list(jobs):
if len(jobs)<32: break
if jobs[key]['status'] in ('done','error','cancelled'): jobs.pop(key)
if len(jobs)>=32: raise web.HTTPTooManyRequests(text='Attendi le anteprime già in corso.')
uid = uuid.uuid4().hex
job = {'id':uid,'status':'queued','done':0,'total':0,'cancel':threading.Event()}
jobs[uid] = job
job['task'] = asyncio.create_task(work(job,data))
return web.json_response({'id':uid,'status':'queued'})
@routes.get('/dogma/h3/v2/edit/mask-preview/{id}')
async def preview_status(request):
job = jobs.get(request.match_info['id'])
if not job: raise web.HTTPNotFound(text='Anteprima non disponibile.')
return web.json_response({k:v for k,v in job.items() if k not in ('cancel','task')})
@routes.delete('/dogma/h3/v2/edit/mask-preview/{id}')
async def cancel_preview(request):
job = jobs.get(request.match_info['id'])
if job: job['cancel'].set()
return web.json_response({'ok':True})
async def cleanup(app):
for job in jobs.values(): job['cancel'].set()
await asyncio.gather(*(j['task'] for j in jobs.values()),return_exceptions=True)
# The server owns task lifetime; cancellation is also available per preview.
return cleanup
+45
View File
@@ -0,0 +1,45 @@
"""Explicit original-size edit sampling choices, separate from frozen edit inputs."""
SAMPLING_KEYS=('turbo','turbo_strength','steps','sampler','scheduler','shift_video','shift_audio',
'lying','lying_start','lying_end','native_shift','denoise')
def final_options(edit):
return validate_final_options({'mode':edit.get('final_mode','match'),'steps':edit.get('final_steps',20),
'method':edit.get('final_method','preserve'),'denoise':edit.get('final_denoise',.25),
'output_dir':edit.get('final_output_dir',''),'resolution':edit.get('final_resolution','native'),
'matte':{k:v for k,v in (edit.get('mask') or {}).items() if k.startswith('composite_')}})
def validate_final_options(value):
value=value or {}
mode=value.get('mode','match')
if mode not in ('match','turbo','standard'):raise ValueError('Finale edit: scegli Come il provino, Turbo o Standard.')
steps=float(value.get('steps',20))
if not steps.is_integer() or not 1<=steps<=100:raise ValueError('Passaggi finale standard: un intero da 1 a 100.')
method=value.get('method','preserve')
if method not in ('guided','preserve','regenerate'):raise ValueError('Scegli come preparare il finale edit.')
denoise=float(value.get('denoise',.25))
if not .01<=denoise<=1:raise ValueError('Raffinamento finale: intensità tra 0,01 e 1.')
directory=value.get('output_dir','')
if not isinstance(directory,str):raise ValueError('Cartella finale non valida.')
resolution=value.get('resolution','native')
if resolution not in ('native','trial','source'):raise ValueError('Risoluzione H3: scegli nativa, provino o originale.')
from .dogma_h3_edit_mask import mask_settings
matte=mask_settings({**value.get('matte',{}),'enabled':True})
return dict(mode=mode,steps=int(steps),method=method,denoise=denoise,output_dir=directory.strip(),resolution=resolution,
matte={k:v for k,v in matte.items() if k.startswith('composite_')})
def generation_size(meta,edit,trial=False):
import math
w,h=meta['width'],meta['height']
if trial or edit.get('final_resolution')=='trial':scale=min(1.,int(edit.get('trial_edge',512))/max(w,h))
elif edit.get('final_resolution','native')=='source':scale=1.
else:
# Match native H3's 768-short-edge / 768*1344 canvas policy, while
# preserving the source aspect and retaining small technical padding.
ratio=w/h
nw,nh=(768*ratio,768) if ratio>=1 else (768,768/ratio)
factor=min(1.,math.sqrt((768*1344)/(nw*nh)))
scale=min(1.,nw*factor/w,nh*factor/h)
return max(2,round(w*scale/2)*2),max(2,round(h*scale/2)*2)
def is_ref_turbo(name):
return str(name).replace('\\','/').split('/')[-1].startswith('minimax_h3_ref2v_turbo_')
+144
View File
@@ -0,0 +1,144 @@
"""Shared preview atlas and CPU-only, in-place restitch of a completed edit."""
from pathlib import Path
from fractions import Fraction
import asyncio,copy,hashlib,json,math,os,shutil,time,uuid
import av
import numpy as np
from scipy.ndimage import distance_transform_edt
from .dogma_h3_edit_mask import FrameReader,processed_mask,mask_settings,composite_settings,validate_alignment
from .dogma_h3_edit_develop import fingerprint
def inputs(core,session,item_id):
st=core.state(session);root=core.manifest_root(session).resolve()
item=next((x for x in st.get('edits',[]) if x.get('id')==item_id and x.get('complete')),None)
if not item:raise ValueError('Video completo non disponibile.')
c=item['config'];settings=mask_settings(c['edit'].get('mask'))
if not settings['enabled']:raise ValueError('Le varianti di restitch richiedono una maschera video.')
relative=item.get('raw_patch');sha=item.get('raw_patch_sha256')
if not relative:
trial=next((x for x in st.get('edit_trials',[]) if x.get('id')==item.get('trial')),None)
if item.get('development',{}).get('method')=='preserve' and trial:
relative=trial.get('raw_patch');sha=trial.get('raw_patch_sha256')
if not relative:raise ValueError('Questo finale precedente non conserva il tratto generato necessario. Preparalo di nuovo dal provino con i nodi aggiornati; nessuna sostituzione con il video originale.')
raw=(root/relative).resolve()
if not raw.is_relative_to(root) or not raw.is_file():raise ValueError('Tratto generato non disponibile per il restitch.')
f=item['video'];master=(root.parent.parent/f.get('subfolder','')/f['filename']).resolve()
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,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-'+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.,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():
if sha and fingerprint(raw)!=sha:raise ValueError('Il tratto generato è stato modificato.')
readers=[];temp=path.with_name(key+'.'+uuid.uuid4().hex+'.mp4')
try:
src=FrameReader(source,plan['start']);gen=FrameReader(raw);mr=FrameReader(mask,plan['start']);readers=[src,gen,mr]
with av.open(str(temp),'w') as out:
s=out.add_stream('libx264',rate=Fraction(meta['fps_ratio']));s.width=w*4;s.height=h;s.pix_fmt='yuv420p';s.time_base=Fraction(meta['time_base']);s.options={'qp':'0','preset':'ultrafast','bf':'0'}
for second in meta['frame_times'][plan['start_frame']:plan['end_frame']]:
original=src.at(second,nearest=True,size=(w,h));generated=gen.at(second-plan['start'],nearest=True,size=(w,h))
mask_rgb=mr.at(second,nearest=True,size=(w,h));alpha=processed_mask(mask_rgb,{'grow':0,'feather':0})
support=alpha>1/255
if support.any():
inside=distance_transform_edt(np.pad(support,1))[1:-1,1:-1]
outside=distance_transform_edt(~support)
distance=(inside-outside)/(w/meta['width'])
else:distance=np.full((h,w),-field_range)
sdf=np.repeat(np.rint(np.clip(distance/field_range*.5+.5,0,1)*255).astype(np.uint8)[...,None],3,axis=2)
a=np.repeat(np.rint(alpha*255).astype(np.uint8)[...,None],3,axis=2)
atlas=np.concatenate((original,generated,sdf,a),axis=1)
frame=av.VideoFrame.from_ndarray(atlas,format='rgb24');frame.time_base=s.time_base;frame.pts=round((second-plan['start'])/frame.time_base)
for packet in s.encode(frame):out.mux(packet)
for packet in s.encode():out.mux(packet)
temp.replace(path)
finally:
temp.unlink(missing_ok=True)
for reader in readers:reader.close()
base=composite_settings(mask_settings(e.get('mask')))
return dict(file={'filename':path.name,'subfolder':'DOGMA_RESTITCH','type':'temp'},width=w,height=h,
source_width=meta['width'],source_height=meta['height'],fps=meta['fps'],duration=plan['duration'],fade=e['fade'],
field_range=field_range,base={'grow':base['grow'],'feather':base['feather']},revision=item.get('restitch_revision',0),
elapsed_seconds=round(time.monotonic()-started,3),cache_key=key)
def validate_choice(data):
grow,feather=(float(data.get(k,0)) for k in ('grow','feather'))
if not all(math.isfinite(x) for x in (grow,feather)) or abs(grow)>256 or not 0<=feather<=256:
raise ValueError('Restitch: espansione da −256 a +256 px e feather da 0 a 256 px.')
return dict(composite_mode='source',composite_grow=round(grow),composite_feather=feather,composite_metric='distance')
def apply_restitch(core,data):
from .dogma_h3_video_edit import cached_probe,plan_interval,compose
choice=validate_choice(data)
st,item,root,source,mask,raw,master,sha=inputs(core,data['session'],data['id'])
if st.get('status')=='running':raise ValueError('Attendi la fine della generazione prima di applicare il restitch.')
revision=int(item.get('restitch_revision',0))
if int(data.get('revision',revision))!=revision:raise ValueError('Il finale è stato aggiornato. Riapri le varianti di restitch.')
if sha and fingerprint(raw)!=sha:raise ValueError('Il tratto generato è stato modificato.')
previous_hash=item.get('video_sha256') or fingerprint(master)
if fingerprint(master)!=previous_hash:raise ValueError('Il master è stato modificato esternamente. Nessun file sovrascritto.')
c=copy.deepcopy(item['config']);settings=mask_settings({**c['edit']['mask'],**choice});c['edit']['mask']=settings
meta=cached_probe(source);validate_alignment(meta,cached_probe(mask));plan=plan_interval(meta,c['edit']['start'],c['edit']['end'])
temp=master.with_name(master.stem+'.restitch-'+uuid.uuid4().hex+'.mp4')
try:
info=compose(source,raw,temp,meta,plan,c['edit']['fade'],mask_path=mask,settings=settings)
new_hash=fingerprint(temp)
with core._STATE_LOCK:
latest=core.state(data['session']);current=next((x for x in latest.get('edits',[]) if x['id']==item['id']),None)
if not current or latest.get('status')=='running' or current.get('restitch_revision',0)!=revision or fingerprint(master)!=previous_hash:
raise ValueError('La sessione è cambiata durante il restitch. Il finale precedente è conservato.')
temp.replace(master)
saved=Path(current.get('saved_path',str(master)));error=None
if saved.resolve()!=master.resolve():
transfer=saved.with_name(saved.name+'.'+uuid.uuid4().hex+'.tmp')
try:
if not saved.is_file() or fingerprint(saved)!=previous_hash:raise ValueError('La copia esportata è stata spostata o modificata. Il nuovo master resta nella sessione.')
shutil.copy2(master,transfer);transfer.replace(saved)
except (OSError,ValueError) as exc:error=str(exc);saved=master
finally:transfer.unlink(missing_ok=True)
current.setdefault('restitch_initial',{'grow':composite_settings(mask_settings(current['config']['edit']['mask']))['grow'],'feather':composite_settings(mask_settings(current['config']['edit']['mask']))['feather']})
current.update(config=c,video_sha256=new_hash,saved_path=str(saved.resolve()),restitch_revision=revision+1,**info)
current['video']={**current['video'],'dg_revision':revision+1}
if error:current['export_error']=error
else:current.pop('export_error',None)
latest['message']='Restitch applicato al finale.' if not error else error
core.write_json(root/'session.json',latest)
return current
finally:temp.unlink(missing_ok=True)
def register_routes(routes,core,authorize_request):
from aiohttp import web
jobs={};slot=asyncio.Semaphore(1)
async def launch(kind,data):
for k in list(jobs):
if len(jobs)<24:break
if jobs[k]['status'] in ('done','error'):jobs.pop(k)
if len(jobs)>=24:raise web.HTTPTooManyRequests(text='Attendi le lavorazioni già avviate.')
job={'id':uuid.uuid4().hex,'status':'running'};jobs[job['id']]=job
async def run():
try:
async with slot:
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):authorize_request(request);return await launch('preview',await request.json())
@routes.post('/dogma/h3/v2/edit/restitch/apply')
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):
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)
return cleanup
+616
View File
@@ -0,0 +1,616 @@
"""Bounded H3 Ref2VA edits and a streaming, timestamp-preserving full-video export.
No model downloads. Only the selected interval enters the generation tensors.
"""
import math
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
from fractions import Fraction
from bisect import bisect_left
import copy
import json
from functools import lru_cache
from .dogma_h3_edit_mask import mask_defaults, mask_settings, validate_alignment, write_segment, register_mask_routes, prepare_masked_latent, inspect_native_mask
from .dogma_h3_edit_control import begin_batch, PromoteEdit, register_promotion_route
from .dogma_h3_edit_sampling import SAMPLING_KEYS, final_options, is_ref_turbo, generation_size
from .dogma_h3_edit_develop import approved_movie, reference_movie, guide_latent, fingerprint
# Native H3 temporal grid; the selected interval is independent of this grid.
# 362/24 exceeds the 15 s reference budget; keep the source reference <=15 s.
EDIT_FRAMES = tuple(range(5, 346, 17))
def edit_defaults():
return dict(source='', start=0., end=107/24, fade=.125, anchor_edges=True,
mask=mask_defaults(), selected_trial='', trial_edge=512, trial_mode='fast', trial_live_preview=False,final_mode='match',final_steps=20,
final_method='preserve',final_denoise=.25,final_output_dir='',final_resolution='native',
turbo='', turbo_strength=1., steps=20, sampler='res_multistep',
scheduler='simple', shift_video=12., shift_audio=3.,
lying=0., lying_start=.1, lying_end=.9)
def ffmpeg():
executable = shutil.which('ffmpeg')
if not executable:
try:
import imageio_ffmpeg
executable = imageio_ffmpeg.get_ffmpeg_exe()
except (ImportError, RuntimeError):
pass
if not executable or not Path(executable).is_file():
raise ValueError('Video Edit richiede FFmpeg (oppure imageio-ffmpeg) sul computer Comfy.')
return executable
def probe(path):
import av
with av.open(str(path)) as container:
if not container.streams.video:
raise ValueError('Il file non contiene una traccia video.')
s = container.streams.video[0]
rate = s.average_rate or s.guessed_rate
if not rate or rate <= 0:
raise ValueError('FPS del sorgente non leggibili.')
duration = float(s.duration * s.time_base) if s.duration else float((container.duration or 0)/av.time_base)
if not math.isfinite(duration) or duration <= 0:
raise ValueError('Durata del video non leggibile.')
# Reject formats that need a separate color-managed / orientation workflow.
rotation = int(float(s.metadata.get('rotate', '0'))) % 360
decoded = container.decode(s)
first = next(decoded, None)
if first is None:
raise ValueError('Video privo di fotogrammi decodificabili.')
rotation = int(getattr(first, 'rotation', rotation) or rotation) % 360
if rotation:
raise ValueError('Video con rotazione metadata: esportalo con rotazione applicata prima di editarlo.')
if s.sample_aspect_ratio and s.sample_aspect_ratio != 1:
raise ValueError('Video anamorfico: esporta prima una versione con pixel quadrati.')
# AVColorTransferCharacteristic: SMPTE2084 / ARIB_STD_B67.
if s.codec_context.color_trc in (16, 18):
raise ValueError('Sorgente HDR: questa versione Video Edit richiede un master SDR.')
start = float(s.start_time * s.time_base) if s.start_time is not None else float(first.time or 0)
origin = float(container.start_time/av.time_base) if container.start_time is not None else start
times = [0.]
for frame in decoded:
if frame.time is None:
raise ValueError('Il video non ha timestamp utilizzabili per una selezione precisa.')
second = float(frame.time)-start
if second > times[-1]:
times.append(second)
if duration <= times[-1]:
duration = times[-1]+1/float(rate)
return dict(width=s.width, height=s.height, fps=float(rate), fps_ratio=str(rate),
duration=duration, frames=len(times), frame_times=times, video_start=start,
video_offset=start-origin, audio=bool(container.streams.audio),
audio_codecs=[x.codec_context.name for x in container.streams.audio],
time_base=str(s.time_base),generation_frames=list(EDIT_FRAMES), generation_fps=24,
min_seconds=EDIT_FRAMES[0]/24, max_seconds=EDIT_FRAMES[-1]/24)
def plan_interval(meta, start, end):
start, end = float(start), float(end)
if not all(math.isfinite(v) for v in (start, end)):
raise ValueError('Selezione temporale non valida.')
fps = Fraction(meta['fps_ratio'])
times = meta.get('frame_times')
def nearest(second):
if times is None:
return math.floor(second*float(fps)+.5)
boundaries = [*times, meta['duration']]
i = bisect_left(boundaries, second)
if i >= len(boundaries):
return len(times)
if i > 0 and second-boundaries[i-1] < boundaries[i]-second:
i -= 1
return i
start_frame, end_frame = nearest(start), nearest(end)
total = len(times) if times is not None else math.floor(meta['duration']*float(fps)+1e-6)
if start < 0 or end > meta['duration']+1e-6 or start_frame < 0 or end_frame > total:
raise ValueError('La selezione supera il video sorgente.')
if end_frame <= start_frame:
raise ValueError('Seleziona almeno un fotogramma.')
start = times[start_frame] if times is not None else start_frame/float(fps)
end = (times[end_frame] if end_frame < total else meta['duration']) if times is not None else end_frame/float(fps)
required = math.ceil((end-start)*24-1e-8)
generation_frames = next((n for n in EDIT_FRAMES if n >= required), None)
if generation_frames is None:
raise ValueError('La selezione supera la durata disponibile per H3.')
last_time = times[end_frame-1] if times is not None else (end_frame-1)/float(fps)
return dict(start=start, end=end, duration=end-start, generation_frames=generation_frames,
source_frames=end_frame-start_frame, start_frame=start_frame, end_frame=end_frame,
last_source_time=last_time)
@lru_cache(maxsize=64)
def _cached_probe(path,size,mtime):
return probe(Path(path))
def cached_probe(path):
path=Path(path);stat=path.stat()
return _cached_probe(str(path.resolve()),stat.st_size,stat.st_mtime_ns)
def trial_sampling(c):
settings=copy.deepcopy(c['edit'])
mode=settings.get('trial_mode','fast')
if mode=='same':return settings
if mode!='fast':raise ValueError('Modalità provini edit non valida.')
import folder_paths
name='minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors'
turbo=next((p for p in folder_paths.get_filename_list('loras') if p.replace('\\','/').split('/')[-1]==name),None)
if turbo is None:raise ValueError('Provini rapidi: Turbo Ref2Video 4 step non trovato. Seleziona "Stessa qualità del finale" oppure configura questa LoRA.')
settings.update(turbo=turbo,turbo_strength=1.,steps=4,sampler='euler',scheduler='simple',lying=0.)
return settings
def final_sampling(selected,options):
config=copy.deepcopy(selected['config']);settings=copy.deepcopy(config['edit'])
mode=options['mode']
if mode=='standard':
settings.update(turbo='',steps=options['steps'])
elif mode=='turbo':
config['edit']['trial_mode']='fast';settings=trial_sampling(config)
elif selected.get('sampling'):
saved=copy.deepcopy(selected['sampling'])
# Version 1.0.45 saved only steps/sampler/turbo. Recover its known Turbo preset.
if is_ref_turbo(saved.get('turbo','')) and int(saved.get('steps',0))==4:
config['edit']['trial_mode']='fast';settings=trial_sampling(config)
saved.pop('turbo',None)
settings.update({k:v for k,v in saved.items() if k in SAMPLING_KEYS})
return settings
def check_config(c, input_file):
e = c['edit']
if not e.get('source'):
raise ValueError('Carica il video da modificare.')
meta = cached_probe(input_file(e['source']))
plan = plan_interval(meta,e['start'],e['end'])
settings = mask_settings(e.get('mask'))
if settings['enabled']:
if not settings['source']: raise ValueError('Carica il video maschera bianco e nero.')
validate_alignment(meta,cached_probe(input_file(settings['source'])))
import nodes
if 'MVEx_MaskToLatentSpace' not in nodes.NODE_CLASS_MAPPINGS:
raise ValueError('Installa o aggiorna MaskVidExperiments dal Manager per usare la maschera H3.')
if not 0 <= float(e['fade']) <= .5:
raise ValueError('Raccordo: da 0 a 0,5 secondi.')
if not 1 <= int(e['steps']) <= 100:
raise ValueError('Step edit: da 1 a 100.')
if not 0 <= float(e['lying_start']) <= float(e['lying_end']) <= 1:
raise ValueError('Intervallo Lying Sigma non valido.')
if not c['prompt'].strip():
raise ValueError('Descrivi la modifica da applicare.')
videos = [r for r in c['refs'] if r.get('kind') == 'video']
if len(videos)>2 or len(c['refs'])>11:
raise ValueError('Il sorgente occupa Video 1: restano 2 video e 9 immagini reference (11 file).')
reference_duration = 0.
for r in c['refs']:
if r.get('kind') not in ('image','video') or not r.get('name'):
raise ValueError('Carica o rimuovi le reference vuote.')
path = input_file(r['name'])
if r['kind']=='video':
ref = cached_probe(path)
if not 2 <= ref['duration'] <= 15:
raise ValueError('Ogni video reference aggiuntivo deve durare da 2 a 15 secondi.')
plan['generation_frames'] = max(plan['generation_frames'], max(5,math.floor((ref['duration']*24-5)/17)*17+5))
reference_duration += ref['duration']
duration = plan['generation_frames']/24 + reference_duration
if duration>15+1e-6:
raise ValueError(f'Sorgente selezionato + video reference: {duration:.2f} s. Il limite H3 è 15 s totali; accorcia la selezione o le reference.')
ffmpeg() # Fail before loading models.
if int(e.get('trial_edge',512)) not in (256,384,512):raise ValueError('Dimensione provini edit: 256, 384 o 512 px.')
return meta,plan
def segment_tensor(path, start, n, width, height, interrupt=lambda:None, end=None, source_fps=24, last_time=None, resize_size=None):
"""Seek near the selection; sample by timestamps, with no temporal stretch."""
import av
import numpy as np
import torch
with av.open(str(path)) as container:
stream = container.streams.video[0]
origin = float(stream.start_time*stream.time_base) if stream.start_time is not None else 0.
container.seek(int((origin+start)/float(stream.time_base)),stream=stream,backward=True)
last = last_time if last_time is not None else (max(start, end-1/source_fps) if end is not None else float('inf'))
targets = [origin+min(start+i/24,last) for i in range(n)]
result = torch.empty((n,height,width,3),dtype=torch.float32)
prev = None; pos = 0
def put(frame):
nonlocal pos
if resize_size: frame = frame.reformat(width=resize_size[0],height=resize_size[1])
arr = frame.to_ndarray(format='rgb24')
h,w = arr.shape[:2]
if w>width or h>height:
raise ValueError('Dimensioni sorgente cambiate durante la lettura.')
arr = np.pad(arr,((0,height-h),(0,width-w),(0,0)),mode='edge')
result[pos] = torch.from_numpy(arr.astype(np.float32)/255.)
pos+=1
for frame in container.decode(stream):
interrupt()
if frame.time is None:
raise ValueError('Il sorgente non ha timestamp utilizzabili.')
t = float(frame.time)
while pos<n and targets[pos]<t-1e-7:
put(prev if prev is not None else frame)
prev=frame
if pos==n: break
if prev is None:
raise ValueError('Impossibile decodificare la selezione.')
while pos<n: put(prev)
return result
def thumbnail(path, second):
import av
import io
with av.open(str(path)) as container:
s=container.streams.video[0]
origin=float(s.start_time*s.time_base) if s.start_time is not None else 0.
target=origin+max(0.,float(second))
container.seek(int(target/float(s.time_base)),stream=s,backward=True)
chosen=None
for frame in container.decode(s):
chosen=frame
if float(frame.time or 0)>=target: break
if chosen is None: raise ValueError('Fotogramma non disponibile.')
image=chosen.to_image(); image.thumbnail((256,144))
data=io.BytesIO(); image.save(data,format='JPEG',quality=78)
return data.getvalue()
def save_patch(images,path,width,height,n,interrupt=lambda:None):
import av
import numpy as np
if len(images)<n or images.shape[1]<height or images.shape[2]<width:
raise ValueError('H3 ha restituito dimensioni o durata inferiori a quelle richieste.')
with av.open(str(path),'w') as out:
stream=out.add_stream('ffv1',rate=24)
stream.width=width; stream.height=height; stream.pix_fmt='bgr0'
for i,img in enumerate(images[:n]):
interrupt()
arr=(img[:height,:width,:3].detach().cpu().numpy().clip(0,1)*255).round().astype(np.uint8)
frame=av.VideoFrame.from_ndarray(arr,format='rgb24');frame.pts=i;frame.time_base=Fraction(1,24)
for packet in stream.encode(frame): out.mux(packet)
for packet in stream.encode(): out.mux(packet)
def compose(source,patch,dest,meta,plan,fade,interrupt=lambda:None,mask_path=None,settings=None):
from .dogma_h3_edit_export import compose_lossless
return compose_lossless(source,patch,dest,meta,plan,fade,ffmpeg,interrupt,mask_path,settings)
def render_edit(c,root,st,update,progress,uid,trial=False,resources=None,prepared=None,checkpoint=lambda:None,sampling=None,guide=None):
from . import dogma_h3_director_v134 as director
import comfy.model_management as mm
from comfy_extras.nodes_minimax_h3 import MiniMaxH3ReferenceToVideo,MiniMaxH3AddGuide
import uuid
import re
started=time.monotonic();timings={}
def interrupt():
mm.throw_exception_if_processing_interrupted();checkpoint()
def report(step,total):
interrupt();progress(step,total)
interrupt()
meta,plan=check_config(c,director.input_file)
source=director.input_file(c['edit']['source'])
content_width,content_height=generation_size(meta,c['edit'],trial)
width=math.ceil(content_width/32)*32; height=math.ceil(content_height/32)*32
n=plan['generation_frames']
res=resources if resources is not None else director.load_resources(c)
if guide:ensure_trial_patch(director,root,guide,interrupt,vae=res['vae'])
prompt=('integrated_multimodal_description:\n[Shot 1]\n'
'The target video is an edited version of <Video 1>. Preserve its shot, camera, '
'timing, motion and all elements not explicitly requested to change. '
'Keep the beginning and ending visually continuous with the source. '
'Apply only the following edit, using additional references as specified:\n'+c['prompt'].strip())
settings=mask_settings(c['edit'].get('mask'))
key=json.dumps([str(source),plan,width,height,prompt,c['refs'],settings,c['edit']['anchor_edges']],sort_keys=True)
reused=prepared is not None and prepared.get('key')==key
if reused:
cond,latent=prepared['cond'],prepared['latent']
update('running','Provino · riuso di video, prompt e maschera già preparati',active='edit')
else:
if prepared is not None:prepared.clear()
update('running','Lettura della sola selezione…',active='edit')
selected=segment_tensor(source,plan['start'],n,width,height,interrupt,
end=plan['end'],source_fps=meta['fps'],last_time=plan['last_source_time'],resize_size=(content_width,content_height))
images={};videos={'ref_video_1':selected}
if guide:
# The reference must also show the approved edit; the old source wall
# would otherwise keep competing with the transferred draft latent.
gw,gh=guide['width'],guide['height']
videos['ref_video_1']=segment_tensor(reference_movie(root,guide),0,n,math.ceil(gw/32)*32,math.ceil(gh/32)*32,interrupt,
end=plan['duration'],last_time=plan['last_source_time']-plan['start'])
for r in c['refs']:
if r['kind']=='image':
image=director.load_image(r['name'])
ref_edge=max(guide['width'],guide['height']) if guide else max(content_width,content_height)
if ref_edge and max(image.shape[1:3])>ref_edge:
import torch.nn.functional as F
ratio=ref_edge/max(image.shape[1:3])
size=tuple(max(32,round(d*ratio/32)*32) for d in image.shape[1:3])
image=F.interpolate(image.movedim(-1,1),size=size,mode='area').movedim(1,-1)
images[f'ref_image_{len(images)+1}']=image
else:
p=director.input_file(r['name']);m=cached_probe(p)
rn=max(5,math.floor((m['duration']*24-5)/17)*17+5)
if rn>n:raise ValueError('Un video reference è più lungo del tratto editato: accorcialo prima di caricarlo.')
ratio=min(1.,max(content_width,content_height)/max(m['width'],m['height']))
rw,rh=(max(32,round(m[k]*ratio/32)*32) for k in ('width','height'))
videos[f'ref_video_{len(videos)+1}']=segment_tensor(p,0,rn,rw,rh,interrupt,resize_size=(rw,rh))
interrupt();update('running',f'Video Edit · {content_width} × {content_height} · preparazione Ref2VA')
cond,latent=MiniMaxH3ReferenceToVideo.execute(res['clip'],prompt,width,height,n,'match',
vae=res['vae'],audio_vae=res['audio'],ref_images=images,ref_videos=videos)
interrupt()
if settings['enabled']:
update('running','Codifica del sorgente e maschera nativa H3')
latent=prepare_masked_latent(director,latent,res['vae'],selected,director.input_file(settings['source']),settings,plan,
(content_width,content_height),interrupt)
if c['edit']['anchor_edges'] and not settings['enabled']:
cond=MiniMaxH3AddGuide.execute(cond,latent,0,vae=res['vae'],image=selected[:1])[0]
cond=MiniMaxH3AddGuide.execute(cond,latent,-1,vae=res['vae'],image=selected[-1:])[0]
del selected,images,videos
if prepared is not None:prepared.update(key=key,cond=cond,latent=latent)
interrupt();timings['prepare']=round(time.monotonic()-started,2)
effective=copy.deepcopy(sampling if sampling is not None else (trial_sampling(c) if trial else c['edit']))
model_config=copy.deepcopy(c);model_config['edit']=effective
if trial:model_config['preview']['enabled']=bool(c['edit'].get('trial_live_preview',False))
model=director.prepare_model(res,model_config,{'loras':c['common_loras']},'edit',uid)
phase='Provino edit' if trial else 'Video Edit'
update('running',f'{phase} · {content_width} × {content_height} · {effective["steps"]} passaggi')
sampled=time.monotonic()
mask_report=inspect_native_mask(latent) if settings['enabled'] else dict(enabled=False)
development=dict(method='regenerate')
if guide:
update('running','Sviluppo edit · trasferimento del provino approvato e della sua maschera')
def read_trial(movie):
tw,th=guide['width'],guide['height'];pw=math.ceil(tw/32)*32;ph=math.ceil(th/32)*32
return segment_tensor(movie,0,n,pw,ph,interrupt,end=plan['duration'],
last_time=plan['last_source_time']-plan['start'])
latent,development=guide_latent(director,root,guide,res['vae'],latent,width,height,read_trial,interrupt)
if settings['enabled']:mask_report=inspect_native_mask(latent)
result=director.sample(model,cond,latent,{**effective,'lock_audio':False,'require_edit_mask':settings['enabled']},int(c['seed']),progress=report)
interrupt();timings['sample']=round(time.monotonic()-sampled,2);decoded=time.monotonic()
update('running',phase+' · decodifica')
output,_=director.decode(res,result,False)
interrupt();timings['decode']=round(time.monotonic()-decoded,2);exported=time.monotonic()
folder=root/'edits'/uuid.uuid4().hex;folder.mkdir(parents=True)
latent_path=None
if trial:
import torch
from .dogma_h3_edit_develop import pack
latent_path=folder/'approved.pt'
torch.save(pack(result),latent_path)
patch=folder/'generated.nut'
update('running','Video Edit · rimozione bordi tecnici e raccordo')
job=re.sub(r'[^\w.-]+','_',c['job'])[:80] or 'DOGMA'
settings=mask_settings(c['edit'].get('mask'))
mask_path=director.input_file(settings['source']) if settings['enabled'] else None
mixed=folder/'composited.nut'
final=folder/(job+('_H3_EDIT_TRIAL.mp4' if trial else '_H3_VIDEO_EDIT.mp4'))
update('running','Composizione del provino' if trial else 'Ricostruzione del video completo · audio originale')
try:
save_patch(output,patch,content_width,content_height,n,interrupt)
del result,output,cond,latent,model,res
if trial:
write_segment(source,patch,mixed,meta,plan,mask_path=mask_path,settings=settings,
size=(content_width,content_height),fade=c['edit']['fade'],
interrupt=interrupt)
if trial:
encode_trial(mixed,final,plan['duration'],interrupt)
info=dict(audio_copied=False,fps=meta['fps'],width=content_width,height=content_height)
else:
info=compose(source,patch,final,meta,plan,c['edit']['fade'],interrupt,mask_path=mask_path,settings=settings)
except BaseException:
patch.unlink(missing_ok=True)
if latent_path:latent_path.unlink(missing_ok=True)
raise
finally:
mixed.unlink(missing_ok=True)
timings['export']=round(time.monotonic()-exported,2)
saved_path,export_error=(str(final.resolve()),None) if trial else export_final(director,final,c)
return dict(id=uuid.uuid4().hex,label='Provino edit' if trial else 'Video completo · edit applicato',video=director.view_file(final),
saved_path=saved_path,export_error=export_error,complete=True,kind='edit_trial' if trial else 'edit',
selection=plan,duration=plan['duration'] if trial else meta['duration'],config=copy.deepcopy(c),
segment_only=trial,prompt=c['prompt'],seed=int(c['seed']),loras=copy.deepcopy(c['common_loras']),sampling={k:effective[k] for k in SAMPLING_KEYS if k in effective},mask_report=mask_report,
elapsed_seconds=round(time.monotonic()-started,2),timings=timings,prepared_reused=reused,
latent=str(latent_path.relative_to(root)) if latent_path else None,
latent_sha256=fingerprint(latent_path) if latent_path else None,video_sha256=fingerprint(final),development=development,
raw_patch=str(patch.relative_to(root)),raw_patch_sha256=fingerprint(patch),
generation_width=content_width,generation_height=content_height,
**{**info,'timing_version':2})
def export_final(director,final,c):
try:return director.export_file(final,c),None
except (OSError,ValueError) as exc:
# The completed master stays registered even if its extra copy fails.
return str(final.resolve()),str(exc)
def preserve_trial(c,root,selected,update,interrupt):
"""Restitch the raw approved generation; never upscale a lossy/stuttering preview."""
from . import dogma_h3_director_v134 as director
import uuid
source=director.input_file(c['edit']['source']);meta,plan=check_config(c,director.input_file)
approved=approved_movie(root,selected)
raw=ensure_trial_patch(director,root,selected,interrupt)
folder=root/'edits'/uuid.uuid4().hex;folder.mkdir(parents=True)
final=folder/((__import__('re').sub(r'[^\w.-]+','_',c['job'])[:80] or 'DOGMA')+'_H3_VIDEO_EDIT.mp4')
settings=mask_settings(c['edit'].get('mask'))
mask_path=director.input_file(settings['source']) if settings['enabled'] else None
update('running','Mantieni edit · ingrandimento del provino scelto, senza nuova generazione')
info=compose(source,raw,final,meta,plan,c['edit']['fade'],interrupt,mask_path=mask_path,settings=settings)
saved_path,export_error=export_final(director,final,c)
return dict(id=uuid.uuid4().hex,complete=True,kind='edit',video=director.view_file(final),
saved_path=saved_path,export_error=export_error,config=copy.deepcopy(c),selection=plan,segment_only=False,
duration=meta['duration'],prompt=c['prompt'],seed=c['seed'],video_sha256=fingerprint(final),
raw_patch=str(raw.relative_to(root)),raw_patch_sha256=fingerprint(raw),
generation_width=selected['width'],generation_height=selected['height'],
development=dict(method='preserve',trial=selected['id'],input='raw_approved_generation',raw_patch_sha256=fingerprint(raw),video_sha256=fingerprint(approved)),**info)
def ensure_trial_patch(director,root,trial,interrupt,vae=None):
"""1.0.46 kept raw latents: recover their original frames without rerunning H3."""
import uuid
raw=trial.get('raw_patch')
if raw:
path=(root/raw).resolve()
if not path.is_relative_to(root.resolve()) or not path.is_file():raise ValueError('Fotogrammi originali del provino non disponibili.')
if trial.get('raw_patch_sha256') and fingerprint(path)!=trial['raw_patch_sha256']:raise ValueError('Fotogrammi del provino modificati.')
return path
relative=trial.get('latent')
if not relative:raise ValueError('Questo vecchio provino non ha i latenti per recuperare i fotogrammi persi. Rigenera soltanto il provino con i nodi aggiornati.')
path=(root/relative).resolve()
if not path.is_relative_to(root.resolve()) or not path.is_file():raise ValueError('Latenti del provino non disponibili; nessuna sostituzione con il sorgente.')
if trial.get('latent_sha256') and fingerprint(path)!=trial['latent_sha256']:raise ValueError('Latenti del provino modificati.')
import torch
from .dogma_h3_edit_develop import unpack
latent=unpack(torch.load(path,map_location='cpu',weights_only=True))
if vae is None:
import nodes
vae=nodes.VAELoader().load_vae(trial['config']['models']['vae'])[0]
interrupt();images,_=director.decode({'vae':vae},latent,False)
directory=root/'recovered'/uuid.uuid4().hex;directory.mkdir(parents=True)
raw=directory/'generated.nut';n=trial['selection']['generation_frames']
save_patch(images,raw,trial['width'],trial['height'],n,interrupt);del images,latent
c=trial['config'];settings=mask_settings(c['edit'].get('mask'));source=director.input_file(c['edit']['source'])
meta=cached_probe(source);plan=plan_interval(meta,c['edit']['start'],c['edit']['end'])
mixed=directory/'composited.nut';preview=directory/'RECOVERED_EDIT_TRIAL.mp4'
try:
write_segment(source,raw,mixed,meta,plan,mask_path=director.input_file(settings['source']) if settings['enabled'] else None,
settings=settings,size=(trial['width'],trial['height']),fade=c['edit']['fade'],interrupt=interrupt)
encode_trial(mixed,preview,plan['duration'],interrupt)
finally:mixed.unlink(missing_ok=True)
trial.update(raw_patch=str(raw.relative_to(root)),raw_patch_sha256=fingerprint(raw),timing_repaired=True,
corrected_video=director.view_file(preview),corrected_path=str(preview.resolve()),corrected_video_sha256=fingerprint(preview))
return raw
def encode_trial(source,dest,duration,interrupt):
args=[ffmpeg(),'-hide_banner','-loglevel','error','-nostdin','-y','-i',str(source),'-t',str(duration),
'-an','-c:v','libx264','-crf','18','-preset','veryfast','-pix_fmt','yuv420p','-fps_mode','passthrough',
'-enc_time_base:v','1:1000000','-movflags','+faststart',str(dest)]
with tempfile.TemporaryFile() as log:
process=subprocess.Popen(args,stdout=subprocess.DEVNULL,stderr=log)
try:
while process.poll() is None: interrupt();time.sleep(.1)
if process.returncode:
log.seek(0);raise ValueError('Provino non codificato: '+log.read().decode(errors='replace')[-2000:])
except BaseException:
if process.poll() is None: process.kill();process.wait()
dest.unlink(missing_ok=True);raise
def run_edit(c,root,st,update,progress,uid):
from . import dogma_h3_director_v134 as director
def finish(trial_id,resources=None,batch=None):
selected=next((x for x in st.get('edit_trials',[]) if x['id']==trial_id),None)
if selected is None:raise ValueError('Scegli prima un provino edit.')
config=copy.deepcopy(selected['config']);config['action']='finish';config['output_dir']=c['output_dir'];config['job']=c['job']
options=batch.final_options if batch and batch.final_options is not None else final_options(c['edit'])
config['output_dir']=options['output_dir'] or c['output_dir']
if not config['output_dir'].strip():
import folder_paths
config['output_dir']=folder_paths.get_output_directory()
effective=final_sampling(selected,options)
if options['method']=='guided':effective['denoise']=options['denoise']
else:effective['denoise']=1.
config['edit'].update({k:effective[k] for k in SAMPLING_KEYS if k in effective},final_mode=options['mode'],final_steps=options['steps'],
final_method=options['method'],final_denoise=options['denoise'],final_output_dir=config['output_dir'],final_resolution=options['resolution'])
config['edit']['mask']={**(config['edit'].get('mask') or {}),**options['matte']}
if options['mode']=='standard' or is_ref_turbo(effective.get('turbo','')):
config['common_loras']=[x for x in config['common_loras'] if not is_ref_turbo(x.get('name',''))]
if config.get('models')!=c.get('models'):resources=None
if batch:st['edit_batch']=batch.snapshot()
st['edit_target']=dict(trial=selected['id'],label=selected['label'],seed=selected['seed'],mode=options['mode'],sampling={k:effective[k] for k in SAMPLING_KEYS if k in effective})
if options['method']!='regenerate':approved_movie(root,selected)
update('running','Provino scelto · '+('sviluppo alla dimensione originale' if options['method']=='guided' else 'ingrandimento senza rigenerazione' if options['method']=='preserve' else 'nuova generazione dal sorgente'),action='finish',active='edit')
if options['method']=='preserve':
import comfy.model_management as mm
item=preserve_trial(config,root,selected,update,mm.throw_exception_if_processing_interrupted)
else:
item=render_edit(config,root,st,update,progress,uid,trial=False,resources=resources,sampling=effective,
guide=selected if options['method']=='guided' else None)
item.update(trial=selected['id'],origin_trial_label=selected['label'],label='Video completo · da '+selected['label'])
st.setdefault('edits',[]).append(item)
if batch:batch.phase='done';st['edit_batch']=batch.snapshot()
update('error' if item.get('export_error') else 'done',
'Video pronto e conservato nella sessione; copia nella cartella scelta non riuscita: '+item['export_error'] if item.get('export_error') else 'Video completo salvato. Il percorso è riportato sotto il confronto.',action='finish',active=None)
if c['action']=='draft':
trials=director.make_plan(c)
# Validate all trials before loading a model or erasing a previous result.
configs=[]
for trial in trials:
config=copy.deepcopy(c);config['prompt']=trial['prompt'];config['seed']=trial['seed'];config['common_loras']=trial['loras']
check_config(config,director.input_file);configs.append(config)
sampling=trial_sampling(c)
batch=begin_batch(c['session'],len(configs),(x['id'] for x in st.get('edit_trials',[]) if x.get('complete')))
prepared={};resources=None
try:
st['edit_batch']=batch.snapshot();update('running','Provini edit · caricamento condiviso del modello')
try:
batch.checkpoint();resources=director.load_resources(configs[0])
for i,config in enumerate(configs):
batch.checkpoint();batch.current=i+1;st['edit_batch']=batch.snapshot()
update('running',f'Provino edit {i+1}/{len(configs)} · {config["edit"].get("trial_edge",512)} px · {sampling["steps"]} passaggi')
item=render_edit(config,root,st,update,progress,uid,trial=True,resources=resources,prepared=prepared,
checkpoint=batch.checkpoint,sampling=sampling)
item['label']=f'Provino edit {len(st.get("edit_trials",[]))+1}'
st.setdefault('edit_trials',[]).append(item);batch.add(item['id']);st['edit_batch']=batch.snapshot()
update('running',f'Provino edit {i+1}/{len(configs)} pronto',active=None)
except PromoteEdit:
pass # Only this session's unfinished draft is abandoned.
chosen=batch.seal();prepared.clear();st['edit_batch']=batch.snapshot()
if chosen:
finish(chosen,resources,batch)
else:update('ready','Provini edit pronti. Scegli quale rigenerare alla dimensione originale.',active=None)
finally:
prepared.clear();batch.close()
else:
finish(c['edit'].get('selected_trial'))
def register_edit_routes(routes,input_file):
from aiohttp import web
import asyncio
from functools import lru_cache
slots=asyncio.Semaphore(3)
cleanup=register_mask_routes(routes,input_file,probe,plan_interval)
register_promotion_route(routes)
@lru_cache(maxsize=128)
def metadata_cached(path,size,mtime):
return probe(Path(path))
@lru_cache(maxsize=256)
def thumbnail_cached(path,size,mtime,second):
return thumbnail(Path(path),second)
@routes.get('/dogma/h3/v2/edit/metadata')
async def metadata_route(request):
try:
path=input_file(request.query.get('name',''));s=path.stat()
async with slots:
data=await asyncio.to_thread(metadata_cached,str(path),s.st_size,s.st_mtime_ns)
return web.json_response(data)
except (ValueError,OSError) as exc:
raise web.HTTPBadRequest(text=str(exc))
@routes.get('/dogma/h3/v2/edit/thumbnail')
async def thumbnail_route(request):
try:
path=input_file(request.query.get('name',''));s=path.stat()
second=float(request.query.get('time','0'))
if not math.isfinite(second) or not 0<=second<=86400*7:
raise ValueError('Tempo anteprima non valido.')
async with slots:
data=await asyncio.to_thread(thumbnail_cached,str(path),s.st_size,s.st_mtime_ns,round(second,4))
return web.Response(body=data,content_type='image/jpeg',headers={'Cache-Control':'private, max-age=60'})
except (ValueError,OSError) as exc:
raise web.HTTPBadRequest(text=str(exc))
return cleanup
+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.35"
version = "1.0.51"
license = { file = "LICENSE" }
dependencies = ["scipy>=1.10"]
+22
View File
@@ -0,0 +1,22 @@
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';
if(/02 provini|02 scegli/.test(s))return 'layers';if(/video edit/.test(s))return 'settings';
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(/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=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;
}
export function actionButton(text,fn,cls=''){const b=document.createElement('button');b.type='button';b.className=cls;b.onclick=fn;return setAction(b,text);}
export function loraLabel(item){const stack=(item.loras||item.config?.common_loras||[]).filter(l=>l.name&&l.enabled!==false&&Number(l.strength??1)!==0),last=stack.at(-1);return last?last.name.replaceAll('\\','/').split('/').pop().replace(/\.safetensors$/i,'')+' · '+Number(last.strength??1).toLocaleString('it-IT'):'Senza LoRA';}
export function decorateActions(root){for(const b of root.querySelectorAll('button,a[download],summary'))if(!b.classList.contains('dg-edit-handle')&&!b.querySelector('.dg-icon')){const text=b.textContent,icon=inferIcon(text);if(icon)setAction(b,text,icon);}}
+68
View File
@@ -3,3 +3,71 @@
.dg-studio .dg-state{padding:8px 20px;background:#302820}.dg-studio .dg-state .dg-status{padding:0}.dg-studio .dg-progress{width:100%;height:10px;accent-color:#ff6018}.dg-studio .dg-progress-label{display:block;color:#ddd;margin-top:4px}.dg-studio .dg-saved-path{overflow-wrap:anywhere;font-family:monospace}.dg-studio .dg-header{position:relative;z-index:10}
.dg-studio .dg-folder-browser{position:sticky;top:0;z-index:8;padding:22px;background:#202225;border:2px solid var(--orange);border-radius:10px;box-shadow:0 12px 50px #000b;margin-bottom:24px;max-height:85%;overflow:auto}.dg-studio .dg-folders{display:flex;flex-wrap:wrap;gap:4px;max-height:260px;overflow:auto;margin:12px 0}.dg-studio .dg-folder-browser input{font-family:Consolas,monospace}.dg-studio .dg-folder-browser p{overflow-wrap:anywhere}
.dg-studio .dg-edit-player{height:360px;max-height:45vh;background:#080808}
.dg-studio .dg-edit-track{position:relative;height:96px;background:#0b0c0e;margin:12px 16px;touch-action:none;user-select:none;overflow:visible;border-radius:6px}
.dg-studio .dg-edit-thumbs{position:absolute;inset:0;display:grid;grid-template-columns:repeat(8,1fr);overflow:hidden;border-radius:6px;opacity:.7}
.dg-studio .dg-edit-thumbs img{width:100%;height:96px;object-fit:cover;pointer-events:none}
.dg-studio .dg-edit-selection{position:absolute;top:0;bottom:0;border:3px solid var(--orange);background:#ff5b1422;cursor:grab;min-width:2px}
.dg-studio .dg-edit-selection:active{cursor:grabbing}
.dg-studio .dg-edit-handle{position:absolute!important;top:-5px;bottom:-5px;width:24px;padding:0;margin:0!important;transform:translateX(-50%);background:var(--orange);border:2px solid #ffb48c;cursor:ew-resize;color:white;z-index:3;touch-action:none;border-radius:5px}
.dg-studio .dg-edit-handle[hidden]{display:none}
.dg-studio .dg-edit-playhead{position:absolute;top:0;bottom:0;width:2px;background:white;pointer-events:none;z-index:2}
.dg-studio .dg-edit-scale{display:flex;justify-content:space-between;color:#b9bec6;font-variant-numeric:tabular-nums;margin:0 16px 15px}
.dg-studio .dg-edit-controls{display:flex;gap:4px;flex-wrap:wrap}
.dg-studio .dg-edit-panel{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px 20px;align-items:start}
.dg-studio .dg-edit-panel>:not(video){grid-column:2;min-width:0;margin-top:0}
.dg-studio .dg-edit-panel>.dg-edit-player{grid-column:1;grid-row:1 / span 8;position:sticky;top:0;height:320px;max-height:45vh}
.dg-studio .dg-edit-panel .dg-edit-controls button{padding:7px 10px;font-size:13px}
.dg-studio .dg-edit-panel .dg-edit-track{width:calc(100% - 32px)}
@media(max-width:900px){.dg-studio .dg-edit-panel{display:block}.dg-studio .dg-edit-panel>.dg-edit-player{height:180px;z-index:5;margin-bottom:10px}}
.dg-studio input[type=range]{accent-color:var(--orange);padding:8px 0;cursor:ew-resize}
.dg-studio .dg-media-preview{position:relative;min-width:0;background:#080808;border-radius:5px;overflow:hidden}
.dg-studio .dg-media-preview img{display:block;max-width:100%;object-fit:contain}
.dg-studio .dg-media-status{padding:8px 12px;color:#bbb;background:#161719;font-size:13px}
.dg-studio .dg-media-status[hidden]{display:none}
.dg-studio .dg-media-status button{padding:5px 10px;font-size:12px}
.dg-studio .dg-media-preview[data-state=error] .dg-media-status{color:#ffd2bc}
.dg-studio .dg-edit-panel>.dg-media-preview{grid-column:1;grid-row:1 / span 9;position:sticky;top:0}
.dg-studio .dg-edit-panel>.dg-media-preview video{height:320px;max-height:45vh}
.dg-studio .dg-edit-compare{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px}
.dg-studio .dg-edit-compare strong{display:block;padding:8px 0}
.dg-studio .dg-edit-compare[data-mode=original],.dg-studio .dg-edit-compare[data-mode=result]{grid-template-columns:1fr}
.dg-studio .dg-edit-compare[data-mode=original]>:last-child,.dg-studio .dg-edit-compare[data-mode=result]>:first-child{display:none}
@media(max-width:900px){.dg-studio .dg-edit-panel>.dg-media-preview{position:relative;margin-bottom:10px}.dg-studio .dg-edit-panel>.dg-media-preview video{height:220px}}
@media(max-width:600px){.dg-studio .dg-edit-compare{grid-template-columns:1fr}}
.dg-studio .dg-icon{width:18px;height:18px;flex:0 0 18px;color:var(--orange);vertical-align:middle;pointer-events:none}
.dg-studio button:has(>.dg-icon),.dg-studio a:has(>.dg-icon){display:inline-flex;align-items:center;justify-content:center;gap:8px}
.dg-studio summary>.dg-icon{margin-right:8px}.dg-studio .dg-primary>.dg-icon,.dg-studio .is-active>.dg-icon{color:currentColor}
.dg-studio .dg-edit-controls button{padding:8px 12px;font-size:13px}.dg-studio .dg-edit-controls{align-items:center;gap:6px}
.dg-studio .dg-edit-controls button:first-of-type{border-color:#ad572c;background:#3a291f}
.dg-studio .dg-restitch-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:14px 0}
.dg-studio button.dg-restitch-choice{display:block;min-width:0;width:100%;margin:0;padding:8px;background:#17181a;text-align:left;border:2px solid transparent}
.dg-studio .dg-restitch-choice[aria-pressed=true]{border-color:var(--orange);background:#30231d}
.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}
+293 -80
View File
@@ -1,19 +1,22 @@
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-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-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=1.0.35',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=(text,fn,cls='')=>{const b=el('button',text,cls);b.type='button';b.onclick=fn;return b;};
const inputURL=name=>api.apiURL('/view?'+new URLSearchParams({filename:name,type:'input'}));
const viewURL=file=>api.apiURL('/view?'+new URLSearchParams(file));
const film=file=>{const v=el('video');v.controls=true;v.loop=true;v.playsInline=true;v.preload='metadata';v.src=viewURL(file);return v;};
const button=actionButton;
const film=file=>mediaPreview(file,{loop:true}).root;
let active=null;
export class DirectorStudio {
constructor(node){
this.node=node;this.page='prepare';this.data={candidates:[],finals:[]};this.pending=false;this.promptId=null;this.lastNotice='';this.abort=new AbortController();
this.epoch=0;this.requests=new Set();this.node=node;this.page='prepare';this.data={candidates:[],finals:[]};this.pending=false;this.promptId=null;this.lastNotice='';this.abort=new AbortController();
const on=(name,fn)=>api.addEventListener(name,fn,{signal:this.abort.signal});
on('dogma.h3.v2',e=>{if(e.detail.session===this.config?.session)this.applyState(e.detail);});
on('dogma.h3.v2.progress',e=>{if(e.detail.session===this.config?.session){this.data.progress=e.detail;this.status();}});
@@ -36,31 +39,154 @@ export class DirectorStudio {
const merge=(a,b)=>{for(const[k,v]of Object.entries(b||{}))a[k]=v&&typeof v==='object'&&!Array.isArray(v)?merge(a[k]||{},v):v;return a;};
let stored;try{stored=JSON.parse(this.widget.value);}catch{stored={};}
this.config=merge(clone(this.catalog.defaults),stored);
if(!stored.finish?.resolution&&stored.finish?.sigmas==='0.9035, 0.8, 0.6316, 0.3158, 0'){this.config.finish.sigmas='';this.config.finish.denoise=.18;}
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){
const name=this.family(task)==='ref2v'?'minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors':`minimax_h3_fl2v_turbo_${steps}step_v1.0${steps===4?'_768p':''}_comfyui_bf16.safetensors`;
return this.catalog.loras.find(x=>x.replaceAll('\\','/').split('/').pop()===name)||name;
}
presetValues(phase,kind='dogma',task=this.config.task){
const s={},base=this.catalog.defaults[phase];
for(const k of ['sampler','scheduler','steps','shift_video','shift_audio','lying','lying_start','lying_end','turbo_strength'])s[k]=base[k];
s.turbo=phase==='draft'?this.turbo(task,4):base.turbo;
if(phase==='finish'){
s.sigmas=kind==='conservative'?'':'0.9035, 0.8000, 0.6316, 0.3158, 0.0000';s.denoise=kind==='conservative'?.18:.35;
if(this.family(task)==='ref2v')s.steps=4;
for(const k of ['ultimate_steps','ultimate_denoise','ultimate_strength'])s[k]=base[k];
s.ultimate_turbo=this.turbo('i2v',8);
}
return s;
}
preset(phase,kind='dogma',task=this.config.task){Object.assign(this.config[phase],this.presetValues(phase,kind,task));}
presetKind(phase,task=this.config.task){
const same=(a,b,k)=>k.includes('turbo')&&!k.includes('strength')?String(a).replaceAll('\\','/').split('/').pop()===String(b).replaceAll('\\','/').split('/').pop():k==='sigmas'?String(a).replace(/\s/g,'')===String(b).replace(/\s/g,''):String(a)===String(b);
for(const kind of phase==='draft'?['dogma']:['dogma','conservative']){
if(Object.entries(this.presetValues(phase,kind,task)).every(([k,v])=>same(this.config[phase][k],v,k)))return kind;
}
return 'custom';
}
refreshPresetMenus(){for(const x of this.dialog?.querySelectorAll('.dg-preset-select')||[])x.value=this.presetKind(x.dataset.phase,x.dataset.task);}
renderKeepDetails(){
const open=[...this.dialog.querySelectorAll('details')].map(x=>!!x.open);
this.render();[...this.dialog.querySelectorAll('details')].forEach((x,i)=>x.open=open[i]||false);
}
presetMenu(parent,phase,task=this.config.task){
const wrap=el('label','','dg-field');wrap.append(el('span','Preset di campionamento'));
const select=el('select','','dg-preset-select');select.dataset.phase=phase;select.dataset.task=task;
const options=phase==='draft'?[['dogma','Standard · Turbo 4 step (predefinito)']]:[['dogma','Standard · raffinamento DOGMA (predefinito)'],['conservative','Conservativo · denoise 0,18']];
for(const [value,label]of [...options,['custom','Personalizzato']]){const o=el('option',label);o.value=value;o.disabled=value==='custom';select.append(o);}
select.value=this.presetKind(phase,task);
select.onchange=()=>{if(select.value==='custom')return;this.preset(phase,select.value,task);this.save();this.renderKeepDetails();};
wrap.append(select,el('small','La scelta applica i parametri qui sotto. Le modifiche manuali sono indicate come Personalizzato. Le LoRA aggiuntive restano invariate.'));parent.append(wrap);
}
switchTask(previous){
const c=this.config,old=this.family(previous),next=this.family(c.task);
if(c.task!=='edit'&&c.exploration==='loras')c.exploration='prompts';
if(old!==next){
c.sampling_banks ||= {};c.sampling_banks[old]={draft:clone(c.draft),finish:clone(c.finish)};
const saved=c.sampling_banks[next];
if(saved){c.draft=clone(saved.draft);for(const k of ['turbo','turbo_strength','sampler','scheduler','steps','shift_video','shift_audio','lying','lying_start','lying_end','sigmas','denoise'])c.finish[k]=saved.finish[k];}
else{this.preset('draft');this.preset('finish');}
}
this.save();this.render();
}
save(){
if(!this.config)return;
this.widget.value=JSON.stringify(this.config);this.node.setDirtyCanvas?.(true,true);
clearTimeout(this.saveTimer);this.saveTimer=setTimeout(()=>{try{app.graph?.change?.();}catch(e){console.warn('DOGMA: aggiornamento workflow',e);}},250);
}
save(){this.widget.value=JSON.stringify(this.config);this.node.setDirtyCanvas?.(true,true);app.graph?.change?.();}
async request(path,opts={}){
const controller=new AbortController();const timeout=setTimeout(()=>controller.abort(),path.endsWith('choose-folder')?125000:12000);
try{const r=await api.fetchApi(path,{...opts,signal:controller.signal});if(!r.ok)throw new Error(`Comfy: ${r.status} ${await r.text()}`);return await r.json();}
finally{clearTimeout(timeout);}
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,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;}
return result;
}catch(e){
if(expired)throw new Error('Il server non risponde entro 20 secondi. Puoi chiudere il pannello e riprovare.');
throw e;
}finally{clearTimeout(timeout);this.requests.delete(controller);}
}
header(){
const h=el('header','','dg-header'),logo=el('img');logo.src=new URL('dogma_logo_white.png',ROOT);logo.alt='DOGMA';
h.append(logo,el('div','ORIGINALS / H3 DIRECTOR','dg-brand'),button('Torna a Comfy',()=>this.close()));return h;
}
async open(){
if(active&&active!==this)active.close();active=this;
if(active&&active!==this)active.close();
if(this.dialog?.open)return;
this.catalog=await this.request('/dogma/h3/v2/catalog');this.load();
this.dialog=el('dialog','','dg-studio');this.dialog.setAttribute('aria-label','DOGMA H3 Director');
this.dialog.addEventListener('cancel',e=>{e.preventDefault();this.close();});
this.dialog.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();this.close();}e.stopPropagation();});
document.body.append(this.dialog);this.render();this.dialog.showModal();
await this.recover();if(this.dialog)this.poll=setInterval(()=>this.recover(false),3500);
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());
dialog.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();this.close();}});
dialog.append(this.header(),el('nav'),el('div','Caricamento impostazioni…','dg-status'),el('main'),el('footer'));
document.body.append(dialog);dialog.showModal();
try{
this.catalog=await this.request('/dogma/h3/v2/catalog');if(epoch!==this.epoch)return;
this.load();this.loading=false;this.render();
await this.recover();if(epoch===this.epoch&&this.dialog)this.poll=setInterval(()=>this.recover(false),3500);
}catch(e){
if(epoch!==this.epoch||e.name==='AbortError')return;
dialog.querySelector('.dg-status').textContent=e.message;
dialog.querySelector('main').append(button('Riprova',()=>{this.close();this.open();}));
}
}
close(){
clearInterval(this.poll);const dialog=this.dialog;this.dialog=null;if(active===this)active=null;
this.restitchPanel?.destroy();this.restitchPanel=null;this.editTimeline?.destroy();this.editTimeline=null;this.editCompare?.destroy();this.editCompare=null;this.maskEditor?.destroy();this.maskEditor=null;
releaseMedia(this.dialog);
++this.epoch;clearInterval(this.poll);this.recovering=false;
for(const c of this.requests)c.abort();this.requests.clear();
const dialog=this.dialog;this.dialog=null;this.folderPanel=null;if(active===this)active=null;
try{dialog?.querySelectorAll('video').forEach(v=>v.pause());dialog?.close();}finally{dialog?.remove();}
try{this.save();}catch(e){console.warn('DOGMA: pannello chiuso; salvataggio non riuscito',e);}
this.save();
}
destroy(){this.close();clearTimeout(this.saveTimer);this.abort.abort();}
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',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 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;use.hidden=!!options.viewOnly;
const go=button('Vai',()=>load(input.value));
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||'',files:options.viewOnly?'1':'0'}));
if(epoch!==this.epoch||this.folderPanel!==panel)return;
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;}
};
input.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();load(input.value);}});
await load(input.value);
}
destroy(){clearInterval(this.poll);this.abort.abort();this.dialog?.remove();if(active===this)active=null;}
sound(){
if(!this.config.sound)return;
try{this.audio ||= new (window.AudioContext||window.webkitAudioContext)();this.audio.resume();const t=this.audio.currentTime;
@@ -69,40 +195,59 @@ export class DirectorStudio {
}
armSound(){if(this.config.sound){try{this.audio ||= new(window.AudioContext||window.webkitAudioContext)();this.audio.resume();}catch{}}}
applyState(d){
const change=this.data.status!==d.status||this.data.candidates?.length!==d.candidates?.length||this.data.finals?.length!==d.finals?.length;
const change=this.data.status!==d.status||this.data.edit_batch?.phase!==d.edit_batch?.phase||this.data.candidates?.length!==d.candidates?.length||this.data.finals?.length!==d.finals?.length||this.data.edits?.length!==d.edits?.length||this.data.edit_trials?.length!==d.edit_trials?.length||JSON.stringify(this.data.edits?.map(x=>[x.id,x.saved_path]))!==JSON.stringify(d.edits?.map(x=>[x.id,x.saved_path]));
if((d.edit_trials?.length||0)>(this.data.edit_trials?.length||0)&&!d.edit_trials.some(x=>x.id===this.editTrialId)){this.editTrialId=d.edit_trials.at(-1).id;this.editComparePosition=null;}
if((d.edits?.length||0)>(this.data.edits?.length||0)){this.editResultId=d.edits.at(-1).id;this.editComparePosition=null;}
this.data=d;
if(['ready','done','error'].includes(d.status)){this.pending=false;const key=d.run+':'+d.status+':'+d.message;if(key!==this.lastNotice){this.lastNotice=key;this.sound();}}
if(this.promotedTrial&&d.edit_batch?.phase==='finish')this.page='finish';
if(['ready','developed','done','error'].includes(d.status)){this.pending=false;d.progress=null;const key=d.run+':'+d.status+':'+d.message;if(key!==this.lastNotice){this.lastNotice=key;this.sound();}}
if(change&&this.page!=='prepare')this.render();else this.status();
}
async recover(render=true){
if(!this.config||this.recovering)return;this.recovering=true;
if(!this.config||!this.dialog||this.loading||this.recovering)return;const epoch=this.epoch;this.recovering=true;
try{
const d=await this.request('/dogma/h3/v2/session/'+this.config.session);
const q=await this.request('/queue');
const jobs=[...(q.queue_running||[]),...(q.queue_pending||[])];
const owned=jobs.find(x=>this.promptId?x[1]===this.promptId:JSON.stringify(x[2]||{}).includes(this.config.session));
if(owned){this.promptId=owned[1];this.pending=true;if(d.status!=='running'){d.status='running';d.message='In coda · attendo il motore';}}
if(owned){this.promptId=owned[1];this.pending=true;if(!['running','error'].includes(d.status)){d.status='running';d.message='In coda · attendo il motore';}}
else if(d.status==='running'){
d.status='idle';d.message=jobs.length?'DOGMA non è in esecuzione. Comfy sta elaborando un altro lavoro.':'Il lavoro DOGMA non è più in coda. I provini salvati restano disponibili; controlla eventuali errori in Comfy.';this.pending=false;
}else if(!owned)this.pending=false;
if(owned&&d.run===this.data.run&&d.message===this.data.message&&!d.progress)d.progress=this.data.progress;
if(owned&&d.status==='running'&&d.run===this.data.run&&d.message===this.data.message&&!d.progress)d.progress=this.data.progress;
if(render||JSON.stringify(d)!==JSON.stringify(this.data))this.applyState(d);else this.status();
}catch(e){this.data.message='Stato non disponibile: '+e.message+'. Puoi sempre tornare a Comfy.';this.status();}
finally{this.recovering=false;}
if(this.editFinishWaiting&&!jobs.length&&!this.submitting){const intent=this.editFinishWaiting;this.editFinishWaiting=null;this.config.edit.selected_trial=intent.id;for(const [key,name]of Object.entries({mode:'final_mode',steps:'final_steps',method:'final_method',denoise:'final_denoise',output_dir:'final_output_dir',resolution:'final_resolution'}))this.config.edit[name]=intent.options[key];this.config.edit.mask={...this.config.edit.mask,...intent.options.matte};this.pending=false;this.queue('finish');}
}catch(e){if(epoch===this.epoch&&e.name!=='AbortError'){this.data.message='Stato non disponibile: '+e.message;this.status();}}
finally{if(epoch===this.epoch)this.recovering=false;}
}
fail(message){this.pending=false;this.data.status='error';this.data.message=message;this.status();this.sound();}
fail(message){this.pending=false;this.data.status='error';this.data.progress=null;this.data.message=message;this.status();this.sound();}
status(){
const s=this.dialog?.querySelector('.dg-status');if(s){s.textContent=this.data.message||'Prepara il lavoro, genera i provini e scegli quale continuare.';s.dataset.state=this.data.status;}
this.dialog?.querySelectorAll('[data-queue]').forEach(b=>b.disabled=this.pending);
const s=this.dialog?.querySelector('.dg-status');if(s){s.textContent=this.data.message||(this.config?.task==='edit'?'Seleziona un tratto, prepara la maschera se serve e genera i provini edit.':'Prepara il lavoro, genera i provini e scegli quale continuare.');s.dataset.state=this.data.status;}
this.dialog?.querySelectorAll('[data-queue]').forEach(b=>b.disabled=this.pending||this.submitting||this.uploads>0);
this.dialog?.querySelectorAll('[data-edit-finish]').forEach(b=>{
b.disabled=this.promoting||this.submitting||this.uploads>0||!!this.editFinishWaiting||this.pending&&this.data.edit_batch?.phase==='finish';
setAction(b,this.pending&&this.data.edit_batch?.phase!=='finish'?'Usa questo provino e salta gli altri':'Prepara questo provino alla dimensione originale');
});
const bar=this.dialog?.querySelector('.dg-progress');if(bar){const p=this.data.progress;bar.hidden=!this.pending;bar.max=p?.total||1;bar.value=p?.step||0;}
const detail=this.dialog?.querySelector('.dg-progress-label');if(detail)detail.textContent=this.pending?(this.data.progress?`Passaggio corrente: ${this.data.progress.step} / ${this.data.progress.total}`:'Elaborazione in corso · attendo avanzamento o anteprima') : '';
const stop=this.dialog?.querySelector('[data-stop]');if(stop)stop.disabled=!this.pending;
// Always allow a fresh queue check: UI state can lag behind a failed job.
const stop=this.dialog?.querySelector('[data-stop]');if(stop){stop.disabled=!!this.stopping;setAction(stop,this.pending?'Interrompi':'Verifica / interrompi');}
}
async queue(action){
if(this.pending)return;this.armSound();
if(this.pending||this.submitting||this.uploads>0)return;this.submitting=true;this.armSound();
try{
if(action==='finish'&&!this.config.selected)throw new Error('Scegli prima un provino.');
if(action==='draft'){
if(this.config.task!=='edit'&&action!=='draft'&&!this.config.selected)throw new Error('Scegli prima un provino.');
if(action==='draft'&&this.config.task==='edit'){
if(!this.config.edit.source||!this.editMeta)throw new Error('Carica un video e attendi la timeline.');
if(!this.editTimeline?.commitInputs())throw new Error(this.editTimeline?.inputError||'Attendi la preparazione della timeline.');
if((this.editTimeline.video?.readyState||0)<2)throw new Error('Attendi l’anteprima video o ricaricala prima di applicare l’edit.');
if(this.config.exploration==='seeds'&&!this.config.prompt.trim())throw new Error('Descrivi la modifica da applicare.');
if(this.config.exploration==='prompts'&&this.config.variants.some(v=>!v.text.trim()))throw new Error('Completa o rimuovi le varianti prompt vuote.');
if(this.config.edit.mask?.enabled&&!this.config.edit.mask.source)throw new Error('Carica il video maschera oppure disattiva la maschera.');
if(!this.catalog.models.includes(this.config.models.ref2v))throw new Error('Seleziona un checkpoint Ref2VA installato.');
if(this.config.refs.some(r=>!r.name))throw new Error('Carica o rimuovi le reference vuote.');
}
if(action==='draft'&&this.config.task!=='edit'){
if(this.config.task==='i2v'&&!this.config.first&&!this.config.last)throw new Error('Carica il primo o l’ultimo fotogramma.');
if(this.config.task==='ref2v'&&!this.config.refs.length)throw new Error('Carica almeno una reference.');
if(this.config.exploration==='prompts'&&this.config.variants.some(v=>!v.text.trim()))throw new Error('Completa o rimuovi le schede prompt vuote.');
@@ -111,35 +256,69 @@ export class DirectorStudio {
if(!this.catalog.models.includes(checkpoint))throw new Error('Seleziona un checkpoint installato per '+this.config.task.toUpperCase()+' nelle impostazioni avanzate.');
if(this.config.refs.some(r=>!r.name)&&this.config.task==='ref2v')throw new Error('Carica o rimuovi le reference vuote.');
}
if(action==='finish'&&this.config.task==='edit'&&!this.data.edit_trials?.some(p=>p.id===this.config.edit.selected_trial))throw new Error('Scegli prima un provino edit.');
if(action==='finish'&&this.config.task!=='edit'&&!this.data.developments?.some(p=>p.id===this.config.selected_development&&p.candidate===this.config.selected))throw new Error('Scegli prima uno sviluppo di qualità.');
const executionConfig=this.config.task==='edit'&&action==='finish'?this.data.edit_trials.find(p=>p.id===this.config.edit.selected_trial).config:this.config;
const required=[];
if(this.config.preview.enabled)required.push('ModelPreviewOverrideKJ');
if(action==='finish'&&this.config.finish.stages!=='ultimate')required.push('MinimaxH3LatentUpscaler3D');
if(action==='finish'&&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(', '));
if(executionConfig.task==='edit'&&executionConfig.edit.mask?.enabled)required.push('MVEx_MaskToLatentSpace');
if(this.config.task==='edit'&&action==='finish'&&(this.config.edit.final_method||'preserve')==='guided')required.push('MinimaxH3LatentUpscaler3D');
if(action==='develop'&&this.config.develop.mode==='guided')required.push('MinimaxH3LatentUpscaler3D');
const previewEnabled=executionConfig.preview.enabled&&!(this.config.task==='edit'&&action==='draft'&&!executionConfig.edit.trial_live_preview);
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: '+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;}
this.previewData=null;this.previewVideo=false;
this.pending=true;this.data.status='running';this.data.progress=null;this.data.message='Invio a Comfy…';this.status();
const graph=await app.graphToPrompt();
// Direct API returns a prompt_id; app.queuePrompt() may return false for accepted queued work.
const result=await api.queuePrompt(0,graph);
if(!result?.prompt_id)throw new Error('Comfy non ha restituito un identificativo di esecuzione.');
this.promptId=result.prompt_id;this.page=action==='draft'?'choose':'finish';this.data.message='In coda · attendo il motore';this.render();
}catch(e){const detail=e.response?.node_errors||e.response?.error;this.fail(detail?JSON.stringify(detail,null,2):e.message);}
this.promptId=result.prompt_id;this.page=action==='draft'?'choose':action==='develop'?'develop':'finish';this.data.message='In coda · attendo il motore';this.render();
}catch(e){if(e.name!=='AbortError'){const detail=e.response?.node_errors||e.response?.error;this.fail(detail?JSON.stringify(detail,null,2):e.message);}}finally{this.submitting=false;this.status();}
}
async useEditTrial(id){
if(this.promoting||this.editFinishWaiting||this.submitting||this.uploads>0)return;
const selected=this.data.edit_trials?.find(x=>x.id===id&&x.complete);if(!selected)return;
this.config.edit.selected_trial=id;this.save();
if(!this.pending){await this.queue('finish');return;}
this.promoting=true;this.status();
try{
const options={mode:this.config.edit.final_mode||'match',steps:Number(this.config.edit.final_steps??20),method:this.config.edit.final_method||'preserve',denoise:Number(this.config.edit.final_denoise??.25),output_dir:this.config.edit.final_output_dir||this.config.output_dir||'',resolution:this.config.edit.final_resolution||'native',matte:Object.fromEntries(Object.entries(this.config.edit.mask||{}).filter(([k])=>k.startsWith('composite_')))};
const result=await this.request('/dogma/h3/v2/edit/promote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session:this.config.session,trial:id,batch:this.data.edit_batch?.id,options})});
this.promotedTrial=id;
if(result.status==='accepted'){
this.data.edit_batch=result.batch;this.page='finish';this.data.message='Provino scelto · salto gli altri e passo alla dimensione originale';this.render();
}else{
this.editFinishWaiting={id,options};this.data.message='Provino scelto · attendo la chiusura del gruppo e avvio la rigenerazione';
const q=await this.request('/queue');const own=x=>x[1]===this.promptId;
const pending=(q.queue_pending||[]).filter(own).map(x=>x[1]);
if(pending.length)await this.request('/queue',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delete:pending})});
this.page='finish';this.render();await this.recover(false);
}
}catch(e){if(e.name!=='AbortError'){this.data.message='Scelta del provino non riuscita: '+e.message;this.status();}}
finally{this.promoting=false;this.status();}
}
async stop(){
if(this.stopping)return;this.stopping=true;this.status();
try{
const q=await this.request('/queue');
if(q.queue_running?.some(x=>x[1]===this.promptId))await api.interrupt();
else if(q.queue_pending?.some(x=>x[1]===this.promptId))await this.request('/queue',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delete:[this.promptId]})});
else {this.data.message='Nessun lavoro DOGMA da interrompere. Le altre generazioni non vengono fermate.';this.pending=false;this.status();return;}
const own=x=>this.promptId?x[1]===this.promptId:JSON.stringify(x[2]||{}).includes(this.config.session);
if(q.queue_running?.some(own))await api.interrupt();
else if(q.queue_pending?.some(own))await this.request('/queue',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delete:q.queue_pending.filter(own).map(x=>x[1])})});
else {this.data.message=this.data.status==='error'?this.data.message+' · Coda verificata: questo lavoro è terminato.':'Nessun lavoro DOGMA da interrompere. Le altre generazioni non vengono fermate.';this.pending=false;this.data.progress=null;return;}
this.data.message='Interruzione richiesta · attendo Comfy';this.status();
}finally{this.stopping=false;this.status();}
}
field(parent,label,obj,key,opts={}){
const wrap=el('label','','dg-field');wrap.append(el('span',label));let x;
if(opts.options){x=el('select');for(const value of opts.options){const [v,t,disabled]=Array.isArray(value)?value:[value,value];const o=el('option',t);o.value=v;o.disabled=!!disabled;x.append(o);}if(![...x.options].some(o=>o.value===String(obj[key]))&&obj[key]){const o=el('option',obj[key]+' · non disponibile');o.value=obj[key];x.append(o);}x.value=obj[key]??'';}
else if(opts.multi){x=el('textarea');x.rows=opts.rows||5;x.value=obj[key]??'';}
else{x=el('input');x.type=opts.type||'text';if(x.type==='checkbox')x.checked=!!obj[key];else x.value=obj[key]??'';if(opts.min!=null)x.min=opts.min;if(opts.max!=null)x.max=opts.max;if(opts.step!=null)x.step=opts.step;}
x.oninput=()=>{obj[key]=x.type==='checkbox'?x.checked:x.type==='number'?Number(x.value):x.value;this.save();};
x.oninput=()=>{obj[key]=x.type==='checkbox'?x.checked:x.type==='number'?Number(x.value):x.value;this.save();this.refreshPresetMenus();};
x.onchange=()=>{x.oninput();opts.change?.();};wrap.append(x);if(opts.hint)wrap.append(el('small',opts.hint));parent.append(wrap);return x;
}
section(parent,title){const p=el('section','','dg-card');p.append(el('h2',title));parent.append(p);return p;}
@@ -148,45 +327,48 @@ 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();}));
d.append(button('Aggiungi LoRA',()=>{stack.push({name:'',strength:1});this.save();draw();}));decorateActions(d);
};draw();
}
asset(parent,title,obj,key,kind='image',remove){
const p=this.section(parent,title);const name=obj[key];
if(name){const media=el(kind==='video'?'video':'img');media.src=inputURL(name);if(kind==='video')media.controls=true;media.className='dg-asset';p.append(media,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;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;this.save();this.render();}catch(e){this.fail(e.message);}};
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;
asset(parent,title,obj,key,kind='image',remove,onUpload){
return assetField(this,parent,title,obj,key,kind,{remove,onUploaded:onUpload});
}
render(){
if(!this.dialog)return;
this.dialog.querySelectorAll('video').forEach(v=>v.pause());this.dialog.replaceChildren();
const header=el('header','','dg-header');const logo=el('img');logo.src=new URL('dogma_logo_white.png',ROOT);logo.alt='DOGMA';header.append(logo,el('div','ORIGINALS / H3 DIRECTOR','dg-brand'),button('Torna a Comfy',()=>this.close()));this.dialog.append(header);
const nav=el('nav');for(const [key,label]of[['prepare','01 Prepara'],['choose','02 Scegli'],['finish','03 Finalizza']])nav.append(button(label,()=>{this.page=key;this.render();},this.page===key?'is-active':''));
const fresh=button('Nuova sessione',()=>{if(this.pending)return;if(!window.confirm('Avviare una nuova sessione? I video precedenti restano salvati. Salva il workflow in Comfy per poter riaprire anche la galleria precedente.'))return;this.config.session=crypto.randomUUID();this.config.selected='';this.config.action='draft';this.data={candidates:[],finals:[],status:'new'};this.promptId=null;this.A=this.B=null;this.previewData=null;this.page='prepare';this.save();this.render();});fresh.dataset.queue='';nav.append(fresh);
if(!this.dialog||this.loading)return;
const scroll=this.dialog.querySelector('main')?.scrollTop||0;
const expanded=new Map();
for(const d of this.dialog.querySelectorAll('details')){const key=d.querySelector('summary')?.textContent.replace(/ · \d+$/,'');if(!expanded.has(key))expanded.set(key,[]);expanded.get(key).push(d.open);}
this.restitchPanel?.destroy();this.restitchPanel=null;this.editTimeline?.destroy();this.editTimeline=null;this.editCompare?.destroy();this.editCompare=null;this.maskEditor?.destroy();this.maskEditor=null;
releaseMedia(this.dialog);
this.folderPanel=null;this.dialog.querySelectorAll('video').forEach(v=>v.pause());this.dialog.replaceChildren();
this.dialog.append(this.header());
const isEdit=this.config.task==='edit';
if(isEdit&&!['prepare','choose','finish'].includes(this.page))this.page='prepare';
const nav=el('nav');for(const [key,label]of(isEdit?[['prepare','01 Video Edit'],['choose','02 Provini'],['finish','03 Video completo']]:[['prepare','01 Prepara'],['choose','02 Scegli'],['develop','03 Sviluppa'],['finish','04 Finalizza']]))nav.append(button(label,()=>{this.page=key;this.render();this.dialog.querySelector('main').scrollTop=0;},this.page===key?'is-active':''));
const fresh=button('Nuova sessione',()=>{if(this.pending)return;if(!window.confirm('Avviare una nuova sessione? I video precedenti restano salvati. Salva il workflow in Comfy per poter riaprire anche la galleria precedente.'))return;this.config.session=crypto.randomUUID();this.config.selected='';this.config.action='draft';this.config.selected_development='';this.data={candidates:[],developments:[],finals:[],status:'new'};this.promptId=null;this.A=this.B=null;this.previewData=null;this.page='prepare';this.save();this.render();});fresh.dataset.queue='';nav.append(fresh);
const stop=button('Interrompi',()=>this.stop().catch(e=>{this.data.message='Interruzione non confermata: '+e.message;this.status();}));stop.dataset.stop='';nav.append(stop);const state=el('div','','dg-state');state.append(el('div','','dg-status'),el('progress','','dg-progress'),el('small','','dg-progress-label'));this.dialog.append(nav,state);
const body=el('main');this.dialog.append(body);
if(this.page==='prepare')this.prepare(body);if(this.page==='choose')this.choose(body);if(this.page==='finish')this.finish(body);
const foot=el('footer','Salvataggio automatico della sessione · Cartella output configurabile in Prepara · Esc torna a Comfy');this.dialog.append(foot);this.status();
if(this.page==='prepare')this.prepare(body);if(this.page==='choose'){if(isEdit)renderEditTrials(this,body);else this.choose(body);}if(this.page==='develop')this.develop(body);if(this.page==='finish'){if(isEdit)renderEditResults(this,body);else this.finish(body);}
const foot=el('footer','Salvataggio automatico della sessione · Cartella output configurabile in Prepara · Esc torna a Comfy');this.dialog.append(foot);decorateActions(this.dialog);this.status();
for(const d of this.dialog.querySelectorAll('details')){const key=d.querySelector('summary')?.textContent.replace(/ · \d+$/,'');const states=expanded.get(key);if(states?.length)d.open=states.shift();}
body.scrollTop=scroll;
}
prepare(body){
const c=this.config;const intro=this.section(body,'Il tuo lavoro');const row=el('div','','dg-grid');intro.append(row);
this.field(row,'Nome lavoro',c,'job');this.field(row,'Cosa vuoi creare?',c,'task',{options:[['i2v','Da immagini · primo e/o ultimo frame'],['t2v','Da testo · nessuna immagine'],['ref2v','Da reference · immagini e video']],change:()=>{
// Turbo task adapters cannot be silently reused across different H3 checkpoints.
c.draft.turbo='';c.finish.turbo='';c.draft.steps=24;c.finish.steps=24;c.finish.sigmas='';this.save();this.render();
}});
const previousTask=c.task;
this.field(row,'Nome lavoro',c,'job');this.field(row,'Cosa vuoi creare?',c,'task',{options:[['i2v','Da immagini · primo e/o ultimo frame'],['t2v','Da testo · nessuna immagine'],['ref2v','Da reference · immagini e video'],['edit','Video Edit · modifica un tratto del video']],change:()=>this.switchTask(previousTask)});
this.field(intro,'Cartella output · vuoto = cartella Comfy',c,'output_dir',{hint:'Percorso assoluto sul computer dove gira Comfy. I video vengono copiati qui e restano visibili nella galleria.'});
intro.append(button('Scegli cartella…',async()=>{try{const r=await this.request('/dogma/h3/v2/choose-folder',{method:'POST'});if(r.path){c.output_dir=r.path;this.save();this.render();}}catch(e){this.fail(e.message);}}));
intro.append(button('Scegli cartella…',()=>this.browseFolders()));
if(c.task==='edit'){renderEditor(this,body);return;}
this.field(intro,'Genera audio nei video',c,'generate_audio',{type:'checkbox',hint:'Disattivato: provini e finali vengono esportati senza traccia audio. H3 mantiene internamente i latenti audiovisivi.'});
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()});
@@ -198,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();}));
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()});
@@ -214,8 +396,8 @@ export class DirectorStudio {
const c=this.config;const d=el('details','','dg-card');d.append(el('summary','Impostazioni avanzate · modelli, campionamento e anteprima'));body.append(d);
const models=this.section(d,'Modelli installati');this.field(models,`Checkpoint ${c.task.toUpperCase()}`,c.models,c.task,{options:['',...this.catalog.models]});
for(const [k,label,list]of[['clip','Text encoder',this.catalog.clip],['vae','Video VAE',this.catalog.vae],['audio','Audio VAE',this.catalog.vae]])this.field(models,label,c.models,k,{options:list});
models.append(el('p','Scegli il checkpoint e la LoRA Turbo adatti al tipo di generazione. Passare a un’altra modalità disattiva le Turbo precedenti: seleziona quelle compatibili.'));
const p=this.section(d,'Provini');this.sampleFields(p,c.draft);
models.append(el('p','I2V e T2V condividono le impostazioni FL2V. Ref2V conserva un preset separato con Turbo compatibile. Tornando a una modalità ritrovi le sue impostazioni.'));
const p=this.section(d,'Campionamento dei provini');this.presetMenu(p,'draft');this.sampleFields(p,c.draft);
const live=this.section(d,'Anteprima durante la generazione');this.field(live,'Anteprima KJ attiva',c.preview,'enabled',{type:'checkbox'});this.field(live,'Tiny VAE',c.preview,'tiny_vae',{options:this.catalog.tiny_vae});this.field(live,'Risoluzione massima',c.preview,'max_resolution',{type:'number',step:32,min:128,max:1024});
live.append(el('p','Si aggiorna quando il sampler fornisce un passo. Non è un secondo video generato e non garantisce un aggiornamento ogni secondo.'));
}
@@ -243,31 +425,62 @@ export class DirectorStudio {
items.forEach(p=>{
const card=this.section(grid,p.label);if(p.id===this.config.selected)card.classList.add('is-selected');card.append(film(p.video),el('p',`${p.width} × ${p.height} · ${(p.frames/24).toFixed(2)} s · seed ${p.seed}`));
const txt=el('details');txt.append(el('summary','Prompt e LoRA del provino'),el('pre',p.prompt),el('pre',p.loras.map(l=>`${l.name} · ${l.strength}`).join('\n')||'Nessuna LoRA aggiuntiva'));card.append(txt);
card.append(button('Scegli e continua',()=>{this.config.selected=p.id;this.save();this.page='finish';this.render();},'dg-primary'));
card.append(button('Scegli e sviluppa',()=>{this.config.selected=p.id;this.config.selected_development='';this.save();this.page='develop';this.render();},'dg-primary'));
for(const side of ['A','B'])card.append(button('Confronta '+side,()=>{this[side]=p.id;this.render();}));
});
const a=items.find(p=>p.id===this.A),b=items.find(p=>p.id===this.B);
if(a&&b){const compare=this.section(body,'Confronto A / B');const g=el('div','','dg-grid');const av=film(a.video),bv=film(b.video);av.muted=bv.muted=true;g.append(av,bv);compare.append(g,button('Riproduci insieme',()=>{av.currentTime=bv.currentTime=0;av.play();bv.play();}),button('Pausa',()=>{av.pause();bv.pause();}));}
if(a&&b){const compare=this.section(body,'Confronto A / B');const g=el('div','','dg-grid');const ap=mediaPreview(a.video,{loop:true}),bp=mediaPreview(b.video,{loop:true}),av=ap.media,bv=bp.media;av.muted=bv.muted=true;g.append(ap.root,bp.root);compare.append(g,button('Riproduci insieme',()=>{av.currentTime=bv.currentTime=0;av.play();bv.play();}),button('Pausa',()=>{av.pause();bv.pause();}));}
}
develop(body){
const p=this.data.candidates?.find(x=>x.id===this.config.selected);
if(!p){body.append(el('p','Scegli prima una bozza.'));return;}
const src=this.section(body,p.label+' · bozza scelta');src.append(film(p.video));
const s=this.section(body,'Sviluppo di qualità');const d=this.config.develop;
this.field(s,'Metodo',d,'mode',{options:[['guided','Continua la bozza scelta (predefinito)'],['native','Rigenera in qualità · il movimento può cambiare']]});
this.field(s,'Lato lungo dello sviluppo',d,'long_edge',{type:'number',min:512,max:1536,step:32});
s.append(el('p','Standard: circa 960 × 544 per 16:9, 20 step senza Turbo. Continua usa i latenti salvati della bozza; prompt, seed, sorgenti e LoRA vengono ripresi automaticamente. La risoluzione maggiore può cambiare dettagli e movimento.'));
const adv=el('details','','dg-card');adv.append(el('summary','Campionamento dello sviluppo'));body.append(adv);
this.field(adv,'Denoise · solo Continua (maggiore = più cambiamento)',d,'denoise',{type:'number',min:.01,max:.99,step:.01});this.sampleFields(adv,d);
const go=button('Sviluppa questo',()=>this.queue('develop'),'dg-primary');go.dataset.queue='';body.append(go);this.live(body);
const grid=el('div','','dg-gallery');body.append(grid);
for(const item of (this.data.developments||[]).filter(x=>x.candidate===p.id)){
const card=this.section(grid,item.label);card.append(film(item.video),el('p',`${item.width} × ${item.height} · ${item.mode==='guided'?'Continuazione':'Rigenerazione'} · seed ${item.seed}`),el('p',item.saved_path||''));
card.append(button('Usa questo sviluppo → Finalizza',()=>{this.config.selected_development=item.id;this.save();this.page='finish';this.render();},'dg-primary'));
}
}
finish(body){
const p=this.data.candidates?.find(x=>x.id===this.config.selected);
if(!p){body.append(el('p','Scegli un provino dalla sezione Scegli.'));return;}
const p=this.data.developments?.find(x=>x.id===this.config.selected_development&&x.candidate===this.config.selected);
if(!p){body.append(el('p','Scegli uno sviluppo di qualità nella sezione Sviluppa.'));return;}
const selected=this.section(body,p.label+' · selezionato');selected.append(film(p.video),el('p','Prompt, seed, sorgenti, modello e LoRA sono ripresi automaticamente da questo provino.'));
const f=this.config.finish;const s=this.section(body,'Come vuoi finalizzare?');
this.field(s,'Passaggi',f,'stages',{options:[['both','Completo · latent upscale + Ultimate'],['latent','Solo latent upscale e raffinamento'],['ultimate','Solo Ultimate dal provino']],change:()=>this.render()});
const task=p.config?.task||this.config.task;
this.field(s,'Passaggi',f,'stages',{options:[['both','Completo · latent upscale + Ultimate'],['latent','Solo latent upscale e raffinamento'],['ultimate','Solo Ultimate dallo sviluppo']],change:()=>this.render()});
this.field(s,'Risoluzione finale · lato lungo',f,'resolution',{options:[['1k','1K · 1024 px'],['2k','2K · 2048 px'],['3k','3K · 3072 px'],['4k','4K · 4096 px']],change:()=>this.render()});
const edge={ '1k':1024,'2k':2048,'3k':3072,'4k':4096 }[f.resolution];
s.append(el('p',`Uscita: ${Math.max(32,Math.round(p.width/Math.max(p.width,p.height)*edge/32)*32)} × ${Math.max(32,Math.round(p.height/Math.max(p.width,p.height)*edge/32)*32)} px. Proporzioni del provino, allineate a 32 px. Completo usa un passaggio latente fino a ×2 e Ultimate alla risoluzione scelta.`));
s.append(el('small','Il raffinamento può modificare i dettagli. 3K/4K richiedono più memoria e tempo; il solo passaggio latente può superare la memoria disponibile.'));
const d=el('details','','dg-card');d.append(el('summary','Controlli finalizzazione'));body.append(d);this.sampleFields(d,f);
const rate={value:this.config.rife.enabled?String(this.config.rife.fps):'original'};
this.field(s,'Fotogrammi al secondo finali',rate,'value',{options:[['original','Originali · 24 fps — senza RIFE'],['48','48 fps · RIFE'],['60','60 fps · RIFE']],change:()=>{
this.config.rife.enabled=rate.value!=='original';if(this.config.rife.enabled)this.config.rife.fps=Number(rate.value);this.save();this.renderKeepDetails();
}});
if(this.config.rife.enabled){
s.append(el('p','Ordine: latent e raffinamento → RIFE → Ultimate. Con Solo latent si ferma dopo RIFE; con Solo Ultimate interpola prima dei tasselli. La durata e l’audio restano invariati. Il video interpolato viene salvato anche separatamente. Richiede ComfyUI-VFI e il modello RIFE installato.'));
if(!this.catalog.nodes.includes('RIFEInterpolation'))s.append(el('p','RIFE non disponibile: installa ComfyUI-VFI dal Manager oppure scegli Originali nella tendina dei fotogrammi al secondo.'));
}
const d=el('details','','dg-card');d.append(el('summary','Controlli finalizzazione'));body.append(d);this.presetMenu(d,'finish',task);this.sampleFields(d,f);
this.field(d,'Modello latent upscale',f,'upscaler',{options:this.catalog.upscalers});this.field(d,'Sigma del raffinamento · vuoto usa scheduler',f,'sigmas');this.field(d,'Denoise raffinamento · solo senza sigma manuali',f,'denoise',{type:'number',step:.01,min:0,max:1});
this.field(d,'Checkpoint FL2V per Ultimate',f,'ultimate_model',{options:this.catalog.models});this.field(d,'Turbo FL2V per Ultimate',f,'ultimate_turbo',{options:['',...this.catalog.loras]});this.field(d,'Potenza Turbo Ultimate',f,'ultimate_strength',{type:'number',step:.01});
this.field(d,'Sampler Ultimate',f,'ultimate_sampler',{options:this.catalog.samplers});
this.field(d,'Seed raffinamento',f,'refine_seed',{type:'number',step:1});
this.field(d,'Seed Ultimate',f,'ultimate_seed',{type:'number',step:1});
this.field(d,'Checkpoint per Ultimate',f,'ultimate_model',{options:this.catalog.models});this.field(d,'Turbo FL2V per Ultimate',f,'ultimate_turbo',{options:['',...this.catalog.loras]});this.field(d,'Potenza Turbo Ultimate',f,'ultimate_strength',{type:'number',step:.01});
for(const [k,label,step]of[['tile_width','Larghezza tasselli',32],['tile_height','Altezza tasselli',32],['tile_padding','Contesto tasselli',8],['mask_blur','Sfumatura tasselli',1],['ultimate_steps','Step Ultimate',1],['ultimate_denoise','Denoise Ultimate',.01]])this.field(d,label,f,k,{type:'number',step});
this.field(d,'RIFE · batch (1 riduce la memoria)',this.config.rife,'batch_size',{type:'number',min:1,max:32,step:1});
const go=button('Finalizza questo',()=>this.queue('finish'),'dg-primary');go.dataset.queue='';body.append(go,button('Torna ai provini',()=>{this.page='choose';this.render();}));this.live(body);
const out=el('div','','dg-gallery');body.append(out);
for(const item of (this.data.finals||[]).filter(x=>!x.candidate||x.candidate===p.id)){
const card=this.section(out,item.label);card.append(film(item.video),el('p',item.saved_path||'Risultato precedente · cartella Comfy','dg-saved-path'));
if(item.saved_path)card.append(button('Apri cartella',async()=>{try{await this.request('/dogma/h3/v2/open-folder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session:this.config.session,id:item.id})});}catch(e){this.fail(e.message);}}));
if(item.fps)card.append(el('p',`${item.width} × ${item.height} · ${item.fps} fps · ${item.frames} fotogrammi · ${(item.frames/item.fps).toFixed(2)} s`));
if(item.saved_path){const open=button('Apri cartella',async()=>{if(open.disabled)return;open.disabled=true;try{await this.request('/dogma/h3/v2/open-folder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session:this.config.session,id:item.id})});}catch(e){if(e.name!=='AbortError')this.fail(e.message);}finally{open.disabled=false;}});card.append(open);}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
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));
const clock=t=>`${String(Math.floor(t/60)).padStart(2,'0')}:${(t%60).toFixed(3).padStart(6,'0')}`;
export class EditComparison{
constructor(studio,root,item){
this.studio=studio;this.item=item;this.closed=false;this.abort=new AbortController();this.playing=false;
const source=item.config?.edit?.source;
if(!source){root.append(el('p','Il sorgente di questa versione non è registrato.'),mediaPreview(item.video,{loop:true}).root);return;}
const items=[...(studio.data.edit_trials||[]),...(studio.data.edits||[])];
const label=x=>{
if(x.segment_only)return `${x.label} · ${x.config?.exploration==='loras'?loraLabel(x)+' · ':''}seed ${x.seed}`;
if(x.development?.method==='preserve')return `Finale da ${x.origin_trial_label||'provino'} · edit mantenuto · seed ${x.seed}`;
const sampling=x.sampling||x.config?.edit;
return `Finale da ${x.origin_trial_label||items.find(t=>t.id===x.trial)?.label||'provino precedente'} · seed ${x.seed}${sampling?' · '+(sampling.turbo?'Turbo':'standard')+' '+sampling.steps:''}`;
};
this.pool=[{id:'original',label:'Originale',video:inputFile(source),offset:0,end:Infinity},...items.filter(x=>x.config?.edit?.source===source).map(x=>({id:x.id,label:label(x),video:x.corrected_video||x.video,offset:x.segment_only?x.selection.start:0,end:x.segment_only?x.selection.end:x.duration}))];
if(!this.pool.some(x=>x.id===item.id))this.pool.push({id:item.id,label:item.label,video:item.video,offset:item.segment_only?item.selection.start:0,end:item.segment_only?item.selection.end:item.duration});
const saved=studio.editCompareChoice;
let left=saved?.source===source?saved.left:'original',right=saved?.source===source&&saved.selected===item.id?saved.right:item.id;
if(!this.pool.some(x=>x.id===left))left='original';if(!this.pool.some(x=>x.id===right))right=item.id;if(left===right)left=right==='original'?item.id:'original';
studio.editCompareChoice={source,selected:item.id,left,right};
this.left=this.pool.find(x=>x.id===left);this.right=this.pool.find(x=>x.id===right);this.key=source+'|'+left+'|'+right;
const selectors=el('div');selectors.className='dg-grid';
for(const [side,label,other]of[['left','Confronto A · sinistra',right],['right','Confronto B · destra',left]]){
const wrap=el('label',label);wrap.className='dg-field';const select=el('select');
for(const entry of this.pool){const option=el('option',entry.label);option.value=entry.id;option.disabled=entry.id===other;select.append(option);}
select.value=side==='left'?left:right;select.onchange=()=>{studio.editCompareChoice[side]=select.value;studio.render();};wrap.append(select);selectors.append(wrap);
}
root.append(selectors);
const pair=this.pair=el('div');pair.className='dg-edit-compare';pair.dataset.mode=studio.editCompareMode||'pair';
this.original=mediaPreview(this.left.video);this.result=mediaPreview(this.right.video);
for(const [side,entry,preview]of[['A',this.left,this.original],['B',this.right,this.result]]){
const panel=el('div');panel.append(el('strong',entry.label),preview.root);
if((studio.data.edit_trials||[]).some(x=>x.id===entry.id))panel.append(button(`Scegli ${side} per il finale`,()=>{studio.editTrialId=entry.id;studio.editCompareChoice.selected=entry.id;studio.page='choose';studio.render();}));
pair.append(panel);preview.media.controls=false;preview.media.muted=true;
}
root.append(pair);this.a=this.original.media;this.b=this.result.media;
const modes=el('div');modes.className='dg-edit-controls';
for(const [mode,label]of[['pair','Affiancati'],['original','Solo A'],['result','Solo B']])modes.append(button(label,()=>{studio.editCompareMode=mode;pair.dataset.mode=mode;}));
const actions=el('div');actions.className='dg-edit-controls';
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('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');
for(const [value,label]of[['mute','Muto'],['left','A · '+this.left.label],['right','B · '+this.right.label]]){const option=el('option',label);option.value=value;audio.append(option);}
audio.onchange=()=>{this.a.muted=audio.value!=='left';this.b.muted=audio.value!=='right';};sound.append(audio);root.append(modes,actions,sound,this.slider,this.position,this.notice);
const position=studio.editComparePosition;
this.target=position?.key===this.key?position.time:item.selection.start;this.resumeWanted=position?.key===this.key&&position.playing;this.selectionOnly=position?.key===this.key?position.selectionOnly:true;
for(const v of [this.a,this.b]){
v.addEventListener('loadedmetadata',()=>this.restore(),{signal:this.abort.signal});
v.addEventListener('loadeddata',()=>this.ready(),{signal:this.abort.signal});
v.addEventListener('error',()=>{this.pause();this.ready();},{signal:this.abort.signal});
v.addEventListener('waiting',()=>{if(this.playing&&!this.starting&&!this.restarting){this.resumeAfterBuffer=true;this.pause(false);this.notice.textContent='Attendo entrambe le anteprime…';}},{signal:this.abort.signal});
v.addEventListener('canplay',()=>{this.ready();if(this.resumeAfterBuffer&&this.a.readyState>=3&&this.b.readyState>=3){this.resumeAfterBuffer=false;this.play(this.selectionOnly,false);}},{signal:this.abort.signal});
v.addEventListener('ended',()=>this.reachEnd(),{signal:this.abort.signal});
}
this.ready();this.restore();
studio.request('/dogma/h3/v2/edit/metadata?'+new URLSearchParams({name:source}),{signal:this.abort.signal}).then(meta=>{if(!this.closed){this.meta=meta;this.restore();}}).catch(e=>{if(!this.closed&&e.name!=='AbortError')this.notice.textContent='Tempi sorgente non disponibili. Riapri il confronto.';});
}
bounds(selectionOnly=false){
const low=Math.max(this.left.offset,this.right.offset,selectionOnly?this.item.selection.start:0);
const high=Math.min(this.a.duration+this.left.offset,this.b.duration+this.right.offset,this.left.end,this.right.end,selectionOnly?this.item.selection.end:Infinity);
return [low,high];
}
ready(){
if(this.closed||!this.a)return;
const range=this.bounds(),ready=!!this.meta&&this.a.readyState>=2&&this.b.readyState>=2&&!this.a.error&&!this.b.error&&range[1]>range[0];
this.playButton.disabled=this.fullButton.disabled=this.slider.disabled=!ready;
if(ready&&this.resumeWanted){this.resumeWanted=false;this.play(this.selectionOnly,false);}
}
restore(){
if(this.closed||!this.a||this.a.readyState<1||this.b.readyState<1)return;
const [low,high]=this.bounds();if(!(high>low)){this.notice.textContent='Questi video non condividono un intervallo temporale.';this.ready();return;}
this.target=clamp(this.target,low,high);this.slider.min=low;this.slider.max=high;
this.a.currentTime=Math.max(0,this.target-this.left.offset);this.b.currentTime=Math.max(0,this.target-this.right.offset);this.draw();this.ready();
}
seek(time){
this.pause();this.atEnd=false;const [low,high]=this.bounds();
const last=this.meta?frameTime(this.meta,Math.max(0,frameIndex(this.meta,high)-1)):high-.001;
this.target=clamp(this.meta?frameTime(this.meta,Math.min(frameCount(this.meta)-1,frameIndex(this.meta,time))):time,low,last);this.restore();
}
step(delta){if(this.meta)this.seek(stepTime(this.meta,this.a.currentTime+this.left.offset,delta));}
toggle(selection){if((this.playing||this.starting||this.resumeAfterBuffer)&&this.selectionOnly===selection){this.pause();return;}this.play(selection);}
playbackLabel(){if(!this.playButton)return;setAction(this.playButton,(this.playing||this.starting||this.resumeAfterBuffer)&&this.selectionOnly?'Pausa edit':'Riproduci edit');setAction(this.fullButton,(this.playing||this.starting||this.resumeAfterBuffer)&&!this.selectionOnly?'Pausa video':'Riproduci tutto');}
async play(selectionOnly,restart=true){
if(this.closed||!this.meta||this.a.error||this.b.error)return;
const [low,high]=this.bounds(selectionOnly);if(!(high>low)){this.notice.textContent='Nessun tratto comune da riprodurre.';return;}
const now=this.a.currentTime+this.left.offset;const atEnd=this.atEnd;
this.pause();this.selectionOnly=selectionOnly;this.atEnd=false;this.resumeWanted=false;
if(restart&&(atEnd||now<low||now>=high-.001||this.a.ended||this.b.ended)){this.target=low;this.restore();}
const run=this.playRun;this.starting=true;this.notice.textContent='';
this.playbackLabel();
if(Math.abs(this.b.currentTime+this.right.offset-this.a.currentTime-this.left.offset)>.001)this.b.currentTime=this.a.currentTime+this.left.offset-this.right.offset;
const wait=v=>new Promise((resolve,reject)=>{
if(v.readyState>=2&&!v.seeking){resolve();return;}
const done=()=>{if(v.readyState>=2&&!v.seeking){clean();resolve();}};
const cancelled=()=>{clean();reject(new Error('Confronto chiuso'));};
const timer=setTimeout(()=>{clean();reject(new Error('Caricamento in attesa'));},15000);
const clean=()=>{clearTimeout(timer);v.removeEventListener('seeked',done);v.removeEventListener('loadeddata',done);this.abort.signal.removeEventListener('abort',cancelled);};
v.addEventListener('seeked',done);v.addEventListener('loadeddata',done);this.abort.signal.addEventListener('abort',cancelled,{once:true});
});
try{
await Promise.all([wait(this.a),wait(this.b)]);if(this.closed||run!==this.playRun)return;
this.playing=true;await Promise.all([this.a.play(),this.b.play()]);if(this.closed||run!==this.playRun)return;
this.starting=false;this.playbackLabel();this.tick();
}catch(e){if(!this.closed&&run===this.playRun){this.pause();this.notice.textContent='Riproduzione non avviata: attendi il caricamento e riprova.';}}
}
reachEnd(){
if(this.closed||!this.playing||this.restarting)return;
if(this.studio.editLoop!==false){
this.restarting=true;const selection=this.selectionOnly,[low]=this.bounds(selection);this.pause();this.target=low;this.restore();
this.play(selection,false).finally(()=>{this.restarting=false;});
}else{
const [,high]=this.bounds(this.selectionOnly);this.seek(this.meta?stepTime(this.meta,high,-1):high-.001);this.atEnd=true;
}
}
tick(){
if(this.closed||!this.playing)return;
const t=this.a.currentTime+this.left.offset,[,end]=this.bounds(this.selectionOnly);
if(t>=end-.00001){this.reachEnd();return;}
const drift=this.b.currentTime+this.right.offset-t,frame=1/this.meta.fps;
if(Math.abs(drift)>Math.max(frame,.04))this.b.currentTime=t-this.right.offset;else this.b.playbackRate=clamp(1-drift*.5,.97,1.03);
this.draw();this.raf=requestAnimationFrame(()=>this.tick());
}
pause(clearResume=true){this.playRun=(this.playRun||0)+1;this.starting=false;this.playing=false;if(clearResume){this.resumeAfterBuffer=false;this.resumeWanted=false;}cancelAnimationFrame(this.raf);this.a?.pause();this.b?.pause();if(this.b)this.b.playbackRate=1;this.playbackLabel();}
draw(){const t=this.a.currentTime+this.left.offset;this.target=t;this.slider.value=t;this.position.textContent=`${clock(t)} · edit ${clock(this.item.selection.start)} → ${clock(this.item.selection.end)}${this.meta?' · fotogramma '+frameIndex(this.meta,t):''}`;}
destroy(){
if(this.closed)return;
if(this.a?.readyState>=2)this.studio.editComparePosition={key:this.key,time:this.a.currentTime+this.left.offset,playing:this.playing,selectionOnly:this.selectionOnly};
this.closed=true;this.pause();this.abort.abort();this.original?.destroy();this.result?.destroy();
}
}
+87
View File
@@ -0,0 +1,87 @@
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){
const e=c.edit,m=e.mask;
return JSON.stringify([e.source,e.start,e.end,e.fade,m]);
}
export function compositeControls(studio,root,m){
m.composite_mode??='source';m.composite_grow??=0;m.composite_feather??=8;
studio.field(root,'Area da applicare sull’originale',m,'composite_mode',{options:[['source','Maschera caricata · regolazioni separate'],['generation','Stessa area usata per generare']],change:()=>{studio.save();studio.render();},hint:'L’espansione per H3 può includere il telaio e il vagone. Le regolazioni separate evitano di incollare anche quelle ricostruzioni sul video originale.'});
if(m.composite_mode==='source'){
studio.field(root,'Espandi / contrai il montaggio · pixel del sorgente',m,'composite_grow',{type:'number',step:1,hint:'0 rispetta l’area caricata, anche se la generazione usa un’espansione maggiore.'});
studio.field(root,'Feather del montaggio · pixel del sorgente',m,'composite_feather',{type:'number',min:0,step:1,hint:'Sfumatura verso l’interno: ammorbidisce il contorno senza estendere la modifica nelle aree nere. 0 mantiene il bordo netto.'});
}
}
export class MaskEditor{
constructor(studio,parent){
this.studio=studio;this.closed=false;this.abort=new AbortController();
const c=studio.config,e=c.edit;
e.mask={enabled:false,source:'',grow:0,feather:0,opacity:.55,view:'overlay',preview_area:'composite',...e.mask};
const m=e.mask,root=studio.section(parent,'Maschera video · opzionale');
root.append(el('p','Carica il tuo video bianco e nero, allineato al sorgente completo. Bianco: usa la zona generata. Nero: conserva il video originale. I grigi miscelano i due. La maschera non viene generata dal workflow.'));
studio.field(root,'Usa la maschera video',m,'enabled',{type:'checkbox',change:()=>{studio.save();studio.render();}});
studio.asset(root,'Video maschera caricato da te',m,'source','video',()=>{m.source='';m.enabled=false;},()=>{m.enabled=true;});
if(!m.enabled)return;
studio.field(root,'Espandi / contrai per H3 · pixel del sorgente',m,'grow',{type:'number',step:1,hint:'Solo area di generazione: valori positivi espandono il bianco; negativi lo contraggono.'});
studio.field(root,'Feather per H3 · pixel del sorgente',m,'feather',{type:'number',min:0,step:.5,hint:'Sfumatura della maschera che guida il modello.'});
compositeControls(studio,root,m);
studio.field(root,'Area da verificare nell’overlay',m,'preview_area',{options:[['composite','Montaggio finale sull’originale'],['generation','Area di generazione H3']]});
studio.field(root,'Vista dell’anteprima',m,'view',{options:[['overlay','Video + maschera arancione'],['mask','Solo maschera elaborata'],['source','Solo video originale']]});
studio.field(root,'Opacità overlay · solo anteprima',m,'opacity',{type:'number',min:0,max:1,step:.05});
this.go=el('button','Prepara video overlay');this.go.type='button';this.go.onclick=()=>this.start();
this.notice=el('p');this.notice.setAttribute('role','status');this.progress=el('progress');if(this.progress)this.progress.hidden=true;
this.previewRoot=el('div');root.append(this.go,this.notice,this.progress,this.previewRoot);
root.append(el('small','L’overlay verifica l’area scelta e il raccordo temporale senza avviare H3. Generazione e montaggio possono usare bordi diversi. Il modello usa una griglia più grossa; il montaggio rispetta la maschera nei pixel del sorgente.'));
this.key=maskKey(c);this.refresh();
const saved=studio.maskPreview;
if(saved?.key===this.key)this.show(saved.file);
this.watch=setInterval(()=>{if(maskKey(c)!==this.key){this.key=maskKey(c);this.invalidate();}this.refresh();},350);
}
refresh(){
if(!this.go)return;
const timeline=this.studio.editTimeline;
this.go.disabled=this.busy||!this.studio.config.edit.source||!this.studio.config.edit.mask.source||this.studio.uploads>0||!timeline?.ready;
if(!this.busy&&!timeline?.ready){this.notice.textContent=timeline?.error||'Attendi la preparazione della timeline prima di creare l’overlay.';this.readinessNotice=true;}
else if(this.readinessNotice){this.notice.textContent='';this.readinessNotice=false;}
}
invalidate(){
this.cancel();this.player?.destroy();this.player=null;this.previewRoot.replaceChildren();
this.notice.textContent='Impostazioni o selezione cambiate. Prepara di nuovo l’overlay per vedere l’area aggiornata.';
}
cancel(){
clearTimeout(this.poll);this.busy=false;if(this.progress)this.progress.hidden=true;
if(this.job){const id=this.job;this.job=null;this.studio.request('/dogma/h3/v2/edit/mask-preview/'+id,{method:'DELETE'}).catch(()=>{});}
}
async start(){
if(this.closed||this.busy)return;
try{
const timeline=this.studio.editTimeline;
if(!timeline?.commitInputs())throw new Error(timeline?.inputError||'Attendi la preparazione della timeline.');
this.cancel();this.player?.destroy();this.player=null;this.previewRoot.replaceChildren();
const c=this.studio.config;this.key=maskKey(c);const key=this.key;
const data={source:c.edit.source,start:c.edit.start,end:c.edit.end,fade:c.edit.fade,mask:{...c.edit.mask}};
this.busy=true;this.refresh();this.progress.hidden=false;this.progress.removeAttribute('value');this.notice.textContent='Preparazione overlay della tua maschera…';
const result=await this.studio.request('/dogma/h3/v2/edit/mask-preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),signal:this.abort.signal});
if(this.closed||key!==this.key){this.studio.request('/dogma/h3/v2/edit/mask-preview/'+result.id,{method:'DELETE'}).catch(()=>{});return;}
this.job=result.id;this.pollJob(key);
}catch(e){if(!this.closed&&e.name!=='AbortError'){this.busy=false;if(this.progress)this.progress.hidden=true;this.notice.textContent=e.message;this.refresh();}}
}
async pollJob(key){
if(this.closed||!this.job||key!==this.key)return;
try{
const result=await this.studio.request('/dogma/h3/v2/edit/mask-preview/'+this.job,{signal:this.abort.signal});
if(this.closed||key!==this.key)return;
if(result.status==='done'){
this.job=null;this.busy=false;if(this.progress)this.progress.hidden=true;this.studio.maskPreview={key,file:result.file};this.show(result.file);this.refresh();return;
}
if(result.status==='error'||result.status==='cancelled')throw new Error(result.message||'Anteprima annullata.');
if(result.total){this.progress.max=result.total;this.progress.value=result.done;this.notice.textContent=`Preparazione overlay · ${result.done}/${result.total} fotogrammi`;}
this.poll=setTimeout(()=>this.pollJob(key),500);
}catch(e){if(!this.closed&&e.name!=='AbortError'){this.cancel();this.notice.textContent=e.message;this.refresh();}}
}
show(file){this.player?.destroy();this.player=mediaPreview(file,{loop:true});this.previewRoot.replaceChildren(this.player.root);this.notice.textContent=this.studio.config.edit.mask.preview_area==='generation'?'Overlay pronto: area che guida H3. Può essere più ampia del montaggio finale.':'Overlay pronto: area applicata sull’originale, inclusi i bordi sfumati.';}
destroy(){if(this.closed)return;this.closed=true;clearInterval(this.watch);this.cancel();this.abort.abort();this.player?.destroy();}
}
+48
View File
@@ -0,0 +1,48 @@
import { api } from '../../scripts/api.js';
const el=(tag,text='')=>{const e=document.createElement(tag);e.textContent=text;return e;};
export function inputFile(name){
const path=name.replaceAll('\\','/'),i=path.lastIndexOf('/');
return {filename:path.slice(i+1),subfolder:i<0?'':path.slice(0,i),type:'input'};
}
export const viewURL=file=>api.apiURL('/view?'+new URLSearchParams(file));
export function mediaPreview(file,{kind='video',className='',loop=false}={}){
const root=el('div');root.className='dg-media-preview';
const media=el(kind==='image'?'img':'video');media.className=className;
if(kind==='image')media.alt=file.filename;else{media.controls=true;media.playsInline=true;media.preload='auto';media.loop=loop;}
const status=el('div');status.className='dg-media-status';status.setAttribute('role','status');
const label=el('span'),retry=el('button','Ricarica anteprima');retry.type='button';retry.hidden=true;
status.append(label,retry);root.append(media,status);
const abort=new AbortController();let closed=false,timer,attempt=0,resume=0;
const message=(text,error=false)=>{if(closed)return;root.dataset.state=error?'error':'loading';label.textContent=text;status.hidden=false;retry.hidden=!error;};
const ready=()=>{if(closed)return;clearTimeout(timer);root.dataset.state='ready';status.hidden=true;};
const load=()=>{
clearTimeout(timer);resume=kind==='video'?media.currentTime||0:0;
message('Caricamento anteprima…');
media.src=viewURL({...file,...(attempt?{dg_reload:attempt}: {})});
if(kind!=='image')media.load();
timer=setTimeout(()=>message('Anteprima in attesa. Puoi ricaricarla senza perdere la selezione.',true),15000);
};
retry.onclick=()=>{attempt++;load();};
media.addEventListener(kind==='image'?'load':'loadeddata',ready,{signal:abort.signal});
media.addEventListener('loadedmetadata',()=>{if(resume>0)media.currentTime=Math.min(resume,media.duration);},{signal:abort.signal});
media.addEventListener('error',async()=>{
clearTimeout(timer);const current=attempt;
message('Impossibile aprire l’anteprima. Verifico il file…',true);
try{
const response=await api.fetchApi('/view?'+new URLSearchParams(file),{method:'HEAD',signal:abort.signal});
if(closed||current!==attempt)return;
if(response.status===404)message('File non trovato. Ricarica il file sorgente.',true);
else if(!response.ok)message(`Anteprima non disponibile (errore ${response.status}). Riprova.`,true);
else message(kind==='image'?'Immagine non leggibile dal browser. Prova PNG o JPEG.':'Il file esiste, ma il browser non riesce a decodificarlo. Per l’anteprima usa MP4 H.264.',true);
}catch(e){if(!closed&&current===attempt&&e.name!=='AbortError')message('Connessione interrotta. Ricarica l’anteprima.',true);}
},{signal:abort.signal});
const destroy=()=>{if(closed)return;closed=true;clearTimeout(timer);abort.abort();retry.onclick=null;if(kind!=='image')media.pause();media.removeAttribute('src');if(kind!=='image')media.load();};
root._dogmaMedia={destroy};load();
return {root,media,destroy,reload:()=>{attempt++;load();}};
}
export function releaseMedia(root){
for(const e of root?.querySelectorAll('.dg-media-preview')||[])e._dogmaMedia.destroy();
}
+119
View File
@@ -0,0 +1,119 @@
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{
constructor(studio,parent,item){
this.studio=studio;this.item=item;this.closed=false;this.abort=new AbortController();
this.root=el('details','','dg-card dg-restitch');this.root.append(el('summary','Rifinisci maschera · preset e impostazioni personali'));parent.append(this.root);
this.body=el('div');this.root.append(this.body);this.notice=el('p');this.notice.setAttribute('role','status');this.body.append(this.notice);
this.root.addEventListener('toggle',()=>{if(this.closed)return;if(this.root.open){studio.restitchOpenFor=item.id;this.start();}else{studio.restitchOpenFor=null;this.release();this.started=false;}},{signal:this.abort.signal});
if(studio.restitchOpenFor===item.id)this.root.open=true;
}
async job(path,data){
const request=await this.studio.request(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),signal:this.abort.signal});
for(;;){
const result=await this.studio.request('/dogma/h3/v2/edit/restitch/job/'+request.id,{signal:this.abort.signal});
if(result.status==='done')return result.result;if(result.status==='error')throw Error(result.message);
await new Promise(resolve=>{this.pollTimer=setTimeout(resolve,250);this.pollResolve=resolve;});
if(this.closed||!this.root.open)throw new DOMException('Pannello chiuso','AbortError');
}
}
async start(){
if(this.started||this.closed)return;this.started=true;delete this.root.dataset.readyMs;this.openedAt=performance.now();this.body.replaceChildren(this.notice);this.notice.textContent='Preparazione delle anteprime condivise…';
const epoch=this.loadEpoch=(this.loadEpoch||0)+1;
try{
const info=await this.job('/dogma/h3/v2/edit/restitch/preview',{session:this.studio.config.session,id:this.item.id});
if(this.closed||!this.root.open||epoch!==this.loadEpoch)return;
this.info=info;this.build();
}catch(e){if(this.closed||e.name==='AbortError')return;this.notice.textContent=e.message;this.body.append(actionButton('Riprova',()=>{this.started=false;this.start();}));}
}
build(){
const info=this.info,scale=Math.max(.25,info.source_width/1664),r=n=>Math.round(n*scale),g=info.base.grow,f=info.base.feather;
this.presets=[{id:'current',label:'Attuale',grow:g,feather:f},{id:'soft',label:'Raccordo morbido',grow:g,feather:f+r(8)},
{id:'expand',label:'Espansione lieve',grow:g+r(4),feather:Math.max(f,r(8))},{id:'expand-soft',label:'Espansione progressiva',grow:g+r(12),feather:f+r(16)},
{id:'contract',label:'Contrazione lieve',grow:g-r(4),feather:Math.max(f,r(8))},{id:'contract-soft',label:'Contrazione progressiva',grow:g-r(12),feather:f+r(8)}];
for(const p of this.presets){p.grow=clamp(p.grow,-256,256);p.feather=clamp(p.feather,0,256);}
this.studio.restitchDrafts??={};const draft=this.studio.restitchDrafts[this.item.id];
this.custom={id:'custom',label:'Personalizzata',grow:draft?.grow??g,feather:draft?.feather??f};this.presets.push(this.custom);this.selected=this.presets.find(p=>p.id===draft?.selected)||this.presets[0];
this.body.replaceChildren();this.body.append(el('p','Tutte le anteprime sono sincronizzate e in loop. Le prove non modificano il file salvato; applica soltanto quella scelta.'));
this.toolbar=el('div','','dg-edit-controls');this.playButton=actionButton('Pausa anteprime',()=>{if(this.video.paused)this.play();else this.video.pause();});
this.scrub=el('input');this.scrub.type='range';this.scrub.min=0;this.scrub.max=info.duration;this.scrub.step=1/info.fps;this.scrub.setAttribute('aria-label','Posizione di tutte le anteprime restitch');
this.scrub.oninput=()=>{this.video.currentTime=Number(this.scrub.value);};this.position=el('span','0.00 s');this.toolbar.append(this.playButton,this.position);this.body.append(this.toolbar,this.scrub);
const grid=el('div','','dg-restitch-grid');this.cards=[];this.canvases=[];
for(const p of this.presets){
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.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];
input.oninput=()=>{if(!Number.isFinite(input.valueAsNumber))return;this.custom[key]=clamp(input.valueAsNumber,min,max);this.choose(this.custom);if(this.video.paused)this.draw();};input.onchange=()=>{input.value=this.custom[key];};wrap.append(input);manual.append(wrap);
}
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',`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();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();}
refresh(){
for(const c of this.cards){c.card.setAttribute('aria-pressed',String(c.p===this.selected));c.settings.textContent=`${c.p.grow>0?'+':''}${c.p.grow} px · feather ${c.p.feather} px`;}
if(this.applyButton)this.applyButton.disabled=this.applying||this.studio.pending||this.selected.grow===this.info.base.grow&&this.selected.feather===this.info.base.feather;
}
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);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{
const result=await this.job('/dogma/h3/v2/edit/restitch/apply',{session:this.studio.config.session,id:this.item.id,revision:this.info.revision,grow:this.selected.grow,feather:this.selected.feather});
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.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();}
}
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});
if(!gl){this.cpu=document.createElement('canvas');this.cpu.width=info.width*4;this.cpu.height=info.height;this.cpuContext=this.cpu.getContext('2d',{willReadFrequently:true});return;}
const vertex='attribute vec2 p;varying vec2 uv;void main(){uv=p*.5+.5;gl_Position=vec4(p,0.,1.);}';
const fragment=`precision highp float;varying vec2 uv;uniform sampler2D atlas;uniform vec2 size;uniform vec2 source;uniform float grow;uniform float feather;uniform float range;uniform float fade;
vec2 coord(vec2 q,float panel){return vec2((clamp(q.x,0.,1.)*(size.x-1.)+.5+panel*size.x)/(4.*size.x),(clamp(q.y,0.,1.)*(size.y-1.)+.5)/size.y);}
float sdf(vec2 q){return (texture2D(atlas,coord(q,2.)).r*2.-1.)*range;}
float mask(vec2 q){return texture2D(atlas,coord(q,3.)).r;}
void main(){float d=sdf(uv);float a=mask(uv);float weight;
if(abs(grow)<.01){weight=a*(feather>.01?smoothstep(0.,feather,max(d,0.)):1.);}
else{vec2 du=1./size;vec2 grad=vec2(sdf(uv+vec2(du.x,0.))-sdf(uv-vec2(du.x,0.)),sdf(uv+vec2(0.,du.y))-sdf(uv-vec2(0.,du.y)));vec2 near=uv+normalize(grad+vec2(.00001))*max(0.,-d+2.*source.x/size.x)/source;float opacity=max(a,mask(near));weight=(feather>.01?smoothstep(0.,feather,d+grow):step(0.,d+grow))*opacity;}
gl_FragColor=vec4(mix(texture2D(atlas,coord(uv,0.)).rgb,texture2D(atlas,coord(uv,1.)).rgb,weight*fade),1.);}`;
const shader=(type,text)=>{const s=gl.createShader(type);gl.shaderSource(s,text);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw Error('Anteprima GPU non disponibile: '+gl.getShaderInfoLog(s));return s;};
const program=this.program=gl.createProgram();gl.attachShader(program,shader(gl.VERTEX_SHADER,vertex));gl.attachShader(program,shader(gl.FRAGMENT_SHADER,fragment));gl.linkProgram(program);if(!gl.getProgramParameter(program,gl.LINK_STATUS))throw Error('Impossibile preparare le anteprime.');gl.useProgram(program);
this.buffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,this.buffer);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);const p=gl.getAttribLocation(program,'p');gl.enableVertexAttribArray(p);gl.vertexAttribPointer(p,2,gl.FLOAT,false,0,0);
this.texture=gl.createTexture();gl.bindTexture(gl.TEXTURE_2D,this.texture);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,true);
this.u=Object.fromEntries(['size','source','grow','feather','range','fade'].map(k=>[k,gl.getUniformLocation(program,k)]));gl.uniform2f(this.u.size,info.width,info.height);gl.uniform2f(this.u.source,info.source_width,info.source_height);gl.uniform1f(this.u.range,info.field_range);
}
draw(video,presets){
const i=this.info,w=i.width,h=i.height,t=video.currentTime,fade=i.fade>0?clamp(Math.min((t+1/i.fps)/i.fade,(i.duration-t)/i.fade),0,1):1;
if(this.gl){const gl=this.gl;gl.bindTexture(gl.TEXTURE_2D,this.texture);gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,video);gl.uniform1f(this.u.fade,fade);
presets.forEach((p,n)=>{gl.viewport(n*w,0,w,h);gl.uniform1f(this.u.grow,p.grow);gl.uniform1f(this.u.feather,p.feather);gl.drawArrays(gl.TRIANGLE_STRIP,0,4);});
this.contexts.forEach((c,n)=>c.drawImage(this.output,n*w,0,w,h,0,0,w,h));
}else{
this.cpuContext.drawImage(video,0,0);const pixels=this.cpuContext.getImageData(0,0,w*4,h).data;
presets.forEach((p,n)=>{const frame=this.contexts[n].createImageData(w,h),a=frame.data;
for(let y=0;y<h;y++)for(let x=0;x<w;x++){const q=(y*w*4+x)*4,k=(y*w+x)*4,d=(pixels[q+w*8]/255*2-1)*i.field_range;let z=clamp((d+p.grow)/Math.max(p.feather,.001),0,1),alpha=z*z*(3-2*z);if(!p.grow)alpha*=pixels[q+w*12]/255;alpha*=fade;for(let c=0;c<3;c++)a[k+c]=Math.round(pixels[q+c]*(1-alpha)+pixels[q+w*4+c]*alpha);a[k+3]=255;}this.contexts[n].putImageData(frame,0,0);});
}
}
destroy(){if(this.gl){this.gl.deleteTexture(this.texture);this.gl.deleteBuffer(this.buffer);this.gl.deleteProgram(this.program);this.gl.getExtension('WEBGL_lose_context')?.loseContext();}this.output.width=1;this.output.height=1;}
}
+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();}
}
+36
View File
@@ -0,0 +1,36 @@
export function frameTime(meta,index){
if(meta.frame_times)return index>=meta.frame_times.length?meta.duration:meta.frame_times[Math.max(0,index)];
return index/meta.fps;
}
export const frameCount=meta=>meta.frame_times?.length??Math.floor(meta.duration*meta.fps+1e-6);
export function frameIndex(meta,time){
if(!meta.frame_times)return Math.max(0,Math.min(frameCount(meta),Math.floor(time*meta.fps+.5)));
let lo=0,hi=meta.frame_times.length;
while(lo<hi){const mid=(lo+hi)>>1;if(frameTime(meta,mid)<time)lo=mid+1;else hi=mid;}
if(lo>0&&time-frameTime(meta,lo-1)<frameTime(meta,lo)-time)lo--;
return lo;
}
export function snapSelection(start,end,meta,side='end'){
const total=frameCount(meta);let a=Math.max(0,Math.min(total-1,frameIndex(meta,start))),b=Math.max(1,Math.min(total,frameIndex(meta,end)));
if(side==='start')a=Math.min(a,b-1);else b=Math.max(b,a+1);
return {start:frameTime(meta,a),end:frameTime(meta,b)};
}
export function stepTime(meta,time,delta){
return frameTime(meta,Math.max(0,Math.min(frameCount(meta)-1,frameIndex(meta,time)+delta)));
}
export function parseSeconds(value){
const s=value.trim().replace(',','.');
return /^(?:\d+(?:\.\d*)?|\.\d+)$/.test(s)?Number(s):NaN;
}
export function parseSelection(startValue,endValue,meta){
let start=parseSeconds(startValue),end=parseSeconds(endValue);
if(!Number.isFinite(start)||!Number.isFinite(end))throw new Error('Inserisci i secondi usando punto o virgola.');
// The inputs display six decimals; accept their half-unit rounding at EOF.
const tolerance=.5e-6+Number.EPSILON*Math.max(1,meta.duration)*8;
if(start<0||end>meta.duration+tolerance)throw new Error('I tempi devono essere compresi nel video.');
start=Math.max(0,Math.min(meta.duration,start));end=Math.max(0,Math.min(meta.duration,end));
if(start>=end)throw new Error('L’inizio deve precedere la fine. Completa entrambi i tempi.');
const selection=snapSelection(start,end,meta);
if(selection.end-selection.start>meta.generation_frames.at(-1)/24+1e-8)throw new Error('La selezione supera la durata disponibile con queste reference.');
return selection;
}
+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.');}};
}
+272
View File
@@ -0,0 +1,272 @@
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-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;
const url=(path,params)=>api.apiURL(path+'?'+new URLSearchParams(params));
const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
export const clock=t=>{const m=Math.floor(t/60),s=t-m*60;return `${String(m).padStart(2,'0')}:${s.toFixed(3).padStart(6,'0')}`;};
export function renderEditor(studio,body){
const c=studio.config,e=c.edit;
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){
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('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.'});
studio.field(prompt,'Seed',c,'seed',{type:'number',min:0,max:9007199254740990,step:1});
const anchors=studio.field(prompt,'Mantieni i fotogrammi ai due estremi come guida',e,'anchor_edges',{type:'checkbox',hint:e.mask?.enabled?'Con maschera attiva gli estremi non vengono forzati: anche lì può essere richiesta una modifica.':'Aiuta il raccordo. Disattiva se vuoi modificare anche il primo e l’ultimo fotogramma.'});anchors.disabled=!!e.mask?.enabled;
studio.field(prompt,'Raccordo con l’originale · secondi per lato',e,'fade',{type:'number',min:0,max:.5,step:.025,hint:'Sfumatura dentro la selezione. 0 = taglio netto. Una sfumatura troppo lunga può creare doppie immagini.'});
studio.loras(prompt,c.common_loras,'LoRA comuni a tutti i provini edit');
const advanced=el('details','','dg-card');advanced.append(el('summary','Modelli e campionamento personalizzato'));body.append(advanced);
studio.field(advanced,'Checkpoint Ref2VA',c.models,'ref2v',{options:['',...studio.catalog.models]});
for(const [key,label,options]of[['clip','Text encoder',studio.catalog.clip],['vae','Video VAE',studio.catalog.vae],['audio','Audio VAE',studio.catalog.vae]])studio.field(advanced,label,c.models,key,{options});
studio.sampleFields(advanced,e);
studio.field(advanced,'Anteprima durante il calcolo',c.preview,'enabled',{type:'checkbox'});
advanced.append(el('p','Provini alla dimensione scelta, poi sviluppo del provino approvato alla dimensione originale. Eventuali bordi per allineare H3 a 32 px vengono rimossi. Il sorgente può richiedere molta VRAM alla sua risoluzione originale.'));
const trials=studio.section(body,'Provini edit · velocità e varianti');
studio.field(trials,'Velocità dei provini',e,'trial_mode',{options:[['fast','Rapido · Turbo Ref2Video · 4 passaggi'],['same','Personalizzato · parametri avanzati']],hint:'Il rapido usa la LoRA Turbo Ref2Video installata. Il campionamento alla dimensione originale si sceglie nella scheda del provino.'});
studio.field(trials,'Dimensione dei provini · lato lungo',e,'trial_edge',{options:[[256,'256 px · più leggero'],[384,'384 px'],[512,'512 px']],change:()=>studio.render()});
studio.field(trials,'Anteprima durante il calcolo dei provini',e,'trial_live_preview',{type:'checkbox',hint:'Disattivata per velocizzare i provini. Il video completo del provino compare appena pronto.'});
studio.field(trials,'Confronta',c,'exploration',{options:[['seeds','Seed diversi · stesso prompt'],['prompts','Prompt diversi · stesso seed'],['loras','LoRA diverse · stesso prompt e seed']],change:()=>studio.render()});
if(c.exploration==='seeds')studio.field(trials,'Numero di provini',c,'count',{type:'number',min:1,max:10,step:1});
else{
c.variants.forEach((variant,i)=>{const row=studio.section(trials,`${c.exploration==='loras'?'Variante LoRA':'Variante prompt'} ${i+1}`);studio.field(row,'Trigger o istruzioni aggiuntive · facoltative',variant,'text',{multi:true,rows:3});
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('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);
body.append(el('p','I provini sono senza audio. Il video completo conserva l’audio originale. La maschera limita la ricomposizione: nero originale, bianco generato.'));
}
export function renderEditTrials(studio,body){
if(studio.pending||studio.previewData)studio.live(body);
const items=studio.data.edit_trials||[];
if(!items.length){body.append(el('p','Qui appariranno i provini edit a 512 px.'));return;}
const selected=items.find(x=>x.id===studio.editTrialId)||items.at(-1);studio.editTrialId=selected.id;
const versions=studio.section(body,'Provini edit disponibili');
for(const [i,item]of items.entries()){
const choose=button(`Provino ${i+1} · ${item.config?.exploration==='loras'?loraLabel(item):'seed '+item.seed}`,()=>{studio.editTrialId=item.id;studio.editComparePosition=null;studio.render();});
if(item.id===selected.id)choose.className='is-active';versions.append(choose);
}
const card=studio.section(body,`${selected.label} · ${selected.width} × ${selected.height}`);
card.append(el('p',`Seed ${selected.seed} · selezione ${clock(selected.selection.start)} → ${clock(selected.selection.end)}`));
const stack=selected.loras||selected.config.common_loras||[];
if(stack.length){const l=el('details');l.append(el('summary',`LoRA del provino · ${stack.length}`));for(const entry of stack)l.append(el('small',`${entry.name} · peso ${entry.strength??1}${entry.enabled===false?' · disattivata':''}`));card.append(l);}
if(selected.sampling)card.append(el('small',`${selected.sampling.steps} passaggi${selected.sampling.turbo?' · Turbo':''}${selected.elapsed_seconds!=null?' · '+selected.elapsed_seconds.toFixed(1)+' s':''}${selected.prepared_reused?' · preparazione riutilizzata':''}`));
if(selected.timing_repaired)card.append(el('p','Fotogrammi recuperati dai latenti salvati. Il confronto usa l’anteprima ricostruita da quei dati, senza una nuova generazione.'));
else if(selected.timing_version!==2)card.append(el('small',selected.latent?'Questo provino precedente verrà recuperato dai latenti salvati per correggere la fluidità, senza rilanciare MiniMax.':'Questo vecchio provino non contiene i dati necessari al recupero dei fotogrammi: genera un nuovo provino.'));
const mask=selected.config.edit.mask;if(mask?.enabled)card.append(el('small',`Maschera del provino: ${mask.source} · generazione: espansione ${mask.grow} px · feather ${mask.feather} px`));
const prompt=el('details');prompt.append(el('summary','Prompt del provino'),el('pre',selected.prompt));card.append(prompt);
studio.editCompare=new EditComparison(studio,card,selected);
card.append(el('p',`Scelto per il finale: ${selected.label} · seed ${selected.seed}`));
studio.config.edit.final_mode ||= 'match';studio.config.edit.final_steps ??= 20;
studio.config.edit.final_method ||= 'preserve';studio.config.edit.final_denoise ??= .25;
studio.field(card,'Come preparare il finale',studio.config.edit,'final_method',{options:[['preserve','Mantieni edit · ingrandisci senza rigenerare'],['guided','Sviluppa con H3 · può cambiare l’edit'],['regenerate','Nuova generazione dal sorgente']],change:()=>studio.render(),hint:'Mantieni edit è predefinito: ingrandisce il risultato approvato senza sintetizzare nuovo dettaglio. Fuori dalla maschera usa il sorgente originale. Sviluppa con H3 parte dal provino, ma può aggiungere artefatti o cambiarne l’aspetto.'});
const actual=selected.sampling||selected.config.edit;
if(mask?.enabled){
studio.config.edit.mask??={};compositeControls(studio,card,studio.config.edit.mask);
card.append(el('small','Il video maschera resta quello salvato nel provino. Queste regolazioni del montaggio si possono cambiare anche scegliendo Mantieni edit, senza una nuova generazione.'));
}
if(studio.config.edit.final_method!=='preserve'){
studio.config.edit.final_resolution ||= 'native';
studio.field(card,'Risoluzione di calcolo H3',studio.config.edit,'final_resolution',{options:[['native','Nativa H3 · fino a 768 px sul lato corto'],['trial',`Come il provino · ${selected.width} × ${selected.height}`],['source','Dimensione originale · sperimentale']],hint:'L’export resta alla dimensione originale. Il calcolo nativo resta nel regime documentato di H3; la zona generata viene adattata al sorgente sotto la maschera. Il calcolo diretto alla dimensione originale può ignorare la modifica.'});
studio.field(card,'Campionamento del passaggio H3',studio.config.edit,'final_mode',{options:[['match',`Come il provino scelto · ${actual.turbo?'Turbo':'standard'} · ${actual.steps} passaggi`],['turbo','Turbo Ref2Video · 4 passaggi'],['standard','Standard · senza Turbo']],change:()=>studio.render(),hint:'Questa scelta vale anche per un provino già generato. Prompt, seed, sorgente, reference e maschera restano quelli del provino scelto.'});
if(studio.config.edit.final_mode==='standard')studio.field(card,'Passaggi del finale standard',studio.config.edit,'final_steps',{type:'number',min:1,max:100,step:1});
if(studio.config.edit.final_method==='guided'){
studio.field(card,'Intensità del raffinamento',studio.config.edit,'final_denoise',{type:'number',min:.01,max:1,step:.05,hint:'0,25 predefinito. Valori più alti danno più libertà al modello e possono cambiare il risultato.'});
card.append(el('small',selected.latent?'Lo sviluppo trasferisce i latenti salvati del provino e mantiene la maschera.':'Questo provino precedente non ha latenti salvati: lo sviluppo codifica il suo video approvato. Il contenuto può cambiare durante il raffinamento.'));
}
}
studio.field(card,'Cartella del finale · vuoto = cartella output Comfy',studio.config.edit,'final_output_dir',{hint:'Il finale viene copiato qui anche quando scegli un provino mentre gli altri sono ancora in calcolo.'});
card.append(button('Scegli cartella del finale…',()=>studio.browseFolders('edit')));
const finish=button('Prepara questo provino alla dimensione originale',()=>studio.useEditTrial(selected.id));finish.className='dg-primary';finish.dataset.editFinish=selected.id;card.append(finish);
card.append(el('small','Puoi scegliere appena un provino è pronto: il calcolo del provino in corso viene fermato al prossimo punto di controllo e gli altri vengono saltati. Prompt, seed e maschera restano quelli salvati nel provino.'));
}
export function renderEditResults(studio,body){
if(studio.pending||studio.previewData)studio.live(body);
if(studio.pending)body.append(el('p','Nuova lavorazione ancora in corso. I video salvati qui sotto appartengono alle lavorazioni precedenti.'));
const items=studio.data.edits||[];
if(!items.length){body.append(el('p','Qui apparirà il video completo con il tratto già sostituito.'));return;}
const selected=items.find(x=>x.id===studio.editResultId)||items.at(-1);studio.editResultId=selected.id;
const controls=studio.section(body,studio.pending?'Risultati precedenti salvati':'Risultati disponibili');
for(const [i,item]of items.entries()){
const origin=items[i].origin_trial_label||studio.data.edit_trials?.find(x=>x.id===item.trial)?.label||'provino precedente';
const choose=button(`Versione ${i+1} · da ${origin} · seed ${item.seed}`,()=>{studio.editResultId=item.id;studio.render();});
if(item.id===selected.id)choose.className='is-active';controls.append(choose);
}
const card=studio.section(body,'Video completo salvato · confronto sincronizzato');
const origin=selected.origin_trial_label||studio.data.edit_trials?.find(x=>x.id===selected.trial)?.label||'provino precedente';
card.append(el('p',`Risultato da ${origin} · seed ${selected.seed}${selected.sampling?' · '+(selected.sampling.turbo?'Turbo':'standard')+' · '+selected.sampling.steps+' passaggi':''}`));
if(selected.mask_report?.enabled)card.append(el('small','Maschera video verificata nel campionamento e applicata alla ricomposizione.'));
if(selected.development)card.append(el('p',selected.development.method==='preserve'?'Edit del provino mantenuto · ingrandimento senza nuova generazione':selected.development.method==='guided'?'Sviluppato dal provino approvato':'Nuova generazione dal sorgente'));
if(selected.export_encoding==='lossless_h264')card.append(el('p','Export senza perdita · fotogrammi originali e campioni colore protetti conservati. Il file può essere più grande.'));
else card.append(el('p','Export precedente: non include le correzioni della fluidità e del salvataggio senza perdita. Prepara nuovamente il finale dal provino.'));
if(selected.composite_mask_settings){const m=selected.composite_mask_settings;card.append(el('small',`Montaggio: ${m.composite_mode==='generation'?'area H3':'maschera separata'} · espansione ${m.grow} px · feather ${m.feather} px`));}
if(selected.generation_width)card.append(el('small',`Calcolo H3: ${selected.generation_width} × ${selected.generation_height} · export: ${selected.width} × ${selected.height}`));
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{
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')));
const link=el('a','Scarica video completo');link.href=viewURL(selected.video);link.download=selected.video.filename;card.append(link);
card.append(button('Torna alla selezione',()=>{studio.page='prepare';studio.render();}));
if(selected.config?.edit?.mask?.enabled)studio.restitchPanel=new RestitchPanel(studio,body,selected);
}
class EditTimeline{
constructor(studio,root,config){
this.studio=studio;this.root=root;this.config=config;this.closed=false;this.ready=false;this.error='';this.inputError='';this.abort=new AbortController();
root.append(el('p','Lettura dei fotogrammi e preparazione della timeline…'));
this.init().catch(e=>{if(!this.closed&&e.name!=='AbortError'){this.error=e.message;root.replaceChildren(el('p',e.message),button('Ricarica timeline',()=>{this.destroy();root.replaceChildren();studio.editTimeline=new EditTimeline(studio,root,config);}));studio.editMeta=null;}});
}
destroy(){if(this.video)this.studio.editPosition={source:this.config.source,time:this.video.currentTime,viewStart:this.viewStart,span:this.span};this.closed=true;this.ready=false;this.abort.abort();clearTimeout(this.thumbTimer);cancelAnimationFrame(this.seekRAF);cancelAnimationFrame(this.playRAF);this.preview?.destroy();}
async init(){
const studio=this.studio,config=this.config;
const base=await this.studio.request('/dogma/h3/v2/edit/metadata?'+new URLSearchParams({name:this.config.source}),{signal:this.abort.signal});
const others=this.studio.config.refs.filter(r=>r.kind==='video'&&r.name);
const refs=others.length?await Promise.all(others.map(r=>this.studio.request('/dogma/h3/v2/edit/metadata?'+new URLSearchParams({name:r.name}),{signal:this.abort.signal}))):[];
if(refs.some(r=>r.duration<2||r.duration>15))throw new Error('Ogni video reference aggiuntivo deve durare da 2 a 15 secondi.');
const budget=15-refs.reduce((sum,r)=>sum+r.duration,0),minimum=Math.max(5,...refs.map(r=>Math.floor((r.duration*24-5)/17)*17+5));
const m={...base,generation_frames:Array.from({length:21},(_,i)=>5+17*i).filter(n=>n>=minimum&&n/24<=budget+1e-8)};
if(this.closed)return;
if(!m.generation_frames.length)throw new Error('Le reference video non lasciano spazio per la selezione: accorciale o rimuovile.');
this.meta=m;this.studio.editMeta={...base,source:this.config.source};
Object.assign(this.config,snapSelection(this.config.start,this.config.end,m));this.studio.save();
this.viewStart=0;this.span=Math.min(m.duration,Math.max(20,this.config.end-this.config.start+6));
this.viewStart=clamp(this.config.start-2,0,m.duration-this.span);
const saved=studio.editPosition?.source===this.config.source?studio.editPosition:null;
if(saved){this.span=Math.min(m.duration,saved.span);this.viewStart=clamp(saved.viewStart,0,m.duration-this.span);}
this.root.replaceChildren();
this.preview=mediaPreview(inputFile(this.config.source),{className:'dg-edit-player'});
const v=this.video=this.preview.media;this.root.append(this.preview.root);
v.loop=studio.editLoop!==false;
this.info=el('p');this.root.append(this.info);
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('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('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;
overview.setAttribute('aria-label','Posizione nel video completo');
overview.oninput=()=>{const t=Number(overview.value);this.viewStart=clamp(t-this.span/2,0,m.duration-this.span);this.seek(t);this.paint(true);};
overviewLabel.append(overview);this.root.append(overviewLabel);
const track=this.track=el('div','','dg-edit-track');track.tabIndex=0;track.setAttribute('aria-label','Timeline della selezione');
this.thumbs=el('div','','dg-edit-thumbs');track.append(this.thumbs);
this.band=el('div','','dg-edit-selection');this.band.title='Trascina per spostare tutto l’intervallo';track.append(this.band);
this.left=button('❮',()=>{});this.right=button('❯',()=>{});
for(const [h,side,label]of[[this.left,'start','Inizio selezione'],[this.right,'end','Fine selezione']]){
h.className='dg-edit-handle';h.setAttribute('role','slider');h.setAttribute('aria-label',label);h.title=label;
h.addEventListener('pointerdown',e=>this.drag(e,side),{signal:this.abort.signal});
h.addEventListener('keydown',event=>{if(!['ArrowLeft','ArrowRight'].includes(event.key))return;event.preventDefault();
const delta=event.key==='ArrowLeft'?-1:1;this.select(side==='start'?stepTime(m,this.config.start,delta):this.config.start,side==='end'?frameTime(m,clamp(frameIndex(m,this.config.end)+delta,1,frameCount(m))):this.config.end,side);});
track.append(h);
}
this.band.addEventListener('pointerdown',e=>this.drag(e,'move'),{signal:this.abort.signal});
track.addEventListener('pointerdown',event=>{if(event.target===track||this.thumbs.contains(event.target))this.seek(this.timeAt(event.clientX));},{signal:this.abort.signal});
this.playhead=el('div','','dg-edit-playhead');track.append(this.playhead);this.root.append(track);
this.scale=el('div','','dg-edit-scale');this.root.append(this.scale);
const times=el('div','','dg-grid');this.root.append(times);
const owner=this;
this.startInput=studioField('Da · secondi','start');this.endInput=studioField('A · secondi','end');
function studioField(label,key){
const wrap=el('label',label,'dg-field'),i=el('input');i.type='text';i.inputMode='decimal';wrap.append(i);times.append(wrap);
i.oninput=()=>{owner.inputsDirty=true;i.setCustomValidity('');owner.studio.editInputDraft={source:owner.config.source,start:owner.startInput.value,end:owner.endInput.value};};
i.onchange=()=>owner.commitInputs();
i.onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();owner.commitInputs();}};
return i;
}
const draft=studio.editInputDraft;if(draft?.source===this.config.source){this.inputsDirty=true;this.startInput.value=draft.start;this.endInput.value=draft.end;}
this.inputMessage=el('small');this.inputMessage.setAttribute('role','status');this.root.append(this.inputMessage);
this.root.append(el('small',`Trascina i bordi o inserisci i secondi, anche con la virgola. Selezione libera fino a ${(m.generation_frames.at(-1)/24).toFixed(2)} s, con precisione di un fotogramma e senza minimo di 4 secondi. H3 calcola i fotogrammi aggiuntivi necessari; nell’export viene sostituito solo l’intervallo scelto.`));
v.addEventListener('timeupdate',()=>{
if(this.playingSelection&&(v.currentTime>=this.config.end||v.currentTime<this.config.start)){if(studio.editLoop!==false){v.currentTime=this.config.start;v.play().catch(()=>{});}else{v.pause();this.playingSelection=false;}}
this.playhead.style.left=clamp((v.currentTime-this.viewStart)/this.span*100,0,100)+'%';this.overview.value=v.currentTime;
},{signal:this.abort.signal});
v.addEventListener('play',()=>{
setAction(this.playButton,'Pausa selezione');
const tick=()=>{if(this.closed||v.paused)return;if(this.playingSelection&&v.currentTime>=this.config.end){if(studio.editLoop!==false){v.currentTime=this.config.start;}else{this.seek(stepTime(m,this.config.end,-1));return;}}this.playRAF=requestAnimationFrame(tick);};
cancelAnimationFrame(this.playRAF);tick();
},{signal:this.abort.signal});
v.addEventListener('pause',()=>{cancelAnimationFrame(this.playRAF);setAction(this.playButton,'Riproduci selezione');},{signal:this.abort.signal});
v.addEventListener('loadedmetadata',()=>this.seek(saved?.time??this.config.start),{once:true,signal:this.abort.signal});
this.paint(true);this.ready=true;
}
zoom(factor){const center=(this.config.start+this.config.end)/2;this.span=clamp(this.span*factor,Math.min(.25,this.meta.duration),this.meta.duration);this.viewStart=clamp(center-this.span/2,0,this.meta.duration-this.span);this.paint(true);}
timeAt(x){const r=this.track.getBoundingClientRect();return clamp(this.viewStart+(x-r.left)/r.width*this.span,0,this.meta.duration);}
seek(t){this.seekTarget=frameTime(this.meta,Math.min(frameCount(this.meta)-1,frameIndex(this.meta,t)));if(this.seekRAF)return;
this.seekRAF=requestAnimationFrame(()=>{this.seekRAF=null;if(this.closed)return;this.video.pause();this.playingSelection=false;this.video.currentTime=this.seekTarget;});}
commitInputs(){
if(!this.ready){this.inputError=this.error||'Attendi la preparazione della timeline.';return false;}
let value;
try{value=parseSelection(this.inputsDirty?this.startInput.value:String(this.config.start),this.inputsDirty?this.endInput.value:String(this.config.end),this.meta);}
catch(e){this.inputError=e.message;this.inputMessage.textContent=e.message;this.inputsDirty=true;return false;}
this.inputError='';this.inputMessage.textContent='';
if(!this.inputsDirty)return true;
Object.assign(this.config,value);this.studio.editInputDraft=null;this.inputsDirty=false;this.studio.save();this.paint(false);this.seek(this.config.start);return true;
}
select(a,b,side){
const value=snapSelection(a,b,this.meta,side);
if(value.end-value.start>this.meta.generation_frames.at(-1)/24+1e-8){this.inputMessage.textContent='La selezione supera la durata disponibile con queste reference.';return;}
Object.assign(this.config,value);this.studio.editInputDraft=null;this.inputsDirty=false;this.inputError='';this.inputMessage.textContent='';this.studio.save();this.paint(false);this.seek(side==='start'?this.config.start:stepTime(this.meta,this.config.end,-1));
}
drag(event,side){event.preventDefault();event.stopPropagation();const target=event.currentTarget;target.setPointerCapture(event.pointerId);
const initial={a:this.config.start,b:this.config.end,t:this.timeAt(event.clientX)};
const move=ev=>{if(side==='move'){const delta=clamp(this.timeAt(ev.clientX)-initial.t,-initial.a,this.meta.duration-initial.b);this.select(initial.a+delta,initial.b+delta,'end');}
else this.select(side==='start'?this.timeAt(ev.clientX):this.config.start,side==='end'?this.timeAt(ev.clientX):this.config.end,side);};
const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);};
target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end);
}
paint(thumbnails){
const c=this.config,m=this.meta,pct=t=>clamp((t-this.viewStart)/this.span*100,0,100);
const a=pct(c.start),b=pct(c.end);this.band.style.left=a+'%';this.band.style.width=Math.max(0,b-a)+'%';
this.left.style.left=a+'%';this.right.style.left=b+'%';
this.left.hidden=c.start<this.viewStart||c.start>this.viewStart+this.span;this.right.hidden=c.end<this.viewStart||c.end>this.viewStart+this.span;
for(const [h,t]of[[this.left,c.start],[this.right,c.end]]){h.setAttribute('aria-valuenow',t.toFixed(4));h.setAttribute('aria-valuemin','0');h.setAttribute('aria-valuemax',m.duration);h.setAttribute('aria-valuetext',clock(t));}
if(!this.inputsDirty){this.startInput.value=c.start.toFixed(6);this.endInput.value=c.end.toFixed(6);}
this.info.textContent=`${m.width} × ${m.height} · ${m.fps.toFixed(3)} fps · ${clock(c.start)} → ${clock(c.end)} · selezione ${(c.end-c.start).toFixed(3)} s`;
this.scale.replaceChildren(el('span',clock(this.viewStart)),el('span',clock(this.viewStart+this.span)));
this.overview.value=this.video.currentTime;
if(thumbnails){clearTimeout(this.thumbTimer);this.thumbTimer=setTimeout(()=>{if(this.closed)return;this.thumbs.replaceChildren();for(let i=0;i<8;i++){
const t=Math.min(m.duration-1/m.fps,this.viewStart+(i+.5)*this.span/8),img=el('img');img.alt=clock(t);img.loading='lazy';img.draggable=false;
img.src=url('/dogma/h3/v2/edit/thumbnail',{name:c.source,time:t});this.thumbs.append(img);
}},180);}
}
}