Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e4d197314 | ||
|
|
1ff7c1c9e1 | ||
|
|
5a4191f56c | ||
|
|
3ce2eb1ea0 | ||
|
|
27872290fe |
@@ -142,3 +142,84 @@ Sidecar and phase have CPU geometry/rotary parity tests, including mixed image,
|
||||
video and audio, but no full pretrained H3 quality validation yet. They distinguish
|
||||
sources; they do not guarantee the same face. See the
|
||||
[training configuration guide](https://github.com/alisson-anjos/ai-toolkit/blob/minimax-h3-latent-guides/H3_REFERENCE_ROPE.md).
|
||||
|
||||
|
||||
## Video context in CLIP with aligned guide latents
|
||||
|
||||
Select **aligned guide + CLIP video context** in **BFS Shot H3 Conditioning**
|
||||
(or its Simple variant). Qwen receives only the CURRENT SHOT as `<Video 1>` at
|
||||
2 fps with real timestamps, using the same shot interval passed to Add Guide.
|
||||
It does not receive the full source video on every shot. The original shot frames
|
||||
provide visual context even when source blur modifies the aligned latent guide.
|
||||
Images retain their `<Picture 1>` through `<Picture N>` order and native image
|
||||
reference latents. Planner and the new standalone node allow up to 100 pictures
|
||||
(the Comfy autogrow capacity); available model context and memory can limit this.
|
||||
The original H3 node keeps its own unchanged input limits.
|
||||
|
||||
The shot itself enters the diffusion model only as aligned `minimax_keyframes`
|
||||
through Add Guide; the new mode does not create a native `minimax_refs` video block
|
||||
for it. Its Qwen embeddings still travel in `positive`. Explicitly enabling the
|
||||
separate native masked-region video reference adds that as `<Video 2>`.
|
||||
|
||||
For a native workflow, use **MiniMax H3 Reference to Video — Guide 0 CLIP Context
|
||||
(BFS)** in place of **MiniMax H3 Reference to Video**:
|
||||
|
||||
1. Connect your source guide to **guide_video_0**, and pictures to `ref_images`.
|
||||
`0` identifies the input's special role; its prompt token is `<Video 1>` because
|
||||
H3 numbers video tokens from 1. Only this guide is CLIP-only.
|
||||
2. Optional `ref_videos` remain ACTUAL native video references, receiving both Qwen
|
||||
encoding and native reference latents. With guide 0 connected they are
|
||||
`<Video 2>`, `<Video 3>`, `<Video 4>`; without it native numbering starts at 1.
|
||||
Up to 3 actual reference videos are available, as in the original node.
|
||||
3. Connect the returned `positive` and target `latent` to **MiniMax H3 Add Guide**,
|
||||
with the SAME guide frames, VAE and `frame_idx = 0`.
|
||||
4. Feed Add Guide's `positive` and the target `latent` to your sampler.
|
||||
|
||||
Video inputs use the native 24-fps convention, are trimmed to the requested target
|
||||
length, and round down to the native 17*k+5 frame grid. There is no separate 15s
|
||||
hard cap in this helper; practical context/memory and checkpoint training limits
|
||||
still apply. Native reference soundtracks remain paired to their `ref_video_N`
|
||||
input, not to guide 0. Supply guide audio through Add Guide.
|
||||
The helper resizes only the guide frames sampled for Qwen and never VAE-encodes
|
||||
that guide as a native video reference. Images, actual reference videos and audio
|
||||
retain the native implementation. Target latent starts empty; Add Guide supplies
|
||||
aligned motion. The BFS mode performs the context and aligned-guide steps
|
||||
internally; Shot Join only assembles/composites the generated outputs.
|
||||
|
||||
Example: `<Subject 1> comes from <Picture 3> and follows the blond performer in
|
||||
the yellow shirt in <Video 1>. <Subject 2> comes from <Picture 1> and <Picture 2>
|
||||
and follows the orange-haired performer in the white shirt in <Video 1>. Keep
|
||||
these assignments through crossings and occlusions.` A picture need not mean a
|
||||
separate person; multiple images can define one identity, and there can be N
|
||||
assigned subjects/reference assets within the available context.
|
||||
|
||||
Existing modes and defaults are unchanged. Qwen encoding cost and text context
|
||||
length increase. Checkpoints trained with `guide_latent_only` did not see the
|
||||
source video in Qwen: this is an experimental inference option, not a demonstrated
|
||||
identity-binding improvement. GPU and visual validation remain for the user's
|
||||
local workflow.
|
||||
|
||||
|
||||
### Guide sampling rate and console evidence
|
||||
|
||||
`clip_video_fps` is available on both BFS conditioning variants and the Guide 0
|
||||
node. Default is 2 fps; range is 0.5–24 fps, capped to the source frame rate
|
||||
without duplicating frames. It changes guide sampling in Qwen only, not the
|
||||
aligned guide, output fps, or native reference-video sampling. Try 4 or 8 fps
|
||||
against 2 fps with the same shot, prompt, references, seed and sampler settings.
|
||||
Native H3 timestamps are retained; higher sampling rates are an experiment, not
|
||||
a guarantee of stronger identity binding.
|
||||
|
||||
Console messages with `[BFS H3 CLIP context]` report the input/used frame count,
|
||||
requested/effective fps, sent RGB frames, dimensions and first/last timestamps.
|
||||
A second message confirms that the tokenizer actually emitted H3 video vision
|
||||
blocks; an incompatible CLIP fails explicitly rather than silently ignoring the
|
||||
video. The final message appears only after Qwen encoding returns, with elapsed
|
||||
time and conditioning shapes. Qwen can pad an odd sample count to pairs.
|
||||
These logs confirm the conditioning path ran, not visual quality.
|
||||
|
||||
Compare `aligned guide (Add Guide)` against `aligned guide + CLIP video context`
|
||||
with all other settings fixed to assess whether video context changes the result.
|
||||
Use a fresh sampler execution (Planner cached renders may otherwise return old
|
||||
results); the CLIP-context logs only appear when conditioning actually executes.
|
||||
Compare 2/4/8 fps separately to isolate the sampling-rate effect.
|
||||
|
||||
@@ -139,7 +139,10 @@ workflow once per shot, and join the results back with the original timing and s
|
||||
every node downstream runs once per shot.
|
||||
- **BFS Shot Unpack** — guide frames, references, prompt and length of one shot, for any model.
|
||||
- **BFS Shot H3 Conditioning** — MiniMax H3 conditioning for one shot with the native nodes
|
||||
(aligned guide and/or native reference video).
|
||||
(aligned guide, native reference video, or aligned guide + CLIP video context).
|
||||
- **MiniMax H3 Reference to Video — Guide 0 CLIP Context (BFS)** — lets Qwen see the video
|
||||
guide at 2 fps without native guide video-reference latents; use Add Guide separately for motion.
|
||||
See [H3 guide modes](MINIMAX_H3_GUIDES.md#video-context-in-clip-with-aligned-guide-latents).
|
||||
- **BFS Shot Join** — concatenates, trims to the true lengths, cross-fades soft joins, returns audio. With `comparison` on, its `guide` output is the original video frame for frame.
|
||||
- **BFS Video Compare** — one player for two videos (Join's `guide` and `images`): wipe ↔ / ↕, overlay or side by side, any of guide / target / difference on top, plus a per-pixel difference map and the difference per frame (click to jump).
|
||||
|
||||
|
||||
+18
@@ -119,7 +119,23 @@ except Exception as _e: # noqa
|
||||
print(f"[BFSNodes] Video Compare node not loaded: {_e!r}")
|
||||
VCMP_NODE_CLASS_MAPPINGS, VCMP_NODE_DISPLAY_NAME_MAPPINGS = {}, {}
|
||||
|
||||
try:
|
||||
from .minimax_h3_clip_video import NODE_CLASS_MAPPINGS as H3CLIP_NODE_CLASS_MAPPINGS
|
||||
from .minimax_h3_clip_video import NODE_DISPLAY_NAME_MAPPINGS as H3CLIP_NODE_DISPLAY_NAME_MAPPINGS
|
||||
except Exception as _e: # noqa
|
||||
print(f"[BFSNodes] MiniMax H3 CLIP video context not loaded: {_e!r}")
|
||||
H3CLIP_NODE_CLASS_MAPPINGS, H3CLIP_NODE_DISPLAY_NAME_MAPPINGS = {}, {}
|
||||
|
||||
try:
|
||||
from .minimax_h3_embedding_bridge import NODE_CLASS_MAPPINGS as H3BRIDGE_NODE_CLASS_MAPPINGS
|
||||
from .minimax_h3_embedding_bridge import NODE_DISPLAY_NAME_MAPPINGS as H3BRIDGE_NODE_DISPLAY_NAME_MAPPINGS
|
||||
except Exception as _e: # noqa
|
||||
print(f"[BFSNodes] Experimental H3 embedding bridge not loaded: {_e!r}")
|
||||
H3BRIDGE_NODE_CLASS_MAPPINGS, H3BRIDGE_NODE_DISPLAY_NAME_MAPPINGS = {}, {}
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
**H3BRIDGE_NODE_CLASS_MAPPINGS,
|
||||
**H3CLIP_NODE_CLASS_MAPPINGS,
|
||||
**H3GUIDE_NODE_CLASS_MAPPINGS,
|
||||
**SHOT_NODE_CLASS_MAPPINGS,
|
||||
**PANEL_NODE_CLASS_MAPPINGS,
|
||||
@@ -149,6 +165,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**H3BRIDGE_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**H3CLIP_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**H3GUIDE_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**SHOT_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**PANEL_NODE_DISPLAY_NAME_MAPPINGS,
|
||||
|
||||
+69
-24
@@ -504,7 +504,7 @@ def _load_plan(plan_json: str) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
MAX_REFS = 12 # reference pictures per shot, <Picture 1..12>; the continuity and setting pictures come after them
|
||||
MAX_REFS = 100 # matches Comfy autogrow input capacity; continuity/setting pictures follow the shot refs
|
||||
|
||||
|
||||
def ref_list(refs, ref="", ref2="") -> list[str]:
|
||||
@@ -3343,7 +3343,7 @@ class BFSShotPlanner:
|
||||
# the ref_image / ref_image_2 inputs were removed in 1.113 (the references live in the panel, up to
|
||||
# 12); a workflow saved with them connected still links them, and they are ignored
|
||||
print("[BFS Shot Planner] ref_image / ref_image_2 are no longer planner inputs: the connected images "
|
||||
"are ignored. Set the references in the panel (Prompts & refs tab, up to 12).", flush=True)
|
||||
"are ignored. Set the references in the panel (Prompts & refs tab, up to 100).", flush=True)
|
||||
if vlm is not None:
|
||||
_VLM["clip"] = vlm # the panel's Analyse button uses it too
|
||||
vcfg = vlm_cfg(p.get("vlm_cfg"))
|
||||
@@ -4154,7 +4154,7 @@ def inpaint_latent(latent: dict, positive, vae, frames: torch.Tensor, mask: torc
|
||||
class BFSShotH3Conditioning:
|
||||
"""Native MiniMax H3 conditioning for one shot: references, prompt and the shot as a guide."""
|
||||
|
||||
GUIDE_MODES = ["aligned guide (Add Guide)", "native reference video (Video 1)", "both", "none"]
|
||||
GUIDE_MODES = ["aligned guide (Add Guide)", "native reference video (Video 1)", "both", "none", "aligned guide + CLIP video context"]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -4166,7 +4166,8 @@ class BFSShotH3Conditioning:
|
||||
"guide_mode": (cls.GUIDE_MODES, {"default": cls.GUIDE_MODES[0], "tooltip":
|
||||
"aligned guide: the shot's frames sit on the generated frames (MiniMax H3 Add Guide), "
|
||||
"frame by frame. native reference video: the shot enters as <Video 1> like the reference "
|
||||
"video input. both: the two together."}),
|
||||
"video input. both: the two together. aligned guide + CLIP video context: Qwen sees "
|
||||
"the shot at clip_video_fps, while its latents enter only as an aligned guide."}),
|
||||
"first_frame": (["none", "shot's first frame"], {"default": "none", "tooltip":
|
||||
"Anchor the shot's own first frame as an extra aligned image guide."}),
|
||||
"ref_image_size": (["match", "max"], {"default": "match"}),
|
||||
@@ -4233,6 +4234,10 @@ class BFSShotH3Conditioning:
|
||||
"mask_guide '+ reference video' only: size of that reference video (fraction of the generation "
|
||||
"size). The native reference channel keeps a smaller video as it is, so 1/2 costs ~1/4 of the "
|
||||
"tokens; it only has to show the pose and outline."}),
|
||||
"clip_video_fps": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 24.0, "step": 0.5,
|
||||
"tooltip": "Only for aligned guide + CLIP video context. Qwen sampling rate for this shot; "
|
||||
"try 2, 4 or 8 fps. Higher values cost more context/encoding time, not output fps. "
|
||||
"Console confirms sent frames, video vision tokens and completed encoding."}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4241,11 +4246,12 @@ class BFSShotH3Conditioning:
|
||||
FUNCTION = "condition"
|
||||
CATEGORY = "BFS/shot loop"
|
||||
DESCRIPTION = ("Build MiniMax H3 conditioning for one shot with the native nodes: Reference to Video "
|
||||
"(prompt, references, length) plus the shot as an aligned guide and/or reference video.")
|
||||
"(prompt, references, length) plus the shot as an aligned guide and/or reference video. "
|
||||
"CLIP video context lets Qwen see the shot without native shot reference latents.")
|
||||
|
||||
def condition(self, shot, clip, vae, guide_mode, first_frame, ref_image_size,
|
||||
audio_vae=None, with_audio=False, model=None, vlm=None, setting_ref="off", setting_mask=None, inpaint="off", mask_guide="off", mask_ref_size="1/2", mask_guide_look="grey blurred",
|
||||
source_blur="off", source_blur_strength=1.0, source_blur_segmenter="SAM 3.1", source_blur_effect="blur"):
|
||||
source_blur="off", source_blur_strength=1.0, source_blur_segmenter="SAM 3.1", source_blur_effect="blur", clip_video_fps=2.0):
|
||||
from comfy_extras.nodes_minimax_h3 import MiniMaxH3AddGuide, MiniMaxH3ReferenceToVideo
|
||||
# every picture of the shot, <Picture 1> first (chosen in the planner; use_ref_2 left in 1.113)
|
||||
refs = {f"ref_image_{k}": r for k, r in enumerate(shot_refs(shot)[:MAX_REFS])}
|
||||
@@ -4265,7 +4271,8 @@ class BFSShotH3Conditioning:
|
||||
refs[f"ref_image_{len(refs)}"] = prev # one more <Picture n>, after the shot's own
|
||||
audio = shot["audio"] if (with_audio and audio_vae is not None) else None
|
||||
native = guide_mode in (self.GUIDE_MODES[1], self.GUIDE_MODES[2])
|
||||
aligned = guide_mode in (self.GUIDE_MODES[0], self.GUIDE_MODES[2])
|
||||
clip_context = guide_mode == self.GUIDE_MODES[4]
|
||||
aligned = guide_mode in (self.GUIDE_MODES[0], self.GUIDE_MODES[2]) or clip_context
|
||||
text = shot["prompt"] or ""
|
||||
setting = setting_picture(shot, setting_ref, setting_mask) if setting_ref and setting_ref != "off" else None
|
||||
if setting is not None:
|
||||
@@ -4287,9 +4294,6 @@ class BFSShotH3Conditioning:
|
||||
text = add_setting(text, len(refs), "<Subject 1>" in text)
|
||||
text = fill_details_now(text, vlm, shot_refs(shot), f"[BFS Shot H3 Conditioning] shot {shot.get('index', 0) + 1}",
|
||||
shot.get("ref_names"))
|
||||
if len(refs) > 9: # H3's own node stops at 9; OpenJutsu-style LoRAs were trained with up to 9 pictures
|
||||
print(f"[BFS Shot H3 Conditioning] shot {shot.get('index', 0) + 1}: {len(refs)} reference pictures "
|
||||
"(more than the 9 H3 and its LoRAs usually see)", flush=True)
|
||||
kwargs = dict(clip=clip, prompt=text, width=shot["width"], height=shot["height"],
|
||||
length=shot["gen_length"], ref_image_size=ref_image_size, vae=vae,
|
||||
audio_vae=audio_vae, ref_images=refs or None)
|
||||
@@ -4341,10 +4345,15 @@ class BFSShotH3Conditioning:
|
||||
print(f"{gtag}: masked-region reference video at {guide_px.shape[2]}x{guide_px.shape[1]} ({mask_ref_size})",
|
||||
flush=True)
|
||||
vids[f"ref_video_{len(vids) + 1}"] = guide_px
|
||||
kwargs["prompt"] = add_mask_video(kwargs["prompt"], len(vids))
|
||||
print(f"{gtag}: the masked region is <Video {len(vids)}> (write {{mask_video}} in the prompt to place "
|
||||
kwargs["prompt"] = add_mask_video(kwargs["prompt"], len(vids) + int(clip_context))
|
||||
print(f"{gtag}: the masked region is <Video {len(vids) + int(clip_context)}> (write {{mask_video}} in the prompt to place "
|
||||
f"its tag, otherwise a sentence is added)", flush=True)
|
||||
kwargs["ref_videos"] = vids
|
||||
if clip_context:
|
||||
from .minimax_h3_clip_video import clip_with_video_context
|
||||
kwargs["clip"] = clip_with_video_context(clip, {"shot": shot["frames"]}, shot["gen_length"], source_fps=float(shot["fps"]), context_fps=clip_video_fps)
|
||||
print(f"{gtag}: shot is <Video 1> in Qwen/CLIP at {clip_video_fps:g} fps; motion uses Add Guide, "
|
||||
"without native shot reference latents", flush=True)
|
||||
positive, latent = MiniMaxH3ReferenceToVideo.execute(**kwargs).args[:2]
|
||||
if pg in (MASK_GUIDES[1], MASK_GUIDES[2]):
|
||||
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae,
|
||||
@@ -4441,7 +4450,9 @@ class BFSShotH3ConditioningSimple:
|
||||
"vae": ("VAE",),
|
||||
"guide_mode": (cls.GUIDE_MODES, {"default": cls.GUIDE_MODES[0], "tooltip":
|
||||
"aligned guide: preserve the shot frame by frame with MiniMax H3 Add Guide. "
|
||||
"native reference video: provide it as <Video 1> context. both: use both paths."}),
|
||||
"native reference video: provide it as <Video 1> context. both: use both paths. "
|
||||
"aligned guide + CLIP video context: Qwen sees the shot at clip_video_fps without native "
|
||||
"shot reference latents; motion still uses Add Guide."}),
|
||||
"ref_image_size": (["match", "max"], {"default": "match"}),
|
||||
},
|
||||
"optional": {
|
||||
@@ -4464,6 +4475,10 @@ class BFSShotH3ConditioningSimple:
|
||||
"source_blur_effect": (BLUR_EFFECTS, {"default": BLUR_EFFECTS[0], "tooltip":
|
||||
"blur: no detail left. invert colours: keeps the shapes and the expression but not the skin tone "
|
||||
"or the colours."}),
|
||||
"clip_video_fps": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 24.0, "step": 0.5,
|
||||
"tooltip": "Only for aligned guide + CLIP video context. Qwen sampling rate for this shot; "
|
||||
"try 2, 4 or 8 fps. Higher values cost more context/encoding time, not output fps. "
|
||||
"Console confirms sent frames, video vision tokens and completed encoding."}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4476,12 +4491,13 @@ class BFSShotH3ConditioningSimple:
|
||||
|
||||
def condition(self, shot, clip, vae, guide_mode, ref_image_size,
|
||||
audio_vae=None, with_audio=False, vlm=None, inpaint=INPAINT_MODES[0],
|
||||
source_blur="off", source_blur_strength=1.0, source_blur_segmenter="SAM 3.1", source_blur_effect="blur"):
|
||||
source_blur="off", source_blur_strength=1.0, source_blur_segmenter="SAM 3.1", source_blur_effect="blur", clip_video_fps=2.0):
|
||||
positive, latent, _, prompt = BFSShotH3Conditioning().condition(
|
||||
shot, clip, vae, guide_mode, "none", ref_image_size,
|
||||
audio_vae=audio_vae, with_audio=with_audio, vlm=vlm, inpaint=inpaint,
|
||||
source_blur=source_blur, source_blur_strength=source_blur_strength,
|
||||
source_blur_segmenter=source_blur_segmenter, source_blur_effect=source_blur_effect)
|
||||
source_blur_segmenter=source_blur_segmenter, source_blur_effect=source_blur_effect,
|
||||
clip_video_fps=clip_video_fps)
|
||||
return positive, latent, prompt
|
||||
|
||||
|
||||
@@ -4702,6 +4718,7 @@ class BFSShotJoin:
|
||||
shot.get("shot_storage") or "lossless")
|
||||
st = run_state(rid)
|
||||
i = int(shot["index"])
|
||||
st.get("redo", {}).pop(str(i), None)
|
||||
if i not in st["done"]:
|
||||
st["done"].append(i)
|
||||
st["count"] = shot["count"]
|
||||
@@ -5002,15 +5019,16 @@ class BFSShotJoin:
|
||||
|
||||
|
||||
def cache_status(plan: dict, reset: bool = False) -> dict:
|
||||
"""The panel's view of the shot cache of a plan's video (as of the last time the planner ran)."""
|
||||
"""Read stored-shot status. Legacy reset requests only cancel pending redos; never delete files."""
|
||||
path = _input_path(plan["video"])
|
||||
rid = run_key(plan, path)
|
||||
d = run_dir(rid, create=False)
|
||||
if reset:
|
||||
import shutil
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
if reset and os.path.isdir(d):
|
||||
st = run_state(rid)
|
||||
_cancel_redo(st, list(st.get("redo", {})))
|
||||
save_state(rid, st)
|
||||
if not os.path.isdir(d): # nothing stored for this video (no folder is made just to look)
|
||||
return {"run_id": rid, "folder": d, "count": 0, "done": 0, "ok": [], "stored": [], "mb": 0.0,
|
||||
return {"run_id": rid, "folder": d, "count": 0, "done": 0, "ok": [], "stored": [], "redo": [], "mb": 0.0,
|
||||
"temp": rid.startswith("tmp_")}
|
||||
st = run_state(rid)
|
||||
ok = shot_status(st, d) if st.get("cur") else []
|
||||
@@ -5018,15 +5036,41 @@ def cache_status(plan: dict, reset: bool = False) -> dict:
|
||||
stored = sorted(int(f[5:9]) for f in files if f.startswith("shot_") and f.endswith(".pt"))
|
||||
size = sum(os.path.getsize(os.path.join(d, f)) for f in files if f.startswith("shot_"))
|
||||
return {"run_id": rid, "folder": d, "count": len(ok) or st.get("count", 0), "done": sum(ok),
|
||||
"ok": ok, "stored": stored, "mb": round(size / 1e6, 1), "temp": rid.startswith("tmp_")}
|
||||
"ok": ok, "stored": stored, "redo": sorted(int(i) for i in st.get("redo", {})),
|
||||
"mb": round(size / 1e6, 1), "temp": rid.startswith("tmp_")}
|
||||
|
||||
|
||||
def redo_shots(plan: dict, shots: list[int], cascade: bool = True) -> dict:
|
||||
def _cancel_redo(st: dict, shots: list) -> None:
|
||||
for i in shots:
|
||||
key = str(int(i))
|
||||
saved = st.get("redo", {}).pop(key, None)
|
||||
if saved is None or saved.get("gen") != st.get("gen", {}).get(key):
|
||||
continue # a new take has already replaced this selection
|
||||
if saved["done"] and int(i) not in st.setdefault("done", []):
|
||||
st["done"].append(int(i))
|
||||
if saved.get("keep_next"):
|
||||
st.setdefault("keep_next", {})[key] = True
|
||||
else:
|
||||
st.get("keep_next", {}).pop(key, None)
|
||||
|
||||
|
||||
def redo_shots(plan: dict, shots: list[int], cascade: bool = True, cancel: bool = False) -> dict:
|
||||
"""Mark stored shots to run again on the next Queue. cascade: the shots that continue from them are redone too
|
||||
(automatically, once the new take exists); off: they keep their take (the join may show a seam there)."""
|
||||
(automatically, once the new take exists); off: they keep their take (the join may show a seam there).
|
||||
cancel restores the selection's previous state only while its stored take has not been replaced."""
|
||||
rid = run_key(plan, _input_path(plan["video"]))
|
||||
st = run_state(rid)
|
||||
if cancel:
|
||||
_cancel_redo(st, shots)
|
||||
save_state(rid, st)
|
||||
return cache_status(plan)
|
||||
for i in shots:
|
||||
key = str(int(i))
|
||||
st.setdefault("redo", {}).setdefault(key, {
|
||||
"done": int(i) in st.get("done", []),
|
||||
"gen": st.get("gen", {}).get(key),
|
||||
"keep_next": bool(st.get("keep_next", {}).get(key)),
|
||||
})
|
||||
if int(i) in st.get("done", []):
|
||||
st["done"].remove(int(i))
|
||||
if cascade:
|
||||
@@ -5808,7 +5852,8 @@ try:
|
||||
body = await request.json()
|
||||
try:
|
||||
return web.json_response(redo_shots(_load_plan(json.dumps(body.get("plan", {}))),
|
||||
[int(x) for x in body.get("shots", [])], bool(body.get("cascade", True))))
|
||||
[int(x) for x in body.get("shots", [])], bool(body.get("cascade", True)),
|
||||
bool(body.get("cancel", False))))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
||||
|
||||
|
||||
+45
-14
@@ -80,6 +80,28 @@ def save_refs(reference, out_dir: str, height: int) -> list:
|
||||
return names
|
||||
|
||||
|
||||
def video_batches(value, name):
|
||||
parts = value if isinstance(value, list) else [value]
|
||||
if not parts or any(not isinstance(x, torch.Tensor) or x.ndim != 4 or
|
||||
x.shape[0] < 1 or x.shape[-1] < 3 or min(x.shape[1:3]) < 1 for x in parts):
|
||||
raise ValueError(f"{name} must contain nonempty IMAGE batches")
|
||||
return parts
|
||||
|
||||
|
||||
def video_chunk(parts, start, end, h, w):
|
||||
"""Read a bounded frame interval across shot batches without concatenating full videos."""
|
||||
result, pos = [], 0
|
||||
for part in parts:
|
||||
count = int(part.shape[0])
|
||||
a, b = max(0, start - pos), min(count, end - pos)
|
||||
if a < b:
|
||||
result.append(_scale(part[a:b, ..., :3].float(), h, w))
|
||||
pos += count
|
||||
if pos >= end:
|
||||
break
|
||||
return result[0] if len(result) == 1 else torch.cat(result, 0)
|
||||
|
||||
|
||||
class BFSVideoCompare:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -110,8 +132,7 @@ class BFSVideoCompare:
|
||||
},
|
||||
}
|
||||
|
||||
# lists in: the reference can be the refs list of the planner / Unpack (pictures of different sizes); the other
|
||||
# inputs take their first item
|
||||
# IMAGE lists contain every shot, in order. Only scalar settings take their first item.
|
||||
INPUT_IS_LIST = True
|
||||
RETURN_TYPES = ("IMAGE", "STRING", "FLOAT")
|
||||
RETURN_NAMES = ("difference", "report", "mean_difference")
|
||||
@@ -127,18 +148,28 @@ class BFSVideoCompare:
|
||||
def compare(self, guide, target, fps=24.0, audio=None, gain=2.0, size="guide", preview_height=720,
|
||||
label_guide="guide", label_target="target", reference=None):
|
||||
one = lambda v: v[0] if isinstance(v, list) and v else v
|
||||
guide, target, fps, audio, gain, size, preview_height, label_guide, label_target = map(
|
||||
one, (guide, target, fps, audio, gain, size, preview_height, label_guide, label_target))
|
||||
fps, audio, gain, size, preview_height, label_guide, label_target = map(
|
||||
one, (fps, audio, gain, size, preview_height, label_guide, label_target))
|
||||
guide, target = video_batches(guide, "guide"), video_batches(target, "target")
|
||||
ng, nt = sum(x.shape[0] for x in guide), sum(x.shape[0] for x in target)
|
||||
if len(guide) > 1 or len(target) > 1:
|
||||
if ng != nt or (len(guide) == len(target) and any(g.shape[0] != t.shape[0] for g, t in zip(guide, target))):
|
||||
raise ValueError("Guide/target shot lists have different timing. Connect BFS Shot Join's guide and images outputs from the SAME Join; Unpack guide frames can include continuity heads and generation padding.")
|
||||
notes = []
|
||||
n = min(guide.shape[0], target.shape[0])
|
||||
if guide.shape[0] != target.shape[0]:
|
||||
notes.append(f"lengths differ (guide {guide.shape[0]}, target {target.shape[0]} frames): "
|
||||
n = min(ng, nt)
|
||||
if ng != nt:
|
||||
notes.append(f"lengths differ (guide {ng}, target {nt} frames): "
|
||||
f"compared the first {n}")
|
||||
ref = guide if size == "guide" else target
|
||||
H, W = int(ref.shape[1]), int(ref.shape[2])
|
||||
for name, v in (("guide", guide), ("target", target)):
|
||||
if tuple(v.shape[1:3]) != (H, W):
|
||||
notes.append(f"{name} scaled from {v.shape[2]}x{v.shape[1]} to {W}x{H}")
|
||||
H, W = int(ref[0].shape[1]), int(ref[0].shape[2])
|
||||
for name, parts in (("guide", guide), ("target", target)):
|
||||
if len(parts) > 1:
|
||||
notes.append(f"{name}: compared all {len(parts)} shot batches in input order")
|
||||
for dims in sorted(set(tuple(v.shape[1:3]) for v in parts)):
|
||||
if dims != (H, W):
|
||||
notes.append(f"{name} scaled from {dims[1]}x{dims[0]} to {W}x{H}")
|
||||
if ng != nt:
|
||||
notes.append("Timing may be misaligned. For Planner results, connect guide and images from the SAME BFS Shot Join.")
|
||||
ph = min(H, int(preview_height)) if preview_height and preview_height > 0 else H
|
||||
PH, PW = max(16, ph // 2 * 2), max(16, int(round(W * ph / H)) // 2 * 2)
|
||||
|
||||
@@ -151,8 +182,8 @@ class BFSVideoCompare:
|
||||
try:
|
||||
for a in range(0, n, CHUNK):
|
||||
e = min(a + CHUNK, n) # the longer video stops where the shorter one ends
|
||||
g = _scale(guide[a:e, ..., :3].float(), H, W)
|
||||
t = _scale(target[a:e, ..., :3].float(), H, W)
|
||||
g = video_chunk(guide, a, e, H, W)
|
||||
t = video_chunk(target, a, e, H, W)
|
||||
m, e, s = difference(g, t, gain)
|
||||
maps.append(m)
|
||||
mae.append(e)
|
||||
@@ -179,7 +210,7 @@ class BFSVideoCompare:
|
||||
ui = {"bfs_compare": [{**files, "fps": float(fps), "frames": n, "width": PW, "height": PH,
|
||||
"labels": [label_guide, label_target], "mae": [round(float(x), 2) for x in mae],
|
||||
"refs": refs,
|
||||
"report": report}]}
|
||||
"report": report, "notes": notes}]}
|
||||
return {"ui": ui, "result": (diff_map, report, mean)}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Experimental Qwen3.5 9B thinking states -> H3 conditioning
|
||||
|
||||
Branch: `experiment/h3-9b-thinking-embeddings`. No changes to main or the live
|
||||
OpenJutsu training run. This is a prepared research prototype, not a trained
|
||||
encoder replacement. GPU execution and real workflow validation are pending.
|
||||
|
||||
## What is actually tested
|
||||
|
||||
The full Qwen3.5-9B sees numbered pictures and chronological guide frames, plus
|
||||
an unchanged copy of the request. It generates with thinking off/on. A forward
|
||||
hook captures its last decoder-block activations BEFORE final normalization:
|
||||
one prefill bank and a separate bank of generated-token states. The sampled
|
||||
last token may not have been forwarded. Reasoning text is not rewritten into an
|
||||
H3 prompt, not saved, and not fed through the teacher. Token states themselves
|
||||
are the bridge input. A completed `</think>` is required in thinking mode;
|
||||
truncated thinking is not silently labelled successful.
|
||||
|
||||
A shared trainable bridge consumes these states. Position/modality queries
|
||||
produce a variable-length sequence with the original H3 teacher's exact layout,
|
||||
5120-wide unnormalized features and visual/text tags. The bridge never sees
|
||||
teacher feature values as INPUT. Only its reconstruction loss uses them.
|
||||
The 9B and H3 encoders stay frozen; H3 DiT/VAE are not loaded for calibration.
|
||||
Teacher is the existing converted H3 encoder (50 layers), so no 66-GB full
|
||||
teacher download is needed.
|
||||
|
||||
This prototype requires a cached teacher layout for each evaluation case. It is
|
||||
NOT yet a general online encoder, a speedup demonstration, or an identity/motion
|
||||
quality improvement. Distilling ordinary H3 embeddings may suppress useful
|
||||
thinking information. Embedding error alone cannot prove thinking benefits.
|
||||
|
||||
## What is ready and what is not
|
||||
|
||||
Prepared: disjoint input manifest, teacher/student cache scripts, a modality-
|
||||
balanced shared resampling bridge, train/validation/test reconstruction metrics,
|
||||
held-out conditioning exports, and a guarded Comfy cached-case embedding swap.
|
||||
Only syntax and CPU file preflight are checked. No tensor, GPU, generation or
|
||||
quality tests have been run, per the user's local testing preference. No bridge
|
||||
weights have been trained/exported. No new model download or environment upgrade
|
||||
is performed automatically. GPU entrypoints refuse to run while the dataset
|
||||
reviewer is active or GPU memory is occupied; there is no auto-start watcher.
|
||||
|
||||
Full local Qwen3.5-9B weights and a compatible Transformers environment are
|
||||
required. Existing Comfy caption-model support does not guarantee a matching
|
||||
Transformers model directory. Keep the experimental environment separate from
|
||||
Comfy/training; use current official Qwen requirements rather than upgrading
|
||||
the active environments. Generation uses a complete HF model, not GGUF and not
|
||||
the headless H3 encoder.
|
||||
|
||||
## CPU preparation (safe during dataset processing)
|
||||
|
||||
```bash
|
||||
cd /data/bfs-dev-vlm-reasoning/experiments/h3_embedding_bridge
|
||||
python make_manifest.py --output /data/reports/h3_9b_embedding_bridge/manifest.jsonl
|
||||
python preflight.py --manifest /data/reports/h3_9b_embedding_bridge/manifest.jsonl \
|
||||
--output /data/reports/h3_9b_embedding_bridge/preflight.json
|
||||
```
|
||||
|
||||
Default: 32 calibration-train, 8 validation and 8 held-out cases; no exact
|
||||
reference-asset hash reuse across these cases. Inputs are original training
|
||||
split captions/guides, capped at four pictures for the initial cost experiment.
|
||||
They are UNFILTERED calibration requests, NOT approved H3 training examples and
|
||||
NOT a clean identity-quality benchmark. No targets, original captions or
|
||||
production caches are modified. Their source captions may be inaccurate; this
|
||||
limits downstream identity conclusions. Reference-image hashes prevent exact
|
||||
asset overlap, not all appearances of the same real identity.
|
||||
|
||||
Each probe uses at most 73 native 24-fps frames (~3.04 s), source-time resampling,
|
||||
384-pixel width and 2-fps Qwen context. Teacher and student use the same media
|
||||
interval and picture order, with their respective native vision processors.
|
||||
This small probe cannot establish full-video identity stability.
|
||||
|
||||
## Deferred GPU stages (after the dataset reviewer exits)
|
||||
|
||||
Use absolute paths or set `PROBE`, `CALIB`, `H3PY`, `QWENPY` in your shell.
|
||||
`H3PY` is a compatible Comfy Python environment; `QWENPY` is an isolated
|
||||
Transformers environment supporting the complete Qwen3.5 model. Use an empty
|
||||
output directory. Models are loaded sequentially, not concurrently.
|
||||
|
||||
```bash
|
||||
PROBE=/data/bfs-dev-vlm-reasoning/experiments/h3_embedding_bridge
|
||||
CALIB=/data/reports/h3_9b_embedding_bridge
|
||||
H3PY=/data/comfyui/.venv/bin/python
|
||||
# Set QWENPY to your separate compatible environment, and QWEN_MODEL to local weights.
|
||||
PYTHONPATH=/tmp/caption-runtime-site "$H3PY" "$PROBE/cache_teacher.py" \
|
||||
--manifest "$CALIB/manifest.jsonl" --cache "$CALIB/cache"
|
||||
"$QWENPY" "$PROBE/cache_student.py" --model "$QWEN_MODEL" \
|
||||
--manifest "$CALIB/manifest.jsonl" --cache "$CALIB/cache" --max-new-tokens 512
|
||||
"$QWENPY" "$PROBE/train_bridge.py" --manifest "$CALIB/manifest.jsonl" \
|
||||
--cache "$CALIB/cache" --output "$CALIB/run_001" --steps 500
|
||||
```
|
||||
|
||||
Cache only complete, matching input keys. Teacher weights stay in their native
|
||||
conversion/quantization format. If the local Comfy Python cannot load that format,
|
||||
fix the isolated runtime first. The teacher stage has not been executed here.
|
||||
Student HF paths are local-only; partial thinking halts with a metadata record
|
||||
and can be retried with a larger token budget. The scripts do not resume training
|
||||
optimizer state; choose a new output directory for each trial.
|
||||
|
||||
Validation selects one shared bridge using both thinking modes. Test outputs
|
||||
contain `thinking_0`, `thinking_1`, and `thinking_ablated` (same thinking-on
|
||||
prefill, generated states zeroed, same state-bank quota). Never use test teacher
|
||||
features to fit the bridge. The per-channel output scale is estimated from the
|
||||
training split only. There is no video/identity/reward objective in this first
|
||||
calibration. Production use might require H3 adaptation, broader calibration,
|
||||
and a trained task-specific objective.
|
||||
|
||||
## Local Comfy experiment after calibration
|
||||
|
||||
Copy held-out exported `*.thinking_*.safetensors` and
|
||||
`*.thinking_ablated.safetensors` into `ComfyUI/models/h3_embedding_bridge/`.
|
||||
Do not select `bridge.safetensors` there: it contains weights, not conditioning.
|
||||
Restart Comfy with this experimental BFS branch. The node is:
|
||||
|
||||
**BFS H3 Experimental 9B Embedding Bridge (Cached Case)**
|
||||
|
||||
First build native H3 conditioning with the exact SAME encoder, prompt, ordered
|
||||
pictures, source interval/resampling, dimensions and 2-fps guide context used in
|
||||
that export. Feed its `positive` through the experimental node before the
|
||||
sampler; retain the normal latent/Add Guide/reference-VAE path. The node replaces
|
||||
only embeddings and retains existing metadata, native reference latents and
|
||||
aligned guide latents. A teacher-baseline comparison rejects mismatched cached
|
||||
cases/tags; do not raise tolerance to bypass wrong inputs. This baseline pass
|
||||
means the prototype does not save encoder compute.
|
||||
|
||||
Render four matched arms with identical seed, guide, references, LoRA, CFG,
|
||||
steps and sampler: native teacher, 9B thinking off, 9B thinking on, generated-state
|
||||
ablation. Judge wrong-person assignments, identity exchanges after crossings or
|
||||
occlusions, reference wardrobe fidelity and motion separately. A difference in
|
||||
output, or a lower embedding reconstruction error, does not establish a benefit.
|
||||
Use curated longer multi-person clips before changing the production encoder.
|
||||
|
||||
Official Qwen model/config: https://huggingface.co/Qwen/Qwen3.5-9B
|
||||
Original H3 encoder: https://huggingface.co/MiniMaxAI/MiniMax-H3/tree/main/FL2VA/text_encoder
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Experimental 9B hidden-state -> H3 layer-50 bridge, not an encoder replacement yet."""
|
||||
import math
|
||||
from dataclasses import asdict, dataclass
|
||||
import torch
|
||||
from torch import nn
|
||||
from torch.nn import functional as F
|
||||
|
||||
|
||||
@dataclass
|
||||
class BridgeConfig:
|
||||
student_dim: int = 4096
|
||||
teacher_dim: int = 5120
|
||||
width: int = 512
|
||||
heads: int = 8
|
||||
source_slots: int = 512
|
||||
query_chunk: int = 256
|
||||
|
||||
|
||||
class HiddenStateBridge(nn.Module):
|
||||
"""Queries use only target positions/modality tags, NEVER teacher features.
|
||||
|
||||
Teacher layout is needed for this cached-case prototype. Arbitrary-input
|
||||
layout prediction is deliberately not claimed or implemented.
|
||||
"""
|
||||
def __init__(self, config=BridgeConfig()):
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.source = nn.Sequential(nn.LayerNorm(config.student_dim), nn.Linear(config.student_dim, config.width))
|
||||
self.modality = nn.Embedding(2, config.width)
|
||||
self.attention = nn.MultiheadAttention(config.width, config.heads, batch_first=True)
|
||||
self.norm = nn.LayerNorm(config.width)
|
||||
self.output = nn.Linear(config.width, config.teacher_dim)
|
||||
self.register_buffer('teacher_channel_scale', torch.ones(config.teacher_dim))
|
||||
|
||||
def position(self, length, device):
|
||||
# Normalized positions do not depend on teacher feature values.
|
||||
pos = torch.linspace(0, 1, length, device=device)[:, None]
|
||||
freq = torch.exp(torch.arange(0, self.config.width, 2, device=device) * (-math.log(10000) / self.config.width))
|
||||
angles = pos * 1000 * freq
|
||||
return torch.stack((angles.sin(), angles.cos()), -1).flatten(-2)
|
||||
|
||||
def memory(self, prefix, generated):
|
||||
# Separate quotas keep thinking states from being washed out by image tokens.
|
||||
banks = [self.source(prefix)]
|
||||
if generated.shape[0]:
|
||||
banks.append(self.source(generated))
|
||||
quota = max(1, self.config.source_slots // len(banks))
|
||||
pooled = [F.adaptive_avg_pool1d(x.T[None], min(quota, len(x))).transpose(1, 2) for x in banks]
|
||||
result = torch.cat(pooled, 1)
|
||||
return result + self.position(result.shape[1], result.device)[None].to(result.dtype)
|
||||
|
||||
def forward(self, prefix, generated, target_tags):
|
||||
assert prefix.ndim == generated.ndim == 2
|
||||
assert prefix.shape[-1] == generated.shape[-1] == self.config.student_dim
|
||||
assert target_tags.ndim == 1 and len(target_tags) > 0
|
||||
memory = self.memory(prefix, generated)
|
||||
queries = self.modality(target_tags.long()) + self.position(len(target_tags), target_tags.device).to(memory.dtype)
|
||||
output = []
|
||||
for start in range(0, len(queries), self.config.query_chunk):
|
||||
q = queries[None, start:start + self.config.query_chunk]
|
||||
x, _ = self.attention(q, memory, memory, need_weights=False)
|
||||
output.append(self.output(self.norm(x + q))[0])
|
||||
return torch.cat(output) * self.teacher_channel_scale
|
||||
|
||||
def config_dict(self):
|
||||
return asdict(self.config)
|
||||
|
||||
|
||||
def alignment_loss(prediction, teacher, tags):
|
||||
# Equal modality weighting prevents the longer visual prefix dominating text.
|
||||
scale = teacher.float().square().mean(0).sqrt().clamp_min(1e-3)
|
||||
losses = []
|
||||
for kind in (0, 1):
|
||||
mask = tags == kind
|
||||
if mask.any():
|
||||
p, t = prediction[mask].float(), teacher[mask].float()
|
||||
losses.append(((p - t) / scale).square().mean() + .1 * (1 - F.cosine_similarity(p, t).mean()))
|
||||
return torch.stack(losses).mean()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""GPU stage 2. Capture actual 9B decoder activations, not rewritten prompts.
|
||||
|
||||
A forward hook retains one selected layer only, avoiding generate's all-layer
|
||||
hidden-state cache. No generated reasoning text is persisted or fed to H3.
|
||||
"""
|
||||
import argparse, json, sys
|
||||
from pathlib import Path
|
||||
from common import load_manifest, require_idle_gpu, visual_inputs
|
||||
|
||||
|
||||
def decoder_layer(model):
|
||||
for path in ('model.language_model.layers', 'language_model.layers', 'model.layers'):
|
||||
obj = model
|
||||
try:
|
||||
for name in path.split('.'):
|
||||
obj = getattr(obj, name)
|
||||
return obj[-1]
|
||||
except AttributeError:
|
||||
pass
|
||||
raise RuntimeError('Unsupported Transformers decoder layout; no hidden states captured')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(); ap.add_argument('--manifest', required=True); ap.add_argument('--cache', required=True)
|
||||
ap.add_argument('--model', required=True, help='Local full Qwen3.5-9B Transformers directory; no automatic download')
|
||||
ap.add_argument('--max-new-tokens', type=int, default=512)
|
||||
args = ap.parse_args(); require_idle_gpu()
|
||||
if not Path(args.model).is_dir():
|
||||
raise RuntimeError('Full 9B weights are not present locally. Download in an isolated environment first.')
|
||||
import numpy as np
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
from transformers import AutoModelForImageTextToText, AutoProcessor
|
||||
processor = AutoProcessor.from_pretrained(args.model, local_files_only=True)
|
||||
model = AutoModelForImageTextToText.from_pretrained(args.model, dtype=torch.bfloat16, device_map='auto', local_files_only=True)
|
||||
model.eval().requires_grad_(False)
|
||||
layer = decoder_layer(model); cache = Path(args.cache); cache.mkdir(parents=True, exist_ok=True)
|
||||
for row in load_manifest(args.manifest):
|
||||
refs, frames, picks, info = visual_inputs(row)
|
||||
content = []
|
||||
for i, image in enumerate(refs, 1):
|
||||
content.extend([{'type': 'text', 'text': f'<Picture {i}>:'}, {'type': 'image', 'image': image}])
|
||||
content.append({'type': 'text', 'text': '<Video 1> chronological motion guide:'})
|
||||
for i in picks:
|
||||
content.extend([{'type': 'text', 'text': f'<{i / 24:.3f} seconds>:'}, {'type': 'image', 'image': frames[i]}])
|
||||
content.append({'type': 'text', 'text': 'Analyze the requested reference-to-performer assignments across these guide moments. '
|
||||
'Keep reference identity separate from guide identity and motion. Do not invent unseen details. '
|
||||
'Conclude with a short mapping summary. Request: ' + row['prompt']})
|
||||
messages = [{'role': 'user', 'content': content}]
|
||||
for thinking in (False, True):
|
||||
dest = cache / f'{row["id"]}.student_{int(thinking)}.safetensors'
|
||||
if dest.exists():
|
||||
continue
|
||||
inputs = processor.apply_chat_template(messages, tokenize=True, add_generation_prompt=True,
|
||||
return_dict=True, return_tensors='pt', enable_thinking=thinking).to(model.device)
|
||||
captured = []
|
||||
def capture(_module, _inputs, output):
|
||||
x = output[0] if isinstance(output, tuple) else output
|
||||
assert x.ndim == 3 and x.shape[0] == 1 and x.shape[-1] == 4096
|
||||
captured.append(x[0].detach().cpu().to(torch.bfloat16).contiguous())
|
||||
handle = layer.register_forward_hook(capture)
|
||||
try:
|
||||
with torch.inference_mode():
|
||||
generated = model.generate(**inputs, max_new_tokens=args.max_new_tokens, do_sample=False,
|
||||
use_cache=True, return_dict_in_generate=False)
|
||||
finally:
|
||||
handle.remove()
|
||||
assert captured, 'No decoder activations captured'
|
||||
prefix = captured[0]; tail = torch.cat(captured[1:]) if len(captured) > 1 else prefix[:0]
|
||||
text = processor.tokenizer.decode(generated[0, inputs['input_ids'].shape[1]:], skip_special_tokens=False)
|
||||
completed = not thinking or '</think>' in text
|
||||
meta = dict(input_key=row['input_key'], model=str(Path(args.model).resolve()), thinking=thinking,
|
||||
thinking_completed=completed, prefix_tokens=len(prefix), generated_state_tokens=len(tail),
|
||||
generated_tokens=generated.shape[1] - inputs['input_ids'].shape[1],
|
||||
layer='last decoder block before final norm', visual_inputs=info,
|
||||
extraction='Prefill activations plus generated-token activations; final sampled token may not be forwarded')
|
||||
if not completed:
|
||||
(cache / f'{row["id"]}.student_1.incomplete.json').write_text(json.dumps(meta, indent=2))
|
||||
raise RuntimeError('Thinking did not finish within token budget. Increase --max-new-tokens; no valid thinking cache saved.')
|
||||
save_file({'prefix': prefix, 'generated': tail}, str(dest), metadata={'experiment': json.dumps(meta)})
|
||||
print(json.dumps(dict(id=row['id'], thinking=thinking, completed=completed,
|
||||
prefix_tokens=len(prefix), generated_state_tokens=len(tail))), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""GPU stage 1. Reuses the existing H3 encoder; does not load the H3 DiT/VAE."""
|
||||
import argparse, importlib.util, json, sys
|
||||
from pathlib import Path
|
||||
from common import load_manifest, require_idle_gpu, visual_inputs
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--manifest', required=True); ap.add_argument('--cache', required=True)
|
||||
ap.add_argument('--comfy', default='/data/comfyui')
|
||||
ap.add_argument('--teacher', default='/data/models/minimax-h3/text_encoders/qwen3vl_32b_minimax_h3_int8_convrot.safetensors')
|
||||
args = ap.parse_args(); require_idle_gpu(); sys.argv = [sys.argv[0]]
|
||||
import numpy as np
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
sys.path.insert(0, args.comfy)
|
||||
import comfy.options
|
||||
comfy.options.enable_args_parsing()
|
||||
import folder_paths, nodes
|
||||
teacher = Path(args.teacher); folder_paths.add_model_folder_path('text_encoders', str(teacher.parent))
|
||||
clip = nodes.CLIPLoader().load_clip(teacher.name, 'stable_diffusion')[0]
|
||||
path = Path(__file__).resolve().parents[2] / 'minimax_h3_clip_video.py'
|
||||
spec = importlib.util.spec_from_file_location('bridge_native_context', path)
|
||||
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
cache = Path(args.cache); cache.mkdir(parents=True, exist_ok=True)
|
||||
for row in load_manifest(args.manifest):
|
||||
dest = cache / (row['id'] + '.teacher.safetensors')
|
||||
if dest.exists():
|
||||
continue
|
||||
refs, frames, picks, info = visual_inputs(row)
|
||||
images = {f'ref_image_{i}': torch.from_numpy(np.asarray(im).copy()).float()[None] / 255 for i, im in enumerate(refs)}
|
||||
guide = torch.from_numpy(np.stack([np.asarray(im) for im in frames]).copy()).float() / 255
|
||||
positive = module.BFSMiniMaxH3ReferenceToVideoGuideContext.execute(
|
||||
clip=clip, prompt=row['prompt'], width=info['width'], height=info['height'], length=info['length'],
|
||||
ref_image_size='match', ref_images=images, guide_video_0=guide, clip_video_fps=2,
|
||||
).args[0]
|
||||
assert len(positive) == 1
|
||||
embeds, extra = positive[0]; tags = extra['minimax_token_tags']
|
||||
embeds = embeds[0] if embeds.ndim == 3 else embeds
|
||||
tags = tags.reshape(-1)
|
||||
assert embeds.shape == (len(tags), 5120) and torch.isfinite(embeds).all()
|
||||
save_file({'teacher': embeds.cpu().to(torch.bfloat16).contiguous(), 'tags': tags.cpu().long().contiguous()},
|
||||
str(dest), metadata={'input_key': row['input_key'], 'teacher': str(teacher), 'visual_inputs': json.dumps(info)})
|
||||
(cache / (row['id'] + '.input.json')).write_text(json.dumps(row, indent=2, ensure_ascii=False))
|
||||
print(json.dumps(dict(id=row['id'], teacher_shape=list(embeds.shape), split=row['split'])), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
import json, subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def require_idle_gpu():
|
||||
# No background polling/auto launch: fail before loading a model.
|
||||
active = subprocess.check_output(['ps', '-eo', 'args'], text=True)
|
||||
if any('audit_temporal_identity_binding.py' in line and not line.lstrip().startswith(('ps ', 'rg '))
|
||||
for line in active.splitlines()):
|
||||
raise RuntimeError('Dataset reviewer is active. No experiment GPU work was started.')
|
||||
usage = subprocess.check_output(['nvidia-smi', '--query-gpu=memory.used', '--format=csv,noheader,nounits'], text=True)
|
||||
if any(int(v) > 1024 for v in usage.split()):
|
||||
raise RuntimeError('GPU is occupied. No experiment GPU work was started.')
|
||||
|
||||
|
||||
def load_manifest(path):
|
||||
rows = [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
|
||||
assert rows and len({r['id'] for r in rows}) == len(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def visual_inputs(row, length=73, width=384, context_fps=2.0):
|
||||
"""Identical 24-fps source interval and Picture order for teacher/student."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
cap = cv2.VideoCapture(row['guide']); fps = cap.get(cv2.CAP_PROP_FPS); count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
if fps <= 0 or count <= 0:
|
||||
raise ValueError('Unreadable guide ' + row['guide'])
|
||||
n = min(length, int(count / fps * 24)); n -= (n - 5) % 17
|
||||
if n < 5:
|
||||
raise ValueError('Guide shorter than H3 grid')
|
||||
frames = []
|
||||
# Sequential decoding avoids failed random seeks on some original files.
|
||||
wanted = [min(count - 1, round(i / 24 * fps)) for i in range(n)]; cursor = -1; bgr = None
|
||||
for index in wanted:
|
||||
while cursor < index:
|
||||
ok, bgr = cap.read(); cursor += 1
|
||||
if not ok:
|
||||
raise ValueError(f'Guide decoding failed at source frame {cursor}')
|
||||
frame = Image.fromarray(cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB))
|
||||
w = max(32, round(width / 32) * 32); h = max(32, round(frame.height / frame.width * w / 32) * 32)
|
||||
frames.append(frame.resize((w, h), Image.Resampling.LANCZOS))
|
||||
cap.release()
|
||||
picks = sorted({round(i * 24 / context_fps) for i in range(int((n - 1) / 24 * context_fps) + 1)})
|
||||
refs = []
|
||||
for path in row['references']:
|
||||
with Image.open(path) as raw:
|
||||
im = raw.convert('RGB'); scale = min(1.0, (w * h / (im.width * im.height)) ** .5)
|
||||
rw = max(32, round(im.width * scale / 32) * 32); rh = max(32, round(im.height * scale / 32) * 32)
|
||||
refs.append(im.resize((rw, rh), Image.Resampling.LANCZOS))
|
||||
return refs, frames, picks, dict(length=n, width=w, height=h, timestamps=[i / 24 for i in picks],
|
||||
context_fps=context_fps, input_key=row['input_key'])
|
||||
@@ -0,0 +1,51 @@
|
||||
"""CPU-only input selection. These calibration inputs are NOT approved H3 training samples."""
|
||||
import argparse, hashlib, json, random
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--output', required=True)
|
||||
parser.add_argument('--train', type=int, default=32)
|
||||
parser.add_argument('--validation', type=int, default=8)
|
||||
parser.add_argument('--test', type=int, default=8)
|
||||
args = parser.parse_args()
|
||||
rows = []
|
||||
for family, root in [('genjutsu', Path('/data/datasets/openjutsu/train_mt_multi')),
|
||||
('animate', Path('/data/datasets/animate_motion_transfer_v1/train'))]:
|
||||
for caption in sorted((root / 'targets').glob('*.txt')):
|
||||
refs = []
|
||||
for i in range(1, 17):
|
||||
files = sorted((root / f'refs_{i}').glob(caption.stem + '.*'))
|
||||
if files:
|
||||
refs.append(str(files[0]))
|
||||
if not 1 <= len(refs) <= 4:
|
||||
continue
|
||||
guide = root / ('guides_original' if family == 'animate' else 'guides') / (caption.stem + '.mp4')
|
||||
if not guide.is_file():
|
||||
continue
|
||||
rows.append(dict(id=family + '__' + caption.stem, guide=str(guide), references=refs,
|
||||
prompt=caption.read_text().strip(), original_caption_sha256=hashlib.sha256(caption.read_bytes()).hexdigest(),
|
||||
calibration_only=True, approved_for_h3_training=False))
|
||||
random.Random(42).shuffle(rows)
|
||||
selected = []; seen_assets = set()
|
||||
# No exact reference-asset reuse across selected cases/splits.
|
||||
for row in rows:
|
||||
hashes = {hashlib.sha256(Path(p).read_bytes()).hexdigest() for p in row['references']}
|
||||
if hashes & seen_assets:
|
||||
continue
|
||||
seen_assets.update(hashes); row['reference_sha256'] = sorted(hashes); selected.append(row)
|
||||
if len(selected) >= args.train + args.validation + args.test:
|
||||
break
|
||||
assert len(selected) == args.train + args.validation + args.test, 'Insufficient disjoint reference assets'
|
||||
for i, row in enumerate(selected):
|
||||
row['split'] = 'train' if i < args.train else 'validation' if i < args.train + args.validation else 'test'
|
||||
row['input_key'] = hashlib.sha256(json.dumps(row, sort_keys=True).encode()).hexdigest()
|
||||
out = Path(args.output); out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(''.join(json.dumps(r, ensure_ascii=False) + '\n' for r in selected))
|
||||
print(json.dumps(dict(output=str(out), total=len(selected), train=args.train, validation=args.validation, test=args.test,
|
||||
purpose='Embedding reconstruction only; unfiltered input captions, not a curated identity-quality benchmark.')))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Read-only CPU preflight: does not import torch, download weights or launch GPU jobs."""
|
||||
import argparse, ast, json
|
||||
from pathlib import Path
|
||||
from common import load_manifest
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(); ap.add_argument('--manifest', required=True)
|
||||
ap.add_argument('--student', default='/data/models/Qwen3.5-9B')
|
||||
ap.add_argument('--output', required=True); args = ap.parse_args()
|
||||
rows = load_manifest(args.manifest)
|
||||
missing = []
|
||||
for row in rows:
|
||||
for path in [row['guide'], *row['references']]:
|
||||
if not Path(path).is_file():
|
||||
missing.append(path)
|
||||
for path in Path(__file__).parent.glob('*.py'):
|
||||
ast.parse(path.read_text(), filename=str(path))
|
||||
student = Path(args.student)
|
||||
ready = (student / 'config.json').exists() and any(student.glob('*.safetensors'))
|
||||
report = dict(phase='prepared_gpu_execution_pending', samples=len(rows),
|
||||
splits={s: sum(r['split'] == s for r in rows) for s in ['train', 'validation', 'test']},
|
||||
missing_input_files=missing, student_weights_found=ready, student_path=str(student),
|
||||
syntax_checked=True, tensor_tests_run=False, gpu_tests_run=False, adapter_trained=False,
|
||||
h3_quality_validated=False, active_dataset_review_untouched=True,
|
||||
pending=['Provide full local Qwen3.5-9B Transformers weights in an isolated compatible environment.',
|
||||
'After dataset GPU review exits, cache teacher then student activations sequentially.',
|
||||
'Train shared bridge and evaluate held-out embedding reconstruction.',
|
||||
'Render native baseline / thinking off / thinking on / generated-state ablation with fixed inputs.'])
|
||||
if missing:
|
||||
raise RuntimeError('Calibration input files missing: ' + str(missing))
|
||||
Path(args.output).write_text(json.dumps(report, indent=2)); print(json.dumps(report, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""GPU stage 3: one shared bridge, balanced thinking on/off, frozen cached encoders.
|
||||
Embedding reconstruction is calibration only, NOT evidence that thinking helps H3.
|
||||
"""
|
||||
import argparse, json, random
|
||||
from pathlib import Path
|
||||
from common import load_manifest, require_idle_gpu
|
||||
from bridge import BridgeConfig, HiddenStateBridge, alignment_loss
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(); ap.add_argument('--manifest', required=True); ap.add_argument('--cache', required=True)
|
||||
ap.add_argument('--output', required=True); ap.add_argument('--steps', type=int, default=500)
|
||||
ap.add_argument('--eval-every', type=int, default=50); ap.add_argument('--lr', type=float, default=0.0001)
|
||||
args = ap.parse_args(); require_idle_gpu()
|
||||
import torch
|
||||
from safetensors import safe_open
|
||||
from safetensors.torch import load_file, save_file
|
||||
torch.manual_seed(42); rng = random.Random(42)
|
||||
rows = load_manifest(args.manifest); cache = Path(args.cache); out = Path(args.output)
|
||||
if out.exists() and any(out.iterdir()):
|
||||
raise RuntimeError('Refusing to overwrite an existing bridge run')
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for row in rows:
|
||||
for suffix in ['teacher', 'student_0', 'student_1']:
|
||||
path = cache / f'{row["id"]}.{suffix}.safetensors'
|
||||
with safe_open(str(path), framework='pt') as f:
|
||||
md = f.metadata() or {}
|
||||
key = md.get('input_key') if suffix == 'teacher' else json.loads(md['experiment'])['input_key']
|
||||
assert key == row['input_key'], 'Cache belongs to different inputs: ' + str(path)
|
||||
train = [r for r in rows if r['split'] == 'train']; val = [r for r in rows if r['split'] == 'validation']
|
||||
test = [r for r in rows if r['split'] == 'test']; assert train and val and test
|
||||
def case(row, thinking):
|
||||
teacher = load_file(str(cache / f'{row["id"]}.teacher.safetensors'))
|
||||
student = load_file(str(cache / f'{row["id"]}.student_{thinking}.safetensors'))
|
||||
return {k: v.cuda().float() for k, v in {**teacher, **student}.items()}
|
||||
model = HiddenStateBridge(BridgeConfig()).cuda()
|
||||
# Training-only per-channel scale; no held-out target features in model inputs.
|
||||
squares = torch.zeros(5120, dtype=torch.float64); tokens = 0
|
||||
for row in train:
|
||||
t = load_file(str(cache / f'{row["id"]}.teacher.safetensors'))['teacher'].float()
|
||||
squares += t.double().square().sum(0); tokens += len(t)
|
||||
model.teacher_channel_scale.copy_((squares / tokens).sqrt().clamp_min(1e-3).float().cuda())
|
||||
optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=.01)
|
||||
def predict(c, ablate=False):
|
||||
generated = torch.zeros_like(c['generated']) if ablate else c['generated']
|
||||
return model(c['prefix'], generated, c['tags'].long())
|
||||
@torch.no_grad()
|
||||
def measure(subset, export=False):
|
||||
model.eval(); results = []
|
||||
for row in subset:
|
||||
for thinking in (0, 1):
|
||||
c = case(row, thinking)
|
||||
for ablate in ([False, True] if thinking and export else [False]):
|
||||
pred = predict(c, ablate); t = c['teacher']
|
||||
assert torch.isfinite(pred).all()
|
||||
relative = float((pred - t).norm() / t.norm().clamp_min(1e-8))
|
||||
cosine = float(torch.nn.functional.cosine_similarity(pred.flatten(), t.flatten(), dim=0))
|
||||
results.append(dict(id=row['id'], split=row['split'], thinking=bool(thinking),
|
||||
generated_states_zeroed=ablate, relative_error=relative, cosine=cosine,
|
||||
loss=float(alignment_loss(pred, t, c['tags']))))
|
||||
if export:
|
||||
label = 'thinking_ablated' if ablate else f'thinking_{thinking}'
|
||||
name = row['id'] + '.' + label + '.safetensors'
|
||||
metadata = dict(experiment='h3_9b_hidden_state_bridge_v1', input_key=row['input_key'],
|
||||
source_id=row['id'], thinking=str(bool(thinking)), generated_states_zeroed=str(ablate),
|
||||
prompt=row['prompt'], validated_h3_quality='false', teacher_layout_required='true')
|
||||
save_file({'conditioning': pred.detach().cpu().to(torch.bfloat16).contiguous(),
|
||||
'teacher_baseline': t.cpu().to(torch.bfloat16).contiguous(),
|
||||
'minimax_token_tags': c['tags'].long().cpu().contiguous()},
|
||||
str(out / name), metadata=metadata)
|
||||
return results
|
||||
best = float('inf'); history = []
|
||||
for step in range(1, args.steps + 1):
|
||||
model.train(); row = rng.choice(train); thinking = step % 2
|
||||
c = case(row, thinking); optimizer.zero_grad(set_to_none=True)
|
||||
prediction = predict(c); loss = alignment_loss(prediction, c['teacher'], c['tags'])
|
||||
assert torch.isfinite(loss)
|
||||
loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0); optimizer.step()
|
||||
if step % args.eval_every == 0 or step == args.steps:
|
||||
metrics = measure(val); score = sum(r['loss'] for r in metrics) / len(metrics)
|
||||
entry = dict(step=step, train_loss=float(loss), validation_loss=score); history.append(entry)
|
||||
print(json.dumps(entry), flush=True)
|
||||
(out / 'history.json').write_text(json.dumps(history, indent=2))
|
||||
if score < best:
|
||||
best = score
|
||||
save_file({k: v.detach().cpu().contiguous() for k, v in model.state_dict().items()},
|
||||
str(out / 'bridge.safetensors'), metadata={'config': json.dumps(model.config_dict()),
|
||||
'selected_by': 'validation reconstruction loss',
|
||||
'h3_quality_validated': 'false'})
|
||||
model.load_state_dict(load_file(str(out / 'bridge.safetensors')))
|
||||
metrics = measure(test, export=True)
|
||||
report = dict(test_metrics=metrics, h3_quality_validated=False, thinking_benefit_proven=False,
|
||||
next_step='Compare native teacher, thinking off, thinking on and generated-state ablation in H3 with fixed seed/guide/references.',
|
||||
limitation='Teacher-supervised reconstruction can suppress thinking-specific information; lower embedding error is not identity/motion quality.')
|
||||
(out / 'report.json').write_text(json.dumps(report, indent=2))
|
||||
(out / 'manifest.jsonl').write_text(Path(args.manifest).read_text())
|
||||
print(json.dumps(dict(phase='embedding_calibration_complete_gpu_video_validation_pending', output=str(out))), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""H3 video context in Qwen embeddings without a native video-latent reference."""
|
||||
import math
|
||||
import time
|
||||
from dataclasses import replace
|
||||
|
||||
from comfy_extras.nodes_minimax_h3 import MiniMaxH3ReferenceToVideo
|
||||
from comfy_api.latest import io
|
||||
|
||||
|
||||
class _VideoContextClip:
|
||||
"""Request-local adapter; never changes the user's CLIP or cached conditioning."""
|
||||
|
||||
def __init__(self, clip, items):
|
||||
self._clip = clip
|
||||
self._items = items
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._clip, name)
|
||||
|
||||
def tokenize(self, text, **kwargs):
|
||||
items = list(kwargs.get("minimax_ref_items") or [])
|
||||
# Pictures keep their numbering. Context videos precede any explicit
|
||||
# native video/audio references, so the shot remains <Video 1>.
|
||||
index = 0
|
||||
while index < len(items) and items[index].get("type") == "image":
|
||||
index += 1
|
||||
kwargs["minimax_ref_items"] = items[:index] + self._items + items[index:]
|
||||
tokens = self._clip.tokenize(text, **kwargs)
|
||||
def count_video_blocks(value):
|
||||
if isinstance(value, dict):
|
||||
if value.get("minimax_video_block") is True:
|
||||
return 1
|
||||
return sum(count_video_blocks(v) for k, v in value.items() if k != "data")
|
||||
if isinstance(value, (list, tuple)):
|
||||
return sum(count_video_blocks(v) for v in value)
|
||||
return 0
|
||||
expected = sum((item["data"].shape[0] + 1) // 2 for item in self._items)
|
||||
actual = count_video_blocks(tokens)
|
||||
if actual < expected:
|
||||
raise ValueError("CLIP did not produce the H3 video vision blocks. Use the native MiniMax H3 CLIP loader.")
|
||||
print(f"[BFS H3 CLIP context] tokenizer confirmed {actual} video vision blocks "
|
||||
f"(guide requires {expected}); encoding next", flush=True)
|
||||
return tokens
|
||||
|
||||
def encode_from_tokens_scheduled(self, tokens, *args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
result = self._clip.encode_from_tokens_scheduled(tokens, *args, **kwargs)
|
||||
shapes = [tuple(entry[0].shape) for entry in result]
|
||||
print(f"[BFS H3 CLIP context] Qwen encoding complete in {time.perf_counter() - start:.2f}s; "
|
||||
f"conditioning shapes={shapes}. Guide native video reference blocks: 0", flush=True)
|
||||
return result
|
||||
|
||||
|
||||
def clip_with_video_context(clip, videos, length, source_fps=24.0, context_fps=2.0):
|
||||
"""Native H3 24-fps frame convention, presented to Qwen at a selectable sampling rate.
|
||||
|
||||
Only selected frames are resized. No video VAE encoding or minimax_refs
|
||||
video block is created here. Real image reference blocks are unaffected.
|
||||
"""
|
||||
from comfy_extras.nodes_minimax_h3 import (
|
||||
CANVAS_MULTIPLE, _resize, adapt_canvas, temporal_shape,
|
||||
)
|
||||
|
||||
if not math.isfinite(source_fps) or source_fps <= 0:
|
||||
raise ValueError("CLIP video context needs a positive source frame rate")
|
||||
if not math.isfinite(context_fps) or not 0.5 <= context_fps <= 24:
|
||||
raise ValueError("clip_video_fps must be between 0.5 and 24")
|
||||
effective_fps = min(context_fps, source_fps)
|
||||
frame_count = temporal_shape(length)[0]
|
||||
items = []
|
||||
for frames in (videos or {}).values():
|
||||
if frames is None:
|
||||
continue
|
||||
if frames.ndim != 4 or frames.shape[-1] < 3:
|
||||
raise ValueError("CLIP video context needs IMAGE frames [frames, height, width, channels]")
|
||||
n = min(frames.shape[0], frame_count)
|
||||
if n < 5:
|
||||
raise ValueError("H3 CLIP video context needs at least 5 frames at the native 24 fps")
|
||||
n -= (n - 5) % 17
|
||||
height, width = frames.shape[1:3]
|
||||
cw, ch = adapt_canvas(width, height)
|
||||
if width * height < cw * ch:
|
||||
cw = max(CANVAS_MULTIPLE, round(width / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
|
||||
ch = max(CANVAS_MULTIPLE, round(height / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
|
||||
indices = sorted({round(i * source_fps / effective_fps)
|
||||
for i in range(math.ceil(n / source_fps * effective_fps))
|
||||
if round(i * source_fps / effective_fps) < n})
|
||||
selected = _resize(frames[indices], cw, ch, "disabled")
|
||||
times = [index / source_fps for index in indices]
|
||||
preview = times if len(times) <= 6 else times[:3] + times[-3:]
|
||||
print(f"[BFS H3 CLIP context] guide: input={frames.shape[0]} frames at {source_fps:g} fps; "
|
||||
f"used={n}; requested={context_fps:g} fps, effective={effective_fps:g} fps; "
|
||||
f"sent={len(indices)} RGB frames at {cw}x{ch}; "
|
||||
f"timestamps={[round(t, 3) for t in preview]} seconds (first/last); "
|
||||
"native guide video reference latents: OFF", flush=True)
|
||||
items.append({"type": "video", "data": selected,
|
||||
"timestamps": [index / source_fps for index in indices]})
|
||||
return _VideoContextClip(clip, items) if items else clip
|
||||
|
||||
|
||||
class BFSMiniMaxH3ReferenceToVideoGuideContext(MiniMaxH3ReferenceToVideo):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
schema = MiniMaxH3ReferenceToVideo.define_schema()
|
||||
# Keep native image/video/audio reference semantics. The separately
|
||||
# named guide is the only video excluded from native reference latents.
|
||||
inputs = list(schema.inputs)
|
||||
inputs.insert(1, io.Image.Input(
|
||||
"guide_video_0", optional=True,
|
||||
tooltip=("The motion guide / current shot, as native 24-fps frames. Qwen sees it at clip_video_fps "
|
||||
"as <Video 1>; no native guide video reference latents are added. Connect the "
|
||||
"same frames to Add Guide for motion. Other ref_videos start at <Video 2>."),
|
||||
))
|
||||
inputs.append(io.Float.Input(
|
||||
"clip_video_fps", default=2.0, min=0.5, max=24.0, step=0.5,
|
||||
tooltip=("Guide 0 sampling rate for Qwen only. Default/native presentation is 2 fps; "
|
||||
"try 4 or 8 fps. Higher values increase context and encoding cost, not output fps. "
|
||||
"Never duplicates frames above the source rate. Actual reference videos stay native 2 fps."),
|
||||
))
|
||||
for item in inputs:
|
||||
if item.id == "ref_images":
|
||||
item.template = io.Autogrow.TemplatePrefix(
|
||||
input=io.Image.Input("ref_image", tooltip="Reference image, in <Picture N> order"),
|
||||
prefix="ref_image_", min=0, max=100,
|
||||
)
|
||||
elif item.id == "ref_videos":
|
||||
item.tooltip = ("Actual native video references, separate from guide_video_0. With a guide "
|
||||
"connected, these are <Video 2>, <Video 3>, etc. They enter both CLIP and "
|
||||
"native reference latents, as in the original node. Up to 3 reference videos.")
|
||||
item.template.input.tooltip = item.tooltip
|
||||
return replace(
|
||||
schema, node_id="BFSMiniMaxH3ReferenceToVideoGuideContext",
|
||||
display_name="MiniMax H3 Reference to Video — Guide 0 CLIP Context (BFS)",
|
||||
description=("guide_video_0 enters Qwen/CLIP only as <Video 1>, without native guide video "
|
||||
"reference latents. Pictures and other reference videos retain native semantics. "
|
||||
"Use Add Guide separately for aligned motion. The target latent starts empty."),
|
||||
inputs=inputs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, clip, prompt, width, height, length, ref_image_size="match", vae=None,
|
||||
audio_vae=None, guide_video_0=None, ref_images=None, ref_videos=None,
|
||||
ref_video_audios=None, ref_audios=None, clip_video_fps=2.0):
|
||||
if guide_video_0 is not None:
|
||||
clip = clip_with_video_context(clip, {"guide_video_0": guide_video_0}, length, context_fps=clip_video_fps)
|
||||
return MiniMaxH3ReferenceToVideo.execute(
|
||||
clip=clip, prompt=prompt, width=width, height=height, length=length,
|
||||
ref_image_size=ref_image_size, vae=vae, audio_vae=audio_vae,
|
||||
ref_images=ref_images, ref_videos=ref_videos,
|
||||
ref_video_audios=ref_video_audios, ref_audios=ref_audios,
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"BFSMiniMaxH3ReferenceToVideoGuideContext": BFSMiniMaxH3ReferenceToVideoGuideContext,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"BFSMiniMaxH3ReferenceToVideoGuideContext": "MiniMax H3 Reference to Video — Guide 0 CLIP Context (BFS)",
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Experimental cached-case embedding swap, preserving existing H3 latent conditioning."""
|
||||
import os
|
||||
import torch
|
||||
import folder_paths
|
||||
from safetensors import safe_open
|
||||
from safetensors.torch import load_file
|
||||
|
||||
folder_paths.add_model_folder_path('h3_embedding_bridge', os.path.join(folder_paths.models_dir, 'h3_embedding_bridge'))
|
||||
|
||||
|
||||
class BFSH3ExperimentalEmbeddingBridge:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {'required': {
|
||||
'positive': ('CONDITIONING',),
|
||||
'embedding_file': (folder_paths.get_filename_list('h3_embedding_bridge') or ['(no calibrated export)'],),
|
||||
'baseline_tolerance': ('FLOAT', {'default': .02, 'min': 0.0, 'max': .05, 'step': .001,
|
||||
'tooltip': 'Reject cached embeddings for a different input/encoder. Use the matching calibration case, prompt, media interval and encoder. Do not raise this to bypass mismatched inputs.'}),
|
||||
}}
|
||||
|
||||
RETURN_TYPES = ('CONDITIONING', 'STRING')
|
||||
RETURN_NAMES = ('positive', 'experiment_info')
|
||||
FUNCTION = 'apply'
|
||||
CATEGORY = 'BFS/experimental'
|
||||
DESCRIPTION = ('Replace only text/vision embeddings with a calibrated 9B hidden-state bridge export for the SAME cached case. '
|
||||
'Preserves native reference latents and aligned guides. Requires trained bridge exports; this is not a general encoder replacement.')
|
||||
|
||||
def apply(self, positive, embedding_file, baseline_tolerance=.02):
|
||||
if len(positive) != 1:
|
||||
raise ValueError('Experimental bridge supports one native H3 conditioning entry')
|
||||
path = folder_paths.get_full_path_or_raise('h3_embedding_bridge', embedding_file)
|
||||
with safe_open(path, framework='pt') as f:
|
||||
md = f.metadata() or {}
|
||||
if md.get('experiment') != 'h3_9b_hidden_state_bridge_v1':
|
||||
raise ValueError('Select a held-out conditioning export, not bridge weights or another LoRA')
|
||||
data = load_file(path); base = positive[0][0]; source = base[0] if base.ndim == 3 else base
|
||||
expected = data['teacher_baseline'].float(); actual = source.detach().cpu().float()
|
||||
if actual.shape != expected.shape:
|
||||
raise ValueError('Cached case does not match current prompt/media token layout')
|
||||
relative = float((actual - expected).norm() / expected.norm().clamp_min(1e-8))
|
||||
if relative > baseline_tolerance:
|
||||
raise ValueError(f'Cached case mismatch: baseline relative error {relative:.5f}; use exact matching inputs')
|
||||
replacement = data['conditioning']; tags = data['minimax_token_tags']
|
||||
if replacement.shape != expected.shape or len(tags) != len(replacement) or not torch.isfinite(replacement).all():
|
||||
raise ValueError('Invalid bridge conditioning export')
|
||||
current_tags = positive[0][1].get('minimax_token_tags')
|
||||
if current_tags is None or not torch.equal(current_tags.detach().cpu().reshape(-1).long(), tags.reshape(-1).long()):
|
||||
raise ValueError('Cached case has different modality tags')
|
||||
values = dict(positive[0][1])
|
||||
values['minimax_token_tags'] = tags.to(current_tags.device)
|
||||
replacement = replacement.to(device=base.device, dtype=base.dtype)
|
||||
if base.ndim == 3:
|
||||
replacement = replacement[None]
|
||||
info = (f"EXPERIMENTAL {md['source_id']}: thinking={md['thinking']}, "
|
||||
f"generated states zeroed={md['generated_states_zeroed']}; "
|
||||
f"baseline error={relative:.5f}; H3 quality not validated")
|
||||
print('[BFS H3 embedding bridge] ' + info, flush=True)
|
||||
return [[replacement, values]], info
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {'BFSH3ExperimentalEmbeddingBridge': BFSH3ExperimentalEmbeddingBridge}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {'BFSH3ExperimentalEmbeddingBridge': 'BFS H3 Experimental 9B Embedding Bridge (Cached Case)'}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "bfsnodes"
|
||||
description = "Custom ComfyUI nodes for reference-guided video: BFS Sampler (guide-driven IC-LoRAs, auto crop, mask inpainting, temporal chunking), LTX Multiple Controls / Identity Transfer / Edit Anything, Head-Swap and Anime2Real conditioning, MiniMax-H3 latent guides, RoPE layouts and loaders, LoRA Surgery, and Shot Planner / Shot Loop (tabbed panel): split long videos into shots at camera cuts, face-tracked cast, SAM 3 mask per shot with full frame, paste, mask, crop and crop + mask generation modes, optional translucent Colored Mark overlay for manually selected regions, optional Qwen3-VL to suggest settings, describe references ({details}) and the person to replace ({target}), continuity between shots, H3 conditioning with an optional duet and prompt writer, join with the original timing, audio and a side-by-side comparison. H3 Duet: pin a source clip beside the video, training-free."
|
||||
version = "1.115.1"
|
||||
version = "1.115.3"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"torch",
|
||||
|
||||
@@ -1575,3 +1575,58 @@ class FillDetailsTest(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class RedoRecoveryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.directory = Path(self.tmp.name)
|
||||
self.plan = {"video": "test.mp4"}
|
||||
self.state = {"done": [0, 1], "count": 2, "sig": {"0": "a", "1": "b"},
|
||||
"gen": {"0": "old", "1": "next"}, "from": {"1": "old"},
|
||||
"cur": {"sigs": ["a", "b"], "prev": [False, True]}}
|
||||
for i in range(2):
|
||||
(self.directory / f"shot_{i:04d}.pt").write_bytes(b"saved take")
|
||||
for name, value in (("_input_path", lambda _: "video"), ("run_key", lambda *_: "test"),
|
||||
("run_dir", lambda *a, **kw: str(self.directory))):
|
||||
patcher = patch.object(SL, name, value)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
SL.save_state("test", self.state)
|
||||
|
||||
def test_redo_can_be_cancelled_with_continuity_restored(self):
|
||||
self.assertEqual(SL.redo_shots(self.plan, [0])["ok"], [False, False])
|
||||
result = SL.redo_shots(self.plan, [0], cancel=True)
|
||||
self.assertEqual(result["ok"], [True, True])
|
||||
self.assertEqual(result["redo"], [])
|
||||
|
||||
def test_reset_preserves_files_and_state(self):
|
||||
SL.redo_shots(self.plan, [0], cascade=False)
|
||||
result = SL.cache_status(self.plan, reset=True)
|
||||
self.assertEqual(result["ok"], [True, True])
|
||||
self.assertEqual((self.directory / "shot_0000.pt").read_bytes(), b"saved take")
|
||||
self.assertNotIn("0", SL.run_state("test").get("keep_next", {}))
|
||||
self.assertEqual(SL.run_state("test")["gen"], self.state["gen"])
|
||||
|
||||
def test_repeated_selection_does_not_overwrite_undo(self):
|
||||
SL.redo_shots(self.plan, [0])
|
||||
SL.redo_shots(self.plan, [0])
|
||||
self.assertEqual(SL.redo_shots(self.plan, [0], cancel=True)["ok"], [True, True])
|
||||
|
||||
def test_cancel_does_not_make_changed_settings_valid(self):
|
||||
SL.redo_shots(self.plan, [0])
|
||||
st = SL.run_state("test")
|
||||
st["cur"]["sigs"][0] = "changed seed"
|
||||
SL.save_state("test", st)
|
||||
self.assertEqual(SL.redo_shots(self.plan, [0], cancel=True)["ok"], [False, False])
|
||||
|
||||
def test_cancel_does_not_restore_replaced_take(self):
|
||||
SL.redo_shots(self.plan, [0])
|
||||
st = SL.run_state("test")
|
||||
st["gen"]["0"] = "new"
|
||||
SL.save_state("test", st)
|
||||
self.assertEqual(SL.redo_shots(self.plan, [0], cancel=True)["ok"], [False, False])
|
||||
|
||||
def test_reset_without_selection_preserves_completed_run(self):
|
||||
self.assertEqual(SL.cache_status(self.plan, reset=True)["ok"], [True, True])
|
||||
|
||||
@@ -6,6 +6,7 @@ import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
@@ -65,6 +66,47 @@ class CompareNodeTest(unittest.TestCase):
|
||||
n += 1
|
||||
self.assertEqual(n, 18, k)
|
||||
|
||||
def test_all_shots_keep_guide_and_target_order_across_chunk_boundaries(self):
|
||||
class Writer:
|
||||
videos = {}
|
||||
def __init__(self, path, *args, **kwargs):
|
||||
self.frames = self.videos.setdefault(Path(path).name, [])
|
||||
def write(self, frames):
|
||||
self.frames.append(frames.clone())
|
||||
def close(self):
|
||||
pass
|
||||
# Scene 1 red, scene 2 green: 22 frames span the 16-frame encoder chunk.
|
||||
guide = [torch.zeros(10, 32, 48, 3), torch.zeros(12, 32, 48, 3)]
|
||||
guide[0][..., 0] = 1
|
||||
guide[1][..., 1] = 1
|
||||
target = [x.clone() for x in guide]
|
||||
with patch.object(VC.VIO, "Mp4Writer", Writer):
|
||||
out = VC.BFSVideoCompare().compare(guide, target, preview_height=32)
|
||||
self.assertEqual(out["ui"]["bfs_compare"][0]["frames"], 22)
|
||||
self.assertEqual(out["result"][2], 0)
|
||||
for name in ("guide.mp4", "target.mp4"):
|
||||
frames = torch.cat(Writer.videos[name])
|
||||
self.assertEqual(frames.shape[0], 22)
|
||||
self.assertEqual(frames[:10, 0, 0].tolist(), [[1., 0., 0.]] * 10)
|
||||
self.assertEqual(frames[10:, 0, 0].tolist(), [[0., 1., 0.]] * 12)
|
||||
|
||||
def test_joined_video_can_match_multiple_equal_timing_shots(self):
|
||||
g = [torch.zeros(3, 32, 48, 3), torch.ones(4, 32, 48, 3)]
|
||||
self.assertEqual(VC.video_chunk(g, 2, 6, 32, 48)[:, 0, 0, 0].tolist(), [0., 1., 1., 1.])
|
||||
out = VC.BFSVideoCompare().compare(g, torch.cat(g), preview_height=32)
|
||||
self.assertEqual(out["ui"]["bfs_compare"][0]["frames"], 7)
|
||||
self.assertEqual(out["result"][2], 0)
|
||||
|
||||
def test_misaligned_shot_lists_require_join_timing_instead_of_silent_truncation(self):
|
||||
g = [torch.zeros(5, 32, 48, 3), torch.zeros(7, 32, 48, 3)]
|
||||
t = [torch.zeros(6, 32, 48, 3), torch.zeros(6, 32, 48, 3)]
|
||||
with self.assertRaisesRegex(ValueError, "SAME Join"):
|
||||
VC.BFSVideoCompare().compare(g, t)
|
||||
with self.assertRaisesRegex(ValueError, "SAME Join"):
|
||||
VC.BFSVideoCompare().compare(g, torch.zeros(8, 32, 48, 3))
|
||||
with self.assertRaisesRegex(ValueError, "nonempty"):
|
||||
VC.BFSVideoCompare().compare([], t)
|
||||
|
||||
def test_reference_pictures_go_to_the_player_no_taller_than_it(self):
|
||||
from PIL import Image
|
||||
guide, target = torch.rand(4, 32, 48, 3), torch.rand(4, 32, 48, 3)
|
||||
|
||||
@@ -108,7 +108,7 @@ function Panel(io) {
|
||||
plan.auto_prompts = { ...(p.auto_prompts || {}) };
|
||||
if (!Array.isArray(plan.bounds)) plan.bounds = [];
|
||||
if (!Array.isArray(plan.segs)) plan.segs = [];
|
||||
// plans before 1.113 had two references (ref / ref2): now a list of up to 12 pictures
|
||||
// plans before 1.113 had two references (ref / ref2): now a list of up to MAX_REFS pictures
|
||||
plan.global_refs = refList(p.global_refs, p.global_ref, p.global_ref2);
|
||||
plan.segs.forEach(m => { if (m) m.refs = refList(m.refs, m.ref, m.ref2); });
|
||||
// Colored Mark: a color per picture (was: two marker colors), regions tied to a picture (was: region 2)
|
||||
@@ -639,9 +639,9 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// ---- stored shots: redo some, or combine what is stored into an MP4 (low memory, on the server)
|
||||
async function redo(shots, cascade = true) {
|
||||
async function redo(shots, cascade = true, cancel = false) {
|
||||
try {
|
||||
const r = await api.fetchApi("/bfs/shotloop/redo", { method: "POST", body: JSON.stringify({ plan: { ...plan }, shots, cascade }) });
|
||||
const r = await api.fetchApi("/bfs/shotloop/redo", { method: "POST", body: JSON.stringify({ plan: { ...plan }, shots, cascade, cancel }) });
|
||||
const j = await r.json(); if (j.error) error.value = j.error; else { Object.assign(cache, j); prog.done = j.done; }
|
||||
} catch (e) { error.value = `${e}`; }
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ function buildPlayer(host) {
|
||||
const r = strip.getBoundingClientRect(), n = st.data?.frames || 0;
|
||||
if (n) seek(Math.min(n - 1, Math.max(0, Math.floor((e.clientX - r.left) / r.width * n))));
|
||||
});
|
||||
const notes = el("div", {style:"white-space:pre-wrap;font-size:12px;padding:6px;color:#e6b566"});
|
||||
host.append(el("div", { class: "bfsvc" }, [
|
||||
el("div", { class: "row" }, [el("span", {}, "top"), selTop, btnSwap, el("span", {}, "bottom"), selBot, selMode, btnFull]),
|
||||
stage,
|
||||
@@ -132,6 +133,7 @@ function buildPlayer(host) {
|
||||
el("div", { class: "row" }, [btnPrev, btnPlay, btnNext, scrub, btnMute, info]),
|
||||
el("div", { class: "row" }, [btnSave, btnRec, selFormat, el("label", { title: "Draw the names on the saved / recorded video" }, [chkLabels, "labels"]), lblRefs]),
|
||||
strip,
|
||||
notes,
|
||||
]));
|
||||
|
||||
const fps = () => st.data?.fps || 24;
|
||||
@@ -456,6 +458,7 @@ function buildPlayer(host) {
|
||||
lblRefs.style.display = d.refs?.length ? "flex" : "none";
|
||||
showRefs();
|
||||
st.data = d;
|
||||
notes.textContent = (d.notes || []).join("\n");
|
||||
empty.style.display = "none";
|
||||
scrub.max = String(Math.max(0, d.frames - 1));
|
||||
refreshSelects();
|
||||
|
||||
@@ -61,14 +61,14 @@ Three run modes:
|
||||
stored on disk; then BFS Shot Join expands the part of the graph between the planner and itself (ComfyUI node
|
||||
expansion) for the next shot, so that shot is conditioned *after* the previous one exists and continuity
|
||||
(*head* / *history*) works. No browser re-queue (the tab can be closed), Save Video fires once with the full video,
|
||||
and after a failure a new Queue resumes from the stored shots (*Reset loop* starts over). Previews connected inside
|
||||
and after a failure a new Queue resumes from the stored shots (*Redo all* regenerates every shot). Previews connected inside
|
||||
the shot's part of the graph show every shot. Needs exactly one BFS Shot Planner before the join. Same idea as the
|
||||
recursive loop of Ethanfel's MiniMax H3 Context Loop.
|
||||
- **Queue loop (one shot per run)**: each run generates the next pending shot and stores it on
|
||||
disk. The join blocks every node after it until the last shot, then outputs the full video,
|
||||
so a Save Video node only fires once. With *Auto-queue the next shot* on (default), the panel re-queues
|
||||
the workflow after each shot, so one click still renders the whole video (keep the ComfyUI tab open: the panel
|
||||
does the re-queue). *Reset loop* starts over. Changing the plan starts a new run. It resumes from the stored
|
||||
does the re-queue). *Redo all* regenerates every shot. Changing the plan starts a new run. It resumes from the stored
|
||||
shots after a failure, and continuity *head* / *history* needs it: each shot reads the result of the one before.
|
||||
- **Auto loop (all shots at once)**: every shot in one queue run, each node runs for every shot before the next node.
|
||||
Holds all decoded shots in memory. Continuity *head* / *history* does not work here (all shots are conditioned
|
||||
@@ -721,3 +721,11 @@ or *Crop + mask*.
|
||||
| Only the face or the hair is described | the click was on the head: click the body |
|
||||
| The other person is described | add an exclude point (right-click) on the other person, **👁 Segment**, save, describe again |
|
||||
| The swap still hits the wrong person | make the description more specific (position: "on the left", clothing colours) |
|
||||
|
||||
### Cancel a pending redo
|
||||
|
||||
After marking a stored shot for redo, click **Undo redo** to keep its previous take.
|
||||
**Clear redo selection** cancels all pending redo selections and preserves stored files and loop progress.
|
||||
Once a shot has been regenerated, its previous take cannot be restored through this control.
|
||||
**Refresh status** rereads the cache status; it cannot recover files deleted by older versions of Reset loop.
|
||||
Changing the seed, model or other render settings still invalidates stored shots even after cancelling redo.
|
||||
|
||||
@@ -472,7 +472,7 @@ export const secondPassSize = (w, h, sp) => {
|
||||
};
|
||||
|
||||
// ---- reference pictures (<Picture 1..12>)
|
||||
export const MAX_REFS = 12;
|
||||
export const MAX_REFS = 100;
|
||||
// a reference list of the plan: `refs` (plans from 1.113 on), else the old ref / ref2 pair; empty slots at the end dropped
|
||||
export const refList = (refs, a = "", b = "") => {
|
||||
const out = (Array.isArray(refs) ? refs : [a, b]).map(x => (typeof x === "string" ? x : "")).slice(0, MAX_REFS);
|
||||
|
||||
@@ -40,7 +40,7 @@ function loopSection(c) {
|
||||
h("div", { class: "prog" }, [h("div", { style: `width:${(100 * prog.done / Math.max(1, prog.count || active.value.length)).toFixed(1)}%` })]),
|
||||
h("div", { class: "row", style: "margin-top:6px" }, [
|
||||
plan.run === "queue" ? check(plan.auto_continue, v => c.setPlan("auto_continue", v), "Auto-queue the next shot") : null,
|
||||
h("button", { onClick: () => c.progress(false) }, "↻ Status"), h("button", { class: "dng", onClick: () => c.progress(true) }, "⟲ Reset loop"),
|
||||
h("button", { onClick: () => c.progress(false) }, "↻ Refresh status"), h("button", { title: "Cancel pending redo selections. Stored shots and loop progress are preserved.", onClick: () => c.progress(true) }, "Clear redo selection"),
|
||||
]),
|
||||
hint(plan.run === "queue"
|
||||
? "Each run generates one shot and stores it. Nodes after BFS Shot Join only run on the last shot, with the full video."
|
||||
@@ -91,7 +91,8 @@ function cacheSection(c) {
|
||||
const i = c.segs.value.indexOf(active.value[k]);
|
||||
return h("span", { class: ["pill", g ? "ok" : ""], title: g ? "stored and up to date" : "runs on the next Queue" }, [
|
||||
`#${(i >= 0 ? i : k) + 1} ${g ? "💾" : "⏳"}`,
|
||||
g ? h("button", { class: "chip", style: "margin-left:4px", title: "generate this shot again on the next Queue"
|
||||
(cache.redo || []).includes(k) ? h("button", { class: "chip", title: "Cancel redo and keep the stored take",
|
||||
onClick: () => c.redo([k], true, true) }, "Undo redo") : g ? h("button", { class: "chip", style: "margin-left:4px", title: "generate this shot again on the next Queue"
|
||||
+ (plan.redo_only ? " (only this shot)" : " (and the shots that continue from it)"),
|
||||
onClick: () => c.redo([k], !plan.redo_only) }, "↻") : null]);
|
||||
}) : [hint(n ? "Nothing stored yet for this video: Queue the workflow." : "")]),
|
||||
|
||||
Reference in New Issue
Block a user