Add GGUF warm-up overlap writer

This commit is contained in:
kokotu
2026-07-13 20:54:33 +08:00
parent e2fc271ce1
commit 0d11cb4e8a
3 changed files with 268 additions and 3 deletions
+6
View File
@@ -47,6 +47,12 @@ ComfyUI-VideoHelperSuite 的常规 RunPod 环境通常已经具备 FFmpeg。
- 第二段开始自动跳过完整的重复区。
- 最后一段完成时关闭文件并返回最终 MP4。
- 最终完成后在同一个节点内显示播放器和下载按钮。
- `DaSiWa AutoLong GGUF Warm-up Stream Writer`
- 面向 DaSiWa GGUF 原工作流,不需要原版 Wan2.2 模型或任何加速 LoRA。
- 每段多生成一小段隐藏的预热区;下一段从预热区起点重新生成,再对两段重叠区做余弦融合。
- `warmup_frames = 16` 表示在 16 FPS 原始帧中保留 1 秒预热区;配合 2 倍 RIFE 时,生成 97 帧、最终每段写入 160 帧(5 秒)。
- 只在 CPU 内存中暂存一小段 8-bit 重叠帧,不累计完整视频。
- 最后一段完成时在同一个节点内显示播放器和下载按钮。
节点仓库只包含节点代码,不包含任何第三方工作流或工作流 JSON。
+261 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import copy
import math
import os
import shutil
import subprocess
@@ -48,6 +49,8 @@ def _reset_session(session_id: str) -> dict:
"run_id": uuid.uuid4().hex,
"process": None,
"last_frame": None,
"rewind_frame": None,
"pending_tail": None,
"last_latent": None,
"output_path": None,
"subfolder": "",
@@ -175,6 +178,61 @@ def _write_frames(session: dict, frames, skip_count: int) -> int:
return written
def _frames_to_uint8_cpu(frames):
return (
frames.detach()
.clamp(0.0, 1.0)
.mul(255.0)
.byte()
.cpu()
.contiguous()[..., :3]
.clone()
)
def _write_blended_frames(session: dict, previous_frames, current_frames) -> int:
"""Cosine blend two equally-sized IMAGE batches without retaining float copies."""
count = int(previous_frames.shape[0])
if count != int(current_frames.shape[0]):
raise RuntimeError(
"Warm-up overlap size changed between segments: "
f"{count} previous frames vs {int(current_frames.shape[0])} current frames."
)
if tuple(previous_frames.shape[1:3]) != tuple(current_frames.shape[1:3]):
raise RuntimeError("Warm-up overlap resolution changed between segments.")
process = session["process"]
written = 0
try:
for position, (previous, current) in enumerate(
zip(previous_frames, current_frames)
):
current = (
current.detach()
.clamp(0.0, 1.0)
.mul(255.0)
.byte()
.cpu()
.contiguous()[..., :3]
)
if count == 1:
weight = 1.0
else:
phase = position / (count - 1)
weight = 0.5 - 0.5 * math.cos(math.pi * phase)
blended = torch.lerp(previous.float(), current.float(), weight)
process.stdin.write(blended.round().clamp(0, 255).byte().numpy().tobytes())
written += 1
except (BrokenPipeError, OSError) as error:
details = b""
if process.stderr:
details = process.stderr.read()
message = details.decode("utf-8", errors="replace").strip()
raise RuntimeError(f"FFmpeg stopped while encoding: {message or error}") from error
return written
def _finish_encoder(session: dict) -> None:
process = session["process"]
try:
@@ -275,11 +333,16 @@ class DaSiWaAutoLongStart:
motion_latent_count = 0
else:
session = _SESSIONS.get(session_id)
if not session or session.get("last_frame") is None:
continuation_frame = None
if session:
continuation_frame = session.get("rewind_frame")
if continuation_frame is None:
continuation_frame = session.get("last_frame")
if continuation_frame is None:
raise RuntimeError(
"AutoLong continuation state is missing. Set iteration back to 0 and queue again."
)
start_image = session["last_frame"]
start_image = continuation_frame
if session.get("last_latent") is None:
previous_latent = {"samples": torch.zeros((1, 16, 1, 1, 1))}
motion_latent_count = 0
@@ -517,14 +580,210 @@ class DaSiWaAutoLongStream(DaSiWaAutoLongStreamSVIPro):
)
class DaSiWaAutoLongWarmupStream:
"""GGUF-only writer that hides motion restarts inside a short overlap."""
PRESETS = DaSiWaAutoLongStreamSVIPro.PRESETS
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"frames": ("IMAGE",),
"raw_frames": ("IMAGE",),
"index": ("INT", {"forceInput": True}),
"total_segments": ("INT", {"forceInput": True}),
"session_id": ("STRING", {"forceInput": True}),
"frame_rate": (
"FLOAT",
{"default": 32.0, "min": 1.0, "max": 240.0},
),
"warmup_frames": (
"INT",
{"default": 16, "min": 1, "max": 32, "step": 1},
),
"interpolation_multiplier": (
"INT",
{"default": 2, "min": 1, "max": 16, "step": 1},
),
"crf": ("INT", {"default": 17, "min": 0, "max": 51, "step": 1}),
"preset": (cls.PRESETS, {"default": "medium"}),
"filename_prefix": (
"STRING",
{"default": "video/DaSiWa_GGUF_WARMUP"},
),
},
"hidden": {
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("VHS_FILENAMES", "STRING")
RETURN_NAMES = ("filenames", "video_path")
OUTPUT_NODE = True
FUNCTION = "append_segment"
CATEGORY = "DaSiWa/Auto Long Video"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan")
def append_segment(
self,
frames,
raw_frames,
index,
total_segments,
session_id,
frame_rate,
warmup_frames,
interpolation_multiplier,
crf,
preset,
filename_prefix,
prompt=None,
unique_id=None,
):
index = int(index)
total_segments = int(total_segments)
warmup_frames = int(warmup_frames)
interpolation_multiplier = int(interpolation_multiplier)
if index < 0 or index >= total_segments:
raise RuntimeError(f"Invalid segment index {index} for total {total_segments}.")
if len(frames.shape) != 4 or frames.shape[-1] < 3:
raise RuntimeError(f"Expected IMAGE frames in NHWC format, got {tuple(frames.shape)}.")
if len(raw_frames.shape) != 4 or raw_frames.shape[-1] < 3:
raise RuntimeError(
f"Expected raw IMAGE frames in NHWC format, got {tuple(raw_frames.shape)}."
)
raw_count = int(raw_frames.shape[0])
stride_raw = raw_count - warmup_frames - 1
if stride_raw <= warmup_frames:
raise RuntimeError(
"The raw segment is too short for this warm-up. Increase the generated "
"frame count or reduce warmup_frames."
)
expected_frames = interpolation_multiplier * (raw_count - 1) + 1
actual_frames = int(frames.shape[0])
if actual_frames != expected_frames:
raise RuntimeError(
"Unexpected interpolated frame count: received "
f"{actual_frames}, expected {expected_frames} from {raw_count} raw frames "
f"at {interpolation_multiplier}x."
)
tail_start = interpolation_multiplier * stride_raw
tail_count = actual_frames - tail_start
if tail_start <= tail_count:
raise RuntimeError(
"Warm-up overlap is longer than the visible segment. Reduce warmup_frames."
)
height, width = int(frames.shape[1]), int(frames.shape[2])
if width % 2 or height % 2:
raise RuntimeError("H.264 yuv420p output requires even width and height.")
with _LOCK:
session = _SESSIONS.get(str(session_id))
if session is None:
raise RuntimeError("AutoLong session not found. Set iteration to 0 and queue again.")
if index == 0:
_start_encoder(
session,
width,
height,
float(frame_rate),
int(crf),
preset,
filename_prefix,
)
elif session.get("process") is None:
raise RuntimeError("AutoLong FFmpeg stream is not active.")
if (session["width"], session["height"]) != (width, height):
raise RuntimeError("Frame size changed between segments; continuous encoding is impossible.")
if abs(session["frame_rate"] - float(frame_rate)) > 0.001:
raise RuntimeError("Frame rate changed between segments.")
if index == 0:
written = _write_frames(session, frames[:tail_start], skip_count=0)
else:
pending_tail = session.get("pending_tail")
if pending_tail is None:
raise RuntimeError(
"Warm-up overlap state is missing. Set iteration back to 0 and queue again."
)
if int(pending_tail.shape[0]) != tail_count:
raise RuntimeError(
"Warm-up settings changed between segments. Keep frame count, "
"warmup_frames and interpolation_multiplier unchanged."
)
written = _write_blended_frames(
session, pending_tail, frames[:tail_count]
)
written += _write_frames(
session, frames[tail_count:tail_start], skip_count=0
)
output_path = session["output_path"]
is_final = index + 1 >= total_segments
if is_final:
session["pending_tail"] = None
session["rewind_frame"] = None
_finish_encoder(session)
else:
rewind_frame = raw_frames[stride_raw : stride_raw + 1]
session["rewind_frame"] = rewind_frame.detach().cpu().clone()
session["last_frame"] = session["rewind_frame"]
session["pending_tail"] = _frames_to_uint8_cpu(
frames[tail_start:]
)
_requeue_current_workflow(str(session_id), index + 1)
print(
f"[DaSiWa AutoLong Warm-up] Wrote {written} frames for segment "
f"{index + 1}/{total_segments}: {output_path}"
)
filenames = (True, [output_path])
if is_final:
ui = {
"gifs": [
{
"filename": session["filename"],
"subfolder": session["subfolder"],
"type": "output",
"format": "video/h264-mp4",
}
],
"text": [
f"Completed {total_segments} GGUF warm-up segments: {output_path}"
],
}
else:
ui = {
"text": [
f"Segment {index + 1}/{total_segments} complete; "
"next warm-up segment queued automatically."
]
}
return {"ui": ui, "result": (filenames, output_path)}
NODE_CLASS_MAPPINGS = {
"DaSiWaAutoLongStart": DaSiWaAutoLongStart,
"DaSiWaAutoLongStream": DaSiWaAutoLongStream,
"DaSiWaAutoLongStreamSVIPro": DaSiWaAutoLongStreamSVIPro,
"DaSiWaAutoLongWarmupStream": DaSiWaAutoLongWarmupStream,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DaSiWaAutoLongStart": "DaSiWa AutoLong Start",
"DaSiWaAutoLongStream": "DaSiWa AutoLong Stream Writer",
"DaSiWaAutoLongStreamSVIPro": "DaSiWa AutoLong SVI Pro Stream Writer",
"DaSiWaAutoLongWarmupStream": "DaSiWa AutoLong GGUF Warm-up Stream Writer",
}
+1 -1
View File
@@ -13,7 +13,7 @@ app.registerExtension({
name: "DaSiWa.AutoLong.Preview",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!["DaSiWaAutoLongStream", "DaSiWaAutoLongStreamSVIPro"].includes(nodeData?.name)) return;
if (!["DaSiWaAutoLongStream", "DaSiWaAutoLongStreamSVIPro", "DaSiWaAutoLongWarmupStream"].includes(nodeData?.name)) return;
chainCallback(nodeType.prototype, "onNodeCreated", function () {
const container = document.createElement("div");