H3-World 的动作没按时发生,先检查时间线变成了什么

0 阅读23分钟

H3-World 的动作没按时发生,先检查时间线变成了什么

先W后S,怎么变成了静止?

给视频模型安排“先前进,再后退”,如果生成的人物没有照着动,第一反应通常是控制能力不够。但在 H3-World 的公开代码里,有一个更早发生的变化:同一个时间槽内先按 W、后按 S,可能在进入模型之前就变成静止描述。

这不是模型生成失败的实测结果,而是从动作聚合与冲突消除代码得到的确定性推导。它提醒我们,动作控制至少要回答两件事:用户的时间线被编码成了什么,以及编码后的动作能从哪里进入视频模型。H3-World 同时处理这两个问题,但它们不是同一种保证。

“先前进,再后退”,为什么会丢掉先后顺序

保留出现过,不保留先后

H3-World 把动作转成语言,再与视频的时间 latent 对齐。这里的 latent 可以先理解为模型压缩表示中的一个时间槽:它覆盖若干视频帧,并不等于一帧画面。

本文除“完整本地示例”外的 Python 代码块都是官方源码摘录,依赖周围变量、模块和调用上下文,不能独立运行。官方 abot_action.py 中的二值键聚合只有一行核心操作:

out[k, :NUM_KEYS] = mat[s:e, :NUM_KEYS].max(axis=0)

意思是:某个键只要在这个槽覆盖的任何一帧出现,这个槽就把它记成按下。它保留“出现过”,不保留出现顺序与持续帧数。1

随后,action_script.py 的 _purify 处理互斥键:

if on[a] and on[b]:
    on[a] = on[b] = False

角色动作会对 W/S、A/D 分别执行这个规则。没有剩余移动键时,角色子句使用 stands still。2

现在构造一个四帧槽,其他键和镜头状态保持空:

槽内帧第1帧第2帧第3帧第4帧
原始输入WWSS

聚合后 W 和 S 都为真,再被冲突规则一起清掉。由这条路径产生的角色子句是静止;它没有表达“先走两帧、再退两帧”。如果反过来先 S 后 W,结果仍然一样。

这里没有办法靠后面的注意力机制恢复已经丢掉的顺序。因此,评估动作遵循之前,要保存模型实际收到的动作句。只把原始按键录像与生成视频并排,可能会把预处理造成的语义变化算到模型头上。

这个例子适用于逐帧动作经过上述聚合路径的情况。若调用者直接提供每个 latent 的状态,则不经过同样的逐帧汇总步骤;两种输入层级必须分开记录。

把不同原始输入折叠成同一个条件

这个构造例还揭示了一个评估陷阱:模型面对的任务,可能已经比用户下达的任务粗了一层。四帧持续按W,与只在第一帧按W、后三帧松开,在二值最大值汇总这一项上都会留下W。若其他字段相同,就不能指望仅凭这个键字段区分“短点一下”和“持续推进”。同样,WWSS与SSWW的方向切换顺序不同,却落到同一个聚合状态。这里列举的是输入字段的映射关系,不能推断实际生成的位移相同。

协议表达损失应单独记录:丢失的是持续时间、事件顺序,还是互斥键的区分?把原始任务与模型实际条件混在一个“动作准确率”里,分母看似一致,测量对象却不同。下面的完整本地示例同时保留聚合前后字段,先核对这一步。

静止子句也没有唯一逆映射:没有移动输入与相反方向被清除,都能得到同一句话。本地例中的空输入反例会显示这一点。诊断旁路应保存原始帧、聚合状态与清除后状态;它帮助区分来源,不代表模型读取了这些旁路字段。

如果业务必须表达很短的反向动作,首先要决定它在协议里意味着什么:是槽内先后事件,是每槽的代表状态,还是持续到下个事件的状态。三者不能随意替换。提高输入采样密度也未必解决问题,因为更密的帧仍可能被同一次最大值聚合折叠。应该先证明新协议留下了所需区别,再讨论模型能否利用它。

37 个时间槽,不是一把均匀的尺子

时间槽不是均匀刻度

更容易误判的是时间边界。当前代码定义的帧数模式为:

