feat: add edutainment drawing and visual package flows

This commit is contained in:
2026-05-14 14:17:10 +08:00
parent 10e8beea80
commit e444266e1e
109 changed files with 8788 additions and 996 deletions

View File

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