fix: 切换Pio Live2D线上v2契约

This commit is contained in:
sunlei 2026-07-06 08:51:43 +08:00
parent b6ca7c29fc
commit c824b1a42e
5 changed files with 105 additions and 28 deletions

View File

@ -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/<slug>?adminPreview=1&articleId=<id>`

View File

@ -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'));

View File

@ -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);
}

View File

@ -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,
});
}
/**

View File

@ -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<KtPioLive2DRuntimeHandle>;
mount(options: {
canvas: HTMLCanvasElement;
model3: string;
wordpressParity?: BlogLive2DWordpressParity;
}): Promise<KtPioLive2DRuntimeHandle>;
}