From 03dd0155a71b171f1d5e5afa0eb6457950c606a1 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:04:21 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20render=20hook=20=E3=81=AB=20ani?= =?UTF-8?q?mation=20=E3=81=AE=E5=91=A8=E6=9C=9F=E6=83=85=E5=A0=B1=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=97=20API=20version=20=E3=82=92=202=20?= =?UTF-8?q?=E3=81=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/animationRenderHooks.ts | 25 ++++++- src/systems/animationRenderer.ts | 21 +++++- src/tests/animationRenderExport.test.ts | 90 +++++++++++++++++++++++++ src/tests/animationRenderHooks.test.ts | 19 ++++++ 4 files changed, 152 insertions(+), 3 deletions(-) diff --git a/src/systems/animationRenderHooks.ts b/src/systems/animationRenderHooks.ts index 38cb8a62..3bfea091 100644 --- a/src/systems/animationRenderHooks.ts +++ b/src/systems/animationRenderHooks.ts @@ -21,6 +21,13 @@ * 同じ `frameIndex` に対しては何度呼ばれても結果が変わらない (= 冪等) 実装が要る * - `timeSeconds` は `frameTimeSeconds` と一致しないことがある (= pre-post の side sample では * `frameTimeSeconds + 0.001`)。時刻の正本は `frameIndex` であり、 `timeSeconds` は参考値として扱う + * - **animation 単位の周期情報を context に載せている** (= `animationLengthSeconds` / + * `renderSampleCount` / `loopMode` / `loopDelayFrames`)。 このうち `renderSampleCount` / + * `loopMode` / `loopDelayFrames` は **datapack meta の `dur` / `lp` / `dly` と一致する値**で、 + * hook 側が animation の内部構造を推測せずに周期を判断できるようにするために渡している。 + * `renderSampleCount` は render loop が実際に生成する frame 数そのもの (= `animation.length` + * から数え直した値ではない) なので、 `IRenderedAnimation.frames.length` / `duration` と必ず一致する。 + * **「表示上の最終 frame がどれか」 の解釈は hook 側の責務**であり、 AJ は生の値を渡すだけ * - **hook が加える変化は matrix に現れていればよい** (= `pos` / `rot` / `scale` に出る必要はない)。 * `hashAnimations` は node transform の `matrix.elements` 16 要素をそのまま mix するため、 * shear や right rotation だけを動かす変換も reload-skip 判定に反映される。 @@ -40,6 +47,14 @@ export interface RenderAnimationContext { excludedNodeUuids: ReadonlySet /** 指定時刻の keyframe pose を scene へ再評価する。 呼び出し側が閉包として詰める。 */ evaluateBasePose(timeSeconds: number): void + /** `animation.length` (= 秒)。 */ + readonly animationLengthSeconds: number + /** render loop が実際に生成する frame の数。 datapack meta の `dur` と一致する。 */ + readonly renderSampleCount: number + /** `animation.loop`。 datapack meta の `lp` の元になる値。 */ + readonly loopMode: _Animation['loop'] + /** `Number(animation.loop_delay) || 0` (= tick)。 datapack meta の `dly` と一致する。 */ + readonly loopDelayFrames: number } /** frame 単位のコンテキスト (= `RenderAnimationContext` に時刻情報を足したもの)。 */ @@ -198,9 +213,15 @@ export function areRenderHooksSuppressed() { // --- 公開 API --------------------------------------------------------------- -/** 外部 plugin 向けの公開 API。 `version` は互換性確認用。 */ +/** + * 外部 plugin 向けの公開 API。 `version` は互換性確認用。 + * + * - `1` : 初版 + * - `2` : `RenderAnimationContext` に周期情報 (= `animationLengthSeconds` / `renderSampleCount` / + * `loopMode` / `loopDelayFrames`) を必須で追加 + */ export const RENDER_HOOKS_API = { - version: 1, + version: 2, register: registerRenderHooks, unregister: unregisterRenderHooks, } diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index 2c3a6338..9a4897a8 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -405,10 +405,22 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) { const includedNodes = new Set() + // frame ループが訪れる時刻の列。 **ループ本体もこの配列を回す** (= context の + // `renderSampleCount` と実際の frame 数を同じ配列から取るため)。 `animation.length` から + // 別式で数え直すと `roundToNth` の丸めと食い違って off-by-one が出る。 + const sampleTimes: number[] = [] + for (let time = 0; time <= animation.length; time = roundToNth(time + 0.05, 20)) { + sampleTimes.push(time) + } + currentRenderContext = { animation, rig, excludedNodeUuids: collectExcludedNodeUuids(animation), + animationLengthSeconds: animation.length, + renderSampleCount: sampleTimes.length, + loopMode: animation.loop, + loopDelayFrames: Number(animation.loop_delay) || 0, evaluateBasePose(timeSeconds: number) { const previousTime = Timeline.time try { @@ -432,7 +444,7 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) { animationBegun = true let frameIndex = 0 - for (let time = 0; time <= animation.length; time = roundToNth(time + 0.05, 20)) { + for (const time of sampleTimes) { updatePreview(animation, time, frameIndex) updatePreview(animation, time, frameIndex) // IK doesn't work unless I call this twice for some reason... const frame: IRenderedFrame = getFrame(animation, rig.nodes, time, frameIndex) @@ -440,6 +452,13 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) { rendered.frames.push(frame) frameIndex++ } + // dev guard : hook へ渡した `renderSampleCount` と実際の frame 数がずれていたら契約違反。 + // 出力自体は壊さないので throw はせず warn だけ出す。 + if (rendered.frames.length !== sampleTimes.length) { + console.warn( + `Render sample count mismatch on animation '${animation.name}': context reported ${sampleTimes.length}, but ${rendered.frames.length} frames were rendered.` + ) + } } catch (error) { bodyError.failed = true bodyError.error = error diff --git a/src/tests/animationRenderExport.test.ts b/src/tests/animationRenderExport.test.ts index 7c52cc74..7efabeb5 100644 --- a/src/tests/animationRenderExport.test.ts +++ b/src/tests/animationRenderExport.test.ts @@ -19,6 +19,8 @@ * 6e. `onBeginAnimation` の部分失敗で、 成功済み hook の `onEndAnimation` が 1 回だけ走ること * 7. `onPose` の中から `evaluateBasePose` を呼べて、 `Timeline.time` が戻ること * 8. 1 と 2 の render 結果で、 生成される mcfunction が byte 単位で違うこと + * 9. context の周期情報が render 結果と一致すること (= `renderSampleCount` == `frames.length`) + * 9b. `onBeginAnimation` と `onPose` の周期情報が全 dispatch で同一であること * * `animationRenderer.ts` は import 連鎖の **module 評価時**に Blockbench global を要求する * (= `Dialog` / `BoneAnimator.prototype`)。 global を後から生やす方式では越えられないため、 @@ -150,6 +152,7 @@ import { isRenderingSessionActive, RenderHookError, registerRenderHooks, + type RenderAnimationContext, type RenderHookContext, unregisterRenderHooks, } from '../systems/animationRenderHooks' @@ -289,6 +292,37 @@ function extractBoneTransforms(animations: IRenderedAnimation[]) { }) } +/** + * 周期情報の検証で使う animation 設定。 harness の既定値 (= `length: 0.5` / `loop: 'once'` / + * `loop_delay: 0`) のままだと 「context が本当に animation から読んでいるか」 を判別できないため、 + * 3 つとも既定と違う値にしてある。 + */ +const TIMING_LENGTH_SECONDS = 0.35 +const TIMING_LOOP_MODE = 'loop' +/** Blockbench 側の `loop_delay` は string なので、 数値化されることも併せて見る。 */ +const TIMING_LOOP_DELAY_RAW = '3' +const TIMING_LOOP_DELAY = 3 + +/** context から周期情報だけを抜く (= `onBeginAnimation` と `onPose` の比較用)。 */ +function extractTiming(context: RenderAnimationContext) { + return { + animationLengthSeconds: context.animationLengthSeconds, + renderSampleCount: context.renderSampleCount, + loopMode: context.loopMode, + loopDelayFrames: context.loopDelayFrames, + } +} + +/** harness の animation を、 周期情報が既定値と区別できる設定へ差し替える。 */ +function applyTimingFixture(harness: RenderHarness) { + const animation = harness.project.animations[0] as unknown as { + loop: string + loop_delay: string + } + animation.loop = TIMING_LOOP_MODE + animation.loop_delay = TIMING_LOOP_DELAY_RAW +} + describe('renderProjectAnimations - hook 経路の実走', () => { beforeEach(() => { // production が毎 render で戻り値全体を console.log するため、 出力を抑える。 @@ -661,6 +695,62 @@ describe('renderProjectAnimations - hook 経路の実走', () => { expect(entry.after).toBe(entry.before) } }) + + it('9. onBeginAnimation の周期情報が render 結果と一致する', async () => { + const harness = createRenderHarness({ + boneUuid: BONE_UUID, + animationLength: TIMING_LENGTH_SECONDS, + }) + applyTimingFixture(harness) + + let timing: ReturnType | undefined + registerRenderHooks(HOOK_ID, { + onBeginAnimation(context: RenderAnimationContext) { + timing = extractTiming(context) + }, + }) + const animations = await render(harness) + + expect(timing).toBeDefined() + // renderSampleCount は frame ループが実際に回った回数そのもの (= 別式で数え直していない)。 + expect(timing!.renderSampleCount).toBe(animations[0].frames.length) + expect(timing!.renderSampleCount).toBe(harness.expectedFrameTimes.length) + // datapack meta の dur / lp / dly の元になる値と一致する。 + expect(timing!.renderSampleCount).toBe(animations[0].duration) + expect(timing!.loopMode).toBe(animations[0].loop_mode) + expect(timing!.loopDelayFrames).toBe(animations[0].loop_delay) + // 既定値ではなく animation から読んでいる。 + expect(timing!.animationLengthSeconds).toBe(TIMING_LENGTH_SECONDS) + expect(timing!.loopMode).toBe(TIMING_LOOP_MODE) + expect(timing!.loopDelayFrames).toBe(TIMING_LOOP_DELAY) + }) + + it('9b. onPose の周期情報は onBeginAnimation と全 dispatch で同一', async () => { + const harness = createRenderHarness({ + boneUuid: BONE_UUID, + animationLength: TIMING_LENGTH_SECONDS, + }) + applyTimingFixture(harness) + + let beginTiming: ReturnType | undefined + const poseTimings: Array> = [] + registerRenderHooks(HOOK_ID, { + onBeginAnimation(context: RenderAnimationContext) { + beginTiming = extractTiming(context) + }, + onPose(context: RenderHookContext) { + poseTimings.push(extractTiming(context)) + }, + }) + const animations = await render(harness) + + expect(beginTiming).toBeDefined() + expect(poseTimings.length).toBeGreaterThan(animations[0].frames.length) + for (const timing of poseTimings) { + expect(timing).toEqual(beginTiming) + } + expect(beginTiming!.renderSampleCount).toBe(animations[0].frames.length) + }) }) describe('renderProjectAnimations - datapack までの byte 差分', () => { diff --git a/src/tests/animationRenderHooks.test.ts b/src/tests/animationRenderHooks.test.ts index 32593c74..ba36dd7c 100644 --- a/src/tests/animationRenderHooks.test.ts +++ b/src/tests/animationRenderHooks.test.ts @@ -7,9 +7,11 @@ * 2. session の呼び出し順 (= begin / pose は登録順、 end は逆順) と参加者スナップショット * 3. hook 未登録 / session 外 / suppression 中の dispatch が完全 no-op であること * 4. hook が throw したときの `RenderHookError` 包装と、 end 系の全件実行 + * 5. 公開 API の `version` (= context に必須フィールドを足したら上げる契約) */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + RENDER_HOOKS_API, RenderHookError, areRenderHooksSuppressed, beginRenderingSession, @@ -37,6 +39,10 @@ function makeAnimationContext(): RenderAnimationContext { rig: {} as RenderAnimationContext['rig'], excludedNodeUuids: new Set(), evaluateBasePose: () => {}, + animationLengthSeconds: 0.5, + renderSampleCount: 11, + loopMode: 'once', + loopDelayFrames: 0, } } @@ -109,6 +115,19 @@ describe('animationRenderHooks - registry', () => { }) }) +// --- 公開 API --------------------------------------------------------------- + +describe('animationRenderHooks - 公開 API', () => { + it('version は 2 (= context の周期情報を必須で足した版)', () => { + expect(RENDER_HOOKS_API.version).toBe(2) + }) + + it('register / unregister は registry 関数そのもの', () => { + expect(RENDER_HOOKS_API.register).toBe(registerRenderHooks) + expect(RENDER_HOOKS_API.unregister).toBe(unregisterRenderHooks) + }) +}) + // --- session / dispatch ----------------------------------------------------- describe('animationRenderHooks - session と dispatch', () => { From fdcebfd79352ba32cdd46cca358b9e64f60c118b Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:22:36 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20review=20=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=82=92=E5=8F=8D=E6=98=A0=E3=81=97=20hook=20context=20?= =?UTF-8?q?=E3=81=AE=20loop=20=E6=83=85=E5=A0=B1=E3=82=92=20rendered=20?= =?UTF-8?q?=E7=B5=8C=E7=94=B1=E3=81=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/animationRenderHooks.ts | 12 +++- src/systems/animationRenderer.ts | 22 ++++++- src/tests/animationRenderExport.test.ts | 84 +++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/src/systems/animationRenderHooks.ts b/src/systems/animationRenderHooks.ts index 3bfea091..76103fbc 100644 --- a/src/systems/animationRenderHooks.ts +++ b/src/systems/animationRenderHooks.ts @@ -23,8 +23,11 @@ * `frameTimeSeconds + 0.001`)。時刻の正本は `frameIndex` であり、 `timeSeconds` は参考値として扱う * - **animation 単位の周期情報を context に載せている** (= `animationLengthSeconds` / * `renderSampleCount` / `loopMode` / `loopDelayFrames`)。 このうち `renderSampleCount` / - * `loopMode` / `loopDelayFrames` は **datapack meta の `dur` / `lp` / `dly` と一致する値**で、 - * hook 側が animation の内部構造を推測せずに周期を判断できるようにするために渡している。 + * `loopMode` / `loopDelayFrames` は **datapack meta の `dur` / `lp` / `dly` に対応する** + * (= 同じ animation 設定を指す) 値で、 hook 側が animation の内部構造を推測せずに周期を + * 判断できるようにするために渡している。 `renderSampleCount` と `loopDelayFrames` は `dur` / `dly` + * と **同じ値**だが、 `loopMode` だけは **エンコードが違う** (= context は文字列 + * `'once' | 'hold' | 'loop'`、 meta の `lp` は score 用に 0 / 1 / 2 へ畳んだ byte)。 * `renderSampleCount` は render loop が実際に生成する frame 数そのもの (= `animation.length` * から数え直した値ではない) なので、 `IRenderedAnimation.frames.length` / `duration` と必ず一致する。 * **「表示上の最終 frame がどれか」 の解釈は hook 側の責務**であり、 AJ は生の値を渡すだけ @@ -51,7 +54,10 @@ export interface RenderAnimationContext { readonly animationLengthSeconds: number /** render loop が実際に生成する frame の数。 datapack meta の `dur` と一致する。 */ readonly renderSampleCount: number - /** `animation.loop`。 datapack meta の `lp` の元になる値。 */ + /** + * `animation.loop` (= 文字列)。 datapack meta の `lp` に対応するが、 `lp` は score 用に + * 0 / 1 / 2 へ畳んだ byte なので**エンコードは違う**。 + */ readonly loopMode: _Animation['loop'] /** `Number(animation.loop_delay) || 0` (= tick)。 datapack meta の `dly` と一致する。 */ readonly loopDelayFrames: number diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index 9a4897a8..beefc7e7 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -389,6 +389,13 @@ function throwPreferringBody(body: IErrorSlot, cleanup: IErrorSlot) { if (cleanup.failed) throw cleanup.error } +/** + * frame ループが生成する sample 数の上限。 1 sample = 1 tick なので 100,000 で約 83 分ぶんあり、 + * 現実的な animation 長は十分に超えている。 `animation.length` が壊れた値 (= `Infinity` 等) の + * ときに時刻列が際限なく伸びるのを止めるためだけの安全弁。 + */ +const MAX_RENDER_SAMPLES = 100_000 + function renderAnimation(animation: _Animation, rig: IRenderedRig) { const rendered = { name: animation.name, @@ -410,6 +417,12 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) { // 別式で数え直すと `roundToNth` の丸めと食い違って off-by-one が出る。 const sampleTimes: number[] = [] for (let time = 0; time <= animation.length; time = roundToNth(time + 0.05, 20)) { + if (sampleTimes.length >= MAX_RENDER_SAMPLES) { + console.warn( + `Animation '${animation.name}' exceeds the render sample limit (${MAX_RENDER_SAMPLES}); truncating. Check the animation length (${animation.length}).` + ) + break + } sampleTimes.push(time) } @@ -419,8 +432,11 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) { excludedNodeUuids: collectExcludedNodeUuids(animation), animationLengthSeconds: animation.length, renderSampleCount: sampleTimes.length, - loopMode: animation.loop, - loopDelayFrames: Number(animation.loop_delay) || 0, + // loop 情報は `animation` から読み直さず `rendered` を経由する。 `animation.select()` は + // `select_animation` を同期 dispatch するため、 listener が loop 設定を書き換えると + // 「context = select 後の新値 / datapack meta = select 前の旧値」 に割れてしまう + loopMode: rendered.loop_mode, + loopDelayFrames: rendered.loop_delay, evaluateBasePose(timeSeconds: number) { const previousTime = Timeline.time try { @@ -454,6 +470,8 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) { } // dev guard : hook へ渡した `renderSampleCount` と実際の frame 数がずれていたら契約違反。 // 出力自体は壊さないので throw はせず warn だけ出す。 + // **現在の制御フローでは発火しない** (= ループは `sampleTimes` を最後まで回し、 各周で + // 必ず 1 frame push する)。 将来ループ本体に `continue` 等が入ったときの保険として置いている。 if (rendered.frames.length !== sampleTimes.length) { console.warn( `Render sample count mismatch on animation '${animation.name}': context reported ${sampleTimes.length}, but ${rendered.frames.length} frames were rendered.` diff --git a/src/tests/animationRenderExport.test.ts b/src/tests/animationRenderExport.test.ts index 7efabeb5..496b58ac 100644 --- a/src/tests/animationRenderExport.test.ts +++ b/src/tests/animationRenderExport.test.ts @@ -21,6 +21,8 @@ * 8. 1 と 2 の render 結果で、 生成される mcfunction が byte 単位で違うこと * 9. context の周期情報が render 結果と一致すること (= `renderSampleCount` == `frames.length`) * 9b. `onBeginAnimation` と `onPose` の周期情報が全 dispatch で同一であること + * 9c. 格子外 / 端数の length でも `renderSampleCount` == `frames.length` が成立すること + * 9d. context の loop 情報が `rendered` と同一 source から来ていること (= `select()` で割れない) * * `animationRenderer.ts` は import 連鎖の **module 評価時**に Blockbench global を要求する * (= `Dialog` / `BoneAnimator.prototype`)。 global を後から生やす方式では越えられないため、 @@ -303,6 +305,16 @@ const TIMING_LOOP_MODE = 'loop' const TIMING_LOOP_DELAY_RAW = '3' const TIMING_LOOP_DELAY = 3 +/** + * `renderSampleCount` の検証で回す animation 長の一覧。 + * + * **`0.05` の格子から外れた値**と、 **格子ちょうどでも `length / 0.05` が浮動小数で + * 割り切れない値** (= `0.35` / `0.7`) を混ぜてある。 後者があることで、 `renderSampleCount` を + * 禁止された別式 (= `Math.floor(length / 0.05) + 1`) で数え直す実装に差し替えたときに + * この test が落ちる (= 実測から取っていることを実際に見分けられる)。 + */ +const TIMING_LENGTHS = [0.02, 0.11, 0.333, 0.35, 0.37, 0.7] + /** context から周期情報だけを抜く (= `onBeginAnimation` と `onPose` の比較用)。 */ function extractTiming(context: RenderAnimationContext) { return { @@ -751,6 +763,78 @@ describe('renderProjectAnimations - hook 経路の実走', () => { } expect(beginTiming!.renderSampleCount).toBe(animations[0].frames.length) }) + + it('9c. 格子外 / 端数の length でも renderSampleCount が frames.length と一致する', async () => { + const observed: Array<{ length: number; count: number }> = [] + + for (const length of TIMING_LENGTHS) { + const harness = createRenderHarness({ boneUuid: BONE_UUID, animationLength: length }) + let timing: ReturnType | undefined + registerRenderHooks(HOOK_ID, { + onBeginAnimation(context: RenderAnimationContext) { + timing = extractTiming(context) + }, + }) + const animations = await render(harness) + unregisterRenderHooks(HOOK_ID) + + const frameCount = animations[0].frames.length + expect(timing, `length=${length}`).toBeDefined() + expect(timing!.renderSampleCount, `length=${length}`).toBe(frameCount) + expect(timing!.renderSampleCount, `length=${length}`).toBe( + harness.expectedFrameTimes.length + ) + expect(timing!.animationLengthSeconds, `length=${length}`).toBe(length) + observed.push({ length, count: frameCount }) + } + + // 禁止した別式 (= `Math.floor(length / 0.05) + 1`) では少なくとも 1 件で値がずれる。 + // これが 0 件だと、 別式へ差し替えても上の assert が全部通ってしまう (= test が + // 「実測から取っていること」 を見分けられない) ので、 case 選びごと守る。 + const divergent = observed.filter( + entry => Math.floor(entry.length / 0.05) + 1 !== entry.count + ) + expect(divergent.length).toBeGreaterThan(0) + }) + + it('9d. context の loop 情報は rendered と同一 source (= select() 中の書き換えで割れない)', async () => { + const harness = createRenderHarness({ + boneUuid: BONE_UUID, + animationLength: TIMING_LENGTH_SECONDS, + }) + applyTimingFixture(harness) + + // Blockbench の `Animation.select()` は `select_animation` を同期 dispatch するため、 + // listener が loop 設定を書き換えうる。 `rendered` 側は select() の**前**に値を確定させて + // いるので、 context が `animation` から読み直していると + // 「context = 新値 / datapack meta = 旧値」 に割れる。 それを再現する。 + const animation = harness.project.animations[0] as unknown as { + loop: string + loop_delay: string + select: () => void + } + animation.select = () => { + animation.loop = 'hold' + animation.loop_delay = '99' + } + + let timing: ReturnType | undefined + registerRenderHooks(HOOK_ID, { + onBeginAnimation(context: RenderAnimationContext) { + timing = extractTiming(context) + }, + }) + const animations = await render(harness) + + // select() が実際に値を書き換えている (= 前提が成立している)。 + expect(animation.loop).toBe('hold') + expect(animation.loop_delay).toBe('99') + // context は datapack meta 側 (= rendered) と同じ値のまま。 + expect(timing!.loopMode).toBe(animations[0].loop_mode) + expect(timing!.loopDelayFrames).toBe(animations[0].loop_delay) + expect(timing!.loopMode).toBe(TIMING_LOOP_MODE) + expect(timing!.loopDelayFrames).toBe(TIMING_LOOP_DELAY) + }) }) describe('renderProjectAnimations - datapack までの byte 差分', () => { From 1a5802e24afd33fddcf76bf353980fd8b58e5946 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:39:16 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20frame=20=E6=95=B0=E3=81=AE?= =?UTF-8?q?=E4=B8=80=E5=BE=8B=E4=B8=8A=E9=99=90=E3=82=92=E5=BB=83=E6=AD=A2?= =?UTF-8?q?=E3=81=97=E7=B5=82=E3=82=8F=E3=82=89=E3=81=AA=E3=81=84=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=81=A0=E3=81=91=E3=82=92=E5=BC=BE=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/animationRenderer.ts | 32 ++++--- src/tests/animationRenderExport.test.ts | 111 ++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 13 deletions(-) diff --git a/src/systems/animationRenderer.ts b/src/systems/animationRenderer.ts index beefc7e7..718537d1 100644 --- a/src/systems/animationRenderer.ts +++ b/src/systems/animationRenderer.ts @@ -389,13 +389,6 @@ function throwPreferringBody(body: IErrorSlot, cleanup: IErrorSlot) { if (cleanup.failed) throw cleanup.error } -/** - * frame ループが生成する sample 数の上限。 1 sample = 1 tick なので 100,000 で約 83 分ぶんあり、 - * 現実的な animation 長は十分に超えている。 `animation.length` が壊れた値 (= `Infinity` 等) の - * ときに時刻列が際限なく伸びるのを止めるためだけの安全弁。 - */ -const MAX_RENDER_SAMPLES = 100_000 - function renderAnimation(animation: _Animation, rig: IRenderedRig) { const rendered = { name: animation.name, @@ -412,18 +405,31 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) { const includedNodes = new Set() + // `+Infinity` は `time <= animation.length` が永久に真になる (= 旧実装がハングしていた) ので、 + // 時刻列を作る前に弾く。 `NaN` / `-Infinity` は旧実装でも比較が偽で 0 件だったため、 + // **弾かずに 0 件のまま通す** (= 従来の出力を変えない)。 + if (animation.length === Infinity) { + throw new Error( + `Animation '${animation.name}' has a non-finite length (${animation.length}). Cannot render.` + ) + } + // frame ループが訪れる時刻の列。 **ループ本体もこの配列を回す** (= context の // `renderSampleCount` と実際の frame 数を同じ配列から取るため)。 `animation.length` から // 別式で数え直すと `roundToNth` の丸めと食い違って off-by-one が出る。 + // **件数の上限は設けない**。 有限長では hook 導入前の for ループと 1 件も違わない。 const sampleTimes: number[] = [] - for (let time = 0; time <= animation.length; time = roundToNth(time + 0.05, 20)) { - if (sampleTimes.length >= MAX_RENDER_SAMPLES) { - console.warn( - `Animation '${animation.name}' exceeds the render sample limit (${MAX_RENDER_SAMPLES}); truncating. Check the animation length (${animation.length}).` + for (let time = 0; time <= animation.length; ) { + sampleTimes.push(time) + const nextTime = roundToNth(time + 0.05, 20) + // double の精度限界 (= `time` が大きすぎて `+0.05` が丸めで消える) に達すると時刻が + // 進まなくなり、 旧実装は同じ frame を延々と積み続けていた。 黙って回り続けるより失敗させる。 + if (!(nextTime > time)) { + throw new Error( + `Animation '${animation.name}' stopped advancing at ${time}s (length ${animation.length}). Cannot render.` ) - break } - sampleTimes.push(time) + time = nextTime } currentRenderContext = { diff --git a/src/tests/animationRenderExport.test.ts b/src/tests/animationRenderExport.test.ts index 496b58ac..7eec9b8d 100644 --- a/src/tests/animationRenderExport.test.ts +++ b/src/tests/animationRenderExport.test.ts @@ -23,6 +23,9 @@ * 9b. `onBeginAnimation` と `onPose` の周期情報が全 dispatch で同一であること * 9c. 格子外 / 端数の length でも `renderSampleCount` == `frames.length` が成立すること * 9d. context の loop 情報が `rendered` と同一 source から来ていること (= `select()` で割れない) + * 10. 有限長の frame 数が hook 導入前の for ループと一致すること (= 件数上限を設けていない) + * 10b. `length` が `+Infinity` なら失敗し、 `NaN` / `-Infinity` は従来どおり 0 frame で通ること + * 10c. frame ループの時刻が進まなくなったら失敗すること (= 無限ループにしない) * * `animationRenderer.ts` は import 連鎖の **module 評価時**に Blockbench global を要求する * (= `Dialog` / `BoneAnimator.prototype`)。 global を後から生やす方式では越えられないため、 @@ -137,6 +140,25 @@ vi.mock('../formats/blueprint', () => ({ }, })) +/** + * `roundToNth` を差し替えるための制御箱。 `stallTime` に数値を入れると `roundToNth` が常に + * その値を返し、 **frame ループの時刻が進まない状況** (= double の精度限界) を再現できる。 + * + * 本物の精度限界を踏むには `1e15` 秒級の `animation.length` が要る (= その手前で配列が破裂する) + * ため、 丸めだけを差し替えて再現している。 `undefined` の間は本物へ委譲するので他の test には効かない。 + */ +const MISC_CONTROL = vi.hoisted(() => ({ stallTime: undefined as number | undefined })) +vi.mock('../util/misc', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + roundToNth(num: number, nth: number) { + if (MISC_CONTROL.stallTime !== undefined) return MISC_CONTROL.stallTime + return actual.roundToNth(num, nth) + }, + } +}) + // tellraw.ts の `import { type IRenderedVariant } from '../rigRenderer'` は verbatimModuleSyntax の // 下で side-effect import として残り、 型しか使っていないのに実体 (= constants → util/lang の // LANGUAGES 仮想モジュール) がロードされる。 実行時に参照される値は無いので空モジュールで足りる。 @@ -315,6 +337,28 @@ const TIMING_LOOP_DELAY = 3 */ const TIMING_LENGTHS = [0.02, 0.11, 0.333, 0.35, 0.37, 0.7] +/** + * 「旧実装と同じ frame 数か」 を見るための animation 長。 端 (= `0`) と、 tick 数が 3 桁に + * 乗る長さ (= `5`) を含める。 + */ +const LEGACY_EQUIVALENCE_LENGTHS = [0, 0.02, 0.35, 0.7, 5] + +/** + * hook 導入前の frame ループが訪れる時刻の数。 当時の + * `for (let time = 0; time <= animation.length; time = roundToNth(time + 0.05, 20))` を + * そのまま写したもの (= `roundToNth(n, x)` は `Math.round(n * x) / x`)。 + * + * **production の実装からは独立している**ことに意味がある (= harness の `expectedFrameTimes` も + * 同じ列を持つが、 そちらが production 追従で書き換わっても、 この関数は旧実装のまま残る)。 + */ +function legacyFrameCount(length: number): number { + let count = 0 + for (let time = 0; time <= length; time = Math.round((time + 0.05) * 20) / 20) { + count++ + } + return count +} + /** context から周期情報だけを抜く (= `onBeginAnimation` と `onPose` の比較用)。 */ function extractTiming(context: RenderAnimationContext) { return { @@ -346,6 +390,7 @@ describe('renderProjectAnimations - hook 経路の実走', () => { afterEach(() => { unregisterRenderHooks(HOOK_ID) unregisterRenderHooks(SECOND_HOOK_ID) + MISC_CONTROL.stallTime = undefined vi.restoreAllMocks() }) @@ -835,6 +880,72 @@ describe('renderProjectAnimations - hook 経路の実走', () => { expect(timing!.loopMode).toBe(TIMING_LOOP_MODE) expect(timing!.loopDelayFrames).toBe(TIMING_LOOP_DELAY) }) + + it('10. 有限長の frame 数は hook 導入前の for ループと一致する (= 件数上限を設けない)', async () => { + for (const length of LEGACY_EQUIVALENCE_LENGTHS) { + const harness = createRenderHarness({ boneUuid: BONE_UUID, animationLength: length }) + let timing: ReturnType | undefined + registerRenderHooks(HOOK_ID, { + onBeginAnimation(context: RenderAnimationContext) { + timing = extractTiming(context) + }, + }) + const animations = await render(harness) + unregisterRenderHooks(HOOK_ID) + + const expected = legacyFrameCount(length) + expect(animations[0].frames.length, `length=${length}`).toBe(expected) + expect(animations[0].duration, `length=${length}`).toBe(expected) + expect(timing!.renderSampleCount, `length=${length}`).toBe(expected) + } + }) + + it('10b. length が +Infinity なら失敗し、 NaN / -Infinity は 0 frame で通る', async () => { + /** + * harness を組んでから `length` だけ差し替える。 `createRenderHarness` の期待値生成 + * (= `expectedFrameTimes`) が `Infinity` では終わらないため、 option 経由では渡せない。 + */ + const withLength = (length: number) => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + ;(harness.project.animations[0] as unknown as { length: number }).length = length + return harness + } + + // +Infinity は `time <= length` が永久に真 (= 旧実装はハングしていた) なので失敗させる。 + const infinite = withLength(Infinity) + const caught = await render(infinite).then( + () => undefined, + (error: unknown) => error + ) + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).message).toContain('non-finite length') + // 既存の bodyError / cleanup 経路に乗っているので global 状態は復旧している。 + expect(BONE_INTERPOLATION_ENABLED.get()).toBe(true) + expect(infinite.scene.quaternion.w).toBeCloseTo(1, 9) + + // NaN / -Infinity は旧実装でも比較が偽で 0 件だったので、 その挙動を保つ (= throw しない)。 + for (const length of [NaN, -Infinity]) { + const animations = await render(withLength(length)) + expect(animations[0].frames.length, `length=${length}`).toBe(0) + expect(animations[0].duration, `length=${length}`).toBe(0) + } + }) + + it('10c. frame ループの時刻が進まなくなったら失敗する (= 無限ループにしない)', async () => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + // `roundToNth` が常に 0 を返す = 1 周目の時点で時刻が進まない状況。 + MISC_CONTROL.stallTime = 0 + + const caught = await render(harness).then( + () => undefined, + (error: unknown) => error + ) + + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).message).toContain('stopped advancing') + expect(BONE_INTERPOLATION_ENABLED.get()).toBe(true) + expect(harness.scene.quaternion.w).toBeCloseTo(1, 9) + }) }) describe('renderProjectAnimations - datapack までの byte 差分', () => { From d46e6486137ba217853acac752ae7e172507feda Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:51:54 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=85=20=E6=99=82=E5=88=BB=E5=88=97?= =?UTF-8?q?=E3=81=AE=E5=90=8C=E7=AD=89=E6=80=A7=E3=81=A8=20session=20clean?= =?UTF-8?q?up=20=E3=81=AE=E5=9B=9E=E5=B8=B0=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tests/animationRenderExport.test.ts | 84 +++++++++++++++++++++---- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/src/tests/animationRenderExport.test.ts b/src/tests/animationRenderExport.test.ts index 7eec9b8d..1cf93d85 100644 --- a/src/tests/animationRenderExport.test.ts +++ b/src/tests/animationRenderExport.test.ts @@ -23,9 +23,10 @@ * 9b. `onBeginAnimation` と `onPose` の周期情報が全 dispatch で同一であること * 9c. 格子外 / 端数の length でも `renderSampleCount` == `frames.length` が成立すること * 9d. context の loop 情報が `rendered` と同一 source から来ていること (= `select()` で割れない) - * 10. 有限長の frame 数が hook 導入前の for ループと一致すること (= 件数上限を設けていない) + * 10. 有限長の **時刻列**が hook 導入前の for ループと一致すること (= 件数上限を設けていない) * 10b. `length` が `+Infinity` なら失敗し、 `NaN` / `-Infinity` は従来どおり 0 frame で通ること * 10c. frame ループの時刻が進まなくなったら失敗すること (= 無限ループにしない) + * 10d. 10b / 10c の throw が active な hook session の後始末を通ること * * `animationRenderer.ts` は import 連鎖の **module 評価時**に Blockbench global を要求する * (= `Dialog` / `BoneAnimator.prototype`)。 global を後から生やす方式では越えられないため、 @@ -338,25 +339,27 @@ const TIMING_LOOP_DELAY = 3 const TIMING_LENGTHS = [0.02, 0.11, 0.333, 0.35, 0.37, 0.7] /** - * 「旧実装と同じ frame 数か」 を見るための animation 長。 端 (= `0`) と、 tick 数が 3 桁に + * 「旧実装と同じ時刻列か」 を見るための animation 長。 端 (= `0`) と、 tick 数が 3 桁に * 乗る長さ (= `5`) を含める。 */ const LEGACY_EQUIVALENCE_LENGTHS = [0, 0.02, 0.35, 0.7, 5] /** - * hook 導入前の frame ループが訪れる時刻の数。 当時の + * hook 導入前の frame ループが訪れる時刻の列。 当時の * `for (let time = 0; time <= animation.length; time = roundToNth(time + 0.05, 20))` を - * そのまま写したもの (= `roundToNth(n, x)` は `Math.round(n * x) / x`)。 + * 順序も更新式もそのまま写したもの (= `roundToNth(n, x)` は `Math.round(n * x) / x`)。 * * **production の実装からは独立している**ことに意味がある (= harness の `expectedFrameTimes` も * 同じ列を持つが、 そちらが production 追従で書き換わっても、 この関数は旧実装のまま残る)。 + * 守りたい契約は 「件数が同じ」 ではなく 「訪れる時刻が 1 つも変わらない」 なので、 + * 件数ではなく列そのものを返す。 */ -function legacyFrameCount(length: number): number { - let count = 0 +function legacyFrameTimes(length: number): number[] { + const times: number[] = [] for (let time = 0; time <= length; time = Math.round((time + 0.05) * 20) / 20) { - count++ + times.push(time) } - return count + return times } /** context から周期情報だけを抜く (= `onBeginAnimation` と `onPose` の比較用)。 */ @@ -881,7 +884,7 @@ describe('renderProjectAnimations - hook 経路の実走', () => { expect(timing!.loopDelayFrames).toBe(TIMING_LOOP_DELAY) }) - it('10. 有限長の frame 数は hook 導入前の for ループと一致する (= 件数上限を設けない)', async () => { + it('10. 有限長の時刻列は hook 導入前の for ループと一致する (= 件数上限を設けない)', async () => { for (const length of LEGACY_EQUIVALENCE_LENGTHS) { const harness = createRenderHarness({ boneUuid: BONE_UUID, animationLength: length }) let timing: ReturnType | undefined @@ -893,10 +896,15 @@ describe('renderProjectAnimations - hook 経路の実走', () => { const animations = await render(harness) unregisterRenderHooks(HOOK_ID) - const expected = legacyFrameCount(length) - expect(animations[0].frames.length, `length=${length}`).toBe(expected) - expect(animations[0].duration, `length=${length}`).toBe(expected) - expect(timing!.renderSampleCount, `length=${length}`).toBe(expected) + const expected = legacyFrameTimes(length) + // 件数ではなく **訪れた時刻そのもの**を比較する (= 「件数は同じだが時刻がずれた」 + // 回帰を落とすため)。 `frame.time` は frame ループの `time` がそのまま入る。 + expect( + animations[0].frames.map(frame => frame.time), + `length=${length}` + ).toEqual(expected) + expect(animations[0].duration, `length=${length}`).toBe(expected.length) + expect(timing!.renderSampleCount, `length=${length}`).toBe(expected.length) } }) @@ -946,6 +954,56 @@ describe('renderProjectAnimations - hook 経路の実走', () => { expect(BONE_INTERPOLATION_ENABLED.get()).toBe(true) expect(harness.scene.quaternion.w).toBeCloseTo(1, 9) }) + + it('10d. 10b / 10c の throw は active な hook session の後始末を通る', async () => { + /** + * noop hook を登録した状態で render を走らせ、 呼ばれた callback を記録する。 + * 10b / 10c は hook 未登録 (= session を張らない) で走るため、 session 側の + * 後始末が通ることはそちらでは見えない。 + */ + const runWithSession = async (prepare: () => RenderHarness) => { + const log: string[] = [] + registerRenderHooks(HOOK_ID, { + onBeginRendering: () => log.push('beginRendering'), + onBeginAnimation: () => log.push('beginAnimation'), + onPose: () => log.push('pose'), + onEndAnimation: () => log.push('endAnimation'), + onEndRendering: () => log.push('endRendering'), + }) + const harness = prepare() + const caught = await render(harness).then( + () => undefined, + (error: unknown) => error + ) + unregisterRenderHooks(HOOK_ID) + return { log, caught, harness } + } + + // (a) 非有限 length。 throw は animation 単位の context を組む前に出る。 + const infinite = await runWithSession(() => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + ;(harness.project.animations[0] as unknown as { length: number }).length = Infinity + return harness + }) + expect((infinite.caught as Error).message).toContain('non-finite length') + // session は開いたが animation 段へは進んでおらず、 onEndRendering はちょうど 1 回。 + expect(infinite.log).toEqual(['beginRendering', 'endRendering']) + expect(isRenderingSessionActive()).toBe(false) + expect(BONE_INTERPOLATION_ENABLED.get()).toBe(true) + expect(infinite.harness.scene.quaternion.w).toBeCloseTo(1, 9) + + // (b) 時刻が進まないケースも同じ位置で throw する。 + const stalled = await runWithSession(() => { + const harness = createRenderHarness({ boneUuid: BONE_UUID }) + MISC_CONTROL.stallTime = 0 + return harness + }) + expect((stalled.caught as Error).message).toContain('stopped advancing') + expect(stalled.log).toEqual(['beginRendering', 'endRendering']) + expect(isRenderingSessionActive()).toBe(false) + expect(BONE_INTERPOLATION_ENABLED.get()).toBe(true) + expect(stalled.harness.scene.quaternion.w).toBeCloseTo(1, 9) + }) }) describe('renderProjectAnimations - datapack までの byte 差分', () => {