_FRAME_PER_TOKEN = (1, 4, 4, 4, 4)

frame_spans 重复这个模式并累计区间;latent_t_for 将支持的帧数映射到槽数。124 帧对应 37 个时间槽,但这不表示每槽都覆盖 124/37 帧。1

按源码规则手工展开,使用从 0 开始、右端不包含的帧区间,开头几槽是:

时间槽(从0计)覆盖帧区间帧数
0[0, 1)1
1[1, 5)4
2[5, 9)4
3[9, 13)4
4[13, 17)4
5[17, 18)1

这是按代码计算的索引示例,不是视频测量。若输入按 24 FPS 解释,四帧覆盖约 167 毫秒,一帧约 42 毫秒。它们描述输入分组的时间范围,不能叫作系统响应延迟,也不能用平均槽数推成均匀控制频率。

因此,同样长度的短按键脉冲放在不同边界附近,可能进入一个槽,也可能落到两个槽。更极端地,一次方向切换若完整落在同一四帧槽内,就会遇到上一节的冲突抵消;跨过槽边界则可能形成两条不同动作句。

设计“第几秒转向”的测试时,先列出帧号与槽区间,再标动作句,比把整段时间均匀分成 37 份更可靠。

用边界探针检查偏移,而不是只检查数组长度

实际接入时,帧数与槽数正确仍不足以证明对齐正确。一个常见的构造测试,是把单帧按键分别放在边界前、边界上和边界后,再比较导出的槽号。例如帧4属于[1,5),帧5属于[5,9)。若实现把右端也包含进去,帧5就可能被算进两个槽;若应用使用从1开始的帧号却没有转换,则整条控制时间线会发生偏移。这些是建议检查的错误类别,本文没有声称官方实现存在这些缺陷。

记录中应明确帧号从0还是从1开始,结束位置是否包含,以及帧时间取帧首、帧中心还是其他采样时刻。用“第1秒左右”替代这些定义,会把舍入误差与模型响应混到一起。动作落在某个槽里,只证明预处理分配的位置;从哪个生成帧开始出现运动,还需要另一项画面判断。

另一个容易遗漏的条件是帧率。上面的167毫秒和42毫秒依赖24FPS这一解释前提,不能直接移植到其他帧率或不等间隔采样。若上游事件使用真实时钟,应先说明如何把事件映射到视频帧,再经过槽区间映射。若上游已经提供槽状态,则应保存槽编号和构造规则,不能再假装它是逐帧事件记录。

下面用持续W作正对照,将同槽WWSS与跨帧4/5的切换并排核对。事件移到错误槽,要修索引;事件进入正确槽却失去先后语义,要修协议。这组比较检查转换结果,不评价视频是否顺畅。

完整本地示例:把时间线变成可检查的记录

这次补上一个不依赖NumPy、Torch或模型权重的转换例。输入是内存中固定的124帧W/S集合,没有随机种子、时钟采样或真实键盘采集。它只覆盖二值键“槽内出现过”、W/S冲突清除及简化角色子句;不实现其他按键、连续平移/旋转字段、完整镜头语法、文本分词或注意力张量。集合并集在这些固定0/1字段上对应源码的逐列最大值,不能据此声称整个官方预处理器已被复刻。

