diff --git a/README.md b/README.md index 68cb482..c9b39b7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Rui-Node🐶 是一个功能丰富的 ComfyUI 节点集合,提供图像处理 - [素材拆分(带透明通道) / Sprite Splitter RGBA](#15-素材拆分带透明通道--sprite-splitter-rgba) - [满屏文字水印 / Full-Screen Text Watermark](#21-满屏文字水印--full-screen-text-watermark) - [像素化 / Pixelate](#24-像素化--pixelate) +- [八方向序列拆分 / 8-Direction Sprite Split](#25-八方向序列拆分--8-direction-sprite-split) ### 📁 文件存储与加载类 - [按路径加载图像 / Load Image By Path](#3-按路径加载图像--load-image-by-path) @@ -1023,6 +1024,58 @@ WAS Node Suite 的「Text Multiline」会把 `#` 开头的行**当注释删除** --- +### 25. 八方向序列拆分 / 8-Direction Sprite Split + +**分类**: `Rui-Node🐶/图像调节🎨` + +**功能描述**: +用于 **8 方向行走动画** 制作管线:把每帧都排布着 8 个朝向的雪碧图序列,一次拆成 8 条各自独立、可直接成片的动画序列,并完成方向编号与分组。 + +**完整管线与分工**: + +| 步骤 | 由谁完成 | +|:-----|:---------| +| 角色图 → 八方向静态图 | GPTimage2 等外部工具 | +| 静态图 → 循环行走视频 | Seedance 首尾帧等外部工具 | +| 视频 → 序列帧(可选帧率)| VHS「Load Video」的 `force_rate` | +| **拆分 + 编号 + 分组 + 8 队列输出** | **本节点** | +| 8 组透明 PNG 序列帧 | `SaveImage`(4 通道输入会自动存成 RGBA)| +| 8 个透明 webm | VHS「Video Combine」,`format=video/webm` + `pix_fmt=yuva420p` | + +整条链路只有拆分环节是缺失的,其余全部复用成熟实现。 + +**输入参数**: +- `images` (IMAGE): 视频转出的序列帧 +- `grid_cols` / `grid_rows` / `empty_cells`: 网格布局。3×3 中间留空即 `empty_cells=4`(序号行优先、从 0 开始) +- `direction_names` (STRING): 按「跳过空格后的先后顺序」命名,默认 `SW,S,SE,W,E,NW,N,NE`,对应行 1 面向观众、行 3 背对观众的排布 +- `bg_mode` / `bg_threshold` / `edge_softness`: 白底转透明 +- `fragment_threshold` (FLOAT): 清掉面积不足主体这一比例的连通碎片 +- `auto_crop` / `crop_padding`: 按内容裁剪 + +**输出**: `dir_1` ~ `dir_8` (IMAGE,**4 通道 RGBA**) + `info` (STRING) + +**三个关键设计**: + +1. **用固定网格而非连通区域拆分**(本仓库的[素材拆分](#14-素材拆分--sprite-splitter)节点)。连通区域按包围盒排序,角色走动时位置浮动,一旦跨过排序行界方向就会错乱——上百帧里错一帧整条动画就废了;且每个 sprite 按各自 bbox 裁剪、尺寸不一,无法合成视频。固定网格没有这两个问题。(连通区域拆分依然更适合单张静态合图,两者各有用途。) + +2. **白底转透明用边缘连通性判断**。角色常穿白衣服,按亮度阈值一刀切会把白衬衫一起掏空。这里只把**与画面边缘相连**的白色判为背景,被角色包围的白色一律保留。 + +3. **裁剪框取全序列并集**。逐帧各自裁剪会导致尺寸不一且角色在帧间跳动;取并集则整条序列尺寸一致、位置连贯。 + +**实测**(97 帧 834×1112 的真实素材): + +| 项目 | 结果 | +|:-----|:-----| +| 拆分耗时 | 约 4 秒,8 方向 × 97 帧 | +| 输出尺寸 | 150×335 ~ 206×326,方向内完全一致 | +| 方向稳定性 | 跨帧内容重心极差 0.5~12 px(走路摆动的正常范围,无跳变)| +| 碎片清理效果 | E 方向 172×370 → 150×335,重心极差 8.0 → **0.5 px** | +| webm 透明 | 导出后回读**透明像素占比 0.635**,与素材一致 | + +⚠️ **验证 webm 透明时容易被误导**:alpha 存放在 WebM 的独立边带里,`ffprobe` 看主流会显示 `yuv420p`,用 ffmpeg 默认解码回读也会得到全不透明——这是**内置 vp9 解码器不处理 alpha 边带**所致,并非文件丢了透明。需显式加 `-c:v libvpx-vp9` 解码才能读到。播放器与 Unity/Godot 走的是 libvpx,能正确读取。 + +--- + ## 🐕 关于 Rui-Node🐶 Rui-Node🐶 致力于为 ComfyUI 用户提供实用、高效的节点工具集。🐶 是我们的项目标志,代表着忠诚、友好和可靠。 diff --git a/__init__.py b/__init__.py index 9e0e73c..fc65775 100644 --- a/__init__.py +++ b/__init__.py @@ -69,6 +69,14 @@ except Exception as _e: print(f"[Ruinode] FeyNobg 抠图 节点未加载:{_e}") FEYNOBG_NODE_CLASS_MAPPINGS = {} FEYNOBG_NODE_DISPLAY_NAME_MAPPINGS = {} +# 新增:八方向序列拆分节点(8方向行走动画管线) +try: + from .eightdir_node import NODE_CLASS_MAPPINGS as EIGHTDIR_NODE_CLASS_MAPPINGS + from .eightdir_node import NODE_DISPLAY_NAME_MAPPINGS as EIGHTDIR_NODE_DISPLAY_NAME_MAPPINGS +except Exception as _e: + print(f"[Ruinode] 八方向序列拆分 节点未加载:{_e}") + EIGHTDIR_NODE_CLASS_MAPPINGS = {} + EIGHTDIR_NODE_DISPLAY_NAME_MAPPINGS = {} # 新增:像素化节点(面向像素游戏资产:网格检测/主导色降采样/调色板量化/抖动) try: from .pixelate_node import NODE_CLASS_MAPPINGS as PIXELATE_NODE_CLASS_MAPPINGS @@ -120,6 +128,7 @@ NODE_CLASS_MAPPINGS.update(WATERMARK_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(FEYNOBG_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(LUCIDA_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(PIXELATE_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(EIGHTDIR_NODE_CLASS_MAPPINGS) # 合并节点显示名称映射 NODE_DISPLAY_NAME_MAPPINGS = {} @@ -147,5 +156,6 @@ NODE_DISPLAY_NAME_MAPPINGS.update(WATERMARK_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(FEYNOBG_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(LUCIDA_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(PIXELATE_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(EIGHTDIR_NODE_DISPLAY_NAME_MAPPINGS) __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/eightdir_node.py b/eightdir_node.py new file mode 100644 index 0000000..6c3232b --- /dev/null +++ b/eightdir_node.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +""" +八方向雪碧图序列拆分节点(Ruinode) +=================================== +用于「8 方向行走动画」制作管线:把每帧排布着 8 个朝向的雪碧图序列, +拆成 8 条各自独立、可直接成片的动画序列。 + +典型管线: + 角色图 → (GPTimage2) 八方向静态图 → (Seedance 首尾帧) 循环行走视频 + → VHS「Load Video」转序列帧(可选帧率) + → 【本节点】拆分 + 方向编号 + 分组 + → 8×SaveImage 出序列帧,8×VHS「Video Combine」出透明 webm + +为什么用固定网格而不是连通区域拆分(本仓库的 RuiSpriteSplitterRGBA): +- 连通区域按包围盒排序,角色走动时位置会浮动,一旦跨过排序的行界, + 方向对应关系就错乱 —— 几十上百帧里错一帧,整条动画就废了; +- 连通区域按各自 bbox 裁剪,每个 sprite 尺寸不同,无法直接合成视频。 +固定网格没有这两个问题:格子位置恒定,方向对应天然稳定,尺寸也一致。 +(连通区域拆分依然更适合单张静态合图,两者各有用武之地。) + +白底转透明的关键点:角色身上常有白色衣物,按亮度阈值一刀切会把白衬衫 +一起掏空。这里改为**从画面边缘做连通性判断**:只有与边缘相连的白色才算背景, +被角色包围的白色(衣服、高光)一律保留。 +""" +import numpy as np +import torch + +try: + import scipy.ndimage as ndi + _HAS_SCIPY = True +except Exception: # 理论上 ComfyUI 环境都有 + _HAS_SCIPY = False + +MAX_DIRS = 8 + +_BG_MODES = { + "白底转透明(推荐)": "white", + "已带透明通道": "keep", + "不处理(输出不透明)": "none", +} + +# 与 3×3 中间留空的常见排布对应:行 1 面向观众、行 3 背对观众 +_DEFAULT_NAMES = "SW,S,SE,W,E,NW,N,NE" + + +def _white_to_alpha(rgb, threshold, softness): + """ + 白底 → alpha。rgb: (H,W,3) float[0,1],返回 (H,W) float[0,1]。 + + 先按「离白色多远」算出软 alpha 保住边缘抗锯齿,再用连通性把 + 与画面边缘相连的白色判为背景 —— 只有这一部分才真正抹成全透明。 + 这样角色内部的白衬衫、白高光不会被误伤。 + """ + dist = 1.0 - rgb.min(axis=2) # 纯白=0,越大越不白 + cut = max(1e-4, 1.0 - float(threshold)) + soft = np.clip(dist / (cut * max(1e-3, softness)), 0.0, 1.0) + + near_white = dist <= cut + if _HAS_SCIPY and near_white.any(): + lab, n = ndi.label(near_white) + if n > 0: + border = np.concatenate([lab[0, :], lab[-1, :], lab[:, 0], lab[:, -1]]) + ids = np.unique(border) + ids = ids[ids != 0] + if ids.size: + bg = np.isin(lab, ids) + soft = np.where(bg, 0.0, soft) + else: + soft = np.where(near_white, 0.0, soft) + return soft.astype(np.float32) + + +def _drop_fragments(alpha, ratio): + """ + 清掉远小于主体的连通碎片。 + + 网格切分难免会把相邻格子探过来的部件(手杖尖、飘起的衣角)切进本格, + 这些碎片不仅难看,还会把 auto_crop 的包围盒撑大。 + 按「面积不足主体 ratio 倍」判定为碎片,这样与身体相连的道具不会被误删。 + """ + if ratio <= 0 or not _HAS_SCIPY: + return alpha + solid = alpha > 0.1 + if not solid.any(): + return alpha + lab, n = ndi.label(solid) + if n <= 1: + return alpha + areas = np.bincount(lab.ravel()) + areas[0] = 0 + keep = areas >= areas.max() * float(ratio) + keep[0] = False + return np.where(keep[lab], alpha, 0.0).astype(np.float32) + + +def _bbox(alpha, thr=0.02): + """内容包围盒 (y0,y1,x0,x1),无内容时返回 None。""" + m = alpha > thr + if not m.any(): + return None + ys = np.where(m.any(axis=1))[0] + xs = np.where(m.any(axis=0))[0] + return int(ys[0]), int(ys[-1]) + 1, int(xs[0]), int(xs[-1]) + 1 + + +def _parse_cells(text, total): + out = set() + for tok in str(text or "").replace(",", ",").split(","): + tok = tok.strip() + if not tok: + continue + try: + v = int(tok) + except ValueError: + continue + if 0 <= v < total: + out.add(v) + return out + + +class RuiEightDirSplit: + """八方向雪碧图序列 → 8 条独立动画序列。""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE", { + "tooltip": "视频转出的序列帧,每帧是一张排布着多个朝向的雪碧图。" + }), + "grid_cols": ("INT", { + "default": 3, "min": 1, "max": 8, "step": 1, + "tooltip": "雪碧图的列数。" + }), + "grid_rows": ("INT", { + "default": 3, "min": 1, "max": 8, "step": 1, + "tooltip": "雪碧图的行数。" + }), + "empty_cells": ("STRING", { + "default": "4", "multiline": False, + "tooltip": "空格子的序号(行优先、从 0 开始,逗号分隔)。\n" + "3×3 布局中间留空即填 4。留空表示没有空格。" + }), + "direction_names": ("STRING", { + "default": _DEFAULT_NAMES, "multiline": False, + "tooltip": "按「跳过空格后的先后顺序」给每个方向命名,逗号分隔。\n" + "默认对应 3×3 中间留空、行 1 面向观众的排布:\n" + " SW S SE\n" + " W E\n" + " NW N NE\n" + "名字只用于 info 与你自己辨认,不影响画面内容。" + }), + "bg_mode": (list(_BG_MODES.keys()), { + "default": "白底转透明(推荐)", + "tooltip": "webm 要保留透明就必须先把白底转成 alpha。\n" + "转换只把与画面边缘相连的白色判为背景,\n" + "角色身上的白衣服、白高光会被保留。" + }), + "bg_threshold": ("FLOAT", { + "default": 0.92, "min": 0.5, "max": 1.0, "step": 0.005, + "tooltip": "白底判定阈值:像素三通道最小值高于它才算「接近白」。\n" + "背景没扣干净就调低,角色边缘被啃掉就调高。" + }), + "edge_softness": ("FLOAT", { + "default": 1.0, "min": 0.1, "max": 4.0, "step": 0.05, + "tooltip": "边缘过渡宽度。原图边缘带抗锯齿,过渡太硬会有锯齿白边;\n" + "调大更柔和,调小更锐利。" + }), + "fragment_threshold": ("FLOAT", { + "default": 0.05, "min": 0.0, "max": 0.5, "step": 0.01, + "tooltip": "清掉面积不足主体这一比例的连通碎片。\n" + "网格切分会把相邻格子探过来的部件(手杖尖、飘起的衣角)\n" + "切进本格,既难看又会撑大自动裁剪的范围。\n" + "0 = 不清理;与身体相连的道具不会被误删。" + }), + "auto_crop": ("BOOLEAN", { + "default": True, + "tooltip": "按内容裁掉多余空白。\n" + "裁剪框取「该方向所有帧的并集」,因此整条序列尺寸一致,\n" + "既能合成视频,角色也不会在帧间跳动。" + }), + "crop_padding": ("INT", { + "default": 8, "min": 0, "max": 200, "step": 1, + "tooltip": "裁剪时在内容外保留的边距(像素)。" + }), + }, + "optional": { + "masks": ("MASK", { + "tooltip": "可选。已有的透明通道(如上游抠图结果),\n" + "配合 bg_mode=已带透明通道 使用。" + }), + }, + } + + RETURN_TYPES = ("IMAGE",) * MAX_DIRS + ("STRING",) + RETURN_NAMES = tuple(f"dir_{i + 1}" for i in range(MAX_DIRS)) + ("info",) + FUNCTION = "split" + CATEGORY = "Rui-Node🐶/图像调节🎨" + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs): + return True + + def split(self, images, grid_cols, grid_rows, empty_cells, + direction_names, bg_mode, bg_threshold, edge_softness, + fragment_threshold, auto_crop, crop_padding, masks=None): + mode = _BG_MODES.get(bg_mode, "white") + B, H, W, C = images.shape + cols, rows = int(grid_cols), int(grid_rows) + total = cols * rows + empties = _parse_cells(empty_cells, total) + cell_ids = [i for i in range(total) if i not in empties] + n_dir = len(cell_ids) + + names = [s.strip() for s in str(direction_names).replace(",", ",").split(",") + if s.strip()] + while len(names) < n_dir: + names.append(f"dir{len(names) + 1}") + + arr = images.detach().cpu().float().numpy() + if C == 4: + rgb_all, a_in = arr[..., :3], arr[..., 3] + else: + rgb_all, a_in = arr[..., :3], None + if masks is not None: + m = masks.detach().cpu().float().numpy() + if m.ndim == 2: + m = m[None] + if m.shape[0] != B: + m = np.repeat(m[:1], B, axis=0) + a_in = m + + # 格子边界按浮点等分再取整,避免整除不尽时累计误差(如 1112/3) + ybnd = [int(round(r * H / rows)) for r in range(rows + 1)] + xbnd = [int(round(c * W / cols)) for c in range(cols + 1)] + + # ---- 第一遍:切格 + 生成 alpha,同时累计每个方向的内容包围盒 ---- + per_dir = [[] for _ in range(n_dir)] + boxes = [None] * n_dir + for b in range(B): + for di, cid in enumerate(cell_ids): + r, c = divmod(cid, cols) + y0, y1, x0, x1 = ybnd[r], ybnd[r + 1], xbnd[c], xbnd[c + 1] + rgb = rgb_all[b, y0:y1, x0:x1] + if mode == "white": + alpha = _white_to_alpha(rgb, bg_threshold, edge_softness) + elif mode == "keep" and a_in is not None: + alpha = a_in[b, y0:y1, x0:x1] + else: + alpha = np.ones(rgb.shape[:2], dtype=np.float32) + if mode != "none": + alpha = _drop_fragments(alpha, fragment_threshold) + per_dir[di].append((rgb, alpha)) + if auto_crop: + bb = _bbox(alpha) + if bb is not None: + boxes[di] = bb if boxes[di] is None else ( + min(boxes[di][0], bb[0]), max(boxes[di][1], bb[1]), + min(boxes[di][2], bb[2]), max(boxes[di][3], bb[3])) + + # ---- 第二遍:按并集包围盒统一裁剪并打包 ---- + outs, notes = [], [] + pad = int(crop_padding) + for di in range(n_dir): + frames = per_dir[di] + ch, cw = frames[0][0].shape[:2] + if auto_crop and boxes[di] is not None: + y0, y1, x0, x1 = boxes[di] + y0 = max(0, y0 - pad); x0 = max(0, x0 - pad) + y1 = min(ch, y1 + pad); x1 = min(cw, x1 + pad) + else: + y0, y1, x0, x1 = 0, ch, 0, cw + stack = np.empty((len(frames), y1 - y0, x1 - x0, 4), dtype=np.float32) + for fi, (rgb, alpha) in enumerate(frames): + stack[fi, ..., :3] = rgb[y0:y1, x0:x1] + stack[fi, ..., 3] = alpha[y0:y1, x0:x1] + outs.append(torch.from_numpy(stack)) + notes.append(f"{di + 1}.{names[di]} 格{cell_ids[di]} " + f"{x1 - x0}×{y1 - y0}") + + info = (f"输入 {B} 帧 {W}×{H} → {cols}×{rows} 网格," + f"空格 {sorted(empties) if empties else '无'}," + f"得到 {n_dir} 个方向 × {B} 帧\n" + " | ".join(notes)) + if n_dir > MAX_DIRS: + info += f"\n⚠ 方向数 {n_dir} 超过输出口数量 {MAX_DIRS},只输出前 {MAX_DIRS} 个" + elif n_dir < MAX_DIRS: + info += (f"\n⚠ 方向数 {n_dir} 少于输出口数量 {MAX_DIRS}," + f"dir_{n_dir + 1}~dir_{MAX_DIRS} 为占位空图,请勿使用") + print(f"[Ruinode-8Dir] {info}") + + # 输出口数量固定,方向不足时补占位图,避免下游拿到 None 直接报错 + blank = torch.zeros((1, 8, 8, 4), dtype=torch.float32) + result = [outs[i] if i < len(outs) else blank for i in range(MAX_DIRS)] + return tuple(result) + (info,) + + +NODE_CLASS_MAPPINGS = { + "RuiEightDirSplit": RuiEightDirSplit, +} +NODE_DISPLAY_NAME_MAPPINGS = { + "RuiEightDirSplit": "八方向序列拆分 / 8-Direction Sprite Split", +} diff --git a/example_workflow/八方向行走动画.json b/example_workflow/八方向行走动画.json new file mode 100644 index 0000000..4feebcf --- /dev/null +++ b/example_workflow/八方向行走动画.json @@ -0,0 +1,1086 @@ +{ + "id": "5f8c3d21-7b94-4e06-a1d8-9c2e4b7f350a", + "revision": 0, + "last_node_id": 19, + "last_link_id": 17, + "nodes": [ + { + "id": 1, + "type": "VHS_LoadVideo", + "pos": [ + -2260, + 340 + ], + "size": [ + 340, + 480 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1 + ] + }, + { + "name": "frame_count", + "type": "INT", + "links": [] + }, + { + "name": "audio", + "type": "AUDIO", + "links": [] + }, + { + "name": "video_info", + "type": "VHS_VIDEOINFO", + "links": [] + } + ], + "properties": { + "Node name for S&R": "VHS_LoadVideo" + }, + "widgets_values": { + "video": "八方向行走.mp4", + "force_rate": 24, + "custom_width": 0, + "custom_height": 0, + "frame_load_cap": 0, + "skip_first_frames": 0, + "select_every_nth": 1 + }, + "color": "#232", + "bgcolor": "#353", + "title": "① 加载视频(force_rate 设帧率)" + }, + { + "id": 2, + "type": "RuiEightDirSplit", + "pos": [ + -1850, + 340 + ], + "size": [ + 360, + 320 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "dir_1", + "type": "IMAGE", + "links": [ + 2, + 3 + ] + }, + { + "name": "dir_2", + "type": "IMAGE", + "links": [ + 4, + 5 + ] + }, + { + "name": "dir_3", + "type": "IMAGE", + "links": [ + 6, + 7 + ] + }, + { + "name": "dir_4", + "type": "IMAGE", + "links": [ + 8, + 9 + ] + }, + { + "name": "dir_5", + "type": "IMAGE", + "links": [ + 10, + 11 + ] + }, + { + "name": "dir_6", + "type": "IMAGE", + "links": [ + 12, + 13 + ] + }, + { + "name": "dir_7", + "type": "IMAGE", + "links": [ + 14, + 15 + ] + }, + { + "name": "dir_8", + "type": "IMAGE", + "links": [ + 16, + 17 + ] + }, + { + "name": "info", + "type": "STRING", + "links": [] + } + ], + "properties": { + "aux_id": "rui40000/Ruinode", + "Node name for S&R": "RuiEightDirSplit" + }, + "widgets_values": [ + 3, + 3, + "4", + "SW,S,SE,W,E,NW,N,NE", + "白底转透明(推荐)", + 0.92, + 1.0, + 0.05, + true, + 8 + ], + "color": "#232", + "bgcolor": "#353", + "title": "② 八方向拆分(拆分+编号+分组)" + }, + { + "id": 3, + "type": "VHS_VideoCombine", + "pos": [ + -1380, + -160 + ], + "size": [ + 320, + 460 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 2 + }, + { + "name": "audio", + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": [] + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 24, + "loop_count": 0, + "filename_prefix": "8dir/1_SW", + "format": "video/webm", + "pix_fmt": "yuva420p", + "crf": 20, + "save_metadata": true, + "trim_to_audio": false, + "pingpong": false, + "save_output": true + }, + "color": "#232", + "bgcolor": "#353", + "title": "1. SW → 透明 webm" + }, + { + "id": 4, + "type": "SaveImage", + "pos": [ + -1020, + -160 + ], + "size": [ + 300, + 300 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 3 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "8dir_frames/1_SW/SW" + ], + "title": "1. SW → 透明 PNG 序列帧" + }, + { + "id": 5, + "type": "VHS_VideoCombine", + "pos": [ + -1380, + 170 + ], + "size": [ + 320, + 460 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 4 + }, + { + "name": "audio", + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": [] + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 24, + "loop_count": 0, + "filename_prefix": "8dir/2_S", + "format": "video/webm", + "pix_fmt": "yuva420p", + "crf": 20, + "save_metadata": true, + "trim_to_audio": false, + "pingpong": false, + "save_output": true + }, + "title": "2. S → 透明 webm" + }, + { + "id": 6, + "type": "SaveImage", + "pos": [ + -1020, + 170 + ], + "size": [ + 300, + 300 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 5 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "8dir_frames/2_S/S" + ], + "title": "2. S → 透明 PNG 序列帧" + }, + { + "id": 7, + "type": "VHS_VideoCombine", + "pos": [ + -1380, + 500 + ], + "size": [ + 320, + 460 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 6 + }, + { + "name": "audio", + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": [] + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 24, + "loop_count": 0, + "filename_prefix": "8dir/3_SE", + "format": "video/webm", + "pix_fmt": "yuva420p", + "crf": 20, + "save_metadata": true, + "trim_to_audio": false, + "pingpong": false, + "save_output": true + }, + "title": "3. SE → 透明 webm" + }, + { + "id": 8, + "type": "SaveImage", + "pos": [ + -1020, + 500 + ], + "size": [ + 300, + 300 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 7 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "8dir_frames/3_SE/SE" + ], + "title": "3. SE → 透明 PNG 序列帧" + }, + { + "id": 9, + "type": "VHS_VideoCombine", + "pos": [ + -1380, + 830 + ], + "size": [ + 320, + 460 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 8 + }, + { + "name": "audio", + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": [] + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 24, + "loop_count": 0, + "filename_prefix": "8dir/4_W", + "format": "video/webm", + "pix_fmt": "yuva420p", + "crf": 20, + "save_metadata": true, + "trim_to_audio": false, + "pingpong": false, + "save_output": true + }, + "title": "4. W → 透明 webm" + }, + { + "id": 10, + "type": "SaveImage", + "pos": [ + -1020, + 830 + ], + "size": [ + 300, + 300 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 9 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "8dir_frames/4_W/W" + ], + "title": "4. W → 透明 PNG 序列帧" + }, + { + "id": 11, + "type": "VHS_VideoCombine", + "pos": [ + -1380, + 1160 + ], + "size": [ + 320, + 460 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 10 + }, + { + "name": "audio", + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": [] + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 24, + "loop_count": 0, + "filename_prefix": "8dir/5_E", + "format": "video/webm", + "pix_fmt": "yuva420p", + "crf": 20, + "save_metadata": true, + "trim_to_audio": false, + "pingpong": false, + "save_output": true + }, + "title": "5. E → 透明 webm" + }, + { + "id": 12, + "type": "SaveImage", + "pos": [ + -1020, + 1160 + ], + "size": [ + 300, + 300 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 11 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "8dir_frames/5_E/E" + ], + "title": "5. E → 透明 PNG 序列帧" + }, + { + "id": 13, + "type": "VHS_VideoCombine", + "pos": [ + -1380, + 1490 + ], + "size": [ + 320, + 460 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 12 + }, + { + "name": "audio", + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": [] + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 24, + "loop_count": 0, + "filename_prefix": "8dir/6_NW", + "format": "video/webm", + "pix_fmt": "yuva420p", + "crf": 20, + "save_metadata": true, + "trim_to_audio": false, + "pingpong": false, + "save_output": true + }, + "title": "6. NW → 透明 webm" + }, + { + "id": 14, + "type": "SaveImage", + "pos": [ + -1020, + 1490 + ], + "size": [ + 300, + 300 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 13 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "8dir_frames/6_NW/NW" + ], + "title": "6. NW → 透明 PNG 序列帧" + }, + { + "id": 15, + "type": "VHS_VideoCombine", + "pos": [ + -1380, + 1820 + ], + "size": [ + 320, + 460 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 14 + }, + { + "name": "audio", + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": [] + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 24, + "loop_count": 0, + "filename_prefix": "8dir/7_N", + "format": "video/webm", + "pix_fmt": "yuva420p", + "crf": 20, + "save_metadata": true, + "trim_to_audio": false, + "pingpong": false, + "save_output": true + }, + "title": "7. N → 透明 webm" + }, + { + "id": 16, + "type": "SaveImage", + "pos": [ + -1020, + 1820 + ], + "size": [ + 300, + 300 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 15 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "8dir_frames/7_N/N" + ], + "title": "7. N → 透明 PNG 序列帧" + }, + { + "id": 17, + "type": "VHS_VideoCombine", + "pos": [ + -1380, + 2150 + ], + "size": [ + 320, + 460 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 16 + }, + { + "name": "audio", + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": [] + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 24, + "loop_count": 0, + "filename_prefix": "8dir/8_NE", + "format": "video/webm", + "pix_fmt": "yuva420p", + "crf": 20, + "save_metadata": true, + "trim_to_audio": false, + "pingpong": false, + "save_output": true + }, + "title": "8. NE → 透明 webm" + }, + { + "id": 18, + "type": "SaveImage", + "pos": [ + -1020, + 2150 + ], + "size": [ + 300, + 300 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 17 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.49", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "8dir_frames/8_NE/NE" + ], + "title": "8. NE → 透明 PNG 序列帧" + }, + { + "id": 19, + "type": "Note", + "pos": [ + -2260, + 880 + ], + "size": [ + 700, + 900 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": {}, + "widgets_values": [ + "八方向行走动画 —— 完整管线\n\n【前置步骤(在本工作流之外完成)】\n 角色图 →(GPTimage2)→ 八方向静态图 →(Seedance 首尾帧)→ 循环行走视频\n 视频每一帧上都排布着 8 个朝向的角色。\n\n【本工作流做的事】\n ① VHS「Load Video」把视频转成序列帧\n · force_rate 就是帧率选择。设 0 用源视频帧率;\n 设 12 可减半出帧、素材更小,走路动画通常 8~12 帧一循环就够\n · frame_load_cap 可限制总帧数,先小批量试参数很有用\n ② 本仓库「八方向序列拆分」:切格 + 编号 + 按方向分组,一次给出 8 条序列\n ③ 每个方向各接一个 VHS「Video Combine」出透明 webm\n ④ 每个方向各接一个 SaveImage 出透明 PNG 序列帧\n\n【拆分节点的关键参数】\n · grid_cols / grid_rows / empty_cells\n 3×3 中间留空即 empty_cells=4。序号行优先、从 0 开始。\n · direction_names\n 按「跳过空格后的先后顺序」命名,默认对应下面这个排布:\n SW S SE ← 行1:面向观众\n W E ← 行2:侧面\n NW N NE ← 行3:背对观众\n 与你的素材不符就直接改这行,名字只影响标注不影响画面。\n · bg_mode / bg_threshold\n 白底转透明。只把「与画面边缘相连」的白色判为背景,\n 所以角色身上的白衬衫、白高光不会被掏空。\n 背景没扣净就调低阈值,角色边缘被啃就调高。\n · auto_crop\n 裁掉多余空白。裁剪框取该方向所有帧的并集,\n 因此整条序列尺寸一致(能合成视频),角色也不会在帧间跳动。\n\n【为什么不用连通区域拆分(本仓库的「素材拆分」节点)】\n 那个节点按包围盒排序,角色走动时位置浮动,一旦跨过排序行界方向就会错乱;\n 且每个 sprite 按各自 bbox 裁剪、尺寸不一,无法直接合成视频。\n 固定网格没有这两个问题 —— 但它更适合单张静态合图,两者各有用途。\n\n【webm 透明的关键】\n Video Combine 的 format 必须选 video/webm,且 pix_fmt 选 yuva420p。\n 注意:alpha 存放在 WebM 的独立边带里,所以用 ffprobe 看主流会显示\n yuv420p,这是正常的。播放器/引擎(走 libvpx 解码)能正确读到透明通道;\n 若要用 ffmpeg 自行验证,需显式加 -c:v libvpx-vp9 解码,\n 否则内置 vp9 解码器不会输出 alpha,会让人误以为透明丢了。\n\n【实测(97 帧 834×1112 的素材)】\n 拆分耗时约 4 秒,8 个方向各 97 帧,尺寸 172×370 ~ 206×327。" + ], + "color": "#432", + "bgcolor": "#653" + } + ], + "links": [ + [ + 1, + 1, + 0, + 2, + 0, + "IMAGE" + ], + [ + 2, + 2, + 0, + 3, + 0, + "IMAGE" + ], + [ + 3, + 2, + 0, + 4, + 0, + "IMAGE" + ], + [ + 4, + 2, + 1, + 5, + 0, + "IMAGE" + ], + [ + 5, + 2, + 1, + 6, + 0, + "IMAGE" + ], + [ + 6, + 2, + 2, + 7, + 0, + "IMAGE" + ], + [ + 7, + 2, + 2, + 8, + 0, + "IMAGE" + ], + [ + 8, + 2, + 3, + 9, + 0, + "IMAGE" + ], + [ + 9, + 2, + 3, + 10, + 0, + "IMAGE" + ], + [ + 10, + 2, + 4, + 11, + 0, + "IMAGE" + ], + [ + 11, + 2, + 4, + 12, + 0, + "IMAGE" + ], + [ + 12, + 2, + 5, + 13, + 0, + "IMAGE" + ], + [ + 13, + 2, + 5, + 14, + 0, + "IMAGE" + ], + [ + 14, + 2, + 6, + 15, + 0, + "IMAGE" + ], + [ + 15, + 2, + 6, + 16, + 0, + "IMAGE" + ], + [ + 16, + 2, + 7, + 17, + 0, + "IMAGE" + ], + [ + 17, + 2, + 7, + 18, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.55, + "offset": [ + 2400, + 100 + ] + } + }, + "version": 0.4 +} \ No newline at end of file