This commit is contained in:
2026-05-14 21:33:34 +08:00
193 changed files with 17051 additions and 1203 deletions

View File

@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBarkBattleDraft, publishBarkBattleWork } from './barkBattleCreationClient';
const requestJsonMock = vi.hoisted(() => vi.fn());
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('barkBattleCreationClient', () => {
afterEach(() => {
requestJsonMock.mockReset();
});
it('creates a lightweight draft through creation API', async () => {
requestJsonMock.mockResolvedValueOnce({ draftId: 'draft-1' });
await createBarkBattleDraft({
title: '周末狗狗杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'hard',
leaderboardEnabled: true,
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/bark-battle/drafts',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: '周末狗狗杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'hard',
leaderboardEnabled: true,
}),
}),
'创建汪汪声浪大作战草稿失败',
expect.objectContaining({ retry: expect.objectContaining({ retryUnsafeMethods: true }) }),
);
});
it('publishes a draft and returns stable work config', async () => {
requestJsonMock.mockResolvedValueOnce({ workId: 'work-1' });
await publishBarkBattleWork({ draftId: 'draft-1', workId: 'work-1' });
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/bark-battle/works/publish',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ draftId: 'draft-1', workId: 'work-1' }),
}),
'发布汪汪声浪大作战作品失败',
expect.objectContaining({ retry: expect.objectContaining({ retryUnsafeMethods: true }) }),
);
});
});

View File

@@ -0,0 +1,77 @@
import type {
BarkBattleDraftConfig,
BarkBattleDraftCreateRequest,
BarkBattlePublishedConfig,
BarkBattleWorkPublishRequest,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
const BARK_BATTLE_CREATION_API_BASE = '/api/creation/bark-battle';
const BARK_BATTLE_CREATION_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
export type BarkBattleCreationRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
export function createBarkBattleDraft(
payload: BarkBattleDraftCreateRequest,
options: BarkBattleCreationRequestOptions = {},
) {
return requestJson<BarkBattleDraftConfig>(
`${BARK_BATTLE_CREATION_API_BASE}/drafts`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'创建汪汪声浪大作战草稿失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function publishBarkBattleWork(
payload: BarkBattleWorkPublishRequest,
options: BarkBattleCreationRequestOptions = {},
) {
return requestJson<BarkBattlePublishedConfig>(
`${BARK_BATTLE_CREATION_API_BASE}/works/publish`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'发布汪汪声浪大作战作品失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export const barkBattleCreationClient = {
createDraft: createBarkBattleDraft,
publish: publishBarkBattleWork,
};

View File

@@ -0,0 +1,6 @@
export {
barkBattleCreationClient,
type BarkBattleCreationRequestOptions,
createBarkBattleDraft,
publishBarkBattleWork,
} from './barkBattleCreationClient';

View File

@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
finishBarkBattleRun,
getBarkBattleRuntimeConfig,
startBarkBattleRun,
} from './barkBattleRuntimeClient';
const requestJsonMock = vi.hoisted(() => vi.fn());
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('barkBattleRuntimeClient', () => {
afterEach(() => {
requestJsonMock.mockReset();
});
it('reads runtime config from stable work route', async () => {
requestJsonMock.mockResolvedValueOnce({ workId: 'work-1' });
await getBarkBattleRuntimeConfig('work/1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/bark-battle/works/work%2F1/config',
{ method: 'GET' },
'读取汪汪声浪大作战配置失败',
expect.objectContaining({ retry: expect.objectContaining({ maxRetries: 1 }) }),
);
});
it('starts a formal run with workId in body', async () => {
requestJsonMock.mockResolvedValueOnce({ runId: 'run-1' });
await startBarkBattleRun('work-1', { sourceRoute: '/play/work-1' });
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/bark-battle/works/work-1/runs',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceRoute: '/play/work-1', workId: 'work-1' }),
}),
'启动汪汪声浪大作战正式局失败',
expect.objectContaining({ retry: expect.objectContaining({ retryUnsafeMethods: true }) }),
);
});
it('finishes a run using derived metrics only', async () => {
requestJsonMock.mockResolvedValueOnce({ status: 'accepted' });
await finishBarkBattleRun('run-1', {
runId: 'run-1',
runToken: 'token-1',
workId: 'work-1',
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
difficultyPreset: 'normal',
clientStartedAt: '2026-05-13T00:00:00Z',
clientFinishedAt: '2026-05-13T00:00:30Z',
durationMs: 30000,
derivedMetrics: {
triggerCount: 12,
maxVolume: 0.82,
averageVolume: 0.36,
finalEnergy: 58,
comboMax: 4,
},
clientResult: 'player_win',
});
const [, init] = requestJsonMock.mock.calls[0];
expect(requestJsonMock.mock.calls[0][0]).toBe(
'/api/runtime/bark-battle/runs/run-1/finish',
);
expect(JSON.parse(init.body)).toEqual(
expect.objectContaining({
runId: 'run-1',
runToken: 'token-1',
derivedMetrics: expect.objectContaining({ finalEnergy: 58 }),
}),
);
expect(init.body).not.toContain('audio');
expect(init.body).not.toContain('waveform');
expect(init.body).not.toContain('pcm');
});
});

View File

