Merge remote-tracking branch 'origin/master' into hermes/wechat
# Conflicts: # .hermes/shared-memory/pitfalls.md # .hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md
This commit is contained in:
@@ -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 }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
6
src/services/bark-battle-creation/index.ts
Normal file
6
src/services/bark-battle-creation/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
barkBattleCreationClient,
|
||||
type BarkBattleCreationRequestOptions,
|
||||
createBarkBattleDraft,
|
||||
publishBarkBattleWork,
|
||||
} from './barkBattleCreationClient';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
121
src/services/bark-battle-runtime/barkBattleRuntimeClient.ts
Normal file
121
src/services/bark-battle-runtime/barkBattleRuntimeClient.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
7
src/services/bark-battle-runtime/index.ts
Normal file
7
src/services/bark-battle-runtime/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type BarkBattleRuntimeRequestOptions,
|
||||
finishBarkBattleRun,
|
||||
getBarkBattleRun,
|
||||
getBarkBattleRuntimeConfig,
|
||||
startBarkBattleRun,
|
||||
} from './barkBattleRuntimeClient';
|
||||
102
src/services/edutainment-baby-drawing/babyDrawingClient.test.ts
Normal file
102
src/services/edutainment-baby-drawing/babyDrawingClient.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
128
src/services/edutainment-baby-drawing/babyDrawingClient.ts
Normal file
128
src/services/edutainment-baby-drawing/babyDrawingClient.ts
Normal 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,
|
||||
};
|
||||
1
src/services/edutainment-baby-drawing/index.ts
Normal file
1
src/services/edutainment-baby-drawing/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './babyDrawingClient';
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
deleteMatch3DWork,
|
||||
generateMatch3DBackgroundImage,
|
||||
generateMatch3DContainerImage,
|
||||
generateMatch3DCoverImage,
|
||||
generateMatch3DItemAssets,
|
||||
generateMatch3DWorkTags,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
GenerateMatch3DBackgroundImageRequest,
|
||||
GenerateMatch3DBackgroundImageResponse,
|
||||
GenerateMatch3DContainerImageRequest,
|
||||
GenerateMatch3DContainerImageResponse,
|
||||
GenerateMatch3DCoverImageRequest,
|
||||
GenerateMatch3DCoverImageResponse,
|
||||
GenerateMatch3DItemAssetsRequest,
|
||||
@@ -177,6 +179,28 @@ export function generateMatch3DBackgroundImage(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按画面描述重新生成并保存抓大鹅局内容器形象。
|
||||
*/
|
||||
export function generateMatch3DContainerImage(
|
||||
profileId: string,
|
||||
payload: GenerateMatch3DContainerImageRequest,
|
||||
) {
|
||||
return requestJson<GenerateMatch3DContainerImageResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/container-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成抓大鹅容器形象失败',
|
||||
{
|
||||
retry: MATCH3D_WORKS_WRITE_RETRY,
|
||||
timeoutMs: 240_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称批量生成抓大鹅 2D 五视角物品图片。
|
||||
*/
|
||||
@@ -244,6 +268,7 @@ export function deleteMatch3DWork(profileId: string) {
|
||||
export const match3dWorksClient = {
|
||||
delete: deleteMatch3DWork,
|
||||
generateBackgroundImage: generateMatch3DBackgroundImage,
|
||||
generateContainerImage: generateMatch3DContainerImage,
|
||||
generateCoverImage: generateMatch3DCoverImage,
|
||||
generateItemAssets: generateMatch3DItemAssets,
|
||||
generateTags: generateMatch3DWorkTags,
|
||||
|
||||
@@ -26,7 +26,6 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
'编译首关草稿',
|
||||
'生成关卡名称',
|
||||
'生成首关画面',
|
||||
'生成背景音乐',
|
||||
'生成UI背景',
|
||||
'写入正式草稿',
|
||||
]);
|
||||
@@ -34,7 +33,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
expect(progress?.steps[0]?.detail).toBe(
|
||||
'读取画面描述,建立可编辑草稿与首关结构。',
|
||||
);
|
||||
expect(progress?.estimatedRemainingMs).toBe(178_500);
|
||||
expect(progress?.estimatedRemainingMs).toBe(130_500);
|
||||
expect(progress?.overallProgress).toBeGreaterThan(0);
|
||||
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -50,22 +49,19 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
};
|
||||
|
||||
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
|
||||
const musicProgress = buildMiniGameDraftGenerationProgress(state, 96_000);
|
||||
const uiProgress = buildMiniGameDraftGenerationProgress(state, 146_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 176_000);
|
||||
const uiProgress = buildMiniGameDraftGenerationProgress(state, 96_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 126_000);
|
||||
|
||||
expect(imageProgress?.phaseId).toBe('puzzle-images');
|
||||
expect(imageProgress?.estimatedRemainingMs).toBe(155_000);
|
||||
expect(imageProgress?.estimatedRemainingMs).toBe(107_000);
|
||||
expect(imageProgress?.steps[1]?.status).toBe('completed');
|
||||
expect(imageProgress?.steps[2]?.status).toBe('active');
|
||||
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
|
||||
expect(musicProgress?.phaseId).toBe('puzzle-background-music');
|
||||
expect(musicProgress?.phaseLabel).toBe('生成背景音乐');
|
||||
expect(uiProgress?.phaseId).toBe('puzzle-ui-background');
|
||||
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
|
||||
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
|
||||
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
|
||||
expect(writeBackProgress?.steps[5]?.status).toBe('active');
|
||||
expect(writeBackProgress?.estimatedRemainingMs).toBe(7_000);
|
||||
expect(writeBackProgress?.steps[3]?.status).toBe('completed');
|
||||
expect(writeBackProgress?.steps[4]?.status).toBe('active');
|
||||
});
|
||||
|
||||
test('puzzle draft generation keeps moving without claiming completion before response', () => {
|
||||
@@ -83,7 +79,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
expect(progress?.phaseId).toBe('puzzle-select-image');
|
||||
expect(progress?.overallProgress).toBe(98);
|
||||
expect(progress?.estimatedRemainingMs).toBe(0);
|
||||
expect(progress?.steps[5]?.completed).toBe(1);
|
||||
expect(progress?.steps[4]?.completed).toBe(1);
|
||||
});
|
||||
|
||||
test('puzzle ready copy points to result page work info completion', () => {
|
||||
@@ -177,13 +173,12 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
'match3d-slice-images',
|
||||
'match3d-upload-images',
|
||||
'match3d-generate-views',
|
||||
'match3d-background-music',
|
||||
'match3d-background-image',
|
||||
'match3d-write-draft',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('match3d-material-sheet');
|
||||
expect(progress?.phaseLabel).toBe('分批生成素材图');
|
||||
expect(progress?.estimatedRemainingMs).toBe(570_000);
|
||||
expect(progress?.estimatedRemainingMs).toBe(480_000);
|
||||
});
|
||||
|
||||
test('match3d draft generation starts from title generation', () => {
|
||||
@@ -215,29 +210,23 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('match3d-generate-views');
|
||||
expect(progress?.steps[6]?.detail).toContain('音效提示词');
|
||||
expect(progress?.steps[6]?.detail).toContain('五视角图片');
|
||||
expect(progress?.steps[6]?.completed).toBe(1);
|
||||
expect(progress?.steps[6]?.total).toBe(3);
|
||||
});
|
||||
|
||||
test('match3d draft generation reaches music, background image and writeback phases', () => {
|
||||
test('match3d draft generation reaches background image and writeback phases', () => {
|
||||
const state = createMiniGameDraftGenerationState('match3d');
|
||||
|
||||
const musicProgress = buildMiniGameDraftGenerationProgress(
|
||||
const backgroundProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 400_000,
|
||||
);
|
||||
const backgroundProgress = buildMiniGameDraftGenerationProgress(
|
||||
const writeProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 500_000,
|
||||
);
|
||||
const writeProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 550_000,
|
||||
);
|
||||
|
||||
expect(musicProgress?.phaseId).toBe('match3d-background-music');
|
||||
expect(musicProgress?.phaseLabel).toBe('生成背景音乐');
|
||||
expect(backgroundProgress?.phaseId).toBe('match3d-background-image');
|
||||
expect(backgroundProgress?.phaseLabel).toBe('生成UI背景');
|
||||
expect(writeProgress?.phaseId).toBe('match3d-write-draft');
|
||||
@@ -283,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',
|
||||
|
||||
@@ -43,7 +43,6 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'match3d-slice-images'
|
||||
| 'match3d-upload-images'
|
||||
| 'match3d-generate-views'
|
||||
| 'match3d-background-music'
|
||||
| 'match3d-background-image'
|
||||
| 'match3d-write-draft'
|
||||
| 'match3d-ready'
|
||||
@@ -51,7 +50,6 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'baby-object-images'
|
||||
| 'baby-object-ready'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-background-music'
|
||||
| 'puzzle-ui-background'
|
||||
| 'puzzle-select-image'
|
||||
| 'ready'
|
||||
@@ -98,35 +96,29 @@ const PUZZLE_STEPS = [
|
||||
detail: '调用图片模型生成适合切块的正方形首图。',
|
||||
weight: 42,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-background-music',
|
||||
label: '生成背景音乐',
|
||||
detail: '用作品题目生成纯音乐并转存音频资产。',
|
||||
weight: 18,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-ui-background',
|
||||
label: '生成UI背景',
|
||||
detail: '生成不含槽位和控件的 9:16 纯背景。',
|
||||
weight: 14,
|
||||
weight: 32,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-select-image',
|
||||
label: '写入正式草稿',
|
||||
detail: '写入首图、音乐、UI背景和首关数据。',
|
||||
detail: '写入首图、UI背景和首关数据。',
|
||||
weight: 8,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const PUZZLE_ESTIMATED_WAIT_MS = 180_000;
|
||||
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,
|
||||
| 'compile'
|
||||
| 'puzzle-level-name'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-background-music'
|
||||
| 'puzzle-ui-background'
|
||||
| 'puzzle-select-image'
|
||||
>;
|
||||
@@ -135,7 +127,6 @@ const PUZZLE_PHASE_TIMELINE: Array<{
|
||||
{ phase: 'compile', durationMs: 12_000 },
|
||||
{ phase: 'puzzle-level-name', durationMs: 8_000 },
|
||||
{ phase: 'puzzle-images', durationMs: 70_000 },
|
||||
{ phase: 'puzzle-background-music', durationMs: 48_000 },
|
||||
{ phase: 'puzzle-ui-background', durationMs: 32_000 },
|
||||
{ phase: 'puzzle-select-image', durationMs: 10_000 },
|
||||
];
|
||||
@@ -192,7 +183,7 @@ const MATCH3D_STEPS = [
|
||||
{
|
||||
id: 'match3d-item-names',
|
||||
label: '生成作品计划',
|
||||
detail: '生成游戏名称、物品名称、音乐名称与标签。',
|
||||
detail: '生成游戏名称、物品名称与标签。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
@@ -205,31 +196,25 @@ const MATCH3D_STEPS = [
|
||||
id: 'match3d-material-sheet',
|
||||
label: '分批生成素材图',
|
||||
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
|
||||
weight: 22,
|
||||
weight: 24,
|
||||
},
|
||||
{
|
||||
id: 'match3d-slice-images',
|
||||
label: '切割独立图片',
|
||||
detail: '把素材图切成每个物品的五个视角。',
|
||||
weight: 10,
|
||||
weight: 12,
|
||||
},
|
||||
{
|
||||
id: 'match3d-upload-images',
|
||||
label: '上传图片资产',
|
||||
detail: '上传每个物品的 2D 五视角素材。',
|
||||
weight: 12,
|
||||
weight: 14,
|
||||
},
|
||||
{
|
||||
id: 'match3d-generate-views',
|
||||
label: '校验素材结构',
|
||||
detail: '确认物品顺序、五视角图片和音效提示词。',
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
id: 'match3d-background-music',
|
||||
label: '生成背景音乐',
|
||||
detail: '用音乐名称生成纯音乐并转存音频资产。',
|
||||
weight: 14,
|
||||
detail: '确认物品顺序和五视角图片。',
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'match3d-background-image',
|
||||
@@ -240,11 +225,13 @@ const MATCH3D_STEPS = [
|
||||
{
|
||||
id: 'match3d-write-draft',
|
||||
label: '写入草稿页',
|
||||
detail: '保存素材、音乐、背景、容器和作品草稿。',
|
||||
detail: '保存素材、背景、容器和作品草稿。',
|
||||
weight: 2,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const MATCH3D_ESTIMATED_WAIT_MS = 510_000;
|
||||
|
||||
const MATCH3D_PHASE_ORDER: Partial<
|
||||
Record<MiniGameDraftGenerationPhase, number>
|
||||
> = {
|
||||
@@ -255,9 +242,8 @@ const MATCH3D_PHASE_ORDER: Partial<
|
||||
'match3d-slice-images': 4,
|
||||
'match3d-upload-images': 5,
|
||||
'match3d-generate-views': 6,
|
||||
'match3d-background-music': 7,
|
||||
'match3d-background-image': 8,
|
||||
'match3d-write-draft': 9,
|
||||
'match3d-background-image': 7,
|
||||
'match3d-write-draft': 8,
|
||||
};
|
||||
|
||||
const BABY_OBJECT_MATCH_STEPS = [
|
||||
@@ -269,8 +255,8 @@ const BABY_OBJECT_MATCH_STEPS = [
|
||||
},
|
||||
{
|
||||
id: 'baby-object-images',
|
||||
label: '生成物品图',
|
||||
detail: '为两个物品准备绘本风格图片资产。',
|
||||
label: '生成游戏素材',
|
||||
detail: '生成物品图、背景、礼物盒、篮子和界面包装。',
|
||||
weight: 68,
|
||||
},
|
||||
{
|
||||
@@ -392,25 +378,23 @@ function resolveMatch3DPhaseByElapsedMs(
|
||||
currentPhase: MiniGameDraftGenerationPhase,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
const elapsedPhase =
|
||||
elapsedMs >= 540_000
|
||||
elapsedMs >= 492_000
|
||||
? 'match3d-write-draft'
|
||||
: elapsedMs >= 460_000
|
||||
: elapsedMs >= 370_000
|
||||
? 'match3d-background-image'
|
||||
: elapsedMs >= 370_000
|
||||
? 'match3d-background-music'
|
||||
: elapsedMs >= 340_000
|
||||
? 'match3d-generate-views'
|
||||
: elapsedMs >= 260_000
|
||||
? 'match3d-upload-images'
|
||||
: elapsedMs >= 210_000
|
||||
? 'match3d-slice-images'
|
||||
: elapsedMs >= 28_000
|
||||
? 'match3d-material-sheet'
|
||||
: elapsedMs >= 12_000
|
||||
? 'match3d-background-prompt'
|
||||
: elapsedMs >= 4_000
|
||||
? 'match3d-item-names'
|
||||
: 'match3d-work-title';
|
||||
: elapsedMs >= 340_000
|
||||
? 'match3d-generate-views'
|
||||
: elapsedMs >= 260_000
|
||||
? 'match3d-upload-images'
|
||||
: elapsedMs >= 210_000
|
||||
? 'match3d-slice-images'
|
||||
: elapsedMs >= 28_000
|
||||
? 'match3d-material-sheet'
|
||||
: elapsedMs >= 12_000
|
||||
? 'match3d-background-prompt'
|
||||
: elapsedMs >= 4_000
|
||||
? 'match3d-item-names'
|
||||
: 'match3d-work-title';
|
||||
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
|
||||
const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1;
|
||||
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
|
||||
@@ -419,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) {
|
||||
@@ -579,9 +563,12 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? Math.max(0, 12_000 - elapsedMs)
|
||||
: normalizedState.kind === 'match3d'
|
||||
? Math.max(0, 10 * 60_000 - elapsedMs)
|
||||
? 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(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import {
|
||||
@@ -62,7 +63,7 @@ function boardPositionSignature(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
|
||||
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
let nextRun = run;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
const currentLevel = nextRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return nextRun;
|
||||
@@ -574,6 +575,34 @@ describe('puzzleLocalRuntime', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('本地试玩在只有 UI 背景 objectKey 时也能继承生成图', () => {
|
||||
const workWithRuntimeAssets: PuzzleWorkSummary = {
|
||||
...baseWork,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '第一关',
|
||||
pictureDescription: '第一关画面',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/level-1.png',
|
||||
coverAssetId: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
backgroundMusic: null,
|
||||
generationStatus: 'ready',
|
||||
} as PuzzleDraftLevel,
|
||||
],
|
||||
};
|
||||
|
||||
const run = startLocalPuzzleRun(workWithRuntimeAssets);
|
||||
|
||||
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('暂停和冻结时间不会消耗本地倒计时', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const pausedRun = setLocalPuzzlePaused(
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { resolvePuzzleUiBackgroundSource } from './puzzleUiBackgroundSource';
|
||||
|
||||
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
|
||||
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
|
||||
@@ -803,7 +804,10 @@ function buildFallbackLocalLevel(
|
||||
const nextCoverImageSrc =
|
||||
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
|
||||
const nextUiBackgroundImageSrc =
|
||||
nextLevel?.uiBackgroundImageSrc ?? currentLevel.uiBackgroundImageSrc;
|
||||
resolvePuzzleUiBackgroundSource(nextLevel) ?? currentLevel.uiBackgroundImageSrc;
|
||||
const nextUiBackgroundImageObjectKey = resolvePuzzleUiBackgroundSource(nextLevel)
|
||||
? nextLevel?.uiBackgroundImageObjectKey?.trim() || null
|
||||
: currentLevel.uiBackgroundImageObjectKey ?? null;
|
||||
const nextBackgroundMusic =
|
||||
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
|
||||
|
||||
@@ -835,6 +839,7 @@ function buildFallbackLocalLevel(
|
||||
elapsedMs: null,
|
||||
coverImageSrc: nextCoverImageSrc,
|
||||
uiBackgroundImageSrc: nextUiBackgroundImageSrc,
|
||||
uiBackgroundImageObjectKey: nextUiBackgroundImageObjectKey,
|
||||
backgroundMusic: nextBackgroundMusic,
|
||||
...buildLevelTimerFields(nextLevelIndex),
|
||||
leaderboardEntries: [],
|
||||
@@ -860,7 +865,9 @@ export function startLocalPuzzleRun(
|
||||
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
|
||||
const firstLevelName = firstLevel?.levelName || item.levelName;
|
||||
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
|
||||
const firstUiBackgroundImageSrc = firstLevel?.uiBackgroundImageSrc ?? null;
|
||||
const firstUiBackgroundImageSrc = resolvePuzzleUiBackgroundSource(firstLevel);
|
||||
const firstUiBackgroundImageObjectKey =
|
||||
firstLevel?.uiBackgroundImageObjectKey?.trim() || null;
|
||||
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
|
||||
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
|
||||
return {
|
||||
@@ -882,6 +889,7 @@ export function startLocalPuzzleRun(
|
||||
themeTags: item.themeTags,
|
||||
coverImageSrc: firstCoverImageSrc,
|
||||
uiBackgroundImageSrc: firstUiBackgroundImageSrc,
|
||||
uiBackgroundImageObjectKey: firstUiBackgroundImageObjectKey,
|
||||
backgroundMusic: firstBackgroundMusic,
|
||||
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
|
||||
status: 'playing',
|
||||
|
||||
20
src/services/puzzle-runtime/puzzleUiBackgroundSource.ts
Normal file
20
src/services/puzzle-runtime/puzzleUiBackgroundSource.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
type PuzzleUiBackgroundFields = {
|
||||
uiBackgroundImageSrc?: string | null;
|
||||
uiBackgroundImageObjectKey?: string | null;
|
||||
};
|
||||
|
||||
export function resolvePuzzleUiBackgroundSource(
|
||||
level: PuzzleUiBackgroundFields | null | undefined,
|
||||
) {
|
||||
const imageSrc = level?.uiBackgroundImageSrc?.trim();
|
||||
if (imageSrc) {
|
||||
return imageSrc;
|
||||
}
|
||||
|
||||
const objectKey = level?.uiBackgroundImageObjectKey?.trim().replace(/^\/+/u, '');
|
||||
if (!objectKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/${objectKey}`;
|
||||
}
|
||||
@@ -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'}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user