源码的槽数公式是 ((num_frames - 5) // 17) * 5 + 2,官方CLI检查帧数满足17k+5。例子在这一形状规则外加了至少5帧的正长度限制,再检查累计区间恰好覆盖输入。输出中的107、124、141、175、192是源码自检采用的帧数算术案例;这些映射通过不等于相应长度已经在GPU或模型上走通。125帧被拒绝,正好防止把整数除法得到的槽数当成任意帧数都可用的证明。

124帧的最后两槽是[119,120)、[120,124),总共37槽。单帧槽每五槽重复一次,包含开头[0,1),并非仅开头特殊、其余全部四帧。这里的槽0只是原始帧分组索引,首帧保留在其中;它不是模型token编号。官方推理另把首帧放在 keyframe_indices=[0] 的视觉条件中,论文的打包序列还单列首帧条件C₀。不能把本例slot0直接等同于C₀,或根据区间表推断真实张量中的token位置。34

将下面完整代码保存为 timeline_slots.py,或进入包内 examples/ 使用同名附件,运行:

python3 timeline_slots.py
"""Finite W/S grouping example, pinned H3-World f0c7be2; no SDK/model run."""

from itertools import accumulate

_FRAME_PER_TOKEN = (1, 4, 4, 4, 4)


def frame_spans(num_frames):
    # This example adds a positive-length guard to the CLI's 17k+5 rule.
    if num_frames < 5 or (num_frames - 5) % 17:
        raise ValueError("frame_shape")
    latent_t = ((num_frames - 5) // 17) * 5 + 2
    widths = [_FRAME_PER_TOKEN[k % 5] for k in range(latent_t)]
    ends = list(accumulate(widths))
    spans = list(zip([0] + ends[:-1], ends))
    assert spans[-1][1] == num_frames
    return spans


def slot_for(frame, spans):
    for k, (start, end) in enumerate(spans):
        if start <= frame < end:
            return k
    raise ValueError("frame_range")


def aggregate(frames, spans):
    if len(frames) != spans[-1][1]:
        raise ValueError("frame_count")
    if any(keys - {"W", "S"} for keys in frames):
        raise ValueError("example_keys_only")
    rows = []
    for start, end in spans:
        observed = set().union(*frames[start:end])
        cleaned = observed - {"W", "S"} if {"W", "S"} <= observed else observed
        clause = ("walks forward" if "W" in cleaned else
                  "walks backward" if "S" in cleaned else "stands still")
        rows.append((observed, cleaned, clause))
    return rows


def fixture(events):
    frames = [set() for _ in range(124)]
    for frame, key in events:
        frames[frame].add(key)
    return frames


def reject(name, operation, expected):
    try:
        operation()
    except ValueError as exc:
        assert str(exc) == expected
        print(f"reject_{name}={exc}")
    else:
        raise AssertionError(f"accepted {name}")


def main():
    for n, count in [(107, 32), (124, 37), (141, 42), (175, 52), (192, 57)]:
        spans = frame_spans(n)
        assert len(spans) == count
        assert sum(end - start for start, end in spans) == n
        print(f"shape frames={n} slots={len(spans)} end={spans[-1][1]}")
    spans = frame_spans(124)
    assert spans[:6] == [(0, 1), (1, 5), (5, 9), (9, 13), (13, 17), (17, 18)]
    assert spans[-2:] == [(119, 120), (120, 124)]
    assert [slot_for(f, spans) for f in (0, 4, 5, 123)] == [0, 1, 2, 36]
    print(f"first6={spans[:6]}; last2={spans[-2:]}")
    print("boundary frame0=slot0 frame4=slot1 frame5=slot2 frame123=slot36")
    wwss = fixture([(1, "W"), (2, "W"), (3, "S"), (4, "S")])
    ssww = fixture([(1, "S"), (2, "S"), (3, "W"), (4, "W")])
    a, b = aggregate(wwss, spans), aggregate(ssww, spans)
    assert wwss != ssww and a == b and a[1] == ({"W", "S"}, set(), "stands still")
    print("same_slot WWSS=SSWW observed=SW cleaned=- clause=stands still")
    idle = aggregate(fixture([]), spans)
    assert idle[1][0] != a[1][0] and idle[1][2] == a[1][2]
    print("inverse idle!=conflict but both clauses=stands still")
    cross = aggregate(fixture([(3, "W"), (4, "W"), (5, "S"), (6, "S")]), spans)
    assert cross[1][2] == "walks forward" and cross[2][2] == "walks backward"
    print(f"cross_slot slot1={cross[1][2]}; slot2={cross[2][2]}")
    held = aggregate(fixture([(f, "W") for f in range(124)]), spans)
    assert all(row[2] == "walks forward" for row in held)
    short = aggregate(fixture([(3, "W")]), spans)
    assert short[1] == held[1]
    print("held_W=37 forward clauses including slot0; short_W slot1 equals held_W")
    reject("125", lambda: frame_spans(125), "frame_shape")
    reject("end124", lambda: slot_for(124, spans), "frame_range")
    reject("missing_frame", lambda: aggregate(wwss[:-1], spans), "frame_count")
    reject("unknown_key", lambda: aggregate(fixture([(1, "A")]), spans), "example_keys_only")
    print(f"24fps coverage: width1={1000 / 24:.3f}ms width4={4000 / 24:.3f}ms")
    print("scope=constructed_W_S_index_and_clause_checks; SDK_tensor_model_latency=NOT_MEASURED")


if __name__ == "__main__":
    main()

在2026-10-13的本机Python 3.11.6、macOS arm64环境执行,退出码为0,stderr为空。以下是该命令的实际stdout:

shape frames=107 slots=32 end=107
shape frames=124 slots=37 end=124
shape frames=141 slots=42 end=141
shape frames=175 slots=52 end=175
shape frames=192 slots=57 end=192
first6=[(0, 1), (1, 5), (5, 9), (9, 13), (13, 17), (17, 18)]; last2=[(119, 120), (120, 124)]
boundary frame0=slot0 frame4=slot1 frame5=slot2 frame123=slot36
same_slot WWSS=SSWW observed=SW cleaned=- clause=stands still
inverse idle!=conflict but both clauses=stands still
cross_slot slot1=walks forward; slot2=walks backward
held_W=37 forward clauses including slot0; short_W slot1 equals held_W
reject_125=frame_shape
reject_end124=frame_range
reject_missing_frame=frame_count
reject_unknown_key=example_keys_only
24fps coverage: width1=41.667ms width4=166.667ms
scope=constructed_W_S_index_and_clause_checks; SDK_tensor_model_latency=NOT_MEASURED

这些输出回答的是转换问题。observed=SW按固定显示顺序列出聚合后的两个键,cleaned=-表示清除后无移动键;它不是原始输入顺序。WWSS和SSWW的原始帧不同,简化子句却相同。空输入也得到 stands still,但聚合状态为空,因此只保存最终子句会失去冲突来源。跨槽案例把W放在帧3/4、S放在帧5/6,得到相邻槽的前进和后退两句;它证明该边界能留下不同条件,不证明人物实际完成了切换。

持续W覆盖包括slot0在内的37槽,作为分组没有漏掉首帧的正对照;单帧W与持续W在slot1得到相同二值记录,再次显示持续时间被折叠。四个拒绝案例分别守住不合法帧形状、右开末端124不属于输入、缺一帧和本例仅支持W/S的范围。最后一个拒绝并不意味着官方不支持A键,只是避免读者把这个窄例当成完整动作转换器。空槽在本例明确为静止,没有继承前态;若产品需要保持前态,必须另定义协议。

assert比较实际结果与预设区间和子句,错误会导致非零退出。它们没有测得响应延迟;41.667/166.667毫秒只是按24FPS换算的一帧/四帧输入覆盖长度。结尾的 NOT_MEASURED 保留了尚无SDK、张量、生成与延迟证据的状态。本例也没有实现官方CLI的时间JSON参数或实时输入补丁。接入者可以先复跑并移动构造切换点,确认帧到槽的责任,再决定是否值得启动真实模型对照。

时间位置告诉模型在哪里,路由限制谁能直接读取

限制直接出口,保留间接影响

时间线完成编码之后,H3-World 还需要让动作句与对应视频槽建立联系。论文将这一部分称为时间绑定和 Single-Egress 路由:动作文字与视频一起进入单流注意力,动作时间位置提供对应关系,定向掩码再限制直接读取路径。4

可把动作句记为 Aₖ,对应的视频时间槽记为 Vₖ。这里的箭头表示信息流动,不是播放顺序。

官方补丁的关键判断是:

v_reads_own = (fq >= 0) & (akv >= 0) & (fq == akv)
leak_out = (akv >= 0) & ~same_ann & ~v_reads_own

在这些变量中,fq 表示查询位置所属的视频槽,akv 表示被读取位置所属的动作句;相等才是匹配关系。结合周围判断,动作句可以被自身文字和匹配的视频槽直接读取,其他动作句与不匹配视频槽不能直接读取它。5

不过,Aₖ 不能直接进入 Vⱼ,不等于 Aₖ 永远不会影响 Vⱼ。 视频槽之间仍保留双向通信,于是 Aₖ → Vₖ → Vⱼ 这样的间接路径仍然存在。论文也明确保留视频 latent 之间的双向注意力。它限制的是动作进入视频表示的直接出口,没有把整段视频切成互不影响的独立小块。4

这一区分决定了怎样读结果:若改动一个槽的动作,其他画面也变化了,不能仅凭这一点判定路由失效;应先检查是否存在被禁止的直接读取边,再讨论模型的全局生成变化。反过来,掩码正确也不保证人物按要求移动,它还需要训练后的参数学会使用这条路径。“允许读取”不规定注意力分配,也不规定身体运动的大小和持续时间。本文不为路由与 LoRA 分配效果比例。

掩码通过与行为通过,分别需要什么证据

阅读路由代码时,要先统一“谁读取谁”的方向。查询位置代表读取者,键值位置代表被读取者;画信息流箭头时方向恰好从被读取内容指向读取者。如果表格沿查询和键值排列,却按反方向解释,一个看似合理的单向箭头图也可能把限制说反。小型索引测试应同时列出查询类型、查询槽号、键值类型、键值槽号与预期许可,而不是只打印一串布尔值。

可以构造两个视频槽和两条动作句,分别检查匹配读取、不匹配读取,以及视频槽之间的通信。这样的检查能验证提取出的规则是否自洽。若要进一步声称模型运行时执行了这张掩码,还需核对补丁实际进入的模块、张量的排列与长度,以及最终传入注意力计算的掩码。本文已运行上节有限的帧到槽索引例,但没有运行路由掩码或这些真实张量路径;本段的掩码检查仍是待执行方案。

如果想研究控制的局部性,可以把禁止边是否实际开放与生成变化是否越过目标时段分开记录。前者属于机制执行检查,后者属于模型行为观察。一次行为异常最多提出进一步检查的线索,不能跳过机制证据就宣布补丁失效;一次画面符合预期也不能替代对禁止读取关系的验证。

当前命令行示例,还不是实时动作时间表

当前CLI填的是整段预设

还有一处接口边界值得提前确认。本文核对的官方 infer.py 接收 --action-preset,先创建每槽一行的键状态数组,然后用:

keys9[:, S.KEYS9.index(key)] = 1

把选中 preset 的键填到所有槽,接着生成整组动作句并一次交给 pipeline。3

所以,照 README 的 forward 示例运行,得到的是整段使用同一种预设动作的配置。它不能直接证明命令行已经接受“0到1秒前进、1到2秒后退”的分段输入,更不能证明视频生成过程中接收实时键盘并立即反馈。

若要做分段时间线实验,可以在研究原型中按槽填入不同状态,再沿现有动作句接口传给 pipeline;这是需要实现和验证的改动建议,不是本文交付的可运行扩展。论文描述的整段未来联合去噪,也不能单凭动作接口被称为因果逐帧推演。4

接口升级必须保留输入层级的说明

接入者需要先回答,现有调用究竟接受预设名、逐帧键状态、逐槽键状态,还是已经生成好的动作句。它们处在不同层级,承担的时间映射责任也不同。把一个含时间戳的JSON直接传给原有preset参数,并不会让程序自动获得分段控制能力。新参数、解析规则与调用路径必须有具体实现和验证,不能靠文档中的示意结构冒充可运行接口。

若准备扩展逐槽输入,可以先做一个独立于模型的转换原型:检查槽数是否匹配,未知键如何处理,互斥键如何解释,空槽是静止还是沿用前态,以及数组中的每一行最终生成什么文字。这里尤其要区分“没有提交动作”与“明确要求停止”;如果应用把空值自动继承上一槽状态,就需要在协议中明确说明,不能悄悄改变用户指令的含义。

此外,预先提交整段条件,与生成过程中修改未来条件,是两类执行方式。后者还涉及已生成帧、未生成帧、缓存和当前模型推理过程的关系。公开的整段preset示例没有提供这些运行证据,所以这篇文章的结论止于输入编码和公开方法机制。即使完成分段数组扩展,也应先称为离线分段条件实验,只有真实在线链路的证据齐备后才讨论交互响应。

一个先查接口、再看画面的验证顺序

先导出时间线,再看生成画面

开始花 GPU 时间前,可以准备三组构造输入:持续 W;同一四帧槽内 W→S;在两个槽边界处 W→S。固定其他状态,先把每帧输入、每槽区间、聚合结果和最终动作句导出。第二组若变成静止,应记录为接口转换结果,而不是等待模型把已经消失的先后顺序演出来。

随后检查路由:对小型槽索引列出允许与禁止的读取关系,确认 A₁ 可由 V₁ 直接读取、不能由 V₂ 直接读取,同时 V₁ 与 V₂ 的通信仍存在。此处验证的是掩码规则,不能代替真实模型内的执行路径检查。

最后才做生成对照:固定首帧、场景、实际随机种子、模型权重与补丁版本,并保存实际加载模块/构建身份和本次运行参数,只改变一组动作状态;保留实际动作句,再分别查看角色移动与镜头变化。评价时按真实槽边界对齐,预先约定允许的过渡范围,并记录其他时间段是否变化。单次同种子对照只能帮助定位现象,不能证明总体控制成功率。

本文已执行上节构造记录的区间、W/S聚合和简化子句检查;路由张量执行、模型推理与硬件测量仍未进行,后两步保持待执行。它给接入者的直接产物,是一张可导出的时间线记录:原始帧号 → 槽区间 → 聚合状态 → 动作句 → 允许读取的位置。当动作没有按时发生时,先找到这条链上第一个偏离预期的位置,才知道应该修改输入协议、时间映射,还是继续研究模型行为。

给每次失败留下能够决定下一步的记录

生成对照的判定条件最好在观看结果前写下。例如,目标时段里要求角色朝某方向移动,就要说明依据的是角色相对场景的位置、镜头运动,还是某个可辨认物体的相对关系。只有画面发生位移不足以证明角色执行了前进,因为镜头变化也能产生类似观感。若场景缺少稳定参照,应把无法判断列为结果,而不是强行分成成功和失败。

对照记录要能区分责任:原始动作与转换句不同,先查编码;转换正确但实际掩码不符,查机制接入;两层都有证据而画面仍异常,才查模型行为。输入层级、槽边界表、实际动作句、生成参数和构建版本都应随记录保存,缺项保持未核实,不能用画面倒推补齐。

多次实验时,沿用同一套预先声明的判定规则,并把模糊样本和失败样本保留下来。不能为某次输出临时扩大允许的过渡区间,再将它计为成功。不同种子、场景和动作类型可以用来研究适用范围,但这属于后续实验;本文的代码推导不会自动变成这些条件下的控制表现结论。

最终,这张记录表的价值是让失败有明确去向。先确定表达有没有丢失,再确定时间有没有偏移,然后确定入口有没有按设计限制,最后判断模型有没有完成已被正确表达的动作。每一层都可以形成独立证据,避免把不同责任压缩成一句“模型不听指令”。

来源与适用版本

原核对日:2026-10-01;本轮复核日:2026-10-13。官方main仍指向同一提交,所核对的四个代码文件原字节均与固定版本相同。官方 H3-World 代码固定在 f0c7be2acbbde8256b2473f6e7b088f58272acae,论文为 arXiv:2609.01560v1。未采用原研究包的部署性能、训练数量推断或泛化成功率;H3-World 与本仓此前讨论的 h3.c 推理 Runtime 不是同一项目。

Footnotes

  1. 官方 abot_action.py:时间分组与二值聚合,另见该文件 bin_to_latent。 ↩ ↩2

  2. 官方 action_script.py:冲突消除与动作子句。 ↩

  3. 官方 infer.py:整段动作 preset。 ↩ ↩2

  4. H3-World 论文 v1,§3.1–3.4,方法描述;不作为本机推理或实时延迟证明。 ↩ ↩2 ↩3 ↩4

  5. 官方注意力补丁:定向掩码。 ↩