From 16aadf62c2db1f57de95bb7f8c62f5ff4268054b Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 3 Sep 2026 10:49:29 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(tts):=20=E6=96=B0=E5=A2=9E=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=AF=BB=E5=9D=80=E9=9F=B3=E9=A2=91=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=BA=93=EF=BC=8C=E6=94=B9=E7=A8=BF=E8=B7=A8=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E5=8F=AA=E9=87=8D=E9=85=8D=E5=8F=98=E6=9B=B4=E5=8F=A5?= =?UTF-8?q?;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 集内 audio/ 属 gitignored 本地产物,换 worktree/清盘即丢整集克隆合成成果(Claude Code 系列 mp3 已实测全量遗失)。版本库按「集 slug + 句 id + digest 前 12 位」落机器级持久 目录(默认 ~/Library/Application Support/negentropy-influence/tts-store,NE_TTS_STORE 可覆盖,--no-store 禁用):合成成功入库、集内缓存 miss 先按 digest 回收、--plan 报 「版本库可回收 N 句」并按净合成量估时;不同 digest 并存保留历史版本,整体换风格重配 除外。仅接 indextts(edge 秒级重合成无资产可丢)。纯函数契约由 test_tts_store.py 六用例钉死(回收等价缓存命中/digest 失配 miss/版本并存/同 digest 刷新/禁用直通/环境覆盖)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .../pipeline/scripts/tts.py | 111 +++++++++++++++++- .../pipeline/tests/test_tts_store.py | 84 +++++++++++++ 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 apps/negentropy-influence/pipeline/tests/test_tts_store.py diff --git a/apps/negentropy-influence/pipeline/scripts/tts.py b/apps/negentropy-influence/pipeline/scripts/tts.py index 58231b0cd..3144f25fc 100644 --- a/apps/negentropy-influence/pipeline/scripts/tts.py +++ b/apps/negentropy-influence/pipeline/scripts/tts.py @@ -9,6 +9,10 @@ 通过 --ref 提供参考音色样本、--style 选择风格(sunny 明快阳光为推荐位, sunny-steady 为其定稿档=同参数 + 束宽 3;另有激情/轻快/自信/正能量)。 - 幂等:参数与文本未变则跳过(SHA1 摘要 sidecar 缓存)。 +- 版本库(indextts):合成成功的句子按 digest 存入机器级持久库(默认 + ~/Library/Application Support/negentropy-influence/tts-store,环境变量 NE_TTS_STORE + 覆盖,--no-store 禁用);集内缓存未命中时先按 digest 回收——换 worktree / 清盘不再 + 丢整集合成成果,改稿只重配变更句。历史版本按 digest 文件名并存,不互相覆盖。 用法: edge: uv run --no-project --with edge-tts --with mutagen $R/tts.py \ @@ -30,6 +34,8 @@ import hashlib import json import math +import os +import shutil import sys import urllib.error import urllib.request @@ -633,6 +639,77 @@ def digest_indextts( ).hexdigest() +# ---------------- 音频版本库(内容寻址持久库,仅 indextts) ---------------- +# +# 集内 audio/ 是 gitignored 本地产物 ⇒ 换 worktree / 清盘即丢整集合成成果 +# (Claude Code 系列的 mp3 曾在任何工作区都不剩一份,实测教训)。版本库按 +# 「句 id + digest 前 12 位」命名放在机器级持久目录,与任何 worktree 解耦: +# - 合成成功 → store_deposit 入库(同 digest 刷新为最新音频;不同 digest 并存=保留历史版本); +# - 集内缓存未命中 → store_restore 按 digest 回收,命中即等价集内缓存命中; +# - 改稿/换风格 ⇒ 新 digest 自然 miss 重配,旧版本文件保留可回退。 +# 路径是机器属性:默认值在此 + NE_TTS_STORE 环境变量覆盖,永不写进受版本控制的 +# toml(与 config.py 对 tts.server 的立场一致;tts.py 不 import paths.py 的边界 +# 也不变——默认值是纯字面量)。仅接 indextts:edge 预置音色免密钥秒级重合成, +# 无 2 小时级资产可丢。 + +DEFAULT_STORE = "~/Library/Application Support/negentropy-influence/tts-store" + + +def store_root(disabled: bool) -> Path | None: + """版本库根目录;--no-store 或 NE_TTS_STORE='' 时返回 None(全程直通不落盘)。""" + if disabled: + return None + env = os.environ.get("NE_TTS_STORE") + return Path(env or DEFAULT_STORE).expanduser() + + +def store_entry(store: Path | None, slug: str, sid: str, digest: str) -> Path | None: + """库内条目路径:/<集 slug>/..mp3(同名 .sha 邻档存全量 digest)。""" + if store is None: + return None + return store / slug / f"{sid}.{digest[:12]}.mp3" + + +def store_has(store: Path | None, slug: str, sid: str, digest: str) -> bool: + """--plan 用:库内是否存在该 digest 的句子(校验 .sha 邻档,防 12 位前缀巧合)。""" + if store is None: + return False + mp3 = store_entry(store, slug, sid, digest) + sha = mp3.with_suffix(".sha") + return ( + mp3.is_file() + and mp3.stat().st_size > 0 + and sha.is_file() + and sha.read_text() == digest + ) + + +def store_deposit( + mp3: Path, sid: str, digest: str, store: Path | None, slug: str +) -> None: + """合成成功后入库。失败只 WARN 不断长跑——库是加速器,不是门。""" + if store is None: + return + dst = store_entry(store, slug, sid, digest) + try: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(mp3, dst) + dst.with_suffix(".sha").write_text(digest, encoding="utf-8") + except OSError as e: + print(f"WARN 版本库入库失败({sid}):{e}", file=sys.stderr) + + +def store_restore( + sid: str, digest: str, out_dir: Path, store: Path | None, slug: str +) -> bool: + """集内缓存 miss 时先向库回收;命中则 mp3 + .sha 落位,等价集内缓存命中。""" + if not store_has(store, slug, sid, digest): + return False + shutil.copyfile(store_entry(store, slug, sid, digest), out_dir / f"{sid}.mp3") + (out_dir / f"{sid}.sha").write_text(digest, encoding="utf-8") + return True + + async def synth_indextts( sem: asyncio.Semaphore, item: dict, @@ -652,6 +729,8 @@ async def synth_indextts( emo_ref_sha1: str | None = None, emo_text: str | None = None, sampling: dict | None = None, + store: Path | None = None, + slug: str = "", ) -> dict: sid, text = item["id"], synth_source_text(item) mp3 = out_dir / f"{sid}.mp3" @@ -679,6 +758,8 @@ async def synth_indextts( and meta.read_text() == digest ): pass + elif not force and store_restore(sid, digest, out_dir, store, slug): + pass # 版本库回收命中:mp3 + .sha 已落位,等价集内缓存命中(--force 不走库) else: async with sem: last_err: Exception | None = None @@ -709,6 +790,7 @@ async def synth_indextts( if mp3.stat().st_size == 0: raise RuntimeError("空音频文件") meta.write_text(digest) + store_deposit(mp3, sid, digest, store, slug) break except NonRetryableError: raise @@ -743,6 +825,11 @@ async def main() -> None: ) parser.add_argument("--rate", default=DEFAULT_RATE, help="[edge] 语速(默认 +4%%)") parser.add_argument("--force", action="store_true", help="忽略缓存强制重合成") + parser.add_argument( + "--no-store", + action="store_true", + help="[indextts] 禁用音频版本库(默认启用;库根目录可用环境变量 NE_TTS_STORE 覆盖)", + ) parser.add_argument( "--allow-voice-switch", action="store_true", @@ -1015,6 +1102,8 @@ async def main() -> None: out_dir = root / "video" / "public" / "audio" items = json.loads(src.read_text(encoding="utf-8")) out_dir.mkdir(parents=True, exist_ok=True) + store = store_root(args.no_store) + slug = root.name if args.engine == "edge": signature = f"edge|{args.voice}|{args.rate}" @@ -1129,6 +1218,7 @@ async def main() -> None: ) todo = {b: 0 for b in sorted(set(beams_of.values()))} cached = dict(todo) + store_hits = dict(todo) for i in items: b = beams_of[i["id"]] d = digest_indextts( @@ -1154,20 +1244,35 @@ async def main() -> None: and meta.read_text() == d ) (cached if hit else todo)[b] += 1 + if not hit and not args.force and store_has(store, slug, i["id"], d): + store_hits[b] += 1 # 估时用**整集长跑折算口径**(含降频、机器争用与逐句开销),不是单句空闲口径: # 1 束 RTF≈13(三集 596 句实测 8.5 h 折算)、≥2 束≈45(短句 A/B 实测约 3.2 倍); # 每句音频按 4.2s(三集均值)。单句空闲时可快到 RTF 6–7,故本估算偏保守。 est = sum( - n * AVG_SEC_PER_LINE * (RTF_1BEAM if b == 1 else RTF_MULTIBEAM) + (n - store_hits[b]) + * AVG_SEC_PER_LINE + * (RTF_1BEAM if b == 1 else RTF_MULTIBEAM) for b, n in todo.items() ) for b in sorted(todo): print( f" 束宽 {b}:待合成 {todo[b]:>3} 句 · 已缓存 {cached[b]:>3} 句" + + ( + f" · 版本库可回收 {store_hits[b]:>3} 句" + if store_hits[b] + else "" + ) + ("" if b == 1 else "(高束宽档)") ) print( - f">> 待合成合计 {sum(todo.values())} 句,估算墙钟约 {est / 3600:.1f} 小时" + f">> 待合成合计 {sum(todo.values())} 句" + + ( + f"(其中 {sum(store_hits.values())} 句由版本库直收,不占合成时间)" + if sum(store_hits.values()) + else "" + ) + + f",估算墙钟约 {est / 3600:.1f} 小时" f"(长跑折算口径 RTF 1 束≈{RTF_1BEAM:g} / 高束宽≈{RTF_MULTIBEAM:g}," f"机器负载会显著影响,仅作排期参考)" ) @@ -1238,6 +1343,8 @@ async def main() -> None: emo_ref_sha1=emo_ref_sha1, emo_text=args.emo_text, sampling=sampling, + store=store, + slug=slug, ) for i in items ) diff --git a/apps/negentropy-influence/pipeline/tests/test_tts_store.py b/apps/negentropy-influence/pipeline/tests/test_tts_store.py new file mode 100644 index 000000000..aaa423490 --- /dev/null +++ b/apps/negentropy-influence/pipeline/tests/test_tts_store.py @@ -0,0 +1,84 @@ +"""音频版本库(tts.py 内容寻址持久库)的纯函数契约。 + +背景:集内 audio/ 是 gitignored 本地产物,换 worktree 即丢——版本库按 +(集 slug, 句 id, digest)三元组持久化合成成果,使「改稿只重配变更句」 +跨工作区成立。此处钉死四条不变量:回收等价缓存命中、digest 失配必 miss、 +历史版本并存不覆盖、禁用时全程直通。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from tts import ( + store_deposit, + store_entry, + store_has, + store_restore, + store_root, +) + + +def _mk(root: Path, name: str, size: int = 64) -> Path: + """占位音频:store 只搬运字节不解析,内容无语义。""" + p = root / name + p.write_bytes(bytes(range(size % 251))) + return p + + +def test_roundtrip_deposit_then_restore(tmp_path): + audio, store = tmp_path / "audio", tmp_path / "store" + audio.mkdir() + src = _mk(tmp_path, "p0-01.mp3") + store_deposit(src, "p0-01", "d" * 40, store, "ep-video") + assert store_has(store, "ep-video", "p0-01", "d" * 40) + # 集内目录为空(换 worktree 后缓存丢失的形态)→ 回收命中并落位 mp3 + .sha + assert store_restore("p0-01", "d" * 40, audio, store, "ep-video") + assert (audio / "p0-01.mp3").read_bytes() == src.read_bytes() + assert (audio / "p0-01.sha").read_text() == "d" * 40 + + +def test_digest_mismatch_is_miss(tmp_path): + audio, store = tmp_path / "audio", tmp_path / "store" + audio.mkdir() + src = _mk(tmp_path, "p0-02.mp3") + store_deposit(src, "p0-02", "a" * 40, store, "ep-video") + assert not store_has(store, "ep-video", "p0-02", "b" * 40) + assert not store_restore("p0-02", "b" * 40, audio, store, "ep-video") + assert not (audio / "p0-02.mp3").exists() + + +def test_history_versions_coexist(tmp_path): + store = tmp_path / "store" + v1, v2 = _mk(tmp_path, "v1.mp3", 10), _mk(tmp_path, "v2.mp3", 200) + store_deposit(v1, "p1-07", "1" * 40, store, "ep") + store_deposit(v2, "p1-07", "2" * 40, store, "ep") + # 不同 digest 不同文件名 ⇒ 历史版本并存,改稿不丢旧版(可回退) + assert store_entry(store, "ep", "p1-07", "1" * 40).is_file() + assert store_entry(store, "ep", "p1-07", "2" * 40).is_file() + + +def test_same_digest_deposit_refreshes(tmp_path): + store = tmp_path / "store" + old, new = _mk(tmp_path, "old.mp3", 10), _mk(tmp_path, "new.mp3", 230) + d = "e" * 40 + store_deposit(old, "p3-01", d, store, "ep") + store_deposit( + new, "p3-01", d, store, "ep" + ) # 同 digest 重跑(--force)→ 刷新为新音频 + assert store_entry(store, "ep", "p3-01", d).read_bytes() == new.read_bytes() + + +def test_disabled_store_is_transparent(tmp_path): + assert store_root(disabled=True) is None + src = _mk(tmp_path, "x.mp3") + store_deposit(src, "x", "c" * 40, None, "ep") # no-op 不炸、不建目录 + assert not (tmp_path / "store").exists() + assert not store_restore("x", "c" * 40, tmp_path, None, "ep") + + +def test_env_override(monkeypatch, tmp_path): + monkeypatch.setenv("NE_TTS_STORE", str(tmp_path / "custom")) + assert store_root(disabled=False) == tmp_path / "custom" From 43921ea57492df7e27efffeb6810f454acf686bc Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 3 Sep 2026 11:09:20 +0800 Subject: [PATCH 2/9] =?UTF-8?q?build(pnpm):=20=E6=9E=84=E5=BB=BA=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E8=AE=B8=E5=8F=AF=E8=BF=81=20pnpm-workspace.yaml=20al?= =?UTF-8?q?lowBuilds=EF=BC=8C=E4=BF=AE=E5=A4=8D=20pnpm=2012=20=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E4=B8=AD=E6=96=AD;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm 12 不再读取 package.json 的 pnpm.onlyBuiltDependencies 字段:esbuild 构建脚本被静默 忽略,安装以 ERR_PNPM_IGNORED_BUILDS 中断、node_modules 半残(tsc 在而 esbuild 缺)。许可 迁至 pnpm-workspace.yaml 的 allowBuilds 键(与 `pnpm approve-builds esbuild --yes` 实际写入 形态逐字一致),并删除 package.json 中已死的 pnpm 字段。模板与 8 集同步(frozen/structured 档执法面),根 lockfile 零改动。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .../claude-code-concurrency-video/video/package.json | 5 ----- .../video/pnpm-workspace.yaml | 7 ++++++- .../claude-code-explained-video/video/package.json | 5 ----- .../claude-code-explained-video/video/pnpm-workspace.yaml | 7 ++++++- .../episodes/claude-code-memory-video/video/package.json | 5 ----- .../claude-code-memory-video/video/pnpm-workspace.yaml | 7 ++++++- .../claude-code-multiagent-video/video/package.json | 5 ----- .../claude-code-multiagent-video/video/pnpm-workspace.yaml | 7 ++++++- .../episodes/claude-code-planning-video/video/package.json | 5 ----- .../claude-code-planning-video/video/pnpm-workspace.yaml | 7 ++++++- .../experience-era-agents-video/video/package.json | 5 ----- .../experience-era-agents-video/video/pnpm-workspace.yaml | 7 ++++++- .../self-evolving-coding-agents-video/video/package.json | 5 ----- .../video/pnpm-workspace.yaml | 7 ++++++- .../self-improving-agents-video/video/package.json | 5 ----- .../self-improving-agents-video/video/pnpm-workspace.yaml | 7 ++++++- .../templates/video-skeleton/video/package.json.tmpl | 5 ----- .../templates/video-skeleton/video/pnpm-workspace.yaml | 7 ++++++- 18 files changed, 54 insertions(+), 54 deletions(-) diff --git a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/package.json b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/package.json index 83b2d0536..33258e8bf 100644 --- a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/package.json +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/package.json @@ -19,10 +19,5 @@ "devDependencies": { "@types/react": "^19.0.0", "typescript": "^5.6.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] } } diff --git a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/pnpm-workspace.yaml b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/pnpm-workspace.yaml index f2686ac7d..add822dbe 100644 --- a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/pnpm-workspace.yaml +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/pnpm-workspace.yaml @@ -1,4 +1,9 @@ -# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的唯一理由。 +# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的首要理由。 # pnpm 12 起仅靠 `--ignore-workspace` 不够:pnpm 仍会沿 packageManager 向上锚定到仓库根, # 用本工程的解析结果覆写根 pnpm-lock.yaml(overrides 全丢)且当场不报错。见 ISSUE-175。 packages: [] +# pnpm 12 的构建脚本许可(package.json 的 pnpm.onlyBuiltDependencies 已不被读取, +# 静默忽略会使安装以 ERR_PNPM_IGNORED_BUILDS 中断、node_modules 半残)。键名与取值 +# 与 `pnpm approve-builds esbuild --yes` 实际写入的形态逐字一致——勿改回旧键名。 +allowBuilds: + esbuild: true diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/package.json b/apps/negentropy-influence/episodes/claude-code-explained-video/video/package.json index ca5fe3297..7f7d020ed 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/video/package.json +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/package.json @@ -19,10 +19,5 @@ "devDependencies": { "@types/react": "^19.0.0", "typescript": "^5.6.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] } } diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/pnpm-workspace.yaml b/apps/negentropy-influence/episodes/claude-code-explained-video/video/pnpm-workspace.yaml index f2686ac7d..add822dbe 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/video/pnpm-workspace.yaml +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/pnpm-workspace.yaml @@ -1,4 +1,9 @@ -# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的唯一理由。 +# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的首要理由。 # pnpm 12 起仅靠 `--ignore-workspace` 不够:pnpm 仍会沿 packageManager 向上锚定到仓库根, # 用本工程的解析结果覆写根 pnpm-lock.yaml(overrides 全丢)且当场不报错。见 ISSUE-175。 packages: [] +# pnpm 12 的构建脚本许可(package.json 的 pnpm.onlyBuiltDependencies 已不被读取, +# 静默忽略会使安装以 ERR_PNPM_IGNORED_BUILDS 中断、node_modules 半残)。键名与取值 +# 与 `pnpm approve-builds esbuild --yes` 实际写入的形态逐字一致——勿改回旧键名。 +allowBuilds: + esbuild: true diff --git a/apps/negentropy-influence/episodes/claude-code-memory-video/video/package.json b/apps/negentropy-influence/episodes/claude-code-memory-video/video/package.json index 94896ee1c..35d1c2641 100644 --- a/apps/negentropy-influence/episodes/claude-code-memory-video/video/package.json +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/package.json @@ -19,10 +19,5 @@ "devDependencies": { "@types/react": "^19.0.0", "typescript": "^5.6.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] } } diff --git a/apps/negentropy-influence/episodes/claude-code-memory-video/video/pnpm-workspace.yaml b/apps/negentropy-influence/episodes/claude-code-memory-video/video/pnpm-workspace.yaml index f2686ac7d..add822dbe 100644 --- a/apps/negentropy-influence/episodes/claude-code-memory-video/video/pnpm-workspace.yaml +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/pnpm-workspace.yaml @@ -1,4 +1,9 @@ -# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的唯一理由。 +# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的首要理由。 # pnpm 12 起仅靠 `--ignore-workspace` 不够:pnpm 仍会沿 packageManager 向上锚定到仓库根, # 用本工程的解析结果覆写根 pnpm-lock.yaml(overrides 全丢)且当场不报错。见 ISSUE-175。 packages: [] +# pnpm 12 的构建脚本许可(package.json 的 pnpm.onlyBuiltDependencies 已不被读取, +# 静默忽略会使安装以 ERR_PNPM_IGNORED_BUILDS 中断、node_modules 半残)。键名与取值 +# 与 `pnpm approve-builds esbuild --yes` 实际写入的形态逐字一致——勿改回旧键名。 +allowBuilds: + esbuild: true diff --git a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/package.json b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/package.json index 6c9440d49..2153d57e7 100644 --- a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/package.json +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/package.json @@ -19,10 +19,5 @@ "devDependencies": { "@types/react": "^19.0.0", "typescript": "^5.6.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] } } diff --git a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/pnpm-workspace.yaml b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/pnpm-workspace.yaml index f2686ac7d..add822dbe 100644 --- a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/pnpm-workspace.yaml +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/pnpm-workspace.yaml @@ -1,4 +1,9 @@ -# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的唯一理由。 +# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的首要理由。 # pnpm 12 起仅靠 `--ignore-workspace` 不够:pnpm 仍会沿 packageManager 向上锚定到仓库根, # 用本工程的解析结果覆写根 pnpm-lock.yaml(overrides 全丢)且当场不报错。见 ISSUE-175。 packages: [] +# pnpm 12 的构建脚本许可(package.json 的 pnpm.onlyBuiltDependencies 已不被读取, +# 静默忽略会使安装以 ERR_PNPM_IGNORED_BUILDS 中断、node_modules 半残)。键名与取值 +# 与 `pnpm approve-builds esbuild --yes` 实际写入的形态逐字一致——勿改回旧键名。 +allowBuilds: + esbuild: true diff --git a/apps/negentropy-influence/episodes/claude-code-planning-video/video/package.json b/apps/negentropy-influence/episodes/claude-code-planning-video/video/package.json index ea8b2e8e6..f16567046 100644 --- a/apps/negentropy-influence/episodes/claude-code-planning-video/video/package.json +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/package.json @@ -19,10 +19,5 @@ "devDependencies": { "@types/react": "^19.0.0", "typescript": "^5.6.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] } } diff --git a/apps/negentropy-influence/episodes/claude-code-planning-video/video/pnpm-workspace.yaml b/apps/negentropy-influence/episodes/claude-code-planning-video/video/pnpm-workspace.yaml index f2686ac7d..add822dbe 100644 --- a/apps/negentropy-influence/episodes/claude-code-planning-video/video/pnpm-workspace.yaml +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/pnpm-workspace.yaml @@ -1,4 +1,9 @@ -# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的唯一理由。 +# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的首要理由。 # pnpm 12 起仅靠 `--ignore-workspace` 不够:pnpm 仍会沿 packageManager 向上锚定到仓库根, # 用本工程的解析结果覆写根 pnpm-lock.yaml(overrides 全丢)且当场不报错。见 ISSUE-175。 packages: [] +# pnpm 12 的构建脚本许可(package.json 的 pnpm.onlyBuiltDependencies 已不被读取, +# 静默忽略会使安装以 ERR_PNPM_IGNORED_BUILDS 中断、node_modules 半残)。键名与取值 +# 与 `pnpm approve-builds esbuild --yes` 实际写入的形态逐字一致——勿改回旧键名。 +allowBuilds: + esbuild: true diff --git a/apps/negentropy-influence/episodes/experience-era-agents-video/video/package.json b/apps/negentropy-influence/episodes/experience-era-agents-video/video/package.json index 36c1d75d9..50d5e8bb9 100644 --- a/apps/negentropy-influence/episodes/experience-era-agents-video/video/package.json +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/package.json @@ -19,10 +19,5 @@ "devDependencies": { "@types/react": "^19.0.0", "typescript": "^5.6.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] } } diff --git a/apps/negentropy-influence/episodes/experience-era-agents-video/video/pnpm-workspace.yaml b/apps/negentropy-influence/episodes/experience-era-agents-video/video/pnpm-workspace.yaml index f2686ac7d..add822dbe 100644 --- a/apps/negentropy-influence/episodes/experience-era-agents-video/video/pnpm-workspace.yaml +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/pnpm-workspace.yaml @@ -1,4 +1,9 @@ -# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的唯一理由。 +# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的首要理由。 # pnpm 12 起仅靠 `--ignore-workspace` 不够:pnpm 仍会沿 packageManager 向上锚定到仓库根, # 用本工程的解析结果覆写根 pnpm-lock.yaml(overrides 全丢)且当场不报错。见 ISSUE-175。 packages: [] +# pnpm 12 的构建脚本许可(package.json 的 pnpm.onlyBuiltDependencies 已不被读取, +# 静默忽略会使安装以 ERR_PNPM_IGNORED_BUILDS 中断、node_modules 半残)。键名与取值 +# 与 `pnpm approve-builds esbuild --yes` 实际写入的形态逐字一致——勿改回旧键名。 +allowBuilds: + esbuild: true diff --git a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/package.json b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/package.json index d27caa31f..15220012e 100644 --- a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/package.json +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/package.json @@ -19,10 +19,5 @@ "devDependencies": { "@types/react": "^19.0.0", "typescript": "^5.6.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] } } diff --git a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/pnpm-workspace.yaml b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/pnpm-workspace.yaml index f2686ac7d..add822dbe 100644 --- a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/pnpm-workspace.yaml +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/pnpm-workspace.yaml @@ -1,4 +1,9 @@ -# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的唯一理由。 +# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的首要理由。 # pnpm 12 起仅靠 `--ignore-workspace` 不够:pnpm 仍会沿 packageManager 向上锚定到仓库根, # 用本工程的解析结果覆写根 pnpm-lock.yaml(overrides 全丢)且当场不报错。见 ISSUE-175。 packages: [] +# pnpm 12 的构建脚本许可(package.json 的 pnpm.onlyBuiltDependencies 已不被读取, +# 静默忽略会使安装以 ERR_PNPM_IGNORED_BUILDS 中断、node_modules 半残)。键名与取值 +# 与 `pnpm approve-builds esbuild --yes` 实际写入的形态逐字一致——勿改回旧键名。 +allowBuilds: + esbuild: true diff --git a/apps/negentropy-influence/episodes/self-improving-agents-video/video/package.json b/apps/negentropy-influence/episodes/self-improving-agents-video/video/package.json index 425279d55..c6e623823 100644 --- a/apps/negentropy-influence/episodes/self-improving-agents-video/video/package.json +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/package.json @@ -19,10 +19,5 @@ "devDependencies": { "@types/react": "^19.0.0", "typescript": "^5.6.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] } } diff --git a/apps/negentropy-influence/episodes/self-improving-agents-video/video/pnpm-workspace.yaml b/apps/negentropy-influence/episodes/self-improving-agents-video/video/pnpm-workspace.yaml index f2686ac7d..add822dbe 100644 --- a/apps/negentropy-influence/episodes/self-improving-agents-video/video/pnpm-workspace.yaml +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/pnpm-workspace.yaml @@ -1,4 +1,9 @@ -# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的唯一理由。 +# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的首要理由。 # pnpm 12 起仅靠 `--ignore-workspace` 不够:pnpm 仍会沿 packageManager 向上锚定到仓库根, # 用本工程的解析结果覆写根 pnpm-lock.yaml(overrides 全丢)且当场不报错。见 ISSUE-175。 packages: [] +# pnpm 12 的构建脚本许可(package.json 的 pnpm.onlyBuiltDependencies 已不被读取, +# 静默忽略会使安装以 ERR_PNPM_IGNORED_BUILDS 中断、node_modules 半残)。键名与取值 +# 与 `pnpm approve-builds esbuild --yes` 实际写入的形态逐字一致——勿改回旧键名。 +allowBuilds: + esbuild: true diff --git a/apps/negentropy-influence/pipeline/templates/video-skeleton/video/package.json.tmpl b/apps/negentropy-influence/pipeline/templates/video-skeleton/video/package.json.tmpl index 5730b34b2..15cea2dcd 100644 --- a/apps/negentropy-influence/pipeline/templates/video-skeleton/video/package.json.tmpl +++ b/apps/negentropy-influence/pipeline/templates/video-skeleton/video/package.json.tmpl @@ -19,10 +19,5 @@ "devDependencies": { "@types/react": "^19.0.0", "typescript": "^5.6.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] } } diff --git a/apps/negentropy-influence/pipeline/templates/video-skeleton/video/pnpm-workspace.yaml b/apps/negentropy-influence/pipeline/templates/video-skeleton/video/pnpm-workspace.yaml index f2686ac7d..add822dbe 100644 --- a/apps/negentropy-influence/pipeline/templates/video-skeleton/video/pnpm-workspace.yaml +++ b/apps/negentropy-influence/pipeline/templates/video-skeleton/video/pnpm-workspace.yaml @@ -1,4 +1,9 @@ -# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的唯一理由。 +# 把本工程钉成它自己的 pnpm workspace 根——这是本文件存在的首要理由。 # pnpm 12 起仅靠 `--ignore-workspace` 不够:pnpm 仍会沿 packageManager 向上锚定到仓库根, # 用本工程的解析结果覆写根 pnpm-lock.yaml(overrides 全丢)且当场不报错。见 ISSUE-175。 packages: [] +# pnpm 12 的构建脚本许可(package.json 的 pnpm.onlyBuiltDependencies 已不被读取, +# 静默忽略会使安装以 ERR_PNPM_IGNORED_BUILDS 中断、node_modules 半残)。键名与取值 +# 与 `pnpm approve-builds esbuild --yes` 实际写入的形态逐字一致——勿改回旧键名。 +allowBuilds: + esbuild: true From 74e00db881e9828dae45d4224dfc454765d5b330 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 3 Sep 2026 11:25:26 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(qa):=20=E6=8A=BD=E5=B8=A7=E4=BD=93?= =?UTF-8?q?=E6=A3=80=E6=96=B0=E5=A2=9E=20beat=20=E5=A4=B4=E9=83=A8?= =?UTF-8?q?=E8=BF=9E=E6=8A=BD=E4=B8=8E=20A/B=20=E5=AF=B9=E6=8B=8D=EF=BC=8C?= =?UTF-8?q?=E5=8A=A8=E6=95=88=E5=88=97=20@=E5=8A=A8=E8=AF=8D=20=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=E6=9C=BA=E6=A3=80=E9=97=AD=E7=8E=AF;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --beat-heads N:每 beat 首句起点连抽 N 帧——ISSUE-170「句中点采样结构性错过亚秒 入场瞬态」的机械补盲(可组合 --scene 过滤幕;该模式冻帧判定关闭,静止 beat 头帧 指纹相同是合法态)。--compare A B:同帧号抽两版逐帧差异(meanΔ/差异像素占比/bbox, numpy+pillow 零新依赖,advisory 退出码恒 0)——重制/重构「不外溢的意图变更之外 一切差异须归因」的证据工具,JND=12 抗 jpeg 噪声。check_script --check-motion: 分镜动效列 @动词 标注 ↔ 场景代码运动模型调用互比(WARN-only,词表从本集 motion/hooks.ts 派生不复制)——「FadeUp 写在分镜却零调用」实测缺陷类的机械化。 pipeline.py qa 子命令同步转发两 flag;纯函数与互比逻辑均有单测钉死。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .../video/scripts/motion.test.ts | 123 ++++++++ .../video/src/motion/gallery.tsx | 265 +++++++++++++++++ .../video/src/motion/hooks.ts | 268 ++++++++++++++++++ .../video/src/motion/index.ts | 5 + .../video/src/motion/schedule.ts | 59 ++++ .../video/src/motion/tokens.ts | 87 ++++++ .../video/src/motion/window.ts | 28 ++ .../video/scripts/motion.test.ts | 123 ++++++++ .../video/src/motion/gallery.tsx | 265 +++++++++++++++++ .../video/src/motion/hooks.ts | 268 ++++++++++++++++++ .../video/src/motion/index.ts | 5 + .../video/src/motion/schedule.ts | 59 ++++ .../video/src/motion/tokens.ts | 87 ++++++ .../video/src/motion/window.ts | 28 ++ .../video/scripts/motion.test.ts | 123 ++++++++ .../video/src/motion/gallery.tsx | 265 +++++++++++++++++ .../video/src/motion/hooks.ts | 268 ++++++++++++++++++ .../video/src/motion/index.ts | 5 + .../video/src/motion/schedule.ts | 59 ++++ .../video/src/motion/tokens.ts | 87 ++++++ .../video/src/motion/window.ts | 28 ++ .../video/scripts/motion.test.ts | 123 ++++++++ .../video/src/motion/gallery.tsx | 265 +++++++++++++++++ .../video/src/motion/hooks.ts | 268 ++++++++++++++++++ .../video/src/motion/index.ts | 5 + .../video/src/motion/schedule.ts | 59 ++++ .../video/src/motion/tokens.ts | 87 ++++++ .../video/src/motion/window.ts | 28 ++ .../video/scripts/motion.test.ts | 123 ++++++++ .../video/src/motion/gallery.tsx | 265 +++++++++++++++++ .../video/src/motion/hooks.ts | 268 ++++++++++++++++++ .../video/src/motion/index.ts | 5 + .../video/src/motion/schedule.ts | 59 ++++ .../video/src/motion/tokens.ts | 87 ++++++ .../video/src/motion/window.ts | 28 ++ .../video/scripts/motion.test.ts | 123 ++++++++ .../video/src/motion/gallery.tsx | 265 +++++++++++++++++ .../video/src/motion/hooks.ts | 268 ++++++++++++++++++ .../video/src/motion/index.ts | 5 + .../video/src/motion/schedule.ts | 59 ++++ .../video/src/motion/tokens.ts | 87 ++++++ .../video/src/motion/window.ts | 28 ++ .../video/scripts/motion.test.ts | 123 ++++++++ .../video/src/motion/gallery.tsx | 265 +++++++++++++++++ .../video/src/motion/hooks.ts | 268 ++++++++++++++++++ .../video/src/motion/index.ts | 5 + .../video/src/motion/schedule.ts | 59 ++++ .../video/src/motion/tokens.ts | 87 ++++++ .../video/src/motion/window.ts | 28 ++ .../video/scripts/motion.test.ts | 123 ++++++++ .../video/src/motion/gallery.tsx | 265 +++++++++++++++++ .../video/src/motion/hooks.ts | 268 ++++++++++++++++++ .../video/src/motion/index.ts | 5 + .../video/src/motion/schedule.ts | 59 ++++ .../video/src/motion/tokens.ts | 87 ++++++ .../video/src/motion/window.ts | 28 ++ .../pipeline/scripts/check_script.py | 71 +++++ .../pipeline/scripts/pipeline.py | 22 +- .../pipeline/scripts/qa_frames.py | 185 +++++++++++- .../templates/video-skeleton/skeleton.toml | 17 +- .../video/scripts/motion.test.ts | 123 ++++++++ .../video/src/motion/gallery.tsx | 265 +++++++++++++++++ .../video-skeleton/video/src/motion/hooks.ts | 268 ++++++++++++++++++ .../video-skeleton/video/src/motion/index.ts | 5 + .../video/src/motion/schedule.ts | 59 ++++ .../video-skeleton/video/src/motion/tokens.ts | 87 ++++++ .../video-skeleton/video/src/motion/window.ts | 28 ++ .../pipeline/tests/test_check_script.py | 69 +++++ .../pipeline/tests/test_qa_checks.py | 74 ++++- .../pipeline/tests/test_skeleton.py | 68 +++++ 70 files changed, 8008 insertions(+), 13 deletions(-) create mode 100644 apps/negentropy-influence/episodes/claude-code-concurrency-video/video/scripts/motion.test.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/gallery.tsx create mode 100644 apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/hooks.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/index.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/schedule.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/tokens.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/window.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-explained-video/video/scripts/motion.test.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/gallery.tsx create mode 100644 apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/hooks.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/index.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/schedule.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/tokens.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/window.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-memory-video/video/scripts/motion.test.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/gallery.tsx create mode 100644 apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/hooks.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/index.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/schedule.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/tokens.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/window.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-multiagent-video/video/scripts/motion.test.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/gallery.tsx create mode 100644 apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/hooks.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/index.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/schedule.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/tokens.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/window.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-planning-video/video/scripts/motion.test.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/gallery.tsx create mode 100644 apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/hooks.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/index.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/schedule.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/tokens.ts create mode 100644 apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/window.ts create mode 100644 apps/negentropy-influence/episodes/experience-era-agents-video/video/scripts/motion.test.ts create mode 100644 apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/gallery.tsx create mode 100644 apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/hooks.ts create mode 100644 apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/index.ts create mode 100644 apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/schedule.ts create mode 100644 apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/tokens.ts create mode 100644 apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/window.ts create mode 100644 apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/scripts/motion.test.ts create mode 100644 apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/gallery.tsx create mode 100644 apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/hooks.ts create mode 100644 apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/index.ts create mode 100644 apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/schedule.ts create mode 100644 apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/tokens.ts create mode 100644 apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/window.ts create mode 100644 apps/negentropy-influence/episodes/self-improving-agents-video/video/scripts/motion.test.ts create mode 100644 apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/gallery.tsx create mode 100644 apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/hooks.ts create mode 100644 apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/index.ts create mode 100644 apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/schedule.ts create mode 100644 apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/tokens.ts create mode 100644 apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/window.ts create mode 100644 apps/negentropy-influence/pipeline/templates/video-skeleton/video/scripts/motion.test.ts create mode 100644 apps/negentropy-influence/pipeline/templates/video-skeleton/video/src/motion/gallery.tsx create mode 100644 apps/negentropy-influence/pipeline/templates/video-skeleton/video/src/motion/hooks.ts create mode 100644 apps/negentropy-influence/pipeline/templates/video-skeleton/video/src/motion/index.ts create mode 100644 apps/negentropy-influence/pipeline/templates/video-skeleton/video/src/motion/schedule.ts create mode 100644 apps/negentropy-influence/pipeline/templates/video-skeleton/video/src/motion/tokens.ts create mode 100644 apps/negentropy-influence/pipeline/templates/video-skeleton/video/src/motion/window.ts diff --git a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/scripts/motion.test.ts b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/scripts/motion.test.ts new file mode 100644 index 000000000..4a55380f7 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/scripts/motion.test.ts @@ -0,0 +1,123 @@ +/** + * 运动层纯函数单测——`node --test scripts/motion.test.ts`(Node ≥ 23.6 原生跑 TS)。 + * + * 刻意放在 video/ 而非 src/:tsconfig include 只有 src(frozen,不为测试改动), + * 而本文件 import 需带 .ts 后缀(Node ESM 解析规则)——tsc 若收编会因 + * allowImportingTsExtensions 未开而报错。src/ 的类型覆盖由 tsc --noEmit 全量保证, + * 本文件只测纯函数行为,不做类型承重。 + * + * 只 import 纯模块(tokens/window/schedule——零 remotion/react 依赖): + * hooks 是它们的薄包装,行为由 MotionGallery 目视 + 场景抽帧覆盖。 + */ +import {strict as assert} from 'node:assert'; +import test from 'node:test'; + +import {DUR, EASING_CP, EXIT_FACTOR, SAFE_TOP_Y, SPRING, clampRiseDist, dampingRatio, overshootPeak} from '../src/motion/tokens.ts'; +import {beatProgress, clamp01, progress, win} from '../src/motion/window.ts'; +import {schedule} from '../src/motion/schedule.ts'; + +// ── tokens ──────────────────────────────────────────────────────────── + +test('时长标尺在 30fps 下相邻档可辨(≥1 帧差)', () => { + const v = Object.values(DUR) as number[]; + for (let i = 1; i < v.length; i++) { + assert.ok(v[i] - v[i - 1] >= 1, `第 ${i} 档与前一档同帧数(伪选择)`); + } + assert.ok(v.length === 6); +}); + +test('ζ→过冲换算钉死:snap 轻过冲、settle 零过冲(直抄 dampingRatio 的反例护栏)', () => { + const zSnap = dampingRatio(SPRING.snap); + const zSettle = dampingRatio(SPRING.settle); + assert.ok(zSnap > 0.4 && zSnap < 0.9, `snap ζ=${zSnap}`); + // Mp = exp(-πζ/√(1-ζ²)) 是超出幅度;峰值 = 1 + Mp(ζ=0.6 → 峰值 ≈1.095) + assert.ok(overshootPeak(zSnap) > 1.02 && overshootPeak(zSnap) < 1.2, `snap 峰值 ${overshootPeak(zSnap)}`); + assert.ok(zSettle > 1, 'settle 须过阻尼'); + assert.equal(overshootPeak(zSettle), 1); + // 反例:把设计系统的 ζ 当 damping 直填(0.8)→ ζ≈0.04、峰值≈1.88 暴力弹跳 + const wrong = dampingRatio({damping: 0.8, stiffness: 100, mass: 1}); + assert.ok(overshootPeak(wrong) > 1.8, `直抄 ζ 的峰值=${overshootPeak(wrong)},必须被此断言抓住`); +}); + +test('缓动控制点合法(CSS 规则 x∈[0,1],且 x(t) 数值单调——可作函数求值)', () => { + for (const cp of Object.values(EASING_CP)) { + const [x1, , x2] = cp; + assert.ok(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1, `x 越界:${cp}`); + // x1 -1e-9, `x(t) 非单调 @t=${t}:${cp}`); + } + } +}); + +test('出场快于入场系数与安全带口径为常量', () => { + assert.equal(EXIT_FACTOR, 0.4); + assert.equal(SAFE_TOP_Y, 920); +}); + +test('clampRiseDist:自下方入场行程不探进字幕安全带', () => { + assert.equal(clampRiseDist(120, 836), 84, 'ISSUE-170 实测几何:rest 836 → 行程封顶 84'); + assert.equal(clampRiseDist(40, 836), 40, '未超限不动'); + assert.equal(clampRiseDist(120, 960), 0, '落位已在安全带内 → 零行程(退化但安全)'); +}); + +// ── window ──────────────────────────────────────────────────────────── + +test('clamp01 / progress / win 的钳制语义', () => { + assert.equal(clamp01(-1), 0); + assert.equal(clamp01(2), 1); + assert.equal(progress(10, 10, 10), 0, '起点为 0'); + assert.equal(progress(20, 10, 10), 1, '终点为 1'); + assert.equal(progress(5, 10, 10), 0, '窗外前钳 0'); + assert.equal(progress(99, 10, 10), 1, '窗外后钳 1'); + assert.equal(win(0.5, [0.25, 0.75]), 0.5); + assert.equal(win(0.1, [0.25, 0.75]), 0); + assert.equal(win(0.9, [0.25, 0.75]), 1); + assert.equal(beatProgress(0, -30, 90), 1 / 3); +}); + +// ── schedule ────────────────────────────────────────────────────────── + +test('fit 模式:末项恰在窗口末完成、不外溢', () => { + const p = schedule(5, {dur: 10, fit: {total: 90}}); + const last = p.starts[4] + p.dur; + assert.ok(last <= 90 + 1, `末项 ${last} 外溢`); + assert.ok(last >= 89, `末项 ${last} 未到窗口末`); +}); + +test('fit 装不下时缩子项时长,不外溢窗口(优先级:不外溢 > 最小步长 > 子项时长)', () => { + // total 30 时 (30-12)/7≈2.57 ≥ minStride=2 仍装得下;压到 24 才触发缩时长 + const p = schedule(8, {dur: 12, fit: {total: 24}}); + const last = p.starts[7] + p.dur; + assert.ok(last <= 31, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); +}); + +test('stride 模式与三选一守卫', () => { + assert.deepEqual(schedule(3, {dur: 5, stride: 4}).starts, [0, 4, 8]); + assert.throws(() => schedule(3, {dur: 5, stride: 4, lag: 1})); + assert.throws(() => schedule(3, {dur: 5, lag: 1, fit: {total: 40}})); +}); + +test('最小步长与空集', () => { + assert.deepEqual(schedule(0, {dur: 5, stride: 4}), {starts: [], dur: 5}); + const p = schedule(2, {dur: 5, stride: 0}); // 非法步长 → 抬到下限 + assert.ok(p.starts[1] - p.starts[0] >= 1); +}); + +test('起点取整且单调不减', () => { + const p = schedule(6, {dur: 7, fit: {total: 53}}); + for (let i = 1; i < p.starts.length; i++) { + assert.ok(p.starts[i] >= p.starts[i - 1], '起点须单调不减'); + assert.ok(Number.isInteger(p.starts[i])); + } +}); diff --git a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/gallery.tsx b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/gallery.tsx new file mode 100644 index 000000000..ad2e067cf --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/gallery.tsx @@ -0,0 +1,265 @@ +/** MotionGallery——运动层的评审面(独立 Remotion 入口,不经 Root.tsx)。 + * + * 用法(在 video/ 目录,.bin 直调防污染根 workspace): + * ./node_modules/.bin/remotion still src/motion/gallery.tsx MotionGallery \ + * out/motion-gallery.png --frame=30 + * + * 全部模型 × 变体一屏可渲:秒级出图,供 token 校准轮逐格目视。 + * 色板为本文件内字面量(dev 工具面,不进成片、不读 theme——保持 frozen 跨系列共享)。 + */ +import React from 'react'; +import {AbsoluteFill, Composition, registerRoot} from 'remotion'; +import { + useAccelTravel, + useBreathe, + useCount, + useDim, + useDraw, + useEnter, + useFadeOut, + useFlowDash, + useImpulse, + usePushIn, + useReveal, + useShake, + useStagger, + useTravel, +} from './hooks'; + +const COLS = 5; +const CW = 360; +const CH = 240; +const GAP = 18; + +/** dev 工具面字面量色板(与各集 theme 底座同值但刻意独立声明——不读 theme)。 */ +const C = { + bg: '#0E1116', + panel: '#171C26', + border: '#2A3242', + text: '#F2F5FA', + dim: '#9AA7B8', + core: '#D97757', + mech: '#64C4C0', + deny: '#EF6461', +}; + +/** 单元格壳:定位 + 角标。 */ +const Cell: React.FC<{i: number; name: string; children: React.ReactNode}> = ({i, name, children}) => ( +
+
+ {children} +
+
+ {name} +
+
+); + +const Box: React.FC<{color?: string; w?: number; h?: number; style?: React.CSSProperties}> = ({ + color = C.core, + w = 200, + h = 80, + style, +}) =>
; + +// ── 每格一个组件:hooks 各归其位(Rules of Hooks 的最简守法形态) ──────── + +const EnterCell: React.FC<{kind: Parameters[0]}> = ({kind}) => { + const e = useEnter(kind, {}); + return ; +}; + +const StaggerCell: React.FC = () => { + const ps = useStagger(5, {dur: 5, fit: {total: 60}}); + return ( +
+ {ps.map((p, i) => ( +
+ ))} +
+ ); +}; + +const DrawCell: React.FC = () => { + const d = useDraw(0, 24); + return ( + + + + ); +}; + +const ImpulseCell: React.FC = () => { + const g = useImpulse({dur: 30, peak: 1}); + return ( +
+ ); +}; + +const BreatheCell: React.FC = () => { + const b = useBreathe({period: 30}); + return ( + + + + ); +}; + +const TravelCell: React.FC = () => { + const t = useTravel({cx: 140, cy: 55, r: 42, secPerLap: 3}); + return ( + + + + + ); +}; + +const AccelCell: React.FC = () => { + const t = useAccelTravel({cx: 140, cy: 55, r: 42, durs: [28, 20, 14], at: 4}); + const heat = `rgb(${217 + Math.round(38 * t.heat)}, ${119 - Math.round(60 * t.heat)}, ${87 - Math.round(20 * t.heat)})`; + return ( + + + + + ); +}; + +const CountCell: React.FC = () => { + const v = useCount({to: 255, dur: 40}); + return ( +
+ {Math.round(v)} +
+ ); +}; + +const RevealCell: React.FC = () => { + const s = useReveal('while (true) { think(); act(); }', {cps: 14}); + const blink = useBreathe({period: 16, amp: 0.5, base: 0.5}); + return ( +
+ {s} + +
+ ); +}; + +const PushInCell: React.FC = () => { + const t = usePushIn(0, {scale: 0.12}); + return ; +}; + +const DimCell: React.FC = () => { + const dim = useDim({at: 40, to: 0.35}); + return ( +
+ + + +
+ ); +}; + +const FlowCell: React.FC = () => { + const f = useFlowDash({period: 24}); + return ( + + + + ); +}; + +const ShakeCell: React.FC = () => { + const x = useShake({at: 10, amp: 5, decay: true, dur: 40}); + return ; +}; + +const FadeCell: React.FC = () => { + const op = useFadeOut(90, {frames: 36}); + return ( +
+ +
+
+ ); +}; + +const CELLS: Array<[string, React.ReactNode]> = [ + ['enter:fall', ], + ['enter:rise', ], + ['enter:slideL', ], + ['enter:pop', ], + ['enter:flyIn', ], + ['enter:fade', ], + ['stagger×5 fit60', ], + ['draw 24f', ], + ['impulse 30f', ], + ['breathe p30', ], + ['travel 3s/lap', ], + ['accelTravel', ], + ['count→255', ], + ['reveal 14cps', ], + ['pushIn .12', ], + ['dim .35@40', ], + ['flowDash p24', ], + ['shake decay', ], + ['fadeOut 36f', ], +]; + +const MotionGallery: React.FC = () => ( + +
+ MotionGallery · 30fps · 120f +
+ {CELLS.map(([name, node], i) => ( + + {node} + + ))} +
+); + +registerRoot(() => ( + +)); diff --git a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/hooks.ts b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/hooks.ts new file mode 100644 index 000000000..bfa87b05f --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/hooks.ts @@ -0,0 +1,268 @@ +/** 运动模型(hooks)——分镜「动效」列动词到帧数学的唯一映射。 + * + * 设计约束(违反任何一条即失去本层存在意义): + * 1. hooks 返回数值 / CSS 片段,不渲染 DOM——FadeUp 式包装组件打不进 svg// + * absolute 布局,是「组件存在却零调用」的实测根因;数值可落进任意 JSX。 + * 2. 弹簧一律吃局部帧(frame - at):spring() 每次调用从第 0 帧重模拟,喂全局帧 + * 会让长片末帧每个 spring 跑两万余次迭代。 + * 3. effects(不透明度/颜色)永不吃弹簧——一律时长+缓动(tokens 的二分不变量)。 + * 4. 不读 theme:颜色一律经参数传入(frozen 跨系列共享的前提)。 + * 5. `at` 锚点一律来自句边界(rel(beat, '句id')),禁写死帧数。 + */ +import {Easing, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + DUR, + EASING_CP, + SPRING, + clampRiseDist, + type DurToken, + type EasingToken, + type SpringPreset, +} from './tokens'; +import {progress} from './window'; +import {schedule, type ScheduleOpts} from './schedule'; + +/** 帧数解析:token 或直接帧数;缺省用 def。 */ +export const frames = (d: number | DurToken | undefined, def: number): number => + d === undefined ? def : typeof d === 'number' ? d : DUR[d]; + +/** 缓动令牌 → Remotion 缓动函数(linear 直通)。 */ +export const easeF = (t: EasingToken): ((x: number) => number) => + t === 'linear' + ? Easing.linear + : Easing.bezier( + ...(EASING_CP[t] as [number, number, number, number]), + ); + +/** 缓动后的 0..1 进度(各模型共用的原子)。 */ +const eased = ( + frame: number, + at: number, + dur: number, + e: EasingToken, +): number => interpolate(progress(frame, at, dur), [0, 1], [0, 1], {easing: easeF(e)}); + +// ── 入场(enter:落下/上浮/滑入/弹出/飞入/淡入) ──────────────────────── + +export type EnterKind = 'fall' | 'rise' | 'slideL' | 'slideR' | 'pop' | 'flyIn' | 'fade'; +export type EnterOpts = { + /** 句边界锚(局部帧)。 */ + at?: number; + dur?: number | DurToken; + easing?: EasingToken; + /** 空间通道用弹簧(位移类才有意义;fade/pop 无效)。 */ + springPreset?: SpringPreset; + /** 位移像素(fall/rise/slide*;缺省 30)。 */ + dist?: number; + /** rise 专用:落位态底边 y——行程经 clampRiseDist 钳进字幕安全带之上。 */ + restBottom?: number; +}; +export type EnterStyle = {opacity: number; transform: string}; + +export function useEnter(kind: EnterKind, o: EnterOpts = {}): EnterStyle { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const at = o.at ?? 0; + const dur = frames(o.dur, DUR.f4); + let dist = o.dist ?? 30; + if (kind === 'rise' && o.restBottom !== undefined) { + dist = clampRiseDist(dist, o.restBottom); + } + // effects 通道:纯缓动、略快于空间通道(元素先「看见」再「落位」) + const opacity = progress(frame, at, Math.max(2, Math.round(dur * 0.8))); + // spatial 通道:可选弹簧(局部帧 + durationInFrames 截停,防窗口外余振) + const p = o.springPreset + ? spring({ + frame: frame - at, + fps, + config: SPRING[o.springPreset], + durationInFrames: dur, + }) + : eased(frame, at, dur, o.easing ?? 'standard'); + const inv = 1 - p; + const t: string[] = []; + if (kind === 'fall') t.push(`translateY(${-inv * dist}px)`); + if (kind === 'rise') t.push(`translateY(${inv * dist}px)`); + if (kind === 'slideL') t.push(`translateX(${-inv * dist}px)`); + if (kind === 'slideR') t.push(`translateX(${inv * dist}px)`); + if (kind === 'pop') t.push(`scale(${0.9 + 0.1 * p})`); + if (kind === 'flyIn') t.push(`scale(${0.6 + 0.4 * p})`); + return {opacity, transform: t.length ? t.join(' ') : 'none'}; +} + +// ── 序列错峰(stagger:依次/逐行/逐条/逐格) ─────────────────────────── + +export type StaggerOpts = ScheduleOpts & {at?: number; easing?: EasingToken}; + +/** 返回 n 个 0..1 进度——第 i 项随编排依次入场。 */ +export function useStagger(n: number, o: StaggerOpts = {}): number[] { + const frame = useCurrentFrame(); + const {at = 0, easing = 'standard'} = o; + const plan = schedule(n, o); + return plan.starts.map((s) => eased(frame, at + s, plan.dur, easing)); +} + +// ── 描线(draw:红线三由构造保证——只产 pathLength 归一化三元组) ──────── + +export type DrawProps = {pathLength: 1; strokeDasharray: 1; strokeDashoffset: number}; + +export function useDraw(at: number, dur: number | DurToken = DUR.f5): DrawProps { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(dur, DUR.f5), 'decelerate'); + return {pathLength: 1, strokeDasharray: 1, strokeDashoffset: 1 - p}; +} + +// ── 脉冲 / 呼吸(glow 语法:impulse=一次性强调,breathe=持续辉光) ─────── + +/** 一次性冲击:sin(πp) 包络,起于 0 归于 0,峰值 peak。 */ +export function useImpulse(o: {at?: number; dur?: number | DurToken; peak?: number} = {}): number { + const frame = useCurrentFrame(); + const p = progress(frame, o.at ?? 0, frames(o.dur, DUR.f5)); + return Math.sin(Math.PI * p) * (o.peak ?? 1); +} + +/** 持续呼吸(原 0.55+0.45·sin(frame/K) 散写的收敛;period 帧一周期)。 */ +export function useBreathe(o: {period?: number; amp?: number; base?: number} = {}): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26} = o; + return base + amp * Math.sin((2 * Math.PI * frame) / period); +} + +// ── 巡游(travel:环形为主;absorb 原 useRingDot 与加速绕行累加器克隆) ── + +export type TravelPos = {x: number; y: number; angle: number}; + +/** 匀速环形巡游(angle 单位度,-90 = 12 点方向起)。 */ +export function useTravel(o: { + cx: number; + cy: number; + r: number; + secPerLap?: number; + offset?: number; +}): TravelPos { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const lap = (frame / (fps * (o.secPerLap ?? 2.5)) + (o.offset ?? 0)) % 1; + const a = -90 + lap * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), angle: a}; +} + +/** 加速绕行:逐圈时长 durs[](如 [40,30,22,16]),跑完全部圈后冻结在终点。 + * heat 0..1 随圈数推进(「失控感」的配色偏移系数)。 */ +export function useAccelTravel(o: { + cx: number; + cy: number; + r: number; + durs: number[]; + at?: number; + heatPerLap?: number; +}): {x: number; y: number; heat: number} { + const frame = useCurrentFrame(); + let t = Math.max(0, frame - (o.at ?? 0)); + let lap = 0; + while (lap < o.durs.length && t >= o.durs[lap]) { + t -= o.durs[lap]; + lap += 1; + } + const within = lap >= o.durs.length ? 1 : t / o.durs[lap]; + const heat = Math.min(1, lap / (o.heatPerLap ?? o.durs.length)); + const a = -90 + within * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), heat}; +} + +// ── 计数 / 水位(meter 语法;显示层自行 Math.round / toFixed) ────────── + +export function useCount(o: { + from?: number; + to: number; + at?: number; + dur?: number | DurToken; + ease?: EasingToken; +}): number { + const frame = useCurrentFrame(); + const {from = 0, to, at = 0} = o; + const p = eased(frame, at, frames(o.dur, DUR.f6), o.ease ?? 'standard'); + return from + (to - from) * p; +} + +// ── 打字机 / 逐字流出(type;Terminal 之外的泛化) ────────────────────── + +export function useReveal( + text: string, + o: {at?: number; cps?: number; framesPerChar?: number} = {}, +): string { + const frame = useCurrentFrame(); + const per = o.framesPerChar ?? Math.max(1, Math.round(30 / (o.cps ?? 12))); + const n = Math.floor(Math.max(0, frame - (o.at ?? 0)) / per); + return text.slice(0, Math.min(text.length, n)); +} + +// ── 镜头推近(pushIn 语法:beat 切换的镜头语言,替代纯淡入) ──────────── + +export function usePushIn(at: number, o: {scale?: number; dur?: number | DurToken} = {}): string { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(o.dur, DUR.f5), 'decelerate'); + return `scale(${1 + (o.scale ?? 0.06) * p})`; +} + +// ── 压暗 / 提亮(emphasis 反向:让主体从群像中浮出) ───────────────────── + +/** 返回目标透明度系数(1 = 原;to 0.4 即压暗到 40%)。 */ +export function useDim(o: {at: number; to?: number; dur?: number | DurToken}): number { + const frame = useCurrentFrame(); + const p = eased(frame, o.at, frames(o.dur, DUR.f4), 'standard'); + return 1 + ((o.to ?? 0.4) - 1) * p; +} + +// ── 流光(flow 语法:连线上的行进虚线) ───────────────────────────────── + +/** 返回可直接展开到 / 的描边属性(像素 dasharray——与 draw 的 + * pathLength 归一化描线是两个正交特性,勿混用于同一元素:红线三)。 */ +export function useFlowDash(o: { + dash?: number; + gap?: number; + /** 帧速率:每 period 帧行进一个 dash+gap 周期。 */ + period?: number; +}): {strokeDasharray: string; strokeDashoffset: number} { + const frame = useCurrentFrame(); + const {dash = 10, gap = 14, period = 40} = o; + return { + strokeDasharray: `${dash} ${gap}`, + strokeDashoffset: -(frame * (dash + gap)) / period, + }; +} + +// ── 抖动(错误/故障语义;收敛 P2/P3 两处克隆) ────────────────────────── + +/** 返回 translateX 像素值。active 缺省 true;decay=true 时按 dur 衰减归零。 */ +export function useShake(o: { + at: number; + active?: boolean; + amp?: number; + /** 相位分母(原手写 /1.6、/2.2 的口径)。 */ + freq?: number; + decay?: boolean; + dur?: number | DurToken; +}): number { + const frame = useCurrentFrame(); + const {amp = 3, freq = 1.6} = o; + const t = frame - o.at; + if (o.active === false || t < 0) { + return 0; + } + if (o.decay) { + const env = 1 - progress(frame, o.at, frames(o.dur, DUR.f5)); + return amp * env * Math.sin(t / freq); + } + return amp * Math.sin(t / freq); +} + +// ── 片尾渐黑(红线四:从 beat 总时长推导,勿用末句时长) ──────────────── + +export function useFadeOut(durationInFrames: number, o: {frames?: number} = {}): number { + const frame = useCurrentFrame(); + const f = o.frames ?? 36; // 1.2s + return 1 - progress(frame, durationInFrames - f, f); +} diff --git a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/index.ts b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/index.ts new file mode 100644 index 000000000..97fd22a5c --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/index.ts @@ -0,0 +1,5 @@ +/** 运动层门面——场景代码统一 `import {...} from '../motion'`。 */ +export * from './tokens'; +export * from './window'; +export * from './schedule'; +export * from './hooks'; diff --git a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/schedule.ts b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/schedule.ts new file mode 100644 index 000000000..0aa1ebfa4 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/schedule.ts @@ -0,0 +1,59 @@ +/** 错峰编排(stagger)纯函数——收敛手写 `frame - i*N`(跨 8 集约 356 处、 + * 步长 K∈{2,3,4,5,6,8,10} 任意取值、无节奏标尺)。 + * + * 三种模式(互斥,参数即文档): + * - stride:固定步长(与旧手写行为一致——迁移期的保守选项); + * - lag:Manim LaggedStart 语义 start[i] = i·dur·lagRatio(0 = 同刻齐动,1 = 首尾相接); + * - fit:n 个子项恰好装进窗口 total(末项恰在窗口末完成——「随句推进」的首选)。 + * + * 钳制优先级(motion.test.ts 钉死,高者让位低者):不外溢窗口 > 最小步长 > 子项时长。 + * 窗口属于 beat 时间轴,外溢会踩进下一 beat;装不下时**缩子项时长**,不延窗口。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点最小间隔,默认 2 帧(30fps 下仍可辨先后)。 */ + minStride?: number; + /** 子项时长下限,默认 3 帧(低于此相当于瞬现)。 */ + minDur?: number; +}; +export type Schedule = {starts: number[]; dur: number}; + +export function schedule(n: number, o: ScheduleOpts): Schedule { + const minStride = o.minStride ?? 2; + const minDur = o.minDur ?? 3; + const modes = [o.stride !== undefined, o.lag !== undefined, o.fit !== undefined].filter( + Boolean, + ).length; + if (modes > 1) { + throw new Error('schedule: stride / lag / fit 三选一'); + } + let dur = Math.max(minDur, o.dur ?? 5); + if (n <= 0) { + return {starts: [], dur}; + } + let stride: number; + if (o.fit) { + const total = Math.max(1, o.fit.total); + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + if (n > 1 && stride < minStride) { + // 装不下:缩子项时长换最小步长(保先后可辨),仍不外溢 + dur = Math.max(minDur, total - minStride * (n - 1)); + dur = Math.max(1, Math.min(dur, total)); + stride = Math.max(1, (total - dur) / (n - 1)); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 ? 0 : 1, stride); + return {starts: Array.from({length: n}, (_, i) => Math.round(i * stride)), dur}; +} diff --git a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/tokens.ts b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/tokens.ts new file mode 100644 index 000000000..457448260 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/tokens.ts @@ -0,0 +1,87 @@ +/** 运动令牌——时长标尺 / 缓动曲线 / 弹簧手感 的单一事实源。 + * + * 只放纯数据与纯函数:不 import remotion、不读 theme(颜色一律经参数注入)—— + * 这是本层能以 frozen 档跨两个系列共享的前提(两系列 theme token 名已分叉: + * CC 用 core/mech/deny、SE 用 danger),判据同 test_chrome_motifs_only_read_base_theme_tokens。 + * + * 取值依据(勿凭感觉改;改前先在本集校准轮逐幕目视复测,依据写回此处注释): + * - 时长六档取 IBM Carbon DTCG(70/110/150/240/400/700ms)@30fps 四舍五入。 + * 弃 Material 十六档:30fps 量化下其 15 个相邻对里 6 对落进同一帧数(伪选择)。 + * - 缓动控制点取 Material 3 标准三件;曲线本体在 hooks.ts 经 Remotion Easing.bezier 求值。 + * - 弹簧预设锚定本仓实测手感:settle=200 即既有 9/10 调用点的惯用值(延续成片观感), + * snap=12 来自 P4「插头咬合」的过冲;ζ 与过冲峰值的关系由 motion.test.ts 用 + * Mp = exp(-πζ/√(1-ζ²)) 钉死。 + * - effects 不变量:不透明度/颜色永不过冲——effects 类动画一律时长+缓动, + * 弹簧只用于空间位移(M3 spatial/effects 二分的落地)。 + */ + +/** 时长标尺(帧 @30fps)。叙事节拍(4–8s)不用此表——那是 window/schedule 的职责。 */ +export const DUR = { + /** 70ms:微反馈(辉光起点、光标) */ + f1: 2, + /** 110ms:快速子项(列表错峰的单项时长) */ + f2: 3, + /** 150ms:标准入场 */ + f3: 5, + /** 200ms:强调入场 */ + f4: 7, + /** 400ms:大位移 / 镜头推近 / 描线 */ + f5: 12, + /** 700ms:幕级大动作(少用) */ + f6: 21, +} as const; +export type DurToken = keyof typeof DUR; + +/** 缓动令牌。 */ +export type EasingToken = 'standard' | 'decelerate' | 'accelerate' | 'linear'; + +/** 贝塞尔控制点(x1,y1,x2,y2);linear 无控制点。 */ +export const EASING_CP: Record< + Exclude, + readonly [number, number, number, number] +> = { + // M3 standard:入场默认 + standard: [0.2, 0, 0, 1], + // M3 decelerate:强减速(大位移入场、镜头推近) + decelerate: [0.05, 0.7, 0.1, 1], + // M3 accelerate:出场加速 + accelerate: [0.3, 0, 0.8, 0.15], +}; + +/** 弹簧预设(直传 Remotion spring config;ζ = c/(2√(k·m)))。 */ +export type SpringPreset = 'settle' | 'settleSoft' | 'snap'; +export const SPRING: Record = { + // ζ≈10:无过冲平滑滑入——本仓主流手感(既有场景 9/10 处 damping 200) + settle: {damping: 200, stiffness: 100, mass: 1}, + // ζ≈8.5:更绵一点(P0/P1 既有 180/170 档的收敛) + settleSoft: {damping: 170, stiffness: 100, mass: 1}, + // ζ≈0.6:轻微过冲(咬合/弹入——原 P4 damping 12) + snap: {damping: 12, stiffness: 100, mass: 1}, +}; + +/** 阻尼比 ζ。设计系统文档普遍给 ζ(无量纲),Remotion 取阻尼系数 c——直抄会得 + * ζ≈0.02 的暴力弹跳且能通过渲染体检,这是迁移期最高风险项(单测钉死)。 */ +export const dampingRatio = (s: { + damping: number; + stiffness: number; + mass: number; +}): number => s.damping / (2 * Math.sqrt(s.stiffness * s.mass)); + +/** 欠阻尼弹簧的峰值位置(1 = 恰好到终点不过冲;ζ ≥ 1 恒 1)。 + * Mp = exp(-πζ/√(1-ζ²)) 是超出终点的幅度,峰值 = 1 + Mp。 */ +export const overshootPeak = (zeta: number): number => + zeta >= 1 ? 1 : 1 + Math.exp((-Math.PI * zeta) / Math.sqrt(1 - zeta * zeta)); + +/** 出场快于入场的系数(MDC 实测 400ms 入 / 150ms 出 ≈ 0.375,取 0.4 禁手填)。 */ +export const EXIT_FACTOR = 0.4; + +/** 字幕安全带上沿:1080 - qa_frames.SUBTITLE_BAND_PX(160),与体检口径同源。 */ +export const SAFE_TOP_Y = 920; + +/** 自下方入场的行程安全钳制:落位态底边 restBottom 之上才是可用的进场空间。 + * ISSUE-170 的手工逐卡反算收敛于此——该缺陷类从「评审抽帧抓」变「构造不可能」。 */ +export const clampRiseDist = ( + dist: number, + restBottom: number, + safeTop: number = SAFE_TOP_Y, +): number => Math.max(0, Math.min(dist, safeTop - restBottom)); diff --git a/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/window.ts b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/window.ts new file mode 100644 index 000000000..5d8e5c016 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/window.ts @@ -0,0 +1,28 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 audio-first 时序与可复用运动模型兼容的核心机制:beat 的绝对帧来自 + * beatWindow()(数据源是 TTS 实测 manifest),子动画不写死帧数、只声明自己在 + * beat 进度上的 [start, end] 窗口 ⇒ 旁白实测时长变化时全部窗口自动重定时, + * 「写死帧数与口播脱钩」缺陷类(skills/08 实录)由构造消灭。 + * + * 语义借 MDC TransitionUtils.lerp(startFraction, endFraction):窗外钳制端点。 + * 本模块零依赖(不 import remotion / theme)——frozen 跨系列共享与 node 单测的前提。 + */ + +/** 钳制到 [0,1]——一切进度的唯一出口,防负值/超 1 渗进 transform。 */ +export const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x); + +/** 子动画窗口:父进度 p(0..1) 在 [s,e] 片段上的局部进度。 */ +export const win = (p: number, w: readonly [number, number]): number => + clamp01((p - w[0]) / (w[1] - w[0])); + +/** beat 进度:局部帧 → 该 beat 的 0..1 进度。 */ +export const beatProgress = ( + frame: number, + from: number, + durationInFrames: number, +): number => clamp01((frame - from) / Math.max(1, durationInFrames)); + +/** 帧域进度:[at, at+dur] 上的 0..1(dur ≤ 0 视作 1,防除零)。 */ +export const progress = (frame: number, at: number, dur: number): number => + clamp01((frame - at) / Math.max(1, dur)); diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/scripts/motion.test.ts b/apps/negentropy-influence/episodes/claude-code-explained-video/video/scripts/motion.test.ts new file mode 100644 index 000000000..4a55380f7 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/scripts/motion.test.ts @@ -0,0 +1,123 @@ +/** + * 运动层纯函数单测——`node --test scripts/motion.test.ts`(Node ≥ 23.6 原生跑 TS)。 + * + * 刻意放在 video/ 而非 src/:tsconfig include 只有 src(frozen,不为测试改动), + * 而本文件 import 需带 .ts 后缀(Node ESM 解析规则)——tsc 若收编会因 + * allowImportingTsExtensions 未开而报错。src/ 的类型覆盖由 tsc --noEmit 全量保证, + * 本文件只测纯函数行为,不做类型承重。 + * + * 只 import 纯模块(tokens/window/schedule——零 remotion/react 依赖): + * hooks 是它们的薄包装,行为由 MotionGallery 目视 + 场景抽帧覆盖。 + */ +import {strict as assert} from 'node:assert'; +import test from 'node:test'; + +import {DUR, EASING_CP, EXIT_FACTOR, SAFE_TOP_Y, SPRING, clampRiseDist, dampingRatio, overshootPeak} from '../src/motion/tokens.ts'; +import {beatProgress, clamp01, progress, win} from '../src/motion/window.ts'; +import {schedule} from '../src/motion/schedule.ts'; + +// ── tokens ──────────────────────────────────────────────────────────── + +test('时长标尺在 30fps 下相邻档可辨(≥1 帧差)', () => { + const v = Object.values(DUR) as number[]; + for (let i = 1; i < v.length; i++) { + assert.ok(v[i] - v[i - 1] >= 1, `第 ${i} 档与前一档同帧数(伪选择)`); + } + assert.ok(v.length === 6); +}); + +test('ζ→过冲换算钉死:snap 轻过冲、settle 零过冲(直抄 dampingRatio 的反例护栏)', () => { + const zSnap = dampingRatio(SPRING.snap); + const zSettle = dampingRatio(SPRING.settle); + assert.ok(zSnap > 0.4 && zSnap < 0.9, `snap ζ=${zSnap}`); + // Mp = exp(-πζ/√(1-ζ²)) 是超出幅度;峰值 = 1 + Mp(ζ=0.6 → 峰值 ≈1.095) + assert.ok(overshootPeak(zSnap) > 1.02 && overshootPeak(zSnap) < 1.2, `snap 峰值 ${overshootPeak(zSnap)}`); + assert.ok(zSettle > 1, 'settle 须过阻尼'); + assert.equal(overshootPeak(zSettle), 1); + // 反例:把设计系统的 ζ 当 damping 直填(0.8)→ ζ≈0.04、峰值≈1.88 暴力弹跳 + const wrong = dampingRatio({damping: 0.8, stiffness: 100, mass: 1}); + assert.ok(overshootPeak(wrong) > 1.8, `直抄 ζ 的峰值=${overshootPeak(wrong)},必须被此断言抓住`); +}); + +test('缓动控制点合法(CSS 规则 x∈[0,1],且 x(t) 数值单调——可作函数求值)', () => { + for (const cp of Object.values(EASING_CP)) { + const [x1, , x2] = cp; + assert.ok(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1, `x 越界:${cp}`); + // x1 -1e-9, `x(t) 非单调 @t=${t}:${cp}`); + } + } +}); + +test('出场快于入场系数与安全带口径为常量', () => { + assert.equal(EXIT_FACTOR, 0.4); + assert.equal(SAFE_TOP_Y, 920); +}); + +test('clampRiseDist:自下方入场行程不探进字幕安全带', () => { + assert.equal(clampRiseDist(120, 836), 84, 'ISSUE-170 实测几何:rest 836 → 行程封顶 84'); + assert.equal(clampRiseDist(40, 836), 40, '未超限不动'); + assert.equal(clampRiseDist(120, 960), 0, '落位已在安全带内 → 零行程(退化但安全)'); +}); + +// ── window ──────────────────────────────────────────────────────────── + +test('clamp01 / progress / win 的钳制语义', () => { + assert.equal(clamp01(-1), 0); + assert.equal(clamp01(2), 1); + assert.equal(progress(10, 10, 10), 0, '起点为 0'); + assert.equal(progress(20, 10, 10), 1, '终点为 1'); + assert.equal(progress(5, 10, 10), 0, '窗外前钳 0'); + assert.equal(progress(99, 10, 10), 1, '窗外后钳 1'); + assert.equal(win(0.5, [0.25, 0.75]), 0.5); + assert.equal(win(0.1, [0.25, 0.75]), 0); + assert.equal(win(0.9, [0.25, 0.75]), 1); + assert.equal(beatProgress(0, -30, 90), 1 / 3); +}); + +// ── schedule ────────────────────────────────────────────────────────── + +test('fit 模式:末项恰在窗口末完成、不外溢', () => { + const p = schedule(5, {dur: 10, fit: {total: 90}}); + const last = p.starts[4] + p.dur; + assert.ok(last <= 90 + 1, `末项 ${last} 外溢`); + assert.ok(last >= 89, `末项 ${last} 未到窗口末`); +}); + +test('fit 装不下时缩子项时长,不外溢窗口(优先级:不外溢 > 最小步长 > 子项时长)', () => { + // total 30 时 (30-12)/7≈2.57 ≥ minStride=2 仍装得下;压到 24 才触发缩时长 + const p = schedule(8, {dur: 12, fit: {total: 24}}); + const last = p.starts[7] + p.dur; + assert.ok(last <= 31, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); +}); + +test('stride 模式与三选一守卫', () => { + assert.deepEqual(schedule(3, {dur: 5, stride: 4}).starts, [0, 4, 8]); + assert.throws(() => schedule(3, {dur: 5, stride: 4, lag: 1})); + assert.throws(() => schedule(3, {dur: 5, lag: 1, fit: {total: 40}})); +}); + +test('最小步长与空集', () => { + assert.deepEqual(schedule(0, {dur: 5, stride: 4}), {starts: [], dur: 5}); + const p = schedule(2, {dur: 5, stride: 0}); // 非法步长 → 抬到下限 + assert.ok(p.starts[1] - p.starts[0] >= 1); +}); + +test('起点取整且单调不减', () => { + const p = schedule(6, {dur: 7, fit: {total: 53}}); + for (let i = 1; i < p.starts.length; i++) { + assert.ok(p.starts[i] >= p.starts[i - 1], '起点须单调不减'); + assert.ok(Number.isInteger(p.starts[i])); + } +}); diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/gallery.tsx b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/gallery.tsx new file mode 100644 index 000000000..ad2e067cf --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/gallery.tsx @@ -0,0 +1,265 @@ +/** MotionGallery——运动层的评审面(独立 Remotion 入口,不经 Root.tsx)。 + * + * 用法(在 video/ 目录,.bin 直调防污染根 workspace): + * ./node_modules/.bin/remotion still src/motion/gallery.tsx MotionGallery \ + * out/motion-gallery.png --frame=30 + * + * 全部模型 × 变体一屏可渲:秒级出图,供 token 校准轮逐格目视。 + * 色板为本文件内字面量(dev 工具面,不进成片、不读 theme——保持 frozen 跨系列共享)。 + */ +import React from 'react'; +import {AbsoluteFill, Composition, registerRoot} from 'remotion'; +import { + useAccelTravel, + useBreathe, + useCount, + useDim, + useDraw, + useEnter, + useFadeOut, + useFlowDash, + useImpulse, + usePushIn, + useReveal, + useShake, + useStagger, + useTravel, +} from './hooks'; + +const COLS = 5; +const CW = 360; +const CH = 240; +const GAP = 18; + +/** dev 工具面字面量色板(与各集 theme 底座同值但刻意独立声明——不读 theme)。 */ +const C = { + bg: '#0E1116', + panel: '#171C26', + border: '#2A3242', + text: '#F2F5FA', + dim: '#9AA7B8', + core: '#D97757', + mech: '#64C4C0', + deny: '#EF6461', +}; + +/** 单元格壳:定位 + 角标。 */ +const Cell: React.FC<{i: number; name: string; children: React.ReactNode}> = ({i, name, children}) => ( +
+
+ {children} +
+
+ {name} +
+
+); + +const Box: React.FC<{color?: string; w?: number; h?: number; style?: React.CSSProperties}> = ({ + color = C.core, + w = 200, + h = 80, + style, +}) =>
; + +// ── 每格一个组件:hooks 各归其位(Rules of Hooks 的最简守法形态) ──────── + +const EnterCell: React.FC<{kind: Parameters[0]}> = ({kind}) => { + const e = useEnter(kind, {}); + return ; +}; + +const StaggerCell: React.FC = () => { + const ps = useStagger(5, {dur: 5, fit: {total: 60}}); + return ( +
+ {ps.map((p, i) => ( +
+ ))} +
+ ); +}; + +const DrawCell: React.FC = () => { + const d = useDraw(0, 24); + return ( + + + + ); +}; + +const ImpulseCell: React.FC = () => { + const g = useImpulse({dur: 30, peak: 1}); + return ( +
+ ); +}; + +const BreatheCell: React.FC = () => { + const b = useBreathe({period: 30}); + return ( + + + + ); +}; + +const TravelCell: React.FC = () => { + const t = useTravel({cx: 140, cy: 55, r: 42, secPerLap: 3}); + return ( + + + + + ); +}; + +const AccelCell: React.FC = () => { + const t = useAccelTravel({cx: 140, cy: 55, r: 42, durs: [28, 20, 14], at: 4}); + const heat = `rgb(${217 + Math.round(38 * t.heat)}, ${119 - Math.round(60 * t.heat)}, ${87 - Math.round(20 * t.heat)})`; + return ( + + + + + ); +}; + +const CountCell: React.FC = () => { + const v = useCount({to: 255, dur: 40}); + return ( +
+ {Math.round(v)} +
+ ); +}; + +const RevealCell: React.FC = () => { + const s = useReveal('while (true) { think(); act(); }', {cps: 14}); + const blink = useBreathe({period: 16, amp: 0.5, base: 0.5}); + return ( +
+ {s} + +
+ ); +}; + +const PushInCell: React.FC = () => { + const t = usePushIn(0, {scale: 0.12}); + return ; +}; + +const DimCell: React.FC = () => { + const dim = useDim({at: 40, to: 0.35}); + return ( +
+ + + +
+ ); +}; + +const FlowCell: React.FC = () => { + const f = useFlowDash({period: 24}); + return ( + + + + ); +}; + +const ShakeCell: React.FC = () => { + const x = useShake({at: 10, amp: 5, decay: true, dur: 40}); + return ; +}; + +const FadeCell: React.FC = () => { + const op = useFadeOut(90, {frames: 36}); + return ( +
+ +
+
+ ); +}; + +const CELLS: Array<[string, React.ReactNode]> = [ + ['enter:fall', ], + ['enter:rise', ], + ['enter:slideL', ], + ['enter:pop', ], + ['enter:flyIn', ], + ['enter:fade', ], + ['stagger×5 fit60', ], + ['draw 24f', ], + ['impulse 30f', ], + ['breathe p30', ], + ['travel 3s/lap', ], + ['accelTravel', ], + ['count→255', ], + ['reveal 14cps', ], + ['pushIn .12', ], + ['dim .35@40', ], + ['flowDash p24', ], + ['shake decay', ], + ['fadeOut 36f', ], +]; + +const MotionGallery: React.FC = () => ( + +
+ MotionGallery · 30fps · 120f +
+ {CELLS.map(([name, node], i) => ( + + {node} + + ))} +
+); + +registerRoot(() => ( + +)); diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/hooks.ts b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/hooks.ts new file mode 100644 index 000000000..bfa87b05f --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/hooks.ts @@ -0,0 +1,268 @@ +/** 运动模型(hooks)——分镜「动效」列动词到帧数学的唯一映射。 + * + * 设计约束(违反任何一条即失去本层存在意义): + * 1. hooks 返回数值 / CSS 片段,不渲染 DOM——FadeUp 式包装组件打不进 svg// + * absolute 布局,是「组件存在却零调用」的实测根因;数值可落进任意 JSX。 + * 2. 弹簧一律吃局部帧(frame - at):spring() 每次调用从第 0 帧重模拟,喂全局帧 + * 会让长片末帧每个 spring 跑两万余次迭代。 + * 3. effects(不透明度/颜色)永不吃弹簧——一律时长+缓动(tokens 的二分不变量)。 + * 4. 不读 theme:颜色一律经参数传入(frozen 跨系列共享的前提)。 + * 5. `at` 锚点一律来自句边界(rel(beat, '句id')),禁写死帧数。 + */ +import {Easing, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + DUR, + EASING_CP, + SPRING, + clampRiseDist, + type DurToken, + type EasingToken, + type SpringPreset, +} from './tokens'; +import {progress} from './window'; +import {schedule, type ScheduleOpts} from './schedule'; + +/** 帧数解析:token 或直接帧数;缺省用 def。 */ +export const frames = (d: number | DurToken | undefined, def: number): number => + d === undefined ? def : typeof d === 'number' ? d : DUR[d]; + +/** 缓动令牌 → Remotion 缓动函数(linear 直通)。 */ +export const easeF = (t: EasingToken): ((x: number) => number) => + t === 'linear' + ? Easing.linear + : Easing.bezier( + ...(EASING_CP[t] as [number, number, number, number]), + ); + +/** 缓动后的 0..1 进度(各模型共用的原子)。 */ +const eased = ( + frame: number, + at: number, + dur: number, + e: EasingToken, +): number => interpolate(progress(frame, at, dur), [0, 1], [0, 1], {easing: easeF(e)}); + +// ── 入场(enter:落下/上浮/滑入/弹出/飞入/淡入) ──────────────────────── + +export type EnterKind = 'fall' | 'rise' | 'slideL' | 'slideR' | 'pop' | 'flyIn' | 'fade'; +export type EnterOpts = { + /** 句边界锚(局部帧)。 */ + at?: number; + dur?: number | DurToken; + easing?: EasingToken; + /** 空间通道用弹簧(位移类才有意义;fade/pop 无效)。 */ + springPreset?: SpringPreset; + /** 位移像素(fall/rise/slide*;缺省 30)。 */ + dist?: number; + /** rise 专用:落位态底边 y——行程经 clampRiseDist 钳进字幕安全带之上。 */ + restBottom?: number; +}; +export type EnterStyle = {opacity: number; transform: string}; + +export function useEnter(kind: EnterKind, o: EnterOpts = {}): EnterStyle { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const at = o.at ?? 0; + const dur = frames(o.dur, DUR.f4); + let dist = o.dist ?? 30; + if (kind === 'rise' && o.restBottom !== undefined) { + dist = clampRiseDist(dist, o.restBottom); + } + // effects 通道:纯缓动、略快于空间通道(元素先「看见」再「落位」) + const opacity = progress(frame, at, Math.max(2, Math.round(dur * 0.8))); + // spatial 通道:可选弹簧(局部帧 + durationInFrames 截停,防窗口外余振) + const p = o.springPreset + ? spring({ + frame: frame - at, + fps, + config: SPRING[o.springPreset], + durationInFrames: dur, + }) + : eased(frame, at, dur, o.easing ?? 'standard'); + const inv = 1 - p; + const t: string[] = []; + if (kind === 'fall') t.push(`translateY(${-inv * dist}px)`); + if (kind === 'rise') t.push(`translateY(${inv * dist}px)`); + if (kind === 'slideL') t.push(`translateX(${-inv * dist}px)`); + if (kind === 'slideR') t.push(`translateX(${inv * dist}px)`); + if (kind === 'pop') t.push(`scale(${0.9 + 0.1 * p})`); + if (kind === 'flyIn') t.push(`scale(${0.6 + 0.4 * p})`); + return {opacity, transform: t.length ? t.join(' ') : 'none'}; +} + +// ── 序列错峰(stagger:依次/逐行/逐条/逐格) ─────────────────────────── + +export type StaggerOpts = ScheduleOpts & {at?: number; easing?: EasingToken}; + +/** 返回 n 个 0..1 进度——第 i 项随编排依次入场。 */ +export function useStagger(n: number, o: StaggerOpts = {}): number[] { + const frame = useCurrentFrame(); + const {at = 0, easing = 'standard'} = o; + const plan = schedule(n, o); + return plan.starts.map((s) => eased(frame, at + s, plan.dur, easing)); +} + +// ── 描线(draw:红线三由构造保证——只产 pathLength 归一化三元组) ──────── + +export type DrawProps = {pathLength: 1; strokeDasharray: 1; strokeDashoffset: number}; + +export function useDraw(at: number, dur: number | DurToken = DUR.f5): DrawProps { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(dur, DUR.f5), 'decelerate'); + return {pathLength: 1, strokeDasharray: 1, strokeDashoffset: 1 - p}; +} + +// ── 脉冲 / 呼吸(glow 语法:impulse=一次性强调,breathe=持续辉光) ─────── + +/** 一次性冲击:sin(πp) 包络,起于 0 归于 0,峰值 peak。 */ +export function useImpulse(o: {at?: number; dur?: number | DurToken; peak?: number} = {}): number { + const frame = useCurrentFrame(); + const p = progress(frame, o.at ?? 0, frames(o.dur, DUR.f5)); + return Math.sin(Math.PI * p) * (o.peak ?? 1); +} + +/** 持续呼吸(原 0.55+0.45·sin(frame/K) 散写的收敛;period 帧一周期)。 */ +export function useBreathe(o: {period?: number; amp?: number; base?: number} = {}): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26} = o; + return base + amp * Math.sin((2 * Math.PI * frame) / period); +} + +// ── 巡游(travel:环形为主;absorb 原 useRingDot 与加速绕行累加器克隆) ── + +export type TravelPos = {x: number; y: number; angle: number}; + +/** 匀速环形巡游(angle 单位度,-90 = 12 点方向起)。 */ +export function useTravel(o: { + cx: number; + cy: number; + r: number; + secPerLap?: number; + offset?: number; +}): TravelPos { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const lap = (frame / (fps * (o.secPerLap ?? 2.5)) + (o.offset ?? 0)) % 1; + const a = -90 + lap * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), angle: a}; +} + +/** 加速绕行:逐圈时长 durs[](如 [40,30,22,16]),跑完全部圈后冻结在终点。 + * heat 0..1 随圈数推进(「失控感」的配色偏移系数)。 */ +export function useAccelTravel(o: { + cx: number; + cy: number; + r: number; + durs: number[]; + at?: number; + heatPerLap?: number; +}): {x: number; y: number; heat: number} { + const frame = useCurrentFrame(); + let t = Math.max(0, frame - (o.at ?? 0)); + let lap = 0; + while (lap < o.durs.length && t >= o.durs[lap]) { + t -= o.durs[lap]; + lap += 1; + } + const within = lap >= o.durs.length ? 1 : t / o.durs[lap]; + const heat = Math.min(1, lap / (o.heatPerLap ?? o.durs.length)); + const a = -90 + within * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), heat}; +} + +// ── 计数 / 水位(meter 语法;显示层自行 Math.round / toFixed) ────────── + +export function useCount(o: { + from?: number; + to: number; + at?: number; + dur?: number | DurToken; + ease?: EasingToken; +}): number { + const frame = useCurrentFrame(); + const {from = 0, to, at = 0} = o; + const p = eased(frame, at, frames(o.dur, DUR.f6), o.ease ?? 'standard'); + return from + (to - from) * p; +} + +// ── 打字机 / 逐字流出(type;Terminal 之外的泛化) ────────────────────── + +export function useReveal( + text: string, + o: {at?: number; cps?: number; framesPerChar?: number} = {}, +): string { + const frame = useCurrentFrame(); + const per = o.framesPerChar ?? Math.max(1, Math.round(30 / (o.cps ?? 12))); + const n = Math.floor(Math.max(0, frame - (o.at ?? 0)) / per); + return text.slice(0, Math.min(text.length, n)); +} + +// ── 镜头推近(pushIn 语法:beat 切换的镜头语言,替代纯淡入) ──────────── + +export function usePushIn(at: number, o: {scale?: number; dur?: number | DurToken} = {}): string { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(o.dur, DUR.f5), 'decelerate'); + return `scale(${1 + (o.scale ?? 0.06) * p})`; +} + +// ── 压暗 / 提亮(emphasis 反向:让主体从群像中浮出) ───────────────────── + +/** 返回目标透明度系数(1 = 原;to 0.4 即压暗到 40%)。 */ +export function useDim(o: {at: number; to?: number; dur?: number | DurToken}): number { + const frame = useCurrentFrame(); + const p = eased(frame, o.at, frames(o.dur, DUR.f4), 'standard'); + return 1 + ((o.to ?? 0.4) - 1) * p; +} + +// ── 流光(flow 语法:连线上的行进虚线) ───────────────────────────────── + +/** 返回可直接展开到 / 的描边属性(像素 dasharray——与 draw 的 + * pathLength 归一化描线是两个正交特性,勿混用于同一元素:红线三)。 */ +export function useFlowDash(o: { + dash?: number; + gap?: number; + /** 帧速率:每 period 帧行进一个 dash+gap 周期。 */ + period?: number; +}): {strokeDasharray: string; strokeDashoffset: number} { + const frame = useCurrentFrame(); + const {dash = 10, gap = 14, period = 40} = o; + return { + strokeDasharray: `${dash} ${gap}`, + strokeDashoffset: -(frame * (dash + gap)) / period, + }; +} + +// ── 抖动(错误/故障语义;收敛 P2/P3 两处克隆) ────────────────────────── + +/** 返回 translateX 像素值。active 缺省 true;decay=true 时按 dur 衰减归零。 */ +export function useShake(o: { + at: number; + active?: boolean; + amp?: number; + /** 相位分母(原手写 /1.6、/2.2 的口径)。 */ + freq?: number; + decay?: boolean; + dur?: number | DurToken; +}): number { + const frame = useCurrentFrame(); + const {amp = 3, freq = 1.6} = o; + const t = frame - o.at; + if (o.active === false || t < 0) { + return 0; + } + if (o.decay) { + const env = 1 - progress(frame, o.at, frames(o.dur, DUR.f5)); + return amp * env * Math.sin(t / freq); + } + return amp * Math.sin(t / freq); +} + +// ── 片尾渐黑(红线四:从 beat 总时长推导,勿用末句时长) ──────────────── + +export function useFadeOut(durationInFrames: number, o: {frames?: number} = {}): number { + const frame = useCurrentFrame(); + const f = o.frames ?? 36; // 1.2s + return 1 - progress(frame, durationInFrames - f, f); +} diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/index.ts b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/index.ts new file mode 100644 index 000000000..97fd22a5c --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/index.ts @@ -0,0 +1,5 @@ +/** 运动层门面——场景代码统一 `import {...} from '../motion'`。 */ +export * from './tokens'; +export * from './window'; +export * from './schedule'; +export * from './hooks'; diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/schedule.ts b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/schedule.ts new file mode 100644 index 000000000..0aa1ebfa4 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/schedule.ts @@ -0,0 +1,59 @@ +/** 错峰编排(stagger)纯函数——收敛手写 `frame - i*N`(跨 8 集约 356 处、 + * 步长 K∈{2,3,4,5,6,8,10} 任意取值、无节奏标尺)。 + * + * 三种模式(互斥,参数即文档): + * - stride:固定步长(与旧手写行为一致——迁移期的保守选项); + * - lag:Manim LaggedStart 语义 start[i] = i·dur·lagRatio(0 = 同刻齐动,1 = 首尾相接); + * - fit:n 个子项恰好装进窗口 total(末项恰在窗口末完成——「随句推进」的首选)。 + * + * 钳制优先级(motion.test.ts 钉死,高者让位低者):不外溢窗口 > 最小步长 > 子项时长。 + * 窗口属于 beat 时间轴,外溢会踩进下一 beat;装不下时**缩子项时长**,不延窗口。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点最小间隔,默认 2 帧(30fps 下仍可辨先后)。 */ + minStride?: number; + /** 子项时长下限,默认 3 帧(低于此相当于瞬现)。 */ + minDur?: number; +}; +export type Schedule = {starts: number[]; dur: number}; + +export function schedule(n: number, o: ScheduleOpts): Schedule { + const minStride = o.minStride ?? 2; + const minDur = o.minDur ?? 3; + const modes = [o.stride !== undefined, o.lag !== undefined, o.fit !== undefined].filter( + Boolean, + ).length; + if (modes > 1) { + throw new Error('schedule: stride / lag / fit 三选一'); + } + let dur = Math.max(minDur, o.dur ?? 5); + if (n <= 0) { + return {starts: [], dur}; + } + let stride: number; + if (o.fit) { + const total = Math.max(1, o.fit.total); + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + if (n > 1 && stride < minStride) { + // 装不下:缩子项时长换最小步长(保先后可辨),仍不外溢 + dur = Math.max(minDur, total - minStride * (n - 1)); + dur = Math.max(1, Math.min(dur, total)); + stride = Math.max(1, (total - dur) / (n - 1)); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 ? 0 : 1, stride); + return {starts: Array.from({length: n}, (_, i) => Math.round(i * stride)), dur}; +} diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/tokens.ts b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/tokens.ts new file mode 100644 index 000000000..457448260 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/tokens.ts @@ -0,0 +1,87 @@ +/** 运动令牌——时长标尺 / 缓动曲线 / 弹簧手感 的单一事实源。 + * + * 只放纯数据与纯函数:不 import remotion、不读 theme(颜色一律经参数注入)—— + * 这是本层能以 frozen 档跨两个系列共享的前提(两系列 theme token 名已分叉: + * CC 用 core/mech/deny、SE 用 danger),判据同 test_chrome_motifs_only_read_base_theme_tokens。 + * + * 取值依据(勿凭感觉改;改前先在本集校准轮逐幕目视复测,依据写回此处注释): + * - 时长六档取 IBM Carbon DTCG(70/110/150/240/400/700ms)@30fps 四舍五入。 + * 弃 Material 十六档:30fps 量化下其 15 个相邻对里 6 对落进同一帧数(伪选择)。 + * - 缓动控制点取 Material 3 标准三件;曲线本体在 hooks.ts 经 Remotion Easing.bezier 求值。 + * - 弹簧预设锚定本仓实测手感:settle=200 即既有 9/10 调用点的惯用值(延续成片观感), + * snap=12 来自 P4「插头咬合」的过冲;ζ 与过冲峰值的关系由 motion.test.ts 用 + * Mp = exp(-πζ/√(1-ζ²)) 钉死。 + * - effects 不变量:不透明度/颜色永不过冲——effects 类动画一律时长+缓动, + * 弹簧只用于空间位移(M3 spatial/effects 二分的落地)。 + */ + +/** 时长标尺(帧 @30fps)。叙事节拍(4–8s)不用此表——那是 window/schedule 的职责。 */ +export const DUR = { + /** 70ms:微反馈(辉光起点、光标) */ + f1: 2, + /** 110ms:快速子项(列表错峰的单项时长) */ + f2: 3, + /** 150ms:标准入场 */ + f3: 5, + /** 200ms:强调入场 */ + f4: 7, + /** 400ms:大位移 / 镜头推近 / 描线 */ + f5: 12, + /** 700ms:幕级大动作(少用) */ + f6: 21, +} as const; +export type DurToken = keyof typeof DUR; + +/** 缓动令牌。 */ +export type EasingToken = 'standard' | 'decelerate' | 'accelerate' | 'linear'; + +/** 贝塞尔控制点(x1,y1,x2,y2);linear 无控制点。 */ +export const EASING_CP: Record< + Exclude, + readonly [number, number, number, number] +> = { + // M3 standard:入场默认 + standard: [0.2, 0, 0, 1], + // M3 decelerate:强减速(大位移入场、镜头推近) + decelerate: [0.05, 0.7, 0.1, 1], + // M3 accelerate:出场加速 + accelerate: [0.3, 0, 0.8, 0.15], +}; + +/** 弹簧预设(直传 Remotion spring config;ζ = c/(2√(k·m)))。 */ +export type SpringPreset = 'settle' | 'settleSoft' | 'snap'; +export const SPRING: Record = { + // ζ≈10:无过冲平滑滑入——本仓主流手感(既有场景 9/10 处 damping 200) + settle: {damping: 200, stiffness: 100, mass: 1}, + // ζ≈8.5:更绵一点(P0/P1 既有 180/170 档的收敛) + settleSoft: {damping: 170, stiffness: 100, mass: 1}, + // ζ≈0.6:轻微过冲(咬合/弹入——原 P4 damping 12) + snap: {damping: 12, stiffness: 100, mass: 1}, +}; + +/** 阻尼比 ζ。设计系统文档普遍给 ζ(无量纲),Remotion 取阻尼系数 c——直抄会得 + * ζ≈0.02 的暴力弹跳且能通过渲染体检,这是迁移期最高风险项(单测钉死)。 */ +export const dampingRatio = (s: { + damping: number; + stiffness: number; + mass: number; +}): number => s.damping / (2 * Math.sqrt(s.stiffness * s.mass)); + +/** 欠阻尼弹簧的峰值位置(1 = 恰好到终点不过冲;ζ ≥ 1 恒 1)。 + * Mp = exp(-πζ/√(1-ζ²)) 是超出终点的幅度,峰值 = 1 + Mp。 */ +export const overshootPeak = (zeta: number): number => + zeta >= 1 ? 1 : 1 + Math.exp((-Math.PI * zeta) / Math.sqrt(1 - zeta * zeta)); + +/** 出场快于入场的系数(MDC 实测 400ms 入 / 150ms 出 ≈ 0.375,取 0.4 禁手填)。 */ +export const EXIT_FACTOR = 0.4; + +/** 字幕安全带上沿:1080 - qa_frames.SUBTITLE_BAND_PX(160),与体检口径同源。 */ +export const SAFE_TOP_Y = 920; + +/** 自下方入场的行程安全钳制:落位态底边 restBottom 之上才是可用的进场空间。 + * ISSUE-170 的手工逐卡反算收敛于此——该缺陷类从「评审抽帧抓」变「构造不可能」。 */ +export const clampRiseDist = ( + dist: number, + restBottom: number, + safeTop: number = SAFE_TOP_Y, +): number => Math.max(0, Math.min(dist, safeTop - restBottom)); diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/window.ts b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/window.ts new file mode 100644 index 000000000..5d8e5c016 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/window.ts @@ -0,0 +1,28 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 audio-first 时序与可复用运动模型兼容的核心机制:beat 的绝对帧来自 + * beatWindow()(数据源是 TTS 实测 manifest),子动画不写死帧数、只声明自己在 + * beat 进度上的 [start, end] 窗口 ⇒ 旁白实测时长变化时全部窗口自动重定时, + * 「写死帧数与口播脱钩」缺陷类(skills/08 实录)由构造消灭。 + * + * 语义借 MDC TransitionUtils.lerp(startFraction, endFraction):窗外钳制端点。 + * 本模块零依赖(不 import remotion / theme)——frozen 跨系列共享与 node 单测的前提。 + */ + +/** 钳制到 [0,1]——一切进度的唯一出口,防负值/超 1 渗进 transform。 */ +export const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x); + +/** 子动画窗口:父进度 p(0..1) 在 [s,e] 片段上的局部进度。 */ +export const win = (p: number, w: readonly [number, number]): number => + clamp01((p - w[0]) / (w[1] - w[0])); + +/** beat 进度:局部帧 → 该 beat 的 0..1 进度。 */ +export const beatProgress = ( + frame: number, + from: number, + durationInFrames: number, +): number => clamp01((frame - from) / Math.max(1, durationInFrames)); + +/** 帧域进度:[at, at+dur] 上的 0..1(dur ≤ 0 视作 1,防除零)。 */ +export const progress = (frame: number, at: number, dur: number): number => + clamp01((frame - at) / Math.max(1, dur)); diff --git a/apps/negentropy-influence/episodes/claude-code-memory-video/video/scripts/motion.test.ts b/apps/negentropy-influence/episodes/claude-code-memory-video/video/scripts/motion.test.ts new file mode 100644 index 000000000..4a55380f7 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/scripts/motion.test.ts @@ -0,0 +1,123 @@ +/** + * 运动层纯函数单测——`node --test scripts/motion.test.ts`(Node ≥ 23.6 原生跑 TS)。 + * + * 刻意放在 video/ 而非 src/:tsconfig include 只有 src(frozen,不为测试改动), + * 而本文件 import 需带 .ts 后缀(Node ESM 解析规则)——tsc 若收编会因 + * allowImportingTsExtensions 未开而报错。src/ 的类型覆盖由 tsc --noEmit 全量保证, + * 本文件只测纯函数行为,不做类型承重。 + * + * 只 import 纯模块(tokens/window/schedule——零 remotion/react 依赖): + * hooks 是它们的薄包装,行为由 MotionGallery 目视 + 场景抽帧覆盖。 + */ +import {strict as assert} from 'node:assert'; +import test from 'node:test'; + +import {DUR, EASING_CP, EXIT_FACTOR, SAFE_TOP_Y, SPRING, clampRiseDist, dampingRatio, overshootPeak} from '../src/motion/tokens.ts'; +import {beatProgress, clamp01, progress, win} from '../src/motion/window.ts'; +import {schedule} from '../src/motion/schedule.ts'; + +// ── tokens ──────────────────────────────────────────────────────────── + +test('时长标尺在 30fps 下相邻档可辨(≥1 帧差)', () => { + const v = Object.values(DUR) as number[]; + for (let i = 1; i < v.length; i++) { + assert.ok(v[i] - v[i - 1] >= 1, `第 ${i} 档与前一档同帧数(伪选择)`); + } + assert.ok(v.length === 6); +}); + +test('ζ→过冲换算钉死:snap 轻过冲、settle 零过冲(直抄 dampingRatio 的反例护栏)', () => { + const zSnap = dampingRatio(SPRING.snap); + const zSettle = dampingRatio(SPRING.settle); + assert.ok(zSnap > 0.4 && zSnap < 0.9, `snap ζ=${zSnap}`); + // Mp = exp(-πζ/√(1-ζ²)) 是超出幅度;峰值 = 1 + Mp(ζ=0.6 → 峰值 ≈1.095) + assert.ok(overshootPeak(zSnap) > 1.02 && overshootPeak(zSnap) < 1.2, `snap 峰值 ${overshootPeak(zSnap)}`); + assert.ok(zSettle > 1, 'settle 须过阻尼'); + assert.equal(overshootPeak(zSettle), 1); + // 反例:把设计系统的 ζ 当 damping 直填(0.8)→ ζ≈0.04、峰值≈1.88 暴力弹跳 + const wrong = dampingRatio({damping: 0.8, stiffness: 100, mass: 1}); + assert.ok(overshootPeak(wrong) > 1.8, `直抄 ζ 的峰值=${overshootPeak(wrong)},必须被此断言抓住`); +}); + +test('缓动控制点合法(CSS 规则 x∈[0,1],且 x(t) 数值单调——可作函数求值)', () => { + for (const cp of Object.values(EASING_CP)) { + const [x1, , x2] = cp; + assert.ok(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1, `x 越界:${cp}`); + // x1 -1e-9, `x(t) 非单调 @t=${t}:${cp}`); + } + } +}); + +test('出场快于入场系数与安全带口径为常量', () => { + assert.equal(EXIT_FACTOR, 0.4); + assert.equal(SAFE_TOP_Y, 920); +}); + +test('clampRiseDist:自下方入场行程不探进字幕安全带', () => { + assert.equal(clampRiseDist(120, 836), 84, 'ISSUE-170 实测几何:rest 836 → 行程封顶 84'); + assert.equal(clampRiseDist(40, 836), 40, '未超限不动'); + assert.equal(clampRiseDist(120, 960), 0, '落位已在安全带内 → 零行程(退化但安全)'); +}); + +// ── window ──────────────────────────────────────────────────────────── + +test('clamp01 / progress / win 的钳制语义', () => { + assert.equal(clamp01(-1), 0); + assert.equal(clamp01(2), 1); + assert.equal(progress(10, 10, 10), 0, '起点为 0'); + assert.equal(progress(20, 10, 10), 1, '终点为 1'); + assert.equal(progress(5, 10, 10), 0, '窗外前钳 0'); + assert.equal(progress(99, 10, 10), 1, '窗外后钳 1'); + assert.equal(win(0.5, [0.25, 0.75]), 0.5); + assert.equal(win(0.1, [0.25, 0.75]), 0); + assert.equal(win(0.9, [0.25, 0.75]), 1); + assert.equal(beatProgress(0, -30, 90), 1 / 3); +}); + +// ── schedule ────────────────────────────────────────────────────────── + +test('fit 模式:末项恰在窗口末完成、不外溢', () => { + const p = schedule(5, {dur: 10, fit: {total: 90}}); + const last = p.starts[4] + p.dur; + assert.ok(last <= 90 + 1, `末项 ${last} 外溢`); + assert.ok(last >= 89, `末项 ${last} 未到窗口末`); +}); + +test('fit 装不下时缩子项时长,不外溢窗口(优先级:不外溢 > 最小步长 > 子项时长)', () => { + // total 30 时 (30-12)/7≈2.57 ≥ minStride=2 仍装得下;压到 24 才触发缩时长 + const p = schedule(8, {dur: 12, fit: {total: 24}}); + const last = p.starts[7] + p.dur; + assert.ok(last <= 31, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); +}); + +test('stride 模式与三选一守卫', () => { + assert.deepEqual(schedule(3, {dur: 5, stride: 4}).starts, [0, 4, 8]); + assert.throws(() => schedule(3, {dur: 5, stride: 4, lag: 1})); + assert.throws(() => schedule(3, {dur: 5, lag: 1, fit: {total: 40}})); +}); + +test('最小步长与空集', () => { + assert.deepEqual(schedule(0, {dur: 5, stride: 4}), {starts: [], dur: 5}); + const p = schedule(2, {dur: 5, stride: 0}); // 非法步长 → 抬到下限 + assert.ok(p.starts[1] - p.starts[0] >= 1); +}); + +test('起点取整且单调不减', () => { + const p = schedule(6, {dur: 7, fit: {total: 53}}); + for (let i = 1; i < p.starts.length; i++) { + assert.ok(p.starts[i] >= p.starts[i - 1], '起点须单调不减'); + assert.ok(Number.isInteger(p.starts[i])); + } +}); diff --git a/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/gallery.tsx b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/gallery.tsx new file mode 100644 index 000000000..ad2e067cf --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/gallery.tsx @@ -0,0 +1,265 @@ +/** MotionGallery——运动层的评审面(独立 Remotion 入口,不经 Root.tsx)。 + * + * 用法(在 video/ 目录,.bin 直调防污染根 workspace): + * ./node_modules/.bin/remotion still src/motion/gallery.tsx MotionGallery \ + * out/motion-gallery.png --frame=30 + * + * 全部模型 × 变体一屏可渲:秒级出图,供 token 校准轮逐格目视。 + * 色板为本文件内字面量(dev 工具面,不进成片、不读 theme——保持 frozen 跨系列共享)。 + */ +import React from 'react'; +import {AbsoluteFill, Composition, registerRoot} from 'remotion'; +import { + useAccelTravel, + useBreathe, + useCount, + useDim, + useDraw, + useEnter, + useFadeOut, + useFlowDash, + useImpulse, + usePushIn, + useReveal, + useShake, + useStagger, + useTravel, +} from './hooks'; + +const COLS = 5; +const CW = 360; +const CH = 240; +const GAP = 18; + +/** dev 工具面字面量色板(与各集 theme 底座同值但刻意独立声明——不读 theme)。 */ +const C = { + bg: '#0E1116', + panel: '#171C26', + border: '#2A3242', + text: '#F2F5FA', + dim: '#9AA7B8', + core: '#D97757', + mech: '#64C4C0', + deny: '#EF6461', +}; + +/** 单元格壳:定位 + 角标。 */ +const Cell: React.FC<{i: number; name: string; children: React.ReactNode}> = ({i, name, children}) => ( +
+
+ {children} +
+
+ {name} +
+
+); + +const Box: React.FC<{color?: string; w?: number; h?: number; style?: React.CSSProperties}> = ({ + color = C.core, + w = 200, + h = 80, + style, +}) =>
; + +// ── 每格一个组件:hooks 各归其位(Rules of Hooks 的最简守法形态) ──────── + +const EnterCell: React.FC<{kind: Parameters[0]}> = ({kind}) => { + const e = useEnter(kind, {}); + return ; +}; + +const StaggerCell: React.FC = () => { + const ps = useStagger(5, {dur: 5, fit: {total: 60}}); + return ( +
+ {ps.map((p, i) => ( +
+ ))} +
+ ); +}; + +const DrawCell: React.FC = () => { + const d = useDraw(0, 24); + return ( + + + + ); +}; + +const ImpulseCell: React.FC = () => { + const g = useImpulse({dur: 30, peak: 1}); + return ( +
+ ); +}; + +const BreatheCell: React.FC = () => { + const b = useBreathe({period: 30}); + return ( + + + + ); +}; + +const TravelCell: React.FC = () => { + const t = useTravel({cx: 140, cy: 55, r: 42, secPerLap: 3}); + return ( + + + + + ); +}; + +const AccelCell: React.FC = () => { + const t = useAccelTravel({cx: 140, cy: 55, r: 42, durs: [28, 20, 14], at: 4}); + const heat = `rgb(${217 + Math.round(38 * t.heat)}, ${119 - Math.round(60 * t.heat)}, ${87 - Math.round(20 * t.heat)})`; + return ( + + + + + ); +}; + +const CountCell: React.FC = () => { + const v = useCount({to: 255, dur: 40}); + return ( +
+ {Math.round(v)} +
+ ); +}; + +const RevealCell: React.FC = () => { + const s = useReveal('while (true) { think(); act(); }', {cps: 14}); + const blink = useBreathe({period: 16, amp: 0.5, base: 0.5}); + return ( +
+ {s} + +
+ ); +}; + +const PushInCell: React.FC = () => { + const t = usePushIn(0, {scale: 0.12}); + return ; +}; + +const DimCell: React.FC = () => { + const dim = useDim({at: 40, to: 0.35}); + return ( +
+ + + +
+ ); +}; + +const FlowCell: React.FC = () => { + const f = useFlowDash({period: 24}); + return ( + + + + ); +}; + +const ShakeCell: React.FC = () => { + const x = useShake({at: 10, amp: 5, decay: true, dur: 40}); + return ; +}; + +const FadeCell: React.FC = () => { + const op = useFadeOut(90, {frames: 36}); + return ( +
+ +
+
+ ); +}; + +const CELLS: Array<[string, React.ReactNode]> = [ + ['enter:fall', ], + ['enter:rise', ], + ['enter:slideL', ], + ['enter:pop', ], + ['enter:flyIn', ], + ['enter:fade', ], + ['stagger×5 fit60', ], + ['draw 24f', ], + ['impulse 30f', ], + ['breathe p30', ], + ['travel 3s/lap', ], + ['accelTravel', ], + ['count→255', ], + ['reveal 14cps', ], + ['pushIn .12', ], + ['dim .35@40', ], + ['flowDash p24', ], + ['shake decay', ], + ['fadeOut 36f', ], +]; + +const MotionGallery: React.FC = () => ( + +
+ MotionGallery · 30fps · 120f +
+ {CELLS.map(([name, node], i) => ( + + {node} + + ))} +
+); + +registerRoot(() => ( + +)); diff --git a/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/hooks.ts b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/hooks.ts new file mode 100644 index 000000000..bfa87b05f --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/hooks.ts @@ -0,0 +1,268 @@ +/** 运动模型(hooks)——分镜「动效」列动词到帧数学的唯一映射。 + * + * 设计约束(违反任何一条即失去本层存在意义): + * 1. hooks 返回数值 / CSS 片段,不渲染 DOM——FadeUp 式包装组件打不进 svg// + * absolute 布局,是「组件存在却零调用」的实测根因;数值可落进任意 JSX。 + * 2. 弹簧一律吃局部帧(frame - at):spring() 每次调用从第 0 帧重模拟,喂全局帧 + * 会让长片末帧每个 spring 跑两万余次迭代。 + * 3. effects(不透明度/颜色)永不吃弹簧——一律时长+缓动(tokens 的二分不变量)。 + * 4. 不读 theme:颜色一律经参数传入(frozen 跨系列共享的前提)。 + * 5. `at` 锚点一律来自句边界(rel(beat, '句id')),禁写死帧数。 + */ +import {Easing, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + DUR, + EASING_CP, + SPRING, + clampRiseDist, + type DurToken, + type EasingToken, + type SpringPreset, +} from './tokens'; +import {progress} from './window'; +import {schedule, type ScheduleOpts} from './schedule'; + +/** 帧数解析:token 或直接帧数;缺省用 def。 */ +export const frames = (d: number | DurToken | undefined, def: number): number => + d === undefined ? def : typeof d === 'number' ? d : DUR[d]; + +/** 缓动令牌 → Remotion 缓动函数(linear 直通)。 */ +export const easeF = (t: EasingToken): ((x: number) => number) => + t === 'linear' + ? Easing.linear + : Easing.bezier( + ...(EASING_CP[t] as [number, number, number, number]), + ); + +/** 缓动后的 0..1 进度(各模型共用的原子)。 */ +const eased = ( + frame: number, + at: number, + dur: number, + e: EasingToken, +): number => interpolate(progress(frame, at, dur), [0, 1], [0, 1], {easing: easeF(e)}); + +// ── 入场(enter:落下/上浮/滑入/弹出/飞入/淡入) ──────────────────────── + +export type EnterKind = 'fall' | 'rise' | 'slideL' | 'slideR' | 'pop' | 'flyIn' | 'fade'; +export type EnterOpts = { + /** 句边界锚(局部帧)。 */ + at?: number; + dur?: number | DurToken; + easing?: EasingToken; + /** 空间通道用弹簧(位移类才有意义;fade/pop 无效)。 */ + springPreset?: SpringPreset; + /** 位移像素(fall/rise/slide*;缺省 30)。 */ + dist?: number; + /** rise 专用:落位态底边 y——行程经 clampRiseDist 钳进字幕安全带之上。 */ + restBottom?: number; +}; +export type EnterStyle = {opacity: number; transform: string}; + +export function useEnter(kind: EnterKind, o: EnterOpts = {}): EnterStyle { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const at = o.at ?? 0; + const dur = frames(o.dur, DUR.f4); + let dist = o.dist ?? 30; + if (kind === 'rise' && o.restBottom !== undefined) { + dist = clampRiseDist(dist, o.restBottom); + } + // effects 通道:纯缓动、略快于空间通道(元素先「看见」再「落位」) + const opacity = progress(frame, at, Math.max(2, Math.round(dur * 0.8))); + // spatial 通道:可选弹簧(局部帧 + durationInFrames 截停,防窗口外余振) + const p = o.springPreset + ? spring({ + frame: frame - at, + fps, + config: SPRING[o.springPreset], + durationInFrames: dur, + }) + : eased(frame, at, dur, o.easing ?? 'standard'); + const inv = 1 - p; + const t: string[] = []; + if (kind === 'fall') t.push(`translateY(${-inv * dist}px)`); + if (kind === 'rise') t.push(`translateY(${inv * dist}px)`); + if (kind === 'slideL') t.push(`translateX(${-inv * dist}px)`); + if (kind === 'slideR') t.push(`translateX(${inv * dist}px)`); + if (kind === 'pop') t.push(`scale(${0.9 + 0.1 * p})`); + if (kind === 'flyIn') t.push(`scale(${0.6 + 0.4 * p})`); + return {opacity, transform: t.length ? t.join(' ') : 'none'}; +} + +// ── 序列错峰(stagger:依次/逐行/逐条/逐格) ─────────────────────────── + +export type StaggerOpts = ScheduleOpts & {at?: number; easing?: EasingToken}; + +/** 返回 n 个 0..1 进度——第 i 项随编排依次入场。 */ +export function useStagger(n: number, o: StaggerOpts = {}): number[] { + const frame = useCurrentFrame(); + const {at = 0, easing = 'standard'} = o; + const plan = schedule(n, o); + return plan.starts.map((s) => eased(frame, at + s, plan.dur, easing)); +} + +// ── 描线(draw:红线三由构造保证——只产 pathLength 归一化三元组) ──────── + +export type DrawProps = {pathLength: 1; strokeDasharray: 1; strokeDashoffset: number}; + +export function useDraw(at: number, dur: number | DurToken = DUR.f5): DrawProps { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(dur, DUR.f5), 'decelerate'); + return {pathLength: 1, strokeDasharray: 1, strokeDashoffset: 1 - p}; +} + +// ── 脉冲 / 呼吸(glow 语法:impulse=一次性强调,breathe=持续辉光) ─────── + +/** 一次性冲击:sin(πp) 包络,起于 0 归于 0,峰值 peak。 */ +export function useImpulse(o: {at?: number; dur?: number | DurToken; peak?: number} = {}): number { + const frame = useCurrentFrame(); + const p = progress(frame, o.at ?? 0, frames(o.dur, DUR.f5)); + return Math.sin(Math.PI * p) * (o.peak ?? 1); +} + +/** 持续呼吸(原 0.55+0.45·sin(frame/K) 散写的收敛;period 帧一周期)。 */ +export function useBreathe(o: {period?: number; amp?: number; base?: number} = {}): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26} = o; + return base + amp * Math.sin((2 * Math.PI * frame) / period); +} + +// ── 巡游(travel:环形为主;absorb 原 useRingDot 与加速绕行累加器克隆) ── + +export type TravelPos = {x: number; y: number; angle: number}; + +/** 匀速环形巡游(angle 单位度,-90 = 12 点方向起)。 */ +export function useTravel(o: { + cx: number; + cy: number; + r: number; + secPerLap?: number; + offset?: number; +}): TravelPos { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const lap = (frame / (fps * (o.secPerLap ?? 2.5)) + (o.offset ?? 0)) % 1; + const a = -90 + lap * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), angle: a}; +} + +/** 加速绕行:逐圈时长 durs[](如 [40,30,22,16]),跑完全部圈后冻结在终点。 + * heat 0..1 随圈数推进(「失控感」的配色偏移系数)。 */ +export function useAccelTravel(o: { + cx: number; + cy: number; + r: number; + durs: number[]; + at?: number; + heatPerLap?: number; +}): {x: number; y: number; heat: number} { + const frame = useCurrentFrame(); + let t = Math.max(0, frame - (o.at ?? 0)); + let lap = 0; + while (lap < o.durs.length && t >= o.durs[lap]) { + t -= o.durs[lap]; + lap += 1; + } + const within = lap >= o.durs.length ? 1 : t / o.durs[lap]; + const heat = Math.min(1, lap / (o.heatPerLap ?? o.durs.length)); + const a = -90 + within * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), heat}; +} + +// ── 计数 / 水位(meter 语法;显示层自行 Math.round / toFixed) ────────── + +export function useCount(o: { + from?: number; + to: number; + at?: number; + dur?: number | DurToken; + ease?: EasingToken; +}): number { + const frame = useCurrentFrame(); + const {from = 0, to, at = 0} = o; + const p = eased(frame, at, frames(o.dur, DUR.f6), o.ease ?? 'standard'); + return from + (to - from) * p; +} + +// ── 打字机 / 逐字流出(type;Terminal 之外的泛化) ────────────────────── + +export function useReveal( + text: string, + o: {at?: number; cps?: number; framesPerChar?: number} = {}, +): string { + const frame = useCurrentFrame(); + const per = o.framesPerChar ?? Math.max(1, Math.round(30 / (o.cps ?? 12))); + const n = Math.floor(Math.max(0, frame - (o.at ?? 0)) / per); + return text.slice(0, Math.min(text.length, n)); +} + +// ── 镜头推近(pushIn 语法:beat 切换的镜头语言,替代纯淡入) ──────────── + +export function usePushIn(at: number, o: {scale?: number; dur?: number | DurToken} = {}): string { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(o.dur, DUR.f5), 'decelerate'); + return `scale(${1 + (o.scale ?? 0.06) * p})`; +} + +// ── 压暗 / 提亮(emphasis 反向:让主体从群像中浮出) ───────────────────── + +/** 返回目标透明度系数(1 = 原;to 0.4 即压暗到 40%)。 */ +export function useDim(o: {at: number; to?: number; dur?: number | DurToken}): number { + const frame = useCurrentFrame(); + const p = eased(frame, o.at, frames(o.dur, DUR.f4), 'standard'); + return 1 + ((o.to ?? 0.4) - 1) * p; +} + +// ── 流光(flow 语法:连线上的行进虚线) ───────────────────────────────── + +/** 返回可直接展开到 / 的描边属性(像素 dasharray——与 draw 的 + * pathLength 归一化描线是两个正交特性,勿混用于同一元素:红线三)。 */ +export function useFlowDash(o: { + dash?: number; + gap?: number; + /** 帧速率:每 period 帧行进一个 dash+gap 周期。 */ + period?: number; +}): {strokeDasharray: string; strokeDashoffset: number} { + const frame = useCurrentFrame(); + const {dash = 10, gap = 14, period = 40} = o; + return { + strokeDasharray: `${dash} ${gap}`, + strokeDashoffset: -(frame * (dash + gap)) / period, + }; +} + +// ── 抖动(错误/故障语义;收敛 P2/P3 两处克隆) ────────────────────────── + +/** 返回 translateX 像素值。active 缺省 true;decay=true 时按 dur 衰减归零。 */ +export function useShake(o: { + at: number; + active?: boolean; + amp?: number; + /** 相位分母(原手写 /1.6、/2.2 的口径)。 */ + freq?: number; + decay?: boolean; + dur?: number | DurToken; +}): number { + const frame = useCurrentFrame(); + const {amp = 3, freq = 1.6} = o; + const t = frame - o.at; + if (o.active === false || t < 0) { + return 0; + } + if (o.decay) { + const env = 1 - progress(frame, o.at, frames(o.dur, DUR.f5)); + return amp * env * Math.sin(t / freq); + } + return amp * Math.sin(t / freq); +} + +// ── 片尾渐黑(红线四:从 beat 总时长推导,勿用末句时长) ──────────────── + +export function useFadeOut(durationInFrames: number, o: {frames?: number} = {}): number { + const frame = useCurrentFrame(); + const f = o.frames ?? 36; // 1.2s + return 1 - progress(frame, durationInFrames - f, f); +} diff --git a/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/index.ts b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/index.ts new file mode 100644 index 000000000..97fd22a5c --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/index.ts @@ -0,0 +1,5 @@ +/** 运动层门面——场景代码统一 `import {...} from '../motion'`。 */ +export * from './tokens'; +export * from './window'; +export * from './schedule'; +export * from './hooks'; diff --git a/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/schedule.ts b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/schedule.ts new file mode 100644 index 000000000..0aa1ebfa4 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/schedule.ts @@ -0,0 +1,59 @@ +/** 错峰编排(stagger)纯函数——收敛手写 `frame - i*N`(跨 8 集约 356 处、 + * 步长 K∈{2,3,4,5,6,8,10} 任意取值、无节奏标尺)。 + * + * 三种模式(互斥,参数即文档): + * - stride:固定步长(与旧手写行为一致——迁移期的保守选项); + * - lag:Manim LaggedStart 语义 start[i] = i·dur·lagRatio(0 = 同刻齐动,1 = 首尾相接); + * - fit:n 个子项恰好装进窗口 total(末项恰在窗口末完成——「随句推进」的首选)。 + * + * 钳制优先级(motion.test.ts 钉死,高者让位低者):不外溢窗口 > 最小步长 > 子项时长。 + * 窗口属于 beat 时间轴,外溢会踩进下一 beat;装不下时**缩子项时长**,不延窗口。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点最小间隔,默认 2 帧(30fps 下仍可辨先后)。 */ + minStride?: number; + /** 子项时长下限,默认 3 帧(低于此相当于瞬现)。 */ + minDur?: number; +}; +export type Schedule = {starts: number[]; dur: number}; + +export function schedule(n: number, o: ScheduleOpts): Schedule { + const minStride = o.minStride ?? 2; + const minDur = o.minDur ?? 3; + const modes = [o.stride !== undefined, o.lag !== undefined, o.fit !== undefined].filter( + Boolean, + ).length; + if (modes > 1) { + throw new Error('schedule: stride / lag / fit 三选一'); + } + let dur = Math.max(minDur, o.dur ?? 5); + if (n <= 0) { + return {starts: [], dur}; + } + let stride: number; + if (o.fit) { + const total = Math.max(1, o.fit.total); + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + if (n > 1 && stride < minStride) { + // 装不下:缩子项时长换最小步长(保先后可辨),仍不外溢 + dur = Math.max(minDur, total - minStride * (n - 1)); + dur = Math.max(1, Math.min(dur, total)); + stride = Math.max(1, (total - dur) / (n - 1)); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 ? 0 : 1, stride); + return {starts: Array.from({length: n}, (_, i) => Math.round(i * stride)), dur}; +} diff --git a/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/tokens.ts b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/tokens.ts new file mode 100644 index 000000000..457448260 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/tokens.ts @@ -0,0 +1,87 @@ +/** 运动令牌——时长标尺 / 缓动曲线 / 弹簧手感 的单一事实源。 + * + * 只放纯数据与纯函数:不 import remotion、不读 theme(颜色一律经参数注入)—— + * 这是本层能以 frozen 档跨两个系列共享的前提(两系列 theme token 名已分叉: + * CC 用 core/mech/deny、SE 用 danger),判据同 test_chrome_motifs_only_read_base_theme_tokens。 + * + * 取值依据(勿凭感觉改;改前先在本集校准轮逐幕目视复测,依据写回此处注释): + * - 时长六档取 IBM Carbon DTCG(70/110/150/240/400/700ms)@30fps 四舍五入。 + * 弃 Material 十六档:30fps 量化下其 15 个相邻对里 6 对落进同一帧数(伪选择)。 + * - 缓动控制点取 Material 3 标准三件;曲线本体在 hooks.ts 经 Remotion Easing.bezier 求值。 + * - 弹簧预设锚定本仓实测手感:settle=200 即既有 9/10 调用点的惯用值(延续成片观感), + * snap=12 来自 P4「插头咬合」的过冲;ζ 与过冲峰值的关系由 motion.test.ts 用 + * Mp = exp(-πζ/√(1-ζ²)) 钉死。 + * - effects 不变量:不透明度/颜色永不过冲——effects 类动画一律时长+缓动, + * 弹簧只用于空间位移(M3 spatial/effects 二分的落地)。 + */ + +/** 时长标尺(帧 @30fps)。叙事节拍(4–8s)不用此表——那是 window/schedule 的职责。 */ +export const DUR = { + /** 70ms:微反馈(辉光起点、光标) */ + f1: 2, + /** 110ms:快速子项(列表错峰的单项时长) */ + f2: 3, + /** 150ms:标准入场 */ + f3: 5, + /** 200ms:强调入场 */ + f4: 7, + /** 400ms:大位移 / 镜头推近 / 描线 */ + f5: 12, + /** 700ms:幕级大动作(少用) */ + f6: 21, +} as const; +export type DurToken = keyof typeof DUR; + +/** 缓动令牌。 */ +export type EasingToken = 'standard' | 'decelerate' | 'accelerate' | 'linear'; + +/** 贝塞尔控制点(x1,y1,x2,y2);linear 无控制点。 */ +export const EASING_CP: Record< + Exclude, + readonly [number, number, number, number] +> = { + // M3 standard:入场默认 + standard: [0.2, 0, 0, 1], + // M3 decelerate:强减速(大位移入场、镜头推近) + decelerate: [0.05, 0.7, 0.1, 1], + // M3 accelerate:出场加速 + accelerate: [0.3, 0, 0.8, 0.15], +}; + +/** 弹簧预设(直传 Remotion spring config;ζ = c/(2√(k·m)))。 */ +export type SpringPreset = 'settle' | 'settleSoft' | 'snap'; +export const SPRING: Record = { + // ζ≈10:无过冲平滑滑入——本仓主流手感(既有场景 9/10 处 damping 200) + settle: {damping: 200, stiffness: 100, mass: 1}, + // ζ≈8.5:更绵一点(P0/P1 既有 180/170 档的收敛) + settleSoft: {damping: 170, stiffness: 100, mass: 1}, + // ζ≈0.6:轻微过冲(咬合/弹入——原 P4 damping 12) + snap: {damping: 12, stiffness: 100, mass: 1}, +}; + +/** 阻尼比 ζ。设计系统文档普遍给 ζ(无量纲),Remotion 取阻尼系数 c——直抄会得 + * ζ≈0.02 的暴力弹跳且能通过渲染体检,这是迁移期最高风险项(单测钉死)。 */ +export const dampingRatio = (s: { + damping: number; + stiffness: number; + mass: number; +}): number => s.damping / (2 * Math.sqrt(s.stiffness * s.mass)); + +/** 欠阻尼弹簧的峰值位置(1 = 恰好到终点不过冲;ζ ≥ 1 恒 1)。 + * Mp = exp(-πζ/√(1-ζ²)) 是超出终点的幅度,峰值 = 1 + Mp。 */ +export const overshootPeak = (zeta: number): number => + zeta >= 1 ? 1 : 1 + Math.exp((-Math.PI * zeta) / Math.sqrt(1 - zeta * zeta)); + +/** 出场快于入场的系数(MDC 实测 400ms 入 / 150ms 出 ≈ 0.375,取 0.4 禁手填)。 */ +export const EXIT_FACTOR = 0.4; + +/** 字幕安全带上沿:1080 - qa_frames.SUBTITLE_BAND_PX(160),与体检口径同源。 */ +export const SAFE_TOP_Y = 920; + +/** 自下方入场的行程安全钳制:落位态底边 restBottom 之上才是可用的进场空间。 + * ISSUE-170 的手工逐卡反算收敛于此——该缺陷类从「评审抽帧抓」变「构造不可能」。 */ +export const clampRiseDist = ( + dist: number, + restBottom: number, + safeTop: number = SAFE_TOP_Y, +): number => Math.max(0, Math.min(dist, safeTop - restBottom)); diff --git a/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/window.ts b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/window.ts new file mode 100644 index 000000000..5d8e5c016 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/window.ts @@ -0,0 +1,28 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 audio-first 时序与可复用运动模型兼容的核心机制:beat 的绝对帧来自 + * beatWindow()(数据源是 TTS 实测 manifest),子动画不写死帧数、只声明自己在 + * beat 进度上的 [start, end] 窗口 ⇒ 旁白实测时长变化时全部窗口自动重定时, + * 「写死帧数与口播脱钩」缺陷类(skills/08 实录)由构造消灭。 + * + * 语义借 MDC TransitionUtils.lerp(startFraction, endFraction):窗外钳制端点。 + * 本模块零依赖(不 import remotion / theme)——frozen 跨系列共享与 node 单测的前提。 + */ + +/** 钳制到 [0,1]——一切进度的唯一出口,防负值/超 1 渗进 transform。 */ +export const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x); + +/** 子动画窗口:父进度 p(0..1) 在 [s,e] 片段上的局部进度。 */ +export const win = (p: number, w: readonly [number, number]): number => + clamp01((p - w[0]) / (w[1] - w[0])); + +/** beat 进度:局部帧 → 该 beat 的 0..1 进度。 */ +export const beatProgress = ( + frame: number, + from: number, + durationInFrames: number, +): number => clamp01((frame - from) / Math.max(1, durationInFrames)); + +/** 帧域进度:[at, at+dur] 上的 0..1(dur ≤ 0 视作 1,防除零)。 */ +export const progress = (frame: number, at: number, dur: number): number => + clamp01((frame - at) / Math.max(1, dur)); diff --git a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/scripts/motion.test.ts b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/scripts/motion.test.ts new file mode 100644 index 000000000..4a55380f7 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/scripts/motion.test.ts @@ -0,0 +1,123 @@ +/** + * 运动层纯函数单测——`node --test scripts/motion.test.ts`(Node ≥ 23.6 原生跑 TS)。 + * + * 刻意放在 video/ 而非 src/:tsconfig include 只有 src(frozen,不为测试改动), + * 而本文件 import 需带 .ts 后缀(Node ESM 解析规则)——tsc 若收编会因 + * allowImportingTsExtensions 未开而报错。src/ 的类型覆盖由 tsc --noEmit 全量保证, + * 本文件只测纯函数行为,不做类型承重。 + * + * 只 import 纯模块(tokens/window/schedule——零 remotion/react 依赖): + * hooks 是它们的薄包装,行为由 MotionGallery 目视 + 场景抽帧覆盖。 + */ +import {strict as assert} from 'node:assert'; +import test from 'node:test'; + +import {DUR, EASING_CP, EXIT_FACTOR, SAFE_TOP_Y, SPRING, clampRiseDist, dampingRatio, overshootPeak} from '../src/motion/tokens.ts'; +import {beatProgress, clamp01, progress, win} from '../src/motion/window.ts'; +import {schedule} from '../src/motion/schedule.ts'; + +// ── tokens ──────────────────────────────────────────────────────────── + +test('时长标尺在 30fps 下相邻档可辨(≥1 帧差)', () => { + const v = Object.values(DUR) as number[]; + for (let i = 1; i < v.length; i++) { + assert.ok(v[i] - v[i - 1] >= 1, `第 ${i} 档与前一档同帧数(伪选择)`); + } + assert.ok(v.length === 6); +}); + +test('ζ→过冲换算钉死:snap 轻过冲、settle 零过冲(直抄 dampingRatio 的反例护栏)', () => { + const zSnap = dampingRatio(SPRING.snap); + const zSettle = dampingRatio(SPRING.settle); + assert.ok(zSnap > 0.4 && zSnap < 0.9, `snap ζ=${zSnap}`); + // Mp = exp(-πζ/√(1-ζ²)) 是超出幅度;峰值 = 1 + Mp(ζ=0.6 → 峰值 ≈1.095) + assert.ok(overshootPeak(zSnap) > 1.02 && overshootPeak(zSnap) < 1.2, `snap 峰值 ${overshootPeak(zSnap)}`); + assert.ok(zSettle > 1, 'settle 须过阻尼'); + assert.equal(overshootPeak(zSettle), 1); + // 反例:把设计系统的 ζ 当 damping 直填(0.8)→ ζ≈0.04、峰值≈1.88 暴力弹跳 + const wrong = dampingRatio({damping: 0.8, stiffness: 100, mass: 1}); + assert.ok(overshootPeak(wrong) > 1.8, `直抄 ζ 的峰值=${overshootPeak(wrong)},必须被此断言抓住`); +}); + +test('缓动控制点合法(CSS 规则 x∈[0,1],且 x(t) 数值单调——可作函数求值)', () => { + for (const cp of Object.values(EASING_CP)) { + const [x1, , x2] = cp; + assert.ok(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1, `x 越界:${cp}`); + // x1 -1e-9, `x(t) 非单调 @t=${t}:${cp}`); + } + } +}); + +test('出场快于入场系数与安全带口径为常量', () => { + assert.equal(EXIT_FACTOR, 0.4); + assert.equal(SAFE_TOP_Y, 920); +}); + +test('clampRiseDist:自下方入场行程不探进字幕安全带', () => { + assert.equal(clampRiseDist(120, 836), 84, 'ISSUE-170 实测几何:rest 836 → 行程封顶 84'); + assert.equal(clampRiseDist(40, 836), 40, '未超限不动'); + assert.equal(clampRiseDist(120, 960), 0, '落位已在安全带内 → 零行程(退化但安全)'); +}); + +// ── window ──────────────────────────────────────────────────────────── + +test('clamp01 / progress / win 的钳制语义', () => { + assert.equal(clamp01(-1), 0); + assert.equal(clamp01(2), 1); + assert.equal(progress(10, 10, 10), 0, '起点为 0'); + assert.equal(progress(20, 10, 10), 1, '终点为 1'); + assert.equal(progress(5, 10, 10), 0, '窗外前钳 0'); + assert.equal(progress(99, 10, 10), 1, '窗外后钳 1'); + assert.equal(win(0.5, [0.25, 0.75]), 0.5); + assert.equal(win(0.1, [0.25, 0.75]), 0); + assert.equal(win(0.9, [0.25, 0.75]), 1); + assert.equal(beatProgress(0, -30, 90), 1 / 3); +}); + +// ── schedule ────────────────────────────────────────────────────────── + +test('fit 模式:末项恰在窗口末完成、不外溢', () => { + const p = schedule(5, {dur: 10, fit: {total: 90}}); + const last = p.starts[4] + p.dur; + assert.ok(last <= 90 + 1, `末项 ${last} 外溢`); + assert.ok(last >= 89, `末项 ${last} 未到窗口末`); +}); + +test('fit 装不下时缩子项时长,不外溢窗口(优先级:不外溢 > 最小步长 > 子项时长)', () => { + // total 30 时 (30-12)/7≈2.57 ≥ minStride=2 仍装得下;压到 24 才触发缩时长 + const p = schedule(8, {dur: 12, fit: {total: 24}}); + const last = p.starts[7] + p.dur; + assert.ok(last <= 31, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); +}); + +test('stride 模式与三选一守卫', () => { + assert.deepEqual(schedule(3, {dur: 5, stride: 4}).starts, [0, 4, 8]); + assert.throws(() => schedule(3, {dur: 5, stride: 4, lag: 1})); + assert.throws(() => schedule(3, {dur: 5, lag: 1, fit: {total: 40}})); +}); + +test('最小步长与空集', () => { + assert.deepEqual(schedule(0, {dur: 5, stride: 4}), {starts: [], dur: 5}); + const p = schedule(2, {dur: 5, stride: 0}); // 非法步长 → 抬到下限 + assert.ok(p.starts[1] - p.starts[0] >= 1); +}); + +test('起点取整且单调不减', () => { + const p = schedule(6, {dur: 7, fit: {total: 53}}); + for (let i = 1; i < p.starts.length; i++) { + assert.ok(p.starts[i] >= p.starts[i - 1], '起点须单调不减'); + assert.ok(Number.isInteger(p.starts[i])); + } +}); diff --git a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/gallery.tsx b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/gallery.tsx new file mode 100644 index 000000000..ad2e067cf --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/gallery.tsx @@ -0,0 +1,265 @@ +/** MotionGallery——运动层的评审面(独立 Remotion 入口,不经 Root.tsx)。 + * + * 用法(在 video/ 目录,.bin 直调防污染根 workspace): + * ./node_modules/.bin/remotion still src/motion/gallery.tsx MotionGallery \ + * out/motion-gallery.png --frame=30 + * + * 全部模型 × 变体一屏可渲:秒级出图,供 token 校准轮逐格目视。 + * 色板为本文件内字面量(dev 工具面,不进成片、不读 theme——保持 frozen 跨系列共享)。 + */ +import React from 'react'; +import {AbsoluteFill, Composition, registerRoot} from 'remotion'; +import { + useAccelTravel, + useBreathe, + useCount, + useDim, + useDraw, + useEnter, + useFadeOut, + useFlowDash, + useImpulse, + usePushIn, + useReveal, + useShake, + useStagger, + useTravel, +} from './hooks'; + +const COLS = 5; +const CW = 360; +const CH = 240; +const GAP = 18; + +/** dev 工具面字面量色板(与各集 theme 底座同值但刻意独立声明——不读 theme)。 */ +const C = { + bg: '#0E1116', + panel: '#171C26', + border: '#2A3242', + text: '#F2F5FA', + dim: '#9AA7B8', + core: '#D97757', + mech: '#64C4C0', + deny: '#EF6461', +}; + +/** 单元格壳:定位 + 角标。 */ +const Cell: React.FC<{i: number; name: string; children: React.ReactNode}> = ({i, name, children}) => ( +
+
+ {children} +
+
+ {name} +
+
+); + +const Box: React.FC<{color?: string; w?: number; h?: number; style?: React.CSSProperties}> = ({ + color = C.core, + w = 200, + h = 80, + style, +}) =>
; + +// ── 每格一个组件:hooks 各归其位(Rules of Hooks 的最简守法形态) ──────── + +const EnterCell: React.FC<{kind: Parameters[0]}> = ({kind}) => { + const e = useEnter(kind, {}); + return ; +}; + +const StaggerCell: React.FC = () => { + const ps = useStagger(5, {dur: 5, fit: {total: 60}}); + return ( +
+ {ps.map((p, i) => ( +
+ ))} +
+ ); +}; + +const DrawCell: React.FC = () => { + const d = useDraw(0, 24); + return ( + + + + ); +}; + +const ImpulseCell: React.FC = () => { + const g = useImpulse({dur: 30, peak: 1}); + return ( +
+ ); +}; + +const BreatheCell: React.FC = () => { + const b = useBreathe({period: 30}); + return ( + + + + ); +}; + +const TravelCell: React.FC = () => { + const t = useTravel({cx: 140, cy: 55, r: 42, secPerLap: 3}); + return ( + + + + + ); +}; + +const AccelCell: React.FC = () => { + const t = useAccelTravel({cx: 140, cy: 55, r: 42, durs: [28, 20, 14], at: 4}); + const heat = `rgb(${217 + Math.round(38 * t.heat)}, ${119 - Math.round(60 * t.heat)}, ${87 - Math.round(20 * t.heat)})`; + return ( + + + + + ); +}; + +const CountCell: React.FC = () => { + const v = useCount({to: 255, dur: 40}); + return ( +
+ {Math.round(v)} +
+ ); +}; + +const RevealCell: React.FC = () => { + const s = useReveal('while (true) { think(); act(); }', {cps: 14}); + const blink = useBreathe({period: 16, amp: 0.5, base: 0.5}); + return ( +
+ {s} + +
+ ); +}; + +const PushInCell: React.FC = () => { + const t = usePushIn(0, {scale: 0.12}); + return ; +}; + +const DimCell: React.FC = () => { + const dim = useDim({at: 40, to: 0.35}); + return ( +
+ + + +
+ ); +}; + +const FlowCell: React.FC = () => { + const f = useFlowDash({period: 24}); + return ( + + + + ); +}; + +const ShakeCell: React.FC = () => { + const x = useShake({at: 10, amp: 5, decay: true, dur: 40}); + return ; +}; + +const FadeCell: React.FC = () => { + const op = useFadeOut(90, {frames: 36}); + return ( +
+ +
+
+ ); +}; + +const CELLS: Array<[string, React.ReactNode]> = [ + ['enter:fall', ], + ['enter:rise', ], + ['enter:slideL', ], + ['enter:pop', ], + ['enter:flyIn', ], + ['enter:fade', ], + ['stagger×5 fit60', ], + ['draw 24f', ], + ['impulse 30f', ], + ['breathe p30', ], + ['travel 3s/lap', ], + ['accelTravel', ], + ['count→255', ], + ['reveal 14cps', ], + ['pushIn .12', ], + ['dim .35@40', ], + ['flowDash p24', ], + ['shake decay', ], + ['fadeOut 36f', ], +]; + +const MotionGallery: React.FC = () => ( + +
+ MotionGallery · 30fps · 120f +
+ {CELLS.map(([name, node], i) => ( + + {node} + + ))} +
+); + +registerRoot(() => ( + +)); diff --git a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/hooks.ts b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/hooks.ts new file mode 100644 index 000000000..bfa87b05f --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/hooks.ts @@ -0,0 +1,268 @@ +/** 运动模型(hooks)——分镜「动效」列动词到帧数学的唯一映射。 + * + * 设计约束(违反任何一条即失去本层存在意义): + * 1. hooks 返回数值 / CSS 片段,不渲染 DOM——FadeUp 式包装组件打不进 svg// + * absolute 布局,是「组件存在却零调用」的实测根因;数值可落进任意 JSX。 + * 2. 弹簧一律吃局部帧(frame - at):spring() 每次调用从第 0 帧重模拟,喂全局帧 + * 会让长片末帧每个 spring 跑两万余次迭代。 + * 3. effects(不透明度/颜色)永不吃弹簧——一律时长+缓动(tokens 的二分不变量)。 + * 4. 不读 theme:颜色一律经参数传入(frozen 跨系列共享的前提)。 + * 5. `at` 锚点一律来自句边界(rel(beat, '句id')),禁写死帧数。 + */ +import {Easing, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + DUR, + EASING_CP, + SPRING, + clampRiseDist, + type DurToken, + type EasingToken, + type SpringPreset, +} from './tokens'; +import {progress} from './window'; +import {schedule, type ScheduleOpts} from './schedule'; + +/** 帧数解析:token 或直接帧数;缺省用 def。 */ +export const frames = (d: number | DurToken | undefined, def: number): number => + d === undefined ? def : typeof d === 'number' ? d : DUR[d]; + +/** 缓动令牌 → Remotion 缓动函数(linear 直通)。 */ +export const easeF = (t: EasingToken): ((x: number) => number) => + t === 'linear' + ? Easing.linear + : Easing.bezier( + ...(EASING_CP[t] as [number, number, number, number]), + ); + +/** 缓动后的 0..1 进度(各模型共用的原子)。 */ +const eased = ( + frame: number, + at: number, + dur: number, + e: EasingToken, +): number => interpolate(progress(frame, at, dur), [0, 1], [0, 1], {easing: easeF(e)}); + +// ── 入场(enter:落下/上浮/滑入/弹出/飞入/淡入) ──────────────────────── + +export type EnterKind = 'fall' | 'rise' | 'slideL' | 'slideR' | 'pop' | 'flyIn' | 'fade'; +export type EnterOpts = { + /** 句边界锚(局部帧)。 */ + at?: number; + dur?: number | DurToken; + easing?: EasingToken; + /** 空间通道用弹簧(位移类才有意义;fade/pop 无效)。 */ + springPreset?: SpringPreset; + /** 位移像素(fall/rise/slide*;缺省 30)。 */ + dist?: number; + /** rise 专用:落位态底边 y——行程经 clampRiseDist 钳进字幕安全带之上。 */ + restBottom?: number; +}; +export type EnterStyle = {opacity: number; transform: string}; + +export function useEnter(kind: EnterKind, o: EnterOpts = {}): EnterStyle { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const at = o.at ?? 0; + const dur = frames(o.dur, DUR.f4); + let dist = o.dist ?? 30; + if (kind === 'rise' && o.restBottom !== undefined) { + dist = clampRiseDist(dist, o.restBottom); + } + // effects 通道:纯缓动、略快于空间通道(元素先「看见」再「落位」) + const opacity = progress(frame, at, Math.max(2, Math.round(dur * 0.8))); + // spatial 通道:可选弹簧(局部帧 + durationInFrames 截停,防窗口外余振) + const p = o.springPreset + ? spring({ + frame: frame - at, + fps, + config: SPRING[o.springPreset], + durationInFrames: dur, + }) + : eased(frame, at, dur, o.easing ?? 'standard'); + const inv = 1 - p; + const t: string[] = []; + if (kind === 'fall') t.push(`translateY(${-inv * dist}px)`); + if (kind === 'rise') t.push(`translateY(${inv * dist}px)`); + if (kind === 'slideL') t.push(`translateX(${-inv * dist}px)`); + if (kind === 'slideR') t.push(`translateX(${inv * dist}px)`); + if (kind === 'pop') t.push(`scale(${0.9 + 0.1 * p})`); + if (kind === 'flyIn') t.push(`scale(${0.6 + 0.4 * p})`); + return {opacity, transform: t.length ? t.join(' ') : 'none'}; +} + +// ── 序列错峰(stagger:依次/逐行/逐条/逐格) ─────────────────────────── + +export type StaggerOpts = ScheduleOpts & {at?: number; easing?: EasingToken}; + +/** 返回 n 个 0..1 进度——第 i 项随编排依次入场。 */ +export function useStagger(n: number, o: StaggerOpts = {}): number[] { + const frame = useCurrentFrame(); + const {at = 0, easing = 'standard'} = o; + const plan = schedule(n, o); + return plan.starts.map((s) => eased(frame, at + s, plan.dur, easing)); +} + +// ── 描线(draw:红线三由构造保证——只产 pathLength 归一化三元组) ──────── + +export type DrawProps = {pathLength: 1; strokeDasharray: 1; strokeDashoffset: number}; + +export function useDraw(at: number, dur: number | DurToken = DUR.f5): DrawProps { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(dur, DUR.f5), 'decelerate'); + return {pathLength: 1, strokeDasharray: 1, strokeDashoffset: 1 - p}; +} + +// ── 脉冲 / 呼吸(glow 语法:impulse=一次性强调,breathe=持续辉光) ─────── + +/** 一次性冲击:sin(πp) 包络,起于 0 归于 0,峰值 peak。 */ +export function useImpulse(o: {at?: number; dur?: number | DurToken; peak?: number} = {}): number { + const frame = useCurrentFrame(); + const p = progress(frame, o.at ?? 0, frames(o.dur, DUR.f5)); + return Math.sin(Math.PI * p) * (o.peak ?? 1); +} + +/** 持续呼吸(原 0.55+0.45·sin(frame/K) 散写的收敛;period 帧一周期)。 */ +export function useBreathe(o: {period?: number; amp?: number; base?: number} = {}): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26} = o; + return base + amp * Math.sin((2 * Math.PI * frame) / period); +} + +// ── 巡游(travel:环形为主;absorb 原 useRingDot 与加速绕行累加器克隆) ── + +export type TravelPos = {x: number; y: number; angle: number}; + +/** 匀速环形巡游(angle 单位度,-90 = 12 点方向起)。 */ +export function useTravel(o: { + cx: number; + cy: number; + r: number; + secPerLap?: number; + offset?: number; +}): TravelPos { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const lap = (frame / (fps * (o.secPerLap ?? 2.5)) + (o.offset ?? 0)) % 1; + const a = -90 + lap * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), angle: a}; +} + +/** 加速绕行:逐圈时长 durs[](如 [40,30,22,16]),跑完全部圈后冻结在终点。 + * heat 0..1 随圈数推进(「失控感」的配色偏移系数)。 */ +export function useAccelTravel(o: { + cx: number; + cy: number; + r: number; + durs: number[]; + at?: number; + heatPerLap?: number; +}): {x: number; y: number; heat: number} { + const frame = useCurrentFrame(); + let t = Math.max(0, frame - (o.at ?? 0)); + let lap = 0; + while (lap < o.durs.length && t >= o.durs[lap]) { + t -= o.durs[lap]; + lap += 1; + } + const within = lap >= o.durs.length ? 1 : t / o.durs[lap]; + const heat = Math.min(1, lap / (o.heatPerLap ?? o.durs.length)); + const a = -90 + within * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), heat}; +} + +// ── 计数 / 水位(meter 语法;显示层自行 Math.round / toFixed) ────────── + +export function useCount(o: { + from?: number; + to: number; + at?: number; + dur?: number | DurToken; + ease?: EasingToken; +}): number { + const frame = useCurrentFrame(); + const {from = 0, to, at = 0} = o; + const p = eased(frame, at, frames(o.dur, DUR.f6), o.ease ?? 'standard'); + return from + (to - from) * p; +} + +// ── 打字机 / 逐字流出(type;Terminal 之外的泛化) ────────────────────── + +export function useReveal( + text: string, + o: {at?: number; cps?: number; framesPerChar?: number} = {}, +): string { + const frame = useCurrentFrame(); + const per = o.framesPerChar ?? Math.max(1, Math.round(30 / (o.cps ?? 12))); + const n = Math.floor(Math.max(0, frame - (o.at ?? 0)) / per); + return text.slice(0, Math.min(text.length, n)); +} + +// ── 镜头推近(pushIn 语法:beat 切换的镜头语言,替代纯淡入) ──────────── + +export function usePushIn(at: number, o: {scale?: number; dur?: number | DurToken} = {}): string { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(o.dur, DUR.f5), 'decelerate'); + return `scale(${1 + (o.scale ?? 0.06) * p})`; +} + +// ── 压暗 / 提亮(emphasis 反向:让主体从群像中浮出) ───────────────────── + +/** 返回目标透明度系数(1 = 原;to 0.4 即压暗到 40%)。 */ +export function useDim(o: {at: number; to?: number; dur?: number | DurToken}): number { + const frame = useCurrentFrame(); + const p = eased(frame, o.at, frames(o.dur, DUR.f4), 'standard'); + return 1 + ((o.to ?? 0.4) - 1) * p; +} + +// ── 流光(flow 语法:连线上的行进虚线) ───────────────────────────────── + +/** 返回可直接展开到 / 的描边属性(像素 dasharray——与 draw 的 + * pathLength 归一化描线是两个正交特性,勿混用于同一元素:红线三)。 */ +export function useFlowDash(o: { + dash?: number; + gap?: number; + /** 帧速率:每 period 帧行进一个 dash+gap 周期。 */ + period?: number; +}): {strokeDasharray: string; strokeDashoffset: number} { + const frame = useCurrentFrame(); + const {dash = 10, gap = 14, period = 40} = o; + return { + strokeDasharray: `${dash} ${gap}`, + strokeDashoffset: -(frame * (dash + gap)) / period, + }; +} + +// ── 抖动(错误/故障语义;收敛 P2/P3 两处克隆) ────────────────────────── + +/** 返回 translateX 像素值。active 缺省 true;decay=true 时按 dur 衰减归零。 */ +export function useShake(o: { + at: number; + active?: boolean; + amp?: number; + /** 相位分母(原手写 /1.6、/2.2 的口径)。 */ + freq?: number; + decay?: boolean; + dur?: number | DurToken; +}): number { + const frame = useCurrentFrame(); + const {amp = 3, freq = 1.6} = o; + const t = frame - o.at; + if (o.active === false || t < 0) { + return 0; + } + if (o.decay) { + const env = 1 - progress(frame, o.at, frames(o.dur, DUR.f5)); + return amp * env * Math.sin(t / freq); + } + return amp * Math.sin(t / freq); +} + +// ── 片尾渐黑(红线四:从 beat 总时长推导,勿用末句时长) ──────────────── + +export function useFadeOut(durationInFrames: number, o: {frames?: number} = {}): number { + const frame = useCurrentFrame(); + const f = o.frames ?? 36; // 1.2s + return 1 - progress(frame, durationInFrames - f, f); +} diff --git a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/index.ts b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/index.ts new file mode 100644 index 000000000..97fd22a5c --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/index.ts @@ -0,0 +1,5 @@ +/** 运动层门面——场景代码统一 `import {...} from '../motion'`。 */ +export * from './tokens'; +export * from './window'; +export * from './schedule'; +export * from './hooks'; diff --git a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/schedule.ts b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/schedule.ts new file mode 100644 index 000000000..0aa1ebfa4 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/schedule.ts @@ -0,0 +1,59 @@ +/** 错峰编排(stagger)纯函数——收敛手写 `frame - i*N`(跨 8 集约 356 处、 + * 步长 K∈{2,3,4,5,6,8,10} 任意取值、无节奏标尺)。 + * + * 三种模式(互斥,参数即文档): + * - stride:固定步长(与旧手写行为一致——迁移期的保守选项); + * - lag:Manim LaggedStart 语义 start[i] = i·dur·lagRatio(0 = 同刻齐动,1 = 首尾相接); + * - fit:n 个子项恰好装进窗口 total(末项恰在窗口末完成——「随句推进」的首选)。 + * + * 钳制优先级(motion.test.ts 钉死,高者让位低者):不外溢窗口 > 最小步长 > 子项时长。 + * 窗口属于 beat 时间轴,外溢会踩进下一 beat;装不下时**缩子项时长**,不延窗口。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点最小间隔,默认 2 帧(30fps 下仍可辨先后)。 */ + minStride?: number; + /** 子项时长下限,默认 3 帧(低于此相当于瞬现)。 */ + minDur?: number; +}; +export type Schedule = {starts: number[]; dur: number}; + +export function schedule(n: number, o: ScheduleOpts): Schedule { + const minStride = o.minStride ?? 2; + const minDur = o.minDur ?? 3; + const modes = [o.stride !== undefined, o.lag !== undefined, o.fit !== undefined].filter( + Boolean, + ).length; + if (modes > 1) { + throw new Error('schedule: stride / lag / fit 三选一'); + } + let dur = Math.max(minDur, o.dur ?? 5); + if (n <= 0) { + return {starts: [], dur}; + } + let stride: number; + if (o.fit) { + const total = Math.max(1, o.fit.total); + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + if (n > 1 && stride < minStride) { + // 装不下:缩子项时长换最小步长(保先后可辨),仍不外溢 + dur = Math.max(minDur, total - minStride * (n - 1)); + dur = Math.max(1, Math.min(dur, total)); + stride = Math.max(1, (total - dur) / (n - 1)); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 ? 0 : 1, stride); + return {starts: Array.from({length: n}, (_, i) => Math.round(i * stride)), dur}; +} diff --git a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/tokens.ts b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/tokens.ts new file mode 100644 index 000000000..457448260 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/tokens.ts @@ -0,0 +1,87 @@ +/** 运动令牌——时长标尺 / 缓动曲线 / 弹簧手感 的单一事实源。 + * + * 只放纯数据与纯函数:不 import remotion、不读 theme(颜色一律经参数注入)—— + * 这是本层能以 frozen 档跨两个系列共享的前提(两系列 theme token 名已分叉: + * CC 用 core/mech/deny、SE 用 danger),判据同 test_chrome_motifs_only_read_base_theme_tokens。 + * + * 取值依据(勿凭感觉改;改前先在本集校准轮逐幕目视复测,依据写回此处注释): + * - 时长六档取 IBM Carbon DTCG(70/110/150/240/400/700ms)@30fps 四舍五入。 + * 弃 Material 十六档:30fps 量化下其 15 个相邻对里 6 对落进同一帧数(伪选择)。 + * - 缓动控制点取 Material 3 标准三件;曲线本体在 hooks.ts 经 Remotion Easing.bezier 求值。 + * - 弹簧预设锚定本仓实测手感:settle=200 即既有 9/10 调用点的惯用值(延续成片观感), + * snap=12 来自 P4「插头咬合」的过冲;ζ 与过冲峰值的关系由 motion.test.ts 用 + * Mp = exp(-πζ/√(1-ζ²)) 钉死。 + * - effects 不变量:不透明度/颜色永不过冲——effects 类动画一律时长+缓动, + * 弹簧只用于空间位移(M3 spatial/effects 二分的落地)。 + */ + +/** 时长标尺(帧 @30fps)。叙事节拍(4–8s)不用此表——那是 window/schedule 的职责。 */ +export const DUR = { + /** 70ms:微反馈(辉光起点、光标) */ + f1: 2, + /** 110ms:快速子项(列表错峰的单项时长) */ + f2: 3, + /** 150ms:标准入场 */ + f3: 5, + /** 200ms:强调入场 */ + f4: 7, + /** 400ms:大位移 / 镜头推近 / 描线 */ + f5: 12, + /** 700ms:幕级大动作(少用) */ + f6: 21, +} as const; +export type DurToken = keyof typeof DUR; + +/** 缓动令牌。 */ +export type EasingToken = 'standard' | 'decelerate' | 'accelerate' | 'linear'; + +/** 贝塞尔控制点(x1,y1,x2,y2);linear 无控制点。 */ +export const EASING_CP: Record< + Exclude, + readonly [number, number, number, number] +> = { + // M3 standard:入场默认 + standard: [0.2, 0, 0, 1], + // M3 decelerate:强减速(大位移入场、镜头推近) + decelerate: [0.05, 0.7, 0.1, 1], + // M3 accelerate:出场加速 + accelerate: [0.3, 0, 0.8, 0.15], +}; + +/** 弹簧预设(直传 Remotion spring config;ζ = c/(2√(k·m)))。 */ +export type SpringPreset = 'settle' | 'settleSoft' | 'snap'; +export const SPRING: Record = { + // ζ≈10:无过冲平滑滑入——本仓主流手感(既有场景 9/10 处 damping 200) + settle: {damping: 200, stiffness: 100, mass: 1}, + // ζ≈8.5:更绵一点(P0/P1 既有 180/170 档的收敛) + settleSoft: {damping: 170, stiffness: 100, mass: 1}, + // ζ≈0.6:轻微过冲(咬合/弹入——原 P4 damping 12) + snap: {damping: 12, stiffness: 100, mass: 1}, +}; + +/** 阻尼比 ζ。设计系统文档普遍给 ζ(无量纲),Remotion 取阻尼系数 c——直抄会得 + * ζ≈0.02 的暴力弹跳且能通过渲染体检,这是迁移期最高风险项(单测钉死)。 */ +export const dampingRatio = (s: { + damping: number; + stiffness: number; + mass: number; +}): number => s.damping / (2 * Math.sqrt(s.stiffness * s.mass)); + +/** 欠阻尼弹簧的峰值位置(1 = 恰好到终点不过冲;ζ ≥ 1 恒 1)。 + * Mp = exp(-πζ/√(1-ζ²)) 是超出终点的幅度,峰值 = 1 + Mp。 */ +export const overshootPeak = (zeta: number): number => + zeta >= 1 ? 1 : 1 + Math.exp((-Math.PI * zeta) / Math.sqrt(1 - zeta * zeta)); + +/** 出场快于入场的系数(MDC 实测 400ms 入 / 150ms 出 ≈ 0.375,取 0.4 禁手填)。 */ +export const EXIT_FACTOR = 0.4; + +/** 字幕安全带上沿:1080 - qa_frames.SUBTITLE_BAND_PX(160),与体检口径同源。 */ +export const SAFE_TOP_Y = 920; + +/** 自下方入场的行程安全钳制:落位态底边 restBottom 之上才是可用的进场空间。 + * ISSUE-170 的手工逐卡反算收敛于此——该缺陷类从「评审抽帧抓」变「构造不可能」。 */ +export const clampRiseDist = ( + dist: number, + restBottom: number, + safeTop: number = SAFE_TOP_Y, +): number => Math.max(0, Math.min(dist, safeTop - restBottom)); diff --git a/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/window.ts b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/window.ts new file mode 100644 index 000000000..5d8e5c016 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/window.ts @@ -0,0 +1,28 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 audio-first 时序与可复用运动模型兼容的核心机制:beat 的绝对帧来自 + * beatWindow()(数据源是 TTS 实测 manifest),子动画不写死帧数、只声明自己在 + * beat 进度上的 [start, end] 窗口 ⇒ 旁白实测时长变化时全部窗口自动重定时, + * 「写死帧数与口播脱钩」缺陷类(skills/08 实录)由构造消灭。 + * + * 语义借 MDC TransitionUtils.lerp(startFraction, endFraction):窗外钳制端点。 + * 本模块零依赖(不 import remotion / theme)——frozen 跨系列共享与 node 单测的前提。 + */ + +/** 钳制到 [0,1]——一切进度的唯一出口,防负值/超 1 渗进 transform。 */ +export const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x); + +/** 子动画窗口:父进度 p(0..1) 在 [s,e] 片段上的局部进度。 */ +export const win = (p: number, w: readonly [number, number]): number => + clamp01((p - w[0]) / (w[1] - w[0])); + +/** beat 进度:局部帧 → 该 beat 的 0..1 进度。 */ +export const beatProgress = ( + frame: number, + from: number, + durationInFrames: number, +): number => clamp01((frame - from) / Math.max(1, durationInFrames)); + +/** 帧域进度:[at, at+dur] 上的 0..1(dur ≤ 0 视作 1,防除零)。 */ +export const progress = (frame: number, at: number, dur: number): number => + clamp01((frame - at) / Math.max(1, dur)); diff --git a/apps/negentropy-influence/episodes/claude-code-planning-video/video/scripts/motion.test.ts b/apps/negentropy-influence/episodes/claude-code-planning-video/video/scripts/motion.test.ts new file mode 100644 index 000000000..4a55380f7 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/scripts/motion.test.ts @@ -0,0 +1,123 @@ +/** + * 运动层纯函数单测——`node --test scripts/motion.test.ts`(Node ≥ 23.6 原生跑 TS)。 + * + * 刻意放在 video/ 而非 src/:tsconfig include 只有 src(frozen,不为测试改动), + * 而本文件 import 需带 .ts 后缀(Node ESM 解析规则)——tsc 若收编会因 + * allowImportingTsExtensions 未开而报错。src/ 的类型覆盖由 tsc --noEmit 全量保证, + * 本文件只测纯函数行为,不做类型承重。 + * + * 只 import 纯模块(tokens/window/schedule——零 remotion/react 依赖): + * hooks 是它们的薄包装,行为由 MotionGallery 目视 + 场景抽帧覆盖。 + */ +import {strict as assert} from 'node:assert'; +import test from 'node:test'; + +import {DUR, EASING_CP, EXIT_FACTOR, SAFE_TOP_Y, SPRING, clampRiseDist, dampingRatio, overshootPeak} from '../src/motion/tokens.ts'; +import {beatProgress, clamp01, progress, win} from '../src/motion/window.ts'; +import {schedule} from '../src/motion/schedule.ts'; + +// ── tokens ──────────────────────────────────────────────────────────── + +test('时长标尺在 30fps 下相邻档可辨(≥1 帧差)', () => { + const v = Object.values(DUR) as number[]; + for (let i = 1; i < v.length; i++) { + assert.ok(v[i] - v[i - 1] >= 1, `第 ${i} 档与前一档同帧数(伪选择)`); + } + assert.ok(v.length === 6); +}); + +test('ζ→过冲换算钉死:snap 轻过冲、settle 零过冲(直抄 dampingRatio 的反例护栏)', () => { + const zSnap = dampingRatio(SPRING.snap); + const zSettle = dampingRatio(SPRING.settle); + assert.ok(zSnap > 0.4 && zSnap < 0.9, `snap ζ=${zSnap}`); + // Mp = exp(-πζ/√(1-ζ²)) 是超出幅度;峰值 = 1 + Mp(ζ=0.6 → 峰值 ≈1.095) + assert.ok(overshootPeak(zSnap) > 1.02 && overshootPeak(zSnap) < 1.2, `snap 峰值 ${overshootPeak(zSnap)}`); + assert.ok(zSettle > 1, 'settle 须过阻尼'); + assert.equal(overshootPeak(zSettle), 1); + // 反例:把设计系统的 ζ 当 damping 直填(0.8)→ ζ≈0.04、峰值≈1.88 暴力弹跳 + const wrong = dampingRatio({damping: 0.8, stiffness: 100, mass: 1}); + assert.ok(overshootPeak(wrong) > 1.8, `直抄 ζ 的峰值=${overshootPeak(wrong)},必须被此断言抓住`); +}); + +test('缓动控制点合法(CSS 规则 x∈[0,1],且 x(t) 数值单调——可作函数求值)', () => { + for (const cp of Object.values(EASING_CP)) { + const [x1, , x2] = cp; + assert.ok(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1, `x 越界:${cp}`); + // x1 -1e-9, `x(t) 非单调 @t=${t}:${cp}`); + } + } +}); + +test('出场快于入场系数与安全带口径为常量', () => { + assert.equal(EXIT_FACTOR, 0.4); + assert.equal(SAFE_TOP_Y, 920); +}); + +test('clampRiseDist:自下方入场行程不探进字幕安全带', () => { + assert.equal(clampRiseDist(120, 836), 84, 'ISSUE-170 实测几何:rest 836 → 行程封顶 84'); + assert.equal(clampRiseDist(40, 836), 40, '未超限不动'); + assert.equal(clampRiseDist(120, 960), 0, '落位已在安全带内 → 零行程(退化但安全)'); +}); + +// ── window ──────────────────────────────────────────────────────────── + +test('clamp01 / progress / win 的钳制语义', () => { + assert.equal(clamp01(-1), 0); + assert.equal(clamp01(2), 1); + assert.equal(progress(10, 10, 10), 0, '起点为 0'); + assert.equal(progress(20, 10, 10), 1, '终点为 1'); + assert.equal(progress(5, 10, 10), 0, '窗外前钳 0'); + assert.equal(progress(99, 10, 10), 1, '窗外后钳 1'); + assert.equal(win(0.5, [0.25, 0.75]), 0.5); + assert.equal(win(0.1, [0.25, 0.75]), 0); + assert.equal(win(0.9, [0.25, 0.75]), 1); + assert.equal(beatProgress(0, -30, 90), 1 / 3); +}); + +// ── schedule ────────────────────────────────────────────────────────── + +test('fit 模式:末项恰在窗口末完成、不外溢', () => { + const p = schedule(5, {dur: 10, fit: {total: 90}}); + const last = p.starts[4] + p.dur; + assert.ok(last <= 90 + 1, `末项 ${last} 外溢`); + assert.ok(last >= 89, `末项 ${last} 未到窗口末`); +}); + +test('fit 装不下时缩子项时长,不外溢窗口(优先级:不外溢 > 最小步长 > 子项时长)', () => { + // total 30 时 (30-12)/7≈2.57 ≥ minStride=2 仍装得下;压到 24 才触发缩时长 + const p = schedule(8, {dur: 12, fit: {total: 24}}); + const last = p.starts[7] + p.dur; + assert.ok(last <= 31, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); +}); + +test('stride 模式与三选一守卫', () => { + assert.deepEqual(schedule(3, {dur: 5, stride: 4}).starts, [0, 4, 8]); + assert.throws(() => schedule(3, {dur: 5, stride: 4, lag: 1})); + assert.throws(() => schedule(3, {dur: 5, lag: 1, fit: {total: 40}})); +}); + +test('最小步长与空集', () => { + assert.deepEqual(schedule(0, {dur: 5, stride: 4}), {starts: [], dur: 5}); + const p = schedule(2, {dur: 5, stride: 0}); // 非法步长 → 抬到下限 + assert.ok(p.starts[1] - p.starts[0] >= 1); +}); + +test('起点取整且单调不减', () => { + const p = schedule(6, {dur: 7, fit: {total: 53}}); + for (let i = 1; i < p.starts.length; i++) { + assert.ok(p.starts[i] >= p.starts[i - 1], '起点须单调不减'); + assert.ok(Number.isInteger(p.starts[i])); + } +}); diff --git a/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/gallery.tsx b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/gallery.tsx new file mode 100644 index 000000000..ad2e067cf --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/gallery.tsx @@ -0,0 +1,265 @@ +/** MotionGallery——运动层的评审面(独立 Remotion 入口,不经 Root.tsx)。 + * + * 用法(在 video/ 目录,.bin 直调防污染根 workspace): + * ./node_modules/.bin/remotion still src/motion/gallery.tsx MotionGallery \ + * out/motion-gallery.png --frame=30 + * + * 全部模型 × 变体一屏可渲:秒级出图,供 token 校准轮逐格目视。 + * 色板为本文件内字面量(dev 工具面,不进成片、不读 theme——保持 frozen 跨系列共享)。 + */ +import React from 'react'; +import {AbsoluteFill, Composition, registerRoot} from 'remotion'; +import { + useAccelTravel, + useBreathe, + useCount, + useDim, + useDraw, + useEnter, + useFadeOut, + useFlowDash, + useImpulse, + usePushIn, + useReveal, + useShake, + useStagger, + useTravel, +} from './hooks'; + +const COLS = 5; +const CW = 360; +const CH = 240; +const GAP = 18; + +/** dev 工具面字面量色板(与各集 theme 底座同值但刻意独立声明——不读 theme)。 */ +const C = { + bg: '#0E1116', + panel: '#171C26', + border: '#2A3242', + text: '#F2F5FA', + dim: '#9AA7B8', + core: '#D97757', + mech: '#64C4C0', + deny: '#EF6461', +}; + +/** 单元格壳:定位 + 角标。 */ +const Cell: React.FC<{i: number; name: string; children: React.ReactNode}> = ({i, name, children}) => ( +
+
+ {children} +
+
+ {name} +
+
+); + +const Box: React.FC<{color?: string; w?: number; h?: number; style?: React.CSSProperties}> = ({ + color = C.core, + w = 200, + h = 80, + style, +}) =>
; + +// ── 每格一个组件:hooks 各归其位(Rules of Hooks 的最简守法形态) ──────── + +const EnterCell: React.FC<{kind: Parameters[0]}> = ({kind}) => { + const e = useEnter(kind, {}); + return ; +}; + +const StaggerCell: React.FC = () => { + const ps = useStagger(5, {dur: 5, fit: {total: 60}}); + return ( +
+ {ps.map((p, i) => ( +
+ ))} +
+ ); +}; + +const DrawCell: React.FC = () => { + const d = useDraw(0, 24); + return ( + + + + ); +}; + +const ImpulseCell: React.FC = () => { + const g = useImpulse({dur: 30, peak: 1}); + return ( +
+ ); +}; + +const BreatheCell: React.FC = () => { + const b = useBreathe({period: 30}); + return ( + + + + ); +}; + +const TravelCell: React.FC = () => { + const t = useTravel({cx: 140, cy: 55, r: 42, secPerLap: 3}); + return ( + + + + + ); +}; + +const AccelCell: React.FC = () => { + const t = useAccelTravel({cx: 140, cy: 55, r: 42, durs: [28, 20, 14], at: 4}); + const heat = `rgb(${217 + Math.round(38 * t.heat)}, ${119 - Math.round(60 * t.heat)}, ${87 - Math.round(20 * t.heat)})`; + return ( + + + + + ); +}; + +const CountCell: React.FC = () => { + const v = useCount({to: 255, dur: 40}); + return ( +
+ {Math.round(v)} +
+ ); +}; + +const RevealCell: React.FC = () => { + const s = useReveal('while (true) { think(); act(); }', {cps: 14}); + const blink = useBreathe({period: 16, amp: 0.5, base: 0.5}); + return ( +
+ {s} + +
+ ); +}; + +const PushInCell: React.FC = () => { + const t = usePushIn(0, {scale: 0.12}); + return ; +}; + +const DimCell: React.FC = () => { + const dim = useDim({at: 40, to: 0.35}); + return ( +
+ + + +
+ ); +}; + +const FlowCell: React.FC = () => { + const f = useFlowDash({period: 24}); + return ( + + + + ); +}; + +const ShakeCell: React.FC = () => { + const x = useShake({at: 10, amp: 5, decay: true, dur: 40}); + return ; +}; + +const FadeCell: React.FC = () => { + const op = useFadeOut(90, {frames: 36}); + return ( +
+ +
+
+ ); +}; + +const CELLS: Array<[string, React.ReactNode]> = [ + ['enter:fall', ], + ['enter:rise', ], + ['enter:slideL', ], + ['enter:pop', ], + ['enter:flyIn', ], + ['enter:fade', ], + ['stagger×5 fit60', ], + ['draw 24f', ], + ['impulse 30f', ], + ['breathe p30', ], + ['travel 3s/lap', ], + ['accelTravel', ], + ['count→255', ], + ['reveal 14cps', ], + ['pushIn .12', ], + ['dim .35@40', ], + ['flowDash p24', ], + ['shake decay', ], + ['fadeOut 36f', ], +]; + +const MotionGallery: React.FC = () => ( + +
+ MotionGallery · 30fps · 120f +
+ {CELLS.map(([name, node], i) => ( + + {node} + + ))} +
+); + +registerRoot(() => ( + +)); diff --git a/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/hooks.ts b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/hooks.ts new file mode 100644 index 000000000..bfa87b05f --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/hooks.ts @@ -0,0 +1,268 @@ +/** 运动模型(hooks)——分镜「动效」列动词到帧数学的唯一映射。 + * + * 设计约束(违反任何一条即失去本层存在意义): + * 1. hooks 返回数值 / CSS 片段,不渲染 DOM——FadeUp 式包装组件打不进 svg// + * absolute 布局,是「组件存在却零调用」的实测根因;数值可落进任意 JSX。 + * 2. 弹簧一律吃局部帧(frame - at):spring() 每次调用从第 0 帧重模拟,喂全局帧 + * 会让长片末帧每个 spring 跑两万余次迭代。 + * 3. effects(不透明度/颜色)永不吃弹簧——一律时长+缓动(tokens 的二分不变量)。 + * 4. 不读 theme:颜色一律经参数传入(frozen 跨系列共享的前提)。 + * 5. `at` 锚点一律来自句边界(rel(beat, '句id')),禁写死帧数。 + */ +import {Easing, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + DUR, + EASING_CP, + SPRING, + clampRiseDist, + type DurToken, + type EasingToken, + type SpringPreset, +} from './tokens'; +import {progress} from './window'; +import {schedule, type ScheduleOpts} from './schedule'; + +/** 帧数解析:token 或直接帧数;缺省用 def。 */ +export const frames = (d: number | DurToken | undefined, def: number): number => + d === undefined ? def : typeof d === 'number' ? d : DUR[d]; + +/** 缓动令牌 → Remotion 缓动函数(linear 直通)。 */ +export const easeF = (t: EasingToken): ((x: number) => number) => + t === 'linear' + ? Easing.linear + : Easing.bezier( + ...(EASING_CP[t] as [number, number, number, number]), + ); + +/** 缓动后的 0..1 进度(各模型共用的原子)。 */ +const eased = ( + frame: number, + at: number, + dur: number, + e: EasingToken, +): number => interpolate(progress(frame, at, dur), [0, 1], [0, 1], {easing: easeF(e)}); + +// ── 入场(enter:落下/上浮/滑入/弹出/飞入/淡入) ──────────────────────── + +export type EnterKind = 'fall' | 'rise' | 'slideL' | 'slideR' | 'pop' | 'flyIn' | 'fade'; +export type EnterOpts = { + /** 句边界锚(局部帧)。 */ + at?: number; + dur?: number | DurToken; + easing?: EasingToken; + /** 空间通道用弹簧(位移类才有意义;fade/pop 无效)。 */ + springPreset?: SpringPreset; + /** 位移像素(fall/rise/slide*;缺省 30)。 */ + dist?: number; + /** rise 专用:落位态底边 y——行程经 clampRiseDist 钳进字幕安全带之上。 */ + restBottom?: number; +}; +export type EnterStyle = {opacity: number; transform: string}; + +export function useEnter(kind: EnterKind, o: EnterOpts = {}): EnterStyle { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const at = o.at ?? 0; + const dur = frames(o.dur, DUR.f4); + let dist = o.dist ?? 30; + if (kind === 'rise' && o.restBottom !== undefined) { + dist = clampRiseDist(dist, o.restBottom); + } + // effects 通道:纯缓动、略快于空间通道(元素先「看见」再「落位」) + const opacity = progress(frame, at, Math.max(2, Math.round(dur * 0.8))); + // spatial 通道:可选弹簧(局部帧 + durationInFrames 截停,防窗口外余振) + const p = o.springPreset + ? spring({ + frame: frame - at, + fps, + config: SPRING[o.springPreset], + durationInFrames: dur, + }) + : eased(frame, at, dur, o.easing ?? 'standard'); + const inv = 1 - p; + const t: string[] = []; + if (kind === 'fall') t.push(`translateY(${-inv * dist}px)`); + if (kind === 'rise') t.push(`translateY(${inv * dist}px)`); + if (kind === 'slideL') t.push(`translateX(${-inv * dist}px)`); + if (kind === 'slideR') t.push(`translateX(${inv * dist}px)`); + if (kind === 'pop') t.push(`scale(${0.9 + 0.1 * p})`); + if (kind === 'flyIn') t.push(`scale(${0.6 + 0.4 * p})`); + return {opacity, transform: t.length ? t.join(' ') : 'none'}; +} + +// ── 序列错峰(stagger:依次/逐行/逐条/逐格) ─────────────────────────── + +export type StaggerOpts = ScheduleOpts & {at?: number; easing?: EasingToken}; + +/** 返回 n 个 0..1 进度——第 i 项随编排依次入场。 */ +export function useStagger(n: number, o: StaggerOpts = {}): number[] { + const frame = useCurrentFrame(); + const {at = 0, easing = 'standard'} = o; + const plan = schedule(n, o); + return plan.starts.map((s) => eased(frame, at + s, plan.dur, easing)); +} + +// ── 描线(draw:红线三由构造保证——只产 pathLength 归一化三元组) ──────── + +export type DrawProps = {pathLength: 1; strokeDasharray: 1; strokeDashoffset: number}; + +export function useDraw(at: number, dur: number | DurToken = DUR.f5): DrawProps { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(dur, DUR.f5), 'decelerate'); + return {pathLength: 1, strokeDasharray: 1, strokeDashoffset: 1 - p}; +} + +// ── 脉冲 / 呼吸(glow 语法:impulse=一次性强调,breathe=持续辉光) ─────── + +/** 一次性冲击:sin(πp) 包络,起于 0 归于 0,峰值 peak。 */ +export function useImpulse(o: {at?: number; dur?: number | DurToken; peak?: number} = {}): number { + const frame = useCurrentFrame(); + const p = progress(frame, o.at ?? 0, frames(o.dur, DUR.f5)); + return Math.sin(Math.PI * p) * (o.peak ?? 1); +} + +/** 持续呼吸(原 0.55+0.45·sin(frame/K) 散写的收敛;period 帧一周期)。 */ +export function useBreathe(o: {period?: number; amp?: number; base?: number} = {}): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26} = o; + return base + amp * Math.sin((2 * Math.PI * frame) / period); +} + +// ── 巡游(travel:环形为主;absorb 原 useRingDot 与加速绕行累加器克隆) ── + +export type TravelPos = {x: number; y: number; angle: number}; + +/** 匀速环形巡游(angle 单位度,-90 = 12 点方向起)。 */ +export function useTravel(o: { + cx: number; + cy: number; + r: number; + secPerLap?: number; + offset?: number; +}): TravelPos { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const lap = (frame / (fps * (o.secPerLap ?? 2.5)) + (o.offset ?? 0)) % 1; + const a = -90 + lap * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), angle: a}; +} + +/** 加速绕行:逐圈时长 durs[](如 [40,30,22,16]),跑完全部圈后冻结在终点。 + * heat 0..1 随圈数推进(「失控感」的配色偏移系数)。 */ +export function useAccelTravel(o: { + cx: number; + cy: number; + r: number; + durs: number[]; + at?: number; + heatPerLap?: number; +}): {x: number; y: number; heat: number} { + const frame = useCurrentFrame(); + let t = Math.max(0, frame - (o.at ?? 0)); + let lap = 0; + while (lap < o.durs.length && t >= o.durs[lap]) { + t -= o.durs[lap]; + lap += 1; + } + const within = lap >= o.durs.length ? 1 : t / o.durs[lap]; + const heat = Math.min(1, lap / (o.heatPerLap ?? o.durs.length)); + const a = -90 + within * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), heat}; +} + +// ── 计数 / 水位(meter 语法;显示层自行 Math.round / toFixed) ────────── + +export function useCount(o: { + from?: number; + to: number; + at?: number; + dur?: number | DurToken; + ease?: EasingToken; +}): number { + const frame = useCurrentFrame(); + const {from = 0, to, at = 0} = o; + const p = eased(frame, at, frames(o.dur, DUR.f6), o.ease ?? 'standard'); + return from + (to - from) * p; +} + +// ── 打字机 / 逐字流出(type;Terminal 之外的泛化) ────────────────────── + +export function useReveal( + text: string, + o: {at?: number; cps?: number; framesPerChar?: number} = {}, +): string { + const frame = useCurrentFrame(); + const per = o.framesPerChar ?? Math.max(1, Math.round(30 / (o.cps ?? 12))); + const n = Math.floor(Math.max(0, frame - (o.at ?? 0)) / per); + return text.slice(0, Math.min(text.length, n)); +} + +// ── 镜头推近(pushIn 语法:beat 切换的镜头语言,替代纯淡入) ──────────── + +export function usePushIn(at: number, o: {scale?: number; dur?: number | DurToken} = {}): string { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(o.dur, DUR.f5), 'decelerate'); + return `scale(${1 + (o.scale ?? 0.06) * p})`; +} + +// ── 压暗 / 提亮(emphasis 反向:让主体从群像中浮出) ───────────────────── + +/** 返回目标透明度系数(1 = 原;to 0.4 即压暗到 40%)。 */ +export function useDim(o: {at: number; to?: number; dur?: number | DurToken}): number { + const frame = useCurrentFrame(); + const p = eased(frame, o.at, frames(o.dur, DUR.f4), 'standard'); + return 1 + ((o.to ?? 0.4) - 1) * p; +} + +// ── 流光(flow 语法:连线上的行进虚线) ───────────────────────────────── + +/** 返回可直接展开到 / 的描边属性(像素 dasharray——与 draw 的 + * pathLength 归一化描线是两个正交特性,勿混用于同一元素:红线三)。 */ +export function useFlowDash(o: { + dash?: number; + gap?: number; + /** 帧速率:每 period 帧行进一个 dash+gap 周期。 */ + period?: number; +}): {strokeDasharray: string; strokeDashoffset: number} { + const frame = useCurrentFrame(); + const {dash = 10, gap = 14, period = 40} = o; + return { + strokeDasharray: `${dash} ${gap}`, + strokeDashoffset: -(frame * (dash + gap)) / period, + }; +} + +// ── 抖动(错误/故障语义;收敛 P2/P3 两处克隆) ────────────────────────── + +/** 返回 translateX 像素值。active 缺省 true;decay=true 时按 dur 衰减归零。 */ +export function useShake(o: { + at: number; + active?: boolean; + amp?: number; + /** 相位分母(原手写 /1.6、/2.2 的口径)。 */ + freq?: number; + decay?: boolean; + dur?: number | DurToken; +}): number { + const frame = useCurrentFrame(); + const {amp = 3, freq = 1.6} = o; + const t = frame - o.at; + if (o.active === false || t < 0) { + return 0; + } + if (o.decay) { + const env = 1 - progress(frame, o.at, frames(o.dur, DUR.f5)); + return amp * env * Math.sin(t / freq); + } + return amp * Math.sin(t / freq); +} + +// ── 片尾渐黑(红线四:从 beat 总时长推导,勿用末句时长) ──────────────── + +export function useFadeOut(durationInFrames: number, o: {frames?: number} = {}): number { + const frame = useCurrentFrame(); + const f = o.frames ?? 36; // 1.2s + return 1 - progress(frame, durationInFrames - f, f); +} diff --git a/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/index.ts b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/index.ts new file mode 100644 index 000000000..97fd22a5c --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/index.ts @@ -0,0 +1,5 @@ +/** 运动层门面——场景代码统一 `import {...} from '../motion'`。 */ +export * from './tokens'; +export * from './window'; +export * from './schedule'; +export * from './hooks'; diff --git a/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/schedule.ts b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/schedule.ts new file mode 100644 index 000000000..0aa1ebfa4 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/schedule.ts @@ -0,0 +1,59 @@ +/** 错峰编排(stagger)纯函数——收敛手写 `frame - i*N`(跨 8 集约 356 处、 + * 步长 K∈{2,3,4,5,6,8,10} 任意取值、无节奏标尺)。 + * + * 三种模式(互斥,参数即文档): + * - stride:固定步长(与旧手写行为一致——迁移期的保守选项); + * - lag:Manim LaggedStart 语义 start[i] = i·dur·lagRatio(0 = 同刻齐动,1 = 首尾相接); + * - fit:n 个子项恰好装进窗口 total(末项恰在窗口末完成——「随句推进」的首选)。 + * + * 钳制优先级(motion.test.ts 钉死,高者让位低者):不外溢窗口 > 最小步长 > 子项时长。 + * 窗口属于 beat 时间轴,外溢会踩进下一 beat;装不下时**缩子项时长**,不延窗口。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点最小间隔,默认 2 帧(30fps 下仍可辨先后)。 */ + minStride?: number; + /** 子项时长下限,默认 3 帧(低于此相当于瞬现)。 */ + minDur?: number; +}; +export type Schedule = {starts: number[]; dur: number}; + +export function schedule(n: number, o: ScheduleOpts): Schedule { + const minStride = o.minStride ?? 2; + const minDur = o.minDur ?? 3; + const modes = [o.stride !== undefined, o.lag !== undefined, o.fit !== undefined].filter( + Boolean, + ).length; + if (modes > 1) { + throw new Error('schedule: stride / lag / fit 三选一'); + } + let dur = Math.max(minDur, o.dur ?? 5); + if (n <= 0) { + return {starts: [], dur}; + } + let stride: number; + if (o.fit) { + const total = Math.max(1, o.fit.total); + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + if (n > 1 && stride < minStride) { + // 装不下:缩子项时长换最小步长(保先后可辨),仍不外溢 + dur = Math.max(minDur, total - minStride * (n - 1)); + dur = Math.max(1, Math.min(dur, total)); + stride = Math.max(1, (total - dur) / (n - 1)); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 ? 0 : 1, stride); + return {starts: Array.from({length: n}, (_, i) => Math.round(i * stride)), dur}; +} diff --git a/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/tokens.ts b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/tokens.ts new file mode 100644 index 000000000..457448260 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/tokens.ts @@ -0,0 +1,87 @@ +/** 运动令牌——时长标尺 / 缓动曲线 / 弹簧手感 的单一事实源。 + * + * 只放纯数据与纯函数:不 import remotion、不读 theme(颜色一律经参数注入)—— + * 这是本层能以 frozen 档跨两个系列共享的前提(两系列 theme token 名已分叉: + * CC 用 core/mech/deny、SE 用 danger),判据同 test_chrome_motifs_only_read_base_theme_tokens。 + * + * 取值依据(勿凭感觉改;改前先在本集校准轮逐幕目视复测,依据写回此处注释): + * - 时长六档取 IBM Carbon DTCG(70/110/150/240/400/700ms)@30fps 四舍五入。 + * 弃 Material 十六档:30fps 量化下其 15 个相邻对里 6 对落进同一帧数(伪选择)。 + * - 缓动控制点取 Material 3 标准三件;曲线本体在 hooks.ts 经 Remotion Easing.bezier 求值。 + * - 弹簧预设锚定本仓实测手感:settle=200 即既有 9/10 调用点的惯用值(延续成片观感), + * snap=12 来自 P4「插头咬合」的过冲;ζ 与过冲峰值的关系由 motion.test.ts 用 + * Mp = exp(-πζ/√(1-ζ²)) 钉死。 + * - effects 不变量:不透明度/颜色永不过冲——effects 类动画一律时长+缓动, + * 弹簧只用于空间位移(M3 spatial/effects 二分的落地)。 + */ + +/** 时长标尺(帧 @30fps)。叙事节拍(4–8s)不用此表——那是 window/schedule 的职责。 */ +export const DUR = { + /** 70ms:微反馈(辉光起点、光标) */ + f1: 2, + /** 110ms:快速子项(列表错峰的单项时长) */ + f2: 3, + /** 150ms:标准入场 */ + f3: 5, + /** 200ms:强调入场 */ + f4: 7, + /** 400ms:大位移 / 镜头推近 / 描线 */ + f5: 12, + /** 700ms:幕级大动作(少用) */ + f6: 21, +} as const; +export type DurToken = keyof typeof DUR; + +/** 缓动令牌。 */ +export type EasingToken = 'standard' | 'decelerate' | 'accelerate' | 'linear'; + +/** 贝塞尔控制点(x1,y1,x2,y2);linear 无控制点。 */ +export const EASING_CP: Record< + Exclude, + readonly [number, number, number, number] +> = { + // M3 standard:入场默认 + standard: [0.2, 0, 0, 1], + // M3 decelerate:强减速(大位移入场、镜头推近) + decelerate: [0.05, 0.7, 0.1, 1], + // M3 accelerate:出场加速 + accelerate: [0.3, 0, 0.8, 0.15], +}; + +/** 弹簧预设(直传 Remotion spring config;ζ = c/(2√(k·m)))。 */ +export type SpringPreset = 'settle' | 'settleSoft' | 'snap'; +export const SPRING: Record = { + // ζ≈10:无过冲平滑滑入——本仓主流手感(既有场景 9/10 处 damping 200) + settle: {damping: 200, stiffness: 100, mass: 1}, + // ζ≈8.5:更绵一点(P0/P1 既有 180/170 档的收敛) + settleSoft: {damping: 170, stiffness: 100, mass: 1}, + // ζ≈0.6:轻微过冲(咬合/弹入——原 P4 damping 12) + snap: {damping: 12, stiffness: 100, mass: 1}, +}; + +/** 阻尼比 ζ。设计系统文档普遍给 ζ(无量纲),Remotion 取阻尼系数 c——直抄会得 + * ζ≈0.02 的暴力弹跳且能通过渲染体检,这是迁移期最高风险项(单测钉死)。 */ +export const dampingRatio = (s: { + damping: number; + stiffness: number; + mass: number; +}): number => s.damping / (2 * Math.sqrt(s.stiffness * s.mass)); + +/** 欠阻尼弹簧的峰值位置(1 = 恰好到终点不过冲;ζ ≥ 1 恒 1)。 + * Mp = exp(-πζ/√(1-ζ²)) 是超出终点的幅度,峰值 = 1 + Mp。 */ +export const overshootPeak = (zeta: number): number => + zeta >= 1 ? 1 : 1 + Math.exp((-Math.PI * zeta) / Math.sqrt(1 - zeta * zeta)); + +/** 出场快于入场的系数(MDC 实测 400ms 入 / 150ms 出 ≈ 0.375,取 0.4 禁手填)。 */ +export const EXIT_FACTOR = 0.4; + +/** 字幕安全带上沿:1080 - qa_frames.SUBTITLE_BAND_PX(160),与体检口径同源。 */ +export const SAFE_TOP_Y = 920; + +/** 自下方入场的行程安全钳制:落位态底边 restBottom 之上才是可用的进场空间。 + * ISSUE-170 的手工逐卡反算收敛于此——该缺陷类从「评审抽帧抓」变「构造不可能」。 */ +export const clampRiseDist = ( + dist: number, + restBottom: number, + safeTop: number = SAFE_TOP_Y, +): number => Math.max(0, Math.min(dist, safeTop - restBottom)); diff --git a/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/window.ts b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/window.ts new file mode 100644 index 000000000..5d8e5c016 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/window.ts @@ -0,0 +1,28 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 audio-first 时序与可复用运动模型兼容的核心机制:beat 的绝对帧来自 + * beatWindow()(数据源是 TTS 实测 manifest),子动画不写死帧数、只声明自己在 + * beat 进度上的 [start, end] 窗口 ⇒ 旁白实测时长变化时全部窗口自动重定时, + * 「写死帧数与口播脱钩」缺陷类(skills/08 实录)由构造消灭。 + * + * 语义借 MDC TransitionUtils.lerp(startFraction, endFraction):窗外钳制端点。 + * 本模块零依赖(不 import remotion / theme)——frozen 跨系列共享与 node 单测的前提。 + */ + +/** 钳制到 [0,1]——一切进度的唯一出口,防负值/超 1 渗进 transform。 */ +export const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x); + +/** 子动画窗口:父进度 p(0..1) 在 [s,e] 片段上的局部进度。 */ +export const win = (p: number, w: readonly [number, number]): number => + clamp01((p - w[0]) / (w[1] - w[0])); + +/** beat 进度:局部帧 → 该 beat 的 0..1 进度。 */ +export const beatProgress = ( + frame: number, + from: number, + durationInFrames: number, +): number => clamp01((frame - from) / Math.max(1, durationInFrames)); + +/** 帧域进度:[at, at+dur] 上的 0..1(dur ≤ 0 视作 1,防除零)。 */ +export const progress = (frame: number, at: number, dur: number): number => + clamp01((frame - at) / Math.max(1, dur)); diff --git a/apps/negentropy-influence/episodes/experience-era-agents-video/video/scripts/motion.test.ts b/apps/negentropy-influence/episodes/experience-era-agents-video/video/scripts/motion.test.ts new file mode 100644 index 000000000..4a55380f7 --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/scripts/motion.test.ts @@ -0,0 +1,123 @@ +/** + * 运动层纯函数单测——`node --test scripts/motion.test.ts`(Node ≥ 23.6 原生跑 TS)。 + * + * 刻意放在 video/ 而非 src/:tsconfig include 只有 src(frozen,不为测试改动), + * 而本文件 import 需带 .ts 后缀(Node ESM 解析规则)——tsc 若收编会因 + * allowImportingTsExtensions 未开而报错。src/ 的类型覆盖由 tsc --noEmit 全量保证, + * 本文件只测纯函数行为,不做类型承重。 + * + * 只 import 纯模块(tokens/window/schedule——零 remotion/react 依赖): + * hooks 是它们的薄包装,行为由 MotionGallery 目视 + 场景抽帧覆盖。 + */ +import {strict as assert} from 'node:assert'; +import test from 'node:test'; + +import {DUR, EASING_CP, EXIT_FACTOR, SAFE_TOP_Y, SPRING, clampRiseDist, dampingRatio, overshootPeak} from '../src/motion/tokens.ts'; +import {beatProgress, clamp01, progress, win} from '../src/motion/window.ts'; +import {schedule} from '../src/motion/schedule.ts'; + +// ── tokens ──────────────────────────────────────────────────────────── + +test('时长标尺在 30fps 下相邻档可辨(≥1 帧差)', () => { + const v = Object.values(DUR) as number[]; + for (let i = 1; i < v.length; i++) { + assert.ok(v[i] - v[i - 1] >= 1, `第 ${i} 档与前一档同帧数(伪选择)`); + } + assert.ok(v.length === 6); +}); + +test('ζ→过冲换算钉死:snap 轻过冲、settle 零过冲(直抄 dampingRatio 的反例护栏)', () => { + const zSnap = dampingRatio(SPRING.snap); + const zSettle = dampingRatio(SPRING.settle); + assert.ok(zSnap > 0.4 && zSnap < 0.9, `snap ζ=${zSnap}`); + // Mp = exp(-πζ/√(1-ζ²)) 是超出幅度;峰值 = 1 + Mp(ζ=0.6 → 峰值 ≈1.095) + assert.ok(overshootPeak(zSnap) > 1.02 && overshootPeak(zSnap) < 1.2, `snap 峰值 ${overshootPeak(zSnap)}`); + assert.ok(zSettle > 1, 'settle 须过阻尼'); + assert.equal(overshootPeak(zSettle), 1); + // 反例:把设计系统的 ζ 当 damping 直填(0.8)→ ζ≈0.04、峰值≈1.88 暴力弹跳 + const wrong = dampingRatio({damping: 0.8, stiffness: 100, mass: 1}); + assert.ok(overshootPeak(wrong) > 1.8, `直抄 ζ 的峰值=${overshootPeak(wrong)},必须被此断言抓住`); +}); + +test('缓动控制点合法(CSS 规则 x∈[0,1],且 x(t) 数值单调——可作函数求值)', () => { + for (const cp of Object.values(EASING_CP)) { + const [x1, , x2] = cp; + assert.ok(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1, `x 越界:${cp}`); + // x1 -1e-9, `x(t) 非单调 @t=${t}:${cp}`); + } + } +}); + +test('出场快于入场系数与安全带口径为常量', () => { + assert.equal(EXIT_FACTOR, 0.4); + assert.equal(SAFE_TOP_Y, 920); +}); + +test('clampRiseDist:自下方入场行程不探进字幕安全带', () => { + assert.equal(clampRiseDist(120, 836), 84, 'ISSUE-170 实测几何:rest 836 → 行程封顶 84'); + assert.equal(clampRiseDist(40, 836), 40, '未超限不动'); + assert.equal(clampRiseDist(120, 960), 0, '落位已在安全带内 → 零行程(退化但安全)'); +}); + +// ── window ──────────────────────────────────────────────────────────── + +test('clamp01 / progress / win 的钳制语义', () => { + assert.equal(clamp01(-1), 0); + assert.equal(clamp01(2), 1); + assert.equal(progress(10, 10, 10), 0, '起点为 0'); + assert.equal(progress(20, 10, 10), 1, '终点为 1'); + assert.equal(progress(5, 10, 10), 0, '窗外前钳 0'); + assert.equal(progress(99, 10, 10), 1, '窗外后钳 1'); + assert.equal(win(0.5, [0.25, 0.75]), 0.5); + assert.equal(win(0.1, [0.25, 0.75]), 0); + assert.equal(win(0.9, [0.25, 0.75]), 1); + assert.equal(beatProgress(0, -30, 90), 1 / 3); +}); + +// ── schedule ────────────────────────────────────────────────────────── + +test('fit 模式:末项恰在窗口末完成、不外溢', () => { + const p = schedule(5, {dur: 10, fit: {total: 90}}); + const last = p.starts[4] + p.dur; + assert.ok(last <= 90 + 1, `末项 ${last} 外溢`); + assert.ok(last >= 89, `末项 ${last} 未到窗口末`); +}); + +test('fit 装不下时缩子项时长,不外溢窗口(优先级:不外溢 > 最小步长 > 子项时长)', () => { + // total 30 时 (30-12)/7≈2.57 ≥ minStride=2 仍装得下;压到 24 才触发缩时长 + const p = schedule(8, {dur: 12, fit: {total: 24}}); + const last = p.starts[7] + p.dur; + assert.ok(last <= 31, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); +}); + +test('stride 模式与三选一守卫', () => { + assert.deepEqual(schedule(3, {dur: 5, stride: 4}).starts, [0, 4, 8]); + assert.throws(() => schedule(3, {dur: 5, stride: 4, lag: 1})); + assert.throws(() => schedule(3, {dur: 5, lag: 1, fit: {total: 40}})); +}); + +test('最小步长与空集', () => { + assert.deepEqual(schedule(0, {dur: 5, stride: 4}), {starts: [], dur: 5}); + const p = schedule(2, {dur: 5, stride: 0}); // 非法步长 → 抬到下限 + assert.ok(p.starts[1] - p.starts[0] >= 1); +}); + +test('起点取整且单调不减', () => { + const p = schedule(6, {dur: 7, fit: {total: 53}}); + for (let i = 1; i < p.starts.length; i++) { + assert.ok(p.starts[i] >= p.starts[i - 1], '起点须单调不减'); + assert.ok(Number.isInteger(p.starts[i])); + } +}); diff --git a/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/gallery.tsx b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/gallery.tsx new file mode 100644 index 000000000..ad2e067cf --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/gallery.tsx @@ -0,0 +1,265 @@ +/** MotionGallery——运动层的评审面(独立 Remotion 入口,不经 Root.tsx)。 + * + * 用法(在 video/ 目录,.bin 直调防污染根 workspace): + * ./node_modules/.bin/remotion still src/motion/gallery.tsx MotionGallery \ + * out/motion-gallery.png --frame=30 + * + * 全部模型 × 变体一屏可渲:秒级出图,供 token 校准轮逐格目视。 + * 色板为本文件内字面量(dev 工具面,不进成片、不读 theme——保持 frozen 跨系列共享)。 + */ +import React from 'react'; +import {AbsoluteFill, Composition, registerRoot} from 'remotion'; +import { + useAccelTravel, + useBreathe, + useCount, + useDim, + useDraw, + useEnter, + useFadeOut, + useFlowDash, + useImpulse, + usePushIn, + useReveal, + useShake, + useStagger, + useTravel, +} from './hooks'; + +const COLS = 5; +const CW = 360; +const CH = 240; +const GAP = 18; + +/** dev 工具面字面量色板(与各集 theme 底座同值但刻意独立声明——不读 theme)。 */ +const C = { + bg: '#0E1116', + panel: '#171C26', + border: '#2A3242', + text: '#F2F5FA', + dim: '#9AA7B8', + core: '#D97757', + mech: '#64C4C0', + deny: '#EF6461', +}; + +/** 单元格壳:定位 + 角标。 */ +const Cell: React.FC<{i: number; name: string; children: React.ReactNode}> = ({i, name, children}) => ( +
+
+ {children} +
+
+ {name} +
+
+); + +const Box: React.FC<{color?: string; w?: number; h?: number; style?: React.CSSProperties}> = ({ + color = C.core, + w = 200, + h = 80, + style, +}) =>
; + +// ── 每格一个组件:hooks 各归其位(Rules of Hooks 的最简守法形态) ──────── + +const EnterCell: React.FC<{kind: Parameters[0]}> = ({kind}) => { + const e = useEnter(kind, {}); + return ; +}; + +const StaggerCell: React.FC = () => { + const ps = useStagger(5, {dur: 5, fit: {total: 60}}); + return ( +
+ {ps.map((p, i) => ( +
+ ))} +
+ ); +}; + +const DrawCell: React.FC = () => { + const d = useDraw(0, 24); + return ( + + + + ); +}; + +const ImpulseCell: React.FC = () => { + const g = useImpulse({dur: 30, peak: 1}); + return ( +
+ ); +}; + +const BreatheCell: React.FC = () => { + const b = useBreathe({period: 30}); + return ( + + + + ); +}; + +const TravelCell: React.FC = () => { + const t = useTravel({cx: 140, cy: 55, r: 42, secPerLap: 3}); + return ( + + + + + ); +}; + +const AccelCell: React.FC = () => { + const t = useAccelTravel({cx: 140, cy: 55, r: 42, durs: [28, 20, 14], at: 4}); + const heat = `rgb(${217 + Math.round(38 * t.heat)}, ${119 - Math.round(60 * t.heat)}, ${87 - Math.round(20 * t.heat)})`; + return ( + + + + + ); +}; + +const CountCell: React.FC = () => { + const v = useCount({to: 255, dur: 40}); + return ( +
+ {Math.round(v)} +
+ ); +}; + +const RevealCell: React.FC = () => { + const s = useReveal('while (true) { think(); act(); }', {cps: 14}); + const blink = useBreathe({period: 16, amp: 0.5, base: 0.5}); + return ( +
+ {s} + +
+ ); +}; + +const PushInCell: React.FC = () => { + const t = usePushIn(0, {scale: 0.12}); + return ; +}; + +const DimCell: React.FC = () => { + const dim = useDim({at: 40, to: 0.35}); + return ( +
+ + + +
+ ); +}; + +const FlowCell: React.FC = () => { + const f = useFlowDash({period: 24}); + return ( + + + + ); +}; + +const ShakeCell: React.FC = () => { + const x = useShake({at: 10, amp: 5, decay: true, dur: 40}); + return ; +}; + +const FadeCell: React.FC = () => { + const op = useFadeOut(90, {frames: 36}); + return ( +
+ +
+
+ ); +}; + +const CELLS: Array<[string, React.ReactNode]> = [ + ['enter:fall', ], + ['enter:rise', ], + ['enter:slideL', ], + ['enter:pop', ], + ['enter:flyIn', ], + ['enter:fade', ], + ['stagger×5 fit60', ], + ['draw 24f', ], + ['impulse 30f', ], + ['breathe p30', ], + ['travel 3s/lap', ], + ['accelTravel', ], + ['count→255', ], + ['reveal 14cps', ], + ['pushIn .12', ], + ['dim .35@40', ], + ['flowDash p24', ], + ['shake decay', ], + ['fadeOut 36f', ], +]; + +const MotionGallery: React.FC = () => ( + +
+ MotionGallery · 30fps · 120f +
+ {CELLS.map(([name, node], i) => ( + + {node} + + ))} +
+); + +registerRoot(() => ( + +)); diff --git a/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/hooks.ts b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/hooks.ts new file mode 100644 index 000000000..bfa87b05f --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/hooks.ts @@ -0,0 +1,268 @@ +/** 运动模型(hooks)——分镜「动效」列动词到帧数学的唯一映射。 + * + * 设计约束(违反任何一条即失去本层存在意义): + * 1. hooks 返回数值 / CSS 片段,不渲染 DOM——FadeUp 式包装组件打不进 svg// + * absolute 布局,是「组件存在却零调用」的实测根因;数值可落进任意 JSX。 + * 2. 弹簧一律吃局部帧(frame - at):spring() 每次调用从第 0 帧重模拟,喂全局帧 + * 会让长片末帧每个 spring 跑两万余次迭代。 + * 3. effects(不透明度/颜色)永不吃弹簧——一律时长+缓动(tokens 的二分不变量)。 + * 4. 不读 theme:颜色一律经参数传入(frozen 跨系列共享的前提)。 + * 5. `at` 锚点一律来自句边界(rel(beat, '句id')),禁写死帧数。 + */ +import {Easing, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + DUR, + EASING_CP, + SPRING, + clampRiseDist, + type DurToken, + type EasingToken, + type SpringPreset, +} from './tokens'; +import {progress} from './window'; +import {schedule, type ScheduleOpts} from './schedule'; + +/** 帧数解析:token 或直接帧数;缺省用 def。 */ +export const frames = (d: number | DurToken | undefined, def: number): number => + d === undefined ? def : typeof d === 'number' ? d : DUR[d]; + +/** 缓动令牌 → Remotion 缓动函数(linear 直通)。 */ +export const easeF = (t: EasingToken): ((x: number) => number) => + t === 'linear' + ? Easing.linear + : Easing.bezier( + ...(EASING_CP[t] as [number, number, number, number]), + ); + +/** 缓动后的 0..1 进度(各模型共用的原子)。 */ +const eased = ( + frame: number, + at: number, + dur: number, + e: EasingToken, +): number => interpolate(progress(frame, at, dur), [0, 1], [0, 1], {easing: easeF(e)}); + +// ── 入场(enter:落下/上浮/滑入/弹出/飞入/淡入) ──────────────────────── + +export type EnterKind = 'fall' | 'rise' | 'slideL' | 'slideR' | 'pop' | 'flyIn' | 'fade'; +export type EnterOpts = { + /** 句边界锚(局部帧)。 */ + at?: number; + dur?: number | DurToken; + easing?: EasingToken; + /** 空间通道用弹簧(位移类才有意义;fade/pop 无效)。 */ + springPreset?: SpringPreset; + /** 位移像素(fall/rise/slide*;缺省 30)。 */ + dist?: number; + /** rise 专用:落位态底边 y——行程经 clampRiseDist 钳进字幕安全带之上。 */ + restBottom?: number; +}; +export type EnterStyle = {opacity: number; transform: string}; + +export function useEnter(kind: EnterKind, o: EnterOpts = {}): EnterStyle { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const at = o.at ?? 0; + const dur = frames(o.dur, DUR.f4); + let dist = o.dist ?? 30; + if (kind === 'rise' && o.restBottom !== undefined) { + dist = clampRiseDist(dist, o.restBottom); + } + // effects 通道:纯缓动、略快于空间通道(元素先「看见」再「落位」) + const opacity = progress(frame, at, Math.max(2, Math.round(dur * 0.8))); + // spatial 通道:可选弹簧(局部帧 + durationInFrames 截停,防窗口外余振) + const p = o.springPreset + ? spring({ + frame: frame - at, + fps, + config: SPRING[o.springPreset], + durationInFrames: dur, + }) + : eased(frame, at, dur, o.easing ?? 'standard'); + const inv = 1 - p; + const t: string[] = []; + if (kind === 'fall') t.push(`translateY(${-inv * dist}px)`); + if (kind === 'rise') t.push(`translateY(${inv * dist}px)`); + if (kind === 'slideL') t.push(`translateX(${-inv * dist}px)`); + if (kind === 'slideR') t.push(`translateX(${inv * dist}px)`); + if (kind === 'pop') t.push(`scale(${0.9 + 0.1 * p})`); + if (kind === 'flyIn') t.push(`scale(${0.6 + 0.4 * p})`); + return {opacity, transform: t.length ? t.join(' ') : 'none'}; +} + +// ── 序列错峰(stagger:依次/逐行/逐条/逐格) ─────────────────────────── + +export type StaggerOpts = ScheduleOpts & {at?: number; easing?: EasingToken}; + +/** 返回 n 个 0..1 进度——第 i 项随编排依次入场。 */ +export function useStagger(n: number, o: StaggerOpts = {}): number[] { + const frame = useCurrentFrame(); + const {at = 0, easing = 'standard'} = o; + const plan = schedule(n, o); + return plan.starts.map((s) => eased(frame, at + s, plan.dur, easing)); +} + +// ── 描线(draw:红线三由构造保证——只产 pathLength 归一化三元组) ──────── + +export type DrawProps = {pathLength: 1; strokeDasharray: 1; strokeDashoffset: number}; + +export function useDraw(at: number, dur: number | DurToken = DUR.f5): DrawProps { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(dur, DUR.f5), 'decelerate'); + return {pathLength: 1, strokeDasharray: 1, strokeDashoffset: 1 - p}; +} + +// ── 脉冲 / 呼吸(glow 语法:impulse=一次性强调,breathe=持续辉光) ─────── + +/** 一次性冲击:sin(πp) 包络,起于 0 归于 0,峰值 peak。 */ +export function useImpulse(o: {at?: number; dur?: number | DurToken; peak?: number} = {}): number { + const frame = useCurrentFrame(); + const p = progress(frame, o.at ?? 0, frames(o.dur, DUR.f5)); + return Math.sin(Math.PI * p) * (o.peak ?? 1); +} + +/** 持续呼吸(原 0.55+0.45·sin(frame/K) 散写的收敛;period 帧一周期)。 */ +export function useBreathe(o: {period?: number; amp?: number; base?: number} = {}): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26} = o; + return base + amp * Math.sin((2 * Math.PI * frame) / period); +} + +// ── 巡游(travel:环形为主;absorb 原 useRingDot 与加速绕行累加器克隆) ── + +export type TravelPos = {x: number; y: number; angle: number}; + +/** 匀速环形巡游(angle 单位度,-90 = 12 点方向起)。 */ +export function useTravel(o: { + cx: number; + cy: number; + r: number; + secPerLap?: number; + offset?: number; +}): TravelPos { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const lap = (frame / (fps * (o.secPerLap ?? 2.5)) + (o.offset ?? 0)) % 1; + const a = -90 + lap * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), angle: a}; +} + +/** 加速绕行:逐圈时长 durs[](如 [40,30,22,16]),跑完全部圈后冻结在终点。 + * heat 0..1 随圈数推进(「失控感」的配色偏移系数)。 */ +export function useAccelTravel(o: { + cx: number; + cy: number; + r: number; + durs: number[]; + at?: number; + heatPerLap?: number; +}): {x: number; y: number; heat: number} { + const frame = useCurrentFrame(); + let t = Math.max(0, frame - (o.at ?? 0)); + let lap = 0; + while (lap < o.durs.length && t >= o.durs[lap]) { + t -= o.durs[lap]; + lap += 1; + } + const within = lap >= o.durs.length ? 1 : t / o.durs[lap]; + const heat = Math.min(1, lap / (o.heatPerLap ?? o.durs.length)); + const a = -90 + within * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), heat}; +} + +// ── 计数 / 水位(meter 语法;显示层自行 Math.round / toFixed) ────────── + +export function useCount(o: { + from?: number; + to: number; + at?: number; + dur?: number | DurToken; + ease?: EasingToken; +}): number { + const frame = useCurrentFrame(); + const {from = 0, to, at = 0} = o; + const p = eased(frame, at, frames(o.dur, DUR.f6), o.ease ?? 'standard'); + return from + (to - from) * p; +} + +// ── 打字机 / 逐字流出(type;Terminal 之外的泛化) ────────────────────── + +export function useReveal( + text: string, + o: {at?: number; cps?: number; framesPerChar?: number} = {}, +): string { + const frame = useCurrentFrame(); + const per = o.framesPerChar ?? Math.max(1, Math.round(30 / (o.cps ?? 12))); + const n = Math.floor(Math.max(0, frame - (o.at ?? 0)) / per); + return text.slice(0, Math.min(text.length, n)); +} + +// ── 镜头推近(pushIn 语法:beat 切换的镜头语言,替代纯淡入) ──────────── + +export function usePushIn(at: number, o: {scale?: number; dur?: number | DurToken} = {}): string { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(o.dur, DUR.f5), 'decelerate'); + return `scale(${1 + (o.scale ?? 0.06) * p})`; +} + +// ── 压暗 / 提亮(emphasis 反向:让主体从群像中浮出) ───────────────────── + +/** 返回目标透明度系数(1 = 原;to 0.4 即压暗到 40%)。 */ +export function useDim(o: {at: number; to?: number; dur?: number | DurToken}): number { + const frame = useCurrentFrame(); + const p = eased(frame, o.at, frames(o.dur, DUR.f4), 'standard'); + return 1 + ((o.to ?? 0.4) - 1) * p; +} + +// ── 流光(flow 语法:连线上的行进虚线) ───────────────────────────────── + +/** 返回可直接展开到 / 的描边属性(像素 dasharray——与 draw 的 + * pathLength 归一化描线是两个正交特性,勿混用于同一元素:红线三)。 */ +export function useFlowDash(o: { + dash?: number; + gap?: number; + /** 帧速率:每 period 帧行进一个 dash+gap 周期。 */ + period?: number; +}): {strokeDasharray: string; strokeDashoffset: number} { + const frame = useCurrentFrame(); + const {dash = 10, gap = 14, period = 40} = o; + return { + strokeDasharray: `${dash} ${gap}`, + strokeDashoffset: -(frame * (dash + gap)) / period, + }; +} + +// ── 抖动(错误/故障语义;收敛 P2/P3 两处克隆) ────────────────────────── + +/** 返回 translateX 像素值。active 缺省 true;decay=true 时按 dur 衰减归零。 */ +export function useShake(o: { + at: number; + active?: boolean; + amp?: number; + /** 相位分母(原手写 /1.6、/2.2 的口径)。 */ + freq?: number; + decay?: boolean; + dur?: number | DurToken; +}): number { + const frame = useCurrentFrame(); + const {amp = 3, freq = 1.6} = o; + const t = frame - o.at; + if (o.active === false || t < 0) { + return 0; + } + if (o.decay) { + const env = 1 - progress(frame, o.at, frames(o.dur, DUR.f5)); + return amp * env * Math.sin(t / freq); + } + return amp * Math.sin(t / freq); +} + +// ── 片尾渐黑(红线四:从 beat 总时长推导,勿用末句时长) ──────────────── + +export function useFadeOut(durationInFrames: number, o: {frames?: number} = {}): number { + const frame = useCurrentFrame(); + const f = o.frames ?? 36; // 1.2s + return 1 - progress(frame, durationInFrames - f, f); +} diff --git a/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/index.ts b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/index.ts new file mode 100644 index 000000000..97fd22a5c --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/index.ts @@ -0,0 +1,5 @@ +/** 运动层门面——场景代码统一 `import {...} from '../motion'`。 */ +export * from './tokens'; +export * from './window'; +export * from './schedule'; +export * from './hooks'; diff --git a/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/schedule.ts b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/schedule.ts new file mode 100644 index 000000000..0aa1ebfa4 --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/schedule.ts @@ -0,0 +1,59 @@ +/** 错峰编排(stagger)纯函数——收敛手写 `frame - i*N`(跨 8 集约 356 处、 + * 步长 K∈{2,3,4,5,6,8,10} 任意取值、无节奏标尺)。 + * + * 三种模式(互斥,参数即文档): + * - stride:固定步长(与旧手写行为一致——迁移期的保守选项); + * - lag:Manim LaggedStart 语义 start[i] = i·dur·lagRatio(0 = 同刻齐动,1 = 首尾相接); + * - fit:n 个子项恰好装进窗口 total(末项恰在窗口末完成——「随句推进」的首选)。 + * + * 钳制优先级(motion.test.ts 钉死,高者让位低者):不外溢窗口 > 最小步长 > 子项时长。 + * 窗口属于 beat 时间轴,外溢会踩进下一 beat;装不下时**缩子项时长**,不延窗口。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点最小间隔,默认 2 帧(30fps 下仍可辨先后)。 */ + minStride?: number; + /** 子项时长下限,默认 3 帧(低于此相当于瞬现)。 */ + minDur?: number; +}; +export type Schedule = {starts: number[]; dur: number}; + +export function schedule(n: number, o: ScheduleOpts): Schedule { + const minStride = o.minStride ?? 2; + const minDur = o.minDur ?? 3; + const modes = [o.stride !== undefined, o.lag !== undefined, o.fit !== undefined].filter( + Boolean, + ).length; + if (modes > 1) { + throw new Error('schedule: stride / lag / fit 三选一'); + } + let dur = Math.max(minDur, o.dur ?? 5); + if (n <= 0) { + return {starts: [], dur}; + } + let stride: number; + if (o.fit) { + const total = Math.max(1, o.fit.total); + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + if (n > 1 && stride < minStride) { + // 装不下:缩子项时长换最小步长(保先后可辨),仍不外溢 + dur = Math.max(minDur, total - minStride * (n - 1)); + dur = Math.max(1, Math.min(dur, total)); + stride = Math.max(1, (total - dur) / (n - 1)); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 ? 0 : 1, stride); + return {starts: Array.from({length: n}, (_, i) => Math.round(i * stride)), dur}; +} diff --git a/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/tokens.ts b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/tokens.ts new file mode 100644 index 000000000..457448260 --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/tokens.ts @@ -0,0 +1,87 @@ +/** 运动令牌——时长标尺 / 缓动曲线 / 弹簧手感 的单一事实源。 + * + * 只放纯数据与纯函数:不 import remotion、不读 theme(颜色一律经参数注入)—— + * 这是本层能以 frozen 档跨两个系列共享的前提(两系列 theme token 名已分叉: + * CC 用 core/mech/deny、SE 用 danger),判据同 test_chrome_motifs_only_read_base_theme_tokens。 + * + * 取值依据(勿凭感觉改;改前先在本集校准轮逐幕目视复测,依据写回此处注释): + * - 时长六档取 IBM Carbon DTCG(70/110/150/240/400/700ms)@30fps 四舍五入。 + * 弃 Material 十六档:30fps 量化下其 15 个相邻对里 6 对落进同一帧数(伪选择)。 + * - 缓动控制点取 Material 3 标准三件;曲线本体在 hooks.ts 经 Remotion Easing.bezier 求值。 + * - 弹簧预设锚定本仓实测手感:settle=200 即既有 9/10 调用点的惯用值(延续成片观感), + * snap=12 来自 P4「插头咬合」的过冲;ζ 与过冲峰值的关系由 motion.test.ts 用 + * Mp = exp(-πζ/√(1-ζ²)) 钉死。 + * - effects 不变量:不透明度/颜色永不过冲——effects 类动画一律时长+缓动, + * 弹簧只用于空间位移(M3 spatial/effects 二分的落地)。 + */ + +/** 时长标尺(帧 @30fps)。叙事节拍(4–8s)不用此表——那是 window/schedule 的职责。 */ +export const DUR = { + /** 70ms:微反馈(辉光起点、光标) */ + f1: 2, + /** 110ms:快速子项(列表错峰的单项时长) */ + f2: 3, + /** 150ms:标准入场 */ + f3: 5, + /** 200ms:强调入场 */ + f4: 7, + /** 400ms:大位移 / 镜头推近 / 描线 */ + f5: 12, + /** 700ms:幕级大动作(少用) */ + f6: 21, +} as const; +export type DurToken = keyof typeof DUR; + +/** 缓动令牌。 */ +export type EasingToken = 'standard' | 'decelerate' | 'accelerate' | 'linear'; + +/** 贝塞尔控制点(x1,y1,x2,y2);linear 无控制点。 */ +export const EASING_CP: Record< + Exclude, + readonly [number, number, number, number] +> = { + // M3 standard:入场默认 + standard: [0.2, 0, 0, 1], + // M3 decelerate:强减速(大位移入场、镜头推近) + decelerate: [0.05, 0.7, 0.1, 1], + // M3 accelerate:出场加速 + accelerate: [0.3, 0, 0.8, 0.15], +}; + +/** 弹簧预设(直传 Remotion spring config;ζ = c/(2√(k·m)))。 */ +export type SpringPreset = 'settle' | 'settleSoft' | 'snap'; +export const SPRING: Record = { + // ζ≈10:无过冲平滑滑入——本仓主流手感(既有场景 9/10 处 damping 200) + settle: {damping: 200, stiffness: 100, mass: 1}, + // ζ≈8.5:更绵一点(P0/P1 既有 180/170 档的收敛) + settleSoft: {damping: 170, stiffness: 100, mass: 1}, + // ζ≈0.6:轻微过冲(咬合/弹入——原 P4 damping 12) + snap: {damping: 12, stiffness: 100, mass: 1}, +}; + +/** 阻尼比 ζ。设计系统文档普遍给 ζ(无量纲),Remotion 取阻尼系数 c——直抄会得 + * ζ≈0.02 的暴力弹跳且能通过渲染体检,这是迁移期最高风险项(单测钉死)。 */ +export const dampingRatio = (s: { + damping: number; + stiffness: number; + mass: number; +}): number => s.damping / (2 * Math.sqrt(s.stiffness * s.mass)); + +/** 欠阻尼弹簧的峰值位置(1 = 恰好到终点不过冲;ζ ≥ 1 恒 1)。 + * Mp = exp(-πζ/√(1-ζ²)) 是超出终点的幅度,峰值 = 1 + Mp。 */ +export const overshootPeak = (zeta: number): number => + zeta >= 1 ? 1 : 1 + Math.exp((-Math.PI * zeta) / Math.sqrt(1 - zeta * zeta)); + +/** 出场快于入场的系数(MDC 实测 400ms 入 / 150ms 出 ≈ 0.375,取 0.4 禁手填)。 */ +export const EXIT_FACTOR = 0.4; + +/** 字幕安全带上沿:1080 - qa_frames.SUBTITLE_BAND_PX(160),与体检口径同源。 */ +export const SAFE_TOP_Y = 920; + +/** 自下方入场的行程安全钳制:落位态底边 restBottom 之上才是可用的进场空间。 + * ISSUE-170 的手工逐卡反算收敛于此——该缺陷类从「评审抽帧抓」变「构造不可能」。 */ +export const clampRiseDist = ( + dist: number, + restBottom: number, + safeTop: number = SAFE_TOP_Y, +): number => Math.max(0, Math.min(dist, safeTop - restBottom)); diff --git a/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/window.ts b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/window.ts new file mode 100644 index 000000000..5d8e5c016 --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/window.ts @@ -0,0 +1,28 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 audio-first 时序与可复用运动模型兼容的核心机制:beat 的绝对帧来自 + * beatWindow()(数据源是 TTS 实测 manifest),子动画不写死帧数、只声明自己在 + * beat 进度上的 [start, end] 窗口 ⇒ 旁白实测时长变化时全部窗口自动重定时, + * 「写死帧数与口播脱钩」缺陷类(skills/08 实录)由构造消灭。 + * + * 语义借 MDC TransitionUtils.lerp(startFraction, endFraction):窗外钳制端点。 + * 本模块零依赖(不 import remotion / theme)——frozen 跨系列共享与 node 单测的前提。 + */ + +/** 钳制到 [0,1]——一切进度的唯一出口,防负值/超 1 渗进 transform。 */ +export const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x); + +/** 子动画窗口:父进度 p(0..1) 在 [s,e] 片段上的局部进度。 */ +export const win = (p: number, w: readonly [number, number]): number => + clamp01((p - w[0]) / (w[1] - w[0])); + +/** beat 进度:局部帧 → 该 beat 的 0..1 进度。 */ +export const beatProgress = ( + frame: number, + from: number, + durationInFrames: number, +): number => clamp01((frame - from) / Math.max(1, durationInFrames)); + +/** 帧域进度:[at, at+dur] 上的 0..1(dur ≤ 0 视作 1,防除零)。 */ +export const progress = (frame: number, at: number, dur: number): number => + clamp01((frame - at) / Math.max(1, dur)); diff --git a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/scripts/motion.test.ts b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/scripts/motion.test.ts new file mode 100644 index 000000000..4a55380f7 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/scripts/motion.test.ts @@ -0,0 +1,123 @@ +/** + * 运动层纯函数单测——`node --test scripts/motion.test.ts`(Node ≥ 23.6 原生跑 TS)。 + * + * 刻意放在 video/ 而非 src/:tsconfig include 只有 src(frozen,不为测试改动), + * 而本文件 import 需带 .ts 后缀(Node ESM 解析规则)——tsc 若收编会因 + * allowImportingTsExtensions 未开而报错。src/ 的类型覆盖由 tsc --noEmit 全量保证, + * 本文件只测纯函数行为,不做类型承重。 + * + * 只 import 纯模块(tokens/window/schedule——零 remotion/react 依赖): + * hooks 是它们的薄包装,行为由 MotionGallery 目视 + 场景抽帧覆盖。 + */ +import {strict as assert} from 'node:assert'; +import test from 'node:test'; + +import {DUR, EASING_CP, EXIT_FACTOR, SAFE_TOP_Y, SPRING, clampRiseDist, dampingRatio, overshootPeak} from '../src/motion/tokens.ts'; +import {beatProgress, clamp01, progress, win} from '../src/motion/window.ts'; +import {schedule} from '../src/motion/schedule.ts'; + +// ── tokens ──────────────────────────────────────────────────────────── + +test('时长标尺在 30fps 下相邻档可辨(≥1 帧差)', () => { + const v = Object.values(DUR) as number[]; + for (let i = 1; i < v.length; i++) { + assert.ok(v[i] - v[i - 1] >= 1, `第 ${i} 档与前一档同帧数(伪选择)`); + } + assert.ok(v.length === 6); +}); + +test('ζ→过冲换算钉死:snap 轻过冲、settle 零过冲(直抄 dampingRatio 的反例护栏)', () => { + const zSnap = dampingRatio(SPRING.snap); + const zSettle = dampingRatio(SPRING.settle); + assert.ok(zSnap > 0.4 && zSnap < 0.9, `snap ζ=${zSnap}`); + // Mp = exp(-πζ/√(1-ζ²)) 是超出幅度;峰值 = 1 + Mp(ζ=0.6 → 峰值 ≈1.095) + assert.ok(overshootPeak(zSnap) > 1.02 && overshootPeak(zSnap) < 1.2, `snap 峰值 ${overshootPeak(zSnap)}`); + assert.ok(zSettle > 1, 'settle 须过阻尼'); + assert.equal(overshootPeak(zSettle), 1); + // 反例:把设计系统的 ζ 当 damping 直填(0.8)→ ζ≈0.04、峰值≈1.88 暴力弹跳 + const wrong = dampingRatio({damping: 0.8, stiffness: 100, mass: 1}); + assert.ok(overshootPeak(wrong) > 1.8, `直抄 ζ 的峰值=${overshootPeak(wrong)},必须被此断言抓住`); +}); + +test('缓动控制点合法(CSS 规则 x∈[0,1],且 x(t) 数值单调——可作函数求值)', () => { + for (const cp of Object.values(EASING_CP)) { + const [x1, , x2] = cp; + assert.ok(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1, `x 越界:${cp}`); + // x1 -1e-9, `x(t) 非单调 @t=${t}:${cp}`); + } + } +}); + +test('出场快于入场系数与安全带口径为常量', () => { + assert.equal(EXIT_FACTOR, 0.4); + assert.equal(SAFE_TOP_Y, 920); +}); + +test('clampRiseDist:自下方入场行程不探进字幕安全带', () => { + assert.equal(clampRiseDist(120, 836), 84, 'ISSUE-170 实测几何:rest 836 → 行程封顶 84'); + assert.equal(clampRiseDist(40, 836), 40, '未超限不动'); + assert.equal(clampRiseDist(120, 960), 0, '落位已在安全带内 → 零行程(退化但安全)'); +}); + +// ── window ──────────────────────────────────────────────────────────── + +test('clamp01 / progress / win 的钳制语义', () => { + assert.equal(clamp01(-1), 0); + assert.equal(clamp01(2), 1); + assert.equal(progress(10, 10, 10), 0, '起点为 0'); + assert.equal(progress(20, 10, 10), 1, '终点为 1'); + assert.equal(progress(5, 10, 10), 0, '窗外前钳 0'); + assert.equal(progress(99, 10, 10), 1, '窗外后钳 1'); + assert.equal(win(0.5, [0.25, 0.75]), 0.5); + assert.equal(win(0.1, [0.25, 0.75]), 0); + assert.equal(win(0.9, [0.25, 0.75]), 1); + assert.equal(beatProgress(0, -30, 90), 1 / 3); +}); + +// ── schedule ────────────────────────────────────────────────────────── + +test('fit 模式:末项恰在窗口末完成、不外溢', () => { + const p = schedule(5, {dur: 10, fit: {total: 90}}); + const last = p.starts[4] + p.dur; + assert.ok(last <= 90 + 1, `末项 ${last} 外溢`); + assert.ok(last >= 89, `末项 ${last} 未到窗口末`); +}); + +test('fit 装不下时缩子项时长,不外溢窗口(优先级:不外溢 > 最小步长 > 子项时长)', () => { + // total 30 时 (30-12)/7≈2.57 ≥ minStride=2 仍装得下;压到 24 才触发缩时长 + const p = schedule(8, {dur: 12, fit: {total: 24}}); + const last = p.starts[7] + p.dur; + assert.ok(last <= 31, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); +}); + +test('stride 模式与三选一守卫', () => { + assert.deepEqual(schedule(3, {dur: 5, stride: 4}).starts, [0, 4, 8]); + assert.throws(() => schedule(3, {dur: 5, stride: 4, lag: 1})); + assert.throws(() => schedule(3, {dur: 5, lag: 1, fit: {total: 40}})); +}); + +test('最小步长与空集', () => { + assert.deepEqual(schedule(0, {dur: 5, stride: 4}), {starts: [], dur: 5}); + const p = schedule(2, {dur: 5, stride: 0}); // 非法步长 → 抬到下限 + assert.ok(p.starts[1] - p.starts[0] >= 1); +}); + +test('起点取整且单调不减', () => { + const p = schedule(6, {dur: 7, fit: {total: 53}}); + for (let i = 1; i < p.starts.length; i++) { + assert.ok(p.starts[i] >= p.starts[i - 1], '起点须单调不减'); + assert.ok(Number.isInteger(p.starts[i])); + } +}); diff --git a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/gallery.tsx b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/gallery.tsx new file mode 100644 index 000000000..ad2e067cf --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/gallery.tsx @@ -0,0 +1,265 @@ +/** MotionGallery——运动层的评审面(独立 Remotion 入口,不经 Root.tsx)。 + * + * 用法(在 video/ 目录,.bin 直调防污染根 workspace): + * ./node_modules/.bin/remotion still src/motion/gallery.tsx MotionGallery \ + * out/motion-gallery.png --frame=30 + * + * 全部模型 × 变体一屏可渲:秒级出图,供 token 校准轮逐格目视。 + * 色板为本文件内字面量(dev 工具面,不进成片、不读 theme——保持 frozen 跨系列共享)。 + */ +import React from 'react'; +import {AbsoluteFill, Composition, registerRoot} from 'remotion'; +import { + useAccelTravel, + useBreathe, + useCount, + useDim, + useDraw, + useEnter, + useFadeOut, + useFlowDash, + useImpulse, + usePushIn, + useReveal, + useShake, + useStagger, + useTravel, +} from './hooks'; + +const COLS = 5; +const CW = 360; +const CH = 240; +const GAP = 18; + +/** dev 工具面字面量色板(与各集 theme 底座同值但刻意独立声明——不读 theme)。 */ +const C = { + bg: '#0E1116', + panel: '#171C26', + border: '#2A3242', + text: '#F2F5FA', + dim: '#9AA7B8', + core: '#D97757', + mech: '#64C4C0', + deny: '#EF6461', +}; + +/** 单元格壳:定位 + 角标。 */ +const Cell: React.FC<{i: number; name: string; children: React.ReactNode}> = ({i, name, children}) => ( +
+
+ {children} +
+
+ {name} +
+
+); + +const Box: React.FC<{color?: string; w?: number; h?: number; style?: React.CSSProperties}> = ({ + color = C.core, + w = 200, + h = 80, + style, +}) =>
; + +// ── 每格一个组件:hooks 各归其位(Rules of Hooks 的最简守法形态) ──────── + +const EnterCell: React.FC<{kind: Parameters[0]}> = ({kind}) => { + const e = useEnter(kind, {}); + return ; +}; + +const StaggerCell: React.FC = () => { + const ps = useStagger(5, {dur: 5, fit: {total: 60}}); + return ( +
+ {ps.map((p, i) => ( +
+ ))} +
+ ); +}; + +const DrawCell: React.FC = () => { + const d = useDraw(0, 24); + return ( + + + + ); +}; + +const ImpulseCell: React.FC = () => { + const g = useImpulse({dur: 30, peak: 1}); + return ( +
+ ); +}; + +const BreatheCell: React.FC = () => { + const b = useBreathe({period: 30}); + return ( + + + + ); +}; + +const TravelCell: React.FC = () => { + const t = useTravel({cx: 140, cy: 55, r: 42, secPerLap: 3}); + return ( + + + + + ); +}; + +const AccelCell: React.FC = () => { + const t = useAccelTravel({cx: 140, cy: 55, r: 42, durs: [28, 20, 14], at: 4}); + const heat = `rgb(${217 + Math.round(38 * t.heat)}, ${119 - Math.round(60 * t.heat)}, ${87 - Math.round(20 * t.heat)})`; + return ( + + + + + ); +}; + +const CountCell: React.FC = () => { + const v = useCount({to: 255, dur: 40}); + return ( +
+ {Math.round(v)} +
+ ); +}; + +const RevealCell: React.FC = () => { + const s = useReveal('while (true) { think(); act(); }', {cps: 14}); + const blink = useBreathe({period: 16, amp: 0.5, base: 0.5}); + return ( +
+ {s} + +
+ ); +}; + +const PushInCell: React.FC = () => { + const t = usePushIn(0, {scale: 0.12}); + return ; +}; + +const DimCell: React.FC = () => { + const dim = useDim({at: 40, to: 0.35}); + return ( +
+ + + +
+ ); +}; + +const FlowCell: React.FC = () => { + const f = useFlowDash({period: 24}); + return ( + + + + ); +}; + +const ShakeCell: React.FC = () => { + const x = useShake({at: 10, amp: 5, decay: true, dur: 40}); + return ; +}; + +const FadeCell: React.FC = () => { + const op = useFadeOut(90, {frames: 36}); + return ( +
+ +
+
+ ); +}; + +const CELLS: Array<[string, React.ReactNode]> = [ + ['enter:fall', ], + ['enter:rise', ], + ['enter:slideL', ], + ['enter:pop', ], + ['enter:flyIn', ], + ['enter:fade', ], + ['stagger×5 fit60', ], + ['draw 24f', ], + ['impulse 30f', ], + ['breathe p30', ], + ['travel 3s/lap', ], + ['accelTravel', ], + ['count→255', ], + ['reveal 14cps', ], + ['pushIn .12', ], + ['dim .35@40', ], + ['flowDash p24', ], + ['shake decay', ], + ['fadeOut 36f', ], +]; + +const MotionGallery: React.FC = () => ( + +
+ MotionGallery · 30fps · 120f +
+ {CELLS.map(([name, node], i) => ( + + {node} + + ))} +
+); + +registerRoot(() => ( + +)); diff --git a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/hooks.ts b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/hooks.ts new file mode 100644 index 000000000..bfa87b05f --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/hooks.ts @@ -0,0 +1,268 @@ +/** 运动模型(hooks)——分镜「动效」列动词到帧数学的唯一映射。 + * + * 设计约束(违反任何一条即失去本层存在意义): + * 1. hooks 返回数值 / CSS 片段,不渲染 DOM——FadeUp 式包装组件打不进 svg// + * absolute 布局,是「组件存在却零调用」的实测根因;数值可落进任意 JSX。 + * 2. 弹簧一律吃局部帧(frame - at):spring() 每次调用从第 0 帧重模拟,喂全局帧 + * 会让长片末帧每个 spring 跑两万余次迭代。 + * 3. effects(不透明度/颜色)永不吃弹簧——一律时长+缓动(tokens 的二分不变量)。 + * 4. 不读 theme:颜色一律经参数传入(frozen 跨系列共享的前提)。 + * 5. `at` 锚点一律来自句边界(rel(beat, '句id')),禁写死帧数。 + */ +import {Easing, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + DUR, + EASING_CP, + SPRING, + clampRiseDist, + type DurToken, + type EasingToken, + type SpringPreset, +} from './tokens'; +import {progress} from './window'; +import {schedule, type ScheduleOpts} from './schedule'; + +/** 帧数解析:token 或直接帧数;缺省用 def。 */ +export const frames = (d: number | DurToken | undefined, def: number): number => + d === undefined ? def : typeof d === 'number' ? d : DUR[d]; + +/** 缓动令牌 → Remotion 缓动函数(linear 直通)。 */ +export const easeF = (t: EasingToken): ((x: number) => number) => + t === 'linear' + ? Easing.linear + : Easing.bezier( + ...(EASING_CP[t] as [number, number, number, number]), + ); + +/** 缓动后的 0..1 进度(各模型共用的原子)。 */ +const eased = ( + frame: number, + at: number, + dur: number, + e: EasingToken, +): number => interpolate(progress(frame, at, dur), [0, 1], [0, 1], {easing: easeF(e)}); + +// ── 入场(enter:落下/上浮/滑入/弹出/飞入/淡入) ──────────────────────── + +export type EnterKind = 'fall' | 'rise' | 'slideL' | 'slideR' | 'pop' | 'flyIn' | 'fade'; +export type EnterOpts = { + /** 句边界锚(局部帧)。 */ + at?: number; + dur?: number | DurToken; + easing?: EasingToken; + /** 空间通道用弹簧(位移类才有意义;fade/pop 无效)。 */ + springPreset?: SpringPreset; + /** 位移像素(fall/rise/slide*;缺省 30)。 */ + dist?: number; + /** rise 专用:落位态底边 y——行程经 clampRiseDist 钳进字幕安全带之上。 */ + restBottom?: number; +}; +export type EnterStyle = {opacity: number; transform: string}; + +export function useEnter(kind: EnterKind, o: EnterOpts = {}): EnterStyle { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const at = o.at ?? 0; + const dur = frames(o.dur, DUR.f4); + let dist = o.dist ?? 30; + if (kind === 'rise' && o.restBottom !== undefined) { + dist = clampRiseDist(dist, o.restBottom); + } + // effects 通道:纯缓动、略快于空间通道(元素先「看见」再「落位」) + const opacity = progress(frame, at, Math.max(2, Math.round(dur * 0.8))); + // spatial 通道:可选弹簧(局部帧 + durationInFrames 截停,防窗口外余振) + const p = o.springPreset + ? spring({ + frame: frame - at, + fps, + config: SPRING[o.springPreset], + durationInFrames: dur, + }) + : eased(frame, at, dur, o.easing ?? 'standard'); + const inv = 1 - p; + const t: string[] = []; + if (kind === 'fall') t.push(`translateY(${-inv * dist}px)`); + if (kind === 'rise') t.push(`translateY(${inv * dist}px)`); + if (kind === 'slideL') t.push(`translateX(${-inv * dist}px)`); + if (kind === 'slideR') t.push(`translateX(${inv * dist}px)`); + if (kind === 'pop') t.push(`scale(${0.9 + 0.1 * p})`); + if (kind === 'flyIn') t.push(`scale(${0.6 + 0.4 * p})`); + return {opacity, transform: t.length ? t.join(' ') : 'none'}; +} + +// ── 序列错峰(stagger:依次/逐行/逐条/逐格) ─────────────────────────── + +export type StaggerOpts = ScheduleOpts & {at?: number; easing?: EasingToken}; + +/** 返回 n 个 0..1 进度——第 i 项随编排依次入场。 */ +export function useStagger(n: number, o: StaggerOpts = {}): number[] { + const frame = useCurrentFrame(); + const {at = 0, easing = 'standard'} = o; + const plan = schedule(n, o); + return plan.starts.map((s) => eased(frame, at + s, plan.dur, easing)); +} + +// ── 描线(draw:红线三由构造保证——只产 pathLength 归一化三元组) ──────── + +export type DrawProps = {pathLength: 1; strokeDasharray: 1; strokeDashoffset: number}; + +export function useDraw(at: number, dur: number | DurToken = DUR.f5): DrawProps { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(dur, DUR.f5), 'decelerate'); + return {pathLength: 1, strokeDasharray: 1, strokeDashoffset: 1 - p}; +} + +// ── 脉冲 / 呼吸(glow 语法:impulse=一次性强调,breathe=持续辉光) ─────── + +/** 一次性冲击:sin(πp) 包络,起于 0 归于 0,峰值 peak。 */ +export function useImpulse(o: {at?: number; dur?: number | DurToken; peak?: number} = {}): number { + const frame = useCurrentFrame(); + const p = progress(frame, o.at ?? 0, frames(o.dur, DUR.f5)); + return Math.sin(Math.PI * p) * (o.peak ?? 1); +} + +/** 持续呼吸(原 0.55+0.45·sin(frame/K) 散写的收敛;period 帧一周期)。 */ +export function useBreathe(o: {period?: number; amp?: number; base?: number} = {}): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26} = o; + return base + amp * Math.sin((2 * Math.PI * frame) / period); +} + +// ── 巡游(travel:环形为主;absorb 原 useRingDot 与加速绕行累加器克隆) ── + +export type TravelPos = {x: number; y: number; angle: number}; + +/** 匀速环形巡游(angle 单位度,-90 = 12 点方向起)。 */ +export function useTravel(o: { + cx: number; + cy: number; + r: number; + secPerLap?: number; + offset?: number; +}): TravelPos { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const lap = (frame / (fps * (o.secPerLap ?? 2.5)) + (o.offset ?? 0)) % 1; + const a = -90 + lap * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), angle: a}; +} + +/** 加速绕行:逐圈时长 durs[](如 [40,30,22,16]),跑完全部圈后冻结在终点。 + * heat 0..1 随圈数推进(「失控感」的配色偏移系数)。 */ +export function useAccelTravel(o: { + cx: number; + cy: number; + r: number; + durs: number[]; + at?: number; + heatPerLap?: number; +}): {x: number; y: number; heat: number} { + const frame = useCurrentFrame(); + let t = Math.max(0, frame - (o.at ?? 0)); + let lap = 0; + while (lap < o.durs.length && t >= o.durs[lap]) { + t -= o.durs[lap]; + lap += 1; + } + const within = lap >= o.durs.length ? 1 : t / o.durs[lap]; + const heat = Math.min(1, lap / (o.heatPerLap ?? o.durs.length)); + const a = -90 + within * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), heat}; +} + +// ── 计数 / 水位(meter 语法;显示层自行 Math.round / toFixed) ────────── + +export function useCount(o: { + from?: number; + to: number; + at?: number; + dur?: number | DurToken; + ease?: EasingToken; +}): number { + const frame = useCurrentFrame(); + const {from = 0, to, at = 0} = o; + const p = eased(frame, at, frames(o.dur, DUR.f6), o.ease ?? 'standard'); + return from + (to - from) * p; +} + +// ── 打字机 / 逐字流出(type;Terminal 之外的泛化) ────────────────────── + +export function useReveal( + text: string, + o: {at?: number; cps?: number; framesPerChar?: number} = {}, +): string { + const frame = useCurrentFrame(); + const per = o.framesPerChar ?? Math.max(1, Math.round(30 / (o.cps ?? 12))); + const n = Math.floor(Math.max(0, frame - (o.at ?? 0)) / per); + return text.slice(0, Math.min(text.length, n)); +} + +// ── 镜头推近(pushIn 语法:beat 切换的镜头语言,替代纯淡入) ──────────── + +export function usePushIn(at: number, o: {scale?: number; dur?: number | DurToken} = {}): string { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(o.dur, DUR.f5), 'decelerate'); + return `scale(${1 + (o.scale ?? 0.06) * p})`; +} + +// ── 压暗 / 提亮(emphasis 反向:让主体从群像中浮出) ───────────────────── + +/** 返回目标透明度系数(1 = 原;to 0.4 即压暗到 40%)。 */ +export function useDim(o: {at: number; to?: number; dur?: number | DurToken}): number { + const frame = useCurrentFrame(); + const p = eased(frame, o.at, frames(o.dur, DUR.f4), 'standard'); + return 1 + ((o.to ?? 0.4) - 1) * p; +} + +// ── 流光(flow 语法:连线上的行进虚线) ───────────────────────────────── + +/** 返回可直接展开到 / 的描边属性(像素 dasharray——与 draw 的 + * pathLength 归一化描线是两个正交特性,勿混用于同一元素:红线三)。 */ +export function useFlowDash(o: { + dash?: number; + gap?: number; + /** 帧速率:每 period 帧行进一个 dash+gap 周期。 */ + period?: number; +}): {strokeDasharray: string; strokeDashoffset: number} { + const frame = useCurrentFrame(); + const {dash = 10, gap = 14, period = 40} = o; + return { + strokeDasharray: `${dash} ${gap}`, + strokeDashoffset: -(frame * (dash + gap)) / period, + }; +} + +// ── 抖动(错误/故障语义;收敛 P2/P3 两处克隆) ────────────────────────── + +/** 返回 translateX 像素值。active 缺省 true;decay=true 时按 dur 衰减归零。 */ +export function useShake(o: { + at: number; + active?: boolean; + amp?: number; + /** 相位分母(原手写 /1.6、/2.2 的口径)。 */ + freq?: number; + decay?: boolean; + dur?: number | DurToken; +}): number { + const frame = useCurrentFrame(); + const {amp = 3, freq = 1.6} = o; + const t = frame - o.at; + if (o.active === false || t < 0) { + return 0; + } + if (o.decay) { + const env = 1 - progress(frame, o.at, frames(o.dur, DUR.f5)); + return amp * env * Math.sin(t / freq); + } + return amp * Math.sin(t / freq); +} + +// ── 片尾渐黑(红线四:从 beat 总时长推导,勿用末句时长) ──────────────── + +export function useFadeOut(durationInFrames: number, o: {frames?: number} = {}): number { + const frame = useCurrentFrame(); + const f = o.frames ?? 36; // 1.2s + return 1 - progress(frame, durationInFrames - f, f); +} diff --git a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/index.ts b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/index.ts new file mode 100644 index 000000000..97fd22a5c --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/index.ts @@ -0,0 +1,5 @@ +/** 运动层门面——场景代码统一 `import {...} from '../motion'`。 */ +export * from './tokens'; +export * from './window'; +export * from './schedule'; +export * from './hooks'; diff --git a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/schedule.ts b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/schedule.ts new file mode 100644 index 000000000..0aa1ebfa4 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/schedule.ts @@ -0,0 +1,59 @@ +/** 错峰编排(stagger)纯函数——收敛手写 `frame - i*N`(跨 8 集约 356 处、 + * 步长 K∈{2,3,4,5,6,8,10} 任意取值、无节奏标尺)。 + * + * 三种模式(互斥,参数即文档): + * - stride:固定步长(与旧手写行为一致——迁移期的保守选项); + * - lag:Manim LaggedStart 语义 start[i] = i·dur·lagRatio(0 = 同刻齐动,1 = 首尾相接); + * - fit:n 个子项恰好装进窗口 total(末项恰在窗口末完成——「随句推进」的首选)。 + * + * 钳制优先级(motion.test.ts 钉死,高者让位低者):不外溢窗口 > 最小步长 > 子项时长。 + * 窗口属于 beat 时间轴,外溢会踩进下一 beat;装不下时**缩子项时长**,不延窗口。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点最小间隔,默认 2 帧(30fps 下仍可辨先后)。 */ + minStride?: number; + /** 子项时长下限,默认 3 帧(低于此相当于瞬现)。 */ + minDur?: number; +}; +export type Schedule = {starts: number[]; dur: number}; + +export function schedule(n: number, o: ScheduleOpts): Schedule { + const minStride = o.minStride ?? 2; + const minDur = o.minDur ?? 3; + const modes = [o.stride !== undefined, o.lag !== undefined, o.fit !== undefined].filter( + Boolean, + ).length; + if (modes > 1) { + throw new Error('schedule: stride / lag / fit 三选一'); + } + let dur = Math.max(minDur, o.dur ?? 5); + if (n <= 0) { + return {starts: [], dur}; + } + let stride: number; + if (o.fit) { + const total = Math.max(1, o.fit.total); + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + if (n > 1 && stride < minStride) { + // 装不下:缩子项时长换最小步长(保先后可辨),仍不外溢 + dur = Math.max(minDur, total - minStride * (n - 1)); + dur = Math.max(1, Math.min(dur, total)); + stride = Math.max(1, (total - dur) / (n - 1)); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 ? 0 : 1, stride); + return {starts: Array.from({length: n}, (_, i) => Math.round(i * stride)), dur}; +} diff --git a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/tokens.ts b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/tokens.ts new file mode 100644 index 000000000..457448260 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/tokens.ts @@ -0,0 +1,87 @@ +/** 运动令牌——时长标尺 / 缓动曲线 / 弹簧手感 的单一事实源。 + * + * 只放纯数据与纯函数:不 import remotion、不读 theme(颜色一律经参数注入)—— + * 这是本层能以 frozen 档跨两个系列共享的前提(两系列 theme token 名已分叉: + * CC 用 core/mech/deny、SE 用 danger),判据同 test_chrome_motifs_only_read_base_theme_tokens。 + * + * 取值依据(勿凭感觉改;改前先在本集校准轮逐幕目视复测,依据写回此处注释): + * - 时长六档取 IBM Carbon DTCG(70/110/150/240/400/700ms)@30fps 四舍五入。 + * 弃 Material 十六档:30fps 量化下其 15 个相邻对里 6 对落进同一帧数(伪选择)。 + * - 缓动控制点取 Material 3 标准三件;曲线本体在 hooks.ts 经 Remotion Easing.bezier 求值。 + * - 弹簧预设锚定本仓实测手感:settle=200 即既有 9/10 调用点的惯用值(延续成片观感), + * snap=12 来自 P4「插头咬合」的过冲;ζ 与过冲峰值的关系由 motion.test.ts 用 + * Mp = exp(-πζ/√(1-ζ²)) 钉死。 + * - effects 不变量:不透明度/颜色永不过冲——effects 类动画一律时长+缓动, + * 弹簧只用于空间位移(M3 spatial/effects 二分的落地)。 + */ + +/** 时长标尺(帧 @30fps)。叙事节拍(4–8s)不用此表——那是 window/schedule 的职责。 */ +export const DUR = { + /** 70ms:微反馈(辉光起点、光标) */ + f1: 2, + /** 110ms:快速子项(列表错峰的单项时长) */ + f2: 3, + /** 150ms:标准入场 */ + f3: 5, + /** 200ms:强调入场 */ + f4: 7, + /** 400ms:大位移 / 镜头推近 / 描线 */ + f5: 12, + /** 700ms:幕级大动作(少用) */ + f6: 21, +} as const; +export type DurToken = keyof typeof DUR; + +/** 缓动令牌。 */ +export type EasingToken = 'standard' | 'decelerate' | 'accelerate' | 'linear'; + +/** 贝塞尔控制点(x1,y1,x2,y2);linear 无控制点。 */ +export const EASING_CP: Record< + Exclude, + readonly [number, number, number, number] +> = { + // M3 standard:入场默认 + standard: [0.2, 0, 0, 1], + // M3 decelerate:强减速(大位移入场、镜头推近) + decelerate: [0.05, 0.7, 0.1, 1], + // M3 accelerate:出场加速 + accelerate: [0.3, 0, 0.8, 0.15], +}; + +/** 弹簧预设(直传 Remotion spring config;ζ = c/(2√(k·m)))。 */ +export type SpringPreset = 'settle' | 'settleSoft' | 'snap'; +export const SPRING: Record = { + // ζ≈10:无过冲平滑滑入——本仓主流手感(既有场景 9/10 处 damping 200) + settle: {damping: 200, stiffness: 100, mass: 1}, + // ζ≈8.5:更绵一点(P0/P1 既有 180/170 档的收敛) + settleSoft: {damping: 170, stiffness: 100, mass: 1}, + // ζ≈0.6:轻微过冲(咬合/弹入——原 P4 damping 12) + snap: {damping: 12, stiffness: 100, mass: 1}, +}; + +/** 阻尼比 ζ。设计系统文档普遍给 ζ(无量纲),Remotion 取阻尼系数 c——直抄会得 + * ζ≈0.02 的暴力弹跳且能通过渲染体检,这是迁移期最高风险项(单测钉死)。 */ +export const dampingRatio = (s: { + damping: number; + stiffness: number; + mass: number; +}): number => s.damping / (2 * Math.sqrt(s.stiffness * s.mass)); + +/** 欠阻尼弹簧的峰值位置(1 = 恰好到终点不过冲;ζ ≥ 1 恒 1)。 + * Mp = exp(-πζ/√(1-ζ²)) 是超出终点的幅度,峰值 = 1 + Mp。 */ +export const overshootPeak = (zeta: number): number => + zeta >= 1 ? 1 : 1 + Math.exp((-Math.PI * zeta) / Math.sqrt(1 - zeta * zeta)); + +/** 出场快于入场的系数(MDC 实测 400ms 入 / 150ms 出 ≈ 0.375,取 0.4 禁手填)。 */ +export const EXIT_FACTOR = 0.4; + +/** 字幕安全带上沿:1080 - qa_frames.SUBTITLE_BAND_PX(160),与体检口径同源。 */ +export const SAFE_TOP_Y = 920; + +/** 自下方入场的行程安全钳制:落位态底边 restBottom 之上才是可用的进场空间。 + * ISSUE-170 的手工逐卡反算收敛于此——该缺陷类从「评审抽帧抓」变「构造不可能」。 */ +export const clampRiseDist = ( + dist: number, + restBottom: number, + safeTop: number = SAFE_TOP_Y, +): number => Math.max(0, Math.min(dist, safeTop - restBottom)); diff --git a/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/window.ts b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/window.ts new file mode 100644 index 000000000..5d8e5c016 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/window.ts @@ -0,0 +1,28 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 audio-first 时序与可复用运动模型兼容的核心机制:beat 的绝对帧来自 + * beatWindow()(数据源是 TTS 实测 manifest),子动画不写死帧数、只声明自己在 + * beat 进度上的 [start, end] 窗口 ⇒ 旁白实测时长变化时全部窗口自动重定时, + * 「写死帧数与口播脱钩」缺陷类(skills/08 实录)由构造消灭。 + * + * 语义借 MDC TransitionUtils.lerp(startFraction, endFraction):窗外钳制端点。 + * 本模块零依赖(不 import remotion / theme)——frozen 跨系列共享与 node 单测的前提。 + */ + +/** 钳制到 [0,1]——一切进度的唯一出口,防负值/超 1 渗进 transform。 */ +export const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x); + +/** 子动画窗口:父进度 p(0..1) 在 [s,e] 片段上的局部进度。 */ +export const win = (p: number, w: readonly [number, number]): number => + clamp01((p - w[0]) / (w[1] - w[0])); + +/** beat 进度:局部帧 → 该 beat 的 0..1 进度。 */ +export const beatProgress = ( + frame: number, + from: number, + durationInFrames: number, +): number => clamp01((frame - from) / Math.max(1, durationInFrames)); + +/** 帧域进度:[at, at+dur] 上的 0..1(dur ≤ 0 视作 1,防除零)。 */ +export const progress = (frame: number, at: number, dur: number): number => + clamp01((frame - at) / Math.max(1, dur)); diff --git a/apps/negentropy-influence/episodes/self-improving-agents-video/video/scripts/motion.test.ts b/apps/negentropy-influence/episodes/self-improving-agents-video/video/scripts/motion.test.ts new file mode 100644 index 000000000..4a55380f7 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/scripts/motion.test.ts @@ -0,0 +1,123 @@ +/** + * 运动层纯函数单测——`node --test scripts/motion.test.ts`(Node ≥ 23.6 原生跑 TS)。 + * + * 刻意放在 video/ 而非 src/:tsconfig include 只有 src(frozen,不为测试改动), + * 而本文件 import 需带 .ts 后缀(Node ESM 解析规则)——tsc 若收编会因 + * allowImportingTsExtensions 未开而报错。src/ 的类型覆盖由 tsc --noEmit 全量保证, + * 本文件只测纯函数行为,不做类型承重。 + * + * 只 import 纯模块(tokens/window/schedule——零 remotion/react 依赖): + * hooks 是它们的薄包装,行为由 MotionGallery 目视 + 场景抽帧覆盖。 + */ +import {strict as assert} from 'node:assert'; +import test from 'node:test'; + +import {DUR, EASING_CP, EXIT_FACTOR, SAFE_TOP_Y, SPRING, clampRiseDist, dampingRatio, overshootPeak} from '../src/motion/tokens.ts'; +import {beatProgress, clamp01, progress, win} from '../src/motion/window.ts'; +import {schedule} from '../src/motion/schedule.ts'; + +// ── tokens ──────────────────────────────────────────────────────────── + +test('时长标尺在 30fps 下相邻档可辨(≥1 帧差)', () => { + const v = Object.values(DUR) as number[]; + for (let i = 1; i < v.length; i++) { + assert.ok(v[i] - v[i - 1] >= 1, `第 ${i} 档与前一档同帧数(伪选择)`); + } + assert.ok(v.length === 6); +}); + +test('ζ→过冲换算钉死:snap 轻过冲、settle 零过冲(直抄 dampingRatio 的反例护栏)', () => { + const zSnap = dampingRatio(SPRING.snap); + const zSettle = dampingRatio(SPRING.settle); + assert.ok(zSnap > 0.4 && zSnap < 0.9, `snap ζ=${zSnap}`); + // Mp = exp(-πζ/√(1-ζ²)) 是超出幅度;峰值 = 1 + Mp(ζ=0.6 → 峰值 ≈1.095) + assert.ok(overshootPeak(zSnap) > 1.02 && overshootPeak(zSnap) < 1.2, `snap 峰值 ${overshootPeak(zSnap)}`); + assert.ok(zSettle > 1, 'settle 须过阻尼'); + assert.equal(overshootPeak(zSettle), 1); + // 反例:把设计系统的 ζ 当 damping 直填(0.8)→ ζ≈0.04、峰值≈1.88 暴力弹跳 + const wrong = dampingRatio({damping: 0.8, stiffness: 100, mass: 1}); + assert.ok(overshootPeak(wrong) > 1.8, `直抄 ζ 的峰值=${overshootPeak(wrong)},必须被此断言抓住`); +}); + +test('缓动控制点合法(CSS 规则 x∈[0,1],且 x(t) 数值单调——可作函数求值)', () => { + for (const cp of Object.values(EASING_CP)) { + const [x1, , x2] = cp; + assert.ok(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1, `x 越界:${cp}`); + // x1 -1e-9, `x(t) 非单调 @t=${t}:${cp}`); + } + } +}); + +test('出场快于入场系数与安全带口径为常量', () => { + assert.equal(EXIT_FACTOR, 0.4); + assert.equal(SAFE_TOP_Y, 920); +}); + +test('clampRiseDist:自下方入场行程不探进字幕安全带', () => { + assert.equal(clampRiseDist(120, 836), 84, 'ISSUE-170 实测几何:rest 836 → 行程封顶 84'); + assert.equal(clampRiseDist(40, 836), 40, '未超限不动'); + assert.equal(clampRiseDist(120, 960), 0, '落位已在安全带内 → 零行程(退化但安全)'); +}); + +// ── window ──────────────────────────────────────────────────────────── + +test('clamp01 / progress / win 的钳制语义', () => { + assert.equal(clamp01(-1), 0); + assert.equal(clamp01(2), 1); + assert.equal(progress(10, 10, 10), 0, '起点为 0'); + assert.equal(progress(20, 10, 10), 1, '终点为 1'); + assert.equal(progress(5, 10, 10), 0, '窗外前钳 0'); + assert.equal(progress(99, 10, 10), 1, '窗外后钳 1'); + assert.equal(win(0.5, [0.25, 0.75]), 0.5); + assert.equal(win(0.1, [0.25, 0.75]), 0); + assert.equal(win(0.9, [0.25, 0.75]), 1); + assert.equal(beatProgress(0, -30, 90), 1 / 3); +}); + +// ── schedule ────────────────────────────────────────────────────────── + +test('fit 模式:末项恰在窗口末完成、不外溢', () => { + const p = schedule(5, {dur: 10, fit: {total: 90}}); + const last = p.starts[4] + p.dur; + assert.ok(last <= 90 + 1, `末项 ${last} 外溢`); + assert.ok(last >= 89, `末项 ${last} 未到窗口末`); +}); + +test('fit 装不下时缩子项时长,不外溢窗口(优先级:不外溢 > 最小步长 > 子项时长)', () => { + // total 30 时 (30-12)/7≈2.57 ≥ minStride=2 仍装得下;压到 24 才触发缩时长 + const p = schedule(8, {dur: 12, fit: {total: 24}}); + const last = p.starts[7] + p.dur; + assert.ok(last <= 31, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); +}); + +test('stride 模式与三选一守卫', () => { + assert.deepEqual(schedule(3, {dur: 5, stride: 4}).starts, [0, 4, 8]); + assert.throws(() => schedule(3, {dur: 5, stride: 4, lag: 1})); + assert.throws(() => schedule(3, {dur: 5, lag: 1, fit: {total: 40}})); +}); + +test('最小步长与空集', () => { + assert.deepEqual(schedule(0, {dur: 5, stride: 4}), {starts: [], dur: 5}); + const p = schedule(2, {dur: 5, stride: 0}); // 非法步长 → 抬到下限 + assert.ok(p.starts[1] - p.starts[0] >= 1); +}); + +test('起点取整且单调不减', () => { + const p = schedule(6, {dur: 7, fit: {total: 53}}); + for (let i = 1; i < p.starts.length; i++) { + assert.ok(p.starts[i] >= p.starts[i - 1], '起点须单调不减'); + assert.ok(Number.isInteger(p.starts[i])); + } +}); diff --git a/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/gallery.tsx b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/gallery.tsx new file mode 100644 index 000000000..ad2e067cf --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/gallery.tsx @@ -0,0 +1,265 @@ +/** MotionGallery——运动层的评审面(独立 Remotion 入口,不经 Root.tsx)。 + * + * 用法(在 video/ 目录,.bin 直调防污染根 workspace): + * ./node_modules/.bin/remotion still src/motion/gallery.tsx MotionGallery \ + * out/motion-gallery.png --frame=30 + * + * 全部模型 × 变体一屏可渲:秒级出图,供 token 校准轮逐格目视。 + * 色板为本文件内字面量(dev 工具面,不进成片、不读 theme——保持 frozen 跨系列共享)。 + */ +import React from 'react'; +import {AbsoluteFill, Composition, registerRoot} from 'remotion'; +import { + useAccelTravel, + useBreathe, + useCount, + useDim, + useDraw, + useEnter, + useFadeOut, + useFlowDash, + useImpulse, + usePushIn, + useReveal, + useShake, + useStagger, + useTravel, +} from './hooks'; + +const COLS = 5; +const CW = 360; +const CH = 240; +const GAP = 18; + +/** dev 工具面字面量色板(与各集 theme 底座同值但刻意独立声明——不读 theme)。 */ +const C = { + bg: '#0E1116', + panel: '#171C26', + border: '#2A3242', + text: '#F2F5FA', + dim: '#9AA7B8', + core: '#D97757', + mech: '#64C4C0', + deny: '#EF6461', +}; + +/** 单元格壳:定位 + 角标。 */ +const Cell: React.FC<{i: number; name: string; children: React.ReactNode}> = ({i, name, children}) => ( +
+
+ {children} +
+
+ {name} +
+
+); + +const Box: React.FC<{color?: string; w?: number; h?: number; style?: React.CSSProperties}> = ({ + color = C.core, + w = 200, + h = 80, + style, +}) =>
; + +// ── 每格一个组件:hooks 各归其位(Rules of Hooks 的最简守法形态) ──────── + +const EnterCell: React.FC<{kind: Parameters[0]}> = ({kind}) => { + const e = useEnter(kind, {}); + return ; +}; + +const StaggerCell: React.FC = () => { + const ps = useStagger(5, {dur: 5, fit: {total: 60}}); + return ( +
+ {ps.map((p, i) => ( +
+ ))} +
+ ); +}; + +const DrawCell: React.FC = () => { + const d = useDraw(0, 24); + return ( + + + + ); +}; + +const ImpulseCell: React.FC = () => { + const g = useImpulse({dur: 30, peak: 1}); + return ( +
+ ); +}; + +const BreatheCell: React.FC = () => { + const b = useBreathe({period: 30}); + return ( + + + + ); +}; + +const TravelCell: React.FC = () => { + const t = useTravel({cx: 140, cy: 55, r: 42, secPerLap: 3}); + return ( + + + + + ); +}; + +const AccelCell: React.FC = () => { + const t = useAccelTravel({cx: 140, cy: 55, r: 42, durs: [28, 20, 14], at: 4}); + const heat = `rgb(${217 + Math.round(38 * t.heat)}, ${119 - Math.round(60 * t.heat)}, ${87 - Math.round(20 * t.heat)})`; + return ( + + + + + ); +}; + +const CountCell: React.FC = () => { + const v = useCount({to: 255, dur: 40}); + return ( +
+ {Math.round(v)} +
+ ); +}; + +const RevealCell: React.FC = () => { + const s = useReveal('while (true) { think(); act(); }', {cps: 14}); + const blink = useBreathe({period: 16, amp: 0.5, base: 0.5}); + return ( +
+ {s} + +
+ ); +}; + +const PushInCell: React.FC = () => { + const t = usePushIn(0, {scale: 0.12}); + return ; +}; + +const DimCell: React.FC = () => { + const dim = useDim({at: 40, to: 0.35}); + return ( +
+ + + +
+ ); +}; + +const FlowCell: React.FC = () => { + const f = useFlowDash({period: 24}); + return ( + + + + ); +}; + +const ShakeCell: React.FC = () => { + const x = useShake({at: 10, amp: 5, decay: true, dur: 40}); + return ; +}; + +const FadeCell: React.FC = () => { + const op = useFadeOut(90, {frames: 36}); + return ( +
+ +
+
+ ); +}; + +const CELLS: Array<[string, React.ReactNode]> = [ + ['enter:fall', ], + ['enter:rise', ], + ['enter:slideL', ], + ['enter:pop', ], + ['enter:flyIn', ], + ['enter:fade', ], + ['stagger×5 fit60', ], + ['draw 24f', ], + ['impulse 30f', ], + ['breathe p30', ], + ['travel 3s/lap', ], + ['accelTravel', ], + ['count→255', ], + ['reveal 14cps', ], + ['pushIn .12', ], + ['dim .35@40', ], + ['flowDash p24', ], + ['shake decay', ], + ['fadeOut 36f', ], +]; + +const MotionGallery: React.FC = () => ( + +
+ MotionGallery · 30fps · 120f +
+ {CELLS.map(([name, node], i) => ( + + {node} + + ))} +
+); + +registerRoot(() => ( + +)); diff --git a/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/hooks.ts b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/hooks.ts new file mode 100644 index 000000000..bfa87b05f --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/hooks.ts @@ -0,0 +1,268 @@ +/** 运动模型(hooks)——分镜「动效」列动词到帧数学的唯一映射。 + * + * 设计约束(违反任何一条即失去本层存在意义): + * 1. hooks 返回数值 / CSS 片段,不渲染 DOM——FadeUp 式包装组件打不进 svg// + * absolute 布局,是「组件存在却零调用」的实测根因;数值可落进任意 JSX。 + * 2. 弹簧一律吃局部帧(frame - at):spring() 每次调用从第 0 帧重模拟,喂全局帧 + * 会让长片末帧每个 spring 跑两万余次迭代。 + * 3. effects(不透明度/颜色)永不吃弹簧——一律时长+缓动(tokens 的二分不变量)。 + * 4. 不读 theme:颜色一律经参数传入(frozen 跨系列共享的前提)。 + * 5. `at` 锚点一律来自句边界(rel(beat, '句id')),禁写死帧数。 + */ +import {Easing, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import { + DUR, + EASING_CP, + SPRING, + clampRiseDist, + type DurToken, + type EasingToken, + type SpringPreset, +} from './tokens'; +import {progress} from './window'; +import {schedule, type ScheduleOpts} from './schedule'; + +/** 帧数解析:token 或直接帧数;缺省用 def。 */ +export const frames = (d: number | DurToken | undefined, def: number): number => + d === undefined ? def : typeof d === 'number' ? d : DUR[d]; + +/** 缓动令牌 → Remotion 缓动函数(linear 直通)。 */ +export const easeF = (t: EasingToken): ((x: number) => number) => + t === 'linear' + ? Easing.linear + : Easing.bezier( + ...(EASING_CP[t] as [number, number, number, number]), + ); + +/** 缓动后的 0..1 进度(各模型共用的原子)。 */ +const eased = ( + frame: number, + at: number, + dur: number, + e: EasingToken, +): number => interpolate(progress(frame, at, dur), [0, 1], [0, 1], {easing: easeF(e)}); + +// ── 入场(enter:落下/上浮/滑入/弹出/飞入/淡入) ──────────────────────── + +export type EnterKind = 'fall' | 'rise' | 'slideL' | 'slideR' | 'pop' | 'flyIn' | 'fade'; +export type EnterOpts = { + /** 句边界锚(局部帧)。 */ + at?: number; + dur?: number | DurToken; + easing?: EasingToken; + /** 空间通道用弹簧(位移类才有意义;fade/pop 无效)。 */ + springPreset?: SpringPreset; + /** 位移像素(fall/rise/slide*;缺省 30)。 */ + dist?: number; + /** rise 专用:落位态底边 y——行程经 clampRiseDist 钳进字幕安全带之上。 */ + restBottom?: number; +}; +export type EnterStyle = {opacity: number; transform: string}; + +export function useEnter(kind: EnterKind, o: EnterOpts = {}): EnterStyle { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const at = o.at ?? 0; + const dur = frames(o.dur, DUR.f4); + let dist = o.dist ?? 30; + if (kind === 'rise' && o.restBottom !== undefined) { + dist = clampRiseDist(dist, o.restBottom); + } + // effects 通道:纯缓动、略快于空间通道(元素先「看见」再「落位」) + const opacity = progress(frame, at, Math.max(2, Math.round(dur * 0.8))); + // spatial 通道:可选弹簧(局部帧 + durationInFrames 截停,防窗口外余振) + const p = o.springPreset + ? spring({ + frame: frame - at, + fps, + config: SPRING[o.springPreset], + durationInFrames: dur, + }) + : eased(frame, at, dur, o.easing ?? 'standard'); + const inv = 1 - p; + const t: string[] = []; + if (kind === 'fall') t.push(`translateY(${-inv * dist}px)`); + if (kind === 'rise') t.push(`translateY(${inv * dist}px)`); + if (kind === 'slideL') t.push(`translateX(${-inv * dist}px)`); + if (kind === 'slideR') t.push(`translateX(${inv * dist}px)`); + if (kind === 'pop') t.push(`scale(${0.9 + 0.1 * p})`); + if (kind === 'flyIn') t.push(`scale(${0.6 + 0.4 * p})`); + return {opacity, transform: t.length ? t.join(' ') : 'none'}; +} + +// ── 序列错峰(stagger:依次/逐行/逐条/逐格) ─────────────────────────── + +export type StaggerOpts = ScheduleOpts & {at?: number; easing?: EasingToken}; + +/** 返回 n 个 0..1 进度——第 i 项随编排依次入场。 */ +export function useStagger(n: number, o: StaggerOpts = {}): number[] { + const frame = useCurrentFrame(); + const {at = 0, easing = 'standard'} = o; + const plan = schedule(n, o); + return plan.starts.map((s) => eased(frame, at + s, plan.dur, easing)); +} + +// ── 描线(draw:红线三由构造保证——只产 pathLength 归一化三元组) ──────── + +export type DrawProps = {pathLength: 1; strokeDasharray: 1; strokeDashoffset: number}; + +export function useDraw(at: number, dur: number | DurToken = DUR.f5): DrawProps { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(dur, DUR.f5), 'decelerate'); + return {pathLength: 1, strokeDasharray: 1, strokeDashoffset: 1 - p}; +} + +// ── 脉冲 / 呼吸(glow 语法:impulse=一次性强调,breathe=持续辉光) ─────── + +/** 一次性冲击:sin(πp) 包络,起于 0 归于 0,峰值 peak。 */ +export function useImpulse(o: {at?: number; dur?: number | DurToken; peak?: number} = {}): number { + const frame = useCurrentFrame(); + const p = progress(frame, o.at ?? 0, frames(o.dur, DUR.f5)); + return Math.sin(Math.PI * p) * (o.peak ?? 1); +} + +/** 持续呼吸(原 0.55+0.45·sin(frame/K) 散写的收敛;period 帧一周期)。 */ +export function useBreathe(o: {period?: number; amp?: number; base?: number} = {}): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26} = o; + return base + amp * Math.sin((2 * Math.PI * frame) / period); +} + +// ── 巡游(travel:环形为主;absorb 原 useRingDot 与加速绕行累加器克隆) ── + +export type TravelPos = {x: number; y: number; angle: number}; + +/** 匀速环形巡游(angle 单位度,-90 = 12 点方向起)。 */ +export function useTravel(o: { + cx: number; + cy: number; + r: number; + secPerLap?: number; + offset?: number; +}): TravelPos { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const lap = (frame / (fps * (o.secPerLap ?? 2.5)) + (o.offset ?? 0)) % 1; + const a = -90 + lap * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), angle: a}; +} + +/** 加速绕行:逐圈时长 durs[](如 [40,30,22,16]),跑完全部圈后冻结在终点。 + * heat 0..1 随圈数推进(「失控感」的配色偏移系数)。 */ +export function useAccelTravel(o: { + cx: number; + cy: number; + r: number; + durs: number[]; + at?: number; + heatPerLap?: number; +}): {x: number; y: number; heat: number} { + const frame = useCurrentFrame(); + let t = Math.max(0, frame - (o.at ?? 0)); + let lap = 0; + while (lap < o.durs.length && t >= o.durs[lap]) { + t -= o.durs[lap]; + lap += 1; + } + const within = lap >= o.durs.length ? 1 : t / o.durs[lap]; + const heat = Math.min(1, lap / (o.heatPerLap ?? o.durs.length)); + const a = -90 + within * 360; + const rad = (a * Math.PI) / 180; + return {x: o.cx + o.r * Math.cos(rad), y: o.cy + o.r * Math.sin(rad), heat}; +} + +// ── 计数 / 水位(meter 语法;显示层自行 Math.round / toFixed) ────────── + +export function useCount(o: { + from?: number; + to: number; + at?: number; + dur?: number | DurToken; + ease?: EasingToken; +}): number { + const frame = useCurrentFrame(); + const {from = 0, to, at = 0} = o; + const p = eased(frame, at, frames(o.dur, DUR.f6), o.ease ?? 'standard'); + return from + (to - from) * p; +} + +// ── 打字机 / 逐字流出(type;Terminal 之外的泛化) ────────────────────── + +export function useReveal( + text: string, + o: {at?: number; cps?: number; framesPerChar?: number} = {}, +): string { + const frame = useCurrentFrame(); + const per = o.framesPerChar ?? Math.max(1, Math.round(30 / (o.cps ?? 12))); + const n = Math.floor(Math.max(0, frame - (o.at ?? 0)) / per); + return text.slice(0, Math.min(text.length, n)); +} + +// ── 镜头推近(pushIn 语法:beat 切换的镜头语言,替代纯淡入) ──────────── + +export function usePushIn(at: number, o: {scale?: number; dur?: number | DurToken} = {}): string { + const frame = useCurrentFrame(); + const p = eased(frame, at, frames(o.dur, DUR.f5), 'decelerate'); + return `scale(${1 + (o.scale ?? 0.06) * p})`; +} + +// ── 压暗 / 提亮(emphasis 反向:让主体从群像中浮出) ───────────────────── + +/** 返回目标透明度系数(1 = 原;to 0.4 即压暗到 40%)。 */ +export function useDim(o: {at: number; to?: number; dur?: number | DurToken}): number { + const frame = useCurrentFrame(); + const p = eased(frame, o.at, frames(o.dur, DUR.f4), 'standard'); + return 1 + ((o.to ?? 0.4) - 1) * p; +} + +// ── 流光(flow 语法:连线上的行进虚线) ───────────────────────────────── + +/** 返回可直接展开到 / 的描边属性(像素 dasharray——与 draw 的 + * pathLength 归一化描线是两个正交特性,勿混用于同一元素:红线三)。 */ +export function useFlowDash(o: { + dash?: number; + gap?: number; + /** 帧速率:每 period 帧行进一个 dash+gap 周期。 */ + period?: number; +}): {strokeDasharray: string; strokeDashoffset: number} { + const frame = useCurrentFrame(); + const {dash = 10, gap = 14, period = 40} = o; + return { + strokeDasharray: `${dash} ${gap}`, + strokeDashoffset: -(frame * (dash + gap)) / period, + }; +} + +// ── 抖动(错误/故障语义;收敛 P2/P3 两处克隆) ────────────────────────── + +/** 返回 translateX 像素值。active 缺省 true;decay=true 时按 dur 衰减归零。 */ +export function useShake(o: { + at: number; + active?: boolean; + amp?: number; + /** 相位分母(原手写 /1.6、/2.2 的口径)。 */ + freq?: number; + decay?: boolean; + dur?: number | DurToken; +}): number { + const frame = useCurrentFrame(); + const {amp = 3, freq = 1.6} = o; + const t = frame - o.at; + if (o.active === false || t < 0) { + return 0; + } + if (o.decay) { + const env = 1 - progress(frame, o.at, frames(o.dur, DUR.f5)); + return amp * env * Math.sin(t / freq); + } + return amp * Math.sin(t / freq); +} + +// ── 片尾渐黑(红线四:从 beat 总时长推导,勿用末句时长) ──────────────── + +export function useFadeOut(durationInFrames: number, o: {frames?: number} = {}): number { + const frame = useCurrentFrame(); + const f = o.frames ?? 36; // 1.2s + return 1 - progress(frame, durationInFrames - f, f); +} diff --git a/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/index.ts b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/index.ts new file mode 100644 index 000000000..97fd22a5c --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/index.ts @@ -0,0 +1,5 @@ +/** 运动层门面——场景代码统一 `import {...} from '../motion'`。 */ +export * from './tokens'; +export * from './window'; +export * from './schedule'; +export * from './hooks'; diff --git a/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/schedule.ts b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/schedule.ts new file mode 100644 index 000000000..0aa1ebfa4 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/schedule.ts @@ -0,0 +1,59 @@ +/** 错峰编排(stagger)纯函数——收敛手写 `frame - i*N`(跨 8 集约 356 处、 + * 步长 K∈{2,3,4,5,6,8,10} 任意取值、无节奏标尺)。 + * + * 三种模式(互斥,参数即文档): + * - stride:固定步长(与旧手写行为一致——迁移期的保守选项); + * - lag:Manim LaggedStart 语义 start[i] = i·dur·lagRatio(0 = 同刻齐动,1 = 首尾相接); + * - fit:n 个子项恰好装进窗口 total(末项恰在窗口末完成——「随句推进」的首选)。 + * + * 钳制优先级(motion.test.ts 钉死,高者让位低者):不外溢窗口 > 最小步长 > 子项时长。 + * 窗口属于 beat 时间轴,外溢会踩进下一 beat;装不下时**缩子项时长**,不延窗口。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点最小间隔,默认 2 帧(30fps 下仍可辨先后)。 */ + minStride?: number; + /** 子项时长下限,默认 3 帧(低于此相当于瞬现)。 */ + minDur?: number; +}; +export type Schedule = {starts: number[]; dur: number}; + +export function schedule(n: number, o: ScheduleOpts): Schedule { + const minStride = o.minStride ?? 2; + const minDur = o.minDur ?? 3; + const modes = [o.stride !== undefined, o.lag !== undefined, o.fit !== undefined].filter( + Boolean, + ).length; + if (modes > 1) { + throw new Error('schedule: stride / lag / fit 三选一'); + } + let dur = Math.max(minDur, o.dur ?? 5); + if (n <= 0) { + return {starts: [], dur}; + } + let stride: number; + if (o.fit) { + const total = Math.max(1, o.fit.total); + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + if (n > 1 && stride < minStride) { + // 装不下:缩子项时长换最小步长(保先后可辨),仍不外溢 + dur = Math.max(minDur, total - minStride * (n - 1)); + dur = Math.max(1, Math.min(dur, total)); + stride = Math.max(1, (total - dur) / (n - 1)); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 ? 0 : 1, stride); + return {starts: Array.from({length: n}, (_, i) => Math.round(i * stride)), dur}; +} diff --git a/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/tokens.ts b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/tokens.ts new file mode 100644 index 000000000..457448260 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/tokens.ts @@ -0,0 +1,87 @@ +/** 运动令牌——时长标尺 / 缓动曲线 / 弹簧手感 的单一事实源。 + * + * 只放纯数据与纯函数:不 import remotion、不读 theme(颜色一律经参数注入)—— + * 这是本层能以 frozen 档跨两个系列共享的前提(两系列 theme token 名已分叉: + * CC 用 core/mech/deny、SE 用 danger),判据同 test_chrome_motifs_only_read_base_theme_tokens。 + * + * 取值依据(勿凭感觉改;改前先在本集校准轮逐幕目视复测,依据写回此处注释): + * - 时长六档取 IBM Carbon DTCG(70/110/150/240/400/700ms)@30fps 四舍五入。 + * 弃 Material 十六档:30fps 量化下其 15 个相邻对里 6 对落进同一帧数(伪选择)。 + * - 缓动控制点取 Material 3 标准三件;曲线本体在 hooks.ts 经 Remotion Easing.bezier 求值。 + * - 弹簧预设锚定本仓实测手感:settle=200 即既有 9/10 调用点的惯用值(延续成片观感), + * snap=12 来自 P4「插头咬合」的过冲;ζ 与过冲峰值的关系由 motion.test.ts 用 + * Mp = exp(-πζ/√(1-ζ²)) 钉死。 + * - effects 不变量:不透明度/颜色永不过冲——effects 类动画一律时长+缓动, + * 弹簧只用于空间位移(M3 spatial/effects 二分的落地)。 + */ + +/** 时长标尺(帧 @30fps)。叙事节拍(4–8s)不用此表——那是 window/schedule 的职责。 */ +export const DUR = { + /** 70ms:微反馈(辉光起点、光标) */ + f1: 2, + /** 110ms:快速子项(列表错峰的单项时长) */ + f2: 3, + /** 150ms:标准入场 */ + f3: 5, + /** 200ms:强调入场 */ + f4: 7, + /** 400ms:大位移 / 镜头推近 / 描线 */ + f5: 12, + /** 700ms:幕级大动作(少用) */ + f6: 21, +} as const; +export type DurToken = keyof typeof DUR; + +/** 缓动令牌。 */ +export type EasingToken = 'standard' | 'decelerate' | 'accelerate' | 'linear'; + +/** 贝塞尔控制点(x1,y1,x2,y2);linear 无控制点。 */ +export const EASING_CP: Record< + Exclude, + readonly [number, number, number, number] +> = { + // M3 standard:入场默认 + standard: [0.2, 0, 0, 1], + // M3 decelerate:强减速(大位移入场、镜头推近) + decelerate: [0.05, 0.7, 0.1, 1], + // M3 accelerate:出场加速 + accelerate: [0.3, 0, 0.8, 0.15], +}; + +/** 弹簧预设(直传 Remotion spring config;ζ = c/(2√(k·m)))。 */ +export type SpringPreset = 'settle' | 'settleSoft' | 'snap'; +export const SPRING: Record = { + // ζ≈10:无过冲平滑滑入——本仓主流手感(既有场景 9/10 处 damping 200) + settle: {damping: 200, stiffness: 100, mass: 1}, + // ζ≈8.5:更绵一点(P0/P1 既有 180/170 档的收敛) + settleSoft: {damping: 170, stiffness: 100, mass: 1}, + // ζ≈0.6:轻微过冲(咬合/弹入——原 P4 damping 12) + snap: {damping: 12, stiffness: 100, mass: 1}, +}; + +/** 阻尼比 ζ。设计系统文档普遍给 ζ(无量纲),Remotion 取阻尼系数 c——直抄会得 + * ζ≈0.02 的暴力弹跳且能通过渲染体检,这是迁移期最高风险项(单测钉死)。 */ +export const dampingRatio = (s: { + damping: number; + stiffness: number; + mass: number; +}): number => s.damping / (2 * Math.sqrt(s.stiffness * s.mass)); + +/** 欠阻尼弹簧的峰值位置(1 = 恰好到终点不过冲;ζ ≥ 1 恒 1)。 + * Mp = exp(-πζ/√(1-ζ²)) 是超出终点的幅度,峰值 = 1 + Mp。 */ +export const overshootPeak = (zeta: number): number => + zeta >= 1 ? 1 : 1 + Math.exp((-Math.PI * zeta) / Math.sqrt(1 - zeta * zeta)); + +/** 出场快于入场的系数(MDC 实测 400ms 入 / 150ms 出 ≈ 0.375,取 0.4 禁手填)。 */ +export const EXIT_FACTOR = 0.4; + +/** 字幕安全带上沿:1080 - qa_frames.SUBTITLE_BAND_PX(160),与体检口径同源。 */ +export const SAFE_TOP_Y = 920; + +/** 自下方入场的行程安全钳制:落位态底边 restBottom 之上才是可用的进场空间。 + * ISSUE-170 的手工逐卡反算收敛于此——该缺陷类从「评审抽帧抓」变「构造不可能」。 */ +export const clampRiseDist = ( + dist: number, + restBottom: number, + safeTop: number = SAFE_TOP_Y, +): number => Math.max(0, Math.min(dist, safeTop - restBottom)); diff --git a/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/window.ts b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/window.ts new file mode 100644 index 000000000..5d8e5c016 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/window.ts @@ -0,0 +1,28 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 audio-first 时序与可复用运动模型兼容的核心机制:beat 的绝对帧来自 + * beatWindow()(数据源是 TTS 实测 manifest),子动画不写死帧数、只声明自己在 + * beat 进度上的 [start, end] 窗口 ⇒ 旁白实测时长变化时全部窗口自动重定时, + * 「写死帧数与口播脱钩」缺陷类(skills/08 实录)由构造消灭。 + * + * 语义借 MDC TransitionUtils.lerp(startFraction, endFraction):窗外钳制端点。 + * 本模块零依赖(不 import remotion / theme)——frozen 跨系列共享与 node 单测的前提。 + */ + +/** 钳制到 [0,1]——一切进度的唯一出口,防负值/超 1 渗进 transform。 */ +export const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x); + +/** 子动画窗口:父进度 p(0..1) 在 [s,e] 片段上的局部进度。 */ +export const win = (p: number, w: readonly [number, number]): number => + clamp01((p - w[0]) / (w[1] - w[0])); + +/** beat 进度:局部帧 → 该 beat 的 0..1 进度。 */ +export const beatProgress = ( + frame: number, + from: number, + durationInFrames: number, +): number => clamp01((frame - from) / Math.max(1, durationInFrames)); + +/** 帧域进度:[at, at+dur] 上的 0..1(dur ≤ 0 视作 1,防除零)。 */ +export const progress = (frame: number, at: number, dur: number): number => + clamp01((frame - at) / Math.max(1, dur)); diff --git a/apps/negentropy-influence/pipeline/scripts/check_script.py b/apps/negentropy-influence/pipeline/scripts/check_script.py index b5d338174..164a6b5a4 100644 --- a/apps/negentropy-influence/pipeline/scripts/check_script.py +++ b/apps/negentropy-influence/pipeline/scripts/check_script.py @@ -14,6 +14,13 @@ 可选 --check-scenes:从 video/src/scenes/*.tsx 提取 beatWindow/w('id','id') 调用,与分镜表互比(WARN-only,TSX 正则本质近似)。 +可选 --check-motion:分镜「动效」列的 @动词 标注 ↔ 场景代码运动模型调用互比 +(WARN-only)。动效列可写 `@enter:fall`、`@stagger`、`@draw` 等(动词表从本集 +video/src/motion/hooks.ts 的 use* 导出**派生**,单一事实源不复制);镜内声明了 +@动词 而该幕场景文件未调用对应 use 模型 → WARN——「FadeUp 写在分镜里却没进代码」 +(本仓实测发生过 3 处)这一缺陷类的机械化。反向(代码用了模型而分镜没写)不报: +动效列是意图摘要而非全量清单。 + 可选 --pre-tts(TTS 前置门):只跑**不需要分镜**的检查——时长预算(估算口径; manifest 若在则含实测口径)+ 读法陷阱 + 发音标注合法性(build_narration 已在 生成期拦非法标注,此处对 narration.json 再收口一遍)。两遍法草稿遍(A 遍)写完 @@ -279,6 +286,63 @@ def check_scenes( warn(msgs, f"场景代码区间 {pair[0]}..{pair[1]} 未在分镜表中登记(分镜陈旧)") +#: 动效列的结构化标注:`@enter:fall` / `@stagger` / `@accelTravel` … +MOTION_TAG_RE = re.compile(r"@([A-Za-z][A-Za-z0-9]*)") + + +def parse_motion_tags(board: Path) -> list[tuple[str, str]]: + """返回 [(镜号, 动词)]。动效列 = 表格行第 4 单元格(| 镜 | 句区间 | 画面 | 动效 |)。""" + tags: list[tuple[str, str]] = [] + for raw in board.read_text(encoding="utf-8").splitlines(): + if not raw.startswith("|") or "---" in raw: + continue + cells = [c.strip() for c in raw.strip().strip("|").split("|")] + if len(cells) < 4 or not re.match(r"^\d+-[A-Z]\d*$", cells[0]): + continue + for m in MOTION_TAG_RE.finditer(cells[3]): + tags.append((cells[0], m.group(1))) + return tags + + +def check_motion(root: Path, msgs: list[str]) -> None: + """@动词 标注 ↔ 场景代码运动模型调用互比(WARN-only)。 + + 动词表从本集 video/src/motion/hooks.ts 派生(use 词首字母小写化), + 不在本文件复制第二份——hooks.ts 加模型,这里自动跟随。 + """ + board = root / "script" / "storyboard.md" + hooks_ts = root / "video" / "src" / "motion" / "hooks.ts" + if not hooks_ts.is_file(): + return # 运动层未铺设的集(如已冻结的旧集)——此门静默不适用 + verbs = { + m.group(1)[0].lower() + m.group(1)[1:] + for m in re.finditer( + r"export (?:async )?function use(\w+)", hooks_ts.read_text(encoding="utf-8") + ) + } + # 场景文件 → 该文件调用的运动模型(import 来源限定 ../motion,防同名误配) + scenes_dir = root / "video" / "src" / "scenes" + per_file: dict[str, set[str]] = {} + for tsx in sorted(scenes_dir.glob("*.tsx")): + src = tsx.read_text(encoding="utf-8") + used = set() + if re.search(r"from ['\"]\.\./motion['\"]", src): + for m in re.finditer(r"\buse([A-Z][A-Za-z0-9]*)\s*\(", src): + v = m.group(1)[0].lower() + m.group(1)[1:] + if v in verbs: + used.add(v) + per_file[tsx.name] = used + # beat 镜号数字前缀 → 幕场景文件(0-A → P0*.tsx) + for beat, verb in parse_motion_tags(board): + if verb not in verbs: + warn(msgs, f"镜 {beat}:@{verb} 不在运动模型词表(hooks.ts 派生;拼写?)") + continue + scene_pref = f"P{beat.split('-')[0]}" + owners = [n for n in per_file if n.startswith(scene_pref)] + if not owners or not any(verb in per_file[n] for n in owners): + warn(msgs, f"镜 {beat}:分镜声明 @{verb},但 {scene_pref} 场景代码未调用") + + def main() -> None: ap = argparse.ArgumentParser(description="④⑤ 内容门:覆盖性/预算/淡入不变式") ap.add_argument("--project", default=".", help="视频工程根目录") @@ -287,6 +351,11 @@ def main() -> None: action="store_true", help="附:分镜↔场景代码 beat 互比(WARN-only)", ) + ap.add_argument( + "--check-motion", + action="store_true", + help="附:分镜动效列 @动词 标注 ↔ 场景代码运动模型互比(WARN-only)", + ) ap.add_argument( "--pre-tts", action="store_true", @@ -354,6 +423,8 @@ def main() -> None: check_fade_invariant(root, msgs) if args.check_scenes: check_scenes(root, beats, msgs) + if args.check_motion: + check_motion(root, msgs) fails = [m for m in msgs if m.startswith("FAIL")] warns = [m for m in msgs if m.startswith("WARN")] diff --git a/apps/negentropy-influence/pipeline/scripts/pipeline.py b/apps/negentropy-influence/pipeline/scripts/pipeline.py index f665af55d..1ce57f689 100644 --- a/apps/negentropy-influence/pipeline/scripts/pipeline.py +++ b/apps/negentropy-influence/pipeline/scripts/pipeline.py @@ -287,15 +287,21 @@ def cmd_qa( ids: list[str], check: bool, scale: float | None, + beat_heads: int | None = None, + compare: list[str] | None = None, ) -> int: cmd = ["uv", "run", "--no-project"] - if check: + if check or compare: cmd += ["--with", "pillow", "--with", "numpy"] cmd += [str(SCRIPTS / "qa_frames.py"), "--project", str(root)] for s in scene or []: cmd += ["--scene", s] if last_n: cmd += ["--last-n", str(last_n)] + if beat_heads: + cmd += ["--beat-heads", str(beat_heads)] + if compare: + cmd += ["--compare", *compare] if check: cmd += ["--check"] # 字幕带/亮块间隔是全分辨率像素常数:草渲(0.5x)不折算则带高×2、间隔×2, @@ -513,6 +519,18 @@ def main() -> None: # 与 qa_frames 对齐:可重复传多幕(单值 store 会静默只留末幕,见 qa_frames 注释) p.add_argument("--scene", action="append", metavar="Pn") p.add_argument("--last-n", type=int) + p.add_argument( + "--beat-heads", + type=int, + metavar="N", + help="每 beat 头部连抽 N 帧(入场瞬态补盲,ISSUE-170)", + ) + p.add_argument( + "--compare", + nargs=2, + metavar=("A.mp4", "B.mp4"), + help="A/B 对拍(重制/重构回归归因;advisory)", + ) p.add_argument("--check", action="store_true", help="自动体检") p.add_argument( "--scale", @@ -572,6 +590,8 @@ def main() -> None: args.ids, args.check, args.scale, + getattr(args, "beat_heads", None), + getattr(args, "compare", None), ), "all": lambda: cmd_all(root, cfg), "clean-samples": lambda: cmd_clean_samples(root, cfg), diff --git a/apps/negentropy-influence/pipeline/scripts/qa_frames.py b/apps/negentropy-influence/pipeline/scripts/qa_frames.py index 55e762185..c6e456554 100644 --- a/apps/negentropy-influence/pipeline/scripts/qa_frames.py +++ b/apps/negentropy-influence/pipeline/scripts/qa_frames.py @@ -9,6 +9,16 @@ --scene P1 该幕抽样至多 ~8 帧 --last-n 6 末 N 句——直指「末句短于 beat → 渐黑提前 → 长黑尾」 上线 bug 的抽样盲区(尾幕必查) + --beat-heads N [--scene P4] + 每 beat 头部连抽 N 帧(0..N-1)——ISSUE-170 的机械 + 补盲:句中点采样结构性错过亚秒入场瞬态。可与 --scene + 组合过滤幕。此模式 --check 关闭冻帧判定(静止 beat + 的头帧指纹相同是合法态) +A/B 对拍(advisory,退出码恒 0,供重制/重构回归归因): + --compare A.mp4 B.mp4 --scene P4|<句id>… + 同帧号抽 A/B 两版逐帧差异(meanΔ / 差异像素占比 / + 变化区域 bbox),按占比降序——「不外溢的意图变更」 + 之外的一切差异都应被归因后再接受 自动体检(--check,惰性依赖 pillow+numpy): 黑帧/早渐黑 帧平均相对亮度 < 0.02 → FAIL(仅末 beat 且分镜末行写「渐黑」时豁免) @@ -113,6 +123,59 @@ def stills_plan(root: Path, chars_per_sec: float) -> None: ) +def beat_head_samples( + beats: list[tuple[str, str, str, str]], + tl: dict[str, tuple[float, float]], + fps: int, + n: int, + offset: float = 0.0, + scene_filter: list[str] | None = None, +) -> list[tuple[str, float]]: + """每 beat 头部连抽 N 帧的 (帧名, 时间戳)。beat 起点 = 其首句 start。 + + 纯函数(tests/test_qa_checks 对拍黄金帧号)。scene_filter 形如 ['P4']:按 + 镜号数字前缀过滤(0-A → P0)。区间首句不在 manifest 时跳过该 beat(分镜陈旧)。 + """ + samples: list[tuple[str, float]] = [] + for beat_id, left, _right, _cell in beats: + if scene_filter and beat_id.split("-")[0] not in { + sf.upper().lstrip("P") for sf in scene_filter + }: + continue + if left not in tl: + continue + start = tl[left][0] + for i in range(max(1, n)): + samples.append((f"{beat_id}-h{i}", start + i / fps - offset)) + return samples + + +def frame_diff(a: Path, b: Path) -> dict: + """两帧的差异摘要(纯函数,供 --compare 与单测)。 + + mean:RGB 三通道平均绝对差(0-255);frac:任一通道差 > DIFF_JND 的像素占比; + bbox:差异像素的包围盒 (x0, y0, x1, y1),全同帧为 None。JND 取 12——抗 jpeg + 压缩噪声的经验下限,非感知模型。 + """ + import numpy as np + from PIL import Image + + A = np.asarray(Image.open(a).convert("RGB"), dtype=np.float32) + B = np.asarray(Image.open(b).convert("RGB"), dtype=np.float32) + if A.shape != B.shape: + return {"mean": 255.0, "frac": 1.0, "bbox": None, "shape_mismatch": True} + d = np.abs(A - B).max(axis=2) + mask = d > DIFF_JND + if not mask.any(): + return {"mean": float(np.abs(A - B).mean()), "frac": 0.0, "bbox": None} + ys, xs = np.nonzero(mask) + return { + "mean": float(np.abs(A - B).mean()), + "frac": float(mask.mean()), + "bbox": (int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())), + } + + def extract_frame( ffmpeg: list[str], video: Path, cwd: Path, ts: float, dst: Path ) -> None: @@ -164,6 +227,8 @@ def extract_frame( #: 侵入物最小宽度(全分辨率像素,随 scale 折算):窄于此的多是抗锯齿碎片 INTRUSION_MIN_W_PX = 24 CONTRAST_MIN = 4.5 +#: A/B 对拍的逐像素刚可辨差异(just-noticeable diff 的经验值) +DIFF_JND = 12 def bright_segments(col, threshold: float) -> list[tuple[int, int]]: @@ -255,7 +320,12 @@ def tail_row_has_fade(board: Path) -> bool: def check_frames( - out: Path, ids: list[str], scale: float, fade_exempt_last: bool, msgs: list[str] + out: Path, + ids: list[str], + scale: float, + fade_exempt_last: bool, + msgs: list[str], + freeze_check: bool = True, ) -> None: try: import numpy as np @@ -300,10 +370,11 @@ def check_frames( np.asarray(Image.open(png).convert("L").resize((16, 16))) ) - for (a, _), (b, _) in zip(ordered, ordered[1:], strict=False): - if a.rsplit("-", 1)[0][:2] == b.rsplit("-", 1)[0][:2]: # 同幕前缀 - if hashes[a] == hashes[b]: - msgs.append(f"WARN {a} 与 {b} 帧指纹相同(疑似冻帧/beat 窗口错位)") + if freeze_check: + for (a, _), (b, _) in zip(ordered, ordered[1:], strict=False): + if a.rsplit("-", 1)[0][:2] == b.rsplit("-", 1)[0][:2]: # 同幕前缀 + if hashes[a] == hashes[b]: + msgs.append(f"WARN {a} 与 {b} 帧指纹相同(疑似冻帧/beat 窗口错位)") # ---------------- main ---------------- @@ -329,6 +400,19 @@ def main() -> None: parser.add_argument( "--check", action="store_true", help="对抽出的帧做自动体检(需 pillow+numpy)" ) + parser.add_argument( + "--beat-heads", + type=int, + metavar="N", + help="每 beat 头部连抽 N 帧(0..N-1,fps 间隔)——入场瞬态的机械补盲" + "(ISSUE-170);只可与 --scene 组合,此模式下 --check 关闭冻帧判定", + ) + parser.add_argument( + "--compare", + nargs=2, + metavar=("A.mp4", "B.mp4"), + help="A/B 对拍:同帧号抽两版逐帧差异(advisory;需 --scene/--last-n/ids 之一)", + ) parser.add_argument( "--check-theme", action="store_true", @@ -377,15 +461,100 @@ def main() -> None: sys.exit(1 if any(m.startswith("FAIL") for m in msgs) else 0) selectors = sum(bool(x) for x in (args.scene, args.last_n, args.ids)) - if not args.video or selectors != 1: + if args.beat_heads: + if args.last_n or args.ids: + parser.error("--beat-heads 只可与 --scene 组合过滤幕") + if not args.video: + parser.error("--beat-heads 需要