diff --git a/.github/scripts/test_h3_segment_decode.py b/.github/scripts/test_h3_segment_decode.py index 49c4590..851754b 100644 --- a/.github/scripts/test_h3_segment_decode.py +++ b/.github/scripts/test_h3_segment_decode.py @@ -48,6 +48,7 @@ def test_segment_decode_crops_owned_frame_and_sample_outputs(source,added): assert out.shape[0] == added and out[0,0,0,0] == source-p['frame_offset'] assert audio['waveform'].shape == (1,2,p['crop_samples']) and audio['sample_rate'] == 32000 assert audio['waveform'][0,0,0] == p['crop_start_sample'] + assert audio['h3_segment_interval'] == {'start':(source,24),'end':(source+added,24)} assert out.untyped_storage().nbytes() == out.numel()*out.element_size() assert audio['waveform'].untyped_storage().nbytes() == audio['waveform'].numel()*audio['waveform'].element_size() diff --git a/.github/scripts/test_h3_segment_opus.py b/.github/scripts/test_h3_segment_opus.py new file mode 100644 index 0000000..275e8a2 --- /dev/null +++ b/.github/scripts/test_h3_segment_opus.py @@ -0,0 +1,145 @@ +"""Real shared-writer regression: global H3 boundaries survive Opus resampling.""" +from test_h3_segment_backend import core, latent, context, root, importlib, torch +from fractions import Fraction +from types import SimpleNamespace +import sys +import av +import numpy as np +import pytest + +helper = importlib.import_module('h3_segment_test.nodes.helper_pyav_video') + + +@pytest.fixture +def combine(monkeypatch): + monkeypatch.setitem(sys.modules, 'server', SimpleNamespace(PromptServer=None)) + return importlib.import_module('h3_segment_test.nodes.nodes_enhanced_video_combine') + + +def audio_value(source, frames): + count = round(Fraction((source+frames)*32000,24))-round(Fraction(source*32000,24)) + wave = torch.arange(count).float().mul_(0.01).sin_().mul_(0.1).expand(1,2,-1).clone() + return dict(waveform=wave, sample_rate=32000, + h3_segment_interval=dict(start=(source,24),end=(source+frames,24))) + + +def encode(path, container, frames, audio, audio_encoder='libopus'): + helper.encode_attempt(str(path),container,'libvpx-vp9' if container == 'WebM' else 'libx264', + 16,16,24,8,35, + lambda: (np.full((16,16,3),i%255,np.uint8) for i in range(frames)), + audio=audio,audio_encoder=audio_encoder) + + +def decoded_audio(path): + with av.open(str(path)) as media: + rate = media.streams.audio[0].codec_context.sample_rate + count = sum(f.samples for f in media.decode(audio=0)) + return rate,count + + +@pytest.mark.parametrize('container',['MKV','WebM']) +@pytest.mark.parametrize('source',[5,124,243]) +@pytest.mark.parametrize('frames',[17,119,187,238]) +def test_real_opus_exact_global_interval(tmp_path,combine,container,source,frames): + value = audio_value(source,frames) + audio,_ = combine._audio_file(value) + assert audio[2] == value['h3_segment_interval'] + path = tmp_path / ('segment.webm' if container == 'WebM' else 'segment.mkv') + encode(path,container,frames,audio) + assert decoded_audio(path) == (48000,frames*2000) + + +@pytest.mark.parametrize('container,frames',[('MKV',187),('WebM',238),('MKV',119)]) +def test_publish_accepts_real_shared_writer(tmp_path,monkeypatch,combine,container,frames): + nodes = importlib.import_module('h3_segment_test.nodes.h3_continuity.nodes') + store = core.ClipStore(tmp_path/'store') + metadata = root(store) + ctx = {**context(),'extension_frames':frames} + ticket = store.stage_delta(latent(frames+22,9.),ctx,metadata) + monkeypatch.setattr(nodes,'ClipStore',lambda:store) + import folder_paths + monkeypatch.setattr(folder_paths,'get_output_directory',lambda:str(tmp_path)) + path = tmp_path / ('segment.webm' if container == 'WebM' else 'segment.mkv') + audio,_ = combine._audio_file(audio_value(124,frames)) + encode(path,container,frames,audio) + nodes.DaSiWaH3ContinuityPublish().publish(str(path),ticket) + assert store.inspect('s',ticket['clip_id'])['export']['audio']['samples'] == frames*2000 + + +@pytest.mark.parametrize('interval',[None,{}, {'start':(0,0),'end':(17,24)}, + {'start':(True,24),'end':(17,24)}, {'start':(17,24),'end':(0,24)}, + {'start':(0.0,24),'end':(17,24)}, {'start':(-1,24),'end':(17,24)}]) +def test_bad_contract_rejected(combine,interval): + value = audio_value(5,17) + value['h3_segment_interval'] = interval + with pytest.raises(ValueError,match='H3 segment'): combine._audio_file(value) + + +@pytest.mark.parametrize('difference',[-1000,1,1000]) +def test_changed_waveform_rejected(tmp_path,combine,difference): + value = audio_value(5,17) + count = value['waveform'].shape[-1]+difference + value['waveform'] = torch.zeros(1,2,count) + with pytest.raises(ValueError,match='source sample interval'): combine._audio_file(value) + with pytest.raises(ValueError,match='source sample interval'): + encode(tmp_path/'bad.mkv','MKV',17,(np.zeros((2,count),np.float32),32000,value['h3_segment_interval'])) + assert not (tmp_path/'bad.mkv').exists() + assert not list(tmp_path.glob('*.attempt-*')) + + +def test_large_resampler_mismatch_rejected(tmp_path,combine,monkeypatch): + original = helper.av.AudioResampler + class BrokenResampler: + def __init__(self,**kwargs): self.real = original(**kwargs) + def resample(self,frame): return [] if frame is None else self.real.resample(frame) + monkeypatch.setattr(helper.av,'AudioResampler',BrokenResampler) + audio,_ = combine._audio_file(audio_value(5,187)) + with pytest.raises(ValueError,match='meaningfully'): + encode(tmp_path/'bad.mkv','MKV',187,audio) + assert not (tmp_path/'bad.mkv').exists() + + +def test_normal_audio_is_not_padded_to_video(tmp_path,combine): + value = dict(waveform=torch.zeros(1,2,16000),sample_rate=32000) + audio,_ = combine._audio_file(value) + assert len(audio) == 2 + encode(tmp_path/'normal.mkv','MKV',119,audio) + assert decoded_audio(tmp_path/'normal.mkv') == (48000,24000) + encode(tmp_path/'pcm.mkv','MKV',119,audio,'pcm_s16le') + assert decoded_audio(tmp_path/'pcm.mkv') == (32000,16000) + assert helper._audio_rate('aac',32000) == 32000 + assert helper._audio_rate('libopus',24000) == 24000 + + +@pytest.mark.parametrize('audio_encoder',['aac','pcm_s16le']) +def test_contract_does_not_change_native_rate_codecs(tmp_path,combine,audio_encoder): + audio,_ = combine._audio_file(audio_value(124,119)) + encode(tmp_path/'contract.mkv','MKV',119,audio,audio_encoder) + encode(tmp_path/'plain.mkv','MKV',119,audio[:2],audio_encoder) + assert decoded_audio(tmp_path/'contract.mkv') == decoded_audio(tmp_path/'plain.mkv') + assert decoded_audio(tmp_path/'contract.mkv')[0] == 32000 + + +def test_contract_rejects_multiple_batches(combine): + value = audio_value(5,17) + value['waveform'] = value['waveform'].expand(2,-1,-1) + with pytest.raises(ValueError,match='source sample interval'): combine._audio_file(value) + + +@pytest.mark.parametrize('frames',[22,39]) +def test_first_take_interval_and_serialized_rationals(tmp_path,combine,frames): + value = audio_value(0,frames) + value['h3_segment_interval'] = {'start':[0,24],'end':[frames,24]} + audio,_ = combine._audio_file(value) + encode(tmp_path/'first.mkv','MKV',frames,audio) + assert decoded_audio(tmp_path/'first.mkv') == (48000,frames*2000) + + +def test_combine_forwards_contract_to_shared_writer(tmp_path,combine,monkeypatch): + audio,_ = combine._audio_file(audio_value(5,17)) + monkeypatch.setattr(combine,'_available_encoders',lambda:{'libx264','libopus'}) + path = tmp_path/'forwarded.mkv' + combine._encode_with_available_encoder(None,'H.264',8,16,16,24, + lambda: (np.zeros((16,16,3),np.uint8) for _ in range(17)), + str(path),'MKV',35,35,None,audio=audio,audio_codec='Opus') + assert decoded_audio(path) == (48000,34000) diff --git a/.github/scripts/test_h3_segment_opus_join.py b/.github/scripts/test_h3_segment_opus_join.py new file mode 100644 index 0000000..9e25402 --- /dev/null +++ b/.github/scripts/test_h3_segment_opus_join.py @@ -0,0 +1,55 @@ +"""Real Opus exports: Publish a chain, then Join Clips in both output modes.""" +from test_h3_segment_opus import audio_value, encode, combine, core, root, context, latent, importlib +from pathlib import Path +import av +import pytest + + +@pytest.mark.parametrize('container', ['MKV', 'WebM']) +@pytest.mark.parametrize('copy_video', [False, True]) +def test_published_opus_chain_joins_sample_exactly(tmp_path, monkeypatch, combine, container, copy_video): + nodes = importlib.import_module('h3_segment_test.nodes.h3_continuity.nodes') + assembly = importlib.import_module('h3_segment_test.nodes.h3_continuity.assembly') + store = core.ClipStore(tmp_path/'store') + metadata = root(store) + suffix = '.webm' if container == 'WebM' else '.mkv' + prefix = tmp_path/('root'+suffix) + audio, _ = combine._audio_file(audio_value(0,124)) + encode(prefix, container, 124, audio) + store.publish(dict(session='s',clip_id='root'),str(prefix)) + monkeypatch.setattr(nodes, 'ClipStore', lambda:store) + import folder_paths + monkeypatch.setattr(folder_paths,'get_output_directory',lambda:str(tmp_path)) + selected = [dict(kind='checkpoint',session='s',clip_id='root')] + for index, frames in enumerate((187,238)): + metadata = store.inspect('s', selected[-1]['clip_id']) + ctx = {**context(), 'source_id':metadata['clip_id'], 'extension_frames':frames} + ticket = store.stage_delta(latent(frames+22,9.),ctx,metadata) + audio, _ = combine._audio_file(audio_value(metadata['frames'],frames)) + path = tmp_path/(f'segment{index}'+suffix) + encode(path,container,frames,audio) + nodes.DaSiWaH3ContinuityPublish().publish(str(path),ticket) + assert store.inspect('s',ticket['clip_id'])['export']['audio']['samples'] == frames*2000 + selected.append(dict(kind='checkpoint',**ticket)) + data = dict(version=1,rows=selected,repair_transitions=False,audio_policy='preserve',copy_video=copy_video) + preflight = assembly.preflight(data,store,input_root=tmp_path,output_root=tmp_path) + assert preflight['ok'], preflight + assert preflight['frames'] == 549 + if copy_video: + assert preflight['mode'] == 'copy', preflight + result = assembly.assemble(data,'joined',store,input_root=tmp_path,output_root=tmp_path,check=lambda:None) + if copy_video and result['mode'] != 'copy': + assert result['mode'] == 'reencode' and any('copy failed' in note for note in result['notes']), result + else: + assert result['mode'] == ('copy' if copy_video else 'reencode'), result + output = Path(result['filename']) + with av.open(str(output)) as media: + assert sum(1 for _ in media.decode(video=0)) == 549 + with av.open(str(output)) as media: + stream = media.streams.audio[0] + assert stream.codec_context.sample_rate == 48000 + assert stream.duration*stream.time_base*48000 == 549*2000 + frames = list(media.decode(audio=0)) + assert frames[0].pts == 0 + assert 549*2000 <= sum(f.samples for f in frames) <= 549*2000+1024 + assert not list(tmp_path.glob('*.partial*')) diff --git a/docs/news_and_changelog.md b/docs/news_and_changelog.md index 68b332a..512fdce 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-16**. Older history lives in the ## News +- **Sample-exact Opus segment export (0.5.28):** Segment Decode carries the exact segment interval into Enhanced Video Combine. Opus resamples H3 audio directly to 48 kHz, and the exporter corrects only the bounded sample-grid rounding remainder before encoding. Publish keeps its strict duration checks; ordinary audio without a segment interval is not stretched to video length. No workflow rewiring is needed. + - **Continuity subgraph validation fix (0.5.27):** New segment only checks ComfyUI's compiled prompt, so valid Segment Prepare/Decode chains inside subgraphs and bypassed upscalers are accepted without a full-timeline fallback. Saving a workflow no longer runs execution validation. Advanced output hints explain full-timeline versus new-section export with the same wiring. - **Join Clips review fixes (Git update within 0.5.26):** Restore H.264 side-data compatibility and sample-exact joined audio duration with PyAV 18 and 19, keep inactive Continuity from blocking ordinary takes, preserve Core decoding and audio normalization for uncaptured Full timeline takes, reject oversized transition joins before rendering, and avoid leftover partial files after copy preflight errors. Transition guides resize edge frames before retaining batches and check host RAM before allocation. No new controls or version bump. @@ -180,6 +182,7 @@ Quick reference for the version bumps inside this window, newest first: | Version | Date | Headline | |---|---|---| +| 0.5.28 | 10-16 | Exact H3 segment boundaries through Opus export; native 48-kHz Opus input and strict Publish checks | | 0.5.27 | 10-16 | Continuity validates compiled subgraphs and bypasses; clearer Advanced output hints | | 0.5.26 | 10-16 | Join Clips: opt-in lossless video copy with automatic re-encode fallback | | 0.5.25 | 10-16 | Enhanced Video Combine: RGBA alpha in Animated WebP and VP9 WebM/MKV; WebP loops forever and honours `quality` | diff --git a/nodes/h3_continuity/segment_nodes.py b/nodes/h3_continuity/segment_nodes.py index e8eb2c7..23d7a1e 100644 --- a/nodes/h3_continuity/segment_nodes.py +++ b/nodes/h3_continuity/segment_nodes.py @@ -148,7 +148,9 @@ class DaSiWaH3ContinuitySegmentDecode: raise ValueError('Audio decoder ended before the global segment boundary.') waveform = torch.nn.functional.pad(waveform,(0,end-waveform.shape[-1])) result = waveform[...,start:end].clone() - return images,{'waveform':result,'sample_rate':32000} + global_start = plan['frame_offset'] + plan['crop_start_frame'] + return images,{'waveform':result,'sample_rate':32000, + 'h3_segment_interval':{'start':(global_start,24),'end':(global_start+plan['crop_frames'],24)}} NODE_CLASS_MAPPINGS = {cls.__name__:cls for cls in (DaSiWaH3ContinuitySegmentPrepare,DaSiWaH3ContinuitySegmentDecode)} diff --git a/nodes/helper_pyav_video.py b/nodes/helper_pyav_video.py index a02c166..1db5aee 100644 --- a/nodes/helper_pyav_video.py +++ b/nodes/helper_pyav_video.py @@ -122,6 +122,8 @@ def _audio_rate(encoder, source_rate): rates = sorted(codec.audio_rates or []) if not rates: return source_rate + if encoder == "libopus" and source_rate == 32000 and 48000 in rates: + return 48000 # Opus decodes at 48 kHz; avoid a lossy 32 -> 24 -> 48 kHz path. return min(rates, key=lambda candidate: abs(candidate - source_rate)) @@ -133,27 +135,78 @@ def _configure_audio_stream(container, encoder, source_rate, channels, bitrate): return stream, target_rate +def segment_sample_interval(interval, sample_rate): + if not isinstance(interval, dict) or set(interval) != {"start", "end"}: + raise ValueError("Invalid H3 segment audio interval.") + bounds = [] + for key in ("start", "end"): + pair = interval[key] + if (not isinstance(pair, (tuple, list)) or len(pair) != 2 + or any(type(value) is not int for value in pair) or pair[0] < 0 or pair[1] <= 0): + raise ValueError("H3 segment audio boundaries must be nonnegative rational times.") + bounds.append(Fraction(*pair)) + start, end = bounds + if end <= start: + raise ValueError("H3 segment audio interval must have positive duration.") + return round(end * sample_rate) - round(start * sample_rate) + + def _encode_audio(container, stream, audio, target_rate): - waveform, source_rate = audio + waveform, source_rate = audio[:2] + expected = None + if len(audio) == 3: + interval = audio[2] + if waveform.shape[1] != segment_sample_interval(interval, source_rate): + raise ValueError("H3 segment audio length differs from its source sample interval.") + expected = segment_sample_interval(interval, target_rate) channels = waveform.shape[0] layout = _audio_layout(channels) codec_formats = [fmt.name for fmt in (av.Codec(stream.codec_context.name, "w").audio_formats or [])] target_format = codec_formats[0] if codec_formats else "fltp" resampler = av.AudioResampler(format=target_format, layout=layout, rate=target_rate, frame_size=stream.codec_context.frame_size or 1024) - offset = 0 - for start in range(0, waveform.shape[1], 32768): - chunk = np.ascontiguousarray(waveform[:, start:start + 32768], dtype=np.float32) - frame = av.AudioFrame.from_ndarray(chunk, format="fltp", layout=layout) - frame.sample_rate = source_rate - frame.pts = offset - frame.time_base = Fraction(1, source_rate) - offset += frame.samples - for converted in resampler.resample(frame): - for packet in stream.encode(converted): - container.mux(packet) - for converted in resampler.resample(None): + def converted_frames(): + for start in range(0, waveform.shape[1], 32768): + chunk = np.ascontiguousarray(waveform[:, start:start + 32768], dtype=np.float32) + frame = av.AudioFrame.from_ndarray(chunk, format="fltp", layout=layout) + frame.sample_rate = source_rate + frame.pts = start + frame.time_base = Fraction(1, source_rate) + yield from resampler.resample(frame) + yield from resampler.resample(None) + + total = written = 0 + converted = None + for converted in converted_frames(): + total += converted.samples + if expected is not None: + keep = min(converted.samples, max(0, expected - written)) + if not keep: + continue + if keep != converted.samples: + array = converted.to_ndarray() + stride = 1 if converted.format.is_planar else channels + converted = av.AudioFrame.from_ndarray(np.ascontiguousarray(array[:, :keep * stride]), format=target_format, layout=layout) + converted.sample_rate = target_rate + converted.pts = written + converted.time_base = Fraction(1, target_rate) + written += converted.samples for packet in stream.encode(converted): container.mux(packet) + if expected is not None: + # Only global-grid rounding plus at most one resampler sample may differ. + resampled = Fraction(waveform.shape[1] * target_rate, source_rate) + if converted is None or abs(total - round(resampled)) > 1 or abs(total - expected) > math.ceil(Fraction(target_rate, source_rate)): + raise ValueError("H3 segment resampled audio differs meaningfully from its exact interval.") + if written < expected: + planar = av.AudioFormat(target_format).is_planar + dtype = converted.to_ndarray().dtype + shape = (channels, expected - written) if planar else (1, (expected - written) * channels) + padding = av.AudioFrame.from_ndarray(np.zeros(shape, dtype=dtype), format=target_format, layout=layout) + padding.sample_rate = target_rate + padding.pts = written + padding.time_base = Fraction(1, target_rate) + for packet in stream.encode(padding): + container.mux(packet) for packet in stream.encode(None): container.mux(packet) diff --git a/nodes/nodes_enhanced_video_combine.py b/nodes/nodes_enhanced_video_combine.py index 9f19d52..a411178 100644 --- a/nodes/nodes_enhanced_video_combine.py +++ b/nodes/nodes_enhanced_video_combine.py @@ -338,6 +338,12 @@ def _audio_file(audio): raise ValueError("audio must have a positive sample rate and at least one sample.") channels = waveform.shape[1] planar = waveform.permute(1, 0, 2).reshape(channels, -1).contiguous().clamp_(-1, 1).numpy() + if "h3_segment_interval" in audio: + interval = audio["h3_segment_interval"] + expected = helper_pyav_video.segment_sample_interval(interval, sample_rate) + if waveform.shape[0] != 1 or planar.shape[1] != expected: + raise ValueError("H3 segment audio length differs from its source sample interval.") + return (planar, sample_rate, interval), planar.shape[1] / sample_rate return (planar, sample_rate), planar.shape[1] / sample_rate diff --git a/pyproject.toml b/pyproject.toml index 3982228..14d29b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI-DaSiWa-Nodes" description = "DaSiWa Custom Nodes Collection" -version = "0.5.27" +version = "0.5.28" license = {file = "LICENSE"} dependencies = [ "torch",