feat: 接入Pio官方Live2D清单加载
This commit is contained in:
parent
b320a553c8
commit
37feb7a707
@ -42,6 +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。
|
||||||
- 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>`。
|
||||||
|
|
||||||
|
|||||||
194
src/__tests__/BlogLive2D.spec.ts
Normal file
194
src/__tests__/BlogLive2D.spec.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { fetchBlogLive2DManifest } from '@/api/live2dManifest';
|
||||||
|
import BlogLive2D from '@/components/blog/BlogLive2D';
|
||||||
|
import { mountOfficialPioRuntime } from '@/components/blog/live2d/officialRuntimeBridge';
|
||||||
|
|
||||||
|
const pioManifest = {
|
||||||
|
character: 'pio',
|
||||||
|
desktopOnly: true,
|
||||||
|
fallback: null,
|
||||||
|
model3: '/api/blog/live2d/pio/v1/pio.model3.json',
|
||||||
|
runtimeScript: '/api/blog/live2d/pio/v1/pio-runtime.js',
|
||||||
|
version: 'v1',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe('fetchBlogLive2DManifest', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts only the Pio manifest without fallback model chains', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const manifest = await fetchBlogLive2DManifest('/manifest.json');
|
||||||
|
|
||||||
|
expect(manifest.character).toBe('pio');
|
||||||
|
expect(manifest.fallback).toBeNull();
|
||||||
|
expect(manifest.model3).toBe('/api/blog/live2d/pio/v1/pio.model3.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-Pio or fallback manifests before runtime loading', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
...pioManifest,
|
||||||
|
character: 'haru',
|
||||||
|
fallback: 'https://cdn.example.com/model.json',
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fetchBlogLive2DManifest('/manifest.json')).rejects.toThrow(
|
||||||
|
'Only Pio Live2D manifest is allowed.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed manifest payloads before runtime loading', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => new Response(JSON.stringify(null), { status: 200 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fetchBlogLive2DManifest('/manifest.json')).rejects.toThrow(
|
||||||
|
'Only Pio Live2D manifest is allowed.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountOfficialPioRuntime', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
delete window.KtPioLive2D;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads official runtime script once and mounts with the manifest model URL', async () => {
|
||||||
|
const destroy = vi.fn();
|
||||||
|
const runtime = {
|
||||||
|
mount: vi.fn(async () => ({ destroy })),
|
||||||
|
};
|
||||||
|
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||||
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||||
|
const element = originalAppendChild(node);
|
||||||
|
if (element instanceof HTMLScriptElement) {
|
||||||
|
window.KtPioLive2D = runtime;
|
||||||
|
element.onload?.(new Event('load'));
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
await mountOfficialPioRuntime(canvas, pioManifest);
|
||||||
|
await mountOfficialPioRuntime(canvas, pioManifest);
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('script#kt-blog-pio-live2d-runtime')).toHaveLength(1);
|
||||||
|
expect(runtime.mount).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runtime.mount).toHaveBeenCalledWith({
|
||||||
|
canvas,
|
||||||
|
model3: '/api/blog/live2d/pio/v1/pio.model3.json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces concurrent runtime script loads before mounting', async () => {
|
||||||
|
const runtime = {
|
||||||
|
mount: vi.fn(async () => ({ destroy: vi.fn() })),
|
||||||
|
};
|
||||||
|
const runtimeScripts: HTMLScriptElement[] = [];
|
||||||
|
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||||
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||||
|
const element = originalAppendChild(node);
|
||||||
|
if (element instanceof HTMLScriptElement) {
|
||||||
|
runtimeScripts.push(element);
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstCanvas = document.createElement('canvas');
|
||||||
|
const secondCanvas = document.createElement('canvas');
|
||||||
|
const firstMount = mountOfficialPioRuntime(firstCanvas, pioManifest);
|
||||||
|
const secondMount = mountOfficialPioRuntime(secondCanvas, pioManifest);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('script#kt-blog-pio-live2d-runtime')).toHaveLength(1);
|
||||||
|
|
||||||
|
window.KtPioLive2D = runtime;
|
||||||
|
expect(runtimeScripts).toHaveLength(1);
|
||||||
|
runtimeScripts[0]?.onload?.(new Event('load'));
|
||||||
|
|
||||||
|
await Promise.all([firstMount, secondMount]);
|
||||||
|
|
||||||
|
expect(runtime.mount).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runtime.mount).toHaveBeenNthCalledWith(1, {
|
||||||
|
canvas: firstCanvas,
|
||||||
|
model3: '/api/blog/live2d/pio/v1/pio.model3.json',
|
||||||
|
});
|
||||||
|
expect(runtime.mount).toHaveBeenNthCalledWith(2, {
|
||||||
|
canvas: secondCanvas,
|
||||||
|
model3: '/api/blog/live2d/pio/v1/pio.model3.json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BlogLive2D', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch the manifest below the desktop viewport', async () => {
|
||||||
|
vi.stubGlobal('innerWidth', 1199);
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
mount(BlogLive2D);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(document.querySelector('#live2d')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts Pio runtime on desktop and destroys it when unmounted', async () => {
|
||||||
|
vi.stubGlobal('innerWidth', 1280);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })),
|
||||||
|
);
|
||||||
|
const destroy = vi.fn();
|
||||||
|
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 })),
|
||||||
|
};
|
||||||
|
element.onload?.(new Event('load'));
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(BlogLive2D);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const canvas = wrapper.find('canvas#live2d');
|
||||||
|
expect(canvas.exists()).toBe(true);
|
||||||
|
expect(window.KtPioLive2D?.mount).toHaveBeenCalledWith({
|
||||||
|
canvas: canvas.element,
|
||||||
|
model3: '/api/blog/live2d/pio/v1/pio.model3.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
|
||||||
|
expect(destroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
src/api/live2dManifest.ts
Normal file
26
src/api/live2dManifest.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { BlogLive2DManifest } from '@/components/blog/live2d/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the Pio-only Live2D manifest used by the official runtime bridge.
|
||||||
|
* @param manifestUrl Runtime manifest URL configured for the current Blog deployment.
|
||||||
|
* @returns Validated Pio manifest consumed by `BlogLive2D`.
|
||||||
|
*/
|
||||||
|
export async function fetchBlogLive2DManifest(manifestUrl: string): Promise<BlogLive2DManifest> {
|
||||||
|
const response = await fetch(manifestUrl, { credentials: 'omit' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Live2D manifest failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = (await response.json()) as Partial<BlogLive2DManifest> | null;
|
||||||
|
if (
|
||||||
|
!manifest ||
|
||||||
|
manifest.character !== 'pio' ||
|
||||||
|
manifest.fallback !== null ||
|
||||||
|
typeof manifest.model3 !== 'string' ||
|
||||||
|
typeof manifest.runtimeScript !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('Only Pio Live2D manifest is allowed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest as BlogLive2DManifest;
|
||||||
|
}
|
||||||
@ -1,208 +1,53 @@
|
|||||||
import { defineComponent, onBeforeUnmount, onMounted } from 'vue';
|
import { defineComponent, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import {
|
import { fetchBlogLive2DManifest } from '@/api/live2dManifest';
|
||||||
BLOG_ANIMATION_TIMING_MS,
|
import { BLOG_VIEWPORT_GEOMETRY } from '@/factories/blogAnimationFactory';
|
||||||
BLOG_VIEWPORT_GEOMETRY,
|
import { blogDomId } from '@/factories/blogDomFactory';
|
||||||
clearBlogDelay,
|
|
||||||
runAfterBlogDelay,
|
|
||||||
} from '@/factories/blogAnimationFactory';
|
|
||||||
import { BLOG_LIVE2D_SCRIPT_IDS, blogDomId, blogDomSelector, blogDomSelectorFromId } from '@/factories/blogDomFactory';
|
|
||||||
|
|
||||||
const LIVE2D_ASSET_BASE = 'https://blog.kwitsukasa.top/wp-content/plugins/live-2d/assets';
|
import { mountOfficialPioRuntime } from './live2d/officialRuntimeBridge';
|
||||||
const LIVE2D_MODEL_API = 'https://live2d.fghrsh.net/api/';
|
import type { KtPioLive2DRuntimeHandle } from './live2d/types';
|
||||||
const LIVE2D_SCRIPT_IDS = [
|
|
||||||
[BLOG_LIVE2D_SCRIPT_IDS.live2dV1Core, `${LIVE2D_ASSET_BASE}/live2dv1.min.js?ver=2.2.1`, true],
|
|
||||||
[BLOG_LIVE2D_SCRIPT_IDS.live2dV2Core, `${LIVE2D_ASSET_BASE}/cubism-core/live2dcubismcore.min.js?ver=2.2.1`, false],
|
|
||||||
[BLOG_LIVE2D_SCRIPT_IDS.live2dV2Sdk, `${LIVE2D_ASSET_BASE}/live2dv2.min.js?ver=2.2.1`, true],
|
|
||||||
[BLOG_LIVE2D_SCRIPT_IDS.live2dWeb, `${LIVE2D_ASSET_BASE}/live2dwebsdk.min.js?ver=2.2.1`, true],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
declare global {
|
const LIVE2D_MANIFEST_URL = import.meta.env.VITE_BLOG_LIVE2D_MANIFEST_URL || '/api/blog/live2d/pio/v1/manifest.json';
|
||||||
interface Window {
|
|
||||||
initLive2dWeb?: () => void;
|
|
||||||
live2d_settings?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'BlogLive2D',
|
name: 'BlogLive2D',
|
||||||
|
/**
|
||||||
|
* Owns the Pio canvas lifecycle and defers all drawing to the official runtime bridge.
|
||||||
|
* @returns Render function that only creates the canvas on desktop viewports.
|
||||||
|
*/
|
||||||
setup() {
|
setup() {
|
||||||
let fallbackTimer: number | null = null;
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const isDesktop = window.innerWidth >= BLOG_VIEWPORT_GEOMETRY.live2dDesktopMinWidthPx;
|
||||||
|
let disposed = false;
|
||||||
|
let runtimeHandle: KtPioLive2DRuntimeHandle | null = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
if (window.innerWidth < BLOG_VIEWPORT_GEOMETRY.live2dDesktopMinWidthPx) {
|
if (!isDesktop || !canvasRef.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
installLive2d()
|
try {
|
||||||
.then(() => {
|
const manifest = await fetchBlogLive2DManifest(LIVE2D_MANIFEST_URL);
|
||||||
fallbackTimer = runAfterBlogDelay(ensureFallbackLive2dDom, BLOG_ANIMATION_TIMING_MS.live2dFallbackWarmup);
|
const mountedHandle = await mountOfficialPioRuntime(canvasRef.value, manifest);
|
||||||
})
|
if (disposed) {
|
||||||
.catch(() => {
|
mountedHandle.destroy();
|
||||||
ensureFallbackLive2dDom();
|
return;
|
||||||
});
|
}
|
||||||
|
runtimeHandle = mountedHandle;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.warn('[KT Blog] Pio Live2D unavailable.', error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearBlogDelay(fallbackTimer);
|
disposed = true;
|
||||||
document.querySelector(blogDomSelector('live2dFallback'))?.remove();
|
runtimeHandle?.destroy();
|
||||||
|
runtimeHandle = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => null;
|
return () =>
|
||||||
|
isDesktop ? (
|
||||||
|
<canvas id={blogDomId('live2dCanvas')} ref={canvasRef} class="kt-blog__live2d-canvas" />
|
||||||
|
) : null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads and initializes the same public Live2D Web plugin assets used by the live WordPress Argon page.
|
|
||||||
*
|
|
||||||
* The local Vue app owns only the mounting lifecycle; rendering remains delegated to the
|
|
||||||
* upstream Live2D scripts and the public model API so the visible widget is not a hand-drawn placeholder.
|
|
||||||
*/
|
|
||||||
async function installLive2d() {
|
|
||||||
installLive2dSettings();
|
|
||||||
appendLive2dStyle();
|
|
||||||
|
|
||||||
for (const [id, src, isModule] of LIVE2D_SCRIPT_IDS) {
|
|
||||||
await appendScriptOnce(id, src, isModule);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.initLive2dWeb?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors the live site's non-secret Live2D settings required for model selection and widget geometry.
|
|
||||||
*/
|
|
||||||
function installLive2dSettings() {
|
|
||||||
window.live2d_settings = {
|
|
||||||
currentPage: {
|
|
||||||
get_the_id: 61,
|
|
||||||
is_home: true,
|
|
||||||
is_single: false,
|
|
||||||
},
|
|
||||||
localPath: `${LIVE2D_ASSET_BASE.replace('/assets', '')}/model`,
|
|
||||||
settings: {
|
|
||||||
aboutPageUrl: '#',
|
|
||||||
aiProvider: 'live2dweb',
|
|
||||||
apiType: false,
|
|
||||||
canCloseLive2d: true,
|
|
||||||
canSwitchHitokoto: true,
|
|
||||||
canSwitchModel: true,
|
|
||||||
canSwitchTextures: true,
|
|
||||||
canTakeScreenshot: true,
|
|
||||||
canTurnToAboutPage: true,
|
|
||||||
canTurnToHomePage: true,
|
|
||||||
hitokotoAPI: 'lwl12.com',
|
|
||||||
homePageUrl: 'https://blog.kwitsukasa.top',
|
|
||||||
isBotButton: true,
|
|
||||||
live2dLayoutType: true,
|
|
||||||
modelAPI: LIVE2D_MODEL_API,
|
|
||||||
modelId: '1',
|
|
||||||
modelPoint: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
zoom: '1.0',
|
|
||||||
},
|
|
||||||
modelRandMode: 'rand',
|
|
||||||
modelStorage: true,
|
|
||||||
modelTexturesId: '53',
|
|
||||||
modelTexturesRandMode: 'switch',
|
|
||||||
protectV2: 'direct',
|
|
||||||
screenshotCaptureName: 'live2d.png',
|
|
||||||
showF12Message: true,
|
|
||||||
showF12OpenMsg: true,
|
|
||||||
showF12Status: true,
|
|
||||||
showHitokoto: true,
|
|
||||||
showToolMenu: true,
|
|
||||||
waifuDraggable: 'axis-x',
|
|
||||||
waifuDraggableRevert: true,
|
|
||||||
waifuEdgeSide: 'left',
|
|
||||||
waifuEdgeSize: 0,
|
|
||||||
waifuFontSize: 12,
|
|
||||||
waifuMinWidth: 768,
|
|
||||||
waifuSize: {
|
|
||||||
height: 250,
|
|
||||||
width: 280,
|
|
||||||
},
|
|
||||||
waifuTipsSize: {
|
|
||||||
height: 70,
|
|
||||||
width: 250,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
userInfo: {
|
|
||||||
certserialnumber: 0,
|
|
||||||
sign: '',
|
|
||||||
userName: '',
|
|
||||||
},
|
|
||||||
waifuTips: {
|
|
||||||
click_msg: ['是...是不小心碰到了吧'],
|
|
||||||
click_selector: '.waifu #live2d',
|
|
||||||
console_open_msg: ['哈哈,你打开了控制台,是想要看看我的秘密吗?'],
|
|
||||||
copy_message: ['转载要记得加上出处哦!'],
|
|
||||||
hidden_message: ['我们还能再见面的吧...?'],
|
|
||||||
hour_tips: [['default', '嗨~ 快来逗我玩吧!']],
|
|
||||||
load_rand_textures: ['我的新衣服好看嘛?'],
|
|
||||||
mouseover_msg: [],
|
|
||||||
referrer_message: [['none', '欢迎阅读<span style="{highlight}">『{title}』</span>']],
|
|
||||||
screenshot_message: ['照好了嘛,是不是很可爱呢?'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds the Live2D stylesheet once so the SDK-created DOM uses the same geometry as WordPress.
|
|
||||||
*/
|
|
||||||
function appendLive2dStyle() {
|
|
||||||
if (document.querySelector(blogDomSelector('live2dStyle'))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styleLink = document.createElement('link');
|
|
||||||
styleLink.id = blogDomId('live2dStyle');
|
|
||||||
styleLink.rel = 'stylesheet';
|
|
||||||
styleLink.href = `${LIVE2D_ASSET_BASE}/waifu.css?ver=2.2.1`;
|
|
||||||
document.head.appendChild(styleLink);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param id Stable script id used by the live WordPress plugin.
|
|
||||||
* @param src Public script URL to load once.
|
|
||||||
* @param isModule Whether the upstream script is loaded as an ES module on the live site.
|
|
||||||
*/
|
|
||||||
function appendScriptOnce(id: string, src: string, isModule: boolean) {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
if (document.querySelector(blogDomSelectorFromId(id))) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.id = id;
|
|
||||||
script.src = src;
|
|
||||||
if (isModule) {
|
|
||||||
script.type = 'module';
|
|
||||||
}
|
|
||||||
script.onload = () => resolve();
|
|
||||||
script.onerror = () => reject(new Error(`Live2D script failed: ${src}`));
|
|
||||||
document.body.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the same visible DOM contract as WordPress when the SDK is unavailable or still warming up.
|
|
||||||
*/
|
|
||||||
function ensureFallbackLive2dDom() {
|
|
||||||
if (document.querySelector(blogDomSelector('live2dCanvas'))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.id = blogDomId('live2dFallback');
|
|
||||||
root.className = 'waifu kt-blog__live2d-fallback';
|
|
||||||
root.innerHTML = `
|
|
||||||
<canvas id="${blogDomId('live2dCanvas')}" class="live2d" width="280" height="250"></canvas>
|
|
||||||
<input type="text" id="${blogDomId('live2dChatText')}" />
|
|
||||||
<button class="wp-element-button" id="${blogDomId('live2dSend')}">发送</button>
|
|
||||||
<i id="${blogDomId('live2dSendClose')}" class="fa-solid fa-circle-xmark"></i>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(root);
|
|
||||||
}
|
|
||||||
|
|||||||
71
src/components/blog/live2d/officialRuntimeBridge.ts
Normal file
71
src/components/blog/live2d/officialRuntimeBridge.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import type {
|
||||||
|
BlogLive2DManifest,
|
||||||
|
KtPioLive2DRuntime,
|
||||||
|
KtPioLive2DRuntimeHandle,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const RUNTIME_SCRIPT_ID = 'kt-blog-pio-live2d-runtime';
|
||||||
|
let runtimeScriptPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
KtPioLive2D?: KtPioLive2DRuntime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the generated official Cubism runtime script and mounts the Pio model.
|
||||||
|
* @param canvas Canvas element owned by the Vue component.
|
||||||
|
* @param manifest Validated Pio manifest with runtime script and model URL.
|
||||||
|
* @returns Runtime handle used by the component teardown path.
|
||||||
|
*/
|
||||||
|
export async function mountOfficialPioRuntime(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
manifest: BlogLive2DManifest,
|
||||||
|
): Promise<KtPioLive2DRuntimeHandle> {
|
||||||
|
await appendRuntimeScript(manifest.runtimeScript);
|
||||||
|
if (!window.KtPioLive2D) {
|
||||||
|
throw new Error('KtPioLive2D runtime was not registered.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.KtPioLive2D.mount({ canvas, model3: manifest.model3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the official Cubism runtime wrapper only once per document.
|
||||||
|
* @param src Versioned runtime script URL generated from the self-hosted Pio package.
|
||||||
|
* @returns Promise that resolves after the script is available to register `window.KtPioLive2D`.
|
||||||
|
*/
|
||||||
|
async function appendRuntimeScript(src: string): Promise<void> {
|
||||||
|
if (runtimeScriptPromise) {
|
||||||
|
return runtimeScriptPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.getElementById(RUNTIME_SCRIPT_ID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolveLoad: () => void = () => undefined;
|
||||||
|
let rejectLoad: (error: Error) => void = () => undefined;
|
||||||
|
runtimeScriptPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
resolveLoad = resolve;
|
||||||
|
rejectLoad = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.id = RUNTIME_SCRIPT_ID;
|
||||||
|
script.src = src;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => {
|
||||||
|
runtimeScriptPromise = null;
|
||||||
|
resolveLoad();
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
runtimeScriptPromise = null;
|
||||||
|
script.remove();
|
||||||
|
rejectLoad(new Error(`Live2D runtime failed: ${src}`));
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
|
||||||
|
return runtimeScriptPromise;
|
||||||
|
}
|
||||||
27
src/components/blog/live2d/types.ts
Normal file
27
src/components/blog/live2d/types.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export interface BlogLive2DManifest {
|
||||||
|
character: 'pio';
|
||||||
|
desktopOnly: boolean;
|
||||||
|
fallback: null;
|
||||||
|
integrity?: {
|
||||||
|
manifestSha256?: string;
|
||||||
|
};
|
||||||
|
model3: string;
|
||||||
|
runtimeScript: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KtPioLive2DRuntimeHandle {
|
||||||
|
/**
|
||||||
|
* Releases canvas bindings, animation frames, and WebGL/runtime resources owned by the mounted Pio model.
|
||||||
|
*/
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KtPioLive2DRuntime {
|
||||||
|
/**
|
||||||
|
* Mounts the self-hosted Pio model into the canvas controlled by `BlogLive2D`.
|
||||||
|
* @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>;
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@ export const BLOG_ANIMATION_TIMING_MS = {
|
|||||||
catalogManualLock: 400,
|
catalogManualLock: 400,
|
||||||
catalogNormal: 400,
|
catalogNormal: 400,
|
||||||
floatSideUnload: 300,
|
floatSideUnload: 300,
|
||||||
live2dFallbackWarmup: 1500,
|
|
||||||
scrollToTop: 800,
|
scrollToTop: 800,
|
||||||
toastVisible: 5000,
|
toastVisible: 5000,
|
||||||
} as const;
|
} as const;
|
||||||
@ -27,7 +26,7 @@ export const BLOG_SCROLL_GEOMETRY = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const BLOG_VIEWPORT_GEOMETRY = {
|
export const BLOG_VIEWPORT_GEOMETRY = {
|
||||||
live2dDesktopMinWidthPx: 768,
|
live2dDesktopMinWidthPx: 1200,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const BLOG_MOTION_CSS_VARS = {
|
export const BLOG_MOTION_CSS_VARS = {
|
||||||
|
|||||||
@ -29,11 +29,6 @@ export const BLOG_DOM_IDS = {
|
|||||||
leftbarSearchContainer: 'leftbar_search_container',
|
leftbarSearchContainer: 'leftbar_search_container',
|
||||||
leftbarSearchInput: 'leftbar_search_input',
|
leftbarSearchInput: 'leftbar_search_input',
|
||||||
live2dCanvas: 'live2d',
|
live2dCanvas: 'live2d',
|
||||||
live2dChatText: 'live2dChatText',
|
|
||||||
live2dFallback: 'kt-blog-live2d-fallback',
|
|
||||||
live2dSend: 'live2dSend',
|
|
||||||
live2dSendClose: 'live2dSendClose',
|
|
||||||
live2dStyle: 'waifu_css-css',
|
|
||||||
main: 'main',
|
main: 'main',
|
||||||
openSidebar: 'open_sidebar',
|
openSidebar: 'open_sidebar',
|
||||||
postArticleTitle: 'post_article_title',
|
postArticleTitle: 'post_article_title',
|
||||||
@ -63,13 +58,6 @@ export const BLOG_META_NAMES = {
|
|||||||
themeColorRgb: 'theme-color-rgb',
|
themeColorRgb: 'theme-color-rgb',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const BLOG_LIVE2D_SCRIPT_IDS = {
|
|
||||||
live2dV1Core: 'live2dv1core-js',
|
|
||||||
live2dV2Core: 'live2dv2core-js',
|
|
||||||
live2dV2Sdk: 'live2dv2sdk-js',
|
|
||||||
live2dWeb: 'live2dweb-js',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type BlogDomIdKey = keyof typeof BLOG_DOM_IDS;
|
export type BlogDomIdKey = keyof typeof BLOG_DOM_IDS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -274,42 +274,22 @@ html.filter-darkness .kt-blog__primary::after {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__live2d-fallback {
|
&__live2d-canvas {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
right: 16px;
|
||||||
left: 0;
|
bottom: 76px;
|
||||||
z-index: 80;
|
z-index: 30;
|
||||||
width: 280px;
|
width: 220px;
|
||||||
height: 250px;
|
height: 320px;
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
#live2d {
|
|
||||||
width: 280px;
|
|
||||||
height: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#live2dChatText,
|
|
||||||
#live2dSend,
|
|
||||||
#live2dSendClose {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#live2dChatText {
|
}
|
||||||
left: 111px;
|
|
||||||
width: 6px;
|
|
||||||
height: 29px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#live2dSend {
|
@media (max-width: 1199px) {
|
||||||
left: 116px;
|
@include blog.block {
|
||||||
height: 29px;
|
&__live2d-canvas {
|
||||||
}
|
display: none;
|
||||||
|
|
||||||
#live2dSendClose {
|
|
||||||
left: 166px;
|
|
||||||
bottom: 7px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user