diff --git a/.agent/skills/science-video-pipeline/SKILL.md b/.agent/skills/science-video-pipeline/SKILL.md index e1e6a1690..b26e7cbac 100644 --- a/.agent/skills/science-video-pipeline/SKILL.md +++ b/.agent/skills/science-video-pipeline/SKILL.md @@ -18,8 +18,8 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash | ③ 逐字稿 | narration.md ★单一事实源 | [03](../../../apps/negentropy-influence/pipeline/skills/03-narration.md) | `pipeline.py build` | `build_narration.py` 通过 | | ④⑤ 双重校验 + 分镜 | 真实性回溯 + 易懂性;beat 覆盖性 | [04](../../../apps/negentropy-influence/pipeline/skills/04-verification.md) / [05](../../../apps/negentropy-influence/pipeline/skills/05-storyboard.md) | `pipeline.py check`(+`--check-scenes`) | RISKY=0;覆盖率无缺句 | | ⑥ TTS 配音 | 本人音色克隆(IndexTTS-2.5) | [07](../../../apps/negentropy-influence/pipeline/skills/07-tts-voice.md) | `pipeline.py tts --plan` | refs 指纹门 + 试听 + ETA | -| ⑦ Remotion 场景 | 代码动画实现 | [06](../../../apps/negentropy-influence/pipeline/skills/06-remotion-implementation.md) | `tsc --noEmit` | 七条渲染红线 | -| ⑧ 草渲 + 抽帧 QA | 半分辨率快速迭代 | [08](../../../apps/negentropy-influence/pipeline/skills/08-render-qa.md) | `pipeline.py render` + `qa` | 自动体检零 FAIL | +| ⑦ Remotion 场景 | 代码动画实现(动效走 `src/motion/` 运动模型,分镜动效列 `@动词` 标注) | [06](../../../apps/negentropy-influence/pipeline/skills/06-remotion-implementation.md) | `tsc --noEmit` + `node --test scripts/motion.test.ts` | 七条渲染红线 + 运动层铁律 | +| ⑧ 草渲 + 抽帧 QA | 半分辨率快速迭代(`--beat-heads` 入场瞬态补盲;重制回归 `--compare`) | [08](../../../apps/negentropy-influence/pipeline/skills/08-render-qa.md) | `pipeline.py render` + `qa` | 自动体检零 FAIL | | ⑨ 终渲 + 交付 | 1080p30 + srt/vtt | [09](../../../apps/negentropy-influence/pipeline/skills/09-final-render.md) | `render --final` + `captions` | 实测时长在预算窗 | ## 关键不变量 @@ -30,6 +30,6 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash - **证据分级**:B 型信源里「他人对闭源产品源码的分析」属三级证据,口播必须带归属句, 不得表述为产品既成事实;活数据(行数、总量、star 数)不进口播。 - 每集 `pipeline.toml` 是可执行参数的唯一来源;README 不复制命令行参数。 -- 时序常数只在 `video/src/timing.json`(timing.ts 与 Python 共读)。 +- 时序常数只在 `video/src/timing.json`(timing.ts 与 Python 共读);运动语汇(时长/缓动/弹簧/错峰)只在 `video/src/motion/`(frozen,改 = 模板 + 8 集同步)。 - 声音样本是生物特征:不入库(`voices/refs.toml` 只存指纹),试听后即删。 - 新集脚手架与复用边界(Python 集中 SSOT / Remotion 复制不共享)见 [pipeline/README.md](../../../apps/negentropy-influence/pipeline/README.md)。 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-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..5e29cab52 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/scripts/motion.test.ts @@ -0,0 +1,141 @@ +/** + * 运动层纯函数单测——`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, revealCharCount, 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); +}); + +test('逐字揭示:cps 按实际 fps 换算并向下取整,framesPerChar 保持固定帧间隔', () => { + assert.equal(revealCharCount(29, 60, 12), 5); + assert.equal(revealCharCount(30, 60, 12), 6); + assert.equal(revealCharCount(30, 24, 12, 3), 10); + assert.equal(revealCharCount(30, 120, 12, 3), 10); +}); + +// ── 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 <= 24, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('fit 窗口连 1 帧子项也容不下时显式拒绝,不返回外溢计划', () => { + assert.throws(() => schedule(8, {dur: 12, fit: {total: 5}}), /fit\.total=5.*8/); +}); + +test('fit 可行极限先牺牲子项时长,仍完整落在窗口内', () => { + const p = schedule(8, {dur: 12, fit: {total: 8}}); + assert.equal(p.dur, 1); + assert.equal(p.starts[7] + p.dur, 8); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); + assert.deepEqual(schedule(3, {dur: 10, lag: 0}).starts, [0, 0, 0], 'lag=0 须同刻齐动'); +}); + +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..a71737133 --- /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 = 340; +const CH = 230; +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..f87b8a129 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/hooks.ts @@ -0,0 +1,303 @@ +/** 运动模型(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, revealCharCount} 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)}); + +/** 缓动数值进度——「clamped 0→1 手写 interpolate」惯用形的统一替身: + * 时机值走 token,缓动默认 standard(线性原版会被这一档替换——质量提升点之一)。 */ +export function useProgress( + at: number, + dur: number | DurToken = DUR.f5, + easing: EasingToken = 'standard', +): number { + const frame = useCurrentFrame(); + return eased(frame, at, frames(dur, DUR.f5), easing); +} + +/** 弹簧数值进度(驱动几何量/位移;effects 通道用 useProgress——tokens 二分不变量)。 + * 局部帧 + 可选 durationInFrames 截停(防窗口外余振;缺省不设上限,与既有手写等价)。 */ +export function useSpring( + preset: SpringPreset, + o: {at?: number; dur?: number | DurToken} = {}, +): number { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const cfg: Parameters[0] = { + frame: frame - (o.at ?? 0), + fps, + config: SPRING[preset], + }; + if (o.dur !== undefined) { + cfg.durationInFrames = frames(o.dur, DUR.f5); + } + return spring(cfg); +} + +// ── 入场(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 帧一周期——注意与 + * 原除数 K 的换算 period = 2πK,如 sin(frame/26) → period 163)。offset 为相位 + * 平移(错峰辉光的 per-index 形态)。 */ +export function useBreathe( + o: {period?: number; amp?: number; base?: number; offset?: number} = {}, +): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26, offset = 0} = o; + return base + amp * Math.sin((2 * Math.PI * (frame - offset)) / 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 {fps} = useVideoConfig(); + const elapsed = Math.max(0, frame - (o.at ?? 0)); + const n = revealCharCount(elapsed, fps, o.cps ?? 12, o.framesPerChar); + 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..5703e3315 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/schedule.ts @@ -0,0 +1,65 @@ +/** 错峰编排(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;装不下时先缩子项时长,连 1 帧 + * 子项都无法错开时显式抛错,不返回违反 fit 契约的计划。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点期望下限,默认 2 帧;fit 紧窗时可按优先级退至 1 帧。 */ + minStride?: number; + /** 子项时长期望下限,默认 3 帧;fit 为守住窗口可退至 1 帧。 */ + 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); + if (n > 1 && total < n) { + throw new Error( + `schedule: fit.total=${total} 无法容纳 ${n} 个至少 1 帧且起点间隔至少 1 帧的子项`, + ); + } + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + const targetStride = Math.max(1, minStride); + if (n > 1 && stride < targetStride) { + // 先缩时长争取期望步长;仍不足时牺牲步长,但至少保留 1 帧先后。 + dur = Math.max(1, Math.min(dur, total - targetStride * (n - 1))); + stride = (total - dur) / (n - 1); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 || o.lag === 0 ? 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..69bd59c7a --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-concurrency-video/video/src/motion/window.ts @@ -0,0 +1,43 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 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)); + +/** 已过帧数 → 可见字符数;cps 随实际 fps 换算,显式帧间隔不受 fps 影响。 */ +export const revealCharCount = ( + elapsedFrames: number, + fps: number, + cps: number, + framesPerChar?: number, +): number => { + const elapsed = Math.max(0, elapsedFrames); + const count = + framesPerChar === undefined + ? Math.floor((elapsed / fps) * cps) + : Math.floor(elapsed / Math.max(1, framesPerChar)); + return Math.max(0, count); +}; diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/README.md b/apps/negentropy-influence/episodes/claude-code-explained-video/README.md index 232a779ca..17985c03a 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/README.md +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/README.md @@ -1,7 +1,7 @@ # 《执行层:一个循环,就是全部》 > 系列「Claude Code Harness Engineering」首作(发布顺序 SSOT:[../series.json](../../series.json);口播永不携带集数序号)。 -> **改造状态(2026-08-23)**:Harness Engineering 改造版——口播 v-next 已定稿、场景组件已升级、配音完成(167 句全缓存,`tts --plan` 待合成 0);草渲交付待审(终渲未启动)。 +> **改造状态(2026-09-03 运动层重制)**:全片动效收敛至 frozen 运动层(`video/src/motion/`,跨集共享「怎么动」);HarnessStack 系列装置落地(P0 开场落板+顶边常驻条+P6 收尾);配音全量重合成(音频版本库入库,改稿只重配变更句)。 > 选题:开源课程 [Learn Claude Code](https://learn.shareai.run/zh/s01/) 的「工具与执行」四章 > —— s01 Agent Loop / s02 Tool Use / s03 Permission / s04 Hooks。 > 形态:1080p30 横屏,代码动画图解 + 本人音色克隆旁白,无真人出镜、无 BGM。 @@ -11,17 +11,17 @@ | 项 | 状态 | |---|---| -| 逐字稿 | ✅ 170 句 / 4048 字 / 7 幕 | +| 逐字稿 | ✅ 167 句 / 3979 字 / 7 幕(原「170 句/4048 字」为勘误——narration.json 实测口径) | | 分镜 | ✅ 39 镜,`check_script` 覆盖性 FAIL 0 | | 信源取证 | ✅ 12 条双轨信源入清单(8 条仓库 @ 固定提交 + 4 条站点),`source_ledger verify` FAIL 0 | | 场景实现 | ✅ 7 幕 / 39 镜,`tsc --noEmit` 通过;39 镜逐镜抽帧复检无黑帧、无重复帧 | -| 配音 | ✅ 170/170 句,`sunny-steady` + `me-bright.wav`(`.engine` 签名 `indextts\|sunny-steady\|54b699cce97f`);纯语音 13.20 分钟,**实测语速 307 字/分**;合成墙钟 2.1 h;2026-08-22 缓存随 `--update` 迁移复验 `tts --plan`:**待合成 0 句 / 已缓存 170 句**(画面优化零重合成)| -| 时长双口径门 | ✅ 估算 14.5 分钟(4048 字 ÷ 280)· **实测含时距 14.2 分钟**,均落预算窗 13.0–14.6;`check` FAIL 0 WARN 0 | -| 字幕 | ✅ `out/captions.srt` + `.vtt`,各 170 cue | +| 配音 | ✅ 167/167 句,`sunny-steady` + `me-bright.wav`(`.engine` 签名 `indextts\|sunny-steady\|54b699cce97f`);纯语音 12.95 分钟(2026-09-03 全量重合成,墙钟 1.6 h——旧缓存随 worktree 遗失,本次入库**音频版本库** `~/Library/Application Support/negentropy-influence/tts-store`,跨工作区改稿只重配变更句,回收幂等已 e2e 验证)| +| 时长双口径门 | ✅ 估算 14.2 分钟(3979 字 ÷ 280)· **实测含时距 13.98 分钟**(838.8 秒 / 25164 帧;TTS 复跑漂移 -478 帧属正常 nondeterminism),均落预算窗 13.0–14.6;`check --check-scenes --check-motion` FAIL 0 WARN 0 | +| 字幕 | ✅ `out/captions.srt` + `.vtt`,各 167 cue | | 主题对比度 | ✅ `--check-theme` FAIL 0(core 6.06 / mech 9.18 / deny 6.00 : 1,全过 4.5:1) | | 尾幕渐黑 | ✅ 末 beat 中部 0.085 → 前 24 帧 0.056 → 末帧 0.012,窗口贴合 beat 结尾(无提前收尾的长黑屏) | -| 草渲 + 抽帧 QA | ✅ 25642 帧 / 6.0 分钟;7 幕 FAIL 0(WARN 1 为刻意设计:p6-06/07 信源卡停留;p0 画面凝住为 0-A 的 `freezeCursorAt` 设计,冻结帧指纹检测豁免) | -| **终渲交付** | ✅ `out/final.mp4` **14:14.73** · 1920×1080 @30fps · h264 yuvj420p 201kb/s · aac 189kb/s · 42.6 MB(42,621,158 字节;末次重渲 7.9 分钟)
时长**可复算**:`total_duration_in_frames(narration.json, timing.json)` = 25642 帧 @30fps = 854.73s。narration / manifest / timing.json 本轮零改动 ⇒ 画面优化不改变时长,此数与首次交付一致
终渲全分辨率七幕抽帧 **FAIL 0 · WARN 1**(p0-09/10 帧指纹相同 = 0-D 标题卡跨两句静止,刻意)
**2026-08-22 画面优化版**(narration 与配音零改动,纯 tier-i 视觉增强 + `--update` 走查):P6 三挂件卡物理咬合上环 + 信源卡增站点/双钉/实测口径诚实行;P0 走秒芯片(凝住即停)、轮次钢印 01/02/03、载荷箭头(命令/输出);P5 环第 6 次出场 + 20–28 行实测基准带;1-D 诚实角注(站点教学版仍按停止标记);3-C 三判定小抄(allow/ask/deny 各带载荷);`📣`/`⚠` emoji 全部替换为绘制图形
**同日评审四修**(逐帧抽帧实证,见下方「评审修复」):6-A 挂件卡文字朝向与落位、6-A 挂件卡**入场瞬态**压字幕安全带、5-B 基准带锚点与标注避让、3-C 小抄不再遮挡闸门名。末次重渲 473s,七幕抽帧复检 **FAIL 0 · WARN 1**(仍为 0-D 刻意静止) | +| 草渲 + 抽帧 QA | ✅ 25164 帧;七幕中点 + **beat 头部连抽**(39 镜 × 4 帧,ISSUE-170 入场瞬态补盲)+ 末 6 句全量 **FAIL 0 · WARN 0**;A/B 对拍(基线=重制前代码、同 manifest):167 帧中仅 2 帧 >5%(p0-01/p6-08 = HarnessStack 新装置),其余 ≤1.5% 且全部归因(顶边常驻条 / SceneTag 移位右上 / 令牌化缓动 / 硬门改淡入)| +| **终渲交付** | ✅ `out/final.mp4` **13:58.80**(838.8 秒 / 25164 帧,与草渲同 manifest 同帧数)· 1920×1080 @30fps · 43.6 MB;`cover.png` = 标题卡帧 1031
时长**可复算**:`total_duration_in_frames(narration.json, timing.json)` = 25164 帧 @30fps = 838.8s
终渲全分辨率七幕抽帧 **FAIL 0 · WARN 1**(p0-09/10 帧指纹相同 = 0-D 标题卡跨两句静止,刻意)
**2026-08-22 画面优化版**(narration 与配音零改动,纯 tier-i 视觉增强 + `--update` 走查):P6 三挂件卡物理咬合上环 + 信源卡增站点/双钉/实测口径诚实行;P0 走秒芯片(凝住即停)、轮次钢印 01/02/03、载荷箭头(命令/输出);P5 环第 6 次出场 + 20–28 行实测基准带;1-D 诚实角注(站点教学版仍按停止标记);3-C 三判定小抄(allow/ask/deny 各带载荷);`📣`/`⚠` emoji 全部替换为绘制图形
**同日评审四修**(逐帧抽帧实证,见下方「评审修复」):6-A 挂件卡文字朝向与落位、6-A 挂件卡**入场瞬态**压字幕安全带、5-B 基准带锚点与标注避让、3-C 小抄不再遮挡闸门名。末次重渲 473s,七幕抽帧复检 **FAIL 0 · WARN 1**(仍为 0-D 刻意静止) | | 交付件 | ✅ `final.mp4` + `captions.srt` + `captions.vtt` + `cover.png`(标题卡帧,1920×1080) | > 音画同步的验证方式:配音期间**分幕**用已合成的真实时长重算 beat 帧位并逐镜抽帧复检 @@ -44,9 +44,7 @@ | 5-B `P5Stack.tsx` | 「20–28 行实测带」悬在 core 段上方约 60px | 带体锚在区间**上界** + `+90` 比柱底实距多 45px | 锚到下界 `yFor(20) + BAR_BASE`;标注移到带右侧(原位置撞四柱的 loop 行数) | | 3-C `P3Gates.tsx` | 闸门名「拒绝表 / 规则匹配」被 allow/ask/deny 小抄完全盖住 | 小抄 `top: 150` 与 `GateRouter` 的 `y - h - 18`(本幕 y≈243)相交 | 小抄整体上移到 `top: 56`,收在闸柱之上 | -> 同轮修正两处**失实的交付数字**:时长 14:12.52 → **14:14.73**(narration / timing.json -> 本轮零改动,`total_duration_in_frames` 复算恒为 25642 帧,画面改动不可能改变时长); -> 终渲抽帧 WARN 0 → **WARN 1**(0-D 标题卡跨两句静止,刻意)。这正是 ISSUE-168 的形态: +> 同轮还将终渲抽帧 WARN 0 修正为 **WARN 1**(0-D 标题卡跨两句静止,刻意)。这正是 ISSUE-168 的形态: > 旧 `--scene` 单值 store 只查末幕,出来的「WARN 0」与「全幕通过」不可区分。 ## 目录结构 @@ -63,6 +61,8 @@ | `scripts/*.py` | 薄包装,转发到 [`../../pipeline/scripts/`](../../pipeline/scripts/) | | [video/](./video/) | Remotion 独立 pnpm 工程 | | `video/src/components/motifs.tsx` | 本集五个视觉母题(终端 / 环形循环 / 分发表 / 闸门 / 插槽) | +| `video/src/motion/` | ★frozen 运动层(令牌/窗口/编排/12 运动模型 hooks——规格见 [skills/06 运动层](../../pipeline/skills/06-remotion-implementation.md)) | +| `video/src/components/harness-stack.tsx` | 系列身份装置(P0 开场栈 / 顶边常驻条 / P6 收尾栈;层数据派生自 series.json) | | `out/` | 渲染产物(gitignored) | ## 复现流水线 diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/script/storyboard.md b/apps/negentropy-influence/episodes/claude-code-explained-video/script/storyboard.md index e23b84237..05b9accc0 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/script/storyboard.md +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/script/storyboard.md @@ -13,72 +13,72 @@ | 镜 | 句区间 | 画面 | 动效 | |---|---|---|---| -| 0-A | p0-01..02 | 全屏终端窗口(`panel` 底 + `panelBorder` 框),顶栏三个灰点。命令打完光标变灰凝住;右侧对话框浮出 | 终端打字机(每字 2 帧);命令打完后光标**停止闪烁并变灰**,画面凝住 0.6s;p0-02 三轮「复制→粘贴→回车→回贴」搬运弧线**加速**塞满灰色残影(节奏比旧版快一倍) | -| 0-B | p0-03..05 | 全部搬运残影瞬间收束成一条外框线,包住一枚「模型」内核——**命名帧**:外框即Harness | 外框自四面向内合拢包住内核(spring);「Harness」二字落框(serif 大字),英文 agentic harness 进角标;p0-05 官方引文条压上("Claude Code is the harness, Claude is the model" 中英并列淡入) | -| 0-C | p0-06..07 | Harness供给图:外框上长出五根辐条(文件 / 命令 / 权限 / 记忆 / 护栏),内侧连着模型内核 | p0-06 五根辐条逐根点亮(`mech` 辉光各 8 帧);「记忆」那根先灰并浮「后面拆」小标;p0-07 外框整体轻旋一次,片名标题卡压入(`core` 色细线自中心向两侧生长) | +| 0-A | p0-01..02 | 全屏终端窗口(`panel` 底 + `panelBorder` 框),顶栏三个灰点。命令打完光标变灰凝住;右侧对话框浮出 | 终端打字机(每字 2 帧);命令打完后光标**停止闪烁并变灰**,画面凝住 0.6s;p0-02 三轮「复制→粘贴→回车→回贴」搬运弧线**加速**塞满灰色残影(节奏比旧版快一倍) ;`@stagger` `@progress`(系列栈落板错峰 + 搬运弧线等速行程——机械感是主题,用纯 progress) | +| 0-B | p0-03..05 | 全部搬运残影瞬间收束成一条外框线,包住一枚「模型」内核——**命名帧**:外框即Harness | 外框自四面向内合拢包住内核(spring);「Harness」二字落框(serif 大字),英文 agentic harness 进角标;p0-05 官方引文条压上("Claude Code is the harness, Claude is the model" 中英并列淡入) ;`@spring` `@breathe` `@progress`(命名帧合拢为本集豁免运动层的签名镜头,弹簧直接驱动几何) | +| 0-C | p0-06..07 | Harness供给图:外框上长出五根辐条(文件 / 命令 / 权限 / 记忆 / 护栏),内侧连着模型内核 | p0-06 五根辐条逐根点亮(`mech` 辉光各 8 帧);「记忆」那根先灰并浮「后面拆」小标;p0-07 外框整体轻旋一次,片名标题卡压入(`core` 色细线自中心向两侧生长) ;`@stagger` `@spring` `@progress` | ## P1 一个循环,就是全部 | 镜 | 句区间 | 画面 | 动效 | |---|---|---|---| -| 1-A | p1-01..07 | **环形循环**(全片视觉锚):`core` 色闭合轨道居中,轨道上四个节点「问模型 / 看回答 / 执行工具 / 填回结果」。右侧分出一支「停机」出口 | 轨道用 `interpolate` 描线成形(0→1,1.2s);一个 `core` 光点沿轨匀速巡游(周期 2.5s);讲到「有」时工具节点 `mech` 辉光脉冲一次并让光点继续,讲到「没有」时光点滑出到「停机」出口并定格 | -| 1-B | p1-08..12 | 环形循环缩至左侧 40%,右侧展开五步编号列表 + 一个 `messages[]` 面板 | 五步逐句点亮(每句一条,`FadeUp` 12px);每点亮一步,`messages[]` 面板**长高一格**并压入一个色块(用户句=dim / 模型回答=core / 工具结果=mech);第五步点亮后一条弧线从列表尾弹回第二步 | -| 1-C | p1-13..16 | 全屏代码卡:`agent_loop` 的真实骨架(mono,约 12 行精简版),右下角计数「23」 | 代码逐行渲染(每行 3 帧);「二十三行」时行号列整体 `core` 辉光一次,计数器从 0 滚动到 23;p1-15/16 时画面左右分屏——左「模型:决定」右「脚手架:执行」,中间一条竖线 | -| 1-D | p1-17..24 | 上半:模型回答的字符流从左向右逐字流出,其中一段 `mech` 色的「工具调用」块提前出现;下半:一个「停止原因」标记牌仍显示「进行中」,滞后一拍才翻牌 | 字符流以 12 字/秒流动;工具调用块出现时 `mech` 辉光;标记牌**延迟 25 帧**才翻转(视觉化「不可靠」);p1-22 时标记牌打上 `deny` 色斜叉;p1-24 金句卡 `serif` 居中淡入 | -| 1-E | p1-25..29 | **三相环升级**:主环上浮现三个相位标签(收集 / 行动 / 验证)交融旋转;一只「手」图标随时可插入环上任意点;右侧十格抽屉墙以角标清单形式一闪 | 三个相位标签沿环依次点亮并**保持同时可见**(交融感,非硬阶段);p1-27 手图标落向环上一节点、环上光点被拨偏一档(人可打断);p1-28/29 十格抽屉墙快速亮起(20ms/格)后整体退至 40% 亮度、State 字段清单进角标 | -| 1-F | p1-30..32 | 环形循环的「停机」出口分岔成五条细支线,各标一个退出原因;随后五条支线淡为 `dim`,只留主环 | 支线逐条以 `interpolate` 描线(每句一条);p1-32 时五条支线亮度降到 30% 并后移,主环 `core` 亮度不变——「它们是保护装置,不是骨架」 | +| 1-A | p1-01..07 | **环形循环**(全片视觉锚):`core` 色闭合轨道居中,轨道上四个节点「问模型 / 看回答 / 执行工具 / 填回结果」。右侧分出一支「停机」出口 | 轨道用 `interpolate` 描线成形(0→1,1.2s);一个 `core` 光点沿轨匀速巡游(周期 2.5s);讲到「有」时工具节点 `mech` 辉光脉冲一次并让光点继续,讲到「没有」时光点滑出到「停机」出口并定格 ;`@progress`(描线 decelerate + 出口滑出) | +| 1-B | p1-08..12 | 环形循环缩至左侧 40%,右侧展开五步编号列表 + 一个 `messages[]` 面板 | 五步逐句点亮(每句一条,`FadeUp` 12px);每点亮一步,`messages[]` 面板**长高一格**并压入一个色块(用户句=dim / 模型回答=core / 工具结果=mech);第五步点亮后一条弧线从列表尾弹回第二步 ;`@stagger`(五步非均匀锚点显式五行) | +| 1-C | p1-13..16 | 全屏代码卡:`agent_loop` 的真实骨架(mono,约 12 行精简版),右下角计数「23」 | 代码逐行渲染(每行 3 帧);「二十三行」时行号列整体 `core` 辉光一次,计数器从 0 滚动到 23;p1-15/16 时画面左右分屏——左「模型:决定」右「脚手架:执行」,中间一条竖线 ;`@progress` | +| 1-D | p1-17..24 | 上半:模型回答的字符流从左向右逐字流出,其中一段 `mech` 色的「工具调用」块提前出现;下半:一个「停止原因」标记牌仍显示「进行中」,滞后一拍才翻牌 | 字符流以 12 字/秒流动;工具调用块出现时 `mech` 辉光;标记牌**延迟 25 帧**才翻转(视觉化「不可靠」);p1-22 时标记牌打上 `deny` 色斜叉;p1-24 金句卡 `serif` 居中淡入 ;`@progress`(打字机保持内联:按 toolIdx 分色切片) | +| 1-E | p1-25..29 | **三相环升级**:主环上浮现三个相位标签(收集 / 行动 / 验证)交融旋转;一只「手」图标随时可插入环上任意点;右侧十格抽屉墙以角标清单形式一闪 | 三个相位标签沿环依次点亮并**保持同时可见**(交融感,非硬阶段);p1-27 手图标落向环上一节点、环上光点被拨偏一档(人可打断);p1-28/29 十格抽屉墙快速亮起(20ms/格)后整体退至 40% 亮度、State 字段清单进角标 ;`@stagger` `@spring` | +| 1-F | p1-30..32 | 环形循环的「停机」出口分岔成五条细支线,各标一个退出原因;随后五条支线淡为 `dim`,只留主环 | 支线逐条以 `interpolate` 描线(每句一条);p1-32 时五条支线亮度降到 30% 并后移,主环 `core` 亮度不变——「它们是保护装置,不是骨架」 ;`@stagger` | ## P2 加一个工具,只改一行 | 镜 | 句区间 | 画面 | 动效 | |---|---|---|---| -| 2-A | p2-01..04 | 环形循环留在左上角(缩小但**同色同线宽**)。主体是终端:模型的意图气泡「读这个文件」旁边,笨拙地拼出一条命令 | 意图气泡与命令行之间画一条「翻译」箭头,箭头上叠一个 `deny` 色的小计数「+1 层」;p2-02 打出两条拼接命令(读/写各一条);p2-04 时命令里一个字符被高亮成 `deny` 并抖动(拼错) | -| 2-B | p2-05..06 | 五张工具卡横排弹出(panel 底 + 编号,反枚举)+ 卡下官方五类分区标注;两个虚线空槽(执行/网页——官方地图另有) | 卡片 spring 弹入(仅编号染 mech);分区小字浮现;两空槽淡入(`panelBorder` 虚线);角标「官方内置五类:文件/搜索/执行/网页/代码智能」 | -| 2-C | p2-07..10 | **字典分发表**:两列表格,左列工具名(mono),右列处理函数名。旁边一个前台号码簿的极简图形 | p2-07 表格框线描出;p2-08 逐行填入(每行 4 帧);p2-09 一个「来电」光点从左飞入命中某一行 → 整行推入 `mech` 辉光 → 右列函数名弹出;p2-10 表格底部**多出一行空槽**,一条新映射滑入落槽 | -| 2-D | p2-11..13 | 左右并置两段代码(前后两版),仅执行那一行不同 | 两段代码同时淡入,**逐行做同色对齐比对**;唯一不同的那一行左侧打 `mech` 竖标并整行高亮,其余行降到 40% 亮度;p2-13 时左上角环形循环 `core` 脉冲一次(强调"它没动") | -| 2-E | p2-14..16 | 模型一次吐出四个工具调用卡,排成一列等待 | 四张卡以 80ms 间隔落入队列;p2-15 左右分屏:左侧「排队」四卡依次亮(串行),右侧「并行」前三卡同时亮;p2-16 画面中央压入一行提示条(`core` 描边) | -| 2-F | p2-17..24 | **真值表**:三列(工具 / 只读 / 能并行),五行(读文件、找文件、`ls`、`rm`、建任务) | p2-17 先只显示「只读」列并全部打勾(符合直觉);p2-19 时 `ls` 与 `rm` 两行的「能并行」列**结论分岔**——`ls` 打 `mech` 勾、`rm` 打 `deny` 叉,两行之间画一条对撞线;p2-20 两行工具名同时闪一次(同名不同命);p2-22 建任务行的「只读」叉与「能并行」勾同时亮起(更绕的反例);p2-24 金句卡 | -| 2-G | p2-25..26 | 一排五个工具调用块按原顺序排列,上方落下分组括号 | 括号自左向右扫描落位:前三块归入一个 `mech` 组(组内三块**同时**脉冲=并行),中间一块单独成组(单独脉冲=串行),末块再一组;组与组之间画顺序箭头 | -| 2-H | p2-27..31 | 一个「输出」长条超出边框 → 变成文件图标 + 预览卡;随后「读文件」工具被单独框出并标「不许落盘」 | p2-27 长条超框部分被裁切并飞成文件图标;p2-29「读文件」卡外框变 `core` 并挂一枚「∞」标记;p2-30/31 形成**闭合循环箭头**(读→落盘→读→落盘),箭头每绕一圈加速一档、颜色向 `deny` 偏移,绕四圈后整体定格并压入「转不出来」字样 | +| 2-A | p2-01..04 | 环形循环留在左上角(缩小但**同色同线宽**)。主体是终端:模型的意图气泡「读这个文件」旁边,笨拙地拼出一条命令 | 意图气泡与命令行之间画一条「翻译」箭头,箭头上叠一个 `deny` 色的小计数「+1 层」;p2-02 打出两条拼接命令(读/写各一条);p2-04 时命令里一个字符被高亮成 `deny` 并抖动(拼错) ;`@progress` `@shake`(拼错抖动) | +| 2-B | p2-05..06 | 五张工具卡横排弹出(panel 底 + 编号,反枚举)+ 卡下官方五类分区标注;两个虚线空槽(执行/网页——官方地图另有) | 卡片 spring 弹入(仅编号染 mech);分区小字浮现;两空槽淡入(`panelBorder` 虚线);角标「官方内置五类:文件/搜索/执行/网页/代码智能」 ;`@stagger` `@progress`(五卡弹入走 NumberedCard 的 motif 弹簧) | +| 2-C | p2-07..10 | **字典分发表**:两列表格,左列工具名(mono),右列处理函数名。旁边一个前台号码簿的极简图形 | p2-07 表格框线描出;p2-08 逐行填入(每行 4 帧);p2-09 一个「来电」光点从左飞入命中某一行 → 整行推入 `mech` 辉光 → 右列函数名弹出;p2-10 表格底部**多出一行空槽**,一条新映射滑入落槽 ;`@stagger` `@progress`(来电光点终点锚定 14f 显式时长) | +| 2-D | p2-11..13 | 左右并置两段代码(前后两版),仅执行那一行不同 | 两段代码同时淡入,**逐行做同色对齐比对**;唯一不同的那一行左侧打 `mech` 竖标并整行高亮,其余行降到 40% 亮度;p2-13 时左上角环形循环 `core` 脉冲一次(强调"它没动") ;`@progress` | +| 2-E | p2-14..16 | 模型一次吐出四个工具调用卡,排成一列等待 | 四张卡以 80ms 间隔落入队列;p2-15 左右分屏:左侧「排队」四卡依次亮(串行),右侧「并行」前三卡同时亮;p2-16 画面中央压入一行提示条(`core` 描边) ;`@stagger`(队列弹簧为 map 内纯 spring+SPRING) | +| 2-F | p2-17..24 | **真值表**:三列(工具 / 只读 / 能并行),五行(读文件、找文件、`ls`、`rm`、建任务) | p2-17 先只显示「只读」列并全部打勾(符合直觉);p2-19 时 `ls` 与 `rm` 两行的「能并行」列**结论分岔**——`ls` 打 `mech` 勾、`rm` 打 `deny` 叉,两行之间画一条对撞线;p2-20 两行工具名同时闪一次(同名不同命);p2-22 建任务行的「只读」叉与「能并行」勾同时亮起(更绕的反例);p2-24 金句卡 ;`@impulse` `@stagger`(频闪标签包络淡入淡出) | +| 2-G | p2-25..26 | 一排五个工具调用块按原顺序排列,上方落下分组括号 | 括号自左向右扫描落位:前三块归入一个 `mech` 组(组内三块**同时**脉冲=并行),中间一块单独成组(单独脉冲=串行),末块再一组;组与组之间画顺序箭头 ;`@stagger`(分组括号时点非均匀,走 prop) | +| 2-H | p2-27..31 | 一个「输出」长条超出边框 → 变成文件图标 + 预览卡;随后「读文件」工具被单独框出并标「不许落盘」 | p2-27 长条超框部分被裁切并飞成文件图标;p2-29「读文件」卡外框变 `core` 并挂一枚「∞」标记;p2-30/31 形成**闭合循环箭头**(读→落盘→读→落盘),箭头每绕一圈加速一档、颜色向 `deny` 偏移,绕四圈后整体定格并压入「转不出来」字样 ;`@accelTravel` `@progress`(加速绕行累加器→模型,heat 驱动偏色) | ## P3 执行之前,先过闸门 | 镜 | 句区间 | 画面 | 动效 | |---|---|---|---| -| 3-A | p3-01..06 | 五张工具卡回归,其中四张外面套着一个 `mech` 色的「工作目录」框,跑命令那张**在框外** | p3-02 框线描出并把四张卡收进去,第五张卡留在框外并轻微抖动;p3-04 该卡吐出一条 `deny` 色命令,命令文字放大并使画面整体压暗一档;p3-05 环形循环的执行节点照常亮起("老老实实替它执行"的反讽);p3-06 一道竖线从顶部落下 | -| 3-B | p3-07..09 | **闸门路由**成形:请求从左侧进入,前方三道竖闸依次落下(1 号 `deny` 描边、2 号 `mech`、3 号 `mech` 虚线) | 三道闸门以 `spring` 自上落下(每句一道);p3-08 第一道闸门上浮出四个禁令词;p3-09 一个请求光点撞上第一道闸门被**反弹回左侧**并碎成 `deny` 色粒子 | -| 3-C | p3-10..13 | 第二、三道闸门工作:一个请求穿过一道被标记,停在第三道前等待;另一个请求三道全过直接抵达执行口 | p3-10/11 请求光点穿过一闸后头顶浮出「看情况」标签;p3-12 第三道闸门前画面**整体降速并压暗**,弹出「工具名 + 参数」审批卡与 y/N 光标;p3-13 另一条通路上一个光点畅通无阻直达执行口,并在轨迹上留下 `core` 尾迹 | -| 3-D | p3-14..19 | 拒绝表代码卡(mono,七个字符串)居中;随后表下方浮出原话引用 | p3-15 代码卡逐行渲染;p3-16/17 表的边框由 `deny` 变为 `dim` 虚线("示意"而非"边界"的视觉降级);p3-18 表右侧生成三条变体写法,逐条**绕过**表框飞向执行口;p3-19 金句卡 `serif` 居中 | -| 3-E | p3-20..24 | 四个结果徽标呈四宫格:放行(`core`)、拒绝(`deny`)、问你(`mech`)、不表态(`dim` 描边) | p3-21 前三格先亮(对应三道闸门),第四格空着并有呼吸描边;p3-23 第四格亮起「不表态」;p3-24 从第四格拉出一条弧线**回连到「问你」格**,弧线上压「兜底」二字 | -| 3-F | p3-25..29 | **官方求值顺序流水线**:拒绝 → 询问 → 放行 三站闸门横排,一枚请求光点沿轨道行进 | 三站以 spring 落下(顺序点亮,每站 7 帧);「头一个命中,就出局」横幅压上;p3-27 光点在拒绝站被截获、`deny` 辉光 + 「出局」标签;p3-28 右下浮「裸名 deny → 整件工具从模型身上消失」(Bash 加删除线) | -| 3-G | p3-30..35 | **auto 分类器视野分屏**:五行记录自上而下滚入(辩解 / 工具结果 / 调用描述 / 用户消息 / 裸命令),前三行被划除,末两行金色高亮 | p3-31 五行逐条滚入;p3-32 前三行依次划除并各浮移除理由;p3-33 末两行染 `core` 高亮 + 「只看这两条」;p3-34 反例帧:一条伪装「用户早就批准了」的文本飞向视野、被 `deny` 色边界弹回;p3-35 金句压底 + 遥测角标(93%,官方 2026-03) | +| 3-A | p3-01..06 | 五张工具卡回归,其中四张外面套着一个 `mech` 色的「工作目录」框,跑命令那张**在框外** | p3-02 框线描出并把四张卡收进去,第五张卡留在框外并轻微抖动;p3-04 该卡吐出一条 `deny` 色命令,命令文字放大并使画面整体压暗一档;p3-05 环形循环的执行节点照常亮起("老老实实替它执行"的反讽);p3-06 一道竖线从顶部落下 ;`@progress` `@shake` | +| 3-B | p3-07..09 | **闸门路由**成形:请求从左侧进入,前方三道竖闸依次落下(1 号 `deny` 描边、2 号 `mech`、3 号 `mech` 虚线) | 三道闸门以 `spring` 自上落下(每句一道);p3-08 第一道闸门上浮出四个禁令词;p3-09 一个请求光点撞上第一道闸门被**反弹回左侧**并碎成 `deny` 色粒子 ;`@stagger` `@progress`(三闸 spring 落下走 SPRING) | +| 3-C | p3-10..13 | 第二、三道闸门工作:一个请求穿过一道被标记,停在第三道前等待;另一个请求三道全过直接抵达执行口 | p3-10/11 请求光点穿过一闸后头顶浮出「看情况」标签;p3-12 第三道闸门前画面**整体降速并压暗**,弹出「工具名 + 参数」审批卡与 y/N 光标;p3-13 另一条通路上一个光点畅通无阻直达执行口,并在轨迹上留下 `core` 尾迹 ;`@progress`(审批卡淡入替代瞬现) | +| 3-D | p3-14..19 | 拒绝表代码卡(mono,七个字符串)居中;随后表下方浮出原话引用 | p3-15 代码卡逐行渲染;p3-16/17 表的边框由 `deny` 变为 `dim` 虚线("示意"而非"边界"的视觉降级);p3-18 表右侧生成三条变体写法,逐条**绕过**表框飞向执行口;p3-19 金句卡 `serif` 居中 ;`@stagger` `@progress`(三条绕过变体错峰) | +| 3-E | p3-20..24 | 四个结果徽标呈四宫格:放行(`core`)、拒绝(`deny`)、问你(`mech`)、不表态(`dim` 描边) | p3-21 前三格先亮(对应三道闸门),第四格空着并有呼吸描边;p3-23 第四格亮起「不表态」;p3-24 从第四格拉出一条弧线**回连到「问你」格**,弧线上压「兜底」二字 ;`@progress` `@breathe`(兜底弧描线内联合规红线三) | +| 3-F | p3-25..29 | **官方求值顺序流水线**:拒绝 → 询问 → 放行 三站闸门横排,一枚请求光点沿轨道行进 | 三站以 spring 落下(顺序点亮,每站 7 帧);「头一个命中,就出局」横幅压上;p3-27 光点在拒绝站被截获、`deny` 辉光 + 「出局」标签;p3-28 右下浮「裸名 deny → 整件工具从模型身上消失」(Bash 加删除线) ;`@stagger` `@progress`(三站 rail 结构保留 bespoke) | +| 3-G | p3-30..35 | **auto 分类器视野分屏**:五行记录自上而下滚入(辩解 / 工具结果 / 调用描述 / 用户消息 / 裸命令),前三行被划除,末两行金色高亮 | p3-31 五行逐条滚入;p3-32 前三行依次划除并各浮移除理由;p3-33 末两行染 `core` 高亮 + 「只看这两条」;p3-34 反例帧:一条伪装「用户早就批准了」的文本飞向视野、被 `deny` 色边界弹回;p3-35 金句压底 + 遥测角标(93%,官方 2026-03) ;`@stagger` `@progress` | ## P4 挂在循环上,不写进循环里 | 镜 | 句区间 | 画面 | 动效 | |---|---|---|---| -| 4-A | p4-01..04 | 环形循环恢复居中(`core`,仍同色同线宽)。右侧列出三条「想加的东西」 | p4-02/03 三条需求以 `FadeUp` 依次浮现;p4-04 三条需求各射出一支箭头**扎进环形循环内部**,环的线宽被撑粗一档并出现 `deny` 色应力点 | -| 4-B | p4-05..07 | 退化的循环代码卡:循环体内被塞进五行杂活 | 五行以每句 2 行的节奏插入,每插入一行代码卡**整体下移**并把最外层的 `while` 挤出可视区;p4-06 结束时代码卡边框变 `deny` 虚线;p4-07 金句卡覆盖上层 | -| 4-C | p4-08..12 | 代码卡收回,五行杂活被「拔」出循环、变成五张回调卡浮在环外;环恢复原线宽 | p4-08 五行代码逐行飞出环外并变形为卡片(0.5s 形变);p4-09/10 环外浮出「时机 → 函数列表」两列表格;p4-11/12 环与卡片之间出现四个插座图形,一张卡片「啪」地插入(`spring` 过冲 + 插入瞬间 `mech` 闪一帧) | -| 4-D | p4-13..19 | **插槽注册板**:环形循环外围四个插槽,按一整轮的时间顺序分布在环的四个方位 | 四个插槽逐句亮起并各插入对应回调卡:p4-14 入口槽(补背景信息的卡片飞入并在消息上加一小块 `mech` 补丁);p4-15/16 执行前槽——**P3 的三道闸门整体缩小、平移进这个槽**(跨幕视觉呼应),环上原本的检查标记同时消失;p4-17 执行后槽;p4-18/19 停机前槽亮 `core`,卡片返回一句话 → 一条回流箭头把它送回环的入口,环重新转起来 | -| 4-E | p4-20..22 | 四个插槽同时点亮,环上标出四个时机点;环本身刻意**不加任何装饰** | 四点沿环依次脉冲一圈(顺时针,各 8 帧);p4-21 环上只浮出四个极简的喇叭图标("只喊一声");p4-22 金句卡;金句期间环继续匀速转动,不做任何强调动效——用"无动效"表达"故意保持无聊" | -| 4-F | p4-23..25 | **31 事件 × 三层嵌套**(官方口径):外层会话框(一次)套中层回合框(×N)套内层工具调用链(工具前→执行→工具后→整批落定);右栏异步事件网格;右下计数徽章 | 外层成形→中层边框闪两下浮 ×N→内层虚线框内一个光点沿四节点链循环跑(80 帧一圈);右栏 6 个异步事件逐条打出;计数从 0 滚到 31(官方文档事件表逐行计数) | -| 4-G1 | p4-27..31 | **六道闸门流水线**(官方 SDK 权限判定图):hook/deny/ask/settings/mode/默认 六站横排,两端 Execute/Blocked 终点 | 轨道描出→一枚「我批了」光点出发→行至 deny 站被截入 Blocked(`deny` 辉光+冲击波);p4-31 切全放行模式重跑、光点仍在 deny 站被截 + 「全放行模式也一样:拒绝压得住」 | -| 4-G2 | p4-32..34 | **印章对撞**(保留高潮母题):「我批了」印章 vs 「禁止/询问」印章 + 单向箭头 | 印章落下对撞、持续抵抗抖动;单向箭头(只允许「加限制」方向);金句卡「扩展点能加限制,不能解除限制」收口 | -| 4-H | p4-35..37 | 「别停」插槽与环的一段形成小闭环,反复自激;随后一枚标记落下把闭环打断 | p4-36 小闭环连转三圈,每圈提速一档、边缘 `deny` 色渐强(自激失控感);p4-37 一枚 `core` 色方形标记从上落下嵌入闭环,第四圈行进到标记处被**截断**,整体归于静止 | +| 4-A | p4-01..04 | 环形循环恢复居中(`core`,仍同色同线宽)。右侧列出三条「想加的东西」 | p4-02/03 三条需求以 `FadeUp` 依次浮现;p4-04 三条需求各射出一支箭头**扎进环形循环内部**,环的线宽被撑粗一档并出现 `deny` 色应力点 ;`@stagger` `@progress`(辐条应力辉光 Math.max 钳制) | +| 4-B | p4-05..07 | 退化的循环代码卡:循环体内被塞进五行杂活 | 五行以每句 2 行的节奏插入,每插入一行代码卡**整体下移**并把最外层的 `while` 挤出可视区;p4-06 结束时代码卡边框变 `deny` 虚线;p4-07 金句卡覆盖上层 ;`@progress`(量化舞台刻意离散) | +| 4-C | p4-08..12 | 代码卡收回,五行杂活被「拔」出循环、变成五张回调卡浮在环外;环恢复原线宽 | p4-08 五行代码逐行飞出环外并变形为卡片(0.5s 形变);p4-09/10 环外浮出「时机 → 函数列表」两列表格;p4-11/12 环与卡片之间出现四个插座图形,一张卡片「啪」地插入(`spring` 过冲 + 插入瞬间 `mech` 闪一帧) ;`@spring` `@stagger` `@progress`(插头咬合 = snap 轻过冲) | +| 4-D | p4-13..19 | **插槽注册板**:环形循环外围四个插槽,按一整轮的时间顺序分布在环的四个方位 | 四个插槽逐句亮起并各插入对应回调卡:p4-14 入口槽(补背景信息的卡片飞入并在消息上加一小块 `mech` 补丁);p4-15/16 执行前槽——**P3 的三道闸门整体缩小、平移进这个槽**(跨幕视觉呼应),环上原本的检查标记同时消失;p4-17 执行后槽;p4-18/19 停机前槽亮 `core`,卡片返回一句话 → 一条回流箭头把它送回环的入口,环重新转起来 ;`@stagger` `@progress`(插槽点亮) | +| 4-E | p4-20..22 | 四个插槽同时点亮,环上标出四个时机点;环本身刻意**不加任何装饰** | 四点沿环依次脉冲一圈(顺时针,各 8 帧);p4-21 环上只浮出四个极简的喇叭图标("只喊一声");p4-22 金句卡;金句期间环继续匀速转动,不做任何强调动效——用"无动效"表达"故意保持无聊" ;`@progress`(刻意零动效的「无聊」表达) | +| 4-F | p4-23..25 | **31 事件 × 三层嵌套**(官方口径):外层会话框(一次)套中层回合框(×N)套内层工具调用链(工具前→执行→工具后→整批落定);右栏异步事件网格;右下计数徽章 | 外层成形→中层边框闪两下浮 ×N→内层虚线框内一个光点沿四节点链循环跑(80 帧一圈);右栏 6 个异步事件逐条打出;计数从 0 滚到 31(官方文档事件表逐行计数) ;`@stagger` `@progress`(六格异步事件墙) | +| 4-G1 | p4-27..31 | **六道闸门流水线**(官方 SDK 权限判定图):hook/deny/ask/settings/mode/默认 六站横排,两端 Execute/Blocked 终点 | 轨道描出→一枚「我批了」光点出发→行至 deny 站被截入 Blocked(`deny` 辉光+冲击波);p4-31 切全放行模式重跑、光点仍在 deny 站被截 + 「全放行模式也一样:拒绝压得住」 ;`@stagger` `@progress`(六站管线 rail 保留 bespoke) | +| 4-G2 | p4-32..34 | **印章对撞**(保留高潮母题):「我批了」印章 vs 「禁止/询问」印章 + 单向箭头 | 印章落下对撞、持续抵抗抖动;单向箭头(只允许「加限制」方向);金句卡「扩展点能加限制,不能解除限制」收口 ;`@progress`(对撞冲击波为周期包络,保留内联) | +| 4-H | p4-35..37 | 「别停」插槽与环的一段形成小闭环,反复自激;随后一枚标记落下把闭环打断 | p4-36 小闭环连转三圈,每圈提速一档、边缘 `deny` 色渐强(自激失控感);p4-37 一枚 `core` 色方形标记从上落下嵌入闭环,第四圈行进到标记处被**截断**,整体归于静止 ;`@accelTravel` `@progress`(自激闭环→模型,精确一致) | ## P5 四层叠起来是什么 | 镜 | 句区间 | 画面 | 动效 | |---|---|---|---| -| 5-A | p5-01..06 | 四层横向卡片:循环(`core` 实心)/ 分发表 / 闸门 / 插槽(后三者 `mech` 描边);卡片上方 p5-05 落下**官方四件套标尺**(循环 / 工具 / 上下文管理 / 护栏) | 四张卡逐句自下弹入并水平排开;p5-05 标尺自上淡入落位;p5-06 三根竖连线把本集三层接到对应格(循环↔循环、分发表↔工具、插口↔护栏),「上下文管理」格虚线空置 + 「空着 · 后面拆」小标;随后第一张卡**留在原地并加实底**,后三张同时上浮 8px 并转为半透明——"骨架 vs 挂件"的视觉分层 | -| 5-B | p5-07..10 | **四级台阶**:四段柱体高度递增(141/191/241/255),每根柱子内部有一段等高的 `core` 色块 | p5-08/09 四根柱体自左向右逐根升起(`spring`),柱顶数字滚动到位;p5-10 时四根柱子里的 `core` 色块**同时描出**——四段高度肉眼相同且色值相同,柱体其余部分降到 30% 亮度 | -| 5-C | p5-11..14 | 环形循环回到画面中央(第五次出现,仍完全同色同线宽),执行节点位置换过四次写法的四个标签轮播 | p5-11/12 环上四个节点依次脉冲一圈;p5-13/14 执行节点上方的标签以 0.4s 间隔翻牌四次(写死命令 → 查表 → 闸门 → 插槽),**环本身一帧未变**;末尾金句卡 | -| 5-D | p5-15..19 | 回到 P0 的终端画面(同构图),但这次命令自己跑了起来、输出自动回流;末尾英文原句金句卡 | p5-16 P0 的灰色搬运轨迹以 `core` 色重画一次(这次是自动的);人形剪影淡出;p5-18 四层结构在终端外围合拢成一个壳;p5-19 英文原句 `serif` 与中文并列淡入 | +| 5-A | p5-01..06 | 四层横向卡片:循环(`core` 实心)/ 分发表 / 闸门 / 插槽(后三者 `mech` 描边);卡片上方 p5-05 落下**官方四件套标尺**(循环 / 工具 / 上下文管理 / 护栏) | 四张卡逐句自下弹入并水平排开;p5-05 标尺自上淡入落位;p5-06 三根竖连线把本集三层接到对应格(循环↔循环、分发表↔工具、插口↔护栏),「上下文管理」格虚线空置 + 「空着 · 后面拆」小标;随后第一张卡**留在原地并加实底**,后三张同时上浮 8px 并转为半透明——"骨架 vs 挂件"的视觉分层 ;`@progress`(四卡阵 stagger 为 map 内纯 spring+SPRING) | +| 5-B | p5-07..10 | **四级台阶**:四段柱体高度递增(141/191/241/255),每根柱子内部有一段等高的 `core` 色块 | p5-08/09 四根柱体自左向右逐根升起(`spring`),柱顶数字滚动到位;p5-10 时四根柱子里的 `core` 色块**同时描出**——四段高度肉眼相同且色值相同,柱体其余部分降到 30% 亮度 ;`@progress`(基准带实测数据驱动) | +| 5-C | p5-11..14 | 环形循环回到画面中央(第五次出现,仍完全同色同线宽),执行节点位置换过四次写法的四个标签轮播 | p5-11/12 环上四个节点依次脉冲一圈;p5-13/14 执行节点上方的标签以 0.4s 间隔翻牌四次(写死命令 → 查表 → 闸门 → 插槽),**环本身一帧未变**;末尾金句卡 ;`@progress`(翻牌离散保持内联) | +| 5-D | p5-15..19 | 回到 P0 的终端画面(同构图),但这次命令自己跑了起来、输出自动回流;末尾英文原句金句卡 | p5-16 P0 的灰色搬运轨迹以 `core` 色重画一次(这次是自动的);人形剪影淡出;p5-18 四层结构在终端外围合拢成一个壳;p5-19 英文原句 `serif` 与中文并列淡入 ;`@progress` | ## P6 收尾 | 镜 | 句区间 | 画面 | 动效 | |---|---|---|---| -| 6-A | p6-01..04 | 壳的四层分别点亮并各配一句短标签(手脚 / 工具 / 底线 / 发挥) | 四层以每句一层的节奏点亮,点亮时对应标签 `FadeUp`;四层齐亮后整体轻微收拢一次(呼吸) | -| 6-B | p6-05..08 | 信源卡四行(官方文档+取数日期 / 工程博客 / 源码分析已逐处标注 / 数字均为实测口径)+ 版本护栏行 → 系列身份卡 + **下期预告卡**(标题只在画面;series.json 派生硬编码,规则 8 对账) → **渐黑** | 信源卡逐行浮现(mono 小字);系列身份卡 `serif` 淡入;下期卡 `panel` 底描边框浮现(「下期 · 规划层」+ 标题);**渐黑窗口从本 beat 的总时长推导**(`beatDurationInFrames`,红线四),末 1.2s 线性压暗到全黑 | +| 6-A | p6-01..04 | 壳的四层分别点亮并各配一句短标签(手脚 / 工具 / 底线 / 发挥) | 四层以每句一层的节奏点亮,点亮时对应标签 `FadeUp`;四层齐亮后整体轻微收拢一次(呼吸) ;`@progress`(径向滑入时程钳制 SAFE_TOP_Y——ISSUE-170 结构化) | +| 6-B | p6-05..08 | 信源卡四行(官方文档+取数日期 / 工程博客 / 源码分析已逐处标注 / 数字均为实测口径)+ 版本护栏行 → 系列身份卡 + **下期预告卡**(标题只在画面;series.json 派生硬编码,规则 8 对账) → **渐黑** | 信源卡逐行浮现(mono 小字);系列身份卡 `serif` 淡入;下期卡 `panel` 底描边框浮现(「下期 · 规划层」+ 标题);**渐黑窗口从本 beat 的总时长推导**(`beatDurationInFrames`,红线四),末 1.2s 线性压暗到全黑 ;`@spring` `@stagger` `@fadeOut`(渐黑窗口从 beat 总时长推导——红线四) | 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-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..5e29cab52 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/scripts/motion.test.ts @@ -0,0 +1,141 @@ +/** + * 运动层纯函数单测——`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, revealCharCount, 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); +}); + +test('逐字揭示:cps 按实际 fps 换算并向下取整,framesPerChar 保持固定帧间隔', () => { + assert.equal(revealCharCount(29, 60, 12), 5); + assert.equal(revealCharCount(30, 60, 12), 6); + assert.equal(revealCharCount(30, 24, 12, 3), 10); + assert.equal(revealCharCount(30, 120, 12, 3), 10); +}); + +// ── 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 <= 24, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('fit 窗口连 1 帧子项也容不下时显式拒绝,不返回外溢计划', () => { + assert.throws(() => schedule(8, {dur: 12, fit: {total: 5}}), /fit\.total=5.*8/); +}); + +test('fit 可行极限先牺牲子项时长,仍完整落在窗口内', () => { + const p = schedule(8, {dur: 12, fit: {total: 8}}); + assert.equal(p.dur, 1); + assert.equal(p.starts[7] + p.dur, 8); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); + assert.deepEqual(schedule(3, {dur: 10, lag: 0}).starts, [0, 0, 0], 'lag=0 须同刻齐动'); +}); + +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/components/harness-stack.tsx b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/components/harness-stack.tsx new file mode 100644 index 000000000..c85c03085 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/components/harness-stack.tsx @@ -0,0 +1,203 @@ +/** 系列身份装置:五层 Harness 栈——skills/06「系列身份视觉」规格的落地。 + * + * 层序/层名/发布态/下集标题一律取 series-layers.json(build_narration 从 + * series.json 派生——硬编码即漂移,规格原话)。注意 P6 下期卡内的**标题主段** + * 仍是硬编码字符串:check_series 规则 8 以 tsx 文本对账 series.json,数据化会 + * 让那条门失明(两层口径:层短名走数据,标题主段走规则 8 的受检硬编码)。 + * + * 两个组件: + * - HarnessStackP0:开场编排(落板 → 本集层高亮呼吸 → 缩退淡出,末段交叉淡入常驻条); + * - HarnessBadge:P1–P6 的常驻顶边条(横向五 chip,y 12–48)。 + * + * ⚠️ 形式对规格的一处适配(评审实测):规格写「缩退左上角纵向角标(宽 ≤300)」, + * 但纵向角标(300×194)与既有各幕左上内容五处碰撞(P2 CornerRing 同坐标、P3 小抄、 + * P5 标尺、P1 1-E 环、P6 标签);全片唯一零碰撞的常驻区是**顶边 y<50 横条**—— + * 所有幕的左上内容最早从 y=56 起。故常驻形式取顶边横条(紧贴上缘,不占字幕安全带 + * 也不与 SceneTag 相干)。缩退以交叉淡出衔接,避免纵向→横向的形态跳变。 + */ +import React from 'react'; +import {AbsoluteFill} from 'remotion'; +import {theme} from '../design/theme'; +import {DUR, useBreathe, useDim, useProgress, useStagger} from '../motion'; +import series from '../series-layers.json'; + +export type Layer = {index: number; layer: string; title: string; published: boolean}; + +export const LAYERS = series.layers as Layer[]; +export const ACTIVE_INDEX = series.activeIndex as number; +export const NEXT_LAYER = LAYERS[ACTIVE_INDEX] ?? null; // P6 呼吸预告的层 + +const STACK_CROSSFADE_FRAMES = 8; + +/** 开场全尺寸栈与常驻顶边条开始交叉淡化的局部帧。 */ +export const harnessStackCrossAt = (recedeAt: number): number => + recedeAt + DUR.f6 - STACK_CROSSFADE_FRAMES; + +/** 单块层板。mode: full(开场全尺寸)/ chip(常驻顶边条)/ p6(收尾放大)。 */ +const Plate: React.FC<{ + layer: Layer; + active: boolean; + dim: number; + glow?: number; + scale: 'full' | 'chip' | 'p6'; +}> = ({layer, active, dim, glow = 0, scale}) => { + const chip = scale === 'chip'; + const h = chip ? 34 : scale === 'p6' ? 56 : 64; + const nameSize = chip ? 17 : scale === 'p6' ? 26 : 28; + const idSize = chip ? 12 : 17; + return ( +
+
+ {String(layer.index).padStart(2, '0')} +
+
+ {layer.layer} +
+
+ ); +}; + +/** 常驻顶边条:P1–P6(横向五 chip,y 12–48——见文件头形式适配说明)。 */ +export const HarnessBadge: React.FC<{style?: React.CSSProperties}> = ({style}) => ( +
+ {LAYERS.map((l) => ( + + ))} +
+); + +/** P0 开场编排:自底向上落板(6 帧一层)→ 本集层高亮呼吸两次 → 其余压暗 55% → + * 缩退淡出(向左上收小;末 8 帧与常驻顶边条交叉淡入——衔接 P1 起的 HarnessBadge)。 */ +export const HarnessStackP0: React.FC<{recedeAt: number}> = ({recedeAt}) => { + const drops = useStagger(LAYERS.length, {at: 2, dur: DUR.f4, stride: 6, easing: 'decelerate'}); + const hiAt = 2 + (LAYERS.length - 1) * 6 + DUR.f4 + 2; + const glowBreathe = useBreathe({period: 15, amp: 0.5, base: 0.5}); + // 呼吸两次(30f)后收敛到 0.7 的持续高亮 + const breathePhase = useProgress(hiAt + 30, DUR.f6); + const glow = glowBreathe * (1 - breathePhase) + 0.7 * breathePhase; + const dim = useDim({at: hiAt, to: 0.55, dur: DUR.f5}); + const rec = useProgress(recedeAt, DUR.f6); + // 缩退:向左上收小 + 尾段淡出;常驻条同步交叉淡入(换形式不跳变) + const crossAt = harnessStackCrossAt(recedeAt); + const out = useProgress(crossAt, STACK_CROSSFADE_FRAMES); + const fullX = 730; + const fullY = 300; + const tx = fullX + (64 - fullX) * rec; + const ty = fullY + (12 - fullY) * rec; + const s = 1 - 0.55 * rec; + return ( + + +
+ {LAYERS.slice() + .reverse() + .map((l) => { + // 自底向上落板:index 1(执行层,最底)最先动 + const p = drops[l.index - 1]; + const isHi = l.index === ACTIVE_INDEX; + return ( +
+ +
+ ); + })} +
+
+ ); +}; + +/** P6 收尾:栈重新放大居中;已发布层保持点亮(EP1 时即本集层);下期层呼吸预告。 */ +export const HarnessStackP6: React.FC<{at: number; nextBreathAt: number}> = ({at, nextBreathAt}) => { + const enter = useStagger(LAYERS.length, {at, dur: DUR.f4, stride: 4, easing: 'decelerate'}); + const breath = useBreathe({period: 40, amp: 0.5, base: 0.5}); + const nextOn = useProgress(nextBreathAt, DUR.f5); + return ( +
+ {LAYERS.slice() + .reverse() + .map((l) => { + const p = enter[l.index - 1]; + const isNext = NEXT_LAYER !== null && l.index === NEXT_LAYER.index; + const lit = l.published || l.index === ACTIVE_INDEX; + const glow = isNext ? nextOn * breath : 0; + return ( +
+ 0.5)} + dim={lit || (isNext && nextOn > 0.5) ? 1 : 0.55} + glow={glow} + scale="p6" + /> +
+ ); + })} +
+ ); +}; diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/components/motifs.tsx b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/components/motifs.tsx index 32f01bf81..099f33ff3 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/components/motifs.tsx +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/components/motifs.tsx @@ -74,8 +74,9 @@ export const Footnote: React.FC<{children: React.ReactNode; delay?: number}> = ( export const SceneTag: React.FC<{chapter: string; tagline: string}> = ({chapter, tagline}) => { const frame = useCurrentFrame(); const o = interpolate(frame, [6, 24], [0, 1], {extrapolateRight: 'clamp'}); + // 右上:左上角让位给常驻 HarnessBadge(系列身份栈缩退位,harness-stack.tsx) return ( -
+
{chapter}
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..a71737133 --- /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 = 340; +const CH = 230; +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..f87b8a129 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/hooks.ts @@ -0,0 +1,303 @@ +/** 运动模型(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, revealCharCount} 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)}); + +/** 缓动数值进度——「clamped 0→1 手写 interpolate」惯用形的统一替身: + * 时机值走 token,缓动默认 standard(线性原版会被这一档替换——质量提升点之一)。 */ +export function useProgress( + at: number, + dur: number | DurToken = DUR.f5, + easing: EasingToken = 'standard', +): number { + const frame = useCurrentFrame(); + return eased(frame, at, frames(dur, DUR.f5), easing); +} + +/** 弹簧数值进度(驱动几何量/位移;effects 通道用 useProgress——tokens 二分不变量)。 + * 局部帧 + 可选 durationInFrames 截停(防窗口外余振;缺省不设上限,与既有手写等价)。 */ +export function useSpring( + preset: SpringPreset, + o: {at?: number; dur?: number | DurToken} = {}, +): number { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const cfg: Parameters[0] = { + frame: frame - (o.at ?? 0), + fps, + config: SPRING[preset], + }; + if (o.dur !== undefined) { + cfg.durationInFrames = frames(o.dur, DUR.f5); + } + return spring(cfg); +} + +// ── 入场(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 帧一周期——注意与 + * 原除数 K 的换算 period = 2πK,如 sin(frame/26) → period 163)。offset 为相位 + * 平移(错峰辉光的 per-index 形态)。 */ +export function useBreathe( + o: {period?: number; amp?: number; base?: number; offset?: number} = {}, +): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26, offset = 0} = o; + return base + amp * Math.sin((2 * Math.PI * (frame - offset)) / 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 {fps} = useVideoConfig(); + const elapsed = Math.max(0, frame - (o.at ?? 0)); + const n = revealCharCount(elapsed, fps, o.cps ?? 12, o.framesPerChar); + 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..5703e3315 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/schedule.ts @@ -0,0 +1,65 @@ +/** 错峰编排(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;装不下时先缩子项时长,连 1 帧 + * 子项都无法错开时显式抛错,不返回违反 fit 契约的计划。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点期望下限,默认 2 帧;fit 紧窗时可按优先级退至 1 帧。 */ + minStride?: number; + /** 子项时长期望下限,默认 3 帧;fit 为守住窗口可退至 1 帧。 */ + 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); + if (n > 1 && total < n) { + throw new Error( + `schedule: fit.total=${total} 无法容纳 ${n} 个至少 1 帧且起点间隔至少 1 帧的子项`, + ); + } + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + const targetStride = Math.max(1, minStride); + if (n > 1 && stride < targetStride) { + // 先缩时长争取期望步长;仍不足时牺牲步长,但至少保留 1 帧先后。 + dur = Math.max(1, Math.min(dur, total - targetStride * (n - 1))); + stride = (total - dur) / (n - 1); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 || o.lag === 0 ? 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..69bd59c7a --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/motion/window.ts @@ -0,0 +1,43 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 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)); + +/** 已过帧数 → 可见字符数;cps 随实际 fps 换算,显式帧间隔不受 fps 影响。 */ +export const revealCharCount = ( + elapsedFrames: number, + fps: number, + cps: number, + framesPerChar?: number, +): number => { + const elapsed = Math.max(0, elapsedFrames); + const count = + framesPerChar === undefined + ? Math.floor((elapsed / fps) * cps) + : Math.floor(elapsed / Math.max(1, framesPerChar)); + return Math.max(0, count); +}; diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P0Hook.tsx b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P0Hook.tsx index 403d98c09..08c4a5028 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P0Hook.tsx +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P0Hook.tsx @@ -1,24 +1,32 @@ /** P0 钩子与命名帧(分镜 0-A…0-C) - * 三句内完成:搬运工痛点 → 官方命名「Harness」→ 五辐条供给图。 - * 改造(2026-08 Harness Engineering):10 句压缩为 7 句,命名帧是全系列的开场锚。 */ + * 三句内完成:系列身份栈开场 → 搬运工痛点 → 官方命名「Harness」→ 五辐条供给图。 + * 重制(2026-09 运动层):动效收敛到 motion 模型(分镜动效列 @动词 可机检); + * HarnessStackP0 落地 skills/06 系列身份视觉规格(五层栈开场 + 缩退常驻角标)。 */ import React from 'react'; -import {AbsoluteFill, interpolate, Sequence, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import {AbsoluteFill, Sequence, useCurrentFrame} from 'remotion'; import {theme} from '../design/theme'; import {beatWindow} from '../timing'; import type {SceneRange} from '../types'; -import {Footnote, Panel, Terminal} from '../components/motifs'; +import {Panel, Terminal} from '../components/motifs'; +import {HarnessStackP0, harnessStackCrossAt} from '../components/harness-stack'; +import {DUR, progress, useBreathe, useProgress, useSpring, useStagger} from '../motion'; -/** 0-A 终端打字 → 命令凝住 → 复制粘贴弧线加速塞满(两句内完成旧版六句的信息量) */ -const StallAndCarry: React.FC<{carryAt: number}> = ({carryAt}) => { +/** 0-A 系列栈开场 → 终端打字 → 命令凝住 → 复制粘贴弧线加速塞满 */ +const StallAndCarry: React.FC<{carryAt: number; revealAt: number}> = ({carryAt, revealAt}) => { const frame = useCurrentFrame(); const W = 1560; const H = 400; const frozen = frame >= carryAt - 6; - // 三轮搬运轨迹(加速:每轮 9 帧,旧版是 15 帧——钩子提速) + // 终端在系列栈缩退让位后再进场(栈 choreography 见 HarnessStackP0) + const reveal = useProgress(revealAt, DUR.f5); + // 三轮搬运轨迹(加速:每轮 9 帧,旧版是 15 帧——钩子提速)。线性匀速是刻意: + // 搬运的机械感就该是等速的(motion 模型的逃生舱条款) const roundGap = [0, 9, 18]; + // 侧标(竖排警示):凝住即淡入,不再布尔硬门瞬现 + const warnOp = useProgress(carryAt - 6, DUR.f5) * 0.9; return ( -
+
= ({carryAt}) => { {a: 300, b: W - 300, y: 240 + i * 22, dir: 1}, ]; return legs.map((lg, k) => { - const t = interpolate(frame - base - k * 6, [0, 9], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const t = progress(frame - base - k * 6, 0, 9); if (t <= 0) return null; const x = lg.a + (lg.b - lg.a) * t; return ( @@ -63,7 +68,7 @@ const StallAndCarry: React.FC<{carryAt: number}> = ({carryAt}) => { color: theme.deny, writingMode: 'vertical-rl', letterSpacing: 5, - opacity: interpolate(frame, [carryAt - 6, carryAt + 6], [0, 0.9], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}), + opacity: warnOp, }} > {'每一轮的搬运工,是你'} @@ -76,25 +81,20 @@ const StallAndCarry: React.FC<{carryAt: number}> = ({carryAt}) => { /** 0-B 命名帧:搬运残影收束成外框,包住「模型」内核——外框即 Harness + 官方引文条 */ const NamingFrame: React.FC<{officialAt: number}> = ({officialAt}) => { - const frame = useCurrentFrame(); - const {fps} = useVideoConfig(); - // 外框四面合拢(spring 收拢感) - const close = spring({frame, fps, config: {damping: 180}}); + // 外框四面合拢(settleSoft 弹簧;局部帧——全局帧会让 spring 每帧重模拟 O(frame)) + const close = useSpring('settleSoft'); + const label = useProgress(22, DUR.f5); + const quote = useProgress(officialAt, DUR.f5); + // 命名帧的「仪式感」呼吸辉光(sin(frame/26) → period 2π·26 ≈ 163) + const breathe = useBreathe({period: 163}); const CX = 960; const CY = 460; const full = 420; const wHalf = full / 2 + (1 - close) * 620; const hHalf = full / 2 + (1 - close) * 380; - const label = interpolate(frame - 22, [0, 14], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const quote = interpolate(frame - officialAt, [0, 18], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - // 呼吸辉光(命名帧的「仪式感」) - const breathe = 0.5 + 0.5 * Math.sin(frame / 26); + // 四角旧搬运残影:35% → 0 淡出(收束感) + const residue = 0.35 * (1 - useProgress(0, DUR.f6)); + const core = progress(close, 0.3, 0.7); return ( {/* 四面合拢的残影 → 外框 */} @@ -116,21 +116,18 @@ const NamingFrame: React.FC<{officialAt: number}> = ({officialAt}) => { [1, -1], [-1, 1], [1, 1], - ].map(([sx, sy], i) => { - const o = interpolate(frame, [0, 18], [0.35, 0], {extrapolateRight: 'clamp'}); - return ( - - ); - })} + ].map(([sx, sy], i) => ( + + ))} {/* 内核:模型 */}
= ({officialAt}) => { top: CY, transform: 'translate(-50%, -50%)', textAlign: 'center', - opacity: interpolate(close, [0.3, 1], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}), + opacity: core, }} >
= ({officialAt}) => { {'把动力源,套进一个可控的结构里'}
{/* 官方引文条(p0-05 句锚) */} - {quote > 0 ? ( -
- - {/* 中英并列(分镜 0-B 承诺):中文口播句 + 英文原句 */} -
- {'“Claude Code 是 Harness,Claude 是里面的模型。”'} -
-
- {'“Claude Code is the harness, Claude is the model.”'} -
-
- {'— 官方文档 how-claude-code-works(code.claude.com,取数2026年8月)'} -
-
-
- ) : null} +
+ + {/* 中英并列(分镜 0-B 承诺):中文口播句 + 英文原句 */} +
+ {'“Claude Code 是 Harness,Claude 是里面的模型。”'} +
+
+ {'“Claude Code is the harness, Claude is the model.”'} +
+
+ {'— 官方文档 how-claude-code-works(code.claude.com,取数2026年8月)'} +
+
+
); }; @@ -232,10 +227,8 @@ const NamingFrame: React.FC<{officialAt: number}> = ({officialAt}) => { /** 0-C 五辐条供给图:外框上长出五根辐条(文件/命令/权限/记忆/护栏)+ 标题卡收尾 */ const SupplySpokes: React.FC<{titleAt: number}> = ({titleAt}) => { const frame = useCurrentFrame(); - const {fps} = useVideoConfig(); const CX = 960; const CY = 440; - const R = 300; const spokes = [ {label: '文件', zh: '读写项目'}, {label: '命令', zh: '执行与回传'}, @@ -243,15 +236,14 @@ const SupplySpokes: React.FC<{titleAt: number}> = ({titleAt}) => { {label: '记忆', zh: '上下文与规则', later: true}, {label: '护栏', zh: '拦截与兜底'}, ]; - const title = spring({frame: frame - titleAt, fps, config: {damping: 200}}); - const line = interpolate(frame - titleAt, [4, 40], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const ring = interpolate(frame, [0, 16], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const title = useSpring('settle', {at: titleAt}); + // 标题卡细线生长(beat 级慢动作,刻意不走 micro token) + const line = useProgress(titleAt + 4, 36); + // 外框缩小型持续在场:16f 恰在两档 token 中间(Δ4/Δ5),保留显式时长 + const ring = useProgress(0, 16); + // 五辐条依次点亮:stride 8、单项 12f(=DUR.f5,与原手写一致) + const lit = useStagger(5, {at: 6, stride: 8, dur: DUR.f5}); + const laterOp = useProgress(40, DUR.f4); return ( @@ -270,12 +262,10 @@ const SupplySpokes: React.FC<{titleAt: number}> = ({titleAt}) => { {'模型 Claude'} - {/* 五辐条:自外框上沿五点连向内核 */} + {/* 五辐条:自外框上沿五点连向内核。辉光是逐根错相呼吸(per-index 相位), + useBreathe(offset) 的语义此处以内联等式表达(map 内不可调 hook) */} {spokes.map((sp, i) => { - const t = interpolate(frame - 6 - i * 8, [0, 12], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const t = lit[i]; if (t <= 0) return null; // 五点沿外框上沿分布 const fx = CX - 300 + i * 150; @@ -305,55 +295,45 @@ const SupplySpokes: React.FC<{titleAt: number}> = ({titleAt}) => { ); })} {/* 「记忆」辐条的「后面拆」小标 */} - {(() => { - const o = interpolate(frame - 6 - 3 * 8 - 10, [0, 10], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - return o > 0 ? ( - - {'后面单独拆'} - - ) : null; - })()} + + {'后面单独拆'} + - {/* 标题卡(p0-07 收尾压入) */} - {title > 0 ? ( + {/* 标题卡(p0-07 收尾压入;弹簧负帧返回 0,无须布尔门) */} +
+
+ {'执行层:一个循环,就是全部'} +
-
- {'执行层:一个循环,就是全部'} -
-
-
- {'Claude Code Harness Engineering · 执行层'} -
+ /> +
+ {'Claude Code Harness Engineering · 执行层'}
- ) : null} +
); }; @@ -364,10 +344,17 @@ export const P0Hook: React.FC<{scene: SceneRange}> = ({scene}) => { const bA = w('p0-01', 'p0-02'); const bB = w('p0-03', 'p0-05'); const bC = w('p0-06', 'p0-07'); + // 系列栈缩退在搬运高潮前完成(recedeAt 由句边界推导——禁写死帧数) + const p2 = at('p0-02') - bA.from; + const recedeAt = Math.max(66, p2 - DUR.f6 - 6); + const terminalRevealAt = harnessStackCrossAt(recedeAt); return ( + {/* 系列身份栈:scene 级渲染(beat Sequence 之外)——落板 → 本集层高亮 → + 缩退左上角后**常驻整幕**;P1 起由各幕的 HarnessBadge 接棒 */} + - + diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P1Loop.tsx b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P1Loop.tsx index 9dede2541..615c19817 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P1Loop.tsx +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P1Loop.tsx @@ -1,7 +1,7 @@ /** P1 一个循环,就是全部(分镜 1-A…1-F)—— 开源教学素材「Agent While-Loop」可视化的概念重建 * ★ 本幕建立全片视觉锚:LoopRing 的色与线宽从此不再改变。 */ import React from 'react'; -import {AbsoluteFill, interpolate, Sequence, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import {AbsoluteFill, Sequence, useCurrentFrame} from 'remotion'; import {theme} from '../design/theme'; import {beatWindow} from '../timing'; import type {SceneRange} from '../types'; @@ -15,20 +15,17 @@ import { SceneTag, useRingDot, } from '../components/motifs'; +import {HarnessBadge} from '../components/harness-stack'; +import {DUR, useProgress, useSpring, useStagger} from '../motion'; /** 1-A 环形循环成形 + 两个信号分支 */ const RingBirth: React.FC<{yesAt: number; noAt: number}> = ({yesAt, noAt}) => { const frame = useCurrentFrame(); - const draw = interpolate(frame, [4, 40], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // 描线成形改 decelerate(原线性):环的「生长感」需要先快后慢 + const draw = useProgress(4, 36, 'decelerate'); const dot = useRingDot(2.5, 40); // 讲到「没有」时光点滑出到停机出口并定格 - const pull = interpolate(frame - noAt, [8, 30], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const pull = useProgress(noAt + 8, 22); const active = frame >= yesAt && frame < noAt ? 2 : undefined; return ( @@ -52,15 +49,20 @@ const FiveSteps: React.FC<{stepAt: number[]}> = ({stepAt}) => { '结果打包成新消息,回到第二步', ]; const lit = stepAt.filter((s) => frame >= s).length; + // 五步逐句点亮(12f = DUR.f5;锚点非均匀,显式五行而非 useStagger) + const litP = [ + useProgress(stepAt[0], DUR.f5), + useProgress(stepAt[1], DUR.f5), + useProgress(stepAt[2], DUR.f5), + useProgress(stepAt[3], DUR.f5), + useProgress(stepAt[4], DUR.f5), + ]; const blocks: {color: string; label: string}[] = []; if (lit >= 1) blocks.push({color: theme.dim, label: 'user'}); if (lit >= 3) blocks.push({color: theme.core, label: 'assistant'}); if (lit >= 4) blocks.push({color: theme.mech, label: 'tool_result'}); if (lit >= 5) blocks.push({color: theme.core, label: 'assistant'}); - const loopBack = interpolate(frame - stepAt[4], [6, 26], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const loopBack = useProgress(stepAt[4] + 6, DUR.f6); return (
@@ -69,10 +71,7 @@ const FiveSteps: React.FC<{stepAt: number[]}> = ({stepAt}) => {
{steps.map((s, i) => { const on = frame >= stepAt[i]; - const e = interpolate(frame - stepAt[i], [0, 12], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const e = litP[i]; return (
= ({countAt ' "tool_use_id": block.id, "content": output})', ' messages.append({"role": "user", "content": results})', ]; - const split = interpolate(frame - splitAt, [0, 20], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const split = useProgress(splitAt, DUR.f6); return (
@@ -237,10 +233,7 @@ const UnreliableFlag: React.FC<{crossAt: number; quoteAt: number}> = ({crossAt, // 工具调用已流出的帧(每 2 帧 1 字);标记牌刻意再滞后 25 帧才翻转 —— 这就是「不可靠」 const toolOutAt = toolIdx * 2; const flipped = frame >= toolOutAt + 25; - const cross = interpolate(frame - crossAt, [0, 14], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const cross = useProgress(crossAt, DUR.f5); if (frame >= quoteAt) { return ( @@ -328,25 +321,24 @@ const ThreePhaseRing: React.FC<{phaseAt: number; handAt: number; drawersAt: numb recedeAt, }) => { const frame = useCurrentFrame(); - const {fps} = useVideoConfig(); const dot = useRingDot(2.5); const phases = [ {label: '收集上下文', zh: '读文件 · 搜代码', ang: -90}, {label: '采取行动', zh: '改文件 · 跑命令', ang: 30}, {label: '验证结果', zh: '看输出 · 再补一刀', ang: 150}, ]; - const recede = interpolate(frame - recedeAt, [0, 24], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const hand = spring({frame: frame - handAt, fps, config: {damping: 170}}); - const drawerOn = interpolate(frame - drawersAt, [0, 30], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const recede = useProgress(recedeAt, DUR.f6); + const hand = useSpring('settleSoft', {at: handAt}); + // 抽屉墙整体一闪:1s 的 beat 级动作(30f 距两档 token 均 >3,保留显式时长) + const drawerOn = useProgress(drawersAt, 30); const CX = 560; const CY = 500; const R = 250; + // 三相位标签沿环依次点亮(stride 10、单项 f5) + const phaseOn = useStagger(3, {at: phaseAt, stride: 10, dur: DUR.f5}); + // 十格抽屉墙快闪(快节奏:stride 2、单项 f3) + const drawerCells = useStagger(10, {at: drawersAt, stride: 2, dur: DUR.f3}); + const fuseOp = useProgress(phaseAt + 34, DUR.f4); return (
{/* 三个相位标签:沿环依次点亮并保持同时可见(交融感) */} {phases.map((ph, i) => { - const on = interpolate(frame - phaseAt - i * 10, [0, 12], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const on = phaseOn[i]; const rad = (ph.ang * Math.PI) / 180; const lx = CX + Math.cos(rad) * (R + 96); const ly = CY + Math.sin(rad) * (R + 96); @@ -385,12 +374,18 @@ const ThreePhaseRing: React.FC<{phaseAt: number; handAt: number; drawersAt: numb ); })} - {/* 「交融」标注:三段弧互相咬合(非硬阶段) */} - {frame > phaseAt + 34 ? ( - - {'三相互相交融——不是硬阶段'} - - ) : null} + {/* 「交融」标注:三段弧互相咬合(非硬阶段)——淡入而非瞬现 */} + + {'三相互相交融——不是硬阶段'} + {/* 人的手:落向「行动」节点,环上光点被拨偏一档 */} {hand > 0 ? ( @@ -441,10 +436,7 @@ const ThreePhaseRing: React.FC<{phaseAt: number; handAt: number; drawersAt: numb '轮数计数', '上次继续原因', ].map((label, i) => { - const e = interpolate(frame - drawersAt - i * 2, [0, 8], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const e = drawerCells[i]; return (
= ({branchAt, dimAt}) => { const frame = useCurrentFrame(); const names = ['报错', '中断', '钩子叫停', '轮数到顶', '预算烧完']; - const dim = interpolate(frame - dimAt, [0, 20], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const dim = useProgress(dimAt, DUR.f6); + // 五条支线逐条描线(stride 6;16f 距两档 token 均 >3,保留显式时长) + const branchP = useStagger(names.length, {at: branchAt, stride: 6, dur: 16}); return (
@@ -486,10 +477,7 @@ const ExitPaths: React.FC<{branchAt: number; dimAt: number}> = ({branchAt, dimAt
{names.map((n, i) => { - const t = interpolate(frame - branchAt - i * 6, [0, 16], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const t = branchP[i]; const y0 = 240; const y1 = 96 + i * 76; const x0 = 618; @@ -540,6 +528,7 @@ export const P1Loop: React.FC<{scene: SceneRange}> = ({scene}) => { const rF = (id: string) => w(id).from - bF.from; return ( + diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P2Dispatch.tsx b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P2Dispatch.tsx index ac2db9f4e..aec9c3a5a 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P2Dispatch.tsx +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P2Dispatch.tsx @@ -1,7 +1,7 @@ /** P2 加一个工具,只改一行(分镜 2-A…2-H)—— 开源教学素材「Tool Dispatch Map」的概念重建 * 重点视觉演绎:并发安全 ≠ 只读(真值表对撞)、连续块分批、读文件落盘自循环。 */ import React from 'react'; -import {AbsoluteFill, interpolate, Sequence, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import {AbsoluteFill, Sequence, spring, useCurrentFrame, useVideoConfig} from 'remotion'; import {theme} from '../design/theme'; import {beatWindow} from '../timing'; import type {SceneRange} from '../types'; @@ -17,12 +17,15 @@ import { Terminal, useRingDot, } from '../components/motifs'; +import {HarnessBadge} from '../components/harness-stack'; +import {DUR, SPRING, useAccelTravel, useBreathe, useImpulse, useProgress, useShake, useStagger} from '../motion'; /** 环留在左上角:缩小但同色同线宽 */ const CornerRing: React.FC<{pulse?: boolean}> = ({pulse = false}) => { - const frame = useCurrentFrame(); + // pulse 态呼吸:原 0.6+0.4·sin(frame/4),period = 2π·4 ≈ 25 + const breathe = useBreathe({period: 25, base: 0.6, amp: 0.4}); const dot = useRingDot(2.5); - const p = pulse ? 0.6 + 0.4 * Math.sin(frame / 4) : 1; + const p = pulse ? breathe : 1; return (
@@ -32,9 +35,8 @@ const CornerRing: React.FC<{pulse?: boolean}> = ({pulse = false}) => { /** 2-A 命令行拼接的笨拙 */ const ClumsyCommands: React.FC<{typoAt: number}> = ({typoAt}) => { - const frame = useCurrentFrame(); - const bad = frame >= typoAt; - const shake = bad ? Math.sin((frame - typoAt) / 1.6) * 3 : 0; + // typoAt 起持续抖动(负帧归零,与原 bad 布尔门等价) + const shake = useShake({at: typoAt, amp: 3, freq: 1.6}); return ( @@ -69,11 +71,13 @@ const ClumsyCommands: React.FC<{typoAt: number}> = ({typoAt}) => { /** 2-B 五张工具卡(反枚举:只有编号染色) */ const FiveTools: React.FC = () => { - const frame = useCurrentFrame(); const tools = ['跑命令', '读文件', '写文件', '改文件', '按模式找文件']; const mono = ['bash', 'read_file', 'write_file', 'edit_file', 'glob']; // 官方五类工具地图(how-claude-code-works):教学五件归位官方分区,两个空槽亮虚线(Harness Engineering 改造) const zones = ['文件操作', '文件操作', '文件操作', '文件操作', '搜索']; + // 分区小标同刻淡入(12f = DUR.f5);两个空槽错峰(stride 8、单项 f5,原终值 0.8 保留为系数) + const zoneOp = useProgress(20, DUR.f5); + const slotOn = useStagger(2, {at: 26, stride: 8, dur: DUR.f5}); return ( @@ -87,10 +91,7 @@ const FiveTools: React.FC = () => { fontSize: 17, color: theme.dim, marginTop: 10, - opacity: interpolate(frame - 20, [0, 12], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }), + opacity: zoneOp, }} > {zones[i]} @@ -111,10 +112,7 @@ const FiveTools: React.FC = () => { alignItems: 'center', justifyContent: 'center', gap: 8, - opacity: interpolate(frame - 26 - i * 8, [0, 12], [0, 0.8], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }), + opacity: slotOn[i] * 0.8, alignSelf: 'center', }} > @@ -141,10 +139,9 @@ const Dispatch: React.FC<{hitAt: number; slotAt: number}> = ({hitAt, slotAt}) => {key: 'glob', value: 'run_glob'}, ]; const hit = frame >= hitAt ? 1 : -1; - const flyT = interpolate(frame - hitAt, [-14, 0], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // 飞点在 hitAt 前 14 帧起飞、恰与命中行高亮同刻落位——窗口终点锚定, + // 换 token 会推后/提前落位,保留显式时长 + const flyT = useProgress(hitAt - 14, 14); return ( @@ -250,7 +247,7 @@ const QueueVsParallel: React.FC<{splitAt: number}> = ({splitAt}) => { {!split ? (
{names.map((n, i) => { - const e = spring({frame: frame - i * 5, fps, config: {damping: 200}}); + const e = spring({frame: frame - i * 5, fps, config: SPRING.settle}); return ( {n} @@ -302,6 +299,8 @@ const ConcurrencyTable: React.FC<{ quoteAt: number; }> = ({splitAt, flashAt, taskAt, quoteAt}) => { const frame = useCurrentFrame(); + // 「同名,结论相反」标注的闪烁包络:sin 起于 0 归于 0(须在 quoteAt 早退之前调用,hook 数量恒定) + const flashOp = useImpulse({at: flashAt, dur: 10}); if (frame >= quoteAt) { return ( - {flash ? ( -
- {'同名,结论相反'} -
- ) : null} + {/* 标注常驻渲染、以包络透明度一闪(原布尔瞬现 → 淡入淡出;窗口与背景 flash 同宽 10f) */} +
+ {'同名,结论相反'} +
{'isConcurrencySafe ≠ isReadOnly —— 第三方的源码分析'} @@ -461,29 +460,19 @@ const Batching: React.FC<{groupAt: number[]; noteAt: number}> = ({groupAt, noteA /** 2-H 落盘自循环:读→落盘→读→落盘,四圈后定格 */ const SpillLoop: React.FC<{markAt: number; loopAt: number}> = ({markAt, loopAt}) => { const frame = useCurrentFrame(); - const t = Math.max(0, frame - loopAt); - // 每圈加速一档:圈时长 40 → 30 → 22 → 16 帧 + // 每圈加速一档:圈时长 40 → 30 → 22 → 16 帧(useAccelTravel:跑完四圈冻结在终点, + // heat = lap/3 即原 denyMix 的失控感偏移系数) const durs = [40, 30, 22, 16]; - let acc = 0; - let lap = 0; - let within = 0; - for (let i = 0; i < durs.length; i++) { - if (t < acc + durs[i]) { - lap = i; - within = (t - acc) / durs[i]; - break; - } - acc += durs[i]; - lap = i + 1; - within = 1; - } - const done = lap >= durs.length; - const angle = done ? 300 : -90 + within * 360; const cx = 300; const cy = 210; const r = 132; - const rad = (angle * Math.PI) / 180; - const denyMix = Math.min(1, lap / 3); + const travel = useAccelTravel({cx, cy, r, durs, at: loopAt, heatPerLap: 3}); + const denyMix = travel.heat; + // 定格时刻 = 四圈时长累加(与 useAccelTravel 内部推进同口径) + const doneAt = loopAt + durs.reduce((a, b) => a + b, 0); + const done = frame >= doneAt; + // 定格判词:原布尔瞬现 → f4 淡入 + 轻微上移落位 + const verdictOp = useProgress(doneAt, DUR.f4); return ( @@ -500,9 +489,7 @@ const SpillLoop: React.FC<{markAt: number; loopAt: number}> = ({markAt, loopAt}) strokeDasharray="10 8" opacity={0.55 + denyMix * 0.45} /> - {!done ? ( - - ) : null} + {!done ? : null} {'读文件'} @@ -516,23 +503,23 @@ const SpillLoop: React.FC<{markAt: number; loopAt: number}> = ({markAt, loopAt}) {'再落盘'} - {done ? ( -
- {'转不出来'} -
- ) : null} +
+ {'转不出来'} +
= markAt ? theme.core : theme.panelBorder} @@ -573,6 +560,7 @@ export const P2Dispatch: React.FC<{scene: SceneRange}> = ({scene}) => { const rel = (b: {from: number}, id: string) => w(id).from - b.from; return ( + diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P3Gates.tsx b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P3Gates.tsx index 7169694d1..70536cc89 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P3Gates.tsx +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P3Gates.tsx @@ -1,11 +1,13 @@ /** P3 执行之前,先过闸门(分镜 3-A…3-G)—— 开源教学素材「Permission Desk」的概念重建 - * 三种结果不各占一色:allow 回 core(放行=回主干)、ask 用 mech、deny 用 danger。 */ + * 三种结果不各占一色:allow 回 core(放行=回主干)、ask 用 mech、deny 用 danger。 + * 重制(2026-09 运动层):动效收敛到 motion 模型(分镜动效列 @动词 可机检)。 */ import React from 'react'; -import {AbsoluteFill, interpolate, Sequence, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import {AbsoluteFill, Sequence, spring, useCurrentFrame, useVideoConfig} from 'remotion'; import {theme} from '../design/theme'; import {beatWindow} from '../timing'; import type {SceneRange} from '../types'; import {QuoteCard} from '../components/cards'; +import {HarnessBadge} from '../components/harness-stack'; import { CodeCard, Footnote, @@ -16,6 +18,7 @@ import { SceneTag, useRingDot, } from '../components/motifs'; +import {DUR, SPRING, useBreathe, useProgress, useShake, useStagger, win} from '../motion'; /** 3-A 五张工具卡,跑命令那张在「工作目录」框外 */ const UnguardedShell: React.FC<{frameAt: number; cmdAt: number; execAt: number}> = ({ @@ -25,13 +28,17 @@ const UnguardedShell: React.FC<{frameAt: number; cmdAt: number; execAt: number}> }) => { const frame = useCurrentFrame(); const dot = useRingDot(2.5); - const boxT = interpolate(frame - frameAt, [0, 22], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const shake = frame >= frameAt + 22 ? Math.sin(frame / 2.2) * 2.5 : 0; + // 合框 22f 是 beat 级动作(≥22 保留显式时长) + const boxT = useProgress(frameAt, 22); + // 闸门成形后持续微抖(错误语义;局部相位——模型口径) + const shake = useShake({at: frameAt + 22, amp: 2.5, freq: 2.2}); const showCmd = frame >= cmdAt; const dim = showCmd ? 0.55 : 1; + // rm -rf 大字:瞬现门 → 恒渲染 + f3 淡入;弹入 14f → f6 档 + const cmdOp = useProgress(cmdAt, DUR.f3); + const cmdPop = useProgress(cmdAt, DUR.f6); + // 循环角标:同款瞬现门 → 淡入 + const execOp = useProgress(execAt, DUR.f3); return ( @@ -70,56 +77,49 @@ const UnguardedShell: React.FC<{frameAt: number; cmdAt: number; execAt: number}>
- {showCmd ? ( +
+ {'rm -rf /'} +
+
+
- {'rm -rf /'} -
- ) : null} - {frame >= execAt ? ( -
- -
- {'循环照常执行'} -
+ {'循环照常执行'}
- ) : null} +
); }; /** 3-B 三道闸门落下,第一道把请求弹回 */ const GatesFall: React.FC<{gateAt: number[]; bounceAt: number}> = ({gateAt, bounceAt}) => { - const frame = useCurrentFrame(); - const g = gateAt.map((a) => - interpolate(frame - a, [0, 16], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}), - ); - const travel = interpolate(frame - bounceAt, [0, 20], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // 三道闸门:锚点等差(stride 取自 prop 锚距),落下 16f → f6 档 + const g = useStagger(3, {at: gateAt[0], stride: gateAt[1] - gateAt[0], dur: DUR.f6}); + const travel = useProgress(bounceAt, DUR.f6); + // 危险词 chips:第一道闸近落定(gateAt[0]+12)起 stride 4 错峰,10f → f5 + const banOp = useStagger(4, {at: gateAt[0] + 12, stride: 4, dur: DUR.f5}); return ( + {/* 容器参与 AbsoluteFill 垂直居中——恒渲染会整体上移 GateRouter(几何变化), + 保留门;子项自身已错峰淡入 */} {g[0] > 0.8 ? (
{['删根目录', '提权', '关机', '格式化'].map((t, i) => ( @@ -128,10 +128,7 @@ const GatesFall: React.FC<{gateAt: number[]; bounceAt: number}> = ({gateAt, boun accent={theme.deny} style={{ padding: '10px 20px', - opacity: interpolate(frame - gateAt[0] - 12 - i * 4, [0, 10], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }), + opacity: banOp[i], }} > {t} @@ -146,12 +143,11 @@ const GatesFall: React.FC<{gateAt: number[]; bounceAt: number}> = ({gateAt, boun /** 3-C 二三道闸门工作:一个停在审批前,一个畅通直达 */ const AskAndPass: React.FC<{askAt: number; passAt: number}> = ({askAt, passAt}) => { const frame = useCurrentFrame(); - const t1 = interpolate(frame, [0, 26], [0, 1], {extrapolateRight: 'clamp'}); - const t2 = interpolate(frame - passAt, [0, 30], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const t1 = useProgress(0, 26); + const t2 = useProgress(passAt, 30); const asking = frame >= askAt; + // 审批卡:瞬现门 → 恒渲染 + f3 淡入 + const askOp = useProgress(askAt, DUR.f3); return ( {/* 审批卡弹出时闸门整体上移让位——否则卡片会盖住三道闸, @@ -164,45 +160,44 @@ const AskAndPass: React.FC<{askAt: number; passAt: number}> = ({askAt, passAt}) >
- {asking ? ( - -
- {/* 绘制的三角叹号(不用 emoji——全彩字形会击穿三色契约) */} - - - - - - - {'Potentially destructive command'} - -
-
- {'bash("rm -rf ./tmp/build-cache")'} -
-
- {'Allow? [y/N] '} - - {'▍'} - -
-
- ) : null} + +
+ {/* 绘制的三角叹号(不用 emoji——全彩字形会击穿三色契约) */} + + + + + + + {'Potentially destructive command'} + +
+
+ {'bash("rm -rf ./tmp/build-cache")'} +
+
+ {'Allow? [y/N] '} + + {'▍'} + +
+
{t2 > 0 ? (
@@ -238,7 +233,7 @@ const AskAndPass: React.FC<{askAt: number; passAt: number}> = ({askAt, passAt}) top: 56, display: 'flex', gap: 18, - opacity: interpolate(t2, [0.4, 0.8], [0, 1], {extrapolateRight: 'clamp'}), + opacity: win(t2, [0.4, 0.8]), }} > {[ @@ -280,6 +275,11 @@ const DenyListHonesty: React.FC<{degradeAt: number; bypassAt: number; quoteAt: n quoteAt, }) => { const frame = useCurrentFrame(); + // hooks 先于 QuoteCard 早退(Rules of Hooks:调用数须恒定) + const degradeOp = useProgress(degradeAt, DUR.f3); + const bypassOp = useProgress(bypassAt, DUR.f3); + // 绕过变体:stride 8 错峰滑入,22f 描入(≥22 保留显式时长) + const variantP = useStagger(3, {at: bypassAt, stride: 8, dur: 22}); if (frame >= quoteAt) { return ; } @@ -302,17 +302,18 @@ const DenyListHonesty: React.FC<{degradeAt: number; bypassAt: number; quoteAt: n showLineNumbers={false} accent={degraded ? undefined : theme.deny} /> - {degraded ? ( -
- ) : null} + {/* 降级虚框:绝对定位无布局影响——瞬现门 → 淡入 */} +
+ {/* 降级说明是常规流子节点:恒渲染会永久改变行内居中(几何变化),保留瞬现门 */} {degraded ? (
{variants.map((v, i) => { - const t = interpolate(frame - bypassAt - i * 8, [0, 22], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const t = variantP[i]; if (t <= 0) return null; return (
); })} - {frame >= bypassAt ? ( -
- {'换个拼法就绕过去了'} -
- ) : null} + {/* 末位流子节点且右列恒短于左列——恒渲染无布局影响,瞬现门 → 淡入 */} +
+ {'换个拼法就绕过去了'} +
); @@ -370,11 +369,12 @@ const FourResults: React.FC<{fourthAt: number; arcAt: number}> = ({fourthAt, arc {t: '问你', en: 'ask', c: theme.mech}, {t: '不表态', en: 'passthrough', c: theme.dim}, ]; - const arc = interpolate(frame - arcAt, [0, 24], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const breathe = 0.5 + 0.5 * Math.sin(frame / 8); + // 回连弧 24f 是 beat 级(≥22 保留显式时长)。弧进度同时驱动 svg 守卫与「兜底」 + // 字门——draw 三元组保持内联共享同一进度(换 useDraw 会 fork 出 decelerate + // 进度,使 0.9 字门与描线失同步) + const arc = useProgress(arcAt, 24); + // sin(frame/8) → period 2π·8 + const breathe = useBreathe({period: 2 * Math.PI * 8, amp: 0.5, base: 0.5}); return (
@@ -382,7 +382,9 @@ const FourResults: React.FC<{fourthAt: number; arcAt: number}> = ({fourthAt, arc {cells.map((c, i) => { const fourth = i === 3; const on = fourth ? frame >= fourthAt : true; - const e = spring({frame: frame - (fourth ? fourthAt : i * 5), fps, config: {damping: 200}}); + // 弹簧在 map 内保持纯调用(锚点非均匀:三格 i*5、第四格 fourthAt); + // damping 200 → SPRING.settle + const e = spring({frame: frame - (fourth ? fourthAt : i * 5), fps, config: SPRING.settle}); return ( = denyHitAt; const naked = frame >= nakedAt; - const orderOn = interpolate(frame - orderAt, [0, 20], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // 顺序点亮 20f → f6 档 + const orderOn = useProgress(orderAt, DUR.f6); + // 裸名 deny 注释:门保留(元素本就以 opacity 淡入),14f → f6 档 + const nakedOp = useProgress(nakedAt, DUR.f6); const PX = 300; // 轨道起点 const GAP = 380; return ( @@ -501,7 +500,8 @@ const EvalOrder: React.FC<{orderAt: number; ballAt: number; denyHitAt: number; n {/* 三站闸门 */} {stations.map((st, i) => { const at = orderAt + 4 + i * 7; - const e = spring({frame: frame - at, fps, config: {damping: 200}}); + // map 内弹簧保持纯调用;damping 200 → SPRING.settle + const e = spring({frame: frame - at, fps, config: SPRING.settle}); const color = st.c === 'deny' ? theme.deny : st.c === 'mech' ? theme.mech : theme.core; return (
@@ -575,10 +575,12 @@ const ClassifierVision: React.FC<{scrollAt: number; stripAt: number; goldenAt: n {t: '裸命令:npm install --save-dev vitest', why: '', strike: false}, ]; const spoofOn = frame >= spoofAt; - const golden = interpolate(frame - goldenAt, [0, 18], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // 金化 18f → f6 档 + const golden = useProgress(goldenAt, DUR.f6); + // 五行逐条滚入:stride 9、单项 12f = DUR.f5 + const rowP = useStagger(rows.length, {at: scrollAt, stride: 9, dur: DUR.f5}); + // 反例帧行进 30f(≥22 保留显式时长)——提升到组件顶(IIFE 内不可调 hook) + const spoofT = useProgress(spoofAt, 30); return (
@@ -594,11 +596,7 @@ const ClassifierVision: React.FC<{scrollAt: number; stripAt: number; goldenAt: n
{rows.map((r, i) => { - const at = scrollAt + i * 9; - const e = interpolate(frame - at, [0, 12], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const e = rowP[i]; const stricken = r.strike && frame >= stripAt + i * 6; return (
@@ -641,22 +639,18 @@ const ClassifierVision: React.FC<{scrollAt: number; stripAt: number; goldenAt: n ); })}
- {/* 反例帧:伪装的「用户早就批准了」被视野边界弹回 */} + {/* 反例帧:伪装的「用户早就批准了」被视野边界弹回(几何随 spoofT 变化,保留守卫门) */} {spoofOn ? ( {(() => { - const t2 = interpolate(frame - spoofAt, [0, 30], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const x = 200 + t2 * 900; - const rejected = t2 > 0.75; + const x = 200 + spoofT * 900; + const rejected = spoofT > 0.75; return ( - {t2 < 0.75 ? ( + {spoofT < 0.75 ? ( 「用户早就批准了这个操作」 @@ -699,6 +693,7 @@ export const P3Gates: React.FC<{scene: SceneRange}> = ({scene}) => { const bG = w('p3-30', 'p3-35'); return ( + = ({needAt, pierceAt}) => { - const frame = useCurrentFrame(); const dot = useRingDot(2.5); const needs = ['每次跑命令记一笔日志', '某些操作后自动提交代码', '危险动作往群里发通知']; - const pierce = interpolate(frame - pierceAt, [0, 20], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // 环线应力撑开:20f → f6(13-21 档) + const pierce = useProgress(pierceAt, DUR.f6); + // 三张需求卡错峰滑入(stride 8、单项 14f → f6) + const needP = useStagger(needs.length, {at: needAt, stride: 8, dur: DUR.f6}); return (
- {pierce > 0.4 ? ( -
- ) : null} + {/* 应力辉光:原布尔门改常渲染,透明度自 0.4 起亮(负值钳 0) */} +
{needs.map((n, i) => { - const t = interpolate(frame - needAt - i * 8, [0, 14], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const t = needP[i]; return (
= ({quoteAt}) => { ' auto_git_add(block) # 再加一行', ' # ... 很快循环就认不出来了', ]; + // 逐条插入的量化节拍(14f 一档):阶梯式挤出视区是刻意的量化舞台,不模型化 const inserted = Math.min(5, Math.max(0, Math.floor((frame - 6) / 14))); const shift = inserted * 26; return ( @@ -122,11 +121,18 @@ const DegradedLoop: React.FC<{quoteAt: number}> = ({quoteAt}) => { /** 4-C 杂活被拔出循环、变成回调卡,环恢复原线宽 */ const PullOut: React.FC<{tableAt: number; plugAt: number}> = ({tableAt, plugAt}) => { const frame = useCurrentFrame(); - const {fps} = useVideoConfig(); const dot = useRingDot(2.5); - const out = interpolate(frame, [0, 26], [0, 1], {extrapolateRight: 'clamp'}); - const plug = spring({frame: frame - plugAt, fps, config: {damping: 12}}); + // 拔出收束:26f 属 beat 级大位移(≥22,保留显式时长) + const out = useProgress(0, 26); + // 插头咬合的过冲:damping 12 即 snap 预设的锚定来源 + const plug = useSpring('snap', {at: plugAt}); + // 咬合瞬间的 2 帧高亮闪(模式切换,保留硬切) const flash = frame >= plugAt + 6 && frame < plugAt + 8; + // 表格与插头句:原布尔瞬现门 → 常渲染 + f3 淡入 + const tableOp = useProgress(tableAt, DUR.f3); + const plugOp = useProgress(plugAt, DUR.f3); + // 四行时机错峰浮现(stride 5、单项 10f → f5) + const rowP = useStagger(4, {at: tableAt, stride: 5, dur: DUR.f5}); return (
@@ -152,46 +158,45 @@ const PullOut: React.FC<{tableAt: number; plugAt: number}> = ({tableAt, plugAt}) ); })}
+ {/* 两元素均为常规流子节点:布局门保留(常驻会把 Panel 顶高 ~80px),门内淡入 */}
{frame >= tableAt ? ( - -
-
{'时机'}
-
{'要跑的函数'}
+ +
+
{'时机'}
+
{'要跑的函数'}
+
+ {['进模型前', '执行前', '执行后', '停机前'].map((k, i) => ( +
+
{k}
+
{'[ … ]'}
- {['进模型前', '执行前', '执行后', '停机前'].map((k, i) => ( -
-
{k}
-
{'[ … ]'}
-
- ))} -
+ ))} + ) : null} {frame >= plugAt ? ( -
- {'加功能是插一个插头,不是重新布线'} -
+
+ {'加功能是插一个插头,不是重新布线'} +
) : null}
@@ -207,14 +212,12 @@ const SlotsLightUp: React.FC<{slotAt: number[]; gateMoveAt: number; backflowAt: const frame = useCurrentFrame(); const dot = useRingDot(2.5); const lit = slotAt.filter((a) => frame >= a).length - 1; - const gm = interpolate(frame - gateMoveAt, [0, 24], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const back = interpolate(frame - backflowAt, [0, 22], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // P3 闸门搬入插槽:24f 属 beat 级(≥22,保留显式时长) + const gm = useProgress(gateMoveAt, 24); + // 回流描线:22f 属 beat 级(≥22,保留显式时长) + const back = useProgress(backflowAt, 22); + // 闸门组原布尔瞬现门 → 常渲染 + f3 淡入(再叠加 gm 的压暗) + const gateOp = useProgress(gateMoveAt, DUR.f3); // 容器尺寸由 SlotRing 的定位契约推导(见该组件 docstring),环居中 const RING = 400; const W = RING + 2 * (SLOT_W + SLOT_GAP); @@ -227,35 +230,33 @@ const SlotsLightUp: React.FC<{slotAt: number[]; gateMoveAt: number; backflowAt:
{/* P3 的三道闸门缩小平移进右上角的「工具执行之前」插槽(跨幕视觉呼应) */} - {gm > 0 ? ( - - {[0, 1, 2].map((i) => ( - - ))} - - ) : null} + + {[0, 1, 2].map((i) => ( + + ))} + {/* 「别停,接着干」:从左下角插槽回流到环的入口(问模型节点) */} {back > 0 ? ( { const frame = useCurrentFrame(); - const {fps} = useVideoConfig(); - // 三层依次成形的进度 - const o = interpolate(frame - outerAt, [0, 16], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); - const m = interpolate(frame - midAt, [0, 16], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); - const i = interpolate(frame - innerAt, [0, 16], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); - // 内层光点沿「工具前→执行→工具后→整批落定」跑一圈 + // 三层依次成形的进度(单项 16f → f6) + const o = useProgress(outerAt, DUR.f6); + const m = useProgress(midAt, DUR.f6); + const i = useProgress(innerAt, DUR.f6); + // 内层光点沿「工具前→执行→工具后→整批落定」跑一圈:80f 周期模除驱动 bespoke 位置 + // 高亮(非环形巡游,useTravel 不适用),保留内联(deferred) const lap = ((frame - innerAt) % 80) / 80; const lapOn = frame >= innerAt + 16; const asyncEvents = ['会话开始', '会话结束', '配置变更', '目录切换', '文件被改', '通知']; + // 右栏整体淡入(16f → f6)+ 六格错峰(stride 3、单项 8f → f3) + const asyncOp = useProgress(asyncAt, DUR.f6); + const asyncCells = useStagger(asyncEvents.length, {at: asyncAt, stride: 3, dur: DUR.f3}); return (
@@ -465,10 +469,7 @@ const HookNesting: React.FC<{outerAt: number; midAt: number; innerAt: number; co right: 0, top: 10, width: 420, - opacity: interpolate(frame - asyncAt, [0, 16], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }), + opacity: asyncOp, }} >
@@ -486,10 +487,7 @@ const HookNesting: React.FC<{outerAt: number; midAt: number; innerAt: number; co fontSize: 19, color: theme.dim, background: theme.panel, - opacity: interpolate(frame - asyncAt - k * 3, [0, 8], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }), + opacity: asyncCells[k], }} > {e} @@ -528,13 +526,14 @@ const StampClash: React.FC<{ quoteAt: number; }> = ({pushAt, blockAt, arrowAt, gapAt, quoteAt}) => { const frame = useCurrentFrame(); + // 上层印章推进 18f → f6;箭头组 20f → f6;缺口警示条原布尔门 → f3 淡入。 + // hooks 必须先于金句早退调用(Rules of Hooks:早退后不得再出现 hook) + const push = useProgress(pushAt, DUR.f6); + const arrow = useProgress(arrowAt, DUR.f6); + const gapOp = useProgress(gapAt, DUR.f3); if (frame >= quoteAt) { return ; } - const push = interpolate(frame - pushAt, [0, 18], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); const blocked = frame >= blockAt; /* 「顶住」不能是一次性瞬间事件:旁白「配置里的禁止和询问仍然要再走一遍」有 4 秒多, 若冲击波 16 帧就衰减完,画面在这句的大部分时间里是静止的、读不出对撞。 @@ -545,15 +544,11 @@ const StampClash: React.FC<{ extrapolateRight: 'clamp', }) + (frame - blockAt > 24 ? Math.sin((frame - blockAt) / 7) * 3.5 : 0) : 0; - // 冲击波每 34 帧复发一次(持续施压 → 持续被顶回) + // 冲击波每 34 帧复发一次(持续施压 → 持续被顶回)——模除包络 + 条件抖动是刻意的 + // 周期性冲击模型,motion 层暂无对应原语,保留 bespoke(deferred:需 periodic-impulse 模型) const shock = blocked ? interpolate((frame - blockAt) % 34, [0, 16], [0, 1], {extrapolateRight: 'clamp'}) : 0; - const arrow = interpolate(frame - arrowAt, [0, 20], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const gap = frame >= gapAt; return (
@@ -607,50 +602,47 @@ const StampClash: React.FC<{ ))} ) : null} - {arrow > 0 ? ( -
- -
- {'加限制 ↑ 可以'} -
-
- -
- {'解除限制 ↓ 不行'} -
-
-
- {'方向是单向的'} +
+ +
+ {'加限制 ↑ 可以'}
-
- ) : null} - {gap ? ( + + +
+ {'解除限制 ↓ 不行'} +
+
- {'教学版缺这一层 → 生产环境的安全漏洞'} + {'方向是单向的'}
- ) : null} +
+
+ {'教学版缺这一层 → 生产环境的安全漏洞'} +
{'hook allow 不能绕过 settings 的 deny/ask —— 第三方的源码分析'} @@ -671,14 +663,12 @@ const SixGatePipeline: React.FC<{railAt: number; runAt: number; yoloAt: number}> {t: 'mode', sub: '模式'}, {t: '默认', sub: '兜底问你'}, ]; - const rail = interpolate(frame - railAt, [0, 22], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const travel = interpolate(frame - runAt, [0, 50], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // 轨道浮现 22f / 光点过闸 50f:均属 beat 级窗口(≥22,保留显式时长) + const rail = useProgress(railAt, 22); + const travel = useProgress(runAt, 50); + // 六站错峰点亮(stride 4、单项 10f → f5);yolo 标注 12f = f5 + const stationP = useStagger(stations.length, {at: railAt + 6, stride: 4, dur: DUR.f5}); + const yoloOp = useProgress(yoloAt, DUR.f5); const yolo = frame >= yoloAt; // 两轮演示:第一轮光点在 deny 站被截;第二轮(全放行模式)仍在 deny 站被截 const cutAt = 1 / 6 + 0.5 / 6; @@ -719,11 +709,7 @@ const SixGatePipeline: React.FC<{railAt: number; runAt: number; yoloAt: number}> {/* 六站 */} {stations.map((st, i) => { - const at = railAt + 6 + i * 4; - const e = interpolate(frame - at, [0, 10], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const e = stationP[i]; const isDeny = st.t === 'deny'; const bypassed = yolo && (st.t === 'mode'); return ( @@ -756,7 +742,7 @@ const SixGatePipeline: React.FC<{railAt: number; runAt: number; yoloAt: number}> fontFamily: theme.sans, fontSize: 23, color: theme.deny, - opacity: interpolate(frame - yoloAt, [0, 12], [0, 1], {extrapolateRight: 'clamp'}), + opacity: yoloOp, }} > {'全放行模式也一样:拒绝压得住'} @@ -773,29 +759,13 @@ const SixGatePipeline: React.FC<{railAt: number; runAt: number; yoloAt: number}> /** 4-H 自激小闭环,被一枚标记截断 */ const SelfLoopBreak: React.FC<{spinAt: number; markAt: number}> = ({spinAt, markAt}) => { const frame = useCurrentFrame(); - const t = Math.max(0, frame - spinAt); - const durs = [34, 26, 20]; - let acc = 0; - let lap = 0; - let within = 0; - for (let i = 0; i < durs.length; i++) { - if (t < acc + durs[i]) { - lap = i; - within = (t - acc) / durs[i]; - break; - } - acc += durs[i]; - lap = i + 1; - within = 0; - } - const marked = frame >= markAt; - // 第四圈行进到标记处被截断 - const angle = marked ? -30 : -90 + within * 360; const cx = 260; const cy = 220; const r = 128; - const rad = (angle * Math.PI) / 180; - const heat = Math.min(1, lap / 2.5); + // 加速绕行(34/26/20 逐圈提速,heat 随圈数 /2.5 升温)——手写累加器 → useAccelTravel + const travel = useAccelTravel({cx, cy, r, durs: [34, 26, 20], at: spinAt, heatPerLap: 2.5}); + const marked = frame >= markAt; + const heat = travel.heat; return (
@@ -809,8 +779,9 @@ const SelfLoopBreak: React.FC<{spinAt: number; markAt: number}> = ({spinAt, mark strokeWidth={5} opacity={0.5 + heat * 0.5} /> + {/* 光点行进至 -30°(标记处)被截断:marked 后由下方标记方块接管该位置 */} {!marked ? ( - + ) : null} {'插口说「别停」'} @@ -858,6 +829,7 @@ export const P4Hooks: React.FC<{scene: SceneRange}> = ({scene}) => { const bH = w('p4-35', 'p4-37'); return ( + diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P5Stack.tsx b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P5Stack.tsx index 27d72dd15..adfa4fc67 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P5Stack.tsx +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P5Stack.tsx @@ -1,14 +1,17 @@ /** P5 四层叠起来是什么(分镜 5-A…5-D)—— 主题回收 * 5-B 的柱体用**实测值**:总行 141/191/241/255,其中循环段 23/20/24/28 * (非空非注释 @ pinned commit)。刻意不美化成「完全等高」——口播说的是 - * 「一直在二十到二十八行之间」,画面必须与这句话一致。 */ + * 「一直在二十到二十八行之间」,画面必须与这句话一致。 + * 重制(2026-09 运动层):动效收敛 motion 模型;常驻角标 HarnessBadge(skills/06)。 */ import React from 'react'; -import {AbsoluteFill, interpolate, Sequence, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import {AbsoluteFill, Sequence, spring, useCurrentFrame, useVideoConfig} from 'remotion'; import {theme} from '../design/theme'; import {beatWindow} from '../timing'; import type {SceneRange} from '../types'; import {QuoteCard} from '../components/cards'; import {Footnote, LoopRing, Panel, Terminal, useRingDot} from '../components/motifs'; +import {HarnessBadge} from '../components/harness-stack'; +import {DUR, SPRING, useProgress, win} from '../motion'; const CHAPTERS = [ {name: '一个循环', total: 141, loop: 23}, @@ -32,10 +35,8 @@ const FourLayers: React.FC<{splitAt: number; mapAt: number}> = ({splitAt, mapAt} {t: '三道闸门', s: '让危险的事在执行之前被拦住', core: false}, {t: '一排插口', s: '让扩展挂在外面,不侵入内核', core: false}, ]; - const split = interpolate(frame - splitAt, [0, 22], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // 卡片阵重排(骨架让位 / 挂件滑拢)是幕级动作:22f ≥22 不入 micro token;原线性 → standard + const split = useProgress(splitAt, 22); // 官方四件套标尺:位置对齐下方四卡的水平中心(卡宽 350 + gap 22 → 步距 372) const OFFICIAL = [ {t: '循环', link: 0}, @@ -43,14 +44,12 @@ const FourLayers: React.FC<{splitAt: number; mapAt: number}> = ({splitAt, mapAt} {t: '上下文管理', link: -1}, {t: '护栏', link: 3}, ]; - const map = interpolate(frame - mapAt, [0, 18], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const linkOn = interpolate(frame - mapAt - 10, [0, 14], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // 标尺落下 18f → f6(Δ3);原线性 → standard + const map = useProgress(mapAt, DUR.f6); + // 连线生长 14f → f5(Δ2);原线性 → standard + const linkOn = useProgress(mapAt + 10, DUR.f5); + // 「骨架 / 挂件 ×3」命名标注:split 窗口中点(11f)浮现,与 P6 同名标注同档 + const nameOp = useProgress(splitAt + 11, DUR.f5); const CARD_W = 350; const GAP = 22; const stripLeft = 960 - (CARD_W * 4 + GAP * 3) / 2; @@ -122,7 +121,8 @@ const FourLayers: React.FC<{splitAt: number; mapAt: number}> = ({splitAt, mapAt}
{layers.map((l, i) => { - const e = spring({frame: frame - i * 8, fps, config: {damping: 200}}); + // 四卡错峰弹入:map 内只跑纯 spring()(hook 禁入 map),settle = 既有 damping 200 + const e = spring({frame: frame - i * 8, fps, config: SPRING.settle}); // 骨架卡:split 后向左漂出并让位给环;挂件卡:split 后向左滑拢(对环的预演) const drift = l.core ? split * -60 : split * -14; const lift = l.core ? 0 : -split * 10; @@ -169,6 +169,8 @@ const FourLayers: React.FC<{splitAt: number; mapAt: number}> = ({splitAt, mapAt} {l.s}
+ {/* 骨架小标:布局门保留(in-flow 子节点,常驻会顶起卡 1 底对齐), + 门内淡入替代瞬现——布局归布局、透明度归透明度 */} {l.core && split > 0.5 ? (
= ({splitAt, mapAt} fontFamily: theme.sans, fontSize: 24, color: theme.core, + opacity: nameOp, }} > {'骨架'} @@ -186,44 +189,43 @@ const FourLayers: React.FC<{splitAt: number; mapAt: number}> = ({splitAt, mapAt} ); })}
- {/* 环自 split 中段从卡片阵后浮出(第 6 次出场:同色同线宽),三张挂件卡尾部各引一条挂线贴向它 */} - {split > 0.35 ? ( -
- -
- {'骨架'} -
-
- ) : null} - {split > 0.5 ? ( + {/* 环自 split 中段从卡片阵后浮出(第 6 次出场:同色同线宽),三张挂件卡尾部各引一条挂线贴向它。 + 常驻渲染:透明度走 split 域窗口,窗外恒 0(替代原布尔门) */} +
+
- {'挂件 ×3'} + {'骨架'}
- ) : null} +
+ {/* 挂件计数:split 中点淡入(原 split>0.5 硬门瞬现) */} +
+ {'挂件 ×3'} +
); }; @@ -234,11 +236,10 @@ const GrowthBars: React.FC<{barAt: number; coreAt: number}> = ({barAt, coreAt}) const {fps} = useVideoConfig(); const H = 430; const maxLines = 260; + // 测量模式切换(柱体压暗 / core 段点亮 / 行数标注现身):离散状态,保留硬门 const showCore = frame >= coreAt; - const bandT = interpolate(frame - coreAt - 6, [0, 16], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + // 实测带 16f:距 f5/f6 均 >3(Δ4/Δ5),保留显式时长;原线性 → standard + const bandT = useProgress(coreAt + 6, 16); const yFor = (lines: number) => (lines / maxLines) * H; //: 柱底距容器底的实测像素:幕名标签(22px 字,行盒 ~33)+ `marginTop: 12`。 //: 基准带必须减掉它才能落在柱子自己的行数刻度上——此前写 90,带体整体上浮 45px。 @@ -248,7 +249,8 @@ const GrowthBars: React.FC<{barAt: number; coreAt: number}> = ({barAt, coreAt})
{CHAPTERS.map((c, i) => { const at = barAt + i * 9; - const e = spring({frame: frame - at, fps, config: {damping: 200}}); + // 四柱错峰生长:map 内只跑纯 spring()(hook 禁入 map),settle = 既有 damping 200 + const e = spring({frame: frame - at, fps, config: SPRING.settle}); const h = (c.total / maxLines) * H * e; const ch = (c.loop / maxLines) * H * e; return ( @@ -320,62 +322,61 @@ const GrowthBars: React.FC<{barAt: number; coreAt: number}> = ({barAt, coreAt})
); })} - {/* 20–28 行实测带:横贯四柱的虚线基准带——「恒在 20–28 行」这句口播的测量仪器 */} - {bandT > 0 ? ( + {/* 20–28 行实测带:横贯四柱的虚线基准带——「恒在 20–28 行」这句口播的测量仪器。 + 常驻渲染:整体透明度随 bandT 自 0 起(替代原布尔门) */} +
+
+
+ {/* 带宽只有 13px,标注压在带子上方会撞四根柱子各自的 loop 行数(`bottom: ch + 6` + 恰好落在同一高度)——放到带子右侧的空白区,垂直居中对齐带体 */}
-
-
- {/* 带宽只有 13px,标注压在带子上方会撞四根柱子各自的 loop 行数(`bottom: ch + 6` - 恰好落在同一高度)——放到带子右侧的空白区,垂直居中对齐带体 */} -
- {'循环函数行数带 20–28(实测)'} -
+ {'循环函数行数带 20–28(实测)'}
- ) : null} +
{'总行 141 → 255(+81%)· 循环函数恒在 20–28 行(非空非注释)'} @@ -389,7 +390,10 @@ const SameRingFourLabels: React.FC<{flipAt: number; quoteAt: number}> = ({flipAt const frame = useCurrentFrame(); const dot = useRingDot(2.5); const labels = ['写死一个命令', '查一张表', '先过一道闸门', '交给一排插口']; + // 标签翻牌是离散事件(每 12f 一翻),保留整数翻转 const idx = frame < flipAt ? -1 : Math.min(labels.length - 1, Math.floor((frame - flipAt) / 12)); + // 首翻起淡入计数标注(原 idx>=0 硬门瞬现);在 QuoteCard 早退之前取——hook 数量须恒定 + const flipOp = useProgress(flipAt, DUR.f3); if (frame >= quoteAt) { return ( @@ -405,22 +409,21 @@ const SameRingFourLabels: React.FC<{flipAt: number; quoteAt: number}> = ({flipAt activeNode={2} nodeLabels={['问模型', '看回答', idx < 0 ? '执行工具' : labels[idx], '填回结果']} /> - {idx >= 0 ? ( -
- {`第 ${idx + 1} 次写法`} -
- ) : null} +
+ {`第 ${idx + 1} 次写法`} +
{'同一个位置,换了四次写法'} @@ -430,6 +433,9 @@ const SameRingFourLabels: React.FC<{flipAt: number; quoteAt: number}> = ({flipAt /** 5-D 回到 P0 的终端,这次它自己跑起来 */ const SelfRunning: React.FC<{shellAt: number; quoteAt: number}> = ({shellAt, quoteAt}) => { const frame = useCurrentFrame(); + // 壳合拢是幕级动作:26f ≥22 不入 micro token;原线性 → standard。 + // 在 QuoteCard 早退之前取——hook 数量须恒定 + const shell = useProgress(shellAt, 26); if (frame >= quoteAt) { return ( = ({shellAt, quo /> ); } - const shell = interpolate(frame - shellAt, [0, 26], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); return (
@@ -457,22 +459,20 @@ const SelfRunning: React.FC<{shellAt: number; quoteAt: number}> = ({shellAt, quo {text: 'python ./tools/fmt.py → 完成', color: theme.mech, delay: 80}, ]} /> - {/* 四层结构在终端外围合拢成一个壳 */} - {shell > 0 ? ( -
- ) : null} + {/* 四层结构在终端外围合拢成一个壳。常驻渲染:透明度随 shell 自 0 起(替代原布尔门) */} +
{'一个愿意反复跑、能查表、会拦门、留了插口的壳'} @@ -488,6 +488,7 @@ export const P5Stack: React.FC<{scene: SceneRange}> = ({scene}) => { const bD = w('p5-15', 'p5-19'); return ( + diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P6Ending.tsx b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P6Ending.tsx index 5b8834817..606cc8402 100644 --- a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P6Ending.tsx +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/scenes/P6Ending.tsx @@ -1,13 +1,15 @@ /** P6 收尾(分镜 6-A…6-B) - * ★ 渐黑窗口从**末 beat 总时长**推导(beatDurationInFrames),不是末句时长 - * —— 第三集上线教训:末句短于 beat 时渐黑提前收尾,导致收尾长黑屏 - * (skills/06 渲染红线四)。 */ + * ★ 渐黑窗口从**末 beat 总时长**推导(useFadeOut 模型化——红线四由构造保证)。 + * 重制(2026-09 运动层):动效收敛 motion 模型;系列栈收尾(HarnessStackP6) + * 落地 skills/06「P6 用法」:栈重新放大居中、已发布层点亮、下期层呼吸预告。 */ import React from 'react'; -import {AbsoluteFill, interpolate, Sequence, spring, useCurrentFrame, useVideoConfig} from 'remotion'; +import {AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig} from 'remotion'; import {theme} from '../design/theme'; import {beatWindow} from '../timing'; import type {SceneRange} from '../types'; import {LoopRing, Panel, useRingDot} from '../components/motifs'; +import {HarnessBadge, HarnessStackP6, NEXT_LAYER} from '../components/harness-stack'; +import {DUR, SAFE_TOP_Y, progress, useFadeOut, useProgress, useSpring, useStagger} from '../motion'; /** 6-A 壳的四层:三张挂件卡沿径向滑入、逐一「咬合」上环(骨架 vs 挂件的物理化收束)。 * 循环层不用卡——它就是环本身(p6-03 的口播顺序:循环→分发表→闸门→插口)。 @@ -21,10 +23,6 @@ const ShellLayers: React.FC<{layerAt: number[]}> = ({layerAt}) => { const RING_R = (R * 2 + 76) / 2 - 46; const CX = 560; const CY = 540; - // 字幕安全带上沿:与 qa_frames.py 的 SUBTITLE_BAND_PX = 160 同口径(比 skills/06 - // 红线二「角标 bottom ≥ 150」再严 10px,留出体检余量)。入场轨迹必须自己让开它—— - // **落位态干净、入场越界**这一类缺陷 `--check` 每幕只抽 ~8 帧,结构性看不见。 - const SAFE_TOP_Y = 1080 - 160; const CARD_HALF_H = 36; // 卡片 rect 半高 34 + 描边 2.5 的一半,向上取整 // 三张挂件卡沿环 120° 均分,且刻意避开 0/±90/180 —— LoopRing 的四个节点文案 // (问模型/看回答/执行工具/填回结果)就画在那四个方位上,挂脚会横穿它们。 @@ -33,8 +31,18 @@ const ShellLayers: React.FC<{layerAt: number[]}> = ({layerAt}) => { {t: '闸门', s: '守着底线', c: theme.deny, ang: 75}, {t: '插口', s: '留给你发挥', c: theme.mech, ang: 195}, ]; + // 三卡径向滑入:时点来自句锚(layerAt[1..3]),单项 12f = DUR.f5。 + // 显式三个调用而非 map——map 内调 hook 违反 Rules of Hooks(数量须恒定) + const lit = [ + useProgress(layerAt[1], DUR.f5), + useProgress(layerAt[2], DUR.f5), + useProgress(layerAt[3], DUR.f5), + ]; const allOn = frame >= layerAt[3]; + // 咬合后的整组轻呼吸:单向半波(只向内收),与 useBreathe 的正弦不同——保留内联 const breathe = allOn ? 1 - 0.02 * Math.max(0, Math.sin((frame - layerAt[3]) / 9)) : 1; + const cycleOp = useProgress(layerAt[0], DUR.f5); + const hangOp = useProgress(layerAt[3], DUR.f5); return ( @@ -43,12 +51,8 @@ const ShellLayers: React.FC<{layerAt: number[]}> = ({layerAt}) => { {attach.map((a, i) => { - const on = frame >= layerAt[i + 1]; - const t = interpolate(frame - layerAt[i + 1], [0, 16], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - if (!on) return null; + const t = lit[i]; + if (t <= 0) return null; const rad = (a.ang * Math.PI) / 180; // 卡片中心的落位半径:环外一圈(dist > RING_R,卡片整体在环之外—— // 底部那句「挂件 ×3,都挂在外面」是字面意思,卡片不得压在环线上) @@ -56,9 +60,9 @@ const ShellLayers: React.FC<{layerAt: number[]}> = ({layerAt}) => { // 挂脚长度 = 卡心到环线的径向距离;本组已按 ang 旋转,故 -x 指向环心 const stem = dist - RING_R; // 入场自环外 startPx 倍半径径向滑入。**向下**的卡(sin > 0)滑到最远处时 - // 会探进字幕安全带(1.6 倍时闸门卡下沿到 y≈1047,被字幕条切掉半张), - // 故其起步倍率按「卡片下沿贴住 SAFE_TOP_Y」封顶;向上的两张不受此限, - // 仍走完整行程。三张卡入场时刻错开,行程长短不同不构成可比性问题。 + // 会探进字幕安全带——其起步倍率按「卡片下沿贴住 SAFE_TOP_Y(motion 的 + // SSOT 常量,与 qa_frames.SUBTITLE_BAND_PX 同口径)」封顶;向上的两张 + // 不受此限。ISSUE-170 的缺陷类由常量单源化。 const sinA = Math.sin(rad); const startPx = Math.min( 1.6, @@ -126,10 +130,7 @@ const ShellLayers: React.FC<{layerAt: number[]}> = ({layerAt}) => { position: 'absolute', left: 120, top: 220, - opacity: interpolate(frame - layerAt[0], [0, 12], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }), + opacity: cycleOp, }} >
@@ -147,10 +148,7 @@ const ShellLayers: React.FC<{layerAt: number[]}> = ({layerAt}) => { fontFamily: theme.sans, fontSize: 26, color: theme.mech, - opacity: interpolate(frame - layerAt[3], [0, 14], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }), + opacity: hangOp, }} > {'挂件 ×3,都挂在外面'} @@ -160,33 +158,29 @@ const ShellLayers: React.FC<{layerAt: number[]}> = ({layerAt}) => { }; /** - * 6-B 信源卡 + 系列身份卡 + 渐黑。 - * `beatDurationInFrames` 是**本 beat 的总时长**,渐黑窗口据此反推。 + * 6-B 信源卡 + 系列身份(栈收尾)+ 渐黑。 + * `beatDurationInFrames` 是**本 beat 的总时长**,渐黑窗口由 useFadeOut 据此反推。 */ const SourceAndFade: React.FC<{beatDurationInFrames: number; seriesAt: number}> = ({ beatDurationInFrames, seriesAt, }) => { - const frame = useCurrentFrame(); const {fps} = useVideoConfig(); - const enter = spring({frame: frame - 4, fps, config: {damping: 200}}); + const enter = useSpring('settle', {at: 4}); + // 信源四行 + 诚实行依次浮现(stride 4、单项 f4) + const rowsOn = useStagger(4, {at: 8, stride: 4, dur: DUR.f4}); const rows = [ ['官方文档', 'code.claude.com/docs · 取数2026年8月'], ['工程博客', 'anthropic.com/engineering'], ['源码分析', '第三方逆向分析 · 片中逐处标注'], ['数字口径', '开源仓库钉版实测 · 行数均为非空非注释口径'], ]; - const seriesT = interpolate(frame - seriesAt, [0, 20], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - // 渐黑:末 1.2 秒线性压暗到全黑,窗口从 beat 总时长反推 - const fadeFrames = Math.round(1.2 * fps); - const fadeStart = beatDurationInFrames - fadeFrames; - const dark = interpolate(frame, [fadeStart, beatDurationInFrames], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); + const seriesT = useProgress(seriesAt, DUR.f6); + const honesty = useProgress(8 + rows.length * 4, DUR.f4) * 0.9; + // 渐黑:末 1.2s 线性压暗——useFadeOut 从 beat 总时长反推(红线四模型化) + const keep = useFadeOut(beatDurationInFrames, {frames: Math.round(1.2 * fps)}); + const dark = 1 - keep; + const stackBreathAt = seriesAt + DUR.f5; return (
@@ -200,10 +194,7 @@ const SourceAndFade: React.FC<{beatDurationInFrames: number; seriesAt: number}> style={{ display: 'flex', marginBottom: 10, - opacity: interpolate(frame - 8 - i * 4, [0, 10], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }), + opacity: rowsOn[i], }} >
@@ -221,60 +212,59 @@ const SourceAndFade: React.FC<{beatDurationInFrames: number; seriesAt: number}> fontFamily: theme.sans, fontSize: 20, color: theme.dim, - opacity: interpolate(frame - 8 - rows.length * 4, [0, 10], [0, 0.9], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }), + opacity: honesty, }} > {'画面中的行数均为本片在固定提交上的实测(非空非注释口径)'}
- {seriesT > 0 ? ( +
+ {/* 系列栈收尾(skills/06 P6 用法):已发布层点亮、下期层呼吸预告 */} +
+ +
+
+ {'Claude Code Harness Engineering'} +
-
- {'Claude Code Harness Engineering'} -
-
- {'执行层:一个循环,就是全部'} + {'执行层:一个循环,就是全部'} +
+ {/* 下期预告卡:标题只在画面(口播为话题描述——反串线纪律)。 + 标题主段是 series.json 派生数据的硬编码镜像,由 check_series 规则 8 + 与清单对账(改标题先改 series.json,再同步此串——规则会拦漂移)。 */} +
+
+ {`下期 · ${NEXT_LAYER?.layer ?? ''}`}
- {/* 下期预告卡:标题只在画面(口播为话题描述——反串线纪律)。 - 标题为 series.json 派生数据的硬编码镜像,由 check_series 规则 8 - 与清单对账(改标题先改 series.json,再同步此串——规则会拦漂移)。 */} -
-
- {'下期 · 规划层'} -
-
- {'模型的视野是安排出来的'} -
+
+ {'模型的视野是安排出来的'}
- ) : null} +
{/* 渐黑遮罩 */} @@ -286,6 +276,11 @@ export const P6Ending: React.FC<{scene: SceneRange}> = ({scene}) => { const rel = (b: {from: number}, id: string) => w(id).from - b.from; const bA = w('p6-01', 'p6-04'); const bB = w('p6-05', 'p6-08'); + const frame = useCurrentFrame(); + // 常驻角标在 6-A 全程;6-B(= bB.from 起)信源/收尾卡占满视野,角标淡出避免拥挤。 + // 非 beat 用途不得写 w('句id') 字面形态——check_script 的 SCENE_CALL_RE 会把它当 + // beat 区间登记面(用 bB.from 取同一锚点) + const badgeFade = progress(frame - bB.from, 0, DUR.f5); return ( @@ -300,6 +295,11 @@ export const P6Ending: React.FC<{scene: SceneRange}> = ({scene}) => { seriesAt={rel(bB, 'p6-08')} /> + {frame < bB.from + DUR.f5 ? ( +
+ +
+ ) : null}
); }; diff --git a/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/series-layers.json b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/series-layers.json new file mode 100644 index 000000000..e8c5d4011 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-explained-video/video/src/series-layers.json @@ -0,0 +1,37 @@ +{ + "seriesId": "claude-code-explained", + "layers": [ + { + "index": 1, + "layer": "执行层", + "title": "执行层:一个循环,就是全部", + "published": false + }, + { + "index": 2, + "layer": "规划层", + "title": "规划层:模型的视野是安排出来的", + "published": false + }, + { + "index": 3, + "layer": "记忆层", + "title": "记忆层:会丢的和不能丢的", + "published": false + }, + { + "index": 4, + "layer": "时机层", + "title": "时机层:谁来按下开始", + "published": false + }, + { + "index": 5, + "layer": "协作层", + "title": "协作层:从一个到一群", + "published": false + } + ], + "activeIndex": 1, + "next": "规划层:模型的视野是安排出来的" +} 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-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..5e29cab52 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/scripts/motion.test.ts @@ -0,0 +1,141 @@ +/** + * 运动层纯函数单测——`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, revealCharCount, 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); +}); + +test('逐字揭示:cps 按实际 fps 换算并向下取整,framesPerChar 保持固定帧间隔', () => { + assert.equal(revealCharCount(29, 60, 12), 5); + assert.equal(revealCharCount(30, 60, 12), 6); + assert.equal(revealCharCount(30, 24, 12, 3), 10); + assert.equal(revealCharCount(30, 120, 12, 3), 10); +}); + +// ── 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 <= 24, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('fit 窗口连 1 帧子项也容不下时显式拒绝,不返回外溢计划', () => { + assert.throws(() => schedule(8, {dur: 12, fit: {total: 5}}), /fit\.total=5.*8/); +}); + +test('fit 可行极限先牺牲子项时长,仍完整落在窗口内', () => { + const p = schedule(8, {dur: 12, fit: {total: 8}}); + assert.equal(p.dur, 1); + assert.equal(p.starts[7] + p.dur, 8); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); + assert.deepEqual(schedule(3, {dur: 10, lag: 0}).starts, [0, 0, 0], 'lag=0 须同刻齐动'); +}); + +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..a71737133 --- /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 = 340; +const CH = 230; +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..f87b8a129 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/hooks.ts @@ -0,0 +1,303 @@ +/** 运动模型(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, revealCharCount} 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)}); + +/** 缓动数值进度——「clamped 0→1 手写 interpolate」惯用形的统一替身: + * 时机值走 token,缓动默认 standard(线性原版会被这一档替换——质量提升点之一)。 */ +export function useProgress( + at: number, + dur: number | DurToken = DUR.f5, + easing: EasingToken = 'standard', +): number { + const frame = useCurrentFrame(); + return eased(frame, at, frames(dur, DUR.f5), easing); +} + +/** 弹簧数值进度(驱动几何量/位移;effects 通道用 useProgress——tokens 二分不变量)。 + * 局部帧 + 可选 durationInFrames 截停(防窗口外余振;缺省不设上限,与既有手写等价)。 */ +export function useSpring( + preset: SpringPreset, + o: {at?: number; dur?: number | DurToken} = {}, +): number { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const cfg: Parameters[0] = { + frame: frame - (o.at ?? 0), + fps, + config: SPRING[preset], + }; + if (o.dur !== undefined) { + cfg.durationInFrames = frames(o.dur, DUR.f5); + } + return spring(cfg); +} + +// ── 入场(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 帧一周期——注意与 + * 原除数 K 的换算 period = 2πK,如 sin(frame/26) → period 163)。offset 为相位 + * 平移(错峰辉光的 per-index 形态)。 */ +export function useBreathe( + o: {period?: number; amp?: number; base?: number; offset?: number} = {}, +): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26, offset = 0} = o; + return base + amp * Math.sin((2 * Math.PI * (frame - offset)) / 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 {fps} = useVideoConfig(); + const elapsed = Math.max(0, frame - (o.at ?? 0)); + const n = revealCharCount(elapsed, fps, o.cps ?? 12, o.framesPerChar); + 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..5703e3315 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/schedule.ts @@ -0,0 +1,65 @@ +/** 错峰编排(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;装不下时先缩子项时长,连 1 帧 + * 子项都无法错开时显式抛错,不返回违反 fit 契约的计划。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点期望下限,默认 2 帧;fit 紧窗时可按优先级退至 1 帧。 */ + minStride?: number; + /** 子项时长期望下限,默认 3 帧;fit 为守住窗口可退至 1 帧。 */ + 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); + if (n > 1 && total < n) { + throw new Error( + `schedule: fit.total=${total} 无法容纳 ${n} 个至少 1 帧且起点间隔至少 1 帧的子项`, + ); + } + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + const targetStride = Math.max(1, minStride); + if (n > 1 && stride < targetStride) { + // 先缩时长争取期望步长;仍不足时牺牲步长,但至少保留 1 帧先后。 + dur = Math.max(1, Math.min(dur, total - targetStride * (n - 1))); + stride = (total - dur) / (n - 1); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 || o.lag === 0 ? 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..69bd59c7a --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-memory-video/video/src/motion/window.ts @@ -0,0 +1,43 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 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)); + +/** 已过帧数 → 可见字符数;cps 随实际 fps 换算,显式帧间隔不受 fps 影响。 */ +export const revealCharCount = ( + elapsedFrames: number, + fps: number, + cps: number, + framesPerChar?: number, +): number => { + const elapsed = Math.max(0, elapsedFrames); + const count = + framesPerChar === undefined + ? Math.floor((elapsed / fps) * cps) + : Math.floor(elapsed / Math.max(1, framesPerChar)); + return Math.max(0, count); +}; 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-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..5e29cab52 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/scripts/motion.test.ts @@ -0,0 +1,141 @@ +/** + * 运动层纯函数单测——`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, revealCharCount, 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); +}); + +test('逐字揭示:cps 按实际 fps 换算并向下取整,framesPerChar 保持固定帧间隔', () => { + assert.equal(revealCharCount(29, 60, 12), 5); + assert.equal(revealCharCount(30, 60, 12), 6); + assert.equal(revealCharCount(30, 24, 12, 3), 10); + assert.equal(revealCharCount(30, 120, 12, 3), 10); +}); + +// ── 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 <= 24, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('fit 窗口连 1 帧子项也容不下时显式拒绝,不返回外溢计划', () => { + assert.throws(() => schedule(8, {dur: 12, fit: {total: 5}}), /fit\.total=5.*8/); +}); + +test('fit 可行极限先牺牲子项时长,仍完整落在窗口内', () => { + const p = schedule(8, {dur: 12, fit: {total: 8}}); + assert.equal(p.dur, 1); + assert.equal(p.starts[7] + p.dur, 8); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); + assert.deepEqual(schedule(3, {dur: 10, lag: 0}).starts, [0, 0, 0], 'lag=0 须同刻齐动'); +}); + +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..a71737133 --- /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 = 340; +const CH = 230; +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..f87b8a129 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/hooks.ts @@ -0,0 +1,303 @@ +/** 运动模型(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, revealCharCount} 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)}); + +/** 缓动数值进度——「clamped 0→1 手写 interpolate」惯用形的统一替身: + * 时机值走 token,缓动默认 standard(线性原版会被这一档替换——质量提升点之一)。 */ +export function useProgress( + at: number, + dur: number | DurToken = DUR.f5, + easing: EasingToken = 'standard', +): number { + const frame = useCurrentFrame(); + return eased(frame, at, frames(dur, DUR.f5), easing); +} + +/** 弹簧数值进度(驱动几何量/位移;effects 通道用 useProgress——tokens 二分不变量)。 + * 局部帧 + 可选 durationInFrames 截停(防窗口外余振;缺省不设上限,与既有手写等价)。 */ +export function useSpring( + preset: SpringPreset, + o: {at?: number; dur?: number | DurToken} = {}, +): number { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const cfg: Parameters[0] = { + frame: frame - (o.at ?? 0), + fps, + config: SPRING[preset], + }; + if (o.dur !== undefined) { + cfg.durationInFrames = frames(o.dur, DUR.f5); + } + return spring(cfg); +} + +// ── 入场(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 帧一周期——注意与 + * 原除数 K 的换算 period = 2πK,如 sin(frame/26) → period 163)。offset 为相位 + * 平移(错峰辉光的 per-index 形态)。 */ +export function useBreathe( + o: {period?: number; amp?: number; base?: number; offset?: number} = {}, +): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26, offset = 0} = o; + return base + amp * Math.sin((2 * Math.PI * (frame - offset)) / 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 {fps} = useVideoConfig(); + const elapsed = Math.max(0, frame - (o.at ?? 0)); + const n = revealCharCount(elapsed, fps, o.cps ?? 12, o.framesPerChar); + 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..5703e3315 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/schedule.ts @@ -0,0 +1,65 @@ +/** 错峰编排(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;装不下时先缩子项时长,连 1 帧 + * 子项都无法错开时显式抛错,不返回违反 fit 契约的计划。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点期望下限,默认 2 帧;fit 紧窗时可按优先级退至 1 帧。 */ + minStride?: number; + /** 子项时长期望下限,默认 3 帧;fit 为守住窗口可退至 1 帧。 */ + 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); + if (n > 1 && total < n) { + throw new Error( + `schedule: fit.total=${total} 无法容纳 ${n} 个至少 1 帧且起点间隔至少 1 帧的子项`, + ); + } + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + const targetStride = Math.max(1, minStride); + if (n > 1 && stride < targetStride) { + // 先缩时长争取期望步长;仍不足时牺牲步长,但至少保留 1 帧先后。 + dur = Math.max(1, Math.min(dur, total - targetStride * (n - 1))); + stride = (total - dur) / (n - 1); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 || o.lag === 0 ? 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..69bd59c7a --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-multiagent-video/video/src/motion/window.ts @@ -0,0 +1,43 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 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)); + +/** 已过帧数 → 可见字符数;cps 随实际 fps 换算,显式帧间隔不受 fps 影响。 */ +export const revealCharCount = ( + elapsedFrames: number, + fps: number, + cps: number, + framesPerChar?: number, +): number => { + const elapsed = Math.max(0, elapsedFrames); + const count = + framesPerChar === undefined + ? Math.floor((elapsed / fps) * cps) + : Math.floor(elapsed / Math.max(1, framesPerChar)); + return Math.max(0, count); +}; 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/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..5e29cab52 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/scripts/motion.test.ts @@ -0,0 +1,141 @@ +/** + * 运动层纯函数单测——`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, revealCharCount, 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); +}); + +test('逐字揭示:cps 按实际 fps 换算并向下取整,framesPerChar 保持固定帧间隔', () => { + assert.equal(revealCharCount(29, 60, 12), 5); + assert.equal(revealCharCount(30, 60, 12), 6); + assert.equal(revealCharCount(30, 24, 12, 3), 10); + assert.equal(revealCharCount(30, 120, 12, 3), 10); +}); + +// ── 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 <= 24, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('fit 窗口连 1 帧子项也容不下时显式拒绝,不返回外溢计划', () => { + assert.throws(() => schedule(8, {dur: 12, fit: {total: 5}}), /fit\.total=5.*8/); +}); + +test('fit 可行极限先牺牲子项时长,仍完整落在窗口内', () => { + const p = schedule(8, {dur: 12, fit: {total: 8}}); + assert.equal(p.dur, 1); + assert.equal(p.starts[7] + p.dur, 8); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); + assert.deepEqual(schedule(3, {dur: 10, lag: 0}).starts, [0, 0, 0], 'lag=0 须同刻齐动'); +}); + +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..a71737133 --- /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 = 340; +const CH = 230; +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..f87b8a129 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/hooks.ts @@ -0,0 +1,303 @@ +/** 运动模型(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, revealCharCount} 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)}); + +/** 缓动数值进度——「clamped 0→1 手写 interpolate」惯用形的统一替身: + * 时机值走 token,缓动默认 standard(线性原版会被这一档替换——质量提升点之一)。 */ +export function useProgress( + at: number, + dur: number | DurToken = DUR.f5, + easing: EasingToken = 'standard', +): number { + const frame = useCurrentFrame(); + return eased(frame, at, frames(dur, DUR.f5), easing); +} + +/** 弹簧数值进度(驱动几何量/位移;effects 通道用 useProgress——tokens 二分不变量)。 + * 局部帧 + 可选 durationInFrames 截停(防窗口外余振;缺省不设上限,与既有手写等价)。 */ +export function useSpring( + preset: SpringPreset, + o: {at?: number; dur?: number | DurToken} = {}, +): number { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const cfg: Parameters[0] = { + frame: frame - (o.at ?? 0), + fps, + config: SPRING[preset], + }; + if (o.dur !== undefined) { + cfg.durationInFrames = frames(o.dur, DUR.f5); + } + return spring(cfg); +} + +// ── 入场(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 帧一周期——注意与 + * 原除数 K 的换算 period = 2πK,如 sin(frame/26) → period 163)。offset 为相位 + * 平移(错峰辉光的 per-index 形态)。 */ +export function useBreathe( + o: {period?: number; amp?: number; base?: number; offset?: number} = {}, +): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26, offset = 0} = o; + return base + amp * Math.sin((2 * Math.PI * (frame - offset)) / 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 {fps} = useVideoConfig(); + const elapsed = Math.max(0, frame - (o.at ?? 0)); + const n = revealCharCount(elapsed, fps, o.cps ?? 12, o.framesPerChar); + 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..5703e3315 --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/schedule.ts @@ -0,0 +1,65 @@ +/** 错峰编排(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;装不下时先缩子项时长,连 1 帧 + * 子项都无法错开时显式抛错,不返回违反 fit 契约的计划。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点期望下限,默认 2 帧;fit 紧窗时可按优先级退至 1 帧。 */ + minStride?: number; + /** 子项时长期望下限,默认 3 帧;fit 为守住窗口可退至 1 帧。 */ + 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); + if (n > 1 && total < n) { + throw new Error( + `schedule: fit.total=${total} 无法容纳 ${n} 个至少 1 帧且起点间隔至少 1 帧的子项`, + ); + } + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + const targetStride = Math.max(1, minStride); + if (n > 1 && stride < targetStride) { + // 先缩时长争取期望步长;仍不足时牺牲步长,但至少保留 1 帧先后。 + dur = Math.max(1, Math.min(dur, total - targetStride * (n - 1))); + stride = (total - dur) / (n - 1); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 || o.lag === 0 ? 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..69bd59c7a --- /dev/null +++ b/apps/negentropy-influence/episodes/claude-code-planning-video/video/src/motion/window.ts @@ -0,0 +1,43 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 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)); + +/** 已过帧数 → 可见字符数;cps 随实际 fps 换算,显式帧间隔不受 fps 影响。 */ +export const revealCharCount = ( + elapsedFrames: number, + fps: number, + cps: number, + framesPerChar?: number, +): number => { + const elapsed = Math.max(0, elapsedFrames); + const count = + framesPerChar === undefined + ? Math.floor((elapsed / fps) * cps) + : Math.floor(elapsed / Math.max(1, framesPerChar)); + return Math.max(0, count); +}; 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/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..5e29cab52 --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/scripts/motion.test.ts @@ -0,0 +1,141 @@ +/** + * 运动层纯函数单测——`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, revealCharCount, 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); +}); + +test('逐字揭示:cps 按实际 fps 换算并向下取整,framesPerChar 保持固定帧间隔', () => { + assert.equal(revealCharCount(29, 60, 12), 5); + assert.equal(revealCharCount(30, 60, 12), 6); + assert.equal(revealCharCount(30, 24, 12, 3), 10); + assert.equal(revealCharCount(30, 120, 12, 3), 10); +}); + +// ── 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 <= 24, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('fit 窗口连 1 帧子项也容不下时显式拒绝,不返回外溢计划', () => { + assert.throws(() => schedule(8, {dur: 12, fit: {total: 5}}), /fit\.total=5.*8/); +}); + +test('fit 可行极限先牺牲子项时长,仍完整落在窗口内', () => { + const p = schedule(8, {dur: 12, fit: {total: 8}}); + assert.equal(p.dur, 1); + assert.equal(p.starts[7] + p.dur, 8); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); + assert.deepEqual(schedule(3, {dur: 10, lag: 0}).starts, [0, 0, 0], 'lag=0 须同刻齐动'); +}); + +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..a71737133 --- /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 = 340; +const CH = 230; +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..f87b8a129 --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/hooks.ts @@ -0,0 +1,303 @@ +/** 运动模型(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, revealCharCount} 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)}); + +/** 缓动数值进度——「clamped 0→1 手写 interpolate」惯用形的统一替身: + * 时机值走 token,缓动默认 standard(线性原版会被这一档替换——质量提升点之一)。 */ +export function useProgress( + at: number, + dur: number | DurToken = DUR.f5, + easing: EasingToken = 'standard', +): number { + const frame = useCurrentFrame(); + return eased(frame, at, frames(dur, DUR.f5), easing); +} + +/** 弹簧数值进度(驱动几何量/位移;effects 通道用 useProgress——tokens 二分不变量)。 + * 局部帧 + 可选 durationInFrames 截停(防窗口外余振;缺省不设上限,与既有手写等价)。 */ +export function useSpring( + preset: SpringPreset, + o: {at?: number; dur?: number | DurToken} = {}, +): number { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const cfg: Parameters[0] = { + frame: frame - (o.at ?? 0), + fps, + config: SPRING[preset], + }; + if (o.dur !== undefined) { + cfg.durationInFrames = frames(o.dur, DUR.f5); + } + return spring(cfg); +} + +// ── 入场(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 帧一周期——注意与 + * 原除数 K 的换算 period = 2πK,如 sin(frame/26) → period 163)。offset 为相位 + * 平移(错峰辉光的 per-index 形态)。 */ +export function useBreathe( + o: {period?: number; amp?: number; base?: number; offset?: number} = {}, +): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26, offset = 0} = o; + return base + amp * Math.sin((2 * Math.PI * (frame - offset)) / 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 {fps} = useVideoConfig(); + const elapsed = Math.max(0, frame - (o.at ?? 0)); + const n = revealCharCount(elapsed, fps, o.cps ?? 12, o.framesPerChar); + 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..5703e3315 --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/schedule.ts @@ -0,0 +1,65 @@ +/** 错峰编排(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;装不下时先缩子项时长,连 1 帧 + * 子项都无法错开时显式抛错,不返回违反 fit 契约的计划。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点期望下限,默认 2 帧;fit 紧窗时可按优先级退至 1 帧。 */ + minStride?: number; + /** 子项时长期望下限,默认 3 帧;fit 为守住窗口可退至 1 帧。 */ + 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); + if (n > 1 && total < n) { + throw new Error( + `schedule: fit.total=${total} 无法容纳 ${n} 个至少 1 帧且起点间隔至少 1 帧的子项`, + ); + } + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + const targetStride = Math.max(1, minStride); + if (n > 1 && stride < targetStride) { + // 先缩时长争取期望步长;仍不足时牺牲步长,但至少保留 1 帧先后。 + dur = Math.max(1, Math.min(dur, total - targetStride * (n - 1))); + stride = (total - dur) / (n - 1); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 || o.lag === 0 ? 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..69bd59c7a --- /dev/null +++ b/apps/negentropy-influence/episodes/experience-era-agents-video/video/src/motion/window.ts @@ -0,0 +1,43 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 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)); + +/** 已过帧数 → 可见字符数;cps 随实际 fps 换算,显式帧间隔不受 fps 影响。 */ +export const revealCharCount = ( + elapsedFrames: number, + fps: number, + cps: number, + framesPerChar?: number, +): number => { + const elapsed = Math.max(0, elapsedFrames); + const count = + framesPerChar === undefined + ? Math.floor((elapsed / fps) * cps) + : Math.floor(elapsed / Math.max(1, framesPerChar)); + return Math.max(0, count); +}; 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-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..5e29cab52 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/scripts/motion.test.ts @@ -0,0 +1,141 @@ +/** + * 运动层纯函数单测——`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, revealCharCount, 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); +}); + +test('逐字揭示:cps 按实际 fps 换算并向下取整,framesPerChar 保持固定帧间隔', () => { + assert.equal(revealCharCount(29, 60, 12), 5); + assert.equal(revealCharCount(30, 60, 12), 6); + assert.equal(revealCharCount(30, 24, 12, 3), 10); + assert.equal(revealCharCount(30, 120, 12, 3), 10); +}); + +// ── 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 <= 24, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('fit 窗口连 1 帧子项也容不下时显式拒绝,不返回外溢计划', () => { + assert.throws(() => schedule(8, {dur: 12, fit: {total: 5}}), /fit\.total=5.*8/); +}); + +test('fit 可行极限先牺牲子项时长,仍完整落在窗口内', () => { + const p = schedule(8, {dur: 12, fit: {total: 8}}); + assert.equal(p.dur, 1); + assert.equal(p.starts[7] + p.dur, 8); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); + assert.deepEqual(schedule(3, {dur: 10, lag: 0}).starts, [0, 0, 0], 'lag=0 须同刻齐动'); +}); + +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..a71737133 --- /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 = 340; +const CH = 230; +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..f87b8a129 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/hooks.ts @@ -0,0 +1,303 @@ +/** 运动模型(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, revealCharCount} 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)}); + +/** 缓动数值进度——「clamped 0→1 手写 interpolate」惯用形的统一替身: + * 时机值走 token,缓动默认 standard(线性原版会被这一档替换——质量提升点之一)。 */ +export function useProgress( + at: number, + dur: number | DurToken = DUR.f5, + easing: EasingToken = 'standard', +): number { + const frame = useCurrentFrame(); + return eased(frame, at, frames(dur, DUR.f5), easing); +} + +/** 弹簧数值进度(驱动几何量/位移;effects 通道用 useProgress——tokens 二分不变量)。 + * 局部帧 + 可选 durationInFrames 截停(防窗口外余振;缺省不设上限,与既有手写等价)。 */ +export function useSpring( + preset: SpringPreset, + o: {at?: number; dur?: number | DurToken} = {}, +): number { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const cfg: Parameters[0] = { + frame: frame - (o.at ?? 0), + fps, + config: SPRING[preset], + }; + if (o.dur !== undefined) { + cfg.durationInFrames = frames(o.dur, DUR.f5); + } + return spring(cfg); +} + +// ── 入场(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 帧一周期——注意与 + * 原除数 K 的换算 period = 2πK,如 sin(frame/26) → period 163)。offset 为相位 + * 平移(错峰辉光的 per-index 形态)。 */ +export function useBreathe( + o: {period?: number; amp?: number; base?: number; offset?: number} = {}, +): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26, offset = 0} = o; + return base + amp * Math.sin((2 * Math.PI * (frame - offset)) / 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 {fps} = useVideoConfig(); + const elapsed = Math.max(0, frame - (o.at ?? 0)); + const n = revealCharCount(elapsed, fps, o.cps ?? 12, o.framesPerChar); + 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..5703e3315 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/schedule.ts @@ -0,0 +1,65 @@ +/** 错峰编排(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;装不下时先缩子项时长,连 1 帧 + * 子项都无法错开时显式抛错,不返回违反 fit 契约的计划。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点期望下限,默认 2 帧;fit 紧窗时可按优先级退至 1 帧。 */ + minStride?: number; + /** 子项时长期望下限,默认 3 帧;fit 为守住窗口可退至 1 帧。 */ + 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); + if (n > 1 && total < n) { + throw new Error( + `schedule: fit.total=${total} 无法容纳 ${n} 个至少 1 帧且起点间隔至少 1 帧的子项`, + ); + } + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + const targetStride = Math.max(1, minStride); + if (n > 1 && stride < targetStride) { + // 先缩时长争取期望步长;仍不足时牺牲步长,但至少保留 1 帧先后。 + dur = Math.max(1, Math.min(dur, total - targetStride * (n - 1))); + stride = (total - dur) / (n - 1); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 || o.lag === 0 ? 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..69bd59c7a --- /dev/null +++ b/apps/negentropy-influence/episodes/self-evolving-coding-agents-video/video/src/motion/window.ts @@ -0,0 +1,43 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 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)); + +/** 已过帧数 → 可见字符数;cps 随实际 fps 换算,显式帧间隔不受 fps 影响。 */ +export const revealCharCount = ( + elapsedFrames: number, + fps: number, + cps: number, + framesPerChar?: number, +): number => { + const elapsed = Math.max(0, elapsedFrames); + const count = + framesPerChar === undefined + ? Math.floor((elapsed / fps) * cps) + : Math.floor(elapsed / Math.max(1, framesPerChar)); + return Math.max(0, count); +}; 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/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..5e29cab52 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/scripts/motion.test.ts @@ -0,0 +1,141 @@ +/** + * 运动层纯函数单测——`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, revealCharCount, 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); +}); + +test('逐字揭示:cps 按实际 fps 换算并向下取整,framesPerChar 保持固定帧间隔', () => { + assert.equal(revealCharCount(29, 60, 12), 5); + assert.equal(revealCharCount(30, 60, 12), 6); + assert.equal(revealCharCount(30, 24, 12, 3), 10); + assert.equal(revealCharCount(30, 120, 12, 3), 10); +}); + +// ── 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 <= 24, `末项 ${last} 外溢`); + assert.ok(p.dur >= 3, '子项时长跌破下限'); + assert.ok(p.dur < 12, '装不下却未缩子项时长'); +}); + +test('fit 窗口连 1 帧子项也容不下时显式拒绝,不返回外溢计划', () => { + assert.throws(() => schedule(8, {dur: 12, fit: {total: 5}}), /fit\.total=5.*8/); +}); + +test('fit 可行极限先牺牲子项时长,仍完整落在窗口内', () => { + const p = schedule(8, {dur: 12, fit: {total: 8}}); + assert.equal(p.dur, 1); + assert.equal(p.starts[7] + p.dur, 8); +}); + +test('lag 模式:Manim 语义 start[i] = i·dur·lag', () => { + const p = schedule(3, {dur: 10, lag: 0.5}); + assert.deepEqual(p.starts, [0, 5, 10]); + assert.deepEqual(schedule(3, {dur: 10, lag: 0}).starts, [0, 0, 0], 'lag=0 须同刻齐动'); +}); + +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..a71737133 --- /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 = 340; +const CH = 230; +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..f87b8a129 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/hooks.ts @@ -0,0 +1,303 @@ +/** 运动模型(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, revealCharCount} 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)}); + +/** 缓动数值进度——「clamped 0→1 手写 interpolate」惯用形的统一替身: + * 时机值走 token,缓动默认 standard(线性原版会被这一档替换——质量提升点之一)。 */ +export function useProgress( + at: number, + dur: number | DurToken = DUR.f5, + easing: EasingToken = 'standard', +): number { + const frame = useCurrentFrame(); + return eased(frame, at, frames(dur, DUR.f5), easing); +} + +/** 弹簧数值进度(驱动几何量/位移;effects 通道用 useProgress——tokens 二分不变量)。 + * 局部帧 + 可选 durationInFrames 截停(防窗口外余振;缺省不设上限,与既有手写等价)。 */ +export function useSpring( + preset: SpringPreset, + o: {at?: number; dur?: number | DurToken} = {}, +): number { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const cfg: Parameters[0] = { + frame: frame - (o.at ?? 0), + fps, + config: SPRING[preset], + }; + if (o.dur !== undefined) { + cfg.durationInFrames = frames(o.dur, DUR.f5); + } + return spring(cfg); +} + +// ── 入场(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 帧一周期——注意与 + * 原除数 K 的换算 period = 2πK,如 sin(frame/26) → period 163)。offset 为相位 + * 平移(错峰辉光的 per-index 形态)。 */ +export function useBreathe( + o: {period?: number; amp?: number; base?: number; offset?: number} = {}, +): number { + const frame = useCurrentFrame(); + const {amp = 0.45, base = 0.55, period = 26, offset = 0} = o; + return base + amp * Math.sin((2 * Math.PI * (frame - offset)) / 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 {fps} = useVideoConfig(); + const elapsed = Math.max(0, frame - (o.at ?? 0)); + const n = revealCharCount(elapsed, fps, o.cps ?? 12, o.framesPerChar); + 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..5703e3315 --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/schedule.ts @@ -0,0 +1,65 @@ +/** 错峰编排(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;装不下时先缩子项时长,连 1 帧 + * 子项都无法错开时显式抛错,不返回违反 fit 契约的计划。 + */ +export type ScheduleOpts = { + /** 子项时长(帧;缺省 DUR.f3=5——「快速子项」档)。 */ + dur?: number; + /** 模式一:固定步长。 */ + stride?: number; + /** 模式二:lag 比率(Manim lag_ratio)。 */ + lag?: number; + /** 模式三:拟装入的窗口总长(帧)。 */ + fit?: {total: number}; + /** 相邻起点期望下限,默认 2 帧;fit 紧窗时可按优先级退至 1 帧。 */ + minStride?: number; + /** 子项时长期望下限,默认 3 帧;fit 为守住窗口可退至 1 帧。 */ + 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); + if (n > 1 && total < n) { + throw new Error( + `schedule: fit.total=${total} 无法容纳 ${n} 个至少 1 帧且起点间隔至少 1 帧的子项`, + ); + } + dur = Math.min(dur, total); + stride = n === 1 ? 0 : (total - dur) / (n - 1); + const targetStride = Math.max(1, minStride); + if (n > 1 && stride < targetStride) { + // 先缩时长争取期望步长;仍不足时牺牲步长,但至少保留 1 帧先后。 + dur = Math.max(1, Math.min(dur, total - targetStride * (n - 1))); + stride = (total - dur) / (n - 1); + } + } else if (o.lag !== undefined) { + stride = dur * o.lag; + } else { + stride = o.stride ?? minStride; + } + stride = Math.max(n === 1 || o.lag === 0 ? 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..69bd59c7a --- /dev/null +++ b/apps/negentropy-influence/episodes/self-improving-agents-video/video/src/motion/window.ts @@ -0,0 +1,43 @@ +/** 运动窗口纯函数——「父级持有绝对时间,子动画只是父进度上的窗口」。 + * + * 这是 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)); + +/** 已过帧数 → 可见字符数;cps 随实际 fps 换算,显式帧间隔不受 fps 影响。 */ +export const revealCharCount = ( + elapsedFrames: number, + fps: number, + cps: number, + framesPerChar?: number, +): number => { + const elapsed = Math.max(0, elapsedFrames); + const count = + framesPerChar === undefined + ? Math.floor((elapsed / fps) * cps) + : Math.floor(elapsed / Math.max(1, framesPerChar)); + return Math.max(0, count); +}; diff --git a/apps/negentropy-influence/pipeline/README.md b/apps/negentropy-influence/pipeline/README.md index a5cda4e52..542f09b83 100644 --- a/apps/negentropy-influence/pipeline/README.md +++ b/apps/negentropy-influence/pipeline/README.md @@ -137,8 +137,9 @@ schema、默认值与校验的单一事实源是 [scripts/config.py](./scripts/c ## 四、复用边界(显式权衡) - **Python 脚本:集中共享(SSOT)**——三个纯文本变换工具,跨集零差异,中心化防 split-brain。 -- **Remotion 工程原语:复制适配,不做共享包**——`timing.ts` / `Subtitle` / `cards.tsx` / `theme.ts` 等每集复制后按本集视觉契约修改。理由:每集工程须保持 pnpm `--ignore-workspace` 独立可渲染(嵌套 workspace 隔离 + Remotion 版本自由),共享 TS 包会把「一集的视觉改动」泄漏进其他集。**复制源头是 [templates/video-skeleton/](./templates/video-skeleton/)**(15 个 frozen 文件 + skeleton.toml 档位清单),新集用 `scaffold.py` 实例化、「改任何一处须同步」由 `verify_skeleton.py` 机器执法——此前「以任一既有集为模板」的说法等于给 391 行冻结基建设 4 个同权真理声明者,且纸面义务从未被执行过(详见 skeleton.toml 内注)。同类做法:`go mod vendor` + `go mod verify`(物理副本 + 校验门)、Copier(模板 + 应答记录)。 +- **Remotion 工程原语:复制适配,不做共享包**——`timing.ts` / `Subtitle` / `cards.tsx` / `theme.ts` 等每集复制后按本集视觉契约修改。理由:每集工程须保持 pnpm `--ignore-workspace` 独立可渲染(嵌套 workspace 隔离 + Remotion 版本自由),共享 TS 包会把「一集的视觉改动」泄漏进其他集。**复制源头是 [templates/video-skeleton/](./templates/video-skeleton/)**(frozen 文件清单以 skeleton.toml 为准,勿在文档里维护数字),新集用 `scaffold.py` 实例化、「改任何一处须同步」由 `verify_skeleton.py` 机器执法——此前「以任一既有集为模板」的说法等于给 391 行冻结基建设 4 个同权真理声明者,且纸面义务从未被执行过(详见 skeleton.toml 内注)。同类做法:`go mod vendor` + `go mod verify`(物理副本 + 校验门)、Copier(模板 + 应答记录)。 - **每集视觉契约独立设计**(色彩语义映射到本集核心概念),但底层规范复用:深色底 `#0E1116` 系、警示红 `#FF5C5C`、确认绿 `#7ED321`、金句卡衬线体、公式只作角标彩蛋。 +- **运动层(`video/src/motion/`,frozen):共享的是「怎么动」,不是「画什么」**——时长/缓动/弹簧/错峰/巡游等时序语汇跨集一致(同一只手感),theme/motifs/场景构图仍各集自由。2026-09 重制 EP1 时引入:令牌(Carbon 六档时长 + M3 缓动 + 本仓实测弹簧手感)+ 窗口/编排纯函数 + 12 个运动模型 hooks + MotionGallery 评审面,规格与铁律见 [skills/06 运动层](./skills/06-remotion-implementation.md)。不读 theme token(两系列概念色名已分叉)是其可 frozen 的前提,由 tests/test_skeleton.py 执法。 ## 五、音画同步机制(零手工对轨) @@ -157,16 +158,15 @@ schema、默认值与校验的单一事实源是 [scripts/config.py](./scripts/c **刻意不生成 scenes/**(样例留在模板里)、不改根 `.gitignore`(已通配到分集级)、不写 series.json。 跑完立刻 `uv run --no-project $R/verify_skeleton.py` 确认新集与模板零漂移。 2. `theme.ts` 换本集概念色;`video/src/scenes/*` 与 `Main.tsx` 注册表全部新写。 -3. `cd video && pnpm install --ignore-workspace`(必须显式忽略根 workspace;`onlyBuiltDependencies: [esbuild]` 已在 package.json);装完检查根 lockfile 零变更。 +3. `cd video && pnpm install --ignore-workspace`(必须显式忽略根 workspace;构建脚本许可已在 + `video/pnpm-workspace.yaml` 的 `allowBuilds.esbuild` 配置);装完检查根 lockfile 零变更。 > pnpm 12 起只声明 `--ignore-workspace` 不够:pnpm 仍会沿 `packageManager` 向上锚定到仓库根, > **覆写根 `pnpm-lock.yaml`** 且当场不报错。隔离由 `video/pnpm-workspace.yaml` 真正兜住 > ([ISSUE-175](../../../docs/.agents/issue.md))。 - > pnpm ≥ 11 会提示 `package.json` 的 `pnpm` 字段不再被读取,并报 `ERR_PNPM_IGNORED_BUILDS: esbuild` - > ([ISSUE-076](../../../docs/.agents/issue.md))。**已实测确认对本工程无害**:esbuild 的平台二进制 - > 是 optionalDependency,落地不依赖 postinstall —— 旧写法下 `remotion bundle` 端到端通过。 - > 故**刻意不改** `.npmrc`/`package.json` 去消掉这条提示(改了会让 A 档冻结文件产生一处 - > 只为静音噪声的跨集差异)。真正需要 postinstall 的依赖若将来出现,再按官方新家 - > `pnpm-workspace.yaml` 的 `allowBuilds` 处理。 + > pnpm ≥ 11 已不再读取 `package.json` 的 `pnpm.onlyBuiltDependencies`;构建脚本许可统一放在 + > `pnpm-workspace.yaml` 的 `allowBuilds`([ISSUE-076](../../../docs/.agents/issue.md))。骨架已显式允许 + > `esbuild`,勿改回旧字段;缺失该许可会以 `ERR_PNPM_IGNORED_BUILDS` 中断安装并留下半残 + > `node_modules`。 4. **登记到 [../series.json](../series.json)**(阻塞门:`check_series.py` 规则 4 反向执法—— 未登记目录一旦写下 `script/narration.md` 即 FAIL;脚手架期为 WARN 分级):顶层是 `seriesList[]`,新系列追加一个 series 对象 (`id` / `title` / `sourceKind` / `rule` / `episodes`),既有系列的新集追加到其 `episodes`。 diff --git a/apps/negentropy-influence/pipeline/scripts/build_narration.py b/apps/negentropy-influence/pipeline/scripts/build_narration.py index e4454b45b..a9a280323 100644 --- a/apps/negentropy-influence/pipeline/scripts/build_narration.py +++ b/apps/negentropy-influence/pipeline/scripts/build_narration.py @@ -119,6 +119,50 @@ def main() -> None: f"发音标注: {marked} 句带 ttsText(字数与字幕仍取剥离后的 text)" + ("" if vocab else ";未找到 pinyin.vocab,已跳过「音节是否在表内」告警") ) + emit_series_layers(root) + + +def emit_series_layers(root: Path) -> None: + """series.json → video/src/series-layers.json(系列身份装置的数据面)。 + + skills/06 五层 Harness 栈的层序/层名**必须**从 series.json 派生(硬编码即漂移); + Remotion 的打包根是 video/,读不到工程外文件,故由本脚本每次 build 重派生落盘。 + 仅取本集所属系列;集不在任何系列(脚手架期)则跳过不写。层短名取 cardSub + 首段(「执行层 · 循环」→「执行层」)。next 为下一集标题(P6 呼吸预告用)。 + """ + series_json = root.parent.parent / "series.json" + if not series_json.is_file(): + return + series_list = json.loads(series_json.read_text(encoding="utf-8"))["seriesList"] + for series in series_list: + eps = series["episodes"] + for k, ep in enumerate(eps): + if ep["path"] != f"episodes/{root.name}": + continue + layers = [ + { + "index": i + 1, + "layer": e["cardSub"].split(" · ")[0], + "title": e["title"], + "published": e.get("status") == "ready" + and "已上线" in e.get("voice", ""), + } + for i, e in enumerate(eps) + ] + payload = { + "seriesId": series["id"], + "layers": layers, + "activeIndex": k + 1, + "next": eps[k + 1]["title"] if k + 1 < len(eps) else None, + } + out = root / "video" / "src" / "series-layers.json" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + json.dumps(payload, ensure_ascii=False, indent=1) + "\n", + encoding="utf-8", + ) + print(f"系列层: {series['id']} 第 {k + 1}/{len(eps)} 集 · layers 已派生") + return if __name__ == "__main__": 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..929c2410f 100644 --- a/apps/negentropy-influence/pipeline/scripts/pipeline.py +++ b/apps/negentropy-influence/pipeline/scripts/pipeline.py @@ -187,8 +187,14 @@ def cmd_build(root: Path, _cfg: dict) -> int: return run(uv_no_project("build_narration.py", [], project=root)) -def cmd_check(root: Path, _cfg: dict, scenes: bool = False) -> int: - extra = ["--check-scenes"] if scenes else [] +def cmd_check( + root: Path, _cfg: dict, scenes: bool = False, motion: bool = False +) -> int: + extra = [] + if scenes: + extra.append("--check-scenes") + if motion: + extra.append("--check-motion") return run(uv_no_project("check_script.py", [], *extra, project=root)) @@ -201,6 +207,7 @@ def cmd_tts( style: str | None, allow_voice_switch: bool = False, skip_pre_tts: bool = False, + no_store: bool = False, ) -> int: if not skip_pre_tts: # TTS 前置门:预算/读法陷阱/标注合法性都是「合成前可判定的文本门」—— @@ -249,6 +256,8 @@ def cmd_tts( # 两遍法经单入口的放行阀:.engine 签名护栏会拦「换风格=整集重录」, # 草稿遍(A)/定稿遍(B)任一换档都须显式带上(护栏原理见 tts.py §音色签名) cmd.append("--allow-voice-switch") + if no_store: + cmd.append("--no-store") return run(cmd, cwd=root) @@ -287,15 +296,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, @@ -483,11 +498,21 @@ def main() -> None: action="store_true", help="附:分镜↔场景代码 beat 互比(WARN-only)", ) + p.add_argument( + "--check-motion", + action="store_true", + help="附:分镜动效标注↔场景运动模型互比(WARN-only)", + ) sub.add_parser("captions", help="⑥+ 导出 srt/vtt") sub.add_parser("clean-samples", help="清理 .temp/voice-samples(生物特征)") p = sub.add_parser("tts", help="⑥ 配音合成(参数来自 pipeline.toml)") p.add_argument("--plan", action="store_true", help="只看排期不实跑") p.add_argument("--force", action="store_true", help="忽略缓存") + p.add_argument( + "--no-store", + action="store_true", + help="禁用 IndexTTS 音频版本库", + ) p.add_argument("--steady", help="混合档选择器") p.add_argument("--style", help="覆写风格(两遍法草稿遍用 --style sunny)") p.add_argument( @@ -513,6 +538,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", @@ -550,7 +587,7 @@ def main() -> None: "status": lambda: cmd_status(root, cfg), "doctor": lambda: cmd_doctor(root, cfg, origin), "build": lambda: cmd_build(root, cfg), - "check": lambda: cmd_check(root, cfg, args.check_scenes), + "check": lambda: cmd_check(root, cfg, args.check_scenes, args.check_motion), "tts": lambda: cmd_tts( root, cfg, @@ -560,6 +597,7 @@ def main() -> None: args.style, args.allow_voice_switch, args.skip_pre_tts, + args.no_store, ), "captions": lambda: cmd_captions(root, cfg), "render": lambda: cmd_render(root, cfg, args.final), @@ -572,6 +610,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..a063ec0e9 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;零匹配帧硬失败,供重制/重构回归归因): + --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,13 @@ 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, + subtitle_check: bool = True, ) -> None: try: import numpy as np @@ -284,7 +355,9 @@ def check_frames( ) band = img[img.shape[0] - band_px :, :] if band_px else img bright_px = float((band > TEXT_BRIGHTNESS).mean()) - if bright_px == 0: + # 字幕缺失只在句中点采样下有意义:beat 头帧落在句首淡入与句间空隙, + # 无字幕是合法态(--beat-heads 传 subtitle_check=False) + if subtitle_check and bright_px == 0: msgs.append(f"WARN {sid}: 字幕带内无文字亮度像素(字幕缺失?)") elif band_px > box_h_px: # 侵入检测按**几何**做(见 SUBTITLE_BOX_H_PX 注释):只看字幕框上方那条窄带, @@ -300,10 +373,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 +403,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", @@ -364,6 +451,12 @@ def main() -> None: root = Path(args.project).resolve() + # --compare 自带两个视频路径,不消费普通模式的