fix: 对齐Pio源动作加载边界

This commit is contained in:
sunlei 2026-07-05 19:41:02 +08:00
parent c2cd06ae4a
commit b6ca7c29fc
5 changed files with 197 additions and 34 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 后不得重放卡片入场动画或产生缩放闪烁。 - 左栏 overview sticky/relative 切换用 no-headroom 回归用例固定,解除 fixed 后不得重放卡片入场动画或产生缩放闪烁。
- 左栏文章目录/站点概览切换必须保留 Bootstrap tab fade 节奏,采用 active/show 分帧保持 Argon 手感;回归用例需要断言切换中 opacity 处于 0 到 1 之间,而不是只检查最终显隐。 - 左栏文章目录/站点概览切换必须保留 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。 - 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。 - 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。
- 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 主题色和暗色可读性。 - 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>` - Admin 文章预览通过 `VITE_KT_BLOG_WEB_BASE_URL` 打开公开 Blog Web 路由,本地默认 `http://127.0.0.1:5173/#/post/<slug>?adminPreview=1&articleId=<id>`

View File

@ -10,8 +10,8 @@ const pioManifest = {
character: 'pio', character: 'pio',
desktopOnly: true, desktopOnly: true,
fallback: null, fallback: null,
model3: '/api/blog/live2d/pio/v1/pio.model3.json', model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
runtimeScript: '/api/blog/live2d/pio/v1/pio-runtime.js', runtimeScript: '/api/blog/live2d/pio/v1/assets/runtime/pio-runtime.js',
version: 'v1', version: 'v1',
} as const; } as const;
@ -30,7 +30,7 @@ describe('fetchBlogLive2DManifest', () => {
expect(manifest.character).toBe('pio'); expect(manifest.character).toBe('pio');
expect(manifest.fallback).toBeNull(); expect(manifest.fallback).toBeNull();
expect(manifest.model3).toBe('/api/blog/live2d/pio/v1/pio.model3.json'); expect(manifest.model3).toBe('/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json');
}); });
it('rejects non-Pio or fallback manifests before runtime loading', async () => { it('rejects non-Pio or fallback manifests before runtime loading', async () => {
@ -97,7 +97,7 @@ describe('mountOfficialPioRuntime', () => {
expect(runtime.mount).toHaveBeenCalledTimes(2); expect(runtime.mount).toHaveBeenCalledTimes(2);
expect(runtime.mount).toHaveBeenCalledWith({ expect(runtime.mount).toHaveBeenCalledWith({
canvas, canvas,
model3: '/api/blog/live2d/pio/v1/pio.model3.json', model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
}); });
}); });
@ -132,11 +132,11 @@ describe('mountOfficialPioRuntime', () => {
expect(runtime.mount).toHaveBeenCalledTimes(2); expect(runtime.mount).toHaveBeenCalledTimes(2);
expect(runtime.mount).toHaveBeenNthCalledWith(1, { expect(runtime.mount).toHaveBeenNthCalledWith(1, {
canvas: firstCanvas, canvas: firstCanvas,
model3: '/api/blog/live2d/pio/v1/pio.model3.json', model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
}); });
expect(runtime.mount).toHaveBeenNthCalledWith(2, { expect(runtime.mount).toHaveBeenNthCalledWith(2, {
canvas: secondCanvas, canvas: secondCanvas,
model3: '/api/blog/live2d/pio/v1/pio.model3.json', model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
}); });
}); });
}); });
@ -192,7 +192,7 @@ describe('BlogLive2D', () => {
expect(canvas.exists()).toBe(true); expect(canvas.exists()).toBe(true);
expect(window.KtPioLive2D?.mount).toHaveBeenCalledWith({ expect(window.KtPioLive2D?.mount).toHaveBeenCalledWith({
canvas: canvas.element, canvas: canvas.element,
model3: '/api/blog/live2d/pio/v1/pio.model3.json', model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
}); });
frames.shift()?.(1000); frames.shift()?.(1000);
@ -203,6 +203,75 @@ describe('BlogLive2D', () => {
expect(destroy).toHaveBeenCalledTimes(1); expect(destroy).toHaveBeenCalledTimes(1);
expect(cancelFrame).toHaveBeenCalled(); expect(cancelFrame).toHaveBeenCalled();
}); });
it('does not start the canvas idle fallback when source idle motions exist', async () => {
vi.stubGlobal('innerWidth', 1280);
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
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;
});
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
const element = Node.prototype.appendChild.call(document.body, node);
if (element instanceof HTMLScriptElement) {
window.KtPioLive2D = {
mount: vi.fn(async () => ({ destroy: vi.fn() })),
};
element.onload?.(new Event('load'));
}
return element;
});
const wrapper = mount(BlogLive2D);
await flushPromises();
const canvas = wrapper.find('canvas#live2d');
expect(canvas.exists()).toBe(true);
const canvasElement = canvas.element as HTMLCanvasElement;
vi.spyOn(canvasElement, 'getBoundingClientRect').mockReturnValue({
bottom: 420,
height: 320,
left: 100,
right: 320,
toJSON: () => ({}),
top: 100,
width: 220,
x: 100,
y: 100,
} as DOMRect);
expect(canvasElement.style.transform).toBe('');
expect(frames).toHaveLength(0);
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }));
expect(canvasElement.style.transform).toBe('');
canvasElement.dispatchEvent(new MouseEvent('pointermove', { clientX: 210, clientY: 260 }));
expect(canvasElement.style.transform).toContain('translate3d');
canvasElement.dispatchEvent(new MouseEvent('pointerleave'));
expect(canvasElement.style.transform).toBe('');
wrapper.unmount();
});
}); });
describe('createBlogLive2DIdleAnimator', () => { describe('createBlogLive2DIdleAnimator', () => {

View File

@ -39,7 +39,9 @@ export default defineComponent({
return; return;
} }
runtimeHandle = mountedHandle; runtimeHandle = mountedHandle;
idleAnimatorHandle = createBlogLive2DIdleAnimator(canvasRef.value); idleAnimatorHandle = createBlogLive2DIdleAnimator(canvasRef.value, {
enableIdleMotion: !manifest.motionGroups?.Idle,
});
} catch (error: unknown) { } catch (error: unknown) {
console.warn('[KT Blog] Pio Live2D unavailable.', error); console.warn('[KT Blog] Pio Live2D unavailable.', error);
} }

View File

@ -1,12 +1,38 @@
export interface BlogLive2DManifest { export interface BlogLive2DManifest {
assets?: {
shaderBase?: string;
sourceTextureBase?: string;
textureBase?: string;
};
character: 'pio'; character: 'pio';
directoryStandard?: {
catalog: string;
opsRoot: string;
publicRoot: string;
version: string;
};
desktopOnly: boolean; desktopOnly: boolean;
fallback: null; fallback: null;
integrity?: { integrity?: {
manifestSha256?: string; manifestSha256?: string;
mocSha256?: string;
model3Sha256?: string;
motionValidationSha256?: string;
runtimeSha256?: string;
sourceMotionExportSha256?: string;
sourceTextureManifestSha256?: string;
}; };
model3: string; model3: string;
motionGroups?: Record<string, number>;
runtimeRig?: {
type: 'moc3-model-motion';
validation: string;
};
runtimeScript: string; runtimeScript: string;
sourceTextures?: {
count: number;
manifest: string;
};
version: string; version: string;
} }

View File

@ -64,6 +64,11 @@ export interface BlogLive2DIdleAnimatorHandle {
destroy: () => void; destroy: () => void;
} }
export interface BlogLive2DIdleAnimatorOptions {
enableIdleMotion?: boolean;
enablePointerParallax?: boolean;
}
/** /**
* @returns CSS custom property block generated from the Blog animation factory. * @returns CSS custom property block generated from the Blog animation factory.
*/ */
@ -116,14 +121,18 @@ export function createBlogFrameScheduler(callback: () => void): BlogFrameSchedul
} }
/** /**
* Adds a lightweight visual idle loop around the self-hosted Pio canvas. * Adds lightweight fallback motion and pointer parallax around the self-hosted Pio canvas.
* The current reconstructed MOC has no shipped motion/physics files, so this
* wrapper-level motion keeps the character alive without claiming Cubism rigging.
* @param canvas Pio canvas owned by `BlogLive2D`; its inline transform is restored on destroy. * @param canvas Pio canvas owned by `BlogLive2D`; its inline transform is restored on destroy.
* @param options Flags that disable idle fallback once source model motions are available.
* @returns Lifecycle handle that must be destroyed with the runtime mount handle. * @returns Lifecycle handle that must be destroyed with the runtime mount handle.
*/ */
export function createBlogLive2DIdleAnimator(canvas: HTMLCanvasElement): BlogLive2DIdleAnimatorHandle { export function createBlogLive2DIdleAnimator(
canvas: HTMLCanvasElement,
options: BlogLive2DIdleAnimatorOptions = {},
): BlogLive2DIdleAnimatorHandle {
const initialTransform = canvas.style.transform; const initialTransform = canvas.style.transform;
const enableIdleMotion = options.enableIdleMotion ?? true;
const enablePointerParallax = options.enablePointerParallax ?? true;
let frameId = 0; let frameId = 0;
let disposed = false; let disposed = false;
let startedAt = 0; let startedAt = 0;
@ -131,27 +140,81 @@ export function createBlogLive2DIdleAnimator(canvas: HTMLCanvasElement): BlogLiv
let pointerTargetY = 0; let pointerTargetY = 0;
let pointerX = 0; let pointerX = 0;
let pointerY = 0; let pointerY = 0;
let pointerActive = false;
/**
* Restores the canvas transform to the value owned before this fallback animator.
*/
const restoreTransform = () => {
canvas.style.transform = initialTransform;
};
/** /**
* Tracks the visitor pointer as a small parallax target around the fixed Pio canvas. * Tracks the visitor pointer as a small parallax target around the fixed Pio canvas.
* @param event Pointer movement on the document viewport. * @param event Pointer movement on the active parallax surface.
*/ */
const onPointerMove = (event: PointerEvent) => { const onPointerMove = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const right = rect.left + rect.width;
const bottom = rect.top + rect.height;
const pointerInsideCanvas =
event.clientX >= rect.left && event.clientX <= right && event.clientY >= rect.top && event.clientY <= bottom;
if (!enableIdleMotion && !pointerInsideCanvas) {
return;
}
pointerActive = true;
const centerX = rect.left + rect.width / 2; const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2; const centerY = rect.top + rect.height / 2;
const normalizedX = Math.max(-1, Math.min(1, (event.clientX - centerX) / Math.max(rect.width, 1))); const normalizedX = Math.max(-1, Math.min(1, (event.clientX - centerX) / Math.max(rect.width, 1)));
const normalizedY = Math.max(-1, Math.min(1, (event.clientY - centerY) / Math.max(rect.height, 1))); const normalizedY = Math.max(-1, Math.min(1, (event.clientY - centerY) / Math.max(rect.height, 1)));
pointerTargetX = normalizedX * BLOG_LIVE2D_IDLE_MOTION.pointerMaxX; pointerTargetX = normalizedX * BLOG_LIVE2D_IDLE_MOTION.pointerMaxX;
pointerTargetY = normalizedY * BLOG_LIVE2D_IDLE_MOTION.pointerMaxY; pointerTargetY = normalizedY * BLOG_LIVE2D_IDLE_MOTION.pointerMaxY;
if (!enableIdleMotion) {
pointerX = pointerTargetX;
pointerY = pointerTargetY;
writeTransform(0);
}
}; };
/** /**
* Lets the idle loop ease Pio back to neutral when the pointer leaves the document. * Lets the idle loop ease Pio back to neutral when the pointer leaves the document.
*/ */
const onPointerLeave = () => { const onPointerLeave = () => {
pointerActive = false;
pointerTargetX = 0; pointerTargetX = 0;
pointerTargetY = 0; pointerTargetY = 0;
if (!enableIdleMotion) {
pointerX = 0;
pointerY = 0;
restoreTransform();
}
};
/**
* Writes the composed fallback transform for either idle frames or pointer-only updates.
* @param elapsedSeconds Elapsed time used by idle bob, breath, and sway terms.
*/
const writeTransform = (elapsedSeconds: number) => {
if (!enableIdleMotion && !pointerActive) {
restoreTransform();
return;
}
const bob = enableIdleMotion ? Math.sin(elapsedSeconds * 1.65) * BLOG_LIVE2D_IDLE_MOTION.bobPx : 0;
const scale = enableIdleMotion ? 1 + Math.sin(elapsedSeconds * 2.1) * BLOG_LIVE2D_IDLE_MOTION.breathScale : 1;
const sway = enableIdleMotion ? Math.sin(elapsedSeconds * 0.95) * BLOG_LIVE2D_IDLE_MOTION.swayDeg : 0;
const rotate =
sway + (pointerX / BLOG_LIVE2D_IDLE_MOTION.pointerMaxX) * BLOG_LIVE2D_IDLE_MOTION.pointerMaxRotateDeg;
canvas.style.transform = [
initialTransform,
`translate3d(${(pointerX * 0.45).toFixed(2)}px, ${(bob + pointerY * 0.35).toFixed(2)}px, 0)`,
`rotate(${rotate.toFixed(3)}deg)`,
`scale(${scale.toFixed(4)})`,
]
.filter(Boolean)
.join(' ');
}; };
/** /**
@ -169,34 +232,37 @@ export function createBlogLive2DIdleAnimator(canvas: HTMLCanvasElement): BlogLiv
const elapsedSeconds = (timestamp - startedAt) / 1000; const elapsedSeconds = (timestamp - startedAt) / 1000;
pointerX += (pointerTargetX - pointerX) * BLOG_LIVE2D_IDLE_MOTION.pointerSmoothing; pointerX += (pointerTargetX - pointerX) * BLOG_LIVE2D_IDLE_MOTION.pointerSmoothing;
pointerY += (pointerTargetY - pointerY) * BLOG_LIVE2D_IDLE_MOTION.pointerSmoothing; pointerY += (pointerTargetY - pointerY) * BLOG_LIVE2D_IDLE_MOTION.pointerSmoothing;
const bob = Math.sin(elapsedSeconds * 1.65) * BLOG_LIVE2D_IDLE_MOTION.bobPx; writeTransform(elapsedSeconds);
const scale = 1 + Math.sin(elapsedSeconds * 2.1) * BLOG_LIVE2D_IDLE_MOTION.breathScale;
const rotate =
Math.sin(elapsedSeconds * 0.95) * BLOG_LIVE2D_IDLE_MOTION.swayDeg
+ (pointerX / BLOG_LIVE2D_IDLE_MOTION.pointerMaxX) * BLOG_LIVE2D_IDLE_MOTION.pointerMaxRotateDeg;
canvas.style.transform = [
initialTransform,
`translate3d(${(pointerX * 0.45).toFixed(2)}px, ${(bob + pointerY * 0.35).toFixed(2)}px, 0)`,
`rotate(${rotate.toFixed(3)}deg)`,
`scale(${scale.toFixed(4)})`,
]
.filter(Boolean)
.join(' ');
frameId = requestBlogFrame(tick); frameId = requestBlogFrame(tick);
}; };
if (enablePointerParallax) {
if (enableIdleMotion) {
window.addEventListener('pointermove', onPointerMove, { passive: true }); window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('pointerleave', onPointerLeave); window.addEventListener('pointerleave', onPointerLeave);
} else {
canvas.addEventListener('pointermove', onPointerMove, { passive: true });
canvas.addEventListener('pointerleave', onPointerLeave);
}
}
if (enableIdleMotion) {
frameId = requestBlogFrame(tick); frameId = requestBlogFrame(tick);
}
return { return {
destroy: () => { destroy: () => {
disposed = true; disposed = true;
cancelBlogFrame(frameId); cancelBlogFrame(frameId);
if (enablePointerParallax) {
if (enableIdleMotion) {
window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerleave', onPointerLeave); window.removeEventListener('pointerleave', onPointerLeave);
canvas.style.transform = initialTransform; } else {
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerleave', onPointerLeave);
}
}
restoreTransform();
}, },
}; };
} }