diff --git a/e2e/live2d-wordpress-widget.spec.ts b/e2e/live2d-wordpress-widget.spec.ts index ba92482..f30cfbd 100644 --- a/e2e/live2d-wordpress-widget.spec.ts +++ b/e2e/live2d-wordpress-widget.spec.ts @@ -108,6 +108,38 @@ async function fulfillLocalBlogImageAsset(route: Route) { }) } +/** + * Preserves WebGL drawing-buffer pixels so the E2E can inspect a completed rendered frame. + */ +function installPreservedWebGLDrawingBuffer(): void { + type CanvasContextGetter = ( + this: HTMLCanvasElement, + contextId: string, + options?: Record, + ) => unknown + const originalGetContext = HTMLCanvasElement.prototype.getContext as unknown as CanvasContextGetter + const canvasPrototype = HTMLCanvasElement.prototype as unknown as { + getContext: CanvasContextGetter + } + + /** + * Delegates canvas context creation while enabling pixel retention only for WebGL contexts. + * @param contextId Browser canvas context identifier. + * @param options Caller-provided context attributes. + * @returns Canvas rendering context returned by the browser. + */ + canvasPrototype.getContext = function getContextWithPreservedBuffer( + contextId: string, + options?: Record, + ): unknown { + const contextOptions = + contextId === 'webgl' || contextId === 'experimental-webgl' + ? { ...options, preserveDrawingBuffer: true } + : options + return originalGetContext.call(this, contextId, contextOptions) + } +} + /** * Routes the Pio/Tia MOC API to local release artifacts and records runtime console errors. * @param page Playwright page that will open Blog Web. @@ -121,6 +153,7 @@ async function prepareLive2DPage( scriptRequests: string[] = [], assetRequests: string[] = [], ) { + await page.addInitScript(installPreservedWebGLDrawingBuffer) await page.route('https://s3.kwitsukasa.top/images/*', fulfillLocalBlogImageAsset) await page.route('**/api/blog/live2d/*/moc/**', fulfillLocalMocAsset) page.on('request', (request) => { @@ -173,6 +206,18 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) { await expect(widget).toBeVisible() await expect(canvas).toHaveAttribute('width', '280') await expect(canvas).toHaveAttribute('height', '250') + await expect + .poll(() => page.evaluate(() => localStorage.getItem('kt-blog-live2d:model'))) + .toBe('pio') + await expect + .poll(() => + page.evaluate( + () => + document.querySelector('canvas#live2d')?.toDataURL('image/png') + .length ?? 0, + ), + ) + .toBeGreaterThan(5000) const widgetBox = await widget.boundingBox() expect(widgetBox).not.toBeNull() expect(Math.round(widgetBox?.x ?? -1)).toBe(0) @@ -191,6 +236,9 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) { await expect(modelPicker).toContainText('选择看板娘') await modelPicker.locator('.kt-blog__live2d-picker-option', { hasText: 'Tia' }).click() await expect(modelPicker).toHaveCount(0) + await expect + .poll(() => page.evaluate(() => localStorage.getItem('kt-blog-live2d:model'))) + .toBe('tia') await clickLive2DToolbarIcon(page, '.fui-eye') const texturePicker = page.locator('.kt-blog__modal-host--open.kt-blog__live2d-picker-modal') await expect(texturePicker).toContainText('选择服装') @@ -204,7 +252,7 @@ async function verifyDesktopWidgetParity({ page }: { page: Page }) { .length ?? 0, ), ) - .toBeGreaterThan(1000) + .toBeGreaterThan(5000) await clickLive2DToolbarIcon(page, '.fui-photo') await expect(page.locator('.waifu-tips')).toContainText('保存') await clickLive2DToolbarIcon(page, '.fui-info-circle') diff --git a/src/__tests__/live2d/Cubism2ModelProjection.spec.ts b/src/__tests__/live2d/Cubism2ModelProjection.spec.ts new file mode 100644 index 0000000..b3f8fb2 --- /dev/null +++ b/src/__tests__/live2d/Cubism2ModelProjection.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' + +import { configureCubism2ModelProjection } from '../../components/blog/live2d/runtime/cubism2ModelProjection' + +describe('Cubism2 model projection', () => { + it('applies the source L2D model and canvas projection matrix before drawing', () => { + const setMatrix = vi.fn() + + configureCubism2ModelProjection( + { + getCanvasHeight: () => 2, + getCanvasWidth: () => 4, + setMatrix, + }, + { + height: 250, + width: 280, + }, + { + center_x: 0, + center_y: -0.05, + width: 2, + }, + ) + + expect(setMatrix).toHaveBeenCalledOnce() + const matrix = setMatrix.mock.calls[0]?.[0] as Float32Array + expect(matrix).toBeInstanceOf(Float32Array) + expect(matrix[0]).toBeCloseTo(0.5) + expect(matrix[5]).toBeCloseTo(-0.56) + expect(matrix[12]).toBeCloseTo(-1) + expect(matrix[13]).toBeCloseTo(0.504) + expect(matrix[10]).toBe(1) + expect(matrix[15]).toBe(1) + }) + + it('rejects invalid model or canvas dimensions instead of drawing a transparent frame', () => { + const model = { + getCanvasHeight: () => 2, + getCanvasWidth: () => 0, + setMatrix: vi.fn(), + } + + expect(() => configureCubism2ModelProjection(model, { height: 250, width: 280 })).toThrow( + 'Cubism2 model and canvas dimensions must be positive.', + ) + }) +}) diff --git a/src/components/blog/live2d/runtime/cubism2ModelProjection.ts b/src/components/blog/live2d/runtime/cubism2ModelProjection.ts new file mode 100644 index 0000000..3e39d27 --- /dev/null +++ b/src/components/blog/live2d/runtime/cubism2ModelProjection.ts @@ -0,0 +1,106 @@ +import type { Live2DCoreModel } from './live2dRuntimeTypes'; + +type Cubism2ProjectionModel = Pick; +type Cubism2ProjectionCanvas = Pick; + +/** + * Applies the original Cubism2 `projection × view × modelMatrix` transform to a loaded model. + * @param model Loaded Cubism2 model that owns the WebGL draw matrix. + * @param canvas Runtime canvas whose aspect ratio defines the projection scale. + * @param layout Optional `index.json` layout fields applied in the original source order. + */ +export function configureCubism2ModelProjection( + model: Cubism2ProjectionModel, + canvas: Cubism2ProjectionCanvas, + layout?: Record, +): void { + if (typeof model.setMatrix !== 'function') { + throw new Error('Cubism2 model does not expose setMatrix().'); + } + model.setMatrix( + createCubism2ModelProjectionMatrix( + model.getCanvasWidth(), + model.getCanvasHeight(), + canvas.width, + canvas.height, + layout, + ), + ); +} + +/** + * Reconstructs the source L2DModelMatrix, layout overrides, and canvas projection as one matrix. + * @param modelWidth Cubism2 logical model canvas width. + * @param modelHeight Cubism2 logical model canvas height. + * @param canvasWidth WebGL drawing-buffer width. + * @param canvasHeight WebGL drawing-buffer height. + * @param layout Optional model layout values from `index.json`. + * @returns Column-major matrix passed directly to `Live2DModelWebGL.setMatrix()`. + */ +function createCubism2ModelProjectionMatrix( + modelWidth: number, + modelHeight: number, + canvasWidth: number, + canvasHeight: number, + layout?: Record, +): Float32Array { + if (![modelWidth, modelHeight, canvasWidth, canvasHeight].every((value) => Number.isFinite(value) && value > 0)) { + throw new Error('Cubism2 model and canvas dimensions must be positive.'); + } + + let scaleX = 2 / modelWidth; + let scaleY = -scaleX; + let translateX = -1; + let translateY = -(modelHeight * scaleY) / 2; + + const width = readLayoutNumber(layout, 'width'); + if (width !== undefined) { + scaleX = width / modelWidth; + scaleY = -scaleX; + } + const height = readLayoutNumber(layout, 'height'); + if (height !== undefined) { + scaleX = height / modelHeight; + scaleY = -scaleX; + } + translateX = readLayoutNumber(layout, 'x') ?? translateX; + translateY = readLayoutNumber(layout, 'y') ?? translateY; + + const centerX = readLayoutNumber(layout, 'center_x'); + if (centerX !== undefined) { + translateX = centerX - (modelWidth * scaleX) / 2; + } + const centerY = readLayoutNumber(layout, 'center_y'); + if (centerY !== undefined) { + translateY = centerY - (modelHeight * scaleY) / 2; + } + translateY = readLayoutNumber(layout, 'top') ?? translateY; + const bottom = readLayoutNumber(layout, 'bottom'); + if (bottom !== undefined) { + translateY = bottom - modelHeight * scaleY; + } + translateX = readLayoutNumber(layout, 'left') ?? translateX; + const right = readLayoutNumber(layout, 'right'); + if (right !== undefined) { + translateX = right - modelWidth * scaleX; + } + + const projectionScaleY = canvasWidth / canvasHeight; + return new Float32Array([ + scaleX, 0, 0, 0, + 0, scaleY * projectionScaleY, 0, 0, + 0, 0, 1, 0, + translateX, translateY * projectionScaleY, 0, 1, + ]); +} + +/** + * Reads one finite numeric layout value without coercing malformed external JSON. + * @param layout Optional raw layout map. + * @param key Cubism2 layout key read in source order. + * @returns Finite numeric value, or undefined when absent or invalid. + */ +function readLayoutNumber(layout: Record | undefined, key: string): number | undefined { + const value = layout?.[key]; + return Number.isFinite(value) ? value : undefined; +} diff --git a/src/components/blog/live2d/runtime/webglLive2DRenderer.ts b/src/components/blog/live2d/runtime/webglLive2DRenderer.ts index 7aa519d..1ed57af 100644 --- a/src/components/blog/live2d/runtime/webglLive2DRenderer.ts +++ b/src/components/blog/live2d/runtime/webglLive2DRenderer.ts @@ -1,4 +1,5 @@ import { loadCubism2Core } from './cubism2CoreLoader'; +import { configureCubism2ModelProjection } from './cubism2ModelProjection'; import type { Live2DCoreModel, Live2DRendererAdapter, Live2DResolvedState } from './live2dRuntimeTypes'; import { installCubism2WebGLTextureReleaseHook } from '../vendor/cubism2Core/compatibility/webglTextureRelease'; @@ -30,6 +31,7 @@ export function createWebGLLive2DRenderer(canvas: HTMLCanvasElement): Live2DRend const context = await resolveContext(); const modelBuffer = await fetchArrayBuffer(resolveAssetUrl(state.settings.baseUrl, state.settings.model)); const nextModel = window.Live2DModelWebGL!.loadModel(modelBuffer); + configureCubism2ModelProjection(nextModel, canvas, state.settings.layout); const nextTexture = await loadTexture( context, nextModel,