Merge remote-tracking branch 'origin/master' into hermes/hermes-1e775b03
# Conflicts: # server-rs/crates/api-server/src/app.rs # server-rs/crates/api-server/src/creation_entry_config.rs # server-rs/crates/api-server/src/puzzle.rs # server-rs/crates/spacetime-client/src/lib.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
@@ -586,4 +586,46 @@ describe('apiClient', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses api error details.reason when details.message is absent', async () => {
|
||||
setStoredAccessToken('details-reason-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 503,
|
||||
body: JSON.stringify({
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: '服务暂不可用',
|
||||
details: {
|
||||
provider: 'vector-engine',
|
||||
reason: 'VECTOR_ENGINE_API_KEY 未配置',
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestJson(
|
||||
'/api/creation/match3d/sessions/test/actions',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'执行抓大鹅共创操作失败',
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: 'VECTOR_ENGINE_API_KEY 未配置',
|
||||
status: 503,
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
details: {
|
||||
provider: 'vector-engine',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
authEntry,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
changePassword,
|
||||
consumeAuthCallbackResult,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
redeemRegistrationInviteCode,
|
||||
revokeAuthSession,
|
||||
revokeAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
updateAuthProfile,
|
||||
@@ -154,6 +157,44 @@ describe('authService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('change password clears local auth session after backend success', async () => {
|
||||
window.localStorage.setItem(
|
||||
'genarrative:access-token',
|
||||
'jwt-before-password-change',
|
||||
);
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_1',
|
||||
publicUserCode: 'SY-00000001',
|
||||
username: 'phone_00000001',
|
||||
displayName: '旅人甲',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await changePassword(' old-password ', ' new-password ');
|
||||
|
||||
expect(user.id).toBe('user_1');
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/password/change',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
currentPassword: 'old-password',
|
||||
newPassword: 'new-password',
|
||||
}),
|
||||
}),
|
||||
'修改密码失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends phone login code through the auth endpoint', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -475,8 +516,15 @@ describe('authService', () => {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'usess_1',
|
||||
sessionIds: ['usess_1', 'usess_2'],
|
||||
sessionCount: 2,
|
||||
clientType: 'browser',
|
||||
clientRuntime: 'chrome',
|
||||
clientPlatform: 'windows',
|
||||
clientLabel: '网页端浏览器',
|
||||
deviceDisplayName: 'Windows / Chrome',
|
||||
miniProgramAppId: null,
|
||||
miniProgramEnv: null,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '127.0.*.*',
|
||||
isCurrent: true,
|
||||
@@ -490,6 +538,47 @@ describe('authService', () => {
|
||||
const sessions = await getAuthSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
const [session] = sessions;
|
||||
expect(session?.sessionIds).toEqual(['usess_1', 'usess_2']);
|
||||
expect(session?.sessionCount).toBe(2);
|
||||
});
|
||||
|
||||
it('revokes a single auth session by backend route', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||||
|
||||
await revokeAuthSession('usess_1');
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions/usess_1/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes grouped auth sessions once per unique session id', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||||
|
||||
await revokeAuthSessions([' usess_1 ', 'usess_2', 'usess_1', '']);
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(2);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/auth/sessions/usess_1/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/auth/sessions/usess_2/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads recent auth audit logs', async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatBindPhoneRequest,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
PublicUserSearchResponse,
|
||||
@@ -193,15 +194,16 @@ export async function redeemRegistrationInviteCode(inviteCode: string) {
|
||||
}
|
||||
|
||||
export async function bindWechatPhone(phone: string, code: string) {
|
||||
const payload: AuthWechatBindPhoneRequest = {
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
};
|
||||
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'绑定手机号失败',
|
||||
);
|
||||
@@ -287,6 +289,7 @@ export async function changePassword(
|
||||
'修改密码失败',
|
||||
);
|
||||
|
||||
clearAuthSession();
|
||||
return response.user;
|
||||
}
|
||||
|
||||
@@ -439,6 +442,16 @@ export async function revokeAuthSession(sessionId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeAuthSessions(sessionIds: string[]) {
|
||||
const uniqueSessionIds = Array.from(
|
||||
new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
uniqueSessionIds.map((sessionId) => revokeAuthSession(sessionId)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAuthAuditLogs() {
|
||||
const response = await requestJson<AuthAuditLogsResponse>(
|
||||
'/api/auth/audit-logs',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
fetchWithApiAuth: vi.fn(),
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
import { createCreationAgentClient } from './creationAgentClientFactory';
|
||||
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ session: { sessionId: 'session-1' } });
|
||||
});
|
||||
|
||||
test('creation agent action requests are not auto-retried by default', async () => {
|
||||
const client = createCreationAgentClient<
|
||||
Record<string, never>,
|
||||
{ session: { sessionId: string } },
|
||||
{ session: { sessionId: string } },
|
||||
{ sessionId: string },
|
||||
{ text: string },
|
||||
{ session: { sessionId: string } },
|
||||
{ action: string },
|
||||
{ session: { sessionId: string } }
|
||||
>({
|
||||
apiBase: '/api/runtime/puzzle/agent/sessions',
|
||||
messages: {
|
||||
createSession: '创建失败',
|
||||
getSession: '读取失败',
|
||||
sendMessage: '发送失败',
|
||||
streamIncomplete: '流式结果不完整',
|
||||
executeAction: '执行失败',
|
||||
},
|
||||
});
|
||||
|
||||
await client.executeAction('session-1', { action: 'compile_puzzle_draft' });
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/agent/sessions/session-1/actions',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'执行失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 0 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ type CreationAgentClientOptions = {
|
||||
executeActionTimeoutMs?: number;
|
||||
readRetry?: ApiRetryOptions;
|
||||
writeRetry?: ApiRetryOptions;
|
||||
executeActionRetry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
const DEFAULT_CREATION_AGENT_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -37,6 +38,10 @@ const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = {
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
const DEFAULT_CREATION_AGENT_ACTION_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 0,
|
||||
};
|
||||
|
||||
function buildJsonPostInit(payload: unknown): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
@@ -68,6 +73,28 @@ async function openCreationAgentSsePost(
|
||||
return response;
|
||||
}
|
||||
|
||||
type CreationAgentNormalizedStreamEvent =
|
||||
| {
|
||||
kind: 'reply_delta';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
kind: 'session';
|
||||
session: unknown;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
}
|
||||
| null;
|
||||
|
||||
type CreationAgentStreamOptions = TextStreamOptions & {
|
||||
normalizeEvent?: (
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
) => CreationAgentNormalizedStreamEvent;
|
||||
};
|
||||
|
||||
/**
|
||||
* 三类作品创作 Agent 都遵循同一组 HTTP/SSE 端点形状。
|
||||
* 这里统一请求骨架,玩法 client 只保留路径、类型与中文错误文案差异。
|
||||
@@ -88,6 +115,7 @@ export function createCreationAgentClient<
|
||||
executeActionTimeoutMs,
|
||||
readRetry = DEFAULT_CREATION_AGENT_READ_RETRY,
|
||||
writeRetry = DEFAULT_CREATION_AGENT_WRITE_RETRY,
|
||||
executeActionRetry = DEFAULT_CREATION_AGENT_ACTION_RETRY,
|
||||
}: CreationAgentClientOptions) {
|
||||
const createSession = (
|
||||
payload: TCreateSessionPayload,
|
||||
@@ -128,7 +156,7 @@ export function createCreationAgentClient<
|
||||
const streamMessage = async (
|
||||
sessionId: string,
|
||||
payload: TSendMessagePayload,
|
||||
options: TextStreamOptions = {},
|
||||
options: CreationAgentStreamOptions = {},
|
||||
): Promise<TSession> => {
|
||||
const response = await openCreationAgentSsePost(
|
||||
`${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
@@ -153,7 +181,7 @@ export function createCreationAgentClient<
|
||||
buildJsonPostInit(payload),
|
||||
messages.executeAction,
|
||||
{
|
||||
retry: writeRetry,
|
||||
retry: executeActionRetry,
|
||||
timeoutMs: executeActionTimeoutMs,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { readCreationAgentSessionFromSse } from './creationAgentSse';
|
||||
import {
|
||||
normalizeVisualNovelAgentStreamEvent,
|
||||
readCreationAgentSessionFromSse,
|
||||
} from './creationAgentSse';
|
||||
|
||||
function createChunkedStreamResponse(chunks: Uint8Array[]) {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
@@ -76,3 +79,51 @@ test('readCreationAgentSessionFromSse keeps streamed updates before error event'
|
||||
|
||||
expect(updates).toEqual(['先把方洞万能的反差定住。']);
|
||||
});
|
||||
|
||||
test('readCreationAgentSessionFromSse can normalize typed visual novel stream events', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const session = {
|
||||
sessionId: 'vn-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
progressPercent: 100,
|
||||
stage: 'draft_ready',
|
||||
};
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
const response = createChunkedStreamResponse([
|
||||
encoder.encode(
|
||||
'data: {"type":"start","sessionId":"vn-session-1"}\n\n' +
|
||||
'data: {"type":"phase","phase":"synthesis"}\n\n' +
|
||||
'data: {"type":"text_delta","text":"视觉小说底稿已生成。"}\n\n' +
|
||||
`data: ${JSON.stringify({ type: 'complete', session })}\n\n` +
|
||||
'data: {"type":"done"}\n\n',
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readCreationAgentSessionFromSse(response, {
|
||||
fallbackMessage: '发送失败',
|
||||
incompleteMessage: '结果不完整',
|
||||
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
|
||||
onUpdate,
|
||||
}),
|
||||
).resolves.toEqual(session);
|
||||
expect(onUpdate).toHaveBeenCalledWith('视觉小说底稿已生成。');
|
||||
});
|
||||
|
||||
test('readCreationAgentSessionFromSse surfaces typed visual novel error events', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const response = createChunkedStreamResponse([
|
||||
encoder.encode(
|
||||
'data: {"type":"error","message":"视觉小说流式创作失败","retryable":true}\n\n',
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readCreationAgentSessionFromSse(response, {
|
||||
fallbackMessage: '发送失败',
|
||||
incompleteMessage: '结果不完整',
|
||||
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
|
||||
}),
|
||||
).rejects.toThrow('视觉小说流式创作失败');
|
||||
});
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
|
||||
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||
fallbackMessage: string;
|
||||
incompleteMessage: string;
|
||||
resolveSession?: (rawSession: unknown) => TSession | null;
|
||||
normalizeEvent?: (
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
) =>
|
||||
| {
|
||||
kind: 'reply_delta';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
kind: 'session';
|
||||
session: unknown;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
}
|
||||
| null;
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
@@ -65,6 +83,66 @@ function parseJsonObject(data: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type NormalizedCreationAgentSseEvent = NonNullable<
|
||||
CreationAgentSseOptions<unknown>['normalizeEvent']
|
||||
> extends (eventName: string, parsed: Record<string, unknown>) => infer TResult
|
||||
? TResult
|
||||
: never;
|
||||
|
||||
function normalizeDefaultCreationAgentEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
): NormalizedCreationAgentSseEvent {
|
||||
if (eventName === 'reply_delta') {
|
||||
const text = parsed.text;
|
||||
return typeof text === 'string' ? { kind: 'reply_delta', text } : null;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed.session) {
|
||||
return { kind: 'session', session: parsed.session };
|
||||
}
|
||||
|
||||
if (eventName === 'error') {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: '';
|
||||
return { kind: 'error', message };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeVisualNovelAgentStreamEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
): NormalizedCreationAgentSseEvent {
|
||||
const typedEventName =
|
||||
eventName === 'message' && typeof parsed.type === 'string'
|
||||
? parsed.type
|
||||
: eventName;
|
||||
const event = {
|
||||
...parsed,
|
||||
type: typedEventName,
|
||||
} as VisualNovelAgentStreamEvent;
|
||||
|
||||
switch (event.type) {
|
||||
case 'text_delta':
|
||||
return typeof event.text === 'string'
|
||||
? { kind: 'reply_delta', text: event.text }
|
||||
: null;
|
||||
case 'complete':
|
||||
return event.session ? { kind: 'session', session: event.session } : null;
|
||||
case 'error':
|
||||
return {
|
||||
kind: 'error',
|
||||
message: event.message.trim(),
|
||||
};
|
||||
default:
|
||||
return normalizeDefaultCreationAgentEvent(eventName, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCreationAgentSessionFromSse<TSession>(
|
||||
response: Response,
|
||||
options: CreationAgentSseOptions<TSession>,
|
||||
@@ -81,15 +159,10 @@ export async function readCreationAgentSessionFromSse<TSession>(
|
||||
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
||||
let buffer = '';
|
||||
let finalSession: TSession | null = null;
|
||||
const normalizeEvent =
|
||||
options.normalizeEvent ?? normalizeDefaultCreationAgentEvent;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
@@ -105,70 +178,40 @@ export async function readCreationAgentSessionFromSse<TSession>(
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeEvent(eventName, parsed);
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
if (normalized?.kind === 'reply_delta') {
|
||||
options.onUpdate?.(normalized.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = resolveSession(parsed.session);
|
||||
if (normalized?.kind === 'session') {
|
||||
finalSession = resolveSession(normalized.session);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: options.fallbackMessage;
|
||||
throw new Error(message);
|
||||
if (normalized?.kind === 'error') {
|
||||
throw new Error(normalized.message || options.fallbackMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
}
|
||||
|
||||
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
||||
buffer += decoder.decode();
|
||||
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = resolveSession(parsed.session);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: options.fallbackMessage;
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
consumeBuffer();
|
||||
|
||||
if (!finalSession) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
|
||||
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';
|
||||
@@ -0,0 +1,328 @@
|
||||
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', () => {
|
||||
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);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: ' 苹果 ',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
|
||||
expect(response.draft.templateName).toBe('宝贝识物');
|
||||
expect(response.draft.itemNames).toEqual(['苹果', '香蕉']);
|
||||
expect(response.draft.itemAssets).toHaveLength(2);
|
||||
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
|
||||
'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,
|
||||
);
|
||||
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({
|
||||
itemAName: '苹果',
|
||||
itemBName: ' ',
|
||||
}),
|
||||
).rejects.toThrow('请填写两个物品名称。');
|
||||
});
|
||||
|
||||
test('publish normalizes exact edutainment tag into payload', async () => {
|
||||
stubSuccessfulAssetGeneration();
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: '杯子',
|
||||
itemBName: '勺子',
|
||||
});
|
||||
const published = await publishBabyObjectMatchWork({
|
||||
draft: {
|
||||
...response.draft,
|
||||
themeTags: ['儿童教育', '寓教于乐 '],
|
||||
},
|
||||
});
|
||||
|
||||
expect(published.publicWorkCode).toMatch(/^BO-/u);
|
||||
expect(published.draft.publicationStatus).toBe('published');
|
||||
expect(published.draft.themeTags[0]).toBe(
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
);
|
||||
expect(hasBabyObjectMatchRequiredTag(published.draft.themeTags)).toBe(true);
|
||||
});
|
||||
|
||||
test('deletes local baby object match draft by profile id', async () => {
|
||||
stubSuccessfulAssetGeneration();
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: '苹果',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
|
||||
await expect(listLocalBabyObjectMatchDrafts()).resolves.toHaveLength(1);
|
||||
|
||||
const nextItems = await deleteLocalBabyObjectMatchDraft(
|
||||
response.draft.profileId,
|
||||
);
|
||||
|
||||
expect(nextItems).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,
|
||||
);
|
||||
});
|
||||
});
|
||||
461
src/services/edutainment-baby-object/babyObjectMatchClient.ts
Normal file
461
src/services/edutainment-baby-object/babyObjectMatchClient.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
BabyObjectMatchItemAsset,
|
||||
BabyObjectMatchPublishRequest,
|
||||
BabyObjectMatchPublishResponse,
|
||||
BabyObjectMatchVisualAsset,
|
||||
BabyObjectMatchVisualAssetKind,
|
||||
BabyObjectMatchVisualPackage,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
GenerateBabyObjectMatchAssetsResponse,
|
||||
SaveBabyObjectMatchDraftRequest,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
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 canUseIndexedDb() {
|
||||
return typeof indexedDB !== 'undefined';
|
||||
}
|
||||
|
||||
function readLegacyLocalDraftStore(): LocalDraftStore {
|
||||
if (!canUseLocalStorage()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!rawValue) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(rawValue) as LocalDraftStore;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
([profileId]) => !ignoredLegacyProfileIds.has(profileId),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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 normalizeGeneratedAssets(
|
||||
assets: BabyObjectMatchItemAsset[] | null | undefined,
|
||||
itemNames: [string, string],
|
||||
): [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset] | null {
|
||||
if (!Array.isArray(assets) || assets.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedAssets = assets.map((asset, index) => ({
|
||||
...asset,
|
||||
itemId: `baby-object-item-${index + 1}`,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
draft: BabyObjectMatchDraft,
|
||||
): BabyObjectMatchDraft {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
...draft,
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: draft.workTitle.trim() || '宝贝识物',
|
||||
workDescription: draft.workDescription.trim(),
|
||||
itemNames: [draft.itemNames[0].trim(), draft.itemNames[1].trim()],
|
||||
itemAssets: [
|
||||
{
|
||||
...draft.itemAssets[0],
|
||||
itemName: draft.itemNames[0].trim(),
|
||||
},
|
||||
{
|
||||
...draft.itemAssets[1],
|
||||
itemName: draft.itemNames[1].trim(),
|
||||
},
|
||||
],
|
||||
visualPackage: draft.visualPackage ?? null,
|
||||
themeTags: normalizeBabyObjectMatchTags(draft.themeTags),
|
||||
updatedAt: draft.updatedAt || now,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
const validated = validateBabyObjectMatchItemNames(payload);
|
||||
if (!validated.valid) {
|
||||
throw new Error('请填写两个物品名称。');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const draftId = createLocalId('baby-object-draft');
|
||||
const profileId = createLocalId('baby-object-profile');
|
||||
const itemNames: [string, string] = [
|
||||
validated.itemAName,
|
||||
validated.itemBName,
|
||||
];
|
||||
const generated = await generateBabyObjectMatchAssets(itemNames);
|
||||
const draft = normalizeBabyObjectMatchDraft({
|
||||
draftId,
|
||||
profileId,
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: `${itemNames[0]}和${itemNames[1]}识物分类`,
|
||||
itemNames,
|
||||
itemAssets: generated.assets,
|
||||
visualPackage: generated.visualPackage,
|
||||
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
|
||||
publicationStatus: 'draft',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
});
|
||||
|
||||
await saveDraftToLocalStore(draft);
|
||||
return { draft };
|
||||
}
|
||||
|
||||
export async function saveBabyObjectMatchDraft(
|
||||
payload: SaveBabyObjectMatchDraftRequest,
|
||||
) {
|
||||
const draft = normalizeBabyObjectMatchDraft({
|
||||
...payload.draft,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await saveDraftToLocalStore(draft);
|
||||
|
||||
return { draft };
|
||||
}
|
||||
|
||||
export async function publishBabyObjectMatchWork(
|
||||
payload: BabyObjectMatchPublishRequest,
|
||||
): Promise<BabyObjectMatchPublishResponse> {
|
||||
const draft = normalizeBabyObjectMatchDraft({
|
||||
...payload.draft,
|
||||
publicationStatus: 'published',
|
||||
publishedAt: payload.draft.publishedAt ?? new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await saveDraftToLocalStore(draft);
|
||||
|
||||
return {
|
||||
draft,
|
||||
publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLocalBabyObjectMatchDrafts() {
|
||||
return Object.values(await readLocalDraftStore()).sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteLocalBabyObjectMatchDraft(profileId: string) {
|
||||
const normalizedProfileId = profileId.trim();
|
||||
if (!normalizedProfileId) {
|
||||
return await listLocalBabyObjectMatchDrafts();
|
||||
}
|
||||
|
||||
ignoredLegacyProfileIds.add(normalizedProfileId);
|
||||
delete memoryDraftStore[normalizedProfileId];
|
||||
await deleteIndexedDbDraft(normalizedProfileId);
|
||||
|
||||
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
src/services/edutainment-baby-object/index.ts
Normal file
1
src/services/edutainment-baby-object/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './babyObjectMatchClient';
|
||||
@@ -2,6 +2,8 @@ export {
|
||||
buildLocalMatch3DOptimisticRun,
|
||||
confirmLocalMatch3DClick,
|
||||
MATCH3D_VISUAL_SEEDS,
|
||||
normalizeLocalMatch3DRuntimeClearCount,
|
||||
resolveLocalMatch3DItemTypeCount,
|
||||
resolveLocalMatch3DTimer,
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
|
||||
@@ -221,8 +221,23 @@ function resolveSizeTierPlan(typeCount: number) {
|
||||
return baseCounts.flatMap((rule) => Array(rule.count).fill(rule));
|
||||
}
|
||||
|
||||
export function resolveLocalMatch3DItemTypeCount(clearCount: number) {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
if (normalizedClearCount === 8) return 3;
|
||||
if (normalizedClearCount === 12) return 9;
|
||||
if (normalizedClearCount === 16) return 15;
|
||||
if (normalizedClearCount === 20 || normalizedClearCount === 21) return 21;
|
||||
return Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, normalizedClearCount);
|
||||
}
|
||||
|
||||
export function normalizeLocalMatch3DRuntimeClearCount(clearCount: number) {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
// 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩按新硬核 21 组三消执行。
|
||||
return normalizedClearCount === 20 ? 21 : normalizedClearCount;
|
||||
}
|
||||
|
||||
function selectVisualSeeds(clearCount: number): Match3DSelectedVisualSeed[] {
|
||||
const typeCount = Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, clearCount);
|
||||
const typeCount = resolveLocalMatch3DItemTypeCount(clearCount);
|
||||
const seeds = [...MATCH3D_VISUAL_SEEDS];
|
||||
let state = hashNumber(clearCount * 2_654_435_761);
|
||||
for (let index = seeds.length - 1; index > 0; index -= 1) {
|
||||
@@ -410,7 +425,7 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
}
|
||||
|
||||
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
const normalizedClearCount = normalizeLocalMatch3DRuntimeClearCount(clearCount);
|
||||
const selectedSeeds = selectVisualSeeds(normalizedClearCount);
|
||||
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
|
||||
Array.from({ length: 3 }, (_, copyOffset) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
Match3DClickRejectReason,
|
||||
Match3DClickResponse,
|
||||
Match3DRunResponse,
|
||||
StartMatch3DRunRequest,
|
||||
StopMatch3DRunRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
@@ -30,7 +31,9 @@ export type Match3DRuntimeRequestOptions = Pick<
|
||||
| 'skipRefresh'
|
||||
| 'notifyAuthStateChange'
|
||||
| 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
> & {
|
||||
itemTypeCountOverride?: number | null;
|
||||
};
|
||||
|
||||
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
|
||||
switch (reason) {
|
||||
@@ -73,12 +76,17 @@ export function startMatch3DRun(
|
||||
profileId: string,
|
||||
options: Match3DRuntimeRequestOptions = {},
|
||||
) {
|
||||
const payload: StartMatch3DRunRequest = {
|
||||
profileId,
|
||||
itemTypeCountOverride: options.itemTypeCountOverride ?? null,
|
||||
};
|
||||
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profileId }),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'启动抓大鹅玩法失败',
|
||||
{
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
export {
|
||||
deleteMatch3DWork,
|
||||
generateMatch3DBackgroundImage,
|
||||
generateMatch3DCoverImage,
|
||||
generateMatch3DItemAssets,
|
||||
generateMatch3DWorkTags,
|
||||
getMatch3DWorkDetail,
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
match3dWorksClient,
|
||||
persistMatch3DGeneratedModel,
|
||||
publishMatch3DWork,
|
||||
updateMatch3DAudioAssets,
|
||||
updateMatch3DGeneratedItemAssets,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type {
|
||||
GenerateMatch3DBackgroundImageRequest,
|
||||
GenerateMatch3DBackgroundImageResponse,
|
||||
GenerateMatch3DCoverImageRequest,
|
||||
GenerateMatch3DCoverImageResponse,
|
||||
GenerateMatch3DItemAssetsRequest,
|
||||
GenerateMatch3DItemAssetsResponse,
|
||||
GenerateMatch3DWorkTagsRequest,
|
||||
GenerateMatch3DWorkTagsResponse,
|
||||
Match3DWorkDetailResponse,
|
||||
Match3DWorkMutationResponse,
|
||||
Match3DWorksResponse,
|
||||
PersistMatch3DGeneratedModelRequest,
|
||||
PersistMatch3DGeneratedModelResponse,
|
||||
PutMatch3DAudioAssetsRequest,
|
||||
PutMatch3DWorkRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
@@ -103,10 +111,100 @@ export function updateMatch3DGeneratedItemAssets(
|
||||
|
||||
export const updateMatch3DAudioAssets = updateMatch3DGeneratedItemAssets;
|
||||
|
||||
/**
|
||||
* 将历史外部 GLB 链接转存为抓大鹅私有模型资产;新草稿不再调用。
|
||||
*/
|
||||
export function persistMatch3DGeneratedModel(
|
||||
profileId: string,
|
||||
payload: PersistMatch3DGeneratedModelRequest,
|
||||
) {
|
||||
return requestJson<PersistMatch3DGeneratedModelResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/generated-models`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'保存抓大鹅历史模型失败',
|
||||
{
|
||||
retry: MATCH3D_WORKS_WRITE_RETRY,
|
||||
timeoutMs: 240_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成并保存抓大鹅作品封面图。
|
||||
*/
|
||||
export function generateMatch3DCoverImage(
|
||||
profileId: string,
|
||||
payload: GenerateMatch3DCoverImageRequest,
|
||||
) {
|
||||
return requestJson<GenerateMatch3DCoverImageResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/cover-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成抓大鹅封面图失败',
|
||||
{
|
||||
retry: MATCH3D_WORKS_WRITE_RETRY,
|
||||
timeoutMs: 240_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按画面描述重新生成并保存抓大鹅局内 UI 背景图。
|
||||
*/
|
||||
export function generateMatch3DBackgroundImage(
|
||||
profileId: string,
|
||||
payload: GenerateMatch3DBackgroundImageRequest,
|
||||
) {
|
||||
return requestJson<GenerateMatch3DBackgroundImageResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/background-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成抓大鹅背景图失败',
|
||||
{
|
||||
retry: MATCH3D_WORKS_WRITE_RETRY,
|
||||
timeoutMs: 240_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称批量生成抓大鹅 2D 五视角物品图片。
|
||||
*/
|
||||
export function generateMatch3DItemAssets(
|
||||
profileId: string,
|
||||
payload: GenerateMatch3DItemAssetsRequest,
|
||||
) {
|
||||
return requestJson<GenerateMatch3DItemAssetsResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/item-assets`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成抓大鹅物品素材失败',
|
||||
{
|
||||
retry: MATCH3D_WORKS_WRITE_RETRY,
|
||||
timeoutMs: 20 * 60 * 1000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前作品名称与题材生成发布标签。
|
||||
*/
|
||||
export function generateMatch3DWorkTags(payload: GenerateMatch3DWorkTagsRequest) {
|
||||
export function generateMatch3DWorkTags(
|
||||
payload: GenerateMatch3DWorkTagsRequest,
|
||||
) {
|
||||
return requestJson<GenerateMatch3DWorkTagsResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/tags`,
|
||||
{
|
||||
@@ -145,10 +243,14 @@ export function deleteMatch3DWork(profileId: string) {
|
||||
|
||||
export const match3dWorksClient = {
|
||||
delete: deleteMatch3DWork,
|
||||
generateBackgroundImage: generateMatch3DBackgroundImage,
|
||||
generateCoverImage: generateMatch3DCoverImage,
|
||||
generateItemAssets: generateMatch3DItemAssets,
|
||||
generateTags: generateMatch3DWorkTags,
|
||||
getDetail: getMatch3DWorkDetail,
|
||||
listGallery: listMatch3DGallery,
|
||||
list: listMatch3DWorks,
|
||||
persistGeneratedModel: persistMatch3DGeneratedModel,
|
||||
publish: publishMatch3DWork,
|
||||
updateAudioAssets: updateMatch3DAudioAssets,
|
||||
updateGeneratedItemAssets: updateMatch3DGeneratedItemAssets,
|
||||
|
||||
350
src/services/match3dGeneratedModelCache.test.ts
Normal file
350
src/services/match3dGeneratedModelCache.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { setStoredAccessToken, clearStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
clearMatch3DGeneratedModelBytesCache,
|
||||
getMatch3DGeneratedImageViewSources,
|
||||
getMatch3DGeneratedImageAssetSources,
|
||||
getMatch3DGeneratedModelAssetSources,
|
||||
hasMatch3DGeneratedImageAsset,
|
||||
mergeMatch3DGeneratedItemAssetsForRuntime,
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
||||
preloadMatch3DGeneratedImageAssets,
|
||||
preloadMatch3DGeneratedModelAssets,
|
||||
readMatch3DGeneratedModelBytes,
|
||||
} from './match3dGeneratedModelCache';
|
||||
|
||||
describe('match3dGeneratedModelCache', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearMatch3DGeneratedModelBytesCache();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
test('预加载生成模型字节并复用本地缓存', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([103, 108, 84, 70]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'model/gltf-binary',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preloadMatch3DGeneratedModelAssets(
|
||||
[
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
modelSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
|
||||
modelObjectKey: null,
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const bytes = await readMatch3DGeneratedModelBytes(
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
|
||||
expect(Array.from(new Uint8Array(bytes))).toEqual([103, 108, 84, 70]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('模型源列表会去重并兼容 modelObjectKey', () => {
|
||||
const sources = getMatch3DGeneratedModelAssetSources([
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
modelSrc: null,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
itemId: 'match3d-item-1-duplicate',
|
||||
itemName: '草莓副本',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
modelSrc:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
|
||||
modelObjectKey: null,
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sources).toEqual([
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
|
||||
]);
|
||||
});
|
||||
|
||||
test('同时存在外部 modelSrc 和平台 modelObjectKey 时优先预加载平台对象', () => {
|
||||
const sources = getMatch3DGeneratedModelAssetSources([
|
||||
{
|
||||
itemId: 'match3d-item-legacy',
|
||||
itemName: '苹果',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
modelSrc: 'https://rodin.example.com/expired/model.glb',
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-legacy/model.glb',
|
||||
modelFileName: 'apple.glb',
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sources).toEqual([
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-legacy/model.glb',
|
||||
]);
|
||||
});
|
||||
|
||||
test('多视角图片源优先使用 imageViews,兼容首图只做兜底', () => {
|
||||
const sources = getMatch3DGeneratedImageViewSources({
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/item-1/legacy-primary.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/item-1/legacy-primary.png',
|
||||
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
|
||||
viewId: `view-${String(viewIndex).padStart(2, '0')}`,
|
||||
viewIndex,
|
||||
imageSrc: `/generated-match3d-assets/session/profile/items/item-1/views/view-${String(viewIndex).padStart(2, '0')}.png`,
|
||||
imageObjectKey: null,
|
||||
})),
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(sources).toHaveLength(5);
|
||||
expect(sources[0]).toContain('views/view-01.png');
|
||||
expect(sources[4]).toContain('views/view-05.png');
|
||||
expect(sources.some((source) => source.includes('legacy-primary'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态图片素材判断只认物品图片,不把背景或音频当物品素材', () => {
|
||||
expect(
|
||||
hasMatch3DGeneratedImageAsset([
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
backgroundMusic: {
|
||||
taskId: 'music-task-1',
|
||||
provider: 'vector-engine-suno',
|
||||
assetObjectId: 'asset-music-1',
|
||||
assetKind: 'match3d_background_music',
|
||||
audioSrc:
|
||||
'/generated-match3d-assets/session/profile/audio/background.mp3',
|
||||
prompt: '',
|
||||
title: '果园轻舞',
|
||||
updatedAt: '2026-05-13T10:00:00.000Z',
|
||||
},
|
||||
backgroundAsset: {
|
||||
prompt: '果园背景',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
imageObjectKey: null,
|
||||
containerPrompt: '果园浅盘',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
containerImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
hasMatch3DGeneratedImageAsset([
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'view-01',
|
||||
viewIndex: 1,
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
|
||||
imageObjectKey: null,
|
||||
},
|
||||
],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('运行态预加载使用 2D 图片源而不是旧模型源', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
signedUrl: 'https://oss.example.com/view-01.png',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
);
|
||||
const assets = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'view-01',
|
||||
viewIndex: 1,
|
||||
imageSrc: null,
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
|
||||
},
|
||||
],
|
||||
modelSrc:
|
||||
'/generated-match3d-assets/session/profile/items/item-1/model/model.glb',
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
];
|
||||
|
||||
expect(getMatch3DGeneratedImageAssetSources(assets)).toEqual([
|
||||
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
|
||||
]);
|
||||
await preloadMatch3DGeneratedImageAssets(assets, { expireSeconds: 300 });
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'views%2Fview-01.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('作品级背景音乐会归一化到首个抓大鹅素材', () => {
|
||||
const assets = normalizeMatch3DGeneratedItemAssetsForRuntime([
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/match3d/strawberry.png',
|
||||
imageObjectKey: null,
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
{
|
||||
itemId: 'match3d-item-2',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/match3d/apple.png',
|
||||
imageObjectKey: null,
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
backgroundMusicTitle: '果园轻舞',
|
||||
backgroundMusic: {
|
||||
taskId: 'music-task-1',
|
||||
provider: 'vector-engine-suno',
|
||||
assetObjectId: 'asset-music-1',
|
||||
assetKind: 'match3d_background_music',
|
||||
audioSrc: '/generated-match3d-assets/audio/music.mp3',
|
||||
prompt: '',
|
||||
title: '果园轻舞',
|
||||
updatedAt: '2026-05-14T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(assets[0]?.backgroundMusic?.audioSrc).toBe(
|
||||
'/generated-match3d-assets/audio/music.mp3',
|
||||
);
|
||||
expect(assets[0]?.backgroundMusicTitle).toBe('果园轻舞');
|
||||
expect(assets[1]?.backgroundMusic).toBeNull();
|
||||
});
|
||||
|
||||
test('合并 action 草稿和作品详情时保留详情里的背景音乐', () => {
|
||||
const assets = mergeMatch3DGeneratedItemAssetsForRuntime(
|
||||
[
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/match3d/strawberry.png',
|
||||
imageObjectKey: null,
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/match3d/strawberry.png',
|
||||
imageObjectKey: null,
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
backgroundMusic: {
|
||||
taskId: 'music-task-1',
|
||||
provider: 'vector-engine-suno',
|
||||
assetObjectId: 'asset-music-1',
|
||||
assetKind: 'match3d_background_music',
|
||||
audioSrc: '/generated-match3d-assets/audio/music.mp3',
|
||||
prompt: '',
|
||||
title: '果园轻舞',
|
||||
updatedAt: '2026-05-14T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]?.backgroundMusic?.audioSrc).toBe(
|
||||
'/generated-match3d-assets/audio/music.mp3',
|
||||
);
|
||||
});
|
||||
});
|
||||
390
src/services/match3dGeneratedModelCache.ts
Normal file
390
src/services/match3dGeneratedModelCache.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import type { Match3DGeneratedItemAsset } from '../../packages/shared/src/contracts/match3dWorks';
|
||||
import { readAssetBytes, resolveAssetReadUrl } from './assetReadUrlService';
|
||||
|
||||
type CachedMatch3DModelBytes = {
|
||||
accessedAt: number;
|
||||
promise: Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
type Match3DModelBytesOptions = {
|
||||
expireSeconds?: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const MATCH3D_MODEL_BYTES_CACHE_LIMIT = 36;
|
||||
const match3dModelBytesCache = new Map<string, CachedMatch3DModelBytes>();
|
||||
|
||||
function normalizeMatch3DModelSource(source: string | null | undefined) {
|
||||
return source?.trim() ?? '';
|
||||
}
|
||||
|
||||
function isExternalMatch3DModelSource(source: string) {
|
||||
return /^(?:https?:)?\/\//iu.test(source.trim());
|
||||
}
|
||||
|
||||
function trimMatch3DModelBytesCache() {
|
||||
if (match3dModelBytesCache.size <= MATCH3D_MODEL_BYTES_CACHE_LIMIT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const staleKeys = [...match3dModelBytesCache.entries()]
|
||||
.sort((left, right) => left[1].accessedAt - right[1].accessedAt)
|
||||
.slice(0, match3dModelBytesCache.size - MATCH3D_MODEL_BYTES_CACHE_LIMIT)
|
||||
.map(([source]) => source);
|
||||
staleKeys.forEach((source) => match3dModelBytesCache.delete(source));
|
||||
}
|
||||
|
||||
function waitWithAbort<T>(promise: Promise<T>, signal?: AbortSignal) {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(new DOMException('加载已取消', 'AbortError'));
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const handleAbort = () => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
reject(new DOMException('加载已取消', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMatch3DGeneratedModelAssetSource(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
// 中文注释:历史草稿可能同时保留已过期的 Rodin 外部 modelSrc 和后续修复出的平台 objectKey;
|
||||
// 试玩、正式游戏和预览都必须优先读取平台私有对象,避免继续请求过期外链。
|
||||
const modelSrc = normalizeMatch3DModelSource(asset.modelSrc);
|
||||
const objectKey = normalizeMatch3DModelSource(asset.modelObjectKey);
|
||||
if (modelSrc && (!isExternalMatch3DModelSource(modelSrc) || !objectKey)) {
|
||||
return modelSrc;
|
||||
}
|
||||
return objectKey || modelSrc;
|
||||
}
|
||||
|
||||
export function resolveMatch3DGeneratedImageViewSource(
|
||||
view:
|
||||
| NonNullable<Match3DGeneratedItemAsset['imageViews']>[number]
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
const imageSrc = normalizeMatch3DModelSource(view?.imageSrc);
|
||||
const objectKey = normalizeMatch3DModelSource(view?.imageObjectKey);
|
||||
return objectKey || imageSrc;
|
||||
}
|
||||
|
||||
export function getMatch3DGeneratedImageViewSources(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
const viewSources =
|
||||
asset.imageViews
|
||||
?.map(resolveMatch3DGeneratedImageViewSource)
|
||||
.filter((source) => source.length > 0) ?? [];
|
||||
if (viewSources.length > 0) {
|
||||
return [...new Set(viewSources)];
|
||||
}
|
||||
const primarySource =
|
||||
normalizeMatch3DModelSource(asset.imageObjectKey) ||
|
||||
normalizeMatch3DModelSource(asset.imageSrc);
|
||||
return primarySource ? [primarySource] : [];
|
||||
}
|
||||
|
||||
export function resolveMatch3DGeneratedImageAssetSource(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
return getMatch3DGeneratedImageViewSources(asset)[0] ?? '';
|
||||
}
|
||||
|
||||
export function getMatch3DGeneratedImageAssetSources(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
assets.flatMap((asset) => getMatch3DGeneratedImageViewSources(asset)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function hasMatch3DGeneratedImageAsset(
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
assets?.some((asset) => getMatch3DGeneratedImageViewSources(asset).length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function findMatch3DBackgroundMusicCarrier(
|
||||
assets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
return assets.find((asset) => asset.backgroundMusic?.audioSrc?.trim());
|
||||
}
|
||||
|
||||
function findMatch3DBackgroundMusicMetadataCarrier(
|
||||
assets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
return assets.find(
|
||||
(asset) =>
|
||||
asset.backgroundMusicTitle?.trim() ||
|
||||
asset.backgroundMusicStyle?.trim() ||
|
||||
asset.backgroundMusicPrompt?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓大鹅背景音乐当前暂存在 generatedItemAssets 里,但它表达的是作品级音乐。
|
||||
* 归一化到首个素材,避免前端只读首项时把已生成音乐显示成“暂无音乐”。
|
||||
*/
|
||||
export function normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
) {
|
||||
if (!assets?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const musicCarrier = findMatch3DBackgroundMusicCarrier(assets);
|
||||
const metadataCarrier =
|
||||
musicCarrier ?? findMatch3DBackgroundMusicMetadataCarrier(assets);
|
||||
if (!musicCarrier && !metadataCarrier) {
|
||||
return [...assets];
|
||||
}
|
||||
|
||||
return assets.map((asset, index) => {
|
||||
if (index !== 0) {
|
||||
if (
|
||||
!asset.backgroundMusic &&
|
||||
!asset.backgroundMusicTitle &&
|
||||
!asset.backgroundMusicStyle &&
|
||||
!asset.backgroundMusicPrompt
|
||||
) {
|
||||
return asset;
|
||||
}
|
||||
return {
|
||||
...asset,
|
||||
backgroundMusic: null,
|
||||
backgroundMusicTitle: null,
|
||||
backgroundMusicStyle: null,
|
||||
backgroundMusicPrompt: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...asset,
|
||||
backgroundMusic:
|
||||
asset.backgroundMusic ?? musicCarrier?.backgroundMusic ?? null,
|
||||
backgroundMusicTitle:
|
||||
asset.backgroundMusicTitle ??
|
||||
metadataCarrier?.backgroundMusicTitle ??
|
||||
musicCarrier?.backgroundMusic?.title ??
|
||||
null,
|
||||
backgroundMusicStyle:
|
||||
asset.backgroundMusicStyle ??
|
||||
metadataCarrier?.backgroundMusicStyle ??
|
||||
null,
|
||||
backgroundMusicPrompt:
|
||||
asset.backgroundMusicPrompt ??
|
||||
metadataCarrier?.backgroundMusicPrompt ??
|
||||
musicCarrier?.backgroundMusic?.prompt ??
|
||||
null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeMatch3DGeneratedItemAssetsForRuntime(
|
||||
primaryAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
fallbackAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
) {
|
||||
const primary = primaryAssets ?? [];
|
||||
const fallback = fallbackAssets ?? [];
|
||||
if (primary.length <= 0) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(fallback);
|
||||
}
|
||||
if (fallback.length <= 0) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(primary);
|
||||
}
|
||||
|
||||
const fallbackById = new Map(fallback.map((asset) => [asset.itemId, asset]));
|
||||
const merged = primary.map((asset) => {
|
||||
const fallbackAsset = fallbackById.get(asset.itemId);
|
||||
if (!fallbackAsset) {
|
||||
return asset;
|
||||
}
|
||||
const hasPrimaryImage = getMatch3DGeneratedImageViewSources(asset).length > 0;
|
||||
const hasPrimaryModel = resolveMatch3DGeneratedModelAssetSource(asset).length > 0;
|
||||
return {
|
||||
...asset,
|
||||
itemName: asset.itemName.trim() || fallbackAsset.itemName,
|
||||
imageSrc: asset.imageSrc?.trim()
|
||||
? asset.imageSrc
|
||||
: (fallbackAsset.imageSrc ?? null),
|
||||
imageObjectKey: asset.imageObjectKey?.trim()
|
||||
? asset.imageObjectKey
|
||||
: (fallbackAsset.imageObjectKey ?? null),
|
||||
imageViews:
|
||||
asset.imageViews && asset.imageViews.length > 0
|
||||
? asset.imageViews
|
||||
: (fallbackAsset.imageViews ?? []),
|
||||
modelSrc: asset.modelSrc?.trim()
|
||||
? asset.modelSrc
|
||||
: (fallbackAsset.modelSrc ?? null),
|
||||
modelObjectKey: asset.modelObjectKey?.trim()
|
||||
? asset.modelObjectKey
|
||||
: (fallbackAsset.modelObjectKey ?? null),
|
||||
modelFileName: asset.modelFileName?.trim()
|
||||
? asset.modelFileName
|
||||
: (fallbackAsset.modelFileName ?? null),
|
||||
taskUuid: asset.taskUuid?.trim()
|
||||
? asset.taskUuid
|
||||
: (fallbackAsset.taskUuid ?? null),
|
||||
subscriptionKey: asset.subscriptionKey?.trim()
|
||||
? asset.subscriptionKey
|
||||
: (fallbackAsset.subscriptionKey ?? null),
|
||||
backgroundMusic:
|
||||
asset.backgroundMusic ?? fallbackAsset.backgroundMusic ?? null,
|
||||
backgroundMusicTitle:
|
||||
asset.backgroundMusicTitle ?? fallbackAsset.backgroundMusicTitle ?? null,
|
||||
backgroundMusicStyle:
|
||||
asset.backgroundMusicStyle ?? fallbackAsset.backgroundMusicStyle ?? null,
|
||||
backgroundMusicPrompt:
|
||||
asset.backgroundMusicPrompt ??
|
||||
fallbackAsset.backgroundMusicPrompt ??
|
||||
null,
|
||||
backgroundAsset: asset.backgroundAsset ?? fallbackAsset.backgroundAsset ?? null,
|
||||
clickSound: asset.clickSound ?? fallbackAsset.clickSound ?? null,
|
||||
soundPrompt: asset.soundPrompt ?? fallbackAsset.soundPrompt ?? null,
|
||||
status:
|
||||
!hasPrimaryImage && !hasPrimaryModel && fallbackAsset.status
|
||||
? fallbackAsset.status
|
||||
: asset.status,
|
||||
error: asset.error ?? fallbackAsset.error ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
for (const fallbackAsset of fallback) {
|
||||
if (!merged.some((asset) => asset.itemId === fallbackAsset.itemId)) {
|
||||
merged.push(fallbackAsset);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(merged);
|
||||
}
|
||||
|
||||
export function getMatch3DGeneratedModelAssetSources(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
assets
|
||||
.map(resolveMatch3DGeneratedModelAssetSource)
|
||||
.filter((source) => source.length > 0),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function readMatch3DGeneratedModelBytes(
|
||||
source: string | null | undefined,
|
||||
options: Match3DModelBytesOptions = {},
|
||||
) {
|
||||
const normalizedSource = normalizeMatch3DModelSource(source);
|
||||
if (!normalizedSource) {
|
||||
return Promise.reject(new Error('抓大鹅 3D 模型路径不能为空'));
|
||||
}
|
||||
|
||||
const cached = match3dModelBytesCache.get(normalizedSource);
|
||||
if (cached) {
|
||||
cached.accessedAt = Date.now();
|
||||
return waitWithAbort(cached.promise, options.signal);
|
||||
}
|
||||
|
||||
const entry: CachedMatch3DModelBytes = {
|
||||
accessedAt: Date.now(),
|
||||
promise: readAssetBytes(normalizedSource, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
}).then(async (response) => {
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength <= 0) {
|
||||
throw new Error('抓大鹅 3D 模型内容为空');
|
||||
}
|
||||
return bytes;
|
||||
}),
|
||||
};
|
||||
match3dModelBytesCache.set(normalizedSource, entry);
|
||||
trimMatch3DModelBytesCache();
|
||||
|
||||
entry.promise.catch(() => {
|
||||
if (match3dModelBytesCache.get(normalizedSource) === entry) {
|
||||
match3dModelBytesCache.delete(normalizedSource);
|
||||
}
|
||||
});
|
||||
|
||||
return waitWithAbort(entry.promise, options.signal);
|
||||
}
|
||||
|
||||
export async function preloadMatch3DGeneratedModelSources(
|
||||
sources: readonly string[],
|
||||
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
|
||||
) {
|
||||
const normalizedSources = [
|
||||
...new Set(
|
||||
sources
|
||||
.map(normalizeMatch3DModelSource)
|
||||
.filter((source) => source.length > 0),
|
||||
),
|
||||
];
|
||||
await Promise.allSettled(
|
||||
normalizedSources.map((source) =>
|
||||
readMatch3DGeneratedModelBytes(source, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function preloadMatch3DGeneratedModelAssets(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
|
||||
) {
|
||||
return preloadMatch3DGeneratedModelSources(
|
||||
getMatch3DGeneratedModelAssetSources(assets),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function preloadMatch3DGeneratedImageAssets(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
|
||||
) {
|
||||
const sources = getMatch3DGeneratedImageAssetSources(assets);
|
||||
await Promise.allSettled(
|
||||
sources.map((source) =>
|
||||
resolveAssetReadUrl(source, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function preloadMatch3DGeneratedRuntimeAssets(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
|
||||
) {
|
||||
// 中文注释:新抓大鹅运行态以 2D 图片为主;3D 模型只作为历史草稿预览兼容。
|
||||
await preloadMatch3DGeneratedImageAssets(
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime(assets),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function clearMatch3DGeneratedModelBytesCache() {
|
||||
match3dModelBytesCache.clear();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBabyObjectMatchGenerationAnchorEntries,
|
||||
buildMatch3DGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
@@ -19,23 +20,26 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 1500);
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 2500);
|
||||
|
||||
expect(progress?.steps.map((step) => step.label)).toEqual([
|
||||
'编译首关草稿',
|
||||
'生成关卡名称',
|
||||
'生成首关画面',
|
||||
'生成背景音乐',
|
||||
'生成UI背景',
|
||||
'写入正式草稿',
|
||||
]);
|
||||
expect(progress?.phaseLabel).toBe('编译首关草稿');
|
||||
expect(progress?.steps[0]?.detail).toBe(
|
||||
'理解画面描述,生成首关名称与可编辑草稿。',
|
||||
'读取画面描述,建立可编辑草稿与首关结构。',
|
||||
);
|
||||
expect(progress?.estimatedRemainingMs).toBe(59_500);
|
||||
expect(progress?.estimatedRemainingMs).toBe(178_500);
|
||||
expect(progress?.overallProgress).toBeGreaterThan(0);
|
||||
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('puzzle draft generation advances steps across the 60 second estimate', () => {
|
||||
test('puzzle draft generation advances steps across the current asset pipeline', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
@@ -45,18 +49,23 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
error: null,
|
||||
};
|
||||
|
||||
const imageProgress = buildMiniGameDraftGenerationProgress(state, 16_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 56_000);
|
||||
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
|
||||
const musicProgress = buildMiniGameDraftGenerationProgress(state, 96_000);
|
||||
const uiProgress = buildMiniGameDraftGenerationProgress(state, 146_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 176_000);
|
||||
|
||||
expect(imageProgress?.phaseId).toBe('puzzle-images');
|
||||
expect(imageProgress?.estimatedRemainingMs).toBe(45_000);
|
||||
expect(imageProgress?.steps[0]?.status).toBe('completed');
|
||||
expect(imageProgress?.steps[1]?.status).toBe('active');
|
||||
expect(imageProgress?.steps[1]?.completed).toBeGreaterThan(0);
|
||||
expect(imageProgress?.estimatedRemainingMs).toBe(155_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[1]?.status).toBe('completed');
|
||||
expect(writeBackProgress?.steps[2]?.status).toBe('active');
|
||||
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
|
||||
expect(writeBackProgress?.steps[5]?.status).toBe('active');
|
||||
});
|
||||
|
||||
test('puzzle draft generation keeps moving without claiming completion before response', () => {
|
||||
@@ -69,12 +78,12 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 80_000);
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 200_000);
|
||||
|
||||
expect(progress?.phaseId).toBe('puzzle-select-image');
|
||||
expect(progress?.overallProgress).toBe(98);
|
||||
expect(progress?.estimatedRemainingMs).toBe(0);
|
||||
expect(progress?.steps[2]?.completed).toBe(1);
|
||||
expect(progress?.steps[5]?.completed).toBe(1);
|
||||
});
|
||||
|
||||
test('puzzle ready copy points to result page work info completion', () => {
|
||||
@@ -157,20 +166,24 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 17_000,
|
||||
state.startedAtMs + 30_000,
|
||||
);
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'match3d-work-title',
|
||||
'match3d-item-names',
|
||||
'match3d-background-prompt',
|
||||
'match3d-material-sheet',
|
||||
'match3d-slice-images',
|
||||
'match3d-upload-images',
|
||||
'match3d-generate-models',
|
||||
'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(583_000);
|
||||
expect(progress?.phaseLabel).toBe('分批生成素材图');
|
||||
expect(progress?.estimatedRemainingMs).toBe(570_000);
|
||||
});
|
||||
|
||||
test('match3d draft generation starts from title generation', () => {
|
||||
@@ -182,14 +195,16 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('match3d-work-title');
|
||||
expect(progress?.phaseLabel).toBe('生成游戏名称');
|
||||
expect(progress?.steps[0]?.detail).toBe('根据题材设定生成作品名称与标签。');
|
||||
expect(progress?.phaseLabel).toBe('建立草稿存档');
|
||||
expect(progress?.steps[0]?.detail).toBe(
|
||||
'创建可恢复作品草稿,锁定本次题材和难度。',
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d draft generation keeps backend observed model phase', () => {
|
||||
test('match3d draft generation keeps backend observed asset phase', () => {
|
||||
const state = {
|
||||
...createMiniGameDraftGenerationState('match3d'),
|
||||
phase: 'match3d-generate-models' as const,
|
||||
phase: 'match3d-generate-views' as const,
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 3,
|
||||
};
|
||||
@@ -199,12 +214,37 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
state.startedAtMs + 20_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('match3d-generate-models');
|
||||
expect(progress?.steps.at(-1)?.completed).toBe(1);
|
||||
expect(progress?.steps.at(-1)?.total).toBe(3);
|
||||
expect(progress?.phaseId).toBe('match3d-generate-views');
|
||||
expect(progress?.steps[6]?.detail).toContain('音效提示词');
|
||||
expect(progress?.steps[6]?.completed).toBe(1);
|
||||
expect(progress?.steps[6]?.total).toBe(3);
|
||||
});
|
||||
|
||||
test('match3d generation anchors show theme and fixed three items', () => {
|
||||
test('match3d draft generation reaches music, background image and writeback phases', () => {
|
||||
const state = createMiniGameDraftGenerationState('match3d');
|
||||
|
||||
const musicProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 400_000,
|
||||
);
|
||||
const backgroundProgress = 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');
|
||||
expect(writeProgress?.phaseLabel).toBe('写入草稿页');
|
||||
});
|
||||
|
||||
test('match3d generation anchors show theme and difficulty item count', () => {
|
||||
const entries = buildMatch3DGenerationAnchorEntries(null, {
|
||||
themeText: '水果',
|
||||
clearCount: 20,
|
||||
@@ -221,7 +261,39 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
{
|
||||
id: 'match3d-items',
|
||||
label: '物品数量',
|
||||
value: '3 件',
|
||||
value: '25 件',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('baby object match generation exposes two item names', () => {
|
||||
const state = createMiniGameDraftGenerationState('baby-object-match');
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 9_000,
|
||||
);
|
||||
const entries = buildBabyObjectMatchGenerationAnchorEntries({
|
||||
itemAName: '苹果',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'baby-object-draft',
|
||||
'baby-object-images',
|
||||
'baby-object-ready',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('baby-object-images');
|
||||
expect(progress?.estimatedRemainingMs).toBe(351_000);
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'baby-object-item-1',
|
||||
label: '物品 1',
|
||||
value: '苹果',
|
||||
},
|
||||
{
|
||||
id: 'baby-object-item-2',
|
||||
label: '物品 2',
|
||||
value: '香蕉',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
} from '../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
@@ -18,11 +22,13 @@ export type MiniGameDraftGenerationKind =
|
||||
| 'puzzle'
|
||||
| 'big-fish'
|
||||
| 'square-hole'
|
||||
| 'match3d';
|
||||
| 'match3d'
|
||||
| 'baby-object-match';
|
||||
|
||||
export type MiniGameDraftGenerationPhase =
|
||||
| 'idle'
|
||||
| 'compile'
|
||||
| 'puzzle-level-name'
|
||||
| 'big-fish-draft'
|
||||
| 'big-fish-levels'
|
||||
| 'big-fish-runtime'
|
||||
@@ -32,12 +38,21 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'square-hole-ready'
|
||||
| 'match3d-work-title'
|
||||
| 'match3d-item-names'
|
||||
| 'match3d-background-prompt'
|
||||
| 'match3d-material-sheet'
|
||||
| 'match3d-slice-images'
|
||||
| 'match3d-upload-images'
|
||||
| 'match3d-generate-models'
|
||||
| 'match3d-generate-views'
|
||||
| 'match3d-background-music'
|
||||
| 'match3d-background-image'
|
||||
| 'match3d-write-draft'
|
||||
| 'match3d-ready'
|
||||
| 'baby-object-draft'
|
||||
| 'baby-object-images'
|
||||
| 'baby-object-ready'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-background-music'
|
||||
| 'puzzle-ui-background'
|
||||
| 'puzzle-select-image'
|
||||
| 'ready'
|
||||
| 'failed';
|
||||
@@ -68,35 +83,62 @@ const PUZZLE_STEPS = [
|
||||
{
|
||||
id: 'compile',
|
||||
label: '编译首关草稿',
|
||||
detail: '理解画面描述,生成首关名称与可编辑草稿。',
|
||||
weight: 20,
|
||||
detail: '读取画面描述,建立可编辑草稿与首关结构。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-level-name',
|
||||
label: '生成关卡名称',
|
||||
detail: '根据画面描述和图像语义整理首关题目。',
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-images',
|
||||
label: '生成首关画面',
|
||||
detail: '调用图片模型生成适合切块的正方形首图。',
|
||||
weight: 70,
|
||||
weight: 42,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-background-music',
|
||||
label: '生成背景音乐',
|
||||
detail: '用作品题目生成纯音乐并转存音频资产。',
|
||||
weight: 18,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-ui-background',
|
||||
label: '生成UI背景',
|
||||
detail: '生成不含槽位和控件的 9:16 纯背景。',
|
||||
weight: 14,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-select-image',
|
||||
label: '写入正式草稿',
|
||||
detail: '确认首图并同步关卡数据,准备进入结果页。',
|
||||
weight: 10,
|
||||
detail: '写入首图、音乐、UI背景和首关数据。',
|
||||
weight: 8,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const PUZZLE_ESTIMATED_WAIT_MS = 60_000;
|
||||
const PUZZLE_ESTIMATED_WAIT_MS = 180_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-images' | 'puzzle-select-image'
|
||||
| 'compile'
|
||||
| 'puzzle-level-name'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-background-music'
|
||||
| 'puzzle-ui-background'
|
||||
| 'puzzle-select-image'
|
||||
>;
|
||||
durationMs: number;
|
||||
}> = [
|
||||
{ phase: 'compile', durationMs: 12_000 },
|
||||
{ phase: 'puzzle-images', durationMs: 42_000 },
|
||||
{ phase: 'puzzle-select-image', durationMs: 6_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 },
|
||||
];
|
||||
|
||||
const BIG_FISH_STEPS = [
|
||||
@@ -144,39 +186,63 @@ const SQUARE_HOLE_STEPS = [
|
||||
const MATCH3D_STEPS = [
|
||||
{
|
||||
id: 'match3d-work-title',
|
||||
label: '生成游戏名称',
|
||||
detail: '根据题材设定生成作品名称与标签。',
|
||||
label: '建立草稿存档',
|
||||
detail: '创建可恢复作品草稿,锁定本次题材和难度。',
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'match3d-item-names',
|
||||
label: '生成物品名称',
|
||||
detail: '根据题材生成本局的 3 个物品名称。',
|
||||
weight: 8,
|
||||
label: '生成作品计划',
|
||||
detail: '生成游戏名称、物品名称、音乐名称与标签。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'match3d-background-prompt',
|
||||
label: '生成背景提示词',
|
||||
detail: '整理纯背景图与容器 UI 图提示词。',
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
id: 'match3d-material-sheet',
|
||||
label: '生成素材图',
|
||||
detail: '生成一张 1:1 的网格素材图。',
|
||||
weight: 18,
|
||||
label: '分批生成素材图',
|
||||
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
|
||||
weight: 22,
|
||||
},
|
||||
{
|
||||
id: 'match3d-slice-images',
|
||||
label: '切割独立图片',
|
||||
detail: '把素材图切成独立物品参考图。',
|
||||
weight: 8,
|
||||
detail: '把素材图切成每个物品的五个视角。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'match3d-upload-images',
|
||||
label: '上传图片资产',
|
||||
detail: '写入素材图和独立物品参考图。',
|
||||
weight: 8,
|
||||
detail: '上传每个物品的 2D 五视角素材。',
|
||||
weight: 12,
|
||||
},
|
||||
{
|
||||
id: 'match3d-generate-models',
|
||||
label: '生成3D模型',
|
||||
detail: '调用 Hyper3D Rodin 生成 GLB 模型并转存。',
|
||||
weight: 50,
|
||||
id: 'match3d-generate-views',
|
||||
label: '校验素材结构',
|
||||
detail: '确认物品顺序、五视角图片和音效提示词。',
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
id: 'match3d-background-music',
|
||||
label: '生成背景音乐',
|
||||
detail: '用音乐名称生成纯音乐并转存音频资产。',
|
||||
weight: 14,
|
||||
},
|
||||
{
|
||||
id: 'match3d-background-image',
|
||||
label: '生成UI背景',
|
||||
detail: '生成无 UI 元素纯背景,并生成题材容器 UI 图。',
|
||||
weight: 16,
|
||||
},
|
||||
{
|
||||
id: 'match3d-write-draft',
|
||||
label: '写入草稿页',
|
||||
detail: '保存素材、音乐、背景、容器和作品草稿。',
|
||||
weight: 2,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
@@ -185,12 +251,37 @@ const MATCH3D_PHASE_ORDER: Partial<
|
||||
> = {
|
||||
'match3d-work-title': 0,
|
||||
'match3d-item-names': 1,
|
||||
'match3d-material-sheet': 2,
|
||||
'match3d-slice-images': 3,
|
||||
'match3d-upload-images': 4,
|
||||
'match3d-generate-models': 5,
|
||||
'match3d-background-prompt': 2,
|
||||
'match3d-material-sheet': 3,
|
||||
'match3d-slice-images': 4,
|
||||
'match3d-upload-images': 5,
|
||||
'match3d-generate-views': 6,
|
||||
'match3d-background-music': 7,
|
||||
'match3d-background-image': 8,
|
||||
'match3d-write-draft': 9,
|
||||
};
|
||||
|
||||
const BABY_OBJECT_MATCH_STEPS = [
|
||||
{
|
||||
id: 'baby-object-draft',
|
||||
label: '整理识物草稿',
|
||||
detail: '写入两个物品名称与寓教于乐标签。',
|
||||
weight: 22,
|
||||
},
|
||||
{
|
||||
id: 'baby-object-images',
|
||||
label: '生成游戏素材',
|
||||
detail: '生成物品图、背景、礼物盒、篮子和界面包装。',
|
||||
weight: 68,
|
||||
},
|
||||
{
|
||||
id: 'baby-object-ready',
|
||||
label: '准备结果页',
|
||||
detail: '校验草稿字段并进入结果页。',
|
||||
weight: 10,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
@@ -205,6 +296,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'match3d') {
|
||||
return MATCH3D_STEPS;
|
||||
}
|
||||
if (kind === 'baby-object-match') {
|
||||
return BABY_OBJECT_MATCH_STEPS;
|
||||
}
|
||||
return BIG_FISH_STEPS;
|
||||
}
|
||||
|
||||
@@ -260,7 +354,9 @@ export function createMiniGameDraftGenerationState(
|
||||
? 'square-hole-draft'
|
||||
: kind === 'match3d'
|
||||
? 'match3d-work-title'
|
||||
: 'compile',
|
||||
: kind === 'baby-object-match'
|
||||
? 'baby-object-draft'
|
||||
: 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
@@ -297,22 +393,42 @@ function resolveMatch3DPhaseByElapsedMs(
|
||||
currentPhase: MiniGameDraftGenerationPhase,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
const elapsedPhase =
|
||||
elapsedMs >= 92_000
|
||||
? 'match3d-generate-models'
|
||||
: elapsedMs >= 72_000
|
||||
? 'match3d-upload-images'
|
||||
: elapsedMs >= 58_000
|
||||
? 'match3d-slice-images'
|
||||
: elapsedMs >= 16_000
|
||||
? 'match3d-material-sheet'
|
||||
: elapsedMs >= 4_000
|
||||
? 'match3d-item-names'
|
||||
: 'match3d-work-title';
|
||||
elapsedMs >= 540_000
|
||||
? 'match3d-write-draft'
|
||||
: elapsedMs >= 460_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';
|
||||
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
|
||||
const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1;
|
||||
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
|
||||
}
|
||||
|
||||
function resolveBabyObjectMatchPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 330_000) {
|
||||
return 'baby-object-ready';
|
||||
}
|
||||
if (elapsedMs >= 8_000) {
|
||||
return 'baby-object-images';
|
||||
}
|
||||
return 'baby-object-draft';
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||||
let elapsedBeforePhase = 0;
|
||||
|
||||
@@ -360,27 +476,34 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
phase: puzzleTimeline.phase,
|
||||
}
|
||||
: state.kind === 'big-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'square-hole' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||||
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'match3d' &&
|
||||
: state.kind === 'square-hole' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
|
||||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
: state.kind === 'match3d' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
|
||||
}
|
||||
: state.kind === 'baby-object-match' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
@@ -401,13 +524,15 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 1
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? (puzzleTimeline?.activeStepProgressRatio ?? 0)
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? 0.55
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? 0.42
|
||||
: normalizedState.kind === 'match3d'
|
||||
? 0.5
|
||||
: 0;
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? 0.55
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? 0.42
|
||||
: normalizedState.kind === 'match3d'
|
||||
? 0.5
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? 0.52
|
||||
: 0;
|
||||
const overallProgress =
|
||||
normalizedState.phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
@@ -436,7 +561,9 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
|
||||
: normalizedState.kind === 'match3d'
|
||||
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: clampProgress(cappedOverallProgress),
|
||||
@@ -448,13 +575,18 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 0
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? Math.max(0, 12_000 - elapsedMs)
|
||||
: normalizedState.kind === 'match3d'
|
||||
? Math.max(0, 10 * 60_000 - elapsedMs)
|
||||
: null,
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? Math.max(0, 12_000 - elapsedMs)
|
||||
: normalizedState.kind === 'match3d'
|
||||
? Math.max(0, 10 * 60_000 - elapsedMs)
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? Math.max(
|
||||
0,
|
||||
BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS - elapsedMs,
|
||||
)
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(
|
||||
steps,
|
||||
@@ -552,7 +684,9 @@ export function buildMatch3DGenerationAnchorEntries(
|
||||
}
|
||||
|
||||
const config = session?.config;
|
||||
const itemCount = 3;
|
||||
const clearCount = formPayload?.clearCount ?? config?.clearCount ?? null;
|
||||
const difficulty = formPayload?.difficulty ?? config?.difficulty ?? null;
|
||||
const itemCount = resolveMatch3DGeneratedItemCount(clearCount, difficulty);
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
{
|
||||
key: 'match3d-theme',
|
||||
@@ -580,6 +714,41 @@ export function buildMatch3DGenerationAnchorEntries(
|
||||
.filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
function resolveMatch3DGeneratedItemCount(
|
||||
clearCount: number | null | undefined,
|
||||
difficulty: number | null | undefined,
|
||||
) {
|
||||
const roundToSheet = (count: number) => Math.ceil(count / 5) * 5;
|
||||
if (clearCount === 8) return roundToSheet(3);
|
||||
if (clearCount === 12) return roundToSheet(9);
|
||||
if (clearCount === 16) return roundToSheet(15);
|
||||
if (clearCount === 20 || clearCount === 21) return roundToSheet(21);
|
||||
const normalizedDifficulty =
|
||||
typeof difficulty === 'number' && Number.isFinite(difficulty)
|
||||
? Math.max(1, Math.min(10, Math.round(difficulty)))
|
||||
: 4;
|
||||
if (normalizedDifficulty <= 2) return roundToSheet(3);
|
||||
if (normalizedDifficulty <= 4) return roundToSheet(9);
|
||||
if (normalizedDifficulty <= 6) return roundToSheet(15);
|
||||
return roundToSheet(21);
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchGenerationAnchorEntries(
|
||||
formPayload: CreateBabyObjectMatchDraftRequest | null | undefined,
|
||||
draft: BabyObjectMatchDraft | null | undefined = null,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
const itemNames =
|
||||
formPayload?.itemAName?.trim() || formPayload?.itemBName?.trim()
|
||||
? [formPayload.itemAName.trim(), formPayload.itemBName.trim()]
|
||||
: (draft?.itemNames ?? []);
|
||||
|
||||
return itemNames.filter(Boolean).map((value, index) => ({
|
||||
id: `baby-object-item-${index + 1}`,
|
||||
label: `物品 ${index + 1}`,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSquareHoleGenerationAnchorEntries(
|
||||
session: SquareHoleSessionSnapshot | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
|
||||
@@ -45,6 +45,14 @@ export function buildVisualNovelPublicWorkCode(profileId: string) {
|
||||
return `VN-${suffix}`;
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `BO-${suffix}`;
|
||||
}
|
||||
|
||||
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
@@ -103,3 +111,16 @@ export function isSameVisualNovelPublicWorkCode(
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameBabyObjectMatchPublicWorkCode(
|
||||
keyword: string,
|
||||
profileId: string,
|
||||
) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildBabyObjectMatchPublicWorkCode(profileId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -535,6 +535,45 @@ describe('puzzleLocalRuntime', () => {
|
||||
).toBe('explicit-level');
|
||||
});
|
||||
|
||||
test('本地试玩继承关卡 UI 背景和背景音乐资源', () => {
|
||||
const workWithRuntimeAssets: PuzzleWorkSummary = {
|
||||
...baseWork,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '第一关',
|
||||
pictureDescription: '第一关画面',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/level-1.png',
|
||||
coverAssetId: null,
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
backgroundMusic: {
|
||||
taskId: 'audio-task-1',
|
||||
provider: 'vector-engine',
|
||||
assetObjectId: 'asset-audio-1',
|
||||
assetKind: 'puzzle_background_music',
|
||||
audioSrc: '/generated-puzzle-assets/session/audio.mp3',
|
||||
prompt: '雨夜猫街音乐',
|
||||
title: '雨夜猫街',
|
||||
updatedAt: '2026-05-12T00:00:00.000Z',
|
||||
},
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const run = startLocalPuzzleRun(workWithRuntimeAssets);
|
||||
|
||||
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
);
|
||||
expect(run.currentLevel?.backgroundMusic?.audioSrc).toBe(
|
||||
'/generated-puzzle-assets/session/audio.mp3',
|
||||
);
|
||||
});
|
||||
|
||||
test('暂停和冻结时间不会消耗本地倒计时', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const pausedRun = setLocalPuzzlePaused(
|
||||
|
||||
@@ -802,6 +802,10 @@ function buildFallbackLocalLevel(
|
||||
buildLocalLevelName(currentLevel.levelName, nextLevelIndex);
|
||||
const nextCoverImageSrc =
|
||||
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
|
||||
const nextUiBackgroundImageSrc =
|
||||
nextLevel?.uiBackgroundImageSrc ?? currentLevel.uiBackgroundImageSrc;
|
||||
const nextBackgroundMusic =
|
||||
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
|
||||
|
||||
const nextRun: PuzzleRunSnapshot = {
|
||||
...run,
|
||||
@@ -830,6 +834,8 @@ function buildFallbackLocalLevel(
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
coverImageSrc: nextCoverImageSrc,
|
||||
uiBackgroundImageSrc: nextUiBackgroundImageSrc,
|
||||
backgroundMusic: nextBackgroundMusic,
|
||||
...buildLevelTimerFields(nextLevelIndex),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
@@ -854,6 +860,8 @@ 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 firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
|
||||
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
|
||||
return {
|
||||
runId,
|
||||
@@ -873,6 +881,8 @@ export function startLocalPuzzleRun(
|
||||
authorDisplayName: item.authorDisplayName,
|
||||
themeTags: item.themeTags,
|
||||
coverImageSrc: firstCoverImageSrc,
|
||||
uiBackgroundImageSrc: firstUiBackgroundImageSrc,
|
||||
backgroundMusic: firstBackgroundMusic,
|
||||
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
|
||||
@@ -201,7 +201,7 @@ export async function updatePuzzleRunPause(
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用正式拼图道具,服务端负责扣除光点并更新运行态。
|
||||
* 使用正式拼图道具,服务端负责扣除泥点并更新运行态。
|
||||
*/
|
||||
export async function usePuzzleRuntimeProp(
|
||||
runId: string,
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function deletePuzzleWork(profileId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取当前用户名下拼图作品的整数光点激励。
|
||||
* 领取当前用户名下拼图作品的整数泥点激励。
|
||||
*/
|
||||
export async function claimPuzzleWorkPointIncentive(profileId: string) {
|
||||
return requestJson<PuzzleWorkMutationResponse>(
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function streamRpgCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendRpgAgentMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
): Promise<RpgAgentSessionSnapshot> {
|
||||
const response = await openRpgCreationSsePost(
|
||||
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
payload,
|
||||
|
||||
@@ -90,6 +90,7 @@ export function getRpgProfileRechargeCenter(
|
||||
|
||||
export function createRpgProfileRechargeOrder(
|
||||
productId: string,
|
||||
paymentChannel = 'mock',
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
|
||||
@@ -97,7 +98,7 @@ export function createRpgProfileRechargeOrder(
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ productId, paymentChannel: 'mock' }),
|
||||
body: JSON.stringify({ productId, paymentChannel }),
|
||||
},
|
||||
'充值失败',
|
||||
options,
|
||||
|
||||
69
src/services/runtimeAudioFeedback.ts
Normal file
69
src/services/runtimeAudioFeedback.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export const DEFAULT_RUNTIME_CLICK_SOUND_SRC = '/audio/ui-click-soft.wav';
|
||||
export const DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC =
|
||||
'/audio/ui-level-clear.wav';
|
||||
export const DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC =
|
||||
'/audio/ui-countdown-warning.wav';
|
||||
export const DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS = 5_000;
|
||||
|
||||
export const DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG = {
|
||||
clickSoundSrc: DEFAULT_RUNTIME_CLICK_SOUND_SRC,
|
||||
levelClearSoundSrc: DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC,
|
||||
countdownSoundSrc: DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC,
|
||||
countdownWarningThresholdMs: DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS,
|
||||
} as const;
|
||||
|
||||
const runtimeAudioCache = new Map<string, HTMLAudioElement>();
|
||||
|
||||
function clampRuntimeAudioVolume(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0.6;
|
||||
}
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
export function playRuntimeClickSound(
|
||||
source = DEFAULT_RUNTIME_CLICK_SOUND_SRC,
|
||||
volume = 0.6,
|
||||
) {
|
||||
if (import.meta.env.MODE === 'test' || typeof Audio === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSource = source.trim();
|
||||
if (!normalizedSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audio =
|
||||
runtimeAudioCache.get(normalizedSource) ?? new Audio(normalizedSource);
|
||||
runtimeAudioCache.set(normalizedSource, audio);
|
||||
audio.currentTime = 0;
|
||||
audio.volume = clampRuntimeAudioVolume(volume);
|
||||
try {
|
||||
const playResult = audio.play();
|
||||
void playResult?.catch?.(() => {
|
||||
// 中文注释:浏览器可能在用户手势外拒绝播放,点击反馈不应中断主交互。
|
||||
});
|
||||
} catch {
|
||||
// 中文注释:测试环境或极端浏览器可能未实现 play,同样不能影响主交互。
|
||||
}
|
||||
}
|
||||
|
||||
export function playRuntimeLevelClearSound(volume = 0.6) {
|
||||
playRuntimeClickSound(DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC, volume);
|
||||
}
|
||||
|
||||
export function playRuntimeCountdownSound(volume = 0.6) {
|
||||
playRuntimeClickSound(DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC, volume);
|
||||
}
|
||||
|
||||
export function resolveRuntimeCountdownSecondBucket(remainingMs: number) {
|
||||
if (
|
||||
!Number.isFinite(remainingMs) ||
|
||||
remainingMs <= 0 ||
|
||||
remainingMs > DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './visualNovelCreationClient';
|
||||
export * from './visualNovelAssetClient';
|
||||
export * from './visualNovelAudioGenerationClient';
|
||||
export * from './visualNovelCreationClient';
|
||||
export * from './visualNovelImageGenerationClient';
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
import {
|
||||
createCreationAgentClient,
|
||||
normalizeVisualNovelAgentStreamEvent,
|
||||
} from '../creation-agent';
|
||||
|
||||
const VISUAL_NOVEL_AGENT_API_BASE = '/api/creation/visual-novel/sessions';
|
||||
const VISUAL_NOVEL_CREATION_WRITE_RETRY: ApiRetryOptions = {
|
||||
@@ -61,7 +64,10 @@ export function streamVisualNovelMessage(
|
||||
payload: SendVisualNovelMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
return visualNovelAgentHttpClient.streamMessage(sessionId, payload, options);
|
||||
return visualNovelAgentHttpClient.streamMessage(sessionId, payload, {
|
||||
...options,
|
||||
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
|
||||
});
|
||||
}
|
||||
|
||||
export function executeVisualNovelAction(
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
VisualNovelCharacterDraft,
|
||||
VisualNovelResultDraft,
|
||||
VisualNovelSceneDraft,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
CustomWorldSceneImageRequest,
|
||||
CustomWorldSceneImageResult,
|
||||
} from '../aiTypes';
|
||||
import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient';
|
||||
|
||||
export type VisualNovelImageGenerationKind =
|
||||
| 'cover'
|
||||
| 'scene_background'
|
||||
| 'character_standee';
|
||||
|
||||
export type VisualNovelImageGenerationRequest = {
|
||||
kind: VisualNovelImageGenerationKind;
|
||||
draft: VisualNovelResultDraft;
|
||||
scene?: VisualNovelSceneDraft | null;
|
||||
character?: VisualNovelCharacterDraft | null;
|
||||
prompt?: string;
|
||||
referenceImageSrc?: string;
|
||||
};
|
||||
|
||||
function buildVisualNovelProfile(
|
||||
draft: VisualNovelResultDraft,
|
||||
): CustomWorldSceneImageRequest['profile'] {
|
||||
return {
|
||||
id: draft.profileId?.trim() || 'visual-novel-draft',
|
||||
name: draft.workTitle.trim() || draft.world.title.trim() || '视觉小说作品',
|
||||
subtitle: draft.world.title.trim() || draft.workTitle.trim() || '视觉小说',
|
||||
summary: draft.workDescription.trim() || draft.world.summary.trim(),
|
||||
tone:
|
||||
draft.world.defaultTone.trim() || draft.world.literaryStyle.trim() || '视觉小说',
|
||||
playerGoal: draft.world.playerRole.trim() || '推进剧情并完成关键选择',
|
||||
settingText: [
|
||||
draft.world.premise,
|
||||
draft.world.background,
|
||||
draft.world.literaryStyle,
|
||||
]
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelLandmark(
|
||||
payload: VisualNovelImageGenerationRequest,
|
||||
): CustomWorldSceneImageRequest['landmark'] {
|
||||
if (payload.kind === 'scene_background' && payload.scene) {
|
||||
return {
|
||||
id: payload.scene.sceneId,
|
||||
name: payload.scene.name.trim() || '视觉小说场景',
|
||||
description: payload.scene.description.trim() || payload.draft.world.summary,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.kind === 'character_standee' && payload.character) {
|
||||
return {
|
||||
id: payload.character.characterId,
|
||||
name: `${payload.character.name.trim() || '视觉小说角色'}立绘`,
|
||||
description: [
|
||||
payload.character.appearance,
|
||||
payload.character.personality,
|
||||
payload.character.role,
|
||||
payload.character.relationshipToPlayer,
|
||||
]
|
||||
.map((part) => part?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.draft.profileId?.trim() || 'visual-novel-cover',
|
||||
name: `${payload.draft.workTitle.trim() || '视觉小说'}封面`,
|
||||
description:
|
||||
payload.draft.workDescription.trim() ||
|
||||
payload.draft.world.summary.trim() ||
|
||||
payload.draft.world.premise.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultVisualNovelImagePrompt(
|
||||
payload: VisualNovelImageGenerationRequest,
|
||||
) {
|
||||
const draft = payload.draft;
|
||||
if (payload.kind === 'scene_background' && payload.scene) {
|
||||
return [
|
||||
`视觉小说场景背景:${payload.scene.name}`,
|
||||
payload.scene.description,
|
||||
draft.world.defaultTone,
|
||||
'16:9 横版背景图,无文字,无 UI,无人物特写',
|
||||
]
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
if (payload.kind === 'character_standee' && payload.character) {
|
||||
return [
|
||||
`视觉小说角色立绘:${payload.character.name}`,
|
||||
payload.character.appearance,
|
||||
payload.character.personality,
|
||||
payload.character.tone,
|
||||
'透明感二次元全身或半身立绘,干净背景,无文字,无 UI',
|
||||
]
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
return [
|
||||
`视觉小说作品封面:${draft.workTitle}`,
|
||||
draft.workDescription,
|
||||
draft.world.summary,
|
||||
draft.world.defaultTone,
|
||||
'精致视觉小说封面构图,无文字,无 UI,适合 4:3/16:9 裁切',
|
||||
]
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function resolveVisualNovelImageSize(kind: VisualNovelImageGenerationKind) {
|
||||
if (kind === 'character_standee') {
|
||||
return '768*1024';
|
||||
}
|
||||
return '1280*720';
|
||||
}
|
||||
|
||||
export async function generateVisualNovelImageAsset(
|
||||
payload: VisualNovelImageGenerationRequest,
|
||||
): Promise<CustomWorldSceneImageResult> {
|
||||
const userPrompt =
|
||||
payload.prompt?.trim() || buildDefaultVisualNovelImagePrompt(payload);
|
||||
|
||||
if (!userPrompt.trim()) {
|
||||
throw new Error('请先补充图片生成提示词。');
|
||||
}
|
||||
|
||||
return generateRpgWorldSceneImage({
|
||||
profile: buildVisualNovelProfile(payload.draft),
|
||||
landmark: buildVisualNovelLandmark(payload),
|
||||
userPrompt,
|
||||
size: resolveVisualNovelImageSize(payload.kind),
|
||||
...(payload.referenceImageSrc?.trim()
|
||||
? { referenceImageSrc: payload.referenceImageSrc.trim() }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildVisualNovelImageGenerationPrompt(
|
||||
payload: VisualNovelImageGenerationRequest,
|
||||
) {
|
||||
return buildDefaultVisualNovelImagePrompt(payload);
|
||||
}
|
||||
Reference in New Issue
Block a user