From 0d3e744a6fe8b2fd796ddcd593c7e281d41e4dea Mon Sep 17 00:00:00 2001 From: Darksidewalker <> Date: Fri, 16 Oct 2026 10:27:16 +0200 Subject: [PATCH] Run both Continuity output modes through Segment Prepare/Decode (0.5.24) --- .github/scripts/test_h3_clips_director.mjs | 2 +- .github/scripts/test_h3_segment_backend.py | 10 +++++++ .github/scripts/test_h3_segment_decode.py | 33 ++++++++++++++++++++++ docs/h3_continuity.md | 6 ++-- docs/news_and_changelog.md | 3 ++ js/minimax_h3_director.js | 3 +- nodes/h3_continuity/segment_nodes.py | 14 +++++++-- nodes/h3_continuity/validation.py | 16 +++++++---- pyproject.toml | 2 +- 9 files changed, 74 insertions(+), 15 deletions(-) diff --git a/.github/scripts/test_h3_clips_director.mjs b/.github/scripts/test_h3_clips_director.mjs index a0d4caa..d371bc4 100644 --- a/.github/scripts/test_h3_clips_director.mjs +++ b/.github/scripts/test_h3_clips_director.mjs @@ -1,7 +1,7 @@ import test from 'node:test';import assert from 'node:assert/strict';import {readFileSync}from 'node:fs'; const source=readFileSync(new URL('../../js/minimax_h3_director.js',import.meta.url),'utf8'); test('Director exposes only contextual Join clips and Advanced output policy hooks',()=>{ - assert.match(source,/output_mode === "segment" \? types\.has\("DaSiWaH3ContinuitySegmentPrepare"\)/); + assert.match(source,/output_mode === "segment" \? segmentNodes : segmentNodes \|\| types\.has\("DaSiWaH3ContinuityAppend"\)/); assert.match(source,/button\("Join clips…"/);assert.match(source,/Output /);assert.match(source,/New segment only/); assert.match(source,/closeClipAssembly\?\.\(\)/);assert.match(source,/dataWidget\.serializeValue\s*=/);assert.match(source,/validateSegmentGraph/); }); diff --git a/.github/scripts/test_h3_segment_backend.py b/.github/scripts/test_h3_segment_backend.py index d8cde4c..a79dee8 100644 --- a/.github/scripts/test_h3_segment_backend.py +++ b/.github/scripts/test_h3_segment_backend.py @@ -166,6 +166,16 @@ def test_segment_graph_requires_prepare_decode_context(): else: bad['decode']['inputs'][change] = ['guide',2 if change == 'context' else 1] with pytest.raises(ValueError): validation.validate_capture_graph(bad,'guide','segment') with pytest.raises(ValueError,match='Segment Prepare'): validation.validate_capture_graph(graph(),'guide','segment') + post = copy.deepcopy(p) # spatial post-processing between decode and exporter keeps 24 fps segments + post['wm'] = {'class_type':'DaSiWa_Watermark','inputs':{'images':['decode',0]}} + post['export']['inputs']['images'] = ['wm',0] + validation.validate_capture_graph(post,'guide','segment') + post['wm']['inputs']['images'] = ['other',0] + with pytest.raises(ValueError): validation.validate_capture_graph(post,'guide','segment') + full = copy.deepcopy(p) + validation.validate_capture_graph(full,'guide','full') # one wiring serves both output modes + full['publish']['inputs']['ticket'] = ['append',1] + with pytest.raises(ValueError): validation.validate_capture_graph(full,'guide','full') def test_segment_upscale_plan_local_prefix_and_audio(tmp_path,monkeypatch): mod = importlib.import_module('h3_segment_test.nodes.h3_continuity.segment_nodes') diff --git a/.github/scripts/test_h3_segment_decode.py b/.github/scripts/test_h3_segment_decode.py index 0093f36..f85c15a 100644 --- a/.github/scripts/test_h3_segment_decode.py +++ b/.github/scripts/test_h3_segment_decode.py @@ -97,3 +97,36 @@ def test_audio_window_matches_global_decode_crop(): local = model.decode(z[...,p['audio_start']:]) start = round(source*32000/24) assert torch.allclose(full[...,start:],local[...,start-p['audio_start']*800:],atol=1e-7,rtol=1e-5) + +def test_full_mode_prepare_is_append_and_stage(tmp_path,monkeypatch): + from test_h3_segment_backend import root, context, latent + mod = importlib.import_module('h3_segment_test.nodes.h3_continuity.segment_nodes') + legacy = importlib.import_module('h3_segment_test.nodes.h3_continuity.nodes') + store = core.ClipStore(tmp_path) + root(store) + monkeypatch.setattr(mod,'ClipStore',lambda:store) + monkeypatch.setattr(legacy,'ClipStore',lambda:store) + ctx = {**context(),'output_mode':'full','operation':'continue','overlap_frames':22,'extension_frames':119} + _,sampled,layout = core.prepare_continuation(latent(124),store.inspect('s','root'),dict(mode='REF2VA',width=64,height=64),core.parse_settings({**ctx,'version':2,'continuation_prompt':'new'})) + ctx['layout'] = layout + for tensor in sampled['samples'].tensors: tensor.fill_(5.) + expected,_ = legacy.DaSiWaH3ContinuityAppend().commit(sampled,ctx) + render,out_ctx,ticket = mod.DaSiWaH3ContinuitySegmentPrepare().prepare(sampled,ctx) + assert out_ctx is ctx and 'render_kind' not in out_ctx + for a,b in zip(render['samples'].tensors,expected['samples'].tensors): assert torch.equal(a,b) + assert store.inspect('s',ticket['clip_id'],ready=False).get('schema') != 2 + +def test_full_mode_decode_matches_core_decoders(monkeypatch): + import nodes as comfy_nodes + from test_h3_segment_backend import latent + from comfy_extras.nodes_audio import vae_decode_audio + mod = importlib.import_module('h3_segment_test.nodes.h3_continuity.segment_nodes') + monkeypatch.setattr(mod,'validate_segment_vaes',lambda *a: pytest.fail('Full mode must not require segment VAE checks')) + take = latent(39) + torch.manual_seed(0) + vae = SimpleNamespace(decode=lambda z: torch.rand(1,39,8,8,3,generator=torch.Generator().manual_seed(1))) + avae = SimpleNamespace(decode=lambda z: torch.rand(1,z.shape[-1]*800,2,generator=torch.Generator().manual_seed(2))*4,audio_sample_rate=32000) + images,audio = mod.DaSiWaH3ContinuitySegmentDecode().decode(take,vae,avae,{'output_mode':'full','operation':'new'}) + assert torch.equal(images,comfy_nodes.VAEDecode().decode(vae,take)[0]) + core_audio = vae_decode_audio(avae,take) + assert torch.equal(audio['waveform'],core_audio['waveform']) and audio['sample_rate'] == core_audio['sample_rate'] diff --git a/docs/h3_continuity.md b/docs/h3_continuity.md index aea7fd2..c854f6c 100644 --- a/docs/h3_continuity.md +++ b/docs/h3_continuity.md @@ -47,8 +47,8 @@ flowchart LR | Goal | Use | Cost per run | | --- | --- | --- | -| One growing video, the simplest setup (default) | **Output: Full timeline** with Append & Stage | Decode/upscale/export reprocess the whole timeline, which grows with every continuation | -| Long chains on limited VRAM/RAM, choosing takes later | **Output: New segment only** with Segment Prepare/Decode, then **Join clips → Continuity segments** | Only the new section plus a small context is decoded and exported; the join loads no model | +| One growing video, the simplest setup (default) | **Output: Full timeline** with Append & Stage, or with the Segment Prepare/Decode wiring | Decode/upscale/export reprocess the whole timeline, which grows with every continuation | +| Long chains on limited VRAM/RAM, choosing takes later | **Output: New segment only** with Segment Prepare/Decode (the same wiring also runs Full timeline), then **Join clips → Continuity segments** | Only the new section plus a small context is decoded and exported; the join loads no model | | Combine separate videos with plain cuts | **Join clips → Video files** | No model; re-encode only | | Combine separate videos with generated bridges | **Join clips → Video files → H3 transitions** | One short H3 sampling run per cut, then a model-free join | @@ -171,7 +171,7 @@ No model monkeypatch is installed. Native tail/window/append code remains the MI ### Continuity output: full timeline or new segment -**Full timeline** remains the default. Existing workflows and schema-1 checkpoints keep their original behavior. For segment exports, choose **Advanced → Output → New segment only** and use this separate post-sampler path: +**Full timeline** remains the default. Existing workflows and schema-1 checkpoints keep their original behavior. For segment exports, choose **Advanced → Output → New segment only** and use this post-sampler path. The same wiring also runs **Full timeline**: there Segment Prepare behaves exactly like Append & Stage and Segment Decode like the core VAE Decode + VAE Decode Audio, so one workflow supports both modes and you only switch the Director setting: ```mermaid flowchart LR diff --git a/docs/news_and_changelog.md b/docs/news_and_changelog.md index b2ed8dd..12c75e8 100644 --- a/docs/news_and_changelog.md +++ b/docs/news_and_changelog.md @@ -6,6 +6,8 @@ This changelog covers **2026-07-05 → 2026-10-15**. Older history lives in the ## News +- **One wiring for both Continuity modes (0.5.24):** Segment Prepare and Segment Decode now also run **Full timeline** (identical to Append & Stage + core VAE decoding), so a workflow wired once can switch between Full timeline and New segment only in the Director. Segment exports may now pass through spatial post-processing (upscale, resize, watermark) before the exporter. [Usage →](h3_continuity.md#which-option-to-use) + - **H3 transitions in Join Clips (0.5.23):** The Join clips dialog can render a short H3 bridge for every cut between uploaded videos. It reuses the Director's Guide, model and sampler: the clip edges become native H3 video+audio guides, only the generated middle is inserted, then the usual model-free join runs. New nodes MiniMaxH3 • Transition Guide/Render work in API mode too. Overlay fields now have labels, source/selected durations and Remove. [Usage →](h3_continuity.md#join-exported-clips) - **H3 segments and Join Clips (0.5.22, Gitea testing):** Opt-in segment-only Continuity uses immutable AV deltas and bounded source-tail rendering, with separate Segment Prepare/Decode nodes. The Director adds one contextual Join clips dialog for Continuity chains or uploaded videos; assembly is a model-free H.264/AAC re-encode with explicit preflight confirmation. Full-timeline workflows remain the default. CPU Core/codec contracts and real isolated classic/Nodes 2.0 queue tests are covered; pretrained seam quality and peak-VRAM savings remain live-test gates. AI repair of independent clip transitions is not released. [Usage and limits →](h3_continuity.md#continuity-output-full-timeline-or-new-segment) @@ -170,6 +172,7 @@ Quick reference for the version bumps inside this window, newest first: | Version | Date | Headline | |---|---|---| +| 0.5.24 | 10-16 | Segment Prepare/Decode run both output modes; segment exports allow spatial post-processing | | 0.5.23 | 10-16 | H3 transition bridges in Join Clips; labelled overlay fields, durations, Remove; PyAV 19 H.264 metadata fix | | 0.5.22 | 10-15 | Gitea testing: opt-in H3 delta segments, bounded decode and model-free Join Clips; AI transition repair gated | | 0.5.21 | 10-15 | RefMod export fix: read-write fsync descriptor so bundle saving works on Windows | diff --git a/js/minimax_h3_director.js b/js/minimax_h3_director.js index 9f53e2a..4907e01 100644 --- a/js/minimax_h3_director.js +++ b/js/minimax_h3_director.js @@ -678,7 +678,8 @@ function install(node) { const types = new Set(), seen = new Set(); const visit = graph => { if (!graph || seen.has(graph)) return; seen.add(graph); for (const item of graph._nodes || []) { types.add(item.type || item.comfyClass); visit(item.subgraph); } }; visit(graph.rootGraph || graph); - return (continuityState().output_mode === "segment" ? types.has("DaSiWaH3ContinuitySegmentPrepare") && types.has("DaSiWaH3ContinuitySegmentDecode") : types.has("DaSiWaH3ContinuityAppend")) && types.has("DaSiWaH3ContinuityPublish"); + const segmentNodes = types.has("DaSiWaH3ContinuitySegmentPrepare") && types.has("DaSiWaH3ContinuitySegmentDecode"); + return (continuityState().output_mode === "segment" ? segmentNodes : segmentNodes || types.has("DaSiWaH3ContinuityAppend")) && types.has("DaSiWaH3ContinuityPublish"); } function continuityGraphError() { setStatus(continuityState().output_mode === "segment" ? "Capture unavailable: wire Segment Prepare → optional Upscale → Segment Decode → Export → Publish." : "Capture unavailable: wire Append & Stage and Publish Export as shown in the continuity workflow.", true); } function selectRealMode(value) { diff --git a/nodes/h3_continuity/segment_nodes.py b/nodes/h3_continuity/segment_nodes.py index 288b844..4871785 100644 --- a/nodes/h3_continuity/segment_nodes.py +++ b/nodes/h3_continuity/segment_nodes.py @@ -14,7 +14,7 @@ class DaSiWaH3ContinuitySegmentPrepare: @classmethod def INPUT_TYPES(cls): return {'required':{'sampled':('LATENT',{'tooltip':'Source-resolution H3 sampler output; hidden sampled overlap is discarded, not rendered.'}), - 'context':('DF_H3_CONTINUITY_CONTEXT',{'tooltip':'This sampler’s Director Guide continuity_context, with New segment only enabled.'})}} + 'context':('DF_H3_CONTINUITY_CONTEXT',{'tooltip':'This sampler’s Director Guide continuity_context. Works in both Director output modes: Full timeline behaves like Append & Stage, New segment only renders the bounded segment.'})}} RETURN_TYPES = ('LATENT','DF_H3_CONTINUITY_CONTEXT','DF_H3_CONTINUITY_TICKET') RETURN_NAMES = ('render_latent','segment_context','ticket') @@ -25,7 +25,10 @@ class DaSiWaH3ContinuitySegmentPrepare: if context.get('disabled'): return sampled,context,{'disabled':True} if context.get('output_mode') != 'segment': - raise ValueError('Segment Prepare requires Director Continuity Advanced → New segment only. Use Append & Stage for full timelines.') + # Full timeline through the same wiring: identical to Append & Stage. + from .nodes import DaSiWaH3ContinuityAppend + cumulative, ticket = DaSiWaH3ContinuityAppend().commit(sampled, context) + return cumulative, context, ticket comfy.model_management.throw_exception_if_processing_interrupted() store = ClipStore() if context['operation'] != 'continue': @@ -107,7 +110,7 @@ class DaSiWaH3ContinuitySegmentDecode: return {'required':{ 'latent':('LATENT',{'tooltip':'Segment Prepare.render_latent, optionally spatially upscaled; do not connect the raw sampler or stored delta.'}), 'vae':('VAE',{'tooltip':'Native MiniMax H3 video VAE. Other temporal layouts are rejected.'}), - 'audio_vae':('VAE',{'tooltip':'Native MiniMax H3 32 kHz audio VAE. Audio is cropped at cumulative sample boundaries without whole-clip loudness normalization.'}), + 'audio_vae':('VAE',{'tooltip':'Native MiniMax H3 32 kHz audio VAE. In New segment only, audio is cropped at cumulative sample boundaries without whole-clip loudness normalization; Full timeline decodes exactly like VAE Decode Audio.'}), 'context':('DF_H3_CONTINUITY_CONTEXT',{'tooltip':'The matching Segment Prepare.segment_context; retain it unchanged through optional Upscale.'})}} RETURN_TYPES = ('IMAGE','AUDIO') @@ -116,6 +119,11 @@ class DaSiWaH3ContinuitySegmentDecode: CATEGORY = 'DaSiWa/MiniMax H3' def decode(self, latent, vae, audio_vae, context): + if not context.get('disabled') and context.get('render_kind') != 'segment': + # Full timeline: exactly the core VAE Decode / VAE Decode Audio results. + import nodes as comfy_nodes + from comfy_extras.nodes_audio import vae_decode_audio + return comfy_nodes.VAEDecode().decode(vae, latent)[0], vae_decode_audio(audio_vae, latent) video,audio = latent['samples'].tensors if context.get('disabled'): _,_,frames = validate_h3_av_latent(latent,name='uncaptured first take') diff --git a/nodes/h3_continuity/validation.py b/nodes/h3_continuity/validation.py index 43512c9..ab1fd22 100644 --- a/nodes/h3_continuity/validation.py +++ b/nodes/h3_continuity/validation.py @@ -10,13 +10,15 @@ def validate_capture_graph(prompt, guide_id, output_mode="full"): return isinstance(value, (list, tuple)) and len(value) == 2 and str(value[0]) == str(node_id) and value[1] == slot segment = output_mode == "segment" - writer = "DaSiWaH3ContinuitySegmentPrepare" if segment else "DaSiWaH3ContinuityAppend" + # Segment Prepare also runs full timelines, so either writer is valid in full mode. + writers = {"DaSiWaH3ContinuitySegmentPrepare"} if segment else {"DaSiWaH3ContinuityAppend", "DaSiWaH3ContinuitySegmentPrepare"} appends = [(key, node) for key, node in nodes.items() - if node.get("class_type") == writer + if node.get("class_type") in writers and linked(node.get("inputs", {}).get("context"), guide_id, 2)] if len(appends) != 1: - raise ValueError("Wire this Director Guide's continuity_context to one Segment Prepare → Segment Decode → Export → Publish chain." if segment else "Wire this Director Guide's continuity_context to one Append & Stage node.") + raise ValueError("Wire this Director Guide's continuity_context to one Segment Prepare → Segment Decode → Export → Publish chain." if segment else "Wire this Director Guide's continuity_context to one Append & Stage or Segment Prepare node.") append_id, append = appends[0] + ticket_slot = 2 if append.get("class_type") == "DaSiWaH3ContinuitySegmentPrepare" else 1 sampled = append.get("inputs", {}).get("sampled") if not isinstance(sampled, (list, tuple)) or len(sampled) != 2: raise ValueError("Wire the H3 sampler output to Append & Stage.sampled.") @@ -48,7 +50,7 @@ def validate_capture_graph(prompt, guide_id, output_mode="full"): raise ValueError("Append & Stage must use the sampler driven by this Director Guide.") publishes = [node for node in nodes.values() if node.get("class_type") == "DaSiWaH3ContinuityPublish" - and linked(node.get("inputs", {}).get("ticket"), append_id, 2 if segment else 1)] + and linked(node.get("inputs", {}).get("ticket"), append_id, ticket_slot)] if len(publishes) != 1: raise ValueError("Wire Append & Stage.ticket to one Publish Export node.") filename = publishes[0].get("inputs", {}).get("filename") @@ -91,8 +93,10 @@ def validate_capture_graph(prompt, guide_id, output_mode="full"): if len(decoders) != 1: raise ValueError("Wire Segment Prepare.render_latent and segment_context through optional Upscale to one Segment Decode.") decode_id = decoders[0][0] - if not linked(exporter.get("inputs",{}).get("images"),decode_id,0) or not linked(exporter.get("inputs",{}).get("audio"),decode_id,1): - raise ValueError("Export both images and audio from the same Segment Decode.") + images = exporter.get("inputs",{}).get("images") + # Spatial post-processing may sit between decode and exporter; Publish still checks 24 fps and frame count. + if not (isinstance(images,(list,tuple)) and len(images) == 2 and depends_on(str(images[0]),decode_id)) or not linked(exporter.get("inputs",{}).get("audio"),decode_id,1): + raise ValueError("Export images derived from, and audio directly from, the same Segment Decode.") if not depends_on(exporter_id, append_id): raise ValueError("The selected exporter does not receive this cumulative latent. Check the Publish filename wire.") if exporter.get("class_type") == "DaSiWa_EnhancedVideoCombine": diff --git a/pyproject.toml b/pyproject.toml index a6b0ce1..8a10d05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI-DaSiWa-Nodes" description = "DaSiWa Custom Nodes Collection" -version = "0.5.23" +version = "0.5.24" license = {file = "LICENSE"} dependencies = [ "torch",