@@ -0,0 +1,121 @@
import type {
BarkBattleFinishResponse,
BarkBattleRunFinishRequest,
BarkBattleRunStartRequest,
BarkBattleRunStartResponse,
BarkBattleRuntimeConfig,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
const BARK_BATTLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const BARK_BATTLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
export type BarkBattleRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
export function getBarkBattleRuntimeConfig(
workId: string,
options: BarkBattleRuntimeRequestOptions = {},
) {
return requestJson<BarkBattleRuntimeConfig>(
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/config`,
{ method: 'GET' },
'读取汪汪声浪大作战配置失败',
{
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function startBarkBattleRun(
workId: string,
payload: Partial<BarkBattleRunStartRequest> = {},
options: BarkBattleRuntimeRequestOptions = {},
) {
return requestJson<BarkBattleRunStartResponse>(
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/runs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
workId: payload.workId ?? workId,
}),
},
'启动汪汪声浪大作战正式局失败',
{
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function getBarkBattleRun(
runId: string,
options: BarkBattleRuntimeRequestOptions = {},
) {
return requestJson<unknown>(
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}`,
{ method: 'GET' },
'读取汪汪声浪大作战单局失败',
{
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function finishBarkBattleRun(
runId: string,
payload: BarkBattleRunFinishRequest,
options: BarkBattleRuntimeRequestOptions = {},
) {
return requestJson<BarkBattleFinishResponse>(
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}/finish`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
runId: payload.runId ?? runId,
}),
},
'提交汪汪声浪大作战成绩失败',
{
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}

View File

@@ -0,0 +1,7 @@
export {
type BarkBattleRuntimeRequestOptions,
finishBarkBattleRun,
getBarkBattleRun,
getBarkBattleRuntimeConfig,
startBarkBattleRun,
} from './barkBattleRuntimeClient';

View File

@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { requestJson } from '../apiClient';
import {
createBabyLoveDrawingMagicImage,
listLocalBabyLoveDrawings,
saveBabyLoveDrawing,
} from './babyDrawingClient';
vi.mock('../apiClient', () => ({
requestJson: vi.fn(),
}));
const requestJsonMock = vi.mocked(requestJson);
describe('babyDrawingClient', () => {
beforeEach(() => {
const store = new Map<string, string>();
vi.stubGlobal('window', {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
},
});
vi.stubGlobal('crypto', {
randomUUID: () => '11111111-2222-3333-4444-555555555555',
});
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T08:00:00.000Z'));
requestJsonMock.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test('saves original drawing only when magic image is absent', () => {
const response = saveBabyLoveDrawing({
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
strokeTrace: [],
});
expect(response.record).toMatchObject({
templateName: '宝贝爱画',
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
saveMode: 'original-only',
themeTags: ['寓教于乐', '宝贝爱画'],
});
expect(listLocalBabyLoveDrawings()).toHaveLength(1);
});
test('saves original and magic image together after magic generation', () => {
const response = saveBabyLoveDrawing({
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: 'data:image/png;base64,magic',
strokeTrace: [
{
strokeId: 'stroke-1',
tool: 'brush',
color: '#ef4444',
points: [{ x: 0.1, y: 0.2, t: 1 }],
},
],
});
expect(response.record.saveMode).toBe('original-and-magic');
expect(response.record.magicImageSrc).toBe('data:image/png;base64,magic');
expect(listLocalBabyLoveDrawings()[0]?.strokeTrace).toHaveLength(1);
});
test('creates magic image through backend image-2 proxy', async () => {
requestJsonMock.mockResolvedValueOnce({
magicImageSrc: 'data:image/png;base64,magic',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '绘本风格',
});
const payload = {
originalImageSrc: 'data:image/png;base64,original',
strokeTrace: [],
};
const response = await createBabyLoveDrawingMagicImage(payload);
expect(response.magicImageSrc).toContain('magic');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/edutainment/baby-love-drawing/magic',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(payload),
}),
'生成宝贝爱画魔法图片失败',
expect.objectContaining({
timeoutMs: 180000,
}),
);
});
});

View File

@@ -0,0 +1,128 @@
import type {
BabyLoveDrawingRecord,
CreateBabyLoveDrawingMagicRequest,
CreateBabyLoveDrawingMagicResponse,
SaveBabyLoveDrawingRequest,
SaveBabyLoveDrawingResponse,
} from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import {
BABY_LOVE_DRAWING_EDUTAINMENT_TAG,
BABY_LOVE_DRAWING_TEMPLATE_ID,
BABY_LOVE_DRAWING_TEMPLATE_NAME,
normalizeBabyLoveDrawingTags,
} from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const STORAGE_KEY = 'genarrative.edutainmentBabyDrawing.localDrawings.v1';
const BABY_LOVE_DRAWING_MAGIC_API =
'/api/creation/edutainment/baby-love-drawing/magic';
const BABY_LOVE_DRAWING_MAGIC_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 800,
maxDelayMs: 2000,
retryUnsafeMethods: true,
};
type LocalDrawingStore = Record<string, BabyLoveDrawingRecord>;
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
function readLocalDrawingStore(): LocalDrawingStore {
if (!canUseLocalStorage()) {
return {};
}
try {
const rawValue = window.localStorage.getItem(STORAGE_KEY);
if (!rawValue) {
return {};
}
const parsed = JSON.parse(rawValue) as LocalDrawingStore;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeLocalDrawingStore(store: LocalDrawingStore) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
}
function createLocalId(prefix: string) {
const randomPart =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID().replace(/-/gu, '')
: Math.random().toString(36).slice(2);
return `${prefix}-${Date.now().toString(36)}-${randomPart.slice(0, 12)}`;
}
function saveRecordToLocalStore(record: BabyLoveDrawingRecord) {
const store = readLocalDrawingStore();
store[record.drawingId] = record;
writeLocalDrawingStore(store);
}
export function listLocalBabyLoveDrawings() {
return Object.values(readLocalDrawingStore()).sort(
(left, right) =>
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
);
}
export function saveBabyLoveDrawing(
payload: SaveBabyLoveDrawingRequest,
): SaveBabyLoveDrawingResponse {
const now = new Date().toISOString();
const magicImageSrc = payload.magicImageSrc?.trim() || null;
const record: BabyLoveDrawingRecord = {
drawingId: createLocalId('baby-love-drawing'),
templateId: BABY_LOVE_DRAWING_TEMPLATE_ID,
templateName: BABY_LOVE_DRAWING_TEMPLATE_NAME,
originalImageSrc: payload.originalImageSrc,
magicImageSrc,
strokeTrace: payload.strokeTrace,
saveMode: magicImageSrc ? 'original-and-magic' : 'original-only',
themeTags: normalizeBabyLoveDrawingTags([
BABY_LOVE_DRAWING_EDUTAINMENT_TAG,
BABY_LOVE_DRAWING_TEMPLATE_NAME,
]),
createdAt: now,
updatedAt: now,
};
saveRecordToLocalStore(record);
return { record };
}
export async function createBabyLoveDrawingMagicImage(
payload: CreateBabyLoveDrawingMagicRequest,
) {
return requestJson<CreateBabyLoveDrawingMagicResponse>(
BABY_LOVE_DRAWING_MAGIC_API,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成宝贝爱画魔法图片失败',
{
retry: BABY_LOVE_DRAWING_MAGIC_RETRY,
timeoutMs: 180000,
},
);
}
export const babyDrawingClient = {
createMagicImage: createBabyLoveDrawingMagicImage,
listLocalDrawings: listLocalBabyLoveDrawings,
saveDrawing: saveBabyLoveDrawing,
};

View File

@@ -0,0 +1 @@
export * from './babyDrawingClient';

View File

@@ -2,13 +2,18 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
type BabyObjectMatchDraft,
hasBabyObjectMatchRequiredTag,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
__resetBabyObjectMatchLocalDraftStorageForTests,
BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS,
createBabyObjectMatchDraft,
deleteLocalBabyObjectMatchDraft,
hasBabyObjectMatchPlaceholderAssets,
listLocalBabyObjectMatchDrafts,
publishBabyObjectMatchWork,
regenerateBabyObjectMatchDraftAssets,
} from './babyObjectMatchClient';
describe('babyObjectMatchClient', () => {
@@ -25,10 +30,88 @@ describe('babyObjectMatchClient', () => {
});
afterEach(() => {
__resetBabyObjectMatchLocalDraftStorageForTests();
vi.unstubAllGlobals();
});
function stubSuccessfulAssetGeneration() {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
return new Response(
JSON.stringify({
assets: [
{
itemId: 'server-item-1',
itemName: '苹果',
imageSrc: 'data:image/png;base64,apple',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'prompt apple',
},
{
itemId: 'server-item-2',
itemName: '香蕉',
imageSrc: 'data:image/png;base64,banana',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'prompt banana',
},
],
visualPackage: {
themePrompt: '果园主题视觉包装',
assets: [
{
assetId: 'server-background',
assetKind: 'background',
imageSrc: 'data:image/png;base64,background',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'background prompt',
},
{
assetId: 'server-ui',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'ui prompt',
},
{
assetId: 'server-gift',
assetKind: 'gift-box',
imageSrc: 'data:image/png;base64,gift',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'gift prompt',
},
{
assetId: 'server-basket',
assetKind: 'basket',
imageSrc: 'data:image/png;base64,basket',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'basket prompt',
},
{
assetId: 'server-smoke',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'smoke prompt',
},
],
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}),
);
}
test('creates local demo draft with exact edutainment tag', async () => {
stubSuccessfulAssetGeneration();
vi.stubGlobal('crypto', {
randomUUID: () => '11111111-2222-3333-4444-555555555555',
});
@@ -42,7 +125,11 @@ describe('babyObjectMatchClient', () => {
expect(response.draft.itemNames).toEqual(['苹果', '香蕉']);
expect(response.draft.itemAssets).toHaveLength(2);
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
'placeholder',
'vector-engine-gpt-image-2',
);
expect(response.draft.visualPackage?.assets).toHaveLength(5);
expect(response.draft.visualPackage?.assets[0]?.generationProvider).toBe(
'vector-engine-gpt-image-2',
);
expect(response.draft.themeTags).toContain(
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
@@ -50,6 +137,69 @@ describe('babyObjectMatchClient', () => {
expect(hasBabyObjectMatchRequiredTag(response.draft.themeTags)).toBe(true);
});
test('uses backend generated transparent image assets and visual package when available', async () => {
stubSuccessfulAssetGeneration();
const response = await createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: '香蕉',
});
expect(fetch).toHaveBeenCalledWith(
'/api/creation/edutainment/baby-object-match/assets',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ itemNames: ['苹果', '香蕉'] }),
signal: expect.any(AbortSignal),
}),
);
expect(BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS).toBe(600_000);
expect(response.draft.itemAssets[0]).toMatchObject({
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/png;base64,apple',
generationProvider: 'vector-engine-gpt-image-2',
});
expect(response.draft.itemAssets[1]).toMatchObject({
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/png;base64,banana',
generationProvider: 'vector-engine-gpt-image-2',
});
expect(response.draft.visualPackage?.themePrompt).toBe('果园主题视觉包装');
expect(
response.draft.visualPackage?.assets.map((asset) => asset.assetKind),
).toEqual(['background', 'ui-frame', 'gift-box', 'basket', 'smoke-puff']);
expect(response.draft.visualPackage?.assets[0]).toMatchObject({
assetId: 'baby-object-visual-background',
generationProvider: 'vector-engine-gpt-image-2',
});
});
test('rejects draft creation when backend asset generation fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
return new Response(
JSON.stringify({ error: { message: 'missing key' } }),
{
status: 503,
headers: { 'Content-Type': 'application/json' },
},
);
}),
);
await expect(
createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: '香蕉',
}),
).rejects.toThrow('missing key');
await expect(listLocalBabyObjectMatchDrafts()).resolves.toHaveLength(0);
});
test('rejects draft creation when any item name is empty', async () => {
await expect(
createBabyObjectMatchDraft({
@@ -60,6 +210,7 @@ describe('babyObjectMatchClient', () => {
});
test('publish normalizes exact edutainment tag into payload', async () => {
stubSuccessfulAssetGeneration();
const response = await createBabyObjectMatchDraft({
itemAName: '杯子',
itemBName: '勺子',
@@ -80,16 +231,98 @@ describe('babyObjectMatchClient', () => {
});
test('deletes local baby object match draft by profile id', async () => {
stubSuccessfulAssetGeneration();
const response = await createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: '香蕉',
});
expect(listLocalBabyObjectMatchDrafts()).toHaveLength(1);
await expect(listLocalBabyObjectMatchDrafts()).resolves.toHaveLength(1);
const nextItems = deleteLocalBabyObjectMatchDraft(response.draft.profileId);
const nextItems = await deleteLocalBabyObjectMatchDraft(
response.draft.profileId,
);
expect(nextItems).toHaveLength(0);
expect(listLocalBabyObjectMatchDrafts()).toHaveLength(0);
await expect(listLocalBabyObjectMatchDrafts()).resolves.toHaveLength(0);
});
test('regenerates placeholder draft assets before playback or publish', async () => {
stubSuccessfulAssetGeneration();
const placeholderDraft: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-legacy',
profileId: 'baby-object-profile-legacy',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '宝贝识物',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/svg+xml;utf8,a',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: 'legacy apple',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/svg+xml;utf8,b',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: 'legacy banana',
},
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:00.000Z',
publishedAt: null,
};
expect(hasBabyObjectMatchPlaceholderAssets(placeholderDraft)).toBe(true);
const response = await regenerateBabyObjectMatchDraftAssets(
placeholderDraft,
);
expect(hasBabyObjectMatchPlaceholderAssets(response.draft)).toBe(false);
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
'vector-engine-gpt-image-2',
);
expect((await listLocalBabyObjectMatchDrafts())[0]?.profileId).toBe(
'baby-object-profile-legacy',
);
});
test('stores generated image drafts without writing large payloads to localStorage', async () => {
stubSuccessfulAssetGeneration();
vi.stubGlobal('window', {
localStorage: {
getItem: () => null,
setItem: () => {
throw new DOMException(
'Setting the value exceeded the quota.',
'QuotaExceededError',
);
},
removeItem: () => {},
},
});
const response = await createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: '香蕉',
});
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
'vector-engine-gpt-image-2',
);
expect((await listLocalBabyObjectMatchDrafts())[0]?.profileId).toBe(
response.draft.profileId,
);
});
});

View File

@@ -3,7 +3,11 @@ import type {
BabyObjectMatchItemAsset,
BabyObjectMatchPublishRequest,
BabyObjectMatchPublishResponse,
BabyObjectMatchVisualAsset,
BabyObjectMatchVisualAssetKind,
BabyObjectMatchVisualPackage,
CreateBabyObjectMatchDraftRequest,
GenerateBabyObjectMatchAssetsResponse,
SaveBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
@@ -13,19 +17,38 @@ import {
normalizeBabyObjectMatchTags,
validateBabyObjectMatchItemNames,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { type ApiRetryOptions, requestJson } from '../apiClient';
import { buildBabyObjectMatchPublicWorkCode } from '../publicWorkCode';
const STORAGE_KEY = 'genarrative.edutainmentBabyObject.localDrafts.v1';
const BABY_OBJECT_MATCH_ASSET_API =
'/api/creation/edutainment/baby-object-match/assets';
export const BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS = 600_000;
const BABY_OBJECT_MATCH_ASSET_REQUEST_RETRY: ApiRetryOptions = {
maxRetries: 0,
};
const BABY_OBJECT_MATCH_REQUIRED_VISUAL_KINDS: BabyObjectMatchVisualAssetKind[] =
['background', 'ui-frame', 'gift-box', 'basket', 'smoke-puff'];
const DRAFT_DB_NAME = 'genarrative-edutainment-baby-object-drafts';
const DRAFT_DB_VERSION = 1;
const DRAFT_STORE_NAME = 'drafts';
type LocalDraftStore = Record<string, BabyObjectMatchDraft>;
let memoryDraftStore: LocalDraftStore = {};
const ignoredLegacyProfileIds = new Set<string>();
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
function readLocalDraftStore(): LocalDraftStore {
function canUseIndexedDb() {
return typeof indexedDB !== 'undefined';
}
function readLegacyLocalDraftStore(): LocalDraftStore {
if (!canUseLocalStorage()) {
return {};
}
@@ -36,18 +59,129 @@ function readLocalDraftStore(): LocalDraftStore {
return {};
}
const parsed = JSON.parse(rawValue) as LocalDraftStore;
return parsed && typeof parsed === 'object' ? parsed : {};
if (!parsed || typeof parsed !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(parsed).filter(
([profileId]) => !ignoredLegacyProfileIds.has(profileId),
),
);
} catch {
return {};
}
}
function writeLocalDraftStore(store: LocalDraftStore) {
if (!canUseLocalStorage()) {
function clearLegacyLocalDraftStore() {
if (canUseLocalStorage()) {
window.localStorage.removeItem(STORAGE_KEY);
}
}
function idbRequestToPromise<T>(request: IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => {
reject(request.error ?? new Error('IndexedDB request failed'));
});
});
}
function openDraftDb() {
return new Promise<IDBDatabase>((resolve, reject) => {
if (!canUseIndexedDb()) {
reject(new Error('IndexedDB unavailable'));
return;
}
const request = indexedDB.open(DRAFT_DB_NAME, DRAFT_DB_VERSION);
request.addEventListener('upgradeneeded', () => {
const db = request.result;
if (!db.objectStoreNames.contains(DRAFT_STORE_NAME)) {
db.createObjectStore(DRAFT_STORE_NAME, { keyPath: 'profileId' });
}
});
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => {
reject(request.error ?? new Error('IndexedDB open failed'));
});
});
}
async function withDraftStore<T>(
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T>,
) {
const db = await openDraftDb();
try {
const transaction = db.transaction(DRAFT_STORE_NAME, mode);
const result = await idbRequestToPromise(run(transaction.objectStore(DRAFT_STORE_NAME)));
await new Promise<void>((resolve, reject) => {
transaction.addEventListener('complete', () => resolve());
transaction.addEventListener('abort', () => {
reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
});
transaction.addEventListener('error', () => {
reject(transaction.error ?? new Error('IndexedDB transaction failed'));
});
});
return result;
} finally {
db.close();
}
}
async function readIndexedDbDraftStore(): Promise<LocalDraftStore> {
if (!canUseIndexedDb()) {
return {};
}
try {
const drafts = await withDraftStore<BabyObjectMatchDraft[]>(
'readonly',
(store) => store.getAll() as IDBRequest<BabyObjectMatchDraft[]>,
);
return Object.fromEntries(drafts.map((draft) => [draft.profileId, draft]));
} catch {
return {};
}
}
async function putIndexedDbDraft(draft: BabyObjectMatchDraft) {
if (!canUseIndexedDb()) {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
await withDraftStore<IDBValidKey>('readwrite', (store) => store.put(draft));
}
async function deleteIndexedDbDraft(profileId: string) {
if (!canUseIndexedDb()) {
return;
}
await withDraftStore<undefined>('readwrite', (store) =>
store.delete(profileId),
);
}
async function readLocalDraftStore(): Promise<LocalDraftStore> {
const indexedDrafts = await readIndexedDbDraftStore();
const legacyDrafts = readLegacyLocalDraftStore();
const merged = {
...legacyDrafts,
...memoryDraftStore,
...indexedDrafts,
};
if (canUseIndexedDb() && Object.keys(legacyDrafts).length > 0) {
await Promise.all(Object.values(legacyDrafts).map(putIndexedDbDraft));
clearLegacyLocalDraftStore();
}
memoryDraftStore = { ...memoryDraftStore, ...merged };
return merged;
}
function createLocalId(prefix: string) {
@@ -59,50 +193,101 @@ function createLocalId(prefix: string) {
return `${prefix}-${Date.now().toString(36)}-${randomPart.slice(0, 12)}`;
}
function encodeSvgDataUri(svg: string) {
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
function normalizeGeneratedAssets(
assets: BabyObjectMatchItemAsset[] | null | undefined,
itemNames: [string, string],
): [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset] | null {
if (!Array.isArray(assets) || assets.length !== 2) {
return null;
}
function buildPlaceholderItemImage(itemName: string, index: number) {
const palettes = [
{
bg: '#fef3c7',
accent: '#fb7185',
shadow: '#f59e0b',
text: '#7c2d12',
},
{
bg: '#dbeafe',
accent: '#34d399',
shadow: '#60a5fa',
text: '#064e3b',
},
] as const;
const palette = palettes[index % palettes.length]!;
const displayText = itemName.slice(0, 6);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="96" fill="${palette.bg}"/><circle cx="256" cy="238" r="132" fill="${palette.accent}" opacity=".92"/><ellipse cx="256" cy="356" rx="132" ry="34" fill="${palette.shadow}" opacity=".22"/><circle cx="210" cy="202" r="24" fill="#fff" opacity=".82"/><circle cx="302" cy="202" r="24" fill="#fff" opacity=".82"/><path d="M204 276c30 30 74 30 104 0" fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round"/><text x="256" y="438" text-anchor="middle" font-family="Arial,'Microsoft YaHei',sans-serif" font-size="42" font-weight="700" fill="${palette.text}">${displayText}</text></svg>`;
return encodeSvgDataUri(svg);
}
function buildItemAsset(
itemName: string,
index: number,
): BabyObjectMatchItemAsset {
return {
const normalizedAssets = assets.map((asset, index) => ({
...asset,
itemId: `baby-object-item-${index + 1}`,
itemName,
imageSrc: buildPlaceholderItemImage(itemName, index),
assetObjectId: null,
generationProvider: 'placeholder',
prompt: `生成适合 4-8 岁儿童识物分类游戏的${itemName}物品图,绘本草地舞台风格,单个物体,透明或干净背景,无文字、无水印、无按钮。`,
itemName: itemNames[index],
}));
if (
normalizedAssets.some(
(asset) =>
asset.generationProvider !== 'vector-engine-gpt-image-2' ||
!asset.imageSrc.startsWith('data:image/png;base64,'),
)
) {
return null;
}
return [
normalizedAssets[0] as BabyObjectMatchItemAsset,
normalizedAssets[1] as BabyObjectMatchItemAsset,
];
}
function normalizeGeneratedVisualPackage(
visualPackage: BabyObjectMatchVisualPackage | null | undefined,
): BabyObjectMatchVisualPackage | null {
if (!visualPackage || !Array.isArray(visualPackage.assets)) {
return null;
}
const normalizedAssets: BabyObjectMatchVisualAsset[] = [];
for (const kind of BABY_OBJECT_MATCH_REQUIRED_VISUAL_KINDS) {
const asset = visualPackage.assets.find(
(entry) => entry.assetKind === kind,
);
if (
!asset ||
asset.generationProvider !== 'vector-engine-gpt-image-2' ||
!asset.imageSrc.startsWith('data:image/png;base64,')
) {
return null;
}
normalizedAssets.push({
...asset,
assetId: `baby-object-visual-${kind}`,
});
}
return {
themePrompt: visualPackage.themePrompt.trim(),
assets: normalizedAssets,
};
}
function saveDraftToLocalStore(draft: BabyObjectMatchDraft) {
const store = readLocalDraftStore();
store[draft.profileId] = draft;
writeLocalDraftStore(store);
async function generateBabyObjectMatchAssets(
itemNames: [string, string],
): Promise<{
assets: [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset];
visualPackage: BabyObjectMatchVisualPackage;
}> {
const response = await requestJson<GenerateBabyObjectMatchAssetsResponse>(
BABY_OBJECT_MATCH_ASSET_API,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemNames }),
},
'生成宝贝识物物品素材失败',
{
retry: BABY_OBJECT_MATCH_ASSET_REQUEST_RETRY,
timeoutMs: BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS,
},
);
const assets = normalizeGeneratedAssets(response.assets, itemNames);
const visualPackage = normalizeGeneratedVisualPackage(response.visualPackage);
if (!assets || !visualPackage) {
throw new Error('宝贝识物 image-2 资源生成结果不完整,请重试。');
}
return { assets, visualPackage };
}
async function saveDraftToLocalStore(draft: BabyObjectMatchDraft) {
memoryDraftStore[draft.profileId] = draft;
await putIndexedDbDraft(draft);
}
export function normalizeBabyObjectMatchDraft(
@@ -115,10 +300,7 @@ export function normalizeBabyObjectMatchDraft(
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: draft.workTitle.trim() || '宝贝识物',
workDescription: draft.workDescription.trim(),
itemNames: [
draft.itemNames[0].trim(),
draft.itemNames[1].trim(),
],
itemNames: [draft.itemNames[0].trim(), draft.itemNames[1].trim()],
itemAssets: [
{
...draft.itemAssets[0],
@@ -129,15 +311,55 @@ export function normalizeBabyObjectMatchDraft(
itemName: draft.itemNames[1].trim(),
},
],
visualPackage: draft.visualPackage ?? null,
themeTags: normalizeBabyObjectMatchTags(draft.themeTags),
updatedAt: draft.updatedAt || now,
};
}
/**
* 当前为本地 Demo 创作链路。真实 image-2 接入后替换为后端接口,
* 但返回契约保持 BabyObjectMatchDraftResponse。
*/
export function hasBabyObjectMatchPlaceholderAssets(
draft: BabyObjectMatchDraft,
) {
const visualAssets = draft.visualPackage?.assets ?? [];
return (
draft.itemAssets.some(
(asset) =>
asset.generationProvider !== 'vector-engine-gpt-image-2' ||
!asset.imageSrc.startsWith('data:image/png;base64,'),
) ||
!draft.visualPackage ||
BABY_OBJECT_MATCH_REQUIRED_VISUAL_KINDS.some(
(kind) =>
!visualAssets.some(
(asset) =>
asset.assetKind === kind &&
asset.generationProvider === 'vector-engine-gpt-image-2' &&
asset.imageSrc.startsWith('data:image/png;base64,'),
),
)
);
}
export async function regenerateBabyObjectMatchDraftAssets(
draft: BabyObjectMatchDraft,
) {
const itemNames: [string, string] = [
draft.itemNames[0].trim(),
draft.itemNames[1].trim(),
];
const generated = await generateBabyObjectMatchAssets(itemNames);
const nextDraft = normalizeBabyObjectMatchDraft({
...draft,
itemNames,
itemAssets: generated.assets,
visualPackage: generated.visualPackage,
updatedAt: new Date().toISOString(),
});
await saveDraftToLocalStore(nextDraft);
return { draft: nextDraft };
}
export async function createBabyObjectMatchDraft(
payload: CreateBabyObjectMatchDraftRequest,
) {
@@ -153,6 +375,7 @@ export async function createBabyObjectMatchDraft(
validated.itemAName,
validated.itemBName,
];
const generated = await generateBabyObjectMatchAssets(itemNames);
const draft = normalizeBabyObjectMatchDraft({
draftId,
profileId,
@@ -161,7 +384,8 @@ export async function createBabyObjectMatchDraft(
workTitle: '宝贝识物',
workDescription: `${itemNames[0]}${itemNames[1]}识物分类`,
itemNames,
itemAssets: [buildItemAsset(itemNames[0], 0), buildItemAsset(itemNames[1], 1)],
itemAssets: generated.assets,
visualPackage: generated.visualPackage,
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
publicationStatus: 'draft',
createdAt: now,
@@ -169,7 +393,7 @@ export async function createBabyObjectMatchDraft(
publishedAt: null,
});
saveDraftToLocalStore(draft);
await saveDraftToLocalStore(draft);
return { draft };
}
@@ -180,7 +404,7 @@ export async function saveBabyObjectMatchDraft(
...payload.draft,
updatedAt: new Date().toISOString(),
});
saveDraftToLocalStore(draft);
await saveDraftToLocalStore(draft);
return { draft };
}
@@ -194,7 +418,7 @@ export async function publishBabyObjectMatchWork(
publishedAt: payload.draft.publishedAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
saveDraftToLocalStore(draft);
await saveDraftToLocalStore(draft);
return {
draft,
@@ -202,29 +426,35 @@ export async function publishBabyObjectMatchWork(
};
}
export function listLocalBabyObjectMatchDrafts() {
return Object.values(readLocalDraftStore()).sort(
export async function listLocalBabyObjectMatchDrafts() {
return Object.values(await readLocalDraftStore()).sort(
(left, right) =>
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
);
}
export function deleteLocalBabyObjectMatchDraft(profileId: string) {
export async function deleteLocalBabyObjectMatchDraft(profileId: string) {
const normalizedProfileId = profileId.trim();
if (!normalizedProfileId) {
return listLocalBabyObjectMatchDrafts();
return await listLocalBabyObjectMatchDrafts();
}
const store = readLocalDraftStore();
delete store[normalizedProfileId];
writeLocalDraftStore(store);
ignoredLegacyProfileIds.add(normalizedProfileId);
delete memoryDraftStore[normalizedProfileId];
await deleteIndexedDbDraft(normalizedProfileId);
return listLocalBabyObjectMatchDrafts();
return await listLocalBabyObjectMatchDrafts();
}
export function __resetBabyObjectMatchLocalDraftStorageForTests() {
memoryDraftStore = {};
ignoredLegacyProfileIds.clear();
}
export const babyObjectMatchClient = {
createDraft: createBabyObjectMatchDraft,
deleteDraft: deleteLocalBabyObjectMatchDraft,
regenerateDraftAssets: regenerateBabyObjectMatchDraftAssets,
saveDraft: saveBabyObjectMatchDraft,
publish: publishBabyObjectMatchWork,
listLocalDrafts: listLocalBabyObjectMatchDrafts,

View File

@@ -272,6 +272,7 @@ describe('miniGameDraftGenerationProgress', () => {
'baby-object-ready',
]);
expect(progress?.phaseId).toBe('baby-object-images');
expect(progress?.estimatedRemainingMs).toBe(351_000);
expect(entries).toEqual([
{
id: 'baby-object-item-1',

View File

@@ -112,6 +112,7 @@ const PUZZLE_STEPS = [
const PUZZLE_ESTIMATED_WAIT_MS = 132_000;
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
const PUZZLE_PHASE_TIMELINE: Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
@@ -254,8 +255,8 @@ const BABY_OBJECT_MATCH_STEPS = [
},
{
id: 'baby-object-images',
label: '生成物品图',
detail: '为两个物品准备绘本风格图片资产。',
label: '生成游戏素材',
detail: '生成物品图、背景、礼物盒、篮子和界面包装。',
weight: 68,
},
{
@@ -402,7 +403,7 @@ function resolveMatch3DPhaseByElapsedMs(
function resolveBabyObjectMatchPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 52_000) {
if (elapsedMs >= 330_000) {
return 'baby-object-ready';
}
if (elapsedMs >= 8_000) {
@@ -564,7 +565,10 @@ export function buildMiniGameDraftGenerationProgress(
: normalizedState.kind === 'match3d'
? Math.max(0, MATCH3D_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'baby-object-match'
? Math.max(0, 60_000 - elapsedMs)
? Math.max(
0,
BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS - elapsedMs,
)
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(

View File

@@ -87,6 +87,12 @@ describe('parseMocapPacket', () => {
body: {
center_norm: [0.34, 0.58],
},
limb_nodes: [
{ name: 'left_shoulder', x: 0.28, y: 0.42 },
{ name: 'left_elbow', x: 0.24, y: 0.5 },
{ name: 'right_shoulder', x: 0.72, y: 0.42 },
{ name: 'right_elbow', x: 0.76, y: 0.5 },
],
},
actions: [{ gesture: 'wave-left-hand' }],
hands: [
@@ -111,11 +117,21 @@ describe('parseMocapPacket', () => {
});
expect(command.bodyCenter).toEqual({x: 0.34, y: 0.58});
expect(command.bodyJoints).toEqual({
leftShoulder: {x: 0.28, y: 0.42},
leftElbow: {x: 0.24, y: 0.5},
rightShoulder: {x: 0.72, y: 0.42},
rightElbow: {x: 0.76, y: 0.5},
});
expect(command.actions).toEqual(
expect.arrayContaining(['wave_left_hand', 'open_palm']),
);
expect(command.leftHand).toEqual(
expect.objectContaining({side: 'left', source: 'palm_center'}),
expect.objectContaining({
side: 'left',
source: 'palm_center',
wrist: {x: 0.21, y: 0.31},
}),
);
expect(command.rightHand).toEqual(
expect.objectContaining({x: 0.72, y: 0.32, side: 'right'}),

View File

@@ -6,17 +6,27 @@ export type MocapHandState = 'open_palm' | 'grab' | 'unknown';
export type MocapHandSide = 'left' | 'right' | 'unknown';
export type MocapHandSource = 'palm_center' | 'direct' | 'landmark';
export type MocapPointInput = {
x: number;
y: number;
};
export type MocapHandInput = {
x: number;
y: number;
state: MocapHandState;
side: MocapHandSide;
source?: MocapHandSource;
wrist?: MocapPointInput | null;
};
export type MocapBodyCenterInput = {
x: number;
y: number;
export type MocapBodyCenterInput = MocapPointInput;
export type MocapBodyJointsInput = {
leftShoulder?: MocapPointInput | null;
rightShoulder?: MocapPointInput | null;
leftElbow?: MocapPointInput | null;
rightElbow?: MocapPointInput | null;
};
export type MocapInputCommand = {
@@ -26,6 +36,7 @@ export type MocapInputCommand = {
leftHand?: MocapHandInput | null;
rightHand?: MocapHandInput | null;
bodyCenter?: MocapBodyCenterInput | null;
bodyJoints?: MocapBodyJointsInput;
parseWarnings?: string[];
};
@@ -251,6 +262,36 @@ function normaliseHandState(state: unknown): MocapHandState {
return 'unknown';
}
function normalizeBodyJointName(name: unknown) {
if (typeof name !== 'string') {
return null;
}
const normalized = name
.trim()
.toLocaleLowerCase('en-US')
.replace(/\s+/gu, '_')
.replace(/-/gu, '_');
if (normalized === 'left_shoulder' || normalized === 'leftshoulder') {
return 'leftShoulder' as const;
}
if (normalized === 'right_shoulder' || normalized === 'rightshoulder') {
return 'rightShoulder' as const;
}
if (normalized === 'left_elbow' || normalized === 'leftelbow') {
return 'leftElbow' as const;
}
if (normalized === 'right_elbow' || normalized === 'rightelbow') {
return 'rightElbow' as const;
}
return null;
}
function resolveMocapHand(record: unknown, fallbackSide: MocapHandSide) {
if (!record || typeof record !== 'object') {
return null;
@@ -277,23 +318,45 @@ function resolveMocapHand(record: unknown, fallbackSide: MocapHandSide) {
if (Array.isArray(handRecord.landmarks)) {
const landmarks = handRecord.landmarks as Array<MocapLandmarkRecord>;
const wristPoint = resolveLandmarkCoordinate(
landmarks.find((item) => item?.name === 'wrist'),
);
const palmCenter = resolveMocapPalmCenter(landmarks);
if (palmCenter) {
return {...palmCenter, state, side, source: 'palm_center' as const};
return {
...palmCenter,
state,
side,
source: 'palm_center' as const,
wrist: wristPoint ?? palmCenter,
};
}
const landmark =
landmarks.find((item) => item?.name === 'wrist') ?? landmarks[0];
const fallbackPoint = resolveLandmarkCoordinate(landmark);
if (fallbackPoint) {
return {...fallbackPoint, state, side, source: 'landmark' as const};
return {
...fallbackPoint,
state,
side,
source: 'landmark' as const,
wrist: wristPoint ?? fallbackPoint,
};
}
}
const directX = normalizeCoordinate(handRecord.x);
const directY = normalizeCoordinate(handRecord.y);
if (directX !== null && directY !== null) {
return {x: directX, y: directY, state, side, source: 'direct' as const};
return {
x: directX,
y: directY,
state,
side,
source: 'direct' as const,
wrist: {x: directX, y: directY},
};
}
return null;
@@ -354,6 +417,53 @@ function resolveBodyCenter(packetRecord: Record<string, unknown>) {
return null;
}
function resolveBodyJoints(packetRecord: Record<string, unknown>) {
const joints: MocapBodyJointsInput = {};
const generalRecord =
packetRecord.general && typeof packetRecord.general === 'object'
? (packetRecord.general as Record<string, unknown>)
: null;
const bodyRecord =
generalRecord?.body && typeof generalRecord.body === 'object'
? (generalRecord.body as Record<string, unknown>)
: null;
const limbCandidates = [
generalRecord?.limb_nodes,
generalRecord?.limbNodes,
bodyRecord?.limb_nodes,
bodyRecord?.limbNodes,
packetRecord.limb_nodes,
packetRecord.limbNodes,
];
for (const candidate of limbCandidates) {
if (!Array.isArray(candidate)) {
continue;
}
for (const node of candidate) {
if (!node || typeof node !== 'object') {
continue;
}
const nodeRecord = node as Record<string, unknown>;
const jointName = normalizeBodyJointName(
nodeRecord.name ?? nodeRecord.label ?? nodeRecord.type,
);
if (!jointName || joints[jointName]) {
continue;
}
const point = resolveNormalizedPoint(nodeRecord);
if (point) {
joints[jointName] = point;
}
}
}
return Object.keys(joints).length > 0 ? joints : undefined;
}
export function parseMocapPacket(packet: unknown): MocapInputCommand {
if (!packet || typeof packet !== 'object') {
return {actions: [], parseWarnings: ['packet 不是对象']};
@@ -365,6 +475,7 @@ export function parseMocapPacket(packet: unknown): MocapInputCommand {
const leftHand = hands.find((hand) => hand.side === 'left') ?? null;
const rightHand = hands.find((hand) => hand.side === 'right') ?? null;
const bodyCenter = resolveBodyCenter(packetRecord);
const bodyJoints = resolveBodyJoints(packetRecord);
const actions = new Set<string>();
const parseWarnings: string[] = [];
addMocapActions(actions, packetRecord.actions);
@@ -401,6 +512,7 @@ export function parseMocapPacket(packet: unknown): MocapInputCommand {
leftHand,
rightHand,
bodyCenter,
bodyJoints,
parseWarnings,
};
}