diff --git a/.codex/skills/gpt-image-2-apimart/SKILL.md b/.codex/skills/gpt-image-2-apimart/SKILL.md new file mode 100644 index 00000000..a29d0889 --- /dev/null +++ b/.codex/skills/gpt-image-2-apimart/SKILL.md @@ -0,0 +1,91 @@ +--- +name: gpt-image-2-apimart +description: Generate or inspect project image assets through this repository's APIMart OpenAI-compatible gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 request body, dry-run image prompts, batch-generate local project thumbnails, or debug APIMART_BASE_URL / APIMART_API_KEY image-generation configuration without exposing secrets. +--- + +# gpt-image-2 APIMart + +Use this skill for project-local image asset generation that must match the repository's `server-rs` APIMart `gpt-image-2` path. + +## Workflow + +1. Read the local task and decide whether the image is project-bound. +2. Prefer `scripts/generate-template-samples.mjs` for puzzle template thumbnails or small batches. +3. Run dry-run first: + + ```powershell + node .codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs --dry-run + ``` + +4. If dry-run looks correct and the user asked for real assets, run live generation with a small limit: + + ```powershell + node .codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs --live --limit 6 + ``` + +5. Save final project assets under `public/` or another explicitly requested workspace path. +6. Never print `APIMART_API_KEY`. Report only whether configuration exists. + +## Request Contract + +The repository image path uses: + +```text +POST {APIMART_BASE_URL}/images/generations +Authorization: Bearer {APIMART_API_KEY} +Content-Type: application/json +``` + +Default body: + +```json +{ + "model": "gpt-image-2", + "prompt": "", + "n": 1, + "size": "1:1" +} +``` + +For a reference image, add: + +```json +{ + "image_urls": ["data:image/png;base64,..."] +} +``` + +Poll async responses with: + +```text +GET {APIMART_BASE_URL}/tasks/{task_id} +``` + +Accept image output from `data[].url`, `data[].b64_json`, direct nested `url` fields, or async task results. + +## Environment + +Load environment values from process env first, then `.env.secrets.local`, `.env.local`, and `.env.example`. + +Required for live generation: + +- `APIMART_BASE_URL` +- `APIMART_API_KEY` + +Optional: + +- `APIMART_IMAGE_REQUEST_TIMEOUT_MS` + +If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat. + +## Prompt Rules + +- Use Chinese prompts when generating project puzzle templates. +- Keep template samples square, clear, image-only, and suitable for puzzle thumbnails. +- Avoid text, watermark, UI chrome, buttons, borders, and tutorial overlays. +- Include local negative constraints in the prompt instead of relying on provider-specific negative prompt fields. + +## Resources + +- `scripts/generate-template-samples.mjs`: dry-run or live-generate puzzle template sample thumbnails. +- `assets/puzzle-template-prompts.json`: default prompt list consumed by the script. diff --git a/.codex/skills/gpt-image-2-apimart/agents/openai.yaml b/.codex/skills/gpt-image-2-apimart/agents/openai.yaml new file mode 100644 index 00000000..2debf01b --- /dev/null +++ b/.codex/skills/gpt-image-2-apimart/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "GPT Image 2 APIMart" + short_description: "Generate project thumbnails through APIMart" + brand_color: "#10B981" + default_prompt: "Use $gpt-image-2-apimart to dry-run or generate puzzle template thumbnails through APIMart." +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/gpt-image-2-apimart/assets/puzzle-template-prompts.json b/.codex/skills/gpt-image-2-apimart/assets/puzzle-template-prompts.json new file mode 100644 index 00000000..736559cc --- /dev/null +++ b/.codex/skills/gpt-image-2-apimart/assets/puzzle-template-prompts.json @@ -0,0 +1,62 @@ +[ + { + "id": "couple-memory", + "title": "情侣合照拼图", + "prompt": "温暖自然光下的一对情侣纪念合照,城市咖啡馆窗边,桌面有花束和两杯热饮,人物神情自然,画面主体清晰,前中后景层次明确,适合切成拼图。" + }, + { + "id": "family-keepsake", + "title": "家庭纪念拼图", + "prompt": "三代家人在客厅沙发前的家庭纪念合照,柔和午后阳光,孩子抱着生日蛋糕,长辈微笑,画面温暖完整,细节丰富但不杂乱。" + }, + { + "id": "friends-party", + "title": "朋友聚会拼图", + "prompt": "朋友们在露台夜晚聚会,彩灯、桌上零食和举杯瞬间,人物分布有层次,中央焦点清楚,氛围轻松热闹,适合社交分享拼图。" + }, + { + "id": "festival-card", + "title": "节日贺卡拼图", + "prompt": "节日餐桌与礼物布置,暖色灯光、彩带、蜡烛和窗外烟花,画面像无字贺卡,主体集中,边角细节可辨,适合节日拼图。" + }, + { + "id": "knowledge-summary", + "title": "知识总结拼图", + "prompt": "一张无文字的知识学习主题插画,书桌上有打开的笔记本、便签、咖啡、台灯和思维导图式图形元素,构图整洁,重点明确,适合学习打卡拼图。" + }, + { + "id": "product-detail", + "title": "商品细节拼图", + "prompt": "精致商品静物展示,一只高质感香水瓶放在丝绸与花瓣之间,玻璃反光清晰,包装和材质细节丰富,背景干净,适合作为电商细节拼图。" + }, + { + "id": "healing-landscape", + "title": "治愈风景拼图", + "prompt": "治愈风景插画,清晨湖边、薄雾、远山、木栈道和一盏小灯,色彩柔和,层次清楚,局部元素可辨,适合长时间拼图。" + }, + { + "id": "cute-pet", + "title": "宠物可爱拼图", + "prompt": "一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。" + }, + { + "id": "hot-topic-poster", + "title": "热点海报拼图", + "prompt": "电影感热点海报风插画,雨夜街头、霓虹反光、奔跑的人影和远处光束,强烈视觉焦点,画面无文字,适合热点话题拼图。" + }, + { + "id": "event-invitation", + "title": "活动邀请拼图", + "prompt": "活动邀请主题插画,展厅入口、花艺装置、签到台和柔和灯带,人群剪影自然分布,画面高级干净,无文字,适合活动预热拼图。" + }, + { + "id": "daily-challenge", + "title": "每日挑战拼图", + "prompt": "每日挑战主题插画,清爽桌面上摆放相机、明信片、计时器和小奖章,色彩明亮,构图有趣,细节可拆解,适合平台每日拼图。" + }, + { + "id": "children-learning", + "title": "儿童认知拼图", + "prompt": "儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字,适合儿童教育拼图。" + } +] diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs new file mode 100644 index 00000000..c8732519 --- /dev/null +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs @@ -0,0 +1,347 @@ +import { Buffer } from 'node:buffer'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const skillRoot = path.resolve(__dirname, '..'); +const repoRoot = path.resolve(skillRoot, '..', '..', '..'); +const promptsPath = path.join( + skillRoot, + 'assets', + 'puzzle-template-prompts.json', +); +const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates'); +const defaultTimeoutMs = 180000; +const pollDelayMs = 3000; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 1) { + const raw = process.argv[index]; + if (raw.startsWith('--')) { + const next = process.argv[index + 1]; + if (next && !next.startsWith('--')) { + args.set(raw, next); + index += 1; + } else { + args.set(raw, true); + } + } +} + +function readDotenv(fileName) { + const filePath = path.join(repoRoot, fileName); + if (!existsSync(filePath)) { + return {}; + } + + const values = {}; + for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed); + if (!match) { + continue; + } + let value = match[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + values[match[1]] = value; + } + return values; +} + +function resolveEnv() { + const loaded = { + ...readDotenv('.env.example'), + ...readDotenv('.env.local'), + ...readDotenv('.env.secrets.local'), + ...process.env, + }; + return { + baseUrl: String(loaded.APIMART_BASE_URL || '').trim().replace(/\/+$/u, ''), + apiKey: String(loaded.APIMART_API_KEY || '').trim(), + timeoutMs: Number.parseInt( + String(loaded.APIMART_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + 10, + ), + }; +} + +function buildPrompt(template) { + return [ + '请生成一张高清 1:1 方形插画,用作拼图创作模板样例图。', + `画面主体:${template.prompt}`, + '要求:主体清晰集中,前中后景层次明确,边角有可辨识细节,适合切成 3x3 到 7x7 拼图。', + '避免:文字、水印、边框、按钮、UI 元素、教程标注、低清晰度、过度模糊、杂乱构图。', + ].join(''); +} + +function collectStringsByKey(value, targetKey, output) { + if (Array.isArray(value)) { + value.forEach((entry) => collectStringsByKey(entry, targetKey, output)); + return; + } + if (!value || typeof value !== 'object') { + return; + } + + for (const [key, nested] of Object.entries(value)) { + if (key === targetKey) { + if (typeof nested === 'string' && nested.trim()) { + output.push(nested.trim()); + } + if (Array.isArray(nested)) { + nested.forEach((entry) => { + if (typeof entry === 'string' && entry.trim()) { + output.push(entry.trim()); + } + }); + } + } + collectStringsByKey(nested, targetKey, output); + } +} + +function extractImageUrls(payload) { + const urls = []; + collectStringsByKey(payload, 'url', urls); + collectStringsByKey(payload, 'image', urls); + collectStringsByKey(payload, 'image_url', urls); + return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url)); +} + +function extractBase64Images(payload) { + const values = []; + collectStringsByKey(payload, 'b64_json', values); + return values; +} + +function extractTaskId(payload) { + const ids = []; + collectStringsByKey(payload, 'task_id', ids); + collectStringsByKey(payload, 'taskId', ids); + collectStringsByKey(payload, 'id', ids); + return ids[0] || null; +} + +function inferExtensionFromContentType(contentType) { + const normalized = contentType.split(';')[0]?.trim().toLowerCase(); + if (normalized === 'image/png') { + return 'png'; + } + if (normalized === 'image/webp') { + return 'webp'; + } + if (normalized === 'image/gif') { + return 'gif'; + } + return 'jpg'; +} + +function inferExtensionFromBytes(bytes) { + if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) { + return 'png'; + } + if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) { + return 'jpg'; + } + if ( + bytes.subarray(0, 4).toString('ascii') === 'RIFF' && + bytes.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'webp'; + } + return 'png'; +} + +async function fetchJson(url, options, timeoutMs) { + const abortController = new AbortController(); + const timer = setTimeout(() => abortController.abort(), timeoutMs); + try { + const response = await fetch(url, { + ...options, + signal: abortController.signal, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`APIMart ${response.status}: ${text.slice(0, 600)}`); + } + return JSON.parse(text); + } finally { + clearTimeout(timer); + } +} + +async function downloadUrl(url, timeoutMs) { + const abortController = new AbortController(); + const timer = setTimeout(() => abortController.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: abortController.signal }); + if (!response.ok) { + throw new Error(`download ${response.status}`); + } + const bytes = Buffer.from(await response.arrayBuffer()); + return { + bytes, + extension: inferExtensionFromContentType( + response.headers.get('content-type') || 'image/jpeg', + ), + }; + } finally { + clearTimeout(timer); + } +} + +async function waitForTask(env, taskId) { + const deadline = Date.now() + env.timeoutMs; + await new Promise((resolve) => setTimeout(resolve, 10000)); + + while (Date.now() < deadline) { + const payload = await fetchJson( + `${env.baseUrl}/tasks/${encodeURIComponent(taskId)}`, + { + headers: { + Authorization: `Bearer ${env.apiKey}`, + }, + }, + env.timeoutMs, + ); + const statuses = []; + collectStringsByKey(payload, 'status', statuses); + collectStringsByKey(payload, 'task_status', statuses); + const status = String(statuses[0] || '').trim().toLowerCase(); + + if (['completed', 'succeeded', 'success'].includes(status)) { + return payload; + } + if (['failed', 'error', 'canceled', 'cancelled', 'unknown'].includes(status)) { + throw new Error(`APIMart task ${taskId} failed: ${JSON.stringify(payload).slice(0, 600)}`); + } + + await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); + } + + throw new Error(`APIMart task ${taskId} timed out`); +} + +async function generateOne(env, template, outDir) { + const requestBody = { + model: 'gpt-image-2', + prompt: buildPrompt(template), + n: 1, + size: '1:1', + }; + const payload = await fetchJson( + `${env.baseUrl}/images/generations`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + env.timeoutMs, + ); + + const resolvedPayload = + extractImageUrls(payload).length || extractBase64Images(payload).length + ? payload + : await waitForTask(env, extractTaskId(payload)); + const urls = extractImageUrls(resolvedPayload); + const b64Images = extractBase64Images(resolvedPayload); + + let image; + if (urls[0]) { + image = await downloadUrl(urls[0], env.timeoutMs); + } else if (b64Images[0]) { + const bytes = Buffer.from(b64Images[0], 'base64'); + image = { + bytes, + extension: inferExtensionFromBytes(bytes), + }; + } else { + throw new Error(`APIMart returned no image for ${template.id}`); + } + + mkdirSync(outDir, { recursive: true }); + const outputPath = path.join(outDir, `${template.id}.${image.extension}`); + writeFileSync(outputPath, image.bytes); + return outputPath; +} + +const dryRun = args.has('--dry-run') || !args.has('--live'); +const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir)); +const limit = Number.parseInt(String(args.get('--limit') || '0'), 10); +const onlyIds = String(args.get('--only') || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +const templates = JSON.parse(readFileSync(promptsPath, 'utf8')).filter( + (template) => !onlyIds.length || onlyIds.includes(template.id), +); +const selectedTemplates = limit > 0 ? templates.slice(0, limit) : templates; + +if (dryRun) { + console.log( + JSON.stringify( + { + mode: 'dry-run', + outDir, + count: selectedTemplates.length, + requests: selectedTemplates.map((template) => ({ + id: template.id, + title: template.title, + body: { + model: 'gpt-image-2', + prompt: buildPrompt(template), + n: 1, + size: '1:1', + }, + })), + }, + null, + 2, + ), + ); + process.exit(0); +} + +const env = resolveEnv(); +if (!env.baseUrl || !env.apiKey) { + console.error( + JSON.stringify({ + ok: false, + error: 'Missing APIMART_BASE_URL or APIMART_API_KEY', + hasBaseUrl: Boolean(env.baseUrl), + hasApiKey: Boolean(env.apiKey), + }), + ); + process.exit(1); +} + +const generated = []; +for (const template of selectedTemplates) { + console.log(`Generating ${template.id}...`); + generated.push(await generateOne(env, template, outDir)); +} + +console.log( + JSON.stringify( + { + ok: true, + count: generated.length, + files: generated, + }, + null, + 2, + ), +); diff --git a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md index 4bb9ea12..d594a7c0 100644 --- a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md +++ b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md @@ -85,6 +85,11 @@ 2. 用户返回成本更低 3. 操作像手游副面板,更符合预期 +### 4.3.1 弹出确认面板不能透明 +- 删除作品、发布后分享、确认离开等关键弹窗必须有实体面板底色,不能只靠透明背景、毛玻璃或遮罩承载内容。 +- 通过 portal 挂到 `body` 的平台弹窗必须在遮罩层补齐平台主题类,否则主题变量会脱离页面容器,轻则颜色漂移,重则面板背景看起来透明。 +- 移动端关键确认弹窗优先居中显示,并保留 `max-height + 内部滚动`,避免被底部导航、安全区或底部抽屉布局遮住。 + ### 4.4 图标优于文字按钮 - 在底部工具区,队伍/背包改成 icon 后更紧凑。 - 但必须保留 `aria-label`,保证语义清晰、后续也方便测试。 diff --git a/docs/technical/MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md b/docs/technical/MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md index 2c14788e..3efa6942 100644 --- a/docs/technical/MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md +++ b/docs/technical/MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md @@ -1,5 +1,7 @@ # 抓大鹅创作入口开放与错误隔离 2026-05-01 +> 2026-05-03 更新:抓大鹅创作端入口已按运营节奏暂时下线,`match3d.visible` 调整为 `false`。本文保留为 2026-05-01 入口开放阶段的历史记录;当前入口状态以 `NEW_WORK_ENTRY_CONFIG_2026-05-01.md` 和 `src/config/newWorkEntryConfig.ts` 为准。 + ## 1. 背景 抓大鹅 Match3D 玩法域已完成当前 demo 主链接入,本轮恢复创作页入口,使玩家可以从创作中心直接进入抓大鹅共创工作台。同时,平台首页会并行读取 RPG、拼图、抓大鹅等公开广场数据,公开广场接口未就绪、空表或临时失败不应污染创作入口错误态,也不应表现成登录异常。 diff --git a/docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md b/docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md index b4adf225..db4fb842 100644 --- a/docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md +++ b/docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md @@ -1,5 +1,7 @@ # 抓大鹅 Match3D F1 创作入口与 Agent UI 落地记录 2026-04-30 +> 2026-05-03 更新:抓大鹅创作端入口已暂时下线,当前 `match3d.visible` 为 `false`。本文件记录 F1 接入能力,入口是否展示以 `NEW_WORK_ENTRY_CONFIG_2026-05-01.md` 和 `src/config/newWorkEntryConfig.ts` 为准。 + ## 1. 阶段边界 本文件承接《MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md》的 F1 包。 diff --git a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md index 4847ac0d..a00fea82 100644 --- a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md +++ b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md @@ -18,10 +18,10 @@ | 玩法 | 展示 | 开放 | 说明 | | --- | --- | --- | --- | -| 角色扮演 | 是 | 是 | 点击后进入 RPG Agent 共创工作台 | +| 角色扮演 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 | | 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 | | 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 | -| 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Agent 共创工作台 | +| 抓大鹅 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 | | AIRP | 是 | 否 | 保留入口,显示敬请期待 | | 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 | diff --git a/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md b/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md index caa07f13..e33b1a47 100644 --- a/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md +++ b/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md @@ -20,7 +20,7 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进 - 前端只负责展示生成进度与触发已有后端动作,不新增 server-node 或 PostgreSQL 链路。 - 后端继续沿用 `server-rs` + `SpacetimeDB` 的会话、草稿与资产写入能力。 -- 拼图生成草稿链路仍包含:结果页草稿、候选图生成、正式图确认。 +- 拼图生成草稿链路仍包含:首关草稿编译、首关画面生成、正式草稿写入。 - 大鱼吃小鱼生成草稿链路只包含:玩法草稿、等级蓝图、背景蓝图与运行参数编译。 - 大鱼吃小鱼的主图、动作、背景都在结果页工坊单独触发,不再属于草稿编译阶段。 - 生成过程中展示的“角色描述、角色图片、动作”等,统一映射为锚点、草稿蓝图与资产步骤,不把规则说明类文本写成默认 UI 文案。 @@ -38,9 +38,9 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进 ### 拼图 -- `compile_puzzle_draft`:在 `server-rs` 内整理主题、主体、构图与标签,写入结果页草稿。 -- `compile_puzzle_draft`:同一次后端 action 内根据草稿摘要生成候选图。 -- `compile_puzzle_draft`:同一次后端 action 内自动选择第一张候选图作为正式图。 +- `compile_puzzle_draft`:在 `server-rs` 内根据入口画面描述生成首关名称和结果页草稿。 +- `compile_puzzle_draft`:同一次后端 action 内根据画面描述、参考图和当前图片模型生成首关画面。 +- `compile_puzzle_draft`:同一次后端 action 内自动把首图设为正式图,并同步到结果页草稿。 - `ready`:进入拼图结果页。 ### 大鱼吃小鱼 diff --git a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md index dd646464..326b4796 100644 --- a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md +++ b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md @@ -4,8 +4,18 @@ 拼图创作入口不再使用 Agent 对话收集题材锚点。新流程让玩家填写作品名称、作品描述、画面描述三类信息,其中画面描述只服务首关画面生成与关卡画面语义,不再作为作品详情页的作品描述。画面描述支持上传参考图。玩家确认后直接进入草稿生成进度页,后续草稿生成、首图生成、正式图选择、结果页编辑和发布沿用现有后端编排。 +2026-05-03 后入口进一步收口为画面描述直创:入口表单只保留画面描述、参考图和图片模型选择;作品名称、作品描述、作品标签全部进入结果页补全。若本文件早期段落仍提到入口必填作品名称或作品描述,以 `PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md` 为准。 + ## 入口表单 +### 2026-05-03 画面描述直创补充 + +1. 入口表单只展示 `画面描述`、参考图和图片模型选择;`画面描述` 是唯一必填字段。 +2. 表单自动保存只保存 `pictureDescription`,不再保存入口作品名称、作品描述或推断标签。 +3. 点击“生成草稿”后进入生成进度页,步骤固定为“编译首关草稿 -> 生成首关画面 -> 写入正式草稿”。 +4. 生成进度页“当前拼图信息”只展示画面描述;不得展示空作品名称、空作品描述或旧五锚点结构。 +5. 结果页打开后,作品名称默认使用首关名称,作品描述与作品标签保持为空,等待用户在作品信息 Tab 补全或触发 AI 标签生成。 + ### 2026-04-30 初始表单草稿保存补充 1. 玩家在创作页点击“拼图”入口时,前端必须立即创建一个新的拼图 Agent session,并同步生成一条 `publicationStatus = draft` 的拼图作品卡;此时不触发 `compile_puzzle_draft`,不生成图片,不进入生成进度页。 diff --git a/docs/technical/PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md b/docs/technical/PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md new file mode 100644 index 00000000..f5ed9549 --- /dev/null +++ b/docs/technical/PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md @@ -0,0 +1,67 @@ +# 拼图画面描述直创与 AI 标签生成调整 2026-05-03 + +## 背景 + +拼图创作入口继续保留填表式体验,但入口表单不再要求百梦主提前填写作品名称和作品描述。入口只收集“拼图画面描述”,后端用该描述完成首图生成和第一关关卡名生成;进入结果页后再补作品信息。 + +## 入口表单 + +1. 点击“开始创作”后的拼图表单只展示 `画面描述`、参考图和图片模型选择。 +2. `画面描述` 是唯一必填字段,提交时写入 `pictureDescription`,并作为 `promptText` 传给 `compile_puzzle_draft`。 +3. `workTitle`、`workDescription` 不再从入口表单传入;`seedText` 只由画面描述组成,格式为 `画面描述:...`。 +4. 表单自动保存只保存画面描述,不生成图片,不消耗光点。 +5. 生成进度页“当前拼图信息”只展示画面描述,不再展示空作品名称或空作品描述。 + +## 生成进度步骤 + +1. `compile` 展示为“编译首关草稿”:根据画面描述生成首关名称和结果页草稿,不在本步骤生成作品标签。 +2. `puzzle-images` 展示为“生成首关画面”:按画面描述、参考图和当前图片模型生成第一张拼图图。 +3. `puzzle-select-image` 展示为“写入正式草稿”:把首图设为第一关正式图,并同步到结果页草稿。 +4. `ready` 文案提示进入结果页补作品信息;不得暗示作品名称、作品描述或作品标签已经完整生成。 + +## 草稿默认值 + +1. 后端先由 `module-puzzle` 生成可回滚的确定性草稿,再由 `api-server` 基于画面描述调用文本模型生成第一关关卡名;模型不可用或返回非法时才降级到确定性兜底名。 +2. 第一关关卡名生成后,必须写回首关 `levelName`,并在入口直创默认场景下作为 `workTitle` 同步写入草稿和作品草稿卡。 +3. `workDescription` 默认保持空字符串,不再回退为画面描述。 +4. `themeTags` 默认保持空数组,不再由入口画面描述自动推断为正式作品标签。 +5. `formDraft` 只保留 `pictureDescription`,`workTitle` 与 `workDescription` 为空。 + +## 作品标签 + +1. 作品信息 Tab 继续支持手动新增、删除标签。 +2. 作品标签合法数量仍为 `3~6` 个,发布前和后端发布逻辑都要检查。 +3. 新增 `generate_puzzle_tags` action: + - 前端点击 AI 生成标签时先检查作品名称和作品描述。 + - 若任一为空,前端直接提示先填写,不请求后端。 + - 两者都不为空时,后端基于作品名称和作品描述调用文本模型,生成 6 个中文短标签。 + - 生成结果回写 session draft 与 puzzle work profile,前端直接使用返回 session 更新界面。 +4. AI 标签生成失败时可以降级为确定性关键词标签,但仍必须返回去重后的 6 个标签,保证用户能继续编辑。 + +## 保存与发布 + +1. 用户在结果页修改作品名称、作品描述、作品标签、关卡名称或画面描述时,继续通过 `PUT /api/runtime/puzzle/works/{profileId}` 自动保存。 +2. 自动保存允许标签为空,用于支持初始草稿和用户清空标签后的继续编辑。 +3. 发布前必须检查: + - 每个关卡名称非空。 + - 作品名称非空。 + - 作品描述非空。 + - 作品标签数量为 `3~6`。 + - 每关正式图存在。 +4. `publish_puzzle_work` 仍由 SpacetimeDB procedure 执行最终校验和发布,前端不能绕过后端门禁。 + +## 结果页返回 + +1. 从拼图草稿结果页点击左上角返回时,直接回到平台创作页。 +2. 结果页返回不回到上一页填表工作区;表单页只作为发起新草稿或恢复纯表单草稿的入口。 +3. 返回创作页时清理拼图生成态、运行态和临时操作态,保留后端已保存的草稿,用户后续从作品卡继续完善。 + +## 验收 + +1. 拼图入口表单不再出现作品名称和作品描述输入框。 +2. 只填写画面描述即可生成草稿、图片和第一关关卡名。 +3. 进入结果页后作品名称默认为模型生成的第一关关卡名,作品描述为空,作品标签为空。 +4. 点击 AI 生成标签时,作品名称或作品描述为空会先提示补齐。 +5. 作品名称和作品描述都不为空时,AI 生成 6 个作品标签,并自动保存到后端。 +6. 手动增删标签仍可用,发布前标签必须至少 3 个且最多 6 个。 +7. 拼图草稿结果页左上角返回直接回到创作页,不再显示上一页表单。 diff --git a/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md b/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md new file mode 100644 index 00000000..df682190 --- /dev/null +++ b/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md @@ -0,0 +1,87 @@ +# 拼图创作模板表单与 gpt-image-2 Skill 封装 2026-05-03 + +## 背景 + +拼图创作入口已经从对话式 Agent 收口为填表式表单。本次改版目标是让“点击拼图创作”后的表单更接近图像创作工具的单屏体验:先选创作模板,再补充提示词,最后直接生成首关草稿与首张拼图图。 + +## 落地范围 + +1. `src/components/puzzle-agent/PuzzleAgentWorkspace.tsx` + - 改为顶部标题、模板横滑区、大输入框、底部操作区的布局。 + - 保留参考图上传、模型切换和生成草稿。 + - 不再提供输入框底部的 `try` 示例入口。 +2. `src/components/puzzle-agent/puzzleCreationTemplates.ts` + - 新增拼图创作模板数据。 + - 模板来源按社交、热点、职场学习、电商、治愈、营销、儿童教育等场景抽样。 + - 点击模板后把模板提示词写入画面描述。 +3. `public/puzzle-creation-templates/` + - 存放模板样例图。 + - 样例图只用于创作模板缩略图,不作为正式拼图作品资产。 +4. `.codex/skills/gpt-image-2-apimart/` + - 封装仓库内 `gpt-image-2` 的 APIMart OpenAI 兼容调用流程。 + - Skill 默认读取本地环境变量,不把密钥写入代码、文档或前端。 + +## UI 规则 + +1. 顶部只展示“创建拼图”和轻量状态标识,不写玩法规则说明。 +2. 模板区横向滚动,移动端优先;每个模板卡包含样例图、短标题和选中态。 +3. 点击模板时: + - 立即选中该模板。 + - 如果输入框为空,直接填入模板提示词。 + - 如果输入框已有内容,替换为该模板提示词,避免追加后变得冗长。 +4. 输入区保留: + - 参考图上传按钮。 + - 图片模型切换按钮。 +5. 输入区不保留: + - `try` 文本。 + - 示例 prompt chip。 + - 玩法规则说明。 + +## 模板抽样 + +首批模板不追求覆盖图二所有条目,而是选择高频且适合拼图主图的代表项: + +1. 情侣合照拼图 +2. 家庭纪念拼图 +3. 朋友聚会拼图 +4. 节日贺卡拼图 +5. 知识点总结拼图 +6. 商品细节拼图 +7. 治愈风景拼图 +8. 宠物可爱拼图 +9. 热点海报拼图 +10. 活动邀请拼图 +11. 每日挑战拼图 +12. 儿童认知拼图 + +模板提示词必须是可直接送入拼图生图链路的画面描述,不写 UI、按钮、教程、规则或营销解释。 + +## gpt-image-2 Skill 规则 + +Skill 封装仓库现有后端口径: + +```text +POST {APIMART_BASE_URL}/images/generations +Authorization: Bearer {APIMART_API_KEY} +model = gpt-image-2 +size = 1:1 +n = 1 +``` + +响应兼容: + +1. `data[].url` +2. `data[].b64_json` +3. `task_id` 后续 `GET /tasks/{task_id}` + +本次 Skill 只封装生成样例图和研发复用流程,不改变正式后端接口、扣费、OSS、SpacetimeDB 写入和发布链路。 + +## 验收 + +1. 点击拼图创作后,表单首屏呈现模板横滑区和大输入框。 +2. 点击任一模板后,输入框填入该模板提示词。 +3. 输入框里没有 `try` 示例功能。 +4. 图片模型切换仍可打开并选择 `gpt-image-2` / `nanobanana2`。 +5. 模板样例图文件存在,并能在创作表单缩略图中显示。 +6. gpt-image-2 Skill 校验通过,且脚本 dry-run 能输出计划请求而不泄露密钥。 +7. `npm run check:encoding` 通过。 diff --git a/docs/technical/RPG_NPC_CHAT_CONTINUE_ADVENTURE_SCENE_TRANSITION_2026-05-03.md b/docs/technical/RPG_NPC_CHAT_CONTINUE_ADVENTURE_SCENE_TRANSITION_2026-05-03.md new file mode 100644 index 00000000..f3743526 --- /dev/null +++ b/docs/technical/RPG_NPC_CHAT_CONTINUE_ADVENTURE_SCENE_TRANSITION_2026-05-03.md @@ -0,0 +1,43 @@ +# RPG 聊天退出后继续冒险过场方案(2026-05-03) + +## 1. 目标 + +玩家退出 NPC 聊天后点击“继续冒险”,不能直接瞬间切到下一幕或下一场景。继续冒险必须先完成一段清晰的角色退场与入场演出,再让新对面角色主动开启对话。 + +## 2. 时序约束 + +点击“继续冒险”后的顺序固定为: + +1. 保持旧场景画面,隐藏当前场景对面的所有角色。 +2. 主角色与同行角色播放行走动画,向右走出屏幕。 +3. 点击后可以先更新真实 `gameState/currentStory`,但画布继续使用过场模型缓存的旧可见态;退场完成前不得把新幕画面展示出来。 +4. 新场景或新幕画面展示后,主角色从左侧走到默认站位。 +5. 新场景对面角色从屏幕左侧走入到指定对面站位。 +6. 入场完成后,如果后续选项里存在 `npc_preview_talk` 或 `npc_chat`,自动执行该选项,直接开启主角色与对面角色的对话。 + +## 3. 代码落点 + +1. `src/hooks/rpg-runtime-story/choiceActions.ts` + - 点击 `story_continue_adventure` 时只提交延迟状态与选项,不直接进入对话。 + - 若延迟故事标记了自动执行,则把目标 option 放到新的 `deferredAutoChoice`。 + +2. `src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts` + - `story_continue_adventure` 也纳入 `content-change` 过场。 + - 入场动画结束后触发 `deferredAutoChoice`,避免在角色尚未走到位前开聊。 + - 自动触发时通过最新回调读取当前运行态,避免计时器拿到点击“继续冒险”前的旧状态。 + +3. `src/components/game-canvas/GameCanvasEntityLayer.tsx` + - 退场期隐藏旧对面角色。 + - 入场期让新对面角色从左侧走入到右侧指定站位。 + - 对面角色入场期使用移动动画,完成后恢复 idle 与对话气泡。 + +4. `src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts` + - `story_continue_adventure` 只要携带 `deferredRuntimeState` 或 `deferredAutoChoice`,就先进入过场,再交给 story choice 处理真实状态。 + +## 4. 验收标准 + +1. 退出 NPC 聊天后点击“继续冒险”,不会在同一帧瞬间切换到下一幕对话。 +2. 退场时旧对面角色不可见,主角色向右走出画面。 +3. 入场时新对面角色从左侧进入右侧站位。 +4. 入场完成后自动进入新对面角色对话。 +5. 移动端与桌面端都不新增说明类 UI 文案,只保留游戏内演出。 diff --git a/docs/technical/RPG_OPENING_CG_MANUAL_GENERATION_2026-05-03.md b/docs/technical/RPG_OPENING_CG_MANUAL_GENERATION_2026-05-03.md new file mode 100644 index 00000000..30bc9505 --- /dev/null +++ b/docs/technical/RPG_OPENING_CG_MANUAL_GENERATION_2026-05-03.md @@ -0,0 +1,171 @@ +# RPG 世界草稿开局 CG 手动生成技术方案(2026-05-03) + +## 1. 背景与本次口径 + +本方案落地“RPG 游戏开场 CG”第一版。它继承 `docs/prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md` 的资产化方向,但本次不采用旧 PRD 中“4 张关键帧 + 3 段视频拼接”的方案,而采用更短的两阶段链路: + +```text +世界草稿 +-> GPT Image 2 生成 3*4 故事板图,2k,16:9 +-> Seedance 使用故事板作为参考图生成单段 15 秒视频,480p,16:9 +-> OSS 保存故事板与成片 +-> 前端把 openingCg 回写到当前世界草稿 profile +``` + +本次明确不在生成世界草稿时自动生成开局 CG。入口只放在世界草稿结果页的世界 Tab,由用户手动触发。 + +## 2. 用户体验 + +1. 世界 Tab 展示一个轻量的“开局 CG”资产槽。 +2. 未生成时只提供手动生成按钮。 +3. 生成中展示阶段状态和进度,不把规则说明长文写进 UI。 +4. 生成成功后展示视频预览和重新生成按钮。 +5. 每次点击生成扣 `80` 积分,失败自动退款。 +6. UI 预计等待文案为 `预计 10 分钟`,真实等待由后端同步请求完成后返回。 +7. 只在世界草稿中手动生成;世界底稿、角色图、幕背景图自动补齐流程不生成开局 CG。 + +## 3. 数据结构 + +在 `CustomWorldProfile` 新增可选字段: + +```ts +type CustomWorldOpeningCgStatus = + | 'not_started' + | 'storyboard_generating' + | 'video_generating' + | 'ready' + | 'failed'; + +type CustomWorldOpeningCgProfile = { + id: string; + status: CustomWorldOpeningCgStatus; + storyboardImageSrc?: string | null; + storyboardAssetId?: string | null; + videoSrc?: string | null; + videoAssetId?: string | null; + posterImageSrc?: string | null; + posterAssetId?: string | null; + storyboardPrompt?: string | null; + videoPrompt?: string | null; + imageModel: 'gpt-image-2'; + videoModel: string; + aspectRatio: '16:9'; + imageSize: '2k'; + videoResolution: '480p'; + durationSeconds: 15; + pointCost: 80; + estimatedWaitMinutes: 10; + generatedAt?: string | null; + updatedAt: string; + errorMessage?: string | null; +}; +``` + +该字段保存在现有 profile JSON 内,不新增 SpacetimeDB 表字段。发布与保存沿用当前 `profile_payload_json` 整包存储能力。 + +## 4. 后端接口 + +新增接口: + +```text +POST /api/runtime/custom-world/opening-cg +``` + +请求: + +```ts +type GenerateCustomWorldOpeningCgRequest = { + profile: CustomWorldProfile; +}; +``` + +响应: + +```ts +type GenerateCustomWorldOpeningCgResponse = { + openingCg: CustomWorldOpeningCgProfile; +}; +``` + +接口职责: + +1. 校验登录态与 profile 基本结构。 +2. 校验至少存在可扮演角色、世界基调、世界概述、核心冲突和首个场景第一幕背景图。 +3. 使用 `execute_billable_asset_operation_with_cost(..., 80, ...)` 做预扣和失败退款。 +4. 生成故事板图片并持久化为 `custom_world_opening_cg_storyboard` 资产。 +5. 使用故事板图作为 Seedance 参考图生成视频并持久化为 `custom_world_opening_cg_video` 资产。 +6. 返回可直接合并进 profile 的 `openingCg`。 + +## 5. 提示词 + +### 5.1 故事板 + +图片模型固定使用 `gpt-image-2`,尺寸语义为 `2k`、`16:9`,当前 APIMart/OpenAI 兼容入口用 `2048x1152` 作为下游 size。 + +模板: + +```text +以3*4网格格式创建故事板,16:9。像素风角色扮演游戏开场动画CG。 + +故事流程:先展示角色,展示故事背景,然后表现核心冲突,最后衔接开局场景 +故事基调:{世界草稿.tone} + +玩家扮演:将玩家扮演角色作为角色参考图并引用世界草稿中的角色简介 +故事背景:{世界草稿.summary} +核心冲突:{世界草稿.coreConflicts} +开局场景:将首个场景的第一幕背景图作为参考图 +``` + +参考图: + +1. 玩家扮演角色使用第一个可扮演角色的 `imageSrc`。 +2. 开局场景使用 `sceneChapterBlueprints[0].acts[0].backgroundImageSrc`。 +3. 若缺少任一参考图,返回可理解错误,不降级到无参考图生成。 + +### 5.2 视频 + +视频模型复用当前 Ark Seedance 配置,分辨率 `480p`,比例 `16:9`,时长 `15` 秒。 + +提示词固定: + +```text +利用参考图作为故事板,生成一段连贯的动画,没有旁白 +``` + +请求参数必须开启生成音频与联网搜索。若当前上游字段名存在差异,后端在 Ark 请求体中以 `audio` / `generate_audio` / `web_search` 的兼容布尔字段表达,保证不会影响现有角色动画接口。 + +开局 CG 视频链路的上游等待窗口不得低于 `10` 分钟,以匹配产品侧“预计 10 分钟”的展示口径。 + +## 6. 资产与计费 + +资产写入: + +| 产物 | assetKind | entityKind | slot | +| --- | --- | --- | --- | +| 故事板图 | `custom_world_opening_cg_storyboard` | `custom_world_profile` | `opening_cg_storyboard` | +| 成片视频 | `custom_world_opening_cg_video` | `custom_world_profile` | `opening_cg_video` | + +计费: + +1. 每次点击生成消耗 `80` 积分。 +2. 故事板生成失败、视频任务创建失败、轮询失败、下载失败或 OSS 持久化失败都退款。 +3. 扣费流水的 asset id 使用本次 opening CG id,避免同一次请求重试重复扣费。 + +## 7. 前端落点 + +1. `CustomWorldEntityCatalog` 在世界 Tab 增加开局 CG 槽。 +2. `rpgCreationAssetClient` 新增 `generateOpeningCg`。 +3. `RpgCreationResultViewImpl` 持有生成中状态,生成完成后 `onProfileChange({ ...profile, openingCg })`。 +4. 视频展示使用签名 URL 读取组件,不把签名 URL 写入 profile。 +5. 草稿生成时不调用该接口。 + +## 8. 验收点 + +1. 新草稿生成完成后 `openingCg` 为空或不存在。 +2. 世界 Tab 可以手动生成开局 CG。 +3. 生成请求 payload 包含角色参考图与开局第一幕背景图。 +4. 故事板请求使用 `gpt-image-2`、`2048x1152`/`2k` 语义、`16:9`。 +5. 视频请求使用 Seedance、`480p`、`16:9`、`15` 秒,并传入故事板参考图。 +6. 单次生成扣 `80` 积分,任一失败路径退款。 +7. 生成成功后 profile 内出现 `openingCg.videoSrc`,刷新/保存/发布后能保留。 +8. 视频链路上游超时不低于 `10` 分钟,避免低于产品展示的预计等待时长。 diff --git a/docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_CLOSURE_2026-05-01.md b/docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_CLOSURE_2026-05-01.md index 5c0817dd..0168c36b 100644 --- a/docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_CLOSURE_2026-05-01.md +++ b/docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_CLOSURE_2026-05-01.md @@ -20,6 +20,17 @@ - 后端调用 `module-runtime-story` 纯规则结算动作,推进 `runtimeActionVersion`,写回 runtime snapshot,并用 `continue_story` 记录本轮 narrative event。 - 响应同样返回 `StoryRuntimeMutationResponse { projection }`,不返回旧 `viewModel / presentation / patches / snapshot` 组合。 +## 版本口径 + +`StoryRuntimeProjectionResponse.serverVersion` 只表示动作并发版本,必须与 `projection.gameState.runtimeActionVersion` 保持一致。前端点击运行时选项时把该值作为 `clientVersion` 提交,后端只用它防止基于旧动作快照重复结算。 + +以下字段不能参与 `serverVersion` 计算: + +1. `runtime_snapshot.version`:这是保存快照结构版本,当前由 `SAVE_SNAPSHOT_VERSION` 固定维护,写快照不会把它当作动作轮次递增。 +2. `story_session.version`:这是故事事件流版本,`continue_story` 会推进它,但它不一定等同于当前运行时动作快照版本。 + +读取 `/runtime-projection` 和写入 `/actions/resolve` 的回包都必须从持久化 `gameState.runtimeActionVersion` 解析 `serverVersion`。如果旧快照缺少该字段,才允许回退到 `storySession.version` 或本轮 resolver 输出版本,避免历史存档无法恢复;不得再使用 `runtime_snapshot.version.max(story_session.version)` 这类混合口径。 + ## 契约收口 本轮新增 story contract 下的运行时写侧 DTO: diff --git a/docs/technical/SERVER_RS_DDD_WP_SC_STORY_RUNTIME_LEGACY_OPTION_SCOPE_COMPAT_2026-05-03.md b/docs/technical/SERVER_RS_DDD_WP_SC_STORY_RUNTIME_LEGACY_OPTION_SCOPE_COMPAT_2026-05-03.md new file mode 100644 index 00000000..cf2df0ec --- /dev/null +++ b/docs/technical/SERVER_RS_DDD_WP_SC_STORY_RUNTIME_LEGACY_OPTION_SCOPE_COMPAT_2026-05-03.md @@ -0,0 +1,22 @@ +# WP-SC Story runtime legacy option scope 兼容修复(2026-05-03) + +## 背景 + +`/api/story/sessions/{storySessionId}/runtime-projection` 读取侧在解析历史 `currentStory.options` 时,曾直接把 option JSON 反序列化为后端投影类型,并要求 `scope` 必填。 + +但旧快照里的 `currentStory.options` 只保证 `functionId` / `actionText` / `text` 等基础字段,`scope` 并不是历史存档的稳定字段。于是旧存档在读取 runtime inventory view 时会报: + +`currentStory.options 无法映射为后端选项投影: missing field 'scope'` + +## 修复口径 + +1. `spacetime-client` 的 story runtime projection 读取不再直接反序列化 `currentStory.options`。 +2. 改为复用 `module-runtime-story::build_runtime_story_options(...)`,让历史快照通过领域 helper 统一补齐 `story / combat / npc` 作用域。 +3. 保持 `StoryRuntimeProjectionSource` 与 `StoryRuntimeProjectionResponse` 输出结构不变,不改 SpacetimeDB schema,不改 reducer,不改 API route。 + +## 验收 + +```powershell +cargo test -p spacetime-client story_runtime --manifest-path server-rs\Cargo.toml +cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml +``` diff --git a/docs/technical/WORK_PUBLISH_SHARE_PANEL_2026-05-02.md b/docs/technical/WORK_PUBLISH_SHARE_PANEL_2026-05-02.md index 0831967f..71878bb5 100644 --- a/docs/technical/WORK_PUBLISH_SHARE_PANEL_2026-05-02.md +++ b/docs/technical/WORK_PUBLISH_SHARE_PANEL_2026-05-02.md @@ -37,12 +37,14 @@ 本次只做前端分享引导,不接入微信、QQ、抖音的原生 SDK。点击渠道 icon 与主“分享”按钮保持一致,复制同一份分享文本。 -仓库现有 `media/social-media-group/wechat.png` 与 `qq.png` 是社群二维码,不作为本面板渠道 icon 使用。渠道 icon 采用轻量圆形文字标识,避免误导用户进入社群。 +仓库现有 `media/social-media-group/wechat.png` 与 `qq.png` 是社群二维码,不作为本面板渠道 icon 使用。渠道 icon 必须使用微信、QQ、抖音的品牌 SVG 轮廓,外层保持圆形触控底座;不能用通用聊天气泡、音乐符号或纯文字替代 logo。 ## 面板样式约束 分享面板通过 `UnifiedModal` portal 挂载到页面根部时,需要在遮罩层补齐当前平台主题类,避免主题变量脱离页面容器后丢失。面板外壳继续使用 `platform-modal-shell` 的 `--platform-modal-fill` 背景,并在移动端覆盖平台弹窗默认底部抽屉布局,保持居中显示。 +同类平台弹窗,包括删除作品等确认面板,也必须遵守同一条约束:portal 挂载时遮罩层必须带 `platform-theme platform-theme--light/dark`,面板必须保留 `platform-modal-shell` 的实体背景,不能把主面板做成透明或只依赖 backdrop blur。移动端高风险确认弹窗必须显式居中显示,避免被底部导航、安全区或底部抽屉布局遮住。 + ## 接入范围 - `RpgCreationResultActionBar`:RPG 发布成功后由父层回传分享数据并打开面板。 diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 39131649..9b9cc372 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -4,6 +4,7 @@ export type PuzzleAgentSuggestedActionType = | 'request_summary' | 'compile_puzzle_draft' | 'generate_puzzle_images' + | 'generate_puzzle_tags' | 'publish_puzzle_work'; export interface PuzzleAgentSuggestedAction { @@ -16,6 +17,7 @@ export type PuzzleAgentActionType = | 'save_puzzle_form_draft' | 'compile_puzzle_draft' | 'generate_puzzle_images' + | 'generate_puzzle_tags' | 'select_puzzle_image' | 'publish_puzzle_work'; @@ -71,6 +73,15 @@ export type PuzzleAgentActionRequest = themeTags?: string[]; levelsJson?: string; } + | { + action: 'generate_puzzle_tags'; + workTitle: string; + workDescription: string; + levelName?: string; + summary?: string; + themeTags?: string[]; + levelsJson?: string; + } | { action: 'select_puzzle_image'; levelId?: string | null; diff --git a/public/puzzle-creation-templates/children-learning.webp b/public/puzzle-creation-templates/children-learning.webp new file mode 100644 index 00000000..48b2e1f4 Binary files /dev/null and b/public/puzzle-creation-templates/children-learning.webp differ diff --git a/public/puzzle-creation-templates/couple-memory.webp b/public/puzzle-creation-templates/couple-memory.webp new file mode 100644 index 00000000..2b602d4a Binary files /dev/null and b/public/puzzle-creation-templates/couple-memory.webp differ diff --git a/public/puzzle-creation-templates/cute-pet.webp b/public/puzzle-creation-templates/cute-pet.webp new file mode 100644 index 00000000..4c13558c Binary files /dev/null and b/public/puzzle-creation-templates/cute-pet.webp differ diff --git a/public/puzzle-creation-templates/daily-challenge.webp b/public/puzzle-creation-templates/daily-challenge.webp new file mode 100644 index 00000000..0cad4b78 Binary files /dev/null and b/public/puzzle-creation-templates/daily-challenge.webp differ diff --git a/public/puzzle-creation-templates/event-invitation.webp b/public/puzzle-creation-templates/event-invitation.webp new file mode 100644 index 00000000..c21a2ee6 Binary files /dev/null and b/public/puzzle-creation-templates/event-invitation.webp differ diff --git a/public/puzzle-creation-templates/family-keepsake.webp b/public/puzzle-creation-templates/family-keepsake.webp new file mode 100644 index 00000000..4a073c41 Binary files /dev/null and b/public/puzzle-creation-templates/family-keepsake.webp differ diff --git a/public/puzzle-creation-templates/festival-card.webp b/public/puzzle-creation-templates/festival-card.webp new file mode 100644 index 00000000..09304699 Binary files /dev/null and b/public/puzzle-creation-templates/festival-card.webp differ diff --git a/public/puzzle-creation-templates/friends-party.webp b/public/puzzle-creation-templates/friends-party.webp new file mode 100644 index 00000000..b52d4b4f Binary files /dev/null and b/public/puzzle-creation-templates/friends-party.webp differ diff --git a/public/puzzle-creation-templates/healing-landscape.webp b/public/puzzle-creation-templates/healing-landscape.webp new file mode 100644 index 00000000..7d4346a8 Binary files /dev/null and b/public/puzzle-creation-templates/healing-landscape.webp differ diff --git a/public/puzzle-creation-templates/hot-topic-poster.webp b/public/puzzle-creation-templates/hot-topic-poster.webp new file mode 100644 index 00000000..9c5295ba Binary files /dev/null and b/public/puzzle-creation-templates/hot-topic-poster.webp differ diff --git a/public/puzzle-creation-templates/knowledge-summary.webp b/public/puzzle-creation-templates/knowledge-summary.webp new file mode 100644 index 00000000..ab4ce1f6 Binary files /dev/null and b/public/puzzle-creation-templates/knowledge-summary.webp differ diff --git a/public/puzzle-creation-templates/product-detail.webp b/public/puzzle-creation-templates/product-detail.webp new file mode 100644 index 00000000..9a1a6dfc Binary files /dev/null and b/public/puzzle-creation-templates/product-detail.webp differ diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 73eaae75..d3ef6ccd 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -28,7 +28,9 @@ use webp::Encoder as WebpEncoder; use crate::{ api_response::json_success_body, - asset_billing::execute_billable_asset_operation, + asset_billing::{ + execute_billable_asset_operation, execute_billable_asset_operation_with_cost, + }, auth::AuthenticatedAccessToken, custom_world_result_prompts::{ build_result_entity_system_prompt, build_result_entity_user_prompt, @@ -115,6 +117,12 @@ pub(crate) struct CustomWorldCoverUploadRequest { crop_rect: CustomWorldCoverCropRect, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CustomWorldOpeningCgGenerateRequest { + profile: Value, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct GeneratedAssetResponse { @@ -133,6 +141,38 @@ struct GeneratedAssetResponse { actual_prompt: Option, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GeneratedOpeningCgResponse { + opening_cg: CustomWorldOpeningCgProfileResponse, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CustomWorldOpeningCgProfileResponse { + id: String, + status: &'static str, + storyboard_image_src: String, + storyboard_asset_id: String, + video_src: String, + video_asset_id: String, + poster_image_src: Option, + poster_asset_id: Option, + storyboard_prompt: String, + video_prompt: String, + image_model: &'static str, + video_model: String, + aspect_ratio: &'static str, + image_size: &'static str, + video_resolution: &'static str, + duration_seconds: u32, + point_cost: u64, + estimated_wait_minutes: u32, + generated_at: String, + updated_at: String, + error_message: Option, +} + #[derive(Clone, Debug)] pub(crate) struct GeneratedCustomWorldSceneImage { pub image_src: String, @@ -317,6 +357,22 @@ struct DownloadedRemoteImage { } const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL; +const OPENING_CG_POINTS_COST: u64 = 80; +const OPENING_CG_ESTIMATED_WAIT_MINUTES: u32 = 10; +const OPENING_CG_IMAGE_SIZE_LABEL: &str = "2k"; +const OPENING_CG_STORYBOARD_IMAGE_SIZE: &str = "2048x1152"; +const OPENING_CG_VIDEO_PROMPT: &str = "利用参考图作为故事板,生成一段连贯的动画,没有旁白"; +const OPENING_CG_VIDEO_RESOLUTION: &str = "480p"; +const OPENING_CG_VIDEO_RATIO: &str = "16:9"; +const OPENING_CG_VIDEO_DURATION_SECONDS: u32 = 15; +const OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS: u64 = 600_000; +const OPENING_CG_ASPECT_RATIO: &str = "16:9"; +const OPENING_CG_STORYBOARD_ASSET_KIND: &str = "custom_world_opening_cg_storyboard"; +const OPENING_CG_VIDEO_ASSET_KIND: &str = "custom_world_opening_cg_video"; +const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile"; +const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard"; +const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video"; +const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000; struct CoverPromptContext { opening_act_title: String, @@ -336,6 +392,39 @@ struct NormalizedSceneImageRequest { reference_image_src: Option, } +struct NormalizedOpeningCgRequest { + profile_id: Option, + world_name: String, + opening_cg_id: String, + storyboard_prompt: String, + video_prompt: String, + player_role_image_src: String, + opening_scene_image_src: String, +} + +struct ArkVideoSettings { + base_url: String, + api_key: String, + request_timeout_ms: u64, + model: String, +} + +struct GeneratedOpeningCgStoryboard { + image_src: String, + asset_id: String, +} + +struct GeneratedOpeningCgVideo { + video_src: String, + asset_id: String, +} + +struct DownloadedRemoteVideo { + mime_type: String, + extension: String, + bytes: Vec, +} + #[derive(Debug)] struct NormalizedCropRect { left: u32, @@ -884,6 +973,119 @@ pub async fn upload_custom_world_cover_image( Ok(json_success_body(Some(&request_context), asset)) } +pub async fn generate_custom_world_opening_cg( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + custom_world_ai_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-opening-cg", + "message": error.body_text(), + })), + ) + })?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let normalized = normalize_opening_cg_request(&payload.profile) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + require_openai_image_settings(&state) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + require_ark_video_settings(&state) + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + + let opening_cg_id = normalized.opening_cg_id.clone(); + let generated = execute_billable_asset_operation_with_cost( + &state, + &owner_user_id, + "custom_world_opening_cg", + opening_cg_id.as_str(), + OPENING_CG_POINTS_COST, + async { + let image_settings = require_openai_image_settings(&state)?; + let image_http_client = build_openai_image_http_client(&image_settings)?; + let video_settings = require_ark_video_settings(&state)?; + let video_http_client = build_upstream_http_client(video_settings.request_timeout_ms)?; + let player_role_reference = resolve_reference_image_as_data_url( + &state, + &image_http_client, + normalized.player_role_image_src.as_str(), + "playerRoleImageSrc", + ) + .await?; + let opening_scene_reference = resolve_reference_image_as_data_url( + &state, + &image_http_client, + normalized.opening_scene_image_src.as_str(), + "openingSceneImageSrc", + ) + .await?; + let storyboard = generate_opening_cg_storyboard( + &state, + &owner_user_id, + &image_http_client, + &image_settings, + &normalized, + &[player_role_reference, opening_scene_reference], + ) + .await?; + let storyboard_reference = resolve_reference_image_as_data_url( + &state, + &image_http_client, + storyboard.image_src.as_str(), + "storyboardImageSrc", + ) + .await?; + let video = generate_opening_cg_video( + &state, + &owner_user_id, + &video_http_client, + &video_settings, + &normalized, + storyboard_reference.as_str(), + ) + .await?; + let generated_at = current_utc_iso_text(); + + Ok(CustomWorldOpeningCgProfileResponse { + id: opening_cg_id.clone(), + status: "ready", + storyboard_image_src: storyboard.image_src, + storyboard_asset_id: storyboard.asset_id, + video_src: video.video_src, + video_asset_id: video.asset_id, + poster_image_src: None, + poster_asset_id: None, + storyboard_prompt: normalized.storyboard_prompt.clone(), + video_prompt: normalized.video_prompt.clone(), + image_model: GPT_IMAGE_2_MODEL, + video_model: video_settings.model, + aspect_ratio: OPENING_CG_ASPECT_RATIO, + image_size: OPENING_CG_IMAGE_SIZE_LABEL, + video_resolution: OPENING_CG_VIDEO_RESOLUTION, + duration_seconds: OPENING_CG_VIDEO_DURATION_SECONDS, + point_cost: OPENING_CG_POINTS_COST, + estimated_wait_minutes: OPENING_CG_ESTIMATED_WAIT_MINUTES, + generated_at: generated_at.clone(), + updated_at: generated_at, + error_message: None, + }) + }, + ) + .await + .map_err(|error| custom_world_ai_error_response(&request_context, error))?; + + Ok(json_success_body( + Some(&request_context), + GeneratedOpeningCgResponse { + opening_cg: generated, + }, + )) +} + async fn persist_custom_world_asset( state: &AppState, owner_user_id: &str, @@ -974,6 +1176,337 @@ async fn persist_custom_world_asset( Ok(response) } +async fn generate_opening_cg_storyboard( + state: &AppState, + owner_user_id: &str, + http_client: &reqwest::Client, + settings: &crate::openai_image_generation::OpenAiImageSettings, + normalized: &NormalizedOpeningCgRequest, + reference_images: &[String], +) -> Result { + let generated = create_openai_image_generation( + http_client, + settings, + normalized.storyboard_prompt.as_str(), + None, + OPENING_CG_STORYBOARD_IMAGE_SIZE, + 1, + reference_images, + "开局 CG 故事板生成失败", + ) + .await?; + let downloaded = generated + .images + .into_iter() + .next() + .map(downloaded_openai_to_custom_world_image) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": "开局 CG 故事板生成成功但未返回图片。", + })) + })?; + let asset_id = format!("opening-cg-storyboard-{}", current_utc_millis()); + let upload = PreparedAssetUpload { + prefix: LegacyAssetPrefix::CustomWorldScenes, + path_segments: vec![ + sanitize_storage_segment( + normalized + .profile_id + .as_deref() + .unwrap_or(normalized.world_name.as_str()), + "world", + ), + "opening-cg".to_string(), + sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"), + ], + file_name: format!("storyboard.{}", downloaded.extension), + content_type: downloaded.mime_type, + body: downloaded.bytes, + asset_kind: OPENING_CG_STORYBOARD_ASSET_KIND, + entity_kind: OPENING_CG_ENTITY_KIND, + entity_id: normalized + .profile_id + .clone() + .unwrap_or_else(|| normalized.world_name.clone()), + profile_id: normalized.profile_id.clone(), + slot: OPENING_CG_STORYBOARD_SLOT, + source_job_id: Some(generated.task_id.clone()), + }; + let asset = persist_custom_world_asset( + state, + owner_user_id, + upload, + GeneratedAssetResponse { + image_src: String::new(), + asset_id: asset_id.clone(), + source_type: "generated".to_string(), + model: Some(GPT_IMAGE_2_MODEL.to_string()), + size: Some(OPENING_CG_STORYBOARD_IMAGE_SIZE.to_string()), + task_id: Some(generated.task_id.clone()), + prompt: Some(normalized.storyboard_prompt.clone()), + actual_prompt: generated.actual_prompt, + }, + ) + .await?; + + Ok(GeneratedOpeningCgStoryboard { + image_src: asset.image_src, + asset_id, + }) +} + +async fn generate_opening_cg_video( + state: &AppState, + owner_user_id: &str, + http_client: &reqwest::Client, + settings: &ArkVideoSettings, + normalized: &NormalizedOpeningCgRequest, + storyboard_reference_data_url: &str, +) -> Result { + let upstream_task_id = create_ark_storyboard_to_video_task( + http_client, + settings, + normalized.video_prompt.as_str(), + storyboard_reference_data_url, + ) + .await?; + let video_url = + wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str()) + .await?; + let downloaded = + download_generated_video(http_client, video_url.as_str(), "下载开局 CG 视频失败").await?; + let asset_id = format!("opening-cg-video-{}", current_utc_millis()); + let video_src = persist_opening_cg_video_asset( + state, + owner_user_id, + normalized, + asset_id.as_str(), + Some(upstream_task_id.clone()), + downloaded, + ) + .await?; + + Ok(GeneratedOpeningCgVideo { + video_src, + asset_id, + }) +} + +async fn persist_opening_cg_video_asset( + state: &AppState, + owner_user_id: &str, + normalized: &NormalizedOpeningCgRequest, + asset_id: &str, + source_job_id: Option, + video: DownloadedRemoteVideo, +) -> Result { + let upload = PreparedAssetUpload { + prefix: LegacyAssetPrefix::CustomWorldScenes, + path_segments: vec![ + sanitize_storage_segment( + normalized + .profile_id + .as_deref() + .unwrap_or(normalized.world_name.as_str()), + "world", + ), + "opening-cg".to_string(), + sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"), + ], + file_name: format!("opening.{}", video.extension), + content_type: video.mime_type, + body: video.bytes, + asset_kind: OPENING_CG_VIDEO_ASSET_KIND, + entity_kind: OPENING_CG_ENTITY_KIND, + entity_id: normalized + .profile_id + .clone() + .unwrap_or_else(|| normalized.world_name.clone()), + profile_id: normalized.profile_id.clone(), + slot: OPENING_CG_VIDEO_SLOT, + source_job_id, + }; + let asset = persist_custom_world_asset( + state, + owner_user_id, + upload, + GeneratedAssetResponse { + image_src: String::new(), + asset_id: asset_id.to_string(), + source_type: "generated".to_string(), + model: Some("ark-seedance".to_string()), + size: Some(format!( + "{}:{}:{}s", + OPENING_CG_VIDEO_RESOLUTION, + OPENING_CG_VIDEO_RATIO, + OPENING_CG_VIDEO_DURATION_SECONDS + )), + task_id: None, + prompt: Some(normalized.video_prompt.clone()), + actual_prompt: None, + }, + ) + .await?; + Ok(asset.image_src) +} + +async fn create_ark_storyboard_to_video_task( + http_client: &reqwest::Client, + settings: &ArkVideoSettings, + prompt: &str, + storyboard_reference_data_url: &str, +) -> Result { + let response = http_client + .post(format!("{}/contents/generations/tasks", settings.base_url)) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&json!({ + "model": settings.model, + "content": [ + { + "type": "text", + "text": prompt, + }, + { + "type": "image_url", + "image_url": { + "url": storyboard_reference_data_url, + }, + "role": "reference_image", + } + ], + "resolution": OPENING_CG_VIDEO_RESOLUTION, + "ratio": OPENING_CG_VIDEO_RATIO, + "duration": OPENING_CG_VIDEO_DURATION_SECONDS, + "watermark": false, + "audio": true, + "generate_audio": true, + "web_search": true, + "enable_web_search": true, + })) + .send() + .await + .map_err(|error| map_ark_video_request_error(format!("请求 Seedance 视频服务失败:{error}")))?; + let status = response.status(); + let text = response.text().await.map_err(|error| { + map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}")) + })?; + if !status.is_success() { + return Err(parse_ark_video_upstream_error( + text.as_str(), + "创建开局 CG 视频任务失败。", + )); + } + let payload = parse_ark_video_json_payload(text.as_str(), "创建开局 CG 视频任务失败。")?; + extract_ark_task_id(&payload.payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": "开局 CG 视频任务未返回任务 id。", + })) + }) +} + +async fn wait_for_ark_content_generation_task( + http_client: &reqwest::Client, + settings: &ArkVideoSettings, + task_id: &str, +) -> Result { + let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); + while Instant::now() < deadline { + let response = http_client + .get(format!( + "{}/contents/generations/tasks/{}", + settings.base_url, task_id + )) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .send() + .await + .map_err(|error| map_ark_video_request_error(format!("查询 Seedance 视频任务失败:{error}")))?; + let status = response.status(); + let text = response.text().await.map_err(|error| { + map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}")) + })?; + if !status.is_success() { + return Err(parse_ark_video_upstream_error( + text.as_str(), + "查询开局 CG 视频任务失败。", + )); + } + let payload = parse_ark_video_json_payload(text.as_str(), "查询开局 CG 视频任务失败。")?; + if let Some(video_url) = extract_video_url(&payload.payload) { + return Ok(video_url); + } + let normalized_status = normalize_generation_task_status( + extract_generation_task_status(&payload.payload).as_str(), + ); + if is_completed_generation_task_status(normalized_status.as_str()) { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": "开局 CG 视频任务完成但没有返回 video_url。", + "taskId": task_id, + }))); + } + if is_failed_generation_task_status(normalized_status.as_str()) { + return Err(parse_ark_video_upstream_error( + text.as_str(), + "开局 CG 视频任务执行失败。", + )); + } + + sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await; + } + + Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": "开局 CG 视频生成超时,请稍后重试。", + "taskId": task_id, + }))) +} + +async fn download_generated_video( + http_client: &reqwest::Client, + video_url: &str, + fallback_message: &str, +) -> Result { + let response = http_client + .get(video_url) + .send() + .await + .map_err(|error| map_ark_video_request_error(format!("{fallback_message}:{error}")))?; + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("video/mp4") + .to_string(); + let body = response + .bytes() + .await + .map_err(|error| map_ark_video_request_error(format!("{fallback_message}:{error}")))?; + if !status.is_success() { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": fallback_message, + "status": status.as_u16(), + }))); + } + let normalized_mime_type = normalize_downloaded_video_mime_type(content_type.as_str()); + + Ok(DownloadedRemoteVideo { + extension: video_mime_to_extension(normalized_mime_type.as_str()).to_string(), + mime_type: normalized_mime_type, + bytes: body.to_vec(), + }) +} + fn build_asset_metadata( asset_kind: &str, owner_user_id: &str, @@ -1225,6 +1758,176 @@ fn normalize_scene_image_request( }) } +fn normalize_opening_cg_request(profile: &Value) -> Result { + let object = profile.as_object().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-opening-cg", + "message": "profile 必须是 JSON object", + })) + })?; + let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string()); + let profile_id = read_string_field(object, "id"); + let world_tone = read_string_field(object, "tone").ok_or_else(|| { + missing_opening_cg_field_error("世界基调缺失,无法生成开局 CG。") + })?; + let world_summary = read_string_field(object, "summary").ok_or_else(|| { + missing_opening_cg_field_error("世界概述缺失,无法生成开局 CG。") + })?; + let core_conflicts = read_string_array_field(object, "coreConflicts"); + if core_conflicts.is_empty() { + return Err(missing_opening_cg_field_error( + "核心冲突缺失,无法生成开局 CG。", + )); + } + let player_role = object + .get("playableNpcs") + .and_then(Value::as_array) + .and_then(|roles| roles.first()) + .and_then(Value::as_object) + .ok_or_else(|| missing_opening_cg_field_error("缺少玩家扮演角色。"))?; + let player_role_image_src = read_string_field(player_role, "imageSrc").ok_or_else(|| { + missing_opening_cg_field_error("玩家扮演角色缺少角色参考图。") + })?; + let player_role_brief = build_opening_cg_player_role_brief(player_role); + let opening_scene_image_src = profile + .pointer("/sceneChapterBlueprints/0/acts/0/backgroundImageSrc") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + missing_opening_cg_field_error("首个场景第一幕背景图缺失,无法生成开局 CG。") + })?; + let opening_cg_id = format!("opening-cg-{}", current_utc_millis()); + let storyboard_prompt = build_opening_cg_storyboard_prompt( + world_tone.as_str(), + player_role_brief.as_str(), + world_summary.as_str(), + core_conflicts.as_slice(), + ); + + Ok(NormalizedOpeningCgRequest { + profile_id, + world_name, + opening_cg_id, + storyboard_prompt, + video_prompt: OPENING_CG_VIDEO_PROMPT.to_string(), + player_role_image_src, + opening_scene_image_src, + }) +} + +fn missing_opening_cg_field_error(message: &str) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-opening-cg", + "message": message, + })) +} + +fn build_opening_cg_storyboard_prompt( + world_tone: &str, + player_role_brief: &str, + world_summary: &str, + core_conflicts: &[String], +) -> String { + format!( + "以3*4网格格式创建故事板,16:9。像素风角色扮演游戏开场动画CG。\n\n故事流程:先展示角色,展示故事背景,然后表现核心冲突,最后衔接开局场景\n故事基调:{}\n\n玩家扮演:将玩家扮演角色作为角色参考图并引用世界草稿中的角色简介:{}\n故事背景:{}\n核心冲突:{}\n开局场景:将首个场景的第一幕背景图作为参考图", + clamp_opening_cg_prompt_text(world_tone, 160), + clamp_opening_cg_prompt_text(player_role_brief, 320), + clamp_opening_cg_prompt_text(world_summary, 420), + clamp_opening_cg_prompt_text(core_conflicts.join(";").as_str(), 360), + ) +} + +fn build_opening_cg_player_role_brief(role: &Map) -> String { + [ + read_string_field(role, "name") + .map(|value| format!("姓名:{value}")) + .unwrap_or_default(), + read_string_field(role, "role") + .map(|value| format!("身份:{value}")) + .unwrap_or_default(), + read_string_field(role, "description") + .map(|value| format!("简介:{value}")) + .unwrap_or_default(), + read_string_field(role, "visualDescription") + .map(|value| format!("形象:{value}")) + .unwrap_or_default(), + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(";") +} + +fn read_string_array_field(object: &Map, key: &str) -> Vec { + object + .get(key) + .and_then(Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn clamp_opening_cg_prompt_text(value: &str, max_length: usize) -> String { + clamp_text(value, max_length, false) +} + +fn require_ark_video_settings(state: &AppState) -> Result { + let base_url = state + .config + .ark_character_video_base_url + .trim() + .trim_end_matches('/'); + if base_url.is_empty() { + return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "ark", + "reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置", + }))); + } + let api_key = state + .config + .ark_character_video_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "ark", + "reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置", + })) + })?; + + Ok(ArkVideoSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state + .config + .ark_character_video_request_timeout_ms + .max(OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS), + model: state.config.ark_character_video_model.clone(), + }) +} + +fn build_upstream_http_client(timeout_ms: u64) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "custom-world-opening-cg", + "message": format!("构造上游 HTTP 客户端失败:{error}"), + })) + }) +} + fn require_dashscope_settings(state: &AppState) -> Result { // Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。 let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); @@ -2143,6 +2846,20 @@ fn parse_json_payload( }) } +fn parse_ark_video_json_payload( + raw_text: &str, + fallback_message: &str, +) -> Result { + serde_json::from_str::(raw_text) + .map(|payload| ParsedJsonPayload { payload }) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": format!("{fallback_message}:解析响应失败:{error}"), + })) + }) +} + fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { if raw_text.trim().is_empty() { return fallback_message.to_string(); @@ -2193,6 +2910,13 @@ fn map_dashscope_request_error(message: String) -> AppError { })) } +fn map_ark_video_request_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": message, + })) +} + fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", @@ -2200,6 +2924,13 @@ fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppEr })) } +fn parse_ark_video_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": parse_api_error_message(raw_text, fallback_message), + })) +} + fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { match value { Value::Array(entries) => { @@ -2236,6 +2967,61 @@ fn extract_task_id(payload: &Value) -> Option { find_first_string_by_key(payload, "task_id") } +fn extract_ark_task_id(payload: &Value) -> Option { + payload + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| find_first_string_by_key(payload, "task_id")) + .or_else(|| find_first_string_by_key(payload, "taskId")) + .or_else(|| find_first_string_by_key(payload, "id")) +} + +fn extract_video_url(payload: &Value) -> Option { + find_first_string_by_key(payload, "video_url") + .or_else(|| find_first_string_by_key(payload, "videoUrl")) + .or_else(|| find_first_string_by_key(payload, "url")) +} + +fn extract_generation_task_status(payload: &Value) -> String { + payload + .get("status") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| find_first_string_by_key(payload, "task_status")) + .or_else(|| find_first_string_by_key(payload, "status")) + .unwrap_or_default() +} + +fn normalize_generation_task_status(value: &str) -> String { + value.trim().to_ascii_lowercase().replace(' ', "_") +} + +fn is_completed_generation_task_status(status: &str) -> bool { + matches!( + status, + "completed" | "complete" | "done" | "finished" | "success" | "succeeded" | "succeed" + ) +} + +fn is_failed_generation_task_status(status: &str) -> bool { + matches!( + status, + "failed" + | "canceled" + | "cancelled" + | "error" + | "aborted" + | "rejected" + | "expired" + | "unknown" + ) +} + fn extract_image_urls(payload: &Value) -> Vec { let mut urls = Vec::new(); collect_strings_by_key(payload, "image", &mut urls); @@ -2263,6 +3049,20 @@ fn normalize_downloaded_image_mime_type(content_type: &str) -> String { } } +fn normalize_downloaded_video_mime_type(content_type: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or("video/mp4"); + match mime_type { + "video/mp4" | "video/quicktime" | "video/webm" | "video/x-msvideo" => { + mime_type.to_string() + } + _ => "video/mp4".to_string(), + } +} + fn mime_to_extension(mime_type: &str) -> &str { match mime_type { "image/png" => "png", @@ -2272,6 +3072,15 @@ fn mime_to_extension(mime_type: &str) -> &str { } } +fn video_mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "video/quicktime" => "mov", + "video/webm" => "webm", + "video/x-msvideo" => "avi", + _ => "mp4", + } +} + fn conditional_prompt_line(prefix: &str, value: &str) -> String { if value.is_empty() { String::new() @@ -2391,6 +3200,12 @@ fn current_utc_micros() -> i64 { i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } +fn current_utc_iso_text() -> String { + time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| format!("{}.000000Z", current_utc_millis())) +} + fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } diff --git a/server-rs/crates/api-server/src/prompt/puzzle/draft.rs b/server-rs/crates/api-server/src/prompt/puzzle/draft.rs index ada339c0..f1eb2b9b 100644 --- a/server-rs/crates/api-server/src/prompt/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/prompt/puzzle/draft.rs @@ -58,15 +58,12 @@ mod tests { #[test] fn form_seed_prompt_keeps_only_user_visible_fields() { let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { - title: Some(" 暖灯猫街 "), - work_description: Some("雨夜礼物拼图"), + title: None, + work_description: None, picture_description: Some("猫咪在灯牌下回头"), }); - assert_eq!( - prompt, - "作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头" - ); + assert_eq!(prompt, "画面描述:猫咪在灯牌下回头"); } #[test] diff --git a/server-rs/crates/api-server/src/prompt/puzzle/level_name.rs b/server-rs/crates/api-server/src/prompt/puzzle/level_name.rs new file mode 100644 index 00000000..a18a15e7 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle/level_name.rs @@ -0,0 +1,35 @@ +/// 拼图首关关卡名生成提示词。 +/// +/// 模型只负责把画面描述压缩成可直接展示的中文关卡名;写回草稿和作品卡由业务路由处理。 +pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。 + +你会收到拼图第一关的画面描述。请生成 1 个适合直接展示在游戏关卡卡片上的中文关卡名。 + +硬约束: +1. 只输出 JSON,不要输出 Markdown、解释或代码块。 +2. JSON 格式必须是 {"levelName":"关卡名"}。 +3. levelName 必须是 2 到 8 个中文字符为主。 +4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。 +5. 不要输出标点、引号、编号、英文、emoji 或空白。 +6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。 +"#; + +pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String { + format!( + "画面描述:{picture_description}\n\n请生成第一关关卡名。", + picture_description = picture_description.trim(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn level_name_prompt_contains_picture_description() { + let prompt = build_puzzle_first_level_name_user_prompt("一只猫在雨夜灯牌下回头。"); + + assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。")); + assert!(prompt.contains("第一关关卡名")); + } +} diff --git a/server-rs/crates/api-server/src/prompt/puzzle/mod.rs b/server-rs/crates/api-server/src/prompt/puzzle/mod.rs index c579b9c0..5152ff46 100644 --- a/server-rs/crates/api-server/src/prompt/puzzle/mod.rs +++ b/server-rs/crates/api-server/src/prompt/puzzle/mod.rs @@ -1,3 +1,5 @@ pub(crate) mod agent_chat; pub(crate) mod draft; pub(crate) mod image; +pub(crate) mod level_name; +pub(crate) mod tags; diff --git a/server-rs/crates/api-server/src/prompt/puzzle/tags.rs b/server-rs/crates/api-server/src/prompt/puzzle/tags.rs new file mode 100644 index 00000000..f5759c88 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle/tags.rs @@ -0,0 +1,40 @@ +/// 拼图作品标签生成提示词。 +/// +/// 这里只负责标签生成的文本契约,业务路由负责调用 LLM、解析结果和写回草稿。 +pub(crate) const PUZZLE_TAG_GENERATION_SYSTEM_PROMPT: &str = r#"你是一个中文内容标签编辑。 + +你会收到拼图作品名称和作品描述。请生成 6 个适合作品广场检索和相似推荐的中文短标签。 + +硬约束: +1. 只输出 JSON,不要输出 Markdown、解释或代码块。 +2. JSON 格式必须是 {"tags":["标签1","标签2","标签3","标签4","标签5","标签6"]}。 +3. tags 必须正好 6 个。 +4. 每个标签 2 到 6 个中文字符为主,不要整句描述。 +5. 不要输出空标签、重复标签、英文标签、编号、标点或井号。 +6. 标签要覆盖题材、主体、氛围、场景、风格和拼图辨识点。 +"#; + +pub(crate) fn build_puzzle_tag_generation_user_prompt( + work_title: &str, + work_description: &str, +) -> String { + format!( + "作品名称:{work_title}\n作品描述:{work_description}\n\n请生成 6 个作品标签。", + work_title = work_title.trim(), + work_description = work_description.trim(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tag_prompt_contains_title_and_description() { + let prompt = build_puzzle_tag_generation_user_prompt("雨夜猫街", "一套暖灯街角主题拼图。"); + + assert!(prompt.contains("作品名称:雨夜猫街")); + assert!(prompt.contains("作品描述:一套暖灯街角主题拼图。")); + assert!(prompt.contains("6 个作品标签")); + } +} diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 8df96204..c760c231 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -18,6 +18,7 @@ use module_assets::{ build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus}; +use platform_llm::{LlmMessage, LlmTextRequest}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, @@ -76,6 +77,7 @@ use crate::{ }, auth::AuthenticatedAccessToken, http_error::AppError, + llm_model_routing::CREATION_TEMPLATE_LLM_MODEL, platform_errors::map_oss_error, prompt::puzzle::{ draft::{ @@ -83,6 +85,10 @@ use crate::{ resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt, }, image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, + level_name::{ + PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT, build_puzzle_first_level_name_user_prompt, + }, + tags::{PUZZLE_TAG_GENERATION_SYSTEM_PROMPT, build_puzzle_tag_generation_user_prompt}, }, puzzle_agent_turn::{ PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, @@ -527,15 +533,15 @@ pub async fn execute_puzzle_agent_action( }); ( "compile_puzzle_draft", - "完整拼图草稿", - "已编译草稿、生成拼图图片并应用为正式图。", + "首关拼图草稿", + "已编译首关草稿、生成首关画面并写入正式草稿。", session, ) } "save_puzzle_form_draft" => { let seed_text = build_puzzle_form_seed_text_from_parts( - payload.work_title.as_deref(), - payload.work_description.as_deref(), + None, + None, payload .picture_description .as_deref() @@ -705,6 +711,66 @@ pub async fn execute_puzzle_agent_action( session, ) } + "generate_puzzle_tags" => { + let work_title = payload + .work_title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + "作品名称不能为空", + ) + })?; + let work_description = payload + .work_description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + "作品描述不能为空", + ) + })?; + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|message| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })), + ) + })?; + let generated_tags = + generate_puzzle_work_tags(&state, work_title, work_description).await; + let session = save_generated_puzzle_tags_to_session( + &state, + &session_id, + &owner_user_id, + &payload, + generated_tags, + levels_json, + now, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); + ( + "generate_puzzle_tags", + "作品标签生成", + "已生成 6 个作品标签。", + session, + ) + } "select_puzzle_image" => { let candidate_id = payload .candidate_id @@ -2058,12 +2124,12 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String { fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { - title: payload - .work_title + title: None, + work_description: None, + picture_description: payload + .picture_description .as_deref() .or(payload.seed_text.as_deref()), - work_description: payload.work_description.as_deref(), - picture_description: payload.picture_description.as_deref(), }) } @@ -2088,8 +2154,8 @@ async fn save_puzzle_form_payload_before_compile( now: i64, ) -> Result { let seed_text = build_puzzle_form_seed_text_from_parts( - payload.work_title.as_deref(), - payload.work_description.as_deref(), + None, + None, payload .picture_description .as_deref() @@ -2486,6 +2552,176 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) { ) } +async fn generate_puzzle_first_level_name(state: &AppState, picture_description: &str) -> String { + if let Some(llm_client) = state.llm_client() { + let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(), + ) + .await; + match response { + Ok(response) => { + if let Some(level_name) = + parse_puzzle_first_level_name_from_text(response.content.as_str()) + { + return level_name; + } + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + picture_chars = picture_description.chars().count(), + "拼图首关名模型返回非法,降级使用关键词名" + ); + } + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + picture_chars = picture_description.chars().count(), + error = %error, + "拼图首关名生成失败,降级使用关键词名" + ); + } + } + } + + build_fallback_puzzle_first_level_name(picture_description) +} + +fn parse_puzzle_first_level_name_from_text(text: &str) -> Option { + let trimmed = text.trim(); + let json_text = if let Some(start) = trimmed.find('{') + && let Some(end) = trimmed.rfind('}') + && end > start + { + &trimmed[start..=end] + } else { + trimmed + }; + let parsed = serde_json::from_str::(json_text).ok(); + let raw_name = parsed + .as_ref() + .and_then(|value| value.get("levelName").and_then(Value::as_str)) + .or_else(|| { + parsed + .as_ref() + .and_then(|value| value.get("level_name").and_then(Value::as_str)) + }) + .unwrap_or(trimmed); + normalize_puzzle_first_level_name(raw_name) +} + +fn normalize_puzzle_first_level_name(value: &str) -> Option { + let normalized = value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')')) + .chars() + .filter(|ch| { + !matches!( + ch, + '#' | '"' + | '\'' + | '`' + | ' ' + | '\t' + | '\r' + | '\n' + | ',' + | '。' + | '、' + | ';' + | ':' + | '!' + | '?' + | '“' + | '”' + | '《' + | '》' + ) + }) + .take(12) + .collect::(); + let normalized = strip_puzzle_level_name_generic_words(normalized); + if normalized.chars().count() >= 2 + && !matches!( + normalized.as_str(), + "第一关" | "画面" | "拼图" | "作品" | "关卡" + ) + { + Some(normalized) + } else { + None + } +} + +fn strip_puzzle_level_name_generic_words(mut value: String) -> String { + for prefix in ["第一关", "关卡名", "关卡"] { + value = value.trim_start_matches(prefix).to_string(); + } + for suffix in ["第一关", "关卡名", "关卡", "画面", "拼图", "作品"] { + value = value.trim_end_matches(suffix).to_string(); + } + value.chars().take(8).collect() +} + +fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String { + let source = picture_description.trim(); + if source.contains("猫") && (source.contains("雨夜") || source.contains('雨')) { + return "雨夜猫街".to_string(); + } + if source.contains("猫") && source.contains('灯') { + return "暖灯猫街".to_string(); + } + for (keyword, level_name) in [ + ("雨夜", "雨夜灯街"), + ("猫", "暖灯猫街"), + ("狗", "花园小狗"), + ("神庙", "神庙遗光"), + ("遗迹", "遗迹谜光"), + ("森林", "森林秘境"), + ("城市", "霓虹城市"), + ("机械", "机械迷城"), + ("蒸汽", "蒸汽街区"), + ("海", "海岸微光"), + ("花", "花园晨光"), + ("雪", "雪境小径"), + ("龙", "龙影高塔"), + ("灯", "暖灯街角"), + ("塔", "塔顶星光"), + ] { + if source.contains(keyword) { + return level_name.to_string(); + } + } + "奇境初见".to_string() +} + +fn build_puzzle_levels_with_primary_name( + draft: &PuzzleResultDraftRecord, + target_level: &PuzzleDraftLevelRecord, +) -> Vec { + let mut levels = draft.levels.clone(); + if let Some(index) = levels + .iter() + .position(|level| level.level_id == target_level.level_id) + .or_else(|| (!levels.is_empty()).then_some(0)) + { + levels[index].level_name = target_level.level_name.clone(); + } + levels +} + async fn compile_puzzle_draft_with_initial_cover( state: &AppState, session_id: String, @@ -2506,7 +2742,14 @@ async fn compile_puzzle_draft_with_initial_cover( "message": "拼图结果页草稿尚未生成", })) })?; - let target_level = select_puzzle_level_for_api(&draft, None)?; + let mut target_level = select_puzzle_level_for_api(&draft, None)?; + let fallback_level_name = target_level.level_name.clone(); + let generated_level_name = + generate_puzzle_first_level_name(state, &target_level.picture_description).await; + target_level.level_name = generated_level_name.clone(); + let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module( + &build_puzzle_levels_with_primary_name(&draft, &target_level), + )?); let image_prompt = resolve_puzzle_draft_cover_prompt( prompt_text, &target_level.picture_description, @@ -2554,7 +2797,7 @@ async fn compile_puzzle_draft_with_initial_cover( session_id: compiled_session.session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: Some(target_level.level_id.clone()), - levels_json: None, + levels_json: levels_json_with_generated_name, candidates_json, saved_at_micros: current_utc_micros(), }) @@ -2572,7 +2815,13 @@ async fn compile_puzzle_draft_with_initial_cover( "拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" ); let session = apply_generated_puzzle_candidates_to_session_snapshot( - compiled_session.clone(), + apply_generated_puzzle_first_level_name_to_session_snapshot( + compiled_session.clone(), + target_level.level_id.as_str(), + generated_level_name.as_str(), + fallback_level_name.as_str(), + now, + ), target_level.level_id.as_str(), candidates.clone(), now, @@ -2655,6 +2904,39 @@ fn apply_generated_puzzle_candidates_to_session_snapshot( session } +fn apply_generated_puzzle_first_level_name_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + target_level_id: &str, + level_name: &str, + previous_level_name: &str, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + let normalized_name = level_name.trim(); + if normalized_name.is_empty() { + return session; + } + let Some(target_index) = draft + .levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!draft.levels.is_empty()).then_some(0)) + else { + return session; + }; + draft.levels[target_index].level_name = normalized_name.to_string(); + let should_default_work_title = + draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim(); + if target_index == 0 && should_default_work_title { + draft.work_title = normalized_name.to_string(); + } + sync_puzzle_primary_draft_fields_from_level(draft); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) { let Some(primary_level) = draft.levels.first() else { return; @@ -2677,6 +2959,305 @@ fn replace_puzzle_session_draft_snapshot( session } +async fn generate_puzzle_work_tags( + state: &AppState, + work_title: &str, + work_description: &str, +) -> Vec { + if let Some(llm_client) = state.llm_client() { + let user_prompt = build_puzzle_tag_generation_user_prompt(work_title, work_description); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(PUZZLE_TAG_GENERATION_SYSTEM_PROMPT), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(), + ) + .await; + match response { + Ok(response) => { + let tags = normalize_puzzle_tag_candidates(parse_puzzle_tags_from_text( + response.content.as_str(), + )); + if tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { + return tags; + } + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + work_title, + "拼图 AI 标签数量不足,降级使用关键词补齐" + ); + } + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + work_title, + error = %error, + "拼图 AI 标签生成失败,降级使用关键词标签" + ); + } + } + } + + normalize_puzzle_tag_candidates(build_fallback_puzzle_tags(work_title, work_description)) +} + +fn parse_puzzle_tags_from_text(text: &str) -> Vec { + let trimmed = text.trim(); + let json_text = if let Some(start) = trimmed.find('{') + && let Some(end) = trimmed.rfind('}') + && end > start + { + &trimmed[start..=end] + } else { + trimmed + }; + let Ok(value) = serde_json::from_str::(json_text) else { + return normalize_puzzle_tag_candidates(trimmed.split([',', ',', '、', '\n'])); + }; + let Some(tags) = value.get("tags").and_then(Value::as_array) else { + return Vec::new(); + }; + normalize_puzzle_tag_candidates(tags.iter().filter_map(Value::as_str)) +} + +fn normalize_puzzle_tag_candidates(candidates: impl IntoIterator) -> Vec +where + S: AsRef, +{ + let mut tags = Vec::new(); + for candidate in candidates { + let normalized = normalize_puzzle_tag(candidate.as_ref()); + if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) { + continue; + } + tags.push(normalized); + if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT { + break; + } + } + for fallback in ["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"] { + if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT { + break; + } + if !tags.iter().any(|tag| tag == fallback) { + tags.push(fallback.to_string()); + } + } + tags +} + +fn normalize_puzzle_tag(value: &str) -> String { + value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')')) + .trim() + .chars() + .filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`')) + .take(6) + .collect::() +} + +fn build_fallback_puzzle_tags(work_title: &str, work_description: &str) -> Vec<&'static str> { + let source = format!("{work_title} {work_description}"); + let mut tags = Vec::new(); + for (keyword, tag) in [ + ("猫", "猫咪"), + ("狗", "小狗"), + ("神庙", "神庙遗迹"), + ("遗迹", "神庙遗迹"), + ("森林", "童话森林"), + ("雨", "雨夜"), + ("夜", "夜景"), + ("城市", "城市奇景"), + ("蒸汽", "蒸汽城市"), + ("机械", "机械幻想"), + ("海", "海岸"), + ("花", "花园"), + ("雪", "雪景"), + ("龙", "幻想生物"), + ("灯", "暖灯"), + ("塔", "高塔"), + ] { + if source.contains(keyword) && !tags.contains(&tag) { + tags.push(tag); + } + } + tags.extend(["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"]); + tags +} + +async fn save_generated_puzzle_tags_to_session( + state: &AppState, + session_id: &str, + owner_user_id: &str, + payload: &ExecutePuzzleAgentActionRequest, + generated_tags: Vec, + levels_json: Option, + now: i64, +) -> Result { + let session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string()) + .await + .map_err(map_puzzle_client_error)?; + let draft = session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + let mut levels = if let Some(levels_json) = levels_json.as_deref() { + parse_puzzle_level_records_from_module_json(levels_json)? + } else { + draft.levels.clone() + }; + if levels.is_empty() { + levels = draft.levels.clone(); + } + let first_level = levels.first().cloned().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿缺少可编辑关卡", + })) + })?; + let work_title = payload + .work_title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(draft.work_title.as_str()) + .to_string(); + let work_description = payload + .work_description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(draft.work_description.as_str()) + .to_string(); + let levels_json = Some(serialize_puzzle_level_records_for_module(&levels)?); + let (_, profile_id) = build_stable_puzzle_work_ids(session_id); + state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id: owner_user_id.to_string(), + work_title: work_title.clone(), + work_description: work_description.clone(), + level_name: first_level.level_name.clone(), + summary: work_description.clone(), + theme_tags: generated_tags.clone(), + cover_image_src: first_level.cover_image_src.clone(), + cover_asset_id: first_level.cover_asset_id.clone(), + levels_json, + updated_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error)?; + + Ok(apply_generated_puzzle_tags_to_session_snapshot( + session, + generated_tags, + work_title, + work_description, + levels, + now, + )) +} + +fn apply_generated_puzzle_tags_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + generated_tags: Vec, + work_title: String, + work_description: String, + levels: Vec, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + draft.work_title = work_title; + draft.work_description = work_description.clone(); + draft.summary = work_description; + draft.theme_tags = generated_tags; + draft.levels = levels; + sync_puzzle_primary_draft_fields_from_level(draft); + session.progress_percent = session.progress_percent.max(96); + session.stage = if is_puzzle_session_snapshot_publish_ready(draft) { + "ready_to_publish".to_string() + } else { + "image_refining".to_string() + }; + session.last_assistant_reply = Some("作品标签已生成。".to_string()); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool { + !draft.work_title.trim().is_empty() + && !draft.work_description.trim().is_empty() + && draft.theme_tags.len() >= module_puzzle::PUZZLE_MIN_TAG_COUNT + && draft.theme_tags.len() <= module_puzzle::PUZZLE_MAX_TAG_COUNT + && !draft.levels.is_empty() + && draft.levels.iter().all(|level| { + !level.level_name.trim().is_empty() + && level + .cover_image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + }) +} + +fn serialize_puzzle_level_records_for_module( + levels: &[PuzzleDraftLevelRecord], +) -> Result { + let payload = levels + .iter() + .map(|level| { + json!({ + "level_id": level.level_id, + "level_name": level.level_name, + "picture_description": level.picture_description, + "candidates": level + .candidates + .iter() + .map(|candidate| { + json!({ + "candidate_id": candidate.candidate_id, + "image_src": candidate.image_src, + "asset_id": candidate.asset_id, + "prompt": candidate.prompt, + "actual_prompt": candidate.actual_prompt, + "source_type": candidate.source_type, + "selected": candidate.selected, + }) + }) + .collect::>(), + "selected_candidate_id": level.selected_candidate_id, + "cover_image_src": level.cover_image_src, + "cover_asset_id": level.cover_asset_id, + "generation_status": level.generation_status, + }) + }) + .collect::>(); + serde_json::to_string(&payload).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图关卡列表序列化失败:{error}"), + })) + }) +} + fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool { matches!( error.status_code(), @@ -3069,6 +3650,84 @@ mod tests { ); } + #[test] + fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() { + assert_eq!( + parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#), + Some("雨夜猫街".to_string()) + ); + assert_eq!( + parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"), + Some("暖灯猫街".to_string()) + ); + assert_eq!( + parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#), + Some("雨夜猫街".to_string()) + ); + } + + #[test] + fn puzzle_first_level_name_fallback_uses_picture_keywords() { + assert_eq!( + build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"), + "雨夜猫街" + ); + assert_eq!( + build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"), + "奇境初见" + ); + } + + #[test] + fn puzzle_first_level_name_snapshot_defaults_work_title() { + let levels_json = serde_json::to_string(&vec![json!({ + "level_id": "puzzle-level-1", + "level_name": "猫画面", + "picture_description": "一只猫在雨夜灯牌下回头。", + "candidates": [], + "selected_candidate_id": null, + "cover_image_src": null, + "cover_asset_id": null, + "generation_status": "idle", + })]) + .expect("levels json"); + let payload = ExecutePuzzleAgentActionRequest { + action: "generate_puzzle_images".to_string(), + prompt_text: None, + reference_image_src: None, + image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), + candidate_count: Some(1), + candidate_id: None, + level_id: Some("puzzle-level-1".to_string()), + work_title: Some("猫画面".to_string()), + work_description: None, + picture_description: None, + level_name: None, + summary: None, + theme_tags: Some(vec![]), + levels_json: Some(levels_json.clone()), + }; + let session = build_puzzle_session_snapshot_from_action_payload( + "puzzle-session-1", + &payload, + Some(levels_json.as_str()), + 1_713_686_401_234_567, + ) + .expect("fallback session"); + + let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot( + session, + "puzzle-level-1", + "雨夜猫街", + "猫画面", + 1_713_686_401_234_568, + ); + let draft = renamed.draft.expect("draft"); + assert_eq!(draft.level_name, "雨夜猫街"); + assert_eq!(draft.work_title, "雨夜猫街"); + assert_eq!(draft.levels[0].level_name, "雨夜猫街"); + } + #[test] fn freeze_boundary_sync_only_matches_freeze_invalid_operation() { let invalid_operation = diff --git a/server-rs/crates/api-server/src/story_sessions.rs b/server-rs/crates/api-server/src/story_sessions.rs index 39f69633..3caeb625 100644 --- a/server-rs/crates/api-server/src/story_sessions.rs +++ b/server-rs/crates/api-server/src/story_sessions.rs @@ -115,7 +115,7 @@ pub async fn begin_story_runtime_session( story_session_payload_from_record(story_result.session), vec![story_event_payload_from_record(story_result.event)], &persisted, - persisted.version, + None, ), }, )) @@ -257,7 +257,7 @@ pub async fn resolve_story_runtime_action( story_session_payload_from_record(story_result.session), vec![story_event_payload_from_record(story_result.event)], &persisted, - resolved.server_version.max(persisted.version), + Some(resolved.server_version), ), }, )) @@ -395,7 +395,7 @@ fn build_story_runtime_projection_from_persisted( story_session: StorySessionPayload, story_events: Vec, record: &RuntimeSnapshotRecord, - server_version: u32, + resolved_version: Option, ) -> shared_contracts::story::StoryRuntimeProjectionResponse { let snapshot = story_runtime_snapshot_payload_from_record(record); let current_story = snapshot.current_story.as_ref(); @@ -405,6 +405,8 @@ fn build_story_runtime_projection_from_persisted( .or_else(|| Some(story_session.latest_narrative_text.clone())); let action_result_text = read_story_runtime_current_field(current_story, "resultText"); let toast = read_story_runtime_current_field(current_story, "toast"); + let server_version = + resolve_story_runtime_projection_version(&snapshot.game_state, resolved_version); module_runtime_story::build_story_runtime_projection( module_runtime_story::StoryRuntimeProjectionSource { @@ -420,6 +422,15 @@ fn build_story_runtime_projection_from_persisted( ) } +fn resolve_story_runtime_projection_version( + game_state: &Value, + resolved_version: Option, +) -> u32 { + module_runtime_story::read_u32_field(game_state, "runtimeActionVersion") + .or(resolved_version) + .unwrap_or(1) +} + fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option { read_story_runtime_current_field(current_story, "text") .or_else(|| read_story_runtime_current_field(current_story, "storyText")) @@ -619,10 +630,12 @@ mod tests { use time::OffsetDateTime; use tower::ServiceExt; - use super::require_story_session_owner; + use super::{build_story_runtime_projection_from_persisted, require_story_session_owner}; use crate::{ app::build_router, config::AppConfig, request_context::RequestContext, state::AppState, }; + use module_runtime::RuntimeSnapshotRecord; + use shared_contracts::story::StorySessionPayload; #[tokio::test] async fn begin_story_session_requires_authentication() { @@ -1028,6 +1041,56 @@ mod tests { ); } + #[test] + fn story_runtime_projection_version_prefers_runtime_action_version() { + let projection = build_story_runtime_projection_from_persisted( + StorySessionPayload { + story_session_id: "storysess_001".to_string(), + runtime_session_id: "runtime_001".to_string(), + actor_user_id: "user_1".to_string(), + world_profile_id: "profile_1".to_string(), + initial_prompt: "进入营地".to_string(), + opening_summary: Some("营地开场".to_string()), + latest_narrative_text: "最新故事".to_string(), + latest_choice_function_id: Some("npc_chat".to_string()), + status: "active".to_string(), + version: 9, + created_at: "1.000000Z".to_string(), + updated_at: "3.000000Z".to_string(), + }, + vec![], + &RuntimeSnapshotRecord { + user_id: "user_1".to_string(), + version: 2, + saved_at: "3.000000Z".to_string(), + saved_at_micros: 3, + bottom_tab: "adventure".to_string(), + game_state: json!({ + "runtimeSessionId": "runtime_001", + "runtimeActionVersion": 7, + "playerHp": 30, + "playerMaxHp": 40, + "playerMana": 10, + "playerMaxMana": 20, + "playerCurrency": 0, + "playerInventory": [], + "playerEquipment": { "weapon": null, "armor": null, "relic": null }, + "inBattle": false, + "npcInteractionActive": false, + "storyHistory": [] + }), + current_story: None, + game_state_json: "{}".to_string(), + current_story_json: None, + created_at_micros: 1, + updated_at_micros: 3, + }, + None, + ); + + assert_eq!(projection.server_version, 7); + } + #[test] fn story_session_owner_guard_rejects_mismatched_actor() { let context = RequestContext::new( diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index 2793687f..64dcd8de 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -125,10 +125,7 @@ pub fn build_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleA pack.visual_mood.status = PuzzleAnchorStatus::Inferred; pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string(); pack.composition_hooks.status = PuzzleAnchorStatus::Inferred; - pack.tags_and_forbidden.value = build_form_tags_and_forbidden( - normalized_title.as_deref().unwrap_or(""), - normalized_description.as_deref().unwrap_or(""), - ); + pack.tags_and_forbidden.value = build_form_tags_and_forbidden(title, picture_description); pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred; pack @@ -178,12 +175,12 @@ pub fn compile_result_draft_from_seed( seed_text: Option<&str>, ) -> PuzzleResultDraft { let creator_intent = build_creator_intent(anchor_pack, messages); - let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone()); - let work_title = build_work_title(anchor_pack); + let normalized_tags = resolve_initial_theme_tags(seed_text, &creator_intent); let work_description = resolve_work_description(seed_text, anchor_pack); let picture_description = fallback_text(&anchor_pack.visual_subject.value, "画面主体"); let level_name = build_level_name_from_picture(picture_description.as_str(), &normalized_tags, 1); + let work_title = resolve_work_title(seed_text, anchor_pack, &level_name); let level = PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), level_name: level_name.clone(), @@ -238,16 +235,6 @@ pub fn build_form_draft_from_parts( let work_description = work_description.and_then(|value| normalize_required_string(&value)); let picture_description = picture_description.and_then(|value| normalize_required_string(&value)); - let title_for_tags = work_title.as_deref().unwrap_or(""); - let picture_for_tags = picture_description.as_deref().unwrap_or(""); - let mut tags = normalize_theme_tags(derive_form_theme_tags(title_for_tags, picture_for_tags)); - if tags.is_empty() { - tags = vec![ - "拼图".to_string(), - "插画".to_string(), - "清晰构图".to_string(), - ]; - } let summary = work_description.clone().unwrap_or_default(); let level = PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), @@ -266,7 +253,7 @@ pub fn build_form_draft_from_parts( work_description: summary.clone(), level_name: String::new(), summary, - theme_tags: tags, + theme_tags: Vec::new(), forbidden_directives: Vec::new(), creator_intent: None, anchor_pack: anchor_pack.clone(), @@ -349,12 +336,6 @@ pub fn apply_selected_candidate( } pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft { - if draft.work_title.trim().is_empty() { - draft.work_title = fallback_text(&draft.anchor_pack.theme_promise.value, &draft.level_name); - } - if draft.work_description.trim().is_empty() { - draft.work_description = draft.summary.clone(); - } if draft.levels.is_empty() { draft.levels = vec![PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), @@ -383,9 +364,6 @@ pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) { draft.cover_asset_id = primary_level.cover_asset_id.clone(); draft.generation_status = primary_level.generation_status.clone(); } - if draft.work_description.trim().is_empty() { - draft.work_description = draft.summary.clone(); - } draft.summary = draft.work_description.clone(); if draft.form_draft.is_some() { draft.form_draft = Some(PuzzleFormDraft { @@ -642,23 +620,19 @@ pub fn apply_publish_overrides_to_draft( ) -> Result { let mut next_draft = normalize_puzzle_draft(draft.clone()); - if let Some(next_work_title) = work_title - && let Some(normalized_work_title) = normalize_required_string(&next_work_title) - { - next_draft.work_title = normalized_work_title; + if let Some(next_work_title) = work_title { + next_draft.work_title = normalize_required_string(&next_work_title).unwrap_or_default(); } - if let Some(next_work_description) = work_description - && let Some(normalized_work_description) = normalize_required_string(&next_work_description) - { - next_draft.work_description = normalized_work_description; + if let Some(next_work_description) = work_description { + next_draft.work_description = + normalize_required_string(&next_work_description).unwrap_or_default(); } - if let Some(next_level_name) = level_name - && let Some(normalized_level_name) = normalize_required_string(&next_level_name) - { + if let Some(next_level_name) = level_name { if let Some(primary_level) = next_draft.levels.first_mut() { - primary_level.level_name = normalized_level_name; + primary_level.level_name = + normalize_required_string(&next_level_name).unwrap_or_default(); } } @@ -689,7 +663,7 @@ pub fn apply_publish_overrides_to_draft( pub fn normalize_puzzle_levels( levels: Vec, - theme_tags: &[String], + _theme_tags: &[String], ) -> Result, PuzzleFieldError> { let mut normalized_levels = Vec::new(); for (index, mut level) in levels.into_iter().enumerate() { @@ -697,9 +671,7 @@ pub fn normalize_puzzle_levels( .unwrap_or_else(|| format!("puzzle-level-{}", index + 1)); let picture_description = normalize_required_string(&level.picture_description) .unwrap_or_else(|| format!("第{}关画面", index + 1)); - let level_name = normalize_required_string(&level.level_name).unwrap_or_else(|| { - build_level_name_from_picture(picture_description.as_str(), theme_tags, index + 1) - }); + let level_name = normalize_required_string(&level.level_name).unwrap_or_default(); level.level_id = level_id; level.level_name = level_name; level.picture_description = picture_description; @@ -1959,21 +1931,67 @@ fn build_result_summary(anchor_pack: &PuzzleAnchorPack) -> String { } fn resolve_work_description(seed_text: Option<&str>, anchor_pack: &PuzzleAnchorPack) -> String { - seed_text - .and_then(parse_form_seed_text) - .and_then(|parts| { - parts - .work_description - .or(parts.picture_description) - .or(parts.work_title) - }) - .unwrap_or_else(|| build_result_summary(anchor_pack)) + if let Some(parts) = seed_text.and_then(parse_form_seed_text) { + if parts.picture_description.is_some() + && parts.work_title.is_none() + && parts.work_description.is_none() + { + return String::new(); + } + return parts + .work_description + .unwrap_or_else(|| build_result_summary(anchor_pack)); + } + build_result_summary(anchor_pack) } fn build_work_title(anchor_pack: &PuzzleAnchorPack) -> String { fallback_text(&anchor_pack.theme_promise.value, "奇景拼图") } +fn resolve_work_title( + seed_text: Option<&str>, + anchor_pack: &PuzzleAnchorPack, + level_name: &str, +) -> String { + seed_text + .and_then(parse_form_seed_text) + .and_then(|parts| { + parts + .work_title + .or_else(|| normalize_required_string(level_name)) + }) + .unwrap_or_else(|| build_work_title(anchor_pack)) +} + +fn resolve_initial_theme_tags( + seed_text: Option<&str>, + creator_intent: &PuzzleCreatorIntent, +) -> Vec { + if let Some(parts) = seed_text.and_then(parse_form_seed_text) { + if parts.picture_description.is_some() + && parts.work_title.is_none() + && parts.work_description.is_none() + { + return Vec::new(); + } + let derived_tags = normalize_theme_tags(derive_form_theme_tags( + parts + .work_title + .as_deref() + .unwrap_or(creator_intent.theme_promise.as_str()), + parts + .picture_description + .as_deref() + .unwrap_or(creator_intent.visual_subject.as_str()), + )); + if !derived_tags.is_empty() { + return derived_tags; + } + } + normalize_theme_tags(creator_intent.theme_tags.clone()) +} + fn extract_forbidden_directive(source: &str) -> String { if let Some((_, tail)) = source.split_once(';') { return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string()); @@ -1996,7 +2014,7 @@ fn build_level_name_from_picture( } } if let Some(tag) = normalized_tags.first() { - return format!("{tag}第{level_index}关"); + return format!("{tag}画面"); } format!("第{level_index}关") } @@ -2912,6 +2930,23 @@ mod tests { assert!(draft.theme_tags.len() >= PUZZLE_MIN_TAG_COUNT); } + #[test] + fn picture_only_form_seed_uses_level_name_as_work_title_and_empty_metadata() { + let seed_text = "画面描述:一只猫在雨夜灯牌下回头。"; + let anchor_pack = infer_anchor_pack(seed_text, None); + let draft = compile_result_draft_from_seed(&anchor_pack, &[], Some(seed_text)); + + assert_eq!(draft.level_name, "猫画面"); + assert_eq!(draft.work_title, "猫画面"); + assert_eq!(draft.work_description, ""); + assert_eq!(draft.summary, ""); + assert!(draft.theme_tags.is_empty()); + assert_eq!( + draft.levels[0].picture_description, + "一只猫在雨夜灯牌下回头。" + ); + } + #[test] fn form_seed_keeps_multiline_picture_description() { let anchor_pack = infer_anchor_pack( @@ -3452,4 +3487,34 @@ mod tests { assert_eq!(error, PuzzleFieldError::InvalidTagCount); } + + #[test] + fn apply_publish_overrides_preserves_empty_level_name_for_publish_gate() { + let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙")); + let draft = compile_result_draft(&anchor_pack, &[]); + let mut levels = draft.levels.clone(); + levels[0].level_name = " ".to_string(); + + let updated = apply_publish_overrides_to_draft( + &draft, + Some("雨夜猫塔作品".to_string()), + Some("作品描述。".to_string()), + Some("".to_string()), + Some("作品描述。".to_string()), + Some(vec![ + "雨夜".to_string(), + "猫咪".to_string(), + "遗迹".to_string(), + ]), + Some(levels), + ) + .expect("empty level name should remain editable before publish gate"); + + assert_eq!(updated.levels[0].level_name, ""); + assert!( + validate_publish_requirements(&updated, Some("玩家")) + .iter() + .any(|blocker| blocker.code == "MISSING_LEVEL_NAME") + ); + } } diff --git a/server-rs/crates/spacetime-client/README.md b/server-rs/crates/spacetime-client/README.md index 7309b7d0..85880049 100644 --- a/server-rs/crates/spacetime-client/README.md +++ b/server-rs/crates/spacetime-client/README.md @@ -29,6 +29,7 @@ 4. 生成绑定到 BFF record / module record 的 row snapshot mapper 已集中在 `mapper.rs`。 5. SDK 调用错误、reducer 业务错误、procedure 业务错误、缺快照错误和本地输入校验错误已统一收口到 `SpacetimeClientError` helper。 6. Story runtime projection source 已复用 runtime inventory typed facade,读取投影不再只依赖 runtime snapshot 中的历史背包 JSON 副本。 +7. Story runtime 投影读取会对历史 `currentStory.options` 做兼容推断:若旧快照缺少 `scope`,仍会按 `functionId` 通过 `module-runtime-story` 的 option helper 还原为 `story / combat / npc` 作用域,避免旧存档把读取链路卡死。 `confirm_asset_object_and_return` 与 `bind_asset_object_to_entity_and_return` 的调用必须等到 SDK `on_connect` 回调后再发起。`DbConnection::build()` 只代表 WebSocket 已经初始化,不代表 SpacetimeDB 身份握手完成;如果过早调用 procedure,本地联调会表现为连接建立但请求长期没有回调,最终等到 idle timeout。 diff --git a/server-rs/crates/spacetime-client/src/story_runtime.rs b/server-rs/crates/spacetime-client/src/story_runtime.rs index dfe0b60f..b0b0023b 100644 --- a/server-rs/crates/spacetime-client/src/story_runtime.rs +++ b/server-rs/crates/spacetime-client/src/story_runtime.rs @@ -1,10 +1,7 @@ use module_inventory::{RuntimeInventorySlotRecord, RuntimeInventoryStateRecord}; use module_runtime_story::StoryRuntimeProjectionSource; use serde_json::{Map, Value, json}; -use shared_contracts::{ - runtime_story::RuntimeStoryOptionView, - story::{StoryEventPayload, StorySessionPayload}, -}; +use shared_contracts::story::{StoryEventPayload, StorySessionPayload}; use std::collections::HashMap; use super::*; @@ -43,7 +40,10 @@ impl SpacetimeClient { )?; let current_story = runtime_snapshot.current_story.as_ref(); let latest_narrative_text = story_state.session.latest_narrative_text.clone(); - let server_version = runtime_snapshot.version.max(story_state.session.version); + let server_version = + resolve_story_runtime_server_version(&game_state, story_state.session.version); + + let options = module_runtime_story::build_runtime_story_options(current_story, &game_state); Ok(StoryRuntimeProjectionSource { story_session: build_story_session_payload(story_state.session), @@ -53,7 +53,7 @@ impl SpacetimeClient { .map(build_story_event_payload) .collect(), game_state, - options: read_runtime_story_options(current_story)?, + options, server_version, current_narrative_text: read_current_story_text(current_story) .or(Some(latest_narrative_text)), @@ -311,20 +311,6 @@ fn build_story_event_payload(record: StoryEventRecord) -> StoryEventPayload { } } -fn read_runtime_story_options( - current_story: Option<&Value>, -) -> Result, SpacetimeClientError> { - let Some(options) = current_story.and_then(|story| story.get("options")) else { - return Ok(Vec::new()); - }; - - serde_json::from_value::>(options.clone()).map_err(|error| { - SpacetimeClientError::Runtime(format!( - "currentStory.options 无法映射为后端选项投影: {error}" - )) - }) -} - fn read_current_story_text(current_story: Option<&Value>) -> Option { read_current_story_string(current_story, "text") .or_else(|| read_current_story_string(current_story, "storyText")) @@ -340,6 +326,20 @@ fn read_current_story_string(current_story: Option<&Value>, field: &str) -> Opti .map(ToOwned::to_owned) } +fn read_current_runtime_action_version(game_state: &Value) -> Option { + game_state + .as_object()? + .get("runtimeActionVersion")? + .as_u64() + .and_then(|value| u32::try_from(value).ok()) +} + +fn resolve_story_runtime_server_version(game_state: &Value, story_session_version: u32) -> u32 { + read_current_runtime_action_version(game_state) + .or(Some(story_session_version)) + .unwrap_or(1) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -434,16 +434,26 @@ mod tests { } #[test] - fn current_story_options_parse_runtime_story_options() { - let options = read_runtime_story_options(Some(&json!({ + fn runtime_projection_source_uses_runtime_action_version() { + let game_state = json!({ + "runtimeSessionId": "runtime_1", + "runtimeActionVersion": 1 + }); + + assert_eq!(resolve_story_runtime_server_version(&game_state, 3), 1); + } + + #[test] + fn current_story_options_infer_scope_for_legacy_story_options() { + let current_story = json!({ "text": "守火人抬眼看着你。", "options": [{ "functionId": "npc_chat", - "actionText": "继续交谈", - "scope": "npc" + "actionText": "继续交谈" }] - }))) - .expect("options should parse"); + }); + let options = + module_runtime_story::build_runtime_story_options(Some(¤t_story), &json!({})); assert_eq!(options[0].function_id, "npc_chat"); assert_eq!(options[0].action_text, "继续交谈"); diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index fde32b88..a7e60fb9 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -946,10 +946,18 @@ fn save_puzzle_generated_images_tx( ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let mut draft = deserialize_draft_required(&row.draft_json)?; + let previous_primary_level_name = draft.level_name.clone(); + let previous_work_title = draft.work_title.clone(); if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? { // 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。 draft.levels = levels; module_puzzle::sync_primary_level_fields(&mut draft); + // 中文注释:入口直创会在 api-server 生成首关名后随 levels_json 写入;作品名仍是旧首关名或空值时才跟随首关名,避免覆盖用户手动命名。 + sync_generated_primary_level_name_as_default_work_title( + &mut draft, + &previous_work_title, + &previous_primary_level_name, + ); } let candidates: Vec = json_from_str(&input.candidates_json) .map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?; @@ -1014,6 +1022,18 @@ fn save_puzzle_generated_images_tx( ) } +fn sync_generated_primary_level_name_as_default_work_title( + draft: &mut PuzzleResultDraft, + previous_work_title: &str, + previous_primary_level_name: &str, +) { + if previous_work_title.trim().is_empty() + || previous_work_title.trim() == previous_primary_level_name.trim() + { + draft.work_title = draft.level_name.clone(); + } +} + fn select_puzzle_cover_image_tx( ctx: &TxContext, input: PuzzleSelectCoverImageInput, @@ -1189,7 +1209,7 @@ fn update_puzzle_work_tx( return Err("无权修改该拼图作品".to_string()); } let theme_tags = normalize_theme_tags(input.theme_tags); - if theme_tags.is_empty() || theme_tags.len() > PUZZLE_MAX_TAG_COUNT { + if theme_tags.len() > PUZZLE_MAX_TAG_COUNT { return Err("拼图标签数量不合法".to_string()); } let levels = deserialize_optional_levels_input(input.levels_json.as_deref())? @@ -1251,6 +1271,7 @@ fn update_puzzle_work_tx( published_at: row.published_at, }; replace_puzzle_work_profile(ctx, &row, next_row); + sync_puzzle_source_session_draft_from_work(ctx, &row, &preview_draft, input.updated_at_micros)?; get_puzzle_work_detail_tx( ctx, PuzzleWorkGetInput { @@ -1259,6 +1280,53 @@ fn update_puzzle_work_tx( ) } +fn sync_puzzle_source_session_draft_from_work( + ctx: &TxContext, + work_row: &PuzzleWorkProfileRow, + draft: &PuzzleResultDraft, + updated_at_micros: i64, +) -> Result<(), String> { + let Some(session_id) = work_row.source_session_id.as_ref() else { + return Ok(()); + }; + let Some(session_row) = ctx.db.puzzle_agent_session().session_id().find(session_id) else { + return Ok(()); + }; + if session_row.owner_user_id != work_row.owner_user_id { + return Ok(()); + } + let normalized_draft = normalize_puzzle_draft(draft.clone()); + let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); + let next_stage = if session_row.stage == PuzzleAgentStage::Published { + PuzzleAgentStage::Published + } else if build_result_preview(&normalized_draft, Some(&work_row.author_display_name)) + .publish_ready + { + PuzzleAgentStage::ReadyToPublish + } else { + PuzzleAgentStage::ImageRefining + }; + replace_puzzle_agent_session( + ctx, + &session_row, + PuzzleAgentSessionRow { + session_id: session_row.session_id.clone(), + owner_user_id: session_row.owner_user_id.clone(), + seed_text: session_row.seed_text.clone(), + current_turn: session_row.current_turn, + progress_percent: session_row.progress_percent.max(94), + stage: next_stage, + anchor_pack_json: session_row.anchor_pack_json.clone(), + draft_json: Some(serialize_json(&normalized_draft)), + last_assistant_reply: session_row.last_assistant_reply.clone(), + published_profile_id: session_row.published_profile_id.clone(), + created_at: session_row.created_at, + updated_at, + }, + ); + Ok(()) +} + fn delete_puzzle_work_tx( ctx: &TxContext, input: PuzzleWorkDeleteInput, @@ -3298,6 +3366,53 @@ mod tests { assert!(draft.candidates[0].selected); } + #[test] + fn generated_first_level_name_defaults_work_title_when_previous_title_is_fallback() { + let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None); + let mut draft = compile_result_draft_from_seed( + &anchor_pack, + &[], + Some("画面描述:一只猫在雨夜灯牌下回头。"), + ); + let previous_level_name = draft.level_name.clone(); + let previous_work_title = draft.work_title.clone(); + draft.levels[0].level_name = "雨夜猫街".to_string(); + module_puzzle::sync_primary_level_fields(&mut draft); + + sync_generated_primary_level_name_as_default_work_title( + &mut draft, + &previous_work_title, + &previous_level_name, + ); + + assert_eq!(draft.level_name, "雨夜猫街"); + assert_eq!(draft.work_title, "雨夜猫街"); + } + + #[test] + fn generated_first_level_name_keeps_manual_work_title() { + let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None); + let mut draft = compile_result_draft_from_seed( + &anchor_pack, + &[], + Some("画面描述:一只猫在雨夜灯牌下回头。"), + ); + let previous_level_name = draft.level_name.clone(); + let previous_work_title = "我的猫街合集".to_string(); + draft.work_title = previous_work_title.clone(); + draft.levels[0].level_name = "雨夜猫街".to_string(); + module_puzzle::sync_primary_level_fields(&mut draft); + + sync_generated_primary_level_name_as_default_work_title( + &mut draft, + &previous_work_title, + &previous_level_name, + ); + + assert_eq!(draft.level_name, "雨夜猫街"); + assert_eq!(draft.work_title, "我的猫街合集"); + } + #[test] fn puzzle_recommendation_score_prefers_same_author_weight() { let left = PuzzleWorkProfile { diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 32e36d64..a82acfc6 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -18,12 +18,14 @@ import { AnimationState, type Character, type CustomWorldProfile, + type CustomWorldOpeningCgProfile, type SceneActBlueprint, type SceneChapterBlueprint, } from '../types'; import { CharacterAnimator } from './CharacterAnimator'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; import { ResolvedAssetImage } from './ResolvedAssetImage'; +import { ResolvedAssetVideo } from './ResolvedAssetVideo'; import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal'; export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks'; @@ -50,6 +52,10 @@ interface CustomWorldEntityCatalogProps { createActionLabel?: string; onCreateAction?: () => void; createActionDisabled?: boolean; + openingCgGenerating?: boolean; + openingCgPhaseLabel?: string | null; + openingCgGenerateDisabled?: boolean; + onGenerateOpeningCg?: () => void; pendingGeneratedEntity?: PendingGeneratedEntity | null; recentGeneratedIds?: RecentGeneratedIds; readOnly?: boolean; @@ -240,6 +246,85 @@ function PendingEntityCard({ ); } +function OpeningCgPreview({ + openingCg, + isGenerating, + phaseLabel, + generateDisabled, + readOnly, + onGenerate, +}: { + openingCg?: CustomWorldOpeningCgProfile | null; + isGenerating: boolean; + phaseLabel?: string | null; + generateDisabled?: boolean; + readOnly: boolean; + onGenerate?: () => void; +}) { + const hasVideo = Boolean(openingCg?.videoSrc?.trim()); + const buttonLabel = hasVideo ? '重新生成' : '生成'; + + return ( +
+
+ {hasVideo ? ( + + ) : openingCg?.storyboardImageSrc ? ( + + ) : ( +
+ 开局 CG +
+ )} +
+
+ + 80 积分 + + + 预计 10 分钟 + + {hasVideo ? ( + + 已生成 + + ) : null} + {!readOnly && onGenerate ? ( +
+ + {isGenerating ? (phaseLabel ?? '生成中') : buttonLabel} + +
+ ) : null} +
+ {isGenerating ? ( +
+
+
+ ) : null} + {openingCg?.status === 'failed' && openingCg.errorMessage ? ( +
+ {openingCg.errorMessage} +
+ ) : null} +
+ ); +} + function buildSceneActParticipantText( act: SceneActBlueprint, roleById: Map< @@ -557,6 +642,10 @@ export function CustomWorldEntityCatalog({ createActionLabel, onCreateAction, createActionDisabled = false, + openingCgGenerating = false, + openingCgPhaseLabel = null, + openingCgGenerateDisabled = false, + onGenerateOpeningCg, pendingGeneratedEntity = null, recentGeneratedIds = { playable: [], @@ -916,6 +1005,17 @@ export function CustomWorldEntityCatalog({
+
+ +
+
{ const generateLandmark = vi.fn(); const generateSceneImage = vi.fn(); const generateSceneNpc = vi.fn(); + const generateOpeningCg = vi.fn(); return { rpgCreationAssetClient: { @@ -23,6 +24,7 @@ vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => { generateLandmark, generateSceneImage, generateSceneNpc, + generateOpeningCg, }, generateCustomWorldPlayableNpc: generatePlayableNpc, generateCustomWorldStoryNpc: generateStoryNpc, @@ -343,6 +345,46 @@ test('world basic setting renders eight anchor fields and hides legacy parsed/so expect(screen.getByText(/沉钟异动和旧案灭口是同一条线/u)).toBeTruthy(); }); +test('world tab generates opening cg only after manual click and writes it back to profile', async () => { + const user = userEvent.setup(); + mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({ + id: 'opening-cg-1', + status: 'ready', + storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png', + storyboardAssetId: 'storyboard-1', + videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4', + videoAssetId: 'video-1', + imageModel: 'gpt-image-2', + videoModel: 'doubao-seedance-2-0-fast-260128', + aspectRatio: '16:9', + imageSize: '2k', + videoResolution: '480p', + durationSeconds: 15, + pointCost: 80, + estimatedWaitMinutes: 10, + updatedAt: '2026-05-03T00:00:00Z', + }); + + render(); + + expect(mockedRpgCreationAssetClient.generateOpeningCg).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(mockedRpgCreationAssetClient.generateOpeningCg).toHaveBeenCalledTimes( + 1, + ); + }); + await waitFor(() => { + expect( + document.querySelector( + 'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]', + ), + ).toBeTruthy(); + }); +}); + test('playable tab prefers generated portrait over runtime preview placeholder', async () => { const user = userEvent.setup(); const profile = { diff --git a/src/components/ResolvedAssetVideo.tsx b/src/components/ResolvedAssetVideo.tsx new file mode 100644 index 00000000..4f0a8b02 --- /dev/null +++ b/src/components/ResolvedAssetVideo.tsx @@ -0,0 +1,30 @@ +import type { VideoHTMLAttributes } from 'react'; + +import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl'; + +type ResolvedAssetVideoProps = Omit< + VideoHTMLAttributes, + 'src' +> & { + src?: string | null; + fallbackSrc?: string | null; + refreshKey?: string | number | null; +}; + +export function ResolvedAssetVideo({ + src, + fallbackSrc, + refreshKey, + ...rest +}: ResolvedAssetVideoProps) { + const { resolvedUrl } = useResolvedAssetReadUrl(src, { + refreshKey, + }); + const finalSrc = resolvedUrl || fallbackSrc?.trim() || ''; + + if (!finalSrc) { + return null; + } + + return