diff --git a/README.md b/README.md index fd85b4f..20a193c 100644 --- a/README.md +++ b/README.md @@ -1090,6 +1090,37 @@ Lucida 一次就能识别整张雪碧图的全部 8 个角色(各格前景占 **输出**: `dir_1` ~ `dir_8` (IMAGE,**4 通道 RGBA**) + `info` (STRING) +**锚点对齐:让 8 个方向尺寸统一、切换朝向不跳** + +做游戏素材时这一步是刚需。不开对齐时,每个方向各按自己的内容裁剪,8 个方向出 8 种尺寸,角色在各自画面里的位置也不一致——游戏里切换朝向角色就会跳一下。人工做法是「一帧一帧手动对位置」,本节点把它自动化了: + +| 参数 | 说明 | +|:-----|:-----| +| `align_mode` | 锚点对齐·统一画布(默认)/不对齐 | +| `anchor_type` | **脚底中心**(默认)/包围盒底边中心/包围盒中心 | +| `align_scope` | **逐帧对齐·脚底钉死**(默认)/按方向统一平移 | + +**为什么锚点取「脚底中心」而不是包围盒中心**:角色站在地面上,脚底才是它在世界里的位置;而斗篷、披风、手杖会把包围盒拽向一侧。所以 y 取最低的不透明行,x 取**底部窄带的水平质心**——那些外挂物基本不会垂到脚底,走路时两脚一前一后,窄带质心正好落在两脚之间,也就是人真正站立的点。 + +**实测**(97 帧真实素材): + +| | 输出尺寸 | 各方向锚点散布 | 同方向跨帧位移 | +|:---|:---|---:|---:| +| 不对齐 | 8 种各不相同 | x **48.9px** / y **27px** | — | +| 按方向统一平移 | 统一 267×372 | x 7.1px / y 4px | 13~23px(保留摆动)| +| **逐帧对齐(默认)** | **统一 284×367** | **x 0.76px / y 0px** | ~1px | + +默认选逐帧对齐,是因为行走循环本就该原地播放、位移交给游戏代码,sprite 内部不该有整体漂移;而 AI 生成的视频往往有(实测同方向跨帧漂移达 22px)。角色本就该有前后摆动的动作(挥剑、跳跃)则改用「按方向统一平移」。 + +`info` 输出会给出**统一画布尺寸、锚点坐标、以及 Unity/Godot 的归一化 pivot**(左下为原点),例如: + +``` +统一画布 267×372,锚点(脚底中心)位于 (144.5, 346.0) +Unity/Godot 归一化 pivot(左下为原点):(0.5411, 0.0699) +``` + +把那个 pivot 填进引擎的 Sprite 设置,8 个方向就能共用同一套坐标。 + **三个关键设计**: 1. **用固定网格而非连通区域拆分**(本仓库的[素材拆分](#14-素材拆分--sprite-splitter)节点)。连通区域按包围盒排序,角色走动时位置浮动,一旦跨过排序行界方向就会错乱——上百帧里错一帧整条动画就废了;且每个 sprite 按各自 bbox 裁剪、尺寸不一,无法合成视频。固定网格没有这两个问题。(连通区域拆分依然更适合单张静态合图,两者各有用途。) diff --git a/eightdir_node.py b/eightdir_node.py index 718f7c7..56344b7 100644 --- a/eightdir_node.py +++ b/eightdir_node.py @@ -46,6 +46,23 @@ _BG_ALIAS = { "已带透明通道": "keep", } +_ALIGN_MODES = { + "锚点对齐·统一画布(做游戏必选)": "anchor", + "不对齐(各方向独立裁剪)": "none", +} +_ANCHORS = { + "脚底中心(推荐)": "foot", + "包围盒底边中心": "bbox_bottom", + "包围盒中心": "bbox_center", +} +_ALIGN_SCOPES = { + "逐帧对齐·脚底钉死(推荐)": "per_frame", + "按方向统一平移(保留帧内起伏)": "per_dir", +} +_ANCHOR_LABEL = { + "foot": "脚底中心", "bbox_bottom": "包围盒底边中心", "bbox_center": "包围盒中心", +} + # 与 3×3 中间留空的常见排布对应:行 1 面向观众、行 3 背对观众 _DEFAULT_NAMES = "SW,S,SE,W,E,NW,N,NE" @@ -100,6 +117,44 @@ def _drop_fragments(alpha, ratio): return np.where(keep[lab], alpha, 0.0).astype(np.float32) +def _anchor_of(alpha, mode, y0, y1, x0, x1, band_ratio=0.08): + """ + 求该帧的锚点(轴心),返回全图坐标 (ax, ay)。 + + 游戏里角色站在地面上,所以锚点取「脚底中心」最合理: + ay = 最低的不透明行(脚底所在高度) + ax = 底部窄带内的水平质心 + x 之所以不用包围盒中心:斗篷、披风、手杖会把包围盒拽向一侧, + 而这些东西基本不会垂到脚底,取底部窄带就避开了它们。 + 走路时两脚一前一后,窄带质心正好落在两脚之间,即人站立的位置。 + """ + if mode == "bbox_center": + return ((x0 + x1 - 1) / 2.0, (y0 + y1 - 1) / 2.0) + if mode == "bbox_bottom": + return ((x0 + x1 - 1) / 2.0, float(y1 - 1)) + band = max(2, int((y1 - y0) * band_ratio)) + sub = alpha[max(y0, y1 - band):y1, x0:x1] + total = float(sub.sum()) + if total <= 1e-6: + return ((x0 + x1 - 1) / 2.0, float(y1 - 1)) + col = sub.sum(axis=0) + ax = float((col * np.arange(x0, x1)).sum() / total) + return (ax, float(y1 - 1)) + + +def _paste_anchored(canvas, src, src_anchor, dst_anchor): + """把 src(RGBA) 按锚点对齐贴进 canvas,越界部分自动裁掉。""" + ox = int(round(dst_anchor[0] - src_anchor[0])) + oy = int(round(dst_anchor[1] - src_anchor[1])) + H, W = canvas.shape[:2] + h, w = src.shape[:2] + x0, y0 = max(0, ox), max(0, oy) + x1, y1 = min(W, ox + w), min(H, oy + h) + if x1 <= x0 or y1 <= y0: + return + canvas[y0:y1, x0:x1] = src[y0 - oy:y1 - oy, x0 - ox:x1 - ox] + + def _shrink_alpha(alpha, shrink): """ 把边缘那圈「几乎全是背景」的半透明像素收掉。 @@ -284,6 +339,40 @@ class RuiEightDirSplit: "对三种 bg_mode 都有效:选「不处理」时也会在内部算一份\n" "白底检测来圈定范围,输出仍保持不透明。" }), + "align_mode": (list(_ALIGN_MODES.keys()), { + "default": "锚点对齐·统一画布(做游戏必选)", + "tooltip": "【做游戏素材必选】把 8 个方向对齐到同一张画布上。\n\n" + "不对齐时每个方向各按自己的内容裁剪,尺寸互不相同\n" + "(如 208×328 与 192×323),且角色在各自画面里的位置\n" + "也不一致 —— 游戏里切换朝向时角色就会跳一下。\n\n" + "开启后:所有方向输出同一尺寸,且锚点(默认脚底中心)\n" + "落在画布里的同一个位置,因此切换朝向时角色纹丝不动。\n" + "info 输出里会给出画布尺寸与锚点坐标(含 Unity 归一化\n" + "pivot),照着填进引擎即可。" + }), + "anchor_type": (list(_ANCHORS.keys()), { + "default": "脚底中心(推荐)", + "tooltip": "用什么当锚点(轴心)。\n\n" + "脚底中心:y 取最低的不透明行,x 取底部窄带的水平质心。\n" + " 角色站在地面上,脚底才是它在世界里的位置,所以这个最合理。\n" + " x 不用包围盒中心,是因为斗篷、披风、手杖会把包围盒\n" + " 拽向一侧,而它们基本不会垂到脚底。\n" + "包围盒底边中心:省掉质心计算,角色没有大幅外挂物时够用。\n" + "包围盒中心:适合飞行单位、投射物这类不站地面的素材。" + }), + "align_scope": (list(_ALIGN_SCOPES.keys()), { + "default": "逐帧对齐·脚底钉死(推荐)", + "tooltip": "对齐的粒度,直接影响动画观感。\n\n" + "【逐帧对齐·脚底钉死】每一帧都把自己的锚点钉在同一点,\n" + " 等价于人工「一帧一帧对位置」。\n" + " 行走循环本就该原地播放、位移交给游戏代码,所以\n" + " sprite 内部不该有整体漂移。AI 生成的视频往往有\n" + " (本仓库实测同方向跨帧漂移达 22px),这个模式能压到 1px。\n\n" + "【按方向统一平移】同方向所有帧平移同样的距离,\n" + " 保留帧内的重心起伏。方向之间照样对齐。\n" + " 适合角色本来就该有前后摆动的动作(如挥剑、跳跃),\n" + " 或素材本身很干净、不需要修漂移时。" + }), "auto_crop": ("BOOLEAN", { "default": True, "tooltip": "按内容裁掉多余空白。\n" @@ -317,8 +406,12 @@ class RuiEightDirSplit: def split(self, images, grid_cols, grid_rows, empty_cells, direction_names, bg_mode, bg_threshold, edge_shrink, decontaminate, edge_softness, fragment_threshold, - expand_beyond_cell, auto_crop, crop_padding, masks=None): + expand_beyond_cell, align_mode, anchor_type, align_scope, + auto_crop, crop_padding, masks=None): mode = _BG_MODES.get(bg_mode) or _BG_ALIAS.get(bg_mode, "white") + align_kind = _ALIGN_MODES.get(align_mode, "anchor") + anchor_kind = _ANCHORS.get(anchor_type, "foot") + align_scope_kind = _ALIGN_SCOPES.get(align_scope, "per_dir") B, H, W, C = images.shape cols, rows = int(grid_cols), int(grid_rows) total = cols * rows @@ -373,24 +466,110 @@ class RuiEightDirSplit: if use_expand: # ===== 内容自适应:格子只定方向,范围由角色自身的连通块决定 ===== - # 第一遍只求包围盒(全图坐标),不留像素,避免整段序列驻留内存 + # 第一遍求包围盒与锚点(全图坐标),不留像素,避免整段序列驻留内存 boxes = [None] * n_dir + fbox = [[None] * B for _ in range(n_dir)] # 每帧各自的包围盒 + fanc = [[None] * B for _ in range(n_dir)] # 每帧各自的锚点 for b in range(B): - lab, groups = _assign_blocks(locate_alpha(b), ybnd, xbnd, - cols, fragment_threshold) + la = locate_alpha(b) + lab, groups = _assign_blocks(la, ybnd, xbnd, cols, + fragment_threshold) if lab is None: continue for di, cid in enumerate(cell_ids): blk = groups.get(cid) if not blk: continue - bb = _bbox(np.isin(lab, blk).astype(np.float32), 0.5) + m = np.isin(lab, blk) + bb = _bbox(m.astype(np.float32), 0.5) if bb is None: continue + fbox[di][b] = bb + fanc[di][b] = _anchor_of(la * m, anchor_kind, *bb) 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])) + # ---- 锚点对齐:所有方向输出同一尺寸,且锚点落在画布同一位置 ---- + if align_kind == "anchor": + # 方向代表锚点取中位数:个别帧抠图不稳也不会把整个方向带偏 + rep = [] + for di in range(n_dir): + aa = [a for a in fanc[di] if a is not None] + rep.append((float(np.median([a[0] for a in aa])), + float(np.median([a[1] for a in aa]))) + if aa else None) + + # 以锚点为原点,统计所有方向、所有帧向四周的最大延展 + L = R = T = Bt = 0.0 + for di in range(n_dir): + if rep[di] is None or boxes[di] is None: + continue + if align_scope_kind == "per_dir": + pairs = [(boxes[di], rep[di])] + else: + pairs = [(fbox[di][b], fanc[di][b]) for b in range(B) + if fbox[di][b] is not None] + for bb, an in pairs: + L = max(L, an[0] - bb[2]) + R = max(R, bb[3] - 1 - an[0]) + T = max(T, an[1] - bb[0]) + Bt = max(Bt, bb[1] - 1 - an[1]) + + cw = int(np.ceil(L + R)) + 1 + 2 * pad + ch = int(np.ceil(T + Bt)) + 1 + 2 * pad + canvas_anchor = (pad + L, pad + T) + outs = [np.zeros((B, ch, cw, 4), dtype=np.float32) + for _ in range(n_dir)] + + for b in range(B): + la = locate_alpha(b) + af = frame_alpha(b) + lab, groups = _assign_blocks(la, ybnd, xbnd, cols, + fragment_threshold) + for di, cid in enumerate(cell_ids): + bb = fbox[di][b] + blk = groups.get(cid) if groups else None + if bb is None or not blk: + continue + y0, y1, x0, x1 = bb + rgb = rgb_all[b, y0:y1, x0:x1] + m = np.isin(lab[y0:y1, x0:x1], blk) + src = np.zeros((y1 - y0, x1 - x0, 4), dtype=np.float32) + if mode == "none": + a = (la[y0:y1, x0:x1] * m)[..., None] + src[..., :3] = rgb * a + (1.0 - a) + src[..., 3] = 1.0 + else: + ac = _shrink_alpha(af[y0:y1, x0:x1] * m, edge_shrink) + src[..., :3] = _decontaminate(rgb, ac, decontaminate) + src[..., 3] = ac + # per_dir 用方向代表锚点:源是全图坐标系,各帧都把同一个 + # 全图点对齐到画布锚点,等价于整方向平移同样的距离, + # 因此帧间的自然起伏被原样保留。 + an = rep[di] if align_scope_kind == "per_dir" else fanc[di][b] + _paste_anchored(outs[di][b], src, + (an[0] - x0, an[1] - y0), canvas_anchor) + + for di in range(n_dir): + notes.append(f"{di + 1}.{names[di]} 格{cell_ids[di]}") + outs[di] = torch.from_numpy(outs[di]) + align_note = ( + f"\n统一画布 {cw}×{ch},锚点({_ANCHOR_LABEL.get(anchor_kind, anchor_kind)})" + f"位于 ({canvas_anchor[0]:.1f}, {canvas_anchor[1]:.1f})" + f"\nUnity/Godot 归一化 pivot(左下为原点):" + f"({canvas_anchor[0] / cw:.4f}, {1.0 - canvas_anchor[1] / ch:.4f})" + f"\n对齐粒度:{align_scope}") + info = (f"输入 {B} 帧 {W}×{H} → {cols}×{rows} 网格," + f"空格 {sorted(empties) if empties else '无'}," + f"得到 {n_dir} 个方向 × {B} 帧\n" + + " | ".join(notes) + align_note) + print(f"[Ruinode-8Dir] {info}") + 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,) + # 没有 auto_crop 时退回该格的格线范围,仍允许块超界的像素带出来 final = [] for di, cid in enumerate(cell_ids): diff --git a/example_workflow/八方向行走动画.json b/example_workflow/八方向行走动画.json index b894ed3..ae21314 100644 --- a/example_workflow/八方向行走动画.json +++ b/example_workflow/八方向行走动画.json @@ -225,6 +225,9 @@ 1.0, 0.05, true, + "锚点对齐·统一画布(做游戏必选)", + "脚底中心(推荐)", + "逐帧对齐·脚底钉死(推荐)", true, 16 ], @@ -988,7 +991,7 @@ "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 ② Lucida 抠图,为拆分提供语义级 alpha(见下方「为什么要接抠图节点」)\n ③ 八方向拆分:切格 + 编号 + 按方向分组,一次给出 8 条序列\n ④ 每个方向各接一个 VHS「Video Combine」出透明 webm\n ⑤ 每个方向各接一个 SaveImage 出透明 PNG 序列帧\n\n■ 为什么要接抠图节点,而不是让拆分节点自己扣白底 ————\n\n拆分节点自带的「白底转透明」是纯颜色阈值法:按「离白色多远」估 alpha。\n它的死穴是角色身上的白色衣物 —— 白衬衫本身就接近白,alpha 天生偏低,\n一旦收边压白边,衬衫就会被啃出破洞(实测会看到背景色透过衣服)。\n\nLucida 是按语义判断的,知道那是衣服而不是背景。实测(同一素材、\n同等白边水平):主体被啃掉的面积比阈值法少 35%,白衬衫完好。\nLucida 一次就能把整张雪碧图的 8 个角色全部识别出来\n(各格前景占比 0.18~0.24,中间空格 0.002)。\n\n素材里确实没有白色元素时,可以把 bg_mode 改成「白底转透明」省掉模型开销;\n此时 edge_shrink 要调到 0.35 以上才压得住白边。\n\n■ 治白边的两个参数 ————————————————————\n\n · edge_shrink(主力)把边缘那圈几乎全是背景的半透明像素收掉。\n 配模型 alpha 用 0.15~0.25;配阈值法要 0.35+。\n 实测:0 → 白边强度 0.048;0.2 → 0.026;0.5 → 0.016。\n · decontaminate(辅助)颜色反溢出,按\n 观察色 = 前景×a + 白×(1-a) 反解真正的前景色。\n 单独用改善很小(约 3%),要和收边配合。\n\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 · expand_beyond_cell(务必开启)\n 允许角色超出格子边界。关闭时按格线严格切分,角色只要探出格线\n 就会被切断(最典型的是脚被削掉)。开启后格子只用来判定方向,\n 实际范围由角色自身的连通区域决定。三种 bg_mode 都有效。\n · auto_crop\n 裁剪框取该方向所有帧的并集,因此整条序列尺寸一致(能合成视频),\n 角色也不会在帧间跳动。\n\n■ webm 透明的关键 ————————————————————\n\n Video Combine 的 format 必须选 video/webm,pix_fmt 必须选 yuva420p。\n 注意:alpha 存放在 WebM 的独立边带里,用 ffprobe 看主流会显示 yuv420p,\n 这是正常的。播放器/引擎(走 libvpx 解码)能正确读到透明通道;\n 若要用 ffmpeg 自行验证,需显式加 -c:v libvpx-vp9 解码,\n 否则内置 vp9 解码器不输出 alpha,会让人误以为透明丢了。" + "八方向行走动画 —— 完整管线\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 ② Lucida 抠图,为拆分提供语义级 alpha(见下方「为什么要接抠图节点」)\n ③ 八方向拆分:切格 + 编号 + 按方向分组,一次给出 8 条序列\n ④ 每个方向各接一个 VHS「Video Combine」出透明 webm\n ⑤ 每个方向各接一个 SaveImage 出透明 PNG 序列帧\n\n■ 为什么要接抠图节点,而不是让拆分节点自己扣白底 ————\n\n拆分节点自带的「白底转透明」是纯颜色阈值法:按「离白色多远」估 alpha。\n它的死穴是角色身上的白色衣物 —— 白衬衫本身就接近白,alpha 天生偏低,\n一旦收边压白边,衬衫就会被啃出破洞(实测会看到背景色透过衣服)。\n\nLucida 是按语义判断的,知道那是衣服而不是背景。实测(同一素材、\n同等白边水平):主体被啃掉的面积比阈值法少 35%,白衬衫完好。\nLucida 一次就能把整张雪碧图的 8 个角色全部识别出来\n(各格前景占比 0.18~0.24,中间空格 0.002)。\n\n素材里确实没有白色元素时,可以把 bg_mode 改成「白底转透明」省掉模型开销;\n此时 edge_shrink 要调到 0.35 以上才压得住白边。\n\n■ 治白边的两个参数 ————————————————————\n\n · edge_shrink(主力)把边缘那圈几乎全是背景的半透明像素收掉。\n 配模型 alpha 用 0.15~0.25;配阈值法要 0.35+。\n 实测:0 → 白边强度 0.048;0.2 → 0.026;0.5 → 0.016。\n · decontaminate(辅助)颜色反溢出,按\n 观察色 = 前景×a + 白×(1-a) 反解真正的前景色。\n 单独用改善很小(约 3%),要和收边配合。\n\n\n■ 尺寸统一与锚点对齐(做游戏的关键)——————————\n\n 不开对齐时,8 个方向各按自己的内容裁剪,尺寸互不相同\n (实测 8 个方向出了 8 种尺寸),角色在各自画面里的位置也不一致\n ——游戏里切换朝向时角色会跳一下。\n\n · align_mode = 锚点对齐·统一画布\n 所有方向输出同一尺寸,锚点落在画布同一位置。\n 实测各方向锚点散布:x 48.9px → 0.76px,y 27px → 0px。\n · anchor_type = 脚底中心\n y 取最低的不透明行,x 取底部窄带的水平质心。\n x 不用包围盒中心,是因为斗篷、披风、手杖会把包围盒拽向一侧,\n 而它们基本不会垂到脚底。走路时两脚一前一后,窄带质心正好\n 落在两脚之间,也就是人真正站立的点。\n · align_scope = 逐帧对齐·脚底钉死\n 等价于人工「一帧一帧对位置」。行走循环本就该原地播放、\n 位移交给游戏代码,sprite 内部不该有整体漂移;AI 生成的视频\n 往往有(实测同方向跨帧漂移 22px),此模式能压到 1px。\n 若角色本就该有前后摆动(挥剑、跳跃),改用「按方向统一平移」。\n\n ⚠ info 输出里会给出统一画布尺寸、锚点坐标,以及 Unity/Godot 的\n 归一化 pivot(左下为原点)。把那个 pivot 填进引擎的 Sprite 设置,\n 8 个方向就能共用同一套坐标,切换朝向纹丝不动。\n\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 · expand_beyond_cell(务必开启)\n 允许角色超出格子边界。关闭时按格线严格切分,角色只要探出格线\n 就会被切断(最典型的是脚被削掉)。开启后格子只用来判定方向,\n 实际范围由角色自身的连通区域决定。三种 bg_mode 都有效。\n · auto_crop\n 裁剪框取该方向所有帧的并集,因此整条序列尺寸一致(能合成视频),\n 角色也不会在帧间跳动。\n\n■ webm 透明的关键 ————————————————————\n\n Video Combine 的 format 必须选 video/webm,pix_fmt 必须选 yuva420p。\n 注意:alpha 存放在 WebM 的独立边带里,用 ffprobe 看主流会显示 yuv420p,\n 这是正常的。播放器/引擎(走 libvpx 解码)能正确读到透明通道;\n 若要用 ffmpeg 自行验证,需显式加 -c:v libvpx-vp9 解码,\n 否则内置 vp9 解码器不输出 alpha,会让人误以为透明丢了。" ], "color": "#432", "bgcolor": "#653" diff --git a/example_workflow/角色图到八方向行走动画(一体化).json b/example_workflow/角色图到八方向行走动画(一体化).json index 7d489e3..9e4d68a 100644 --- a/example_workflow/角色图到八方向行走动画(一体化).json +++ b/example_workflow/角色图到八方向行走动画(一体化).json @@ -908,6 +908,9 @@ 1.0, 0.05, true, + "锚点对齐·统一画布(做游戏必选)", + "脚底中心(推荐)", + "逐帧对齐·脚底钉死(推荐)", true, 16 ], @@ -1808,7 +1811,7 @@ "outputs": [], "properties": {}, "widgets_values": [ - "角色图 → 八方向行走动画(一体化)\n\n原来的两个工作流合并成了这一个。断点在于:\nSeedance 输出的是 ComfyUI 的 VIDEO 类型,而八方向拆分要的是 IMAGE 帧序列,\nVHS「Load Video」只能从文件读、接不上 VIDEO。\n\n■ 桥梁:GetVideoComponents(ComfyUI 原生,无需装任何东西)———\n\n 节点名「Get Video Components」,分类 image/video。\n 输入 VIDEO,输出 images / audio / fps —— 正好把视频拆成帧序列。\n Holopix 插件的 README 里也明确写了「可直接连接原生 Save Video、\n Get Video Components」,且它内部是先把视频下载到本地再包装成 VIDEO,\n 所以解码没有问题。\n → 没有为此新写任何节点。\n\n■ 帧率的两处必须匹配 ————————————————————\n\n Seedance 出的是 720p / 4 秒 / 24fps ≈ 97 帧。游戏里的行走循环\n 8~12 帧就够,全量 97 帧既占空间也没必要,所以中间加了抽帧:\n\n ⑨ VHS「Select Every Nth Image」select_every_nth = 3\n → 24 ÷ 3 = 8fps,约 32 帧\n 输出帧率(PrimitiveFloat)= 8\n\n 这两个值必须对应,否则 webm 的播放速度会不对:\n 想要 12fps → 抽帧间隔 2(24÷2),输出帧率 12\n 想要全量 24fps → 抽帧间隔 1,输出帧率 24\n\n■ 为什么保留「留存原始视频」——————————————\n\n Seedance 是付费生成。把 VIDEO 同时接一份 SaveVideo 存下来,\n 后半段(抠图/拆分/导出)参数没调好时可以用「八方向行走动画.json」\n 加载这个视频单独重跑,不必再花钱生成一次。\n 调参阶段建议把前面的 Holopix 两个节点 Ctrl+M 静音,只跑后半段。\n\n■ 分两段跑的建议 ————————————————————\n\n ⑤ 生成静态图之后有个预览节点。八方向布局/朝向是否正确、角色是否变形,\n 在这一步就能看出来 —— 确认没问题再往下跑视频,能省掉很多废单。\n\n■ 抠图这一环 ——————————————————————\n\n 用的是你原来的 LayerMask BiRefNetUltraV2(语义级 alpha)。\n 拆分节点的 bg_mode 因此选「已带透明通道」。\n 不要改成「白底转透明」:那是颜色阈值法,角色身上的白色衣物会被\n 啃出破洞;而且这套提示词要求纯黑背景,颜色阈值法本就不适用。\n 仓库里的 Lucida 抠图节点也可以替换这一环,两者都是语义级。\n\n■ 拆分节点的关键参数 —————————————————\n\n · empty_cells = 4 3×3 中间留空(行优先、从 0 开始)\n · direction_names 默认 SW,S,SE / W,E / NW,N,NE\n 对应行1面向观众、行3背对观众。与实际朝向不符就改这一行,\n 名字只影响标注不影响画面。\n · expand_beyond_cell 务必开启,否则角色探出格线的部分(脚、\n 飘起的衣角、手杖)会被切断。\n · edge_shrink = 0.2 治白边的主力。配语义级 alpha 用 0.15~0.25。\n · crop_padding = 16 给描边/发光等后续特效留余量。\n\n■ 产出 ————————————————————————\n\n output/8dir/ 8 个透明 webm(VP9 + yuva420p)\n output/8dir_frames/ 8 组透明 PNG 序列帧\n output/8dir_src/ Seedance 生成的原始视频" + "角色图 → 八方向行走动画(一体化)\n\n原来的两个工作流合并成了这一个。断点在于:\nSeedance 输出的是 ComfyUI 的 VIDEO 类型,而八方向拆分要的是 IMAGE 帧序列,\nVHS「Load Video」只能从文件读、接不上 VIDEO。\n\n■ 桥梁:GetVideoComponents(ComfyUI 原生,无需装任何东西)———\n\n 节点名「Get Video Components」,分类 image/video。\n 输入 VIDEO,输出 images / audio / fps —— 正好把视频拆成帧序列。\n Holopix 插件的 README 里也明确写了「可直接连接原生 Save Video、\n Get Video Components」,且它内部是先把视频下载到本地再包装成 VIDEO,\n 所以解码没有问题。\n → 没有为此新写任何节点。\n\n■ 帧率的两处必须匹配 ————————————————————\n\n Seedance 出的是 720p / 4 秒 / 24fps ≈ 97 帧。游戏里的行走循环\n 8~12 帧就够,全量 97 帧既占空间也没必要,所以中间加了抽帧:\n\n ⑨ VHS「Select Every Nth Image」select_every_nth = 3\n → 24 ÷ 3 = 8fps,约 32 帧\n 输出帧率(PrimitiveFloat)= 8\n\n 这两个值必须对应,否则 webm 的播放速度会不对:\n 想要 12fps → 抽帧间隔 2(24÷2),输出帧率 12\n 想要全量 24fps → 抽帧间隔 1,输出帧率 24\n\n■ 为什么保留「留存原始视频」——————————————\n\n Seedance 是付费生成。把 VIDEO 同时接一份 SaveVideo 存下来,\n 后半段(抠图/拆分/导出)参数没调好时可以用「八方向行走动画.json」\n 加载这个视频单独重跑,不必再花钱生成一次。\n 调参阶段建议把前面的 Holopix 两个节点 Ctrl+M 静音,只跑后半段。\n\n■ 分两段跑的建议 ————————————————————\n\n ⑤ 生成静态图之后有个预览节点。八方向布局/朝向是否正确、角色是否变形,\n 在这一步就能看出来 —— 确认没问题再往下跑视频,能省掉很多废单。\n\n■ 抠图这一环 ——————————————————————\n\n 用的是你原来的 LayerMask BiRefNetUltraV2(语义级 alpha)。\n 拆分节点的 bg_mode 因此选「已带透明通道」。\n 不要改成「白底转透明」:那是颜色阈值法,角色身上的白色衣物会被\n 啃出破洞;而且这套提示词要求纯黑背景,颜色阈值法本就不适用。\n 仓库里的 Lucida 抠图节点也可以替换这一环,两者都是语义级。\n\n\n■ 尺寸统一与锚点对齐(做游戏的关键)——————————\n\n 不开对齐时,8 个方向各按自己的内容裁剪,尺寸互不相同\n (实测 8 个方向出了 8 种尺寸),角色在各自画面里的位置也不一致\n ——游戏里切换朝向时角色会跳一下。\n\n · align_mode = 锚点对齐·统一画布\n 所有方向输出同一尺寸,锚点落在画布同一位置。\n 实测各方向锚点散布:x 48.9px → 0.76px,y 27px → 0px。\n · anchor_type = 脚底中心\n y 取最低的不透明行,x 取底部窄带的水平质心。\n x 不用包围盒中心,是因为斗篷、披风、手杖会把包围盒拽向一侧,\n 而它们基本不会垂到脚底。走路时两脚一前一后,窄带质心正好\n 落在两脚之间,也就是人真正站立的点。\n · align_scope = 逐帧对齐·脚底钉死\n 等价于人工「一帧一帧对位置」。行走循环本就该原地播放、\n 位移交给游戏代码,sprite 内部不该有整体漂移;AI 生成的视频\n 往往有(实测同方向跨帧漂移 22px),此模式能压到 1px。\n 若角色本就该有前后摆动(挥剑、跳跃),改用「按方向统一平移」。\n\n ⚠ info 输出里会给出统一画布尺寸、锚点坐标,以及 Unity/Godot 的\n 归一化 pivot(左下为原点)。把那个 pivot 填进引擎的 Sprite 设置,\n 8 个方向就能共用同一套坐标,切换朝向纹丝不动。\n\n■ 拆分节点的关键参数 —————————————————\n\n · empty_cells = 4 3×3 中间留空(行优先、从 0 开始)\n · direction_names 默认 SW,S,SE / W,E / NW,N,NE\n 对应行1面向观众、行3背对观众。与实际朝向不符就改这一行,\n 名字只影响标注不影响画面。\n · expand_beyond_cell 务必开启,否则角色探出格线的部分(脚、\n 飘起的衣角、手杖)会被切断。\n · edge_shrink = 0.2 治白边的主力。配语义级 alpha 用 0.15~0.25。\n · crop_padding = 16 给描边/发光等后续特效留余量。\n\n■ 产出 ————————————————————————\n\n output/8dir/ 8 个透明 webm(VP9 + yuva420p)\n output/8dir_frames/ 8 组透明 PNG 序列帧\n output/8dir_src/ Seedance 生成的原始视频" ], "color": "#432", "bgcolor": "#653"