From c824b1a42efd8fe16b12e2ed30b69b0e75b45c67 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 6 Jul 2026 08:51:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=88=87=E6=8D=A2Pio=20Live2D=E7=BA=BF?= =?UTF-8?q?=E4=B8=8Av2=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/__tests__/BlogLive2D.spec.ts | 76 ++++++++++++++----- src/components/blog/BlogLive2D.tsx | 10 ++- .../blog/live2d/officialRuntimeBridge.ts | 6 +- src/components/blog/live2d/types.ts | 39 +++++++++- 5 files changed, 105 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0856c34..4ec882e 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ pnpm exec playwright test e2e/argon-parity/baseline.spec.ts --project=chromium - 左栏 overview sticky/relative 切换用 no-headroom 回归用例固定,解除 fixed 后不得重放卡片入场动画或产生缩放闪烁。 - 左栏文章目录/站点概览切换必须保留 Bootstrap tab fade 节奏,采用 active/show 分帧保持 Argon 手感;回归用例需要断言切换中 opacity 处于 0 到 1 之间,而不是只检查最终显隐。 - Argon motion、滚动几何、延迟、RAF 调度、DOM id/selector、hash anchor 和跨组件 ref 必须从 `src/factories/blogAnimationFactory.ts` 与 `src/factories/blogDomFactory.ts` 取值;组件和 Hook 不再散写行为层 id、裸 `requestAnimationFrame`、裸 `setTimeout` 或重复 timing。 -- Live2D 不依赖旧 WordPress `live-2d` 插件、`live2d-widgets`、随机 CDN fallback 模型或插件账号签名;`BlogLive2D` 只在桌面端读取 `VITE_BLOG_LIVE2D_MANIFEST_URL`,默认 `/api/blog/live2d/pio/v1/manifest.json`,并通过自托管 Pio manifest 加载官方 Cubism runtime。Manifest 已提供源 `Idle` motions 时,前端不得再叠加自动 idle bob/breath/sway,只保留 pointer parallax 兜底;线上 Pio 源目录必须遵守 `docs/live2d-pio-minio-directory-standard.md`,保留 `catalog.json`、`textures/manifest.json` 与完整 source texture catalog。 +- Live2D 不依赖旧 WordPress `live-2d` 插件、`live2d-widgets`、随机 CDN fallback 模型或插件账号签名;`BlogLive2D` 只在桌面端读取 `VITE_BLOG_LIVE2D_MANIFEST_URL`,默认 `/api/blog/live2d/pio/v2/manifest.json`,并通过自托管 Pio manifest 加载官方 Cubism runtime。Manifest 声明 `wordpressParity` 时,Idle/Sleepy、呼吸、鼠标视差、头部点击和身体点击都由生成 runtime 接管,前端不得再叠加 bob/breath/sway/parallax 兜底;线上 Pio 源目录必须遵守 `docs/live2d-pio-minio-directory-standard.md`,保留 `catalog.json`、`textures/manifest.json` 与完整 source texture catalog。 - Modal 不强求一比一复刻线上 Argon 动画,打开/关闭 motion 与 `centered` 居中定位交给 antdv-next;外层按 `packages/@core/ui-kit/popup-ui/src/modal/modal.vue` 保留 Header/Content/Footer 三段能力、`p-0`、`max-height`、纵向 flex、Content `min-h-40` 滚动和 `px-5 py-4`/`p-3`/`p-2` 间距,但无 footer slot 时不渲染 footer,不搬 draggable/fullscreen/loading/footer 按钮等复杂能力,自有颜色只守 Blog 主题色和暗色可读性。 - Admin 文章预览通过 `VITE_KT_BLOG_WEB_BASE_URL` 打开公开 Blog Web 路由,本地默认 `http://127.0.0.1:5173/#/post/?adminPreview=1&articleId=`。 diff --git a/src/__tests__/BlogLive2D.spec.ts b/src/__tests__/BlogLive2D.spec.ts index 8582d2c..ca3d549 100644 --- a/src/__tests__/BlogLive2D.spec.ts +++ b/src/__tests__/BlogLive2D.spec.ts @@ -11,8 +11,50 @@ const pioManifest = { desktopOnly: true, fallback: null, model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', + motionGroups: { + FlickHead: 6, + Idle: 6, + Sleepy: 1, + TapBody: 9, + }, runtimeScript: '/api/blog/live2d/pio/v1/assets/runtime/pio-runtime.js', version: 'v1', + wordpressParity: { + hitAreasCustom: { + body_x: [-0.3, -0.25], + body_y: [0.3, -0.9], + head_x: [-0.35, 0.6], + head_y: [0.19, -0.2], + }, + layout: { + center_x: 0, + center_y: -0.05, + width: 2, + }, + motionGroups: { + FlickHead: { + legacyName: 'flick_head', + sourceFiles: ['motions/Touch Dere1.mtn'], + }, + Idle: { + legacyName: 'idle', + sourceFiles: ['motions/Breath1.mtn'], + }, + Sleepy: { + legacyName: 'sleepy', + sourceFiles: ['motions/Sleeping.mtn'], + }, + TapBody: { + legacyName: 'tap_body', + sourceFiles: ['motions/Touch1.mtn'], + }, + }, + parameterRig: { + breath: [{ cycle: 3.2345, id: 'ParamBreath', offset: 0.5, peak: 0.5, weight: 1 }], + look: [{ factorX: 30, factorXY: 0, factorY: 0, id: 'ParamAngleX' }], + }, + version: '2026-07-05-wordpress-v1', + }, } as const; describe('fetchBlogLive2DManifest', () => { @@ -98,6 +140,7 @@ describe('mountOfficialPioRuntime', () => { expect(runtime.mount).toHaveBeenCalledWith({ canvas, model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', + wordpressParity: pioManifest.wordpressParity, }); }); @@ -133,10 +176,12 @@ describe('mountOfficialPioRuntime', () => { expect(runtime.mount).toHaveBeenNthCalledWith(1, { canvas: firstCanvas, model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', + wordpressParity: pioManifest.wordpressParity, }); expect(runtime.mount).toHaveBeenNthCalledWith(2, { canvas: secondCanvas, model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', + wordpressParity: pioManifest.wordpressParity, }); }); }); @@ -166,13 +211,8 @@ describe('BlogLive2D', () => { 'fetch', vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })), ); - const frames: FrameRequestCallback[] = []; - const cancelFrame = vi.fn(); - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { - frames.push(callback); - return frames.length; - }); - vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(cancelFrame); + const requestFrame = vi.spyOn(window, 'requestAnimationFrame'); + const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame'); const destroy = vi.fn(); vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => { const element = Node.prototype.appendChild.call(document.body, node); @@ -193,18 +233,19 @@ describe('BlogLive2D', () => { expect(window.KtPioLive2D?.mount).toHaveBeenCalledWith({ canvas: canvas.element, model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', + wordpressParity: pioManifest.wordpressParity, }); - frames.shift()?.(1000); - expect((canvas.element as HTMLCanvasElement).style.transform).toContain('translate3d'); + expect((canvas.element as HTMLCanvasElement).style.transform).toBe(''); wrapper.unmount(); expect(destroy).toHaveBeenCalledTimes(1); - expect(cancelFrame).toHaveBeenCalled(); + expect(requestFrame).not.toHaveBeenCalled(); + expect(cancelFrame).not.toHaveBeenCalled(); }); - it('does not start the canvas idle fallback when source idle motions exist', async () => { + it('does not start canvas transform fallback when WordPress parity owns motion', async () => { vi.stubGlobal('innerWidth', 1280); vi.stubGlobal( 'fetch', @@ -213,19 +254,12 @@ describe('BlogLive2D', () => { new Response( JSON.stringify({ ...pioManifest, - motionGroups: { - Idle: 6, - }, }), { status: 200 }, ), ), ); - const frames: FrameRequestCallback[] = []; - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { - frames.push(callback); - return frames.length; - }); + const requestFrame = vi.spyOn(window, 'requestAnimationFrame'); vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => { const element = Node.prototype.appendChild.call(document.body, node); if (element instanceof HTMLScriptElement) { @@ -256,7 +290,7 @@ describe('BlogLive2D', () => { } as DOMRect); expect(canvasElement.style.transform).toBe(''); - expect(frames).toHaveLength(0); + expect(requestFrame).not.toHaveBeenCalled(); window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 })); @@ -264,7 +298,7 @@ describe('BlogLive2D', () => { canvasElement.dispatchEvent(new MouseEvent('pointermove', { clientX: 210, clientY: 260 })); - expect(canvasElement.style.transform).toContain('translate3d'); + expect(canvasElement.style.transform).toBe(''); canvasElement.dispatchEvent(new MouseEvent('pointerleave')); diff --git a/src/components/blog/BlogLive2D.tsx b/src/components/blog/BlogLive2D.tsx index 156b49d..befa1bf 100644 --- a/src/components/blog/BlogLive2D.tsx +++ b/src/components/blog/BlogLive2D.tsx @@ -11,7 +11,7 @@ import { blogDomId } from '@/factories/blogDomFactory'; import { mountOfficialPioRuntime } from './live2d/officialRuntimeBridge'; import type { KtPioLive2DRuntimeHandle } from './live2d/types'; -const LIVE2D_MANIFEST_URL = import.meta.env.VITE_BLOG_LIVE2D_MANIFEST_URL || '/api/blog/live2d/pio/v1/manifest.json'; +const LIVE2D_MANIFEST_URL = import.meta.env.VITE_BLOG_LIVE2D_MANIFEST_URL || '/api/blog/live2d/pio/v2/manifest.json'; export default defineComponent({ name: 'BlogLive2D', @@ -39,9 +39,11 @@ export default defineComponent({ return; } runtimeHandle = mountedHandle; - idleAnimatorHandle = createBlogLive2DIdleAnimator(canvasRef.value, { - enableIdleMotion: !manifest.motionGroups?.Idle, - }); + if (!manifest.wordpressParity) { + idleAnimatorHandle = createBlogLive2DIdleAnimator(canvasRef.value, { + enableIdleMotion: !manifest.motionGroups?.Idle, + }); + } } catch (error: unknown) { console.warn('[KT Blog] Pio Live2D unavailable.', error); } diff --git a/src/components/blog/live2d/officialRuntimeBridge.ts b/src/components/blog/live2d/officialRuntimeBridge.ts index e127a9e..2622582 100644 --- a/src/components/blog/live2d/officialRuntimeBridge.ts +++ b/src/components/blog/live2d/officialRuntimeBridge.ts @@ -28,7 +28,11 @@ export async function mountOfficialPioRuntime( throw new Error('KtPioLive2D runtime was not registered.'); } - return window.KtPioLive2D.mount({ canvas, model3: manifest.model3 }); + return window.KtPioLive2D.mount({ + canvas, + model3: manifest.model3, + wordpressParity: manifest.wordpressParity, + }); } /** diff --git a/src/components/blog/live2d/types.ts b/src/components/blog/live2d/types.ts index 2114cee..8e01505 100644 --- a/src/components/blog/live2d/types.ts +++ b/src/components/blog/live2d/types.ts @@ -1,3 +1,35 @@ +export interface BlogLive2DWordpressParity { + hitAreasCustom: Record<'body_x' | 'body_y' | 'head_x' | 'head_y', readonly [number, number]>; + layout: { + center_x: number; + center_y: number; + width: number; + }; + motionGroups: Record< + 'FlickHead' | 'Idle' | 'Sleepy' | 'TapBody', + { + legacyName: string; + sourceFiles: readonly string[]; + } + >; + parameterRig: { + breath: readonly { + cycle: number; + id: string; + offset: number; + peak: number; + weight: number; + }[]; + look: readonly { + factorX: number; + factorXY: number; + factorY: number; + id: string; + }[]; + }; + version: string; +} + export interface BlogLive2DManifest { assets?: { shaderBase?: string; @@ -34,6 +66,7 @@ export interface BlogLive2DManifest { manifest: string; }; version: string; + wordpressParity?: BlogLive2DWordpressParity; } export interface KtPioLive2DRuntimeHandle { @@ -49,5 +82,9 @@ export interface KtPioLive2DRuntime { * @param options Canvas and model URL emitted by the validated manifest. * @returns Runtime handle used by Vue teardown to release rendering resources. */ - mount(options: { canvas: HTMLCanvasElement; model3: string }): Promise; + mount(options: { + canvas: HTMLCanvasElement; + model3: string; + wordpressParity?: BlogLive2DWordpressParity; + }): Promise; }