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({ > 打开快速编辑 - + + + + +