[verified] Fix sample-exact H3 Opus segment export (0.5.28)
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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*'))
|
||||
Reference in New Issue
Block a user