diff --git a/H3_NATIVE_SHOT_LOOP.md b/H3_NATIVE_SHOT_LOOP.md new file mode 100644 index 0000000..438092b --- /dev/null +++ b/H3_NATIVE_SHOT_LOOP.md @@ -0,0 +1,69 @@ +# Native H3 shot loop without Planner (experimental) + +Requires BFSNodes **1.115.27**. Update, restart ComfyUI and refresh the browser. + +Load [the auto-prompt loop workflow](examples/h3_tara_native_auto_prompt_loop.comfy.json) +(or [API graph](examples/h3_tara_native_auto_prompt_loop.api.json)). It is a separate +variant of the single-clip workflow, with the same models, reference description, +per-segment prompt generation and native H3 sampling. No Planner or Planner run +state is involved. + +## Wiring + +```mermaid +flowchart LR + Load["Load Video · force 24fps"] --> Start["Native Shot Loop Start"] + Start --> Prepare["Prepare Native Video"] + Prepare --> Prompt["Guide analysis + H3 prompt generation"] + Refs["Describe References · original Pictures 1..N"] --> Continuity + Prompt --> Continuity["Native Shot Continuity"] + Start --> Continuity + Continuity --> Encode["Native Reference Set to Video"] + Prepare --> Encode + Encode --> Sample["Sampler + decode + trim padding"] + Sample --> End["Native Shot Loop End / Join"] + Start --> End + End --> Save["Create Video + Save Video"] + Load --> Audio["Original audio"] --> Save +``` + +- **Loop Start:** default chunks of 107 source frames. `camera_cuts` accepts + comma-separated 24fps frame indices relative to the loaded clip. A cut always + starts a new segment. Empty means one continuous take divided into chunks; + **there is no automatic scene detection in these nodes**. Set VHS `force_rate` + to 24 and `select_every_nth` to 1. Set cuts after any loader trim/skip. +- **Continuity:** enabled by default. After the first segment, append its last + generated frame as the next native Picture. All user Picture indices remain + unchanged. The appended instruction limits that image to appearance/scene + continuity while the current guide supplies movement. Disable at camera cuts + automatically; switch `enabled` off to disable it entirely. At most **8 user + images** when continuity is active (H3 node limit: 9 including the anchor). +- **Loop End:** stores each decoded segment in the ComfyUI temp directory, + expands only the start-to-end body, then concatenates the segments. Model + loading/LoRA/TARA nodes stay shared outside the loop. Connect the final images + to Create Video; connect original loader audio separately. Do not add final + Save Video inside the repeated body. + +Keep Prepare Native Video's `max_frames=0` and `frame_rounding=pad last frame`. +The example trims H3's `17n+5` padding before the join. A final segment under five +frames repeats its last source frame internally to meet H3's minimum; Loop End +keeps only its actual frame count. No overlap or crossfade is applied, so output +length remains the original loaded clip length at 24fps. + +This is **soft native-image continuity**, not Add Guide, first-frame latent +locking or a guarantee of seamless boundaries. TARA can remain enabled because +no latent head/history guide is added. Continuity may still bias composition or +propagate errors; disable it to compare. There is no performer tracking in these +nodes: ambiguous source assignments after crossings/cuts still require suitable +prompts or bindings. + +The temp manifest records the segment ranges and stored frame tensors. They are +not durable checkpoints or an automatic restart/resume mechanism. Float image +caches can be large; the final concatenation also uses CPU RAM. Remove the +corresponding `temp/bfs_h3_native_loop/` folder after saving the final +video if it is no longer needed. Do not remove it while that run is active. + +Validation: four CPU checks cover exact frame ranges/cuts, anchor ordering and +cut resets, short-tail trimming, actual ComfyUI GraphBuilder expansion and +bidirectional links in the example. No end-to-end GPU render, browser execution, +identity quality or seamlessness claim has been validated for this feature yet. diff --git a/README.md b/README.md index c47a48e..65b142e 100644 --- a/README.md +++ b/README.md @@ -222,3 +222,7 @@ attached reference in Picture 1..N order. Original sizes and pixels are preserve images of different sizes are not resized into a batch. The existing reference bundle and text outputs retain their indices. This preview connection does not change which images are sent to the encoder. + +### Native shot loop without Planner (1.115.27, experimental) + +Separate Start, Continuity and End/Join nodes now have a [native auto-prompt loop example](examples/h3_tara_native_auto_prompt_loop.comfy.json). See [wiring, manual cuts, continuity and validation limits](H3_NATIVE_SHOT_LOOP.md). diff --git a/__init__.py b/__init__.py index 7aa92f1..2c60248 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,5 @@ +from .bfs_h3_native_shot_loop import NODE_CLASS_MAPPINGS as H3LOOP_NODE_CLASS_MAPPINGS +from .bfs_h3_native_shot_loop import NODE_DISPLAY_NAME_MAPPINGS as H3LOOP_NODE_DISPLAY_NAME_MAPPINGS from .nodes import NODE_CLASS_MAPPINGS as BFS_NODE_CLASS_MAPPINGS from .nodes import NODE_DISPLAY_NAME_MAPPINGS as BFS_NODE_DISPLAY_NAME_MAPPINGS from .ltxv_editanything import NODE_CLASS_MAPPINGS as LTXV_EA_NODE_CLASS_MAPPINGS @@ -140,6 +142,7 @@ except Exception as _e: # noqa H3WORKFLOW_NODE_CLASS_MAPPINGS, H3WORKFLOW_NODE_DISPLAY_NAME_MAPPINGS = {}, {} NODE_CLASS_MAPPINGS = { + **H3LOOP_NODE_CLASS_MAPPINGS, **H3WORKFLOW_NODE_CLASS_MAPPINGS, **H3CLIP_NODE_CLASS_MAPPINGS, **H3GUIDE_NODE_CLASS_MAPPINGS, @@ -172,6 +175,7 @@ NODE_CLASS_MAPPINGS = { } NODE_DISPLAY_NAME_MAPPINGS = { + **H3LOOP_NODE_DISPLAY_NAME_MAPPINGS, **H3WORKFLOW_NODE_DISPLAY_NAME_MAPPINGS, **H3CLIP_NODE_DISPLAY_NAME_MAPPINGS, **H3GUIDE_NODE_DISPLAY_NAME_MAPPINGS, diff --git a/bfs_h3_native_shot_loop.py b/bfs_h3_native_shot_loop.py new file mode 100644 index 0000000..71660f4 --- /dev/null +++ b/bfs_h3_native_shot_loop.py @@ -0,0 +1,160 @@ +"""Sequential native H3 workflow expansion, independent of Shot Planner. + +The loop body is expanded after each decoded segment. Models stay outside it. +Decoded segments are cached on disk; no mutable global session state is used. +""" +import json +import math +import uuid +from pathlib import Path + +import torch + +CATEGORY = 'BFS/MiniMax H3/Native Shot Loop' + + +def ranges(count, chunk_frames, cuts): + if count < 1 or chunk_frames < 5: + raise ValueError('Load a nonempty video and choose at least 5 frames per segment') + try: + cut_list = sorted(set(int(x.strip()) for x in cuts.split(',') if x.strip())) + except ValueError as exc: + raise ValueError('Camera cuts must be comma-separated source-frame indices') from exc + if any(x <= 0 or x >= count for x in cut_list): + raise ValueError('Camera cuts must be inside the loaded video frame range') + result = [] + for a, b in zip([0] + cut_list, cut_list + [count]): + for start in range(a, b, chunk_frames): + result.append(dict(start=start, end=min(b, start+chunk_frames), + camera_cut=start in cut_list)) + return result + + +class BFSH3NativeShotLoopStart: + @classmethod + def INPUT_TYPES(cls): + return {'required': {'images': ('IMAGE',), + 'source_fps': ('FLOAT', {'default':24., 'min':.01}), + 'chunk_frames': ('INT', {'default':107, 'min':5, 'max':3600}), + 'camera_cuts': ('STRING', {'default':'', 'tooltip':'Comma-separated SOURCE frame indices, relative to the loaded clip. Empty = one continuous take split by chunk_frames. No automatic cut detection.'})}, + 'optional': {'loop_state': ('BFS_H3_NATIVE_LOOP',)}} + RETURN_TYPES = ('IMAGE', 'BFS_H3_NATIVE_LOOP', 'FLOAT', 'INT', 'INT') + RETURN_NAMES = ('shot_images', 'loop_state', 'source_fps', 'shot_index', 'shot_count') + FUNCTION = 'start'; CATEGORY = CATEGORY + + def start(self, images, source_fps=24., chunk_frames=107, camera_cuts='', loop_state=None): + if not math.isfinite(source_fps) or abs(source_fps-24.)>1e-6: + raise ValueError('Native H3 shot loop requires a 24 fps loaded video. Set VHS Load Video force_rate=24 before this node to keep boundaries and audio aligned.') + if loop_state is None: + import folder_paths + folder = Path(folder_paths.get_temp_directory()) / 'bfs_h3_native_loop' / uuid.uuid4().hex + folder.mkdir(parents=True) + loop_state = dict(index=0, ranges=ranges(len(images),chunk_frames,camera_cuts), + folder=str(folder), segments=[], fps=24., + source_frames=len(images), source_fps=source_fps) + state = dict(loop_state) + if len(images) != state['source_frames'] or source_fps != state['source_fps']: + raise ValueError('Source video changed during the native shot loop') + shot = state['ranges'][state['index']] + print(f"[BFS H3 native loop] segment {state['index']+1}/{len(state['ranges'])}: source frames {shot['start']}..{shot['end']-1}",flush=True) + frames=images[shot['start']:shot['end']] + if len(frames)<5: + frames=torch.cat((frames,frames[-1:].expand(5-len(frames),-1,-1,-1))) + return frames,state,source_fps,state['index']+1,len(state['ranges']) + + +class BFSH3NativeShotContinuity: + @classmethod + def INPUT_TYPES(cls): + return {'required': {'references': ('BFS_H3_REFERENCE_SET',), + 'prompt': ('STRING', {'forceInput':True}), 'loop_state': ('BFS_H3_NATIVE_LOOP',), + 'enabled': ('BOOLEAN', {'default':True})}} + RETURN_TYPES = ('BFS_H3_REFERENCE_SET', 'STRING', 'STRING') + RETURN_NAMES = ('references', 'prompt', 'continuity_status') + FUNCTION = 'prepare'; CATEGORY = CATEGORY + DESCRIPTION = 'Experimental native still-image continuity, not first-frame latent locking. Appends the previous generated last frame after all user pictures, only within a continuous take. Original Picture numbers stay fixed.' + + def prepare(self, references, prompt, loop_state, enabled=True): + state=loop_state + shot=state['ranges'][state['index']] + if not enabled or not state['segments'] or shot['camera_cut']: + reason='disabled' if not enabled else 'camera cut' if shot['camera_cut'] else 'first segment' + return references,prompt,'Continuity OFF: '+reason + if len(references)>=9: + raise ValueError('Native continuity needs one free Picture slot: use at most 8 user reference images, or disable continuity') + previous=torch.load(state['segments'][-1]['path'],map_location='cpu',weights_only=True) + anchor=previous[-1:].clone() + index=len(references)+1 + text=(f' Continuity anchor: is the last generated frame of the preceding segment of this same take. ' + 'Use it only for consistent scene appearance, character identity and wardrobe at the boundary. ' + 'The original assigned identity and environment pictures and user edits remain authoritative. ' + 'Continue the current