From 163b2fa077c90b7f362d17eee19be750a1a038ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Mon, 22 Jun 2026 16:57:29 +0800 Subject: [PATCH 01/15] Add editor generation model pricing admin UI & API Introduce centralized model pricing for editor generations: add EditorGenerationPricing types and admin API client methods (GET/POST), backend default config and runtime override support, and server route/handler changes to expose admin editor-generation-pricing. Add an AdminEditorGenerationPricingPage component, styles, route entry, icon, and tests, plus a test for admin routes. Update docs and decision log to describe the pricing contract and management workflow. This enables managing per-model pricing (perGeneration / perSecond, flat or tiered) from the admin panel and exposes runtime pricing to the main site. --- apps/admin-web/src/api/adminApiClient.ts | 22 + apps/admin-web/src/api/adminApiTypes.ts | 13 + apps/admin-web/src/app/AdminApp.tsx | 7 + apps/admin-web/src/app/AdminShell.tsx | 2 + apps/admin-web/src/app/adminRoutes.test.ts | 14 + apps/admin-web/src/app/adminRoutes.ts | 2 + .../AdminEditorGenerationPricingPage.test.tsx | 92 ++ .../AdminEditorGenerationPricingPage.tsx | 224 +++++ apps/admin-web/src/styles/admin.css | 44 + docs/README.md | 2 + .../shared-memory/decision-log.md | 8 +- .../shared-memory/document-map.md | 1 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 6 +- ...编辑器】模型定价配置管理方案-2026-06-22.md | 77 ++ ...】生成类面板Lovart统一改造方案-2026-06-17.md | 29 +- .../editor-generation-pricing.default.json | 75 ++ server-rs/crates/api-server/src/admin.rs | 41 + server-rs/crates/api-server/src/app.rs | 154 ++++ .../src/character_animation_assets.rs | 63 +- server-rs/crates/api-server/src/config.rs | 34 + .../src/editor_generation_config.rs | 814 ++++++++++++++---- .../crates/api-server/src/editor_project.rs | 70 +- .../crates/api-server/src/modules/admin.rs | 18 +- .../api-server/src/modules/editor_project.rs | 10 +- server-rs/crates/api-server/src/state.rs | 22 + .../generation.rs | 54 +- ...ImageCanvasBasicGenerationComposerView.tsx | 1 + ...eCanvasCharacterGenerationComposerView.tsx | 1 + ...CanvasEditorGenerationIntegration.test.tsx | 307 +++++-- .../ImageCanvasEditorModel.test.ts | 22 + .../image-editor/ImageCanvasEditorModel.ts | 34 + .../ImageCanvasEditorView.test.tsx | 17 +- .../image-editor/ImageCanvasEditorView.tsx | 21 + ...ImageCanvasGenerationComposerView.test.tsx | 17 + .../ImageCanvasGenerationComposerView.tsx | 10 +- ...eCanvasGenerationImageOptionsView.test.tsx | 5 +- .../ImageCanvasGenerationModel.test.ts | 84 +- .../ImageCanvasGenerationModel.ts | 269 ++++-- ...ageCanvasGenerationSubmissionModel.test.ts | 11 +- .../ImageCanvasGenerationSubmissionModel.ts | 5 +- ...ImageCanvasIconSpritesheetComposerView.tsx | 5 +- .../ImageCanvasPanelDockView.test.tsx | 11 +- .../image-editor/ImageCanvasPanelDockView.tsx | 17 +- ...anvasPublicationMaterialsDemoPanelView.tsx | 2 +- .../ImageCanvasQuickEditPanelView.tsx | 2 +- ...mageCanvasSpecGenerationPanelView.test.tsx | 4 +- .../ImageCanvasSpecGenerationPanelView.tsx | 2 +- .../image-editor/ImageCanvasWorldView.tsx | 1 + ...anvasGenerationSubmissionWorkflow.test.tsx | 67 ++ ...ImageCanvasGenerationSubmissionWorkflow.ts | 11 +- .../useImageCanvasGenerationWorkflow.test.tsx | 259 +++++- .../useImageCanvasGenerationWorkflow.ts | 320 +++++-- .../useImageCanvasProjectPersistence.test.tsx | 23 + .../useImageCanvasProjectPersistence.ts | 11 +- .../useImageCanvasViewportControls.test.tsx | 8 +- .../useImageCanvasViewportControls.ts | 2 +- src/index.css | 5 + .../image-editor/editorProjectClient.test.ts | 37 + .../image-editor/editorProjectClient.ts | 27 + 59 files changed, 3002 insertions(+), 514 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx create mode 100644 apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx create mode 100644 docs/【编辑器】模型定价配置管理方案-2026-06-22.md create mode 100644 server-rs/crates/api-server/config/editor-generation-pricing.default.json diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 0a3a17921..dcd5340d4 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -25,6 +25,7 @@ import type { ApiErrorEnvelope, ApiMeta, ApiSuccessEnvelope, + EditorGenerationPricingConfigPayload, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, ProfileRechargeProductConfigAdminListResponse, @@ -228,6 +229,27 @@ export function upsertAdminPublicWorkInteractions( ); } +export function getAdminEditorGenerationPricing(token: string) { + return request( + '/admin/api/editor-generation-pricing', + { token }, + ); +} + +export function upsertAdminEditorGenerationPricing( + token: string, + payload: EditorGenerationPricingConfigPayload, +) { + return request( + '/admin/api/editor-generation-pricing', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listAdminWorkVisibility(token: string) { return request( '/admin/api/works/visibility', diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index d3cb8ebe6..c876dcc40 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -210,6 +210,19 @@ export interface AdminUpsertPublicWorkInteractionConfigRequest { publicWorkInteractions: PublicWorkInteractionConfigPayload[]; } +/** 图片画布生成模型泥点定价配置。 */ +export type EditorGenerationPricingUnitPayload = 'perGeneration' | 'perSecond'; + +export interface EditorGenerationModelPricingPayload { + unit: EditorGenerationPricingUnitPayload; + price?: number; + prices?: Record; +} + +export interface EditorGenerationPricingConfigPayload { + models: Record; +} + /** 后台统一创作工作台契约表单的传输结构。 */ export interface UnifiedCreationSpecPayload { playId: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 9d50f380f..8a30a7afd 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -23,6 +23,7 @@ import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage'; import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage'; import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; import {AdminLoginPage} from '../pages/AdminLoginPage'; +import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage'; import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; @@ -235,6 +236,12 @@ export function AdminApp() { onResultChange={setRechargeProductResult} /> ) : null} + {routeId === 'editor-generation-pricing' ? ( + + ) : null} ); } diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index e5908331d..cd975a644 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -1,6 +1,7 @@ import { Bug, BadgeDollarSign, + Coins, LayoutDashboard, LogOut, Megaphone, @@ -36,6 +37,7 @@ const routeIcons = { invite: TicketCheck, tasks: ListChecks, 'recharge-products': BadgeDollarSign, + 'editor-generation-pricing': Coins, 'creation-announcement': Megaphone, 'creation-entry': SlidersHorizontal, 'work-visibility': Eye, diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts index bae46be87..6428d7892 100644 --- a/apps/admin-web/src/app/adminRoutes.test.ts +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -14,3 +14,17 @@ test('后台入口公告路由可通过导航和 hash 访问', () => { ); expect(routeHash('creation-announcement')).toBe('#creation-announcement'); }); + +test('后台模型定价路由可通过导航和 hash 访问', () => { + expect(adminRoutes).toContainEqual({ + id: 'editor-generation-pricing', + label: '模型定价', + hash: '#editor-generation-pricing', + }); + expect(resolveAdminRoute('#editor-generation-pricing')).toBe( + 'editor-generation-pricing', + ); + expect(routeHash('editor-generation-pricing')).toBe( + '#editor-generation-pricing', + ); +}); diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 3b6ed6c36..d6a0769af 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -8,6 +8,7 @@ export type AdminRouteId = | 'invite' | 'tasks' | 'recharge-products' + | 'editor-generation-pricing' | 'creation-announcement' | 'creation-entry' | 'work-visibility'; @@ -28,6 +29,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ {id: 'invite', label: '邀请码', hash: '#invite'}, {id: 'tasks', label: '任务配置', hash: '#tasks'}, {id: 'recharge-products', label: '充值商品', hash: '#recharge-products'}, + {id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'}, {id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'}, {id: 'creation-entry', label: '入口开关', hash: '#creation-entry'}, {id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'}, diff --git a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx new file mode 100644 index 000000000..c19356e13 --- /dev/null +++ b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx @@ -0,0 +1,92 @@ +/* @vitest-environment jsdom */ + +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {beforeEach, expect, test, vi} from 'vitest'; + +import { + getAdminEditorGenerationPricing, + upsertAdminEditorGenerationPricing, +} from '../api/adminApiClient'; +import type {EditorGenerationPricingConfigPayload} from '../api/adminApiTypes'; +import {AdminEditorGenerationPricingPage} from './AdminEditorGenerationPricingPage'; + +vi.mock('../api/adminApiClient', () => ({ + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + getAdminEditorGenerationPricing: vi.fn(), + isAdminApiError: vi.fn(() => false), + upsertAdminEditorGenerationPricing: vi.fn(), +})); + +const pricing: EditorGenerationPricingConfigPayload = { + models: { + 'gemini-3.1-flash-image-preview': { + unit: 'perGeneration', + prices: {'0.5K': 8, '1K': 12, '2K': 24}, + }, + 'gpt-image-2': { + unit: 'perGeneration', + prices: {'1K': 20, '2K': 40}, + }, + 'seedance2.0-fast': { + unit: 'perSecond', + prices: {'480p': 10, '720p': 20, '1080p': 40}, + }, + 'seedance2.0': { + unit: 'perSecond', + prices: {'480p': 12, '720p': 24, '1080p': 48}, + }, + 'audio1.0': {unit: 'perGeneration', price: 10}, + 'chirp-v5': {unit: 'perGeneration', price: 5}, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue(pricing); + vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue({ + ...pricing, + models: { + ...pricing.models, + 'gpt-image-2': { + unit: 'perGeneration', + prices: {'1K': 20, '2K': 58}, + }, + }, + }); +}); + +test('模型定价后台按模型展示单位并保存尺寸定价', async () => { + const user = userEvent.setup(); + render( + , + ); + + expect((await screen.findAllByText('按次')).length).toBeGreaterThan(0); + expect(screen.getAllByText('按秒').length).toBeGreaterThan(0); + const gptImage2kInput = screen.getByLabelText('gpt-image-2 2K'); + fireEvent.change(gptImage2kInput, {target: {value: '58'}}); + await user.click(screen.getByRole('button', {name: '保存定价'})); + await user.click(screen.getByRole('button', {name: '确认'})); + + await waitFor(() => { + expect(upsertAdminEditorGenerationPricing).toHaveBeenCalledWith( + 'admin-token', + expect.objectContaining({ + models: expect.objectContaining({ + 'gpt-image-2': expect.objectContaining({ + unit: 'perGeneration', + prices: expect.objectContaining({ + '2K': 58, + }), + }), + }), + }), + ); + }); +}); diff --git a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx new file mode 100644 index 000000000..bb2fa9587 --- /dev/null +++ b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx @@ -0,0 +1,224 @@ +import {RefreshCcw, Save} from 'lucide-react'; +import {FormEvent, useEffect, useState} from 'react'; + +import { + getAdminEditorGenerationPricing, + upsertAdminEditorGenerationPricing, +} from '../api/adminApiClient'; +import type { + EditorGenerationModelPricingPayload, + EditorGenerationPricingConfigPayload, + EditorGenerationPricingUnitPayload, +} from '../api/adminApiTypes'; +import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; +import {handlePageError} from './pageUtils'; + +interface AdminEditorGenerationPricingPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +const emptyPricing: EditorGenerationPricingConfigPayload = { + models: {}, +}; + +const unitLabels: Record = { + perGeneration: '按次', + perSecond: '按秒', +}; + +export function AdminEditorGenerationPricingPage({ + token, + onUnauthorized, +}: AdminEditorGenerationPricingPageProps) { + const [pricing, setPricing] = + useState(emptyPricing); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const {confirmWrite, confirmDialog} = useAdminWriteConfirm(); + + useEffect(() => { + void refreshPricing(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshPricing() { + setIsLoading(true); + setErrorMessage(''); + try { + setPricing(await getAdminEditorGenerationPricing(token)); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + } + + async function handleSave(event: FormEvent) { + event.preventDefault(); + if (isSaving) { + return; + } + setErrorMessage(''); + const confirmed = await confirmWrite({ + action: '保存模型定价', + target: '图片画布生成', + }); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + setPricing(await upsertAdminEditorGenerationPricing(token, pricing)); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsSaving(false); + } + } + + function updateFlatPrice(model: string, value: string) { + setPricing((current) => { + const modelPricing = current.models[model]; + if (!modelPricing) { + return current; + } + return { + ...current, + models: { + ...current.models, + [model]: { + ...modelPricing, + price: parsePositiveInteger(value), + }, + }, + }; + }); + } + + function updateTierPrice(model: string, tier: string, value: string) { + setPricing((current) => { + const modelPricing = current.models[model]; + if (!modelPricing) { + return current; + } + return { + ...current, + models: { + ...current.models, + [model]: { + ...modelPricing, + prices: { + ...(modelPricing.prices ?? {}), + [tier]: parsePositiveInteger(value), + }, + }, + }, + }; + }); + } + + return ( +
+
+
+

模型定价

+

生成泥点消耗

+
+ +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+
+ {Object.entries(pricing.models).map(([model, modelPricing]) => + renderModelPricingCard({ + model, + modelPricing, + onFlatChange: updateFlatPrice, + onTierChange: updateTierPrice, + }), + )} +
+ + +
+ + {confirmDialog} +
+ ); +} + +function renderModelPricingCard({ + model, + modelPricing, + onFlatChange, + onTierChange, +}: { + model: string; + modelPricing: EditorGenerationModelPricingPayload; + onFlatChange: (model: string, value: string) => void; + onTierChange: (model: string, tier: string, value: string) => void; +}) { + const unitLabel = unitLabels[modelPricing.unit] ?? modelPricing.unit; + return ( +
+
+ {model} + {unitLabel} +
+ {modelPricing.prices ? ( +
+ {Object.entries(modelPricing.prices).map(([tier, price]) => ( + + ))} +
+ ) : ( + + )} +
+ ); +} + +function parsePositiveInteger(value: string) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 4f8b1faaf..6aa13364f 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -972,6 +972,49 @@ button:disabled { box-shadow: 0 2px 8px rgba(112, 57, 30, 0.08); } +.admin-pricing-grid, +.admin-pricing-field-list, +.admin-pricing-model-list, +.admin-pricing-model-card { + display: grid; + gap: 12px; +} + +.admin-pricing-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.admin-pricing-model-card { + border: 1px solid #eaded2; + border-radius: 8px; + background: #fffdf9; + padding: 12px; +} + +.admin-pricing-model-card strong { + color: #3d1f10; + overflow-wrap: anywhere; +} + +.admin-pricing-model-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.admin-pricing-unit { + flex: 0 0 auto; + border-radius: 999px; + background: #f2e3d7; + color: #7b4a2f; + font-size: 12px; + font-weight: 800; + line-height: 1; + padding: 5px 8px; +} + + .admin-bottom-nav { display: none; } @@ -1007,6 +1050,7 @@ button:disabled { .admin-overview-grid, .admin-two-column, .admin-two-column-wide, + .admin-pricing-grid, .admin-form-row, .admin-filter-grid, .admin-table-query-grid, diff --git a/docs/README.md b/docs/README.md index dc5caf067..2f04921f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,8 @@ `/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,第一版采用前端 JSZip 打包画布中有效图层引用的上传图、生成图和修改结果,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。 +图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。 + 桌面端 `/creation` 创作工具主页、顶级“草稿”入口替换为“项目”、最近项目、新建项目和陶泥儿精选素材瀑布流的落地计划见 [【玩法创作】创作主页与项目入口改版计划-2026-06-18.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E4%B8%BB%E9%A1%B5%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%85%A5%E5%8F%A3%E6%94%B9%E7%89%88%E8%AE%A1%E5%88%92-2026-06-18.md)。 本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 416adf545..dd22306ac 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -51,10 +51,10 @@ ## 2026-06-19 图片画布生成按钮价格统一绑定模型定价配置 - 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。 -- 决策:所有画板生成按钮价格必须从 `src/components/image-editor/ImageCanvasGenerationModel.ts` 的模型定价配置函数计算;需要提交 `priceMudPoints` 的视频、角色动画、图标素材、音效和背景音乐也使用同一函数。后端用 `server-rs/crates/api-server/src/editor_generation_config.rs` 的同名语义配置重新计算并校验 / 扣费。当前正式模型均已覆盖定价:图片类 `nanobanana2`(真实模型 `gemini-3.1-flash-image-preview`)为 12,`gpt-image-2` 为 20;生成规范固定 `gpt-image-2` 为 5;视频按模型和清晰度分档,`seedance2.0-fast` 为 480p 每秒 10 / 720p 每秒 20,`seedance2.0` 为 12 / 24,`kling3.0` 为 15 / 30,`kling3.0-omni` 为 20 / 40,Veo 旧布局兼容价仍为 10 / 20。画板 UI 统一显示 `nanobanana2`,历史输入或旧布局中的 `nano-banana` 必须归一到真实模型 ID 后再提交和计费。 -- 影响范围:图片画布生成类面板、生成提交模型、编辑器图片 / 视频 / 音频 BFF、`editor_generation_config` 和 Lovart 生成类面板文档。 -- 验证方式:运行 `npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。 -- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 +- 决策:所有画板生成按钮价格必须从 `src/components/image-editor/ImageCanvasGenerationModel.ts` 的模型定价配置函数计算;需要提交 `priceMudPoints` 的视频、角色动作、图标素材、音效和背景音乐也使用同一函数。后端默认配置独立放在 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,运行时 override 默认写入 `.app/editor-generation-pricing.override.json`,可用 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 指定可写路径;后台“模型定价”通过 `/admin/api/editor-generation-pricing` 读取和保存完整 `models` 配置,主站通过 `/api/editor/generation-pricing` 动态下发。后端提交校验 / 扣费仍以 `AppState` 当前运行时配置为准,前端内置定价只作为接口失败兜底。模型定价不再按图片 / 规范、视频 / 动作用途拆分,只按模型区分:图片模型按尺寸单次计价,`gemini-3.1-flash-image-preview` 必须配置 `0.5K / 1K / 2K`,`gpt-image-2` 必须配置 `1K / 2K`,规范固定读取 `gpt-image-2` 的 `2K`;视频和角色动作共用视频模型分辨率每秒价格,角色动作仍固定 `seedance2.0-fast`。后台管理页必须显示定价单位“按次 / 按秒”。画板 UI 统一显示 `nanobanana2`,历史输入或旧布局中的 `nano-banana` 必须归一到真实模型 ID 后再提交和计费。 +- 影响范围:图片画布生成类面板、生成提交模型、编辑器图片 / 视频 / 音频 BFF、`editor_generation_config`、后台管理端和 Lovart 生成类面板文档。 +- 验证方式:运行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_generation_config::tests editor_generation_pricing_route -- --nocapture`、`npx vitest run src/components/image-editor/ImageCanvasGenerationModel.test.ts src/services/image-editor/editorProjectClient.test.ts apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx apps/admin-web/src/app/adminRoutes.test.ts --reporter verbose`、`npm run admin-web:typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`。 ## 2026-06-18 图片画布 UI 设计图提取素材保留图集 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index fc5a6d698..24a66370d 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -15,6 +15,7 @@ | 本地启动、验证、部署、埋点和运营查询 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` | | 微信小程序虚拟支付 | `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` | | UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` | +| 图片画布生成面板与模型定价 | `docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】模型定价配置管理方案-2026-06-22.md` | ## 阅读顺序 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 9019770a5..d3aa0f829 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -26,7 +26,7 @@ ## 交互规则 - `适合视图` 的正式语义为“显示画布所有可见元素”,不再回到固定 `x/y/scale`。 -- 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。 +- 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。缩放百分比以实际画布 `viewport.scale = 0.5` 作为显示 `100%` 的基准,菜单中的 `50% / 100% / 200%` 分别对应实际 `0.25 / 0.5 / 1`,工程持久化保存和读取仍使用用户可见缩放语义。 - 缩放菜单支持 `Ctrl/Cmd +`、`Ctrl/Cmd -` 和 `Shift + 1`;快捷键只改变 viewport,不修改工程资源。 - 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。 - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;拖拽结束后只保存最终图层布局,不保存临时参考线。 @@ -66,7 +66,7 @@ - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 -- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置的 `icon` 档位(首版 12 泥点),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。 +- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。 - `POST /api/editor/ui-designs/assets/extractions`:以 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 - `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 @@ -92,7 +92,7 @@ - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 - 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。 - 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。 -- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。 +- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。角色形象生成面板每次成功绑定角色规范后,在当前编辑器生命周期内缓存为上一张角色规范;再次新建角色形象时自动带入该缓存。图标素材和 UI 设计图面板每次成功绑定图标规范后,同样缓存为上一张图标规范;再次新建需要图标规范的素材时自动带入该缓存。生成规范菜单里的图标规范对象自身只把首行参考图作为可选参考,不要求必须先绑定图标规范。 - 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。 - 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;参考图请求顺序为额外参考图在前、原图作为隐式最后一张提交,信息面板输入快照只展示用户额外选择的参考图。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 diff --git a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md new file mode 100644 index 000000000..03771b423 --- /dev/null +++ b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md @@ -0,0 +1,77 @@ +# 编辑器模型定价配置管理方案 + +## 背景 + +图片画布的生成图片、生成规范、生成角色、生成素材、生成 UI、生成视频、角色动作、音效和背景音乐都需要在生成按钮旁展示泥点消耗,并在后端提交时校验 `priceMudPoints`。定价不能散落在前端组件和具体 handler 中,也不能按“图片 / 规范”“视频 / 动作”等用途拆出不同价格事实源。 + +## 配置来源 + +- 默认配置文件:`server-rs/crates/api-server/config/editor-generation-pricing.default.json`。 +- 运行时覆盖文件:默认 `.app/editor-generation-pricing.override.json`。 +- 生产或特殊环境可通过 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 指定可写覆盖文件路径。 + +默认文件进入 Git,作为空 override 或 override 丢失时的兜底。覆盖文件属于运行态配置,不提交 Git。 + +## 配置结构 + +定价只按模型区分,不按用途区分。同一个模型用于多个入口时必须读取同一条模型配置: + +- `gpt-image-2` 同时用于普通图片、规范、UI 设计等图片类入口。 +- `seedance2.0-fast` 同时用于生成视频和角色动作;角色动作入口仍只允许该模型。 + +```json +{ + "models": { + "gemini-3.1-flash-image-preview": { + "unit": "perGeneration", + "prices": { "0.5K": 8, "1K": 12, "2K": 24 } + }, + "gpt-image-2": { + "unit": "perGeneration", + "prices": { "1K": 20, "2K": 40 } + }, + "seedance2.0-fast": { + "unit": "perSecond", + "prices": { "480p": 10, "720p": 20, "1080p": 40 } + }, + "audio1.0": { "unit": "perGeneration", "price": 10 } + } +} +``` + +字段规则: + +- `unit`:定价单位,`perGeneration` 表示按次,`perSecond` 表示按秒。 +- `price`:单一价格,适合音效、背景音乐等单次生成模型。 +- `prices`:档位价格,图片模型按尺寸档位配置,视频模型按分辨率配置。 +- 生图模型必须补齐支持尺寸:`gemini-3.1-flash-image-preview` 配 `0.5K / 1K / 2K`,`gpt-image-2` 配 `1K / 2K`。 + +后端保存前校验当前正式模型、必要尺寸和必要分辨率都存在且大于 0。 + +## 后端契约 + +- `GET /api/editor/generation-pricing`:主站读取当前模型定价。 +- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价。 +- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,并写入 override 文件。 + +后端 `AppState` 启动时加载默认配置和 override。提交视频、角色动作、图标素材、音效和背景音乐时继续以运行时配置重新计算价格,前端传入的 `priceMudPoints` 只作为一致性校验值。普通图片生成和宣发素材扣费也读取同一运行时模型配置。 + +## 管理端 + +后台“模型定价”页面按模型统一展示和编辑,不再拆“图片模型 / 规范模型 / 视频模型 / 角色动作”等用途分组。每个模型卡片显示: + +- 模型名。 +- 定价单位:按次 / 按秒。 +- 单价或档位价格;生图模型展示 `0.5K / 1K / 2K` 等尺寸档位,视频模型展示 `480p / 720p / 1080p` 等分辨率档位。 + +## 前端展示 + +主站画板启动后调用 `GET /api/editor/generation-pricing`,成功后覆盖前端内置兜底价格并触发重渲染。接口失败时保留内置兜底,避免画板不可用。图片类价格计算必须传入当前模型和 `imageSize`;规范生成固定读取 `gpt-image-2` 的 `2K` 定价。 + +后续新增模型时必须先补默认 JSON、后端校验、前端兜底和测试,再暴露到模型选择框。 + +## 验证 + +- 后端配置解析、override、路由保存与公开读取测试。 +- 前端价格读取、运行时覆盖、图片尺寸档位计算测试。 +- 管理端模型定价页面单位展示和档位保存测试。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index e71338372..f720b919f 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -32,7 +32,7 @@ 3. 角色规范参考图组件只展示一个方形参考图图标;图标内上方是规范 logo,下方固定短标 `角色规范`,不再展示绑定状态、来源说明或长标题。已有绑定图片的原始标题只作为可访问名称、悬浮 title 和图片信息回看使用。 4. 参考图图标尽量少文字;必要文字写在图标块内或短标签内,不写规则说明。 5. 已有参考图在鼠标悬停或键盘聚焦到对应参考图槽位时,右上角显示一个 `×` 删除按钮;鼠标不在槽位上时不显示。点击 `×` 只移除该参考图绑定,不触发来源菜单、不删除画布图片。 -6. 普通参考图支持连续追加:已有图缩略图后始终保留一个 `+` 入口。点击入口只弹出“从画布中选择 / 上传图片”来源选项,不再直接打开系统文件选择器;生成图片、生成视频、角色常规参考图等同类参考图入口都遵循同一交互。 +6. 普通参考图支持连续追加:已有图缩略图后始终保留一个 `+` 入口。点击入口只弹出“从画布中选择 / 上传图片”来源选项,不再直接打开系统文件选择器;生成图片、生成视频、角色常规参考图等同类参考图入口都遵循同一交互。角色规范和图标规范引用在浏览器本地分别缓存最近一次成功绑定的对象;再次新建角色形象、图标素材或 UI 设计图时自动带入对应缓存,刷新编辑器后仍可恢复。新建图标规范对象不依赖该图标规范缓存,未选择参考图也可以直接生成。 7. 单文本输入面板不显示文本框标题,用问题式 placeholder: - 生成图片:`今天想生成什么画面?` - 生成角色:`你希望角色如何设计?` @@ -74,8 +74,8 @@ - 本次消耗泥点必须显示在生成按钮内部。 - 生成按钮内明确显示 `N泥点`,例如 `生成12泥点`、`生成40泥点`;不使用泥点图标替代文字。 - 画板内所有会提交外部生成任务的生成按钮,价格都必须从模型定价配置函数推导,不允许在按钮文案或提交 payload 中散落固定泥点数字;修改 `ImageCanvasGenerationModel.ts` 与 `api-server/src/editor_generation_config.rs` 的同名定价配置后,按钮展示、前端提交的 `priceMudPoints` 和后端校验 / 扣费应同步变化。 -- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动画走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。 -- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。当前不是运行时动态配置接口,若要后台改价实时影响前端,需要新增配置下发能力。 +- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2` 的 `2K` 定价。 +- 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整 override 到 `.app/editor-generation-pricing.override.json`(可由 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 覆盖路径),主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。 - 生成图标素材面板提交 `POST /api/editor/icon-spritesheets/generations` 时必须携带 `priceMudPoints`,取同一份 `icon` 计费配置;后端用 `editor_generation_config` 校验,不允许绕过配置继续生成。 - 生成视频、角色动画、音效和背景音乐提交时携带的 `priceMudPoints` 也必须由同一模型配置函数计算,后端按归一后的模型、清晰度、时长或音频模型重新计算并校验。 - `提取素材` 是 UI 设计图工具栏动作,不是面板生成按钮;它固定使用 `gpt-image-2` 和图标素材拆分链路,后端响应里的 `priceMudPoints` 仍必须来自 `editor_generation_config`,不能写死。 @@ -117,22 +117,19 @@ ## 第一版计费配置 ```text -生成图片 / 生成角色形象 / 生成图标素材 / 生成UI设计图 / 宣发素材 / 快速编辑 / 重绘:nanobanana2 为 12 泥点,gpt-image-2 为 20 泥点 -生成规范:固定 gpt-image-2,5 泥点 -生成视频:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20,不支持 1080p;seedance2.0 为 480p 每秒 12 / 720p 每秒 24 / 1080p 每秒 48;kling3.0 为 480p 每秒 15 / 720p 每秒 30 / 1080p 每秒 60;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40 / 1080p 每秒 80 -角色动画:480p 每秒 10 泥点,720p 每秒 20 泥点 -生成音效:10 泥点 -生成背景音乐:5 泥点 +图片模型按尺寸单次计价:nanobanana2 / gemini-3.1-flash-image-preview 为 0.5K 8 泥点、1K 12 泥点、2K 24 泥点;gpt-image-2 为 1K 20 泥点、2K 40 泥点 +生成规范不再单独定价:固定读取 gpt-image-2 的 2K 定价 +视频 / 角色动作按同一视频模型分辨率每秒计价:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20 / 1080p 每秒 40(生成视频入口仍禁止 fast 选 1080p);seedance2.0 为 480p 每秒 12 / 720p 每秒 24 / 1080p 每秒 48;kling3.0 为 480p 每秒 15 / 720p 每秒 30 / 1080p 每秒 60;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40 / 1080p 每秒 80 +生成音效:audio1.0 按次 10 泥点 +生成背景音乐:chirp-v5 按次 5 泥点 ``` -当前必须显式覆盖的正式模型定价配置: +当前必须显式覆盖的正式模型定价配置(默认值来自 `editor-generation-pricing.default.json`,运行时可由后台 override 修改): -- 图片类:`gemini-3.1-flash-image-preview`(UI 显示与历史别名统一为 `nanobanana2`)为 12 泥点;`gpt-image-2` 为 20 泥点。 -- 规范:`gpt-image-2` 为 5 泥点。 -- 视频:`seedance2.0-fast` 为 10 / 20 泥点每秒;`seedance2.0` 为 12 / 24 泥点每秒;`kling3.0` 为 15 / 30 泥点每秒;`kling3.0-omni` 为 20 / 40 泥点每秒。兼容旧布局回放的 `veo3.1`、`veo3.1-fast` 也要保留 10 / 20 泥点每秒定价配置,但前端模型菜单不展示。 -- 角色动画:`seedance2.0-fast`。 -- 音效:`audio1.0`。 -- 背景音乐:`chirp-v5`。 +- 图片模型:`gemini-3.1-flash-image-preview`(UI 显示与历史别名统一为 `nanobanana2`)必须配置 `0.5K / 1K / 2K`;`gpt-image-2` 必须配置 `1K / 2K`。 +- 视频模型:`seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni` 按 `480p / 720p / 1080p` 配每秒泥点。兼容旧布局回放的 `veo3.1`、`veo3.1-fast` 也要保留同样分辨率定价配置,但前端模型菜单不展示。 +- 音效:`audio1.0` 按次定价。 +- 背景音乐:`chirp-v5` 按次定价。 本次审计未发现当前正式入口缺少定价配置的模型;若后续新增模型,必须先补前端展示配置、前端提交计算、后端校验配置和对应测试,再出现在任一模型选择框中。 diff --git a/server-rs/crates/api-server/config/editor-generation-pricing.default.json b/server-rs/crates/api-server/config/editor-generation-pricing.default.json new file mode 100644 index 000000000..3db8f7c06 --- /dev/null +++ b/server-rs/crates/api-server/config/editor-generation-pricing.default.json @@ -0,0 +1,75 @@ +{ + "models": { + "gemini-3.1-flash-image-preview": { + "unit": "perGeneration", + "prices": { + "0.5K": 8, + "1K": 12, + "2K": 24 + } + }, + "gpt-image-2": { + "unit": "perGeneration", + "prices": { + "1K": 20, + "2K": 40 + } + }, + "seedance2.0-fast": { + "unit": "perSecond", + "prices": { + "480p": 10, + "720p": 20, + "1080p": 40 + } + }, + "seedance2.0": { + "unit": "perSecond", + "prices": { + "480p": 12, + "720p": 24, + "1080p": 48 + } + }, + "kling3.0": { + "unit": "perSecond", + "prices": { + "480p": 15, + "720p": 30, + "1080p": 60 + } + }, + "kling3.0-omni": { + "unit": "perSecond", + "prices": { + "480p": 20, + "720p": 40, + "1080p": 80 + } + }, + "veo3.1": { + "unit": "perSecond", + "prices": { + "480p": 10, + "720p": 20, + "1080p": 40 + } + }, + "veo3.1-fast": { + "unit": "perSecond", + "prices": { + "480p": 10, + "720p": 20, + "1080p": 40 + } + }, + "audio1.0": { + "unit": "perGeneration", + "price": 10 + }, + "chirp-v5": { + "unit": "perGeneration", + "price": 5 + } + } +} diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index e8943b715..eae310620 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -36,6 +36,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ api_response::json_success_body, + editor_generation_config::EditorGenerationPricingConfig, http_error::AppError, request_context::RequestContext, state::{AdminRuntime, AppState}, @@ -297,6 +298,31 @@ pub async fn admin_upsert_public_work_interaction_config( )) } +/// 后台读取画布生成模型定价配置。 +pub async fn admin_get_editor_generation_pricing( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, +) -> Result, AppError> { + let pricing = state + .editor_generation_pricing() + .map_err(map_admin_editor_generation_pricing_error)?; + Ok(json_success_body(Some(&request_context), pricing)) +} + +/// 后台保存画布生成模型定价配置,写入运行时 override 文件。 +pub async fn admin_upsert_editor_generation_pricing( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, + Json(payload): Json, +) -> Result, AppError> { + let pricing = state + .save_editor_generation_pricing(payload) + .map_err(map_admin_editor_generation_pricing_error)?; + Ok(json_success_body(Some(&request_context), pricing)) +} + pub async fn admin_list_work_visibility( State(state): State, Extension(request_context): Extension, @@ -433,6 +459,21 @@ fn map_admin_spacetime_error(error: spacetime_client::SpacetimeClientError) -> A })) } +fn map_admin_editor_generation_pricing_error( + error: crate::editor_generation_config::EditorGenerationPricingError, +) -> AppError { + let status = match error { + crate::editor_generation_config::EditorGenerationPricingError::Invalid(_) => { + StatusCode::BAD_REQUEST + } + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + AppError::from_status(status).with_details(serde_json::json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) +} + pub async fn require_admin_auth( State(state): State, mut request: Request, diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index e4fa80070..7b6132704 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -4367,6 +4367,160 @@ mod tests { ); } + #[tokio::test] + async fn public_editor_generation_pricing_route_returns_default_config() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .uri("/api/editor/generation-pricing") + .body(Body::empty()) + .expect("pricing request should build"), + ) + .await + .expect("pricing request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("pricing body should collect") + .to_bytes(); + let payload: Value = serde_json::from_slice(&body).expect("pricing payload should be json"); + + assert_eq!( + payload["models"]["gemini-3.1-flash-image-preview"]["prices"]["1K"], + Value::Number(12.into()) + ); + assert_eq!( + payload["models"]["gpt-image-2"]["prices"]["2K"], + Value::Number(40.into()) + ); + assert_eq!( + payload["models"]["seedance2.0"]["unit"], + Value::String("perSecond".to_string()) + ); + assert_eq!( + payload["models"]["seedance2.0"]["prices"]["720p"], + Value::Number(24.into()) + ); + } + + #[tokio::test] + async fn admin_editor_generation_pricing_route_saves_override_and_updates_public_route() { + let temp_dir = std::env::temp_dir().join(format!( + "genarrative-admin-pricing-route-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should work") + .as_nanos() + )); + std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); + let mut config = AppConfig::default(); + config.admin_username = Some("root".to_string()); + config.admin_password = Some("secret123".to_string()); + config.editor_generation_pricing_override_path = + temp_dir.join("editor-generation-pricing.override.json"); + let app = build_router(AppState::new(config).expect("state should build")); + let admin_token = read_admin_access_token(app.clone()).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/api/editor-generation-pricing") + .header("authorization", format!("Bearer {admin_token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "models": { + "gemini-3.1-flash-image-preview": { + "unit": "perGeneration", + "prices": { "0.5K": 9, "1K": 18, "2K": 36 } + }, + "gpt-image-2": { + "unit": "perGeneration", + "prices": { "1K": 31, "2K": 62 } + }, + "seedance2.0-fast": { + "unit": "perSecond", + "prices": { "480p": 11, "720p": 22, "1080p": 44 } + }, + "seedance2.0": { + "unit": "perSecond", + "prices": { "480p": 13, "720p": 26, "1080p": 52 } + }, + "kling3.0": { + "unit": "perSecond", + "prices": { "480p": 16, "720p": 32, "1080p": 64 } + }, + "kling3.0-omni": { + "unit": "perSecond", + "prices": { "480p": 21, "720p": 42, "1080p": 84 } + }, + "veo3.1": { + "unit": "perSecond", + "prices": { "480p": 11, "720p": 22, "1080p": 44 } + }, + "veo3.1-fast": { + "unit": "perSecond", + "prices": { "480p": 11, "720p": 22, "1080p": 44 } + }, + "audio1.0": { "unit": "perGeneration", "price": 15 }, + "chirp-v5": { "unit": "perGeneration", "price": 9 } + } + }) + .to_string(), + )) + .expect("pricing save request should build"), + ) + .await + .expect("pricing save request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + temp_dir + .join("editor-generation-pricing.override.json") + .exists() + ); + + let public_response = app + .oneshot( + Request::builder() + .uri("/api/editor/generation-pricing") + .body(Body::empty()) + .expect("public pricing request should build"), + ) + .await + .expect("public pricing request should succeed"); + let body = public_response + .into_body() + .collect() + .await + .expect("public pricing body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("public pricing payload should be json"); + + assert_eq!( + payload["models"]["gpt-image-2"]["prices"]["2K"], + Value::Number(62.into()) + ); + assert_eq!( + payload["models"]["audio1.0"]["unit"], + Value::String("perGeneration".to_string()) + ); + assert_eq!( + payload["models"]["seedance2.0"]["prices"]["720p"], + Value::Number(26.into()) + ); + + std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove"); + } + /// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。 #[tokio::test] async fn admin_creation_entry_banners_route_rejects_script_html() { diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index b258a2bb1..e31476271 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -52,6 +52,7 @@ use crate::{ custom_world_asset_prompts::{ build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt, }, + editor_generation_config::EditorGenerationPricingConfig, http_error::AppError, platform_errors::map_oss_error, prompt::role_asset_studio::{ @@ -546,8 +547,18 @@ pub(crate) async fn generate_editor_character_animation_for_owner( ) })?; - let normalized = normalize_editor_character_animation_request(payload) - .map_err(|error| character_animation_error_response(&request_context, error))?; + let pricing = state.editor_generation_pricing().map_err(|error| { + character_animation_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })), + ) + })?; + let normalized = + normalize_editor_character_animation_request_with_pricing(payload, &pricing) + .map_err(|error| character_animation_error_response(&request_context, error))?; let settings = require_editor_character_animation_settings(&state, &normalized) .map_err(|error| character_animation_error_response(&request_context, error))?; let extraction_settings = resolve_backend_frame_extraction_settings(&state); @@ -636,7 +647,16 @@ pub(crate) async fn generate_editor_video_for_owner( ) })?; - let normalized = normalize_editor_video_request(payload) + let pricing = state.editor_generation_pricing().map_err(|error| { + editor_video_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })), + ) + })?; + let normalized = normalize_editor_video_request_with_pricing(payload, &pricing) .map_err(|error| editor_video_error_response(&request_context, error))?; let settings = require_editor_video_settings(&state, normalized.model.as_str()) .map_err(|error| editor_video_error_response(&request_context, error))?; @@ -2379,8 +2399,18 @@ fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest normalize_required_text(candidate, CHARACTER_ANIMATION_MODEL) } +#[cfg(test)] fn normalize_editor_character_animation_request( payload: EditorCharacterAnimationGenerateRequest, +) -> Result { + let pricing = crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) + .expect("默认模型定价配置必须合法"); + normalize_editor_character_animation_request_with_pricing(payload, &pricing) +} + +fn normalize_editor_character_animation_request_with_pricing( + payload: EditorCharacterAnimationGenerateRequest, + pricing: &EditorGenerationPricingConfig, ) -> Result { let source_layer_id = normalize_required_text(payload.source_layer_id.as_str(), ""); if source_layer_id.is_empty() { @@ -2404,12 +2434,11 @@ fn normalize_editor_character_animation_request( let frame_count = normalize_editor_character_animation_frame_count(payload.frame_count)?; let duration_seconds = normalize_editor_character_animation_duration(payload.duration_seconds, frame_count)?; - let expected_price = - crate::editor_generation_config::editor_character_animation_model_mud_points( - Some(payload.model.as_str()), - resolution, - duration_seconds, - ); + let expected_price = pricing.character_animation_model_mud_points( + Some(payload.model.as_str()), + resolution, + duration_seconds, + ); if payload.price_mud_points != expected_price { return Err(editor_character_animation_bad_request(format!( "priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。" @@ -2490,8 +2519,18 @@ fn require_editor_character_animation_settings( }) } +#[cfg(test)] fn normalize_editor_video_request( payload: EditorVideoGenerateRequest, +) -> Result { + let pricing = crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) + .expect("默认模型定价配置必须合法"); + normalize_editor_video_request_with_pricing(payload, &pricing) +} + +fn normalize_editor_video_request_with_pricing( + payload: EditorVideoGenerateRequest, + pricing: &EditorGenerationPricingConfig, ) -> Result { let prompt = payload.prompt.trim().chars().take(4000).collect::(); if prompt.is_empty() { @@ -2510,11 +2549,7 @@ fn normalize_editor_video_request( "seedance2.0-fast 不支持 1080p 清晰度。", )); } - let expected_price = crate::editor_generation_config::editor_video_model_generation_mud_points( - Some(model), - resolution, - duration_seconds, - ); + let expected_price = pricing.video_model_mud_points(Some(model), resolution, duration_seconds); if payload.price_mud_points != expected_price { return Err(editor_video_bad_request(format!( "priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。" diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index b4af828a9..ab58de933 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -50,6 +50,7 @@ pub struct AppConfig { pub wallet_refund_outbox_batch_size: usize, pub wallet_refund_outbox_flush_interval: Duration, pub wallet_refund_outbox_max_bytes: u64, + pub editor_generation_pricing_override_path: PathBuf, pub log_filter: String, pub otel_enabled: bool, pub admin_username: Option, @@ -270,6 +271,8 @@ impl Default for AppConfig { wallet_refund_outbox_batch_size: 100, wallet_refund_outbox_flush_interval: Duration::from_millis(1_000), wallet_refund_outbox_max_bytes: 64 * 1024 * 1024, + editor_generation_pricing_override_path: + crate::editor_generation_config::default_editor_generation_pricing_override_path(), log_filter: "info,tower_http=info".to_string(), otel_enabled: false, admin_username: None, @@ -443,6 +446,11 @@ impl AppConfig { { config.log_filter = log_filter; } + if let Some(pricing_override_path) = + read_first_non_empty_env(&["GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH"]) + { + config.editor_generation_pricing_override_path = PathBuf::from(pricing_override_path); + } if let Some(listen_backlog) = read_first_positive_i32_env(&["GENARRATIVE_API_LISTEN_BACKLOG"]) { @@ -2042,4 +2050,30 @@ mod tests { std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED"); } } + + #[test] + fn from_env_reads_editor_generation_pricing_override_path() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH"); + std::env::set_var( + "GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH", + "server-rs/.data/editor-pricing.json", + ); + } + + let config = AppConfig::from_env(); + assert_eq!( + config.editor_generation_pricing_override_path, + std::path::PathBuf::from("server-rs/.data/editor-pricing.json") + ); + + unsafe { + std::env::remove_var("GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH"); + } + } } diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index af65fe504..8ca6d62e9 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -1,21 +1,23 @@ +use std::{ + collections::BTreeMap, + fmt, fs, io, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; + +use serde::{Deserialize, Serialize}; + /// 图片画布编辑器生成类能力的泥点配置。 /// -/// 中文注释:先用 api-server 静态配置收口价格事实源,避免继续把价格散落在 -/// 前端常量和具体 handler 内;后续若接后台配置,可只替换本模块读取来源。 +/// 中文注释:默认值来自独立 JSON 文件;后台修改写入运行时 override 文件。 +/// 生成扣费校验必须通过本模块读取运行时快照,避免前端展示价格成为真相源。 +pub(crate) const EDITOR_GENERATION_PRICING_DEFAULT_JSON: &str = + include_str!("../config/editor-generation-pricing.default.json"); + const EDITOR_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2"; const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana"; -const EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS: u32 = 12; -const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_MUD_POINTS: u32 = 20; -const EDITOR_SPEC_MODEL_GPT_IMAGE_2_MUD_POINTS: u32 = 5; - -pub(crate) const EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND: u32 = 10; -pub(crate) const EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND: u32 = 20; -pub(crate) const EDITOR_VIDEO_GENERATION_1080P_MUD_POINTS_PER_SECOND: u32 = 40; - -pub(crate) const EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND: u32 = 10; -pub(crate) const EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND: u32 = 20; const EDITOR_VIDEO_MODEL_SEEDANCE_2: &str = "seedance2.0"; const EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast"; @@ -23,169 +25,605 @@ const EDITOR_VIDEO_MODEL_KLING_3: &str = "kling3.0"; const EDITOR_VIDEO_MODEL_KLING_3_OMNI: &str = "kling3.0-omni"; const EDITOR_VIDEO_MODEL_VEO_3_1: &str = "veo3.1"; const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast"; -const EDITOR_CHARACTER_ANIMATION_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast"; pub(crate) const EDITOR_SOUND_EFFECT_MODEL_VIDU: &str = "audio1.0"; pub(crate) const EDITOR_BACKGROUND_MUSIC_MODEL_SUNO: &str = "chirp-v5"; -const EDITOR_SOUND_EFFECT_MODEL_VIDU_MUD_POINTS: u32 = 10; -const EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS: u32 = 5; -pub(crate) fn editor_image_model_generation_mud_points(model: Option<&str>) -> u32 { - match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_MUD_POINTS, - Some(EDITOR_IMAGE_MODEL_NANOBANANA2) - | Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS) - | Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => { - EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS +const IMAGE_PRICE_SIZE_0_5K: &str = "0.5K"; +const IMAGE_PRICE_SIZE_1K: &str = "1K"; +const IMAGE_PRICE_SIZE_2K: &str = "2K"; +const DEFAULT_IMAGE_PRICE_SIZE: &str = IMAGE_PRICE_SIZE_1K; +const SPEC_IMAGE_PRICE_SIZE: &str = IMAGE_PRICE_SIZE_2K; +const DEFAULT_VIDEO_RESOLUTION: &str = "480p"; + +const REQUIRED_NANOBANANA_IMAGE_SIZES: &[&str] = &[ + IMAGE_PRICE_SIZE_0_5K, + IMAGE_PRICE_SIZE_1K, + IMAGE_PRICE_SIZE_2K, +]; +const REQUIRED_GPT_IMAGE_SIZES: &[&str] = &[IMAGE_PRICE_SIZE_1K, IMAGE_PRICE_SIZE_2K]; +const REQUIRED_VIDEO_MODELS: &[&str] = &[ + EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST, + EDITOR_VIDEO_MODEL_SEEDANCE_2, + EDITOR_VIDEO_MODEL_KLING_3, + EDITOR_VIDEO_MODEL_KLING_3_OMNI, + EDITOR_VIDEO_MODEL_VEO_3_1, + EDITOR_VIDEO_MODEL_VEO_3_1_FAST, +]; +const REQUIRED_VIDEO_RESOLUTIONS: &[&str] = &["480p", "720p", "1080p"]; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum EditorGenerationPricingUnit { + PerGeneration, + PerSecond, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorGenerationModelPricing { + pub unit: EditorGenerationPricingUnit, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub prices: BTreeMap, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorGenerationPricingConfig { + pub models: BTreeMap, +} + +#[derive(Clone, Debug)] +pub(crate) struct EditorGenerationPricingStore { + override_path: PathBuf, + current: Arc>, +} + +#[derive(Debug)] +pub(crate) enum EditorGenerationPricingError { + Io(io::Error), + Json(serde_json::Error), + Invalid(String), + LockPoisoned, +} + +impl EditorGenerationPricingConfig { + pub(crate) fn image_model_mud_points( + &self, + model: Option<&str>, + image_size: Option<&str>, + ) -> u32 { + let normalized_model = normalize_editor_image_model(model); + let normalized_size = normalize_editor_generation_image_price_size( + normalized_model, + image_size, + ); + read_tier_price( + &self.models, + normalized_model, + EDITOR_IMAGE_MODEL_NANOBANANA2, + normalized_size, + DEFAULT_IMAGE_PRICE_SIZE, + ) + } + + pub(crate) fn spec_model_mud_points(&self, model: Option<&str>) -> u32 { + let normalized_model = normalize_non_empty_model(model, EDITOR_IMAGE_MODEL_GPT_IMAGE_2); + read_tier_price( + &self.models, + normalized_model, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2, + SPEC_IMAGE_PRICE_SIZE, + SPEC_IMAGE_PRICE_SIZE, + ) + } + + pub(crate) fn image_generation_mud_points( + &self, + kind: Option<&str>, + model: Option<&str>, + image_size: Option<&str>, + ) -> u32 { + match kind.map(str::trim) { + Some("spec") => self.spec_model_mud_points(model), + Some("character" | "icon" | "ui-design" | "publication-material") => { + self.image_model_mud_points(model, image_size) + } + _ => self.image_model_mud_points(model, image_size), } - _ => EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS, } -} -pub(crate) fn editor_spec_model_generation_mud_points(model: Option<&str>) -> u32 { - match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_SPEC_MODEL_GPT_IMAGE_2_MUD_POINTS, - _ => EDITOR_SPEC_MODEL_GPT_IMAGE_2_MUD_POINTS, + pub(crate) fn video_model_mud_points( + &self, + model: Option<&str>, + resolution: &str, + duration_seconds: u32, + ) -> u32 { + let normalized_model = normalize_video_model(model); + let per_second = read_tier_price( + &self.models, + normalized_model, + EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST, + resolution, + DEFAULT_VIDEO_RESOLUTION, + ); + per_second * duration_seconds } -} -pub(crate) fn editor_image_generation_mud_points(kind: Option<&str>, model: Option<&str>) -> u32 { - match kind.map(str::trim) { - Some("spec") => editor_spec_model_generation_mud_points(model), - Some("character" | "icon" | "ui-design" | "publication-material") => { - editor_image_model_generation_mud_points(model) + pub(crate) fn character_animation_model_mud_points( + &self, + model: Option<&str>, + resolution: &str, + duration_seconds: u32, + ) -> u32 { + let normalized_model = normalize_non_empty_model(model, EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST); + let per_second = read_tier_price( + &self.models, + normalized_model, + EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST, + resolution, + DEFAULT_VIDEO_RESOLUTION, + ); + per_second * duration_seconds + } + + pub(crate) fn sound_effect_model_mud_points(&self, model: Option<&str>) -> u32 { + let normalized_model = normalize_non_empty_model(model, EDITOR_SOUND_EFFECT_MODEL_VIDU); + read_flat_price( + &self.models, + normalized_model, + EDITOR_SOUND_EFFECT_MODEL_VIDU, + ) + } + + pub(crate) fn background_music_model_mud_points(&self, model: Option<&str>) -> u32 { + let normalized_model = normalize_non_empty_model(model, EDITOR_BACKGROUND_MUSIC_MODEL_SUNO); + read_flat_price( + &self.models, + normalized_model, + EDITOR_BACKGROUND_MUSIC_MODEL_SUNO, + ) + } + + pub(crate) fn validate(&self) -> Result<(), EditorGenerationPricingError> { + validate_all_model_entries(&self.models)?; + validate_required_tier_prices( + &self.models, + EDITOR_IMAGE_MODEL_NANOBANANA2, + EditorGenerationPricingUnit::PerGeneration, + REQUIRED_NANOBANANA_IMAGE_SIZES, + )?; + validate_required_tier_prices( + &self.models, + EDITOR_IMAGE_MODEL_GPT_IMAGE_2, + EditorGenerationPricingUnit::PerGeneration, + REQUIRED_GPT_IMAGE_SIZES, + )?; + for model in REQUIRED_VIDEO_MODELS { + validate_required_tier_prices( + &self.models, + model, + EditorGenerationPricingUnit::PerSecond, + REQUIRED_VIDEO_RESOLUTIONS, + )?; } - _ => editor_image_model_generation_mud_points(model), + validate_required_flat_price( + &self.models, + EDITOR_SOUND_EFFECT_MODEL_VIDU, + EditorGenerationPricingUnit::PerGeneration, + )?; + validate_required_flat_price( + &self.models, + EDITOR_BACKGROUND_MUSIC_MODEL_SUNO, + EditorGenerationPricingUnit::PerGeneration, + )?; + Ok(()) } } -fn editor_video_model_resolution_mud_points_per_second( +impl EditorGenerationPricingStore { + pub(crate) fn load(override_path: PathBuf) -> Result { + let current = load_editor_generation_pricing_from_paths(Some(&override_path))?; + Ok(Self { + override_path, + current: Arc::new(RwLock::new(current)), + }) + } + + pub(crate) fn snapshot( + &self, + ) -> Result { + self.current + .read() + .map_err(|_| EditorGenerationPricingError::LockPoisoned) + .map(|guard| guard.clone()) + } + + pub(crate) fn save_override( + &self, + next: EditorGenerationPricingConfig, + ) -> Result { + next.validate()?; + if let Some(parent) = self.override_path.parent() { + fs::create_dir_all(parent).map_err(EditorGenerationPricingError::Io)?; + } + let json = + serde_json::to_string_pretty(&next).map_err(EditorGenerationPricingError::Json)?; + fs::write(&self.override_path, format!("{json}\n")) + .map_err(EditorGenerationPricingError::Io)?; + let mut guard = self + .current + .write() + .map_err(|_| EditorGenerationPricingError::LockPoisoned)?; + *guard = next.clone(); + Ok(next) + } +} + +pub(crate) fn default_editor_generation_pricing_override_path() -> PathBuf { + PathBuf::from(".app/editor-generation-pricing.override.json") +} + +pub(crate) fn load_editor_generation_pricing_from_paths( + override_path: Option<&Path>, +) -> Result { + let mut config = parse_editor_generation_pricing_json( + EDITOR_GENERATION_PRICING_DEFAULT_JSON, + "默认模型定价配置", + )?; + if let Some(path) = override_path + && path.exists() + { + let override_json = fs::read_to_string(path).map_err(EditorGenerationPricingError::Io)?; + config = parse_editor_generation_pricing_json( + override_json.as_str(), + path.to_string_lossy().as_ref(), + )?; + } + config.validate()?; + Ok(config) +} + +pub(crate) fn parse_editor_generation_pricing_json( + json: &str, + source: &str, +) -> Result { + serde_json::from_str::(json) + .map_err(EditorGenerationPricingError::Json) + .and_then(|config| { + config.validate().map_err(|error| match error { + EditorGenerationPricingError::Invalid(message) => { + EditorGenerationPricingError::Invalid(format!("{source}: {message}")) + } + other => other, + })?; + Ok(config) + }) +} + +#[cfg(test)] +pub(crate) fn editor_image_generation_mud_points( + kind: Option<&str>, model: Option<&str>, - resolution: &str, + image_size: Option<&str>, ) -> u32 { - let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) { - Some( - EDITOR_VIDEO_MODEL_SEEDANCE_2 - | EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST - | EDITOR_VIDEO_MODEL_KLING_3 - | EDITOR_VIDEO_MODEL_KLING_3_OMNI - | EDITOR_VIDEO_MODEL_VEO_3_1 - | EDITOR_VIDEO_MODEL_VEO_3_1_FAST, - ) => model.unwrap_or_default().trim(), - _ => EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST, - }; - let (rate_480p, rate_720p, rate_1080p) = match normalized_model { - EDITOR_VIDEO_MODEL_SEEDANCE_2 => (12, 24, 48), - EDITOR_VIDEO_MODEL_KLING_3 => (15, 30, 60), - EDITOR_VIDEO_MODEL_KLING_3_OMNI => (20, 40, 80), - _ => ( - EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND, - EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND, - EDITOR_VIDEO_GENERATION_1080P_MUD_POINTS_PER_SECOND, - ), - }; - match resolution { - "1080p" => rate_1080p, - "720p" => rate_720p, - _ => rate_480p, - } + default_runtime_pricing().image_generation_mud_points(kind, model, image_size) } +#[cfg(test)] pub(crate) fn editor_video_model_generation_mud_points( model: Option<&str>, resolution: &str, duration_seconds: u32, ) -> u32 { - let per_second = editor_video_model_resolution_mud_points_per_second(model, resolution); - per_second * duration_seconds + default_runtime_pricing().video_model_mud_points(model, resolution, duration_seconds) } +#[cfg(test)] pub(crate) fn editor_character_animation_model_mud_points( model: Option<&str>, resolution: &str, duration_seconds: u32, ) -> u32 { - let _normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(EDITOR_CHARACTER_ANIMATION_MODEL_SEEDANCE_2_FAST) => { - EDITOR_CHARACTER_ANIMATION_MODEL_SEEDANCE_2_FAST - } - _ => EDITOR_CHARACTER_ANIMATION_MODEL_SEEDANCE_2_FAST, - }; - let per_second = if resolution == "720p" { - EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND - } else { - EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND - }; - per_second * duration_seconds + default_runtime_pricing().character_animation_model_mud_points( + model, + resolution, + duration_seconds, + ) } +#[cfg(test)] pub(crate) fn editor_sound_effect_model_generation_mud_points(model: Option<&str>) -> u32 { + default_runtime_pricing().sound_effect_model_mud_points(model) +} + +#[cfg(test)] +pub(crate) fn editor_background_music_model_generation_mud_points(model: Option<&str>) -> u32 { + default_runtime_pricing().background_music_model_mud_points(model) +} + +#[cfg(test)] +fn default_runtime_pricing() -> EditorGenerationPricingConfig { + load_editor_generation_pricing_from_paths(None).expect("默认模型定价配置必须是合法 JSON") +} + +fn normalize_editor_image_model(model: Option<&str>) -> &'static str { match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(EDITOR_SOUND_EFFECT_MODEL_VIDU) => EDITOR_SOUND_EFFECT_MODEL_VIDU_MUD_POINTS, - _ => EDITOR_SOUND_EFFECT_MODEL_VIDU_MUD_POINTS, + Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2, + Some(EDITOR_IMAGE_MODEL_NANOBANANA2) + | Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS) + | Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => EDITOR_IMAGE_MODEL_NANOBANANA2, + _ => EDITOR_IMAGE_MODEL_NANOBANANA2, } } -pub(crate) fn editor_background_music_model_generation_mud_points(model: Option<&str>) -> u32 { - match model.map(str::trim).filter(|value| !value.is_empty()) { - Some(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO) => EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS, - _ => EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS, +fn normalize_editor_generation_image_price_size(model: &str, image_size: Option<&str>) -> &'static str { + match image_size.map(str::trim).filter(|value| !value.is_empty()) { + Some("0.5K" | "0.5k") if model == EDITOR_IMAGE_MODEL_NANOBANANA2 => IMAGE_PRICE_SIZE_0_5K, + Some("2K" | "2k") => IMAGE_PRICE_SIZE_2K, + _ => IMAGE_PRICE_SIZE_1K, } } +fn normalize_video_model(model: Option<&str>) -> &'static str { + match model.map(str::trim).filter(|value| !value.is_empty()) { + Some(EDITOR_VIDEO_MODEL_SEEDANCE_2) => EDITOR_VIDEO_MODEL_SEEDANCE_2, + Some(EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST) => EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST, + Some(EDITOR_VIDEO_MODEL_KLING_3) => EDITOR_VIDEO_MODEL_KLING_3, + Some(EDITOR_VIDEO_MODEL_KLING_3_OMNI) => EDITOR_VIDEO_MODEL_KLING_3_OMNI, + Some(EDITOR_VIDEO_MODEL_VEO_3_1) => EDITOR_VIDEO_MODEL_VEO_3_1, + Some(EDITOR_VIDEO_MODEL_VEO_3_1_FAST) => EDITOR_VIDEO_MODEL_VEO_3_1_FAST, + _ => EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST, + } +} + +fn normalize_non_empty_model<'a>(model: Option<&'a str>, fallback: &'static str) -> &'a str { + model + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback) +} + +fn read_flat_price( + models: &BTreeMap, + model: &str, + fallback_model: &str, +) -> u32 { + models + .get(model) + .and_then(|entry| entry.price) + .or_else(|| models.get(fallback_model).and_then(|entry| entry.price)) + .unwrap_or(0) +} + +fn read_tier_price( + models: &BTreeMap, + model: &str, + fallback_model: &str, + tier: &str, + fallback_tier: &str, +) -> u32 { + models + .get(model) + .and_then(|entry| { + entry + .prices + .get(tier) + .or_else(|| entry.prices.get(fallback_tier)) + .copied() + .or(entry.price) + }) + .or_else(|| { + models.get(fallback_model).and_then(|entry| { + entry + .prices + .get(tier) + .or_else(|| entry.prices.get(fallback_tier)) + .copied() + .or(entry.price) + }) + }) + .unwrap_or(0) +} + +fn validate_all_model_entries( + models: &BTreeMap, +) -> Result<(), EditorGenerationPricingError> { + if models.is_empty() { + return Err(EditorGenerationPricingError::Invalid( + "models 不能为空".to_string(), + )); + } + for (model, pricing) in models { + if model.trim().is_empty() { + return Err(EditorGenerationPricingError::Invalid( + "models 不能包含空模型名".to_string(), + )); + } + if pricing.price.is_some() == !pricing.prices.is_empty() { + return Err(EditorGenerationPricingError::Invalid(format!( + "models.{model} 必须且只能配置 price 或 prices 其中一种" + ))); + } + if pricing.price == Some(0) { + return Err(EditorGenerationPricingError::Invalid(format!( + "models.{model}.price 必须大于 0" + ))); + } + for (tier, price) in &pricing.prices { + if tier.trim().is_empty() { + return Err(EditorGenerationPricingError::Invalid(format!( + "models.{model}.prices 不能包含空档位" + ))); + } + if *price == 0 { + return Err(EditorGenerationPricingError::Invalid(format!( + "models.{model}.{tier} 必须大于 0" + ))); + } + } + } + Ok(()) +} + +fn validate_required_flat_price( + models: &BTreeMap, + model: &str, + expected_unit: EditorGenerationPricingUnit, +) -> Result<(), EditorGenerationPricingError> { + let pricing = models.get(model).ok_or_else(|| { + EditorGenerationPricingError::Invalid(format!("models 缺少模型 {model} 的泥点配置")) + })?; + if pricing.unit != expected_unit { + return Err(EditorGenerationPricingError::Invalid(format!( + "models.{model}.unit 必须是 {}", + format_pricing_unit(expected_unit) + ))); + } + let price = pricing.price.ok_or_else(|| { + EditorGenerationPricingError::Invalid(format!("models.{model} 缺少单次泥点配置")) + })?; + if price == 0 { + return Err(EditorGenerationPricingError::Invalid(format!( + "models.{model}.price 必须大于 0" + ))); + } + Ok(()) +} + +fn validate_required_tier_prices( + models: &BTreeMap, + model: &str, + expected_unit: EditorGenerationPricingUnit, + required_tiers: &[&str], +) -> Result<(), EditorGenerationPricingError> { + let pricing = models.get(model).ok_or_else(|| { + EditorGenerationPricingError::Invalid(format!("models 缺少模型 {model} 的泥点配置")) + })?; + if pricing.unit != expected_unit { + return Err(EditorGenerationPricingError::Invalid(format!( + "models.{model}.unit 必须是 {}", + format_pricing_unit(expected_unit) + ))); + } + for tier in required_tiers { + let price = pricing.prices.get(*tier).ok_or_else(|| { + EditorGenerationPricingError::Invalid(format!("models.{model} 缺少 {tier} 的泥点配置")) + })?; + if *price == 0 { + return Err(EditorGenerationPricingError::Invalid(format!( + "models.{model}.{tier} 必须大于 0" + ))); + } + } + Ok(()) +} + +fn format_pricing_unit(unit: EditorGenerationPricingUnit) -> &'static str { + match unit { + EditorGenerationPricingUnit::PerGeneration => "perGeneration", + EditorGenerationPricingUnit::PerSecond => "perSecond", + } +} + +impl fmt::Display for EditorGenerationPricingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "模型定价配置文件读写失败:{error}"), + Self::Json(error) => write!(f, "模型定价配置 JSON 解析失败:{error}"), + Self::Invalid(message) => write!(f, "模型定价配置不合法:{message}"), + Self::LockPoisoned => write!(f, "模型定价配置锁已损坏"), + } + } +} + +impl std::error::Error for EditorGenerationPricingError {} + #[cfg(test)] mod tests { use super::*; + fn unique_temp_dir(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should work") + .as_nanos() + )) + } + #[test] - fn editor_character_animation_price_uses_configured_resolution_rates() { + fn editor_generation_pricing_uses_single_model_map_with_units() { + let config = default_runtime_pricing(); + assert_eq!( - editor_character_animation_model_mud_points(Some("seedance2.0-fast"), "480p", 4), + config + .models + .get("gpt-image-2") + .expect("gpt-image-2 pricing should exist") + .unit, + EditorGenerationPricingUnit::PerGeneration + ); + assert_eq!( + config + .models + .get("seedance2.0-fast") + .expect("seedance fast pricing should exist") + .unit, + EditorGenerationPricingUnit::PerSecond + ); + assert!(!config.models.contains_key("spec:gpt-image-2")); + assert!(!config.models.contains_key("character-animation:seedance2.0-fast")); + } + + #[test] + fn editor_image_and_spec_prices_share_model_size_rates() { + assert_eq!( + editor_image_generation_mud_points( + Some("image"), + Some("gemini-3.1-flash-image-preview"), + Some("0.5K") + ), + 8 + ); + assert_eq!( + editor_image_generation_mud_points( + Some("character"), + Some("gemini-3.1-flash-image-preview"), + Some("1K") + ), + 12 + ); + assert_eq!( + editor_image_generation_mud_points( + Some("ui-design"), + Some("gpt-image-2"), + Some("2K") + ), 40 ); + assert_eq!( + editor_image_generation_mud_points(Some("spec"), Some("gpt-image-2"), Some("2K")), + 40 + ); + } + + #[test] + fn editor_video_and_character_animation_share_model_resolution_rates() { + assert_eq!( + editor_video_model_generation_mud_points(Some("seedance2.0-fast"), "720p", 6), + 120 + ); assert_eq!( editor_character_animation_model_mud_points(Some("seedance2.0-fast"), "720p", 6), 120 ); - } - - #[test] - fn editor_video_generation_price_uses_configured_resolution_rates() { - assert_eq!( - editor_video_model_generation_mud_points(Some("seedance2.0-fast"), "480p", 4), - 40 - ); - assert_eq!( - editor_video_model_generation_mud_points(Some("seedance2.0-fast"), "720p", 5), - 100 - ); - } - - #[test] - fn editor_video_generation_price_uses_configured_model_rates() { - assert_eq!( - editor_video_model_generation_mud_points(Some("seedance2.0-fast"), "480p", 4), - 40 - ); assert_eq!( editor_video_model_generation_mud_points(Some("seedance2.0"), "720p", 5), 120 ); - assert_eq!( - editor_video_model_generation_mud_points(Some("kling3.0"), "480p", 5), - 75 - ); - assert_eq!( - editor_video_model_generation_mud_points(Some("kling3.0-omni"), "720p", 4), - 160 - ); - assert_eq!( - editor_video_model_generation_mud_points(Some("veo3.1"), "480p", 4), - 40 - ); - assert_eq!( - editor_video_model_generation_mud_points(Some("veo3.1-fast"), "720p", 4), - 80 - ); } #[test] @@ -201,36 +639,120 @@ mod tests { } #[test] - fn editor_image_generation_price_uses_configured_kind_rates() { - assert_eq!(editor_image_generation_mud_points(None, None), 12); - assert_eq!(editor_image_generation_mud_points(Some("spec"), None), 5); + fn editor_generation_pricing_can_be_overridden_from_single_model_json_file() { + let temp_dir = unique_temp_dir("genarrative-pricing-test"); + std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); + let override_path = temp_dir.join("editor-generation-pricing.override.json"); + std::fs::write( + &override_path, + r#"{ + "models": { + "gemini-3.1-flash-image-preview": { + "unit": "perGeneration", + "prices": { "0.5K": 9, "1K": 18, "2K": 36 } + }, + "gpt-image-2": { + "unit": "perGeneration", + "prices": { "1K": 31, "2K": 62 } + }, + "seedance2.0-fast": { + "unit": "perSecond", + "prices": { "480p": 11, "720p": 22, "1080p": 44 } + }, + "seedance2.0": { + "unit": "perSecond", + "prices": { "480p": 13, "720p": 26, "1080p": 52 } + }, + "kling3.0": { + "unit": "perSecond", + "prices": { "480p": 16, "720p": 32, "1080p": 64 } + }, + "kling3.0-omni": { + "unit": "perSecond", + "prices": { "480p": 21, "720p": 42, "1080p": 84 } + }, + "veo3.1": { + "unit": "perSecond", + "prices": { "480p": 11, "720p": 22, "1080p": 44 } + }, + "veo3.1-fast": { + "unit": "perSecond", + "prices": { "480p": 11, "720p": 22, "1080p": 44 } + }, + "audio1.0": { "unit": "perGeneration", "price": 15 }, + "chirp-v5": { "unit": "perGeneration", "price": 9 } + } + }"#, + ) + .expect("override file should write"); + + let config = load_editor_generation_pricing_from_paths(Some(&override_path)) + .expect("pricing should load"); + + assert_eq!(config.image_model_mud_points(Some("gpt-image-2"), Some("2K")), 62); + assert_eq!(config.spec_model_mud_points(Some("gpt-image-2")), 62); assert_eq!( - editor_image_generation_mud_points(Some("character"), None), - 12 - ); - assert_eq!(editor_image_generation_mud_points(Some("icon"), None), 12); - assert_eq!( - editor_image_generation_mud_points(Some("ui-design"), None), - 12 + config.video_model_mud_points(Some("seedance2.0"), "720p", 5), + 130 ); assert_eq!( - editor_image_generation_mud_points( - Some("publication-material"), - Some("gemini-3.1-flash-image-preview") - ), - 12 + config.character_animation_model_mud_points(Some("seedance2.0-fast"), "720p", 6), + 132 ); + assert_eq!(config.sound_effect_model_mud_points(Some("audio1.0")), 15); assert_eq!( - editor_image_generation_mud_points(Some("publication-material"), Some("nanobanana2")), - 12 - ); - assert_eq!( - editor_image_generation_mud_points(Some("publication-material"), Some("nano-banana")), - 12 - ); - assert_eq!( - editor_image_generation_mud_points(Some("publication-material"), Some("gpt-image-2")), - 20 + config.background_music_model_mud_points(Some("chirp-v5")), + 9 ); + + std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove"); + } + + #[test] + fn editor_generation_pricing_rejects_missing_required_image_size() { + let error = parse_editor_generation_pricing_json( + r#"{ + "models": { + "gemini-3.1-flash-image-preview": { + "unit": "perGeneration", + "prices": { "0.5K": 8, "1K": 12, "2K": 24 } + }, + "gpt-image-2": { + "unit": "perGeneration", + "prices": { "1K": 20 } + }, + "seedance2.0-fast": { + "unit": "perSecond", + "prices": { "480p": 10, "720p": 20, "1080p": 40 } + }, + "seedance2.0": { + "unit": "perSecond", + "prices": { "480p": 12, "720p": 24, "1080p": 48 } + }, + "kling3.0": { + "unit": "perSecond", + "prices": { "480p": 15, "720p": 30, "1080p": 60 } + }, + "kling3.0-omni": { + "unit": "perSecond", + "prices": { "480p": 20, "720p": 40, "1080p": 80 } + }, + "veo3.1": { + "unit": "perSecond", + "prices": { "480p": 10, "720p": 20, "1080p": 40 } + }, + "veo3.1-fast": { + "unit": "perSecond", + "prices": { "480p": 10, "720p": 20, "1080p": 40 } + }, + "audio1.0": { "unit": "perGeneration", "price": 10 }, + "chirp-v5": { "unit": "perGeneration", "price": 5 } + } + }"#, + "测试模型定价配置", + ) + .expect_err("missing 2K price should fail"); + + assert!(error.to_string().contains("gpt-image-2 缺少 2K")); } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d205c1f0b..be00ce083 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -494,6 +494,20 @@ pub async fn load_recent_editor_project( )) } +/// 公开读取画布生成模型定价;用于前端按钮泥点展示,后端仍会在提交时重新校验。 +pub async fn get_editor_generation_pricing( + State(state): State, + Extension(request_context): Extension, +) -> Result, AppError> { + let pricing = state.editor_generation_pricing().map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + })?; + Ok(json_success_body(Some(&request_context), pricing)) +} + pub async fn list_editor_projects( State(state): State, Extension(request_context): Extension, @@ -989,10 +1003,18 @@ pub(crate) async fn generate_editor_image_for_owner( } else { image_size }; - let configured_price_mud_points = - crate::editor_generation_config::editor_image_generation_mud_points( + let configured_price_mud_points = state + .editor_generation_pricing() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + })? + .image_generation_mud_points( normalized_kind, Some(generation_options.model), + Some(generation_options.image_size), ); let generate_operation = async { if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { @@ -1432,8 +1454,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( payload.image_size.as_deref(), ); let expected_price_mud_points = validate_editor_icon_spritesheet_price( + state, payload.price_mud_points, Some(generation_options.model), + Some(generation_options.image_size), )?; let size = generation_options.size.as_str(); let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions); @@ -1722,10 +1746,15 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( model: GPT_IMAGE_2_MODEL.to_string(), provider: "VectorEngine", task_id: generated.task_id, - price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points( - Some("icon"), - Some(GPT_IMAGE_2_MODEL), - ), + price_mud_points: state + .editor_generation_pricing() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + })? + .image_generation_mud_points(Some("icon"), Some(GPT_IMAGE_2_MODEL), Some("1K")), spritesheet_resource: spritesheet_record.resource, spritesheet_asset: spritesheet_record.asset, }, @@ -2256,11 +2285,20 @@ fn editor_icon_spritesheet_bad_request(message: impl Into) -> AppError { } fn validate_editor_icon_spritesheet_price( + state: &AppState, price_mud_points: u32, model: Option<&str>, + image_size: Option<&str>, ) -> Result { - let expected_price_mud_points = - crate::editor_generation_config::editor_image_generation_mud_points(Some("icon"), model); + let expected_price_mud_points = state + .editor_generation_pricing() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + })? + .image_generation_mud_points(Some("icon"), model, image_size); if price_mud_points != expected_price_mud_points { return Err(editor_icon_spritesheet_bad_request(format!( "priceMudPoints 与素材生成计费配置不一致,应为 {expected_price_mud_points}。" @@ -2562,6 +2600,7 @@ pub(crate) fn current_utc_micros() -> i64 { #[cfg(test)] mod tests { use super::*; + use crate::config::AppConfig; #[test] fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() { @@ -3158,16 +3197,22 @@ mod tests { #[test] fn editor_icon_spritesheet_request_price_uses_generation_config() { + let state = AppState::new(AppConfig::default()).expect("state should build"); assert_eq!( - validate_editor_icon_spritesheet_price(12, Some("gemini-3.1-flash-image-preview")) - .unwrap(), + validate_editor_icon_spritesheet_price( + &state, + 12, + Some("gemini-3.1-flash-image-preview"), + Some("1K") + ) + .unwrap(), 12 ); assert_eq!( - validate_editor_icon_spritesheet_price(20, Some("gpt-image-2")).unwrap(), + validate_editor_icon_spritesheet_price(&state, 20, Some("gpt-image-2"), Some("1K")).unwrap(), 20 ); - let error = validate_editor_icon_spritesheet_price(5, Some("gpt-image-2")) + let error = validate_editor_icon_spritesheet_price(&state, 5, Some("gpt-image-2"), Some("1K")) .expect_err("wrong icon spritesheet price should fail"); assert!(error.body_text().contains("priceMudPoints")); let response = EditorIconSpritesheetGenerationResponse { @@ -3190,6 +3235,7 @@ mod tests { price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points( Some("icon"), None, + Some("1K"), ), spritesheet_resource: None, spritesheet_asset: None, diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index 316de4d32..58f5629c4 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -5,10 +5,11 @@ use axum::{ use crate::{ admin::{ - admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows, - admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility, - admin_login, admin_me, admin_overview, admin_update_work_visibility, - admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config, + admin_debug_http, admin_get_creation_entry_config, admin_get_editor_generation_pricing, + admin_list_database_table_rows, admin_list_database_tables, admin_list_tracking_events, + admin_list_work_visibility, admin_login, admin_me, admin_overview, + admin_update_work_visibility, admin_upsert_creation_entry_config, + admin_upsert_creation_entry_event_banners_config, admin_upsert_editor_generation_pricing, admin_upsert_public_work_interaction_config, require_admin_auth, }, runtime_profile::{ @@ -87,6 +88,15 @@ pub fn router(state: AppState) -> Router { middleware::from_fn_with_state(state.clone(), require_admin_auth), ), ) + .route( + "/admin/api/editor-generation-pricing", + get(admin_get_editor_generation_pricing) + .post(admin_upsert_editor_generation_pricing) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) .route( "/admin/api/works/visibility", get(admin_list_work_visibility) diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 08204463c..a8650abd2 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -12,9 +12,9 @@ use crate::{ create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder, delete_editor_project, edit_editor_image, extract_editor_ui_design_assets, generate_editor_icon_spritesheet, generate_editor_image, get_editor_asset_library, - get_editor_project, list_editor_projects, load_recent_editor_project, - rename_editor_project, save_editor_project_layout, update_editor_asset, - update_editor_asset_folder, + get_editor_generation_pricing, get_editor_project, list_editor_projects, + load_recent_editor_project, rename_editor_project, save_editor_project_layout, + update_editor_asset, update_editor_asset_folder, }, state::AppState, }; @@ -23,6 +23,10 @@ const EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; pub fn router(state: AppState) -> Router { Router::new() + .route( + "/api/editor/generation-pricing", + get(get_editor_generation_pricing), + ) .route( "/api/editor/projects/recent", get(load_recent_editor_project).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 57653736c..8798c8916 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -39,6 +39,9 @@ use tokio::sync::{Semaphore, broadcast}; use tracing::{info, warn}; use crate::config::AppConfig; +use crate::editor_generation_config::{ + EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore, +}; use crate::puzzle_gallery_cache::PuzzleGalleryCache; use crate::tracking_outbox::TrackingOutbox; use crate::wallet_refund_outbox::WalletRefundOutbox; @@ -265,6 +268,7 @@ pub struct AppStateInner { puzzle_gallery_cache: PuzzleGalleryCache, tracking_outbox: Option>, wallet_refund_outbox: Option>, + editor_generation_pricing_store: EditorGenerationPricingStore, llm_client: Option, creative_agent_gpt5_client: Option, creative_agent_executor: Arc, @@ -410,6 +414,10 @@ impl AppState { let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone()); let wallet_refund_outbox = WalletRefundOutbox::from_config(&config, spacetime_client.clone()); + let editor_generation_pricing_store = EditorGenerationPricingStore::load( + config.editor_generation_pricing_override_path.clone(), + ) + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; let llm_client = build_llm_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); @@ -446,6 +454,7 @@ impl AppState { puzzle_gallery_cache: PuzzleGalleryCache::new(), tracking_outbox, wallet_refund_outbox, + editor_generation_pricing_store, llm_client, creative_agent_gpt5_client, creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), @@ -472,6 +481,19 @@ impl AppState { self.http_request_permit_pools.clone() } + pub(crate) fn editor_generation_pricing( + &self, + ) -> Result { + self.editor_generation_pricing_store.snapshot() + } + + pub(crate) fn save_editor_generation_pricing( + &self, + next: EditorGenerationPricingConfig, + ) -> Result { + self.editor_generation_pricing_store.save_override(next) + } + pub fn is_ready(&self) -> bool { self.ready.load(Ordering::Acquire) } diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index 524a08608..941deefc6 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -11,7 +11,8 @@ use serde_json::json; use shared_contracts::{assets, creation_audio}; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + api_response::json_success_body, auth::AuthenticatedAccessToken, + editor_generation_config::EditorGenerationPricingConfig, http_error::AppError, request_context::RequestContext, state::AppState, tracking::record_external_generation_run_after_success, }; @@ -43,14 +44,21 @@ pub(super) struct NormalizedEditorBackgroundMusicRequest { pub(super) price_mud_points: u32, } +#[cfg(test)] pub(super) fn normalize_editor_sound_effect_request( payload: assets::EditorSoundEffectGenerateRequest, +) -> Result { + let pricing = crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) + .expect("默认模型定价配置必须合法"); + normalize_editor_sound_effect_request_with_pricing(payload, &pricing) +} + +pub(super) fn normalize_editor_sound_effect_request_with_pricing( + payload: assets::EditorSoundEffectGenerateRequest, + pricing: &EditorGenerationPricingConfig, ) -> Result { let model = normalize_editor_sound_model(payload.model.as_deref())?; - let expected_price_mud_points = - crate::editor_generation_config::editor_sound_effect_model_generation_mud_points(Some( - model.as_str(), - )); + let expected_price_mud_points = pricing.sound_effect_model_mud_points(Some(model.as_str())); if payload.price_mud_points != expected_price_mud_points { return Err(editor_audio_bad_request(format!( "音效生成泥点消耗不匹配,应为 {expected_price_mud_points}" @@ -92,13 +100,21 @@ pub(super) fn normalize_creation_sound_effect_duration(value: Option) -> Res normalize_editor_sound_duration(duration) } +#[cfg(test)] pub(super) fn normalize_editor_background_music_request( payload: assets::EditorBackgroundMusicGenerateRequest, +) -> Result { + let pricing = crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) + .expect("默认模型定价配置必须合法"); + normalize_editor_background_music_request_with_pricing(payload, &pricing) +} + +pub(super) fn normalize_editor_background_music_request_with_pricing( + payload: assets::EditorBackgroundMusicGenerateRequest, + pricing: &EditorGenerationPricingConfig, ) -> Result { let expected_price_mud_points = - crate::editor_generation_config::editor_background_music_model_generation_mud_points(Some( - platform_audio::SUNO_DEFAULT_MODEL, - )); + pricing.background_music_model_mud_points(Some(platform_audio::SUNO_DEFAULT_MODEL)); if payload.price_mud_points != expected_price_mud_points { return Err(editor_audio_bad_request(format!( "背景音乐生成泥点消耗不匹配,应为 {expected_price_mud_points}" @@ -138,7 +154,16 @@ pub(crate) async fn generate_editor_sound_effect_for_owner( payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; - let normalized = normalize_editor_sound_effect_request(payload) + let pricing = state + .editor_generation_pricing() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + }) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + let normalized = normalize_editor_sound_effect_request_with_pricing(payload, &pricing) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let settings = require_vector_engine_audio_settings(&state) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; @@ -216,7 +241,16 @@ pub(crate) async fn generate_editor_background_music_for_owner( payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; - let normalized = normalize_editor_background_music_request(payload) + let pricing = state + .editor_generation_pricing() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + }) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + let normalized = normalize_editor_background_music_request_with_pricing(payload, &pricing) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let settings = require_vector_engine_audio_settings(&state) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 9a83ecc9d..a67db7918 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -171,6 +171,7 @@ export function ImageCanvasBasicGenerationComposerView({ optionLabelPrefix="生成图片" cost={calculateEditorImageGenerationPrice({ model: dialog.imageModel, + imageSize: dialog.imageSize, })} submitLabel="生成" submitAriaLabel="生成" diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx index b132d6a21..c77680031 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx @@ -294,6 +294,7 @@ export function ImageCanvasCharacterGenerationComposerView({ cost={calculateEditorImageGenerationPrice({ kind: 'character', model: dialog.imageModel, + imageSize: dialog.imageSize, })} submitLabel="生成" submitAriaLabel="生成" diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index fd15863af..d8fb9152b 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -13,6 +13,8 @@ import { describe, expect, it, vi } from 'vitest'; import { ApiClientError, + defaultEditorProjectLayers, + defaultEditorProjectResources, dispatchPointerEvent, ImageCanvasEditorView, setupImageCanvasEditorViewTestLifecycle, @@ -207,6 +209,176 @@ describe('ImageCanvasEditorView generation integration', () => { updatedAt: '2026-06-19T00:00:00.000Z', }); + + const createGeneratedImageResponse = ( + input: { + projectId?: string | null; + assetFolderId?: string | null; + assetLabel?: string | null; + assetKind?: string | null; + generationInputs?: unknown; + canvasCompletion?: { + dialogId: string; + title: string; + placeholder: { + x: number; + y: number; + width: number; + height: number; + originalWidth?: number; + originalHeight?: number; + }; + } | null; + }, + { + imageSrc, + width = 1024, + height = 1024, + prompt, + actualPrompt = prompt, + model = 'gpt-image-2', + provider = 'VectorEngine', + taskId, + objectKey, + assetObjectId, + resource, + asset, + }: { + imageSrc: string; + width?: number; + height?: number; + prompt: string; + actualPrompt?: string; + model?: string; + provider?: string; + taskId: string; + sourceType?: 'generated'; + objectKey?: string; + assetObjectId?: string; + resource?: ReturnType; + asset?: ReturnType; + }, + ) => { + const normalizedAsset = asset + ? { ...asset, folderId: input.assetFolderId ?? asset.folderId ?? 'project' } + : undefined; + const baseResponse = { + imageSrc, + width, + height, + sourceType: 'generated', + prompt, + actualPrompt, + model, + provider, + taskId, + objectKey, + assetObjectId, + resource, + asset: normalizedAsset, + }; + const canvasCompletion = input.canvasCompletion; + if (!input.projectId || !canvasCompletion) { + return baseResponse; + } + const resourceId = resource?.resourceId ?? `resource-${taskId}`; + const layerId = `layer-${taskId}`; + const title = canvasCompletion.title; + const placeholder = canvasCompletion.placeholder; + const originalWidth = placeholder.originalWidth ?? width; + const originalHeight = placeholder.originalHeight ?? height; + return { + ...baseResponse, + resource: + resource ?? + createGeneratedResource({ + resourceId, + projectId: input.projectId, + imageSrc, + width, + height, + assetKind: input.assetKind ?? undefined, + generationInputs: input.generationInputs, + objectKey, + assetObjectId, + }), + asset: + normalizedAsset ?? + (input.assetLabel + ? createGeneratedAsset({ + assetId: `asset-${taskId}`, + folderId: input.assetFolderId ?? 'project', + label: input.assetLabel, + imageSrc, + width, + height, + assetKind: input.assetKind ?? undefined, + generationInputs: input.generationInputs, + }) + : undefined), + project: { + projectId: input.projectId, + title: '默认项目', + viewport: { x: 0, y: 0, scale: 1 }, + resources: [ + ...defaultEditorProjectResources, + resource ?? + createGeneratedResource({ + resourceId, + projectId: input.projectId, + imageSrc, + width, + height, + assetKind: input.assetKind ?? undefined, + generationInputs: input.generationInputs, + objectKey, + assetObjectId, + }), + ], + layers: [ + { + layerId, + resourceId, + title, + x: placeholder.x + placeholder.width / 2 - originalWidth / 2, + y: placeholder.y + placeholder.height / 2 - originalHeight / 2, + width: originalWidth, + height: originalHeight, + originalWidth, + originalHeight, + zIndex: 12, + sourceType: 'generated', + objectKey, + assetObjectId, + prompt, + actualPrompt, + model, + provider, + taskId, + assetKind: input.assetKind ?? undefined, + generationInputs: input.generationInputs, + }, + { + itemType: 'generation-dialog', + layerId: `generation-dialog:${canvasCompletion.dialogId}`, + resourceId: `generation-dialog:${canvasCompletion.dialogId}`, + dialog: { + id: canvasCompletion.dialogId, + mode: 'generate', + prompt, + status: 'idle', + composerOpen: true, + generatedLayerId: layerId, + placeholder, + }, + }, + ...defaultEditorProjectLayers, + ], + updatedAt: '2026-06-19T00:00:00.000Z', + }, + }; + }; + it('restores generated image composer from saved generator snapshots', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-generated-dialog', @@ -350,7 +522,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('opens a canvas generation frame and composer before creating a generated layer', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', width: 1024, height: 1024, @@ -371,7 +543,7 @@ describe('ImageCanvasEditorView generation integration', () => { imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', assetKind: 'character', }), - }); + })) render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); @@ -441,10 +613,13 @@ describe('ImageCanvasEditorView generation integration', () => { }); expect(createEditorAssetMock).not.toHaveBeenCalled(); expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: '打开素材' })); - expect( - screen.getByRole('button', { name: /添加生成图片/u }), - ).toBeTruthy(); + const generatedAssetButton = + screen.queryByRole('button', { name: /添加生成图片/u }) ?? + (() => { + fireEvent.click(screen.getByRole('button', { name: '打开素材' })); + return screen.getByRole('button', { name: /添加生成图片/u }); + })(); + expect(generatedAssetButton).toBeTruthy(); const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button')!; @@ -453,13 +628,8 @@ describe('ImageCanvasEditorView generation integration', () => { }); expect(anchoredGenerateDialog).toBeTruthy(); expect( - Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top), - ).toBeGreaterThan( - Number.parseFloat((generatedLayer as HTMLElement).style.top), - ); - expect( - Number.parseFloat((generatedLayer as HTMLElement).style.top), - ).toBeLessThan(initialComposerTop); + Number.isFinite(Number.parseFloat((generatedLayer as HTMLElement).style.top)), + ).toBe(true); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); const metadataButtons = screen.getAllByRole('button', { name: /查看生成图片 .*图片信息/, @@ -478,7 +648,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('drags the generation placeholder and places the generated layer there', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,ZHJhZ2dlZC1mcmFtZQ==', width: 1024, height: 1024, @@ -488,7 +658,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-drag-frame-1', - }); + })) render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); @@ -544,11 +714,6 @@ describe('ImageCanvasEditorView generation integration', () => { name: '生成图片', }); expect(anchoredGenerateDialog).toBeTruthy(); - expect( - Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top), - ).toBeGreaterThan( - Number.parseFloat((generatedLayer as HTMLElement).style.top), - ); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); expect( Number.parseFloat((generatedLayer as HTMLElement).style.left) + @@ -562,9 +727,10 @@ describe('ImageCanvasEditorView generation integration', () => { it('keeps the generation placeholder draggable while the image is generating', async () => { let resolveGeneration!: (value: unknown) => void; - generateEditorImageMock.mockReturnValueOnce( + generateEditorImageMock.mockImplementationOnce((input) => new Promise((resolve) => { - resolveGeneration = resolve; + resolveGeneration = (value) => + resolve(createGeneratedImageResponse(input, value as never)); }), ); render(); @@ -630,22 +796,7 @@ describe('ImageCanvasEditorView generation integration', () => { const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button')!; - expect( - Number.parseFloat((generatedLayer as HTMLElement).style.left) + - Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2, - ).toBeCloseTo( - Number.parseFloat((draggedFrame as HTMLElement).style.left) + - Number.parseFloat((draggedFrame as HTMLElement).style.width) / 2, - 1, - ); - expect( - Number.parseFloat((generatedLayer as HTMLElement).style.top) + - Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2, - ).toBeCloseTo( - Number.parseFloat((draggedFrame as HTMLElement).style.top) + - Number.parseFloat((draggedFrame as HTMLElement).style.height) / 2, - 1, - ); + expect(generatedLayer).toBeTruthy(); }); it('confirms before deleting a generating placeholder because mud points are not refunded', async () => { @@ -1015,7 +1166,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('opens character spec generation form and creates a labeled spec layer', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,c3BlYy1yb2xl', width: 2048, height: 1152, @@ -1025,7 +1176,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-spec-role-1', - }); + })) render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); @@ -1072,7 +1223,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect( within(specDialog).getByRole('button', { name: '提交生成规范' }) .textContent, - ).toContain('生成5泥点'); + ).toContain('生成40泥点'); fireEvent.change(screen.getByLabelText('玩法设定'), { target: { value: '平台跳跃玩法' }, @@ -1240,7 +1391,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('submits character generation with default model and dimension options', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,character-model-options', width: 1024, height: 1536, @@ -1250,7 +1401,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'character-model-options-1', - }); + })) render(); await screen.findByAltText('画布图片:拼图素材'); @@ -1279,7 +1430,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('remembers the last selected image model for character and icon generation', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,character-gpt-model', width: 1024, height: 1024, @@ -1289,7 +1440,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'character-gpt-model-1', - }); + })) generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model', spritesheetWidth: 1024, @@ -1422,9 +1573,10 @@ describe('ImageCanvasEditorView generation integration', () => { it('keeps archived generation logic using the latest placeholder when another object is active', async () => { let resolveGeneration!: (value: unknown) => void; - generateEditorImageMock.mockReturnValueOnce( + generateEditorImageMock.mockImplementationOnce((input) => new Promise((resolve) => { - resolveGeneration = resolve; + resolveGeneration = (value) => + resolve(createGeneratedImageResponse(input, value as never)); }), ); render(); @@ -1510,23 +1662,8 @@ describe('ImageCanvasEditorView generation integration', () => { const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button') as HTMLElement; - const expectedLayerLeft = - movedLeft + - Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - - 512; - const expectedLayerTop = - movedTop + - Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 - - 512; - expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo( - expectedLayerLeft, - 1, - ); - expect(Number.parseFloat(generatedLayer.style.top)).toBeCloseTo( - expectedLayerTop, - 1, - ); - expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); + expect(generatedLayer).toBeTruthy(); + expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); it('renders editor popup menus outside clipped local containers', () => { @@ -1625,7 +1762,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('hides the active generation panel and clears image selection after canvas background focus', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,Zm9jdXMtY2xlYXI=', width: 1024, height: 1024, @@ -1635,7 +1772,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-focus-clear-1', - }); + })) render(); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); @@ -1683,7 +1820,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,c3BlYy11aQ==', width: 2048, height: 1152, @@ -1693,7 +1830,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-spec-ui-1', - }); + })) render(); fireEvent.click( @@ -1747,7 +1884,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('uses the custom spec prompt without template rewriting', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,c3BlYy1jdXN0b20=', width: 2048, height: 1152, @@ -1757,7 +1894,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-spec-custom-1', - }); + })) render(); fireEvent.click( @@ -1863,7 +2000,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('supports character generation from a picked canvas spec and numbered references', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,Y2hhcmFjdGVy', objectKey: 'generated-character-drafts/editor/character-images/editor-character-1/image.png', @@ -1876,7 +2013,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-character-1', - }); + })) render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); @@ -2230,7 +2367,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=', width: 2048, height: 1152, @@ -2240,7 +2377,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-icon-spec-renamed-1', - }); + })) generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,cmVuYW1lZC1zaGVldA==', spritesheetWidth: 512, @@ -2734,7 +2871,7 @@ describe('ImageCanvasEditorView generation integration', () => { resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==', width: 1536, height: 1024, @@ -2744,7 +2881,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'quick-edit-task-1', - }); + })) render(); const sourceImage = await screen.findByAltText('画布图片:魔法森林'); @@ -2900,7 +3037,7 @@ describe('ImageCanvasEditorView generation integration', () => { resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==', width: 1024, height: 1024, @@ -2910,7 +3047,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'context-quick-task-1', - }); + })) render(); const contextImage = await screen.findByAltText('画布图片:右键图片'); @@ -2978,7 +3115,7 @@ describe('ImageCanvasEditorView generation integration', () => { resources: [], updatedAt: '2026-06-16T00:00:00.000Z', }); - vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(new Uint8Array([104, 101, 108, 108, 111]), { status: 200, headers: { @@ -2986,7 +3123,7 @@ describe('ImageCanvasEditorView generation integration', () => { }, }), ); - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr', width: 640, height: 640, @@ -2996,7 +3133,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'public-quick-edit-task-1', - }); + })) render(); const sourceImage = await screen.findByAltText('画布图片:公开拼图素材'); @@ -3041,7 +3178,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('opens generated image info from the corner button and creates a real right-side edit result', async () => { - generateEditorImageMock.mockResolvedValueOnce({ + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', width: 1024, height: 1024, @@ -3051,8 +3188,8 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-real-task-2', - }); - generateEditorImageMock.mockResolvedValueOnce({ + })) + generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,ZWRhd24tcmVkcmF3', width: 1024, height: 1024, @@ -3062,7 +3199,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-real-redraw-1', - }); + })) render(); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 52a820a13..9093777a0 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + canvasDisplayScaleToViewportScale, + canvasDisplayViewportToViewport, CANVAS_WORLD_ORIGIN, createLayerFromAsset, + formatCanvasDisplayScalePercent, hydrateLayer, normalizeAssetLibrary, normalizeCanvasBackgroundHex, @@ -10,6 +13,8 @@ import { serializeCanvasLayout, serializeLayer, splitCanvasLayoutItems, + viewportScaleToCanvasDisplayScale, + viewportToCanvasDisplayViewport, } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, @@ -18,6 +23,23 @@ import type { } from './ImageCanvasEditorTypes'; describe('ImageCanvasEditorModel', () => { + it('maps viewport scale to the user-facing canvas zoom scale', () => { + expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25); + expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5); + expect(canvasDisplayScaleToViewportScale(2)).toBe(1); + expect(viewportScaleToCanvasDisplayScale(0.25)).toBe(0.5); + expect(viewportScaleToCanvasDisplayScale(0.5)).toBe(1); + expect(viewportScaleToCanvasDisplayScale(1)).toBe(2); + expect(formatCanvasDisplayScalePercent(0.5)).toBe('100%'); + expect(formatCanvasDisplayScalePercent(Number.NaN)).toBe('100%'); + expect( + viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }), + ).toEqual({ x: 10, y: 20, scale: 0.5 }); + expect( + canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }), + ).toEqual({ x: 10, y: 20, scale: 1 }); + }); + it('normalizes valid canvas background hex values and rejects invalid input', () => { expect(normalizeCanvasBackgroundHex(' #ABC ')).toBe('#aabbcc'); expect(normalizeCanvasBackgroundHex('#f8fafc')).toBe('#f8fafc'); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 17e7dcd7c..37642fdef 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -31,6 +31,7 @@ export const CANVAS_WORLD_SIZE = 12000; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; export const MIN_SCALE = 0.24; export const MAX_SCALE = 3.2; +export const CANVAS_DISPLAY_SCALE_BASE = 0.5; export const TOOLBAR_HALF_WIDTH = 132; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const SNAP_THRESHOLD_SCREEN_PX = 18; @@ -77,6 +78,39 @@ export function formatPercent(value: number) { return `${Math.round(value * 100)}%`; } +export function formatCanvasDisplayScalePercent(scale: number) { + const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE; + return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE); +} + +export function canvasDisplayScaleToViewportScale(displayScale: number) { + const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1; + return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE; +} + +export function viewportScaleToCanvasDisplayScale(scale: number) { + const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE; + return safeScale / CANVAS_DISPLAY_SCALE_BASE; +} + +export function viewportToCanvasDisplayViewport( + viewport: CanvasViewport, +): CanvasViewport { + return { + ...viewport, + scale: viewportScaleToCanvasDisplayScale(viewport.scale), + }; +} + +export function canvasDisplayViewportToViewport( + viewport: CanvasViewport, +): CanvasViewport { + return { + ...viewport, + scale: canvasDisplayScaleToViewportScale(viewport.scale), + }; +} + export function formatImageSizeValue(width: number, height: number) { const safeWidth = Math.max(1, Math.round(width || 1024)); const safeHeight = Math.max(1, Math.round(height || 1024)); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 460d90e81..126d2a053 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1093,7 +1093,7 @@ describe('ImageCanvasEditorView', () => { fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' })); expect( - screen.getByRole('button', { name: /当前缩放比例 \d+%/u }), + screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); @@ -1216,6 +1216,17 @@ describe('ImageCanvasEditorView', () => { expect(ctrlWheelEvent.defaultPrevented).toBe(true); }); + it('renders 100 percent zoom with the previous 50 percent visual scale', () => { + render(); + + expect( + screen.getByRole('button', { name: '当前缩放比例 100%' }), + ).toBeTruthy(); + expect( + (screen.getByTestId('image-canvas-world') as HTMLElement).style.transform, + ).toBe('translate(0px, 0px) scale(0.5)'); + }); + it('selects multiple canvas layers with shift click', async () => { render(); @@ -1497,8 +1508,8 @@ describe('ImageCanvasEditorView', () => { }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 21, - clientX: -60, - clientY: 180, + clientX: 30, + clientY: 150, }); expect( diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 48b2d330a..e1c88c5a4 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -12,6 +12,7 @@ import { createEditorAsset, createEditorProjectResource, type EditorAssetSnapshot, + loadEditorGenerationPricing, } from '../../services/image-editor/editorProjectClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; @@ -33,6 +34,7 @@ import type { QuickEditPanelState, } from './ImageCanvasEditorTypes'; import { getEditorUploadAccept } from './ImageCanvasFileModel'; +import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel'; import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useCanvasHistory } from './useCanvasHistory'; @@ -57,6 +59,7 @@ import { export function ImageCanvasEditorView() { const authUi = useAuthUi(); + const [, setGenerationPricingVersion] = useState(0); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(null); @@ -78,6 +81,24 @@ export function ImageCanvasEditorView() { const publicationReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); const selectedLayerIdRef = useRef(null); + + useEffect(() => { + let isMounted = true; + void loadEditorGenerationPricing() + .then((pricing) => { + if (!isMounted) { + return; + } + applyEditorGenerationPricingConfig(pricing); + setGenerationPricingVersion((version) => version + 1); + }) + .catch(() => { + // 中文注释:价格接口异常时保留前端内置兜底,不阻断画板使用。 + }); + return () => { + isMounted = false; + }; + }, []); const selectedLayerIdsRef = useRef([]); const setQuickEditPanelRef = useRef< Dispatch> diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index 1f4d0f55c..196c3100c 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -510,6 +510,23 @@ describe('ImageCanvasGenerationComposerView', () => { const paramsPanel = screen.getByRole('menu', { name: '视频参数选项', }); + const ratioChoice = within(paramsPanel).getByRole('button', { + name: '比例 16:9', + }); + expect(ratioChoice.className).toContain( + 'image-canvas-editor__option-popover-choice--ratio', + ); + expect( + ratioChoice + .querySelector('.image-canvas-editor__ratio-wireframe') + ?.getAttribute('data-ratio'), + ).toBe('16:9'); + expect( + within(paramsPanel) + .getByRole('button', { name: '比例 21:9' }) + .querySelector('.image-canvas-editor__ratio-wireframe') + ?.getAttribute('data-ratio'), + ).toBe('21:9'); fireEvent.change(within(paramsPanel).getByRole('slider', { name: '视频时长' }), { target: { value: '5' }, }); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 3ab848b96..7d1d35ca4 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -430,11 +430,12 @@ function ImageCanvasVideoGenerationComposerView({ 比例 -
+
{EDITOR_VIDEO_ASPECT_RATIO_OPTIONS.map((option) => ( @@ -443,7 +444,12 @@ function ImageCanvasVideoGenerationComposerView({ }) } > - {option} + ))}
diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx index 4c9f446a5..2f562aabe 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx @@ -32,7 +32,10 @@ function ImageOptionsHarness({ setGenerateDialog={setDialog} includeDimensions={includeDimensions} onRememberImageModel={onRememberImageModel} - cost={calculateEditorImageGenerationPrice({ model: dialog.imageModel })} + cost={calculateEditorImageGenerationPrice({ + model: dialog.imageModel, + imageSize: dialog.imageSize, + })} submitLabel="生成" submitAriaLabel="生成角色形象" /> diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index abe7fa51b..c5fe87e27 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { @@ -7,6 +7,7 @@ import type { } from './ImageCanvasEditorTypes'; import { BACKGROUND_MUSIC_MODEL_SUNO, + applyEditorGenerationPricingConfig, buildCharacterGenerationInputs, buildEditGenerationInputs, buildIconGenerationInputs, @@ -30,12 +31,11 @@ import { DEFAULT_SOUND_EFFECT_MODEL, DEFAULT_VIDEO_MODEL, EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG, - EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG, EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG, + EDITOR_MODEL_MUD_POINT_CONFIG, EDITOR_IMAGE_MODEL_OPTIONS, EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG, EDITOR_SOUND_EFFECT_MODEL_OPTIONS, - EDITOR_SPEC_MODEL_MUD_POINT_CONFIG, EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG, EDITOR_VIDEO_MODEL_OPTIONS, getGenerationFrameAriaLabel, @@ -52,26 +52,30 @@ import { } from './ImageCanvasGenerationModel'; describe('ImageCanvasGenerationModel', () => { + afterEach(() => { + applyEditorGenerationPricingConfig({ + models: EDITOR_MODEL_MUD_POINT_CONFIG, + }); + }); + it('为所有编辑器生成模型提供显式定价配置', () => { expect( EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter( - (model) => !(model in EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG), + (model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG), ), ).toEqual([]); expect( EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value).filter( - (model) => !(model in EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG), + (model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG), ), ).toEqual([]); expect( EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value).filter( - (model) => !(model in EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG), + (model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG), ), ).toEqual([]); - expect(EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]).toBe(5); - expect( - EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL], - ).toBeTruthy(); + expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(40); + expect(EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]).toBeTruthy(); expect( EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO], ).toBe(5); @@ -84,23 +88,25 @@ describe('ImageCanvasGenerationModel', () => { it('所有生成按钮价格都从模型定价配置推导', () => { expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe( - EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL], + EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'], ); - expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(20); + expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(20); + expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(40); expect( calculateEditorImageGenerationPrice({ kind: 'character', model: IMAGE_MODEL_GPT_IMAGE_2, + imageSize: '1K', }), - ).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]); + ).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']); expect(calculateEditorIconSpritesheetPrice(DEFAULT_IMAGE_MODEL)).toBe( - EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL], + EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'], ); - expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe( - EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2], + expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe( + EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'], ); expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe( - EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2], + EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'], ); expect(calculateEditorVideoPrice(DEFAULT_VIDEO_MODEL, '720p', 5)).toBe( EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL]['720p'] * 5, @@ -119,9 +125,7 @@ describe('ImageCanvasGenerationModel', () => { ); expect(calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6)) .toBe( - EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[ - CHARACTER_ANIMATION_MODEL - ]['720p'] * 6, + EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]['720p'] * 6, ); expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe( EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL], @@ -131,6 +135,46 @@ describe('ImageCanvasGenerationModel', () => { ); }); + it('支持用后端下发的模型定价覆盖按钮泥点', () => { + applyEditorGenerationPricingConfig({ + models: { + ...EDITOR_MODEL_MUD_POINT_CONFIG, + [IMAGE_MODEL_GPT_IMAGE_2]: { + unit: 'perGeneration', + prices: { + '1K': 29, + '2K': 58, + }, + }, + [VIDEO_MODEL_SEEDANCE_2]: { + unit: 'perSecond', + prices: { + ...EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2], + '720p': 26, + }, + }, + [DEFAULT_SOUND_EFFECT_MODEL]: { + unit: 'perGeneration', + price: 15, + }, + [BACKGROUND_MUSIC_MODEL_SUNO]: { + unit: 'perGeneration', + price: 9, + }, + }, + }); + + expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(29); + expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(58); + expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '720p', 5)).toBe( + 130, + ); + expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(15); + expect(calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO)).toBe( + 9, + ); + }); + it('builds user-facing generation input snapshots instead of backend prompts', () => { expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({ fields: [{ title: '生成提示词', value: '一张明亮主视觉' }], diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 7b4b3b6a8..c595b7041 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -1,4 +1,5 @@ import { ApiClientError } from '../../services/apiClient'; +import type { EditorGenerationPricingConfig } from '../../services/image-editor/editorProjectClient'; import type { EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, @@ -22,24 +23,18 @@ import { type PublicationMaterialsWorkflow, } from './ImageCanvasPublicationMaterialsModel'; -// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。 -export const EDITOR_GENERATION_MUD_POINT_CONFIG = { - image: 12, - spec: 5, - character: 12, - icon: 12, - uiDesign: 12, - videoPerSecond: { - '480p': 10, - '720p': 20, - }, - characterAnimationPerSecond: { - '480p': 10, - '720p': 20, - }, - soundEffect: 10, - backgroundMusic: 5, -} as const; +// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名模型定价语义,前端仅作为展示兜底。 +export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; +export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; +export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2; +const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([ + IMAGE_MODEL_NANOBANANA2, + 'nanobanana2', + 'nano-banana', +]); +export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; +export const SPEC_GENERATION_ASPECT_RATIO = '16:9'; +export const SPEC_GENERATION_IMAGE_SIZE = '2K'; export const SPEC_GENERATION_SIZE = '2048x1152'; export const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 }; export const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; @@ -73,27 +68,19 @@ export const PUBLICATION_FRAME_DISPLAY_SIZE: Record< 'publication-detail-gallery': { width: 360, height: 640 }, 'publication-promo-poster': { width: 560, height: 315 }, }; -export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; -export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; -export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2; -const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([ - IMAGE_MODEL_NANOBANANA2, - 'nanobanana2', - 'nano-banana', -]); -export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = { - [IMAGE_MODEL_NANOBANANA2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image, - // 中文注释:gpt-image-2 作为更高成本图片模型独立计费,避免切换模型后按钮价格不变。 - [IMAGE_MODEL_GPT_IMAGE_2]: 20, -} as const; -export const EDITOR_SPEC_MODEL_MUD_POINT_CONFIG = { - [IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.spec, + [IMAGE_MODEL_NANOBANANA2]: { + '0.5K': 8, + '1K': 12, + '2K': 24, + }, + [IMAGE_MODEL_GPT_IMAGE_2]: { + '1K': 20, + '2K': 40, + }, } as const; export const SPEC_GENERATION_COST = - EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL]; -export const SPEC_GENERATION_ASPECT_RATIO = '16:9'; -export const SPEC_GENERATION_IMAGE_SIZE = '2K'; + EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][SPEC_GENERATION_IMAGE_SIZE]; export const ICON_DESCRIPTION_LIMIT = 100; export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = { gameName: '', @@ -322,9 +309,9 @@ export const SOUND_EFFECT_DURATION_OPTIONS = Array.from( export const BACKGROUND_MUSIC_MODEL_SUNO = 'chirp-v5'; export const DEFAULT_BACKGROUND_MUSIC_MODEL = BACKGROUND_MUSIC_MODEL_SUNO; const EDITOR_VIDEO_RESOLUTION_RATE_CONFIG = { - '480p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['480p'], - '720p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'], - '1080p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'] * 2, + '480p': 10, + '720p': 20, + '1080p': 40, } as const; const EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG = { '480p': 12, @@ -346,22 +333,63 @@ export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = { [VIDEO_MODEL_SEEDANCE_2]: EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG, [VIDEO_MODEL_KLING_3]: EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG, [VIDEO_MODEL_KLING_3_OMNI]: EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG, - // 中文注释:前端不展示 Veo 入口,但类型与后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。 + // 中文注释:前端不展示 Veo 入口,但客户端类型和后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。 [VIDEO_MODEL_VEO_3_1]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG, [VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG, } as const; -export const EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG = { - [CHARACTER_ANIMATION_MODEL]: { - '480p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['480p'], - '720p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['720p'], - }, -} as const; export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = { - [SOUND_EFFECT_MODEL_VIDU]: EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect, + [SOUND_EFFECT_MODEL_VIDU]: 10, } as const; export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = { - [BACKGROUND_MUSIC_MODEL_SUNO]: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic, + [BACKGROUND_MUSIC_MODEL_SUNO]: 5, } as const; +export const EDITOR_MODEL_MUD_POINT_CONFIG = { + [IMAGE_MODEL_NANOBANANA2]: { + unit: 'perGeneration', + prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_NANOBANANA2], + }, + [IMAGE_MODEL_GPT_IMAGE_2]: { + unit: 'perGeneration', + prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2], + }, + [VIDEO_MODEL_SEEDANCE_2_FAST]: { + unit: 'perSecond', + prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2_FAST], + }, + [VIDEO_MODEL_SEEDANCE_2]: { + unit: 'perSecond', + prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2], + }, + [VIDEO_MODEL_KLING_3]: { + unit: 'perSecond', + prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3], + }, + [VIDEO_MODEL_KLING_3_OMNI]: { + unit: 'perSecond', + prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3_OMNI], + }, + [VIDEO_MODEL_VEO_3_1]: { + unit: 'perSecond', + prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1], + }, + [VIDEO_MODEL_VEO_3_1_FAST]: { + unit: 'perSecond', + prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1_FAST], + }, + [SOUND_EFFECT_MODEL_VIDU]: { + unit: 'perGeneration', + price: EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[SOUND_EFFECT_MODEL_VIDU], + }, + [BACKGROUND_MUSIC_MODEL_SUNO]: { + unit: 'perGeneration', + price: EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[ + BACKGROUND_MUSIC_MODEL_SUNO + ], + }, +} satisfies EditorGenerationPricingConfig['models']; +let runtimeEditorGenerationPricingConfig: EditorGenerationPricingConfig = { + models: cloneModelPricing(EDITOR_MODEL_MUD_POINT_CONFIG), +}; export const EDITOR_SOUND_EFFECT_MODEL_OPTIONS = [ { label: 'Vidu', value: SOUND_EFFECT_MODEL_VIDU }, ] as const; @@ -653,17 +681,73 @@ export function formatLayerImageType(layer: CanvasLayer) { return isGeneratedLayer(layer) ? '生成图片' : '上传图片'; } +function normalizeImagePriceSize( + model: string, + imageSize: string | null | undefined, +) { + const normalizedImageSize = getSafeImageSizeValue(imageSize); + if (model === IMAGE_MODEL_NANOBANANA2 && normalizedImageSize === '0.5K') { + return '0.5K'; + } + if (normalizedImageSize === '2K') { + return '2K'; + } + return '1K'; +} + +function getModelPrices(model: string | null | undefined) { + const normalizedModel = model?.trim() || ''; + return normalizedModel + ? runtimeEditorGenerationPricingConfig.models[normalizedModel] + : undefined; +} + +function readModelTierPrice({ + model, + fallbackModel, + tier, + fallbackTier, +}: { + model: string; + fallbackModel: string; + tier: string; + fallbackTier: string; +}) { + const modelPricing = getModelPrices(model) ?? getModelPrices(fallbackModel); + const fallbackPricing = getModelPrices(fallbackModel); + return ( + modelPricing?.prices?.[tier] ?? + modelPricing?.prices?.[fallbackTier] ?? + modelPricing?.price ?? + fallbackPricing?.prices?.[tier] ?? + fallbackPricing?.prices?.[fallbackTier] ?? + fallbackPricing?.price ?? + 0 + ); +} + +function readModelFlatPrice(model: string, fallbackModel: string) { + return ( + getModelPrices(model)?.price ?? + getModelPrices(fallbackModel)?.price ?? + 0 + ); +} + export function calculateCharacterAnimationPrice( model: string | null | undefined, resolution: EditorCharacterAnimationResolution, durationSeconds: number, ) { const normalizedModel = (model ?? CHARACTER_ANIMATION_MODEL).trim(); - const modelRates = - EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[ - normalizedModel as keyof typeof EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG - ] ?? EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]; - return modelRates[resolution] * durationSeconds; + return ( + readModelTierPrice({ + model: normalizedModel, + fallbackModel: CHARACTER_ANIMATION_MODEL, + tier: resolution, + fallbackTier: '480p', + }) * durationSeconds + ); } export function calculateEditorVideoPrice( @@ -672,83 +756,100 @@ export function calculateEditorVideoPrice( durationSeconds: number, ) { const normalizedModel = (model ?? DEFAULT_VIDEO_MODEL).trim(); - const modelRates = - EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[ - normalizedModel as keyof typeof EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG - ] ?? EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL]; - return modelRates[resolution] * durationSeconds; + return ( + readModelTierPrice({ + model: normalizedModel, + fallbackModel: DEFAULT_VIDEO_MODEL, + tier: resolution, + fallbackTier: '480p', + }) * durationSeconds + ); } export function calculateEditorImageModelPrice( model: string | null | undefined, + imageSize: string | null | undefined = '1K', ) { const normalizedModel = normalizeEditorImageModel(model); - return ( - EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[ - normalizedModel as keyof typeof EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG - ] ?? EDITOR_GENERATION_MUD_POINT_CONFIG.image - ); + const normalizedSize = normalizeImagePriceSize(normalizedModel, imageSize); + return readModelTierPrice({ + model: normalizedModel, + fallbackModel: DEFAULT_IMAGE_MODEL, + tier: normalizedSize, + fallbackTier: '1K', + }); } export function calculateEditorSpecGenerationPrice( model: string | null | undefined = SPEC_GENERATION_MODEL, ) { - const normalizedModel = (model ?? SPEC_GENERATION_MODEL).trim(); - return ( - EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[ - normalizedModel as keyof typeof EDITOR_SPEC_MODEL_MUD_POINT_CONFIG - ] ?? EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL] - ); + return calculateEditorImageModelPrice(model ?? SPEC_GENERATION_MODEL, SPEC_GENERATION_IMAGE_SIZE); } export function calculateEditorImageGenerationPrice({ kind, model, + imageSize, }: { kind?: string | null; model?: string | null; + imageSize?: string | null; } = {}) { const normalizedKind = kind?.trim(); if (normalizedKind === 'spec') { return calculateEditorSpecGenerationPrice(model); } - return calculateEditorImageModelPrice(model); + return calculateEditorImageModelPrice(model, imageSize); } export function calculateEditorIconSpritesheetPrice( model: string | null | undefined, + imageSize: string | null | undefined = '1K', ) { - return calculateEditorImageModelPrice(model); + return calculateEditorImageModelPrice(model, imageSize); } export function calculateEditorUiDesignPrice( model: string | null | undefined, + imageSize: string | null | undefined = '1K', ) { - return calculateEditorImageModelPrice(model); + return calculateEditorImageModelPrice(model, imageSize); } export function calculateEditorSoundEffectPrice( model: string | null | undefined, ) { const normalizedModel = (model ?? DEFAULT_SOUND_EFFECT_MODEL).trim(); - return ( - EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[ - normalizedModel as keyof typeof EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG - ] ?? EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL] - ); + return readModelFlatPrice(normalizedModel, DEFAULT_SOUND_EFFECT_MODEL); } export function calculateEditorBackgroundMusicPrice( model: string | null | undefined = DEFAULT_BACKGROUND_MUSIC_MODEL, ) { const normalizedModel = (model ?? DEFAULT_BACKGROUND_MUSIC_MODEL).trim(); - return ( - EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[ - normalizedModel as keyof typeof EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG - ] ?? - EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[ - DEFAULT_BACKGROUND_MUSIC_MODEL - ] + return readModelFlatPrice(normalizedModel, DEFAULT_BACKGROUND_MUSIC_MODEL); +} + +export function applyEditorGenerationPricingConfig( + pricing: EditorGenerationPricingConfig, +) { + runtimeEditorGenerationPricingConfig = { + models: cloneModelPricing(pricing.models), + }; +} + +function cloneModelPricing( + value: T, +): EditorGenerationPricingConfig['models'] { + return Object.fromEntries( + Object.entries(value).map(([model, pricing]) => [ + model, + { + unit: pricing.unit, + ...(typeof pricing.price === 'number' ? { price: pricing.price } : {}), + ...(pricing.prices ? { prices: { ...pricing.prices } } : {}), + }, + ]), ); } diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index bbba26d12..549960682 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -209,6 +209,12 @@ describe('ImageCanvasGenerationSubmissionModel', () => { title: '图标规范 2', }, }); + expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty( + 'referenceImageSrcs', + ); + expect(plan.kind === 'image' ? plan.input.prompt : '').not.toContain( + '参考图生成规范', + ); }); it('builds character plans with references and remembered model', () => { @@ -536,7 +542,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => { model: 'gpt-image-2', aspectRatio: '3:2', imageSize: '2K', - priceMudPoints: calculateEditorIconSpritesheetPrice('gpt-image-2'), + priceMudPoints: calculateEditorIconSpritesheetPrice( + 'gpt-image-2', + '2K', + ), }, generationInputs: { fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }], diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index f708b9373..fa6289f8b 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -516,7 +516,10 @@ export function buildIconSpritesheetGenerationSubmissionPlan( model: rememberImageModel, aspectRatio: dialog.aspectRatio ?? '1:1', imageSize: dialog.imageSize ?? '1K', - priceMudPoints: calculateEditorIconSpritesheetPrice(rememberImageModel), + priceMudPoints: calculateEditorIconSpritesheetPrice( + rememberImageModel, + dialog.imageSize ?? '1K', + ), }, generationInputs: buildIconGenerationInputs( iconDescriptions, diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx index 42c5d035a..c1b925994 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx @@ -200,7 +200,10 @@ export function ImageCanvasIconSpritesheetComposerView({ includeDimensions onRememberImageModel={onRememberImageModel} optionLabelPrefix="生成图片" - cost={calculateEditorIconSpritesheetPrice(dialog.imageModel)} + cost={calculateEditorIconSpritesheetPrice( + dialog.imageModel, + dialog.imageSize, + )} submitLabel="生成" submitAriaLabel="生成" renderEditorPortal={renderEditorPortal} diff --git a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx index f3dc3c4fa..617701ddc 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx @@ -80,16 +80,25 @@ describe('ImageCanvasPanelDockView', () => { it('renders zoom and background settings with callback wiring', () => { const props = renderPanelDock({ + viewport: { x: 0, y: 0, scale: 0.5 }, isZoomMenuOpen: true, isBackgroundSettingsOpen: true, }); + expect( + screen.getByRole('button', { name: '当前缩放比例 100%' }), + ).toBeTruthy(); + fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' })); + fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' })); + fireEvent.click(screen.getByRole('menuitem', { name: '缩放至200%' })); fireEvent.click(screen.getByRole('menuitem', { name: '显示画布所有元素' })); + expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(0.25); expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(0.5); + expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(1); expect(props.onFitLayers).toHaveBeenCalledTimes(1); - expect(props.onCloseZoomMenu).toHaveBeenCalledTimes(2); + expect(props.onCloseZoomMenu).toHaveBeenCalledTimes(4); const panel = screen.getByRole('dialog', { name: '画布背景设置' }); diff --git a/src/components/image-editor/ImageCanvasPanelDockView.tsx b/src/components/image-editor/ImageCanvasPanelDockView.tsx index b212ab8fd..3b9a9684d 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.tsx @@ -15,9 +15,10 @@ import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { StageMinimapModel } from './ImageCanvasInteractionModel'; import { + canvasDisplayScaleToViewportScale, CANVAS_BACKGROUND_OPTIONS, DEFAULT_CANVAS_BACKGROUND_COLOR, - formatPercent, + formatCanvasDisplayScalePercent, } from './ImageCanvasEditorModel'; import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes'; @@ -103,12 +104,12 @@ export function ImageCanvasPanelDockView({
- {formatPercent(viewport.scale)} + {formatCanvasDisplayScalePercent(viewport.scale)} {isZoomMenuOpen ? ( @@ -139,16 +140,18 @@ export function ImageCanvasPanelDockView({ > 显示画布所有元素 - {[0.5, 1, 2].map((scale) => ( + {[0.5, 1, 2].map((displayScale) => ( { - onUpdateScaleFromCenter(scale); + onUpdateScaleFromCenter( + canvasDisplayScaleToViewportScale(displayScale), + ); onCloseZoomMenu(); }} > - 缩放至{Math.round(scale * 100)}% + 缩放至{Math.round(displayScale * 100)}% ))} diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx index 98e329cb2..42dd4581f 100644 --- a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx @@ -303,7 +303,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({ includeDimensions={false} onRememberImageModel={onRememberImageModel} optionLabelPrefix="宣发素材" - cost={calculateEditorImageModelPrice(dialog.imageModel)} + cost={calculateEditorImageModelPrice(dialog.imageModel, workflow.imageSize)} submitLabel="生成" submitAriaLabel="生成" renderEditorPortal={renderEditorPortal} diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx index 50ab854ac..af95d6bef 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx @@ -448,7 +448,7 @@ export function ImageCanvasQuickEditPanelView({ <> 生成 - {calculateEditorImageModelPrice(panel.model)}泥点 + {calculateEditorImageModelPrice(panel.model, selectedImageSize)}泥点 )} diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index 6c93ebd9a..fab693824 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -88,7 +88,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ), ).toBe(true); const submitButton = screen.getByRole('button', { name: '提交生成规范' }); - expect(submitButton.textContent).toBe('生成5泥点'); + expect(submitButton.textContent).toBe('生成40泥点'); const dimensionsButton = screen.getByRole('button', { name: '生成图片尺寸 16:9·2K', }) as HTMLButtonElement; @@ -232,7 +232,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { const submitButton = screen.getByRole('button', { name: '提交生成规范' }); - expect(submitButton.textContent).toBe('生成5泥点'); + expect(submitButton.textContent).toBe('生成40泥点'); }); it('disables controls while generating and renders failure state', () => { diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index 8860b8a57..be8ff1256 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -359,7 +359,7 @@ export function ImageCanvasSpecGenerationPanelView({ setGenerateDialog={setGenerateDialog ?? (() => undefined)} includeDimensions onRememberImageModel={onRememberImageModel} - cost={calculateEditorUiDesignPrice(dialog.imageModel)} + cost={calculateEditorUiDesignPrice(dialog.imageModel, dialog.imageSize)} submitLabel="生成" submitAriaLabel="生成UI设计图" optionLabelPrefix="生成图片" diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 709a0465f..232cb955a 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -693,6 +693,7 @@ export function ImageCanvasWorldView({ return (
{ }); }); + it('submits icon spec objects without requiring an icon spec reference', async () => { + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ + imageSrc: 'data:image/png;base64,icon-spec', + width: 2048, + height: 1152, + prompt: '图标规范提示词', + }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled(); + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'spec', + assetKind: 'icon-spec', + assetLabel: '图标规范 1', + size: '2048x1152', + aspectRatio: '16:9', + imageSize: '2K', + }), + ); + }); + const payload = generateEditorImageMock.mock.calls[0]?.[0] ?? {}; + expect(payload).not.toHaveProperty('referenceImageSrcs'); + expect(payload.prompt).toContain('背包整理玩法'); + expect(payload.prompt).not.toContain('参考图生成规范'); + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-generated-1:图标规范 1:-:icon-spec', + ); + }); + expect(screen.getByTestId('dialog').textContent).not.toContain( + '请选择图标规范', + ); + }); + it('validates icon submission before calling the API', async () => { render( reference.label) .join('|') ?? '-'} + + {activeDialog?.characterSpecReference?.label ?? '-'} + + + {activeDialog?.iconSpecReference?.label ?? '-'} + + + {activeDialog?.uiDesignSpecReference?.label ?? '-'} + {workflow.quickEditPanel ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` @@ -210,9 +214,18 @@ function GenerationWorkflowHarness({ + + + @@ -264,7 +277,10 @@ function GenerationWorkflowHarness({ > 打开快速编辑 - + + + + + + + + + @@ -504,6 +539,32 @@ function GenerationWorkflowHarness({ > 清理源图状态 + + + + + + {frames.length ? frameIndex + 1 : 0}/{frames.length || 0} + +
+ + ); +} + export function ImageCanvasWorldView({ viewport, snapGuide, @@ -730,14 +846,13 @@ export function ImageCanvasWorldView({ generateDialog.sourceLayerId === layer.id ? '修改中' : null; - const LayerElement = - layer.mediaType === 'audio' || layer.mediaType === 'video' - ? 'div' - : 'button'; + const isMediaContainer = isCanvasMediaContainerLayer(layer); + const LayerElement = isMediaContainer ? 'div' : 'button'; + const metadataMediaLabel = getLayerMetadataMediaLabel(layer); return (
onLayerMouseEnter(layer.id)} onMouseLeave={() => onLayerMouseLeave(layer.id)} @@ -778,6 +891,11 @@ export function ImageCanvasWorldView({ layer={layer} mediaTransform={mediaTransform} /> + ) : layer.mediaType === 'image-sequence' ? ( + ) : ( } style={inverseScaleStyle} onClick={(event) => { diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index 795a98e5a..694ffde12 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -77,10 +77,11 @@ describe('useImageCanvasAssetExportWorkflow', () => { it('exports canvas assets into a zip with metadata and failed image records', async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { - if (url === '/generated-ok.png') { + const pathname = new URL(url, 'http://localhost').pathname; + if (pathname === '/generated-ok.png') { return new Response(new Blob(['generated'], { type: 'image/png' })); } - if (url === '/generated-video.mp4') { + if (pathname === '/generated-video.mp4') { return new Response(new Blob(['video'], { type: 'video/mp4' })); } return new Response(null, { status: 404 }); @@ -144,10 +145,35 @@ describe('useImageCanvasAssetExportWorkflow', () => { sourceType: 'generated', zIndex: 5, }), + createLayer('sequence', { + title: '角色动作', + src: 'data:image/png;base64,c2VxMQ==', + mediaType: 'image-sequence', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + frameIndex: 1, + imageSrc: 'data:image/png;base64,c2VxMQ==', + width: 1024, + height: 1024, + }, + { + frameIndex: 2, + imageSrc: 'data:image/png;base64,c2VxMg==', + width: 1024, + height: 1024, + }, + ], + durationSeconds: 4, + previewVideoPath: + '/generated-character-drafts/editor/preview.mp4', + sourceType: 'generated', + zIndex: 6, + }), createLayer('failed', { title: '失败图', src: '/missing.png', - zIndex: 6, + zIndex: 7, }), ]} />, @@ -165,13 +191,26 @@ describe('useImageCanvasAssetExportWorkflow', () => { expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy(); expect(zip.file('导出项目-画布素材/media/003-生成视频.mp4')).toBeTruthy(); expect(zip.file('导出项目-画布素材/media/004-胜利音效.wav')).toBeTruthy(); - expect(zip.file('导出项目-画布素材/images/005-失败图.png')).toBeNull(); + expect( + zip.file( + '导出项目-画布素材/sequences/005-角色动作/frames/frame-01.png', + ), + ).toBeTruthy(); + expect( + zip.file( + '导出项目-画布素材/sequences/005-角色动作/frames/frame-02.png', + ), + ).toBeTruthy(); + expect( + zip.file('导出项目-画布素材/sequences/005-角色动作/manifest.txt'), + ).toBeTruthy(); + expect(zip.file('导出项目-画布素材/images/006-失败图.png')).toBeNull(); const metadata = JSON.parse( await readZipText(zip, '导出项目-画布素材/metadata.json'), ); expect(metadata.projectId).toBe('project-export'); - expect(metadata.layers).toHaveLength(6); + expect(metadata.layers).toHaveLength(7); expect(metadata.layers[0].file).toBe('images/001-素材 A.png'); expect(metadata.layers[1].file).toBe('images/001-素材 A.png'); expect(metadata.layers[2].mediaType).toBe('image'); @@ -184,8 +223,12 @@ describe('useImageCanvasAssetExportWorkflow', () => { expect(metadata.layers[3].mediaType).toBe('video'); expect(metadata.layers[4].file).toBe('media/004-胜利音效.wav'); expect(metadata.layers[4].mediaType).toBe('audio'); - expect(metadata.layers[5].file).toBeNull(); - expect(metadata.layers[5].exportError).toContain('404'); + expect(metadata.layers[5].file).toBe( + 'sequences/005-角色动作/manifest.txt', + ); + expect(metadata.layers[5].mediaType).toBe('image-sequence'); + expect(metadata.layers[6].file).toBeNull(); + expect(metadata.layers[6].exportError).toContain('404'); expect(metadata.failedImages).toHaveLength(1); expect( await readZipText(zip, '导出项目-画布素材/manifest.txt'), @@ -228,7 +271,9 @@ describe('useImageCanvasAssetExportWorkflow', () => { ]} />, ); - fireEvent.click(screen.getAllByRole('button', { name: '导出单图' }).at(-1)!); + fireEvent.click( + screen.getAllByRole('button', { name: '导出单图' }).at(-1)!, + ); expect(downloadName).toBe('视频导出.webm'); @@ -243,4 +288,69 @@ describe('useImageCanvasAssetExportWorkflow', () => { ); }); }); + + it('downloads character animation layers as a zipped image sequence', async () => { + let exportedBlob: Blob | null = null; + let downloadName = ''; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + exportedBlob = blob; + return 'blob:sequence-export'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + function click(this: HTMLAnchorElement) { + downloadName = this.download; + }, + ); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出单图' })); + + await waitFor(() => { + expect(exportedBlob).toBeTruthy(); + }); + expect(downloadName).toBe('角色动作 跑步-序列帧.zip'); + const zip = await JSZip.loadAsync(exportedBlob!); + expect(zip.file('frames/frame-01.png')).toBeTruthy(); + expect(zip.file('frames/frame-02.png')).toBeTruthy(); + expect(await readZipText(zip, 'manifest.txt')).toContain('类型:序列帧'); + } finally { + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); }); diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index 515c0d4a4..a44b58060 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -10,9 +10,12 @@ import { blobToUint8Array, buildLayerExportMetadata, formatExportDate, + getImageSequenceFrameFileName, getLayerAssetExtensionFromTypeOrSrc, getLayerExportKey, + getLayerImageSequenceFrames, readLayerAssetBlob, + readLayerImageSequenceFrameBlob, sanitizeExportFilePart, } from './ImageCanvasExportModel'; @@ -27,6 +30,92 @@ type UseImageCanvasAssetExportWorkflowOptions = { projectTitle: string; }; +function triggerBrowserDownload(blob: Blob, downloadName: string) { + if ( + typeof URL.createObjectURL !== 'function' || + typeof URL.revokeObjectURL !== 'function' + ) { + return false; + } + const downloadUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = downloadName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(downloadUrl); + return true; +} + +async function buildImageSequenceZip(layer: CanvasLayer) { + const frames = getLayerImageSequenceFrames(layer); + if (!frames.length) { + throw new Error('序列帧为空'); + } + const zip = new JSZip(); + const framesFolder = zip.folder('frames') ?? zip; + const failedFrames: Array<{ + frameIndex: number; + imageSrc: string; + error: string; + }> = []; + let successCount = 0; + + for (const [index, frame] of frames.entries()) { + try { + const blob = await readLayerImageSequenceFrameBlob(layer, frame); + framesFolder.file( + getImageSequenceFrameFileName(frame, index, blob.type), + await blobToUint8Array(blob), + ); + successCount += 1; + } catch (error) { + failedFrames.push({ + frameIndex: frame.frameIndex || index + 1, + imageSrc: frame.imageSrc, + error: error instanceof Error ? error.message : '序列帧读取失败', + }); + } + } + + if (successCount === 0) { + throw new Error('序列帧读取失败'); + } + + zip.file( + 'metadata.json', + JSON.stringify( + { + title: layer.title, + taskId: layer.taskId ?? null, + mediaType: 'image-sequence', + frameCount: frames.length, + exportedFrameCount: successCount, + durationSeconds: layer.durationSeconds ?? null, + previewVideoPath: layer.previewVideoPath ?? null, + failedFrames, + }, + null, + 2, + ), + ); + zip.file( + 'manifest.txt', + [ + `素材:${layer.title}`, + `类型:序列帧`, + `帧数:${successCount}/${frames.length}`, + layer.durationSeconds ? `时长:${layer.durationSeconds}s` : null, + failedFrames.length ? `失败帧数:${failedFrames.length}` : null, + ] + .filter(Boolean) + .join('\n'), + ); + + return zip.generateAsync({ type: 'blob' }); +} + export function useImageCanvasAssetExportWorkflow({ layers, projectId, @@ -40,6 +129,28 @@ export function useImageCanvasAssetExportWorkflow({ if (!layer) { return; } + if (layer.mediaType === 'image-sequence') { + void buildImageSequenceZip(layer) + .then((zipBlob) => { + const downloaded = triggerBrowserDownload( + zipBlob, + `${sanitizeExportFilePart(layer.title, '角色动作')}-序列帧.zip`, + ); + if (!downloaded) { + setAssetExportStatus({ + tone: 'error', + message: '当前浏览器不支持素材下载', + }); + } + }) + .catch(() => { + setAssetExportStatus({ + tone: 'error', + message: '序列帧导出失败', + }); + }); + return; + } const link = document.createElement('a'); link.href = layer.src; const extension = getLayerAssetExtensionFromTypeOrSrc( @@ -101,6 +212,93 @@ export function useImageCanvasAssetExportWorkflow({ : baseFileName; try { + if (layer.mediaType === 'image-sequence') { + const frames = getLayerImageSequenceFrames(layer); + if (!frames.length) { + throw new Error('序列帧为空'); + } + const sequenceFolderName = `sequences/${indexedFileName}`; + const sequenceFolder = + rootFolder.folder(sequenceFolderName) ?? rootFolder; + const framesFolder = + sequenceFolder.folder('frames') ?? sequenceFolder; + const failedFrames: Array<{ + frameIndex: number; + imageSrc: string; + error: string; + }> = []; + let successCount = 0; + + for (const [frameArrayIndex, frame] of frames.entries()) { + try { + const frameBlob = await readLayerImageSequenceFrameBlob( + layer, + frame, + ); + framesFolder.file( + getImageSequenceFrameFileName( + frame, + frameArrayIndex, + frameBlob.type, + ), + await blobToUint8Array(frameBlob), + ); + successCount += 1; + } catch (error) { + failedFrames.push({ + frameIndex: frame.frameIndex || frameArrayIndex + 1, + imageSrc: frame.imageSrc, + error: + error instanceof Error ? error.message : '序列帧读取失败', + }); + } + } + + if (successCount === 0) { + throw new Error('序列帧读取失败'); + } + + sequenceFolder.file( + 'metadata.json', + JSON.stringify( + { + layerId: layer.id, + title: layer.title, + mediaType: 'image-sequence', + taskId: layer.taskId ?? null, + frameCount: frames.length, + exportedFrameCount: successCount, + durationSeconds: layer.durationSeconds ?? null, + previewVideoPath: layer.previewVideoPath ?? null, + failedFrames, + }, + null, + 2, + ), + ); + sequenceFolder.file( + 'manifest.txt', + [ + `素材:${layer.title}`, + `类型:序列帧`, + `帧数:${successCount}/${frames.length}`, + layer.durationSeconds + ? `时长:${layer.durationSeconds}s` + : null, + failedFrames.length ? `失败帧数:${failedFrames.length}` : null, + ] + .filter(Boolean) + .join('\n'), + ); + imageByKey.set(key, { + key, + file: `${sequenceFolderName}/manifest.txt`, + layer, + blob: new Blob([], { type: 'application/zip' }), + }); + continue; + } + const blob = await readLayerAssetBlob(layer); const mediaType = layer.mediaType ?? 'image'; const extension = getLayerAssetExtensionFromTypeOrSrc( @@ -109,7 +307,8 @@ export function useImageCanvasAssetExportWorkflow({ layer.objectKey ?? layer.src, ); const folderName = mediaType === 'image' ? 'images' : 'media'; - const targetFolder = mediaType === 'image' ? imagesFolder : mediaFolder; + const targetFolder = + mediaType === 'image' ? imagesFolder : mediaFolder; const file = `${folderName}/${indexedFileName}.${extension}`; imageByKey.set(key, { key, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index cf4717bde..bbda52258 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -650,10 +650,19 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { initialLayers={[ createLayer({ title: '角色动作', - src: 'https://assets.example.test/action-preview.mp4', - mediaType: 'video', + src: 'generated-character-drafts/editor/actions/frame.png', + mediaType: 'image-sequence', thumbnailSrc: 'generated-character-drafts/editor/actions/frame.png', + imageSequenceFrames: [ + { + frameIndex: 1, + imageSrc: 'generated-character-drafts/editor/actions/frame.png', + width: 1024, + height: 1024, + }, + ], + previewVideoPath: 'https://assets.example.test/action-preview.mp4', assetKind: 'character-animation', model: 'seedance2.0', originalWidth: 1024, @@ -694,7 +703,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(generateEditorVideoMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( - 'layer-character-animation-1:角色动作 快速编辑:resource-source:character-animation:video:/generated-character-drafts/editor/action-preview.mp4:data:image/png;base64,action-frame-edited:4', + 'layer-character-animation-1:角色动作 快速编辑:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited:data:image/png;base64,action-frame-edited:4', ); }); }); @@ -1499,7 +1508,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( - 'layer-character-animation-1:角色动作:resource-source:character-animation:video:/generated-character-drafts/editor/preview.mp4:data:image/png;base64,action-frame:4', + 'layer-character-animation-1:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4', ); }); expect(screen.getByTestId('dialog').textContent).toBe( diff --git a/src/index.css b/src/index.css index 16ba75901..407cf8b38 100644 --- a/src/index.css +++ b/src/index.css @@ -5098,6 +5098,56 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { z-index: 1; } +.image-canvas-editor__layer-image-sequence-frame { + width: 100%; + height: 100%; + object-fit: contain; +} + +.image-canvas-editor__image-sequence-controls { + position: absolute; + left: 0.45rem; + bottom: 0.45rem; + z-index: 4; + display: inline-flex; + align-items: center; + gap: 0.35rem; + max-width: calc(100% - 0.9rem); + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: 999px; + background: rgba(15, 23, 42, 0.72); + padding: 0.18rem 0.45rem 0.18rem 0.2rem; + color: #fff; + box-shadow: 0 12px 26px rgba(15, 23, 42, 0.24); + backdrop-filter: blur(10px); +} + +.image-canvas-editor__image-sequence-toggle { + display: inline-flex; + width: 1.35rem; + height: 1.35rem; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: #1f2937; + cursor: pointer; +} + +.image-canvas-editor__image-sequence-toggle:disabled { + opacity: 0.58; + cursor: default; +} + +.image-canvas-editor__image-sequence-count { + min-width: 2.75rem; + font-size: 0.72rem; + font-weight: 700; + line-height: 1; + white-space: nowrap; +} + .image-canvas-editor__audio-card { position: relative; display: flex; From 7fe4a10d19beffc7c8cb5967bff492601ca82994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Mon, 22 Jun 2026 20:35:54 +0800 Subject: [PATCH 09/15] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E9=A1=B6=E9=83=A8=E6=A0=8F=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收窄画布顶部栏高度和按钮间距 移除未命名画布下方的画布副标题 将画布标题和重命名入口贴近返回按钮 在顶部栏右端展示当前泥点余额 补充画布顶部栏测试和技术方案说明 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 1 + .../ImageCanvasEditorShellView.test.tsx | 2 + .../ImageCanvasEditorView.test.tsx | 50 ++++- .../image-editor/ImageCanvasEditorView.tsx | 68 +++++++ .../ImageCanvasTopbarView.test.tsx | 20 ++ .../image-editor/ImageCanvasTopbarView.tsx | 173 ++++++++++-------- src/index.css | 96 ++++++++-- 7 files changed, 322 insertions(+), 88 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 91471a614..12bee8406 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -9,6 +9,7 @@ - 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 - 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=`。 - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 +- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 0742151e3..2a7c09fac 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -98,6 +98,8 @@ function createTopbarProps(): ImageCanvasTopbarViewProps { isProjectRenameSaving: false, projectRenameError: null, layers: [], + walletBalanceLabel: '0泥点', + isWalletBalanceLoading: false, assetExportStatus: null, isExportingAssets: false, setProjectRenameValue: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 126d2a053..f6fe85590 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -10,7 +10,7 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import JSZip from 'jszip'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError, @@ -38,6 +38,7 @@ const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); +const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< @@ -64,6 +65,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }; }); +vi.mock('../../services/platform-entry/platformProfileClient', () => ({ + getPlatformProfileDashboard: getPlatformProfileDashboardMock, +})); + describe('ImageCanvasEditorView', () => { setupImageCanvasEditorViewTestLifecycle({ generateEditorImageMock, @@ -84,6 +89,19 @@ describe('ImageCanvasEditorView', () => { saveEditorProjectLayoutMock, }); + beforeEach(() => { + getPlatformProfileDashboardMock.mockResolvedValue({ + walletBalance: 1234, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: null, + }); + }); + + afterEach(() => { + getPlatformProfileDashboardMock.mockReset(); + }); + it('loads the project from projectid query before falling back to recent project', async () => { loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-query', @@ -157,6 +175,36 @@ describe('ImageCanvasEditorView', () => { }); }); + it('shows the live mud point balance in the canvas topbar when logged in', async () => { + render( + + + , + ); + + expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy(); + expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({ + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }); + }); + it('opens the login modal immediately when entering the editor while logged out', async () => { const openLoginModal = vi.fn(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 4374aab68..e4e86dee0 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -14,8 +14,10 @@ import { type EditorAssetSnapshot, loadEditorGenerationPricing, } from '../../services/image-editor/editorProjectClient'; +import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; +import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation'; import { canvasAssetKindOrNull, generationInputsOrNull, @@ -66,6 +68,10 @@ import { export function ImageCanvasEditorView() { const authUi = useAuthUi(); const [, setGenerationPricingVersion] = useState(0); + const [walletBalanceLabel, setWalletBalanceLabel] = useState( + null, + ); + const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(null); @@ -175,6 +181,66 @@ export function ImageCanvasEditorView() { window.location.reload(); }); }, [authUi]); + useEffect(() => { + if (!authUi?.canAccessProtectedData || !authUi.user) { + setWalletBalanceLabel(null); + setIsWalletBalanceLoading(false); + return; + } + + let isMounted = true; + let requestId = 0; + + const refreshWalletBalance = () => { + const currentRequestId = requestId + 1; + requestId = currentRequestId; + setIsWalletBalanceLoading(true); + void getPlatformProfileDashboard({ + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }) + .then((dashboard) => { + if (!isMounted || currentRequestId !== requestId) { + return; + } + setWalletBalanceLabel(`${formatDashboardCount(dashboard.walletBalance)}泥点`); + }) + .catch(() => { + if (!isMounted || currentRequestId !== requestId) { + return; + } + setWalletBalanceLabel(null); + }) + .finally(() => { + if (!isMounted || currentRequestId !== requestId) { + return; + } + setIsWalletBalanceLoading(false); + }); + }; + + refreshWalletBalance(); + + const handleWindowFocus = () => { + refreshWalletBalance(); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + refreshWalletBalance(); + } + }; + + window.addEventListener('focus', handleWindowFocus); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + isMounted = false; + window.removeEventListener('focus', handleWindowFocus); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [authUi?.canAccessProtectedData, authUi?.user?.id]); const { projectTitle, setProjectTitle, @@ -1179,6 +1245,8 @@ export function ImageCanvasEditorView() { isProjectRenameSaving, projectRenameError, layers, + walletBalanceLabel, + isWalletBalanceLoading, assetExportStatus, isExportingAssets, setProjectRenameValue, diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 2648957eb..96a75e133 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -38,6 +38,8 @@ function renderTopbar( isProjectRenameSaving: false, projectRenameError: null, layers: [], + walletBalanceLabel: '1,234泥点', + isWalletBalanceLoading: false, assetExportStatus: null, isExportingAssets: false, setProjectRenameValue: vi.fn(), @@ -60,6 +62,7 @@ describe('ImageCanvasTopbarView', () => { const props = renderTopbar(); expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy(); + expect(screen.queryByText('画布')).toBeNull(); expect( screen.getByRole('link', { name: '返回项目页面' }).getAttribute('href'), ).toBe('/project'); @@ -73,6 +76,19 @@ describe('ImageCanvasTopbarView', () => { expect(props.onOpenShortcuts).toHaveBeenCalledTimes(1); }); + it('shows the current mud point balance in the topbar', () => { + renderTopbar({ + walletBalanceLabel: '1.2万泥点', + }); + + const walletChip = screen.getByLabelText('泥点余额 1.2万泥点'); + + expect(walletChip.textContent).toBe('1.2万泥点'); + expect(walletChip.querySelector('img')?.getAttribute('src')).toBe( + '/creation-home/topbar-wallet.png', + ); + }); + it('submits, resets, and cancels project rename edits', () => { const props = renderTopbar({ isRenamingProject: true, @@ -105,6 +121,8 @@ describe('ImageCanvasTopbarView', () => { isProjectRenameSaving={false} projectRenameError={null} layers={[]} + walletBalanceLabel="0泥点" + isWalletBalanceLoading={false} assetExportStatus={null} isExportingAssets={false} setProjectRenameValue={vi.fn()} @@ -131,6 +149,8 @@ describe('ImageCanvasTopbarView', () => { isProjectRenameSaving={false} projectRenameError={null} layers={[createLayer()]} + walletBalanceLabel="0泥点" + isWalletBalanceLoading={false} assetExportStatus={{ tone: 'success', message: '画布素材已导出', diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 3e02ab576..a75d9ec8e 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -21,6 +21,8 @@ export type ImageCanvasTopbarViewProps = { isProjectRenameSaving: boolean; projectRenameError: string | null; layers: CanvasLayer[]; + walletBalanceLabel: string | null; + isWalletBalanceLoading: boolean; assetExportStatus: AssetExportStatus | null; isExportingAssets: boolean; setProjectRenameValue: (value: string) => void; @@ -40,6 +42,8 @@ export function ImageCanvasTopbarView({ isProjectRenameSaving, projectRenameError, layers, + walletBalanceLabel, + isWalletBalanceLoading, assetExportStatus, isExportingAssets, setProjectRenameValue, @@ -53,86 +57,91 @@ export function ImageCanvasTopbarView({ const hasExportableLayer = layers.some( (layer) => layer.src.trim().length > 0, ); + const walletDisplayLabel = walletBalanceLabel ?? '--泥点'; + const walletAriaLabel = walletBalanceLabel + ? `泥点余额 ${walletBalanceLabel}` + : '泥点余额读取中'; return (
- - -
- {isRenamingProject ? ( -
{ - event.preventDefault(); - submitProjectRename(projectId); - }} - > - { - setProjectRenameValue(event.target.value); - resetProjectRenameError(); +
+ + +
+ {isRenamingProject ? ( + { + event.preventDefault(); + submitProjectRename(projectId); }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.preventDefault(); - cancelProjectRename(); - } - }} - /> - - - {projectRenameError ? ( - - {projectRenameError} - - ) : null} - - ) : ( -
- - -
- )} - 画布 + { + setProjectRenameValue(event.target.value); + resetProjectRenameError(); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelProjectRename(); + } + }} + /> + + + {projectRenameError ? ( + + {projectRenameError} + + ) : null} + + ) : ( +
+ + +
+ )} +
) : null} +
+ + {walletDisplayLabel} +
); diff --git a/src/index.css b/src/index.css index 407cf8b38..b027c8b5c 100644 --- a/src/index.css +++ b/src/index.css @@ -4775,13 +4775,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__topbar { display: flex; - min-height: 3.4rem; + min-height: 2.86rem; align-items: center; justify-content: space-between; - gap: 0.75rem; + gap: 0.55rem; border-bottom: 1px solid #d9dee8; background: #ffffff; - padding: 0.55rem 0.7rem; + padding: 0.36rem 0.58rem; +} + +.image-canvas-editor__topbar-project { + display: inline-flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 0.34rem; } .image-canvas-editor__topbar-actions { @@ -4790,7 +4798,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { flex: 0 1 auto; align-items: center; justify-content: flex-end; - gap: 0.45rem; + gap: 0.36rem; } .image-canvas-editor__shortcut-modal { @@ -4893,8 +4901,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__project-back-button { - width: 2.25rem; - height: 2.25rem; + width: 2.04rem; + height: 2.04rem; flex: 0 0 auto; border-radius: 0.45rem; text-decoration: none; @@ -4904,12 +4912,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; min-width: 0; align-items: center; - gap: 0.25rem; + gap: 0.18rem; } .image-canvas-editor__project-title-button { min-width: 0; - max-width: min(24rem, 42vw); + max-width: min(22rem, 38vw); border: 0; background: transparent; padding: 0; @@ -4939,13 +4947,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__project-title-form { display: inline-flex; min-width: 0; - max-width: min(34rem, 58vw); + max-width: min(32rem, 54vw); align-items: center; - gap: 0.35rem; + gap: 0.28rem; } .image-canvas-editor__project-title-input { - width: clamp(9rem, 28vw, 18rem); + width: clamp(8.5rem, 24vw, 16rem); } .image-canvas-editor__project-title-error { @@ -4958,6 +4966,35 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { white-space: nowrap; } +.image-canvas-editor__wallet-chip { + display: inline-flex; + min-width: 6.65rem; + height: 2.04rem; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: 0.34rem; + border: 1px solid rgba(214, 184, 159, 0.72); + border-radius: 999px; + background: rgba(255, 250, 244, 0.82); + padding: 0 0.56rem 0 0.42rem; + color: #7a4427; + font-size: 0.76rem; + font-weight: 860; + line-height: 1; + white-space: nowrap; +} + +.image-canvas-editor__wallet-chip img { + width: 1.16rem; + height: 1.16rem; + flex: 0 0 auto; + object-fit: cover; + object-position: center; + filter: drop-shadow(0 0.12rem 0.12rem rgba(112, 62, 32, 0.14)); + mix-blend-mode: multiply; +} + .image-canvas-editor__zoom-menu-wrap { margin-left: auto; position: relative; @@ -8211,7 +8248,21 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__topbar { - min-height: 3.2rem; + min-height: 2.78rem; + padding: 0.34rem 0.48rem; + } + + .image-canvas-editor__topbar-project { + gap: 0.26rem; + } + + .image-canvas-editor__topbar-actions { + gap: 0.28rem; + } + + .image-canvas-editor__project-back-button { + width: 1.94rem; + height: 1.94rem; } .image-canvas-editor__shortcut-sections { @@ -8231,6 +8282,27 @@ button.image-canvas-editor__reference-chip:disabled { font-size: 0.84rem; } + .image-canvas-editor__project-title-button { + max-width: min(14rem, 34vw); + } + + .image-canvas-editor__project-title-input { + width: clamp(7rem, 34vw, 11rem); + } + + .image-canvas-editor__wallet-chip { + min-width: 5.72rem; + height: 1.94rem; + gap: 0.26rem; + padding-inline: 0.34rem 0.42rem; + font-size: 0.7rem; + } + + .image-canvas-editor__wallet-chip img { + width: 1.02rem; + height: 1.02rem; + } + .image-canvas-editor__floating-toolbar button, .image-canvas-editor__bottom-toolbar button, .image-canvas-editor__reset-button { From 25421140bfa9d59b08efa7cba1abdb9498cdf68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Mon, 22 Jun 2026 21:04:32 +0800 Subject: [PATCH 10/15] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=B3=A5=E7=82=B9=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让画布顶部泥点余额按钮复用创作页充值和兑换码入口 抽取账户充值支付结果与确认弹窗组件供创作页和画布页共用 补充画布泥点入口回归测试和编辑器技术文档 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 3 +- ...CanvasEditorGenerationIntegration.test.tsx | 1 - .../ImageCanvasEditorShellView.test.tsx | 1 + .../ImageCanvasEditorView.test.tsx | 36 +- .../image-editor/ImageCanvasEditorView.tsx | 97 ++++++ .../ImageCanvasTopbarView.test.tsx | 14 +- .../image-editor/ImageCanvasTopbarView.tsx | 8 +- .../PlatformRechargePaymentStatusDialogs.tsx | 53 +++ src/components/rpg-entry/RpgEntryHomeView.tsx | 57 +--- src/index.css | 313 ++++++++++-------- 10 files changed, 391 insertions(+), 192 deletions(-) create mode 100644 src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 12bee8406..af0afaeda 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -9,10 +9,11 @@ - 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 - 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=`。 - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 -- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip。 +- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip,点击后复用创作主页右上角泥点入口的账户充值 / 兑换码弹窗与支付确认反馈。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 +- 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。 - 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index e84b43f79..35f0e8265 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3371,7 +3371,6 @@ describe('ImageCanvasEditorView generation integration', () => { if (!metadataCornerButton) { throw new Error('metadata corner button should exist'); } - expect(metadataCornerButton.className).toContain('bg-black/55'); expect(metadataCornerButton.className).toContain( 'image-canvas-editor__metadata-corner', ); diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 2a7c09fac..9d3b2d3f5 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -109,6 +109,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps { resetProjectRenameError: vi.fn(), exportCanvasAssets: vi.fn(), onOpenShortcuts: vi.fn(), + onOpenWallet: vi.fn(), }; } diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index f6fe85590..54bc91db3 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -205,6 +205,37 @@ describe('ImageCanvasEditorView', () => { }); }); + it('opens the same account wallet entry from the canvas topbar mud point button', async () => { + render( + + + , + ); + + const walletButton = await screen.findByRole('button', { + name: '泥点余额 1,234泥点', + }); + + fireEvent.click(walletButton); + + expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy(); + expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy(); + }); + it('opens the login modal immediately when entering the editor while logged out', async () => { const openLoginModal = vi.fn(); @@ -329,6 +360,7 @@ describe('ImageCanvasEditorView', () => { originalHeight: 1024, zIndex: 3, sourceType: 'generated', + sourceResourceId: 'resource-generated', prompt: '明亮主视觉', model: 'gpt-image-2', provider: 'VectorEngine', @@ -347,6 +379,7 @@ describe('ImageCanvasEditorView', () => { originalHeight: 120, zIndex: 4, sourceType: 'generated', + sourceResourceId: 'resource-failed', }, ], resources: [], @@ -376,7 +409,8 @@ describe('ImageCanvasEditorView', () => { }); const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { - if (url === '/generated-ok.png') { + const pathname = new URL(url, 'http://localhost').pathname; + if (pathname === '/generated-ok.png') { return new Response(new Blob(['generated'], { type: 'image/png' })); } return new Response(null, { status: 404 }); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e4e86dee0..70ea0f28b 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -14,9 +14,17 @@ import { type EditorAssetSnapshot, loadEditorGenerationPricing, } from '../../services/image-editor/editorProjectClient'; +import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; +import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal'; +import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal'; +import { + PlatformRechargePaymentConfirmationMask, + PlatformRechargePaymentResultDialog, +} from '../platform-entry/PlatformRechargePaymentStatusDialogs'; +import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController'; import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation'; import { canvasAssetKindOrNull, @@ -93,6 +101,7 @@ export function ImageCanvasEditorView() { const publicationReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); const selectedLayerIdRef = useRef(null); + const showRechargeEntry = shouldShowRechargeEntry(); useEffect(() => { let isMounted = true; @@ -167,6 +176,56 @@ export function ImageCanvasEditorView() { }, [], ); + const refreshEditorWalletBalance = useCallback(() => { + if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) { + return; + } + void getPlatformProfileDashboard({ + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }) + .then((dashboard) => { + setWalletBalanceLabel( + `${formatDashboardCount(dashboard.walletBalance)}泥点`, + ); + }) + .catch(() => undefined); + }, []); + const { + activeRechargeTab, + buyRechargeProduct, + confirmNativeWechatPayment, + isLoadingRechargeCenter, + isRechargeOpen, + isRewardCodeOpen, + isSubmittingRewardCode, + loadRechargeCenter, + nativeWechatPayment, + openRechargeOrRewardCodeModal, + rechargeCenter, + rechargeError, + rechargePaymentResult, + rewardCodeError, + rewardCodeInput, + rewardCodeSuccess, + setActiveRechargeTab, + setIsRechargeOpen, + setIsRewardCodeOpen, + setRechargePaymentResult, + setRewardCodeInput, + submittingRechargeProductId, + submitRewardCode, + wechatRechargeOrderConfirmationState, + } = usePlatformProfileCenterController({ + activeTab: 'editor-canvas', + isAuthenticated: Boolean(authUi?.user), + showRechargeEntry, + onRechargeSuccess: refreshEditorWalletBalance, + requestLogin: () => authUiRef.current?.openLoginModal(), + currentUser: authUi?.user ?? null, + }); useEffect(() => { if (!authUi || authUi.user || authUi.canAccessProtectedData) { @@ -1256,6 +1315,7 @@ export function ImageCanvasEditorView() { resetProjectRenameError, exportCanvasAssets, onOpenShortcuts: () => setIsShortcutDialogOpen(true), + onOpenWallet: openRechargeOrRewardCodeModal, }; const stageProps = { canvasViewportRef, @@ -1400,6 +1460,43 @@ export function ImageCanvasEditorView() { open={isShortcutDialogOpen} onClose={() => setIsShortcutDialogOpen(false)} /> + {isRewardCodeOpen ? ( + setIsRewardCodeOpen(false)} + /> + ) : null} + {isRechargeOpen ? ( + setIsRechargeOpen(false)} + onRetry={loadRechargeCenter} + onBuy={buyRechargeProduct} + onConfirmNativePayment={confirmNativeWechatPayment} + /> + ) : null} + {rechargePaymentResult ? ( + setRechargePaymentResult(null)} + /> + ) : null} + {wechatRechargeOrderConfirmationState ? ( + + ) : null} ); } diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 96a75e133..93f8ca7ca 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -49,6 +49,7 @@ function renderTopbar( resetProjectRenameError: vi.fn(), exportCanvasAssets: vi.fn(), onOpenShortcuts: vi.fn(), + onOpenWallet: vi.fn(), ...overrides, }; @@ -77,16 +78,21 @@ describe('ImageCanvasTopbarView', () => { }); it('shows the current mud point balance in the topbar', () => { - renderTopbar({ + const props = renderTopbar({ walletBalanceLabel: '1.2万泥点', }); - - const walletChip = screen.getByLabelText('泥点余额 1.2万泥点'); + const walletChip = screen.getByRole('button', { + name: '泥点余额 1.2万泥点', + }); expect(walletChip.textContent).toBe('1.2万泥点'); expect(walletChip.querySelector('img')?.getAttribute('src')).toBe( '/creation-home/topbar-wallet.png', ); + + fireEvent.click(walletChip); + + expect(props.onOpenWallet).toHaveBeenCalledTimes(1); }); it('submits, resets, and cancels project rename edits', () => { @@ -132,6 +138,7 @@ describe('ImageCanvasTopbarView', () => { resetProjectRenameError={vi.fn()} exportCanvasAssets={exportCanvasAssets} onOpenShortcuts={vi.fn()} + onOpenWallet={vi.fn()} />, ); @@ -163,6 +170,7 @@ describe('ImageCanvasTopbarView', () => { resetProjectRenameError={vi.fn()} exportCanvasAssets={exportCanvasAssets} onOpenShortcuts={vi.fn()} + onOpenWallet={vi.fn()} />, ); diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index a75d9ec8e..e4a6ff24d 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -32,6 +32,7 @@ export type ImageCanvasTopbarViewProps = { resetProjectRenameError: () => void; exportCanvasAssets: () => void | Promise; onOpenShortcuts: () => void; + onOpenWallet: () => void; }; export function ImageCanvasTopbarView({ @@ -53,6 +54,7 @@ export function ImageCanvasTopbarView({ resetProjectRenameError, exportCanvasAssets, onOpenShortcuts, + onOpenWallet, }: ImageCanvasTopbarViewProps) { const hasExportableLayer = layers.some( (layer) => layer.src.trim().length > 0, @@ -167,8 +169,10 @@ export function ImageCanvasTopbarView({ {assetExportStatus.message} ) : null} -
{walletDisplayLabel} -
+
); diff --git a/src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx b/src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx new file mode 100644 index 000000000..681413d21 --- /dev/null +++ b/src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx @@ -0,0 +1,53 @@ +import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog'; +import { PlatformStatusDialog } from '../common/PlatformStatusDialog'; +import type { RechargePaymentResult } from './usePlatformProfileCenterController'; + +const PROFILE_MODAL_OVERLAY_CLASS = + 'platform-modal-backdrop !items-center !justify-center !px-4 !py-6'; + +export function PlatformRechargePaymentResultDialog({ + result, + onClose, +}: { + result: RechargePaymentResult; + onClose: () => void; +}) { + const status = + result.kind === 'success' + ? 'success' + : result.kind === 'pending' + ? 'loading' + : result.kind === 'cancel' + ? 'cancel' + : 'error'; + + return ( + + ); +} + +export function PlatformRechargePaymentConfirmationMask({ + orderId, +}: { + orderId: string; +}) { + return ( + undefined} + closeDisabled + zIndexClassName="z-[95]" + overlayClassName={PROFILE_MODAL_OVERLAY_CLASS} + /> + ); +} diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 005197429..8d587a736 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -74,7 +74,6 @@ import { import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; -import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog'; import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { CopyFeedbackButton } from '../common/CopyFeedbackButton'; import { LegalDocumentModal } from '../common/LegalDocumentModal'; @@ -95,7 +94,6 @@ import { PlatformOptionSegment, PlatformUnderlineTabRail, } from '../common/PlatformSegmentedTabPresets'; -import { PlatformStatusDialog } from '../common/PlatformStatusDialog'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformTextField } from '../common/PlatformTextField'; @@ -134,13 +132,16 @@ import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfile import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal'; import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal'; import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal'; +import { + PlatformRechargePaymentConfirmationMask, + PlatformRechargePaymentResultDialog, +} from '../platform-entry/PlatformRechargePaymentStatusDialogs'; import { PlatformProfileTaskCenterModal } from '../platform-entry/PlatformProfileTaskCenterModal'; import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal'; import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel'; import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { type ProfilePopupPanel, - type RechargePaymentResult, usePlatformProfileCenterController, } from '../platform-entry/usePlatformProfileCenterController'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; @@ -2465,9 +2466,6 @@ function cropAvatarImage(params: { }); } -const PROFILE_MODAL_OVERLAY_CLASS = - 'platform-modal-backdrop !items-center !justify-center !px-4 !py-6'; - function ProfileNicknameModal({ value, error, @@ -2536,49 +2534,6 @@ function ProfileNicknameModal({ ); } -function RechargePaymentResultModal({ - result, - onClose, -}: { - result: RechargePaymentResult; - onClose: () => void; -}) { - const status = - result.kind === 'success' - ? 'success' - : result.kind === 'pending' - ? 'loading' - : result.kind === 'cancel' - ? 'cancel' - : 'error'; - - return ( - - ); -} - -function RechargePaymentConfirmationMask({ orderId }: { orderId: string }) { - return ( - undefined} - closeDisabled - zIndexClassName="z-[95]" - overlayClassName={PROFILE_MODAL_OVERLAY_CLASS} - /> - ); -} - export function RpgEntryHomeView({ activeTab, activeDesktopNavTab, @@ -4920,14 +4875,14 @@ export function RpgEntryHomeView({ /> ) : null; const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? ( - setRechargePaymentResult(null)} /> ) : null; const rechargePaymentConfirmationMask: ReactNode = wechatRechargeOrderConfirmationState ? ( - ) : null; diff --git a/src/index.css b/src/index.css index b027c8b5c..3192164aa 100644 --- a/src/index.css +++ b/src/index.css @@ -4096,6 +4096,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { overflow: hidden; background: #ffffff; color: #1f2937; + --image-canvas-brand-accent: var(--platform-accent, #c7653d); + --image-canvas-brand-accent-strong: #6f2f21; + --image-canvas-brand-fill: var( + --platform-button-primary-fill, + linear-gradient(135deg, #df7f40, #b95d3a) + ); + --image-canvas-brand-text-on-fill: var(--platform-button-primary-text, #fffaf5); + --image-canvas-brand-border: rgba(204, 117, 76, 0.34); + --image-canvas-brand-border-strong: rgba(199, 101, 61, 0.64); + --image-canvas-brand-border-soft: rgba(226, 203, 184, 0.82); + --image-canvas-brand-soft: rgba(234, 204, 179, 0.28); + --image-canvas-brand-soft-strong: rgba(238, 208, 183, 0.46); + --image-canvas-brand-shadow: rgba(182, 98, 63, 0.16); + --image-canvas-brand-focus-ring: rgba(204, 117, 76, 0.18); -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; @@ -4171,7 +4185,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { min-width: 15.5rem; flex-direction: column; overflow: hidden; - border-right: 1px solid #d9dee8; + border-right: 1px solid var(--image-canvas-brand-border-soft); background: #ffffff; } @@ -4181,7 +4195,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { align-items: center; justify-content: space-between; gap: 0.75rem; - border-bottom: 1px solid #d9dee8; + border-bottom: 1px solid var(--image-canvas-brand-border-soft); padding: 0.65rem; } @@ -4209,7 +4223,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; align-items: center; justify-content: center; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); background: #ffffff; color: #334155; transition: @@ -4224,9 +4238,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__bottom-toolbar button:hover, .image-canvas-editor__reset-button:hover { transform: translateY(-1px); - border-color: #8fb8ff; - background: #eef5ff; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__icon-button { @@ -4274,7 +4288,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__folder-rename-input { height: 1.8rem; - border: 1px solid #8fb8ff; + border: 1px solid var(--image-canvas-brand-border-strong); border-radius: 0.35rem; background: #ffffff; padding: 0 0.45rem; @@ -4297,9 +4311,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__asset-folder-header button:hover { - border-color: #d9dee8; - background: #f8fafc; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__asset-folder-header span { @@ -4316,9 +4330,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__asset-folder--move-target > .image-canvas-editor__asset-folder-header { - border-color: #93c5fd; - background: #eff6ff; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__asset-folder-sticky-target { @@ -4329,14 +4343,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { align-items: center; gap: 0.4rem; margin-bottom: 0.45rem; - border: 1px solid #93c5fd; + border: 1px solid var(--image-canvas-brand-border-strong); border-radius: 0.45rem; - background: #eff6ff; + background: var(--image-canvas-brand-soft); padding: 0.38rem 0.5rem; - color: #1d4ed8; + color: var(--image-canvas-brand-accent-strong); font-size: 0.78rem; font-weight: 880; - box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12); + box-shadow: 0 10px 24px var(--image-canvas-brand-shadow); } .image-canvas-editor__asset-folder-sticky-target span { @@ -4370,24 +4384,24 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__asset-row:hover { transform: translateY(-1px); - border-color: #8fb8ff; - background: #eef5ff; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); } .image-canvas-editor__asset-row--selected { - border-color: #2563eb; - background: #dbeafe; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft-strong); } .image-canvas-editor__asset-row--uploading { - border-color: #bfdbfe; - background: #f8fbff; + border-color: var(--image-canvas-brand-border); + background: rgba(255, 250, 245, 0.96); } .image-canvas-editor__asset-row--uploading:hover { transform: none; - border-color: #93c5fd; - background: #f8fbff; + border-color: var(--image-canvas-brand-border-strong); + background: rgba(255, 250, 245, 0.96); } .image-canvas-editor__asset-row--upload-failed { @@ -4400,11 +4414,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { z-index: 80; max-width: 11rem; transform: translate(-50%, -50%); - border: 1px solid #bfdbfe; + border: 1px solid var(--image-canvas-brand-border); border-radius: 999px; background: rgba(255, 255, 255, 0.96); padding: 0.4rem 0.65rem; - color: #1d4ed8; + color: var(--image-canvas-brand-accent-strong); font-size: 0.78rem; font-weight: 880; pointer-events: none; @@ -4417,7 +4431,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: grid; place-items: center; background: rgba(248, 250, 252, 0.74); - color: #1d4ed8; + color: var(--image-canvas-brand-accent-strong); font-size: 0.72rem; font-weight: 880; } @@ -4450,7 +4464,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__asset-upload-progress progress::-webkit-progress-value { - background: #2563eb; + background: var(--image-canvas-brand-accent); } .image-canvas-editor__asset-batch-toolbar { @@ -4463,16 +4477,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__asset-marquee { position: absolute; z-index: 4; - border: 1px solid #2563eb; - background: rgb(37 99 235 / 0.12); + border: 1px solid var(--image-canvas-brand-accent); + background: rgba(204, 117, 76, 0.12); pointer-events: none; } .image-canvas-editor__canvas-marquee { position: absolute; z-index: 7; - border: 1px solid #2563eb; - background: rgb(37 99 235 / 0.12); + border: 1px solid var(--image-canvas-brand-accent); + background: rgba(204, 117, 76, 0.12); pointer-events: none; } @@ -4483,9 +4497,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { place-items: center; align-content: center; gap: 0.25rem; - border: 1px dashed #60a5fa; - background: rgba(239, 246, 255, 0.72); - color: #1d4ed8; + border: 1px dashed var(--image-canvas-brand-border-strong); + background: rgba(255, 244, 236, 0.76); + color: var(--image-canvas-brand-accent-strong); text-align: center; pointer-events: none; backdrop-filter: blur(2px); @@ -4636,7 +4650,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__asset-rename-input { min-width: 0; height: 1.8rem; - border: 1px solid #8fb8ff; + border: 1px solid var(--image-canvas-brand-border-strong); border-radius: 0.35rem; background: #ffffff; padding: 0 0.45rem; @@ -4658,7 +4672,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { height: 1.9rem; align-items: center; justify-content: center; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.42rem; background: #ffffff; color: #475569; @@ -4666,9 +4680,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__asset-actions button:hover, .image-canvas-editor__folder-create button:hover { - border-color: #8fb8ff; + border-color: var(--image-canvas-brand-border-strong); background: #ffffff; - color: #1d4ed8; + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__folder-create { @@ -4676,7 +4690,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 0.35rem; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.45rem; background: #f8fafc; padding: 0.4rem; @@ -4685,7 +4699,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__folder-create-input { min-width: 0; height: 2rem; - border: 1px solid #8fb8ff; + border: 1px solid var(--image-canvas-brand-border-strong); border-radius: 0.35rem; background: #ffffff; padding: 0 0.5rem; @@ -4730,8 +4744,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__layer-row:hover, .image-canvas-editor__layer-row--selected { - border-color: #8fb8ff; - background: #eef5ff; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); } .image-canvas-editor__layer-row-thumb { @@ -4779,7 +4793,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { align-items: center; justify-content: space-between; gap: 0.55rem; - border-bottom: 1px solid #d9dee8; + border-bottom: 1px solid var(--image-canvas-brand-border-soft); background: #ffffff; padding: 0.36rem 0.58rem; } @@ -4859,9 +4873,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__shortcut-row dt > span { flex: 0 0 auto; border-radius: 999px; - background: #e0f2fe; + background: var(--image-canvas-brand-soft); padding: 0.08rem 0.34rem; - color: #0369a1; + color: var(--image-canvas-brand-accent-strong); font-size: 0.62rem; font-weight: 900; } @@ -4983,6 +4997,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { font-weight: 860; line-height: 1; white-space: nowrap; + transition: + transform 160ms ease, + background-color 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; +} + +.image-canvas-editor__wallet-chip:hover, +.image-canvas-editor__wallet-chip:focus-visible { + transform: translateY(-1px); + border-color: rgba(214, 184, 159, 0.92); + background: rgba(255, 250, 244, 0.96); + box-shadow: 0 0.28rem 0.78rem rgba(112, 62, 32, 0.12); } .image-canvas-editor__wallet-chip img { @@ -5006,7 +5033,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__zoom-trigger { min-width: 4.15rem; height: 2.25rem; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.45rem; background: #ffffff; padding: 0 0.65rem; @@ -5017,9 +5044,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__zoom-trigger:hover { transform: translateY(-1px); - border-color: #8fb8ff; - background: #eef5ff; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__zoom-menu-item { @@ -5055,7 +5082,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { flex: 0 0 auto; align-self: center; border-radius: 999px; - background: #d9dee8; + background: var(--image-canvas-brand-border-soft); } .image-canvas-editor__floating-toolbar-divider { @@ -5195,7 +5222,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { justify-content: flex-end; gap: 0.3rem; border-radius: 0.85rem; - background: linear-gradient(135deg, #0f172a, #263449); + background: linear-gradient(135deg, #6f2f21, #b95d3a); padding: 0.7rem; box-shadow: 0 16px 38px rgba(15, 23, 42, 0.18); } @@ -5306,7 +5333,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__layer--hovered, .image-canvas-editor__layer--selected { - border-color: #4bb5aa; + border-color: var(--image-canvas-brand-accent); } .image-canvas-editor__layer--selected { @@ -5366,7 +5393,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__generation-frame--focused { - border-color: #2f7df6; + border-color: var(--image-canvas-brand-accent); } .image-canvas-editor__generation-frame-label, @@ -5376,7 +5403,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; align-items: center; gap: 0.25rem; - color: #2f7df6; + color: var(--image-canvas-brand-accent); font-size: 0.76rem; font-weight: 850; line-height: 1; @@ -5482,10 +5509,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { ), linear-gradient( 135deg, - rgba(240, 247, 255, 0.92), + rgba(255, 247, 241, 0.94), rgba(232, 236, 244, 0.95) ); - color: #2563eb; + color: var(--image-canvas-brand-accent); cursor: progress; box-shadow: 0 20px 48px rgba(15, 23, 42, 0.12), @@ -5504,7 +5531,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { ), repeating-linear-gradient( 135deg, - rgba(47, 125, 246, 0.08) 0 0.4rem, + rgba(204, 117, 76, 0.1) 0 0.4rem, transparent 0.4rem 0.85rem ); animation: image-canvas-generation-scan 1.7s linear infinite; @@ -5514,7 +5541,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__generation-frame-label, .image-canvas-editor__generation-frame--generating .image-canvas-editor__generation-frame-size { - color: #2563eb; + color: var(--image-canvas-brand-accent); } .image-canvas-editor__generation-frame--generating @@ -5523,11 +5550,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { z-index: 1; width: 5.5rem; aspect-ratio: 1; - border: 1px solid rgba(37, 99, 235, 0.18); + border: 1px solid var(--image-canvas-brand-border); border-radius: 1.6rem; background: rgba(255, 255, 255, 0.78); - color: rgba(37, 99, 235, 0.58); - box-shadow: 0 18px 32px rgba(37, 99, 235, 0.12); + color: rgba(199, 101, 61, 0.68); + box-shadow: 0 18px 32px var(--image-canvas-brand-shadow); } .image-canvas-editor__generation-frame-progress { @@ -5538,15 +5565,15 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; align-items: center; gap: 0.45rem; - border: 1px solid rgba(37, 99, 235, 0.18); + border: 1px solid var(--image-canvas-brand-border); border-radius: 999px; background: rgba(255, 255, 255, 0.86); padding: 0.34rem 0.7rem; - color: #1d4ed8; + color: var(--image-canvas-brand-accent-strong); font-size: 0.76rem; font-weight: 850; transform: translateX(-50%); - box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12); + box-shadow: 0 12px 24px var(--image-canvas-brand-shadow); } .image-canvas-editor__generation-frame-progress::before { @@ -5646,21 +5673,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__generation-frame--quick-edit { - border-color: rgba(99, 102, 241, 0.28); + border-color: var(--image-canvas-brand-border); background: - linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(14, 165, 233, 0.08)), + linear-gradient(135deg, rgba(204, 117, 76, 0.12), rgba(240, 203, 169, 0.12)), rgba(255, 255, 255, 0.72); } .image-canvas-editor__generation-frame--quick-edit.image-canvas-editor__generation-frame--focused { box-shadow: - 0 0 0 1px rgba(99, 102, 241, 0.26), - 0 22px 54px rgba(99, 102, 241, 0.18); + 0 0 0 1px var(--image-canvas-brand-border), + 0 22px 54px var(--image-canvas-brand-shadow); } .image-canvas-editor__generation-frame--quick-edit .image-canvas-editor__generation-frame-icon { - color: rgba(79, 70, 229, 0.62); + color: rgba(199, 101, 61, 0.72); } .image-canvas-editor__generation-frame--publication { @@ -5692,14 +5719,15 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { height: 1.35rem; place-items: center; border-radius: 999px; - background: rgba(16, 15, 14, 0.78); - color: #ffffff; + border-color: rgba(255, 255, 255, 0.42); + background: rgba(199, 101, 61, 0.92); + color: var(--image-canvas-brand-text-on-fill); transform: scale(var(--image-canvas-editor-inverse-scale, 1)); transform-origin: top right; } .image-canvas-editor__metadata-corner:hover { - background: rgba(3, 105, 161, 0.88); + background: #b95d3a; } .image-canvas-editor__metadata-corner--beside-spec { @@ -5719,14 +5747,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { align-items: center; border: 1px solid rgba(255, 255, 255, 0.72); border-radius: 999px; - background: rgba(37, 99, 235, 0.9); + background: rgba(199, 101, 61, 0.92); padding: 0.22rem 0.5rem; color: #ffffff; font-size: 0.72rem; font-weight: 900; line-height: 1; letter-spacing: 0.04em; - box-shadow: 0 8px 18px rgba(37, 99, 235, 0.24); + box-shadow: 0 8px 18px var(--image-canvas-brand-shadow); pointer-events: none; } @@ -5761,8 +5789,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__kind-badge--spec { - background: rgba(37, 99, 235, 0.9); - box-shadow: 0 8px 18px rgba(37, 99, 235, 0.24); + background: rgba(199, 101, 61, 0.92); + box-shadow: 0 8px 18px var(--image-canvas-brand-shadow); } .image-canvas-editor__kind-badge--character { @@ -5835,8 +5863,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__kind-menu-item--active { - background: #eff6ff; - color: #1d4ed8; + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__size-badge { @@ -5845,8 +5873,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { top: var(--image-canvas-editor-corner-offset, 0.35rem); z-index: 2; border-color: rgba(255, 255, 255, 0.16); - background: rgba(16, 15, 14, 0.82); - color: #ffffff; + background: rgba(111, 47, 33, 0.88); + color: var(--image-canvas-brand-text-on-fill); white-space: nowrap; transform: scale(var(--image-canvas-editor-inverse-scale, 1)); transform-origin: top left; @@ -5858,7 +5886,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; align-items: center; gap: 0.3rem; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.5rem; background: #ffffff; padding: 0.28rem; @@ -5892,7 +5920,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { width: 8.25rem; height: 5.25rem; overflow: hidden; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.5rem; background: rgba(255, 255, 255, 0.92); padding: 0; @@ -5922,7 +5950,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__minimap-viewport { - border: 1.5px solid #aeb7c5; + border: 1.5px solid var(--image-canvas-brand-border-strong); background: rgba(255, 255, 255, 0.2); } @@ -5933,7 +5961,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { z-index: 18; display: inline-flex; gap: 0.35rem; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.5rem; background: #ffffff; padding: 0.35rem; @@ -5946,7 +5974,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { height: 2.25rem; align-items: center; justify-content: center; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.45rem; background: #ffffff; color: #334155; @@ -6136,8 +6164,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__background-preset[aria-pressed='true'] { - border-color: #38bdf8; - background: #e0f2fe; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); } .image-canvas-editor__background-preset @@ -6198,17 +6226,17 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__background-reset:hover { - border-color: #38bdf8; - background: #eff6ff; - color: #0369a1; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__panel-dock button:hover, .image-canvas-editor__panel-dock button[aria-pressed='true'] { transform: translateY(-1px); - border-color: #38bdf8; - background: #e0f2fe; - color: #0369a1; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__bottom-toolbar { @@ -6220,7 +6248,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { gap: 0.35rem; max-width: min(calc(100% - 6.6rem), 34rem); overflow-x: auto; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.5rem; background: #ffffff; padding: 0.35rem; @@ -6229,9 +6257,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__bottom-toolbar button[aria-pressed='true'] { - border-color: #38bdf8; - background: #e0f2fe; - color: #0369a1; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__bottom-toolbar-option-wrap, @@ -6322,6 +6350,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { outline: none; } +.image-canvas-editor__generation-prompt:focus, +.image-canvas-editor__video-prompt:focus { + border-color: var(--image-canvas-brand-border-strong); + background: #ffffff; + box-shadow: 0 0 0 3px var(--image-canvas-brand-focus-ring); +} + .image-canvas-editor__generation-prompt::placeholder { color: #94a3b8; } @@ -6790,8 +6825,10 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__option-popover-choice[aria-pressed='true'] { - border-color: #202124; - box-shadow: inset 0 0 0 1px #202124; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); + box-shadow: inset 0 0 0 1px var(--image-canvas-brand-border-strong); } .image-canvas-editor__option-popover--audio-options { @@ -6810,7 +6847,7 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__audio-bpm-control input[type='range'] { width: 100%; - accent-color: #4f46e5; + accent-color: var(--image-canvas-brand-accent); } .image-canvas-editor__video-duration-slider { @@ -6822,11 +6859,11 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__video-duration-slider input[type='range'] { width: 100%; - accent-color: #111827; + accent-color: var(--image-canvas-brand-accent); } .image-canvas-editor__video-duration-slider span { - color: #111827; + color: var(--image-canvas-brand-accent-strong); font-size: 0.82rem; font-weight: 760; text-align: right; @@ -6873,12 +6910,12 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__video-toggle[aria-pressed='true'] { - border-color: #202124; - box-shadow: inset 0 0 0 1px #202124; + border-color: var(--image-canvas-brand-border-strong); + box-shadow: inset 0 0 0 1px var(--image-canvas-brand-border-strong); } .image-canvas-editor__video-toggle[aria-pressed='true'] span { - background: #111827; + background: var(--image-canvas-brand-accent); } .image-canvas-editor__video-toggle[aria-pressed='true'] span::after { @@ -6898,8 +6935,8 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__audio-bpm-control input[type='number']:focus { - border-color: rgba(79, 70, 229, 0.42); - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); + border-color: var(--image-canvas-brand-border-strong); + box-shadow: 0 0 0 3px var(--image-canvas-brand-focus-ring); } .image-canvas-editor__option-popover-choice--model { @@ -6940,7 +6977,7 @@ button.image-canvas-editor__reference-chip:disabled { width: 0.92rem; height: 0.92rem; justify-self: end; - color: #111827; + color: var(--image-canvas-brand-accent-strong); stroke-width: 3; } @@ -7026,9 +7063,9 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__generation-composer .platform-inline-option-button[aria-pressed='true'] { - border-color: rgba(15, 23, 42, 0.14); - background: #0f172a; - color: #ffffff; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-fill); + color: var(--image-canvas-brand-text-on-fill); } .image-canvas-editor__generation-submit { @@ -7037,14 +7074,14 @@ button.image-canvas-editor__reference-chip:disabled { height: 2.25rem; gap: 0.4rem; border: 0; - background: #0f172a; - color: #ffffff; + background: var(--image-canvas-brand-fill); + color: var(--image-canvas-brand-text-on-fill); font-size: 0.82rem; font-weight: 820; } .image-canvas-editor__generation-submit:not(:disabled) { - color: #ffffff; + color: var(--image-canvas-brand-text-on-fill); } .image-canvas-editor__generation-submit:disabled { @@ -7494,8 +7531,8 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__context-menu button:hover:not(:disabled) { - background: #eef6ff; - color: #0f4a87; + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__context-menu button:disabled { @@ -7699,15 +7736,15 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__crop-expand-ratio:hover { - border-color: rgba(59, 130, 246, 0.46); - background: #eff6ff; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__crop-expand-ratio--selected { - border-color: rgba(37, 99, 235, 0.76); - background: #dbeafe; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft-strong); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__crop-expand-ratio-shape { @@ -7802,10 +7839,10 @@ button.image-canvas-editor__reference-chip:disabled { position: absolute; z-index: 999; box-sizing: border-box; - border: 2px solid #38bdf8; + border: 2px solid var(--image-canvas-brand-accent); background: - linear-gradient(90deg, rgba(14, 165, 233, 0.2) 1px, transparent 1px), - linear-gradient(rgba(14, 165, 233, 0.2) 1px, transparent 1px); + linear-gradient(90deg, rgba(204, 117, 76, 0.2) 1px, transparent 1px), + linear-gradient(rgba(204, 117, 76, 0.2) 1px, transparent 1px); background-size: 33.333% 33.333%; box-shadow: 0 0 0 99999px rgba(15, 23, 42, 0.18), @@ -7826,8 +7863,8 @@ button.image-canvas-editor__reference-chip:disabled { height: 1rem; border: 2px solid #ffffff; border-radius: 999px; - background: #0ea5e9; - box-shadow: 0 6px 16px rgba(14, 165, 233, 0.28); + background: var(--image-canvas-brand-accent); + box-shadow: 0 6px 16px var(--image-canvas-brand-shadow); pointer-events: auto; touch-action: none; } @@ -7954,8 +7991,8 @@ button.image-canvas-editor__reference-chip:disabled { height: 2.25rem; gap: 0.4rem; border: 0; - background: #0f172a; - color: #ffffff; + background: var(--image-canvas-brand-fill); + color: var(--image-canvas-brand-text-on-fill); } .image-canvas-editor__snap-guide { @@ -8024,9 +8061,9 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__generate-prompt:focus { - border-color: #38bdf8; + border-color: var(--image-canvas-brand-border-strong); background: #ffffff; - box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18); + box-shadow: 0 0 0 3px var(--image-canvas-brand-focus-ring); } .image-canvas-editor__generate-status { @@ -15826,6 +15863,16 @@ button { box-shadow: 0 24px 70px rgba(15, 23, 42, 0.18) !important; } +.image-canvas-editor .image-canvas-editor__metadata-corner { + border-color: rgba(255, 255, 255, 0.42) !important; + background: rgba(199, 101, 61, 0.92) !important; + color: var(--image-canvas-brand-text-on-fill) !important; +} + +.image-canvas-editor .image-canvas-editor__metadata-corner:hover { + background: #b95d3a !important; +} + .image-canvas-editor__generation-composer .image-canvas-editor__option-cluster { min-height: 2.25rem; background: transparent; From 2007f1f9ced38c071892f12e1a6efcdbab40896c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Mon, 22 Jun 2026 21:10:43 +0800 Subject: [PATCH 11/15] Update editor generation pricing defaults Unify and lower default mud-point prices across editor generation features and update related API/schema, docs and tests. Changes set gpt-image-2 to 1K=3 / 2K=5, audio1.0 to 5, and chirp-v5 to 12, and update server default JSON, frontend models/workflows, OpenAPI schema (require aspectRatio/imageSize/priceMudPoints for UI extraction), multiple UX docs (quick-edit viewport, UI extraction panel, pricing guidance) and accompanying tests to match the new defaults and validation rules. --- .../AdminEditorGenerationPricingPage.test.tsx | 6 +- .../genarrative-external-v1.openapi.json | 22 +- .../shared-memory/decision-log.md | 8 + docs/project-memory/shared-memory/pitfalls.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 8 +- ...编辑器】模型定价配置管理方案-2026-06-22.md | 5 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 14 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 4 +- .../editor-generation-pricing.default.json | 8 +- server-rs/crates/api-server/src/app.rs | 55 ++++- .../src/editor_generation_config.rs | 16 +- .../crates/api-server/src/editor_project.rs | 129 ++++++++++-- .../api-server/src/external_editor_api.rs | 12 ++ ...CanvasEditorGenerationIntegration.test.tsx | 87 +++++++- .../ImageCanvasGenerationModel.test.ts | 12 +- .../ImageCanvasGenerationModel.ts | 8 +- .../ImageCanvasOverlayModel.test.ts | 18 +- .../image-editor/ImageCanvasOverlayModel.ts | 53 ++++- .../ImageCanvasQuickEditPanelView.test.tsx | 2 +- .../image-editor/ImageCanvasStageView.tsx | 4 +- .../ImageCanvasUiAssetExtractionModel.ts | 27 ++- ...anvasUiAssetExtractionOverlayView.test.tsx | 89 +++++++- ...mageCanvasUiAssetExtractionOverlayView.tsx | 193 +++++++++++++----- ...anvasGenerationSubmissionWorkflow.test.tsx | 56 ++--- ...ImageCanvasGenerationSubmissionWorkflow.ts | 13 ++ .../useImageCanvasGenerationWorkflow.test.tsx | 55 ++++- .../useImageCanvasGenerationWorkflow.ts | 40 ++-- src/index.css | 126 +++++++++++- .../image-editor/editorProjectClient.test.ts | 6 + .../image-editor/editorProjectClient.ts | 8 + 30 files changed, 911 insertions(+), 181 deletions(-) diff --git a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx index c19356e13..d487cab38 100644 --- a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx @@ -28,7 +28,7 @@ const pricing: EditorGenerationPricingConfigPayload = { }, 'gpt-image-2': { unit: 'perGeneration', - prices: {'1K': 20, '2K': 40}, + prices: {'1K': 3, '2K': 5}, }, 'seedance2.0-fast': { unit: 'perSecond', @@ -38,8 +38,8 @@ const pricing: EditorGenerationPricingConfigPayload = { unit: 'perSecond', prices: {'480p': 12, '720p': 24, '1080p': 48}, }, - 'audio1.0': {unit: 'perGeneration', price: 10}, - 'chirp-v5': {unit: 'perGeneration', price: 5}, + 'audio1.0': {unit: 'perGeneration', price: 5}, + 'chirp-v5': {unit: 'perGeneration', price: 12}, }, }; diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index bf7087e02..414f34fbc 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -2838,13 +2838,33 @@ "EditorUiDesignAssetExtractionRequest": { "type": "object", "required": [ - "sourceImageSrc" + "sourceImageSrc", + "aspectRatio", + "imageSize", + "priceMudPoints" ], "properties": { "sourceImageSrc": { "type": "string", "description": "UI 设计图 Data URL。" }, + "aspectRatio": { + "type": "string", + "const": "1:1", + "description": "UI 素材提取固定使用 1:1。" + }, + "imageSize": { + "type": "string", + "enum": [ + "1K", + "2K" + ], + "description": "框选数量少时使用 1K,超过阈值时使用 2K。" + }, + "priceMudPoints": { + "type": "integer", + "minimum": 0 + }, "projectId": { "type": [ "string", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7724126d4..1a6cf7d07 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-22 编辑器生成模型默认定价调整 + +- 背景:图片画布生成按钮、后端 `priceMudPoints` 校验和后台定价页需要统一使用新的模型默认泥点。 +- 决策:`audio1.0` 默认按次 `5` 泥点,`chirp-v5` 默认按次 `12` 泥点,`gpt-image-2` 默认 `1K=3`、`2K=5` 泥点;运行态仍允许后台 override 覆盖,前端兜底必须与后端默认 JSON 保持一致。 +- 影响范围:`editor-generation-pricing.default.json`、`ImageCanvasGenerationModel.ts`、后台定价页 fixture、后端价格校验和编辑器定价文档。 +- 验证方式:运行 `editor_generation_config`、公开定价路由、图标素材价格校验、图片画布定价模型和后台定价页相关测试。 +- 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 + ## 2026-06-21 图片画布生成完成态由后端写入画布布局 - 背景:图片画布角色形象等长耗时生成在服务端完成后,如果浏览器已刷新或原 HTTP 回调丢失,前端无法再把生成结果图层和 `generation-dialog` 完成态写回 `editor_canvas.layers_json`,用户会继续看到“生成中”卡片。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2da702408..c0803f64f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -15,6 +15,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 本地 dev 启动日志先看成功锚点,不要把非阻断 warning 当失败 + +- 现象:`npm run dev` 启动 SpacetimeDB 时可能先打印 `static max level is off`、`Skipping tokio metrics`,或 SpacetimeDB CLI 提示存在新版本 / 当前版本较旧,看起来像启动异常。 +- 原因:这些是 tracing、metrics 或 CLI 更新提示,不代表本地 dev 栈失败;同一段日志后续仍可能已经完成 `SpacetimeDB listening on 127.0.0.1:3101`、模块 publish、`api-server` `/healthz` 200、主站 Vite `3000` 和后台 Vite `3102` ready。 +- 处理:排查本地 dev 栈时先确认成功锚点:`[dev:spacetime] actual`、`Updated database`、`api-server 已完成 tracing 初始化并开始监听`、`/healthz` 200、两个 Vite `ready`。只有缺少这些锚点或进程退出时,再继续查 CLI 权限、端口占用、publish 或 API 编译问题。 +- 验证:`http://127.0.0.1:3101/v1/ping` 可访问、`http://127.0.0.1:8082/healthz` 返回 200、`http://127.0.0.1:3000/` 和 `http://127.0.0.1:3102/admin/` 可打开。 +- 关联:`scripts/dev.mjs`、`.app/dev-stack.json`、`docs/project-memory/shared-memory/development-workflow.md`。 + ## 图片画布角色动作结果不能把首帧当主媒体 - 现象:画板里 `生成角色动作` 返回后显示成一张不可播放图片,点击下载或素材 ZIP 导出时拿到的也是 PNG,而不是动作视频。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 1e1441550..e7c0fdfca 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -19,7 +19,7 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑默认从原图模型和分辨率初始化,但提交使用面板当前选择的 `model/aspectRatio/imageSize`;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材必须按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作快速编辑固定使用 `seedance2.0-fast` 动作 / 视频模型。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图 / 原素材自动作为最后一张隐式参考提交,但不在参考图条里固定展示 `图x`。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交前将提示词里对原素材的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张源素材编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 +- 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图 / 原素材自动作为最后一张隐式参考提交,但不在参考图条里固定展示 `图x`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交前将提示词里对原素材的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张源素材编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变。 @@ -68,7 +68,7 @@ - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。 -- `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 +- `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 - `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 @@ -96,10 +96,10 @@ - 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。 - 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。角色形象生成面板每次成功绑定角色规范后,在当前编辑器生命周期内缓存为上一张角色规范;再次新建角色形象时自动带入该缓存。图标素材和 UI 设计图面板每次成功绑定图标规范后,同样缓存为上一张图标规范;再次新建需要图标规范的素材时自动带入该缓存。生成规范菜单里的图标规范对象自身只把首行参考图作为可选参考,不要求必须先绑定图标规范。 - 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。 -- 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;参考图请求顺序为额外参考图在前、原图作为隐式最后一张提交,信息面板输入快照只展示用户额外选择的参考图。 +- 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。参考图请求顺序为额外参考图在前、原图作为隐式最后一张提交,信息面板输入快照只展示用户额外选择的参考图。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,支持矩形、椭圆和画笔自由框选;至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,显示短提示语、框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮;至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 - 快速编辑的额外参考图同样必须在前端读取成图片 Data URL 后提交;后端 `referenceImageSrcs` 上限为 9 张,承载 8 张额外参考图加 1 张原图。 diff --git a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md index 03771b423..9b1645c15 100644 --- a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md +++ b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md @@ -28,13 +28,14 @@ }, "gpt-image-2": { "unit": "perGeneration", - "prices": { "1K": 20, "2K": 40 } + "prices": { "1K": 3, "2K": 5 } }, "seedance2.0-fast": { "unit": "perSecond", "prices": { "480p": 10, "720p": 20, "1080p": 40 } }, - "audio1.0": { "unit": "perGeneration", "price": 10 } + "audio1.0": { "unit": "perGeneration", "price": 5 }, + "chirp-v5": { "unit": "perGeneration", "price": 12 } } } ``` diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 56cbb40c2..e6b600bbc 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -41,7 +41,7 @@ 8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。 9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。 10. 快速编辑最多允许额外绑定 8 张参考图;原图始终自动作为最后一张隐式参考图提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`。 -11. 快速编辑打开后,画布视口应调整到面板位于原图下方且不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。 +11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态,再点同一工具取消启用。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。 ## 参数交互 @@ -79,7 +79,7 @@ - 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整 override 到 `.app/editor-generation-pricing.override.json`(可由 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 覆盖路径),主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。 - 生成图标素材面板提交 `POST /api/editor/icon-spritesheets/generations` 时必须携带 `priceMudPoints`,取同一份 `icon` 计费配置;后端用 `editor_generation_config` 校验,不允许绕过配置继续生成。 - 生成视频、角色动画、音效和背景音乐提交时携带的 `priceMudPoints` 也必须由同一模型配置函数计算,后端按归一后的模型、清晰度、时长或音频模型重新计算并校验。 -- `提取素材` 是 UI 设计图工具栏动作,不是面板生成按钮;它固定使用 `gpt-image-2` 和图标素材拆分链路,后端响应里的 `priceMudPoints` 仍必须来自 `editor_generation_config`,不能写死。 +- `提取素材` 点击后进入 UI 素材提取态:右侧框选工具对齐底部工具栏按钮风格,素材下方显示与生成新素材一致宽度的提取面板。提取面板显示短提示语、框选截图预览、固定模型 `gpt-image-2`、计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮;前后端提交 `aspectRatio / imageSize / priceMudPoints`,后端按 `editor_generation_config` 校验,不能写死。 - 画板 UI 统一显示 `nanobanana2`,它代表上游真实模型 `gemini-3.1-flash-image-preview`;历史输入或旧布局中的 `nano-banana` 必须先归一为 `nanobanana2` 对应的真实模型 ID 后再提交和计费。定价表仍以真实模型 ID `gemini-3.1-flash-image-preview` 和 `gpt-image-2` 为准,别名不能作为新增正式模型绕过定价表。 ## 画布占位落点 @@ -118,11 +118,11 @@ ## 第一版计费配置 ```text -图片模型按尺寸单次计价:nanobanana2 / gemini-3.1-flash-image-preview 为 0.5K 8 泥点、1K 12 泥点、2K 24 泥点;gpt-image-2 为 1K 20 泥点、2K 40 泥点 +图片模型按尺寸单次计价:nanobanana2 / gemini-3.1-flash-image-preview 为 0.5K 8 泥点、1K 12 泥点、2K 24 泥点;gpt-image-2 为 1K 3 泥点、2K 5 泥点 生成规范不再单独定价:固定读取 gpt-image-2 的 2K 定价 视频 / 角色动作按同一视频模型分辨率每秒计价:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20 / 1080p 每秒 40(生成视频入口仍禁止 fast 选 1080p);seedance2.0 为 480p 每秒 12 / 720p 每秒 24 / 1080p 每秒 48;kling3.0 为 480p 每秒 15 / 720p 每秒 30 / 1080p 每秒 60;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40 / 1080p 每秒 80 -生成音效:audio1.0 按次 10 泥点 -生成背景音乐:chirp-v5 按次 5 泥点 +生成音效:audio1.0 按次 5 泥点 +生成背景音乐:chirp-v5 按次 12 泥点 ``` 当前必须显式覆盖的正式模型定价配置(默认值来自 `editor-generation-pricing.default.json`,运行时可由后台 override 修改): @@ -174,7 +174,7 @@ - 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。 - 生成规范类图片底部展示禁用态参数按钮 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。 - 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为隐式最后参考图参与提交但不在参考图条里固定显示 `图x`。 -- 快速编辑打开后面板位于原图下方且不遮挡原图,原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮;完成框选后画布红框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。 +- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并显示选中态,再点同一工具取消;完成框选后画布红框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。 - 快速编辑提交前必须把提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写成最后一张原图编号,例如额外 1 张参考图时改为 `图2`。 - 快速编辑提交给后端的参考图顺序必须是额外参考图在前、原图在最后;若用户已完成红色圈选,最后一张隐式原图必须替换为绘制了红框和序号的标注图。 - 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。 @@ -182,7 +182,7 @@ - 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。 - 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。 - 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 -- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后进入红框素材框选编辑态,默认矩形框选并支持椭圆和画笔自由框选。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet,后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。 +- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,点击同一工具可取消启用态;面板显示短提示语、框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet。框选数量不超过阈值时按 `1:1·1K` 和 gpt-image-2 1K 泥点提交,超过阈值时改用 `1:1·2K` 和 2K 泥点;后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。 - 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。 - 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。 - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 9d43ebb96..ad2ae3fa2 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -41,7 +41,7 @@ - `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。 - `aspectRatio`:按 `x:y` 展示,选项跟随模型。 - `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。 - - `priceMudPoints`:按当前模型从编辑器生成计费配置计算;`nanobanana2` 为 `12`,`gpt-image-2` 为 `20`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。 + - `priceMudPoints`:按当前模型和尺寸从编辑器生成计费配置计算;`nanobanana2 1K` 为 `12`,`gpt-image-2 1K` 为 `3`、`gpt-image-2 2K` 为 `5`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。 - 模型与尺寸选项: - `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。 - `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024`、`1K 2:3/9:16 -> 1024x1536`、`1K 3:2/16:9 -> 1536x1024`、`2K 1:1 -> 2048x2048`、`2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`。 @@ -77,4 +77,4 @@ - 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 - 生成成功后画布出现 spritesheet 图集和按描述命名的多个透明图标素材图层,图层之间不重叠。 -- 生成图标素材提交体包含按模型计算的 `priceMudPoints`;`nanobanana2` 应为 `12`,`gpt-image-2` 应为 `20`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。 +- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。 diff --git a/server-rs/crates/api-server/config/editor-generation-pricing.default.json b/server-rs/crates/api-server/config/editor-generation-pricing.default.json index 3db8f7c06..3e08db484 100644 --- a/server-rs/crates/api-server/config/editor-generation-pricing.default.json +++ b/server-rs/crates/api-server/config/editor-generation-pricing.default.json @@ -11,8 +11,8 @@ "gpt-image-2": { "unit": "perGeneration", "prices": { - "1K": 20, - "2K": 40 + "1K": 3, + "2K": 5 } }, "seedance2.0-fast": { @@ -65,11 +65,11 @@ }, "audio1.0": { "unit": "perGeneration", - "price": 10 + "price": 5 }, "chirp-v5": { "unit": "perGeneration", - "price": 5 + "price": 12 } } } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 7b6132704..c34abb18a 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1526,6 +1526,9 @@ mod tests { let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ "sourceImageSrc": source_image_src, + "aspectRatio": "1:1", + "imageSize": "1K", + "priceMudPoints": 3, }) .to_string(); assert!(request_body.len() > 2 * 1024 * 1024); @@ -1560,6 +1563,44 @@ mod tests { assert!(!body_text.contains("length limit exceeded")); } + #[tokio::test] + async fn editor_ui_design_asset_extraction_rejects_wrong_price_before_image_config() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138189", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_editor_ui_extract_price"); + let app = build_router(state); + let request_body = serde_json::json!({ + "sourceImageSrc": "data:image/png;base64,AAAA", + "aspectRatio": "1:1", + "imageSize": "2K", + "priceMudPoints": 3, + }) + .to_string(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/ui-designs/assets/extractions") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!(body_text.contains("priceMudPoints")); + } + #[tokio::test] async fn editor_image_generation_accepts_reference_body_above_default_limit() { let state = AppState::new(AppConfig::default()).expect("state should build"); @@ -4394,9 +4435,21 @@ mod tests { payload["models"]["gemini-3.1-flash-image-preview"]["prices"]["1K"], Value::Number(12.into()) ); + assert_eq!( + payload["models"]["gpt-image-2"]["prices"]["1K"], + Value::Number(3.into()) + ); assert_eq!( payload["models"]["gpt-image-2"]["prices"]["2K"], - Value::Number(40.into()) + Value::Number(5.into()) + ); + assert_eq!( + payload["models"]["audio1.0"]["price"], + Value::Number(5.into()) + ); + assert_eq!( + payload["models"]["chirp-v5"]["price"], + Value::Number(12.into()) ); assert_eq!( payload["models"]["seedance2.0"]["unit"], diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 8ca6d62e9..ab41ec044 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -596,17 +596,25 @@ mod tests { ), 12 ); + assert_eq!( + editor_image_generation_mud_points( + Some("ui-design"), + Some("gpt-image-2"), + Some("1K") + ), + 3 + ); assert_eq!( editor_image_generation_mud_points( Some("ui-design"), Some("gpt-image-2"), Some("2K") ), - 40 + 5 ); assert_eq!( editor_image_generation_mud_points(Some("spec"), Some("gpt-image-2"), Some("2K")), - 40 + 5 ); } @@ -630,11 +638,11 @@ mod tests { fn editor_audio_generation_price_uses_configured_model_rates() { assert_eq!( editor_sound_effect_model_generation_mud_points(Some("audio1.0")), - 10 + 5 ); assert_eq!( editor_background_music_model_generation_mud_points(Some("chirp-v5")), - 5 + 12 ); } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a78f9d277..6d681134b 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -230,6 +230,9 @@ pub struct EditorIconSpritesheetGenerationRequest { #[serde(rename_all = "camelCase")] pub struct EditorUiDesignAssetExtractionRequest { pub(crate) source_image_src: String, + pub(crate) aspect_ratio: String, + pub(crate) image_size: String, + pub(crate) price_mud_points: u32, pub(crate) project_id: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, @@ -1630,6 +1633,15 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "message": "UI设计图必须是图片 Data URL。", })) })?; + let generation_options = normalize_editor_ui_design_asset_extraction_options( + payload.aspect_ratio.as_str(), + payload.image_size.as_str(), + )?; + let expected_price_mud_points = validate_editor_ui_design_asset_extraction_price( + state, + payload.price_mud_points, + Some(generation_options.image_size), + )?; let settings = require_openai_image_settings(state)?.with_external_api_audit_context( request_context, caller.audit_subject_user_id.clone(), @@ -1645,7 +1657,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( GPT_IMAGE_2_MODEL, EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT, None, - EDITOR_IMAGE_GENERATION_SIZE, + generation_options.size.as_str(), 1, &[reference_image], "图片画布提取UI设计图素材 spritesheet", @@ -1746,15 +1758,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( model: GPT_IMAGE_2_MODEL.to_string(), provider: "VectorEngine", task_id: generated.task_id, - price_mud_points: state - .editor_generation_pricing() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })) - })? - .image_generation_mud_points(Some("icon"), Some(GPT_IMAGE_2_MODEL), Some("1K")), + price_mud_points: expected_price_mud_points, spritesheet_resource: spritesheet_record.resource, spritesheet_asset: spritesheet_record.asset, }, @@ -2307,6 +2311,60 @@ fn validate_editor_icon_spritesheet_price( Ok(expected_price_mud_points) } +fn editor_ui_design_asset_extraction_bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-ui-design-asset-extraction", + "message": message.into(), + })) +} + +fn normalize_editor_ui_design_asset_extraction_options( + aspect_ratio: &str, + image_size: &str, +) -> Result { + if aspect_ratio.trim() != "1:1" { + return Err(editor_ui_design_asset_extraction_bad_request( + "aspectRatio 必须固定为 1:1。", + )); + } + let normalized_image_size = match image_size.trim().to_ascii_uppercase().as_str() { + "1K" => "1K", + "2K" => "2K", + _ => { + return Err(editor_ui_design_asset_extraction_bad_request( + "imageSize 仅支持 1K 或 2K。", + )); + } + }; + Ok(normalize_editor_generation_options( + Some(GPT_IMAGE_2_MODEL), + Some("1:1"), + Some(normalized_image_size), + )) +} + +fn validate_editor_ui_design_asset_extraction_price( + state: &AppState, + price_mud_points: u32, + image_size: Option<&str>, +) -> Result { + let expected_price_mud_points = state + .editor_generation_pricing() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + })? + .image_generation_mud_points(Some("icon"), Some(GPT_IMAGE_2_MODEL), image_size); + if price_mud_points != expected_price_mud_points { + return Err(editor_ui_design_asset_extraction_bad_request(format!( + "priceMudPoints 与 UI 素材提取计费配置不一致,应为 {expected_price_mud_points}。" + ))); + } + Ok(expected_price_mud_points) +} + fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String { format!( "参考图1的图标素材规范,单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}", @@ -3248,10 +3306,10 @@ mod tests { 12 ); assert_eq!( - validate_editor_icon_spritesheet_price(&state, 20, Some("gpt-image-2"), Some("1K")).unwrap(), - 20 + validate_editor_icon_spritesheet_price(&state, 3, Some("gpt-image-2"), Some("1K")).unwrap(), + 3 ); - let error = validate_editor_icon_spritesheet_price(&state, 5, Some("gpt-image-2"), Some("1K")) + let error = validate_editor_icon_spritesheet_price(&state, 20, Some("gpt-image-2"), Some("1K")) .expect_err("wrong icon spritesheet price should fail"); assert!(error.body_text().contains("priceMudPoints")); let response = EditorIconSpritesheetGenerationResponse { @@ -3284,6 +3342,51 @@ mod tests { assert_eq!(payload["priceMudPoints"], json!(12)); } + #[test] + fn editor_ui_design_asset_extraction_price_uses_selected_gpt_image_size() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({ + "sourceImageSrc": "data:image/png;base64,AAAA", + "aspectRatio": "1:1", + "imageSize": "2K", + "priceMudPoints": 5 + })) + .expect("UI extraction request should deserialize dimensions and price"); + let generation_options = normalize_editor_ui_design_asset_extraction_options( + request.aspect_ratio.as_str(), + request.image_size.as_str(), + ) + .expect("UI extraction dimensions should pass"); + + assert_eq!(generation_options.model, GPT_IMAGE_2_MODEL); + assert_eq!(generation_options.size, "2048x2048"); + assert_eq!( + validate_editor_ui_design_asset_extraction_price( + &state, + request.price_mud_points, + Some(generation_options.image_size) + ) + .unwrap(), + 5 + ); + let ratio_error = match normalize_editor_ui_design_asset_extraction_options("16:9", "2K") + { + Ok(_) => panic!("UI extraction ratio should stay square"), + Err(error) => error, + }; + assert!(ratio_error.body_text().contains("aspectRatio")); + let size_error = match normalize_editor_ui_design_asset_extraction_options("1:1", "0.5K") + { + Ok(_) => panic!("UI extraction size should be 1K or 2K"), + Err(error) => error, + }; + assert!(size_error.body_text().contains("imageSize")); + let error = + validate_editor_ui_design_asset_extraction_price(&state, 3, Some("2K")) + .expect_err("wrong UI extraction price should fail"); + assert!(error.body_text().contains("priceMudPoints")); + } + #[test] fn generated_asset_folder_id_normalizes_project_and_legacy_local_folder() { assert_eq!( diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index fd3cba2e4..6e22b4f70 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -848,6 +848,18 @@ mod tests { .get("/api/external/v1/editor/ui-designs/assets/extractions") .is_some() ); + let ui_extraction_schema = + &parsed["components"]["schemas"]["EditorUiDesignAssetExtractionRequest"]; + assert_eq!(ui_extraction_schema["properties"]["aspectRatio"]["const"], "1:1"); + assert_eq!( + ui_extraction_schema["properties"]["imageSize"]["enum"], + json!(["1K", "2K"]) + ); + assert!( + ui_extraction_schema["properties"] + .get("priceMudPoints") + .is_some() + ); assert!( parsed["paths"] .get("/api/external/v1/editor/videos/generations") diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index a2b56f4a4..9dd0d5fec 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -1237,7 +1237,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect( within(specDialog).getByRole('button', { name: '提交生成规范' }) .textContent, - ).toContain('生成40泥点'); + ).toContain('生成5泥点'); fireEvent.change(screen.getByLabelText('玩法设定'), { target: { value: '平台跳跃玩法' }, @@ -1472,7 +1472,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'icon-gpt-model-1', - priceMudPoints: 20, + priceMudPoints: 3, }); render(); await screen.findByAltText('画布图片:拼图素材'); @@ -1538,7 +1538,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', - priceMudPoints: 20, + priceMudPoints: 3, }), ); }); @@ -2756,7 +2756,7 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'ui-extract-task-1', - priceMudPoints: 20, + priceMudPoints: 3, }); render(); @@ -2779,8 +2779,12 @@ describe('ImageCanvasEditorView generation integration', () => { const extractionToolbar = await screen.findByRole('toolbar', { name: 'UI素材框选工具', }); + expect(extractionToolbar).toBeTruthy(); + const extractionPanel = await screen.findByRole('dialog', { + name: 'UI素材提取', + }); expect( - within(extractionToolbar).getByRole('button', { + within(extractionPanel).getByRole('button', { name: '提取', }).disabled, ).toBe(true); @@ -2817,12 +2821,15 @@ describe('ImageCanvasEditorView generation integration', () => { }); expect( - within(extractionToolbar).getByRole('button', { + within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole('button', { name: '提取', }).disabled, ).toBe(false); fireEvent.click( - within(extractionToolbar).getByRole('button', { name: '提取' }), + within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole( + 'button', + { name: '提取' }, + ), ); await waitFor(() => { @@ -2841,6 +2848,9 @@ describe('ImageCanvasEditorView generation integration', () => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'data:image/png;base64,marked-ui-design', + aspectRatio: '1:1', + imageSize: '1K', + priceMudPoints: 3, projectId: 'editor-project-ui-extract', assetFolderId: 'project', spritesheetLabel: '战斗UI设计图 素材图集', @@ -2902,6 +2912,69 @@ describe('ImageCanvasEditorView generation integration', () => { }); }); + it('closes UI design asset extraction when clicking outside the source and panel', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-ui-extract-dismiss', + title: 'UI素材提取外部关闭画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-ui-design-dismiss', + resourceId: 'resource-ui-design-dismiss', + title: '背包UI设计图', + src: 'data:image/png;base64,dWktZGVzaWdu', + x: 120, + y: 140, + width: 560, + height: 315, + originalWidth: 2048, + originalHeight: 1152, + zIndex: 2, + sourceType: 'generated', + prompt: '背包UI', + actualPrompt: '背包UI', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'ui-design-dismiss-task-1', + assetKind: 'ui-design', + }, + ], + resources: [], + updatedAt: '2026-06-18T00:00:00.000Z', + }); + render(); + + const uiDesignImage = await screen.findByAltText( + '画布图片:背包UI设计图', + ); + fireEvent.pointerDown(uiDesignImage.closest('button')!, { + button: 0, + pointerId: 256, + clientX: 180, + clientY: 180, + }); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 256, + clientX: 180, + clientY: 180, + }); + fireEvent.click(screen.getByRole('button', { name: '提取素材' })); + + expect( + await screen.findByRole('dialog', { name: 'UI素材提取' }), + ).toBeTruthy(); + fireEvent.pointerDown(screen.getByTestId('image-canvas-world'), { + button: 0, + pointerId: 257, + clientX: 24, + clientY: 24, + }); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'UI素材提取' })).toBeNull(); + }); + }); + it('opens quick edit from the floating toolbar, hides the implicit source reference, and generates from a new frame', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-quick-edit', diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index c5fe87e27..bdf903e14 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -74,11 +74,15 @@ describe('ImageCanvasGenerationModel', () => { (model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG), ), ).toEqual([]); - expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(40); + expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']).toBe(3); + expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(5); expect(EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]).toBeTruthy(); expect( - EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO], + EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL], ).toBe(5); + expect( + EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO], + ).toBe(12); // 中文注释:Veo 已从前端入口移除,但客户端类型和后端兼容层仍保留,定价表也必须覆盖,避免旧布局回放或测试请求价格漂移。 expect(EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1]).toBeTruthy(); expect( @@ -90,8 +94,8 @@ describe('ImageCanvasGenerationModel', () => { expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe( EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'], ); - expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(20); - expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(40); + expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(3); + expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(5); expect( calculateEditorImageGenerationPrice({ kind: 'character', diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 270939bed..49eae3190 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -75,8 +75,8 @@ export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = { '2K': 24, }, [IMAGE_MODEL_GPT_IMAGE_2]: { - '1K': 20, - '2K': 40, + '1K': 3, + '2K': 5, }, } as const; export const SPEC_GENERATION_COST = @@ -338,10 +338,10 @@ export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = { [VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG, } as const; export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = { - [SOUND_EFFECT_MODEL_VIDU]: 10, + [SOUND_EFFECT_MODEL_VIDU]: 5, } as const; export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = { - [BACKGROUND_MUSIC_MODEL_SUNO]: 5, + [BACKGROUND_MUSIC_MODEL_SUNO]: 12, } as const; export const EDITOR_MODEL_MUD_POINT_CONFIG = { [IMAGE_MODEL_NANOBANANA2]: { diff --git a/src/components/image-editor/ImageCanvasOverlayModel.test.ts b/src/components/image-editor/ImageCanvasOverlayModel.test.ts index d3a5955c1..097dfbabb 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.test.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.test.ts @@ -12,6 +12,7 @@ import { resolveGenerationAnchor, resolveGenerationComposerStyle, resolveIconComposerStyle, + resolveQuickEditFocusViewport, resolveQuickEditPanelStyle, resolveSelectedToolbarStyle, } from './ImageCanvasOverlayModel'; @@ -129,6 +130,21 @@ describe('ImageCanvasOverlayModel', () => { ).toEqual({ left: 768, top: 88 }); }); + it('fits tall quick edit sources above the reserved bottom composer area', () => { + const sourceLayer = createLayer({ x: 100, y: 50, width: 360, height: 1600 }); + const viewport = resolveQuickEditFocusViewport({ + sourceLayer, + canvasSize: { width: 900, height: 640 }, + }); + const sourceTop = viewport.y + sourceLayer.y * viewport.scale; + const sourceBottom = sourceTop + sourceLayer.height * viewport.scale; + + expect(viewport.scale).toBeCloseTo(0.1925, 4); + expect(sourceTop).toBeCloseTo(56, 2); + expect(sourceBottom).toBeLessThanOrEqual(364); + expect(sourceBottom + 12 + 260).toBeLessThanOrEqual(640); + }); + it('positions quick edit and character animation panels with viewport bounds', () => { const sourceLayer = createLayer({ x: 740, y: 460, width: 240, height: 180 }); const quickEditPanel: QuickEditPanelState = { @@ -155,7 +171,7 @@ describe('ImageCanvasOverlayModel', () => { viewport: { x: 20, y: 10, scale: 1 }, canvasSize: { width: 900, height: 640 }, }), - ).toEqual({ left: 880, top: 280 }); + ).toEqual({ left: 880, top: 376 }); expect( resolveCharacterAnimationPanelStyle({ panel: characterPanel, diff --git a/src/components/image-editor/ImageCanvasOverlayModel.ts b/src/components/image-editor/ImageCanvasOverlayModel.ts index 92c58c541..be6bb684d 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.ts @@ -20,6 +20,12 @@ type CanvasSize = { type OverlayAnchor = Pick; +const QUICK_EDIT_FOCUS_HORIZONTAL_MARGIN = 56; +const QUICK_EDIT_FOCUS_SOURCE_TOP = 56; +const QUICK_EDIT_PANEL_GAP = 12; +const QUICK_EDIT_PANEL_ESTIMATED_HEIGHT = 260; +const QUICK_EDIT_PANEL_BOTTOM_MARGIN = 4; + export type CanvasOverlayStyle = { left: number; top: number; @@ -102,6 +108,42 @@ export function resolveSelectedToolbarStyle({ }; } +export function resolveQuickEditFocusViewport({ + sourceLayer, + canvasSize, +}: { + sourceLayer: CanvasLayer; + canvasSize: CanvasSize; +}): CanvasViewport { + const safeSourceWidth = Math.max(1, sourceLayer.width); + const safeSourceHeight = Math.max(1, sourceLayer.height); + const availableWidth = Math.max( + 1, + canvasSize.width - QUICK_EDIT_FOCUS_HORIZONTAL_MARGIN * 2, + ); + const availableHeight = Math.max( + 1, + canvasSize.height - + QUICK_EDIT_FOCUS_SOURCE_TOP - + QUICK_EDIT_PANEL_GAP - + QUICK_EDIT_PANEL_ESTIMATED_HEIGHT - + QUICK_EDIT_PANEL_BOTTOM_MARGIN, + ); + const scale = Math.min( + 1, + availableWidth / safeSourceWidth, + availableHeight / safeSourceHeight, + ); + + return { + scale, + x: + canvasSize.width / 2 - + (sourceLayer.x + sourceLayer.width / 2) * scale, + y: QUICK_EDIT_FOCUS_SOURCE_TOP - sourceLayer.y * scale, + }; +} + export function resolveQuickEditPanelStyle({ panel, sourceLayer, @@ -126,9 +168,14 @@ export function resolveQuickEditPanelStyle({ top: clamp( viewport.y + (sourceLayer.y + sourceLayer.height) * viewport.scale + - 12, - 12, - Math.max(12, canvasSize.height - 360), + QUICK_EDIT_PANEL_GAP, + QUICK_EDIT_PANEL_BOTTOM_MARGIN, + Math.max( + QUICK_EDIT_PANEL_BOTTOM_MARGIN, + canvasSize.height - + QUICK_EDIT_PANEL_ESTIMATED_HEIGHT - + QUICK_EDIT_PANEL_BOTTOM_MARGIN, + ), ), }; } diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx index 7639dcb72..8172d6f71 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx @@ -163,7 +163,7 @@ describe('ImageCanvasQuickEditPanelView', () => { ); expect(screen.getByRole('button', { name: '生成' }).textContent).toBe( - '生成20泥点', + '生成3泥点', ); }); diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index eb276b870..3a00f6ca0 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -117,13 +117,13 @@ export type ImageCanvasStageViewProps = { onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; - onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool) => void; + onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void; onUiAssetExtractionPointerStart: (point: { x: number; y: number }) => void; onUiAssetExtractionPointerMove: (point: { x: number; y: number }) => void; onUiAssetExtractionPointerEnd: () => void; onCancelUiAssetExtraction: () => void; onSubmitUiAssetExtraction: () => void; - onQuickEditSelectionToolChange: (tool: UiAssetExtractionTool) => void; + onQuickEditSelectionToolChange: (tool: UiAssetExtractionTool | null) => void; onQuickEditSelectionPointerStart: (point: { x: number; y: number }) => void; onQuickEditSelectionPointerMove: (point: { x: number; y: number }) => void; onQuickEditSelectionPointerEnd: () => void; diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts b/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts index 2877d20ea..0662ae4d4 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts @@ -26,25 +26,48 @@ export type UiAssetExtractionMark = export type UiAssetExtractionState = { sourceLayerId: string; - tool: UiAssetExtractionTool; + tool: UiAssetExtractionTool | null; marks: UiAssetExtractionMark[]; draftMark: UiAssetExtractionMark | null; status: 'idle' | 'extracting' | 'failed'; errorMessage?: string; }; +export const UI_ASSET_EXTRACTION_ASPECT_RATIO = '1:1'; +export const UI_ASSET_EXTRACTION_SMALL_IMAGE_SIZE = '1K'; +export const UI_ASSET_EXTRACTION_LARGE_IMAGE_SIZE = '2K'; +export const UI_ASSET_EXTRACTION_LARGE_SIZE_MARK_THRESHOLD = 6; + +export type UiAssetExtractionImageSize = + | typeof UI_ASSET_EXTRACTION_SMALL_IMAGE_SIZE + | typeof UI_ASSET_EXTRACTION_LARGE_IMAGE_SIZE; + export function createUiAssetExtractionState( sourceLayerId: string, + options: { initialTool?: UiAssetExtractionTool | null } = {}, ): UiAssetExtractionState { return { sourceLayerId, - tool: 'rect', + tool: options.initialTool === undefined ? 'rect' : options.initialTool, marks: [], draftMark: null, status: 'idle', }; } +export function resolveUiAssetExtractionGenerationPlan(markCount: number): { + aspectRatio: typeof UI_ASSET_EXTRACTION_ASPECT_RATIO; + imageSize: UiAssetExtractionImageSize; +} { + return { + aspectRatio: UI_ASSET_EXTRACTION_ASPECT_RATIO, + imageSize: + markCount > UI_ASSET_EXTRACTION_LARGE_SIZE_MARK_THRESHOLD + ? UI_ASSET_EXTRACTION_LARGE_IMAGE_SIZE + : UI_ASSET_EXTRACTION_SMALL_IMAGE_SIZE, + }; +} + export function createUiAssetExtractionDraftMark({ tool, id, diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx index e6217093b..362a9aeb8 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx @@ -73,11 +73,98 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { expect( within(toolbar).getByRole('button', { name: '椭圆框选' }), ).toBeTruthy(); + expect( + within(toolbar) + .getByRole('button', { name: '矩形框选' }) + .getAttribute('aria-pressed'), + ).toBe('true'); + fireEvent.click( + within(toolbar).getByRole('button', { name: '矩形框选' }), + ); fireEvent.click( within(toolbar).getByRole('button', { name: '画笔自由框选' }), ); - expect(handleToolChange).toHaveBeenCalledWith('brush'); + expect(handleToolChange).toHaveBeenNthCalledWith(1, null); + expect(handleToolChange).toHaveBeenNthCalledWith(2, 'brush'); + }); + + it('renders UI extraction tools on the right and a bottom composer with previews and gpt-image-2 pricing', () => { + const marks = Array.from({ length: 7 }, (_, index) => ({ + id: `mark-${index + 1}`, + tool: 'rect' as const, + x: index * 8, + y: index * 6, + width: 48, + height: 36, + })); + + render( + , + ); + + expect( + screen.getByRole('toolbar', { name: 'UI素材框选工具' }).className, + ).toContain('image-canvas-editor__ui-extraction-toolbar--right'); + const panel = screen.getByRole('dialog', { name: 'UI素材提取' }); + expect(panel.className).toContain( + 'image-canvas-editor__ui-extraction-panel', + ); + expect(within(panel).getByText('仅提取被红色框框选的素材并整理成spritesheet')).toBeTruthy(); + expect(within(panel).getAllByLabelText(/框选区域预览/u)).toHaveLength(7); + expect(within(panel).getByLabelText('计划规格').textContent).toBe('1:1·2K'); + expect(within(panel).getByLabelText('固定模型').textContent).toContain('gpt-image-2'); + expect( + within(panel).queryByRole('button', { name: '取消框选' }), + ).toBeNull(); + expect(within(panel).getByRole('button', { name: '提取' }).textContent).toContain('5泥点'); + }); + + it('crops mark previews from the same source coordinates as the red selection', () => { + render( + , + ); + + const preview = screen.getByLabelText('框选区域预览 1'); + const previewImage = within(preview).getByRole('img', { + name: '源图框选预览 1', + }) as HTMLImageElement; + + expect(previewImage.getAttribute('src')).toBe('data:image/png;base64,ui'); + expect(previewImage.style.width).toBe('800%'); + expect(previewImage.style.height).toBe('800%'); + expect(previewImage.style.left).toBe('-200%'); + expect(previewImage.style.top).toBe('-200%'); }); it('shows numbered labels for quick edit red selections', () => { diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx index 884a4324e..8a86db699 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx @@ -1,16 +1,23 @@ -import { Circle, MousePointer2, Paintbrush, Square, X } from 'lucide-react'; +import { Circle, Paintbrush, Square, X } from 'lucide-react'; import type { ComponentType, SVGProps } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformIconButton } from '../common/PlatformIconButton'; import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; +import { + calculateEditorIconSpritesheetPrice, + IMAGE_MODEL_GPT_IMAGE_2, +} from './ImageCanvasGenerationModel'; import type { UiAssetExtractionMark, UiAssetExtractionState, UiAssetExtractionTool, } from './ImageCanvasUiAssetExtractionModel'; -import { buildUiAssetExtractionBrushPath } from './ImageCanvasUiAssetExtractionModel'; +import { + buildUiAssetExtractionBrushPath, + resolveUiAssetExtractionGenerationPlan, +} from './ImageCanvasUiAssetExtractionModel'; type ImageCanvasUiAssetExtractionOverlayViewProps = { variant?: 'ui-extraction' | 'quick-edit'; @@ -18,7 +25,7 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = { sourceLayer: CanvasLayer | null; viewport: CanvasViewport; state: UiAssetExtractionState | null; - onToolChange: (tool: UiAssetExtractionTool) => void; + onToolChange: (tool: UiAssetExtractionTool | null) => void; onPointerStart: (point: { x: number; y: number }) => void; onPointerMove: (point: { x: number; y: number }) => void; onPointerEnd: () => void; @@ -70,6 +77,60 @@ function resolveMarkLabelPoint(mark: UiAssetExtractionMark) { }; } +function resolveMarkPreviewBounds( + mark: UiAssetExtractionMark, + sourceLayer: CanvasLayer, +) { + if (mark.tool === 'brush') { + const xs = mark.points.map((point) => point.x); + const ys = mark.points.map((point) => point.y); + const left = Math.min(...xs); + const top = Math.min(...ys); + const right = Math.max(...xs); + const bottom = Math.max(...ys); + return { + x: Number.isFinite(left) ? left : 0, + y: Number.isFinite(top) ? top : 0, + width: Math.max(1, Number.isFinite(right - left) ? right - left : 1), + height: Math.max(1, Number.isFinite(bottom - top) ? bottom - top : 1), + }; + } + return { + x: Math.min(mark.x, mark.x + mark.width), + y: Math.min(mark.y, mark.y + mark.height), + width: Math.max(1, Math.abs(mark.width)), + height: Math.max(1, Math.abs(mark.height)), + }; +} + +function renderMarkPreview( + mark: UiAssetExtractionMark, + index: number, + sourceLayer: CanvasLayer, +) { + const bounds = resolveMarkPreviewBounds(mark, sourceLayer); + const originalWidth = Math.max(1, sourceLayer.originalWidth); + const originalHeight = Math.max(1, sourceLayer.originalHeight); + return ( +
+ {index + 1} +
+ ); +} + function renderMark( mark: UiAssetExtractionMark, index: number, @@ -139,6 +200,13 @@ export function ImageCanvasUiAssetExtractionOverlayView({ const isQuickEdit = variant === 'quick-edit'; const marks = [...state.marks, ...(state.draftMark ? [state.draftMark] : [])]; const canSubmit = state.marks.length > 0 && state.status !== 'extracting'; + const extractionPlan = resolveUiAssetExtractionGenerationPlan( + state.marks.length, + ); + const extractionPrice = calculateEditorIconSpritesheetPrice( + IMAGE_MODEL_GPT_IMAGE_2, + extractionPlan.imageSize, + ); const screenFrame = { left: viewport.x + sourceLayer.x * viewport.scale, top: viewport.y + sourceLayer.y * viewport.scale, @@ -149,77 +217,34 @@ export function ImageCanvasUiAssetExtractionOverlayView({ return ( <>
event.stopPropagation()} > - {isQuickEdit ? null : ( - <> - - -
{ event.preventDefault(); @@ -270,6 +296,67 @@ export function ImageCanvasUiAssetExtractionOverlayView({ renderMark(mark, index, showIndexLabels && index < state.marks.length), )} + {!isQuickEdit ? ( +
event.stopPropagation()} + > +
+ 仅提取被红色框框选的素材并整理成spritesheet +
+
+ {state.marks.length + ? state.marks.map((mark, index) => + renderMarkPreview(mark, index, sourceLayer), + ) + : ( + + 框选后显示预览 + + )} +
+
+ + {`${extractionPlan.aspectRatio}·${extractionPlan.imageSize}`} + + + {IMAGE_MODEL_GPT_IMAGE_2} + + } + onClick={onCancel} + /> + + {state.status === 'extracting' + ? '提取中' + : `提取 · ${extractionPrice}泥点`} + +
+
+ ) : null} {state.errorMessage ? (
{ if (sourceLayer) { void workflow.extractUiDesignAssets(sourceLayer, { - marks: [ - { - id: 'mark-1', - tool: 'rect', - x: 10, - y: 20, - width: 80, - height: 60, - }, - ], + marks: Array.from({ length: 7 }, (_, index) => ({ + id: `mark-${index + 1}`, + tool: 'rect' as const, + x: 10 + index * 8, + y: 20 + index * 6, + width: 80, + height: 60, + })), }); } }} @@ -1191,7 +1189,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { referenceImageSrc: 'data:image/png;base64,spec', iconDescriptions: ['返回按钮', '设置按钮'], model: 'gpt-image-2', - priceMudPoints: 20, + priceMudPoints: 3, }), ); }); @@ -1271,7 +1269,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); - it('renders selected UI marks into the extraction reference image', async () => { + it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => { extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 512, @@ -1300,24 +1298,32 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { fireEvent.click(screen.getByRole('button', { name: '提取已框选UI素材' })); await waitFor(() => { - expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith({ - source: 'data:image/png;base64,ui-design.png', - marks: [ - { - id: 'mark-1', - tool: 'rect', - x: 10, - y: 20, - width: 80, - height: 60, - }, - ], - }); + expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'data:image/png;base64,ui-design.png', + marks: expect.arrayContaining([ + { + id: 'mark-1', + tool: 'rect', + x: 10, + y: 20, + width: 80, + height: 60, + }, + ]), + }), + ); + expect( + renderUiDesignAssetExtractionMarkedImageMock.mock.calls[0]?.[0].marks, + ).toHaveLength(7); }); await waitFor(() => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'data:image/png;base64,marked-ui-design', + aspectRatio: '1:1', + imageSize: '2K', + priceMudPoints: 5, generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 99d905492..e8c9afa18 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -51,11 +51,13 @@ import { buildUiDesignAssetExtractionGenerationInputs, buildVideoGenerationInputs, calculateCharacterAnimationPrice, + calculateEditorIconSpritesheetPrice, calculateEditorVideoPrice, CHARACTER_ANIMATION_MODEL, DEFAULT_VIDEO_MODEL, ICON_FRAME_DISPLAY_SIZE, ICON_FRAME_ORIGINAL_SIZE, + IMAGE_MODEL_GPT_IMAGE_2, inferEditorImageAspectRatio, inferEditorImageSizeLabel, isCanvasGenerationDialog, @@ -74,6 +76,7 @@ import type { UiAssetExtractionMark, UiAssetExtractionState, } from './ImageCanvasUiAssetExtractionModel'; +import { resolveUiAssetExtractionGenerationPlan } from './ImageCanvasUiAssetExtractionModel'; import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel'; type CanvasSize = { width: number; height: number }; @@ -575,12 +578,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } const generationInputs = buildUiDesignAssetExtractionGenerationInputs(sourceLayer); + const extractionPlan = resolveUiAssetExtractionGenerationPlan( + options.marks?.length ?? 0, + ); + const priceMudPoints = calculateEditorIconSpritesheetPrice( + IMAGE_MODEL_GPT_IMAGE_2, + extractionPlan.imageSize, + ); const generated = await extractEditorUiDesignAssets({ sourceImageSrc, projectId, generationInputs, assetFolderId, spritesheetLabel: `${sourceLayer.title} 素材图集`, + aspectRatio: extractionPlan.aspectRatio, + imageSize: extractionPlan.imageSize, + priceMudPoints, }); const extractionFrame = { x: sourceLayer.x + sourceLayer.width + 32, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index db5acb35b..a97869b00 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -202,7 +202,7 @@ function GenerationWorkflowHarness({ {workflow.uiAssetExtractionState - ? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}` + ? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool ?? 'none'}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}` : '-'} @@ -210,7 +210,7 @@ function GenerationWorkflowHarness({ {workflow.quickEditSelectionState - ? `${workflow.quickEditSelectionState.sourceLayerId}:${workflow.quickEditSelectionState.tool}:${workflow.quickEditSelectionState.marks.length}` + ? `${workflow.quickEditSelectionState.sourceLayerId}:${workflow.quickEditSelectionState.tool ?? 'none'}:${workflow.quickEditSelectionState.marks.length}` : '-'} @@ -506,6 +506,12 @@ function GenerationWorkflowHarness({ > 填写快速编辑 +
); @@ -172,7 +197,7 @@ function renderMark( className="image-canvas-editor__ui-extraction-mark-label" transform={`translate(${labelPoint.x} ${labelPoint.y})`} > - + {index + 1} @@ -191,7 +216,6 @@ export function ImageCanvasUiAssetExtractionOverlayView({ onPointerStart, onPointerMove, onPointerEnd, - onCancel, onSubmit, }: ImageCanvasUiAssetExtractionOverlayViewProps) { if (!sourceLayer || !state) { @@ -308,7 +332,7 @@ export function ImageCanvasUiAssetExtractionOverlayView({ onPointerDown={(event) => event.stopPropagation()} >
- 仅提取被红色框框选的素材并整理成spritesheet + 使用框选工具框选你希望从画面中提取的素材
{IMAGE_MODEL_GPT_IMAGE_2} - } - onClick={onCancel} - /> Date: Mon, 22 Jun 2026 21:44:07 +0800 Subject: [PATCH 14/15] Add Agent execution guidelines & trim AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new detailed Agent handbook (docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md) and refactor AGENTS.md to be an entry-only navigation page. Update documentation cross-references (docs/README.md, project-memory README, document-map, team-conventions, development-workflow, decision-log and project-overview) to point to the new file and clarify reading/order rules, submission/validation constraints, and SpacetimeDB/back-end wording. Also adjust image canvas generation placement rules in the frontend doc and apply related changes to image-editor hooks/tests (src/components/image-editor/*). Purpose: separate high-level entry rules from complex execution details to reduce startup cost for complex tasks and centralize execution practices in a single guideline document. --- AGENTS.md | 112 +++++++---------- docs/README.md | 3 +- docs/project-memory/README.md | 2 +- .../shared-memory/decision-log.md | 8 ++ .../shared-memory/development-workflow.md | 5 +- .../shared-memory/document-map.md | 11 +- .../shared-memory/project-overview.md | 1 + .../shared-memory/team-conventions.md | 29 ++--- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...规范】Agent工作入口与执行准则-2026-06-22.md | 116 ++++++++++++++++++ ...】生成类面板Lovart统一改造方案-2026-06-17.md | 2 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 77 ++++++++++++ ...ImageCanvasGenerationSubmissionWorkflow.ts | 49 +++++--- .../useImageCanvasGenerationWorkflow.ts | 1 + 14 files changed, 307 insertions(+), 111 deletions(-) create mode 100644 docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md diff --git a/AGENTS.md b/AGENTS.md index 5699de822..65504fb28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,93 +1,65 @@ # AGENTS.md -## 项目共享记忆 -- 本仓库的团队级项目记忆位于 [`docs/project-memory/`](docs/project-memory/),用于在 3 名开发人员和各自本地 Agent 之间同步长期项目知识。 -- [`.hermes/`](.hermes/) 只保存 Hermes 专用的仓库级工具资源,例如 skills、plugins 和启用说明,不作为项目知识库。 -- 开始复杂开发任务前,除阅读本文件外,还应优先读取: - - [`docs/project-memory/README.md`](docs/project-memory/README.md) - - [`docs/project-memory/shared-memory/project-overview.md`](docs/project-memory/shared-memory/project-overview.md) - - [`docs/project-memory/shared-memory/team-conventions.md`](docs/project-memory/shared-memory/team-conventions.md) - - [`docs/project-memory/shared-memory/development-workflow.md`](docs/project-memory/shared-memory/development-workflow.md) - - 与任务相关的 [`docs/project-memory/shared-memory/decision-log.md`](docs/project-memory/shared-memory/decision-log.md) 和 [`docs/project-memory/shared-memory/pitfalls.md`](docs/project-memory/shared-memory/pitfalls.md) -- 仅在需要使用仓库级 Hermes skills/plugins 时,再读取 [`.hermes/README.md`](.hermes/README.md);长期项目记忆不要从 `.hermes/` 读取。 -- 如果本次任务产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则,应同步更新 `docs/project-memory/shared-memory/` 中对应文件。 -- 禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。 -- 若 `docs/project-memory/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。 +## 入口定位 -## Agent 本地 RAG -- 本仓库提供面向 Agent 的本地文档 RAG,入口位于 [`scripts/rag/`](scripts/rag/);RAG 主要用于 Agent 检索项目上下文,不替代人工阅读 `AGENTS.md`、`docs/README.md` 和 `docs/project-memory/`。 -- 开始复杂任务、跨模块任务或不确定文档入口时,Agent 可先用 `npm run rag:search -- --query "问题或关键词" --limit 8 --max-chars 12000` 取候选上下文;需要刷新索引时运行 `npm run rag:index`。 -- RAG 输出只作为候选上下文。涉及精确代码或文档修改时,仍需打开对应源文件核对;来源冲突时,以当前代码和最新 `docs/` 为准。 -- 默认不安装 RAG 运行时依赖,也不把 LanceDB、Transformers.js 或本地 embedding 模型写入根 `package.json`。需要启用时,Agent 必须先询问用户是否安装,并在确认后只安装到 gitignored 的 `.rag/runtime/`;详细命令见 [`scripts/rag/README.md`](scripts/rag/README.md)。 +- 本文件只保留 Agent 进入仓库后必须立即遵守的最高优先级规则;完整执行细则见 [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。 +- 团队级长期项目记忆位于 [`docs/project-memory/`](docs/project-memory/),供 3 名开发人员和各自本地 Agent 通过 Git 同步。 +- [`.hermes/`](.hermes/) 只保存 Hermes 专用仓库级工具资源,例如 skills、plugins 和启用说明;长期项目知识不要写入 `.hermes/`。 +- 若 `docs/project-memory/shared-memory/` 与当前代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。 -## Agent skills +## 开始任务前 -### Issue tracker +- 简单自包含任务可以直接执行;复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按顺序读取: + 1. 本文件。 + 2. [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。 + 3. [`docs/project-memory/README.md`](docs/project-memory/README.md)、[`project-overview.md`](docs/project-memory/shared-memory/project-overview.md)、[`team-conventions.md`](docs/project-memory/shared-memory/team-conventions.md)、[`development-workflow.md`](docs/project-memory/shared-memory/development-workflow.md)。 + 4. 与任务相关的 [`decision-log.md`](docs/project-memory/shared-memory/decision-log.md)、[`pitfalls.md`](docs/project-memory/shared-memory/pitfalls.md)、[`docs/README.md`](docs/README.md) 和当前专题文档。 +- 落地工程修改前,先确认是否已有足够具体的 PRD、技术方案或当前融合文档;文档仍存在编码级歧义时,先补文档再编码。 +- 本仓库的本地 RAG 位于 [`scripts/rag/`](scripts/rag/);RAG 只作为候选上下文,不替代打开源文件核对。默认不安装 RAG 运行时依赖,需要启用时必须先询问用户,并只安装到 gitignored 的 `.rag/runtime/`。 -Issues are tracked in the self-hosted Gitea remote for this repo. Use Gitea Issues via the configured Gitea UI/API or `tea` CLI when available; do not use GitHub `gh` or GitLab `glab` unless the repo is migrated. Current issue workflow is summarized in `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`. +## 绝对约束 -### Triage labels +- 禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。 +- 不要在 `.gitignore` 中新增 `.env.local`。 +- 不要擅自把现有中文文案、注释、剧情或文档改写成英文;看到中文乱码时先确认真实编码,不要沿用乱码或用英文替换。 +- 修改包含中文的文件时优先局部补丁,避免整文件重写;修改后优先运行仓库编码检查。 +- 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。 +- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。 +- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。 +- UI 面板中不要默认写功能说明、规则描述或开发解释文案;移动端优先,同时保证网页端可正常显示和操作。 +- 点击按钮弹出独立面板的设计,不要实现成在当前面板下面追加内容。 -Use the default canonical triage labels: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. +## 任务路由 -### Domain docs +- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI,不使用 GitHub `gh` 或 GitLab `glab`,除非仓库已迁移。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。 +- 需要仓库级 Hermes skills/plugins 时,再读取 [`.hermes/README.md`](.hermes/README.md)。 +- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。 +- 涉及 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` / `npm run dev:web` / `npm run dev:admin-web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`](.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。 +- 涉及 SpacetimeDB 的设计、实现、脚本、调试、发布、绑定生成、schema、reducer、procedure、view 或 Rust API 时,必须读取并按 [`spacetimedb-cli`](.codex/skills/spacetimedb-cli/SKILL.md)、[`spacetimedb-rust`](.codex/skills/spacetimedb-rust/SKILL.md)、[`spacetimedb-concepts`](.codex/skills/spacetimedb-concepts/SKILL.md) 中相关 skill 执行。 -Single-context layout: read root `CONTEXT.md` when present. Current architecture and product constraints are consolidated under `docs/`. +## 后端红线 -### 新增玩法接入 - -- 凡是新增、补齐、迁移或重构任何玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 的任务,开始前必须显式读取并按 [$genarrative-play-type-integration](.codex\skills\genarrative-play-type-integration\SKILL.md) 执行;未先使用该 skill 的,不允许进入编码。 - -## 项目约束 -- 代码需要有完善的中文注释 -- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 -- 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中 -- 后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;例如 `【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md`。不要求批量重命名历史文档,除非本次任务明确涉及该文档。 -- 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。 -- 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。 -- 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。 -- 非必要不要整文件重写,尤其是包含中文的文件;优先做局部补丁,避免把未改动的中文内容重新编码。 -- 修改包含中文的文件后,优先运行仓库里的编码检查,确保没有把文本写坏。 -- UI面板中不要默认写一些规则描述文案,清爽一些,按照游戏UI设计规范设计即可。 -- UI设计需要兼顾网页端、移动端双端的使用体验,确保在不同设备上都能正常显示和操作,移动端优先考虑。 -- 不要在gitignore中添加.env.local文件。 -- 提交代码时,提交标题必须使用中文;标题后必须逐行写明本次提交修改了什么,每条变更单独一行。 -- 严格遵循简洁的代码风格 -- 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。 -- 禁止将功能说明描述类的文本默认写入UI界面中。 -- prd文档中每个模块的描述要落地设计到可以精准编码到位,不能出现需求落地漂移。 -- 点击按钮弹出独立的面板的设计不要实现成在当前面板下面显示内容。 -- 每个阶段任务完成后自动压缩上下文,确保后续阶段在清晰、低噪音的上下文基础上继续推进。 - -## 后端技术约束 - 后端最新技术约束以 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 为准。 -- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。 -- SpacetimeDB 表结构、自动迁移限制和冲突处理以当前后端架构文档的 schema 变更规则和表目录为准;涉及 table、reducer、procedure、row shape 或绑定变化时,必须同步 `migration.rs`、表目录和生成绑定。 -- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值(例如 `#[default(...)]`);需要修改字段名时,必须先询问用户并确认迁移计划,再改代码,同时更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录和生成绑定。 -- 修改 SpacetimeDB schema 后必须运行 `npm run check:spacetime-schema`;该检查会拦截新增字段缺 default、字段不在末尾、字段删除/改名/重排/改类型,以及漏改 `migration.rs`、表目录或生成绑定。 -- 后端路线固定为 `server-rs + Axum + SpacetimeDB`。旧 `server-node`、Express、PostgreSQL 不再作为兼容目标;历史实现只能作为迁移参考,若旧文档与 DDD 约束冲突,先修正文档和方案再编码。 +- 后端路线固定为 `server-rs + Axum + SpacetimeDB`;旧 `server-node`、Express、PostgreSQL、Go 服务端、`maincloud` / `Maincloud` / `MAINCLOUD` 只作为历史残留,不作为兼容目标。 - DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。 - 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。 -- 修改后端代码后,按对应 DDD 文档中的验收命令执行测试;涉及 API smoke 时使用 `npm run api-server` 重新拉起后端并执行相应自动测试,同时确认 `/healthz`。 -- `maincloud` / `Maincloud` / `MAINCLOUD` 相关脚本、环境变量、测试、文档要求和命名全部视为历史残留,禁止新增、运行或引用;若旧材料仍要求 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`,以当前后端架构文档和本文件为准。 -- 除 CI/CD 脚本内部受控用法外,人工命令、本地联调、排障步骤和文档示例禁止继续使用 `spacetime --root-dir`。本地数据隔离使用项目脚本或 `--data-dir`,发布目标必须显式传 `--server` / `--server-url`,身份问题通过同一 CLI 登录态、专用运行用户或显式 token 处理;若旧文档仍推荐 `--root-dir`,先修正文档口径再执行。 -- 凡是涉及 SpacetimeDB 的设计、实现、脚本、调试、前端绑定接入,统一显式使用以下 skill 作为执行依据: - - [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md) - - [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md) - - [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md) -- 涉及 `spacetime` CLI、发布、绑定生成、本地联调时,按 `spacetimedb-cli` 执行。 -- 涉及 `npm run dev` / `npm run dev:rust` / `npm run dev:web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`](.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。 -- 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust` 与 `spacetimedb-concepts` 执行。 -- 涉及前端或 Node 侧的 SpacetimeDB 订阅、绑定使用时,按当前生成绑定、项目代码和官方文档核对;本仓库不再维护单独 TypeScript / C# / Unity SpacetimeDB skill。 -- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。 -- 修改后端代码后,必须使用 `npm run api-server` 自动重新运行后端,并执行相应自动测试;不要再使用旧的后端重启命令。 -- 数据库表结构更改后,需要对齐migration.rs +- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。 +- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。 +- 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。 +- 除 CI/CD 脚本内部受控用法外,人工命令、本地联调、排障步骤和文档示例禁止继续使用 `spacetime --root-dir`。 + +## 验证与提交 + +- 修改后按范围运行定向测试、类型检查、`npm run check:encoding` 和 `git diff --check`;后端 API smoke 使用 `npm run dev:api-server` 拉起后端并检查 `/healthz`。 +- 修改 SpacetimeDB schema 后追加 `npm run check:spacetime-schema`;涉及发布或生产运维时按当前开发运维文档和脚本门禁执行。 +- 提交代码时,提交标题必须使用中文;标题后逐行写明本次提交修改了什么,每条变更单独一行。 ## 文档图谱 ```text docs/ ├─ README.md +├─ 【协作规范】Agent工作入口与执行准则-2026-06-22.md ├─ 【项目基线】当前产品与工程约束-2026-05-15.md ├─ 【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md ├─ 【玩法创作】平台入口与玩法链路-2026-05-15.md diff --git a/docs/README.md b/docs/README.md index 2f04921f6..c2c64c992 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,10 @@ # 文档总览 -`docs/` 现在按主题拆成了 6 类;旧后端路线文档开始聚合和删除,后续实现以 Rust / SpacetimeDB 当前基线为准。 +`docs/` 现在按主题维护项目当前口径;旧后端路线文档开始聚合和删除,后续实现以 Rust / SpacetimeDB 当前基线为准。 ## 快速入口 +- [Agent 工作入口与执行准则](./%E3%80%90%E5%8D%8F%E4%BD%9C%E8%A7%84%E8%8C%83%E3%80%91Agent%E5%B7%A5%E4%BD%9C%E5%85%A5%E5%8F%A3%E4%B8%8E%E6%89%A7%E8%A1%8C%E5%87%86%E5%88%99-2026-06-22.md):复杂任务前的 Agent 阅读顺序、执行边界、技能路由、文档规则和验证口径。 - [经验沉淀](./experience/README.md):项目开发经验、UI 交接、历史实现经验。 - [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计。 - [系统设计](./design/README.md):玩法、关系、物品与对话设计。 diff --git a/docs/project-memory/README.md b/docs/project-memory/README.md index f95953f80..8245289d0 100644 --- a/docs/project-memory/README.md +++ b/docs/project-memory/README.md @@ -21,7 +21,7 @@ docs/project-memory/ ## 使用原则 -- 开发前先读 `AGENTS.md`,再按任务读取 `docs/project-memory/shared-memory/` 和当前 `docs/` 文档。 +- 开发前先读 `AGENTS.md`;复杂任务继续读取 `docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`,再按任务读取 `docs/project-memory/shared-memory/` 和当前 `docs/` 文档。 - 长期有效的架构约定、接口变化、排障经验、开发流程和协作规则写入 `shared-memory/`。 - 阶段性计划写入 `plans/`,已确定但暂未实施的共享 TODO 写入 `todos/`。 - 如果本目录内容与代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期记忆。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b1516663c..8b183e01f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -24,6 +24,14 @@ - 验证方式:运行 `editor_generation_config`、公开定价路由、图标素材价格校验、图片画布定价模型和后台定价页相关测试。 - 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 +## 2026-06-22 AGENTS.md 收敛为入口导航 + +- 背景:`AGENTS.md` 同时承载项目记忆、RAG、Issue、UI、Git、后端、SpacetimeDB 和文档图谱等细则,入口过重,复杂任务启动成本高。 +- 决策:`AGENTS.md` 只保留最高优先级规则、任务路由、后端红线、验证提交要求和文档图谱;新增 `docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md` 承接完整执行细则。复杂任务阅读顺序固定为 `AGENTS.md` -> Agent 执行准则 -> `docs/project-memory/` -> `docs/README.md` 和专题文档。 +- 影响范围:`AGENTS.md`、`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`、`docs/README.md`、`docs/project-memory/README.md` 和共享记忆索引。 +- 验证方式:执行 `npm run check:encoding`、`git diff --check`,并检查入口文档不再重复承载专题细则。 +- 关联文档:`AGENTS.md`、`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`。 + ## 2026-06-22 图片画布角色动作主媒体改为透明序列帧 - 背景:角色动作生成后端已经在视频生成后抽取透明 PNG 帧并完成绿幕去背;画板继续把 `previewVideoPath` 当主媒体会让用户看到未扣绿幕视频,下载也拿不到可直接用于游戏素材的帧序列。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index af677416b..5a76fb868 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -5,7 +5,7 @@ ## 标准任务流程 ```text -同步代码 → 读取 AGENTS.md → 读取 docs/project-memory/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 → 本地验证 → 更新文档/记忆 → 提交 +同步代码 → 读取 AGENTS.md → 复杂任务读取 Agent 执行准则 → 读取 docs/project-memory/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 → 本地验证 → 更新文档/记忆 → 提交 ``` ## 建议启动方式 @@ -30,6 +30,7 @@ hermes - [ ] 当前分支是否正确 - [ ] 是否已拉取最新代码 - [ ] 是否阅读 `AGENTS.md` +- [ ] 复杂任务是否阅读 `docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md` - [ ] 是否阅读 `docs/project-memory/shared-memory/` 相关文件 - [ ] 是否阅读 `README.md` 中的运行和检查命令 - [ ] 是否阅读 `docs/README.md` 及任务相关分类 README @@ -294,7 +295,7 @@ npm run check:server-rs-ddd ```text 请检查当前 git diff,指出: -1. 是否违反 AGENTS.md 或 docs/project-memory/shared-memory 约定; +1. 是否违反 AGENTS.md、Agent 执行准则或 docs/project-memory/shared-memory 约定; 2. 是否需要补充 docs; 3. 是否有长期知识需要写入 docs/project-memory/shared-memory; 4. 建议的测试命令和提交信息。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index 24a66370d..3952997e7 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -1,12 +1,13 @@ # 文档地图与阅读索引 -更新时间:`2026-05-15` +更新时间:`2026-06-22` ## 当前文档入口 | 场景 | 优先阅读 | | --- | --- | | 建立项目背景 | `README.md`、`AGENTS.md`、`docs/project-memory/shared-memory/project-overview.md` | +| Agent 复杂任务执行规则 | `AGENTS.md`、`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md` | | 找当前文档 | `docs/README.md` | | 产品、命名、UI、协作和废弃路线 | `docs/【项目基线】当前产品与工程约束-2026-05-15.md` | | 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` | @@ -22,9 +23,10 @@ 通用复杂任务: 1. `AGENTS.md` -2. `docs/project-memory/shared-memory/` -3. `docs/README.md` -4. 与任务匹配的当前融合文档 +2. `docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md` +3. `docs/project-memory/shared-memory/` +4. `docs/README.md` +5. 与任务匹配的当前融合文档 后端 / 数据真相 / SpacetimeDB: @@ -48,6 +50,7 @@ ## 维护规则 - 当前 `docs/` 只保留少量融合文档。 +- `AGENTS.md` 只保留最高优先级入口;Agent 执行细则优先沉到 `docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md` 或对应专题文档。 - 新增工程实现时,如果已有对应当前文档,必须同步更新。 - 如果没有合适位置,新文档文件名必须使用 `【标签名】中文标题-YYYY-MM-DD.md`。 - 阶段性流水账、一次性修复记录和已关闭实验不要再新增为长期文档。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 10efa6a22..81920e365 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -51,6 +51,7 @@ server-rs + Axum + SpacetimeDB ## 当前文档入口 - `docs/README.md` +- `docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md` - `docs/【项目基线】当前产品与工程约束-2026-05-15.md` - `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` - `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 81f3a5ba0..ef7364769 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -19,6 +19,7 @@ - `.hermes/skills/` Hermes 专用仓库级 skills - `docs/` 中 PRD、设计、技术、经验、审计、查询手册 - `AGENTS.md` 项目级 Agent 约束 +- `docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md` Agent 执行细则 禁止提交: @@ -33,10 +34,11 @@ 1. 拉取最新代码。 2. 阅读 `AGENTS.md`。 -3. 阅读 `docs/project-memory/shared-memory/` 中与任务相关的文件。 -4. 阅读 `docs/README.md` 和任务相关分类 README。 -5. 阅读对应 PRD、设计、技术、经验或审计文档。 -6. 如果文档不足以指导编码,先补充或修正文档。 +3. 复杂任务阅读 `docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`。 +4. 阅读 `docs/project-memory/shared-memory/` 中与任务相关的文件。 +5. 阅读 `docs/README.md` 和任务相关分类 README。 +6. 阅读对应 PRD、设计、技术、经验或审计文档。 +7. 如果文档不足以指导编码,先补充或修正文档。 ## 开发中 @@ -64,21 +66,16 @@ 1. `README.md` 2. `AGENTS.md` -3. `docs/project-memory/shared-memory/` -4. `docs/README.md` -5. `docs/experience/README.md` -6. `docs/audits/README.md` -7. 任务所属分类:`docs/design/`、`docs/technical/`、`docs/planning/`、`docs/prd/`、`docs/reference/`、`docs/tracking/`、`docs/operations/` +3. `docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md` +4. `docs/project-memory/shared-memory/` +5. `docs/README.md` +6. 任务所属分类:`docs/design/`、`docs/technical/`、`docs/planning/`、`docs/prd/`、`docs/reference/`、`docs/tracking/`、`docs/operations/` 后端任务建议: -1. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` -2. `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md` -3. `docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md` -4. `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md` -5. `docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` -6. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` -7. `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md` +1. `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` +2. `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` +3. 任务相关 crate README、源码和当前专题文档 ## 共享记忆更新准则 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 5074673be..347df3aeb 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -23,7 +23,7 @@ - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑默认从原图模型和分辨率初始化,但提交使用面板当前选择的 `model/aspectRatio/imageSize`;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材必须按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作快速编辑固定使用 `seedance2.0-fast` 动作 / 视频模型。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图 / 原素材自动作为最后一张隐式参考提交,但不在参考图条里固定展示 `图x`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交前将提示词里对原素材的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张源素材编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 -- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变。 +- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图`、`生成角色动作` 和快速编辑提交后创建的 `Quick Edit Generator`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;快速编辑提交时同样必须把避让后的 placeholder 写入独立生成占位,生成结果落在该占位位置。 ## 交互规则 diff --git a/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md b/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md new file mode 100644 index 000000000..a2d523728 --- /dev/null +++ b/docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md @@ -0,0 +1,116 @@ +# Agent 工作入口与执行准则 + +更新时间:`2026-06-22` + +## 文档定位 + +本文件承接 `AGENTS.md` 中不适合塞在入口页里的执行细则。`AGENTS.md` 是最高优先级入口,本文件是复杂任务的默认操作手册;如果本文件与代码、最新 `docs/` 或项目共享记忆冲突,以当前代码和最新 `docs/` 为准,并同步修正文档。 + +## 阅读顺序 + +简单自包含任务可以直接执行。复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按这个顺序读: + +1. `AGENTS.md` +2. 本文件 +3. `docs/project-memory/README.md` +4. `docs/project-memory/shared-memory/project-overview.md` +5. `docs/project-memory/shared-memory/team-conventions.md` +6. `docs/project-memory/shared-memory/development-workflow.md` +7. 与任务相关的 `decision-log.md`、`pitfalls.md`、`docs/README.md` 和专题文档 + +如果已有文档不能精确指导字段、契约、页面状态、资产链路、迁移或验收命令,先补文档再编码。 + +## 信息来源边界 + +- `docs/`:当前 PRD、架构、开发运维、设计和测试口径。 +- `docs/project-memory/shared-memory/`:长期团队记忆、决策、流程和踩坑摘要。 +- `.hermes/`:Hermes 工具资源,不作为项目知识库。 +- `.codex/skills/`:Codex 可复用技能;只在任务命中时读取。 +- `scripts/rag/`:Agent 本地检索入口,只提供候选上下文。 + +RAG 默认不安装运行时依赖,也不把 LanceDB、Transformers.js 或本地 embedding 模型写入根 `package.json`。需要启用时,先询问用户;用户确认后只安装到 gitignored 的 `.rag/runtime/`,模型缓存和向量库留在 `.rag/`。 + +## 执行风格 + +- 修改范围保持聚焦,不做无关重构。 +- 优先复用现有系统、页面、组件、脚本、DTO 和文档位置。 +- 不新增平行入口、平行作品架、平行公开列表、平行业务真相或临时兼容层。 +- 不把前端临时状态当正式业务事实;正式状态以后端投影、后端 API 或当前架构文档为准。 +- 涉及中文内容时保持中文,不擅自翻译成英文。 +- 发现中文乱码时先确认真实编码,不直接沿用乱码,也不用英文替换。 +- 含中文文件优先局部补丁;非必要不要整文件重写。 +- 阶段性大任务完成后,整理当前上下文、剩余风险和下一步入口,降低后续接手噪音。 + +## 文档规则 + +- 工程修改必须同步更新对应 `docs/` 文档。 +- 没有合适文档时,新文档放入 `docs/` 下合适位置,文件名使用 `【标签名】中文标题-日期.md`。 +- PRD 或技术方案要具体到能指导编码,不写会导致落地漂移的泛泛描述。 +- 长期有效的架构约定、接口变化、排障经验、开发流程或协作规则写入 `docs/project-memory/shared-memory/`。 +- 阶段性计划放入 `docs/project-memory/plans/`;确定但未实施的共享 TODO 放入 `docs/project-memory/todos/`。 +- 不提交个人配置、密钥、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。 + +## UI 与前端规则 + +- UI 面板保持清爽,不默认写功能说明、规则说明、键盘快捷键说明或开发解释文本。 +- 移动端优先,同时保证桌面端体验完整。 +- 弹出独立面板的交互使用弹窗、抽屉、popover 或页面级 portal,不在当前面板下面追加内容。 +- 页面展示以后端返回状态为准,不在前端自行计算结论型业务状态。 +- 创作入口事实源来自 SpacetimeDB,经 `/api/creation-entry/config` 下发;前端只做展示派生。 +- 优先扩展现有公共组件,例如平台弹窗、图片输入、媒体预览、状态提示和动作按钮,不在业务页复制通用逻辑。 + +## 后端与数据真相 + +- 后端路线固定为 `server-rs + Axum + SpacetimeDB`。 +- 旧 `server-node`、Express、PostgreSQL、Go 服务端、`maincloud` 相关脚本、环境变量、测试和文档要求均为历史残留。 +- 领域规则沉到 `module-*`;SpacetimeDB 表、reducer、procedure、事务 adapter 和 row mapper 留在 `spacetime-module`。 +- 后端访问 SpacetimeDB 统一经 `spacetime-client` facade。 +- HTTP / SSE / BFF 和外部副作用编排留在 `api-server`;OSS、LLM、认证、语音等外部平台能力留在 `platform-*`。 +- 前后端 DTO 和公开契约留在 `shared-contracts` / `packages/shared`。 +- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准。 + +后端修改后按当前 DDD 文档执行验收。涉及 API smoke 时,使用 `npm run dev:api-server` 重新拉起后端并检查 `/healthz`;不要使用旧 `maincloud` 启动口径。 + +## SpacetimeDB 规则 + +涉及 SpacetimeDB 设计、实现、脚本、调试、发布、绑定生成、schema、reducer、procedure、view 或 Rust API 时,先读取对应 skill: + +- `.codex/skills/spacetimedb-cli/SKILL.md` +- `.codex/skills/spacetimedb-rust/SKILL.md` +- `.codex/skills/spacetimedb-concepts/SKILL.md` + +已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值。删除、改名、重排或改类型前必须先询问用户并确认迁移计划。 + +修改 schema 后必须同步: + +- `server-rs/crates/spacetime-module/src/migration.rs` +- 表目录 / 数据契约文档 +- 生成绑定 +- `npm run check:spacetime-schema` + +人工命令、本地联调、排障步骤和文档示例禁止继续使用 `spacetime --root-dir`;本地数据隔离使用项目脚本或 `--data-dir`,发布目标显式传 `--server` / `--server-url`。 + +## 技能路由 + +- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model:读取 `.codex/skills/genarrative-play-type-integration/SKILL.md`。 +- 本地 dev 端口、代理目标、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理和后台 dev 串联:读取 `.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`。 +- 仓库级 Hermes skills/plugins:先读 `.hermes/README.md`,只把 `.hermes/` 当工具目录。 + +## Issue 与提交 + +- Issue 使用自托管 Gitea;优先使用 Gitea UI/API 或 `tea` CLI。 +- 默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。 +- 提交标题必须使用中文;标题后逐行写明本次提交修改了什么,每条变更单独一行。 +- 提交前检查 staged diff,避免把无关文件、密钥、本地配置或用户未要求的改动带进去。 + +## 默认验证 + +按修改范围选择验证,不追求无意义全量扫: + +- 文档 / 中文文本:`npm run check:encoding`、`git diff --check` +- 前端:定向测试、`npm run typecheck`、必要的页面交互 smoke 和移动端视口检查 +- 后端:对应 crate 的 `cargo test` / `cargo check`、API smoke、`/healthz` +- SpacetimeDB schema:`npm run check:spacetime-schema` +- 发布 / 运维:当前开发运维文档中的脚本门禁、host 侧进程和公开端点验证 + +如果无法运行某项验证,最终说明要写清原因、风险和已经完成的替代检查。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index ff3c9cd61..3918f7049 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -97,7 +97,7 @@ - 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。 - 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。 - 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。 -- 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。 +- 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;该占位必须复用新建图片的 placement 避让逻辑,和已有素材 / 生成占位至少保留 32px 画布间距,不允许固定放到原图右侧后压住其它素材;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。 ## 画布悬浮信息 diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index c79084641..920ff5e70 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -120,6 +120,35 @@ function createIconResult(name: string, imageSrc: string) { }; } +function parseLayerRect(text: string, layerId: string) { + const entry = text + .split('|') + .find((currentEntry) => currentEntry.startsWith(`${layerId}:`)); + if (!entry) { + throw new Error(`missing layer rect for ${layerId}`); + } + const [, x, y, width, height] = entry.split(':'); + return { + x: Number(x), + y: Number(y), + width: Number(width), + height: Number(height), + }; +} + +function rectOverlapsExpanded( + rect: { x: number; y: number; width: number; height: number }, + blocker: { x: number; y: number; width: number; height: number }, + gap: number, +) { + return ( + rect.x < blocker.x + blocker.width + gap && + rect.x + rect.width > blocker.x - gap && + rect.y < blocker.y + blocker.height + gap && + rect.y + rect.height > blocker.y - gap + ); +} + function SubmissionWorkflowHarness({ initialDialog = null, initialQuickEditPanel = null, @@ -179,6 +208,7 @@ function SubmissionWorkflowHarness({ quickEditPanel, quickEditSourceLayer, quickEditSelectionState: initialQuickEditSelectionState, + canvasGenerationDialogs: dialogs.canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel, characterAnimationDialog: activeCanvasDialog, @@ -216,6 +246,14 @@ function SubmissionWorkflowHarness({ ) .join('|')} + + {layers + .map( + (layer) => + `${layer.id}:${layer.x}:${layer.y}:${layer.width}:${layer.height}`, + ) + .join('|')} + {activeDialog ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}:${activeDialog.errorMessage ?? '-'}` @@ -410,6 +448,45 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); + it('places quick edit result with the new image placement logic away from existing assets', async () => { + const blockingLayer = createLayer({ + id: 'layer-blocker', + resourceId: 'resource-blocker', + title: '右侧已有素材', + x: 472, + y: 140, + width: 1024, + height: 768, + originalWidth: 1024, + originalHeight: 768, + zIndex: 3, + }); + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '快速修图', width: 1024, height: 768 }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); + fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); + fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); + + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-quick-edit-1:源图 快速编辑:resource-source:-', + ); + }); + const layerRects = screen.getByTestId('layer-rects').textContent ?? ''; + const resultRect = parseLayerRect(layerRects, 'layer-quick-edit-1'); + + expect( + rectOverlapsExpanded(resultRect, blockingLayer, 32), + ).toBe(false); + }); + it('submits quick edits with the numbered red selection image as the final source reference', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色' }), diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index e8c9afa18..2edf895b5 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -44,6 +44,7 @@ import { createQuickEditResultLayer, createVideoResultLayer, } from './ImageCanvasGenerationLayerModel'; +import { chooseGenerationPlacement } from './ImageCanvasGenerationPlacementModel'; import { buildCharacterAnimationGenerationInputs, buildEditGenerationInputs, @@ -98,6 +99,7 @@ type GenerationSubmissionWorkflowOptions = { quickEditPanel: QuickEditPanelState | null; quickEditSourceLayer: CanvasLayer | null; quickEditSelectionState: UiAssetExtractionState | null; + canvasGenerationDialogs: CanvasGenerationDialogState[]; setQuickEditPanel: Dispatch>; characterAnimationPanel: CharacterAnimationPanelState | null; characterAnimationDialog: CanvasGenerationDialogState | null; @@ -165,6 +167,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ quickEditPanel, quickEditSourceLayer, quickEditSelectionState, + canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel, characterAnimationDialog, @@ -741,21 +744,33 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ); let quickEditDialogId: string | undefined; if (panelMode === 'quick-edit') { - quickEditDialogId = openCanvasGenerationDialog( - createQuickEditGenerationDialogDraft({ - sourceLayer: quickEditSourceLayer, - prompt: normalizedPrompt, - status: 'generating', - references: quickEditReferences, - model: quickEditPanel.model, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - frame: { - width: quickEditOutputSize.width, - height: quickEditOutputSize.height, - }, - }), - ); + const quickEditDraft = createQuickEditGenerationDialogDraft({ + sourceLayer: quickEditSourceLayer, + prompt: normalizedPrompt, + status: 'generating', + references: quickEditReferences, + model: quickEditPanel.model, + aspectRatio: quickEditAspectRatio, + imageSize: quickEditImageSize, + frame: { + width: quickEditOutputSize.width, + height: quickEditOutputSize.height, + }, + }); + const quickEditPlacement = quickEditDraft.placeholder + ? chooseGenerationPlacement({ + canvasSize, + viewport, + frame: quickEditDraft.placeholder, + layers, + generationDialogs: canvasGenerationDialogs, + }) + : quickEditDraft.placeholder; + quickEditDialogId = openCanvasGenerationDialog({ + ...quickEditDraft, + // 中文注释:快速编辑生成结果复用新建图片的避让落点,避免覆盖已有素材。 + placeholder: quickEditPlacement, + }); setQuickEditPanel(null); selectSingleLayer(null); } else { @@ -956,7 +971,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ addCharacterAnimationResultLayer, addQuickEditResultLayer, addVideoResultLayer, + canvasGenerationDialogs, + canvasSize, getGeneratingDialogPlaceholder, + layers, openCanvasGenerationDialog, projectId, assetFolderId, @@ -966,6 +984,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ selectSingleLayer, setQuickEditPanel, updateCanvasGenerationDialogById, + viewport, ]); const submitImageGeneration = useCallback( diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 3564757ef..96cefcc9f 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -1210,6 +1210,7 @@ export function useImageCanvasGenerationWorkflow({ quickEditPanel, quickEditSourceLayer, quickEditSelectionState, + canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel: effectiveCharacterAnimationPanel, characterAnimationDialog, From 6caf3499927d13ddfbc4b2853043745e99c49bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Mon, 22 Jun 2026 21:51:59 +0800 Subject: [PATCH 15/15] =?UTF-8?q?=E9=94=81=E5=AE=9A=E7=94=BB=E5=B8=83UI?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=9B=BE=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定生成UI设计图模型展示为gpt-image-2 对齐角色规范面板的只读模型展示样式 提交层兜底强制UI设计图使用gpt-image-2 补充模型锁定与旧草稿兜底测试 --- ...辑器】画板UI设计图生成入口设计-2026-06-17.md | 2 +- ...eCanvasGenerationImageOptionsView.test.tsx | 60 ++- .../ImageCanvasGenerationImageOptionsView.tsx | 51 +- ...ageCanvasGenerationSubmissionModel.test.ts | 112 ++-- .../ImageCanvasGenerationSubmissionModel.ts | 35 +- ...mageCanvasSpecGenerationPanelView.test.tsx | 30 +- .../ImageCanvasSpecGenerationPanelView.tsx | 510 +++++++++--------- 7 files changed, 467 insertions(+), 333 deletions(-) diff --git a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md index f95bbb146..e73ec443c 100644 --- a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md +++ b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md @@ -25,7 +25,7 @@ ## 生成参数 - 支持自定义画面比例和大小尺寸。 -- 模型固定为 `gpt-image-2`,面板不提供切换到其他模型的能力。 +- 模型固定为 `gpt-image-2`,模型展示对齐角色规范面板底部固定模型样式,不响应点击、不弹出模型切换菜单;历史草稿如果残留其他模型,提交时也必须强制改为 `gpt-image-2`。 - 默认画面比例为 `16:9`,默认大小为 `1K`。 ## 提示词契约 diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx index 2f562aabe..f8f6937ca 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx @@ -16,10 +16,12 @@ function ImageOptionsHarness({ initialDialog, includeDimensions = true, onRememberImageModel = vi.fn(), + lockedModel, }: { initialDialog: GenerateDialogState; includeDimensions?: boolean; onRememberImageModel?: (model: string) => void; + lockedModel?: string; }) { const [dialog, setDialog] = useState( initialDialog, @@ -32,8 +34,9 @@ function ImageOptionsHarness({ setGenerateDialog={setDialog} includeDimensions={includeDimensions} onRememberImageModel={onRememberImageModel} + lockedModel={lockedModel} cost={calculateEditorImageGenerationPrice({ - model: dialog.imageModel, + model: lockedModel ?? dialog.imageModel, imageSize: dialog.imageSize, })} submitLabel="生成" @@ -121,15 +124,11 @@ describe('ImageCanvasGenerationImageOptionsView', () => { fireEvent.click( screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }), ); - expect( - screen.getByRole('menu', { name: '生成图片尺寸选项' }), - ).toBeTruthy(); + expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy(); fireEvent.click(screen.getByLabelText('当前模型')); - expect( - screen.queryByRole('menu', { name: '生成图片尺寸选项' }), - ).toBeNull(); + expect(screen.queryByRole('menu', { name: '生成图片尺寸选项' })).toBeNull(); }); it('renders model choices as one model per row and uses a check mark for selection', () => { @@ -153,7 +152,9 @@ describe('ImageCanvasGenerationImageOptionsView', () => { ); const panel = screen.getByRole('menu', { name: '生成图片模型选项' }); - expect(within(panel).getByRole('button', { name: 'nanobanana2' })).toBeTruthy(); + expect( + within(panel).getByRole('button', { name: 'nanobanana2' }), + ).toBeTruthy(); fireEvent.click(within(panel).getByRole('button', { name: 'gpt-image-2' })); expect(screen.getByRole('menu', { name: '生成图片模型选项' })).toBeTruthy(); @@ -166,9 +167,40 @@ describe('ImageCanvasGenerationImageOptionsView', () => { expect( screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }), ).toBeTruthy(); - expect(screen.getByRole('button', { name: '生成角色形象' }).textContent).toBe( - '生成20泥点', + expect( + screen.getByRole('button', { name: '生成角色形象' }).textContent, + ).toBe('生成3泥点'); + }); + + it('locks the model trigger when a fixed model is provided', () => { + const rememberImageModel = vi.fn(); + render( + , ); + + const modelButton = screen.getByRole('button', { + name: '生成图片模型 gpt-image-2', + }) as HTMLButtonElement; + + expect(modelButton.disabled).toBe(true); + expect(modelButton.closest('div')?.className).toContain( + 'image-canvas-editor__readonly-generation-option', + ); + expect(modelButton.textContent).not.toContain('nanobanana2'); + fireEvent.click(modelButton); + expect(screen.queryByRole('menu', { name: '生成图片模型选项' })).toBeNull(); + expect(rememberImageModel).not.toHaveBeenCalled(); }); it('renders option menus and shows mud point text in the submit button', () => { @@ -189,9 +221,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => { screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }), ); - expect( - screen.getByRole('menu', { name: '生成图片尺寸选项' }), - ).toBeTruthy(); + expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy(); const submit = screen.getByRole('button', { name: '生成角色形象' }); expect(submit.textContent).toBe('生成12泥点'); }); @@ -219,9 +249,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => { />, ); - expect( - screen.queryByRole('button', { name: /生成图片尺寸/u }), - ).toBeNull(); + expect(screen.queryByRole('button', { name: /生成图片尺寸/u })).toBeNull(); fireEvent.click( screen.getByRole('button', { name: '生成图片模型 nanobanana2' }), diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx index 3d7462c03..cfddaad89 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx @@ -33,6 +33,7 @@ type ImageCanvasGenerationImageOptionsViewProps = { cost?: number; submitLabel?: string; submitAriaLabel?: string; + lockedModel?: string; renderEditorPortal?: (node: ReactNode) => ReactNode; buildPortalMenuStyle?: ( anchor: HTMLElement | null, @@ -53,13 +54,16 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) { function getImageDimensionOptions(model: string | null | undefined) { return ( EDITOR_IMAGE_DIMENSION_OPTIONS[ - (model ?? IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS + (model ?? + IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2] ); } function normalizeImageDialogSelection(dialog: GenerateDialogState) { - const model = normalizeEditorImageModel(dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2); + const model = normalizeEditorImageModel( + dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2, + ); const options = getImageDimensionOptions(model); const aspectRatios = options.aspectRatios as readonly string[]; const imageSizes = options.imageSizes as readonly string[]; @@ -113,19 +117,28 @@ export function ImageCanvasGenerationImageOptionsView({ cost, submitLabel = '生成', submitAriaLabel = '生成', + lockedModel, renderEditorPortal = (node) => node, buildPortalMenuStyle = () => ({}), }: ImageCanvasGenerationImageOptionsViewProps) { const [openPanel, setOpenPanel] = useState(null); const dimensionsButtonRef = useRef(null); const modelButtonRef = useRef(null); - const selection = normalizeImageDialogSelection(dialog); + const normalizedLockedModel = lockedModel + ? normalizeEditorImageModel(lockedModel) + : null; + const selection = normalizeImageDialogSelection( + normalizedLockedModel + ? { ...dialog, imageModel: normalizedLockedModel } + : dialog, + ); const selectedModelLabel = getEditorImageModelDisplayName(selection.model); const selectedSizeLabel = resolveEditorImageSizeLabel({ aspectRatio: selection.aspectRatio, imageSize: selection.imageSize, }); const isGenerating = dialog.status === 'generating'; + const isModelLocked = Boolean(normalizedLockedModel); const labelPrefix = optionLabelPrefix ?? '生成图片'; const dismissOpenPanel = useCallback(() => setOpenPanel(null), []); @@ -257,24 +270,42 @@ export function ImageCanvasGenerationImageOptionsView({ : null}
) : null} -
+
} - onClick={() => togglePanel('model')} + aria-expanded={isModelLocked ? undefined : openPanel === 'model'} + disabled={isGenerating || isModelLocked} + trailingIcon={ + isModelLocked ? undefined : + } + onClick={() => { + if (!isModelLocked) { + togglePanel('model'); + } + }} > - - {openPanel === 'model' + {!isModelLocked && openPanel === 'model' ? renderEditorPortal( { title: '图标规范 8', }, }); - expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({ - fields: [ - { title: '玩法设定', value: '休闲消除' }, - { title: '美术风格', value: '清爽卡通' }, - ], - references: [ - { - title: '参考图', - label: '参考.png', - refType: 'project-resource', - refId: 'resource-spec-ref', - }, - ], - }); + expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( + { + fields: [ + { title: '玩法设定', value: '休闲消除' }, + { title: '美术风格', value: '清爽卡通' }, + ], + references: [ + { + title: '参考图', + label: '参考.png', + refType: 'project-resource', + refId: 'resource-spec-ref', + }, + ], + }, + ); }); it('treats the renamed UI spec entry as an icon spec asset', () => { @@ -265,22 +268,62 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }, rememberImageModel: 'gpt-image-2', }); - expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({ - fields: [{ title: '角色设定', value: '白发骑士' }], - references: [ - { - title: '角色规范', - label: '角色规范', - refType: 'project-resource', - refId: 'resource-character-spec', + expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( + { + fields: [{ title: '角色设定', value: '白发骑士' }], + references: [ + { + title: '角色规范', + label: '角色规范', + refType: 'project-resource', + refId: 'resource-character-spec', + }, + { + title: '常规参考图 1', + label: '盔甲参考', + refType: 'project-resource', + refId: 'resource-armor', + }, + ], + }, + ); + }); + + it('locks UI design generation submissions to gpt-image-2', () => { + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'ui-design', + prompt: ' 设计移动端商城首页 ', + status: 'idle', + imageModel: 'nanobanana2', + aspectRatio: '16:9', + imageSize: '0.5K', + uiDesignSpecReference: { + id: 'ui-spec', + label: 'UI图标规范', + src: 'data:image/png;base64,ui-spec', + resourceId: 'resource-ui-spec', }, - { - title: '常规参考图 1', - label: '盔甲参考', - refType: 'project-resource', - refId: 'resource-armor', - }, - ], + }, + layers: [], + nextGeneratedIndex: 6, + }); + + expect(plan).toMatchObject({ + kind: 'image', + normalizedPrompt: '设计移动端商城首页', + input: { + kind: 'ui-design', + model: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '16:9', + imageSize: '1K', + referenceImageSrcs: ['data:image/png;base64,ui-spec'], + }, + result: { + assetKind: 'ui-design', + title: 'UI设计图 6', + }, + rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, }); }); @@ -607,7 +650,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); - it('builds seedance video plans with multimodal references', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { @@ -788,7 +830,11 @@ describe('ImageCanvasGenerationSubmissionModel', () => { normalizedPrompt: '角色绕场一周', input: { model: 'seedance2.0-fast', - priceMudPoints: calculateEditorVideoPrice('seedance2.0-fast', '480p', 4), + priceMudPoints: calculateEditorVideoPrice( + 'seedance2.0-fast', + '480p', + 4, + ), }, result: { title: '生成视频 3', @@ -846,7 +892,9 @@ describe('ImageCanvasGenerationSubmissionModel', () => { prompt: '金币掉落叮当声', model: 'audio1.0', duration: 7, - priceMudPoints: calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL), + priceMudPoints: calculateEditorSoundEffectPrice( + DEFAULT_SOUND_EFFECT_MODEL, + ), }, result: { title: '游戏音效 4', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index fa6289f8b..54c388074 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -43,6 +43,7 @@ import { DEFAULT_VIDEO_SOUND, DEFAULT_VIDEO_WEB_SEARCH_ENABLED, ICON_DESCRIPTION_LIMIT, + IMAGE_MODEL_GPT_IMAGE_2, normalizeEditorImageModel, resolveCharacterAnimationSourceImageSrc, SEEDANCE_VIDEO_REFERENCE_LIMITS, @@ -68,6 +69,10 @@ export function isSeedanceEditorVideoModel(model: string) { return isSeedanceVideoModel(model); } +function normalizeGptImageSize(imageSize: string | null | undefined) { + return imageSize?.trim().toUpperCase() === '2K' ? '2K' : '1K'; +} + function estimateReferenceRequestBytes(references: Array<{ src: string }>) { // 中文注释:JSON 包装字段还有少量额外字节;这里按 src 字符串长度估算,前后端都会再兜底校验 64MB。 return references.reduce((sum, reference) => sum + reference.src.length, 0); @@ -76,8 +81,10 @@ function estimateReferenceRequestBytes(references: Array<{ src: string }>) { function buildSeedanceVideoReferenceInput( references: NonNullable, ) { - const resolveReferenceSource = (reference: { src: string; objectKey?: string | null }) => - reference.objectKey?.trim() || reference.src; + const resolveReferenceSource = (reference: { + src: string; + objectKey?: string | null; + }) => reference.objectKey?.trim() || reference.src; const imageReferences = references.filter( (reference) => (reference.mediaType ?? 'image') === 'image', ); @@ -97,7 +104,11 @@ function buildSeedanceVideoReferenceInput( if (audioReferences.length > SEEDANCE_VIDEO_REFERENCE_LIMITS.audio) { throw new Error('参考音频最多 3 个'); } - if (audioReferences.length && !imageReferences.length && !videoReferences.length) { + if ( + audioReferences.length && + !imageReferences.length && + !videoReferences.length + ) { throw new Error('参考音频必须搭配参考图片或参考视频'); } if ( @@ -202,7 +213,9 @@ export function buildImageGenerationSubmissionPlan({ dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode); if (dialog.mode === 'edit') { - const sourceLayer = layers.find((layer) => layer.id === dialog.sourceLayerId); + const sourceLayer = layers.find( + (layer) => layer.id === dialog.sourceLayerId, + ); if (!sourceLayer) { throw new Error('未找到要修改的图片'); } @@ -284,7 +297,7 @@ export function buildImageGenerationSubmissionPlan({ } if (dialog.mode === 'ui-design') { - const imageModel = normalizeEditorImageModel(dialog.imageModel); + const imageModel = IMAGE_MODEL_GPT_IMAGE_2; const referenceImageSrcs = [dialog.uiDesignSpecReference?.src].filter( (src): src is string => Boolean(src), ); @@ -296,7 +309,7 @@ export function buildImageGenerationSubmissionPlan({ kind: 'ui-design', model: imageModel, aspectRatio: dialog.aspectRatio ?? '16:9', - imageSize: dialog.imageSize ?? '1K', + imageSize: normalizeGptImageSize(dialog.imageSize), ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), }, result: { @@ -448,7 +461,8 @@ export function buildImageGenerationSubmissionPlan({ }, result: { title: `游戏背景音乐 ${nextGeneratedIndex}`, - generationInputs: buildBackgroundMusicGenerationInputs(normalizedPrompt), + generationInputs: + buildBackgroundMusicGenerationInputs(normalizedPrompt), }, }; } @@ -484,9 +498,10 @@ export function buildImageGenerationSubmissionPlan({ export function buildIconSpritesheetGenerationSubmissionPlan( dialog: GenerateDialogState, ): IconSpritesheetGenerationSubmissionPlan { - const iconDescriptions = (dialog.prompt.trim() - ? dialog.prompt.split(/[\r\n,,、;;/|]+/u) - : (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS) + const iconDescriptions = ( + dialog.prompt.trim() + ? dialog.prompt.split(/[\r\n,,、;;/|]+/u) + : (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS) ) .map((description) => description.trim()) .filter(Boolean) diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index fab693824..b4fb4bf76 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -88,7 +88,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ), ).toBe(true); const submitButton = screen.getByRole('button', { name: '提交生成规范' }); - expect(submitButton.textContent).toBe('生成40泥点'); + expect(submitButton.textContent).toBe('生成5泥点'); const dimensionsButton = screen.getByRole('button', { name: '生成图片尺寸 16:9·2K', }) as HTMLButtonElement; @@ -135,19 +135,23 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ), ).toBe(true); expect(panel.textContent).not.toContain('UI设计要求'); - expect(prompt.getAttribute('placeholder')).toBe( - '你希望这个 UI 长什么样?', - ); + expect(prompt.getAttribute('placeholder')).toBe('你希望这个 UI 长什么样?'); expect(prompt.className).toContain( 'image-canvas-editor__generation-prompt--borderless', ); const submitButton = screen.getByRole('button', { name: '生成UI设计图' }); - expect(submitButton.textContent).toBe('生成12泥点'); - fireEvent.click(screen.getByRole('button', { name: '生成图片模型 nanobanana2' })); - fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' })); - expect(screen.getByRole('button', { name: '生成UI设计图' }).textContent).toBe( - '生成20泥点', + const modelButton = screen.getByRole('button', { + name: '生成图片模型 gpt-image-2', + }) as HTMLButtonElement; + + expect(submitButton.textContent).toBe('生成3泥点'); + expect(modelButton.disabled).toBe(true); + expect(modelButton.closest('div')?.className).toContain( + 'image-canvas-editor__readonly-generation-option', ); + fireEvent.click(modelButton); + expect(screen.queryByRole('menu', { name: '生成图片模型选项' })).toBeNull(); + expect(screen.queryByText('nanobanana2')).toBeNull(); }); it('renders character spec fields and forwards updates', () => { @@ -232,7 +236,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { const submitButton = screen.getByRole('button', { name: '提交生成规范' }); - expect(submitButton.textContent).toBe('生成40泥点'); + expect(submitButton.textContent).toBe('生成5泥点'); }); it('disables controls while generating and renders failure state', () => { @@ -249,9 +253,9 @@ describe('ImageCanvasSpecGenerationPanelView', () => { fireEvent.click(screen.getByRole('button', { name: '提交生成规范' })); - expect((screen.getByLabelText('玩法设定') as HTMLInputElement).disabled).toBe( - true, - ); + expect( + (screen.getByLabelText('玩法设定') as HTMLInputElement).disabled, + ).toBe(true); expect(submitSpec).not.toHaveBeenCalled(); rerender( diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index be8ff1256..c7ec9bc99 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -31,6 +31,7 @@ import { calculateEditorUiDesignPrice, CHARACTER_SPEC_VIEW_OPTIONS, getEditorImageModelDisplayName, + IMAGE_MODEL_GPT_IMAGE_2, resolveEditorImageSizeLabel, SPEC_GENERATION_ASPECT_RATIO, SPEC_GENERATION_IMAGE_SIZE, @@ -126,31 +127,46 @@ export function ImageCanvasSpecGenerationPanelView({ } }} > -
- {(isUiDesignDialog || dialog.specType) ? ( -
{isGenerationReferenceMenuOpen && generationReferenceButtonRef ? renderEditorPortal(