diff --git a/.env.example b/.env.example index 60dc47b7e..6d43e2f8a 100644 --- a/.env.example +++ b/.env.example @@ -145,6 +145,15 @@ ELEVENLABS_BASE_URL="https://api.elevenlabs.io" ELEVENLABS_API_KEY="" ELEVENLABS_REQUEST_TIMEOUT_MS="180000" +# Tripo 3D 生成:地址与密钥必须显式给出,没有内置默认值。 +# 缺任意一个,会提交或会执行 3D job 的进程(api / external-generation-worker / all) +# 在启动期直接拒绝启动——不会带着未知网关或空密钥把进程跑起来。 +TRIPO_BASE_URL="https://openapi.tripo3d.com/v3" +TRIPO_API_KEY="" +# 两个运行旋钮可缺省(沿用内置值);显式给出时会校验,非法即拒绝启动。 +TRIPO_REQUEST_TIMEOUT_MS="60000" +TRIPO_RETRIES="2" + # 阿里云 OSS 配置。 # Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量, # 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。 diff --git a/CONTEXT.md b/CONTEXT.md index c22344ea0..7e45cb0d3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -67,6 +67,90 @@ _Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛 ## Language +### 3D Model Generation + +**3D 模型生成操作**: +由文本提示或平台图片引用驱动、可异步查询并最终产生模型文件的 provider-neutral 操作;操作状态和产品资源结果分开建模。 +_Avoid_: 直接把第三方 provider task 当作产品资源、把模型 URL 当作永久资源地址 + +**Provider task**: +第三方 3D 生成平台返回的任务引用与任务快照,只存在于 provider adapter 的 Rust 边界内;它不是 Genarrative 的内部操作 ID。 +_Avoid_: 把 Tripo task ID 当作稳定产品 operationId、让 SDK 类型穿透 api-server + +**临时 provider URL**: +第三方任务完成后返回的短时模型下载地址,必须通过显式下载流程转换为本地或持久化资源,不能直接当作长期资产地址。 +_Avoid_: 永久 OSS URL、资源 ID + +**模型 artifact**: +由 text-to-model、image-to-model 或 multiview-to-model 任务产生、待应用层登记和持久化的模型文件结果;provider adapter 只提供可下载结果,不负责生成 resourceId 或 assetId。 +_Avoid_: 仅 UI 中展示的模型链接、没有来源任务的静态文件 + +**Tripo API facade**: +面向站内已认证调用方的全新 Tripo 3D 生成 API 上下文,负责把 API operation、用户归属、异步状态、产物持久化和正式资源引用连接起来;它独立于历史 Hyper3D adapter,不复用其路由、契约或实现。 +_Avoid_: 把 Tripo API 当成 Hyper3D 的 provider 替换、让 Hyper3D 路由悄悄切换到 Tripo + +**Tripo operation**: +Genarrative 为一次 Tripo 生成请求分配的内部异步操作标识;客户端只看到 operationId,Tripo provider task 只作为服务端 checkpoint 保存,二者不是同一个 ID。 +_Avoid_: 将 Tripo taskId 直接当公开 operationId、把 SDK task 状态模型穿透到 API + +**模型产物持久化**: +Tripo 任务完成后,服务端下载模型与预览、写入受控对象存储并登记资源元数据,成功后才把 Tripo operation 置为 completed;本期按完整字节写入,流式上传是后续目标。 +_Avoid_: 返回临时签名 URL作为永久资源、在 API handler 中把完整模型读成 Vec + +**3D provider 配置门禁**: +Tripo 网关与密钥的可用性在「会提交或会执行 3D job 的进程」(`api` / `external-generation-worker` / `all`)启动期判定:`TRIPO_BASE_URL` 与 `TRIPO_API_KEY` 没有内置默认值,缺失、空白或网关非法即拒绝启动;请求期返回 503 只是第二道防线,不承担发现部署缺失的职责。 +_Avoid_: 内置默认网关、缺配置时静默沿用某个地址或兜底密钥、把「少配置」留到用户提交后才发现 + +**3D 生成底价**: +一次 3D 生成在指定端点和模型版本下、按是否带贴图区分的基准泥点价,不包含任何叠加项,也不随请求的其它参数变化。 +_Avoid_: 把底价与加价项合并成一个档位价、把 provider 的 credit 数值当底价 + +**3D 生成加价项**: +叠加在底价之上、由请求参数判定的可选泥点加价;每个端点各自持有一份加价项价目,同一加价项在不同端点可以是不同价。 +_Avoid_: 全局唯一一份加价项价目、按用途而不是按请求参数判定加价 + +**定价配置归一化**: +读取持久化定价时把它与当前契约对齐的过程:缺的键用受控默认值补齐,契约之外的遗留键剔除并告警;它只发生在读取方向,写入方向仍要求完整合法。 +_Avoid_: 把「省略即沿用」当作保存语义、把归一化当成静默改价 + +**模型生成落点**: +3D 生成结果要进入的平台容器,调用方必须在工程资源与素材库资产之间显式二选一;工程分支可附带画布占位框回填,素材库分支必须给出目录与标签。 +_Avoid_: 两个都不给就默认落素材库、把落点混进 provider 请求参数 + +**模型输入资源引用**: +image-to-model 请求对平台已有图片的明确 tagged 引用,可指向项目资源或素材库资产;服务端负责 owner 校验和 OSS 对象解析,不接受把多种来源塞进裸 `input` 字符串。 +_Avoid_: 任意远程 URL、data URL、未区分语义的 resourceId/assetId 字符串 + +**3D 模型生成工具**: +图片画布工程里从底部工具栏打开、给出「文生 3D 模型」与「图生 3D 模型」两个子项的画布生成工具;提交后按画布既有生成链路的占位框、任务侧栏与失败退款口径推进,产物落成 **3D 模型资源**。 +_Avoid_: 独立 3D 生成页、把 3D 生成做成玩法类型或作品、把 3D 生成接进画布 Agent 对话 + +**3D 生成定价**: +3D 模型生成工具在提交前展示的泥点价格,来自编辑器生成定价查询的 `model3d` 段;该段缺失即入口不可提交,前端不内置兜底数值。该段的权威事实源是 SpacetimeDB 定价表里的两段强类型列,公开读取时投影回迁移前的旧形状。 +_Avoid_: 前端写死 3D 价格、借用图片模型的兜底定价配置、按 0 元放行、把内部两段结构直接暴露给画布 + +**定价版本(乐观锁)**: +后台读取完整模型定价时一并拿到的版本号(SpacetimeDB 行的更新时间微秒值),保存时必须原样回传;与服务端当前版本不一致即整笔拒绝并返回 409,提示重新读取,避免静默覆盖他人的改价。 +_Avoid_: 在前端自造或缓存该版本、把版本不匹配当成可重试的普通失败、把「省略字段」当成「沿用当前值」 + +### 3D 模型资源预览 + +**3D 模型资源**: +类别为 `model3d` 的平台资源 / 素材,一个资源同时持有模型本体与模型预览图两个对象;客户端默认消费预览图,模型本体只在 3D 查看器里加载。 +_Avoid_: 只有模型文件的资源、把模型本体当图片素材、为模型新增并列媒体类型 + +**模型预览图**: +3D 模型资源的静态代表图,落在资源投影的 `imageSrc` 与 `thumbnailSrc` 上,供画布卡片、素材缩略图、后台列表和精选卡片使用。 +_Avoid_: 用模型本体充当预览、按需实时渲染缩略图、把预览图当模型真相 + +**模型本体**: +3D 模型资源里的可下载模型文件,只由资源的 `objectKey` 指向,格式以对象内容类型为准。 +_Avoid_: 把模型本体当图片地址、用预览图字段承载模型文件、按文件扩展名推断格式 + +**3D 查看器预览**: +在资源上打开模态把模型本体交给 3D 查看器渲染的只读能力,不修改资源、不产出新资源,也不是画布图层本身的编辑操作。 +_Avoid_: 在画布里内联渲染模型、把预览当编辑、为预览产出新资源 + ### Puzzle Clear **拼消消**: diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 8aae410af..ed80aa51f 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -60,6 +60,7 @@ import type { AdminUpdateAccountResponse, AdminUpdateAgcTemplateRequest, AdminUploadedEditorShowcaseCampaignImage, + AdminUpsertEditorGenerationPricingRequest, AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, @@ -519,7 +520,7 @@ export function getAdminEditorGenerationPricing(token: string) { export function upsertAdminEditorGenerationPricing( token: string, - payload: EditorGenerationPricingConfigPayload, + payload: AdminUpsertEditorGenerationPricingRequest, ) { return request( '/admin/api/editor-generation-pricing', diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 01e99ed10..8d3b579a1 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -431,8 +431,43 @@ export interface EditorGenerationModelPricingPayload { prices?: Record; } +/** 某个 3D 模型版本在「无贴图 / 带贴图」两种形态下的泥点底价。 */ +export interface EditorGenerationModel3dVersionPricePayload { + noTexture: number; + texture: number; +} + +/** + * 单个 3D 生成端点的整套定价:模型版本底价 + 该端点自己的加价项价目。 + * + * 键集合由后端契约决定,后台只改数值:模型版本键与加价项键都直接来自接口返回, + * 不在前端硬编码清单,也不能新增、删除或停用。 + */ +export interface EditorGenerationModel3dEndpointPricingPayload { + versionPrices: Record; + addOnPrices: Record; +} + +/** 3D 生成定价段:两个端点并列,各持自己的设置。 */ +export interface EditorGenerationModel3dPricingPayload { + textToModelPricing: EditorGenerationModel3dEndpointPricingPayload; + imageToModelPricing: EditorGenerationModel3dEndpointPricingPayload; +} + export interface EditorGenerationPricingConfigPayload { models: Record; + /** 后端尚未配置 3D 段时缺省;缺失即 3D 生成不可提交,后台也不能新建整段。 */ + model3d?: EditorGenerationModel3dPricingPayload | null; +} + +/** + * 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出。 + * + * 不带定价版本:保存即整段覆盖,服务端不比对版本(后台同时只有一个管理员在操作)。 + */ +export interface AdminUpsertEditorGenerationPricingRequest { + models: Record; + model3d: EditorGenerationModel3dPricingPayload | null; } export interface AdminEditorAssetListQuery { diff --git a/apps/admin-web/src/pages/AdminEditorGenerationModel3dPricingSection.tsx b/apps/admin-web/src/pages/AdminEditorGenerationModel3dPricingSection.tsx new file mode 100644 index 000000000..748b3900c --- /dev/null +++ b/apps/admin-web/src/pages/AdminEditorGenerationModel3dPricingSection.tsx @@ -0,0 +1,131 @@ +import type { + EditorGenerationModel3dPricingPayload, + EditorGenerationModel3dVersionPricePayload, +} from '../api/adminApiTypes'; +import { + EDITOR_MODEL3D_ENDPOINT_SECTIONS, + editorModel3dAddOnLabel, + updateEditorModel3dEndpointPricing, +} from './adminEditorGenerationPricing'; +import { parsePositiveInteger } from './pageUtils'; + +/** + * 一个模型版本的两个底价字段。两栏只有字段键与展示名不同,用描述表驱动渲染, + * 避免两段近乎相同的 JSX 各自演化。 + */ +const VERSION_PRICE_FIELDS = [ + { key: 'noTexture', label: '无贴图' }, + { key: 'texture', label: '带贴图' }, +] as const satisfies ReadonlyArray<{ + key: keyof EditorGenerationModel3dVersionPricePayload; + label: string; +}>; + +interface AdminEditorGenerationModel3dPricingSectionProps { + model3d: EditorGenerationModel3dPricingPayload; + onChange: (next: EditorGenerationModel3dPricingPayload) => void; +} + +/** + * 3D 生成定价区块:按端点分成「文生 3D」与「图生 3D」两组,每组是该端点自己的 + * 「模型版本 × {无贴图, 带贴图}」底价表与加价项单值输入。 + * + * 键集合全部来自接口返回,后台只能改数值:不能新增、删除键,也不能停用整段。 + */ +export function AdminEditorGenerationModel3dPricingSection({ + model3d, + onChange, +}: AdminEditorGenerationModel3dPricingSectionProps) { + function updateEndpoint( + section: (typeof EDITOR_MODEL3D_ENDPOINT_SECTIONS)[number]['key'], + updater: Parameters[2], + ) { + onChange(updateEditorModel3dEndpointPricing(model3d, section, updater)); + } + + return ( +
+
+ 3D 生成定价 + 按次 +
+

+ 模型版本与加价项由后端契约给出,只能改数值;两段必须完整给出,缺失即 3D + 生成不可提交。 +

+
+ {EDITOR_MODEL3D_ENDPOINT_SECTIONS.map(({ key, label }) => ( +
+
+ {label} +
+
+ {Object.entries(model3d[key].versionPrices).map( + ([version, price]) => ( +
+ + {version} + + {VERSION_PRICE_FIELDS.map( + ({ key: field, label: fieldLabel }) => ( + + ), + )} +
+ ), + )} +
+
+ {Object.entries(model3d[key].addOnPrices).map( + ([addOn, price]) => ( + + ), + )} +
+
+ ))} +
+
+ ); +} diff --git a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx index fc5718dc0..941a995ae 100644 --- a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx @@ -11,14 +11,16 @@ import { 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(), -})); +vi.mock('../api/adminApiClient', async () => { + const actual = await vi.importActual( + '../api/adminApiClient', + ); + return { + ...actual, + getAdminEditorGenerationPricing: vi.fn(), + upsertAdminEditorGenerationPricing: vi.fn(), + }; +}); const pricing: EditorGenerationPricingConfigPayload = { models: { @@ -41,36 +43,78 @@ const pricing: EditorGenerationPricingConfigPayload = { 'audio1.0': { unit: 'perGeneration', price: 5 }, 'chirp-v5': { unit: 'perGeneration', price: 12 }, }, + model3d: { + textToModelPricing: { + versionPrices: { + 'v3.1-20260211': { noTexture: 8, texture: 16 }, + 'P2-20260801': { noTexture: 80, texture: 88 }, + }, + addOnPrices: { + hdTexture: 8, + ultraTexture: 16, + quadMesh: 4, + generateParts: 16, + }, + }, + imageToModelPricing: { + versionPrices: { + 'v3.1-20260211': { noTexture: 16, texture: 24 }, + 'P2-20260801': { noTexture: 80, texture: 88 }, + }, + addOnPrices: { + hdTexture: 8, + ultraTexture: 16, + quadMesh: 4, + generateParts: 16, + }, + }, + }, }; +function savedPricing( + overrides: Partial = {}, +) { + return { + ...pricing, + ...overrides, + }; +} + 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 }, + vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue( + savedPricing({ + models: { + ...pricing.models, + 'gpt-image-2': { + unit: 'perGeneration', + prices: { '1K': 20, '2K': 58 }, + }, }, - }, - }); + }), + ); }); -test('模型定价后台按模型展示单位并保存尺寸定价', async () => { - const user = userEvent.setup(); +async function renderPage() { render( , ); + await screen.findByText('3D 生成定价'); +} + +test('模型定价后台按模型展示单位并保存尺寸定价', async () => { + const user = userEvent.setup(); + await renderPage(); 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' } }); + fireEvent.change(screen.getByLabelText('gpt-image-2 2K'), { + target: { value: '58' }, + }); await user.click(screen.getByRole('button', { name: '保存定价' })); await user.click(screen.getByRole('button', { name: '确认' })); @@ -90,3 +134,82 @@ test('模型定价后台按模型展示单位并保存尺寸定价', async () => ); }); }); + +test('3D 区块按端点渲染版本底价与加价项,并随保存回传整段与定价版本', async () => { + const user = userEvent.setup(); + await renderPage(); + + expect(screen.getByText('文生 3D')).toBeTruthy(); + expect(screen.getByText('图生 3D')).toBeTruthy(); + expect(screen.getAllByText('高清贴图').length).toBeGreaterThan(0); + + fireEvent.change(screen.getByLabelText('文生 3D v3.1-20260211 带贴图'), { + target: { value: '18' }, + }); + fireEvent.change(screen.getByLabelText('图生 3D P2-20260801 无贴图'), { + target: { value: '86' }, + }); + fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), { + target: { value: '6' }, + }); + + await user.click(screen.getByRole('button', { name: '保存定价' })); + await user.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(upsertAdminEditorGenerationPricing).toHaveBeenCalledWith( + 'admin-token', + expect.objectContaining({ + model3d: { + textToModelPricing: expect.objectContaining({ + versionPrices: expect.objectContaining({ + 'v3.1-20260211': { noTexture: 8, texture: 18 }, + }), + addOnPrices: expect.objectContaining({ quadMesh: 6 }), + }), + imageToModelPricing: expect.objectContaining({ + versionPrices: expect.objectContaining({ + 'P2-20260801': { noTexture: 86, texture: 88 }, + }), + }), + }, + }), + ); + }); +}); + +test('数值小于 1 时禁用保存并给出中文提示', async () => { + const user = userEvent.setup(); + await renderPage(); + + fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), { + target: { value: '0' }, + }); + + expect(await screen.findByText(/以下价格必须是不小于 1 的整数/)).toBeTruthy(); + const saveButton = screen.getByRole('button', { + name: '保存定价', + }) as HTMLButtonElement; + expect(saveButton.disabled).toBe(true); + await user.click(saveButton); + expect(upsertAdminEditorGenerationPricing).not.toHaveBeenCalled(); +}); + +test('后端未配置 3D 段时给出提示并禁止保存', async () => { + vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue({ + ...pricing, + model3d: null, + }); + render( + , + ); + + expect(await screen.findByText(/当前部署未配置 3D 定价段/)).toBeTruthy(); + expect( + (screen.getByRole('button', { name: '保存定价' }) as HTMLButtonElement) + .disabled, + ).toBe(true); +}); diff --git a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx index e8326ed8b..c4c57fc5a 100644 --- a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx @@ -6,12 +6,18 @@ import { upsertAdminEditorGenerationPricing, } from '../api/adminApiClient'; import type { + EditorGenerationModel3dPricingPayload, EditorGenerationModelPricingPayload, EditorGenerationPricingConfigPayload, EditorGenerationPricingUnitPayload, } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; -import { handlePageError } from './pageUtils'; +import { AdminEditorGenerationModel3dPricingSection } from './AdminEditorGenerationModel3dPricingSection'; +import { + buildAdminEditorGenerationPricingRequest, + collectInvalidEditorGenerationPricingLabels, +} from './adminEditorGenerationPricing'; +import { handlePageError, parsePositiveInteger } from './pageUtils'; interface AdminEditorGenerationPricingPageProps { token: string; @@ -20,6 +26,7 @@ interface AdminEditorGenerationPricingPageProps { const emptyPricing: EditorGenerationPricingConfigPayload = { models: {}, + model3d: null, }; const unitLabels: Record = { @@ -57,7 +64,7 @@ export function AdminEditorGenerationPricingPage({ async function handleSave(event: FormEvent) { event.preventDefault(); - if (isSaving) { + if (isSaving || !canSave) { return; } setErrorMessage(''); @@ -71,7 +78,12 @@ export function AdminEditorGenerationPricingPage({ setIsSaving(true); try { - setPricing(await upsertAdminEditorGenerationPricing(token, pricing)); + setPricing( + await upsertAdminEditorGenerationPricing( + token, + buildAdminEditorGenerationPricingRequest(pricing), + ), + ); } catch (error: unknown) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -120,6 +132,15 @@ export function AdminEditorGenerationPricingPage({ }); } + function updateModel3dPrice(next: EditorGenerationModel3dPricingPayload) { + setPricing((current) => ({ ...current, model3d: next })); + } + + const invalidPriceLabels = + collectInvalidEditorGenerationPricingLabels(pricing); + const isModel3dMissing = !pricing.model3d; + const canSave = invalidPriceLabels.length === 0 && !isModel3dMissing; + return (
@@ -144,6 +165,20 @@ export function AdminEditorGenerationPricingPage({
) : null} + {isModel3dMissing ? ( +
+ 当前部署未配置 3D 定价段(缺失即 3D + 生成不可提交):需先由受控默认配置提供两段价格, + 后台不在页面里新建或停用整段。 +
+ ) : null} + + {invalidPriceLabels.length > 0 ? ( +
+ {`以下价格必须是不小于 1 的整数:${invalidPriceLabels.join('、')}`} +
+ ) : null} +
{Object.entries(pricing.models).map(([model, modelPricing]) => @@ -156,9 +191,16 @@ export function AdminEditorGenerationPricingPage({ )}
+ {pricing.model3d ? ( + + ) : null} +
); } - -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/pages/AdminProfileWalletConfigPage.tsx b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx index bfc01d5f0..c6f2ffa56 100644 --- a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx @@ -7,7 +7,7 @@ import { } from '../api/adminApiClient'; import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; -import { handlePageError } from './pageUtils'; +import { handlePageError, parsePositiveInteger } from './pageUtils'; interface AdminProfileWalletConfigPageProps { token: string; @@ -197,8 +197,3 @@ export function AdminProfileWalletConfigPage({ ); } - -function parsePositiveInteger(value: string) { - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0; -} diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index 67eb38b88..3e2d06551 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -11,7 +11,7 @@ import type { ProfileRechargeProductKind, } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; -import { handlePageError } from './pageUtils'; +import { handlePageError, parsePositiveInteger } from './pageUtils'; interface AdminRechargeProductPageProps { token: string; @@ -546,11 +546,6 @@ function formatPrice(priceCents: number) { return `¥${(priceCents / 100).toFixed(2)}`; } -function parsePositiveInteger(value: string) { - const parsed = parseInteger(value); - return parsed > 0 ? parsed : 0; -} - function parseNonNegativeInteger(value: string) { const parsed = parseInteger(value); return parsed > 0 ? parsed : 0; diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx index 1519bb867..6c586fb28 100644 --- a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx @@ -12,7 +12,7 @@ import type { ProfileRedeemCodeMode, } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; -import { handlePageError, splitLines } from './pageUtils'; +import { handlePageError, parsePositiveInteger, splitLines } from './pageUtils'; interface AdminRedeemCodePageProps { token: string; @@ -422,11 +422,6 @@ export function AdminRedeemCodePage({ ); } -function parsePositiveInteger(value: string) { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; -} - function redeemModeLabel(value: ProfileRedeemCodeMode) { return redeemModes.find((item) => item.value === value)?.label ?? value; } diff --git a/apps/admin-web/src/pages/AdminTaskConfigPage.tsx b/apps/admin-web/src/pages/AdminTaskConfigPage.tsx index 33026ce0a..febcbeced 100644 --- a/apps/admin-web/src/pages/AdminTaskConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminTaskConfigPage.tsx @@ -19,7 +19,7 @@ import { filterAdminTrackingEventKeyOptions, findAdminTrackingEventDefinition, } from '../config/trackingEventDefinitions'; -import { handlePageError } from './pageUtils'; +import { handlePageError, parsePositiveInteger } from './pageUtils'; interface AdminTaskConfigPageProps { token: string; @@ -571,11 +571,6 @@ export function AdminTaskConfigPage({ ); } -function parsePositiveInteger(value: string) { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; -} - function parseInteger(value: string) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? parsed : 0; diff --git a/apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts b/apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts new file mode 100644 index 000000000..b2a4d6a9a --- /dev/null +++ b/apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from 'vitest'; + +import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes'; +import { + buildAdminEditorGenerationPricingRequest, + collectInvalidEditorGenerationPricingLabels, + editorModel3dAddOnLabel, +} from './adminEditorGenerationPricing'; +import { parsePositiveInteger } from './pageUtils'; + +function pricingFixture(): EditorGenerationPricingConfigPayload { + return { + models: { + 'gpt-image-2': { unit: 'perGeneration', prices: { '1K': 3, '2K': 5 } }, + 'audio1.0': { unit: 'perGeneration', price: 5 }, + }, + model3d: { + textToModelPricing: { + versionPrices: { 'v3.1-20260211': { noTexture: 8, texture: 16 } }, + addOnPrices: { hdTexture: 8, unknownAddOn: 7 }, + }, + imageToModelPricing: { + versionPrices: { 'v3.1-20260211': { noTexture: 16, texture: 24 } }, + addOnPrices: { hdTexture: 8 }, + }, + }, + }; +} + +test('数值解析把空串、非数字、小数与 0 收敛成 0', () => { + expect(parsePositiveInteger('12')).toBe(12); + expect(parsePositiveInteger('')).toBe(0); + expect(parsePositiveInteger('abc')).toBe(0); + expect(parsePositiveInteger('12abc')).toBe(0); + expect(parsePositiveInteger('3.9')).toBe(0); + expect(parsePositiveInteger('0')).toBe(0); + expect(parsePositiveInteger('-3')).toBe(0); +}); + +test('未登记的 3D 加价项键回退显示原键名', () => { + expect(editorModel3dAddOnLabel('hdTexture')).toBe('高清贴图'); + expect(editorModel3dAddOnLabel('unknownAddOn')).toBe('unknownAddOn'); +}); + +test('校验覆盖模型档位与 3D 两段的全部数值', () => { + expect(collectInvalidEditorGenerationPricingLabels(pricingFixture())).toEqual( + [], + ); + + const invalid = pricingFixture(); + const invalidImagePricing = invalid.models['gpt-image-2']; + const invalidTextEndpoint = invalid.model3d?.textToModelPricing; + const invalidImageEndpoint = invalid.model3d?.imageToModelPricing; + if ( + !invalidImagePricing?.prices || + !invalidTextEndpoint || + !invalidImageEndpoint + ) { + throw new Error('夹具必须包含图片档位与 3D 两段'); + } + const imageVersionPrice = invalidImageEndpoint.versionPrices['v3.1-20260211']; + if (!imageVersionPrice) { + throw new Error('夹具必须包含图生 3D 的版本底价'); + } + invalidImagePricing.prices['2K'] = 0; + invalidTextEndpoint.addOnPrices.hdTexture = 0; + imageVersionPrice.noTexture = 0; + expect(collectInvalidEditorGenerationPricingLabels(invalid)).toEqual([ + 'gpt-image-2 2K', + '文生 3D 高清贴图', + '图生 3D v3.1-20260211 无贴图', + ]); +}); + +test('保存请求整段回传两段且不带定价版本,3D 缺失时回传 null', () => { + const pricing = pricingFixture(); + expect(buildAdminEditorGenerationPricingRequest(pricing)).toEqual({ + models: pricing.models, + model3d: pricing.model3d, + }); + expect( + buildAdminEditorGenerationPricingRequest({ ...pricing, model3d: null }), + ).toEqual({ + models: pricing.models, + model3d: null, + }); +}); diff --git a/apps/admin-web/src/pages/adminEditorGenerationPricing.ts b/apps/admin-web/src/pages/adminEditorGenerationPricing.ts new file mode 100644 index 000000000..044c9159f --- /dev/null +++ b/apps/admin-web/src/pages/adminEditorGenerationPricing.ts @@ -0,0 +1,115 @@ +import type { + AdminUpsertEditorGenerationPricingRequest, + EditorGenerationModel3dEndpointPricingPayload, + EditorGenerationModel3dPricingPayload, + EditorGenerationPricingConfigPayload, +} from '../api/adminApiTypes'; + +/** 3D 端点在两个并列段里的字段名与后台展示名。 */ +export const EDITOR_MODEL3D_ENDPOINT_SECTIONS = [ + { key: 'textToModelPricing', label: '文生 3D' }, + { key: 'imageToModelPricing', label: '图生 3D' }, +] as const satisfies ReadonlyArray<{ + key: keyof EditorGenerationModel3dPricingPayload; + label: string; +}>; + +/** + * 3D 加价项键到中文标签。键来自接口返回,这里只负责展示;遇到未登记的键回退显示原键名, + * 不因为标签缺失就把该项漏掉。 + */ +const EDITOR_MODEL3D_ADD_ON_LABELS: Record = { + hdTexture: '高清贴图', + ultraTexture: '超高清贴图', + hdGeometry: '高清几何', + quadMesh: '四边形网格', + smartLowPoly: '智能低面', + generateParts: '分件生成', +}; + +export function editorModel3dAddOnLabel(addOn: string) { + return EDITOR_MODEL3D_ADD_ON_LABELS[addOn] ?? addOn; +} + +export function isPositiveMudPoints(value: number | null | undefined) { + return typeof value === 'number' && Number.isInteger(value) && value >= 1; +} + +/** + * 提交前校验:所有泥点数值都必须是不小于 1 的整数,返回不合法项的展示名。 + * + * 后端仍会独立校验一遍;这里只是不把明显非法的 payload 发出去,避免保存后才报错。 + */ +export function collectInvalidEditorGenerationPricingLabels( + pricing: EditorGenerationPricingConfigPayload, +) { + const labels: string[] = []; + for (const [model, modelPricing] of Object.entries(pricing.models)) { + const tieredPrices = modelPricing.prices; + // `prices: {}` 没有档位可校验,不能算「这一项走分层计价」——否则底价会被顺手跳过校验。 + if (tieredPrices && Object.keys(tieredPrices).length > 0) { + for (const [tier, price] of Object.entries(tieredPrices)) { + if (!isPositiveMudPoints(price)) { + labels.push(`${model} ${tier}`); + } + } + continue; + } + if (!isPositiveMudPoints(modelPricing.price)) { + labels.push(`${model} 泥点`); + } + } + + const model3d = pricing.model3d; + if (model3d) { + for (const { key, label } of EDITOR_MODEL3D_ENDPOINT_SECTIONS) { + // 段与子表都来自接口返回:缺段或缺子表时只跳过这一段,不能让整页校验崩掉。 + const endpointPricing = model3d[key]; + if (!endpointPricing) { + continue; + } + for (const [version, price] of Object.entries( + endpointPricing.versionPrices ?? {}, + )) { + if (!isPositiveMudPoints(price.noTexture)) { + labels.push(`${label} ${version} 无贴图`); + } + if (!isPositiveMudPoints(price.texture)) { + labels.push(`${label} ${version} 带贴图`); + } + } + for (const [addOn, price] of Object.entries( + endpointPricing.addOnPrices ?? {}, + )) { + if (!isPositiveMudPoints(price)) { + labels.push(`${label} ${editorModel3dAddOnLabel(addOn)}`); + } + } + } + } + return labels; +} + +/** + * 组装保存请求:`models` 与 3D 两段整段回传,不带定价版本(保存即覆盖,服务端不比对版本)。 + * 3D 段缺失时回传 `null`——服务端会以 400 拒绝,后台不允许在这里静默新建或丢弃整段。 + */ +export function buildAdminEditorGenerationPricingRequest( + pricing: EditorGenerationPricingConfigPayload, +): AdminUpsertEditorGenerationPricingRequest { + return { + models: pricing.models, + model3d: pricing.model3d ?? null, + }; +} + +/** 单个端点段的不可变更新:底价与加价项各改一处,其余原样保留。 */ +export function updateEditorModel3dEndpointPricing( + model3d: EditorGenerationModel3dPricingPayload, + section: keyof EditorGenerationModel3dPricingPayload, + updater: ( + current: EditorGenerationModel3dEndpointPricingPayload, + ) => EditorGenerationModel3dEndpointPricingPayload, +): EditorGenerationModel3dPricingPayload { + return { ...model3d, [section]: updater(model3d[section]) }; +} diff --git a/apps/admin-web/src/pages/pageUtils.ts b/apps/admin-web/src/pages/pageUtils.ts index bea44fd7e..51a61cd3d 100644 --- a/apps/admin-web/src/pages/pageUtils.ts +++ b/apps/admin-web/src/pages/pageUtils.ts @@ -1,5 +1,18 @@ import { formatAdminApiError, isAdminApiError } from '../api/adminApiClient'; +/** + * 泥点 / 积分类输入框文本转正整数:空串、非数字、小数、0 与负数一律收敛成 0, + * 由各页的提交前校验拦住。 + * + * 用 `Number` 而不是 `parseInt`:`parseInt` 会把 `3.9` 截成 3、`12abc` 截成 12, + * 等于把编辑中的半截输入静默当成合法值存下去;再用 `isSafeInteger` 挡掉超出安全整数 + * 范围的值(那种值在别处会被静默改写)。后台各页统一用这一份,不再各写一遍。 + */ +export function parsePositiveInteger(value: string) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0; +} + export function handlePageError( error: unknown, onUnauthorized: (message?: string) => void, diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 2198adbdf..a4f0c5c76 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -2972,6 +2972,34 @@ button:disabled { gap: 10px; } +.admin-pricing-model3d-list { + display: grid; + gap: 10px; +} + +.admin-pricing-model3d-row { + display: grid; + grid-template-columns: minmax(0, 1.2fr) repeat(2, minmax(0, 1fr)); + align-items: end; + gap: 10px; +} + +.admin-pricing-model3d-row-label { + color: #5c3a26; + font-size: 13px; + font-weight: 700; + line-height: 1.4; + overflow-wrap: anywhere; + padding-bottom: 10px; +} + +.admin-pricing-model3d-hint { + color: #7b5a45; + font-size: 13px; + line-height: 1.5; + margin: 0; +} + .admin-pricing-unit { flex: 0 0 auto; border-radius: 999px; @@ -3022,6 +3050,7 @@ button:disabled { .admin-two-column-wide, .admin-accounts-layout, .admin-pricing-grid, + .admin-pricing-model3d-row, .admin-form-row, .admin-filter-grid, .admin-table-query-grid, diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 01d477568..8d334c57f 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -6312,8 +6312,7 @@ dependencies = [ [[package]] name = "ts-rs" version = "12.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003" dependencies = [ "thiserror 2.0.18", "ts-rs-macros 12.0.1", @@ -6334,9 +6333,9 @@ dependencies = [ [[package]] name = "ts-rs-macros" version = "12.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003" dependencies = [ + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.118", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index f28a5ebc0..0ecb99302 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -29,7 +29,10 @@ tauri-build = { version = "2.6.2", features = [] } [dependencies] codex-patch-parser = { path = "vendor/codex-patch-parser" } chrono = { version = "0.4", default-features = false, features = ["std"] } -ts-rs = "12.0.1" +# 与 server-rs 同一口径:已发布的 ts-rs 12.0.1 解析不了 enum 级 `serde(deny_unknown_fields)` +# (见 agent/direct_codex_user_item/model.rs),上游修复 #491 未发版,故固定到该 commit; +# 等 crates.io 发布 > 12.0.1 后改回 registry 版本。不要改用 `no-serde-warnings` 关告警。 +ts-rs = { git = "https://github.com/Aleph-Alpha/ts-rs", rev = "fd8679ef20f866f747c046b30bd29c15e3d7a003" } typed_floats = { version = "1.0.7", features = ["serde"] } nalgebra = { version = "0.35.0", features = ["serde-serialize"] } agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } @@ -66,7 +69,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" } portable-pty = "0.9" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } regex = "1" -shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] } +shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index 99e0417d3..d52bfaa22 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -1266,13 +1266,11 @@ max-width: calc(100% - 28px); } -/* 共享 chrome 的 `.genarrative-image-canvas__toolbar` 是横向滚动条(`overflow-x:auto`), - 会把它自己弹出的二级菜单(`bottom:100%+6px`)裁掉。这里改成换行、不裁切: - 「生成规范」的二级菜单必须可见,窄屏用换行兜底而不是横向滚动。 */ +/* 「生成规范」的二级菜单(`bottom:100%+6px`)必须可见:换行与不裁剪由共享工具栏保证 + (`@genarrative/image-canvas-react` 的 `.genarrative-image-canvas__toolbar` 已从 + `overflow-x: auto` 改成 `flex-wrap: wrap`),这里只限宽,不再重复覆盖 overflow。 */ .game-resource-bottom-toolbar-strip { max-width: 100%; - overflow: visible; - flex-wrap: wrap; } .game-resource-bottom-toolbar-action { diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 0487d76b7..9a5b17f46 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -10201,6 +10201,10 @@ export default function ProjectDevelopmentView({ onOpenCharacterAnimationPanel={ openResourceCharacterAnimationPanel } + /* 资源画布不接 3D 预览:`resolveResourceCanvasToolbarActions` + 不登记 preview-model3d,按钮不会渲染,这里只为满足共享工具条 + 的必填签名,与上面其余未接通动作的写法一致。 */ + onPreviewModel3d={() => {}} onDownloadLayer={() => { if ( !selectedResource || diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 362a73b85..ff768099a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8884,9 +8884,20 @@ export function registerProjectAgentStatusTests() { expect(dock).toMatch(/(?:^|[;\s])right:/u); expect(dock).not.toMatch(/(?:^|[;\s])left:/u); - // 二级菜单不能被工具栏自己的横向滚动裁掉(共享 chrome 默认 overflow-x: auto)。 - expect( - styleRuleBody(chromeStyles, '\\.game-resource-bottom-toolbar-strip'), - ).toMatch(/overflow:\s*visible/u); + // 二级菜单不能被工具栏自己的横向滚动裁掉。这个机制住在共享工具栏里:托管规则曾经是 + // `overflow-x: auto`(把它自己底部弹出的二级菜单裁掉,也把尾部工具滚出视野),现在 + // 换成「换行 + 不裁剪」,所以这里直接钉住共享规则本身 —— 谁把滚动容器加回来, + // 「生成规范」的二级菜单就会立刻被裁掉,这条断言先失败。 + const sharedToolbar = styleRuleBody( + readFileSync( + repoPath('packages/image-canvas-react/src/styles.css'), + 'utf8', + ), + '\\.genarrative-image-canvas__toolbar', + ); + expect(sharedToolbar).toMatch(/flex-wrap:\s*wrap/u); + expect(sharedToolbar).not.toMatch( + /overflow(?:-[xy])?\s*:\s*(?:auto|scroll|hidden)/u, + ); }); } diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index 2ce6a6ddb..5e1081bd3 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -47,6 +47,15 @@ GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID= GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET= GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS=30000 +# Tripo 3D 生成:网关与密钥没有内置默认值,会提交 / 执行 3D job 的角色 +#(api / external-generation-worker / all)缺任意一项就拒绝启动。 +# 下面的占位值只用于容器压测与 Jenkins 分支预览这类不跑真实 3D 请求的环境; +# 要真实跑 3D 必须换成真凭据,否则进程能起、3D 提交会在请求期 503。 +TRIPO_BASE_URL=https://openapi.tripo3d.com/v3 +TRIPO_API_KEY=CHANGE_ME_FOR_CONTAINER +TRIPO_REQUEST_TIMEOUT_MS=60000 +TRIPO_RETRIES=2 + GENARRATIVE_OTEL_ENABLED=true OTEL_SERVICE_NAME=genarrative-api OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318 diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 02202a8ec..abd32d3af 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -101,6 +101,15 @@ ELEVENLABS_BASE_URL=https://api.elevenlabs.io ELEVENLABS_API_KEY= ELEVENLABS_REQUEST_TIMEOUT_MS=180000 +# Tripo 3D 生成:两项都是必填,缺任意一项时 api-server / external-generation-worker +# 会在启动期直接拒绝启动(不兜底到任何默认网关)。网关没有内置默认值; +# 密钥按部署机约定放受保护文件或受保护 env,不要写进仓库,也不要把这一行留空后部署。 +TRIPO_BASE_URL=https://openapi.tripo3d.com/v3 +TRIPO_API_KEY= +# 可缺省(沿用内置 60000 / 2);显式给出时零值或越界值会让进程拒绝启动。 +TRIPO_REQUEST_TIMEOUT_MS=60000 +TRIPO_RETRIES=2 + HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2 HYPER3D_API_KEY= HYPER3D_MODEL_REQUEST_TIMEOUT_MS=180000 diff --git a/docs/README.md b/docs/README.md index 20a519f46..932022d80 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,9 @@ - [策划 Agent 生产迁移与工作区浏览](./technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md):已完成;当前策划入口统一使用 Design Agent,采用阶段审批与用户工作区文件浏览。旧 V1/V2 会话、命令、专用展示和测试不再作为兼容目标。 +- [Tripo 3D 模型 Provider 集成](<./technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md>):实现 text-to-model、image-to-model、multiview-to-model 的 Rust provider adapter 与流式下载边界。 +- [Tripo 3D 生成 API 集成](<./technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md>):把 text-to-model 与 image-to-model 接成内部 API,复用异步队列与状态查询,产物落 OSS 并登记资源,按显式泥点定价真实扣费。 + - [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。 - [Responses API 与 Agents SDK 迁移评估](./technical/【技术评估】Responses%20API与Agents%20SDK迁移评估-2026-09-07.md):评估 AGC 核心 Runtime 是否迁移到外部 Agent SDK 及其边界。 @@ -92,6 +95,13 @@ ## 后端、运维与测试 +- [Tripo SDK 隔离与 Provider DTO 边界 ADR](./adr/【ADR】0001-Tripo%20SDK隔离与Provider%20DTO边界-2026-09-18.md):固定 SDK 只存在于 `platform-tripo` 内部,provider task 与产品资源结果分层。 +- [Tripo 生成 API 集成边界与提交时定价 ADR](./adr/【ADR】0002-Tripo生成API集成边界与提交时定价-2026-09-21.md):全新 API 不复用 Hyper3D,at-most-once submit,按提交时定价真实扣费。 +- [3D 资源客户端媒体投影与格式真实性 ADR](./adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md):3D 资源的客户端投影按类别分叉,模型内容类型按字节识别,格式判定只在查看器包内(声明类型 → 字节魔数 → 地址扩展名),画布不判格式。 +- [3D 生成入口的价格真相与幂等身份 ADR](./adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md):3D 生成入口的价格只读实时定价查询,缺段即不可提交;幂等键由前端铸造,重试必须换键。 +- [3D 生成定价迁入 SpacetimeDB 与后台编辑 ADR](./adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md):3D 定价按端点拆成两段并列设置并改为 SpacetimeDB 权威、后台可编辑;公开读模型与画布 3D 入口不变。 +- [Tripo 配置启动期失败关闭 ADR](./adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md):会提交或会执行 3D job 的角色缺 `TRIPO_BASE_URL` / `TRIPO_API_KEY` 即拒绝启动;网关无内置默认值,两个运行旋钮可缺省但声明即校验。 + - [BgFilter 受限资源调度方案](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md) - [Issue225 登录成功 AGC 用户归属修复](./technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md):登录 route tracking 的真实用户归属、`daily_login` 幂等边界和实施验收。 - [SpacetimeDB 连接池取消安全](./【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md) diff --git a/docs/adr/【ADR】0001-Tripo SDK隔离与Provider DTO边界-2026-09-18.md b/docs/adr/【ADR】0001-Tripo SDK隔离与Provider DTO边界-2026-09-18.md new file mode 100644 index 000000000..c0210d8f2 --- /dev/null +++ b/docs/adr/【ADR】0001-Tripo SDK隔离与Provider DTO边界-2026-09-18.md @@ -0,0 +1,9 @@ +# 【ADR】0001-Tripo SDK隔离与Provider DTO边界-2026-09-18 + +状态:已接受 + +`tripo3d-sdk` 只允许出现在 `platform-tripo` 内部。该 crate 以显式必填配置创建可复用 provider client,只公开自己的 text-to-model、image-to-model、multiview-to-model DTO、通用单次 task 查询、显式模型下载流和结构化错误;不暴露 SDK 类型、`wait_for_task` 或后台轮询。模型下载由 provider 自有 HTTP client 逐块消费,不提供完整 `Vec`;未来 OSS 持久化直接承接同一数据流,第三方 SDK 保持原样。共享产品契约与 provider task 结果分层,provider adapter 不伪造 `resourceId`/`assetId`;shared-contracts 的 Rust 类型始终通过 ts-rs 生成目录化 TypeScript binding。 + +选择这个边界是为了避免 API 层绑定第三方 SDK 的任务/错误模型,同时让未来的资源持久化、幂等和 provider 替换由应用层负责。请求 enum 以 Tripo API 文档为准;当前 `compress=geometry`、纹理质量 `fast/standard/detailed/extreme` 及纹理版本已经纳入目录化 Rust/TypeScript contracts。文档确认不存在于这三个 endpoint 的 `style` 字段不再保留占位类型,组合约束在 provider 副作用前执行。 + +完成结果按 endpoint 建模为严格类型:text-to-model 要求模型、渲染图和生成参考图 URL,image-to-model 与 multiview-to-model 要求模型和渲染图 URL;共同 task DTO 只用 enum 标识具体结果,不再提供由 `Vec` 和可选字段组成的宽松通用 output。URL 使用语义中性的 `TripoUrl`,真实响应缺少对应必填字段时以 output schema error 失败关闭。 diff --git a/docs/adr/【ADR】0002-Tripo生成API集成边界与提交时定价-2026-09-21.md b/docs/adr/【ADR】0002-Tripo生成API集成边界与提交时定价-2026-09-21.md new file mode 100644 index 000000000..88a69920f --- /dev/null +++ b/docs/adr/【ADR】0002-Tripo生成API集成边界与提交时定价-2026-09-21.md @@ -0,0 +1,15 @@ +# 【ADR】0002-Tripo 生成 API 集成边界与提交时定价-2026-09-21 + +状态:已接受 + +Tripo 3D 生成以**全新 API** 接入 api-server,不复用 `platform-hyper3d` 的路由、契约、错误映射或实现,也不做 provider 静默切换;旧 Hyper3D 能力保持原样。两个首期端点是 text-to-model 与 image-to-model,multiview 与 Splat 不接入。异步执行复用现有 `external_generation_job` 队列、worker、租约与状态查询,不新建平行队列、状态枚举或查询接口。对外只暴露 Genarrative 自己的 operation 标识,Tripo task 仅以服务端 checkpoint 形式存在,任何 API 都不返回 provider task ID、SDK 类型或带签名的临时 URL。 + +选择这个边界是因为第三方 SDK 的任务与错误模型会随 provider 变化,一旦穿透到 API 与前端就会把手第三方约束固化成产品契约;同时 Hyper3D 与 Tripo 的参数、状态与计费形态都不同,把它们塞进同一组路径会让“路径名”与“实际 provider”不一致,调用方无法分辨。 + +**at-most-once submit**:其它生成任务的崩溃恢复模型是“租约过期后重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。Tripo job 固定 `max_attempts = 1`,崩溃后租约耗尽直接终态失败、不重新 claim,因此没有续跑路径。因此 `external_generation_job` 追加 provider checkpoint 字段并新增受租约栅栏保护的写入 procedure:没有 checkpoint 才允许 submit,submit 成功后必须先落 checkpoint 再轮询,已有 checkpoint 只允许查询、下载与落库(防御性硬约束)。submit 成功但 checkpoint 落库失败的 attempt 只能终态失败,不得退回可重试队列。checkpoint 不写入 `request_payload_json`,因为该字段是请求真相并参与请求指纹与压缩逻辑。 + +**提交时定价与真实扣费**:Tripo 计费形态是“底价 + 可叠加 add-on”,现有“模型 → 档位 → 单价”查表表达不了,因此新增 `model3d` 定价段,全部以泥点计价,底价按 `endpoint × modelVersion × 是否有贴图` 拆分,add-on 按请求参数判定叠加;配置加载即校验全部底价键存在,运行期缺键直接拒绝提交,不复用现有 `unwrap_or(0)` 兜底。扣费依据固定为提交时定价,而不是 provider 返回的实际消耗:用户在提交前即可得到确定价格,失败退款就是 attempt 级单次冲正,不需要按实际用量退差额。provider 的实际消耗只写日志用于成本对账,不进入契约、API 响应或资源行。 + +**provider 成功但落库失败按失败处理并退款**:没有正式资源引用就不算交付成功。这会带来“provider 已消耗、用户已退款”的净亏损,是明确接受的成本,换取的是“用户看到成功就一定拿得到资源”的确定性。首期不提供取消,因为 SDK 没有取消能力,提供本地取消会让调用方误以为 provider 任务已停止。 + +代价与已知取舍:复用队列意味着要在一个被多个 job kind 共用的表上追加 provider 专用列;真实扣费意味着上线前必须先把 20 个底价键与 add-on 价格确认到位,否则接口无法开放;产物下载沿用完整字节缓冲,40MiB 级模型会占用内存,流式上传作为后续 TODO 处理。 diff --git a/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md b/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md new file mode 100644 index 000000000..9f2144996 --- /dev/null +++ b/docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md @@ -0,0 +1,15 @@ +# 【ADR】0003-3D 资源客户端媒体投影与格式真实性-2026-09-21 + +状态:已接受 + +3D 生成结果在资源行里同时持有三个字段:`objectKey` 是模型本体,`imageSrc` 与 `thumbnailSrc` 是预览图。客户端可见媒体投影不能再沿用「`objectKey` 非空就以它充当 `imageSrc`」的历史口径,否则画布卡片、素材缩略图与后台都会把 `.glb` 交给图片元素加载;因此投影改为按 `asset_kind` 分叉:`model3d` 只把预览图投影给客户端(依次取 `thumbnail_src`、`image_src`),模型本体只留在 `objectKey` 里交给 3D 查看器;`model3d` 的两个预览来源都缺失时 `imageSrc` 留空(消费方显示占位图),这是唯一允许「有对象但 `imageSrc` 为空」的类别。其它类别保持原样,两个来源都缺失时仍回落到历史口径。 + +模型格式的真相同样只保留一处:后端写入 `asset_object.content_type` 之前按字节魔数识别真实产物(GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary` 头、glTF 的 JSON 正文),对象键扩展名由该内容类型派生而不是反向推断。客户端不复制这份格式清单,也不在画布层按扩展名预先判定放行:画布只认「`assetKind=model3d` 且 `objectKey` 非空」,真正的格式判定跟着解析器走——`packages/model3d-viewer` 依次采信读接口声明的 `Content-Type`、字节魔数、地址扩展名,格式在该包里是可选输入,三者都判不出来时以 `unsupported-format` 失败,并把原因与支持的格式清单显示在模态里,而不是把入口藏起来去让用户猜。 + +被否决的做法:让画布或宿主外壳按对象键扩展名自行判断放行(客户端第二份格式清单,查看器扩了格式后画布仍会继续禁用入口);给资源表新增 `model_format` 列(同一真相两个来源,且 provider 上报的格式不可信);预览图缺失时把模型文件直接喂给图片元素(静默坏图、无法诊断,也说不出「该资源没有可预览的模型」这类错误信息),以及拿模型对象冒充 `imageSrc` 的投影回落(同一个坏图问题,只是从后端发生);在画布侧补「`assetKind` 为空但对象键像模型」的扩展名兜底(这种资源本就不该出现,兜底只会掩盖后端投影缺陷)。 + +后果:下载 3D 资源时按真实响应头的 MIME 命名、对象键扩展名兜底、最后回落 `glb`,导出目录用 `3d_models/` 与图片、其它媒体分开,且三个目录都在第一次真要写文件时才创建(没有 3D 内容的压缩包里不会出现空的 `3d_models/`);类型角标、缩略图和后台渲染器只要读 `imageSrc` 就能拿到预览图(为空即显示占位),不必各自识别媒体语义;查看器包单模型体积上限 64 MiB,超限与 `webgl-unavailable` 都作为失败原因展示给用户,而不是静默失败。 + +多文件模型不在支持范围内:站内 3D 产物是单文件的(`.glb` 自带几何与贴图,`.fbx` 按媒体内嵌处理),不假设会出现「模型文件 + 同目录 buffer / 贴图」这种产物,最多是「模型 + 一个 bin」。因此 `packages/model3d-viewer` 里把模型地址的签名 query 顺带补到同目录兄弟资源地址上的那段逻辑(`resolveModel3dViewerResourceUrl`)只是兜底,不代表查看器支持多文件模型:签名按单个文件签发时,贴到另一个文件上会被云端以签名不匹配拒绝。将来真要支持多文件模型,正确做法是让宿主为每个兄弟文件分别换取各自的临时地址,而不是继续扩展这段拼接。 + +未进入项:AGC 项目画布自带的两份格式判断(`isResourceModelFbx` 把 `octet-stream` 当 FBX、资源卡预览用扩展名正则选解析器)本期不动,后续统一收敛到 `packages/model3d-viewer`;素材库行、后台 renderer、精选 read model 与公开 grant 的 3D 展示同样未进入。 diff --git a/docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md b/docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md new file mode 100644 index 000000000..4bd06d529 --- /dev/null +++ b/docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md @@ -0,0 +1,13 @@ +# 【ADR】0004-3D 生成入口的价格真相与幂等身份-2026-09-21 + +状态:已接受 + +图片画布新增的 3D 生成入口,在提交前展示的泥点价格**只**来自编辑器生成定价查询(`GET /api/editor/generation-pricing`)返回的 `model3d` 段,前端不内置任何 3D 价格兜底数值;该段缺失时入口不可提交,既不发请求也不静默按 0 计价。这与图片 / 视频 / 音频一族相反——那些工具在前端保留内置兜底配置(`EDITOR_MODEL_MUD_POINT_CONFIG`),接口异常时价格照常展示。3D 刻意反着做:单次成本高一个量级,且后端在价格段缺失时是 fail closed(503 `model3d-pricing-unavailable`),前端若拿兜底数值展示,用户会看到「面板有价、提交被拒」,或者更糟——按错的数字被扣费。 + +被否决的做法:**新增后端 quote / 预检接口**(价格权威,但要新增公开 API、契约与规范流程,而现有请求参数组合已足以在客户端算出同一结果);**前端写死 3D 兜底价格**(与后端 fail closed 冲突,价格出现两个真相)。 + +3D 生成入口同时是画布链路里**第一个必须自带 `Idempotency-Key` 的生成工具**:既有的画布生成 POST 只带 `x-request-id`,且显式禁止自动重试。两个 3D submit 端点缺少该头一律 400,因此键由前端铸造并绑定当次请求内容;用户主动重试必须铸造新键,因为同键同请求只会返回原来那个(已完成或已失败的)operation,复用旧键的「重试」不会真的重跑。键与 payload 都由服务端参与指纹,用户改了参数即视为新请求。 + +落地形态:键 = 尝试代次 + 请求内容指纹(`Model3dGenerationSubmission.resolveModel3dRequestKey`)。服务端按「键 + payload 指纹」dedupe(同键同请求返原 operation,同键不同请求 409),而会改请求内容的入口散落在 `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel` 的通用函数里,因此把内容指纹并进键,而不是靠每处变更都记得重铸键。代次只负责让「用户主动重试」与上一次尝试区分开。 + +后果:`model3d` 定价段是 3D 入口的唯一价格真相,补价必须先落配置再开入口;前端必须保留一份「档位 → 六个必填参数」的映射与加和逻辑,它与后端 `model3d_add_ons` 是两份实现,靠同一组参数组合的用例对齐;提交接口不扣费,价格在提交时确定并写入 job,真正扣费发生在 worker 起跑、调用 provider 之前,失败按 attempt 冲正。 diff --git a/docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md b/docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md new file mode 100644 index 000000000..e881c063c --- /dev/null +++ b/docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md @@ -0,0 +1,15 @@ +# 【ADR】0005-3D 生成定价迁入 SpacetimeDB 与后台编辑-2026-09-23 + +状态:已接受 + +3D 生成定价(Tripo 的 `model3d` 段)的权威事实源,从「受控默认 JSON + 旧 override 文件的本地缓存」改为 SpacetimeDB `editor_generation_pricing_config` 表新增的两段强类型列;后台「模型定价」页面直接编辑这两段,保存与 `models` 段在同一事务内生效,改价不再需要改文件或重启进程。前提是记录形状先能表达 3D 计费:不在一段里用端点做判别键,而是并列 `textToModelPricing` / `imageToModelPricing` 两段,各段自带全部受支持模型版本的 `noTexture` / `texture` 底价与自己的六个加价项键(两段加价项当前数值相同,仍各留一份,便于后续按端点差异化)。 + +选择这个方向,是因为文件方案只解决了它诞生时的临时问题:文件是发布物,改价必须重新构建并重启进程,而价格是运营参数。更糟的是后台保存把「`model3d` 省略即沿用」当合法输入,而 admin-web 会把读取到的整份配置原样回传,于是一个陈旧的浏览器标签页保存图片价格时可以静默写回旧的 3D 段。把 3D 纳入与 `models` 同一行、同一事务、同一审计与 writer 授权之后,「谁是权威」只剩一个答案,价格编辑回到已经处理过并发与权限问题的后台路径。 + +代价与已知取舍:加载期必须做契约归一化。发布新增模型版本时 SpacetimeDB 行里缺键,会让整段读取失败,而失败点同时打掉后台定价页,形成「进程起不来、后台也补不了价」的死锁。因此读取方向按契约对齐——缺键用默认 JSON / 覆盖文件同段值补齐,契约之外的遗留键剔除并告警,只有归一化后仍不合法才判非法;写入方向不做宽松,后台保存要求两段都显式给出且完整合法;写入合法性只在入库事务里判断一次,进程内缓存的替换只是刷新、不是第二个拦截点。覆盖文件从「3D 的生效路径」降级为「表为空或 SpacetimeDB 不可达时的兜底与首次种子」。 + +公开读模型 `GET /api/editor/generation-pricing` 的 3D 段形状保持不变(仍是按端点判别的 `basePrices` 投影),因为它是画布 3D 入口的唯一价格真相且前端零兜底,而 nginx 静态资源与 api-server 是两条独立发布线,改形状会在错开发布窗口内让入口取不到价。保存语义同时收紧为「两段必填、整段覆盖」,旧 payload 的隐式沿用兼容不保留;保存不做版本比对、不返回 409(后台定价同时只有一个管理员在操作,2026-09-24 起删除版本号与比对)。 + +后续修订(2026-09-24):保存不再携带定价版本,`EditorGenerationPricingConfigUpsertInput` 去掉 `expected_updated_at_micros`,后台 GET 不再返回 `updatedAtMicros`,前台 409 提示与后端 409 映射一并删除。理由与取舍见决策记录「模型定价保存去掉版本比对」。 + +被否决的做法:**新建独立表**(同一份「模型定价」事实被拆成两行,跨行不同步与两套 writer / 审计语义,且仍要在同一逻辑保存里原子提交);**后台保存时改写 override JSON 文件**(违背「后台保存不再写文件」的口径,多进程各读各的,且与静态发布物耦合);**加载缺键即失败**(死锁,见上);**连公开读模型一起改成两段**(把发布耦合引入价格真相,收益只是形状统一)。 diff --git a/docs/adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md b/docs/adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md new file mode 100644 index 000000000..d1237c012 --- /dev/null +++ b/docs/adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md @@ -0,0 +1,17 @@ +# 【ADR】0006-Tripo 配置启动期失败关闭-2026-09-24 + +状态:已接受 + +会提交、会执行 3D job 的进程(`api` / `external-generation-worker` / `all`)在启动期校验 `TRIPO_BASE_URL` 与 `TRIPO_API_KEY`:缺失、空白,或网关不是一个带主机名的 HTTP(S) 地址,就拒绝启动。网关不再有内置默认值(`DEFAULT_TRIPO_BASE_URL` 已删除),`AppConfig::default()` 的 `tripo_base_url` 是空串,因此任何没被显式配置过的 `AppConfig` 都过不了这道门。两个运行旋钮 `TRIPO_REQUEST_TIMEOUT_MS` / `TRIPO_RETRIES` 仍可缺省(沿用内置的 `60000` / `2`),但一旦声明就必须合法:零超时、非数字、重试次数超过 `TRIPO_MAX_RETRIES = 10` 都是启动期错误。 + +选启动期而不是继续只做请求期,是因为请求期的失败点离排查入口太远:HTTP 角色收 3D 单时根本不碰 provider,worker 只在自己真的跑到那个 job 时才构造 `TripoSettings`,于是「进程起来了、`/healthz` 全绿、用户拿到 202」之后任务才失败,现场只剩一条「提交成功但结果一直不来」的记录。而 3D 单次成本比图片 / 音频高一个量级,静默回落到某个运维不知道的网关等于把请求、额度和账单发向另一个账号。两点合起来,「起不来」明显优于「起得来但悄悄坏掉」。 + +角色范围刻意只覆盖真正会碰 3D 的角色:`bgfilter-worker` 与 `external-generation-controller` 不会因为缺 `TRIPO_*` 而起不来。这是对仓库既有 provider 惯例的一次偏离——VectorEngine / ElevenLabs / ARK / 阿里云抠图 / AGC OSS 全部是请求期 503,唯一在启动期硬失败的先例是 `BgfilterWorkerRuntime::new` 的角色级校验。偏离的理由就是上面那条:失败成本与失败可见性,3D 和其它 provider 不在一个量级。 + +同时删掉了请求路径上的静默钳制(原来的超时 `.max(1)` 与重试 `.min(10)`)。钳制把一个部署错误翻译成「能跑但行为诡异」,而 `TripoSettings::validate()` 本来就拒绝零超时,钳制反而把这层保护捂住。现在 `tripo_settings()` 原样把值交给构造器,坏配置仍映射成 503。 + +代价与已知取舍:请求期 503 作为第二道防线保留,兜住「启动后配置被改」或「别的入口拼出非法 `AppConfig`」,行为仍是不可用、不扣费、不入队;所有本地与 CI 环境在启动 api-server / worker 前都必须提供这两项,之前 `.env` 里一份 `TRIPO_*` 都没有的开发者会先撞上一次启动失败;Tripo 密钥没有 `_FILE` 变体(bgfilter 的 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN` 有),密钥交付方式仍按部署机约定走 env。 + +这是一条破坏性部署要求,不是可选项:升级到本版本后,凡部署 `api` / `external-generation-worker` / `all` 角色的机器必须先把 `TRIPO_BASE_URL` 与 `TRIPO_API_KEY` 配好再上线,否则新进程起不来,而旧进程一停 3D 入口就是整体不可用。刻意不提供开关,也不加「缺配置先启动」的逃生口——一旦允许缺配置启动,回到的就是本次要消灭的那种「起得来但悄悄坏掉」的形态;确实不需要 3D 的部署只跑 `bgfilter-worker` / `external-generation-controller`,这两个角色不做 Tripo 启动校验。 + +被否决的做法:**保留内置默认网关**(缺配置时请求会打到运维没预期的账号);**只做请求期 503**(本次要修的就是这种「起得来、检查全绿、提交后失败」的形态);**只在 worker 角色校验**(HTTP 角色照样能把 3D 单收进来并返回 202,失败点仍然远离排查入口);**继续在请求路径静默钳制超时与重试**;**把两个旋钮也改成必填**(它们有明确的内置值、缺省是合法部署形态,强制填写只会制造与默认值等价的噪声配置)。 diff --git a/docs/project-memory/plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md b/docs/project-memory/plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md new file mode 100644 index 000000000..de065a6b6 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md @@ -0,0 +1,110 @@ +# 3D 生成定价后台可编辑实施计划 + +Version: 1.1 +Status: active +Date: 2026-09-23 +修订: 2026-09-24 —— 保存删除定价版本比对:`SpacetimeType` input 去掉 `expected_updated_at_micros`,后台 GET 不再返回 `updatedAtMicros`,admin-web 去掉 409 分支与「重新读取」提示,改为整段覆盖;见 `docs/project-memory/shared-memory/decision-log.md`。 +Milestone Spec: `docs/project-memory/plans/【里程碑】3D生成定价后台可编辑-2026-09-23.md` + +## 边界 + +改动集中在定价配置这一条链路:SpacetimeDB 表的两个追加列与 procedure、`spacetime-client` 记录映射、api-server 的定价类型 / 记录转换 / 后台路由、admin-web 的定价页与 API 类型、受控默认 JSON。不改公开读模型形状、不动画布 3D 入口、不改 `models` 段语义、不改 provider 与 worker 扣费链路。 + +## 文件切分 + +| 文件 | 职责 | +| --- | --- | +| `server-rs/crates/spacetime-module/src/editor_project_storage.rs` | 表追加两列,新增按端点分段的 `SpacetimeType` 行类型(版本价行 / 加价项行),upsert input / snapshot 扩字段(2026-09-24 修订后 input 不再有期望版本) | +| `server-rs/crates/spacetime-module/src/migration.rs` | 行形状变更的迁移登记与白名单 | +| `server-rs/crates/spacetime-client/src/module_bindings/**`、`src/active/mapper/editor_project.rs` | 生成绑定的记录映射:两段行 | +| `server-rs/crates/api-server/src/tripo3d/pricing.rs` | 两段强类型定价、按段强校验、按端点段查价 | +| `server-rs/crates/api-server/config/editor-generation-pricing.default.json` | 两段并列的受控默认价 | +| `server-rs/crates/api-server/src/editor_generation_config.rs` | 两段解析 / 校验 / `model3d_price` 与 `with_previous_model3d` 的退役(2026-09-24 修订后进程内缓存只剩配置本身,不含版本) | +| `server-rs/crates/api-server/src/state.rs` | `to_records` / `from_record` 的两段序列化与契约归一化、保存路径接入入库(2026-09-24 修订后不做版本比对) | +| `server-rs/crates/api-server/src/admin.rs` | GET 返回两段定价、POST 两段必填(2026-09-24 修订后不再有定价版本与 409 映射) | +| `apps/admin-web/src/api/adminApiTypes.ts`、`adminApiClient.ts` | 两段类型(2026-09-24 修订后不再有定价版本字段) | +| `apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx` | 3D 区块、本地校验、400 提示(2026-09-24 修订后删除 409 提示) | + +## 步骤 + +1. **schema 两列** + - 表末尾追加 `text_to_model_pricing` / `image_to_model_pricing`,各带显式默认值;行类型保持展平(版本价一行含两档价,加价项一行一条)。 + - 同步 `migration.rs`、表目录文档与生成绑定。 + - 验收:`npm run check:spacetime-schema` 通过;旧行读出来的两段为空且不影响既有 `models`。 + +2. **定价类型与默认文件** + - `Model3dPricingConfig` 改成两段结构,端点由字段名承载而不是 map 键;强校验按段执行,错误信息带端点名。 + - 默认 JSON 改成两段并列形状,换算口径与 credit 表不变。 + - 验收:`tripo3d::pricing` 定向用例(按段缺键即非法、查价 = 该段底价 + 命中加价项)通过;默认价仍按 `ceil(0.8 × credit)` 反算。 + +3. **记录转换与归一化** + - `to_records` 写两段;`from_record` 读两段并按契约归一化(缺键用默认 JSON / 覆盖文件同段值补齐,契约外键剔除),归一化动作写日志。 + - 覆盖文件降级为种子与兜底:不再参与「每次启动覆盖」。 + - 验收:结构体 record 往返不丢 3D;手工从 record 删掉一个版本键后读取仍成功且补齐该键;塞入契约外键后读取成功且该键被剔除并告警。 + +4. **后台契约** + - GET 响应给两段定价;POST 要求两段显式给出,合法即整段覆盖(2026-09-24 修订:不再带期望版本、不返回 409)。 + - 退役 `with_previous_model3d` 与其用例,改为「缺段即 400」。 + - 验收:缺段 400、段内缺键 400 且不落库;保存后公开路由立即返回新价,连续保存以来后一次为准。 + +5. **admin-web 3D 区块** + - 类型补两段;页面按端点渲染版本 × 两档价与加价项输入;提交前校验全部数值 ≥ 1。 + - 验收:组件用例覆盖渲染、校验与保存 payload。 + +6. **文档与验收** + - 同步主规范、表目录、`CONTEXT.md`、ADR 0005 的实现状态,必要时补 `pitfalls.md`(例如「不要把省略即沿用重新引入」)。 + - 手工验收:后台改 3D 价 → 不重启 → 画布 3D 面板价格随之变化;重启后仍是保存值。 + +## 实现状态(2026-09-23 完成) + +| 步骤 | 状态 | 提交 / 落点 | +| --- | --- | --- | +| 1 schema 两列 | 完成 | `774b74d3d`;module 表尾部追加两列与展平行类型,绑定重新生成,`npm run check:spacetime-schema` 通过 | +| 2 定价类型与默认文件 | 完成 | `9f4a71d2e`;`Model3dPricingConfig` 两段并列 + `Model3dEndpointPricing`,默认 JSON 同步 | +| 3 记录转换与归一化 | 完成 | `5e47f0c41`、`952bc1cca`;新增 `editor_generation_model3d_records` 深模块,覆盖文件降级为种子与兜底 | +| 4 后台契约 | 完成 | `ee4a9434f`;GET 两段、POST 两段必填并整段覆盖(2026-09-24 删除版本 CAS / 409),退役 `with_previous_model3d` | +| 5 admin-web 3D 区块 | 完成 | `c901f6954`;`AdminEditorGenerationModel3dPricingSection` + `adminEditorGenerationPricing` 纯模块 | +| 6 文档与验收 | 完成 | 本文件、主规范、ADR 0005、`pitfalls.md`、`decision-log.md` | + +补充落点(不在原步骤内,但为达成验收标准所必需): + +- `ebd904e19` 公开读模型投影:新增 `Model3dPricingPublicView`,`GET /api/editor/generation-pricing` 的 3D 段保持旧形状;加价项取文生段,两段不一致时告警。 +- 公开投影与前端零兜底口径一起构成「内部两段 / 公开单表」的短期不一致,前端升级前不要差异化加价项,见主规范「旧形状覆盖文件的处理」。 + +## 验收证据 + +| 判据 | 证据 | +| --- | --- | +| 1 后台 GET 两段、公开 GET 形状不变 | `cargo test -p api-server app::tests::admin_editor_generation_pricing_route_saves_config_and_updates_public_route`、`app::tests::public_editor_generation_pricing_route_returns_default_config` | +| 2 缺段 400 且不落库、合法保存整段覆盖 | `app::tests::admin_editor_generation_pricing_route_rejects_missing_model3d_section`、`state::tests::saving_pricing_applies_explicit_model3d` / `saving_pricing_twice_keeps_last_write` | +| 3 保存后公开路由立即返回新价 | 同上第一条用例在保存后重读公开路由断言新价 | +| 4 缺键补齐、契约外键剔除告警 | `editor_generation_model3d_records::tests::{missing_keys_and_missing_sections_are_backfilled_from_local_config,out_of_contract_keys_are_dropped,missing_key_without_local_value_fails_closed,half_configured_sections_are_rejected,duplicate_keys_are_rejected,writing_requires_the_complete_contract_key_set}` | +| 5 覆盖文件兜底与缺段补齐 | `editor_generation_config::tests::editor_generation_pricing_legacy_override_backfills_missing_model3d_section`、`..._default_path_falls_back_to_legacy_override_seed` | +| 6 未配置仍 fail closed | `editor_generation_config::tests::editor_generation_pricing_fails_closed_when_model3d_section_is_absent` | +| 7 admin-web 3D 区块渲染 / 校验 / 保存 payload | `npx vitest run apps/admin-web`(210 passed):`AdminEditorGenerationPricingPage.test.tsx`、`adminEditorGenerationPricing.test.ts` | +| 8 门禁 | `cargo test --locked -p api-server`(1183 passed,另有一条与本改动无关的时序敏感用例在整包并行下偶发失败、单跑通过)、`cargo test --locked -p spacetime-module editor_generation`、`npm run check:spacetime-schema`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` | + +尚未执行(需真实环境):手工验收「后台改 3D 价 → 不重启 → 画布 3D 面板价随之变化 → 重启后仍是保存值」;需要在发布 Stdb 模块与 API 后按开发运维文档执行。 + +## 验证命令 + +```bash +cargo check --locked -p api-server --manifest-path server-rs/Cargo.toml +cargo test --locked -p api-server --manifest-path server-rs/Cargo.toml pricing +cargo test --locked -p spacetime-module +npm run spacetime:generate +npm run check:spacetime-schema +npx vitest run apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx +npm run check:encoding +node scripts/check-doc-index.mjs +git diff --check +``` + +## 风险与回滚 + +- **归一化补齐的数值来源**:缺键用默认 JSON / 覆盖文件同段值补齐,若现网运营价与默认值不同,升级瞬间会把表内缺失键补成默认价。上线前先核对现网两段价格;必要时把现网值写进覆盖文件作为种子来源,再执行升级。 +- **覆盖文件的旧形状 3D 段**:处理策略见主规范「未决问题」,确认前不进入实现。 +- **发布顺序**:必须先发 Stdb 模块再发 API;API 先发时会因记录形状不匹配而报错。Web 面板可以晚于 API 发布。 +- **旧形状覆盖文件是发布前置门禁**:覆盖文件里若还留着 `model3d.basePrices`,升级后的 API 会在启动读取阶段解析失败。升级前先确认该文件不含 `model3d.basePrices`(缺整段是安全的,会按受控默认价补齐),见主规范「旧形状覆盖文件的处理」。 +- **回滚点 1(未发布)**:仅回滚代码,两列是纯追加且带默认值,不影响旧行为。 +- **回滚点 2(已发布 API)**:必须同时回滚 Stdb 与 API(旧 API 会把 3D 权威退回文件),回滚窗口内不要在后台改 3D 价;回滚后重新保存一次定价以确认两段仍在。 diff --git a/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md index 33870c0f0..e8f0d64cc 100644 --- a/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md @@ -48,7 +48,7 @@ 1. `cargo fmt --all -- --check` 2. `cargo test --locked -p shared-contracts game_creation_app --manifest-path server-rs/Cargo.toml` -3. `cargo test --locked -p shared-contracts --features ts-bindings export_bindings --manifest-path server-rs/Cargo.toml`(生成 `packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts`) +3. `cargo test --locked -p shared-contracts export_bindings --manifest-path server-rs/Cargo.toml`(生成 `packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts`) 4. `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 shell 自己的 ts-rs 绑定) 5. `npx vitest run apps/ai-game-creator-shell/tests/assetKind.test.ts packages/shared/src/contracts/gameCreationApp.test.ts`(解析 Rust 源码的 `assetKindCanonicalMapping.test.ts` 2026-09-17 已删除:手写词汇表被移除后它没有比对对象) 6. `npm run check:encoding` diff --git a/docs/project-memory/plans/【实施计划】Tripo生成API契约与数据模型-2026-09-21.md b/docs/project-memory/plans/【实施计划】Tripo生成API契约与数据模型-2026-09-21.md new file mode 100644 index 000000000..0df272b91 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Tripo生成API契约与数据模型-2026-09-21.md @@ -0,0 +1,64 @@ +# Tripo 生成 API 契约与数据模型实施计划 + +Version: 1.0 +Status: active +Date: 2026-09-21 +Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成API契约与数据模型-2026-09-21.md` + +## 步骤 + +1. **API 契约** + - `shared-contracts::model3d` 分两层:provider 生成参数(`Model3dTextToModelParams` / `Model3dImageToModelParams`)与 API 层请求(`Model3dTextToModelRequest` / `Model3dImageToModelRequest` = `generation` + 平台字段)。 + - 两层都保持 `deny_unknown_fields`,不使用 `flatten`:serde 的 `deny_unknown_fields` 与 `flatten` 不兼容,展开会让顶层未知字段静默通过。 + - `common/` 新增平台字段类型:`Model3dGenerationSource`(resource / asset 二选一)、结果落点引用(`Model3dGenerationTargetRef`,平坦可选的 `resourceId` / `assetId`,与请求落点同形)与产物元数据类型;请求侧落点与其它生成接口同形,用平坦的可选 `projectId` / `assetFolderId` / `assetLabel` / `canvasCompletion`,不引入 tagged enum。 + - 图片输入不属于 provider 参数:客户端只能给 `source`,`input` 由服务端解析后注入 provider 调用,避免绕过归属校验传任意 URL。 + - 新增按端点的严格结果 payload 类型(模型 artifact + 预览 artifact),二者都只携带正式资源引用与对象元数据。 + - 交付:类型定义、serde 组合测试(`projectId` 与 `assetFolderId` 都不给拒绝、两个都给合法、只给 `assetFolderId` 合法、`canvasCompletion` 与“没有 `projectId`”同现拒绝、`source` 两个 ID 同现拒绝)。 + - 验收:`cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml` 通过。 + +2. **ts-rs 目录绑定** + - 目录常量沿用 `model3d_ts_export_dir!`,新类型导出到既有 `packages/shared/src/contracts/model3d/` 子目录,更新 `index.ts` 导出。 + - 交付:`npm run contracts:model3d:generate` 后对生成目录执行 prettier。 + - 验收:生成产物与 Rust 类型一致,`packages/shared` barrel 可引用新类型。 + +3. **provider checkpoint 数据模型** + - `spacetime-module/src/external_generation.rs` 在 `ExternalGenerationJob` 结构体末尾追加 `providerKind`、`providerTaskId`(显式默认值),同步各 snapshot / mapper。 + - 新增受租约栅栏保护的 checkpoint 写入 procedure,并在 `spacetime-client` 增加对应 facade 方法与错误映射。 + - 交付:`migration.rs`、表目录与生成绑定同步。 + - 验收:`npm run check:spacetime-schema` 通过;新 procedure 对过期租约与错误 lease token 返回拒绝。 + +4. **泥点定价结构** + - `api-server/src/tripo3d/pricing.rs` 定义 `model3d` 段:底价按 `endpoint × modelVersion × 是否有贴图`,add-on 按固定键表;`validate` 要求两个端点 × 每个受支持模型版本 × 两种贴图态与全部 add-on 键同时存在。 + - `editor_generation_config.rs` 把该段作为可选子配置挂进现有定价快照:段缺失代表 3D 定价未配置,提交侧 fail closed,不扣费也不调用 provider。 + - 新增价格解析函数:输入端点、模型版本、贴图态与命中的 add-on 集合,返回泥点价格;缺键返回错误,不回退默认值。 + - 默认配置文件的生产数值待业务提供;在此之前只落地结构与校验,配置测试使用测试夹具,不写入臆测价格。 + - 验收:缺键加载失败、add-on 命中断言、`fast` / `standard` 不加价的单测通过。 + +5. **组合校验规则** + - 平台规则(定价相关参数必填、`texture=false` 禁止 `textureQuality` 且要求 `pbr=false`、`texture=true` 要求 `textureQuality`)落在 `api-server/src/tripo3d/validation.rs`。 + - provider 能力组合规则(模型档位 × 贴图/几何/quad/smart-low-poly/generate-parts 的互斥与取值范围)不复制,改为复用 `platform-tripo` 暴露的请求校验入口,作为唯一真相源。 + - 校验在调用任何 provider 方法与扣费之前执行;provider 抛出的参数错误按字段映射为 400。 + - 验收:非法组合单测覆盖平台规则与 provider 规则各一例。 + +6. **文档同步** + - 主规范、里程碑规范、ADR 已在本次一并落地;表目录文档同步新字段。 + - 验收:`node scripts/check-doc-index.mjs` 通过。 + +## 验证命令 + +```bash +cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml +cargo check --locked -p platform-tripo --manifest-path server-rs/Cargo.toml +npm run contracts:model3d:generate +npm run check:spacetime-schema +npm run check:encoding +node scripts/check-doc-index.mjs +git diff --check +``` + +## 风险与回退 + +- **共用队列表加列**:字段追加在末尾并带默认值,旧行可读;回退只需停止读取新列,不执行字段删除。 +- **定价数值未定**:结构可以先落地,但接口在真实价格写入前不得开放;里程碑二以此为前置依赖。 +- **契约目录扩张**:新类型只落在 `model3d` 既有目录内,不引入新的顶层契约目录,避免 barrel 维护成本上升。 +- **回退点**:本里程碑不新增路由、不改 worker 分支,回退范围为契约、schema 追加字段与定价结构三处。 diff --git a/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md b/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md new file mode 100644 index 000000000..44e4483bb --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md @@ -0,0 +1,101 @@ +# Tripo 生成 Worker 执行链路与 API 路由实施计划 + +Version: 1.0 +Status: active +Date: 2026-09-21 +Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md` + +## 边界 + +新代码只落在 `server-rs/crates/api-server/src/tripo3d/`,按职责拆成小文件;api-server 其余部分只做必要接线(配置文件、路由 merge、worker 分支、超时表)。不复用 `platform-hyper3d`、`hyper3d_generation.rs` 或 `/api/assets/hyper3d/*`,不接 `/api/external/v1`,不接前端。 + +## 文件切分 + +| 文件 | 职责 | +| --- | --- | +| `tripo3d/mod.rs` | 模块声明与 `router(state)`,不承载业务逻辑 | +| `tripo3d/job.rs` | 两个 job kind、端点与 job kind 映射、队列载荷类型与 `max_attempts` 口径 | +| `tripo3d/queue.rs` | `Idempotency-Key` 幂等身份、入队参数、accepted 响应 | +| `tripo3d/routes.rs` | 两个 POST 处理入口与执行顺序(解析 → 校验 → 定价 → 入队) | +| `tripo3d/errors.rs` | `Model3dRequestError`、`TripoError` 到 HTTP 的映射 | +| `tripo3d/image_source.rs` | `source` 分支归属校验与 provider 图片输入解析 | +| `tripo3d/provider.rs` | 从 `AppConfig` 构造 `TripoSettings` 与 provider client,缺配置即拒绝 | +| `tripo3d/artifacts.rs` | provider 产物下载、OSS 写入与产物元数据 | +| `tripo3d/storage.rs` | 产物 PUT、HEAD 复核与 `asset_object` 登记 | +| `tripo3d/result.rs` | 按端点构造严格结果与落点引用,写 `result_payload_json` | +| `tripo3d/worker.rs` | checkpoint 判定、submit、轮询、下载落库与 job 收口 | + +## 步骤 + +1. **配置接入** + - `AppConfig` 增加 Tripo base url / API Key / 请求超时 / 重试,沿用 provider 示例的环境变量口径。 + - `tripo3d/config.rs` 负责构造 `TripoSettings` 与 `TripoProviderClient`;未配置时返回 503 且不扣费、不入队。 + - 2026-09-24 修订:网关与密钥改为启动期必填——`TRIPO_BASE_URL` / `TRIPO_API_KEY` 没有内置默认值,缺任意一项时 `api` / `external-generation-worker` / `all` 直接拒绝启动;请求期 503 保留为第二道防线。见 [ADR 0006](../../adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md)。 + - 验收:缺 key 的提交返回 503,不产生 job。 + +2. **job kind 与队列载荷** + - 新增 `model3d_text_to_model` / `model3d_image_to_model` 两个 job kind;端点与 job kind 的唯一映射放在 `tripo3d/job.rs`。 + - 队列载荷是 API 请求本身,不额外嵌套平台字段;`request_payload_json` 沿用现有 fingerprint 注入方式。 + - Tripo job 固定 `max_attempts = 1`:任何 attempt 失败即终态失败并冲正扣费,崩溃后租约到期同样按耗尽结算,因此不存在第二次 submit 的窗口。 + - 验收:入队参数断言 `max_attempts == 1`。 + +3. **幂等提交路由** + - 两个路由要求 Bearer 与 `Idempotency-Key`;复用现有外部生成幂等身份与 payload fingerprint,同键同请求返回原 operation,同键不同请求 409。 + - 执行顺序固定为:解析 JSON → 平台组合校验(含 provider 预检)→ 图片输入元数据预检(仅 image-to-model)→ 查价 → 入队;任何一步失败都在扣费与 provider 调用之前返回。 + - 验收:缺头 400、缺价格段 fail closed、同键重放不新增扣费。 + +4. **图片输入解析** + - `source` 只接受站内资源或素材 ID。提交时只做元数据预检(按 `source.kind` 定点校验归属、记录类型与对象键),worker 执行时重新确认同一事实,再读出字节并上传 provider 换 `file_token` 提交;不接受客户端直给 URL,也不把带签名的临时地址交给第三方。 + - 预检失败不扣费、不入队:跨 owner / 未登记 / 已删除 / `kind` 不符按 400,`403`(服务身份校验失败)、`409`(版本冲突)与 5xx 保留原状态码,记录已解析但缺对象键按 502;预检只按主键定点查引用,不拉取完整工程列表或素材库。 + - 验收:跨 owner 的资源 / 素材被拒绝。 + +5. **产物落库** + - provider 产物下载由 `platform-tripo` 的下载入口读完整个 body(body 中途失败整体重下,体积上限由调用方传入),模型与预览分别 OSS 写入并记录 `content_type` / `content_length` / `sha256`(流式直传 OSS 留 TODO)。 + - 复用现有原子落库路径写入 `assetKind = "model3d"` 的资源 / 素材、预览图引用与 job 终态;不新增资源列。 + - 按「新增编辑器 `assetKind` 接入清单」对照表执行:把两个 Tripo job kind 加进 `EDITOR_GENERATION_OPERATION_KINDS`(否则原子落库整笔事务被拒);画布回填只走 placement-only,`model3d` 不进 `EDITOR_CANVAS_ASSET_KINDS`,也不动前端 `CANVAS_ASSET_KIND_TAG_OPTIONS`。 + - 验收:两个 job kind 在白名单内;`canvasCompletion` 回填的 layer 只有 `layerId` / `resourceId`,无 `assetKind` / `src` / `objectKey`;用户标签覆盖、快速编辑、改造 capability 三项保持不进入。 + - 验收:素材库落点产出 `assetId`,项目资源落点产出 `resourceId` 且 `content_length` 与实际对象一致。 + +6. **worker 执行与收口** + - 执行分支:无 checkpoint 才 submit,submit 成功后先落 checkpoint 再轮询;已有 checkpoint 只查询、下载与落库;checkpoint 写入失败按失败收口。 + - 轮询在单次 attempt 内进行并按现有心跳续租,超时按现有 worker 预算语义处理。 + - provider 成功但落库失败按失败收口并冲正扣费,checkpoint 保留供对账。 + - 验收:任何带 checkpoint 的执行路径都不产生第二次 submit;`max_attempts = 1` 使崩溃后不重新 claim(不存在续跑);落库失败不退化成“已交付”。 + +7. **接线与文档** + - `app.rs` merge 新路由;`external_generation_worker.rs` 增加两个分支并纳入长任务超时;`tripo3d/mod.rs` 去掉里程碑一的 `dead_code` 豁免。 + - 主规范把 `max_attempts = 1` 与 checkpoint 的实际语义写实(at-most-once 硬约束 + 人工对账,不是续跑能力),里程碑状态同步。 + - 验收:`node scripts/check-doc-index.mjs`、`npm run check:encoding`、`git diff --check` 通过。 + +## 进展 + +步骤 1 至 6 已落地:配置与路由、组合校验、定价、入队、图片输入解析、产物 OSS 写入与 worker 执行链路均已提交;步骤 7 的接线(`app.rs` merge、worker 分支、长任务超时)与文档同步也已完成。尚未完成的是需要真实环境的项: + +- 图片输入在提交时已补元数据预检(`tripo3d::image_source::preflight_image_source`):按 `source.kind` 分支配对记录类型并复用既有定点引用查询 `resolve_editor_reference_record_by_id_for_owner`,取代原先“列工程 / 列素材库再筛”的宽查询;跨 owner、未登记、已删除与 `kind` 不符都在扣费与入队前返回 400,改判口径见同日决策记录(403 / 409 / 5xx 保留原状态码,缺对象键 502),worker 执行时再重新确认同一事实。 +- 提交路由已补应用级验收用例:走真实 `build_router` 断言两个端点未鉴权 401、缺 `Idempotency-Key` 400、组合校验与定价必填字段冲突在定价与 provider 之前 400,因此路由 merge、鉴权中间件与校验顺序都有可执行证据,不依赖 provider 与 SpacetimeDB 环境。 + +- `model3d` 泥点定价已写入受控默认配置:换算口径 `ceil(0.8 × Tripo 官方 credit)`,H 系列文生 `10 / 20`、图生 `20 / 30`,P1 文生 `30 / 40`、图生 `40 / 50`,P2 两端均 `100 / 110`,add-on 六项按同一系数换算;`editor_generation_config` 的定向用例按 credit 表反算默认价,段缺失仍按 fail closed 拒绝。 +- worker 的纯逻辑部分已补定向用例:checkpoint 只在 provider task id 非空时才算存在、完成结果按端点严格区分且不含 provider 事实、预览图尺寸必须能真实解码;全量 `cargo test -p api-server` 1092 通过。 +- `npm run check:spacetime-schema` 通过(144 张表,锚点 `6f161a6b3626588a197208ea6dd68316815c01b5`),`external_generation_job` 的 provider checkpoint 追加字段没有破坏 schema guard。 +- TS 绑定仍是 `npm run contracts:model3d:generate` 目录化生成:重跑后 `packages/shared` 无 diff,`packages/shared/src/contracts/model3d/` 与 `contracts/editor-canvas/` 与 Rust 契约一致,没有手写 TS。 +- 没有跑过端到端真实调用(提交 → worker → OSS → 资源登记),现有证据只到上述单元与应用级用例,缺少真实 provider 与 SpacetimeDB / OSS 的联调证据。 +- 新增的 `tripo3d/` 文件里,`storage.rs` 的完整字节写入、`artifacts.rs` 的完整字节读取都留着流式 TODO。 + +## 验证命令 + +```bash +cargo check --locked -p api-server --manifest-path server-rs/Cargo.toml +cargo test --locked -p api-server --manifest-path server-rs/Cargo.toml tripo +npm run contracts:model3d:generate +npm run check:encoding +node scripts/check-doc-index.mjs +git diff --check +``` + +## 风险与回退 + +- **真实扣费**:默认价已按 `ceil(0.8 × credit)` 写入并随代码发布,部署侧若用 override 文件覆盖定价,必须确保 override 里保留完整的 `model3d` 段,否则该段整体缺失会让 3D 提交 fail closed。 +- **provider 长任务**:单次 attempt 内轮询依赖 lease 续租与长任务超时;超时按现有语义收口为失败并退款。 +- **共用 worker 分支**:新分支只消费自己的 job kind,不改既有分支的入队与写回路径。 +- **worker 占用**:3D 任务在单次 attempt 内阻塞轮询数分钟,会占住一个 worker 并发位;默认并发为 2,接入真实流量前需要确认并发与 `external_generation_worker_long_job_timeout` 是否够用。 +- **回退点**:删除路由 merge、worker 分支与 `tripo3d/` 新文件即可回退;schema 上的 checkpoint 字段保持向后兼容,不需要回滚。 diff --git a/docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md b/docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md new file mode 100644 index 000000000..22f9c7f7c --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md @@ -0,0 +1,131 @@ +# Tripo 生成前端入口实施计划 + +Version: 1.0 +Status: active +Date: 2026-09-21 +Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md` +Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md` + +## 边界 + +新代码只落在 `src/components/image-editor/model3d-generation/`,按 `src/components/image-editor/model3d-preview/` 的命名与切分方式组织:深目录、小文件、不带 `ImageCanvas` 前缀、测试文件与被测文件同名。画布其余部分只做必要接线(工具栏入口、子选项菜单、对话框模式、请求客户端)。不改后端、不改 schema、不新增公开 API、不接 `/api/external/v1`。 + +## 已确认口径 + +1. 宿主是画布生成链路,不新建独立生成页 / 结果页 / runtime / 作品架;3D 是资产型产出。 +2. 入口形态照 `music` / `spec`:工具栏一项入口 → 浮动子选项 → 各自一个提交面板。子项文案沿用后端 `phaseLabel`:「文生 3D 模型」「图生 3D 模型」。 +3. 价格**只**来自 `GET /api/editor/generation-pricing` 的 `model3d` 段(前端已有 `loadEditorGenerationPricing` 与运行时缓存)。段缺失即两个子面板都不可提交且不发请求;前端不写任何 3D 兜底数值,也不把缺价当 0。 +4. 参数永远全量非空提交:`model`、`texture`、`pbr`、`geometryQuality`、`quad`、`smartLowPoly`、`generateParts` 一律显式给出,不依赖后端默认值(provider 隐式默认会改变价格)。两个「不带该字段」的例外都在 `textureQuality` 上:`texture=false` 时后端显式拒绝贴图档位;v2.5 被 provider 预检拒绝任何 `texture_quality`(缺省按 standard 计价,与面板报价同档),因此这两种情况必须**不带**该字段,而不是给 `null`。 +4.1 模型版本(2026-09-24 追加)由用户选择,缺省 `v3.1-20260211`。可选集不写死在前端常量里,而是取**当前端点有底价**的版本(定价段的键),与「缺价不可提交」同一条失败关闭口径;报价与请求体读同一个 `resolveModel3dModelVersion(dialog.model3dModel)`,面板显示的价格与发出的 `generation.model` 不可能分叉。 +4.2 能力组合(2026-09-24 追加)在前端有一份与 provider 对齐的校验:`model3d-generation/Model3dGenerationValidation.ts` 逐条复刻 `platform-tripo::common::validation::validate_generation_options` 与 `api-server::tripo3d::validation::PricingParamView::validate`(模型族能力、分件的前置条件、面数上限边界、平台必显式项)。抄的是规则不是数值,规则冲突以 provider 为准;面板的禁用态与禁用原因都读这一份,`Model3dGenerationFormModel.test.ts` / `Model3dGenerationValidation.test.ts` 用组合表把两边钉在一起。之所以必须抄:契约与公开读模型只暴露价格,不暴露「哪些版本支持哪些参数」,否则用户会选到一个提交后必然被 provider 拒的组合。 +4.3 客户端提交按端点拆成两个函数(`submitModel3dTextToModelRequest` / `submitModel3dImageToModelRequest`),提交计划是 `endpoint` 判别联合。原先「endpoint + 联合提交体」的单函数允许把图生请求体发到文生地址且照样编译通过,配错端点只能等远端按字段报错。 +5. 落点照现有画布生成工具的做法:**同时**发 `projectId`(可空,缺失时不发这个字段)与当前素材夹 `assetFolderId` + `assetLabel`,画布占位框 `canvasCompletion: { dialogId, title, placeholder }` 在 `projectId && canvasCompletionPlaceholder` 时带上;发送门禁是「两个落点至少给一个」,不再要求先保存工程。结果因此同时落画布与素材库,没有工程时只落素材库。 +6. 图生输入照现有参考图来源菜单:从画布中选择 / 上传图片 / 从项目素材中选择,最终收敛成契约允许的 `{ kind: "resource", resourceId }` 或 `{ kind: "asset", assetId }`;只允许一张,不接 multiview。 +7. `Idempotency-Key` 由前端铸造并绑定当次请求,用户主动重试铸造新键、不复用旧键。落地形态是「尝试代次 + 请求内容指纹」(`Model3dGenerationSubmission.resolveModel3dRequestKey`):同一份内容重复提交(双击)仍命中同一个 operation,内容一变(含换参考图、换上传图、换素材库图片)自动换键;重试换代次即换键。之所以把内容指纹并进键里,是因为会改内容的入口散落在 `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel` 的通用函数中,靠「每处都记得重铸键」迟早漏一处。 +8. 失败与退款沿用既有链路:文案由后端归一为「3D 模型生成失败,请稍后重试。」,侧栏与面板展示失败原因与已退还泥点。 +9. 不做项见里程碑「不做」一节。 + +## 文件切分 + +| 文件 | 职责 | +| --- | --- | +| `model3d-generation/Model3dGenerationModal.tsx` | 提交面板外壳:两个子项共用的表单、状态区、提交按钮 | +| `model3d-generation/Model3dGenerationModal.test.tsx` | 面板行为用例 | +| `model3d-generation/Model3dGenerationForm.tsx` | 子项专属字段:文生提示词、图生参考图来源、模型版本选择器与按能力禁用的档位 / 开关(用例并入 `Model3dGenerationModal.test.tsx`:这些字段只在面板里出现,单独挂一个 harness 只是重复) | +| `model3d-generation/Model3dGenerationFormModel.ts` | 模型版本与档位 → 六个必填参数的唯一映射、可选模型版本、价格计算(读运行时定价缓存) | +| `model3d-generation/Model3dGenerationFormModel.test.ts` | 映射、模型版本收敛、价格、缺价不可提交的用例 | +| `model3d-generation/Model3dGenerationValidation.ts` | 与 provider / api-server 对齐的能力校验(模型族、分件前置条件、面数边界、平台必显式项)与原因文案的唯一来源 | +| `model3d-generation/Model3dGenerationValidation.test.ts` | 逐条规则与边界用例(对齐 provider 的同名用例) | +| `model3d-generation/Model3dGenerationSubmission.ts` | 校验后的请求体组装、落点字段、幂等键铸造(代次 + 内容指纹) | +| `model3d-generation/Model3dGenerationSubmission.test.ts` | 请求体全量非空、参考图收敛、幂等键铸造与换键用例 | +| `model3d-generation/useModel3dGenerationTask.ts` | 调用客户端 submit、排队、失败与重试收口(复用既有排队链路) | +| `model3d-generation/useModel3dGenerationTask.test.tsx` | 入队、失败、缺价与缺工程不发请求的用例 | + +外溢接线(各一处): + +| 文件 | 接线 | +| --- | --- | +| `src/components/image-editor/ImageCanvasBottomToolbarView.tsx` | 新增工具栏入口(走 `isToolbarOptionTool` 那一档) | +| `src/components/image-editor/useImageCanvasGenerationSurface.tsx` | 子选项菜单与面板渲染分支 | +| `src/components/image-editor/ImageCanvasEditorTypes.ts` | `CanvasTool`、`CanvasGenerationAction`、`GenerateDialogState.mode` 增加 3D 分支 | +| `src/services/image-editor/editorProjectClient.ts` | 两个 submit 函数(含 `Idempotency-Key`)、定价 DTO 补 `model3d` 段 | +| `src/components/image-editor/ImageCanvasGenerationModel.ts` | 图生 3D 的单参考图槽位上限(`usesSingleImageReferenceSlot` / `resolveDialogExtraImageReferenceLimit`) | +| `src/components/image-editor/ImageCanvasGenerationDialogModel.ts` | 画布点选与项目素材选中进入 `model3d-image-to-model` 的参考图字段;单槽位下素材库选择是替换 | +| `src/components/image-editor/ImageCanvasUploadModel.ts` | 上传图片进入 `model3d-image-to-model` 的参考图字段 | + +后三处是必须的:参考图来源菜单(从画布中选择 / 上传图片 / 从项目素材中选择)的三条路径都按显式 mode 列表分流,新工具漏在列表外会表现为「点了菜单没反应」。 + +## 步骤 + +1. **定价段接入** + - `editorProjectClient.ts` 的 `EditorGenerationPricingConfig` 补 `model3d` 段(形状对齐 Rust `Model3dPricingConfig`:`basePrices` + `addOnPrices`),运行时缓存同步保存。 + - 验收:payload 有段时面板能算出价格;无段时两个子面板都不可提交,且不发请求。 +2. **档位与参数映射** + - `Model3dGenerationFormModel.ts` 定义用户可见档位与开关到六个必填参数的唯一映射,以及价格加和;不得在别处再写一份。 + - 验收:同一组合在用例中给出与后端 `model3d_add_ons` 相同的 add-on 集合。 +3. **请求与幂等身份** + - 两个客户端函数分别打 `/api/assets/tripo/text-to-model` 与 `/api/assets/tripo/image-to-model`,带 Bearer、`Content-Type: application/json`、`Idempotency-Key`,请求体类型直接消费 `packages/shared/src/contracts/model3d/*`。 + - 落点与其它画布生成工具同形:同时发 `projectId` 与 `assetFolderId` / `assetLabel`,并携带 `canvasCompletion`;图生把选中图片收敛成 `resource` / `asset` 引用。 + - 验收:请求体全量非空;同一次提交重放同键;重试换新键。 +4. **入口与子选项** + - 工具栏入口 + 浮动子选项菜单 + 对话框两个 mode,形态与 `music` / `spec` 一致。 + - 验收:点开只有两个子项;选中后进入对应面板。 +5. **提交与回填** + - 复用既有排队路径:置 `status: 'generating'`、下发占位框、`waitForEditorGenerationQueue` 轮询、终态 `loadEditorProject` + `applyProjectSnapshot` 回填。 + - 验收:提交后画布出现占位框、任务侧栏出现任务且阶段文案正确、成功后占位框被正式资源图层替换。 +6. **失败、退款与重试** + - 面板与侧栏展示失败文案与已退还泥点;重试按钮铸造新键重新提交。 + - 验收:失败不残留占位框;重试产生新 operation。 +7. **收口** + - 生成结果可立即用既有「3D 预览」打开;补齐里程碑验收标准 1 至 8 的证据。 + - 验收:`npm run typecheck`、定向 vitest、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 全部通过。 + +## 进展(2026-09-21) + +代码已落地,全部集中在 `src/components/image-editor/model3d-generation/` 与上表接线处;未改后端、未改 schema、未新增公开 API。 + +| 里程碑验收标准 | 证据 | +| --- | --- | +| 1 工具栏入口只有两个子项 | `ImageCanvasBottomToolbarView.test.tsx`、`useImageCanvasGenerationSurface.test.tsx`、`ImageCanvasEditorShellView.test.tsx`;子项文案与后端 `phaseLabel` 对照 | +| 2 请求体全量非空 | `Model3dGenerationSubmission.test.ts`(逐字段断言;纯几何档位断言 `textureQuality` 缺席);`Model3dGenerationFormModel.test.ts` 与后端 `model3d_add_ons` 组合对齐 | +| 3 价格只读实时查询、缺段不可提交 | `Model3dGenerationFormModel.test.ts`、`Model3dGenerationModal.test.tsx`(缺段时按钮禁用、显示原因、不发请求)、`useModel3dGenerationTask.test.tsx`(不发请求) | +| 4 `Idempotency-Key` 与重试换键 | `Model3dGenerationSubmission.test.ts`(同内容同键、换内容换键、重试换代次换键、键可作请求头) | +| 5 占位框与终态回填 | `useModel3dGenerationTask.test.tsx`(以对话框 id 交给 `applyQueuedEditorGenerationProject`);真实排队回填需联调环境 | +| 6 失败与退款文案 | `Model3dGenerationModal.test.tsx`、`useModel3dGenerationTask.test.tsx`;退款提示由既有任务侧栏承接 | +| 7 图生只接受一张平台内图片 | `ImageCanvasGenerationDialogModel.test.ts`、`projectAssetReferencePickerModel.test.ts`、`ImageCanvasUploadModel.test.ts`、`Model3dGenerationSubmission.test.ts`(上传未登记、空白 id 都不构成 `source`) | +| 8 产物可用既有 3D 预览打开 | 由 [`【实施计划】Tripo生成结果前端预览接入-2026-09-21.md`](./【实施计划】Tripo生成结果前端预览接入-2026-09-21.md) 交付;本入口只负责产出 `model3d` 资源 | + +仍未验证的部分需要环境:`model3d` 定价段写入运行配置后的真实扣费与排队回填、provider 端到端产物。 + +### 追加一轮(2026-09-24):模型版本可选 + 参数面按能力门禁 + 端点/提交体类型收口 + +| 变更 | 证据 | +| --- | --- | +| 模型版本可选,可选集只取当前端点有底价的版本 | `Model3dGenerationFormModel.test.ts`(`resolveModel3dModelOptions` 两个端点各一例、缺定价给空集)、`Model3dGenerationModal.test.tsx`(菜单里没有无价的 `P1`,切到 `P2` 后报价跟着换) | +| 报价与提交体读同一个版本 | `Model3dGenerationSubmission.test.ts`(`generation.model` 跟对话框走)、`Model3dGenerationFormModel.test.ts`(缺价版本不可提交,不回落默认价) | +| 能力组合与 provider 逐条对齐 | `Model3dGenerationValidation.test.ts`(12 例:模型族能力、分件三个前置条件、v3.0/v3.1/v2.5/P1/P2 的面数边界、平台必显式项);`Model3dGenerationFormModel.test.ts`(不支持组合直接判非法) | +| 换版本 / 切档位时参数收敛 | `Model3dGenerationFormModel.test.ts`、`Model3dGenerationModal.test.tsx`(高清贴图 → 标准贴图、方形布线自动关掉并禁用) | +| 端点与提交体由类型绑死 | `useModel3dGenerationTask.test.tsx`(图生走图生函数、文生函数不被调用);`npm run typecheck` 通过(旧单函数已删除) | +| v2.5 的贴图档位不带 `textureQuality` | `Model3dGenerationFormModel.test.ts` | + +本轮验证:`npx vitest run src/components/image-editor/model3d-generation`(75 passed)、`npx vitest run src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx packages/image-canvas-react`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;`npx eslint` 对改动文件无告警。 + +## 验证命令 + +```bash +npx vitest run src/components/image-editor/model3d-generation +npx vitest run src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx +npx eslint src/components/image-editor/model3d-generation +npm run typecheck +npm run check:encoding +node scripts/check-doc-index.mjs +git diff --check +``` + +## 风险与回退 + +- **缺价即不可用**:`model3d` 定价段未写入运行环境时,本期交付的入口表现为「不可提交」。这是设计如此(见 ADR 0004),不是缺陷;但演示与验收需要先落配置。 +- **前端等待窗口**:客户端排队轮询上限 20 分钟,后端 3D 长任务超时 1800 秒;排队叠加执行可能顶到前端窗口,需要按真实耗时确认是否调整或改由任务侧栏兜底。 +- **worker 并发**:3D 任务在单次 attempt 内阻塞数分钟,默认并发 2,会占住并发位。 +- **价格两份实现**:前端档位映射与后端 add-on 判定必须靠同一组参数组合的用例对齐,否则出现「面板一个价、账单另一个价」。 +- **回退点**:删除工具栏接线与 `model3d-generation/` 新文件即可回退;后端、schema、定价配置均不受影响。 diff --git a/docs/project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md b/docs/project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md new file mode 100644 index 000000000..cdea6e563 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md @@ -0,0 +1,43 @@ +# Tripo 生成结果前端预览接入实施计划-2026-09-21 + +对应主规范:`docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md`;架构决策:`docs/adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md`。 + +## 交付结果 + +Tripo 生成的 3D 资源在图片画布里作为 `model3d` 类别落地:卡片显示预览图,选中后在工具条上出现「3D 预览」,打开桌面模态把模型本体交给 `packages/model3d-viewer` 渲染;资源行的客户端媒体投影改为按类别分叉,模型本体不再被图片入口加载。 + +## 已确认口径 + +1. `model3d` 是新的 `CanvasAssetKind`,画布媒体类型仍是 `image`:卡片、缩略图消费预览图,模型本体只进查看器。 +2. 工具条入口落在资源上(选中图层的浮动工具条),不动信息角标;同族图层的像素级编辑组(裁扩、去背景、完美像素、改造)整体不适用。 +3. 工具条按钮只在 `objectKey` 为空时置灰(标题「该资源没有可预览的 3D 模型文件」);画布不判格式,格式判不出来是查看器的事,模态里显示失败原因,把错误信息交给用户而不是让他猜功能是否上线。 +4. 类型角标新增「3D模型」,与其它类别互为独立媒体族;覆盖资格只在 `model3d` 与空类别之间成立,图片族不能覆盖成 3D。 +5. 后端 `imageSrc` / `thumbnailSrc` 都是预览图、`objectKey` 是模型本体;投影由 `project_editor_client_media_src` 统一决定,预览缺失才回落到历史口径。 +6. 下载 3D 资源导出模型本体:MIME 优先、对象键扩展名兜底、最后回落 `glb`。 +7. 素材库行已接 `model3d` 分支(缩略图按预览图渲染、拖入画布走既有素材链路);后台 renderer、精选 read model、公开 grant 与 External v1 不进入。 +8. 画布编辑器不承诺移动端,查看器模态固定 `lg` 尺寸、高度 `min(58vh, 30rem)`,不自动旋转,页脚「重置视角」仅在查看器状态为 `ready` 时可用。 +9. 后端写入 `content_type` 前按字节魔数识别 3D 产物;查看器单模型上限 64 MiB;客户端不抄格式清单,格式判定在 `packages/model3d-viewer` 内按「声明 `Content-Type` → 字节魔数 → 地址扩展名」进行,判不出来即 `unsupported-format`。 +10. 「哪些宿主能开 3D 预览」通过工具条的 `preview-model3d` capability 位表达,AGC 项目画布本期不进入该能力集合。 +11. 打开模态时同时选中该图层;画布快捷键与舞台交互在元数据面板或 3D 预览模态打开期间暂停。 +12. 导出 3D 图层落到 `3d_models/` 目录(图片 `images/`、其它媒体 `media/`),文件名按真实响应头 MIME 定扩展名。 +13. 失败原因逐条翻译给用户:超限(64 MiB)、格式不可识别(列出支持格式)、环境不支持 WebGL2;预览中不自动旋转。 + +## 落地改动 + +- 后端:`editor_project.rs` 新增 `project_editor_client_media_src` 与 `EDITOR_MODEL3D_ASSET_KIND`,改写资源、精选快照与素材三处投影;`tripo3d/storage.rs` 新增 `sniff_model_content_type` / `resolve_artifact_content_type` 与模型扩展名映射;`editor_project_storage.rs` 把 `model3d` 加入 `EDITOR_CANVAS_ASSET_KINDS`(白名单与前端标签选项同步)。 +- 前端:`CanvasAssetKind` 与 `image-canvas-core` 类型同步新增 `model3d`;新增 `src/components/image-editor/model3d-preview/`(`Model3dViewerModel.ts` + `Model3dViewerModal.tsx`,Tailwind 内联、无新增 CSS);画布卡片新增 3D 图层渲染(预览图或占位);工具条新增 `preview-model3d` 动作;导出工作流新增 `3d_models/` 目录与模型扩展名映射。 +- 查看器包:`packages/model3d-viewer` 的源对象 `format` 改为可选,新增按声明 `Content-Type` / 字节魔数 / 地址扩展名三级判定格式的解析函数,并导出支持的格式清单供模态文案复用。 +- 文档:新增本计划、ADR 0003,补 `CONTEXT.md` 术语,修正 Tripo 技术方案与后端架构文档里「`model3d` 是后端-only 类别 / 不进 `EDITOR_CANVAS_ASSET_KINDS`」的过期口径。 + +## 验证 + +- `npx vitest run src/components/image-editor` 中与本变更相关的用例(工具条、画布视图、编辑器模型、导出工作流、`model3d-preview`)全部通过;仓储内存不可用的既有用例在本地环境失败,与本次改动无关。 +- 后端 `cargo test --locked -p api-server` 的媒体投影与 Tripo 存储用例通过。 +- `npm run typecheck`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check`。 + +## 风险与未进入项 + +- glTF 的多文件兄弟资源(`.bin`、贴图)暂不解析,只支持自包含模型;provider 若返回多文件 glTF,查看器会加载失败并提示。 +- 一张模型只显示一张预览图;多视角、动画播放、材质切换与模型信息(面数、体积)都不在本次范围。 +- 后台列表、精选卡片与公开 grant 未接 3D renderer,只保证字段正确、不承诺展示;素材库行只做预览图渲染,行内不开 3D 查看器。 +- AGC 项目画布自带两份格式判断(`isResourceModelFbx` 的 `octet-stream` 当 FBX、资源卡预览的扩展名正则)本期不动,后续统一收敛到 `packages/model3d-viewer`。 diff --git a/docs/project-memory/plans/【里程碑】3D生成定价后台可编辑-2026-09-23.md b/docs/project-memory/plans/【里程碑】3D生成定价后台可编辑-2026-09-23.md new file mode 100644 index 000000000..9b15a2a66 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】3D生成定价后台可编辑-2026-09-23.md @@ -0,0 +1,52 @@ +# 3D 生成定价后台可编辑 + +Version: 1.1 +Status: 已实现,待真实环境手工验收与发布 +Date: 2026-09-23 +修订: 2026-09-24 —— 后台保存删除定价版本比对(不再有 `updatedAtMicros` / `expectedUpdatedAtMicros` / 409),改为整段覆盖;理由见 `docs/project-memory/shared-memory/decision-log.md`「模型定价保存去掉版本比对」。下文「范围 / 验收判据」中涉及版本 CAS 与 409 的表述已按此修订。 +Parent Spec: `docs/【编辑器】模型定价配置管理方案-2026-06-22.md` +Implementation Plan: `docs/project-memory/plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md` +Related: `docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md`、`docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md` + +## 目标 + +把 3D 生成定价从「受控默认 JSON + override 文件的本地缓存」迁到 SpacetimeDB `editor_generation_pricing_config`,并让后台「模型定价」页面能编辑按端点拆分的两段定价(底价与加价项);保存与 `models` 段在同一事务内生效,改价不再需要改文件或重启进程。 + +## 范围 + +- 表新增 `text_to_model_pricing` / `image_to_model_pricing` 两列,沿用 `models` 的展平风格;同步 `migration.rs`、表目录文档与生成绑定。 +- 定价文件的 3D 段改成两段并列形状,受控默认 JSON 与定向用例同步。 +- 读取方向契约归一化:表内缺键用默认 JSON / 覆盖文件同段值补齐,契约之外的遗留键剔除并告警。 +- 后台 GET / POST:两段必填;保存是整段覆盖、不做版本比对(2026-09-24 修订后不再携带定价版本、不返回 409),保存成功后返回权威快照。 +- admin-web「模型定价」页新增 3D 区块:按端点分组,模型版本 × 两档价 + 六个加价项;提交前本地校验与 400 中文提示。 +- 覆盖文件降级为种子与兜底,不再是 3D 改价的生效路径。 + +## 不做 + +- 不改公开读模型 `GET /api/editor/generation-pricing` 的 3D 段形状;不改画布 3D 入口与其「零兜底」口径。 +- 不改 `models` 段的字段、列、语义与名字。不新增后台 tab、权限字符串或 3D 定价总开关。 +- 不允许后台增删模型版本键或加价项键,不允许整段停用。 +- 不接 multiview / Splat / rig / animation / texture / convert;不改 provider 侧、worker 扣费与退款链路。 + +## 验收标准 + +1. 后台 GET 返回两段 3D 定价;公开 GET 的 3D 段形状与本期之前逐字段一致。 +2. 后台 POST 缺少任一段 → 400;段内任一键缺失或非法 → 400,且两段与 `models` 均不落库;合法请求整段覆盖,连续保存两次以后一次为准。 +3. 保存成功后不重启进程,`GET /api/editor/generation-pricing` 立即反映新价;重启后仍是保存后的值。 +4. 表内两段缺键(模拟发布新增模型版本)时读取自动补齐并记录告警,读取不失败;契约之外的遗留键被剔除并记录告警。 +5. 表为空或 SpacetimeDB 不可达时,3D 定价用默认 JSON / 覆盖文件同段值兜底;override 文件的存在不再导致「重启覆盖已保存价格」。 +6. 3D 定价未配置时提交仍 fail closed(503 `model3d-pricing-unavailable`),不扣费、不入队。 +7. admin-web 3D 区块渲染两个端点各 5 行 × 2 档价与 6 个加价项;存在 0 或空值时禁用保存并给出中文提示。 +8. `npm run check:spacetime-schema`、`npm run check:encoding`、`cargo test --locked -p api-server` 定向用例、admin-web 定向 vitest、`node scripts/check-doc-index.mjs`、`git diff --check` 全部通过。 + +## 实现与验收记录(2026-09-23) + +- 步骤与提交、逐条判据的证据表见 [`【实施计划】3D生成定价后台可编辑-2026-09-23.md`](./【实施计划】3D生成定价后台可编辑-2026-09-23.md)。 +- 验收标准 1–7 已有自动化用例覆盖;标准 8 的本地门禁全部通过。 +- 待办:真实环境手工验收(后台改 3D 价 → 不重启 → 画布 3D 面板价变化 → 重启后仍是保存值)与按 Stdb → API → Web 顺序发布。 + +## 依赖 + +- 画布 3D 入口已上线,价格只读公开定价查询(ADR 0004),本期不改其契约。 +- 生产 SpacetimeDB 需要先发布含两列的模块版本,发布顺序为 Stdb → API → Web。 +- 生产覆盖文件若含旧形状 3D 段,按主规范「未决问题」确认后的兼容策略处理。 diff --git a/docs/project-memory/plans/【里程碑】Tripo生成API契约与数据模型-2026-09-21.md b/docs/project-memory/plans/【里程碑】Tripo生成API契约与数据模型-2026-09-21.md new file mode 100644 index 000000000..813be315c --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Tripo生成API契约与数据模型-2026-09-21.md @@ -0,0 +1,44 @@ +# Tripo 生成 API 契约与数据模型 + +Version: 1.0 +Status: active +Date: 2026-09-21 +Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md` +Implementation Plan: `docs/project-memory/plans/【实施计划】Tripo生成API契约与数据模型-2026-09-21.md` + +## 目标 + +在动手做路由与 worker 之前,把 Tripo 生成 API 的请求与结果契约、provider checkpoint 的数据模型、泥点定价结构与组合校验先固定下来,让后续执行链路只依赖已评审的契约。 + +## 范围 + +- `shared-contracts::model3d` 新增 API 层请求与结果类型:provider 生成参数与 API 请求分层,图片输入 `source` tagged enum、结果落点 `target` tagged enum、按端点的严格结果类型。 +- `external_generation_job` 追加 `providerKind` / `providerTaskId`,并新增受租约栅栏保护的 checkpoint 写入 procedure 与 `spacetime-client` facade。 +- 定价配置新增 `model3d` 段:底价按 `endpoint × modelVersion × 是否有贴图`,add-on 按参数判定叠加,加载即校验全部底价键存在。 +- 定价相关参数的必填口径与组合校验规则,前置于 provider 副作用;provider 能力组合规则复用 `platform-tripo` 校验入口,不复制第二份。 +- ts-rs 目录化 TypeScript binding 与导出索引。 + +## 不做 + +- 不新增 HTTP 路由,不改 worker 任务分支。 +- 不接前端与 `packages/model3d-viewer`。 +- 不动 `platform-hyper3d` 与 `/api/assets/hyper3d/*`。 +- 不提供生产定价数值;数值由业务给出后写入默认配置。 +- 不改 `platform-oss` 为流式上传。 + +## 验收标准 + +1. API 请求与结果契约可编译,并通过 ts-rs 生成到 `packages/shared/src/contracts/model3d/` 的对应目录;生成产物经 prettier 后提交。 +2. 请求落点用平坦可选字段:`projectId` 与 `assetFolderId` 至少给一个,两个都给合法,只给 `assetFolderId`(无画布工程)同样合法(与其它画布生成工具一致);`canvasCompletion` 与“没有 `projectId`”同现失败。 +3. `source` 同时给出两个 ID、或使用裸字符串时被拒绝。 +4. `external_generation_job` 新字段追加在结构体末尾且带显式默认值;`migration.rs`、表目录与生成绑定同步,`npm run check:spacetime-schema` 通过。 +5. checkpoint procedure 只在租约有效且 lease token 匹配时写入;过期或错误 token 被拒。 +6. `model3d` 定价段存在时缺少任一底价键或 add-on 键即加载失败;段缺失时 Tripo 提交被拒绝且不调用 provider;add-on 判定对贴图四档、几何质量、quad、smart-low-poly、generate-parts 均有单测覆盖,且 `fast` / `standard` 不加价。 +7. 组合校验拒绝 `texture=false` 同现 `textureQuality`(平台规则)与 `generateParts=true` 同现 `texture=true`(provider 规则)等非法组合,且校验发生在任何 provider 调用与扣费之前。 +8. `npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 通过。 + +## 依赖 + +- 主规范 `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md`。 +- 现有 `platform-tripo` provider DTO 与组合校验(`platform-tripo/src/common/validation.rs`)。 +- 现有定价配置与校验入口(`api-server/src/editor_generation_config.rs`)。 diff --git a/docs/project-memory/plans/【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md b/docs/project-memory/plans/【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md new file mode 100644 index 000000000..039daea3b --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md @@ -0,0 +1,36 @@ +# Tripo 生成 Worker 执行链路与 API 路由 + +Version: 1.0 +Status: active +Date: 2026-09-21 +Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md` +Implementation Plan: `docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md` + +## 目标 + +把已固定的契约接到执行链路上:两个 submit 内部 API 完成校验、定价与入队,worker 按 at-most-once submit 规则推进 Tripo 任务,完成后把模型与预览落 OSS 并登记为正式资源,全程真实扣费与失败退款。 + +## 范围 + +- 两个 Bearer 路由:`/api/assets/tripo/text-to-model`、`/api/assets/tripo/image-to-model`,含 `Idempotency-Key` 校验与错误映射。 +- 两个 Tripo job kind 的 worker 分支:submit、单次 `get_task` 轮询、下载、OSS 写入、资源登记、严格结果序列化。 +- checkpoint 规则:无 checkpoint 才 submit,submit 成功后先落 checkpoint 再轮询,已有 checkpoint 只查询(防御性硬约束,当前 `max_attempts = 1` 下不存在续跑路径);submit 成功但 checkpoint 落库失败的 attempt 终态失败。 +- 图片输入的归属校验与 OSS 对象解析;结果落点的两个分支。 +- 提交时定价扣费、attempt 级退款、幂等重放不重复扣费。 +- 真实 Provider smoke 与端到端验收证据。 + +## 不做 + +- 不接 multiview、Splat、rig 等其它能力。 +- 不做取消接口。 +- 不进入 `/api/external/v1`。 +- 不接前端与查看器。 + +## 验收标准 + +主规范“验收标准”第 1 至 12 条全部满足,其中必须包含 text-to-model 与 image-to-model 各一次真实 Provider 调用与产物下载校验。 + +## 依赖 + +- `docs/project-memory/plans/【里程碑】Tripo生成API契约与数据模型-2026-09-21.md` 验收通过。 +- 生产泥点定价数值已确认并写入默认配置:换算口径为 `ceil(0.8 × Tripo 官方 credit)`,已落到 `editor-generation-pricing.default.json` 的 `model3d` 段。 diff --git a/docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md b/docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md new file mode 100644 index 000000000..b56f4ed2d --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md @@ -0,0 +1,51 @@ +# Tripo 生成前端入口 + +Version: 1.0 +Status: active +Date: 2026-09-21 +Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md` +Implementation Plan: `docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md` +Related: `docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md`、`docs/project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md` + +## 目标 + +在图片画布工程 `/editor/canvas` 的底部工具栏新增「生成 3D 模型」入口,点开后给出「文生 3D 模型」与「图生 3D 模型」两个子项,按画布既有生成链路提交到 tripo3d 的两个 submit API;产物以 `model3d` 画布资源落地,并可直接用既有的「3D 预览」查看。 + +## 范围 + +- 底部工具栏入口与子选项菜单,形态照 `music` / `spec` 的「一项入口 → 浮动子选项 → 各自一个提交面板」。 +- 两个提交面板:模型版本选择(只列当前端点有底价的版本)、档位化的参数选择、按模型能力禁用的开关、提交前价格展示、文生提示词、图生参考图来源。 +- 前端铸造 `Idempotency-Key`,并按契约全量非空提交必填参数。 +- 画布占位框、入队、任务侧栏、失败与退款提示、终态刷新回填。 +- 3D 定价段的前端消费:只读实时定价查询。 +- 契约消费:`packages/shared/src/contracts/model3d/*` 只消费,不手写 TS。 + +## 不做 + +- 不新建独立生成页、结果页、runtime、作品架、广场、作品统计;3D 是资产型产出,不进玩法闭环。 +- 不接 multiview-to-model、Splat、rig、animation、texture、convert。 +- 不做后台 renderer、精选 read model、公开 grant、External v1。(素材库落点与素材库行消费端已接:提交与其它画布生成工具同形,同时发 `projectId` + `assetFolderId` + `assetLabel`。) +- 不接画布 Agent 对话调度。 +- 不改后端、不改 schema、不新增公开 API。 +- 不在前端内置任何 3D 价格兜底数值;不承诺移动端画布(跟随现状)。 + +## 验收标准 + +1. 工具栏出现「生成 3D 模型」,点开只有两个子项,子项文案与任务侧栏 `phaseLabel` 一致。 +2. 每次提交的请求体包含全部必填参数(含六个定价参数),不存在缺字段或 `undefined`;`generation.model` 等于面板上选中的模型版本,面板选项不会出现该端点没有底价的版本。 +3. 价格只来自 `GET /api/editor/generation-pricing` 的 `model3d` 段;该段缺失时两个子面板都不可提交,且不发任何请求。 +4. 每次提交携带 `Idempotency-Key`;同一次提交重放不产生第二个 operation;用户主动重试铸造新键。 +5. 提交后画布出现占位框,任务侧栏出现该任务并显示「文生 3D 模型」/「图生 3D 模型」;成功后占位框被正式资源图层替换。 +6. 失败时面板与侧栏展示后端归一文案,并显示已退还泥点。 +7. 图生只接受一张平台内图片(画布资源或素材库资产),不接受 URL、data URL 或本地字节直传。 +8. 生成的 `model3d` 资源可立即用既有「3D 预览」打开。 +9. `npm run typecheck`、定向 vitest、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 通过。 + +## 进展 + +实现已落地(2026-09-21),逐条证据见 [`【实施计划】Tripo生成前端入口-2026-09-21.md`](./【实施计划】Tripo生成前端入口-2026-09-21.md) 的「进展」一节。验收标准 1 至 4、7 已由定向 vitest 覆盖;5、6 的排队回填与退款展示复用既有链路并已覆盖交接点,真实表现需在写入 `model3d` 定价段的联调环境确认;8 由预览接入计划交付。 + +## 依赖 + +- 后端里程碑 [`【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md`](./【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md) 的路由与 worker 分支已上线。 +- `model3d` 定价段已写入运行环境配置;未写入时本入口保持不可提交(fail closed),这不是本期缺陷。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 53b75ec40..1e47ba3ab 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,46 @@ # 决策记录 +## 2026-09-24 模型定价保存去掉版本比对:后台同时只有一个管理员在操作 + +- 背景:2026-09-23 引入的「行内 `updated_at` 版本比对」(后台 GET 回 `updatedAtMicros`、POST 带 `expectedUpdatedAtMicros`、procedure 在事务内比对、不一致返回 409)落地后暴露两件事。一是这个版本号没有真实并发场景支撑:后台定价页同时只有一个管理员在操作,409 的后果只是「刷新一下再保存」,却给前后端都加了一条无从处理的失败路径。二是读内容与读版本号原本是两个分开的动作,在「读数据库失败、退回进程内缓存」这条少见分支上,两次读之间若有人写入,调用方会拿到旧内容配新版本号——本该被拦下的写入会被放行,本该能保存的反而被当成版本冲突。 +- 决策:整体删除版本比对与版本号。`EditorGenerationPricingConfigUpsertInput` 去掉 `expected_updated_at_micros`;`EditorGenerationPricingStore` 去掉版本字段与对应方法;后台 GET 不再返回 `updatedAtMicros`、POST 不再带 `expectedUpdatedAtMicros`,冲突 409 分支与前端「重新读取」提示一并删除;`save_editor_generation_pricing` 与 `editor_generation_pricing()` 只返回配置本身。保存语义固定为「整段覆盖」:请求里的 `models` 与两段 3D 价格就是新的最终状态,后一次保存整体取代前一次。`EditorGenerationPricingStore::replace` 明确记为「只刷新进程内缓存,不做版本校验、不是拦截点」,写入判断只发生在 procedure 的入库事务里。 +- 原因:**没有并发就没有版本号的价值**——加它只是把一次普通的覆盖保存变成需要用户处理的中断;**两次读本来就是错的形状**,正确做法是「保存即覆盖」而不是把内容和版本号拆开再拼回去;**拦截点唯一**,写入合法性只在 procedure 的入库事务里判断一次,进程内缓存不该有第二份判断逻辑。 +- 代价与取舍:放弃「两个人同时编辑时提示冲突」的能力,靠「同时只有一个管理员在操作」这个前提兜住;前提一旦不成立(例如以后开多人同时改价),必须重新设计,而不是把版本号加回来。后台不再能区分「我读到的是不是最新」,页面只能在保存后重新读取确认。 +- 影响面:`server-rs/crates/shared-contracts/src/editor_generation.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs` 与生成绑定、`server-rs/crates/api-server/src/{editor_generation_config.rs,state.rs,admin.rs,app.rs}`、`apps/admin-web/src/{api/adminApiTypes.ts,pages/AdminEditorGenerationPricingPage.tsx,pages/adminEditorGenerationPricing.ts}` 及对应测试、主规范「保存语义」段、后端数据契约文档、ADR 0005、`pitfalls.md`。 +- 验证方式:`cargo test --locked -p api-server -- editor_generation_pricing saving_pricing`、`cargo test --locked -p spacetime-module -- editor_generation_pricing`、`npx vitest run apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts`、`npm run admin-web:typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:[ADR 0005](../../adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md)、[编辑器模型定价配置管理方案](../../【编辑器】模型定价配置管理方案-2026-06-22.md)、[后端架构 server-rs 与 SpacetimeDB 数据契约](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。 + +## 2026-09-24 3D 参数面板开放模型选择:版本取「本端点有底价」的键,能力规则抄 provider,端点与提交体由类型绑死 + +- 背景:3D 面板此前把请求体与报价里的 `model` 一起写死成 `DEFAULT_MODEL3D_MODEL_VERSION`(`v3.1-20260211`),而公开读模型的定价段是按 `endpoint × modelVersion` 展开的;后端加载即校验「每个契约版本 × 两个端点都必须有底价」,于是定价表里已配好的 `v3.0` / `v2.5` / `P1` / `P2` 四个版本在前端永远不可达,用户也看不到任何模型选择入口。同一轮还暴露两个类型层面的口子:提交函数签名是「endpoint 联合 + 提交体联合」两个彼此独立的字段,把图生请求体发到文生地址照样编译通过;`Model3dPricingConfig.basePrices` 内层键写成 `string`,写错的版本号在编译期没有任何反馈。 +- 决策: + 1. `GenerateDialogState.model3dModel` 成为用户可选参数,缺省 `v3.1-20260211`;面板选项只列**当前端点有底价**的版本(`resolveModel3dModelOptions`),报价与请求体的 `generation.model` 读同一个 `resolveModel3dModelVersion(dialog.model3dModel)`。 + 2. 换版本 / 切档位时按新版本能力收敛参数(高清贴图 → 标准贴图、关掉该版本不支持的开关)并开新一代次,面板上不留已经失效的勾选。 + 3. 前端保留一份与 provider 对齐的参数校验 `model3d-generation/Model3dGenerationValidation.ts`,逐条复刻 `platform-tripo::common::validation::validate_generation_options` 与 `api-server::tripo3d::validation::PricingParamView::validate`:模型族能力、分件的三个前置条件、`face_limit_bounds`、平台必显式项。抄的是规则不是数值,两端顺序一致(平台口径在前),冲突以 provider 为准。 + 4. 提交端点与提交体由类型绑死:`submitModel3dTextToModelRequest` / `submitModel3dImageToModelRequest` 一个端点一个函数,`Model3dSubmissionPlan` 做成 `endpoint` 判别联合;`Model3dPricingConfig.basePrices` 的两层键改成契约枚举(`Partial>>>`)。 +- 原因:价格与参数是同一件事的两面——定价按版本分档,把版本写死等于让其余四个版本的定价成为死配置;面板不给版本选择,用户既看不到这些版本,也不可能用上已经付费配置的档位。校验必须抄一份到前端,是因为契约与公开读模型只暴露「价格」,不暴露「哪个版本支持哪些参数」,不抄就只能等用户点提交后由 provider 的字段级报错来教育。类型收口(判别联合 + 契约枚举做键)让同类端点 / 版本错配在 `tsc` 阶段暴露,而不是等远端按字段报错。 +- 代价与取舍:前端多了一份 provider 规则镜像,provider 改规则时两边要一起改;用 `Model3dGenerationValidation.test.ts`(12 例,含五个版本的能力与面数边界)与 `Model3dGenerationFormModel.test.ts` 的组合表把两边钉住,冲突时以 provider 为准(失败关闭)。运行期的「契约版本列表」仍只能靠前端常量表 `MODEL3D_MODEL_TABLE`:ts-rs 只导出联合**类型**,导不出运行期数组,所以表写成 `Record`,契约新增版本后 `npm run contracts:model3d:generate` 会让这里直接编译失败。更干净的做法是后端公开读模型补一份 `modelCapabilities`(版本 → 支持参数 → 加价项),前端就不用镜像规则,本轮未做。 +- 影响面:`src/components/image-editor/model3d-generation/*`、`src/components/image-editor/ImageCanvasOptionChoice.tsx`(新,选项行抽共享)、`src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx`、`src/components/image-editor/ImageCanvasEditorTypes.ts`、`src/services/image-editor/editorProjectClient.ts`、[实施计划](../plans/【实施计划】Tripo生成前端入口-2026-09-21.md)、[里程碑](../plans/【里程碑】Tripo生成前端入口-2026-09-21.md)。 +- 验证方式:`npx vitest run src/components/image-editor/model3d-generation`(75 passed)、`npx vitest run src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx`、`npm run typecheck`、`npx eslint`(改动文件无告警)。 + +## 2026-09-24 画布底部工具栏改用换行兜溢出,不再靠横向滚动 + +- 背景:13 个工具 + 3 个分隔符放进宿主限宽的 `.image-canvas-editor__bottom-toolbar`(`max-width: min(calc(100% - 6.6rem), 34rem)`,约 614px)后必然溢出,共享的 `.genarrative-image-canvas__toolbar` 用 `overflow-x: auto` + `scrollbar-width: thin` 兜住——细到几乎不可见的滚动条让尾部工具(3D 入口等)在桌面端直接「消失」,用户只会看到工具栏被截断。 +- 决策:共享工具栏改 `flex-wrap: wrap`(只换行、不再滚动),宿主上限 34rem → 42rem 并 `justify-content: center`;AGC 资源画布的宿主覆盖删掉与共享默认重复的 `overflow` 覆盖,只保留限宽。 +- 原因:工具条是「入口清单」,被截断等于功能不存在;换行是唯一在任意宽度下都能保证每个入口可见且可点的方案,横向滚动在隐藏滚动条的容器里连「还能滚」这件事都传达不到。 +- 代价与取舍:工具变多时工具栏会占两行、抬高画布底部,视觉上不如一行整齐;换来的是入口不再被容器宽度吃掉。判据固定为 `scrollWidth === clientWidth` 且最后一个按钮完整可见。 +- 影响面:`packages/image-canvas-react/src/{CanvasChrome.tsx,styles.css}`、`src/index.css`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css`。 +- 验证方式:`npx vitest run src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`、`npx vitest run packages/image-canvas-react`;1280px 视口实测不再有横向溢出。 + +## 2026-09-24 Tripo 配置改为启动期失败关闭:网关无内置默认值,缺配置的进程直接起不来 + +- 背景:`TRIPO_BASE_URL` / `TRIPO_API_KEY` 原来只有请求期一道判断,而请求期根本挡不住部署错误:HTTP 角色收 3D 单时不碰 provider,worker 只在自己真的跑到那个 job 时才构造 `TripoSettings`,于是缺配置的部署形态是「进程起来了、`/healthz` 全绿、用户拿到 202」,失败只在任务记录里现身。同时 `AppConfig::default()` 带着内置网关 `https://openapi.tripo3d.com/v3`,`tripo_settings()` 又把超时钳到 `.max(1)`、重试钳到 `.min(10)`,一个写错的配置会被翻译成「能跑但行为诡异」。 +- 决策:会提交、会执行 3D job 的角色(`ProcessRole::Api | ExternalGenerationWorker | All`)在启动期校验 `TRIPO_BASE_URL` / `TRIPO_API_KEY`,缺失、空白或网关不是带主机名的 HTTP(S) 地址即拒绝启动(`validate_tripo_config_for_startup` → `tripo3d::provider::validate_tripo_startup_config`)。`DEFAULT_TRIPO_BASE_URL` 常量删除,`AppConfig::default().tripo_base_url` 为空串。`TRIPO_REQUEST_TIMEOUT_MS` / `TRIPO_RETRIES` 仍可缺省(沿用内置 `60000` / `2`,空串与纯空白视同未声明),但显式声明就必须合法:零超时、非数字、重试超过 `TRIPO_MAX_RETRIES = 10` 都是启动期错误。请求路径的静默钳制一并删除,坏值原样交给 `TripoSettings::new`,仍映射 503。 +- 原因:3D 单次成本比图片 / 音频高一个量级,静默回落到某个运维不知道的网关等于把请求、额度和账单发向另一个账号;「起不来」比「起得来但悄悄坏掉」便宜得多,而且启动失败本身就带着变量名,运维一眼能改。 +- 代价与取舍:这是对仓库既有 provider 惯例的一次刻意偏离——VectorEngine / ElevenLabs / ARK / 阿里云抠图 / AGC OSS 全部保持请求期 503,唯一在启动期硬失败的先例是 `BgfilterWorkerRuntime::new` 的角色级校验;偏离只因为 3D 的失败成本与可见性不在一个量级。请求期 503 保留作第二道防线(配置启动后被改、或别的入口拼出非法 `AppConfig`)。另外所有本地 / CI 环境起 api-server 或 worker 前都必须提供这两项,`.env` 里一份 `TRIPO_*` 都没有的开发者会先撞一次启动失败;`bgfilter-worker` 与 `external-generation-controller` 不受影响。Tripo 密钥仍没有 `_FILE` 变体(bgfilter 的 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN` 有),本次未加。 +- 影响面:`server-rs/crates/api-server/src/{config.rs,main.rs,tripo3d/provider.rs}`、`.env.example`、`deploy/env/api-server.env.example`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、[ADR 0006](../../adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md)。 +- 验证方式:`cargo test -p api-server tripo_startup`(`tripo_startup_gate_covers_every_role_that_touches_3d` / `..._reports_the_missing_variable` / `..._skips_unrelated_roles`)、`cargo test -p api-server tripo3d::provider`(含 `startup_rejects_missing_or_blank_gateway_and_key` / `startup_rejects_unusable_gateway_addresses` / `startup_validates_declared_knobs_without_clamping_them` / `startup_accepts_declared_knobs_at_their_limits` / `request_path_no_longer_clamps_bad_knobs`)、`cargo test -p api-server config`。 + ## 2026-09-22 UI 编辑器预览画布补上右键拖拽平移,节点菜单改为右键抬起弹出 - 背景:预览画布此前只有中键与空格+左键平移,右键整段留给节点操作菜单(`UiTreeRenderer.onContextMenu` 直接弹 `UiNodeContextMenu`)。这次要补右键拖拽平移,并要求"拖拽过就不许再触发右键菜单"。实测(Linux Chromium 151 / Firefox 151,真实 X11 输入)确认 `contextmenu` 在**按下**瞬间触发,且原生菜单一旦弹出,页面之后收不到任何 `pointermove` / `pointerup` / `mouseup` / `auxclick`,所以"先让菜单弹、拖拽时再关"在浏览器层面不可行;headless 没有原生菜单,Playwright 复现不出该行为。macOS 的 `contextmenu` 在 mouseup 触发(本容器无法实测),但同一条实现路径对两种时序都成立。同一次实测:键盘菜单键触发的是 `button: -1`,所以"只认按钮 2"的拦截天然把键盘菜单留给原有节点菜单路径。 @@ -487,6 +528,17 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和不确定执行回执合同,编辑器实现留在 `plugins/agc-godot-editor`。用户选择 DLL 原件随 AGC 安装资源分发,并确认按编辑器实例在 AGC 私有缓存准备临时加载副本,以满足 Godot Windows 加载器的同目录 `~DLL` 写入要求;项目内不复制 DLL,只用受管 `.gdextension` 引导。Godot 自动 UID 伴生文件必须记录归属并在确认卸载后按内容匹配清理。工作区根不迁移到 Godot 子目录,原始项目配置与场景只通过明确编辑操作修改。完整合同及验证范围见 [Godot 编辑器插件接入](<../../technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。 > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 +> 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-19 `ts-rs` 固定到上游 #491 修复 commit,保留 enum 级 `serde(deny_unknown_fields)` + +- 背景:`shared-contracts` 的 `Model3dMultiviewInputs` 用内部 tag 枚举 + enum 级 `#[serde(deny_unknown_fields)]` 严格拒绝未知字段,但每次编译都会打印一条没有 span 的 `ts-rs failed to parse this attribute` 告警。查明 ts-rs 12.0.1 只在 `StructAttr` 里静默忽略 `deny_unknown_fields`,`EnumAttr` 没有该分支;上游 PR #491 已修复但**尚未发版**(crates.io 最新仍是 12.0.1,GitHub 最新 tag 仍是 v12.0.0)。 +- 决策一:保留 enum 级 `deny_unknown_fields`,serde 侧严格性不动;新增契约用例 `server-rs/crates/shared-contracts/tests/model3d_multiview_request_contract.rs` 钉住“变体里的未知字段必须被拒绝”,防止后续为消警改成非严格模式。 +- 决策二:两个 workspace(`server-rs`、`apps/ai-game-creator-shell/src-tauri`)的 `ts-rs` 固定到上游修复 commit `fd8679ef20f866f747c046b30bd29c15e3d7a003`,而不是启用 `no-serde-warnings`:后者会把 `deserialize_with` 等**真实**未支持的 serde 属性一并静默,掩盖 Rust/TS 类型漂移。 +- 退出条件:等 crates.io 发布 > 12.0.1 后改回 `ts-rs = "12.0.1"`(或更高 registry 版本)并删除 commit 固定;届时重新跑一次 `npm run contracts:model3d:generate` + prettier 确认绑定零 diff。 +- 影响面:两个 workspace 的 `Cargo.lock` 把 `ts-rs` / `ts-rs-macros` 从 registry 源改成 git 源(新增 `indexmap` 依赖);CI 与本地构建需要能访问 GitHub(仓库已因 `tripo3d-sdk` 具备该前提)。 +- 验证:`cargo check -p shared-contracts` 与 `cargo check --locked --bin genarrative-ai-game-creator-shell` 均无 `deny_unknown_fields` 告警;`cargo test -p shared-contracts`(101+2+3+2 全绿)后 `npm run contracts:model3d:generate` + prettier 使 `packages/shared/src/contracts/model3d/` 零 diff;`npm run check:encoding`、`git diff --check`、`rustfmt --check` 通过。AGC 侧保留 1 条既有的 `deserialize_with` 告警(`ui_editor/component/image.rs`)。 +- 关联文档:[踩坑与排障记录](pitfalls.md)、[Tripo 3D模型Provider集成](../../technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md)。 + > 当前口径(2026-09-18):历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据。策划 Agent V1/V2 的 Runtime、专用命令、审批卡、展示适配和旧测试已删除;当前策划入口统一使用 Design Agent。如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 ## 2026-09-20 DirectProject 工具并行与交付收敛 @@ -519,7 +571,7 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和 - 决策(已知代价,不补救):既有项目里无法解析的 kind 读入即 `unknown`,依赖 kind 等值比较的运行门禁会按"缺少该资源"处理。这是严格解析的必然结果,本次明确不为存量数据做迁移;将来若要迁就必须单独立项,不能改写解析边界。 - 决策(留痕必须真的落地):原实现用 `tracing::warn!`,而 AGC 壳没有 tracing subscriber,等于没有日志。现在 `shared-contracts` 只暴露可注册回调 `set_non_canonical_asset_kind_reporter()`,AGC 壳在 `main()` 里接到 `app_log!`,日志同时含原始输入串与调用上下文;`kind-observability` feature 与 `tracing` 依赖一并删除。TS 侧对应 `parseGameCreationAppAssetKind()` 的 `console.warn`。 - 决策(平台/画板词汇表):平台生成输入先严格解析为 `GameCreationAppAssetKind`,登记时直接写入已解析的 enum,不再保留 `platform_art_asset_manifest_kind()` 或任何平台别名/fallback 映射;图片快速编辑来源同样只允许 canonical 静态图片成员并要求 `mediaType=image`,原 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS` 兼容白名单已删除。 -- 验证:`cargo test -p shared-contracts`(含词汇表唯一性、严格性与留痕用例)、`cargo test --locked -p shared-contracts --features ts-bindings export_bindings` 后 `git diff` 为空、AGC bin 定向用例(`derived_asset_manifest_kind_is_never_unknown_for_text_derivatives`、`non_canonical_manifest_asset_kinds_report_raw_values_only`)、`npx vitest run packages/shared/src/contracts/gameCreationApp.test.ts apps/ai-game-creator-shell/tests/uiDesignResourceBridge.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts`。 +- 验证:`cargo test -p shared-contracts`(含词汇表唯一性、严格性与留痕用例)、`cargo test --locked -p shared-contracts export_bindings` 后 `git diff` 为空、AGC bin 定向用例(`derived_asset_manifest_kind_is_never_unknown_for_text_derivatives`、`non_canonical_manifest_asset_kinds_report_raw_values_only`)、`npx vitest run packages/shared/src/contracts/gameCreationApp.test.ts apps/ai-game-creator-shell/tests/uiDesignResourceBridge.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts`。 - 关联文档:[AI 游戏创作智能体 App 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的 2026-09-17 节。 ## 2026-09-16 DirectProject 引用渲染收敛到 canonical user item 深模块 @@ -9299,6 +9351,38 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:`cargo test -p preview-deployer-server`(13 项)、`apps/preview-deployer-web` vitest(13 项,含新增公网地址用例)、`npx tsc --noEmit`、`npm run preview-deployer:web:build`(`PREVIEW_DEPLOYER_WEB_BASE=/build/`)、`npm run check:preview-deployer`、`npm run check:encoding`、`git diff --check` 全部通过。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[Jenkins容器预览部署控制面技术方案](../../technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)。 +## 2026-09-21 Tripo 图生 3D 的站内图片引用:提交时元数据预检,执行时重新确认 + +- 背景:图生 3D 的 `source` 只接受当前账号的画布资源 ID 或素材 ID,provider 需要的图片地址由服务端解析。首版实现把归属校验与解析都放在 worker 里,跨 owner / 未登记的引用会被正常受理、扣费、入队后才失败;且解析走的是「列全部工程 / 读素材库再筛」的宽查询。 +- 决策:引用解析固定分两步,提交与执行互不代替。提交时只做元数据预检:按 `source.kind` 分支要求解析结果与记录类型一致,复用既有定点引用查询 `resolve_editor_reference_record_by_id_for_owner`(SpacetimeDB 单事务按 owner + 主键解析项目资源或素材),不读图片正文、不调用 provider,失败即 400 且不产生 operation、不扣费。worker 执行时重新确认同一事实,通过后才读一次图片正文并上传换 `file_token`。跨 owner、未登记、已删除对外收敛成同一句不可用,不把「他人 ID 是否存在」变成可探测信息;`kind` 与实际记录类型不符单独报错,因为两个值都由调用方给出。 +- 边界:预检只按主键定点查引用,禁止拉取当前用户的完整工程列表或素材库,也禁止在提交路径上执行 provider 副作用(上传 `file_token`、submit)。该口径适用于所有「服务端站内引用 → provider 输入」的生成入口,Tripo 是首个按此收口的类别。 +- 验证:`cargo test -p api-server` 1085 通过(含新增的 `tripo3d::image_source` 源码钉住用例,断言预检与定点解析只调用窄查询、不含列工程 / 列素材库 / 读正文 / 上传);`cargo check -p api-server` 无 tripo3d 警告;`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 通过。真实 provider 端到端仍未跑。 +- 关联文档:[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[实施计划 Tripo生成Worker执行链路与API路由](../plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md)。 + +## 2026-09-21 3D 资源的客户端媒体投影按类别分叉,模型格式真相只在内容类型与查看器能力各一处 + +- 背景:Tripo 生成的 `model3d` 资源同时持有模型本体(`objectKey`)与预览图(`imageSrc` / `thumbnailSrc`),而客户端媒体投影历史口径是「`objectKey` 非空就以它充当 `imageSrc`」,于是画布卡片、素材缩略图与后台都会把 `.glb` 交给图片渲染器。 +- 决策:投影收口成一个函数(`project_editor_client_media_src`):`model3d` 只投影预览图(`thumbnail_src` → `image_src`),模型本体只留在 `objectKey`;其它类别保持原口径,预览缺失才回落。写入 `asset_object.content_type` 前按字节魔数识别真实产物(GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary` 头、glTF 的 JSON 正文),对象键扩展名由内容类型派生。客户端不抄格式清单、也不按扩展名预判放行:画布只认「`assetKind=model3d` 且 `objectKey` 非空」,格式判定跟着解析器走——`packages/model3d-viewer` 依次采信读接口声明的 `Content-Type`、字节魔数、地址扩展名,格式在该包里是可选输入,判不出来即 `unsupported-format` 并把原因显示在模态里。 +- 边界:`model3d` 是独立媒体族,标签覆盖只在它与空类别之间成立;快速编辑与改造都不进入。画布工具条新增只读 capability 位 `preview-model3d`,AGC 项目画布不声明;导出 3D 图层落 `3d_models/` 目录;AGC 项目画布自带的两份格式判断本期不动,后续统一收敛到查看器包;素材库行、后台 renderer、精选 read model、公开 grant 与 External v1 本期不进入。 +- 验证:`cargo test --locked -p api-server` 的媒体投影与 Tripo 存储用例通过;前端 `model3d-preview`、选中工具条、画布视图、编辑器模型与导出工作流用例通过;`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 通过。真实 provider 端到端与素材库 / 后台 renderer 仍未接。 +- 关联文档:[ADR 0003](../../adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[实施计划 Tripo生成结果前端预览接入](../plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md)。 + +## 2026-09-21 3D 生成入口落在画布生成链路,价格只读实时定价查询,幂等键由前端铸造 + +- 背景:Tripo 两个 submit API 与 worker 已就绪,需要在前端开一个「生成 3D 模型」入口。3D 是资产型产出(没有 runtime、单局、胜负、作品架语义),而画布已经把 `model3d` 当作 `CanvasAssetKind`,并由只读「3D 预览」承接消费。 +- 决策:宿主固定为图片画布工程 `/editor/canvas` 的底部工具栏,形态照 `music` / `spec` 的「一项入口 → 浮动子选项 → 各自一个提交面板」,两个子项为「文生 3D 模型」「图生 3D 模型」(文案对齐后端 `phaseLabel`);落点固定 `projectResource` + `canvasCompletion`,复用既有占位框、入队、任务侧栏与终态刷新回填;参数永远全量非空提交;图生只接受一张平台内图片(画布资源或素材库资产,经既有参考图来源菜单收敛成 `resource` / `asset` 引用)。不新建独立生成页 / 结果页 / runtime / 作品架 / 广场 / 统计,不接 multiview,不接画布 Agent 对话,不做素材库落点入口(前端不暴露 `target = assetLibrary`)。 +- 价格与幂等(见 ADR 0004):提交前价格只来自 `GET /api/editor/generation-pricing` 的 `model3d` 段,段缺失即入口不可提交且不发请求,前端零 3D 兜底数值;`Idempotency-Key` 由前端铸造(画布链路首次需要该头),用户主动重试必须铸造新键——同键同 payload 只会返回原 operation,而 Tripo 队列 `max_attempts = 1`,不存在「同键重跑」。 +- 边界:该条记录的是文档先行阶段(ADR 0004、里程碑与实施计划、`CONTEXT.md` 术语「3D 模型生成工具」与「3D 生成定价」);前端实现已在同日落地,见下一条。后端 `model3d` 定价段仍未写入运行环境配置,入口上线前必须先把数值落到配置。 +- 关联文档:[ADR 0004](../../adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md)、[里程碑 Tripo生成前端入口](../plans/【里程碑】Tripo生成前端入口-2026-09-21.md)、[实施计划 Tripo生成前端入口](../plans/【实施计划】Tripo生成前端入口-2026-09-21.md)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)。 + +## 2026-09-21 3D 生成入口实现落地:新代码收在 `model3d-generation/` 深目录,幂等键按内容定址 + +- 背景:按上一条决策实现画布侧 3D 生成入口。既要沿用既有生成链路(占位框、入队、任务侧栏、终态回填),又要求新代码不混进画布那些千行大文件。 +- 决策:新代码全部落在 `src/components/image-editor/model3d-generation/`(`Model3dGenerationFormModel` / `Model3dGenerationSubmission` / `useModel3dGenerationTask` / `Model3dGenerationForm` / `Model3dGenerationModal`,测试文件与被测文件同名、不带 `ImageCanvas` 前缀),画布只做工具栏入口、子选项菜单、对话框 mode、定价读取与请求客户端的接线。`Idempotency-Key` 的落地形态是「尝试代次 + 请求内容指纹」,而不是「在每处参数变更里重铸键」——后者要覆盖参考图的上传 / 画布点选 / 素材库三条路径,漏一处就会出现同键换内容的 409。图生 3D 的参考图上限收口成 `usesSingleImageReferenceSlot`,单槽位下素材库选择是替换而不是追加。 +- 边界:不改后端、不改 schema、不新增公开 API;不动素材库落点、后台 renderer、精选 read model、公开 grant、External v1 与画布 Agent 对话;移动端画布不承诺(跟随现状)。 +- 验证:`src/components/image-editor/model3d-generation` 四个测试文件 38 个用例通过;`ImageCanvasGenerationModel` / `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel` / `projectAssetReferencePickerModel` 的定向用例通过;`npm run typecheck`、`npx eslint`(`model3d-generation` 与接线文件)、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 通过。真实扣费与排队回填需在写入 `model3d` 定价段的联调环境验证。 +- 关联文档:[实施计划 Tripo生成前端入口](../plans/【实施计划】Tripo生成前端入口-2026-09-21.md)、[ADR 0004](../../adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)。 + ## 2026-09-17 AGC 自动建项支持用户自选项目创建目录(入口设在设置「工作区」) - 背景:首页「做游戏 / 做方案」与模板库「使用模板」的自动建项固定落在 `/projects`,用户无法把游戏放到自己的工作盘或工程目录;同时该路径不能随意放开(受管私有目录门禁与 Documents 继承 ACL 的既有约束见 `pitfalls.md`)。 @@ -9497,6 +9581,114 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 已知坑:`cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` 掉不是本次新增的文件;本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。 - 验证:Rust 定向 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"`(949 passed);前端 `NODE_OPTIONS=--localstorage-file=/tmp/ls-gen.json npm test`(4473 passed);`npm run ai-game-creator-shell:typecheck`、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。真实客户端观感未复核。 +## 2026-09-22 合并 origin/master 到 feat/tribo3d-integeration:ts-rs 一律常开,`shared-contracts` 的 `ts-bindings` feature 删除 + +- 背景:本分支(Tripo 3D 生成契约、图片画布、AGC 资源 kind)把 `ts-rs` 提为 `shared-contracts` 的常开依赖,所有契约类型无条件 `#[derive(ts_rs::TS)]`;master 同期(`fe85fa2a6`、`a5ee45aa4`、`6782d0ea2`,都在 2026-09-17 合并基之后)给 `shared-contracts` 加了可选 `ts-bindings` feature,只给 `GameCreationAppAssetKind` 挂 `cfg_attr(feature = "ts-bindings", …)`,并让 AGC 构建脚本依赖不打开它。合并时两种机制在 `shared-contracts/Cargo.toml` 相撞:自动合并同时留下 `ts-bindings = ["dep:ts-rs"]` 与 `ts-rs = { workspace = true }`,而 `ts-rs` 不是可选依赖,`cargo metadata` 直接报错;同一份清单里 `server-rs` 还残留一条与 workspace git 固定重复的 registry `ts-rs = "12.0.1"`。 +- 决策:**ts-rs 一律常开**,删除 `shared-contracts` 的 `ts-bindings` feature。`asset_kind.rs` 的 `cfg_attr(feature = "ts-bindings", …)` 与字段级 `ts(...)` 改回无条件展开;AGC 壳对 `shared-contracts` 的 `features = ["ts-bindings"]` 依赖声明删除;两个 workspace 继续把 `ts-rs` 固定到上游修复 commit `fd8679ef…`(见 2026-09-19 那条);绑定生成命令统一为 `cargo test -p shared-contracts export_bindings`,`npm run contracts:model3d:generate` 不变。 +- 原因:feature 开关会让「哪些类型有 TS 绑定」出现两套口径——同一个 crate 里既有常开 derive、又有门控 derive,新增契约类型漏 gate 就会在不带该 feature 的构建(api-server、spacetime-module / WASM、AGC 构建脚本)里直接编译不过;而省下的只是 ts-rs 的编译时间。绑定是契约的既有产物,不按构建形态分叉。 +- 代价与取舍:ts-rs 重新进入 AGC 构建脚本与 `spacetime-module`(wasm32-unknown-unknown)依赖图,即 `a5ee45aa4` 省下的编译开销不再保留。已实测该依赖图可编译:`cargo check -p spacetime-module --target wasm32-unknown-unknown` 通过(`ts-rs` / `ts-rs-macros` 在 wasm32 上可编译)。 +- 影响面:`server-rs/Cargo.toml`、`server-rs/crates/shared-contracts/Cargo.toml`、`server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs`、`apps/ai-game-creator-shell/src-tauri/Cargo.toml`;以及本文件、`pitfalls.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/todos/【待办】AGC资源kind枚举化扫描清单-2026-09-15.md`、`docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md` 里的生成命令(去掉 `--features ts-bindings`)。 +- 验证方式:`cargo metadata --locked`(server-rs 与 AGC 两个 workspace)、`cargo test -p shared-contracts`(119 + 5 + 5 + 2 + 2 全绿、无告警)、`npm run contracts:model3d:generate` 后 `packages/shared` 零 diff、`cargo check -p spacetime-module --target wasm32-unknown-unknown`、`npm run check:encoding`、`npm run check:rustfmt`、`npm run check:spacetime-schema`、`git diff --check`。 + +## 2026-09-23 3D 生成定价迁入 SpacetimeDB 并由后台编辑,两段并列结构取代端点判别键 + +- 背景:3D 生成定价此前只存在于受控默认 JSON 与旧 override 的本地缓存;`editor_generation_pricing_to_records` 只序列化 `models`,`from_record` 再从文件把 `model3d` 补回来,因此后台改 3D 价只在进程内存生效、重启即回滚;而 admin-web 把读取到的整份配置原样回传,陈旧的浏览器标签页保存图片价时会静默写回旧 3D 段。 +- 决策:3D 定价改为 SpacetimeDB `editor_generation_pricing_config` 权威。表新增 `text_to_model_pricing` / `image_to_model_pricing` 两列(沿用 `models` 的展平风格:版本键一行含无贴图 / 带贴图两档价,加价项一行一条),不在一段里用端点做判别键;定价文件同步为两段并列结构,各段自带版本底价与自己的六个加价项键(当前数值相同)。后台「模型定价」页新增 3D 区块编辑两段,POST 两段必填并携带读取时的定价版本做事务内 CAS,不一致返回 409;读取方向做契约归一化(缺键用默认 JSON / 覆盖文件同段值补齐,契约外遗留键剔除并告警),写入方向仍要求两段完整合法;覆盖文件降级为种子与兜底。公开读模型 `GET /api/editor/generation-pricing` 的 3D 段形状与画布 3D 入口不变。 +- 原因:价格是运营参数,不该绑定发布物与进程重启;把 3D 纳入与 `models` 同一行、同一事务、同一 writer 与审计语义后,「谁是权威」只剩一个答案,价格编辑回到已经处理过并发与权限问题的后台路径。 +- 代价与取舍:必须做加载期归一化,否则发布新增模型版本时表内缺键会让整段读取失败,且失败点同时打掉后台定价页,形成「进程起不来、后台也补不了价」的死锁;加价项目前按端点各留一份,用少量冗余换未来按端点差异化加价的空间。公开读取保持旧投影,代价是存储形状与公开形状短期内不一致。 +- 验证方式:文档先行阶段(ADR 0005、主规范、里程碑与实施计划、`CONTEXT.md` 术语);实现与验收证据见 [`【实施计划】3D生成定价后台可编辑-2026-09-23.md`](../plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md)。 +- 关联文档:[ADR 0005](../../adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md)、[编辑器模型定价配置管理方案](../../【编辑器】模型定价配置管理方案-2026-06-22.md)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)。 +- 后续修订(2026-09-24):本条决心里后台保存的「携带定价版本做事务内 CAS、不一致返回 409」已整体删除,保存改为整段覆盖,见同日决策「模型定价保存去掉版本比对」。 + +## 2026-09-23 3D 定价后台可编辑的实现口径:公开投影取文生加价项、CAS 复用行内 updated_at、覆盖文件缺段按默认补齐 + +- 背景:设计口径(ADR 0005、主规范、决策记录同日上一条)落实现时,暴露出四个必须当场钉死的选择,否则实现会在细节上分叉成另一种语义。 +- 决策:① 公开读模型 `GET /api/editor/generation-pricing` 的 3D 段由内部两段投影成迁移前的旧形状(`Model3dPricingPublicView`),加价项只有一张表时取文生 3D 段,两段不一致打告警;前端升级到按端点读取前,两段加价项必须保持一致。② 乐观锁版本复用行内 `updated_at` 微秒值:后台 GET 回 `updatedAtMicros`,POST 带 `expectedUpdatedAtMicros`,比对在 procedure 事务内完成,不一致返回 409;写入的新版本取「当前时间」与「期望版本 + 1」的较大值,保证同一微秒内连续保存也推进版本。③ 覆盖文件整段缺 3D 段时按受控默认 JSON 的两段补齐并告警(与已有的 SFX 模型补齐同一条路径),不把「文件里没有这一段」解释成「3D 未配置」,否则老种子文件会把 3D 生成整段关掉。④ 覆盖文件里的旧形状 3D 段不做兼容分支,改为发布前置门禁:升级 API 前确认现网覆盖文件不含 `model3d.basePrices`。 +- 原因:**公开形状不能跟着存储形状走**——它是画布唯一的价格真相且前端零兜底,而静态站点与 api-server 独立发布,改形状在错开发布窗口内就是 3D 入口不可用;**版本复用 `updated_at`** 让乐观锁不需要新列、新表或第二套时间语义,且天然是「最后一次成功写入」的标识;**缺段补齐**把「老文件 / 老行」与「运营养未配置」区分开,避免一次发布顺手关掉一个已在收费的入口;**旧形状不兼容**是因为本分支未合并,旧形状只可能出自本分支或人工手写,为它保留分支会让配置文件形状长期存在两种真相。 +- 代价与取舍:公开投影有损,两段加价项差异化会让画布预估价按文生段计算(实际扣费仍以服务端账单为准),这条限制写到主规范里当前端升级的触发条件;发布前置门禁依赖人工/发布流程执行,暂未加进发布脚本;`models` 与 3D 两段仍共享同一行与同一版本号,一次保存要么全成要么全不成。 +- 影响面:`server-rs/crates/api-server/src/{tripo3d/pricing.rs,editor_generation_config.rs,editor_generation_model3d_records.rs,state.rs,admin.rs,app.rs,editor_project.rs}`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs` 与生成绑定、`server-rs/crates/shared-contracts/src/editor_generation.rs`、`apps/admin-web/src/{api/adminApiTypes.ts,api/adminApiClient.ts,pages/AdminEditorGenerationPricingPage.tsx,pages/AdminEditorGenerationModel3dPricingSection.tsx,pages/adminEditorGenerationPricing.ts}`、主规范、里程碑与实施计划、`pitfalls.md`。 +- 验证方式:`cargo test --locked -p api-server`(1183 passed;另有一条与本改动无关的时序敏感用例在整包并行下偶发失败、单跑通过)、`cargo test --locked -p spacetime-module editor_generation`、`npm run check:spacetime-schema`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check`、`npx vitest run apps/admin-web`(210 passed)、`npx tsc --noEmit -p apps/admin-web/tsconfig.json` 与改动文件 eslint。真实环境手工验收与发布尚未执行。 +- 关联文档:[ADR 0005](../../adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md)、[编辑器模型定价配置管理方案](../../【编辑器】模型定价配置管理方案-2026-06-22.md)、[实施计划](../plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md)。 +- 后续修订(2026-09-24):本条 ② 的版本比对与 409 已整体删除,保存改为整段覆盖,见同日决策「模型定价保存去掉版本比对」。 + +## 2026-09-23 3D 提交落点改为平坦可选字段:`target` tagged enum 作废,服务端按二选一校验 + +- 背景:3D 生成最初把结果落点设计成请求里的 tagged enum(`target = { kind: "projectResource", … } | { kind: "assetLibrary", … }`),理由是“落项目还是落素材库”是判别分支。落地后发现,其它所有生成接口(图片、音乐、图标、音效等)的落点都是平坦的可选字段 `projectId` / `canvasCompletion` / `assetFolderId` / `assetLabel`,只有 3D 多一层判别结构:同一件事在仓库里有两套形状,前端要为 3D 单独拼一次判别对象,服务端也要单独维护一套 tagged 校验。 +- 决策:撤销 tagged enum,请求契约改为与其它生成接口同形的平坦字段 `projectId?` / `canvasCompletion?` / `assetFolderId?` / `assetLabel?`;两个端点(text-to-model、image-to-model)完全同形。服务端在 `tripo3d/validation.rs` 按「`projectId` 与 `assetFolderId` 二选一」校验:都不给报 `projectId` 缺失、都给报 `assetFolderId` 冲突、`canvasCompletion` 与 `assetFolderId` 同现报冲突,字段名直接进错误响应,便于前端定位。结果侧的 `Model3dGenerationResult` 与 `Model3dGenerationTargetRef` 仍是按端点的 tagged enum,本次不动。同时把落点预检换成其它付费编辑器生成共用的 `preflight_editor_generation_target_and_return`(原先素材库落点只能读整库再匹配)。提交侧新增一步「归一落点并写回请求」:项目 ID trim、素材夹 ID 走 `normalize_generated_asset_folder_id`(`project` / 旧 `folder-*` → 当前 owner 默认素材夹)、素材名走 `resolve_editor_generated_asset_label`(trim + 截断 + 缺省 `MODEL3D_DEFAULT_ASSET_LABEL`),入队的就是归一后的请求;队列行的 `sourceEntityId` 也改用共用 `editor_generation_source_entity_id`(项目 ID,缺项目回落 job kind),不再拿素材夹 ID 顶替。另删掉 3D 专用的「Standard 消费者顶层 `result` 逐字透传」:标准消费者只回来源身份,3D 结果靠画布 placement / 素材行读回,与图片等画布任务一致。 +- 原因:**同一语义只保留一套形状**——判别结构并没有带来额外表达力(“二选一”在两侧都能表达),却让客户端多一层拼装、服务端多一套类型与校验,也让 3D 与其它生成接口的请求体不能共用同一套前端提交路径与错误提示;扁平字段还能天然复用既有的 `preflight_editor_billable_generation_target` / 归属预检口径。 +- 代价与取舍:`deny_unknown_fields` 在请求顶层继续生效,旧的 `target` 字段会被当成未知字段直接拒绝,因此这是一次**破坏性契约变更**(本分支未合并,无外部调用方,不需要兼容层);「二选一」从此是运行期校验而不是类型系统保证,缺两个或多个同时给要在服务端显式报错(已有定向用例钉住)。删掉 `result` 透传后,`GET /api/runtime/external-generation/jobs/{jobId}` 对 3D 不再返回 `result`(此前也没有任何消费方读它):若将来真需要外部读取 3D 结果,应像图片那样单开专用接口或按契约白名单收口,而不是恢复「调用方给了就照抄」。归一化改的是队列载荷(同一份客户端请求始终算出同一份归一载荷),幂等比较仍然一致;「恰好一个落点」与其它工具允许同时落两处仍不同,这是此前明确放弃的组合能力。「二选一」不变。 +- 影响面:`server-rs/crates/shared-contracts/src/model3d/{text_to_model/request.rs,image_to_model/request.rs,common/mod.rs}`(删除 `common/generation_target.rs`)、`server-rs/crates/api-server/src/tripo3d/{job.rs,validation.rs,target.rs,routes.rs,worker.rs}`、`server-rs/crates/api-server/src/editor_project.rs`(删除 Standard 的 `result` 透传)、`packages/shared/src/contracts/model3d/`(删除 `common/Model3dGenerationTarget.ts` 与 barrel 导出)、`src/components/image-editor/model3d-generation/{Model3dGenerationSubmission.ts,useModel3dGenerationTask.test.tsx}`、`src/services/image-editor/editorProjectClient.ts`、技术方案 / 里程碑 / 实施计划与共享记忆。 +- 验证方式:`cargo test -p shared-contracts`、`cargo test -p api-server`(1193 passed)、`cargo test -p api-server tripo3d::`(55 passed,含 `target_requires_exactly_one_flat_locator`、`flat_locator_maps_to_point_lookup_without_rewriting_ids` 与 `flat_target_is_normalized_before_enqueue`)、`npx vitest run src/components/image-editor/model3d-generation src/components/image-editor/model3d-preview`(63 passed)、`npm run typecheck`、`npm run contracts:model3d:generate`、`npm run check:encoding`、`npm run check:rustfmt`、`git diff --check`。 +- 关联文档:[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[实施计划 Tripo生成API契约与数据模型](../plans/【实施计划】Tripo生成API契约与数据模型-2026-09-21.md)。 +- 2026-09-23 追加修正:本条里的「恰好一个落点」「二选一不变」已被同日后续决策推翻——服务端改为「至少一个落点、两个可同给」,结果引用也一并改平坦;见下文「3D 落点对齐其它生成工具」。 + +## 2026-09-23 图生 3D 的图片输入失败语义:只收敛「引用不可用」,缺对象键改判 502 + +- 背景:`tripo3d/image_source.rs` 原先用 `is_client_error()` 把 resolver 的所有 4xx 都收敛成 `400 model3d-image-source-unavailable`(文案「图片输入必须是当前账号已登记的画布资源或素材。」),同时把「记录已解析、属于调用方、但没有对象键」也归进同一句 400。逐条核对后确认前者会把 `403`(`require_editor_generation_runtime_service_identity` → `map_editor_project_error` 的「无权」)与 `409`(版本冲突 / 幂等)说成用户引用写错,后者则把一个「DB 行存在但格式异常」的运维信号说成客户端问题。 +- 决策:① 收敛范围收窄为 `400` / `404`,其余状态码(含 `403`、`409`、5xx)一律按原状态码与原文案上报;② 「记录已解析但缺对象键」单独判 `502 model3d-image-source-object-key-missing`(带 `field: source`),口径与 `editor_project::validate_editor_reference_id_for_owner`、`preflight_editor_icon_spec_reference_metadata` 的 `BAD_GATEWAY` 一致;③ 预检失败仍然不扣费、不入队。 +- 原因:**错误语义要指向用户能改的东西**——`403` 是服务身份配置问题、`409` 是并发写入、缺对象键是数据完整性,三者都不是「你的图片没登记」。原实现的模块注释本身写着「只有基础设施失败保留原状态码」,但代码用 `is_client_error()` 覆盖了 `403` / `409`,注释与实现互相矛盾,按注释收口才自洽。 +- 代价与取舍:`502` 与 `400` 的差异对外可见(前端会把 5xx 当可重试的服务端错误、把 400 当参数错误),这是有意的;token 与 ID 都不出响应体。缺对象键的场景按「上游数据异常」处理,客户端重试不会自愈,需要人工修数据行。 +- 影响面:`server-rs/crates/api-server/src/tripo3d/image_source.rs`(收敛分支、新增 `image_source_object_key_missing`、模块文档与定向用例)、[实施计划 Tripo生成Worker执行链路与API路由](../plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md)。 +- 验证方式:`cargo test --locked -p api-server tripo3d::image_source`(4 passed,含 `image_source_resolution_only_collapses_unavailable_references` 覆盖 400 / 404 收敛与 403 / 409 / 500 保留、`image_source_resolution_reports_missing_object_key_as_server_side_failure` 断言 502 与 reason)、`rustfmt --check`。 +- 关联文档:[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[实施计划 Tripo生成Worker执行链路与API路由](../plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md)。 + +## 2026-09-23 3D 预览产物 content type 改为按字节识别:只信 provider 声明的做法作废 + +- 背景:`tripo3d/storage.rs` 的 `Preview` 槽位原先不做字节嗅探,`application/octet-stream` 或空 `Content-Type` 一律写成 `image/webp` + `.webp` 文件名;而 `worker.rs` 的 `preview_dimensions` 早已用 `ImageReader::with_guessed_format()` 解出过真实格式(PNG / JPEG)却没往下传。Tripo 的渲染图 CDN 不写或写错 `Content-Type` 时,PNG 预览就会带着 `image/webp` 进 OSS metadata、`asset_object.content_type` 与生成结果的 `Model3dGeneratedArtifact.content_type`。 +- 决策:预览槽位与模型槽位同处一个判定点,按字节魔数识别 PNG / JPEG / WebP 后再决定 content type;识别范围外(含模型字节、非图片字节)保持既有回落(声明值可用就用声明值,否则 `image/webp`)。模型槽位行为不变。 +- 原因:**浏览器能容忍,不代表记录值可以继续说谎**。`` 会按魔数嗅探,所以前端预览一直没坏;但 `assets.rs` 的 `"contentType"` 出参、素材库下载命名、后台与将来的服务端处理都信任 `asset_object.content_type`,对象键扩展名也由它派生。仓库同类图片链路的现行口径都是「字节是真相」——bgfilter 用 `guess_format` 定 mime 并把声明头只当一致性校验、VectorEngine 生成图走 `infer_image_mime_type`、生成图落 OSS 适配器的后缀与 PUT `content_type` 都取自嗅探结果;只有嗅不动的视频(`character_animation_assets.rs`)与回读已存对象的 HEAD 才信 header。本次把预览槽位对齐到这条口径,而不是删字段:删字段只会把 `image/webp` 换成同样错的 `application/octet-stream`,还要付 `Model3dGeneratedArtifact.content_type` 的破坏性契约变更代价。 +- 代价与取舍:多一次只读文件头的嗅探(不解码像素);worker 的 `preview_dimensions` 与 storage 的嗅探仍是两处调用,但前者是「必须能解码」的正交校验、后者是写库前的类型归一,判定点只有一个(`resolve_artifact_content_type`)。嗅探不到的格式不猜,仍回落 `image/webp`,因此不会因为一张少见格式的渲染图而失败退款。 +- 影响面:`server-rs/crates/api-server/src/tripo3d/storage.rs`(新增 `sniff_preview_content_type`、`resolve_artifact_content_type` 改为按槽位判定、定向用例重写为「字节优先 / 未知回落」两组)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)。 +- 验证方式:`cargo test --locked -p api-server tripo3d::`(56 passed,含 `preview_content_type_follows_bytes_over_declaration` 与 `preview_content_type_keeps_declaration_when_bytes_are_unknown`)、`rustfmt --check`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`。 +- 关联文档:[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[实施计划 Tripo生成API契约与数据模型](../plans/【实施计划】Tripo生成API契约与数据模型-2026-09-21.md)。 + +## 2026-09-23 3D 落点对齐其它生成工具:双落点同给、结果引用改平坦、素材库行接 3D 消费 + +- 背景:G5 复核(对照图片 / 图标 / 音效 / 角色动作等所有画布生成工具的前后端)发现 3D 仍有三处自成一派:① 服务端要求「`projectId` 与 `assetFolderId` 恰好一个」,而其它工具两个都能给,且前端画布链路**始终**同时发 `projectId` + `assetFolderId`(当前素材夹) + `assetLabel`,落库时项目资源行与素材行两条都写;② 3D 前端提交只发 `projectId`,于是 3D 结果永远不进素材库,同一张画布上的其它生成工具却都会进;③ 结果里的落点引用还是 tagged enum(`{ kind: "projectResource" | "assetLibrary" }`),与请求侧的平坦落点形状不一致;素材库行也没有 `model3d` 分支,一旦真落了素材行,缩略图会拿 `objectKey`(模型 `.glb`)去换签名地址。 +- 决策:① 服务端校验放宽为「至少一个落点」:两个都不给 400 点名 `projectId`,`canvasCompletion` 与「没有 `projectId`」同现 400 点名 `canvasCompletion`,两个都给合法;预检对给出的每个落点逐个定点查,出错时字段名只在「只给一个」时点名该字段,两个都给无法归因就回落到 `target`。② `Model3dGenerationTargetRef` 从 tagged enum 改成与请求落点同形的平坦可选字段 `resourceId?` / `assetId?`:哪个落点写了行就带哪个 ID,两个都写就都带。③ 前端 `buildModel3dSubmissionPlan` 接收当前素材夹与素材名,与其它工具一样同时发 `projectId` + `assetFolderId` + `assetLabel`(缺省名回落本次结果标题),`useModel3dGenerationTask` 从画布生成链路拿到 `assetFolderId`;结果消费沿用既有画布路径(`applyGeneratedProjectSnapshot` 顺带刷新素材库),不再需要 3D 专用刷新。④ 素材库行加 `model3d` 分支:缩略图按预览图(`asset.src`)渲染,`objectKey`(模型本体)不参与图片签名,与画布 3D 卡片的 `objectKey={null}` 口径一致。 +- 原因:**同一件事在各工具间必须同形**——落点是「结果放哪儿」的同一语义,3D 没有理由单独要求二选一;其它工具「画布 + 素材库同时落」既是现状也是用户预期(同一画布上的产出应当都能在素材库里找到)。结果引用跟着请求形状走,消费方不需要为同一个概念准备两套类型。素材库行的隐患属于「不接就永远发现不了」的类型:后端投影已经把 `imageSrc` 指向预览、`objectKey` 指向模型本体(`project_editor_client_media_src`),前端再拿 `objectKey` 签名就会去加载 `.glb`。 +- 代价与取舍:3D 现在会像其它画布工具一样,每次都往素材库写一条素材行(落点是当前素材夹,默认 `project` → owner 默认素材夹),素材库体积与写入量随 3D 使用增加,这是与其它工具一致的既有代价。素材库行只做预览图渲染与拖入画布,行内不开 3D 查看器,要看模型仍需拖到画布后点「3D 预览」。3D **起初仍要求 `projectId`**(初稿理由是「3D 入口只在有画布的编辑器里」);同日按用户决定改为与其它工具完全一致——`projectId` 可空、只给 `assetFolderId` 时结果只落素材库,前端门禁由「必须有工程」改成「两个落点至少给一个」。本分支未合并,无外部调用方,结果引用形状变化不需要兼容层。 +- 2026-09-23 追加(同一需求收尾):`projectId` 也改成可空。前端 `buildModel3dSubmissionPlan` 的发送门禁从「必须有画布工程」(`MODEL3D_PROJECT_REQUIRED_MESSAGE`,已删除)改成「两个落点至少给一个」(`MODEL3D_TARGET_REQUIRED_MESSAGE`),placement 只带实际存在的字段、`canvasCompletion` 只随 `projectId` 下发;Rust 请求结构体的文档注释同步去掉「二选一」旧表述并重新生成绑定。没有工程时结果只落素材库(与其它画布工具在无工程时一致:不套用项目快照、也没有本地图层回流)。 +- 影响面:`server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs`(tagged enum → 平坦结构,`packages/shared/src/contracts/model3d/common/Model3dGenerationTargetRef.ts` 随之变宽)、`server-rs/crates/api-server/src/tripo3d/{validation.rs,target.rs,job.rs,worker.rs}`、`server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs`、`src/components/image-editor/model3d-generation/{Model3dGenerationSubmission.ts,useModel3dGenerationTask.ts}`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/ImageCanvasAssetRowView.tsx`、技术方案 / 里程碑 / 实施计划三份 3D 文档与共享记忆。 +- 验证方式:`cargo test -p api-server tripo3d::`(56 passed,含 `target_accepts_both_locators_but_requires_at_least_one`、`flat_locator_maps_to_point_lookup_without_rewriting_ids`、`unavailable_field_names_the_given_locators`、`completed_result_is_strict_per_endpoint_and_free_of_provider_facts`)、`cargo test -p shared-contracts`、`npm run contracts:model3d:generate`、`npx vitest run src/components/image-editor/model3d-generation src/components/image-editor/ImageCanvasAssetRowView.test.tsx`(67 passed)、`npm run typecheck`、`npm run check:encoding`、`npm run check:rustfmt`、`node scripts/check-doc-index.mjs`、`git diff --check`。 +- 关联文档:[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[里程碑 Tripo生成API契约与数据模型](../plans/【里程碑】Tripo生成API契约与数据模型-2026-09-21.md)、[实施计划 Tripo生成前端入口](../plans/【实施计划】Tripo生成前端入口-2026-09-21.md)、[实施计划 Tripo生成结果前端预览接入](../plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md)。 + +## 2026-09-23 3D 产物 content type 不再兜底:白名单外一律按上游内容不合法失败 + +- 背景:`tripo3d/storage.rs` 原先对字节嗅探失败的情况做两段兜底 —— 声明值为空或 `application/octet-stream` 时按槽位回落成 `model/gltf-binary` / `image/webp`,其余非空声明原样放行;而对象键扩展名只认少数几个 MIME、其余再回落 `.glb` / `.webp`。provider 回 `text/plain`、`text/html` 或任何意外类型时,就会落成「错的 content type + 拼出来的扩展名」,同一个错值还会进 OSS metadata 与 `asset_object.content_type`。 +- 决策:字节优先的口径不变,但「两边都对不上」时不再猜 —— 声明值必须落在槽位白名单内(模型:`model/gltf-binary`、`model/gltf+json`、`model/fbx`、`application/x-fbx`;预览:`image/png`、`image/jpeg`、`image/jpg`、`image/webp`),否则 `resolve_artifact_content_type` 返回 `502 model3d-artifact-content-type-unsupported`,不落库、由 worker 失败退款。`fallback_extension` 删除,`content type ↔ 对象键扩展名` 收敛成槽位上的唯一映射表。 +- 原因:兜底出来的类型会连同扩展名一起写进持久化元数据,比一次可见的失败更难发现;错误体只回显截断到 64 字符的声明值,不把 provider 的任意文本带进响应。 +- 代价与取舍:嗅探不到的罕见格式(例如 `image/avif`)不再被接受,会失败退款 —— 这是有意的「宁可失败也不要错值」。本条取代上一条「预览产物 content type 改为按字节识别」里「识别范围外保持既有回落」的写法。 +- 影响面:`server-rs/crates/api-server/src/tripo3d/storage.rs`(槽位白名单、`resolve_artifact_content_type` 改为返回 `Result`、定向用例重写)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)。 +- 验证方式:`cargo test --locked -p api-server tripo3d::storage`(7 passed,含白名单外声明、跨槽位类型与字节认不出三组)。 + +## 2026-09-23 3D 请求的两个改价字段允许缺省:`geometryQuality` / `textureQuality` 按 standard 计价 + +- 背景:API 层原先要求 `generation.geometryQuality` 对每个模型版本显式给出,`texture=true` 时还要求显式给出 `generation.textureQuality`;而 provider 预检只允许 v3.1 / v3.0 传 `geometry_quality`,并拒绝 v2.5 的任何 `texture_quality`。结果是定价表里已定价的 v2.5 / P1 / P2 无论填不填都过不了校验(`texture=true` 的 v2.5 更是永远不可达)。 +- 决策:两个字段都允许缺省,缺省按 `Model3dTextureQuality::Standard` / `Model3dGeometryQuality::Standard` 计入定价 —— `standard` 本来就不产生 add-on,因此缺省报价与显式 `standard` 完全一致;只有显式 `detailed` / `extreme` 才命中 `hdGeometry` / `hdTexture` / `ultraTexture`。请求本身不改写,缺省字段不会被塞进 provider 请求体。`texture`、`quad`、`smartLowPoly`、`generateParts` 与 `texture=false` 时禁止出现 `textureQuality` 的约束保持不变。 +- 原因:把「不会改价的缺省」从必填里摘出来,才不会让能力预检与必填规则互相打架;真正会改价的参数仍然必须显式给出,避免报价与扣费在「调用方少传字段」时分叉。 +- 代价与取舍:调用方少传这两个字段不再报错,参数缺失的定位信息少了一处;仍未解决的是「非 v3 家族显式传 `geometryQuality=standard` 会被 provider 预检拒绝」(review #80),需要时按该条另行决定。 +- 影响面:`server-rs/crates/api-server/src/tripo3d/validation.rs`(`PricingParamView::validate` 与定向用例)、[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)。 +- 验证方式:`cargo test --locked -p api-server tripo3d`(57 passed,含 `geometry_quality_is_optional_and_defaults_to_standard`、`texture_on_defaults_texture_quality_to_standard`)。 + +## 2026-09-23 provider 客户端两个边界收口:构造即校验配置,产物重试包住整个 body + +- 背景:① `TripoSettings::new` 接受 `request_timeout = Duration::ZERO` 与空白 `api_key` / `base_url`,而零超时在 `tokio::time::timeout` 下「立即超时」、在 `reqwest` 下却是「不超时」,同一个值两种语义;② 产物下载的重试只包住 `download_artifact_once`(拿到响应头就返回),真正几十 MB 的字节在 `next_chunk` 里传输,中途断流不会重入重试循环,而且那时错误还带着 `status: Some(200)`,连 `is_retryable` 都判不出可重试。 +- 决策:① `TripoSettings::new` 改为返回 `Result`,零超时与空白凭据按 `TripoError::Configuration` 在构造时失败,`TripoProviderClient::new` 在使用前再校验一次(字段公开,结构体字面量能绕过构造函数);② 下载入口把「取响应头 + 读完 body」作为一次可重试事务,body 中途失败与长度不一致都归一成可重试的传输错误(`status: None`,HTTP 状态只留在文案里),每次尝试都重新取响应头并整体重下(不做断点续传),体积上限由调用方以 `max_bytes` 传入、超限按 `OutputSchema` 失败且不重试;对外返回读完的 `TripoArtifactBytes`,流式句柄 `TripoDownloadedArtifact` 收回 crate 内部。 +- 原因:重试范围必须与实际传输范围一致,否则一次 CDN 抖动会毁掉已经扣费、provider 任务也跑完的生成;配置错误应该在构造时大声失败,而不是拖到第一次产物下载时以「stalled」这种指错方向的报错暴露。 +- 代价与取舍:重下会丢掉已读字节并重新传输(换连接、重新取响应头),比断点续传多花流量,但代码与状态都更少,也避开了签名地址过期的问题;`download_model` / `download_rendered_image` 的签名加了 `max_bytes` 并改为返回完整字节,属于 `platform-tripo` 的公共 API 变更。 +- 影响面:`server-rs/crates/platform-tripo/src/common/{config,client,types,error}.rs`、`server-rs/crates/api-server/src/tripo3d/{artifacts,worker,errors,provider}.rs`、`platform-tripo` smoke 示例、[技术方案 Tripo 3D模型Provider集成](../../technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md)。 +- 验证方式:`cargo test --locked -p platform-tripo`(16 passed,含 `artifact_download_retries_the_whole_body_after_a_broken_transfer`、`artifact_download_rejects_bodies_over_the_caller_limit`、`zero_timeout_and_blank_credentials_are_rejected`)、`cargo test --locked -p api-server tripo3d`(57 passed)、`cargo check -p platform-tripo --examples`。 + +## 2026-09-23 3D 收口第二轮:视图输入显式化、派生来源取自请求、失败态提交开新一代 + +- 背景:3D review 收尾时确认三处口径:① multiview 的 `views` 契约里 `front` / `left` / `back` / `right` 是裸 `String`,`platform-tripo` 直接走 SDK 的 `FileInput::from(&str)`(裸字符串变体),服务端只能按前缀猜它是 URL、file_token 还是 task_id —— 生产路径上传拿到的是 file_token,最坏情况会被当成 task id(review #77);② worker 落库时 `source_resource_id: has_resource.then(|| resource_id.clone())` 填的是本次新生成的输出资源 id,既不是派生来源,也让「只落素材库」那条路径彻底丢掉溯源(review #75);③ 3D 失败态下主按钮仍可点且沿用同一个 `model3dAttemptNonce`,而幂等键 = 代次 + 内容指纹,同键同请求只会拿回原来那个终态 operation,点「生成 3D 模型」只是重现同一次失败(review #106)。 +- 决策:① 视图输入改成显式契约 `Model3dViewInput`(`url` / `fileToken`,带 `kind` 的 tagged enum),线上 `inputs` 按 Tripo 文档推荐的 view-key 形态构造 `[{"front":{"url":…}},{"left":{"file_token":…}}]`;该字段由 `platform-tripo` 自己构造后经 SDK params 的 `extra` 透传(`MultiviewToModelParams::from_views` 只能发位置数组 `["<裸字符串>", "", …]`,表达不了这个形态),视图校验(front 必填、至少两张、空白按未提供)与 `inputs` 构造合并成同一条路径。② 派生来源改为从请求的 `source` 取:`Model3dJobRequest::source_resource_id()` 只认 `resource { resourceId }`,素材库来源与文生 3D 为 `None`;资源行记该来源,素材行优先指向本项目的资源行、只落素材库时退回该来源。③ 失败态下的任何提交(主按钮与「重试」)都先 `refreshModel3dAttemptNonce` 再提交,主按钮保持可点。 +- 原因:① 契约是唯一的种类来源,写明是 URL 还是 file_token 之后,链路上没有任何一方需要猜前缀;view-key 是文档明确「推荐」的形态,且允许把值写成嵌套 `{url}` / `{file_token}`,正好对上契约里的显式取值。② `source_resource_id` 记的是派生来源,拿产物自己的 id 去填等于自证来源。③ 「再生成一次」必须开新一代:幂等键的用途是把重复点击折成同一次请求,不是把已失败的尝试永久锁死。 +- 代价与取舍:① multiview 请求契约改形状(无现役调用方,只有冒烟示例与契约用例,未加兼容层),且线上形态最终以真实 provider 冒烟跑通为准——仓库单测钉的是发出的 JSON 形状。② `platform-tripo` 对 `inputs` 不再是 SDK 全权构造:失去 SDK `from_views` 的客户端校验,但同样的规则本来就由平台自己再校验一遍。③ 失败态下主按钮与「重试」行为一致,两个入口都开新一代。 +- 影响面:`server-rs/crates/shared-contracts/src/model3d/multiview_to_model/{request,mod}.rs`、`packages/shared/src/contracts/model3d/multiview-to-model/{Model3dMultiviewInputs,Model3dViewInput}.ts` 与 `index.ts`、`server-rs/crates/platform-tripo/src/multiview_to_model/client.rs`、smoke 示例、`server-rs/crates/api-server/src/tripo3d/{job,worker}.rs`、`src/components/image-editor/model3d-generation/Model3dGenerationModal.tsx`、[技术方案 Tripo 3D模型Provider集成](../../technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md)。 +- 验证方式:`cargo test --locked -p shared-contracts`(119 + 各契约用例全过,含 `multiview_views_require_typed_inputs_without_unknown_fields`)、`cargo test --locked -p platform-tripo`(26 passed,含 `views_are_sent_as_view_key_entries_with_explicit_values` / `blank_views_are_treated_as_missing` / `views_require_front_and_at_least_two_entries` / `task_id_reuse_is_validated_and_wrapped`)、`cargo test --locked -p api-server tripo3d::`(59 passed,含 `source_resource_id_comes_from_the_request_source_only`)、`npx vitest run src/components/image-editor/model3d-generation/`(4 files / 50 passed)、`npm run check:encoding`(5284 files)。 + ## 2026-09-23 后台 Dashboard「消耗泥点」改为对冲退还后的净消耗 - 背景:Dashboard 的「消耗泥点数」只累计负向消费流水,生成失败退还、精选审核返还和 LLM Router 正向冲正都不参与抵扣,运营看到的「总消耗」明显高于用户实际花费(用户现场反馈)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0a3a42c64..ced0f524e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,46 @@ # 踩坑与排障记录 +## 4xx 不等于「用户输入错」:服务端没配好不能说成参数写错 + +- **现象**:Tripo 返回 401 / 403 时客户端看到「请检查参数后重试」;落点预检把「当前 identity 无权调用模型生成运行时服务」(403)与「模型定价服务身份尚未初始化」(400)都说成「生成结果的落点必须是当前账号已存在的画布项目或素材库文件夹」;图生 3D 把这两档也收敛成「图片输入必须是当前账号已登记的画布资源或素材」。 +- **成因**:两处都在错误的粒度上判断——provider 错误只看状态码区间(4xx → 400),定点预检只看 `is_client_error()` / 只看 400 与 404。这个区间里混着「密钥没配好」「找不到」「冲突」和「真的是参数写错」,按区间归类必然把服务端配置故障说成用户操作错误。 +- **处理(现行口径)**:`tripo3d/errors.rs` 给 401/403、404、409 与其余 4xx 各写一条分支(只有 400 / 422 是 `tripo-api-rejected`);定点查询(引用 / 落点)先经 `classify_editor_point_lookup_failure` 归类,只有「目标不存在 / 不属于当前账号」的固定文案才是 `Unavailable`,调用方也只对 `Unavailable` 改写成自己的文案。 +- **易错点**:不要再写「4xx 一律转 400」,也不要按「是不是 4xx」判断引用 / 落点不可用;模块新增或改写这类文案时要同步 `EDITOR_POINT_LOOKUP_UNAVAILABLE_MARKERS`,否则要么把新故障误报成用户问题,要么把用户侧的不可用变成 5xx。 +- **验证**:`cargo test --locked -p api-server -- tripo3d::errors tripo3d::target tripo3d::image_source editor_point_lookup`。 +- **关联**:`server-rs/crates/api-server/src/editor_project.rs`、`tripo3d/{errors.rs,target.rs,image_source.rs}`。 + +## 隐藏滚动条的横向滚动容器不等于「放得下」 + +- **现象**:桌面端画布底部工具栏尾部几个工具(含「生成 3D 模型」入口)看不到,用户反馈成「工具栏变成横向可滚动的了」「是不是限宽了」。 +- **原因**:共享 `.genarrative-image-canvas__toolbar` 用 `overflow-x: auto` + `scrollbar-width: thin` 处理溢出,宿主 `.image-canvas-editor__bottom-toolbar` 又把宽度限到约 614px;13 个工具必然溢出,但细滚动条在桌面端几乎不可见,也没有「还有更多」的任何提示,尾部工具等同于消失。 +- **处理(现行口径)**:工具栏 `flex-wrap: wrap`,只换行不滚动;宿主上限放宽并居中,AGC 宿主不再重复覆盖。验收判据固定为 `scrollWidth === clientWidth` 且最后一个按钮完整可见,不能只看「滚动条能不能拖」。 +- **验证**:`npx vitest run src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx` 与 `packages/image-canvas-react` 用例;1280px 视口实测工具栏单行、尾部工具可见。 +- **关联**:`packages/image-canvas-react/src/CanvasChrome.tsx`、`packages/image-canvas-react/src/styles.css`、`src/index.css`。 + +## 定价按模型版本分档时,前端不能把版本写死成常量 + +- **现象**:3D 生成面板没有任何模型选择入口,四个已经在定价表里配好底价的版本(`v3.0` / `v2.5` / `P1` / `P2`)在前端完全不可达;提交的 `generation.model` 永远是 `v3.1-20260211`。 +- **原因**:请求体与报价都把 `model` 写死成 `DEFAULT_MODEL3D_MODEL_VERSION`,而公开读模型的 `basePrices` 是按 `endpoint × modelVersion` 展开的;面板不做版本选择,也就没人去读那些键,定价配置里多数行成了死数据。 +- **处理(现行口径)**:版本由用户选,选项取「当前端点有底价」的版本;报价与请求体读同一个 `resolveModel3dModelVersion(dialog.model3dModel)`;换版本时按新版本能力收敛档位与开关并开新一代次。 +- **验证**:`Model3dGenerationFormModel.test.ts`(`resolveModel3dModelOptions` 两个端点各一例、缺价版本不可提交)、`Model3dGenerationModal.test.tsx`(选项不含无价版本、切版本后报价跟着换)。 +- **关联**:`src/components/image-editor/model3d-generation/Model3dGenerationFormModel.ts`、`Model3dGenerationSubmission.ts`、`src/services/image-editor/editorProjectClient.ts`。 + +## 「endpoint 联合 + 提交体联合」是两个独立的联合,配错端点编译能过 + +- **现象**:3D 生成提交函数的签名是 `{ endpoint: 'text-to-model' | 'image-to-model'; body: TextRequest | ImageRequest }`,把图生请求体发到文生地址没有任何类型错误,只能等远端按字段报错;同类口子还有定价类型的 `basePrices` 内层键写成 `string`,写错的模型版本号在编译期无反馈。 +- **原因**:两个联合类型彼此独立,类型系统不要求它们同进同退;只要两边各自合法,任意组合都成立。写死的常量(默认模型版本)也属于同一类问题:类型正确、语义错位。 +- **处理(现行口径)**:提交函数按端点一分为二(端点字面量与提交体类型在同一个签名里绑死),提交计划改成 `endpoint` 判别联合;`MODEL3D_MODEL_TABLE` 用 `Record`,定价键改成契约枚举。契约新增版本 / 端点时先编译失败,而不是先上生产。 +- **验证**:`useModel3dGenerationTask.test.tsx`(图生只走图生函数、文生函数不被调用)、`npm run typecheck`;`Model3dGenerationValidation.test.ts` 覆盖契约声明的 5 个版本。 +- **关联**:`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts`。 + +## 面板禁用态不能只靠「后端会拒」兜底 + +- **现象**:用户能选到必然被 provider 拒的组合(v2.5 + 贴图档位、P2 + 高清贴图、分件 + 贴图档位),提交后拿到字段级错误,白等一次往返,且错误文案对用户没有指导意义。 +- **原因**:契约与公开读模型只暴露价格,不暴露「哪个版本支持哪些参数」;前端此前的组合判定只覆盖分件与贴图档位,其余全交给 provider。 +- **处理(现行口径)**:前端保留 `model3d-generation/Model3dGenerationValidation.ts` 镜像 provider 与平台侧规则(抄规则不抄数值,冲突以 provider 为准),面板的禁用态与禁用原因读同一份常量;长期正解是后端读模型补 `modelCapabilities`,让前端不再维护第二份规则。 +- **验证**:`Model3dGenerationValidation.test.ts`(12 例,含五个版本的能力与面数边界)、`Model3dGenerationFormModel.test.ts` 的档位 × 开关组合表。 +- **关联**:`src/components/image-editor/model3d-generation/Model3dGenerationValidation.ts`、`server-rs/crates/platform-tripo/src/common/validation.rs`、`server-rs/crates/api-server/src/tripo3d/validation.rs`。 + ## 同一祖先下的多个项目会各自弹一次 UAC - **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。 @@ -204,7 +245,7 @@ AGC 主 crate(`genarrative_ai_game_creator_shell`)单架构 codegen 约 15 ## 2026-09-17 ts-rs 生成物换目录后,忘记同步忽略规则会让「生成物抖动」假装成代码改动 - **现象**:`GameCreationAppAssetKind` 的 ts-rs `export_to` 从 `apps/ai-game-creator-shell/src/contracts/generated/` 换到 `packages/shared/src/contracts/generated/` 后,任何 `cargo build` / `cargo test` 都会重写生成文件;若新目录没进 `.prettierignore` 与 `.eslintrc.cjs` 的 `ignorePatterns`,lint-staged / prettier 会把生成物重新格式化,于是每次提交都出现「生成物被改」,`cargo test export_bindings` 也不再幂等(跑完 `git diff` 不为空)。 -- **处理(现行口径)**:生成目录一律成对登记 `.prettierignore` + eslint `ignorePatterns`;改 `export_to` 时同步改这两处,并用 `cargo test --locked -p shared-contracts --features ts-bindings export_bindings --manifest-path server-rs/Cargo.toml` 后 `git diff` 为空来验证幂等。 +- **处理(现行口径)**:生成目录一律成对登记 `.prettierignore` + eslint `ignorePatterns`;改 `export_to` 时同步改这两处,并用 `cargo test --locked -p shared-contracts export_bindings --manifest-path server-rs/Cargo.toml` 后 `git diff` 为空来验证幂等。 - **易错点**:旧的 `apps/ai-game-creator-shell/src/contracts/generated/` 目录下的同名文件不会自动删除,换目录后必须显式删除旧文件,否则会出现「两个同名 union,改动只落在一个目录」的假绿。 ## universal 主程序必须配套双架构原生依赖 @@ -239,6 +280,14 @@ AGC 的 DMG 生成成功只证明应用可以被打包。平台专属 Codex stag Vite 默认监听应用根下的 Rust `src-tauri/target`,构建产物较多时会创建大量 Windows 文件监听器。AGC 配置通过 `server.watch.ignored: ['**/src-tauri/target/**']` 排除此目录,不关闭业务源码、CSS、共享组件监听或 HMR。排查时区分后端就绪、Vite 扫描和原生窗口首绘;监听目录回归不能代替实机首绘测量,验证入口见本地开发运维文档。 +## 2026-09-19 ts-rs 的 “failed to parse serde attribute” 不代表 `deny_unknown_fields` 失效,不要靠删属性或全局静默消警 + +- **现象**:编译 `shared-contracts`(以及 AGC 壳)稳定打印一条**没有 span**的告警:`warning: failed to parse serde attribute / | deny_unknown_fields | = note: ts-rs failed to parse this attribute. It will be ignored.`。因为不带文件名和行号,容易被当成“这条属性没生效”,进而被顺手删掉。 +- **成因**:告警只来自 ts-rs 宏对 serde 属性的**解析**:`StructAttr` 里有 `"deny_unknown_fields" | "default"` 的静默分支,`EnumAttr` 没有,所以只有 enum 级 `#[serde(deny_unknown_fields)]` 会告警。**serde 侧该属性一直生效**:内部 tag 枚举的 struct 变体会真的拒绝未知字段(契约用例见 `server-rs/crates/shared-contracts/tests/model3d_multiview_request_contract.rs`)。TS 绑定本来就无法表达“拒绝未知字段”,告警与绑定内容无关。 +- **处理(现行口径)**:保留属性,禁止用 `no-serde-warnings` feature 消警(它会连同 `deserialize_with` 这类**真实**未支持的 serde 属性一起静默)。两个 workspace 的 `ts-rs` 固定到上游修复 commit `fd8679ef20f866f747c046b30bd29c15e3d7a003`(PR #491,`EnumAttr` 增加 `"deny_unknown_fields" | "default"` 分支),等 crates.io 发布 > 12.0.1 后改回 registry 版本并删掉固定。 +- **验证**:`cargo check -p shared-contracts`(server-rs)与 `cargo check --locked --bin genarrative-ai-game-creator-shell`(AGC)都不再出现 `deny_unknown_fields` 告警;`cargo test -p shared-contracts` 全绿且 `npm run contracts:model3d:generate` + `npx prettier --write packages/shared/src/contracts/model3d` 后绑定零 diff(固定 commit 不改变生成结果)。AGC 侧仍会打印 1 条 `deserialize_with = "deserialize_fill_amount"` 告警(`ui_editor/component/image.rs`,字段类型仍是 `f32`,属既有无害告警)。 +- **关联**:`server-rs/Cargo.toml`、`apps/ai-game-creator-shell/src-tauri/Cargo.toml`(两处 `ts-rs` 固定)、`server-rs/crates/shared-contracts/src/model3d/multiview_to_model/request.rs`。 + ## 2026-09-17 AGC 输入盒的「推理档」弹层被祖先裁切:要放开裁切而不是挪弹层 - **现象**:窄窗口下(视口 ≤1000px 时右侧对话面板只有 280px 宽)点开输入盒右下角的「推理档」,弹层是个**空盒子**:档位文字(默认 / 低 / 中 / 高 / 最高)整片看不见,只剩一个方框。 @@ -5879,6 +5928,46 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **验证**:`npx vitest run apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx`(正常发布 / 导出失败 / 确认继续 / 取消 / 确认时拒绝 / 权限查询失败 6 条)与 AGC 全量前端用例通过;`npm run ai-game-creator-shell:typecheck`、`npm run check:encoding`、`git diff --check` 通过。 - **关联**:`apps/ai-game-creator-shell/src/App.tsx`、`apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx`、`apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx`。 +## 2026-09-21 改了 `spacetime-client` 的同名文件却不生效:未参与编译的重复副本 + +- **现象**:给 `spacetime-client` 的 external generation facade 新增方法(`set_external_generation_job_provider_checkpoint`)后,`cargo check -p spacetime-client` 通过,但在同一文件里继续加注释、改签名都对调用方没有任何影响,直到 api-server 侧报「方法不存在」才发现改动落在了一份死文件上。 +- **原因**:`src/active.rs` 用 `#[path = "active/.rs"] pub mod ;` 显式指定真实模块路径,`src/.rs` 里另有一份同名副本并不参与编译。`rg "fn <名字>"` 会同时命中两份,肉眼很容易以为改对了。 +- **处理(现行口径)**:改 `spacetime-client` 的模块前先 `rg -n '#\\[path = "active/' server-rs/crates/spacetime-client/src/active.rs` 确认该模块的真实文件;只有 `active/` 下的文件参与编译。历史死副本 `src/external_generation.rs` 已在 `ac08ccbe3` 删除,不要再以「两边都改一遍」的方式维持同步。 +- **验证**:`cargo check -p spacetime-client` 与 `cargo check -p api-server` 同时通过,且 `rg --files server-rs/crates/spacetime-client/src` 中同名文件只剩一份。 +- **关联**:`server-rs/crates/spacetime-client/src/active.rs`、`server-rs/crates/spacetime-client/src/active/external_generation.rs`、`server-rs/crates/spacetime-client/src/active/mapper.rs`。 + +## 2026-09-21 「`objectKey` 非空就覆盖 `imageSrc`」的投影会把模型文件当图片 + +- **风险**:3D 资源(`assetKind=model3d`)的 `objectKey` 是模型本体、`imageSrc` 是预览图,但后端历史投影不分类别地用 `objectKey` 覆盖 `imageSrc`,于是画布卡片、素材缩略图、后台列表会一起去加载 `.glb`。 +- **处理(现行口径)**:客户端可见媒体投影按 `asset_kind` 分叉(`model3d` 只投影预览图,模型本体只留 `objectKey`),模型格式的权威是写入前按字节魔数识别出的 `content_type`,扩展名由它派生。画布图层没有内容类型字段,所以画布只按「`assetKind=model3d` 且 `objectKey` 非空」放行,格式判定交给查看器包(声明 `Content-Type` → 字节魔数 → 地址扩展名,判不出来即 `unsupported-format` 并展示原因);下载侧用真实响应头 MIME、对象键扩展名兜底。 +- **易错点**:① 不要为了「预览缺失也能开」把模型文件喂给图片元素,预览缺失应显示占位与错误信息;② 不要新增 `model_format` 列,也不要在画布 / 素材库各抄一份格式清单或在画布侧按扩展名预判放行,两处真相一定会分叉;③ `EDITOR_CANVAS_ASSET_KINDS` 与前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 必须同步,任一侧多出或少一份都算接入未完成;④ 不要在 `assetKind` 为空时按扩展名兜底(后端投影缺陷会被掩盖),也不要直接照抄 AGC 画布那两份旧判断。 +- **关联**:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/tripo3d/storage.rs`、`src/components/image-editor/model3d-preview/`。 + +## 2026-09-21 复用旧 `Idempotency-Key` 的「重试」不会重跑 3D 生成 + +- **现象**:3D 生成失败后,用同一次请求(同 `Idempotency-Key` + 同 payload)再提交一次,接口返回 accepted 与**同一个** `operationId`,但任务永远停在原来的失败终态,用户以为「已经重试过了」。 +- **成因**:两个 Tripo submit 端点把 `Idempotency-Key` 当作唯一幂等身份(dedupe key = sha256(owner + job_kind + key)),同键同 payload 直接返回既有 operation,同键不同 payload 才 409;而 Tripo job 固定 `max_attempts = 1`,失败即终态。因此这个键上不存在「再跑一次」的可能。 +- **处理(现行口径)**:用户主动重试必须铸造新的 `Idempotency-Key`(画布侧由前端生成并绑定当次请求内容,改参数即视为新请求),不要把它做成「复用同键的可重试」。 +- **实现口径**:画布侧的键是「尝试代次 + 请求内容指纹」两段拼成(`resolveModel3dRequestKey`)。不要改成「只在档位 / 开关 / 提示词的 onChange 里重铸键」——参考图还有上传、画布点选、素材库选择三条路径,漏一条就会出现同键换内容的 409;也不要去掉代次,那样用户点「重试」会回到原来那个终态 operation。 +- **验证**:`cargo test -p api-server` 的 tripo3d 队列用例;前端 `model3d-generation` 的重试用例断言两次提交的键不同。 +- **关联**:`server-rs/crates/api-server/src/tripo3d/queue.rs`、`server-rs/crates/api-server/src/editor_generation_queue.rs`、`src/components/image-editor/model3d-generation/`。 + +## 2026-09-21 3D 生成价格不能照抄图片模型的「前端内置兜底」 + +- **风险**:图片 / 视频 / 音频一族在前端有内置兜底定价(`EDITOR_MODEL_MUD_POINT_CONFIG`),定价接口异常时价格照常显示。3D 入口若照抄这条习惯,会在 `model3d` 段缺失时继续展示一个价格,而后端是 fail closed(503 `model3d-pricing-unavailable`,不扣费不入队),用户看到「面板有价、提交被拒」。 +- **处理(现行口径)**:3D 价格只读 `GET /api/editor/generation-pricing` 的 `model3d` 段;该段缺失即入口不可提交且不发请求,既不写死数值也不按 0 元放行。前端只保留「档位 → 六个必填参数」的映射与加和逻辑,所有数值来自查询。 +- **易错点**:① 缺价时不要「先发请求让后端兜底」,那会把 503 变成用户可见的失败;② 不要拿图片模型的 `prices` 给 3D 兜底;③ 前端这份加和逻辑与后端 `model3d_add_ons` 是两份实现,必须用同一组参数组合的用例对齐。 +- **关联**:`docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md`、`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/ImageCanvasGenerationModel.ts`。 + +## 2026-09-21 画布新生成工具漏在「参考图来源」的 mode 列表外,表现为点了菜单没反应 + +- **现象**:新工具面板里的参考图来源菜单(从画布中选择 / 上传图片 / 从项目素材中选择)点下去没有任何反应,既不报错也不进参考图;`ImageCanvasGenerationSurface` 的入口与子选项菜单却一切正常。 +- **成因**:参考图的三条写入路径都按**显式 mode 列表**分流,而不是按「谁的对话框打开了」:画布点选走 `ImageCanvasGenerationDialogModel.appendGenerationReference`,上传走 `ImageCanvasUploadModel.applyGenerationReferenceUpload`(`target === 'generation-reference'`),素材库走 `ImageCanvasGenerationDialogModel.replaceProjectAssetPickerReferences`。三处各自维护一份 `dialog.mode === 'generate' || 'scene' || ...` 列表,新 mode 不在列表里就被安静地原样返回。 +- **处理(现行口径)**:新增画布生成工具时,除工具栏与子选项菜单外,必须把新 mode 同步进这三处,并在 `resolveDialogExtraImageReferenceLimit` 给出参考图上限;单图槽位(如 `model3d-image-to-model`)用 `usesSingleImageReferenceSlot` 标注,素材库选择按替换而不是追加处理。改完必须有用例覆盖「点选 / 上传 / 素材库各进一次参考图字段」。 +- **易错点**:① 不要只在面板里 `console.warn` 或靠 UI 文案兜底,这类遗漏在自动检查里是静默的;② 单槽位工具不要让「画布点选 vs 素材库选择」互相挤占——留着旧的会让用户这次的选择看起来没生效;③ 参考图上限不要沿用图片生成的默认值(图生 3D 只接受一张)。 +- **验证**:`src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts`、`src/components/image-editor/ImageCanvasUploadModel.test.ts`、`src/components/image-editor/projectAssetReferencePickerModel.test.ts`、`src/components/image-editor/ImageCanvasGenerationModel.test.ts` 中针对 `model3d-image-to-model` 的用例。 +- **关联**:`src/components/image-editor/ImageCanvasGenerationDialogModel.ts`、`src/components/image-editor/ImageCanvasUploadModel.ts`、`src/components/image-editor/ImageCanvasGenerationModel.ts`、`docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md`。 + ## 2026-09-21 受控 Lexical 输入区的回写用被动 effect:滞后渲染的 props 会把用户草稿清空 - **现象**:DirectProject 输入盒里粘贴(或连续输入)长文本,提交时 `chat_with_game_creator_direct_codex` 根本没发出去,界面停在空输入盒;`chat-composer` 用例里表现为「队列/终止/语音追加」五条一起红,但手工操作只在快速输入后偶发。 @@ -5909,6 +5998,13 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理(现行口径)**:`input_text` 允许空字符串、空格和换行,校验过程保持全部片段的原文、分段与顺序,不做合并或删除;遍历完整条消息后,只在既没有非空白文字、也没有任意非文本 part(素材引用 / 运行画面引用 / Skill 引用 / 附件引用)时返回“聊天内容不能为空”。各类引用仍逐个执行原有校验,消息带正文也不能绕过非法引用。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`。 +## 2026-09-22 合并 master 后 CI 报 `cannot find module or crate`:先查 `Cargo.toml` 是否丢了对方新增的依赖行 + +- **现象**:合并 `origin/master` 到 `feat/tribo3d-integeration` 后,CI 的 AGC Rust 分片报 8 条 `error[E0433]` / `error[E0432]`,全部集中在 `cannot find module or crate codex_patch_parser`,落点是 `apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs` 与 `direct_codex_audit.rs`。 +- **成因**:冲突解决只处理了同一个 `[dependencies]` 段落里的 `ts-rs`(本分支改成 git 固定 commit、master 仍是 registry `12.0.1`),把 master 在同段落新增的 `codex-patch-parser = { path = "vendor/codex-patch-parser" }` 与 `chrono = { version = "0.4", … }` 一并丢掉;vendor crate 仍是 workspace member,所以看起来"依赖还在",但 `[dependencies]` 里已经没有它。 +- **处理(现行口径)**:合并两个 Rust workspace 的 `Cargo.toml` 后不能只看 `cargo metadata --locked`——它只验证 lock 与 manifest 自洽,不看代码用了什么。必须各做一次真实编译:`cargo check --locked --workspace --manifest-path server-rs/Cargo.toml` 与 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run`。CI 报 crate 找不到时,先 `git diff ^2 -- ` 核对对方新增的依赖行,不要先怀疑 crate 名字或 vendor 路径。 +- **易错点**:`--locked` 通过不等于依赖齐全;任何 cargo 命令都会按当前 manifest 重新对齐 lock,缺失的依赖条目会被顺手从 lock 里剪掉,于是"缺依赖"看起来像"本来就不该有"。 + ## 2026-09-21 应用日志整行凭据脱敏会吃掉整条结构化诊断 `append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。 @@ -5937,6 +6033,33 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 自动切换:Gitea 1.26.4 的 disabled 检查与 FetchTask 事务不原子,Runner 客户端超时不能证明服务端回滚,容器暂时为空也不能证明没有已领取任务。网关必须解析实际 Connect Protobuf/gzip,转发 FetchTask 结果前持久化任务 ID,仅在最终日志及执行清理后的最终 UpdateTask 确认后清账;取消响应不能提前释放。暂停新领取、在途为零、账本为零且内层活动容器为空才可切换,无需全局 Runner admin API。未知协议/响应或崩溃遗留标记停止切换;旧网关缺 active_tasks 不能默认零。首次接入与账本升级须空闲窗口。.runner 的 mtime 不证明地址已加载,应核验真实 FetchTask 来源及本次容器启动时间。 - 扩展:预热所有 Rust 测试组时保留各自 cwd、profile、features 和锁策略;同一临时 target 的 Cargo fresh 不代表不同 cwd 都已生成缓存键,AGC 提示词契约、分片和 smoke 切换入口前清理预热 target。不要把 workspace 与 spacetime-module 合并成一次编译;Native shell release step 清空双 wrapper,避免将测试缓存扩展成发布缓存。当前 sccache 0.18.0 的 READ_ONLY 在 miss 后仍打包产物并产生 cache write error,不适合用来承诺“未命中无开销”。 +## 2026-09-23 后台定价「省略即沿用」会把别人的改动静默覆盖 + +- **现象**:admin-web 把后台读取到的整份定价原样回传保存;当 3D 段只在进程内存(文件配置)里时,陈旧的浏览器标签页保存图片价格会把旧的 3D 段一并写回,价格在无人察觉的情况下回滚。 +- **成因**:后端把「`model3d` 缺席或为 null」解释成「沿用当前 3D 段」(`with_previous_model3d`),于是 payload 的形状本身携带了两种语义:给值是覆盖,不给是沿用;而前端是整份回传,两种语义在真实浏览器里无法区分。 +- **处理(现行口径,2026-09-24 修订)**:定价保存不再有隐含沿用语义——`models` 与 3D 两段都必须显式给出,缺段即 400。3D 定价存进 SpacetimeDB 与 `models` 同事务后,任何一次保存写的都是页面当时看到的完整状态;保存本身不做版本比对,写进去的整段直接成为新状态(后台定价同时只有一个管理员在操作,所以不再需要版本号;原先的版本号比对已删除)。 +- **易错点**:① 不要为了「顺手支持只改一个字段」再引入任何「省略 = 沿用」分支,缺段就该报错;② 不要再把版本号加回来——后台定价假设同时只有一个管理员在线,保存是整段覆盖,读到的旧版本号只会制造 409 这类无从处理的失败;③ 表为空时的种子写入仍由 procedure 的入库事务负责,不靠「先读空、再无脑写」的两步流程。 +- **验证**:`server-rs/crates/api-server/src/app.rs` 的 `admin_editor_generation_pricing_route_rejects_missing_model3d_section` / `admin_editor_generation_pricing_route_saves_config_and_updates_public_route`;`state::tests::saving_pricing_applies_explicit_model3d` / `saving_pricing_twice_keeps_last_write`(后一次保存直接覆盖前一次)。 +- **关联**:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md`。 + +## 2026-09-23 公开读模型改了存储形状就等于在两条发布线上做破坏性变更 + +- **现象**:3D 定价内部结构从「端点判别键」改成两段并列后,若直接让 `GET /api/editor/generation-pricing` 输出内部结构,画布 3D 入口会因为读不到 `basePrices` 而判定「定价缺失」,整条 3D 生成不可用。 +- **成因**:公开读模型是画布唯一的价格真相且前端零兜底(缺段即不可提交),而 admin-web / 主站静态资源与 api-server 是两条独立发布线——形状变更在错开发布窗口内必然有一段时间两侧不匹配。 +- **处理(现行口径)**:`GET /api/editor/generation-pricing` 的 3D 段由内部两段投影回旧形状(`Model3dPricingPublicView`),前端不需要改动。投影是有损的:加价项只有一张表,取文生 3D 段,两段不一致会打告警,因此前端升级到按端点读取加价项之前,两段加价项必须保持一致。 +- **易错点**:① 改定价内部结构时,先确认公开读模型的形状有没有被顺手带出去,测试要直接断言公开路由的字段名;② 「两段可以独立定价」不等于「两段可以随便不同」,加价项差异会先影响画布预估价。 +- **验证**:`app::tests::public_editor_generation_pricing_route_returns_default_config`(断言 `model3d.basePrices` / `model3d.addOnPrices` 与内部 `textToModelPricing` 不出现);`tripo3d::pricing` 与 `editor_generation_model3d_records` 的定向用例。 +- **关联**:`docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md`。 + +## 2026-09-23 失败态沿用同一个 3D 尝试代次,主按钮点下去只是重现同一次失败 + +- **现象**:3D 生成失败后,用户不改任何参数直接点「生成 3D 模型」,界面没有任何新的尝试,拿回来的还是原来那个失败操作。 +- **成因**:幂等键 = 尝试代次(`model3dAttemptNonce`)+ 请求内容指纹,服务端按「键 + payload 指纹」dedupe。参数一改本来就会换代号次(`applyModel3dPromptChange` / `applyModel3dTierChange` / `applyModel3dSwitchChange`),但「什么都不改再点一次」既不换代次也不改内容,于是命中同一次已终态的 operation。 +- **处理(现行口径)**:失败态下的任何提交入口都先 `refreshModel3dAttemptNonce` 再提交(主按钮与「重试」共用同一份重置后的 dialog)。幂等键的用途是把重复点击折成同一次请求,不是把已失败的尝试永久锁死。 +- **易错点**:① 新增提交入口时要问「这次提交属于新的一代还是同一次尝试」,只看 `canSubmit`(价格 / 输入 / 上传中)挡不住这一类;② 别把失败态的主按钮改成禁用就算修好——参数改过再点主按钮是用户的自然路径,禁用会把这条路堵上。 +- **验证**:`src/components/image-editor/model3d-generation/Model3dGenerationModal.test.tsx` 的「失败态下主按钮也开新一代」与「失败后展示原因与重试」两条用例;`Model3dGenerationFormModel.test.ts` 钉住参数改动换代次。 +- **关联**:`src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts`(`resolveModel3dRequestKey`)。 + ## 2026-09-22 卡片文字用 grid 的 auto 行排版,会被按「一行」裁掉 - **现象**:AGC 模板库卡片标题看着被切掉、简介只剩一行、标签行缺半截;`TEMPLATE_CARD_TEXT_HEIGHT` 与真实内容相差约 24px,但卡片底部看起来仍「刚好贴住」,很容易误判成没问题。 @@ -5977,6 +6100,15 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理**:`TemplateCard` 的 `img` 加 `onError` 直接把自身 `visibility` 设为 `hidden`(不进 state,卡片是 memo 的纯展示组件),留下封面容器本身的中性底色;单测用 `fireEvent.error(cover)` 钉住。 - **关联**:`apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx`、`apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`。 +## 2026-09-24 缺 provider 配置的进程照常启动,健康检查不会替你说出这件事 + +- **现象**:部署里没有 `TRIPO_BASE_URL` / `TRIPO_API_KEY` 时,api-server 与 external-generation-worker 都能正常起来,`/healthz` 全绿,用户提交 3D 生成拿到 202,然后任务在 worker 里失败——现场只有一条「提交成功但结果一直不来」的业务记录,没有任何一条指向「机器上少了一个环境变量」。 +- **成因**:provider 凭据的可用性判断原本只在请求期(`tripo_settings()` 返回 503),而请求期路径并不覆盖启动:HTTP 角色收单时根本不碰 provider,worker 只在自己真的认领到那个 job 时才构造 `TripoSettings`。健康检查只看进程,不看「这个进程声称自己支持的能力是否具备实现它的配置」。 +- **处理(现行口径)**:会提交、会执行 3D job 的角色(`api` / `external-generation-worker` / `all`)在启动期校验 Tripo 配置,缺失即拒绝启动;网关没有内置默认值,密钥没有兜底值。请求期 503 作为第二道防线保留(启动后配置被改、或别的入口拼出非法 `AppConfig`),语义仍是不可用、不扣费、不入队。要点是分层:启动期管「部署是否配全」,请求期管「这一刻能不能调」。 +- **易错点**:① 不要把 `AppConfig::default()` 的字段当成「安全兜底」——`tripo_base_url` 现在是空串,任何忘了读 env 的入口都会在启动期被拒,这是故意的;② 不要在请求路径重新加回 `.max(1)` / `.min(10)` 这类静默钳制:钳制会把配置错误翻译成「能跑但行为诡异」,而 `TripoSettings::validate()` 本来就拒绝零超时;③ 新增会碰 provider 的角色时,同步考虑要不要把它加进启动期门禁的角色名单(现在刻意排除了 `bgfilter-worker` 与 `external-generation-controller`);④ 判断「缺配置」时空白字符串等于未配置,`TRIPO_API_KEY=" "` 不允许被当作有效密钥。 +- **验证**:`server-rs/crates/api-server/src/main.rs` 的 `tripo_startup_gate_covers_every_role_that_touches_3d` / `tripo_startup_gate_reports_the_missing_variable` / `tripo_startup_gate_skips_unrelated_roles`;`tripo3d/provider.rs` 的 `startup_rejects_missing_or_blank_gateway_and_key` / `startup_rejects_unusable_gateway_addresses` / `startup_validates_declared_knobs_without_clamping_them` / `request_path_no_longer_clamps_bad_knobs`;`config::tests` 里 `AppConfig::default()` 的网关断言已改为「为空」。 +- **关联**:`docs/adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。 + ## 2026-09-23 渠道 `--config` 只写窗口标题,打包产物系统标题栏回来了且登录请求被 ACL 拒绝 - **现象**:dev 渠道 0.1.129 安装包启动后,窗口顶部同时出现系统标题栏(浅蓝条 + 原生最小化/最大化/关闭)与前端自绘 `WindowChrome`;窗口缩到 816x639(约 800x600 客户区);登录页常驻「无法连接登录服务,请确认配套后端或 API 代理已启动后重试」。应用日志同一秒出现 `startup.window-title.failed: 缺少 client 主窗口`,而 `https://dev.genarrative.world` 在浏览器/curl 下可正常响应。 diff --git a/docs/project-memory/todos/【待办】AGC资源kind枚举化扫描清单-2026-09-15.md b/docs/project-memory/todos/【待办】AGC资源kind枚举化扫描清单-2026-09-15.md index 930525bcc..68500ea84 100644 --- a/docs/project-memory/todos/【待办】AGC资源kind枚举化扫描清单-2026-09-15.md +++ b/docs/project-memory/todos/【待办】AGC资源kind枚举化扫描清单-2026-09-15.md @@ -83,7 +83,7 @@ - [x] `game-background` / `game-art` / `illustration` / `character-art`:确认只出现在平台请求词汇;写入 manifest 前经显式转换函数收口为 `scene` / `image` / `character`。 - [x] `ui` / `ui-prototype`:确认 UI 设计桥接与工作流已无生产 manifest writer,只写 `ui-design` / `ui-design-doc`。 - [x] 非 canonical 原值留痕为「原始串 + 调用上下文」,由 `shared-contracts` 的可注册回调 `set_non_canonical_asset_kind_reporter()` 交给 AGC 壳的 `app_log!`(2026-09-17 起不再用 `tracing` / `kind-observability`);生产 writer 只传正式枚举成员。 -- [x] ts-rs 生成路径为 `packages/shared/src/contracts/generated/`(2026-09-17 从 `apps/ai-game-creator-shell/src/contracts/generated/` 迁出),命令为 `cargo test -p shared-contracts --features ts-bindings export_bindings`(枚举自带 `export_to`),生成目录已在 `.prettierignore` / eslint `ignorePatterns` 中保持原始格式。 +- [x] ts-rs 生成路径为 `packages/shared/src/contracts/generated/`(2026-09-17 从 `apps/ai-game-creator-shell/src/contracts/generated/` 迁出),命令为 `cargo test -p shared-contracts export_bindings`(枚举自带 `export_to`),生成目录已在 `.prettierignore` / eslint `ignorePatterns` 中保持原始格式。 - [ ] 接入 CI verify(在 CI 中跑一次 `export_bindings` 并断言生成文件无 diff)。 ## 已确认的 manifest kind 入口(M0 审计结果) diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 42fc410fd..2254f5634 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -133,7 +133,8 @@ worker 配置: - `GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS`:空队列轮询间隔。 - `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS`:任务 lease 时长,默认 `600`;worker 会按约三分之一 lease、最长 30 秒的间隔续租。该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。 - `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS`:普通外部生成 job 的执行预算,默认 `900`。超过预算后当前 worker 停止续租并释放 worker 槽位,但不取消已启动的业务 future,也不主动写入失败 / 重试状态;在途执行交由 lease fencing 仲裁:写回在租约有效期内到达则照常完成,否则被拒绝,租约过期后任务可被重新认领,attempt 耗尽时由认领事务原子标记失败并结算退款。 -- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS`:VectorEngine 图片生成 / 编辑、图标 spritesheet 生成、UI 素材提取以及角色动作、视频等长耗时 job 的执行预算,默认 `1800`。其中四类 VectorEngine 图片 job 固定为 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation` 和 `editor_ui_design_asset_extraction`;手动去背景等不直接调用 VectorEngine 的 job 继续使用普通预算。 +- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS`:VectorEngine 图片生成 / 编辑、图标 spritesheet 生成、UI 素材提取以及角色动作、视频等长耗时 job 的执行预算,默认 `1800`。其中四类 VectorEngine 图片 job 固定为 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation` 和 `editor_ui_design_asset_extraction`;手动去背景等不直接调用 VectorEngine 的 job 继续使用普通预算。Tripo 3D 的 `model3d_text_to_model` 与 `model3d_image_to_model` 同样使用该预算:它们在单次 attempt 内把 `get_task` 轮询到 provider 终态再下载产物,因此会持续占用一个 worker 并发位,接入真实流量前必须确认并发数与超时预算。 +- `TRIPO_BASE_URL` / `TRIPO_API_KEY` / `TRIPO_REQUEST_TIMEOUT_MS` / `TRIPO_RETRIES`:Tripo 3D provider 的网关、凭据、API 单请求超时(同时也是产物下载的连接与「无数据推进」预算)与重试次数,由 HTTP 角色与 worker 共享同一份 API env。网关没有内置默认值,`TRIPO_BASE_URL` 与 `TRIPO_API_KEY` 都必须在 env 里显式给出:缺任意一项时 `api` / `external-generation-worker` / `all` 在启动期直接拒绝启动(见 [ADR 0006](../../adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md)),请求期仍保留「配置不可用即 503、不扣费、不入队」作为第二道防线。两个运行旋钮可缺省(沿用内置 `60000` / `2`),显式声明时零值或越界值同样让进程拒绝启动。worker 只使用 submit、单次 `get_task` 与产物下载三项能力,任务 ID 只作为 checkpoint 存在服务端。 worker 在单次 job 开始执行时从同一个单调时钟起点计算绝对 `job deadline` 和更早的 `provider deadline`:常规情况下为终态审计、OSS 持久化及 `complete/fail` 回写保留 `60` 秒;当整个 job 预算小于 `120` 秒时,保留其一半,避免 provider 预算被全部吃掉。该 deadline 只通过进程内 `RequestContext` 传给 VectorEngine 图片调用,不写入 HTTP DTO、队列 payload 或 SpacetimeDB;普通 HTTP / `inline` 上下文没有 deadline,保持原有行为。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4f81fb017..58042cb80 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -223,7 +223,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema 本节覆盖 2026-09-15 节里关于「canonical 字符串列表 / legacy 别名表 / `tracing` 留痕 / ts-rs 生成路径」的表述;枚举成员集合、「不迁移、不静默转换」的总体口径不变。 - **唯一词汇表**:kind 的变体、线上值(kebab-case)、`as_str()`、`ALL`、严格解析与 ts-rs 绑定全部由 `server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs` 的声明表派生。`GAME_CREATION_APP_CANONICAL_ASSET_KINDS`、`canonical_game_creation_app_asset_kind()`(含 `font → document` 特例与 legacy 别名表)已删除;TS 侧同名的 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS`、`GameCreationAppCanonicalAssetKind`、`GAME_CREATION_APP_LEGACY_ASSET_KINDS`、`canonicalGameCreationAppAssetKind()` 一并删除,仓库里不再有第二份 kind 列表。 -- **TS 生成路径**:`GameCreationAppAssetKind` 由 ts-rs 生成到 `packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts`(不再是 `apps/ai-game-creator-shell/src/contracts/generated/`);`packages/shared/src/contracts/gameCreationApp.ts` 直接 re-export 该 union,运行期 kind 列表只有一份 `GAME_CREATION_APP_ASSET_KINDS`(穷举 `Record` 保证不会与生成 union 分叉)。重新生成:`cargo test --locked -p shared-contracts --features ts-bindings export_bindings --manifest-path server-rs/Cargo.toml`,之后 `git diff` 必须为空。两张生成目录的忽略规则同步登记在 `.prettierignore` 与 `.eslintrc.cjs`。 +- **TS 生成路径**:`GameCreationAppAssetKind` 由 ts-rs 生成到 `packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts`(不再是 `apps/ai-game-creator-shell/src/contracts/generated/`);`packages/shared/src/contracts/gameCreationApp.ts` 直接 re-export 该 union,运行期 kind 列表只有一份 `GAME_CREATION_APP_ASSET_KINDS`(穷举 `Record` 保证不会与生成 union 分叉)。重新生成:`cargo test --locked -p shared-contracts export_bindings --manifest-path server-rs/Cargo.toml`,之后 `git diff` 必须为空。两张生成目录的忽略规则同步登记在 `.prettierignore` 与 `.eslintrc.cjs`。 - **严格解析只有一个入口**:`GameCreationAppAssetKind::parse_with_context(value, context)` 是唯一公开解析入口(`FromStr`、serde、所有外部边界都走它),内部严格匹配是私有 `match_canonical()`;先前那批易混名字(`from_str_lossy()`、公开的 `from_str_or_unknown()`)都已删除,认不出 canonical 值只有这一处收口 + 留痕。口径是等值匹配——不 trim、不 lowercase、不查别名、不迁移;`"UI"`、`"ui"`、`" image "`、`"art-spritesheet-slice"` 一律收口成 `unknown`(分类落 `unclassified`)。**接受 unknown 是显式决定**:留痕日志给出原始串与上下文,供回查仍在写 legacy kind 的代码;不写兼容、不做迁移。 - **登记边界同口径**:外部登记 kind(Tauri 命令 `commands::register_local_asset`、`commands::import_canvas_asset`、平台导入)统一走 `assets::registration_asset_kind()`——空白入参按「没有信息」落中性 `image`(→「待归类」),非空但认不出的值走严格解析收口成 `Unknown` 并留痕。三条边界不再各写一份 trim/兜底分支;解析只发生在命令边界,`import_canvas_asset_at` 等内部函数直接接收 `GameCreationAppAssetKind`,不再接受字符串 kind。 - **登记内容的 kind 必须与内容相符**:`normalize_local_project_raster_resource_at` 的源 subtype 只接受图片族成员(`image / scene / character / character-animation / icon / icon-spritesheet / icon-spec / ui-design`)。未提供或空白时按没有类型信息落 `image`;音频、视频、文档、字体、代码等非图片族值,以及未知字符串,均明确报错,不再静默改成 `image`,以暴露调用方错误。派生资源写回 manifest 时不得主动写 `Unknown`:Agent 回执派生物是 markdown 文本,按 `Text => Document` 同口径落 `document`。 diff --git a/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md b/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md new file mode 100644 index 000000000..efdc07ffc --- /dev/null +++ b/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md @@ -0,0 +1,61 @@ +# 【技术方案】Tripo 3D模型 Provider 集成-2026-09-18 + +## 目标 + +本阶段在 `server-rs/crates/platform-tripo` 内接入固定 revision 的 `tripo3d-sdk`,完成 text-to-model、image-to-model 和 multiview-to-model 三个 3D 生成入口的 Rust provider adapter。三个入口共用同一个 `TripoProviderClient`,分别通过 `submit_text_to_model`、`submit_image_to_model`、`submit_multiview_to_model` 提交;client 另提供显式配置、通用单次 task 查询和模型下载能力。共享产品契约继续由 `shared-contracts` 管理,并始终生成目录化 ts-rs TypeScript binding。 + +代码按 API 目录组织:公共 SDK 配置、client、任务映射、错误、下载类型和 task 生命周期 DTO 位于 `platform-tripo/src/common/`;三个生成 API 各自拥有独立目录和结果类型。Rust 与 TypeScript contracts 按 `common/`、`text_to_model/`、`image_to_model/`、`multiview_to_model/` 分目录生成。 + +## 非目标 + +- 不接入 `api-server`、worker、SpacetimeDB、OSS 或计费;应用层接入由 [`【技术方案】Tripo 3D 生成 API 集成-2026-09-21`](./【技术方案】Tripo%203D生成API集成-2026-09-21.md) 承接,前端查看器已由 `packages/model3d-viewer` 独立实现,本阶段不接任何 UI 链路。 +- 不暴露 SDK 的 `wait_for_task`,不在 adapter 内启动后台轮询。 +- 不实现后处理、rig、animation、图片生成或其它 SDK endpoint。 + +## 边界与状态 + +`platform-tripo` 是唯一接触 `tripo3d-sdk` 的边界。它返回自己的 provider DTO,不让 SDK 类型穿透到未来的 `api-server`。Tripo 的 `success` 映射为 `completed`;`failed`/`banned` 映射为 `failed`;`cancelled` 和 `expired` 保留为独立终态;`unknown` 不在 SDK 的终态集合里,因此按「这次看不懂」处理,返回可重试的 `TripoError::TaskStatusUnknown`,由轮询侧继续查到截止时间 —— 判成终态失败不可恢复,而且 provider 以后新增状态时会把仍在跑的任务整批误杀。 + +provider task 结果与产品资源结果分离。provider adapter 不生成 `resourceId` 或 `assetId`;未来应用层在资源持久化后再构造带资源 ID 的产品 DTO。模型 URL 被视为临时 provider 引用,下载由显式方法完成。`TripoUrl` 的 `Debug` 与 `redacted()` 只输出 scheme、host 与 path,隐藏可能带签名的 query 与 fragment,并保留命中的分隔符(`?` 或 `#`),不把 fragment 显示成 query;带 userinfo 的地址在解析阶段直接拒绝;主机必须是公网地址,回环、内网、链路本地、组播与保留段(含云元数据地址 `169.254.169.254`)一律拒收,域名层面拦住 `localhost` 及其子域。域名解析出的真实地址在这一层看不见,域名指向内网的情况要靠连接层处理,属已知限制。完整 URL 仅通过 `as_str()` 显式读取;`TripoUrl` 不实现 `Display`,避免 `{}` 这类通用格式化把带签名的完整地址写进日志,冒烟示例同样只打印脱敏后的地址。 + +task 尚未完成时 `output` 为空;完成后 `output` 必须是与 task type 一致的 enum variant,不使用把不同 endpoint 字段揉在一起的通用可选字段结构。真实 provider smoke 确认:text-to-model 结果固定包含 `model_url`、`rendered_image_url`、`generated_image_url`;image-to-model 和 multiview-to-model 结果固定包含 `model_url`、`rendered_image_url`。这些字段在各自结果 struct 中均为必填 `TripoUrl`;缺失、URL 非法或 task type 不受支持时返回 `TripoError::OutputSchema`。 + +生成结果 DTO 中的 `poll_after_ms`、`updated_at_micros` 与 `size_bytes` 以 TypeScript `number` 导出:毫秒级轮询间隔与几十 MB 的产物字节数远低于 2^53,微秒时间戳在 2^53 内也可精确表示(约到公元 2255 年),因此不做 `bigint`/`string` 特殊处理;若将来出现超过 2^53 的取值再评估。 + +生成参数里的 seed(`image_seed`、`model_seed`、`texture_seed`)同样以 TypeScript `number` 导出,不导出成 `bigint`:请求最终是 JSON 文本,JSON 没有大整数类型,带 `bigint` 的请求体在 `JSON.stringify` 阶段就会直接抛错。代价是前端能精确表示的取值上限为 2^53-1(JS 安全整数),超出该范围的种子没法从 TS 侧精确构造;Rust 侧仍按 `i64` 反序列化,手工构造的请求可以传更大取值。 + +## 三个入口的输入 + +- text-to-model:`prompt` 必填,上限 1024 字符;`negative_prompt` 上限 255 字符。空白 prompt 在提交前拒绝。 +- image-to-model:`source` 是 `Model3dGenerationSource`,即带 `kind` 的站内引用 tagged enum(`resource { resourceId }` 或 `asset { assetId }`),不接受裸字符串、远程 URL 与 data URL。提交时只按 ID 定点校验归属与记录类型(跨 owner、未登记、类型不符统一 400),worker 执行时再从私有 OSS 读出字节、上传 provider 换回 `file_token` 后提交;provider 生成参数在 `generation` 里。 +- multiview-to-model:`inputs` 是带 `kind` 的 tagged enum,二选一——`views { front, left, back, right }` 或复用已有结果的 `taskId`。`views` 下 `front` 必填,其余视图至少再提供一张(少于两张视图直接拒绝);每个视图是 `Model3dViewInput`(`url` 或 `fileToken`,同样是带 `kind` 的 tagged enum),不接受裸字符串。取值空白(含纯空白)的视图按未提供处理,不会上传空文件;图片引用与视图地址都先去掉首尾空白再交给 provider,`taskId` 走统一的 task id 校验。线上 `inputs` 按 Tripo 文档推荐的 view-key 形态构造(`[{"front":{"url":…}},{"left":{"file_token":…}}]`):SDK 的 `MultiviewToModelParams::from_views` 只能发位置数组 `["<裸字符串>", "", …]`,服务端得按前缀猜那是 URL、file_token 还是 task_id,因此该字段由 `platform-tripo` 自己构造后经 `extra` 透传,种类由契约字段决定。 + +三个入口的 `model` 都必填,其余生成参数可选。SDK params 未命名的 `texture_version` 与 `delight`,由 image-to-model 和 multiview-to-model 通过 extra 字段透传。 + +TODO:图生输入当前由 api-server 自己读站内对象的字节再上传换 `file_token`,会多走一次字节中转与一份上行带宽;「让 Tripo 侧直接读站内对象」(交签名 URL,或对端直读 OSS)的**安全口径与可行性待讨论**,讨论前维持现口径。 + +## 请求与类型 + +三个生成请求使用官方文档已确认的 enum:模型版本、纹理版本、纹理质量(含 `fast`)、几何质量、纹理对齐、输入方向、导出方向和压缩类型(`geometry`)。文档未将 `style` 列为这三个 endpoint 的请求字段,因此不再保留占位 enum。`model` 在三个请求中都是必填字段(缺失或取值非法时在反序列化阶段即拒绝);除它以外的参数可选,在 Rust 侧为 `Option`,TypeScript 绑定对应 `field?: T | null`,调用方既可省略字段也可显式传 `null`。TypeScript 绑定仍按目录生成到 `packages/shared/src/contracts/model3d/`,并由该目录的 `index.ts` 与 `packages/shared` 的 barrel 统一再导出;生成命令 `npm run contracts:model3d:generate` 写入的是未格式化的 ts-rs 输出,需再执行 `prettier --write packages/shared/src/contracts/model3d` 后提交。 + +提交前由 provider 统一执行组合校验:模型版本决定 family;`fast` 必须配 `v3.5-20260815`;几何质量 / 压缩 / 自动尺寸 / P 系列与 H 系列能力按模型版本限制;`quad`、`generate_parts`、`smart_low_poly` 和 `texture/pbr` 组合按文档的互斥关系拒绝;`face_limit` 按模型、quad 和 smart-low-poly 模式检查范围。校验失败按原因分档:缺字段为 `Required`、参数组合互斥为 `InvalidCombination`、长度或范围越界为 `OutOfRange`。SDK 返回的参数错误仍统一归一为 `TripoError`。 + +契约分层:三个入口都导出 request 契约,`common/` 导出共享 enum;产品级结果契约(`Model3dGenerationSubmission`、`Model3dGenerationJob`、`Model3dGenerationResult`、`Model3dArtifact`)目前只服务 text-to-model。image-to-model 与 multiview-to-model 的完成结果仍停在 provider 层的 `TripoImageToModelResult`、`TripoMultiviewToModelResult`,等应用层真正做资源持久化时再决定是否提升为产品契约。 + +## 任务查询与下载 + +`get_task` 按 `TripoTaskHandle` 做单次查询,返回通用 `TripoTaskSnapshot`,由 `task_type` 决定 `output` 的具体 variant;adapter 不做轮询、不阻塞等待。单次查询内部带退避重试:生成本来要跑几分钟,一次连接抖动不该作废一笔已经提交、不能重来的计费任务,所以瞬时故障(连接抖动 / 超时、408、429、5xx)按 `TripoSettings::retries` 重试,确定性失败(任务 id 非法、响应结构不符、404)立即返回。 + +`download_model`(及预览图的 `download_rendered_image`)接受 `TripoTaskSnapshot` 与调用方给的体积上限 `max_bytes`,先确认快照确实处于完成态,再从严格 endpoint 结果中取得已校验的产物地址,由 provider 自己的无鉴权 reqwest client 打开签名 URL,读完整个 body 后返回 `TripoArtifactBytes`(`url`、`content_type`、`content_length`、`bytes`、`filename(name)`)。流式句柄 `TripoDownloadedArtifact`(`next_chunk()`)保留为内部实现:**重试必须包住整个 body** —— 只重试「拿到响应头」这一步的时候,几十 MB 的字节其实是在之后才传输的,一次 CDN 抖动就会毁掉一次已经扣费、provider 任务也跑完的生成。因此每次尝试都重新取响应头并整体重下(不做断点续传,签名地址会过期),body 读取失败与长度不一致都归一成可重试的传输错误,重试上限与退避沿用 `TripoSettings::retries`。**可重试性由产生错误的一方显式判定,不再由「`status` 是不是 `None`」推断**:传输层的超时 / 连接失败、「无数据推进」超时、body 提前结束都可重试;客户端构造失败、响应体格式错误(空 body / 缺 `data` 字段)、DNS 与 TLS 之外的永久失败不可重试 —— 只看 `status.is_none()` 会把这两类混在一起,把故障拖到重试耗尽才暴露。`filename(name)` 的扩展名来自远端地址,只接受短的 ASCII 字母数字,其余退回 `glb`,避免远端地址里的 `%2F` 解码后拼出跨目录路径。每次读取都校验实际接收字节数与 `Content-Length`,读到 `max_bytes` 之上直接按输出违约失败(不重试):宁可失败退款也不要把 api-server 内存打满。产物下载不设总超时:几十 MB 的流只要还在出数据就不该被判失败,重下只发生在传输真的断了的时候,因此下载链路只按「无数据推进」判超时,预算取 `TripoSettings::request_timeout`(连接用 `connect_timeout`,响应体用 `read_timeout`,响应头之前由显式的 `tokio::time::timeout` 兜住),超过预算没有数据推进即按传输失败返回;SDK 保持第三方原样,不承担产物下载。smoke example 一次写入完整字节;未来接入 OSS 时应把同一数据流直接送入 OSS 分片上传,不经过完整内存缓冲。 + +TODO:等待上游 `tripo-rust-sdk` 提供原生 artifact stream API 后,删除 provider-side reqwest 下载器,改由 SDK stream 直接承接。 + +## 验收 + +- `platform-tripo` 不公开 re-export SDK 类型或 `wait_for_task`。 +- 三个 3D 生成入口的 `submit_text_to_model` / `submit_image_to_model` / `submit_multiview_to_model`、通用 get task、按 endpoint 严格映射的完成结果和 provider 自有流式 download API 可编译,且共用同一 client 与同一套 `TripoError`。 +- 三个入口的输入校验可拒绝空白 `prompt` / `input`、视图不足两张和空白 `taskId`;组合校验在调用 SDK 前完成。 +- provider 错误统一为 adapter 错误类型。 +- shared contracts 的 ts-rs binding 无 feature 开关且始终可生成。 +- 产物下载只按「无数据推进」判超时:连接、响应头与每个数据块都必须有进展,慢速但持续的下载可完整收完,中途断流按可重试的传输错误失败并由下载入口整体重下。 +- 真实 Provider smoke 已覆盖三个入口;example 在任务未完成时跳过下载并继续跑后续入口,只打印脱敏后的结果结构和下载产物信息。 diff --git a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md new file mode 100644 index 000000000..dd0268c0f --- /dev/null +++ b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md @@ -0,0 +1,217 @@ +# 【技术方案】Tripo 3D 生成 API 集成-2026-09-21 + +更新时间:`2026-09-21` +文档状态:`current` +父规范:`docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md`(provider 层合同,本文件承接应用层) + +## 目标 + +在 `platform-tripo` provider 已就绪的前提下,把 Tripo 的 text-to-model 与 image-to-model 接入 api-server 内部 API:鉴权提交、异步执行、产物落 OSS 并登记为正式资源、按显式泥点定价真实扣费。 + +## 非目标 + +- 不接 multiview-to-model、Gaussian Splat、rig、animation、texture、convert 等其它 Tripo 能力。 +- 不进入 `/api/external/v1`,不改 External OpenAPI,不新增外部 scope 或 API Key 语义。 +- 不复用、不修改、不删除 `platform-hyper3d` 及其 `/api/assets/hyper3d/*` 路由与契约;本能力是全新 API,不是 Hyper3D 的 provider 替换。 +- 本后端里程碑不接前端与 `packages/model3d-viewer`;前端入口与结果预览分别由 [`【里程碑】Tripo生成前端入口-2026-09-21.md`](../project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md) 与 [`【实施计划】Tripo生成结果前端预览接入-2026-09-21.md`](../project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md) 承接。 +- 不提供取消能力:客户端停止查询,worker 继续推进到 provider 终态。 +- 本期不改 `platform-oss` 为流式或分片上传,沿用现有完整字节写入路径;流式上传作为后续 TODO。 + +## 参与入口与分层 + +| 入口 | 方法 | 鉴权 | 职责 | +| --- | --- | --- | --- | +| `/api/assets/tripo/text-to-model` | POST | Bearer | 校验、定价、入队,返回 operation | +| `/api/assets/tripo/image-to-model` | POST | Bearer | 校验、输入解析、定价、入队 | +| `/api/runtime/external-generation/jobs/{jobId}` | GET | Bearer | 复用现有任务查询,返回 operation 与阶段 / 终态(结果由画布 / 素材读回) | + +分层职责固定为: + +```text +api-server HTTP、鉴权、参数组合校验、泥点定价、入队、错误映射 +platform-tripo SDK 隔离、submit、单次 get_task、下载、provider 错误归一 +platform-oss / 资源登记 产物持久化与正式资源引用 +``` + +Tripo task 只以 checkpoint 形式存在于服务端,任何 API 都不返回 provider task ID、SDK 类型或带签名的临时 URL。 + +## 请求契约 + +两个端点的请求 = 现有 `shared-contracts::model3d` 请求 + 平台字段,全部经 ts-rs 目录化生成 TypeScript binding。 + +**图片输入**(仅 image-to-model)使用 tagged enum,服务端按分支做归属校验并解析 OSS 对象: + +```text +source = { kind: "resource", resourceId } | { kind: "asset", assetId } +``` + +不接受裸 `input` 字符串、任意远程 URL、data URL,也不接受同时给两个 ID。 + +服务端按 ID 定点确认归属、解析出对象键后,从私有 OSS 读出字节并**上传 provider 换 `file_token`**,再以显式 `file_token` 提交生成任务:不把带签名的临时地址交给第三方,也不依赖 bucket 对公网可达。签名地址会在异步排队期间过期,且等于把私有桶的临时读取权外泄,因此不作为输入通道。 + +引用解析分两步,提交与执行互不代替:**提交时只做元数据预检**——按 `source.kind` 的分支做定点归属校验,确认记录类型与对象键存在,不读图片正文、不调用 provider,因此跨 owner、未登记、已删除与 `kind` 不符的引用都在扣费与入队之前返回 400;跨 owner、未登记与已删除对外收敛成同一句不可用,不泄漏他人 ID 是否存在,`kind` 与解析结果不符则单独报错(两个值都由调用方给出,不涉及探测他人数据);**worker 执行时重新确认同一事实**,通过后才读一次图片正文并上传换 `file_token`。预检只按主键定点查引用,不允许拉取当前用户的完整工程列表或素材库。 + +**结果落点**(两个端点至少给一个)与其它生成接口同形,是平坦的可选字段,不用 tagged enum;客户端不必为“落项目还是落素材库”多拼一层判别结构: + +```text +projectId? 项目资源落点 +assetFolderId? 素材库落点 +assetLabel? 素材名称,缺省用平台默认名 +canvasCompletion? 画布占位框回填,只在项目资源落点下生效 +``` + +`projectId` 与 `assetFolderId` 至少给一个,并且允许同时给:两个都不给直接 400;同时给时画布资源行与素材行两条都写;只给 `assetFolderId` 时结果只落素材库,不要求调用方先有画布工程。以上三条与图片等画布生成工具完全一致(画布链路本来就会把“当前素材夹”一起发出去,见下)。`assetLabel` 只在素材库落点下生效,缺省用本次结果标题;`canvasCompletion` 是项目资源落点的回填载荷,“没有 `projectId` 却带占位框”被拒绝。不存在“都不给就默认落素材库”的口径。 + +落点预检与其它付费编辑器生成共用同一条路径:提交时调只读 `preflight_editor_generation_target_and_return`,按认证 owner 对给出的落点逐个定点归属校验(两个都给就两个都查,不再为了一个目录 ID 读整个素材库),跨 owner / 已删除 / 不存在收敛成同一句 400。 + +**归一在预检之前完成并写回请求**:项目 ID 与素材夹 ID 都 trim,`project` / 旧 `folder-*` 走与图片画布同一个 `normalize_generated_asset_folder_id` 映射到当前 owner 的默认素材夹,素材名走同一个 `resolve_editor_generated_asset_label`(trim + 截断 + 缺省「3D 模型」)。入队的就是归一后的请求,因此预检值 = 队列载荷 = 落库值;模块侧落库原有的 `normalize_editor_generation_default_asset_folders` 继续兜底老队列载荷里的 `project`。 + +**定价相关参数在 API 层做组合校验**:`texture`、`quad`、`smartLowPoly`、`generateParts` 必须显式给出;`geometryQuality` 与 `textureQuality` 允许缺省,缺省按 `standard` 计价 —— provider 对 v2.5 不支持 `geometry_quality`、并拒绝任何 `texture_quality`,v3.x 的缺省档也是 `standard`,两边同价,因此只有显式 `detailed` / `extreme` 才命中 `hdGeometry` / `hdTexture` / `ultraTexture` 加价(把这两个字段写死必填只会让定价表里已有的 v2.5 / P1 / P2 永远不可达,或让 `texture=true` 的 v2.5 无论填什么都被 provider 预检拒绝)。`texture=false` 时禁止出现 `textureQuality`,且 `pbr` 必须显式 `false`。**provider 预检只拒绝会启用能力的取值**:`geometry_quality` 对非 v3.1 / v3.0 家族只在取值 `detailed` 时拒绝,`standard` 放行;并且 `platform-tripo` 的请求映射会把「不支持的家族 + standard」收敛成「不发该字段」——不支持的家族传 `standard` 与不传等价(provider 默认档就是它,定价也同价),发出去只会换来「不支持该字段」的整单拒绝。 +其余会改价的参数仍必须显式给出:provider 的隐式默认值会直接改变价格,一旦依赖默认值,报价与扣费会在“调用方少传字段”时分叉。 + +两个 submit 都必须携带 `Idempotency-Key`,复用现有头部校验;缺失或格式非法直接 400,不静默生成键。 + +## 异步执行与状态 + +本能力复用现有 `external_generation_job` 队列、worker、租约与 owner 过滤,不新建平行队列、不新增状态枚举、不新增查询接口。状态对外仍是 `queued` / `running` / `completed` / `failed`,阶段提示沿用现有 `phase` 与 `phaseLabel` / `phaseDetail`。 + +**at-most-once submit** 是本能力与其它生成任务的关键差异。其它生成任务的崩溃恢复模型是“租约过期后由别的 worker 重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。Tripo job 固定 `max_attempts = 1`,任何 attempt 失败都是终态失败,崩溃后租约耗尽同样直接终态失败、不会被重新 claim,因此不存在“重新 claim 后继续查询”的续跑路径 —— checkpoint 是 at-most-once 的硬约束与人工对账凭据,不是续跑能力。因此: + +1. 没有 `providerTaskId` 的 job 才允许 submit; +2. submit 成功后必须先把 `providerTaskId` 写回 checkpoint,再进入轮询; +3. 已有 `providerTaskId` 的 job 只允许 `get_task`、下载与落库,绝不允许再次 submit; +4. submit 成功但 checkpoint 写入失败的 attempt 只能终态失败,不得退回可重试队列,否则重试会二次 submit;该 job 保留脱敏对账信息供人工处理。 + +**崩溃窗口(已知并接受,消不掉)**:submit 的真实顺序是「先向 provider 提交(不可撤销、已计费)→ 再把 provider task id 落成 checkpoint」,两步之间进程崩溃时,库里没有任何记录能证明这次提交发生过。这个窗口不能靠加判断消掉——写 checkpoint 这一步本身也可能失败或崩溃,多加一层只会把窗口挪个位置。当前的口径是:Tripo job 固定 `max_attempts = 1`,租约耗尽的 job 直接终态失败、不会被重新 claim,所以**不会二次 submit、不会二次计费**;代价是这一笔 provider 侧已经跑起来的生成没有结果可用(按失败收口并退款),而且连 provider task id 都没留下,人工对账也缺凭据,净亏损由平台承担。因此「重新 claim 一定能读到已提交记录」只在 checkpoint 已经写下之后成立,不能当成从 submit 那一刻起的保证;要缩小窗口只能改 provider 侧(让提交本身可重放,或先申请再提交这类两阶段流程),不在本方案的范围内。 + +失败重试沿用现有语义:`fail` 先按 attempt 冲正扣费,`attempt < maxAttempts` 时回到 `pending` 并延迟重试,否则终态 `failed`。Tripo job 固定 `max_attempts = 1`,这条路径退化为“第一次失败即终态”,永不进入延迟重试。 + +**provider 成功但落库失败按失败处理**:job 失败、该 attempt 退款,客户端看到 failed。没有正式资源引用就不算交付成功;checkpoint 保留,人工对账可证明这次生成确实发生过。由此产生的“provider 已消耗、用户已退款”净亏损是已知并接受的成本。 + +**完成结果使用按端点的严格 tagged enum**,不使用由多个可选字段拼成的宽松结果: + +```text +TextToModelResult = { modelArtifact, renderedPreview } +ImageToModelResult = { modelArtifact, renderedPreview } +``` + +每个 artifact 只携带正式资源引用与对象元数据,不携带 provider 临时 URL。 + +**完成结果的读取**:与其它画布生成任务同形——结果落在画布 / 素材行,客户端靠读回拿到(没有 `projectId` 时只有素材行可读回,与其它画布工具在无工程时的行为一致)。项目资源落点的 worker 写画布 placement(预览图层),客户端在任务终态后用 `loadEditorProject` 复读项目快照,按 `layer.objectKey` 打开模型;素材库落点写素材行(画布链路两个落点都发,所以一次 3D 生成通常两条都写)。结果里的落点引用与请求落点同形:`target` 不再是被判别结构包着的分支,而是平坦的 `resourceId` / `assetId`,哪个落点写了行就带哪个 ID。前端画布链路的结果消费沿用既有画布路径(项目快照 + `applyGeneratedProjectSnapshot` 顺带刷新素材库),素材库行的 3D 缩略图按预览图渲染、不拿模型对象换签名地址。标准消费者的队列结果只回来源身份,**不透传 `result`**,因此 `/api/runtime/external-generation/jobs/{jobId}` 的 `result` 对 3D 为空——图片等其它画布生成任务也是如此。上面那份严格 tagged enum 是 worker 落库时构造的内部结果值:它只携带正式资源引用与对象元数据,不携带 provider 事实。 + +## 定价与扣费 + +定价是**真实扣费**,不是计量占位。Tripo 的计费形态是“底价 + 可叠加加价项”,现有图片/视频那套“模型 → 档位 → 单价”查表表达不了,因此在同一份定价配置中另立 `model3d` 段,按端点拆成两段并列设置,全部以泥点计价: + +```json +"model3d": { + "textToModelPricing": { + "versionPrices": { "": { "noTexture": 泥点, "texture": 泥点 } }, + "addOnPrices": { "hdTexture": 泥点, "ultraTexture": 泥点, "hdGeometry": 泥点, "quadMesh": 泥点, "smartLowPoly": 泥点, "generateParts": 泥点 } + }, + "imageToModelPricing": { + "versionPrices": { "": { "noTexture": 泥点, "texture": 泥点 } }, + "addOnPrices": { "hdTexture": 泥点, "ultraTexture": 泥点, "hdGeometry": 泥点, "quadMesh": 泥点, "smartLowPoly": 泥点, "generateParts": 泥点 } + } +} +``` + +模型版本键使用 `Model3dModelVersion` 的枚举值,不另造字符串。两段各自持有自己的加价项价目(当前数值相同),因此同一加价项在不同端点可以不同价。加价项由请求参数判定,判定规则固定为: + +```text +hdTexture ← texture=true && textureQuality=detailed +ultraTexture ← texture=true && textureQuality=extreme +hdGeometry ← geometryQuality=detailed +quadMesh ← quad=true +smartLowPoly ← smartLowPoly=true +generateParts ← generateParts=true +textureQuality=fast | standard → 不给任何贴图加价 +``` + +最终价格: + +```text +price = <端点段>.versionPrices[modelVersion][texture ? "texture" : "noTexture"] + + Σ <端点段>.addOnPrices[命中的加价项] +``` + +两条硬约束: + +1. **加载即校验**:两段各自强校验——每段的每个受支持模型版本 × 两种贴图态与全部六个加价项键必须同时存在,缺一即该段非法、服务拒绝加载;段内不再写 `unit`,3D 生成恒为按次计价。 +2. **提交即拒绝**:价格计算发生在调用 provider 之前;拿不到价格就不提交、不扣费、不入队。 + +泥点数值已确认并写入受控默认配置 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`。换算口径固定为 **泥点 = `ceil(0.8 × Tripo 官方 credit)`**,credit 取自 Tripo 官方价目表的 H 系列与 P 系列:文生 3D 的 H 系列 `10 / 20`、P1 `30 / 40`、P2 `100 / 110`,图生 3D 的 H 系列 `20 / 30`、P1 `40 / 50`、P2 `100 / 110`(依次为无贴图 / 标准贴图),六个 add-on 为 `HD Texture 10`、`8K Ultra Texture 20`、`HD Geometry 20`、`Quad Mesh 5`、`Smart Low-poly 10`、`Generate Parts 20`。`editor_generation_config` 的定向用例按这张 credit 表反算默认泥点价,改价必须先改 credit 表,避免默认值脱离换算口径。 + +`model3d` 段缺失仍是合法形态:缺失即 3D 定价未配置,提交被拒绝(fail closed),既不扣费也不调用 provider;段存在时走上面的强校验路径。 + +2026-09-23 起这两段的事实源是 SpacetimeDB `editor_generation_pricing_config` 的两段强类型列,后台「模型定价」页面直接编辑,保存与 `models` 段在同一事务内生效,改价不再需要改文件或重启进程。读取方向按契约归一化:表内缺键用默认 JSON / 覆盖文件同段值补齐,契约之外的遗留键剔除并告警;写入方向要求两段完整合法。后台保存是整段覆盖、不做版本比对(后台同时只有一个管理员在操作,2026-09-24 起删除版本号与 409)。公开读模型 `GET /api/editor/generation-pricing` 的 3D 段形状保持不变,画布 3D 入口的价格真相与前端零兜底口径不变。详见 [ADR 0005](../adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md) 与 [编辑器模型定价配置管理方案](../【编辑器】模型定价配置管理方案-2026-06-22.md)。 + +扣费依据固定为**提交时定价**:用户在提交前就能得到确定价格,失败退款是单次冲正,不需要按实际消耗退差额。provider 返回的实际消耗只写日志用于成本对账,不进入契约、不进 API 响应、不进资源行。 + +价格随 job 写入 `price_mud_points`,扣费与冲正沿用现有资产操作账务(attempt 级 consume / refund ledger);素材侧继续用 `generation_cost_mud_points` 记录单次成本。同一 `Idempotency-Key` 命中已有 job 时直接返回原 operation,不新建、不重复扣费。 + +## 数据与契约 + +**队列表**:`external_generation_job` 追加 provider checkpoint 字段(`providerKind`、`providerTaskId`),字段追加在结构体末尾并给显式默认值;新增一个受租约栅栏保护的写入 procedure,供 worker 在 submit 成功后落 checkpoint。checkpoint 不写入 `request_payload_json`,因为该字段是请求真相,参与请求指纹与压缩逻辑。 + +**资源表不新增列**:`EditorAsset` / `EditorProjectResource` 复用既有字段表达 3D 结果: + +```text +assetKind = "model3d" +objectKey = 模型对象 +imageSrc = 预览图稳定引用(有预览才填;没有预览时留空,不拿模型对象顶封面) +thumbnailSrc = 预览图 +width/height = 预览图像素尺寸 +``` + +模型格式与体积由 `AssetObject` 承担(`content_type`、`content_length`、`content_hash`),因此不新增 `model_format`、`size_bytes`、`poly_count` 等列。两个槽位的 `content_type` 都在写入前按字节识别真实产物:模型按魔数认 GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary` 头与 glTF 的 JSON 正文,预览按魔数认 PNG / JPEG / WebP;不直接采信 provider 返回或下载响应头声明的类型,对象键扩展名由该内容类型派生。只有字节判不出来时才采信声明值,且声明值必须落在该槽位的白名单内(模型:`model/gltf-binary`、`model/gltf+json`、`model/fbx`、`application/x-fbx`;预览:`image/png`、`image/jpeg`、`image/jpg`、`image/webp`);两边都对不上就直接按上游内容不合法失败并退款,不做任何兜底 —— 放行 `text/plain` 这类未知类型只会落成「错的 content type + 拼出来的扩展名」。客户端不复制这份格式清单:画布只按「`assetKind=model3d` 且 `objectKey` 非空」放行,能否交给 3D 查看器由 `packages/model3d-viewer` 判定,判据依次是读接口声明的 `Content-Type`、字节魔数、地址扩展名,全判不出来即按 `unsupported-format` 报错。这里的顺序是「信任后端检测结果」:`content_type` 在后端写入前已经按字节核过,所以客户端以声明为先;字节魔数只兜住声明缺失或认不出来(例如 `application/octet-stream`)的情况,不负责纠正一个「认得出来但不对」的声明。 + +产物对象由 api-server 按固定前缀 PUT 写入、再 HEAD 复核后才登记;PUT 带退避重试(最多 3 次尝试,退避 250 / 500 ms),只对传输 / 超时 / 408 / 429 / 5xx 重试,确定性 4xx 直接失败。3D job 最多只尝试一次,一次瞬时网络失败就会废掉一笔已经计费的生成,所以这条链路上的每个网络动作都要能重试。反过来,产物一旦上传成功、后续预览图上传或登记失败,就把已经传上去的对象删掉(删除失败只记警告,残留对象按日志人工清理):不接受“失败就烂在 bucket 里”。 + +`external_generation_job.phase` 的取值集合不变,仍只允许现有两种执行阶段;阶段文案由 api-server 映射,不扩展 schema 常量。 + +## assetKind 接入清单对照 + +对照 [`【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 的「新增编辑器 `assetKind` 接入清单」,`model3d` 现已接图片画布的前端预览入口(工具条「3D 预览」+ 查看器模态,实施计划见 [`【实施计划】Tripo生成结果前端预览接入-2026-09-21.md`](../project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md)),但除画布预览外的前端面与后台面仍未进入。下表逐行登记本期口径。 + +| 清单触碰点 | 本期口径 | +| --- | --- | +| 资源 / 素材 `assetKind` 与 upsert 参数 | `assetKind = "model3d"` 由 worker 在服务端写入,调用方无法指定;Rust 侧 `asset_kind` 是自由字符串,不动共享契约的闭集枚举 | +| schema 与 migration | 不新增类别专属列,复用 `imageSrc` / `thumbnailSrc` / `AssetObject` 元数据;无 `model3d` 存量数据,不写数据迁移 | +| 原子落库 `operation_kind` 白名单 | **必须**把两个 Tripo job kind 加入 `EDITOR_GENERATION_OPERATION_KINDS`,否则 `persist_editor_generation_result_and_return` 整笔事务被拒;这是本期唯一必须改的白名单 | +| 画布回填 | `projectResource` 的 `canvasCompletion` 只走 placement-only(`build_editor_canvas_resource_placement_item`),layer 只拿 `layerId` / `resourceId`,`assetKind`、`src`、`objectKey` 全部归资源所有;`model3d` 已按「白名单与前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 同步」的口径加入 `EDITOR_CANVAS_ASSET_KINDS` | +| 用户标签覆盖资格 | 允许,但只在 `model3d` 与空类别之间成立:3D 资源自成一族媒体语义,图片族不能覆盖成 3D,反之亦然 | +| 快速编辑资格 | 不允许。3D 结果没有图片快速编辑语义,不进入快速编辑正向白名单;画布上「3D 预览」是只读查看能力,不是快速编辑 | +| “改造” capability 与 V2 配方 | 不进入改造 allowlist,不定义 V2 `action` / `fields[].id` / `references[].id`,不发 `canvasCompletion` 之外的可重放配方;画布对 `model3d` 关闭改造入口 | +| 素材库 / 后台 / 精选 read model / 公开 grant / External OpenAPI | 素材库行已按 `assetKind` 派生 renderer 的口径接上 `model3d` 分支(缩略图走预览图,模型本体不进图片入口);后台列表、精选 read model、公开 grant 与 External v1 仍不涉及,只保证字段正确 | + +清单第 3 条要求的“用户标签覆盖”与“快速编辑”两项资格评估互不推导:标签覆盖进入(限 `model3d` 与空类别互转),快速编辑与改造均不进入,理由是 3D 资源没有像素级编辑语义。画布入口只新增只读的「3D 预览」capability 位,由宿主按 `supportedActions` 声明可用;素材库行已接预览渲染与拖入画布,后台 renderer 与精选 read model 仍未接,后续进入时必须单独补齐 renderer 与验收。 + +## 兼容与迁移 + +- 旧 `/api/assets/hyper3d/*` 与 `platform-hyper3d` 保持原样,不因本能力上线而改变行为,也不做 provider 静默切换。 +- schema 变更必须同步 `migration.rs`、表目录、生成绑定,并运行 `npm run check:spacetime-schema`。 +- 新契约继续由 Rust `ts-rs` 目录化生成到 `packages/shared/src/contracts/model3d/`,不手写 TypeScript。 + +## 验收标准 + +1. 两个 submit 路由要求 Bearer 与 `Idempotency-Key`;缺头或非法头返回 400,且不产生 job、不扣费。 +2. 同一 `Idempotency-Key` 重复提交返回同一 operation,钱包只有一条扣费流水。 +3. 定价配置缺任一底价键时服务启动失败;请求命中 add-on 后扣费等于底价加全部命中 add-on;`fast` 与 `standard` 不加价。 +4. 组合校验前置于 provider 副作用:`texture=false` + `textureQuality`、`generateParts=true` 与 `texture=true` 的同现请求被拒绝且不扣费。 +5. image-to-model 的 `source` 元数据预检前置于扣费与入队:跨 owner、未登记、已删除与 `kind` 不符的引用返回 400,不产生 operation、不扣费,也不把图片正文读进内存或调用 provider。 +6. Tripo job 固定 `max_attempts = 1`:崩溃后租约耗尽即终态失败、不重新 claim,不存在续跑路径;同时任何带 checkpoint 的执行路径都只轮询、下载与落库,绝不二次 submit(防御性硬约束,不依赖配置保证)。 +7. submit 成功但 checkpoint 写入失败的 attempt 终态失败且不自动重提。 +8. provider 成功但落库失败时 job 失败、该 attempt 退款,checkpoint 保留供对账。 +9. worker 落库时构造的完成结果按端点严格类型化,只含模型与预览的正式资源引用,不含 provider task ID 与带签名的临时 URL;标准消费者的队列结果不透传它(与其它画布生成任务一致,结果靠画布 / 素材读回)。 +10. 素材库落点产出 `assetId`;项目资源落点产出 `resourceId`,且 `assetKind=model3d`、模型与预览的 `AssetObject.content_type` 都是按字节识别出的 mime(预览不再一律写成 `image/webp`)、`content_length` 与实际对象一致。 +11. 客户端媒体投影对 `model3d` 只暴露预览图:`imageSrc` / `thumbnailSrc` 指向预览对象、`objectKey` 指向模型本体;预览缺失时 `imageSrc` 留空(消费方显示占位图),不得回落成模型对象——那只会让图片入口去加载 `.glb`。 +12. 格式判定的顺序在查看器包内可被用例钉住:声明 `Content-Type` 优先于字节魔数、字节魔数优先于地址扩展名;三者都判不出来时模态展示 `unsupported-format` 原因与支持的格式清单,画布侧不做任何格式判断、也不在 `assetKind` 为空时按扩展名兜底。 +13. 真实 Provider smoke 覆盖 text-to-model 与 image-to-model 各一次,下载字节数与 `Content-Length` 一致。 +14. 定向测试、`cargo check`、`npm run check:spacetime-schema`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 全部通过。 + +## 未决问题 + +- 加价项已按端点各持一份价目(当前数值相同);若 Tripo 后续对某个模型版本差异化加价,再补“按模型版本覆盖”一层,现在不预留。 +- 3D 模型的多文件 glTF 兄弟资源(`.bin`、贴图)、多视角与模型信息展示仍未安排;画布侧接入见 [`【实施计划】Tripo生成结果前端预览接入-2026-09-21.md`](../project-memory/plans/【实施计划】Tripo生成结果前端预览接入-2026-09-21.md)。 +- AGC 项目画布自带两份格式判断(`resourceModelScene.ts` 的 `isResourceModelFbx` 把 `octet-stream` 当 FBX、`resourceCardPreviewModel.ts` 的资源卡预览按扩展名正则选解析器),本期不动,后续统一收敛到 `packages/model3d-viewer`;后台 renderer、精选 read model 与公开 grant 的 3D 展示同样未进入(素材库行只做预览图渲染与拖入画布,行内不开 3D 查看器)。 diff --git a/docs/【协作规范】文档生命周期与现状索引-2026-09-12.md b/docs/【协作规范】文档生命周期与现状索引-2026-09-12.md index 7cd878636..f6e2be480 100644 --- a/docs/【协作规范】文档生命周期与现状索引-2026-09-12.md +++ b/docs/【协作规范】文档生命周期与现状索引-2026-09-12.md @@ -84,7 +84,7 @@ - `docs/project-memory/todos/【待评估】UI编辑器字体跨运行时像素一致性-2026-08-19.md` - `docs/project-memory/todos/【已知问题】AppConfig与AppState调试输出敏感配置泄漏-Master遗留-2026-08-07.md` -当前没有活动计划;新任务按 SDD 规则在 `docs/project-memory/plans/` 建立计划,完成、取消或合并后删除。 +活动计划见 `docs/project-memory/plans/`;新任务按 SDD 规则在该目录建立里程碑规范与实现计划,完成、取消或合并后删除。 ## 下一轮复核顺序 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 4f61f7f79..879e1c289 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -294,6 +294,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 2026-08-06 原子提交对上述 source-only / slice 条款的修正:“provider 原图已持久化”只能解释为 OSS 对象已上传并验证,不再表示 project resource / account asset 已先行入库。source-only、透明整图和成功切片必须先完成最终选择,再作为一份 prepared commit 连同 canvas/job/receipt 一次提交;未选中或失败分支不得留下正式 resource / asset 部分记录。 +- 2026-09-21 provider checkpoint 追加:主表末尾追加可选 `provider_kind` / `provider_task_id`,只服务外部 provider 异步任务的 at-most-once submit。`set_external_generation_job_provider_checkpoint_and_return` 复用与 phase 完全相同的 `job_id + worker_id + lease_token` running 租约栅栏,写入成功时追加一条 `provider_checkpoint` 事件;目标行已有 checkpoint 时直接拒绝覆盖,因此崩溃后重新 claim 的 attempt 只能读到既有 provider task id 继续查询与落库,不会二次 submit、二次扣费。这条保证的前提是 **checkpoint 已经写下**:submit 已经被 provider 受理、但 checkpoint 还没落库时进程崩溃,库里就没有任何 provider 侧记录,这个窗口消不掉(写记录这一步本身也可能失败或崩溃),具体代价与当前应对见 `technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md` 的「at-most-once submit」段。checkpoint 不写入 `request_payload_json`,也不进入任何用户可见 read model、BFF 响应或公开契约,provider task id 只作服务端 checkpoint 与对账;`external_generation_job_summary` 不投影这两个字段,轻量摘要按无 checkpoint 返回。 + ### `external_generation_job_summary` - Rust 结构体:`ExternalGenerationJobSummary` @@ -309,6 +311,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 用途:外部生成任务审计事件表,按 `job_id` 和 `owner_user_id` 记录 `enqueued`、`claimed`、`lease_renewed`、`completed`、`failed`、`acknowledged` 等状态转换事实。状态转换只能由 SpacetimeDB procedure 写入,不由前端或 worker 直接改表;该表用于追溯任务生命周期和排障,不替代 `external_generation_job` 当前状态。 - 保留策略:事件只会随已确认通知的终态任务由 `prune_external_generation_job_history_and_return` 原子删除,不支持按事件单独清理,以保持任务、摘要和审计链一致。单次事务最多删除 256 条事件;若事件未删完,任务和摘要暂不删除,维护脚本用同一个 job cursor 重试剩余事件。 +- 2026-09-21 事件类型追加:provider 异步任务在 submit 成功后落 checkpoint 时追加一条 `provider_checkpoint` 事件,记录 provider task id 与写入 worker;该事件只作服务端追溯与对账,不进入用户可见 read model。 + ### `ai_text_chunk` - Rust 结构体:`AiTextChunk` @@ -403,6 +407,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - `assetKind` 是数据库、Rust DTO 和对外 JSON 的可选语义类别真相;普通静态图片必须为空。不要新增或返回并列的 `mediaType`;前端 renderer 可以保留内部派生媒体类型,但不得回写后端。 - 每次新增 `assetKind` 都必须分别完成“用户标签覆盖”和“快速编辑”两项资格评估;两者互不推导。可被用户选择不等于可快速编辑,属于图片媒体族也不等于自动进入快速编辑正向白名单。 - `assetKind` 只描述素材类别,不代表生成配方可执行或允许“改造”;新增类别必须单独完成上表的 capability 决策和验收。 +- 编辑器前端暂无入口的后端生成类别同样适用本清单:四项资格评估、原子落库 `operation_kind` 白名单和画布回填口径都必须在对应技术方案里显式登记,未登记即视为未接入,不能靠“前端还没入口”跳过。生成类结果落库前 `EDITOR_GENERATION_OPERATION_KINDS` 必须已包含该 job kind,否则整笔事务被拒;该白名单常量位于 `shared-contracts`(`shared_contracts::EDITOR_GENERATION_OPERATION_KINDS`),入队方与 SpacetimeDB 原子落库事务共用同一份清单,新增生成类型只改这一处;画布回填只允许走 placement-only 口径(`build_editor_canvas_resource_placement_item`)。`EDITOR_CANVAS_ASSET_KINDS` 必须与前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 同步:前端已有标签入口而白名单缺项、或白名单多出前端不认的类别,都算接入未完成。Tripo 3D 的 `model3d` 已按该口径进入两端(见 `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md`),其素材库行与后台 renderer 仍未进入。 - 只是新增分类或 renderer 且现有媒体字段足够时,不改 schema。只有必须跨刷新、复用、审核或公开保留的数据才新增类别专属字段。 - legacy 数据必须先通过有界、可审计、带 dry-run/hash/apply 门禁的数据库迁移收口;迁移后的 api-server、mapper、主站、后台和画布只读取正式字段,不保留运行时 fallback。 @@ -639,7 +644,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorGenerationPricingConfig` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global`。`models: Vec` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 只保留在 private 表内,记录首次初始化的真实 `ctx.sender()`,不进入 procedure 返回快照;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时只有 HTTP 角色通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;worker / controller 启动只调用受鉴权的 queue-stats procedure 做只读身份预检。后台保存请求携带 `AppConfig` 中的受保护 bootstrap secret,以便配置行意外缺失时原子恢复;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256,procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,当前生产 API / worker / controller 因此继承同一 runtime token;迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。 +- - 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global`。`models: Vec` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 只保留在 private 表内,记录首次初始化的真实 `ctx.sender()`,不进入 procedure 返回快照;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时只有 HTTP 角色通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;worker / controller 启动只调用受鉴权的 queue-stats procedure 做只读身份预检。后台保存请求携带 `AppConfig` 中的受保护 bootstrap secret,以便配置行意外缺失时原子恢复;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256,procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,当前生产 API / worker / controller 因此继承同一 runtime token;迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。 3D 生成定价按端点拆成 `text_to_model_pricing` / `image_to_model_pricing` 两列(追加在结构体末尾并带显式默认值),列内同样展平为强类型行:版本键一行含无贴图 / 带贴图两档价,加价项一行一条,不在一段里用端点做判别键;两段各自要求全版本 × 两档价与六个加价项键齐全。api-server 读取时按契约归一化——表内缺键用默认 JSON / 覆盖文件同段值补齐,契约之外的遗留键剔除并告警,只有归一化后仍不合法才判非法;后台保存要求两段完整合法并与 `models` 同事务写入。`EditorGenerationPricingConfigUpsertInput` 不带版本字段:后台保存不做版本比对,请求里的两段与 `models` 整段覆盖即最终状态(后台定价同时只有一个管理员在操作,不引入版本号与拦截)。`api-server` 侧替换进程内缓存只做缓存刷新,不做版本校验、也不是拦截点。公开读模型 `GET /api/editor/generation-pricing` 的 3D 段仍是既有的按端点判别投影,不随存储形状变化。 - 索引:主键 `config_id`。 ### `editor_generation_operation` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 2bf1b55cf..db9aca2f5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -113,13 +113,13 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg 开发态 `npm run dev` 与 `npm run dev:api-server` 都会注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号。完整 `npm run dev` 会强制父 API 使用 `GENARRATIVE_PROCESS_ROLE=all`,忽略外层显式角色,确保本地 `api-server` 同时监听 HTTP 并消费外部生成队列;只有单模块 `npm run dev:api-server` 会保留显式 `GENARRATIVE_PROCESS_ROLE`,未设置时默认为 `all`。`all` 不内嵌 BgFilter worker;启动器总是先启动并验活独立 `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程,再启动父 API,并向两者注入同一个内部 base URL / Token。Linux 本地默认 `all` 角色启动前,dev 脚本会停止当前仓库、同一个 SpacetimeDB server / database 下遗留的 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程,避免旧 worker 二进制继续抢同一条队列并在业务写回时制造 procedure 超时;显式拆分 `api` / `external-generation-worker` 做生产式验证时不会触发这项清理。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。 -本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前 worker 只领取 `source_module = editor-canvas` 的图片画布任务,包括 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation`。非 `editor-canvas` 的 source module 任务即使仍为 pending / running 也不领取、不改状态;显式把本地进程角色设为 `api` 且没有 worker 时,现役编辑器生成请求只返回 queued/running,不会兜底执行外部 provider。 +本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前 worker 只领取 `source_module = editor-canvas` 的图片画布任务与 Tripo 3D 生成任务,包括 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 、`editor_background_music_generation`、`model3d_text_to_model` 和 `model3d_image_to_model`。非 `editor-canvas` 的 source module 任务即使仍为 pending / running 也不领取、不改状态;显式把本地进程角色设为 `api` 且没有 worker 时,现役编辑器生成请求只返回 queued/running,不会兜底执行外部 provider。 HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer 调用池大小;池连接不订阅 read model。HTTP 角色会额外创建 1 条共享缓存读连接,当前只保留可选的 `user_account` 读取;配置为 `8` 时基础连接拓扑是 8 条调用连接加 1 条缓存读连接。`/readyz` 同时检查调用池与缓存读连接,缓存连接未准备好时不能放量。 生产拆分角色时,`external-generation-worker` 和 `external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色不创建 API 缓存读连接,只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure。worker / controller 不执行模型定价 seed,启动时先调用受 runtime writer 鉴权的 queue-stats procedure 做只读预检,身份不匹配时 fail-fast;当前正式 systemd unit 通过共同加载 API env 继承同一 `GENARRATIVE_SPACETIME_TOKEN`,默认路径为 `/etc/genarrative/api-server.env`,自定义部署由 provision 和 API deploy 按实际参数渲染,专属角色 env 示例不重复配置该 token。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。 -生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`。`editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction` 四类 VectorEngine 图片任务与角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`,手动去背景、音效和背景音乐继续使用普通预算。worker 在单次尝试超过执行预算后会停止续租,但不会取消已启动的业务 future 或主动写入失败 / 重试状态;执行许可会一直绑定到 active 或 detached work 真正结束(或超过租约仲裁窗口被取消),避免超时任务脱管后立即补进新的高内存任务。在途执行由 lease fencing 仲裁,有效租约内写回仍可完成,租约过期后任务才可重新领取,attempt 耗尽时由认领事务标记失败并结算退款。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env` 与 `/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`。 +生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`。`editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction` 四类 VectorEngine 图片任务与角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`,手动去背景、音效和背景音乐继续使用普通预算。Tripo 3D 的 `model3d_text_to_model` 与 `model3d_image_to_model` 也使用同一长任务预算:两个 job kind 都走 `source_module = editor-canvas` 的既有队列,在单次 attempt 内轮询 provider 终态、下载模型与预览后落库,期间持续占用一个 worker 并发位。worker 在单次尝试超过执行预算后会停止续租,但不会取消已启动的业务 future 或主动写入失败 / 重试状态;执行许可会一直绑定到 active 或 detached work 真正结束(或超过租约仲裁窗口被取消),避免超时任务脱管后立即补进新的高内存任务。在途执行由 lease fencing 仲裁,有效租约内写回仍可完成,租约过期后任务才可重新领取,attempt 耗尽时由认领事务标记失败并结算退款。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env` 与 `/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`。 lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。 @@ -242,6 +242,7 @@ spacetime sql "SELECT * FROM runtime_setting LIMIT 1" --server http:/ 本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine 配置仅保留给 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 Tiantoken provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 编辑器 ElevenLabs 音效生成只从服务端读取 `ELEVENLABS_BASE_URL`、`ELEVENLABS_API_KEY` 和 `ELEVENLABS_REQUEST_TIMEOUT_MS`,timeout 默认 `180000ms`;base URL 或 Key 缺失时失败关闭,不回退 Vidu。生产 API 与 external-generation worker 通过共享 API env 取得同一配置,模板见 `deploy/env/api-server.env.example`;Key 不得进入 Web/Vite 环境、命令参数、日志、fixture 或仓库。普通测试只使用 loopback mock,禁止把真实付费请求作为 T3 自动验收。 +Tripo 3D 生成的 `TRIPO_BASE_URL` / `TRIPO_API_KEY` 是**破坏性部署要求**::网关没有内置默认值,`api` / `external-generation-worker` / `all` 角色缺任意一项就直接拒绝启动(请求期 503 只是第二道防线),见 [ADR 0006](../adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md);本地在 `.env.local` / `.env.secrets.local` 补齐,生产在 `/etc/genarrative/api-server.env` 补齐后再部署或重启。`TRIPO_REQUEST_TIMEOUT_MS` / `TRIPO_RETRIES` 可缺省(沿用内置 `60000` / `2`),显式给出时零值或越界值同样让进程拒绝启动。这条要求不做开关,也没有「先起来、缺配置再说」的逃生口:需要 3D 能力的部署必须在上线前配好这两项(生产改 `/etc/genarrative/api-server.env` 后重启,本地补 `.env.local` / `.env.secrets.local`),否则升级会卡在启动期——旧进程一停,3D 入口就是整体不可用;确认不需要 3D 的机器只跑 `bgfilter-worker` / `external-generation-controller` 这两个不检查 Tripo 配置的角色。 SFX V2 发布必须使用维护窗:先关闭 SFX 入队,再对显式目标执行只读 `spacetime sql --server --format json "SELECT job_id, status, request_payload_json FROM external_generation_job WHERE job_kind = 'editor_sound_effect_generation' AND (status = 'pending' OR status = 'running')"`;结果非零时保持旧 Worker drain,不得删除任务或让新 Worker 解析旧 Vidu payload。禁止依赖默认 server,禁止使用 `--root-dir`。清零后先部署共享 env 已对齐的 api-server / external-generation worker,检查 `/healthz` 和 Worker 启动,再部署 Web 并小流量开放 SFX。灰度对账 job 完成数、退款数、ElevenLabs POST 数、完成资源数和孤儿资源;翻译失败仍调用 provider、单 job provider POST 大于一次、成功退款或失败未退款均应立即停止放量。回滚先停止入队并收口 V2 pending / running job,不自动切回 Vidu,不执行 SpacetimeDB schema 或数据回滚。完整清单见 `docs/【实施记录】SFX生成优化V2.0T6测试与发布门禁-2026-08-07.md`。 diff --git a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md index 671196db6..b07ec77da 100644 --- a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md +++ b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md @@ -7,13 +7,40 @@ ## 配置来源 - 默认配置文件:`server-rs/crates/api-server/config/editor-generation-pricing.default.json`。 -- 运行时事实源:SpacetimeDB `editor_generation_pricing_config` 全局配置表,固定 `config_id = global`,以强类型 `models` 列保存模型、单位、单价和档位列表。 -- 旧运行时覆盖文件仅作为迁移兼容种子读取;当前兼容路径为 `/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json`,并在默认新路径不存在时兼容读取旧 `.app/editor-generation-pricing.override.json`。后台保存不再写文件。 +- 运行时事实源:SpacetimeDB `editor_generation_pricing_config` 全局配置表,固定 `config_id = global`,以强类型 `models` 列保存模型、单位、单价和档位列表,并以 `text_to_model_pricing` / `image_to_model_pricing` 两个列分端点保存 3D 生成定价。 +- 旧运行时覆盖文件仅作为迁移兼容种子读取;当前兼容路径为 `/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json`,并在默认新路径不存在时兼容读取旧 `.app/editor-generation-pricing.override.json`。后台保存不再写文件。图片 / 视频 / 音频与 3D 段都以 SpacetimeDB 为准,覆盖文件只承担「表为空时的种子」与「SpacetimeDB 暂不可达时的兜底」。 默认文件进入 Git,作为空表或 SpacetimeDB 暂不可达时的兜底。只有 HTTP 角色会在启动恢复阶段用当前本地缓存尝试初始化空的 `editor_generation_pricing_config`;本地缓存可来自默认 JSON,也可来自旧 override 文件。`external-generation-worker` / `external-generation-controller` 不调用定价 initializer,而是在启动时通过只读的 queue-stats procedure 验证当前 SpacetimeDB identity 是否具备队列运行权限。生产 API 发布脚本会在切换 `current` 前把旧 release 下的 `.app/editor-generation-pricing.override.json` 迁移到 `/var/lib/genarrative/editor-generation-pricing/`,避免既有后台自定义价格被默认值覆盖。 ## 配置结构 +### 3D 生成定价(两段并列结构,2026-09-23 起后台可编辑) + +Tripo 3D 生成的计费形态是「底价 + 可叠加加价项」,现有「模型 → 档位 → 单价」的 `models` 记录形状表达不了。3D 定价按端点拆成并列的两段,各段承载该端点自己的整套设置,不在一段里用端点做判别键: + +```json +"model3d": { + "textToModelPricing": { + "versionPrices": { "": { "noTexture": 泥点, "texture": 泥点 } }, + "addOnPrices": { "hdTexture": 泥点, "ultraTexture": 泥点, "hdGeometry": 泥点, "quadMesh": 泥点, "smartLowPoly": 泥点, "generateParts": 泥点 } + }, + "imageToModelPricing": { + "versionPrices": { "": { "noTexture": 泥点, "texture": 泥点 } }, + "addOnPrices": { "hdTexture": 泥点, "ultraTexture": 泥点, "hdGeometry": 泥点, "quadMesh": 泥点, "smartLowPoly": 泥点, "generateParts": 泥点 } + } +} +``` + +- 两段各自持有全部受支持模型版本的 `noTexture` / `texture` 底价和本端点自己的六个加价项键;两段加价项当前数值相同,仍各自保留一份,便于后续按端点差异化。加载时按段独立强校验:模型版本键取自 `Model3dModelVersion` 枚举,每段必须给出全部受支持版本 × 两种贴图态与六个加价项键,缺一即该段非法;段内不写 `unit`,3D 生成恒为按次计价。 +- 事实源是 SpacetimeDB:`editor_generation_pricing_config` 表新增 `text_to_model_pricing` / `image_to_model_pricing` 两列,按 `models` 同样的展平风格存键值行(版本键一行含两档价,加价项一行一条),字符串键由 api-server 解析回枚举并强校验。后台「模型定价」页面直接编辑两段,保存与 `models` 段在同一事务内生效,改价不再需要改文件或重启进程。 +- 读取方向做契约归一化:表内两段缺键(例如发布新增模型版本后表里还没有该键)时,用默认 JSON / 覆盖文件同段值补齐;契约之外的遗留键(例如版本下线后表里的旧键)剔除并告警;只有归一化后仍不合法才判配置非法。**写入方向不做宽松**:后台保存要求两段都显式给出且完整合法。整段缺失的老行(迁移前写入的行)同样走补齐,因此老部署不会因为表里没有 3D 段就把 3D 生成关掉;若本地配置也没有 3D 段,才判「未配置」。 +- 覆盖文件整段没有 3D 段时(3D 是后加的段,旧文件天然没有),文件读取方向按受控默认 JSON 的两段补齐并告警:覆盖文件只承担种子与兜底,不代表「3D 未配置」。 +- 公开读模型 `GET /api/editor/generation-pricing` 的 3D 段保持迁移前的旧形状(按端点判别的 `basePrices` + 单表 `addOnPrices`):它是画布 3D 入口的唯一价格真相且前端零兜底,而静态站点与 api-server 是两条独立发布线,改形状会在错开发布窗口内让入口取不到价。内部两段到这份视图的投影是**有损**的——加价项只有一张表,取文生 3D 段;两段加价项不一致时打告警,前端升级到按端点读取加价项之前不要在两段之间差异化。 +- 覆盖文件在 3D 上只保留两个用途:SpacetimeDB 表为空 / 不可达时的兜底,以及表内 3D 段缺失时的首次种子。不再存在「改文件 + 重启」作为 3D 改价的生效路径。 +- 两段缺失仍是合法形态:缺失即 3D 定价未配置,提交被拒绝(fail closed),既不扣费也不调用 provider。 +- 默认价已确认并写入受控默认 JSON,换算口径固定为 **泥点 = `ceil(0.8 × Tripo 官方 credit)`**:文生 3D 的 H 系列 `10 / 20`、P1 `30 / 40`、P2 `100 / 110`,图生 3D 的 H 系列 `20 / 30`、P1 `40 / 50`、P2 `100 / 110`,add-on 为 `HD Texture 10`、`8K Ultra Texture 20`、`HD Geometry 20`、`Quad Mesh 5`、`Smart Low-poly 10`、`Generate Parts 20`。泥点数值是产品定价,不是 provider 的 credit 数值;改价必须先改 credit 表并让 `editor_generation_config` 的定向用例同步,禁止写入未确认的猜测值。 +- 关联规范:[技术方案 Tripo 3D生成API集成](technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[后端架构 server-rs与SpacetimeDB数据契约](【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。 + 定价只按模型区分,不按用途区分。同一个模型用于多个入口时必须读取同一条模型配置: - `gpt-image-2` 同时用于普通图片、规范、UI 设计等图片类入口。 @@ -55,9 +82,9 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝 ## 后端契约 -- `GET /api/editor/generation-pricing`:主站读取当前模型定价。 -- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价。 -- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,并写入 SpacetimeDB `editor_generation_pricing_config`;只有 procedure 入库成功后才更新进程内缓存并返回成功,不能把“仅内存生效”当作保存成功。 +- `GET /api/editor/generation-pricing`:主站读取当前模型定价,3D 段按旧形状投影(见「配置结构」)。 +- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价,返回 `models` 与 3D 两段;不返回定价版本。 +- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,`models` 与 3D 两段都必须显式给出(缺段即 400,不接受「省略即沿用」),并写入 SpacetimeDB `editor_generation_pricing_config`;保存**不做版本校验**——请求不带定价版本,写进去的整段就是最终状态,服务端不比对、不返回 409(后台同时只有一个管理员在操作,见「保存语义」)。只有 procedure 入库成功后才更新进程内缓存并返回成功,不能把“仅内存生效”当作保存成功。 后端 `AppState` 启动时加载默认配置和旧 override 作为本地缓存;接口读取优先走 SpacetimeDB。表为空时调用 `initialize_editor_generation_pricing_config_if_missing_and_return`,在单事务内仅缺失时种子入库,不能使用“先读空、再无条件 upsert”的两事务流程。首次写入把真实 `ctx.sender()` 保存为表内 `writer_identity`;procedure 对外返回的定价快照不包含该身份字段,公开主站和后台仍只经 BFF 读取价格。表已存在时 initializer 只接受同一 writer,bootstrap secret 和迁移操作员都不能借该入口接管既有 writer。后续后台保存只允许同一 writer identity 或已授权迁移操作员,但即使由迁移操作员修复价格也必须保留原 writer;若运行中的配置行意外缺失,保存请求会携带 `AppConfig` 已读取的受保护 bootstrap secret 完成原子首次写入,已有配置不会消费该 secret,也不会隐式轮换 writer。后台用户 ID 只记录审计信息,不能充当数据库授权。SpacetimeDB 暂不可达时才使用本地缓存兜底。 @@ -65,6 +92,13 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝 SFX V2 上线前已经存在的 SpacetimeDB 定价快照或旧本地 override 可能只有 `audio1.0`。读取这类历史快照时,`api-server` 只允许从当前受控默认配置补入缺失的 `eleven_text_to_sound_v2` 条目,再执行完整配置校验,使旧快照可继续读取;其它必需模型缺失仍失败。该兼容不修改 schema,也不在读取时写数据库或 override;下一次后台保存完整定价矩阵时自然持久化新键。发布前仍应确认运行时配置中的新键和价格已经批准。 +## 保存语义(2026-09-24 定稿) + +- 保存是「整段覆盖」:请求里的 `models` 与 3D 两段直接成为新的权威状态,服务端不做「谁先读谁后读」的比对。 +- 后台定价同时只有一个管理员在操作,因此不引入版本号、不加拦截;上一次保存的内容会被下一次保存整体取代。 +- `api-server` 侧替换进程内缓存的方法(`EditorGenerationPricingStore::replace`)**只是缓存刷新,不做版本校验,也不是拦截点**:它的职责是把刚入库的权威快照同步到本地缓存,任何写入判断都已经在 procedure 的入库事务里完成。 +- 该替换在入库成功之后执行;入库失败时进程内缓存保持原值。 + ## 运行时身份首次授权 模型定价 writer、外部生成队列和钱包调用都以真实 SpacetimeDB `ctx.sender()` 校验运行时服务 identity。原始 bootstrap secret 固定为 64 位十六进制;首次授权使用与当前 `spacetime_module.wasm` 构建时注入 SHA-256 摘要对应的原始值,模块收到原始值后重新计算 SHA-256 并做常量时间比较,WASM 只嵌入摘要、不嵌入原文。bootstrap secret 只能在配置表为空时建立首个受信身份,表存在后不能重复使用。queue 和钱包 runtime guard 只接受精确 `writer_identity`,迁移操作员身份不自动获得在线生成或钱包权限;因此当前生产 API、worker 和 controller 必须继承同一份 runtime token。非 HTTP 角色只做 queue procedure 鉴权预检,不具备 seed 或轮换身份的职责。migration operator 与 runtime writer 必须互斥:任何已登记 operator 都不能成为 writer,当前 writer 也不能被授权为 operator;一旦已有 operator,bootstrap secret 不得再新增或接管 operator。 @@ -85,6 +119,13 @@ SFX V2 上线前已经存在的 SpacetimeDB 定价快照或旧本地 override - 定价单位:按次 / 按秒。 - 单价或档位价格;生图模型展示 `0.5K / 1K / 2K` 等尺寸档位,视频模型展示 `480p / 720p / 1080p` 等分辨率档位。 +3D 生成定价在同一页面的独立区块编辑,按端点分成「文生 3D」与「图生 3D」两组: + +- 每组是一张「模型版本 × {无贴图, 带贴图}」的数字表,加上该端点自己的六个加价项单值输入。 +- 模型版本键与加价项键都来自接口返回,后台不硬编码版本清单,也不能增删键、不能停用整段;新增模型版本仍是「先改契约枚举与默认 JSON,再发布」的动作。 +- 提交前要求全部数值 ≥ 1,否则禁用保存并给出中文提示;服务端 400 的中文错误原样透出,保存成功后用回包覆盖本地状态。 +- 保存不做版本比对:页面不会收到 409,也没有「定价已被其他人更新」这类提示;保存成功后回包直接成为页面状态(同时只有一个管理员在操作后台定价)。 + ## 前端展示 主站画板启动后调用 `GET /api/editor/generation-pricing`,成功后覆盖前端内置兜底价格并触发重渲染。接口失败时保留内置兜底,避免画板不可用。图片类价格计算必须传入当前模型和 `imageSize`;规范生成固定读取 `gpt-image-2` 的 `2K` 定价。 @@ -98,3 +139,12 @@ SFX V2 上线前已经存在的 SpacetimeDB 定价快照或旧本地 override - 部署脚本 Bash 语法、生产运维静态门禁、64 位十六进制 secret 校验、manifest 摘要匹配、Build / Publish credential ID 一致性、bootstrap secret 明文日志扫描和 FILE 权限 / 服务重启检查。 - 前端价格读取、运行时覆盖、图片尺寸档位计算测试。 - 管理端模型定价页面单位展示和档位保存测试。 +- 3D 定价两段的加载归一化(缺键补齐、契约外键剔除)、后台保存与重启读取、缺段 400、公开读取仍返回 3D 段且形状不变的测试;后台连续保存两次时以后一次为准。 +- 管理端 3D 区块的渲染、数值校验与保存 payload 测试;画布 3D 入口在后台改价后立即读到新价的定向验证。 + +## 旧形状覆盖文件的处理(2026-09-23 定稿) + +- 文件读取方向**不保留** `basePrices` 旧形状的兼容分支:3D 段形状在本期原地改成两段并列,配置结构不允许两个形状共存。 +- 缺 3D 段是安全的:按受控默认 JSON 的两段补齐并告警(见「配置结构」)。含旧形状 3D 段的覆盖文件解析失败会让进程起不来,因此它是发布前置门禁而非运行时兜底。 +- **发布前置门禁**:升级 API 前确认 `/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json` 不含 `model3d.basePrices`;若含有,先删除该段(3D 价格改由受控默认价补种,再从后台改)或按两段形状改写,然后再发布。发布顺序仍为 Stdb → API → Web。 +- 3D 加价项差异化受公开投影限制:前端升级到按端点读取加价项之前,两段加价项必须保持一致,否则图生 3D 的画布预估价会按文生段计算(实际扣费仍以服务端账单为准)。 diff --git a/package-lock.json b/package-lock.json index 77bd6d2e4..6932299cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "packages/image-canvas-core", "packages/image-canvas-react", "packages/agc-plugin-sdk", + "packages/model3d-viewer", "packages/shared", "plugins/agc-cocos-editor", "plugins/agc-unity-editor", @@ -5194,6 +5195,10 @@ "resolved": "apps/mobile-shell", "link": true }, + "node_modules/@genarrative/model3d-viewer": { + "resolved": "packages/model3d-viewer", + "link": true + }, "node_modules/@genarrative/preview-deployer-web": { "resolved": "apps/preview-deployer-web", "link": true @@ -23083,6 +23088,22 @@ "react-dom": "^19.0.0" } }, + "packages/model3d-viewer": { + "name": "@genarrative/model3d-viewer", + "version": "0.1.0", + "dependencies": { + "three": "^0.184.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/three": "^0.184.1", + "vitest": "^0.34.6" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "packages/shared": { "name": "@genarrative/shared", "version": "0.1.0", @@ -26636,6 +26657,15 @@ } } }, + "@genarrative/model3d-viewer": { + "version": "file:packages/model3d-viewer", + "requires": { + "@testing-library/react": "^16.3.2", + "@types/three": "^0.184.1", + "three": "^0.184.0", + "vitest": "^0.34.6" + } + }, "@genarrative/preview-deployer-web": { "version": "file:apps/preview-deployer-web", "requires": { diff --git a/package.json b/package.json index 87d45e193..39c3b9d8c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "packages/image-canvas-core", "packages/image-canvas-react", "packages/agc-plugin-sdk", + "packages/model3d-viewer", "packages/shared", "plugins/agc-cocos-editor", "plugins/agc-unity-editor", @@ -43,6 +44,7 @@ "admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck", "admin-web:preview": "npm --prefix apps/admin-web run preview --", "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", + "contracts:model3d:generate": "cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml -- export_bindings && prettier --write packages/shared/src/contracts/model3d packages/shared/src/contracts/editor-canvas", "spacetime:external-generation:maintain": "node scripts/spacetime-maintain-external-generation-jobs.mjs", "spacetime:editor-image-asset-kind:clean": "node scripts/spacetime-clean-editor-image-asset-kind.mjs", "spacetime:editor-canvas-layout:migrate": "node scripts/spacetime-migrate-editor-canvas-layout.mjs", diff --git a/packages/image-canvas-core/src/types.ts b/packages/image-canvas-core/src/types.ts index c5f95e997..be88f7dd1 100644 --- a/packages/image-canvas-core/src/types.ts +++ b/packages/image-canvas-core/src/types.ts @@ -15,7 +15,8 @@ export type CanvasAssetKind = | 'video' | 'sound-effect' | 'background-music' - | 'scene'; + | 'scene' + | 'model3d'; export type CanvasMediaType = 'image' | 'video' | 'audio' | 'image-sequence'; @@ -143,7 +144,8 @@ export type CanvasTool = | 'icon' | 'publication' | 'ui-design' - | 'scene'; + | 'scene' + | 'model3d'; export type CanvasGenerationDialogMode = | 'generate' @@ -157,7 +159,9 @@ export type CanvasGenerationDialogMode = | 'character-animation' | 'video' | 'audio-sound-effect' - | 'audio-background-music'; + | 'audio-background-music' + | 'model3d-text-to-model' + | 'model3d-image-to-model'; export type GenerateDialogState = { id?: string; diff --git a/packages/image-canvas-react/src/CanvasChrome.tsx b/packages/image-canvas-react/src/CanvasChrome.tsx index cfec8a54c..808ab5bde 100644 --- a/packages/image-canvas-react/src/CanvasChrome.tsx +++ b/packages/image-canvas-react/src/CanvasChrome.tsx @@ -80,7 +80,11 @@ export type CanvasToolbarProps = Omit< surface?: 'plain' | 'floating'; }; -/** 画布宿主共享的横向工具栏容器。 */ +/** + * 画布宿主共享的工具栏容器。 + * + * 放不下时换行,不做横向滚动:工具栏是导航,任何被滚出视野的工具都等于不存在。 + */ export const CanvasToolbar = forwardRef( function CanvasToolbar( { label, children, surface = 'plain', className, ...toolbarProps }, diff --git a/packages/image-canvas-react/src/styles.css b/packages/image-canvas-react/src/styles.css index e9c0cbbd8..7d26714fe 100644 --- a/packages/image-canvas-react/src/styles.css +++ b/packages/image-canvas-react/src/styles.css @@ -152,16 +152,18 @@ transform: none; } +/* + * 工具栏是「工具导航」不是内容区:放不下时必须换行让所有工具继续可见。 + * 曾经的 `overflow-x: auto` + `scrollbar-width: thin` 会把尾部工具裁掉且几乎看不出 + * 可滚动,用户看到的是「工具少了一个」而不是「这里能滚」。 + */ .genarrative-image-canvas__toolbar { display: inline-flex; min-width: 0; + flex-wrap: wrap; align-items: center; gap: 0.35rem; box-sizing: border-box; - overflow-x: auto; - overflow-y: hidden; - overscroll-behavior-x: contain; - scrollbar-width: thin; } .genarrative-image-canvas__toolbar--floating { diff --git a/packages/model3d-viewer/package.json b/packages/model3d-viewer/package.json new file mode 100644 index 000000000..b31a2b2c8 --- /dev/null +++ b/packages/model3d-viewer/package.json @@ -0,0 +1,21 @@ +{ + "name": "@genarrative/model3d-viewer", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "three": "^0.184.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/three": "^0.184.1", + "vitest": "^0.34.6" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/packages/model3d-viewer/src/Model3DViewer.tsx b/packages/model3d-viewer/src/Model3DViewer.tsx new file mode 100644 index 000000000..c8755cf6f --- /dev/null +++ b/packages/model3d-viewer/src/Model3DViewer.tsx @@ -0,0 +1,186 @@ +import { + type Ref, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; + +import { + describeModel3dViewerError, + Model3dViewerAbortedError, + Model3dViewerLoadError, + type Model3DViewerSource, +} from './loader'; +import { createModel3dViewerScene, type Model3dViewerScene } from './scene'; +import { + MODEL3D_VIEWER_LOADING_TEXT, + MODEL3D_VIEWER_STATUS_ATTRIBUTE, + type Model3dViewerStatus, + type Model3dViewerStatusDetail, + resolveModel3dViewerErrorText, +} from './status'; + +export type Model3DViewerHandle = { + /** 回到打开模型时的归一化取景。 */ + resetView(): void; +}; + +export type Model3DViewerProps = { + /** 模型来源:宿主已解析好的签名地址,或已读入的字节。 */ + source: Model3DViewerSource; + autoRotate?: boolean; + /** 失败时那一行状态文案;不传用内置文案。 */ + errorText?: string; + onStatusChange?: ( + status: Model3dViewerStatus, + detail: Model3dViewerStatusDetail, + ) => void; + className?: string; + ref?: Ref; +}; + +/** + * WebGL2 模型查看器:只负责加载与交互查看,不取业务数据、不产出缩略图。 + * 宿主要给容器一个非零尺寸,并在祖先上带 platform-theme 主题类才能取到平台色值。 + */ +export function Model3DViewer({ + source, + autoRotate = false, + errorText, + onStatusChange, + className, + ref, +}: Model3DViewerProps) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const sceneRef = useRef(null); + const onStatusChangeRef = useRef(onStatusChange); + const autoRotateRef = useRef(autoRotate); + const [status, setStatus] = useState('idle'); + + useEffect(() => { + onStatusChangeRef.current = onStatusChange; + }, [onStatusChange]); + + const reportStatus = useCallback( + (next: Model3dViewerStatus, detail: Model3dViewerStatusDetail) => { + setStatus(next); + onStatusChangeRef.current?.(next, detail); + }, + [], + ); + + useImperativeHandle( + ref, + () => ({ + resetView() { + sceneRef.current?.resetView(); + }, + }), + [], + ); + + const sourceFormat = source.format; + const sourceUrl = source.kind === 'url' ? source.url : null; + const sourceData = source.kind === 'bytes' ? source.data : null; + + useEffect(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + if (!container || !canvas) { + return; + } + + let aborted = false; + let createdScene: Model3dViewerScene | null = null; + // 取消时连在途的模型下载一起停掉,不等字节读完才发现宿主已经走了。 + const loadAbortController = new AbortController(); + const activeSource: Model3DViewerSource = sourceData + ? { kind: 'bytes', data: sourceData, format: sourceFormat } + : { kind: 'url', url: sourceUrl ?? '', format: sourceFormat }; + + reportStatus('loading', {}); + + createModel3dViewerScene({ + canvas, + container, + source: activeSource, + autoRotate: autoRotateRef.current, + isAborted: () => aborted, + signal: loadAbortController.signal, + }) + .then((scene) => { + if (aborted) { + scene.dispose(); + return; + } + createdScene = scene; + sceneRef.current = scene; + // 场景创建期间 autoRotate 可能已经变过,而那时 sceneRef 还是空的, + // 只靠 setAutoRotate 的 effect 会丢掉这次更新,所以这里补一次最新值。 + scene.setAutoRotate(autoRotateRef.current); + reportStatus('ready', {}); + }) + .catch((error: unknown) => { + if (aborted || error instanceof Model3dViewerAbortedError) { + return; + } + if (error instanceof Model3dViewerLoadError) { + reportStatus('failed', { + reason: error.reason, + message: error.message, + byteLength: error.byteLength, + }); + return; + } + reportStatus('failed', { + reason: 'load-failed', + message: describeModel3dViewerError(error), + }); + }); + + return () => { + aborted = true; + loadAbortController.abort(); + createdScene?.dispose(); + if (sceneRef.current === createdScene) { + sceneRef.current = null; + } + }; + }, [sourceFormat, sourceUrl, sourceData, reportStatus]); + + useEffect(() => { + autoRotateRef.current = autoRotate; + sceneRef.current?.setAutoRotate(autoRotate); + }, [autoRotate]); + + return ( +
+ + {status === 'loading' ? ( +
+ {MODEL3D_VIEWER_LOADING_TEXT} +
+ ) : null} + {status === 'failed' ? ( +
+ {resolveModel3dViewerErrorText(errorText)} +
+ ) : null} +
+ ); +} diff --git a/packages/model3d-viewer/src/framing.test.ts b/packages/model3d-viewer/src/framing.test.ts new file mode 100644 index 000000000..a119b0ed5 --- /dev/null +++ b/packages/model3d-viewer/src/framing.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeModel3dViewerBoxCenter, + computeModel3dViewerBoxRadius, + computeModel3dViewerFitDistance, + computeModel3dViewerFraming, + createModel3dViewerFitSolver, + MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + MODEL3D_VIEWER_FIT_FRACTION, + type Model3dViewerBox, + resolveModel3dViewerReframedCameraPosition, +} from './framing'; + +type Point = [number, number, number]; + +function box(min: Point, max: Point): Model3dViewerBox { + return { + min: { x: min[0], y: min[1], z: min[2] }, + max: { x: max[0], y: max[1], z: max[2] }, + }; +} + +function scaleBox(source: Model3dViewerBox, factor: number): Model3dViewerBox { + return { + min: { + x: source.min.x * factor, + y: source.min.y * factor, + z: source.min.z * factor, + }, + max: { + x: source.max.x * factor, + y: source.max.y * factor, + z: source.max.z * factor, + }, + }; +} + +function solveForPoints( + source: Model3dViewerBox, + points: Point[], + aspect: number, +) { + const solver = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(source), + aspect, + fallbackRadius: computeModel3dViewerBoxRadius(source), + }); + for (const [x, y, z] of points) { + solver.add(x, y, z); + } + return solver.resolve(); +} + +/** 旧口径的包围球拟合距离,作为「不该退回到这么远」的对照基线。 */ +function sphereBaselineDistance(source: Model3dViewerBox): number { + const halfFovY = (MODEL3D_VIEWER_CAMERA_FOV_DEGREES * Math.PI) / 360; + return ( + computeModel3dViewerBoxRadius(source) / + (MODEL3D_VIEWER_FIT_FRACTION * Math.tan(halfFovY)) + ); +} + +const CUBE = box([-1, -1, -1], [1, 1, 1]); +const WINGS = box([-4, -0.4, -0.4], [4, 0.4, 0.4]); +const CUBE_CORNERS: Point[] = [ + [-1, -1, -1], + [-1, -1, 1], + [-1, 1, -1], + [-1, 1, 1], + [1, -1, -1], + [1, -1, 1], + [1, 1, -1], + [1, 1, 1], +]; + +describe('computeModel3dViewerFitDistance', () => { + it('与模型尺度成正比,任意尺度都占同样的视野比例', () => { + const small = computeModel3dViewerFitDistance({ box: CUBE, aspect: 1.5 }); + const large = computeModel3dViewerFitDistance({ + box: scaleBox(CUBE, 10000), + aspect: 1.5, + }); + + expect(small).toBeGreaterThan(0); + expect(large / small).toBeCloseTo(10000, 3); + }); + + it('按包围盒投影取景,明显近于按包围球取景', () => { + for (const source of [CUBE, WINGS]) { + const boxFit = computeModel3dViewerFitDistance({ + box: source, + aspect: 2, + }); + expect(boxFit).toBeLessThan(sphereBaselineDistance(source) * 0.9); + } + }); + + it('竖屏时相机更远,避免横向出框', () => { + const landscape = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 }); + const portrait = computeModel3dViewerFitDistance({ + box: CUBE, + aspect: 0.5, + }); + + expect(portrait).toBeGreaterThan(landscape); + }); + + it('退化或非法包围盒回落到可用的默认取景', () => { + const degenerate = [ + box([0, 0, 0], [0, 0, 0]), + box([Number.NaN, 0, 0], [1, 1, 1]), + box([Number.POSITIVE_INFINITY, 0, 0], [Number.NEGATIVE_INFINITY, 1, 1]), + ]; + + for (const candidate of degenerate) { + const distance = computeModel3dViewerFitDistance({ + box: candidate, + aspect: 1, + }); + expect(Number.isFinite(distance)).toBe(true); + expect(distance).toBeGreaterThan(0); + } + }); + + it('fitFraction 越小相机越远', () => { + const near = computeModel3dViewerFitDistance({ box: CUBE, aspect: 1 }); + const far = computeModel3dViewerFitDistance({ + box: CUBE, + aspect: 1, + fitFraction: 0.31, + }); + + expect(far).toBeGreaterThan(near); + }); + + it('传入已算好的距离时直接采用,不再按角点重算', () => { + const distance = computeModel3dViewerFitDistance({ + box: CUBE, + aspect: 1, + distance: 7, + }); + + expect(distance).toBe(7); + }); + + it('传入的距离小到会让缩放范围颠倒时,抬到能站住的下限', () => { + const framing = computeModel3dViewerFraming({ + box: CUBE, + aspect: 1, + distance: computeModel3dViewerFitDistance({ + box: CUBE, + aspect: 1, + distance: 0.001, + }), + }); + + expect(framing.maxDistance).toBeGreaterThanOrEqual(framing.minDistance); + }); +}); + +describe('createModel3dViewerFitSolver', () => { + it('视角超出 (0, 180) 时回落到默认视角,不把取景距离算成非正数', () => { + for (const fovDegrees of [180, 360, 0, -45, Number.NaN]) { + const solver = createModel3dViewerFitSolver({ + center: { x: 0, y: 0, z: 0 }, + aspect: 1, + fovDegrees, + }); + + expect(solver.resolve(), String(fovDegrees)).toBeCloseTo( + createModel3dViewerFitSolver({ + center: { x: 0, y: 0, z: 0 }, + aspect: 1, + fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + }).resolve(), + 6, + ); + } + }); + + it('喂包围盒角点时与包围盒口径一致', () => { + const expected = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 }); + + expect(solveForPoints(CUBE, CUBE_CORNERS, 2)).toBeCloseTo(expected, 6); + }); + + it('喂真实顶点时避开包围盒空角,取景更近', () => { + // 斜向细杆:包围盒是 -1..1 的立方体,但 6 个角点都是空的。 + const rod: Point[] = [ + [-1, -1, -1], + [1, 1, 1], + ]; + const boxFit = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 }); + const vertexFit = solveForPoints(CUBE, rod, 2); + + expect(vertexFit).toBeLessThan(boxFit * 0.9); + }); + + it('没有任何顶点时回落到与尺度相关的可用距离', () => { + const fit = solveForPoints(CUBE, [], 1); + + expect(Number.isFinite(fit)).toBe(true); + expect(fit).toBeGreaterThan(0); + }); + + it('带上新比例可复用同一份顶点样本重算取景距离', () => { + const rod: Point[] = [ + [-1, -1, -1], + [1, 1, 1], + ]; + const solver = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(CUBE), + aspect: 2, + fallbackRadius: computeModel3dViewerBoxRadius(CUBE), + }); + for (const [x, y, z] of rod) { + solver.add(x, y, z); + } + + const wide = solver.resolve(2); + const narrow = solver.resolve(0.5); + + expect(wide).toBeCloseTo(solveForPoints(CUBE, rod, 2), 6); + expect(narrow).toBeCloseTo(solveForPoints(CUBE, rod, 0.5), 6); + expect(narrow).toBeGreaterThan(wide); + // 复用样本:再问一次同一个比例,结果不变。 + expect(solver.resolve(2)).toBe(wide); + }); + + it('顶点里出现非有限值时整次取景回落到与尺度相关的默认距离', () => { + const solver = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(CUBE), + aspect: 1, + fallbackRadius: computeModel3dViewerBoxRadius(CUBE), + }); + solver.add(Number.NaN, 0, 0); + solver.add(1, 1, 1); + // 没有可用顶点时的同一口径:退化取景距离只由包围盒尺度决定。 + const withoutSamples = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(CUBE), + aspect: 1, + fallbackRadius: computeModel3dViewerBoxRadius(CUBE), + }).resolve(1); + + expect(solver.resolve(1)).toBeCloseTo(withoutSamples, 6); + }); +}); + +describe('computeModel3dViewerFraming', () => { + it('推导出合法的裁剪面与缩放范围', () => { + const framing = computeModel3dViewerFraming({ + box: box([-1, -1, -1], [3, 5, 7]), + aspect: 1.6, + }); + + expect(framing.center).toEqual({ x: 1, y: 2, z: 3 }); + expect(framing.near).toBeGreaterThan(0); + expect(framing.near).toBeLessThan(framing.far); + expect(framing.minDistance).toBeLessThan(framing.distance); + expect(framing.maxDistance).toBeGreaterThan(framing.distance); + }); + + it('复位方向是单位向量且带正向仰角', () => { + const { resetDirection } = computeModel3dViewerFraming({ + box: CUBE, + aspect: 1, + }); + + expect( + Math.hypot(resetDirection.x, resetDirection.y, resetDirection.z), + ).toBeCloseTo(1, 6); + expect(resetDirection.y).toBeGreaterThan(0); + expect(resetDirection.z).toBeGreaterThan(0); + }); +}); + +describe('computeModel3dViewerBoxCenter', () => { + it('非法包围盒回落到原点,避免相机拿到 NaN', () => { + expect( + computeModel3dViewerBoxCenter({ + min: { x: Number.POSITIVE_INFINITY, y: 0, z: 0 }, + max: { x: Number.NEGATIVE_INFINITY, y: 0, z: 0 }, + }), + ).toEqual({ x: 0, y: 0, z: 0 }); + }); +}); + +describe('resolveModel3dViewerReframedCameraPosition', () => { + const wide = computeModel3dViewerFraming({ + box: box([-1, -1, -1], [1, 1, 1]), + aspect: 2, + }); + const narrow = computeModel3dViewerFraming({ + box: box([-1, -1, -1], [1, 1, 1]), + aspect: 0.5, + }); + + function resetCameraPosition(framing: typeof wide) { + const { center, resetDirection, distance } = framing; + return { + x: center.x + resetDirection.x * distance, + y: center.y + resetDirection.y * distance, + z: center.z + resetDirection.z * distance, + }; + } + + function distanceTo(position: { x: number; y: number; z: number }) { + return Math.hypot( + position.x - narrow.center.x, + position.y - narrow.center.y, + position.z - narrow.center.z, + ); + } + + it('容器变窄后按新取景距离重算,朝向不变', () => { + const position = resolveModel3dViewerReframedCameraPosition({ + previous: wide, + next: narrow, + cameraPosition: resetCameraPosition(wide), + }); + + expect(narrow.distance).toBeGreaterThan(wide.distance); + expect(distanceTo(position)).toBeCloseTo(narrow.distance, 6); + expect(position.x - narrow.center.x).toBeCloseTo( + narrow.resetDirection.x * narrow.distance, + 6, + ); + }); + + it('保留用户的相对缩放', () => { + const zoomedIn = resetCameraPosition(wide); + const half = { + x: wide.center.x + (zoomedIn.x - wide.center.x) / 2, + y: wide.center.y + (zoomedIn.y - wide.center.y) / 2, + z: wide.center.z + (zoomedIn.z - wide.center.z) / 2, + }; + + const position = resolveModel3dViewerReframedCameraPosition({ + previous: wide, + next: narrow, + cameraPosition: half, + }); + + expect(distanceTo(position)).toBeCloseTo(narrow.distance / 2, 6); + }); + + it('超出新缩放范围时钳到上下限', () => { + const far = { + x: wide.center.x + wide.resetDirection.x * wide.distance * 100, + y: wide.center.y + wide.resetDirection.y * wide.distance * 100, + z: wide.center.z + wide.resetDirection.z * wide.distance * 100, + }; + + const position = resolveModel3dViewerReframedCameraPosition({ + previous: wide, + next: narrow, + cameraPosition: far, + }); + + expect(distanceTo(position)).toBeCloseTo(narrow.maxDistance, 6); + }); + + it('相机落在取景中心(方向退化)时退回复位方向', () => { + const position = resolveModel3dViewerReframedCameraPosition({ + previous: wide, + next: narrow, + cameraPosition: { ...wide.center }, + }); + + expect(distanceTo(position)).toBeCloseTo(narrow.distance, 6); + expect(position.x - narrow.center.x).toBeCloseTo( + narrow.resetDirection.x * narrow.distance, + 6, + ); + }); +}); diff --git a/packages/model3d-viewer/src/framing.ts b/packages/model3d-viewer/src/framing.ts new file mode 100644 index 000000000..fd8dbc5a4 --- /dev/null +++ b/packages/model3d-viewer/src/framing.ts @@ -0,0 +1,359 @@ +export const MODEL3D_VIEWER_CAMERA_FOV_DEGREES = 45; + +/** + * 顶点在视野较短一边上占据的比例:与模型真实尺度无关的归一化取景。 + * 取 0.8 是因为包围盒对角线方向与屏幕上下轴并不重合,实际观感约为六成。 + */ +export const MODEL3D_VIEWER_FIT_FRACTION = 0.8; + +const FALLBACK_RADIUS = 1; +const FALLBACK_ASPECT = 1; +const NEAR_RADIUS_RATIO = 0.01; +const FAR_RADIUS_RATIO = 12; +/** 近裁剪面离相机的距离比例:模型很小时按 1/1000 距离兜底,避免近处被裁。 */ +const NEAR_DISTANCE_RATIO = 1 / 1000; +/** 远裁剪面离相机的距离比例:模型很扁时按 10 倍距离兜底,避免远处被裁。 */ +const FAR_DISTANCE_RATIO = 10; +const MIN_DISTANCE_RATIO = 0.2; +const MAX_DISTANCE_RATIO = 8; +/** 复位视角仰角:过度抬高会把「上轴投影范围」放大,导致模型看起来比目标比例小。 */ +const RESET_ELEVATION = 0.22; +const FIT_SAFETY = 1.02; + +export type Model3dViewerVector3 = { + x: number; + y: number; + z: number; +}; + +export type Model3dViewerBox = { + min: Model3dViewerVector3; + max: Model3dViewerVector3; +}; + +export type Model3dViewerFramingInput = { + box: Model3dViewerBox; + aspect: number; + direction?: Model3dViewerVector3; + fovDegrees?: number; + fitFraction?: number; + /** 已按实际顶点算好的取景距离;缺省时退回包围盒 8 个角点。 */ + distance?: number; +}; + +export type Model3dViewerFitSolverInput = { + center: Model3dViewerVector3; + aspect: number; + direction?: Model3dViewerVector3; + fovDegrees?: number; + fitFraction?: number; + fallbackRadius?: number; +}; + +export type Model3dViewerFitSolver = { + add(x: number, y: number, z: number): void; + /** + * 取景距离。`aspect` 省略时用创建时的比例;带上新比例就能在**不重新采顶点**的前提下重算 —— + * 顶点投影出来的三个量(上下向、左右向、深度)与容器宽高比无关,只有左右向的视野上限随比例 + * 变化,所以容器变窄变宽只需要在这一份样本上重取一次最大值。 + */ + resolve(aspect?: number): number; +}; + +export type Model3dViewerFraming = { + center: Model3dViewerVector3; + radius: number; + distance: number; + near: number; + far: number; + minDistance: number; + maxDistance: number; + /** 复位视角时的相机方向(已归一化,带少量仰角)。 */ + resetDirection: Model3dViewerVector3; +}; + +function normalizePositive(value: number, fallback: number): number { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +/** + * 取景视角必须落在 (0, 180):>=180 时 `tan(halfFovY)` 非正,`limitY/limitX` 与 + * `fallbackDistance` 会算出 0 或负数,取景距离跟着变成负数并污染 near/far 与轨道范围。 + */ +function normalizeFovDegrees(value: number | null | undefined): number { + return typeof value === 'number' && + Number.isFinite(value) && + value > 0 && + value < 180 + ? value + : MODEL3D_VIEWER_CAMERA_FOV_DEGREES; +} + +function subtract( + left: Model3dViewerVector3, + right: Model3dViewerVector3, +): Model3dViewerVector3 { + return { + x: left.x - right.x, + y: left.y - right.y, + z: left.z - right.z, + }; +} + +function dot(left: Model3dViewerVector3, right: Model3dViewerVector3): number { + return left.x * right.x + left.y * right.y + left.z * right.z; +} + +function cross( + left: Model3dViewerVector3, + right: Model3dViewerVector3, +): Model3dViewerVector3 { + return { + x: left.y * right.z - left.z * right.y, + y: left.z * right.x - left.x * right.z, + z: left.x * right.y - left.y * right.x, + }; +} + +function normalize( + value: Model3dViewerVector3, + fallback: Model3dViewerVector3, +): Model3dViewerVector3 { + const length = Math.hypot(value.x, value.y, value.z); + if (!Number.isFinite(length) || length <= Number.EPSILON) { + return fallback; + } + return { x: value.x / length, y: value.y / length, z: value.z / length }; +} + +/** 复位视角的默认方向:从正前方略微抬高,避免正投影视角。 */ +export function resolveModel3dViewerResetDirection( + direction?: Model3dViewerVector3, +): Model3dViewerVector3 { + if (direction) { + return normalize(direction, { x: 0, y: 0, z: 1 }); + } + return normalize({ x: 0, y: RESET_ELEVATION, z: 1 }, { x: 0, y: 0, z: 1 }); +} + +export function computeModel3dViewerBoxCenter( + box: Model3dViewerBox, +): Model3dViewerVector3 { + const center = { + x: (box.min.x + box.max.x) / 2, + y: (box.min.y + box.max.y) / 2, + z: (box.min.z + box.max.z) / 2, + }; + const valid = + Number.isFinite(center.x) && + Number.isFinite(center.y) && + Number.isFinite(center.z); + return valid ? center : { x: 0, y: 0, z: 0 }; +} + +export function computeModel3dViewerBoxRadius(box: Model3dViewerBox): number { + return ( + Math.hypot( + box.max.x - box.min.x, + box.max.y - box.min.y, + box.max.z - box.min.z, + ) / 2 + ); +} + +/** + * 取景求解器:把每个采样点投影到相机右轴与上轴,取满足「点落在 fitFraction + * 视野内」的最小距离。逐点求解而不是用包围球,因为包围球会把「翅膀斜向展开」 + * 这类形状的取景距离放大一倍以上;用真实顶点又可以避开包围盒空角。 + */ +export function createModel3dViewerFitSolver( + input: Model3dViewerFitSolverInput, +): Model3dViewerFitSolver { + const aspect = normalizePositive(input.aspect, FALLBACK_ASPECT); + const fovDegrees = normalizeFovDegrees(input.fovDegrees); + const fitFraction = normalizePositive( + input.fitFraction ?? MODEL3D_VIEWER_FIT_FRACTION, + MODEL3D_VIEWER_FIT_FRACTION, + ); + const center = input.center; + const fallbackRadius = normalizePositive( + input.fallbackRadius ?? FALLBACK_RADIUS, + FALLBACK_RADIUS, + ); + const direction = resolveModel3dViewerResetDirection(input.direction); + const forward = { x: -direction.x, y: -direction.y, z: -direction.z }; + const right = normalize(cross(forward, { x: 0, y: 1, z: 0 }), { + x: 1, + y: 0, + z: 0, + }); + const up = cross(right, forward); + + const halfFovY = (fovDegrees * Math.PI) / 360; + const limitY = fitFraction * Math.tan(halfFovY); + const fallbackDistance = fallbackRadius / (fitFraction * Math.tan(halfFovY)); + + // 采样点按「上下向投影绝对值 / 左右向投影绝对值 / 深度」三个浮点数存下来,比例变化时直接在 + // 这份样本上重算,不必再走一遍 updateMatrixWorld + 逐顶点读取(单个网格上限 20 万点)。 + let sampleCount = 0; + let samples = new Float32Array(3 * 4096); + // 非法顶点照旧要作废整次取景:旧实现里 NaN 会把累加出的最大值污染成 NaN,这里保持同一口径。 + let hasInvalidSample = false; + + function resolveForAspect(value: number): number { + if (hasInvalidSample) { + return fallbackDistance; + } + const limitX = limitY * normalizePositive(value, FALLBACK_ASPECT); + let required = 0; + for (let index = 0; index < sampleCount; index += 1) { + const base = index * 3; + const upAbs = samples[base] ?? 0; + const rightAbs = samples[base + 1] ?? 0; + const depth = samples[base + 2] ?? 0; + const byUp = upAbs / limitY - depth; + if (byUp > required) { + required = byUp; + } + const byRight = rightAbs / limitX - depth; + if (byRight > required) { + required = byRight; + } + } + if (!Number.isFinite(required) || required <= 0) { + return fallbackDistance; + } + return Math.max(required * FIT_SAFETY, fallbackRadius); + } + + return { + add(x: number, y: number, z: number) { + if (sampleCount * 3 + 3 > samples.length) { + const grown = new Float32Array(samples.length * 2); + grown.set(samples); + samples = grown; + } + const corner = subtract({ x, y, z }, center); + const depth = dot(corner, forward); + const upAbs = Math.abs(dot(corner, up)); + const rightAbs = Math.abs(dot(corner, right)); + if ( + !Number.isFinite(upAbs) || + !Number.isFinite(rightAbs) || + !Number.isFinite(depth) + ) { + hasInvalidSample = true; + } + const base = sampleCount * 3; + samples[base] = upAbs; + samples[base + 1] = rightAbs; + samples[base + 2] = depth; + sampleCount += 1; + }, + resolve(value?: number) { + return resolveForAspect(value ?? aspect); + }, + }; +} + +/** 没有顶点可用时,用包围盒 8 个角点求解取景距离。 */ +export function computeModel3dViewerFitDistance( + input: Model3dViewerFramingInput, +): number { + // 调用方给的距离会直接决定 near / far / 轨道缩放范围,非法值(0、负数、NaN) + // 会让 maxDistance 小于 minDistance,取景与轨道钳制一起失效,故按无效处理。 + const distance = input.distance; + if (distance !== undefined && Number.isFinite(distance) && distance > 0) { + // `computeModel3dViewerFraming` 由这个距离推出 maxDistance = distance × 8、minDistance + // = radius × 0.2;距离小到 0.025 × radius 以下时两者会颠倒,取景与轨道钳制一起失效。 + return Math.max( + distance, + (computeModel3dViewerBoxRadius(input.box) * MIN_DISTANCE_RATIO) / + MAX_DISTANCE_RATIO, + ); + } + const solver = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(input.box), + aspect: input.aspect, + direction: input.direction, + fovDegrees: input.fovDegrees, + fitFraction: input.fitFraction, + fallbackRadius: computeModel3dViewerBoxRadius(input.box), + }); + for (const x of [input.box.min.x, input.box.max.x]) { + for (const y of [input.box.min.y, input.box.max.y]) { + for (const z of [input.box.min.z, input.box.max.z]) { + solver.add(x, y, z); + } + } + } + return solver.resolve(); +} + +/** + * 容器宽高比变化后重算取景时,相机该待的位置。 + * + * 保留用户当前的观察方向与相对缩放(当前距离 / 旧取景距离),只把相机沿原方向搬到 + * 新取景距离上,并钳进新的缩放范围,避免重算取景把用户转到的角度和缩放一起丢掉; + * 方向退化(相机正好落在取景中心)时退回复位方向。 + */ +export function resolveModel3dViewerReframedCameraPosition(input: { + /** 变化前的取景。 */ + previous: Model3dViewerFraming; + /** 按新宽高比重算后的取景。 */ + next: Model3dViewerFraming; + cameraPosition: Model3dViewerVector3; +}): Model3dViewerVector3 { + const { previous, next, cameraPosition } = input; + const offset = subtract(cameraPosition, previous.center); + const offsetLength = Math.hypot(offset.x, offset.y, offset.z); + // 相机正好落在取景中心时朝向与相对缩放都无从谈起,按复位视角处理。 + const hasOffset = offsetLength > Number.EPSILON; + const direction = hasOffset + ? { + x: offset.x / offsetLength, + y: offset.y / offsetLength, + z: offset.z / offsetLength, + } + : next.resetDirection; + const ratio = + hasOffset && previous.distance > Number.EPSILON + ? offsetLength / previous.distance + : 1; + const distance = Math.min( + Math.max(next.distance * ratio, next.minDistance), + next.maxDistance, + ); + return { + x: next.center.x + direction.x * distance, + y: next.center.y + direction.y * distance, + z: next.center.z + direction.z * distance, + }; +} + +/** 由包围盒推导取景、裁剪面与轨道缩放范围。 */ +export function computeModel3dViewerFraming( + input: Model3dViewerFramingInput, +): Model3dViewerFraming { + const radius = normalizePositive( + computeModel3dViewerBoxRadius(input.box), + FALLBACK_RADIUS, + ); + const distance = computeModel3dViewerFitDistance({ + ...input, + box: input.box, + }); + return { + center: computeModel3dViewerBoxCenter(input.box), + radius, + distance, + near: Math.max(radius * NEAR_RADIUS_RATIO, distance * NEAR_DISTANCE_RATIO), + far: Math.max( + distance + radius * FAR_RADIUS_RATIO, + distance * FAR_DISTANCE_RATIO, + ), + minDistance: radius * MIN_DISTANCE_RATIO, + maxDistance: distance * MAX_DISTANCE_RATIO, + resetDirection: resolveModel3dViewerResetDirection(input.direction), + }; +} diff --git a/packages/model3d-viewer/src/index.ts b/packages/model3d-viewer/src/index.ts new file mode 100644 index 000000000..6ae70675f --- /dev/null +++ b/packages/model3d-viewer/src/index.ts @@ -0,0 +1,23 @@ +export type { Model3DViewerFormat, Model3DViewerSource } from './loader'; +export { + isModel3dViewerFormatSupported, + isModel3dViewerOverSize, + MODEL3D_VIEWER_MAX_MODEL_BYTES, + MODEL3D_VIEWER_SUPPORTED_FORMATS, + resolveModel3dViewerFormat, + resolveModel3dViewerFormatFromBytes, + resolveModel3dViewerFormatFromMimeType, + resolveModel3dViewerFormatFromUrl, +} from './loader'; +export type { Model3DViewerHandle, Model3DViewerProps } from './Model3DViewer'; +export { Model3DViewer } from './Model3DViewer'; +export type { + Model3dViewerFailureReason, + Model3dViewerStatus, + Model3dViewerStatusDetail, +} from './status'; +export { + MODEL3D_VIEWER_ERROR_TEXT, + MODEL3D_VIEWER_STATUS_ATTRIBUTE, + resolveModel3dViewerErrorText, +} from './status'; diff --git a/packages/model3d-viewer/src/loader.test.ts b/packages/model3d-viewer/src/loader.test.ts new file mode 100644 index 000000000..50413d648 --- /dev/null +++ b/packages/model3d-viewer/src/loader.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + isModel3dViewerFormatSupported, + isModel3dViewerOverSize, + loadModel3dViewerObject, + MODEL3D_VIEWER_MAX_MODEL_BYTES, + Model3dViewerAbortedError, + readModel3dViewerSource, + resolveModel3dViewerFormat, + resolveModel3dViewerFormatFromBytes, + resolveModel3dViewerFormatFromMimeType, + resolveModel3dViewerResourceUrl, + resolveModel3dViewerSourceQuery, +} from './loader'; + +describe('模型格式承诺范围', () => { + it('只承诺 glb / gltf / fbx', () => { + for (const format of ['glb', 'gltf', 'fbx']) { + expect(isModel3dViewerFormatSupported(format)).toBe(true); + } + for (const format of [ + 'obj', + 'stl', + 'usdz', + '3mf', + '', + null, + undefined, + 7, + ]) { + expect(isModel3dViewerFormatSupported(format)).toBe(false); + } + }); +}); + +describe('模型体积上限', () => { + it('超过 64 MiB 判定为超限', () => { + expect(isModel3dViewerOverSize(0)).toBe(false); + expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES)).toBe(false); + expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES + 1)).toBe( + true, + ); + }); + + it('非有限值不算超限,由加载流程自行失败', () => { + expect(isModel3dViewerOverSize(Number.NaN)).toBe(false); + }); +}); + +const GLB_MAGIC = new Uint8Array([0x67, 0x6c, 0x54, 0x46, 0x02, 0x00, 0x00]); +const FBX_MAGIC = new Uint8Array([ + 0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61, 0x20, 0x46, 0x42, 0x58, 0x20, 0x42, + 0x69, 0x6e, 0x61, 0x72, 0x79, 0x00, +]); + +describe('模型格式判定', () => { + it('声明类型按子串匹配各家拼法', () => { + expect(resolveModel3dViewerFormatFromMimeType('model/gltf-binary')).toBe( + 'glb', + ); + expect(resolveModel3dViewerFormatFromMimeType('model/gltf+json')).toBe( + 'gltf', + ); + expect( + resolveModel3dViewerFormatFromMimeType('application/x-fbx; charset=x'), + ).toBe('fbx'); + expect( + resolveModel3dViewerFormatFromMimeType('application/octet-stream'), + ).toBeNull(); + expect(resolveModel3dViewerFormatFromMimeType(null)).toBeNull(); + }); + + it('字节魔数认出自包含的三种容器', () => { + expect(resolveModel3dViewerFormatFromBytes(GLB_MAGIC)).toBe('glb'); + expect(resolveModel3dViewerFormatFromBytes(FBX_MAGIC)).toBe('fbx'); + expect( + resolveModel3dViewerFormatFromBytes( + new TextEncoder().encode('\n {"asset":{"version":"2.0"}}'), + ), + ).toBe('gltf'); + expect( + resolveModel3dViewerFormatFromBytes( + new TextEncoder().encode('\uFEFF{"asset":{}}'), + ), + ).toBe('gltf'); + // ASCII FBX(Blender 默认导出)没有 Kaydara 魔数,按开头这行认。 + expect( + resolveModel3dViewerFormatFromBytes( + new TextEncoder().encode( + '; FBX 7.4.0 project file\nFBXHeaderExtension: {', + ), + ), + ).toBe('fbx'); + expect( + resolveModel3dViewerFormatFromBytes( + new TextEncoder().encode('\n ; FBX 7.3.0 project file'), + ), + ).toBe('fbx'); + expect( + resolveModel3dViewerFormatFromBytes(new Uint8Array([0x00, 0x01])), + ).toBeNull(); + }); + + it('顺序是声明 → 魔数 → 地址扩展名', () => { + // 声明正确时轮不到魔数。 + expect( + resolveModel3dViewerFormat({ + declaredType: 'model/gltf-binary', + url: 'https://x/model.fbx', + bytes: FBX_MAGIC, + }), + ).toBe('glb'); + // 声明写歪(provider 给 octet-stream、扩展名也不对)时按魔数纠正。 + expect( + resolveModel3dViewerFormat({ + declaredType: 'application/octet-stream', + url: 'https://x/model.obj', + bytes: GLB_MAGIC, + }), + ).toBe('glb'); + // 没声明也没字节时只剩扩展名这条派生线索。 + expect( + resolveModel3dViewerFormat({ url: 'https://x/model.glb?sig=1' }), + ).toBe('glb'); + expect(resolveModel3dViewerFormat({ url: 'https://x/model.FBX' })).toBe( + 'fbx', + ); + }); + + it('三条线索都给不出答案时返回 null,交给加载流程报 unsupported-format', () => { + expect( + resolveModel3dViewerFormat({ + declaredType: 'application/octet-stream', + url: 'https://x/model.obj', + bytes: new Uint8Array([0x00, 0x01]), + }), + ).toBeNull(); + }); +}); + +describe('模型下载可中止', () => { + it('把宿主的 signal 交给 fetch,中止后抛取消错误而不是加载失败', async () => { + const controller = new AbortController(); + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; signal?: AbortSignal | null }> = []; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), signal: init?.signal }); + controller.abort(); + return Promise.reject(new DOMException('aborted', 'AbortError')); + }) as typeof fetch; + + try { + await expect( + readModel3dViewerSource( + { kind: 'url', url: 'https://x/model.glb' }, + controller.signal, + ), + ).rejects.toBeInstanceOf(Model3dViewerAbortedError); + } finally { + globalThis.fetch = originalFetch; + } + + expect(calls).toEqual([ + { url: 'https://x/model.glb', signal: controller.signal }, + ]); + }); +}); + +/** + * 签名模型地址的兄弟资源补鉴权。 + * + * 宿主给的是私有对象的签名地址,token 在 query 里;`GLTFLoader` / `FBXLoader` 用 + * `path + uri` 拼多文件模型的兄弟 buffer 与贴图,那一层只拼目录、不带 query。 + */ +describe('签名模型地址的兄弟资源补鉴权', () => { + it('从模型地址的 query 里取出鉴权参数,丢掉片段', () => { + expect( + resolveModel3dViewerSourceQuery( + 'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/model.glb?token=abc&expires=1', + ), + ).toBe('?token=abc&expires=1'); + expect( + resolveModel3dViewerSourceQuery( + 'https://bucket.oss-cn-hangzhou.aliyuncs.com/m.glb?token=abc#frag', + ), + ).toBe('?token=abc'); + expect(resolveModel3dViewerSourceQuery('https://x/m.glb?')).toBeNull(); + expect(resolveModel3dViewerSourceQuery('https://x/m.glb')).toBeNull(); + expect(resolveModel3dViewerSourceQuery(null)).toBeNull(); + }); + + it('只补模型目录下的资源地址,并保留资源自己的 query', () => { + const basePath = + 'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/'; + const query = '?token=abc'; + expect( + resolveModel3dViewerResourceUrl({ + url: `${basePath}buffer.bin`, + basePath, + query, + }), + ).toBe(`${basePath}buffer.bin?token=abc`); + expect( + resolveModel3dViewerResourceUrl({ + url: `${basePath}textures/tex.png`, + basePath, + query, + }), + ).toBe(`${basePath}textures/tex.png?token=abc`); + expect( + resolveModel3dViewerResourceUrl({ + url: `${basePath}buffer.bin?v=2`, + basePath, + query, + }), + ).toBe(`${basePath}buffer.bin?v=2&token=abc`); + }); + + it('第三方 CDN、同源其它对象、无目录与空地址都原样返回', () => { + const basePath = 'https://bucket/editor/model3d/t-1/'; + const query = '?token=abc'; + for (const input of [ + { url: 'https://cdn.example.com/tex.png', basePath, query }, + { url: 'https://bucket/other/tex.png', basePath, query }, + { + url: 'https://bucket/editor/model3d/t-1/buffer.bin', + basePath: '', + query, + }, + { url: '', basePath, query }, + ]) { + expect(resolveModel3dViewerResourceUrl(input)).toBe(input.url); + } + }); + + it('多文件 glTF 加载时把 query 补到兄弟资源地址上', async () => { + const modelUrl = + 'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/model.gltf?token=abc'; + const resolvedUrls: string[] = []; + const originalFetch = globalThis.fetch; + // 动态 import 的替身只记录一次兄弟资源解析,不真的解析 glTF。 + vi.doMock('three/examples/jsm/loaders/GLTFLoader.js', () => ({ + GLTFLoader: class { + private readonly manager: { resolveURL: (url: string) => string }; + + constructor(manager: { resolveURL: (url: string) => string }) { + this.manager = manager; + } + + parse( + _payload: unknown, + path: string, + onLoad: (gltf: { scene: object }) => void, + ) { + resolvedUrls.push(this.manager.resolveURL(`${path}buffer.bin`)); + onLoad({ scene: {} }); + } + }, + })); + globalThis.fetch = (async () => + new Response(new TextEncoder().encode('{"asset":{"version":"2.0"}}'), { + headers: { 'content-type': 'model/gltf+json' }, + })) as typeof fetch; + + try { + await loadModel3dViewerObject({ kind: 'url', url: modelUrl }); + } finally { + globalThis.fetch = originalFetch; + vi.doUnmock('three/examples/jsm/loaders/GLTFLoader.js'); + } + + expect(resolvedUrls).toEqual([ + 'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/buffer.bin?token=abc', + ]); + }); +}); diff --git a/packages/model3d-viewer/src/loader.ts b/packages/model3d-viewer/src/loader.ts new file mode 100644 index 000000000..2e9264811 --- /dev/null +++ b/packages/model3d-viewer/src/loader.ts @@ -0,0 +1,435 @@ +import type * as ThreeTypes from 'three'; + +import type { Model3dViewerFailureReason } from './status'; + +export type Model3DViewerFormat = 'glb' | 'gltf' | 'fbx'; + +export type Model3DViewerSource = + /** + * 调用方已知格式时给出;不传就由加载流程按「声明类型 → 字节魔数 → 地址扩展名」判定。 + */ + | { kind: 'url'; url: string; format?: Model3DViewerFormat } + /** + * 直接给模型字节;调用方负责持有并与生命周期一致地释放这份 buffer。 + * + * **引用必须稳定**:查看器的加载 effect 依赖 `data` 的对象引用,只比对内容。 + * 父层每次渲染都新建一份同内容的 ArrayBuffer 会触发「销毁旧场景 → 重新解析 → + * 重新上传 GPU」,表现为闪烁与资源抖动;因此请在父层缓存 buffer,或只在内容 + * 真的变化时才换新引用。 + */ + | { kind: 'bytes'; data: ArrayBuffer; format?: Model3DViewerFormat }; + +export const MODEL3D_VIEWER_SUPPORTED_FORMATS: readonly Model3DViewerFormat[] = + ['glb', 'gltf', 'fbx']; + +/** + * 声明类型里的模型格式判据。 + * + * 声明来自对象自己的内容类型:read-bytes 响应头、OSS 对象 metadata 或 + * `asset_object.content_type`,它们都是同一份值。provider 仍会写别的拼法 + * (`model/gltf+json`、`application/x-fbx`、`binary/octet-stream`),所以按子串匹配。 + */ +const MODEL3D_VIEWER_MIME_MARKERS: ReadonlyArray< + readonly [marker: string, format: Model3DViewerFormat] +> = [ + ['gltf-binary', 'glb'], + ['glb', 'glb'], + ['gltf', 'gltf'], + ['fbx', 'fbx'], +]; + +const MODEL3D_VIEWER_GLB_MAGIC = [0x67, 0x6c, 0x54, 0x46]; +const MODEL3D_VIEWER_FBX_MAGIC = [ + 0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61, 0x20, 0x46, 0x42, 0x58, 0x20, 0x42, + 0x69, 0x6e, 0x61, 0x72, 0x79, +]; +/** ASCII FBX 的开头:「; FBX 7.4.0 project file」(Blender 等工具的默认导出)。 */ +const MODEL3D_VIEWER_ASCII_FBX_MAGIC = [0x3b, 0x20, 0x46, 0x42, 0x58]; +const MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT = + MODEL3D_VIEWER_SUPPORTED_FORMATS.join(' / '); + +function matchesModel3dViewerMagic( + bytes: Uint8Array, + magic: readonly number[], +) { + if (bytes.length < magic.length) { + return false; + } + return magic.every((value, index) => bytes[index] === value); +} + +/** 跳过 BOM 与前导空白后的正文起点;全是噪声时返回 -1。 */ +function resolveModel3dViewerContentStart(bytes: Uint8Array) { + for (let index = 0; index < bytes.length; index += 1) { + const byte = bytes[index]; + if (byte === 0xef || byte === 0xbb || byte === 0xbf) { + continue; + } + if (byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d) { + continue; + } + return index; + } + return -1; +} + +function isModel3dViewerJsonStart(bytes: Uint8Array) { + // BOM 与空白之后是 `{` 才当 glTF JSON:多文件 glTF 的正文就是一个 JSON 文档。 + const start = resolveModel3dViewerContentStart(bytes); + return start >= 0 && bytes[start] === 0x7b; +} + +/** ASCII FBX 只有「看起来像」的判据,识别不出就交给显式 format 或扩展名。 */ +function isModel3dViewerAsciiFbxStart(bytes: Uint8Array) { + const start = resolveModel3dViewerContentStart(bytes); + if (start < 0) { + return false; + } + return MODEL3D_VIEWER_ASCII_FBX_MAGIC.every( + (value, offset) => bytes[start + offset] === value, + ); +} + +/** 声明类型里的模型格式;不是可渲染格式时返回 null。 */ +export function resolveModel3dViewerFormatFromMimeType( + declaredType: string | null | undefined, +): Model3DViewerFormat | null { + const normalized = declaredType?.split(';')[0]?.trim().toLowerCase() ?? ''; + if (!normalized) { + return null; + } + return ( + MODEL3D_VIEWER_MIME_MARKERS.find(([marker]) => + normalized.includes(marker), + )?.[1] ?? null + ); +} + +/** 按字节魔数识别格式;声明缺失或认不出来时才轮到它,识别不出返回 null。 */ +export function resolveModel3dViewerFormatFromBytes( + bytes: ArrayBuffer | Uint8Array, +): Model3DViewerFormat | null { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + if (matchesModel3dViewerMagic(view, MODEL3D_VIEWER_GLB_MAGIC)) { + return 'glb'; + } + if (matchesModel3dViewerMagic(view, MODEL3D_VIEWER_FBX_MAGIC)) { + return 'fbx'; + } + if (isModel3dViewerAsciiFbxStart(view)) { + return 'fbx'; + } + if (isModel3dViewerJsonStart(view)) { + return 'gltf'; + } + return null; +} + +/** 地址扩展名里的模型格式;它只是派生线索,不是格式真相。 */ +export function resolveModel3dViewerFormatFromUrl( + url: string | null | undefined, +): Model3DViewerFormat | null { + const withoutQuery = + (url?.trim() ?? '').split(/[?#]/u)[0]?.toLowerCase() ?? ''; + const match = /\.([a-z0-9]+)$/u.exec(withoutQuery); + const extension = match?.[1] ?? null; + return isModel3dViewerFormatSupported(extension) ? extension : null; +} + +/** + * 模型格式的判定顺序:声明 → 字节魔数 → 地址扩展名。 + * + * 声明(响应内容类型 / OSS metadata / `asset_object.content_type`)优先:后端的 + * `content_type` 在写入前已经按字节核过,客户端信任这份检测结果,不再自己纠正一个 + * 「认得出来但不对」的声明。魔数只在声明缺失或认不出来(例如 `application/octet-stream`) + * 时兜住,扩展名是同源派生出来的最后一条线索。三处都判不出就返回 null,由加载流程 + * 报 `unsupported-format`,宿主把原因显示给用户 —— 不猜、不半渲染。 + */ +export function resolveModel3dViewerFormat({ + declaredType, + url, + bytes, +}: { + declaredType?: string | null; + url?: string | null; + bytes?: ArrayBuffer | Uint8Array | null; +}): Model3DViewerFormat | null { + const fromDeclaredType = resolveModel3dViewerFormatFromMimeType(declaredType); + if (fromDeclaredType) { + return fromDeclaredType; + } + const fromBytes = bytes ? resolveModel3dViewerFormatFromBytes(bytes) : null; + if (fromBytes) { + return fromBytes; + } + return resolveModel3dViewerFormatFromUrl(url); +} + +export function isModel3dViewerFormatSupported( + value: unknown, +): value is Model3DViewerFormat { + return MODEL3D_VIEWER_SUPPORTED_FORMATS.includes( + value as Model3DViewerFormat, + ); +} + +/** + * 单模型可渲染上限;超过后整体失败,不做半渲染。 + * + * 64 MiB 而不是更小:3D 生成产物带 HD 纹理时本来就在几十 MB 量级,卡在 32 MiB + * 会让一整档结果永远预览不到;而完整读入后送进 WebGL 的内存代价仍在可控范围。 + */ +export const MODEL3D_VIEWER_MAX_MODEL_BYTES = 64 * 1024 * 1024; + +export function isModel3dViewerOverSize(byteLength: number): boolean { + return ( + Number.isFinite(byteLength) && byteLength > MODEL3D_VIEWER_MAX_MODEL_BYTES + ); +} + +export function describeModel3dViewerError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return typeof error === 'string' ? error : '未知错误'; +} + +export class Model3dViewerLoadError extends Error { + readonly reason: Model3dViewerFailureReason; + readonly byteLength?: number; + + constructor( + reason: Model3dViewerFailureReason, + message: string, + byteLength?: number, + ) { + super(message); + this.name = 'Model3dViewerLoadError'; + this.reason = reason; + this.byteLength = byteLength; + } +} + +export class Model3dViewerAbortedError extends Error { + constructor() { + super('模型加载已取消'); + this.name = 'Model3dViewerAbortedError'; + } +} + +function assertModel3dViewerByteLength(byteLength: number) { + if (isModel3dViewerOverSize(byteLength)) { + throw new Model3dViewerLoadError( + 'too-large', + `模型体积 ${byteLength} 字节,超过 ${MODEL3D_VIEWER_MAX_MODEL_BYTES} 字节上限`, + byteLength, + ); + } +} + +/** + * 取模型字节与声明类型:url 走一次 fetch(顺带取响应的 `Content-Type`,它就是对象 + * 自己的内容类型),bytes 直接复用调用方已读入的字节。 + * + * 宿主给了 `signal` 时把它交给 fetch:宿主取消(卸载 / 换源)后立刻停掉在途下载, + * 不再把整份字节(上限 64 MiB)读完才判断是否已取消。 + */ +export async function readModel3dViewerSource( + source: Model3DViewerSource, + signal?: AbortSignal, +): Promise<{ data: ArrayBuffer; declaredType: string | null }> { + if (source.kind === 'bytes') { + assertModel3dViewerByteLength(source.data.byteLength); + return { data: source.data, declaredType: null }; + } + + let response: Response; + try { + response = await fetch(source.url, signal ? { signal } : undefined); + } catch (error) { + if (signal?.aborted) { + throw new Model3dViewerAbortedError(); + } + throw new Model3dViewerLoadError( + 'load-failed', + `模型地址不可读取:${describeModel3dViewerError(error)}`, + ); + } + if (!response.ok) { + throw new Model3dViewerLoadError( + 'load-failed', + `模型地址返回 ${response.status}`, + ); + } + + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength)) { + assertModel3dViewerByteLength(declaredLength); + } + + let data: ArrayBuffer; + try { + data = await response.arrayBuffer(); + } catch (error) { + if (signal?.aborted) { + throw new Model3dViewerAbortedError(); + } + throw new Model3dViewerLoadError( + 'load-failed', + `模型数据读取失败:${describeModel3dViewerError(error)}`, + ); + } + assertModel3dViewerByteLength(data.byteLength); + return { data, declaredType: response.headers.get('content-type') }; +} + +function resolveModel3dViewerBasePath(source: Model3DViewerSource): string { + if (source.kind !== 'url') { + return ''; + } + const separatorIndex = source.url.lastIndexOf('/'); + return separatorIndex >= 0 ? source.url.slice(0, separatorIndex + 1) : ''; +} + +/** + * 模型地址 query 里的鉴权参数(含 `?`,丢掉 `#` 之后的部分);没有 query 返回 null。 + * + * 宿主给的多半是私有对象的签名地址(见 `getSignedAssetReadUrl`),token 就在 query 里。 + */ +export function resolveModel3dViewerSourceQuery( + url: string | null | undefined, +): string | null { + const value = url?.trim() ?? ''; + const queryIndex = value.indexOf('?'); + if (queryIndex < 0) { + return null; + } + const query = value.slice(queryIndex + 1).split('#')[0] ?? ''; + return query ? `?${query}` : null; +} + +/** + * 给模型目录下的兄弟资源地址补回模型地址的鉴权 query。 + * + * 前提:站内的 3D 产物是单文件的——`.glb` 自带几何与贴图,`.fbx` 也按媒体内嵌处理; + * 「同目录另有 buffer / 贴图」的多文件模型不会出现,即使出现,兄弟文件也只会有 + * 「模型 + 一个 bin」这一个。因此下面这段拼接只是最后一层兜底,不是对多文件模型的 + * 支持:签名是按单个文件签的,把模型地址的 token 贴到另一个文件上,云端多半以 + * SignatureDoesNotMatch 拒绝,读不到就是读不到,不指望它真能救回来。真要支持多文件 + * 模型,得改成让宿主给每个兄弟文件分别换取各自的临时地址,而不是继续扩这段拼接。 + * + * `GLTFLoader` / `FBXLoader` 用 `path + uri` 拼兄弟资源地址,签名 token 在这一步就丢了 + * (目录本身不带 query),私有对象于是 401/403。这里只补模型目录下的地址:既不把 token + * 外发到第三方 CDN,资源地址自己带的 query 也保留。 + */ +export function resolveModel3dViewerResourceUrl(input: { + url: string; + /** 模型地址所在目录,由 `resolveModel3dViewerBasePath` 得到;空串表示没有目录可补。 */ + basePath: string; + /** 模型地址的鉴权 query,由 `resolveModel3dViewerSourceQuery` 得到。 */ + query: string; +}): string { + const { url, basePath, query } = input; + if (!url || !basePath || !url.startsWith(basePath)) { + return url; + } + const hashIndex = url.indexOf('#'); + const withoutHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url; + const hash = hashIndex >= 0 ? url.slice(hashIndex) : ''; + const separator = withoutHash.includes('?') ? '&' : '?'; + return `${withoutHash}${separator}${query.slice(1)}${hash}`; +} + +/** + * 解析模型字节为 three 对象。glb / 单文件 gltf 走 GLTFLoader,fbx 走 FBXLoader; + * 多文件 gltf 的兄弟资源按模型地址目录解析,取不到时按加载失败处理。 + */ +export async function loadModel3dViewerObject( + source: Model3DViewerSource, + isAborted: () => boolean = () => false, + signal?: AbortSignal, +): Promise { + const { data, declaredType } = await readModel3dViewerSource(source, signal); + if (isAborted()) { + throw new Model3dViewerAbortedError(); + } + + // 调用方声明的格式就是最明确的一层声明;没给才落回「响应内容类型 → 魔数 → 扩展名」。 + const format = isModel3dViewerFormatSupported(source.format) + ? source.format + : resolveModel3dViewerFormat({ + declaredType, + url: source.kind === 'url' ? source.url : null, + bytes: data, + }); + if (!format) { + throw new Model3dViewerLoadError( + 'unsupported-format', + `无法识别模型格式,只支持 ${MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT}`, + ); + } + + const basePath = resolveModel3dViewerBasePath(source); + const resourceQuery = + source.kind === 'url' ? resolveModel3dViewerSourceQuery(source.url) : null; + + /** + * 兄弟资源(buffer / 贴图)的加载管理器:模型地址带 query 时才建,把同一份鉴权 + * query 补到模型目录下的资源地址上。用专用管理器而不是默认的那个,避免改动全局 + * `DefaultLoadingManager` 影响宿主其它加载器。 + */ + async function createResourceLoadingManager() { + if (!resourceQuery || !basePath) { + return undefined; + } + const { LoadingManager } = await import('three'); + const manager = new LoadingManager(); + manager.setURLModifier((url: string) => + resolveModel3dViewerResourceUrl({ + url, + basePath, + query: resourceQuery, + }), + ); + return manager; + } + + try { + if (format === 'fbx') { + const { FBXLoader } = await import( + 'three/examples/jsm/loaders/FBXLoader.js' + ); + const manager = await createResourceLoadingManager(); + return new FBXLoader(manager).parse(data, basePath); + } + + const { GLTFLoader } = await import( + 'three/examples/jsm/loaders/GLTFLoader.js' + ); + const loader = new GLTFLoader(await createResourceLoadingManager()); + const payload: string | ArrayBuffer = + format === 'gltf' ? new TextDecoder().decode(new Uint8Array(data)) : data; + const gltf = await new Promise<{ scene: ThreeTypes.Object3D }>( + (resolve, reject) => { + loader.parse(payload, basePath, resolve, (error) => { + // GLTFLoader 的 onError 回传的是 ErrorEvent(不是 Error):直接 reject, + // 外层 describeModel3dViewerError 只会拿到「未知错误」,真实解析原因被丢掉。 + const detail = + typeof error === 'object' && error !== null && 'message' in error + ? String((error as { message?: unknown }).message ?? '') + : ''; + reject(new Error(detail.trim() || describeModel3dViewerError(error))); + }); + }, + ); + return gltf.scene; + } catch (error) { + if (error instanceof Model3dViewerLoadError) { + throw error; + } + throw new Model3dViewerLoadError( + 'load-failed', + `模型解析失败:${describeModel3dViewerError(error)}`, + ); + } +} diff --git a/packages/model3d-viewer/src/scene.ts b/packages/model3d-viewer/src/scene.ts new file mode 100644 index 000000000..575f17d46 --- /dev/null +++ b/packages/model3d-viewer/src/scene.ts @@ -0,0 +1,458 @@ +import type * as ThreeTypes from 'three'; + +import { + computeModel3dViewerBoxCenter, + computeModel3dViewerBoxRadius, + computeModel3dViewerFraming, + createModel3dViewerFitSolver, + MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + type Model3dViewerFraming, + resolveModel3dViewerReframedCameraPosition, +} from './framing'; +import { + describeModel3dViewerError, + loadModel3dViewerObject, + Model3dViewerAbortedError, + Model3dViewerLoadError, + type Model3DViewerSource, +} from './loader'; + +const MAX_PIXEL_RATIO = 2; +/** 与共享组件同口径的半球光 / 主光,保证 PBR 材质可见;场景背景交给宿主。 */ +const HEMISPHERE_LIGHT_SKY = 0xffffff; +const HEMISPHERE_LIGHT_GROUND = 0x445566; +const HEMISPHERE_LIGHT_INTENSITY = 2.2; +const KEY_LIGHT_INTENSITY = 2.0; +const ENVIRONMENT_INTENSITY = 0.9; +const TONE_MAPPING_EXPOSURE = 1.1; +const DAMPING_FACTOR = 0.08; +const AUTO_ROTATE_SPEED = 1.6; +/** 单个几何最多采样多少顶点,避免超大模型在打开瞬间卡住主线程。 */ +const MAX_FIT_SAMPLES_PER_GEOMETRY = 200_000; +/** 容器宽高比相对变化超过这个比例才重算取景:轻微抖动不必打断用户当前视角。 */ +const FRAMING_ASPECT_TOLERANCE = 0.01; + +export type Model3dViewerScene = { + resetView(): void; + setAutoRotate(enabled: boolean): void; + dispose(): void; +}; + +export type Model3dViewerSceneInput = { + canvas: HTMLCanvasElement; + container: HTMLElement; + source: Model3DViewerSource; + autoRotate: boolean; + isAborted: () => boolean; + /** 宿主的中止信号:取消后连在途的模型下载一起停掉。 */ + signal?: AbortSignal; +}; + +/** 释放模型与场景里所有几何、材质、贴图,避免反复开关时显存只涨不降。 */ +export function disposeModel3dViewerObject(object: ThreeTypes.Object3D) { + object.traverse((child) => { + const mesh = child as ThreeTypes.Mesh; + mesh.geometry?.dispose?.(); + const material = mesh.material; + let materials: ThreeTypes.Material[] = []; + if (Array.isArray(material)) { + materials = material; + } else if (material) { + materials = [material]; + } + for (const entry of materials) { + for (const value of Object.values( + entry as unknown as Record, + )) { + const texture = value as + | { isTexture?: boolean; dispose?: () => void } + | undefined; + if (texture?.isTexture && typeof texture.dispose === 'function') { + texture.dispose(); + } + } + (entry as { dispose?: () => void }).dispose?.(); + } + }); +} + +/** 把模型真实顶点按世界坐标喂给取景求解器,避免包围球/包围盒空角带来的过度取景。 */ +function feedModel3dViewerFitSolver( + object: ThreeTypes.Object3D, + add: (x: number, y: number, z: number) => void, +) { + const readElement = (source: ArrayLike, index: number) => + source[index] ?? 0; + + object.updateMatrixWorld(true); + object.traverse((child) => { + const mesh = child as ThreeTypes.Mesh; + const position = mesh.geometry?.attributes?.position; + if (!position || position.count === 0) { + return; + } + const elements = mesh.matrixWorld.elements; + const m0 = readElement(elements, 0); + const m1 = readElement(elements, 1); + const m2 = readElement(elements, 2); + const m4 = readElement(elements, 4); + const m5 = readElement(elements, 5); + const m6 = readElement(elements, 6); + const m8 = readElement(elements, 8); + const m9 = readElement(elements, 9); + const m10 = readElement(elements, 10); + const m12 = readElement(elements, 12); + const m13 = readElement(elements, 13); + const m14 = readElement(elements, 14); + const push = (index: number) => { + const x = position.getX(index); + const y = position.getY(index); + const z = position.getZ(index); + add( + m0 * x + m4 * y + m8 * z + m12, + m1 * x + m5 * y + m9 * z + m13, + m2 * x + m6 * y + m10 * z + m14, + ); + }; + const stride = Math.max( + 1, + Math.ceil(position.count / MAX_FIT_SAMPLES_PER_GEOMETRY), + ); + for (let index = 0; index < position.count; index += stride) { + push(index); + } + if (stride > 1) { + // 抽样可能漏掉端点的极值,补最后一个顶点。 + push(position.count - 1); + } + }); +} + +/** + * 创建 WebGL2 交互场景:加载模型、归一化取景、轨道操作、按需释放。 + * 未挂载即完成加载时(React StrictMode 双调用)整体丢弃并释放,不留下第二个上下文。 + */ +export async function createModel3dViewerScene( + input: Model3dViewerSceneInput, +): Promise { + const { canvas, container, source, isAborted } = input; + + const [THREE, orbitControlsModule] = await Promise.all([ + import('three'), + import('three/examples/jsm/controls/OrbitControls.js'), + ]); + + let renderer: ThreeTypes.WebGLRenderer | null = null; + let scene: ThreeTypes.Scene | null = null; + let model: ThreeTypes.Object3D | null = null; + let environmentTexture: ThreeTypes.Texture | null = null; + let pmremGenerator: ThreeTypes.PMREMGenerator | null = null; + let roomEnvironment: ThreeTypes.Scene | null = null; + let controls: { dispose: () => void } | null = null; + let resizeObserver: ResizeObserver | null = null; + let frameHandle = 0; + let disposed = false; + + function teardown() { + if (disposed) { + return; + } + disposed = true; + if (frameHandle !== 0) { + window.cancelAnimationFrame(frameHandle); + frameHandle = 0; + } + resizeObserver?.disconnect(); + resizeObserver = null; + controls?.dispose(); + controls = null; + if (model) { + disposeModel3dViewerObject(model); + model = null; + } + environmentTexture?.dispose(); + environmentTexture = null; + pmremGenerator?.dispose(); + pmremGenerator = null; + if (roomEnvironment) { + disposeModel3dViewerObject(roomEnvironment); + roomEnvironment = null; + } + if (renderer) { + // 只 dispose,不 forceContextLoss:宿主全程复用同一个 , + // 丢掉上下文后下一次 new WebGLRenderer 会拿回同一个已丢失的上下文,画布全黑。 + renderer.dispose(); + renderer = null; + } + } + + function abortIfNeeded() { + if (!isAborted()) { + return; + } + teardown(); + throw new Model3dViewerAbortedError(); + } + + try { + renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + }); + } catch (error) { + throw new Model3dViewerLoadError( + 'webgl-unavailable', + `WebGL2 上下文不可用:${describeModel3dViewerError(error)}`, + ); + } + + scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera( + MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + 1, + 0.01, + 1000, + ); + renderer.setPixelRatio( + Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO), + ); + renderer.toneMapping = THREE.NeutralToneMapping; + renderer.toneMappingExposure = TONE_MAPPING_EXPOSURE; + + const { OrbitControls } = orbitControlsModule; + const orbitControls = new OrbitControls(camera, canvas); + controls = orbitControls; + orbitControls.enableDamping = true; + orbitControls.dampingFactor = DAMPING_FACTOR; + orbitControls.screenSpacePanning = true; + orbitControls.autoRotate = input.autoRotate; + orbitControls.autoRotateSpeed = AUTO_ROTATE_SPEED; + + scene.add( + new THREE.HemisphereLight( + HEMISPHERE_LIGHT_SKY, + HEMISPHERE_LIGHT_GROUND, + HEMISPHERE_LIGHT_INTENSITY, + ), + ); + const keyLight = new THREE.DirectionalLight(0xffffff, KEY_LIGHT_INTENSITY); + keyLight.position.set(2, 3, 4); + scene.add(keyLight); + + try { + const { RoomEnvironment } = await import( + 'three/examples/jsm/environments/RoomEnvironment.js' + ); + roomEnvironment = new RoomEnvironment(); + pmremGenerator = new THREE.PMREMGenerator(renderer); + environmentTexture = pmremGenerator.fromScene( + roomEnvironment, + 0.04, + ).texture; + scene.environment = environmentTexture; + scene.environmentIntensity = ENVIRONMENT_INTENSITY; + } catch { + // 环境贴图只影响材质观感,取不到时退化为纯光照渲染。 + if (roomEnvironment) { + disposeModel3dViewerObject(roomEnvironment); + roomEnvironment = null; + } + pmremGenerator?.dispose(); + pmremGenerator = null; + } + + abortIfNeeded(); + + const model3d = await loadModel3dViewerObject( + source, + isAborted, + input.signal, + ).catch((error: unknown) => { + teardown(); + throw error; + }); + model = model3d; + scene.add(model3d); + + abortIfNeeded(); + + /** + * 模型已加载,但取景 / ResizeObserver / 渲染循环还没装好:这一段里的任何异常都必须先 + * 释放 renderer 与模型资源再抛出,否则调用方拿不到 scene 引用,WebGL 上下文与 GPU 资源就漏了。 + */ + function setupLoadedScene(): Model3dViewerScene { + const bounds = new THREE.Box3().setFromObject(model3d); + const viewport = measureViewport(); + + const fitBox = { + min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z }, + max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z }, + }; + const fitSolver = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(fitBox), + aspect: viewport.aspect, + fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + fallbackRadius: computeModel3dViewerBoxRadius(fitBox), + }); + // 顶点采样只做一次:投影结果与容器宽高比无关,容器尺寸变化只是在同一份样本上重取最大值, + // 不再重复 updateMatrixWorld + 逐顶点读取(拖拽面板时 ResizeObserver 会连续回调)。 + feedModel3dViewerFitSolver(model3d, (x, y, z) => fitSolver.add(x, y, z)); + + /** + * 按给定宽高比重算取景。容器在隐藏面板里挂载时首帧量不到尺寸,只能先按方形兜底, + * 等容器真正有尺寸后必须用真实比例重算,否则模型会按方形取景、被裁掉或显得过小。 + */ + function computeFraming(aspect: number) { + return computeModel3dViewerFraming({ + box: fitBox, + aspect, + fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + distance: fitSolver.resolve(aspect), + }); + } + + let framing = computeFraming(viewport.aspect); + let framedAspect = viewport.aspect; + let hasFramedViewport = viewport.width > 0 && viewport.height > 0; + + function measureViewport(): { + width: number; + height: number; + aspect: number; + } { + const rect = container.getBoundingClientRect(); + const width = Math.max(Math.round(rect.width), 0); + const height = Math.max(Math.round(rect.height), 0); + return { + width, + height, + aspect: width > 0 && height > 0 ? width / height : 1, + }; + } + + function applyViewport() { + const current = measureViewport(); + if (current.width === 0 || current.height === 0 || !renderer) { + return; + } + renderer.setSize(current.width, current.height, false); + camera.aspect = current.aspect; + camera.updateProjectionMatrix(); + } + + /** + * 把取景套到相机与轨道控制器上。`preserveCamera` 时保留用户当前的观察方向与相对 + * 缩放,只把相机沿原方向搬到新取景距离上;否则回到取景复位视角。 + */ + function applyFraming(next: Model3dViewerFraming, preserveCamera: boolean) { + const position = preserveCamera + ? resolveModel3dViewerReframedCameraPosition({ + previous: framing, + next, + cameraPosition: { + x: camera.position.x, + y: camera.position.y, + z: camera.position.z, + }, + }) + : { + x: next.center.x + next.resetDirection.x * next.distance, + y: next.center.y + next.resetDirection.y * next.distance, + z: next.center.z + next.resetDirection.z * next.distance, + }; + camera.near = next.near; + camera.far = next.far; + camera.updateProjectionMatrix(); + orbitControls.minDistance = next.minDistance; + orbitControls.maxDistance = next.maxDistance; + orbitControls.target.set(next.center.x, next.center.y, next.center.z); + camera.position.set(position.x, position.y, position.z); + orbitControls.update(); + framing = next; + } + + /** + * 宽高比确实变化时按新比例重算取景并套用:比例没变(同比例缩放容器)就保留现有取景, + * 不打断用户当前视角。返回是否重算了取景。 + */ + function reframeIfAspectChanged(aspect: number, preserveCamera: boolean) { + if ( + hasFramedViewport && + Math.abs(aspect - framedAspect) <= + framedAspect * FRAMING_ASPECT_TOLERANCE + ) { + return false; + } + applyFraming(computeFraming(aspect), preserveCamera); + framedAspect = aspect; + hasFramedViewport = true; + return true; + } + + /** + * 复位视角:按容器当前宽高比重算取景,不复用可能过期的 `framing` —— 容器变窄后旧取景的 + * 距离与缩放上下限都不再贴合,跟着旧取景复位会把模型裁掉或放得过小。 + */ + function resetView() { + const current = measureViewport(); + const measurable = current.width > 0 && current.height > 0; + // 容器量不到尺寸(隐藏面板、首帧)时只能按现有取景复位,等量到尺寸再重算。 + applyFraming( + measurable ? computeFraming(current.aspect) : framing, + false, + ); + if (measurable) { + framedAspect = current.aspect; + hasFramedViewport = true; + } + } + + // 首帧按创建时的比例(容器量不到尺寸时是方形兜底)套一次取景;真实比例由下面的 + // ResizeObserver 首轮回调重算,重算只走缓存样本,不再重复顶点采样。 + applyFraming(framing, false); + applyViewport(); + + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => { + applyViewport(); + if (disposed) { + return; + } + const current = measureViewport(); + if (current.width === 0 || current.height === 0) { + return; + } + // 首次拿到非零尺寸:按真实比例重算取景并复位,用户此后的缩放不再被覆盖。 + // 此后只有宽高比确实变了(可缩放的面板)才重算,且保留用户当前的朝向与相对缩放。 + reframeIfAspectChanged(current.aspect, hasFramedViewport); + }); + resizeObserver.observe(container); + } + + function renderFrame() { + if (disposed || !renderer || !scene) { + return; + } + frameHandle = window.requestAnimationFrame(renderFrame); + orbitControls.update(); + renderer.render(scene, camera); + } + frameHandle = window.requestAnimationFrame(renderFrame); + + return { + resetView, + setAutoRotate(enabled: boolean) { + orbitControls.autoRotate = enabled; + }, + dispose: teardown, + }; + } + + try { + return setupLoadedScene(); + } catch (error) { + teardown(); + throw error; + } +} diff --git a/packages/model3d-viewer/src/status.ts b/packages/model3d-viewer/src/status.ts new file mode 100644 index 000000000..d36b88cd6 --- /dev/null +++ b/packages/model3d-viewer/src/status.ts @@ -0,0 +1,26 @@ +export type Model3dViewerStatus = 'idle' | 'loading' | 'ready' | 'failed'; + +export type Model3dViewerFailureReason = + | 'unsupported-format' + | 'too-large' + | 'load-failed' + | 'webgl-unavailable'; + +export type Model3dViewerStatusDetail = { + reason?: Model3dViewerFailureReason; + message?: string; + byteLength?: number; +}; + +/** 容器上的状态属性,宿主与测试按它判定渲染结果。 */ +export const MODEL3D_VIEWER_STATUS_ATTRIBUTE = 'data-3d-viewer-status'; + +export const MODEL3D_VIEWER_LOADING_TEXT = '加载中'; + +export const MODEL3D_VIEWER_ERROR_TEXT = '模型加载失败'; + +/** 失败时只渲染一行状态文案:宿主可覆盖,空串回落到内置文案。 */ +export function resolveModel3dViewerErrorText(value?: string | null): string { + const trimmed = value?.trim(); + return trimmed ? trimmed : MODEL3D_VIEWER_ERROR_TEXT; +} diff --git a/packages/shared/src/contracts/editor-canvas/EditorCanvasGenerationCompletionPayload.ts b/packages/shared/src/contracts/editor-canvas/EditorCanvasGenerationCompletionPayload.ts new file mode 100644 index 000000000..f625a40ec --- /dev/null +++ b/packages/shared/src/contracts/editor-canvas/EditorCanvasGenerationCompletionPayload.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EditorCanvasGenerationPlaceholderPayload } from './EditorCanvasGenerationPlaceholderPayload'; + +export type EditorCanvasGenerationCompletionPayload = { + dialogId?: string | null; + title: string; + placeholder: EditorCanvasGenerationPlaceholderPayload; +}; diff --git a/packages/shared/src/contracts/editor-canvas/EditorCanvasGenerationPlaceholderPayload.ts b/packages/shared/src/contracts/editor-canvas/EditorCanvasGenerationPlaceholderPayload.ts new file mode 100644 index 000000000..5d31fce9a --- /dev/null +++ b/packages/shared/src/contracts/editor-canvas/EditorCanvasGenerationPlaceholderPayload.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EditorCanvasGenerationPlaceholderPayload = { + x: number; + y: number; + width: number; + height: number; + originalWidth: number; + originalHeight: number; +}; diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 30d615d47..000df2279 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -4,3 +4,4 @@ export * from './gameCreationApp'; export * from './gameDistribution'; export * from './hostBridge'; export type * from './hyper3d'; +export type * from './model3d'; diff --git a/packages/shared/src/contracts/model3d/Model3dGenerationResult.ts b/packages/shared/src/contracts/model3d/Model3dGenerationResult.ts new file mode 100644 index 000000000..b6b6a3d64 --- /dev/null +++ b/packages/shared/src/contracts/model3d/Model3dGenerationResult.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Model3dImageToModelResult } from './image-to-model/Model3dImageToModelResult'; +import type { Model3dTextToModelResult } from './text-to-model/Model3dTextToModelResult'; + +/** + * 完成结果按端点严格 tagged enum:每个分支只携带该端点真正会产出的字段, + * 不使用“所有端点共用一份大结构 + 一堆 Option”的宽松形态。 + */ +export type Model3dGenerationResult = + | ({ kind: 'textToModel' } & Model3dTextToModelResult) + | ({ kind: 'imageToModel' } & Model3dImageToModelResult); diff --git a/packages/shared/src/contracts/model3d/common/Model3dCompression.ts b/packages/shared/src/contracts/model3d/common/Model3dCompression.ts new file mode 100644 index 000000000..acdd47bf5 --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dCompression.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Model3dCompression = 'geometry'; diff --git a/packages/shared/src/contracts/model3d/common/Model3dExportOrientation.ts b/packages/shared/src/contracts/model3d/common/Model3dExportOrientation.ts new file mode 100644 index 000000000..b41e45107 --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dExportOrientation.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Model3dExportOrientation = '+x' | '-x' | '+y' | '-y'; diff --git a/packages/shared/src/contracts/model3d/common/Model3dGeneratedArtifact.ts b/packages/shared/src/contracts/model3d/common/Model3dGeneratedArtifact.ts new file mode 100644 index 000000000..d28f06be3 --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dGeneratedArtifact.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 单个生成产物的对象元数据。 + * + * 只描述服务端已经持久化的正式对象;不带 provider task ID,也不带带签名的临时下载地址。 + */ +export type Model3dGeneratedArtifact = { + objectKey: string; + contentType: string; + /** + * 对象字节数;单体模型产物为几十 MB,远低于 2^53,按 TS number 导出。 + */ + contentLength: number; + sha256: string; +}; diff --git a/packages/shared/src/contracts/model3d/common/Model3dGenerationSource.ts b/packages/shared/src/contracts/model3d/common/Model3dGenerationSource.ts new file mode 100644 index 000000000..ee067ab28 --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dGenerationSource.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * API 层图片输入:只接受站内的画布资源或素材库对象,二者互斥。 + * + * 不接受裸字符串、远程 URL 与 data URL,因此 provider 需要的图片地址由服务端 + * 按分支校验归属后解析,客户端无法绕过归属校验直接给 provider 传地址。 + */ +export type Model3dGenerationSource = + | { kind: 'resource'; resourceId: string } + | { kind: 'asset'; assetId: string }; diff --git a/packages/shared/src/contracts/model3d/common/Model3dGenerationTargetRef.ts b/packages/shared/src/contracts/model3d/common/Model3dGenerationTargetRef.ts new file mode 100644 index 000000000..36ca403fe --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dGenerationTargetRef.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 结果落点引用:与请求落点同形的平坦可选字段,给的是已创建资源 / 素材的正式 ID。 + * + * 与其它生成工具一致,项目资源与素材库可以同时产出(前端画布链路两个都发), + * 因此两个 ID 都是可选的:哪个落点写了行就带哪个 ID,两个都写了就都带。 + */ +export type Model3dGenerationTargetRef = { + /** + * 项目资源落点的正式资源 ID;没落画布时缺省。 + */ + resourceId?: string | null; + /** + * 素材库落点的正式素材 ID;没落素材库时缺省。 + */ + assetId?: string | null; +}; diff --git a/packages/shared/src/contracts/model3d/common/Model3dGeometryQuality.ts b/packages/shared/src/contracts/model3d/common/Model3dGeometryQuality.ts new file mode 100644 index 000000000..ce0d3e4a9 --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dGeometryQuality.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Model3dGeometryQuality = 'standard' | 'detailed'; diff --git a/packages/shared/src/contracts/model3d/common/Model3dInputOrientation.ts b/packages/shared/src/contracts/model3d/common/Model3dInputOrientation.ts new file mode 100644 index 000000000..c656ed95a --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dInputOrientation.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Model3dInputOrientation = 'default' | 'align_image'; diff --git a/packages/shared/src/contracts/model3d/common/Model3dModelVersion.ts b/packages/shared/src/contracts/model3d/common/Model3dModelVersion.ts new file mode 100644 index 000000000..187eac996 --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dModelVersion.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Model3dModelVersion = + | 'v3.1-20260211' + | 'v3.0-20250812' + | 'v2.5-20250123' + | 'P1-20260311' + | 'P2-20260801'; diff --git a/packages/shared/src/contracts/model3d/common/Model3dOutputFormat.ts b/packages/shared/src/contracts/model3d/common/Model3dOutputFormat.ts new file mode 100644 index 000000000..d12790c00 --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dOutputFormat.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Model3dOutputFormat = + | 'glb' + | 'gltf' + | 'fbx' + | 'obj' + | 'stl' + | 'usdz' + | '3mf'; diff --git a/packages/shared/src/contracts/model3d/common/Model3dTaskStatus.ts b/packages/shared/src/contracts/model3d/common/Model3dTaskStatus.ts new file mode 100644 index 000000000..7e36e7212 --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dTaskStatus.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Model3dTaskStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'expired'; diff --git a/packages/shared/src/contracts/model3d/common/Model3dTextureAlignment.ts b/packages/shared/src/contracts/model3d/common/Model3dTextureAlignment.ts new file mode 100644 index 000000000..9a5635b09 --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dTextureAlignment.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Model3dTextureAlignment = 'original_image' | 'geometry'; diff --git a/packages/shared/src/contracts/model3d/common/Model3dTextureQuality.ts b/packages/shared/src/contracts/model3d/common/Model3dTextureQuality.ts new file mode 100644 index 000000000..9fe7a47bc --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dTextureQuality.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Model3dTextureQuality = + | 'fast' + | 'standard' + | 'detailed' + | 'extreme'; diff --git a/packages/shared/src/contracts/model3d/common/Model3dTextureVersion.ts b/packages/shared/src/contracts/model3d/common/Model3dTextureVersion.ts new file mode 100644 index 000000000..b61bff3ab --- /dev/null +++ b/packages/shared/src/contracts/model3d/common/Model3dTextureVersion.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Model3dTextureVersion = + | 'v3.5-20260815' + | 'v3.0-20250812' + | 'v2.5-20250123'; diff --git a/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelParams.ts b/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelParams.ts new file mode 100644 index 000000000..031f12a70 --- /dev/null +++ b/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelParams.ts @@ -0,0 +1,38 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Model3dCompression } from '../common/Model3dCompression'; +import type { Model3dExportOrientation } from '../common/Model3dExportOrientation'; +import type { Model3dGeometryQuality } from '../common/Model3dGeometryQuality'; +import type { Model3dInputOrientation } from '../common/Model3dInputOrientation'; +import type { Model3dModelVersion } from '../common/Model3dModelVersion'; +import type { Model3dTextureAlignment } from '../common/Model3dTextureAlignment'; +import type { Model3dTextureQuality } from '../common/Model3dTextureQuality'; +import type { Model3dTextureVersion } from '../common/Model3dTextureVersion'; + +/** + * image-to-model 的 provider 生成参数。 + * + * 图片本体不在 provider 参数里:客户端只能给 `Model3dGenerationSource`, + * 服务端解析归属后把 provider 需要的地址注入调用,见 `api-server::tripo3d`。 + */ +export type Model3dImageToModelParams = { + model: Model3dModelVersion; + enableImageAutofix?: boolean | null; + modelSeed?: number | null; + textureSeed?: number | null; + texture?: boolean | null; + pbr?: boolean | null; + textureQuality?: Model3dTextureQuality | null; + textureVersion?: Model3dTextureVersion | null; + delight?: boolean | null; + textureAlignment?: Model3dTextureAlignment | null; + geometryQuality?: Model3dGeometryQuality | null; + faceLimit?: number | null; + autoSize?: boolean | null; + orientation?: Model3dInputOrientation | null; + quad?: boolean | null; + smartLowPoly?: boolean | null; + generateParts?: boolean | null; + compress?: Model3dCompression | null; + exportUv?: boolean | null; + exportOrientation?: Model3dExportOrientation | null; +}; diff --git a/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelRequest.ts b/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelRequest.ts new file mode 100644 index 000000000..a5987a0f2 --- /dev/null +++ b/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelRequest.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EditorCanvasGenerationCompletionPayload } from '../../editor-canvas/EditorCanvasGenerationCompletionPayload'; +import type { Model3dGenerationSource } from '../common/Model3dGenerationSource'; +import type { Model3dImageToModelParams } from './Model3dImageToModelParams'; + +/** + * image-to-model API 请求:站内图片引用 + provider 生成参数 + 平台字段。 + * + * 结果落点与 text-to-model 同形:平坦的可选 `projectId` / `canvasCompletion` / + * `assetFolderId` / `assetLabel`,至少给一个,不用 tagged enum。 + */ +export type Model3dImageToModelRequest = { + source: Model3dGenerationSource; + generation: Model3dImageToModelParams; + /** + * 项目资源落点;与 `assetFolderId` 至少给一个,两个都给合法。 + */ + projectId?: string | null; + /** + * 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。 + */ + canvasCompletion?: EditorCanvasGenerationCompletionPayload | null; + /** + * 素材库落点;与 `projectId` 至少给一个,两个都给合法。 + */ + assetFolderId?: string | null; + /** + * 素材名称;缺省用平台默认名。 + */ + assetLabel?: string | null; +}; diff --git a/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelResult.ts b/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelResult.ts new file mode 100644 index 000000000..2f710bad1 --- /dev/null +++ b/packages/shared/src/contracts/model3d/image-to-model/Model3dImageToModelResult.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Model3dGeneratedArtifact } from '../common/Model3dGeneratedArtifact'; +import type { Model3dGenerationTargetRef } from '../common/Model3dGenerationTargetRef'; + +/** + * image-to-model 的完成结果:只包含正式资源引用与已持久化产物。 + */ +export type Model3dImageToModelResult = { + target: Model3dGenerationTargetRef; + model: Model3dGeneratedArtifact; + preview: Model3dGeneratedArtifact; +}; diff --git a/packages/shared/src/contracts/model3d/index.ts b/packages/shared/src/contracts/model3d/index.ts new file mode 100644 index 000000000..ff7d09170 --- /dev/null +++ b/packages/shared/src/contracts/model3d/index.ts @@ -0,0 +1,23 @@ +export type * from './common/Model3dCompression'; +export type * from './common/Model3dExportOrientation'; +export type * from './common/Model3dGeneratedArtifact'; +export type * from './common/Model3dGenerationSource'; +export type * from './common/Model3dGenerationTargetRef'; +export type * from './common/Model3dGeometryQuality'; +export type * from './common/Model3dInputOrientation'; +export type * from './common/Model3dModelVersion'; +export type * from './common/Model3dOutputFormat'; +export type * from './common/Model3dTaskStatus'; +export type * from './common/Model3dTextureAlignment'; +export type * from './common/Model3dTextureQuality'; +export type * from './common/Model3dTextureVersion'; +export type * from './image-to-model/Model3dImageToModelParams'; +export type * from './image-to-model/Model3dImageToModelRequest'; +export type * from './image-to-model/Model3dImageToModelResult'; +export type * from './Model3dGenerationResult'; +export type * from './multiview-to-model/Model3dMultiviewInputs'; +export type * from './multiview-to-model/Model3dMultiviewToModelRequest'; +export type * from './multiview-to-model/Model3dViewInput'; +export type * from './text-to-model/Model3dTextToModelParams'; +export type * from './text-to-model/Model3dTextToModelRequest'; +export type * from './text-to-model/Model3dTextToModelResult'; diff --git a/packages/shared/src/contracts/model3d/multiview-to-model/Model3dMultiviewInputs.ts b/packages/shared/src/contracts/model3d/multiview-to-model/Model3dMultiviewInputs.ts new file mode 100644 index 000000000..7dd1db54a --- /dev/null +++ b/packages/shared/src/contracts/model3d/multiview-to-model/Model3dMultiviewInputs.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Model3dViewInput } from './Model3dViewInput'; + +export type Model3dMultiviewInputs = + | { + kind: 'views'; + front: Model3dViewInput; + left?: Model3dViewInput | null; + back?: Model3dViewInput | null; + right?: Model3dViewInput | null; + } + | { kind: 'taskId'; taskId: string }; diff --git a/packages/shared/src/contracts/model3d/multiview-to-model/Model3dMultiviewToModelRequest.ts b/packages/shared/src/contracts/model3d/multiview-to-model/Model3dMultiviewToModelRequest.ts new file mode 100644 index 000000000..4d8f8aee9 --- /dev/null +++ b/packages/shared/src/contracts/model3d/multiview-to-model/Model3dMultiviewToModelRequest.ts @@ -0,0 +1,99 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Model3dCompression } from '../common/Model3dCompression'; +import type { Model3dExportOrientation } from '../common/Model3dExportOrientation'; +import type { Model3dGeometryQuality } from '../common/Model3dGeometryQuality'; +import type { Model3dInputOrientation } from '../common/Model3dInputOrientation'; +import type { Model3dModelVersion } from '../common/Model3dModelVersion'; +import type { Model3dTextureAlignment } from '../common/Model3dTextureAlignment'; +import type { Model3dTextureQuality } from '../common/Model3dTextureQuality'; +import type { Model3dTextureVersion } from '../common/Model3dTextureVersion'; +import type { Model3dMultiviewInputs } from './Model3dMultiviewInputs'; + +/** + * 多视图生成 3D 的请求:`inputs` 必填,其余生成参数可选。 + * + * 严格反序列化:顶层与 `inputs` 分支都不接受未知字段,字段取值非法在反序列化阶段即拒绝; + * 组合合法性(模型能力、参数互斥)由 provider 侧提交前统一校验。 + */ +export type Model3dMultiviewToModelRequest = { + /** + * 视图输入:`views` 直接给多视图,`taskId` 复用已有任务的视图数据。 + */ + inputs: Model3dMultiviewInputs; + /** + * 模型版本;决定模型家族与可用的生成能力。 + */ + model: Model3dModelVersion; + /** + * 模型随机种子;固定后可复现同一几何体。 + */ + modelSeed?: number | null; + /** + * 贴图随机种子;固定后可复现同一贴图。 + */ + textureSeed?: number | null; + /** + * 是否生成贴图;`false` 时只产出白模几何体。 + */ + texture?: boolean | null; + /** + * 是否生成 PBR 材质;依赖贴图,`generateParts=true` 时必须为 `false`。 + */ + pbr?: boolean | null; + /** + * 贴图质量;`fast` 必须显式配 `textureVersion=v3.5-20260815`。 + */ + textureQuality?: Model3dTextureQuality | null; + /** + * 贴图模型版本;SDK params 未命名该字段,由 provider 侧按 extra 透传。 + */ + textureVersion?: Model3dTextureVersion | null; + /** + * 是否去除输入图光照;SDK params 未命名该字段,由 provider 侧按 extra 透传。 + */ + delight?: boolean | null; + /** + * 几何质量;仅 v3.1 / v3.0 家族接受 `detailed`。 + */ + geometryQuality?: Model3dGeometryQuality | null; + /** + * 贴图对齐方式:`originalImage` 保留输入图朝向,`geometry` 对齐到几何体。 + */ + textureAlignment?: Model3dTextureAlignment | null; + /** + * 面数上限;允许区间随模型版本、`quad` 与 `smartLowPoly` 变化,取值不超过 2_000_000。 + */ + faceLimit?: number | null; + /** + * 是否按真实尺度自动缩放输出;v2.5 家族不支持。 + */ + autoSize?: boolean | null; + /** + * 输入视图的朝向口径;`alignImage` 按图片方向对齐。 + */ + orientation?: Model3dInputOrientation | null; + /** + * 是否输出四边面网格;仅 v3.x 与 P2 家族支持。 + */ + quad?: boolean | null; + /** + * 是否启用智能低模;仅 v3.x 家族支持。 + */ + smartLowPoly?: boolean | null; + /** + * 是否拆分模型部件;要求 `texture=false`、`pbr=false`,且不能与 `quad` / `smartLowPoly` 同用。 + */ + generateParts?: boolean | null; + /** + * 几何体压缩方式;仅 v3.x 家族支持。 + */ + compress?: Model3dCompression | null; + /** + * 是否在产物里导出 UV。 + */ + exportUv?: boolean | null; + /** + * 产物朝向,取值 `+x` / `-x` / `+y` / `-y`。 + */ + exportOrientation?: Model3dExportOrientation | null; +}; diff --git a/packages/shared/src/contracts/model3d/multiview-to-model/Model3dViewInput.ts b/packages/shared/src/contracts/model3d/multiview-to-model/Model3dViewInput.ts new file mode 100644 index 000000000..71e98057a --- /dev/null +++ b/packages/shared/src/contracts/model3d/multiview-to-model/Model3dViewInput.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 单个视图的输入形态:公网可读地址或 provider 上传返回的 `file_token`,显式二选一。 + * + * 不用裸字符串:Tripo 文档里 `inputs` 的取值可以是 URL、file_token 或嵌套 + * `{url}` / `{file_token}` / `{object:{bucket,key}}`,裸字符串要靠服务端按前缀猜种类, + * file_token 与 task_id 尤其容易混。契约里写明「哪一种」,平台层就能构造显式对象, + * 提交链路上不再有猜测。 + * + * 平台自己只产出这两种:站内引用先上传换成 `file_token`,公网地址按 `url` 直传。 + */ +export type Model3dViewInput = + | { kind: 'url'; url: string } + | { kind: 'fileToken'; fileToken: string }; diff --git a/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelParams.ts b/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelParams.ts new file mode 100644 index 000000000..e8520db02 --- /dev/null +++ b/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelParams.ts @@ -0,0 +1,36 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Model3dCompression } from '../common/Model3dCompression'; +import type { Model3dExportOrientation } from '../common/Model3dExportOrientation'; +import type { Model3dGeometryQuality } from '../common/Model3dGeometryQuality'; +import type { Model3dModelVersion } from '../common/Model3dModelVersion'; +import type { Model3dTextureQuality } from '../common/Model3dTextureQuality'; +import type { Model3dTextureVersion } from '../common/Model3dTextureVersion'; + +/** + * text-to-model 的 provider 生成参数。 + * + * 这里保持与 provider 一致的宽松形态(未提供的参数由 provider 取默认值), + * 平台侧的必填口径与组合校验在调用 provider 之前完成,见 `api-server::tripo3d::validation`。 + */ +export type Model3dTextToModelParams = { + prompt: string; + model: Model3dModelVersion; + negativePrompt?: string | null; + imageSeed?: number | null; + modelSeed?: number | null; + textureSeed?: number | null; + texture?: boolean | null; + pbr?: boolean | null; + textureQuality?: Model3dTextureQuality | null; + textureVersion?: Model3dTextureVersion | null; + delight?: boolean | null; + geometryQuality?: Model3dGeometryQuality | null; + faceLimit?: number | null; + autoSize?: boolean | null; + quad?: boolean | null; + smartLowPoly?: boolean | null; + generateParts?: boolean | null; + compress?: Model3dCompression | null; + exportUv?: boolean | null; + exportOrientation?: Model3dExportOrientation | null; +}; diff --git a/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelRequest.ts b/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelRequest.ts new file mode 100644 index 000000000..26eda1340 --- /dev/null +++ b/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelRequest.ts @@ -0,0 +1,34 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EditorCanvasGenerationCompletionPayload } from '../../editor-canvas/EditorCanvasGenerationCompletionPayload'; +import type { Model3dTextToModelParams } from './Model3dTextToModelParams'; + +/** + * text-to-model API 请求:provider 生成参数 + 平台字段。 + * + * 生成参数单独嵌一层而不是摊平到顶层:serde 的 `deny_unknown_fields` 与 `flatten` + * 不能共存,摊平会让顶层未知字段静默通过。 + * + * 结果落点与其它生成接口同形:平坦的可选 `projectId` / `canvasCompletion` / + * `assetFolderId` / `assetLabel`,不用 tagged enum —— 客户端不必为「落项目还是落素材库」 + * 多拼一层判别结构。服务端按「至少一个落点」校验:两个都不给拒绝,两个都给合法(两条落点都落); + * 没有 `projectId` 时结果只落素材库,与其它画布生成工具一致。 + */ +export type Model3dTextToModelRequest = { + generation: Model3dTextToModelParams; + /** + * 项目资源落点;与 `assetFolderId` 至少给一个,两个都给合法。 + */ + projectId?: string | null; + /** + * 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。 + */ + canvasCompletion?: EditorCanvasGenerationCompletionPayload | null; + /** + * 素材库落点;与 `projectId` 至少给一个,两个都给合法。 + */ + assetFolderId?: string | null; + /** + * 素材名称;缺省用平台默认名。 + */ + assetLabel?: string | null; +}; diff --git a/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelResult.ts b/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelResult.ts new file mode 100644 index 000000000..cdf3f98b1 --- /dev/null +++ b/packages/shared/src/contracts/model3d/text-to-model/Model3dTextToModelResult.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Model3dGeneratedArtifact } from '../common/Model3dGeneratedArtifact'; +import type { Model3dGenerationTargetRef } from '../common/Model3dGenerationTargetRef'; + +/** + * text-to-model 的完成结果:只包含正式资源引用与已持久化产物。 + */ +export type Model3dTextToModelResult = { + target: Model3dGenerationTargetRef; + model: Model3dGeneratedArtifact; + preview: Model3dGeneratedArtifact; +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 87fb5dc00..ef609e55b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from './contracts/editorScene'; export * from './contracts/externalGeneration'; export * from './contracts/gameDistribution'; export type * from './contracts/hyper3d'; +export type * from './contracts/model3d'; export * from './contracts/runtime'; export * from './http'; export * from './llm/narrativeLanguage'; diff --git a/scripts/check-npm-workspaces.mjs b/scripts/check-npm-workspaces.mjs index 55bc2f5fb..05714329e 100644 --- a/scripts/check-npm-workspaces.mjs +++ b/scripts/check-npm-workspaces.mjs @@ -13,6 +13,7 @@ export const REQUIRED_WORKSPACES = Object.freeze([ 'packages/image-canvas-core', 'packages/image-canvas-react', 'packages/agc-plugin-sdk', + 'packages/model3d-viewer', 'packages/shared', 'plugins/agc-cocos-editor', 'plugins/agc-unity-editor', @@ -29,6 +30,7 @@ const WORKSPACE_NAMES = Object.freeze({ 'packages/image-canvas-core': '@genarrative/image-canvas-core', 'packages/image-canvas-react': '@genarrative/image-canvas-react', 'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk', + 'packages/model3d-viewer': '@genarrative/model3d-viewer', 'packages/shared': '@genarrative/shared', 'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor', 'plugins/agc-unity-editor': '@genarrative/agc-plugin-unity-editor', diff --git a/scripts/check-npm-workspaces.test.mjs b/scripts/check-npm-workspaces.test.mjs index 0f04973ee..97c137981 100644 --- a/scripts/check-npm-workspaces.test.mjs +++ b/scripts/check-npm-workspaces.test.mjs @@ -21,6 +21,7 @@ const workspaceNames = { 'packages/image-canvas-core': '@genarrative/image-canvas-core', 'packages/image-canvas-react': '@genarrative/image-canvas-react', 'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk', + 'packages/model3d-viewer': '@genarrative/model3d-viewer', 'packages/shared': '@genarrative/shared', 'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor', 'plugins/agc-unity-editor': '@genarrative/agc-plugin-unity-editor', diff --git a/scripts/container-worker-smoke.mjs b/scripts/container-worker-smoke.mjs index 1beed7036..6b4ce8f45 100644 --- a/scripts/container-worker-smoke.mjs +++ b/scripts/container-worker-smoke.mjs @@ -523,6 +523,10 @@ ALIYUN_OSS_BUCKET= ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com ALIYUN_OSS_ACCESS_KEY_ID= ALIYUN_OSS_ACCESS_KEY_SECRET= +# 3D provider 启动期门禁:api / external-generation-worker 角色缺这两项就拒绝启动。 +# smoke 只跑 unsupported job,不访问真实 Tripo,这里用占位值让进程能起。 +TRIPO_BASE_URL=https://openapi.tripo3d.com/v3 +TRIPO_API_KEY=worker-smoke-tripo-key WECHAT_MINIPROGRAM_MESSAGE_TOKEN= WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY= `; diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index ac57523c6..05203df84 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -229,6 +229,7 @@ dependencies = [ "platform-matting", "platform-oss", "platform-speech", + "platform-tripo", "platform-wechat", "png", "regex", @@ -4175,6 +4176,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "platform-tripo" +version = "0.1.0" +dependencies = [ + "bytes", + "reqwest", + "serde", + "serde_json", + "shared-contracts", + "tokio", + "tripo3d-sdk", + "url", +] + [[package]] name = "platform-wechat" version = "0.1.0" @@ -6309,6 +6324,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "tripo3d-sdk" +version = "0.1.0" +source = "git+https://github.com/VAST-AI-Research/tripo-rust-sdk.git?rev=1986be88627b04b1fa582a795bf6ac15ae6e0c6d#1986be88627b04b1fa582a795bf6ac15ae6e0c6d" +dependencies = [ + "bytes", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "url", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6318,8 +6348,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ts-rs" version = "12.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003" dependencies = [ "thiserror 2.0.18", "ts-rs-macros", @@ -6328,9 +6357,9 @@ dependencies = [ [[package]] name = "ts-rs-macros" version = "12.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003" dependencies = [ + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.118", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 33df718b3..2873ed107 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -30,6 +30,7 @@ members = [ "crates/platform-matting", "crates/platform-wechat", "crates/platform-speech", + "crates/platform-tripo", "crates/platform-editor-agent", "crates/pingora-gateway", "crates/preview-deployer-server", @@ -65,6 +66,7 @@ platform-llm = { path = "crates/platform-llm", default-features = false } platform-matting = { path = "crates/platform-matting", default-features = false } platform-oss = { path = "crates/platform-oss", default-features = false } platform-speech = { path = "crates/platform-speech", default-features = false } +platform-tripo = { path = "crates/platform-tripo", default-features = false } platform-wechat = { path = "crates/platform-wechat", default-features = false } pingora-gateway = { path = "crates/pingora-gateway", default-features = false } shared-contracts = { path = "crates/shared-contracts", default-features = false } @@ -107,7 +109,6 @@ serde_json = "1" serde_urlencoded = "0.7" sha1 = "0.10" sha2 = "0.10" -ts-rs = "12.0.1" socket2 = "0.6" symphonia = { version = "0.5", default-features = false, features = ["mp3"] } spacetimedb = "=2.8.3" @@ -117,6 +118,18 @@ time = "0.3" tokio = "1" tokio-stream = "0.1" tokio-tungstenite = "0.27" +tripo3d-sdk = { git = "https://github.com/VAST-AI-Research/tripo-rust-sdk.git", rev = "1986be88627b04b1fa582a795bf6ac15ae6e0c6d" } +# 已发布到 crates.io 的 ts-rs 12.0.1 解析不了 enum 级 `serde(deny_unknown_fields)`, +# 编译时会打印一条没有 span 的 “failed to parse serde attribute” 告警(该属性仍由 serde 生效)。 +# 上游修复 #491 尚未发版(最新 tag 仍为 v12.0.0,main 版本号还是 12.0.1),因此固定到修复 commit; +# 等 crates.io 发布 > 12.0.1 后改回 registry 版本并删除本固定。不要改用 +# `no-serde-warnings` 关告警:那会连同真实未支持的 serde 属性一起静默。 +# 严格性契约测试见 shared-contracts/tests/model3d_multiview_request_contract.rs。 +# ts-rs 的 feature 一律保持全开(默认 features 原样保留):不要写 `default-features = false`, +# 也不要逐个关掉 —— 任何收窄都会让「哪些 serde 属性被解析、哪些类型有 TS 绑定」变成两套口径, +# 成员 crate 需要新能力时优先用默认已覆盖的部分,不要自己另开开关。 +# 生成命令固定为 `cargo test -p shared-contracts export_bindings`(见 packages/shared)。 +ts-rs = { git = "https://github.com/Aleph-Alpha/ts-rs", rev = "fd8679ef20f866f747c046b30bd29c15e3d7a003" } tower = "0.5" tower-http = "0.6" tracing = "0.1" diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 6292a9490..25343eae9 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -34,6 +34,7 @@ platform-llm = { workspace = true } platform-matting = { workspace = true } platform-oss = { workspace = true } platform-speech = { workspace = true } +platform-tripo = { workspace = true } platform-wechat = { workspace = true } hmac = { workspace = true } ring = { workspace = true } diff --git a/server-rs/crates/api-server/config/editor-generation-pricing.default.json b/server-rs/crates/api-server/config/editor-generation-pricing.default.json index 18d18f1b7..54d1cbfc4 100644 --- a/server-rs/crates/api-server/config/editor-generation-pricing.default.json +++ b/server-rs/crates/api-server/config/editor-generation-pricing.default.json @@ -75,5 +75,41 @@ "unit": "perGeneration", "price": 12 } + }, + "model3d": { + "textToModelPricing": { + "versionPrices": { + "v3.1-20260211": { "noTexture": 8, "texture": 16 }, + "v3.0-20250812": { "noTexture": 8, "texture": 16 }, + "v2.5-20250123": { "noTexture": 8, "texture": 16 }, + "P1-20260311": { "noTexture": 24, "texture": 32 }, + "P2-20260801": { "noTexture": 80, "texture": 88 } + }, + "addOnPrices": { + "hdTexture": 8, + "ultraTexture": 16, + "hdGeometry": 16, + "quadMesh": 4, + "smartLowPoly": 8, + "generateParts": 16 + } + }, + "imageToModelPricing": { + "versionPrices": { + "v3.1-20260211": { "noTexture": 16, "texture": 24 }, + "v3.0-20250812": { "noTexture": 16, "texture": 24 }, + "v2.5-20250123": { "noTexture": 16, "texture": 24 }, + "P1-20260311": { "noTexture": 32, "texture": 40 }, + "P2-20260801": { "noTexture": 80, "texture": 88 } + }, + "addOnPrices": { + "hdTexture": 8, + "ultraTexture": 16, + "hdGeometry": 16, + "quadMesh": 4, + "smartLowPoly": 8, + "generateParts": 16 + } + } } } diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index cb5971076..9329d07d7 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -18,7 +18,7 @@ use axum::{ }; use platform_auth::{hash_password, verify_password}; use reqwest::Client; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use shared_contracts::admin::{ ADMIN_ACTION_PERMISSIONS, ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, @@ -79,7 +79,7 @@ use crate::{ AssetReadAuthorization, confirm_asset_object_for_owner, create_direct_upload_ticket_for_owner, get_asset_read_url_with_query, }, - editor_generation_config::EditorGenerationPricingConfig, + editor_generation_config::{EditorGenerationModelPricing, EditorGenerationPricingConfig}, editor_project::{ current_utc_micros, normalize_editor_image_sequence_frames_value, resolve_editor_asset_kind, sanitize_editor_generation_inputs, @@ -88,6 +88,7 @@ use crate::{ request_context::RequestContext, state::{AdminRuntime, AppState}, tracking::{TrackingEventDraft, record_tracking_event_after_success}, + tripo3d::pricing::Model3dPricingConfig, work_author::resolve_work_author_by_user_id, }; @@ -957,6 +958,25 @@ pub async fn admin_upsert_feature_gate_config( )) } +/// 后台定价响应:`models` 与 3D 两段原样给出。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminEditorGenerationPricingResponse { + pub models: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub model3d: Option, +} + +/// 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出,不接受「省略即沿用」。 +/// +/// 不带定价版本:保存不做版本比对,整段覆盖就是最终状态(后台同时只有一个管理员在操作)。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertEditorGenerationPricingRequest { + pub models: BTreeMap, + pub model3d: Option, +} + /// 后台读取画布生成模型定价配置。 pub async fn admin_get_editor_generation_pricing( State(state): State, @@ -967,7 +987,10 @@ pub async fn admin_get_editor_generation_pricing( .editor_generation_pricing() .await .map_err(map_admin_editor_generation_pricing_error)?; - Ok(json_success_body(Some(&request_context), pricing)) + Ok(json_success_body( + Some(&request_context), + build_admin_editor_generation_pricing_response(pricing), + )) } /// 后台保存画布生成模型定价配置,写入 SpacetimeDB 全局配置表。 @@ -975,13 +998,44 @@ pub async fn admin_upsert_editor_generation_pricing( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, - Json(payload): Json, + Json(payload): Json, ) -> Result, AppError> { + let pricing = validate_admin_editor_generation_pricing_payload(payload)?; let pricing = state - .save_editor_generation_pricing(admin.session().subject.clone(), payload) + .save_editor_generation_pricing(admin.session().subject.clone(), pricing) .await .map_err(map_admin_editor_generation_pricing_error)?; - Ok(json_success_body(Some(&request_context), pricing)) + Ok(json_success_body( + Some(&request_context), + build_admin_editor_generation_pricing_response(pricing), + )) +} + +fn build_admin_editor_generation_pricing_response( + pricing: EditorGenerationPricingConfig, +) -> AdminEditorGenerationPricingResponse { + AdminEditorGenerationPricingResponse { + models: pricing.models, + model3d: pricing.model3d, + } +} + +/// 保存前的形状校验:3D 两段与定价版本都必须显式给出。 +/// +/// 3D 段不能省略或置空——那正是「改一次图 / 视频价就把 3D 段清掉」这类静默覆盖的来源; +/// 键集合由契约决定,后台只能改数值,因此这里不接受的只是「没给」和「给不齐」。 +fn validate_admin_editor_generation_pricing_payload( + payload: AdminUpsertEditorGenerationPricingRequest, +) -> Result { + let Some(model3d) = payload.model3d else { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message( + "model3d 定价段必须整段给出(含 textToModelPricing 与 imageToModelPricing),不能省略或置空", + )); + }; + Ok(EditorGenerationPricingConfig { + models: payload.models, + model3d: Some(model3d), + }) } #[cfg(any())] diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 201f2327c..89f01a851 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -53,6 +53,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::editor_project::router(state.clone())) .merge(modules::platform::router(state.clone())) .merge(modules::external_generation::router(state.clone())) + .merge(crate::tripo3d::router(state.clone())) .merge(modules::platform_support::router(state.clone())) .merge(modules::raw::router(state.clone())) .merge(modules::project_snapshots::router(state.clone())) @@ -6094,74 +6095,118 @@ mod tests { payload["models"]["seedance2.0"]["prices"]["720p"], Value::Number(24.into()) ); + // 公开读模型的 3D 段保持迁移前的旧形状:前端画布按端点读底价、按单表读加价项。 + assert_eq!( + payload["model3d"]["basePrices"]["text-to-model"]["v3.1-20260211"]["noTexture"], + Value::Number(8.into()) + ); + assert_eq!( + payload["model3d"]["basePrices"]["image-to-model"]["v3.1-20260211"]["texture"], + Value::Number(24.into()) + ); + assert_eq!( + payload["model3d"]["addOnPrices"]["quadMesh"], + Value::Number(4.into()) + ); + assert!(payload["model3d"]["textToModelPricing"].is_null()); } - #[tokio::test] - async fn admin_editor_generation_pricing_route_saves_config_and_updates_public_route() { + /// 后台定价读取与保存共用的登录态:测试里返回应用与管理员 token。 + async fn admin_pricing_test_app() -> (Router, String) { let mut config = AppConfig::default(); config.admin_username = Some("root".to_string()); config.admin_password = Some("secret123".to_string()); let app = build_router(AppState::new(config).expect("state should build")); let admin_token = read_admin_access_token(app.clone()).await; + (app, admin_token) + } + /// 后台定价读取:返回完整响应体,包含定价版本与 3D 两段。 + async fn read_admin_editor_generation_pricing(app: &Router, admin_token: &str) -> Value { let response = app .clone() + .oneshot( + Request::builder() + .uri("/admin/api/editor-generation-pricing") + .header("authorization", format!("Bearer {admin_token}")) + .body(Body::empty()) + .expect("admin pricing request should build"), + ) + .await + .expect("admin pricing request should succeed"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("admin pricing body should collect") + .to_bytes(); + serde_json::from_slice(&body).expect("admin pricing payload should be json") + } + + /// 后台保存用的完整模型矩阵:数值都改成与默认值不同,便于断言真的落库了。 + fn admin_pricing_models_payload() -> Value { + serde_json::json!({ + "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 }, + "eleven_text_to_sound_v2": { "unit": "perGeneration", "price": 16 }, + "chirp-v5": { "unit": "perGeneration", "price": 9 } + }) + } + + async fn save_admin_editor_generation_pricing( + app: &Router, + admin_token: &str, + payload: Value, + ) -> axum::response::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 }, - "eleven_text_to_sound_v2": { "unit": "perGeneration", "price": 16 }, - "chirp-v5": { "unit": "perGeneration", "price": 9 } - } - }) - .to_string(), - )) + .body(Body::from(payload.to_string())) .expect("pricing save request should build"), ) .await - .expect("pricing save request should succeed"); + .expect("pricing save request should succeed") + } - assert_eq!(response.status(), StatusCode::OK); - - let public_response = app + async fn read_public_editor_generation_pricing(app: &Router) -> Value { + let response = app + .clone() .oneshot( Request::builder() .uri("/api/editor/generation-pricing") @@ -6170,15 +6215,60 @@ mod tests { ) .await .expect("public pricing request should succeed"); - let body = public_response + let body = 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"); + serde_json::from_slice(&body).expect("public pricing payload should be json") + } + #[tokio::test] + async fn admin_editor_generation_pricing_route_saves_config_and_updates_public_route() { + let (app, admin_token) = admin_pricing_test_app().await; + + // 与后台页面同一套流程:先读旧值,改完整段回传。 + let before = read_admin_editor_generation_pricing(&app, &admin_token).await; + let mut model3d = before["model3d"].clone(); + assert!(!model3d.is_null(), "默认定价必须带 3D 两段"); + // 先按路径读到目标键再改:`Value::index_mut` 在键缺失时直接 panic,默认定价 + // 一旦换版本号或重命名键,只会看到一个没有上下文的 panic。先断言存在, + // fixture 漂移时就能看清是哪一层对不上。 + *model3d + .pointer_mut("/textToModelPricing/addOnPrices/quadMesh") + .expect("默认定价必须带 textToModelPricing.addOnPrices.quadMesh") = + Value::Number(9.into()); + *model3d + .pointer_mut("/imageToModelPricing/versionPrices/P2-20260801/texture") + .expect("默认定价必须带 imageToModelPricing.versionPrices.P2-20260801.texture") = + Value::Number(99.into()); + + let response = save_admin_editor_generation_pricing( + &app, + &admin_token, + serde_json::json!({ + "models": admin_pricing_models_payload(), + "model3d": model3d, + }), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("pricing save body should collect") + .to_bytes(); + let saved: Value = + serde_json::from_slice(&body).expect("pricing save payload should be json"); + assert_eq!( + saved["model3d"]["textToModelPricing"]["addOnPrices"]["quadMesh"], + Value::Number(9.into()) + ); + + let payload = read_public_editor_generation_pricing(&app).await; assert_eq!( payload["models"]["gpt-image-2"]["prices"]["2K"], Value::Number(62.into()) @@ -6195,6 +6285,51 @@ mod tests { payload["models"]["seedance2.0"]["prices"]["720p"], Value::Number(26.into()) ); + // 公开读模型仍是旧形状,但数值必须已经切到后台保存的新价。 + assert_eq!( + payload["model3d"]["addOnPrices"]["quadMesh"], + Value::Number(9.into()) + ); + assert_eq!( + payload["model3d"]["basePrices"]["image-to-model"]["P2-20260801"]["texture"], + Value::Number(99.into()) + ); + } + + #[tokio::test] + async fn admin_editor_generation_pricing_route_rejects_missing_model3d_section() { + let (app, admin_token) = admin_pricing_test_app().await; + let before = read_admin_editor_generation_pricing(&app, &admin_token).await; + + let response = save_admin_editor_generation_pricing( + &app, + &admin_token, + serde_json::json!({ + "models": admin_pricing_models_payload(), + }), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + 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!( + payload["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("定价段必须整段给出")), + "缺 3D 段必须以中文文案拒绝,实际为 {payload}" + ); + // 拒绝的保存不能落库:旧价必须原样保留。 + let after = read_admin_editor_generation_pricing(&app, &admin_token).await; + assert_eq!( + after["models"]["gpt-image-2"]["prices"]["2K"], + before["models"]["gpt-image-2"]["prices"]["2K"] + ); } /// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。 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 bfe357866..c2054e9e8 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -620,7 +620,7 @@ pub async fn generate_editor_character_animation( return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), + queue_state: editor_generation_queue_state(&queue_job), }, )); } @@ -1086,7 +1086,7 @@ pub async fn generate_editor_video( return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), + queue_state: editor_generation_queue_state(&queue_job), }, )); } @@ -3654,7 +3654,7 @@ fn prepare_editor_video_result( } fn build_editor_character_animation_canvas_placement_item( - completion: &shared_contracts::assets::EditorCanvasGenerationCompletionPayload, + completion: &shared_contracts::editor_canvas::EditorCanvasGenerationCompletionPayload, resource_id: &str, width: u32, height: u32, diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 1b71b2cda..2b196579c 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -19,6 +19,11 @@ const DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS: u64 = 900; const DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS: u64 = 1_800; pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000; pub(crate) const DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS: u64 = 180_000; +/// Tripo 网关没有内置默认值:地址与密钥都必须在 env 里显式给出,缺失即拒绝启动 +/// (见 `main::validate_tripo_config_for_startup`)。3D 生成要按真实额度扣费, +/// 静默回落到某个网关等于把请求发向运维不知道的账号。 +pub(crate) const DEFAULT_TRIPO_REQUEST_TIMEOUT_MS: u64 = 60_000; +const DEFAULT_TRIPO_RETRIES: u32 = 2; const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter"; const DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS: u64 = 5_000; const BGFILTER_ATTEMPT_SAFETY_FACTOR: u64 = 2; @@ -225,6 +230,10 @@ pub struct AppConfig { pub hyper3d_base_url: String, pub hyper3d_api_key: Option, pub hyper3d_model_request_timeout_ms: u64, + pub tripo_base_url: String, + pub tripo_api_key: Option, + pub tripo_request_timeout_ms: u64, + pub tripo_retries: u32, pub volcengine_speech_api_key: Option, pub volcengine_speech_app_id: Option, pub volcengine_speech_access_key: Option, @@ -532,6 +541,10 @@ impl Default for AppConfig { hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(), hyper3d_api_key: None, hyper3d_model_request_timeout_ms: 180_000, + tripo_base_url: String::new(), + tripo_api_key: None, + tripo_request_timeout_ms: DEFAULT_TRIPO_REQUEST_TIMEOUT_MS, + tripo_retries: DEFAULT_TRIPO_RETRIES, volcengine_speech_api_key: None, volcengine_speech_app_id: None, volcengine_speech_access_key: None, @@ -1362,6 +1375,23 @@ impl AppConfig { config.hyper3d_model_request_timeout_ms = hyper3d_model_request_timeout_ms; } + // 只做读取,不填默认值:缺失在启动期就被拒,请求期不会拿到一个「猜出来的」网关。 + if let Some(tripo_base_url) = read_first_non_empty_env(&["TRIPO_BASE_URL"]) { + config.tripo_base_url = tripo_base_url; + } + + config.tripo_api_key = read_first_non_empty_env(&["TRIPO_API_KEY"]); + + if let Some(tripo_request_timeout_ms) = + read_first_positive_u64_env(&["TRIPO_REQUEST_TIMEOUT_MS"]) + { + config.tripo_request_timeout_ms = tripo_request_timeout_ms; + } + + if let Some(tripo_retries) = read_first_u32_env(&["TRIPO_RETRIES"]) { + config.tripo_retries = tripo_retries; + } + config.volcengine_speech_api_key = read_first_non_empty_env(&["VOLCENGINE_SPEECH_API_KEY", "VOLCENGINE_API_KEY"]); config.volcengine_speech_app_id = @@ -1791,7 +1821,8 @@ mod tests { DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS, DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS, DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS, DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS, - DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode, + DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, + DEFAULT_TRIPO_REQUEST_TIMEOUT_MS, DEFAULT_TRIPO_RETRIES, ExternalGenerationMode, LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role, tiantoken_api_key, tiantoken_base_url, }; @@ -1966,6 +1997,14 @@ mod tests { ); assert!(config.ark_character_video_base_url.is_empty()); assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2"); + // 网关与密钥都没有默认值:缺配置是启动期错误,不是「用内置值顶上」。 + assert!(config.tripo_base_url.is_empty()); + assert!(config.tripo_api_key.is_none()); + assert_eq!( + config.tripo_request_timeout_ms, + DEFAULT_TRIPO_REQUEST_TIMEOUT_MS + ); + assert_eq!(config.tripo_retries, DEFAULT_TRIPO_RETRIES); assert!(config.ark_character_video_model.is_empty()); assert!(config.dashscope_scene_image_model.is_empty()); assert!(config.dashscope_reference_image_model.is_empty()); diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs index 636eca142..0c8c213f5 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -703,6 +703,7 @@ mod tests { &EditorToolContext::default(), &EditorGenerationPricingConfig { models: Default::default(), + model3d: None, }, ) .expect("successful partial tool output should still build a confirmation card"); diff --git a/server-rs/crates/api-server/src/editor_agent/utils.rs b/server-rs/crates/api-server/src/editor_agent/utils.rs index bc367a2ae..156919576 100644 --- a/server-rs/crates/api-server/src/editor_agent/utils.rs +++ b/server-rs/crates/api-server/src/editor_agent/utils.rs @@ -8,15 +8,15 @@ use platform_oss::{ LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, }; use serde_json::{Value, json}; -use shared_contracts::assets::{ - EditorCanvasGenerationCompletionPayload, EditorCanvasGenerationPlaceholderPayload, -}; use shared_contracts::editor_agent::{ EDITOR_AGENT_ATTACHMENT_LABEL_MAX_CODE_POINTS, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, EditorAgentAttachmentRef, EditorAgentAttachmentSource, EditorAgentConversationDetail, EditorAgentConversationMessagesDocument, EditorAgentConversationSummary, EditorAgentGeneratedImage, EditorAgentMessage, }; +use shared_contracts::editor_canvas::{ + EditorCanvasGenerationCompletionPayload, EditorCanvasGenerationPlaceholderPayload, +}; use shared_kernel::normalize_required_string; use spacetime_client::{ EditorAgentConversationRecord, EditorAssetLibraryRecord, EditorAssetRecord, 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 11fdaa818..988d6ff63 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -6,8 +6,12 @@ use std::{ }; use serde::{Deserialize, Serialize}; +use tracing::warn; use crate::asset_billing::current_external_generation_billing_price_mud_points; +use crate::tripo3d::pricing::{ + Model3dPricingConfig, Model3dPricingError, Model3dPricingPublicView, Model3dPricingQuery, +}; /// 图片画布编辑器生成类能力的泥点配置。 /// @@ -76,6 +80,20 @@ pub(crate) struct EditorGenerationModelPricing { #[serde(rename_all = "camelCase")] pub(crate) struct EditorGenerationPricingConfig { pub models: BTreeMap, + /// Tripo 3D 生成定价。业务给出泥点数值之前允许缺失, + /// 缺失即代表 3D 定价未配置:提交被拒绝,不扣费也不调用 provider。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model3d: Option, +} + +/// 主站公开读模型的响应体:`models` 原样透出,3D 段投影回迁移前的旧形状 +/// ([`Model3dPricingPublicView`]),让画布 3D 入口不需要随本次迁移改动。 +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorGenerationPricingPublicResponse { + pub models: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model3d: Option, } #[derive(Clone, Debug)] @@ -88,12 +106,44 @@ pub(crate) enum EditorGenerationPricingError { Io(io::Error), Json(serde_json::Error), Invalid(String), + /// 3D 定价查询失败:结构化保留「未配置 / 缺底价 / 缺加价项」三种语义。 + Model3d(Model3dPricingError), #[cfg_attr(test, allow(dead_code))] Persistence(String), LockPoisoned, } +impl From for EditorGenerationPricingError { + fn from(error: Model3dPricingError) -> Self { + Self::Model3d(error) + } +} + impl EditorGenerationPricingConfig { + /// 公开读模型视图:字段名与嵌套形状保持迁移前一致,见 + /// [`EditorGenerationPricingPublicResponse`]。 + pub(crate) fn into_public_response(self) -> EditorGenerationPricingPublicResponse { + EditorGenerationPricingPublicResponse { + models: self.models, + model3d: self.model3d.map(Model3dPricingConfig::into_public_view), + } + } + + /// Tripo 3D 提交查价:缺段或缺键都直接报错,不回退默认值。 + /// + /// 错误与同文件其它查询统一成 [`EditorGenerationPricingError`],3D 结构化原因收在 + /// [`EditorGenerationPricingError::Model3d`] 里,调用方不必再处理第二套错误枚举。 + pub(crate) fn model3d_price( + &self, + query: &Model3dPricingQuery, + ) -> Result { + self.model3d + .as_ref() + .ok_or(Model3dPricingError::NotConfigured)? + .price(query) + .map_err(Into::into) + } + pub(crate) fn image_model_mud_points( &self, model: Option<&str>, @@ -213,6 +263,11 @@ impl EditorGenerationPricingConfig { pub(crate) fn validate(&self) -> Result<(), EditorGenerationPricingError> { validate_all_model_entries(&self.models)?; + if let Some(model3d) = &self.model3d { + model3d.validate().map_err(|message| { + EditorGenerationPricingError::Invalid(format!("model3d 定价:{message}")) + })?; + } validate_required_tier_prices( &self.models, EDITOR_IMAGE_MODEL_NANOBANANA2, @@ -269,6 +324,10 @@ impl EditorGenerationPricingStore { .map(|guard| guard.clone()) } + /// 覆盖进程内缓存的定价配置。 + /// + /// 这里只是缓存:保存要不要放行不在这里判断(后台同时只有一个管理员在操作,保存不再做 + /// 版本比对),读取失败时的兜底也直接用这份缓存,所以它不是任何写入的拦截点。 pub(crate) fn replace( &self, next: EditorGenerationPricingConfig, @@ -326,6 +385,7 @@ fn load_editor_generation_pricing_from_candidates( serde_json::from_str::(override_json.as_str()) .map_err(EditorGenerationPricingError::Json)?; backfill_legacy_sfx_pricing(&mut override_config, &config, source.as_ref())?; + backfill_legacy_model3d_pricing(&mut override_config, &config, source.as_ref()); override_config.validate().map_err(|error| match error { EditorGenerationPricingError::Invalid(message) => { EditorGenerationPricingError::Invalid(format!("{source}: {message}")) @@ -364,6 +424,25 @@ fn backfill_legacy_sfx_pricing( Ok(()) } +/// 旧覆盖文件可能整段没有 3D 定价(3D 是后加的段):缺失时用当前受控默认价补齐。 +/// +/// 覆盖文件只承担种子与兜底,不代表「3D 未配置」;这里补齐后,表内缺 3D 段的老行也能 +/// 按同一份默认价归一化,不会因为文件里没有这一段就把 3D 生成整段关掉。 +fn backfill_legacy_model3d_pricing( + config: &mut EditorGenerationPricingConfig, + fallback: &EditorGenerationPricingConfig, + source: &str, +) { + if config.model3d.is_some() || fallback.model3d.is_none() { + return; + } + warn!( + source, + "覆盖文件缺少 3D 定价段,已按受控默认价补齐;确认 3D 价格后再从后台保存一次" + ); + config.model3d = fallback.model3d.clone(); +} + pub(crate) fn parse_editor_generation_pricing_json( json: &str, source: &str, @@ -625,6 +704,8 @@ impl fmt::Display for EditorGenerationPricingError { Self::Invalid(message) => write!(f, "模型定价配置不合法:{message}"), Self::Persistence(message) => write!(f, "模型定价配置入库失败:{message}"), Self::LockPoisoned => write!(f, "模型定价配置锁已损坏"), + // 直接透传内层文案,保持接口响应里的 message 与改造前一致。 + Self::Model3d(error) => write!(f, "{error}"), } } } @@ -634,6 +715,268 @@ impl std::error::Error for EditorGenerationPricingError {} #[cfg(test)] mod tests { use super::*; + use serde_json::{Value, json}; + use shared_contracts::model3d::common::{ + Model3dGeometryQuality, Model3dModelVersion, Model3dTextureQuality, + }; + + use crate::tripo3d::pricing::{ + Model3dAddOn, Model3dAddOnSet, Model3dEndpoint, Model3dPricingError, Model3dPricingQuery, + model3d_add_ons, + }; + + /// 单个端点的最小完整定价段:每个模型版本两种贴图态 + 全部 add-on。 + fn endpoint_pricing_json() -> Value { + let mut version_prices = serde_json::Map::new(); + for version in [ + "v3.1-20260211", + "v3.0-20250812", + "v2.5-20250123", + "P1-20260311", + "P2-20260801", + ] { + version_prices.insert( + version.to_string(), + json!({ "noTexture": 10, "texture": 20 }), + ); + } + json!({ + "versionPrices": Value::Object(version_prices), + "addOnPrices": { + "hdTexture": 5, + "ultraTexture": 10, + "hdGeometry": 15, + "quadMesh": 20, + "smartLowPoly": 25, + "generateParts": 30 + } + }) + } + + /// 最小完整 3D 价格段:两个端点并列,各持自己的版本底价与加价项。 + fn model3d_pricing_json() -> Value { + json!({ + "textToModelPricing": endpoint_pricing_json(), + "imageToModelPricing": endpoint_pricing_json(), + }) + } + + fn pricing_json_with_model3d(model3d: Value) -> String { + let mut config: Value = + serde_json::from_str(EDITOR_GENERATION_PRICING_DEFAULT_JSON).expect("默认配置是 JSON"); + config["model3d"] = model3d; + config.to_string() + } + + fn query( + texture: bool, + add_ons: crate::tripo3d::pricing::Model3dAddOnSet, + ) -> Model3dPricingQuery { + Model3dPricingQuery { + endpoint: Model3dEndpoint::TextToModel, + model_version: Model3dModelVersion::H31, + texture, + add_ons, + } + } + + /// Tripo 官方 credit → 泥点的换算系数:泥点 = `ceil(系数 × credit)`。 + const MODEL3D_CREDIT_TO_MUD_FACTOR: f64 = 0.8; + + fn mud_from_credit(credit: u32) -> u32 { + (f64::from(credit) * MODEL3D_CREDIT_TO_MUD_FACTOR).ceil() as u32 + } + + /// `model3d` 段缺失是合法形态,代表 3D 定价尚未配置:查价必须失败关闭,不扣费。 + /// 受控默认配置现在带真实数值,所以这里显式摘掉该段来验证缺失语义。 + #[test] + fn editor_generation_pricing_fails_closed_when_model3d_section_is_absent() { + let mut json: Value = + serde_json::from_str(EDITOR_GENERATION_PRICING_DEFAULT_JSON).expect("默认配置是 JSON"); + json.as_object_mut() + .expect("默认配置应是对象") + .remove("model3d"); + let config = parse_editor_generation_pricing_json(&json.to_string(), "测试配置") + .expect("缺少 3D 段仍应可加载"); + + assert!(config.model3d.is_none()); + let error = config + .model3d_price(&query( + false, + model3d_add_ons( + false, + Model3dTextureQuality::Standard, + Model3dGeometryQuality::Standard, + false, + false, + false, + ), + )) + .expect_err("未配置 3D 定价时应拒绝查价"); + assert!( + matches!( + error, + EditorGenerationPricingError::Model3d(Model3dPricingError::NotConfigured) + ), + "3D 段缺失应以 NotConfigured 收口,实际 {error:?}" + ); + } + + /// 受控默认配置里的 3D 泥点价必须严格等于 `ceil(0.8 × Tripo 官方 credit)`: + /// 底价按「端点 × 模型版本 × 是否有贴图」,add-on 按项叠加。改价必须先改这里的 + /// credit 表,否则这条断言会失败,避免默认价脱离已确认的换算口径。 + #[test] + fn default_model3d_prices_convert_tripo_credits_at_configured_factor() { + let config = default_runtime_pricing(); + let model3d = config.model3d.as_ref().expect("默认配置必须带 3D 定价段"); + + // (端点, 模型版本, 无贴图 credit, 带贴图 credit) + let base_credits = [ + ( + Model3dEndpoint::TextToModel, + Model3dModelVersion::H31, + 10, + 20, + ), + ( + Model3dEndpoint::TextToModel, + Model3dModelVersion::H30, + 10, + 20, + ), + ( + Model3dEndpoint::TextToModel, + Model3dModelVersion::H25, + 10, + 20, + ), + ( + Model3dEndpoint::TextToModel, + Model3dModelVersion::P1, + 30, + 40, + ), + ( + Model3dEndpoint::TextToModel, + Model3dModelVersion::P2, + 100, + 110, + ), + ( + Model3dEndpoint::ImageToModel, + Model3dModelVersion::H31, + 20, + 30, + ), + ( + Model3dEndpoint::ImageToModel, + Model3dModelVersion::H30, + 20, + 30, + ), + ( + Model3dEndpoint::ImageToModel, + Model3dModelVersion::H25, + 20, + 30, + ), + ( + Model3dEndpoint::ImageToModel, + Model3dModelVersion::P1, + 40, + 50, + ), + ( + Model3dEndpoint::ImageToModel, + Model3dModelVersion::P2, + 100, + 110, + ), + ]; + for (endpoint, model_version, no_texture_credit, texture_credit) in base_credits { + for (texture, credit) in [(false, no_texture_credit), (true, texture_credit)] { + let price = config + .model3d_price(&Model3dPricingQuery { + endpoint, + model_version, + texture, + add_ons: Model3dAddOnSet::default(), + }) + .expect("默认配置必须能查出底价"); + assert_eq!( + price, + mud_from_credit(credit), + "{} {model_version:?} texture={texture} 的底价应为 ceil(0.8 × {credit}) 泥点", + endpoint.as_str(), + ); + } + } + + let add_on_credits = [ + (Model3dAddOn::HdTexture, 10), + (Model3dAddOn::UltraTexture, 20), + (Model3dAddOn::HdGeometry, 20), + (Model3dAddOn::QuadMesh, 5), + (Model3dAddOn::SmartLowPoly, 10), + (Model3dAddOn::GenerateParts, 20), + ]; + for endpoint in Model3dEndpoint::ALL { + for (add_on, credit) in add_on_credits { + let price = model3d + .endpoint_pricing(endpoint) + .add_on_prices + .get(&add_on) + .copied() + .unwrap_or_else(|| { + panic!("默认配置 {} 缺少 {add_on:?} 的价格", endpoint.as_str()) + }); + assert_eq!( + price, + mud_from_credit(credit), + "{} {add_on:?} 的加价应为 ceil(0.8 × {credit}) 泥点", + endpoint.as_str() + ); + } + } + } + + #[test] + fn editor_generation_pricing_validates_model3d_section_when_present() { + let json = pricing_json_with_model3d(model3d_pricing_json()); + let config = parse_editor_generation_pricing_json(&json, "测试配置") + .expect("完整 3D 定价段应可加载"); + + let add_ons = model3d_add_ons( + true, + Model3dTextureQuality::Detailed, + Model3dGeometryQuality::Standard, + false, + false, + false, + ); + assert!(add_ons.contains(Model3dAddOn::HdTexture)); + assert_eq!( + config + .model3d_price(&query(true, add_ons)) + .expect("底价与 add-on 都在夹具里"), + 20 + 5 + ); + + let mut incomplete = model3d_pricing_json(); + incomplete["imageToModelPricing"]["versionPrices"] + .as_object_mut() + .expect("夹具含图片端点底价") + .remove("P2-20260801"); + let error = parse_editor_generation_pricing_json( + &pricing_json_with_model3d(incomplete), + "测试配置", + ) + .expect_err("缺少任一底价键应加载失败"); + assert!( + error.to_string().contains("缺少"), + "报错应说明缺键,实际为:{error}" + ); + } fn unique_temp_dir(prefix: &str) -> PathBuf { std::env::temp_dir().join(format!( @@ -807,6 +1150,36 @@ mod tests { std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove"); } + #[test] + fn editor_generation_pricing_legacy_override_backfills_missing_model3d_section() { + let temp_dir = unique_temp_dir("genarrative-pricing-legacy-model3d-test"); + std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); + let override_path = temp_dir.join("editor-generation-pricing.override.json"); + let mut legacy_config = default_runtime_pricing(); + legacy_config.model3d = None; + legacy_config + .models + .get_mut(EDITOR_SOUND_EFFECT_MODEL_VIDU) + .expect("legacy sound effect pricing should exist") + .price = Some(17); + std::fs::write( + &override_path, + serde_json::to_string(&legacy_config).expect("legacy config should serialize"), + ) + .expect("legacy override should write"); + + let loaded = load_editor_generation_pricing_from_paths(Some(&override_path)) + .expect("legacy override should backfill the 3D section"); + + assert_eq!( + loaded.model3d, + default_runtime_pricing().model3d, + "覆盖文件缺 3D 段时应补上受控默认价" + ); + assert_eq!(loaded.sound_effect_model_mud_points(Some("audio1.0")), 17); + std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove"); + } + #[test] fn editor_generation_pricing_legacy_override_still_rejects_other_missing_models() { let temp_dir = unique_temp_dir("genarrative-pricing-legacy-required-model-test"); diff --git a/server-rs/crates/api-server/src/editor_generation_model3d_records.rs b/server-rs/crates/api-server/src/editor_generation_model3d_records.rs new file mode 100644 index 000000000..197981e36 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_generation_model3d_records.rs @@ -0,0 +1,415 @@ +//! 3D 定价段在「配置文件结构」与「SpacetimeDB 记录结构」之间的转换。 +//! +//! 记录结构按展平风格存键值行:版本价一行含两档价,加价项一行一条,键都是字符串。 +//! 写入方向严格——写之前先按契约强校验,缺键与契约外的键都不允许落库; +//! 读取方向做契约归一化——老记录缺的键从本地配置同段补齐,契约之外的遗留键剔除并告警, +//! 归一化后仍不合法才判配置非法。 + +use std::collections::BTreeMap; + +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; +use shared_contracts::model3d::wire_str; +use spacetime_client::{ + EditorGenerationModel3dAddOnPriceRecord, EditorGenerationModel3dPricingRecord, + EditorGenerationModel3dVersionPriceRecord, +}; +use tracing::warn; + +use crate::editor_generation_config::EditorGenerationPricingError; +use shared_contracts::model3d::common::Model3dModelVersion; + +use crate::tripo3d::pricing::{ + Model3dAddOn, Model3dBasePrice, Model3dEndpoint, Model3dEndpointPricing, Model3dPricingConfig, +}; + +/// 3D 定价在记录形状里的两段:每个端点一段,段内是版本价行与加价项行。 +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct EditorGenerationModel3dSections { + pub text_to_model_pricing: Option, + pub image_to_model_pricing: Option, +} + +/// 配置 → 记录。段整体缺失时两列都写空,代表 3D 定价未配置(提交 fail closed)。 +pub(crate) fn model3d_sections_from_config( + config: Option<&Model3dPricingConfig>, +) -> Result { + let Some(config) = config else { + return Ok(EditorGenerationModel3dSections::default()); + }; + config.validate().map_err(|message| { + EditorGenerationPricingError::Invalid(format!("model3d 定价:{message}")) + })?; + Ok(EditorGenerationModel3dSections { + text_to_model_pricing: Some(record_from_endpoint_pricing( + Model3dEndpoint::TextToModel, + &config.text_to_model_pricing, + )?), + image_to_model_pricing: Some(record_from_endpoint_pricing( + Model3dEndpoint::ImageToModel, + &config.image_to_model_pricing, + )?), + }) +} + +/// 记录 → 配置,并按契约归一化。 +/// +/// `fallback` 是本地配置(受控默认 JSON / 覆盖文件)里的 3D 段,只承担两件事: +/// 补齐记录里缺的键(含整段缺失的老记录),以及在没有表数据时决定「3D 是否已配置」。 +pub(crate) fn model3d_config_from_sections( + sections: EditorGenerationModel3dSections, + fallback: Option<&Model3dPricingConfig>, +) -> Result, EditorGenerationPricingError> { + let text_to_model_pricing = endpoint_pricing_from_record( + Model3dEndpoint::TextToModel, + sections.text_to_model_pricing, + fallback.map(|config| &config.text_to_model_pricing), + )?; + let image_to_model_pricing = endpoint_pricing_from_record( + Model3dEndpoint::ImageToModel, + sections.image_to_model_pricing, + fallback.map(|config| &config.image_to_model_pricing), + )?; + match (text_to_model_pricing, image_to_model_pricing) { + (None, None) => Ok(None), + (Some(text_to_model_pricing), Some(image_to_model_pricing)) => { + Ok(Some(Model3dPricingConfig { + text_to_model_pricing, + image_to_model_pricing, + })) + } + // 单段缺失说明写入侧或归一化出了问题:不能拿一半的定价去收费。 + (text, image) => Err(EditorGenerationPricingError::Invalid(format!( + "SpacetimeDB 模型定价配置 3D 段只配了一半:text_to_model_pricing={} image_to_model_pricing={}", + text.is_some(), + image.is_some() + ))), + } +} + +fn record_from_endpoint_pricing( + endpoint: Model3dEndpoint, + pricing: &Model3dEndpointPricing, +) -> Result { + let version_prices = pricing + .version_prices + .iter() + .map(|(model_version, base)| { + Ok(EditorGenerationModel3dVersionPriceRecord { + model_version: contract_key(model_version, endpoint, "模型版本")?, + no_texture: base.no_texture, + texture: base.texture, + }) + }) + .collect::, EditorGenerationPricingError>>()?; + let add_on_prices = pricing + .add_on_prices + .iter() + .map(|(add_on, price)| { + Ok(EditorGenerationModel3dAddOnPriceRecord { + add_on: contract_key(add_on, endpoint, "加价项")?, + price: *price, + }) + }) + .collect::, EditorGenerationPricingError>>()?; + Ok(EditorGenerationModel3dPricingRecord { + version_prices, + add_on_prices, + }) +} + +fn endpoint_pricing_from_record( + endpoint: Model3dEndpoint, + record: Option, + fallback: Option<&Model3dEndpointPricing>, +) -> Result, EditorGenerationPricingError> { + // 记录整段缺失(老行、或迁移前从未配过 3D)时直接用本地配置:这才是「覆盖文件只做 + // 种子与兜底」的落点,缺键补齐与整段兜底走同一条路径。 + let Some(record) = record else { + // 兜底值同样过一遍端点校验:否则这条分支的正确性要靠调用方事后补一次 + // `config.validate()`,模块自己就不再是 fail-closed 的。 + let Some(fallback) = fallback else { + return Ok(None); + }; + fallback.validate(endpoint).map_err(|message| { + EditorGenerationPricingError::Invalid(format!( + "SpacetimeDB 模型定价配置 3D 段回退到本地配置,但本地配置不合法:{message}" + )) + })?; + return Ok(Some(fallback.clone())); + }; + + let mut version_prices = BTreeMap::new(); + for entry in record.version_prices { + let Some(model_version) = + parse_contract_key::(&entry.model_version, endpoint, "模型版本") + else { + continue; + }; + if version_prices + .insert( + model_version, + Model3dBasePrice { + no_texture: entry.no_texture, + texture: entry.texture, + }, + ) + .is_some() + { + return Err(EditorGenerationPricingError::Invalid(format!( + "SpacetimeDB 模型定价配置 3D 段重复模型版本:endpoint={} model={}", + endpoint.as_str(), + entry.model_version + ))); + } + } + let mut add_on_prices = BTreeMap::new(); + for entry in record.add_on_prices { + let Some(add_on) = parse_contract_key::(&entry.add_on, endpoint, "加价项") + else { + continue; + }; + if add_on_prices.insert(add_on, entry.price).is_some() { + return Err(EditorGenerationPricingError::Invalid(format!( + "SpacetimeDB 模型定价配置 3D 段重复加价项:endpoint={} add_on={}", + endpoint.as_str(), + entry.add_on + ))); + } + } + + // 记录值优先,本地配置补缺:发布新增模型版本后,表里还没有该键时按本地受控价补齐。 + let mut merged = fallback.cloned().unwrap_or_default(); + merged.version_prices.extend(version_prices); + merged.add_on_prices.extend(add_on_prices); + merged.validate(endpoint).map_err(|message| { + EditorGenerationPricingError::Invalid(format!("SpacetimeDB 模型定价配置 {message}")) + })?; + Ok(Some(merged)) +} + +/// 记录里的键是字符串,契约之外的遗留键(例如版本下线后表里的旧键)在这里剔除并告警。 +fn parse_contract_key( + key: &str, + endpoint: Model3dEndpoint, + kind: &str, +) -> Option { + match serde_json::from_value::(Value::String(key.to_string())) { + Ok(value) => Some(value), + Err(_) => { + warn!( + endpoint = endpoint.as_str(), + kind, key, "SpacetimeDB 模型定价配置 3D 段出现契约之外的键,已剔除" + ); + None + } + } +} + +fn contract_key( + value: &T, + endpoint: Model3dEndpoint, + kind: &str, +) -> Result { + match wire_str(value) { + Ok(key) => Ok(key), + Err(error) => Err(EditorGenerationPricingError::Invalid(format!( + "3D 定价段 endpoint={} 的{kind}无法写成线上取值:{error}", + endpoint.as_str() + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tripo3d::pricing::{Model3dBasePrice, Model3dEndpointPricing}; + + fn endpoint_pricing(scale: u32) -> Model3dEndpointPricing { + Model3dEndpointPricing { + version_prices: Model3dModelVersion::ALL + .iter() + .map(|version| { + ( + *version, + Model3dBasePrice { + no_texture: scale, + texture: scale + 1, + }, + ) + }) + .collect(), + add_on_prices: Model3dAddOn::ALL + .iter() + .map(|add_on| (*add_on, scale)) + .collect(), + } + } + + fn config() -> Model3dPricingConfig { + Model3dPricingConfig { + text_to_model_pricing: endpoint_pricing(10), + image_to_model_pricing: endpoint_pricing(20), + } + } + + #[test] + fn not_configured_sections_stay_not_configured() { + let sections = model3d_sections_from_config(None).expect("段缺失应能写成两列空值"); + assert_eq!(sections, EditorGenerationModel3dSections::default()); + assert_eq!( + model3d_config_from_sections(sections, None).expect("两段都空应可读"), + None + ); + } + + #[test] + fn both_endpoints_round_trip_without_local_fallback() { + let expected = config(); + let sections = model3d_sections_from_config(Some(&expected)).expect("完整配置应能写成记录"); + + assert_eq!( + model3d_config_from_sections(sections, None).expect("完整记录应可读"), + Some(expected) + ); + } + + #[test] + fn missing_keys_and_missing_sections_are_backfilled_from_local_config() { + let fallback = config(); + let mut sections = + model3d_sections_from_config(Some(&fallback)).expect("完整配置应能写成记录"); + sections + .text_to_model_pricing + .as_mut() + .expect("文生段应存在") + .version_prices + .retain(|entry| entry.model_version != "P2-20260801"); + sections.image_to_model_pricing = None; + + assert_eq!( + model3d_config_from_sections(sections, Some(&fallback)) + .expect("缺键与缺段都应从本地配置补齐"), + Some(fallback) + ); + } + + #[test] + fn out_of_contract_keys_are_dropped() { + let fallback = config(); + let expected = fallback.clone(); + let mut sections = + model3d_sections_from_config(Some(&fallback)).expect("完整配置应能写成记录"); + let text = sections + .text_to_model_pricing + .as_mut() + .expect("文生段应存在"); + text.version_prices + .push(EditorGenerationModel3dVersionPriceRecord { + model_version: "v9.9-29991231".to_string(), + no_texture: 1, + texture: 1, + }); + text.add_on_prices + .push(EditorGenerationModel3dAddOnPriceRecord { + add_on: "teleport".to_string(), + price: 1, + }); + + assert_eq!( + model3d_config_from_sections(sections, Some(&fallback)).expect("契约外键应被剔除"), + Some(expected) + ); + } + + #[test] + fn missing_key_without_local_value_fails_closed() { + let fallback = config(); + let mut sections = + model3d_sections_from_config(Some(&fallback)).expect("完整配置应能写成记录"); + sections + .text_to_model_pricing + .as_mut() + .expect("文生段应存在") + .version_prices + .retain(|entry| entry.model_version != "P2-20260801"); + let mut incomplete_fallback = fallback.clone(); + incomplete_fallback + .text_to_model_pricing + .version_prices + .remove(&Model3dModelVersion::P2); + + let error = model3d_config_from_sections(sections, Some(&incomplete_fallback)) + .expect_err("记录与本地配置都缺键时不能放行"); + assert!( + error.to_string().contains("缺少"), + "报错应说明缺键,实际为:{error}" + ); + } + + #[test] + fn missing_section_with_incomplete_local_fallback_fails_closed() { + let mut sections = + model3d_sections_from_config(Some(&config())).expect("完整配置应能写成记录"); + sections.text_to_model_pricing = None; + let mut incomplete_fallback = config(); + incomplete_fallback + .text_to_model_pricing + .version_prices + .remove(&Model3dModelVersion::P2); + + let error = model3d_config_from_sections(sections, Some(&incomplete_fallback)) + .expect_err("整段缺失但与兜底配置不合法时不能放行"); + assert!( + error.to_string().contains("本地配置不合法"), + "报错应点名兜底配置不合法,实际为:{error}" + ); + } + + #[test] + fn half_configured_sections_are_rejected() { + let mut sections = + model3d_sections_from_config(Some(&config())).expect("完整配置应能写成记录"); + sections.image_to_model_pricing = None; + + let error = model3d_config_from_sections(sections, None).expect_err("只配一段时不能放行"); + assert!( + error.to_string().contains("只配了一半"), + "报错应说明只配了一半,实际为:{error}" + ); + } + + #[test] + fn duplicate_keys_are_rejected() { + let mut sections = + model3d_sections_from_config(Some(&config())).expect("完整配置应能写成记录"); + let text = sections + .text_to_model_pricing + .as_mut() + .expect("文生段应存在"); + let duplicate = text.version_prices[0].clone(); + text.version_prices.push(duplicate); + + let error = model3d_config_from_sections(sections, None).expect_err("重复键应被拒绝"); + assert!( + error.to_string().contains("重复模型版本"), + "报错应说明重复,实际为:{error}" + ); + } + + #[test] + fn writing_requires_the_complete_contract_key_set() { + let mut incomplete = config(); + incomplete + .image_to_model_pricing + .add_on_prices + .remove(&Model3dAddOn::QuadMesh); + + let error = model3d_sections_from_config(Some(&incomplete)) + .expect_err("写入方向不做宽松:缺键不能落库"); + assert!( + error.to_string().contains("add-on"), + "报错应说明缺 add-on,实际为:{error}" + ); + } +} diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index b615a870b..c9c13a8e8 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -33,7 +33,8 @@ const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker"; const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024; const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation"; const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation"; -const EXTERNAL_API_REQUEST_FINGERPRINT_FIELD: &str = "_externalApiRequestFingerprint"; +/// 队列 payload 由平台注入的幂等指纹字段;worker 侧解析请求前必须先摘掉它。 +pub(crate) const EXTERNAL_API_REQUEST_FINGERPRINT_FIELD: &str = "_externalApiRequestFingerprint"; pub(crate) const GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX: &str = "game-creator-client-generation"; pub(crate) const GAME_CREATOR_CLIENT_GENERATION_SOURCE: &str = "ai-game-creator-client"; @@ -78,7 +79,7 @@ where .await } -fn build_editor_generation_dedupe_key( +pub(crate) fn build_editor_generation_dedupe_key( namespace: &str, owner_user_id: &str, job_kind: &str, @@ -701,7 +702,7 @@ fn is_inline_media_reference(value: &str) -> bool { } pub(crate) fn editor_generation_queue_state( - job: ExternalGenerationJobRecord, + job: &ExternalGenerationJobRecord, ) -> ExternalGenerationJobStatusRecord { let (status, phase_detail, progress) = match job.status.as_str() { "completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100), @@ -713,12 +714,12 @@ pub(crate) fn editor_generation_queue_state( _ => (ExternalGenerationJobStatus::Queued, "排队中。", 8), }; ExternalGenerationJobStatusRecord { - operation_id: job.job_id, + operation_id: job.job_id.clone(), status, - phase_label: job.request_label, + phase_label: job.request_label.clone(), phase_detail: phase_detail.to_string(), progress, - error: job.last_error_message, + error: job.last_error_message.clone(), updated_at_micros: job.updated_at_micros, } } @@ -771,6 +772,8 @@ mod tests { notification_acknowledged_at: None, notification_acknowledged_at_micros: None, phase: phase.map(ToOwned::to_owned), + provider_kind: None, + provider_task_id: None, } } @@ -1444,7 +1447,7 @@ mod tests { for (persisted_status, phase, expected_status, expected_detail, expected_progress) in cases { - let state = editor_generation_queue_state(queue_job_fixture(persisted_status, phase)); + let state = editor_generation_queue_state(&queue_job_fixture(persisted_status, phase)); assert_eq!(state.operation_id, "task-queue-test"); assert_eq!(state.status, expected_status, "status={persisted_status}"); @@ -1464,7 +1467,7 @@ mod tests { let mut job = queue_job_fixture("failed", None); job.last_error_message = Some("生成失败摘要".to_string()); - let state = editor_generation_queue_state(job); + let state = editor_generation_queue_state(&job); assert_eq!(state.status, ExternalGenerationJobStatus::Failed); assert_eq!(state.error.as_deref(), Some("生成失败摘要")); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index f6ec1d0ed..7d9755689 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -28,10 +28,10 @@ use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; #[cfg(test)] use shared_contracts::assets::EditorAudioGenerateResponse; -use shared_contracts::assets::{ - EDITOR_ASSET_LABEL_MAX_CHARS, +use shared_contracts::assets::{EDITOR_ASSET_LABEL_MAX_CHARS, EditorSceneGenerateRequest}; +use shared_contracts::editor_canvas::{ EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest, - EditorCanvasGenerationPlaceholderPayload, EditorSceneGenerateRequest, + EditorCanvasGenerationPlaceholderPayload, }; use shared_contracts::editor_generation::editor_generation_request_fingerprint; use shared_contracts::editor_generation::{ @@ -1244,6 +1244,11 @@ fn serialize_atomic_editor_generation_job_result( compact_external_api_generation_result(response.clone()), ); } + // 中文注释:标准消费者只回来源身份——编辑器结果由画布读回(项目快照 / 素材行), + // 不靠任务查询。3D 与其它标准任务同一条口径:worker 写画布 placement 或素材行, + // 客户端读回快照拿到结果,因此这里不给任何标准任务透传 result。 + // 只按「调用方给了就照抄」放行等于把脱敏交给每个 worker 自觉,provider 原始数据 + // 一旦被写进 result 就会原样出现在任务查询里。 if let Some(warning) = extract_editor_generation_warning(response) && let Some(object) = payload.as_object_mut() { @@ -1991,7 +1996,11 @@ pub async fn get_editor_generation_pricing( "message": error.to_string(), })) })?; - Ok(json_success_body(Some(&request_context), pricing)) + // 公开读模型不改形状:内部两段结构在这里投影回旧的 `basePrices` / `addOnPrices`。 + Ok(json_success_body( + Some(&request_context), + pricing.into_public_response(), + )) } pub async fn list_editor_projects( @@ -2750,7 +2759,7 @@ pub async fn generate_editor_scene( return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), + queue_state: editor_generation_queue_state(&queue_job), }, )); } @@ -2791,7 +2800,7 @@ pub async fn generate_editor_image( return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), + queue_state: editor_generation_queue_state(&queue_job), }, )); } @@ -4318,26 +4327,45 @@ fn ensure_editor_image_edit_target_matches_source( ) } -async fn resolve_editor_reference_record_by_id_for_owner( +/// 定点取一条编辑器引用记录(项目资源或素材)。 +/// +/// 调用方必须传**已认证用户自己的 ID**:这里不做任何授权判断,给什么 owner 就按什么 +/// owner 查,传客户端可控的值等于开了越权读取的口子。 +pub(crate) async fn resolve_editor_reference_record_by_id_for_owner( state: &AppState, owner_user_id: &str, reference_id: &str, ) -> Result { + resolve_editor_reference_record_by_id_for_owner_classified(state, owner_user_id, reference_id) + .await + .map_err(EditorPointLookupFailure::into_error) +} + +/// 与 [`resolve_editor_reference_record_by_id_for_owner`] 同一份查询,但把「引用不可用」与 +/// 其它失败分开给出:调用方据此收敛,不再靠 400 / 404 猜(这两档里混着 runtime 故障与 +/// 通用过程错误,按状态码猜会把服务端问题说成用户的引用写错了)。 +pub(crate) async fn resolve_editor_reference_record_by_id_for_owner_classified( + state: &AppState, + owner_user_id: &str, + reference_id: &str, +) -> Result { let reference_id = reference_id.trim(); if reference_id.is_empty() { - return Err( + return Err(EditorPointLookupFailure::other( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-reference-image", "field": "referenceId", "message": "编辑器引用 ID 不能为空。", })), - ); + )); } let oss_client = state.oss_client().ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "aliyun-oss", - "reason": "OSS 未完成环境变量配置", - })) + EditorPointLookupFailure::other( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })), + ) })?; state .spacetime_client() @@ -4347,7 +4375,7 @@ async fn resolve_editor_reference_record_by_id_for_owner( reference_id: reference_id.to_string(), }) .await - .map_err(map_editor_project_error) + .map_err(classify_editor_point_lookup_failure) } fn ensure_editor_image_edit_source_reference_id_shape( @@ -5700,7 +5728,7 @@ pub async fn edit_editor_image( return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), + queue_state: editor_generation_queue_state(&queue_job), }, )); } @@ -6238,7 +6266,7 @@ pub async fn remove_editor_image_background( Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), + queue_state: editor_generation_queue_state(&queue_job), }, )) } @@ -8201,7 +8229,7 @@ pub async fn extract_editor_ui_design_assets( return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), + queue_state: editor_generation_queue_state(&queue_job), }, )); } @@ -9202,8 +9230,12 @@ fn editor_canvas_payload_from_record( pub(crate) fn editor_project_resource_payload_from_record( record: EditorProjectResourceRecord, ) -> EditorProjectResourcePayload { - let image_src = - normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); + let image_src = project_editor_client_media_src( + record.asset_kind.as_deref(), + record.image_src, + record.object_key.as_deref(), + None, + ); let formal_image_sequence_frames = record .image_sequence_frames .as_ref() @@ -9255,8 +9287,12 @@ pub(crate) fn editor_project_resource_payload_from_record( fn editor_project_resource_payload_from_showcase_record( record: EditorShowcaseAssetRecord, ) -> EditorProjectResourcePayload { - let image_src = - normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); + let image_src = project_editor_client_media_src( + record.asset_kind.as_deref(), + record.image_src, + record.object_key.as_deref(), + record.thumbnail_src.as_deref(), + ); let formal_image_sequence_frames = record .image_sequence_frames .as_ref() @@ -9671,8 +9707,12 @@ pub(crate) fn editor_asset_folder_payload_from_record( } pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload { - let image_src = - normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); + let image_src = project_editor_client_media_src( + record.asset_kind.as_deref(), + record.image_src, + record.object_key.as_deref(), + record.thumbnail_src.as_deref(), + ); let formal_image_sequence_frames = record .image_sequence_frames .as_ref() @@ -10987,10 +11027,30 @@ pub(crate) fn build_editor_canvas_resource_placement_item( completion: &EditorCanvasGenerationCompletionRequest, resource: &EditorProjectResourcePayload, layer_id: String, +) -> Value { + build_editor_canvas_placement_item( + completion, + resource.resource_id.as_str(), + resource.width, + resource.height, + layer_id, + ) +} + +/// 只依赖资源身份与像素尺寸的画布占位回填条目。 +/// +/// 3D 生成结果在落库前只有对象与尺寸、没有完整的资源载荷,因此把这条口径单独暴露出来, +/// 保证它与图片生成回填的是同一份条目形状。 +pub(crate) fn build_editor_canvas_placement_item( + completion: &EditorCanvasGenerationCompletionRequest, + resource_id: &str, + resource_width: u32, + resource_height: u32, + layer_id: String, ) -> Value { let placeholder = &completion.placeholder; - let original_width = positive_f64(resource.width as f64, placeholder.original_width); - let original_height = positive_f64(resource.height as f64, placeholder.original_height); + let original_width = positive_f64(resource_width as f64, placeholder.original_width); + let original_height = positive_f64(resource_height as f64, placeholder.original_height); let width = positive_f64(placeholder.width, original_width); let height = positive_f64(placeholder.height, original_height); let placeholder_width = positive_f64(placeholder.width, width); @@ -11000,7 +11060,7 @@ pub(crate) fn build_editor_canvas_resource_placement_item( json!({ "layerId": layer_id, - "resourceId": resource.resource_id, + "resourceId": resource_id, "title": completion.title.trim(), "x": x, "y": y, @@ -11309,6 +11369,41 @@ pub(crate) fn editor_media_src_from_object_key(object_key: &str) -> String { format!("/{}", object_key.trim().trim_start_matches('/')) } +/// 3D 生成产物的类别:模型本体在 `object_key`,卡片与缩略图消费预览图。 +pub(crate) const EDITOR_MODEL3D_ASSET_KIND: &str = "model3d"; + +/// 客户端可见媒体投影:唯一决定 payload 的 `imageSrc` 指向哪个对象。 +/// +/// - `model3d`:`imageSrc` 与 `thumbnailSrc` 都是预览图,模型本体只留在 `objectKey`。 +/// 预览依次取 `thumbnail_src`、存储的 `image_src`。模型与预览是两个对象,不能让 +/// `object_key` 覆盖 `image_src`,否则画布卡片、素材缩略图和后台都会把模型文件当图片加载。 +/// 两个来源都缺失时 `imageSrc` 留空:这是唯一允许「有对象但 `imageSrc` 为空」的类别, +/// 消费方按空值渲染占位图,不拿 `.glb` 的位置冒充封面。 +/// - 其它类别:沿用「`object_key` 非空即以它为 `imageSrc`」的历史口径,两个来源都缺失时 +/// 回落到它,保证这些类别的「有对象就有 `imageSrc`」不变量不退化。 +fn project_editor_client_media_src( + asset_kind: Option<&str>, + image_src: String, + object_key: Option<&str>, + thumbnail_src: Option<&str>, +) -> String { + if asset_kind.map(str::trim) == Some(EDITOR_MODEL3D_ASSET_KIND) { + let preview_src = thumbnail_src + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + let image_src = image_src.trim(); + (!image_src.is_empty()).then(|| image_src.to_string()) + }); + return preview_src + .map(|preview_src| normalize_editor_record_media_src(preview_src, None)) + // 没有预览就留空:模型文件不是封面,宁可由消费方显示占位图。 + .unwrap_or_default(); + } + normalize_editor_record_media_src(image_src, object_key) +} + fn normalize_editor_record_media_src(image_src: String, object_key: Option<&str>) -> String { if let Some(media_src) = object_key .map(str::trim) @@ -13232,6 +13327,74 @@ pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError } } +/// 定点查一条记录(引用 / 落点)的失败归类。 +/// +/// 只有 [`EditorPointLookupKind::Unavailable`] 是「目标不存在或不属于当前账号」这类用户问题, +/// 调用方才可以把它改写成自己的对外文案;服务身份 403、并发 409、runtime 与连接故障都属于 +/// [`EditorPointLookupKind::Other`],一律带原样映射后的错误,不允许按状态码二次猜测。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EditorPointLookupKind { + Unavailable, + Other, +} + +/// 归类结果:`error` 与 [`map_editor_project_error`] 的输出逐字段一致,因此不改写归类时 +/// 其它调用方的对外行为。 +pub(crate) struct EditorPointLookupFailure { + pub(crate) kind: EditorPointLookupKind, + pub(crate) error: AppError, +} + +impl EditorPointLookupFailure { + pub(crate) fn other(error: AppError) -> Self { + Self { + kind: EditorPointLookupKind::Other, + error, + } + } + + pub(crate) fn into_error(self) -> AppError { + self.error + } +} + +/// 模块在「按主键查一条记录」路径上表示「不存在 / 不属于当前账号」的固定文案。 +/// +/// 只有命中这里的目标才是用户侧的「不可用」;`当前 identity 无权调用模型生成运行时服务`、 +/// `模型定价服务身份尚未初始化`、版本冲突与连接故障都不在此列,必须原样上报。 +const EDITOR_POINT_LOOKUP_UNAVAILABLE_MARKERS: &[&str] = &[ + "编辑器引用不存在", + "编辑器资源引用不属于当前用户", + "编辑器素材引用不属于当前用户", + "图片画布工程不存在", + "素材文件夹不存在", + "无权访问该图片画布工程", + "无权访问该素材文件夹", + "无权访问该画布资源", + "无权访问该素材", + "默认素材文件夹不属于当前 owner", +]; + +/// 把定点查询的原始失败归类:先认「不可用」文案,其余统一映射后再交给调用方。 +pub(crate) fn classify_editor_point_lookup_failure( + error: SpacetimeClientError, +) -> EditorPointLookupFailure { + let kind = match &error { + SpacetimeClientError::Procedure(message) + if EDITOR_POINT_LOOKUP_UNAVAILABLE_MARKERS + .iter() + .any(|marker| message.contains(marker)) => + { + EditorPointLookupKind::Unavailable + } + _ => EditorPointLookupKind::Other, + }; + EditorPointLookupFailure { + kind, + error: map_editor_project_error(error), + } +} + pub(crate) fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", @@ -14312,6 +14475,128 @@ mod tests { ); } + #[test] + fn editor_client_media_projection_keeps_model3d_preview_over_model_object_key() { + // 3D 资源的模型本体在 object_key,卡片与缩略图必须拿到预览图:投影不能像其它 + // 类别那样让 object_key 覆盖 imageSrc,否则所有图片入口都会去加载 .glb。 + assert_eq!( + project_editor_client_media_src( + Some("model3d"), + "/generated-character-drafts/editor/model3d/task-1/preview.webp".to_string(), + Some("generated-character-drafts/editor/model3d/task-1/model.glb"), + None, + ), + "/generated-character-drafts/editor/model3d/task-1/preview.webp" + ); + assert_eq!( + project_editor_client_media_src( + Some("model3d"), + "".to_string(), + Some("generated-character-drafts/editor/model3d/task-1/model.glb"), + Some("/generated-character-drafts/editor/model3d/task-1/preview.png"), + ), + "/generated-character-drafts/editor/model3d/task-1/preview.png" + ); + } + + /// 3D 没有预览图时封面留空:模型文件(.glb)不是图片,拿它顶封面只会得到裂图。 + /// 这是唯一允许「有对象但 imageSrc 为空」的类别,前端按空值渲染占位。 + /// 定点查询的失败归类只看文案清单:目标不存在 / 不属于当前账号才是用户侧的「不可用」, + /// 服务身份 403、并发冲突与 runtime 故障都必须留在 `Other`,调用方不得改写它们。 + #[test] + fn editor_point_lookup_classification_separates_unavailable_from_infrastructure() { + for message in [ + "编辑器引用不存在", + "编辑器资源引用不属于当前用户", + "编辑器素材引用不属于当前用户", + "图片画布工程不存在", + "素材文件夹不存在", + "无权访问该图片画布工程", + "无权访问该素材文件夹", + "默认素材文件夹不属于当前 owner", + ] { + let failure = classify_editor_point_lookup_failure(SpacetimeClientError::Procedure( + message.to_string(), + )); + assert_eq!( + failure.kind, + EditorPointLookupKind::Unavailable, + "{message}" + ); + } + + for (status, message) in [ + ( + StatusCode::FORBIDDEN, + "当前 identity 无权调用模型生成运行时服务", + ), + (StatusCode::BAD_REQUEST, "模型定价服务身份尚未初始化"), + (StatusCode::CONFLICT, "版本冲突:写入被并发更新打断"), + ( + StatusCode::BAD_REQUEST, + "编辑器引用 ID 同时命中项目资源与素材,无法唯一解析", + ), + ] { + let failure = classify_editor_point_lookup_failure(SpacetimeClientError::Procedure( + message.to_string(), + )); + assert_eq!(failure.kind, EditorPointLookupKind::Other, "{message}"); + assert_eq!(failure.error.status_code(), status, "{message}"); + } + + // 连接 / runtime 故障同样不得被归成用户侧「不可用」。 + let runtime = classify_editor_point_lookup_failure(SpacetimeClientError::Runtime( + "runtime exploded".to_string(), + )); + assert_eq!(runtime.kind, EditorPointLookupKind::Other); + let timeout = classify_editor_point_lookup_failure(SpacetimeClientError::ConnectDropped); + assert_eq!(timeout.kind, EditorPointLookupKind::Other); + } + + #[test] + fn editor_client_media_projection_leaves_model3d_cover_empty_without_preview() { + assert_eq!( + project_editor_client_media_src( + Some("model3d"), + "".to_string(), + Some("generated-character-drafts/editor/model3d/task-1/model.glb"), + Some(" "), + ), + "" + ); + assert_eq!( + project_editor_client_media_src( + Some("model3d"), + " ".to_string(), + Some("generated-character-drafts/editor/model3d/task-1/model.glb"), + None, + ), + "" + ); + } + + #[test] + fn editor_client_media_projection_keeps_legacy_object_key_precedence_for_other_kinds() { + assert_eq!( + project_editor_client_media_src( + Some("scene"), + "/generated-character-drafts/editor/scene.png".to_string(), + Some("generated-character-drafts/editor/scene-object.png"), + Some("/generated-character-drafts/editor/scene-thumb.png"), + ), + "/generated-character-drafts/editor/scene-object.png" + ); + assert_eq!( + project_editor_client_media_src( + None, + "/generated-character-drafts/editor/plain.png".to_string(), + None, + None, + ), + "/generated-character-drafts/editor/plain.png" + ); + } + fn test_editor_showcase_asset_record(owner_user_id: &str) -> EditorShowcaseAssetRecord { EditorShowcaseAssetRecord { showcase_id: "showcase-1".to_string(), @@ -20089,6 +20374,8 @@ mod tests { notification_acknowledged_at: None, notification_acknowledged_at_micros: None, phase: Some("processing".to_string()), + provider_kind: None, + provider_task_id: None, } } diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index be15e4583..b83c6c762 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -22,7 +22,7 @@ use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; -use shared_contracts::assets::EditorCanvasGenerationCompletionPayload; +use shared_contracts::editor_canvas::EditorCanvasGenerationCompletionPayload; use shared_contracts::editor_generation::editor_generation_stable_asset_object_id; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::editor_project::{ @@ -619,7 +619,7 @@ pub(crate) async fn generate_icon_spec( return Ok(crate::api_response::json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(job), + queue_state: editor_generation_queue_state(&job), }, )); } @@ -1484,7 +1484,7 @@ pub async fn generate_editor_icon_spritesheet( return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), + queue_state: editor_generation_queue_state(&queue_job), }, )); } diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 2a5eb877f..5a4edaeb2 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -7,7 +7,7 @@ use axum::{ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use shared_contracts::assets::EditorCanvasGenerationCompletionPayload; +use shared_contracts::editor_canvas::EditorCanvasGenerationCompletionPayload; use shared_contracts::external_generation::{ ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse, ExternalGenerationJobStatus, @@ -1031,7 +1031,7 @@ fn external_generation_accepted_response( job: ExternalGenerationJobRecord, ) -> Response { let kind = job.job_kind.clone(); - let status = editor_generation_queue_state(job); + let status = editor_generation_queue_state(&job); let status_url = format!("/api/external/v1/generations/{}", status.operation_id); let mut response = ( StatusCode::ACCEPTED, @@ -1308,6 +1308,8 @@ mod tests { notification_acknowledged_at: None, notification_acknowledged_at_micros: None, phase: None, + provider_kind: None, + provider_task_id: None, } } diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs index 76b18a708..bbbdd27d0 100644 --- a/server-rs/crates/api-server/src/external_generation.rs +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -23,6 +23,7 @@ use crate::editor_generation_queue::{ EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, }; +use crate::tripo3d::job::{MODEL3D_IMAGE_TO_MODEL_JOB_KIND, MODEL3D_TEXT_TO_MODEL_JOB_KIND}; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, @@ -233,6 +234,12 @@ pub(crate) fn user_visible_external_generation_error( if job_kind == EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND && error.is_some() { return Some("背景音乐生成失败,请稍后重试。".to_string()); } + // 3D 生成失败文本会带上 provider 状态码与上游报文,只对用户暴露稳定文案。 + if (job_kind == MODEL3D_TEXT_TO_MODEL_JOB_KIND || job_kind == MODEL3D_IMAGE_TO_MODEL_JOB_KIND) + && error.is_some() + { + return Some("3D 模型生成失败,请稍后重试。".to_string()); + } error } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 4eeb20c6a..28b0bbd79 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -51,6 +51,10 @@ use crate::{ }, request_context::RequestContext, state::AppState, + tripo3d::{ + job::{MODEL3D_IMAGE_TO_MODEL_JOB_KIND, MODEL3D_TEXT_TO_MODEL_JOB_KIND}, + worker::process_model3d_job, + }, vector_engine_audio_generation::{ generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner, }, @@ -1070,6 +1074,18 @@ async fn process_external_generation_job_once( } } } + MODEL3D_TEXT_TO_MODEL_JOB_KIND | MODEL3D_IMAGE_TO_MODEL_JOB_KIND => { + // 3D 生成复用现有 worker 外壳,但内部是 at-most-once submit:崩溃后租约到期 + // 不会重新 submit,失败即终态并冲正扣费。 + let caller = editor_generation_worker_caller(&worker_id, &job)?; + match process_model3d_job(&state, &caller, &job, provider_deadline).await { + Ok(()) => Ok(()), + Err(message) => { + fail_job(&state, &worker_id, &job, message.clone()).await?; + Err(message) + } + } + } unknown => { warn!( job_id = job.job_id, @@ -1181,7 +1197,8 @@ struct LegacyEditorImageEditWorkerPayload { asset_label: Option, source_resource_id: Option, target_layer_id: Option, - canvas_completion: Option, + canvas_completion: + Option, } fn parse_editor_image_edit_worker_payload( @@ -1392,7 +1409,9 @@ fn external_generation_worker_job_timeout(config: &AppConfig, job_kind: &str) -> | EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND | EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND | EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND - | EDITOR_VIDEO_GENERATION_JOB_KIND => config.external_generation_worker_long_job_timeout, + | EDITOR_VIDEO_GENERATION_JOB_KIND + | MODEL3D_TEXT_TO_MODEL_JOB_KIND + | MODEL3D_IMAGE_TO_MODEL_JOB_KIND => config.external_generation_worker_long_job_timeout, _ => config.external_generation_worker_job_timeout, } } @@ -1955,6 +1974,8 @@ mod tests { notification_acknowledged_at: None, notification_acknowledged_at_micros: None, phase: Some("generating".to_string()), + provider_kind: None, + provider_task_id: None, } } } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 8515721f5..0b7cec2a5 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -28,6 +28,7 @@ mod custom_world_asset_prompts; mod editor_agent; mod editor_background_music_prompt_assist; mod editor_generation_config; +mod editor_generation_model3d_records; mod editor_generation_queue; mod editor_green_screen; mod editor_project; @@ -83,6 +84,7 @@ mod state; mod telemetry; mod tracking; mod tracking_outbox; +mod tripo3d; mod vector_engine_audio_generation; mod volcengine_speech; mod wallet_refund_outbox; @@ -158,6 +160,7 @@ fn main() -> Result<(), io::Error> { async fn run_server(config: AppConfig) -> Result<(), io::Error> { validate_bgfilter_internal_token_for_startup(&config).map_err(io::Error::other)?; + validate_tripo_config_for_startup(&config).map_err(io::Error::other)?; validate_llm_router_config_for_startup(&config).map_err(io::Error::other)?; init_tracing( &config.log_filter, @@ -180,6 +183,24 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { run_http_role(config).await } +/// 3D 生成的启动期门禁:会提交或会执行 Tripo job 的角色,缺 `TRIPO_*` 就必须起不来。 +/// +/// 放在这里而不是请求路径上,是因为「进程起来了、健康检查全绿、用户拿到 202 之后 job 失败」 +/// 这种失败点离排查入口太远:HTTP 角色收单时不碰 provider,worker 只在自己跑 job 时才发现。 +fn validate_tripo_config_for_startup(config: &AppConfig) -> Result<(), String> { + if should_validate_tripo_config_for_startup(config.process_role) { + crate::tripo3d::provider::validate_tripo_startup_config(config)?; + } + Ok(()) +} + +fn should_validate_tripo_config_for_startup(process_role: ProcessRole) -> bool { + matches!( + process_role, + ProcessRole::Api | ProcessRole::ExternalGenerationWorker | ProcessRole::All + ) +} + fn validate_llm_router_config_for_startup(config: &AppConfig) -> Result<(), String> { if !matches!(config.process_role, ProcessRole::Api | ProcessRole::All) { return Ok(()); @@ -860,8 +881,10 @@ mod tests { parse_required_bgfilter_worker_capacity, protected_env_keys_from, should_initialize_editor_generation_pricing_for_startup, should_restore_auth_store_for_startup, should_start_profile_recharge_expiration_listener, - should_validate_bgfilter_internal_token_for_startup, strip_env_value, + should_validate_bgfilter_internal_token_for_startup, + should_validate_tripo_config_for_startup, strip_env_value, validate_bgfilter_internal_token_for_startup, validate_llm_router_config_for_startup, + validate_tripo_config_for_startup, }; use crate::config::{AppConfig, ProcessRole}; @@ -1072,4 +1095,48 @@ mod tests { ProcessRole::ExternalGenerationController )); } + + #[test] + fn tripo_startup_gate_covers_every_role_that_touches_3d() { + // HTTP 角色收 3D 单、worker 角色跑 3D job,两者缺配置都不许起。 + assert!(should_validate_tripo_config_for_startup(ProcessRole::Api)); + assert!(should_validate_tripo_config_for_startup( + ProcessRole::ExternalGenerationWorker + )); + assert!(should_validate_tripo_config_for_startup(ProcessRole::All)); + + // 与 3D 无关的角色不因此起不来。 + assert!(!should_validate_tripo_config_for_startup( + ProcessRole::BgfilterWorker + )); + assert!(!should_validate_tripo_config_for_startup( + ProcessRole::ExternalGenerationController + )); + } + + #[test] + fn tripo_startup_gate_reports_the_missing_variable() { + let config = AppConfig { + process_role: ProcessRole::Api, + tripo_base_url: String::new(), + tripo_api_key: None, + ..AppConfig::default() + }; + + let error = validate_tripo_config_for_startup(&config) + .expect_err("HTTP 角色缺 3D 配置必须拒绝启动"); + assert!(error.contains("TRIPO_BASE_URL"), "{error}"); + } + + #[test] + fn tripo_startup_gate_skips_unrelated_roles() { + let config = AppConfig { + process_role: ProcessRole::BgfilterWorker, + tripo_base_url: String::new(), + tripo_api_key: None, + ..AppConfig::default() + }; + + assert!(validate_tripo_config_for_startup(&config).is_ok()); + } } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index a7b970bfc..64bbe3e7c 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -45,6 +45,9 @@ use crate::editor_generation_config::{ EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore, EditorGenerationPricingUnit, }; +use crate::editor_generation_model3d_records::{ + EditorGenerationModel3dSections, model3d_config_from_sections, model3d_sections_from_config, +}; use crate::tracking_outbox::TrackingOutbox; use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox}; use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; @@ -425,11 +428,17 @@ pub enum AppStateInitError { Llm(LlmError), } +/// 定价配置落库用的完整记录内容:`models` 段与 3D 两段同事务写入。 +struct EditorGenerationPricingRecordInput { + models: Vec, + model3d: EditorGenerationModel3dSections, +} + fn editor_generation_pricing_to_records( config: &EditorGenerationPricingConfig, -) -> Result, EditorGenerationPricingError> { +) -> Result { config.validate()?; - Ok(config + let models = config .models .iter() .map(|(model, pricing)| EditorGenerationModelPricingRecord { @@ -449,7 +458,11 @@ fn editor_generation_pricing_to_records( }) .collect(), }) - .collect()) + .collect(); + Ok(EditorGenerationPricingRecordInput { + models, + model3d: model3d_sections_from_config(config.model3d.as_ref())?, + }) } fn editor_generation_pricing_from_record( @@ -506,7 +519,16 @@ fn editor_generation_pricing_from_record( })?; models.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing); } - let config = EditorGenerationPricingConfig { models }; + // 3D 两段以 SpacetimeDB 为准;记录里缺键(含整段缺失的老行)用本地配置同段补齐, + // 契约之外的遗留键剔除。本地配置在这里只承担种子与兜底,不再是 3D 的权威。 + let model3d = model3d_config_from_sections( + EditorGenerationModel3dSections { + text_to_model_pricing: record.text_to_model_pricing, + image_to_model_pricing: record.image_to_model_pricing, + }, + legacy_fallback.model3d.as_ref(), + )?; + let config = EditorGenerationPricingConfig { models, model3d }; config.validate()?; Ok(config) } @@ -514,17 +536,19 @@ fn editor_generation_pricing_from_record( fn editor_generation_pricing_upsert_input( config: &AppConfig, admin_user_id: String, - models: Vec, + records: EditorGenerationPricingRecordInput, updated_at_micros: i64, ) -> EditorGenerationPricingConfigUpsertRecordInput { EditorGenerationPricingConfigUpsertRecordInput { admin_user_id, - models, + models: records.models, updated_at_micros, bootstrap_secret: config .spacetime_runtime_service_bootstrap_secret .clone() .unwrap_or_default(), + text_to_model_pricing: records.model3d.text_to_model_pricing, + image_to_model_pricing: records.model3d.image_to_model_pricing, } } @@ -752,6 +776,10 @@ impl AppState { self.http_request_permit_pools.clone() } + /// 当前定价配置:优先读 SpacetimeDB 行,读不到就用进程内缓存兜底。 + /// + /// 读取不再带版本:后台保存不做版本比对(同一时间只有一个管理员在操作),进程内缓存只 + /// 承担「读库失败时的兜底」,不是任何写入的拦截点。 pub(crate) async fn editor_generation_pricing( &self, ) -> Result { @@ -784,6 +812,10 @@ impl AppState { } } + /// 保存完整定价配置(`models` 与 3D 两段)。 + /// + /// 保存就是整段覆盖,不再做版本比对:后台同时只有一个管理员在操作,读到旧值再保存的 + /// 并发窗口按「后写覆盖先写」处理;进程内缓存只做读取失败时的兜底,不是拦截点。 pub(crate) async fn save_editor_generation_pricing( &self, admin_user_id: String, @@ -797,13 +829,13 @@ impl AppState { #[cfg(not(test))] { - let models = editor_generation_pricing_to_records(&next)?; + let records = editor_generation_pricing_to_records(&next)?; let record = self .spacetime_client .upsert_editor_generation_pricing_config(editor_generation_pricing_upsert_input( &self.config, admin_user_id, - models, + records, crate::editor_project::current_utc_micros(), )) .await @@ -827,14 +859,14 @@ impl AppState { &self, ) -> Result { let fallback = self.editor_generation_pricing_store.snapshot()?; - let models = editor_generation_pricing_to_records(&fallback)?; + let records = editor_generation_pricing_to_records(&fallback)?; let record = self .spacetime_client .initialize_editor_generation_pricing_config_if_missing( editor_generation_pricing_upsert_input( &self.config, "system:editor-generation-pricing".to_string(), - models, + records, crate::editor_project::current_utc_micros(), ), ) @@ -2191,14 +2223,14 @@ async fn initialize_editor_generation_runtime_service_identity_for_startup( let fallback = pricing_store .snapshot() .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; - let models = editor_generation_pricing_to_records(&fallback) + let records = editor_generation_pricing_to_records(&fallback) .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; spacetime_client .initialize_editor_generation_pricing_config_if_missing( editor_generation_pricing_upsert_input( config, "system:editor-generation-pricing".to_string(), - models, + records, crate::editor_project::current_utc_micros(), ), ) @@ -2810,6 +2842,87 @@ mod tests { use spacetime_client::SpacetimeClientStage; use super::*; + use crate::tripo3d::pricing::Model3dAddOn; + + /// 3D 段按 payload 生效(不再有「省略即沿用」)。 + #[tokio::test] + async fn saving_pricing_applies_explicit_model3d() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let before = state + .editor_generation_pricing() + .await + .expect("默认定价必须可读"); + let default_model3d = before.model3d.clone().expect("默认配置必须带 3D 定价段"); + + let mut custom = default_model3d; + custom + .text_to_model_pricing + .add_on_prices + .insert(Model3dAddOn::QuadMesh, 7); + let mut next = before; + next.model3d = Some(custom.clone()); + + let saved = state + .save_editor_generation_pricing("admin:test".to_string(), next) + .await + .expect("显式 3D 段的保存必须成功"); + assert_eq!(saved.model3d.as_ref(), Some(&custom)); + + let reread = state + .editor_generation_pricing() + .await + .expect("定价必须可读"); + assert_eq!(reread.model3d.as_ref(), Some(&custom)); + } + + /// 保存不再做版本比对:同一份旧值连存两次按「后写覆盖先写」处理,不做拒绝。 + #[tokio::test] + async fn saving_pricing_twice_keeps_last_write() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let before = state + .editor_generation_pricing() + .await + .expect("默认定价必须可读"); + + let mut first_next = before.clone(); + first_next + .model3d + .as_mut() + .expect("默认配置必须带 3D 定价段") + .text_to_model_pricing + .add_on_prices + .insert(Model3dAddOn::QuadMesh, 7); + state + .save_editor_generation_pricing("admin:test".to_string(), first_next) + .await + .expect("首次保存必须成功"); + + let mut second_next = before; + second_next + .model3d + .as_mut() + .expect("默认配置必须带 3D 定价段") + .text_to_model_pricing + .add_on_prices + .insert(Model3dAddOn::QuadMesh, 11); + state + .save_editor_generation_pricing("admin:test".to_string(), second_next) + .await + .expect("用读到过的旧值再保存一次必须成功(不再有版本冲突)"); + + let reread = state + .editor_generation_pricing() + .await + .expect("定价必须可读"); + assert_eq!( + reread.model3d.as_ref().and_then(|model3d| model3d + .text_to_model_pricing + .add_on_prices + .get(&Model3dAddOn::QuadMesh)), + Some(&11), + "后写的保存必须生效" + ); + } #[test] fn template_library_credentials_never_mix_dedicated_and_general_pairs() { @@ -2982,6 +3095,13 @@ mod tests { ); } + fn empty_pricing_record_input() -> EditorGenerationPricingRecordInput { + EditorGenerationPricingRecordInput { + models: Vec::new(), + model3d: EditorGenerationModel3dSections::default(), + } + } + #[test] fn editor_generation_pricing_typed_record_round_trips() { let expected = crate::editor_generation_config::parse_editor_generation_pricing_json( @@ -2989,13 +3109,16 @@ mod tests { "test default pricing", ) .expect("default pricing should parse"); + let records = + editor_generation_pricing_to_records(&expected).expect("pricing should map to records"); let record = EditorGenerationPricingConfigRecord { config_id: "global".to_string(), - models: editor_generation_pricing_to_records(&expected) - .expect("pricing should map to records"), + models: records.models, updated_by_admin_user_id: Some("admin:test".to_string()), updated_at: "2026-07-10T00:00:00Z".to_string(), updated_at_micros: 1, + text_to_model_pricing: records.model3d.text_to_model_pricing, + image_to_model_pricing: records.model3d.image_to_model_pricing, }; let actual = editor_generation_pricing_from_record(record, &expected) @@ -3011,15 +3134,19 @@ mod tests { "test default pricing", ) .expect("default pricing should parse"); - let mut models = + let mut records = editor_generation_pricing_to_records(&fallback).expect("pricing should map to records"); - models.retain(|pricing| pricing.model != EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS); + records + .models + .retain(|pricing| pricing.model != EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS); let record = EditorGenerationPricingConfigRecord { config_id: "global".to_string(), - models, + models: records.models, updated_by_admin_user_id: Some("admin:test".to_string()), updated_at: "2026-08-07T00:00:00Z".to_string(), updated_at_micros: 1, + text_to_model_pricing: records.model3d.text_to_model_pricing, + image_to_model_pricing: records.model3d.image_to_model_pricing, }; let actual = editor_generation_pricing_from_record(record, &fallback) @@ -3037,7 +3164,7 @@ mod tests { let without_secret = editor_generation_pricing_upsert_input( &config, "admin:test".to_string(), - Vec::new(), + empty_pricing_record_input(), 123, ); assert_eq!(without_secret.bootstrap_secret, ""); @@ -3046,7 +3173,7 @@ mod tests { let with_secret = editor_generation_pricing_upsert_input( &config, "admin:test".to_string(), - Vec::new(), + empty_pricing_record_input(), 123, ); @@ -3062,15 +3189,18 @@ mod tests { "test default pricing", ) .expect("default pricing should parse"); - let mut models = + let mut records = editor_generation_pricing_to_records(&config).expect("pricing should map to records"); - models.push(models[0].clone()); + let duplicate = records.models[0].clone(); + records.models.push(duplicate); let record = EditorGenerationPricingConfigRecord { config_id: "global".to_string(), - models, + models: records.models, updated_by_admin_user_id: None, updated_at: "2026-07-10T00:00:00Z".to_string(), updated_at_micros: 1, + text_to_model_pricing: records.model3d.text_to_model_pricing, + image_to_model_pricing: records.model3d.image_to_model_pricing, }; let error = editor_generation_pricing_from_record(record, &config) diff --git a/server-rs/crates/api-server/src/tripo3d/artifacts.rs b/server-rs/crates/api-server/src/tripo3d/artifacts.rs new file mode 100644 index 000000000..59238cf47 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/artifacts.rs @@ -0,0 +1,41 @@ +//! provider 产物的读取。 +//! +//! 下载与「body 中途失败整体重下」都在 `platform-tripo` 的下载入口里完成(重试上限 +//! 取自 provider 客户端配置),这里只做 api-server 自己的口径:产物不能为空。 +//! TODO(stream): provider SDK 与 `platform-oss` 都支持流式 / 分片后,这里应当直接 +//! 把流转交 OSS,不再把几十 MB 的模型完整读进 api-server 内存。 + +use axum::http::StatusCode; +use platform_tripo::TripoArtifactBytes; +use serde_json::json; + +use crate::http_error::AppError; + +use super::provider::TRIPO_PROVIDER; + +/// 单次下载允许的单个产物上限。超过它说明响应异常或产物口径变了, +/// 宁可失败退款也不要把 worker 的内存打满。 +pub(crate) const MODEL3D_MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024; + +pub(crate) struct DownloadedArtifact { + pub(crate) bytes: Vec, + pub(crate) content_type: String, +} + +/// 完整读出的产物在落库前的最后一道检查:空产物按上游内容不合法失败, +/// 不写出一个 0 字节的对象再让下游去猜。 +pub(crate) fn read_artifact(artifact: TripoArtifactBytes) -> Result { + if artifact.bytes.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-artifact-empty", + "message": "provider 返回的 3D 产物为空。", + })), + ); + } + Ok(DownloadedArtifact { + bytes: artifact.bytes, + content_type: artifact.content_type.unwrap_or_default(), + }) +} diff --git a/server-rs/crates/api-server/src/tripo3d/errors.rs b/server-rs/crates/api-server/src/tripo3d/errors.rs new file mode 100644 index 000000000..94f6ccfa3 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/errors.rs @@ -0,0 +1,438 @@ +//! Tripo 生成对外错误的统一映射。 +//! +//! 映射目标只有三档:参数问题 400、幂等冲突 409、provider / 配置问题 5xx。 +//! 4 字头里只有 400 / 422 是「你的请求被拒」:401 / 403 是密钥或权限没配好、404 是找不到、 +//! 409 是上游冲突,都不按参数问题回 400,否则客户端会去改一个改不动的地方。 +//! 任何分支都不把 provider task id、带签名的临时地址或 SDK 类型带进响应。 + +use axum::http::StatusCode; +use platform_tripo::TripoError; +use serde_json::json; + +use crate::{editor_generation_config::EditorGenerationPricingError, http_error::AppError}; + +use super::pricing::Model3dPricingError; +use super::provider::TRIPO_PROVIDER; +use super::validation::Model3dRequestError; + +pub(crate) fn map_request_error(error: Model3dRequestError) -> AppError { + let field = error.field(); + let mut details = json!({ + "provider": TRIPO_PROVIDER, + "reason": "invalid-model3d-request", + "message": error.to_string(), + }); + if let Some(field) = field + && let Some(object) = details.as_object_mut() + { + object.insert("field".to_string(), json!(field)); + } + AppError::from_status(StatusCode::BAD_REQUEST).with_details(details) +} + +/// 定价未配置属于部署口径:接口直接拒绝,不扣费、不入队。 +pub(crate) fn map_pricing_error(error: Model3dPricingError) -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-pricing-unavailable", + "message": error.to_string(), + })) +} + +/// 定价快照本身读取失败属于服务端问题,与“3D 定价未配置”分开报。 +pub(crate) fn map_pricing_store_error(error: EditorGenerationPricingError) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + // provider 沿用全仓库定价错误的既有口径(`character_animation_assets`、 + // `editor_agent::api` 同样写 editor-generation-pricing),不改成 TRIPO_PROVIDER。 + "provider": "editor-generation-pricing", + "reason": "model3d-pricing-store-error", + "message": error.to_string(), + })) +} + +/// provider 错误归一:参数类回 400,瞬时状态按可重试语义上报,其余按“上游不可用/上游拒绝”分流。 +pub(crate) fn map_provider_error(error: TripoError) -> AppError { + let (status, reason, message) = match &error { + TripoError::InvalidParameters { .. } | TripoError::SdkInvalidArgument(_) => ( + StatusCode::BAD_REQUEST, + "tripo-invalid-parameters", + error.to_string(), + ), + // 408 / 425 / 429 与 platform-tripo 的 is_retryable 口径一致,属于瞬时状态: + // 混进 400 会让客户端把限流 / 上游超时误读成自己参数写错了。 + TripoError::Api { + status: Some(429), .. + } => ( + StatusCode::TOO_MANY_REQUESTS, + "tripo-api-rate-limited", + "3D 生成服务当前限流,请稍后重试。".to_string(), + ), + TripoError::Api { + status: Some(408), .. + } => ( + StatusCode::GATEWAY_TIMEOUT, + "tripo-api-timeout", + "3D 生成服务响应超时,请稍后重试。".to_string(), + ), + TripoError::Api { + status: Some(425), .. + } => ( + StatusCode::BAD_GATEWAY, + "tripo-api-retry-later", + "3D 生成服务暂时无法处理该请求,请稍后重试。".to_string(), + ), + // 401 / 403 是 provider 侧密钥或调用权限没配好,不是调用方参数写错: + // 混进 400 会让客户端把服务端配置故障当成自己的问题去改参数。 + TripoError::Api { + status: Some(401 | 403), + .. + } => ( + StatusCode::BAD_GATEWAY, + "tripo-api-unauthorized", + "3D 生成服务的调用凭据或权限不可用,请联系支持。".to_string(), + ), + TripoError::Api { + status: Some(404), .. + } => ( + StatusCode::BAD_GATEWAY, + "tripo-api-not-found", + "3D 生成服务未找到请求的资源。".to_string(), + ), + TripoError::Api { + status: Some(409), .. + } => ( + StatusCode::CONFLICT, + "tripo-api-conflict", + "3D 生成服务报告请求冲突,请稍后重试。".to_string(), + ), + // 只有 400 / 422 真的是「参数被拒」,provider 的 code / message / suggestion 是原文 + // 转述的 HTTP 错误体,没有做过 URL 清洗:回给客户端可能带出带签名的地址等内部信息, + // 因此只给固定文案。 + TripoError::Api { + status: Some(400 | 422), + .. + } => ( + StatusCode::BAD_REQUEST, + "tripo-api-rejected", + "3D 生成请求被 provider 拒绝,请检查参数后重试。".to_string(), + ), + // 其余 4 字头(402 余额、405 方法、412 前置条件……)既不是调用方参数写错,也不是 + // 瞬时状态:按上游错误上报,保留原文之外的固定文案。 + TripoError::Api { + status: Some(400..=499), + .. + } => ( + StatusCode::BAD_GATEWAY, + "tripo-api-refused", + "3D 生成服务暂时拒绝了该请求,请稍后重试。".to_string(), + ), + // 以下都是 5xx:完整错误只进日志。`TripoError` 的 Display 会带上 provider task id, + // 直接回给客户端会破坏本文件「不暴露 task id」的口径,因此对外只给固定文案。 + TripoError::TaskFailure { .. } => { + tracing::error!(error = %error, "tripo task failed"); + ( + StatusCode::BAD_GATEWAY, + "tripo-task-failed", + "3D 生成任务在 provider 侧执行失败。".to_string(), + ) + } + TripoError::OutputSchema { .. } => { + tracing::error!(error = %error, "tripo task output schema error"); + ( + StatusCode::BAD_GATEWAY, + "tripo-output-schema", + "3D 生成任务的结果格式不符合预期,未落库。".to_string(), + ) + } + // 轮询侧按可重试继续查到截止时间,真走到这里说明预估的时间已经用尽。 + TripoError::TaskStatusUnknown { .. } => { + tracing::error!(error = %error, "tripo task status is unrecognized"); + ( + StatusCode::BAD_GATEWAY, + "tripo-task-status-unknown", + "3D 生成服务的任务状态暂时无法识别,请稍后重试。".to_string(), + ) + } + // 配置问题正常在 `provider.rs` 就按 503 拦下;这里只是把枚举匹配补全, + // 真走到这一步说明有调用方绕过了构造入口,按服务不可用上报。 + TripoError::Configuration { .. } => { + tracing::error!(error = %error, "tripo client misconfigured"); + ( + StatusCode::SERVICE_UNAVAILABLE, + "tripo-invalid-configuration", + "3D 生成服务配置不可用。".to_string(), + ) + } + TripoError::Request { .. } | TripoError::Sdk { .. } | TripoError::Api { .. } => { + tracing::error!(error = %error, "tripo upstream error"); + ( + StatusCode::BAD_GATEWAY, + "tripo-upstream-error", + "3D 生成服务暂时不可用,请稍后重试。".to_string(), + ) + } + }; + AppError::from_status(status).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": reason, + "message": message, + })) +} + +pub(crate) fn oss_unavailable(message: &str) -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "oss-unavailable", + "message": message, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn detail(error: &AppError, key: &str) -> Option { + error + .details() + .and_then(|details| details.get(key)) + .cloned() + } + + #[test] + fn provider_parameter_errors_map_to_bad_request() { + let error = map_provider_error(TripoError::InvalidParameters { + field: Some(platform_tripo::TripoField::Model), + reason: platform_tripo::TripoValidationReason::Required, + message: "model is required".to_string(), + }); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + } + + #[test] + fn provider_transport_errors_map_to_bad_gateway() { + let error = map_provider_error(TripoError::Request { + message: "transport".to_string(), + status: None, + retryable: true, + }); + assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY); + } + + /// 408 / 425 / 429 是 platform-tripo 认定的瞬时状态,不能按「参数有问题」回 400。 + #[test] + fn provider_transient_4xx_is_not_reported_as_bad_request() { + for (status, expected) in [ + (429u16, StatusCode::TOO_MANY_REQUESTS), + (408, StatusCode::GATEWAY_TIMEOUT), + (425, StatusCode::BAD_GATEWAY), + ] { + let error = map_provider_error(TripoError::Api { + code: 1001, + message: Some("transient".to_string()), + suggestion: None, + status: Some(status), + }); + assert_eq!(error.status_code(), expected, "status {status}"); + } + } + + #[test] + fn provider_client_side_4xx_stays_bad_request() { + for status in [400u16, 422] { + let error = map_provider_error(TripoError::Api { + code: 1002, + message: Some("bad parameter".to_string()), + suggestion: None, + status: Some(status), + }); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{status}"); + assert_eq!(detail(&error, "reason"), Some(json!("tripo-api-rejected"))); + } + } + + /// 401 / 403 / 404 / 409 与其余 4 字头都不是「调用方参数不对」:按上游 / 冲突上报, + /// 且各自有独立 reason,运维能从响应里看出是配置、找不到还是冲突。 + #[test] + fn provider_non_parameter_4xx_is_not_reported_as_bad_request() { + for (status, expected_status, expected_reason) in [ + (401u16, StatusCode::BAD_GATEWAY, "tripo-api-unauthorized"), + (403, StatusCode::BAD_GATEWAY, "tripo-api-unauthorized"), + (404, StatusCode::BAD_GATEWAY, "tripo-api-not-found"), + (409, StatusCode::CONFLICT, "tripo-api-conflict"), + (402, StatusCode::BAD_GATEWAY, "tripo-api-refused"), + ] { + let error = map_provider_error(TripoError::Api { + code: 1003, + message: Some("secret-upstream-body".to_string()), + suggestion: None, + status: Some(status), + }); + assert_eq!(error.status_code(), expected_status, "status {status}"); + assert_eq!(detail(&error, "reason"), Some(json!(expected_reason))); + let serialized = serde_json::to_string(error.details().expect("details 必须存在")) + .expect("可序列化"); + assert!(!serialized.contains("secret-upstream-body"), "{serialized}"); + } + } + + /// 本模块头部承诺「任何分支都不把 provider task id 带进响应」,502 分支必须守住。 + #[test] + fn provider_5xx_does_not_leak_task_id() { + let cases = [ + TripoError::TaskFailure { + task_id: "task-secret-123".to_string(), + status: "failed".to_string(), + message: Some("provider exploded".to_string()), + }, + TripoError::OutputSchema { + task_id: "task-secret-456".to_string(), + message: "output schema error".to_string(), + }, + ]; + for case in cases { + let error = map_provider_error(case); + assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY); + let serialized = serde_json::to_string(error.details().expect("details 必须存在")) + .expect("details 必须可序列化"); + for leaked in ["task-secret", "provider exploded", "output schema error"] { + assert!(!serialized.contains(leaked), "{serialized}"); + } + } + } + + /// SDK 参数错误与 provider 4xx 同档:都是「调用方参数不对」,不该报 5xx。 + #[test] + fn provider_sdk_invalid_argument_maps_to_bad_request() { + let error = map_provider_error(TripoError::SdkInvalidArgument( + "unknown argument: secret-sdk-argument".to_string(), + )); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + detail(&error, "reason"), + Some(json!("tripo-invalid-parameters")) + ); + } + + /// 没有可用状态码的 Api 错误、SDK 兜底错误与传输错误一律 502,且只回固定文案: + /// 上游报文与 task id 都不允许出现在响应里。 + #[test] + fn provider_unknown_upstream_errors_use_fixed_text() { + let cases = [ + ( + "api-without-status", + TripoError::Api { + code: 1001, + message: Some("secret-api-message".to_string()), + suggestion: None, + status: None, + }, + ), + ( + "api-5xx", + TripoError::Api { + code: 1001, + message: Some("secret-api-message".to_string()), + suggestion: None, + status: Some(500), + }, + ), + ( + "sdk-fallback", + TripoError::Sdk { + message: "secret-sdk-message".to_string(), + }, + ), + ( + "request", + TripoError::Request { + message: "secret-request-message".to_string(), + status: None, + retryable: true, + }, + ), + ]; + for (name, case) in cases { + let error = map_provider_error(case); + assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY, "{name}"); + assert_eq!( + detail(&error, "reason"), + Some(json!("tripo-upstream-error")), + "{name}" + ); + let serialized = serde_json::to_string(error.details().expect("details 必须存在")) + .expect("details 必须可序列化"); + for leaked in ["secret-", "task-"] { + assert!(!serialized.contains(leaked), "{name}: {serialized}"); + } + } + } + + /// 平台规则直接给字段路径;provider 预检失败沿用 provider 字段名;字段未知时不写 field 键。 + #[test] + fn request_errors_report_field_only_when_known() { + let platform = map_request_error(Model3dRequestError::InvalidRequest { + field: "prompt", + message: "prompt is required".to_string(), + }); + assert_eq!(platform.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + detail(&platform, "reason"), + Some(json!("invalid-model3d-request")) + ); + assert_eq!(detail(&platform, "field"), Some(json!("prompt"))); + + let provider = map_request_error(Model3dRequestError::Provider( + TripoError::InvalidParameters { + field: Some(platform_tripo::TripoField::GeometryQuality), + reason: platform_tripo::TripoValidationReason::InvalidCombination, + message: "geometry_quality is not supported".to_string(), + }, + )); + assert_eq!(provider.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(detail(&provider, "field"), Some(json!("geometry_quality"))); + + let unknown = map_request_error(Model3dRequestError::Provider(TripoError::Request { + message: "transport".to_string(), + status: None, + retryable: false, + })); + assert_eq!(unknown.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(detail(&unknown, "field"), None); + } + + /// 定价未配置属部署口径:503 且原因可被后台区分,不能混成 500。 + #[test] + fn pricing_not_configured_is_service_unavailable() { + let error = map_pricing_error(Model3dPricingError::NotConfigured); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + detail(&error, "reason"), + Some(json!("model3d-pricing-unavailable")) + ); + } + + /// 定价快照读取失败属服务端问题:500,且沿用全仓库定价错误的 provider 口径。 + #[test] + fn pricing_store_failures_are_internal_errors() { + let error = map_pricing_store_error(EditorGenerationPricingError::Model3d( + Model3dPricingError::NotConfigured, + )); + assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + detail(&error, "provider"), + Some(json!("editor-generation-pricing")) + ); + assert_eq!( + detail(&error, "reason"), + Some(json!("model3d-pricing-store-error")) + ); + } + + #[test] + fn oss_unavailable_is_service_unavailable() { + let error = oss_unavailable("OSS 未完成环境变量配置,无法写入 3D 产物。"); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(detail(&error, "provider"), Some(json!("aliyun-oss"))); + assert_eq!(detail(&error, "reason"), Some(json!("oss-unavailable"))); + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/image_source.rs b/server-rs/crates/api-server/src/tripo3d/image_source.rs new file mode 100644 index 000000000..c4a29ec01 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/image_source.rs @@ -0,0 +1,360 @@ +//! 图生 3D 的图片输入解析。 +//! +//! 请求只给站内的画布资源 ID 或账号素材 ID,分两步处理: +//! 1. 提交时只做元数据预检:按 ID 定点确认归属与记录类型,不读字节、不调用 provider, +//! 因此跨 owner、未登记、已删除与类型不符都在扣费与入队之前失败; +//! 2. worker 执行时重新确认同一事实,再从私有 OSS 读出字节,最后上传 provider 换 +//! `file_token`。**不把带签名的临时地址交给第三方**,也不接受 URL / data URL。 +//! +//! 预检只按主键定点查引用,不拉取当前用户的工程列表或素材库;跨 owner、已删除与不存在 +//! 收敛成同一句 400,避免把别的账号是否存在该 ID 变成可探测信息。判据是查询给出的 +//! [`EditorPointLookupFailure`] 归类信号——不是状态码:403(服务身份校验失败)、409(版本 +//! 冲突)与 5xx 都是基础设施 / 并发问题,原样上报;「记录已解析但缺对象键」是行本身不完整, +//! 按 502 上报。 +//! +//! 定点查询是注入到 `resolve_source_object_key_with` 的参数,解析每分支只发一次;测试按可观察 +//! 行为断言(查询次数、查询 ID、带出的对象键、各分支状态码),不依赖源码文本。 +//! +//! TODO(oss-direct-read): 「让 Tripo 侧直接读站内对象」这条替代路径(交签名 URL,或对端直读 +//! OSS)能省掉一次字节中转与一份上行带宽,但等于把私有对象和带时效的读取凭据交给第三方, +//! 安全口径与可行性都还没讨论;讨论清楚之前保持「api-server 自己读、自己传」。 + +use axum::http::StatusCode; +use platform_tripo::{TripoImageInput, TripoProviderClient}; +use serde_json::json; +use shared_contracts::model3d::common::Model3dGenerationSource; +use spacetime_client::EditorReferenceRecord; + +use crate::{ + editor_project::{ + EditorPointLookupFailure, EditorPointLookupKind, + read_editor_reference_image_object_with_client, + resolve_editor_reference_record_by_id_for_owner_classified, + }, + http_error::AppError, + state::AppState, +}; + +use super::{errors::map_provider_error, provider::TRIPO_PROVIDER}; + +/// 提交时的元数据预检:只回答“这个引用当前是否可用”,不产生任何 provider 副作用。 +pub(crate) async fn preflight_image_source( + state: &AppState, + owner_user_id: &str, + source: &Model3dGenerationSource, +) -> Result<(), AppError> { + resolve_source_object_key(state, owner_user_id, source) + .await + .map(|_| ()) +} + +/// worker 执行时把站内图片引用解析成 provider 可读的图片输入。 +pub(crate) async fn resolve_image_input( + state: &AppState, + client: &TripoProviderClient, + owner_user_id: &str, + source: &Model3dGenerationSource, +) -> Result { + let object_key = resolve_source_object_key(state, owner_user_id, source).await?; + let image = read_editor_reference_image_object_with_client( + state, + object_key.as_str(), + state.editor_oss_http_client(), + ) + .await?; + client + .upload_image( + image.bytes, + image.file_name.as_str(), + image.mime_type.as_str(), + ) + .await + .map_err(map_provider_error) +} + +/// 定点解析出对象键:按 `source` 的分支校验记录类型,并确认归属与对象键都存在。 +async fn resolve_source_object_key( + state: &AppState, + owner_user_id: &str, + source: &Model3dGenerationSource, +) -> Result { + // 生产路径只走「按 ID 定点查一条引用」:不列工程、不列素材库,也不读图片字节。 + resolve_source_object_key_with(source, |reference_id| async move { + resolve_editor_reference_record_by_id_for_owner_classified( + state, + owner_user_id, + &reference_id, + ) + .await + .map(ResolvedReference::from) + }) + .await +} + +/// 定点查询返回的窄结果:只保留判定引用是否可用所需的记录类型与对象键。 +struct ResolvedReference { + kind: ResolvedReferenceKind, + object_key: Option, +} + +/// 记录类型判别:与 `Model3dGenerationSource` 的两个分支一一对应。 +/// +/// 用枚举而不是字符串字面量:`From` 写入的取值与 +/// `resolve_source_object_key_with` 里比较的取值由同一处产出,错字不会再拖到运行期才发现。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ResolvedReferenceKind { + Resource, + Asset, +} + +impl ResolvedReferenceKind { + const fn as_str(self) -> &'static str { + match self { + Self::Resource => "resource", + Self::Asset => "asset", + } + } +} + +impl From for ResolvedReference { + fn from(record: EditorReferenceRecord) -> Self { + match record { + EditorReferenceRecord::ProjectResource(resource) => Self { + kind: ResolvedReferenceKind::Resource, + object_key: resource.object_key, + }, + EditorReferenceRecord::Asset(asset) => Self { + kind: ResolvedReferenceKind::Asset, + object_key: asset.object_key, + }, + } + } +} + +/// 解析规则本身:把「一次按 ID 查询」的结果映射成对象键,或按引用不可用 / 类型不符收口。 +/// +/// 查询以参数注入,测试据此断言只发起一次定点查询、且各失败分支的对外语义; +/// 这里不接触 provider,也不读对象字节。 +async fn resolve_source_object_key_with( + source: &Model3dGenerationSource, + resolve: F, +) -> Result +where + F: FnOnce(String) -> Fut, + Fut: std::future::Future>, +{ + let (requested_kind, reference_id) = match source { + Model3dGenerationSource::Resource { resource_id } => { + (ResolvedReferenceKind::Resource, resource_id.as_str()) + } + Model3dGenerationSource::Asset { asset_id } => { + (ResolvedReferenceKind::Asset, asset_id.as_str()) + } + }; + let resolved = resolve(reference_id.to_string()).await.map_err(|failure| { + // 只看查询给出的归类:`Unavailable` 才是「未登记 / 跨 owner / 已删除」,收敛成同一句 400; + // 其余(服务身份 403、版本冲突 409、runtime 与连接故障)原样上报,不能按状态码猜。 + match failure.kind { + EditorPointLookupKind::Unavailable => image_source_unavailable(), + EditorPointLookupKind::Other => failure.error, + } + })?; + if resolved.kind != requested_kind { + return Err(image_source_kind_mismatch( + requested_kind.as_str(), + resolved.kind.as_str(), + )); + } + resolved + .object_key + // 空串 / 纯空白的对象键与缺字段同义:放进队列后才会在下载源图时失败, + // 那时任务已经提交并计费,正是这条预检要挡下的情况。 + .filter(|key| !key.trim().is_empty()) + .ok_or_else(image_source_object_key_missing) +} + +/// 未登记、跨 owner 与已删除都收敛成同一句 400。 +fn image_source_unavailable() -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-image-source-unavailable", + "field": "source", + "message": "图片输入必须是当前账号已登记的画布资源或素材。", + })) +} + +/// 记录已解析且归属调用方,但缺少对象键:这是 SpacetimeDB 行本身不完整,不是用户的引用不可用。 +/// +/// 归到 400 会让客户端照着「必须是已登记资源」去改一个改不动的地方,也丢掉「行存在但格式异常」 +/// 这个运维信号;口径与 `editor_project::validate_editor_reference_id_for_owner` 一致,按 5xx 上报。 +fn image_source_object_key_missing() -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-image-source-object-key-missing", + "field": "source", + "message": "该图片引用已登记但缺少对象键,请稍后重试或联系支持。", + })) +} + +/// `kind` 与 ID 实际指向的记录类型不一致;两者都由调用方给出,不涉及他人数据是否存在。 +fn image_source_kind_mismatch(requested_kind: &str, resolved_kind: &str) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-image-source-kind-mismatch", + "field": "source.kind", + "message": format!( + "source.kind 是 {requested_kind},但该 ID 指向 {resolved_kind} 记录,两者必须一致。" + ), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn reason_of(error: &AppError) -> Option<&str> { + error.details()?.get("reason")?.as_str() + } + + fn probe_error(status: StatusCode) -> AppError { + AppError::from_status(status).with_details(json!({ "reason": "probe" })) + } + + fn probe_failure(kind: EditorPointLookupKind, status: StatusCode) -> EditorPointLookupFailure { + EditorPointLookupFailure { + kind, + error: probe_error(status), + } + } + + fn resolved(kind: ResolvedReferenceKind, object_key: Option<&str>) -> ResolvedReference { + ResolvedReference { + kind, + object_key: object_key.map(str::to_string), + } + } + + /// 两个 source 分支都只发起一次按 ID 的定点查询,并把查到的那条记录的对象键原样带出: + /// 解析不需要「列工程 / 列素材库再筛」这类宽查询,注入的查询签名本身就是「一个 ID → 一条记录」。 + #[tokio::test] + async fn image_source_resolution_queries_only_the_requested_reference_id() { + for (source, requested_id, kind, object_key) in [ + ( + Model3dGenerationSource::Resource { + resource_id: "res-1".to_string(), + }, + "res-1", + ResolvedReferenceKind::Resource, + "objects/res-1.png", + ), + ( + Model3dGenerationSource::Asset { + asset_id: "asset-9".to_string(), + }, + "asset-9", + ResolvedReferenceKind::Asset, + "objects/asset-9.png", + ), + ] { + let mut queried: Vec = Vec::new(); + + let resolved_key = resolve_source_object_key_with(&source, |reference_id| { + queried.push(reference_id); + async move { Ok(resolved(kind, Some(object_key))) } + }) + .await + .expect("同类型的已登记引用应解析出对象键"); + + assert_eq!(resolved_key, object_key); + assert_eq!(queried, vec![requested_id.to_string()]); + } + } + + /// 记录类型与 `source` 分支不一致时报类型不符,而不是把对象键照用。 + #[tokio::test] + async fn image_source_resolution_rejects_kind_mismatch() { + let source = Model3dGenerationSource::Asset { + asset_id: "asset-1".to_string(), + }; + + let error = resolve_source_object_key_with(&source, |_| async { + Ok(resolved( + ResolvedReferenceKind::Resource, + Some("objects/asset-1.png"), + )) + }) + .await + .expect_err("素材 ID 指向画布资源时必须报错"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + reason_of(&error), + Some("model3d-image-source-kind-mismatch") + ); + assert_eq!( + error.details().and_then(|details| details.get("field")), + Some(&json!("source.kind")) + ); + } + + /// 记录存在但缺对象键是服务端数据问题:报 5xx,不用「引用不可用」的 400 误导客户端。 + #[tokio::test] + async fn image_source_resolution_reports_missing_object_key_as_server_side_failure() { + let source = Model3dGenerationSource::Resource { + resource_id: "res-1".to_string(), + }; + + let error = resolve_source_object_key_with(&source, |_| async { + Ok(resolved(ResolvedReferenceKind::Resource, None)) + }) + .await + .expect_err("缺对象键时必须按服务端问题处理"); + + assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY); + assert_eq!( + reason_of(&error), + Some("model3d-image-source-object-key-missing") + ); + } + + /// 只有查询明确给出的「引用不可用」才收敛成同一句 400;其余一律保留原错误。 + #[tokio::test] + async fn image_source_resolution_only_collapses_unavailable_references() { + let source = Model3dGenerationSource::Resource { + resource_id: "res-1".to_string(), + }; + + let collapsed = resolve_source_object_key_with(&source, |_| async move { + Err(probe_failure( + EditorPointLookupKind::Unavailable, + StatusCode::NOT_FOUND, + )) + }) + .await + .expect_err("引用不可用必须收敛"); + assert_eq!(collapsed.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + reason_of(&collapsed), + Some("model3d-image-source-unavailable") + ); + + // 400 / 404 里混着 runtime 故障与通用过程错误:归类是 Other 时不得改写成「引用不可用」, + // 否则会把服务端问题说成「图片必须是已登记资源」。 + for kept_status in [ + StatusCode::BAD_REQUEST, + StatusCode::NOT_FOUND, + StatusCode::FORBIDDEN, + StatusCode::CONFLICT, + StatusCode::INTERNAL_SERVER_ERROR, + ] { + let kept = resolve_source_object_key_with(&source, |_| async move { + Err(probe_failure(EditorPointLookupKind::Other, kept_status)) + }) + .await + .expect_err("非引用不可用必须继续报原错"); + assert_eq!(kept.status_code(), kept_status); + assert_eq!(reason_of(&kept), Some("probe")); + } + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/job.rs b/server-rs/crates/api-server/src/tripo3d/job.rs new file mode 100644 index 000000000..1545823f8 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/job.rs @@ -0,0 +1,482 @@ +//! Tripo 3D 生成的队列身份。 +//! +//! 两个端点是两个独立 job kind:队列行、worker 分支与 operation receipt 都按 job kind +//! 区分,结果类型也按端点严格区分,不做“一个 kind 带 mode 字段”的变形。 + +use serde_json::Value; +use shared_contracts::editor_canvas::EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest; +use shared_contracts::model3d::common::Model3dGenerationSource; +use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest; +use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest; + +use crate::editor_project::{ + normalize_generated_asset_folder_id, resolve_editor_generated_asset_label, +}; + +use super::worker::MODEL3D_DEFAULT_ASSET_LABEL; + +pub(crate) const MODEL3D_TEXT_TO_MODEL_JOB_KIND: &str = "model3d_text_to_model"; +pub(crate) const MODEL3D_IMAGE_TO_MODEL_JOB_KIND: &str = "model3d_image_to_model"; + +/// checkpoint 的 provider 标识;不含 provider task id,只说明 checkpoint 来自哪个 provider。 +pub(crate) const MODEL3D_PROVIDER_KIND: &str = "tripo"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Model3dJobKind { + TextToModel, + ImageToModel, +} + +impl Model3dJobKind { + pub(crate) const ALL: [Self; 2] = [Self::TextToModel, Self::ImageToModel]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::TextToModel => MODEL3D_TEXT_TO_MODEL_JOB_KIND, + Self::ImageToModel => MODEL3D_IMAGE_TO_MODEL_JOB_KIND, + } + } + + pub(crate) fn from_job_kind(job_kind: &str) -> Option { + Self::ALL + .into_iter() + .find(|kind| kind.as_str() == job_kind.trim()) + } + + /// 任务列表里对用户可见的阶段名;provider 与实际模型档位不出现在这里。 + pub(crate) const fn request_label(self) -> &'static str { + match self { + Self::TextToModel => "文生 3D 模型", + Self::ImageToModel => "图生 3D 模型", + } + } +} + +/// 队列里保存的请求:就是 API 请求本身,不含任何平台执行中间态。 +/// +/// provider task id 属于 checkpoint 字段,不写入请求 payload,因此不参与请求指纹。 +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Model3dJobRequest { + TextToModel(Model3dTextToModelRequest), + ImageToModel(Model3dImageToModelRequest), +} + +impl Model3dJobRequest { + pub(crate) const fn job_kind(&self) -> Model3dJobKind { + match self { + Self::TextToModel(_) => Model3dJobKind::TextToModel, + Self::ImageToModel(_) => Model3dJobKind::ImageToModel, + } + } + + /// 结果落点字段;两个端点的契约落点同形,校验、预检与落库共用这一份视图。 + pub(crate) fn target(&self) -> Model3dJobTarget<'_> { + match self { + Self::TextToModel(request) => text_to_model_target(request), + Self::ImageToModel(request) => image_to_model_target(request), + } + } + + /// 资源 / 素材的 `prompt` 审计字段。图生 3D 的 provider 参数里没有提示词, + /// 因此用固定文案而不是把请求参数复制一份出来。 + pub(crate) fn audit_prompt(&self) -> String { + match self { + Self::TextToModel(request) => request.generation.prompt.clone(), + Self::ImageToModel(_) => MODEL3D_IMAGE_TO_MODEL_AUDIT_PROMPT.to_string(), + } + } + + /// 本次生成派生自哪个画布资源;素材库对象与文生 3D 没有可登记的来源资源。 + /// + /// 资源 / 素材记录的 `source_resource_id` 记的是「派生来源」,所以只能从请求的 + /// `source` 取:本次新生成的输出资源 id 是产物自己,写进去等于自证来源。 + pub(crate) fn source_resource_id(&self) -> Option { + match self { + Self::TextToModel(_) => None, + Self::ImageToModel(request) => match &request.source { + Model3dGenerationSource::Resource { resource_id } => Some(resource_id.clone()), + // 素材库对象没有画布资源 id,没有可登记的来源资源。 + Model3dGenerationSource::Asset { .. } => None, + }, + } + } + + /// 审计与落库用的模型版本线上取值;取值只由契约枚举的 serde rename 定义。 + /// + /// 该值会持久化进资源 / 素材记录的 `model` 字段,因此走严格的 + /// [`shared_contracts::model3d::wire_str`]:契约枚举的序列化形态坏掉时要显式失败, + /// 而不是把 `Debug` 名(如 `H31`)当线上取值落库。 + pub(crate) fn model_version(&self) -> Result { + let model = match self { + Self::TextToModel(request) => request.generation.model, + Self::ImageToModel(request) => request.generation.model, + }; + shared_contracts::model3d::wire_str(&model) + .map_err(|error| format!("模型版本无法序列化成契约取值:{error}")) + } +} + +const MODEL3D_IMAGE_TO_MODEL_AUDIT_PROMPT: &str = "图生 3D 模型"; + +/// 结果落点:与其它生成接口同形的平坦字段(项目资源 / 素材库可选,至少给一个)。 +/// +/// 与其它生成工具一致,两个落点可以同时给(前端画布链路两个都发),因此下游不再按 +/// 「二选一」分辨落点:`project_id` 有就落画布,`asset_folder_id` 有就落素材库。 +#[derive(Clone, Copy, Debug)] +pub(crate) struct Model3dJobTarget<'a> { + pub(crate) project_id: Option<&'a str>, + pub(crate) canvas_completion: Option<&'a EditorCanvasGenerationCompletionRequest>, + pub(crate) asset_folder_id: Option<&'a str>, + pub(crate) asset_label: Option<&'a str>, +} + +/// 归一落点与素材名并写回请求,口径与其它生成工具完全一致: +/// 项目 ID 只 trim;素材夹 ID 走 `project` / 旧 `folder-*` → 当前 owner 默认素材夹的映射; +/// 素材名 trim + 截断到上限 + 缺省用平台默认名。 +/// +/// 必须在入队前调用,且入队的就是归一后的请求:预检、队列载荷与 worker 落库必须是同一个 +/// canonical 值。只校验归一值却把原始值入队 / 落库,会让「落在哪、叫什么」两处各说一套。 +pub(crate) fn normalize_text_to_model_target( + request: &mut Model3dTextToModelRequest, + owner_user_id: &str, +) { + normalize_target_fields( + &mut request.project_id, + &mut request.asset_folder_id, + &mut request.asset_label, + owner_user_id, + ); +} + +pub(crate) fn normalize_image_to_model_target( + request: &mut Model3dImageToModelRequest, + owner_user_id: &str, +) { + normalize_target_fields( + &mut request.project_id, + &mut request.asset_folder_id, + &mut request.asset_label, + owner_user_id, + ); +} + +fn normalize_target_fields( + project_id: &mut Option, + asset_folder_id: &mut Option, + asset_label: &mut Option, + owner_user_id: &str, +) { + *project_id = project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let folder_id = asset_folder_id.take(); + *asset_folder_id = normalize_generated_asset_folder_id(folder_id, owner_user_id); + *asset_label = Some(resolve_editor_generated_asset_label( + asset_label.take(), + MODEL3D_DEFAULT_ASSET_LABEL, + )); +} + +pub(crate) fn text_to_model_target(request: &Model3dTextToModelRequest) -> Model3dJobTarget<'_> { + Model3dJobTarget { + project_id: request.project_id.as_deref(), + canvas_completion: request.canvas_completion.as_ref(), + asset_folder_id: request.asset_folder_id.as_deref(), + asset_label: request.asset_label.as_deref(), + } +} + +pub(crate) fn image_to_model_target(request: &Model3dImageToModelRequest) -> Model3dJobTarget<'_> { + Model3dJobTarget { + project_id: request.project_id.as_deref(), + canvas_completion: request.canvas_completion.as_ref(), + asset_folder_id: request.asset_folder_id.as_deref(), + asset_label: request.asset_label.as_deref(), + } +} + +/// 从队列 payload 还原请求。 +/// +/// 队列 payload 比 API 请求多一个幂等指纹字段,且请求类型是 `deny_unknown_fields`; +/// 这里先摘掉平台自己注入的字段再反序列化,保证契约的严格性不被队列实现细节削弱。 +pub(crate) fn parse_model3d_job_request( + job_kind: &str, + request_payload_json: &str, +) -> Result { + let job_kind = Model3dJobKind::from_job_kind(job_kind) + .ok_or_else(|| format!("未知的 3D 生成任务类型:{job_kind}"))?; + let mut payload = serde_json::from_str::(request_payload_json) + .map_err(|error| format!("3D 生成任务参数不是合法 JSON:{error}"))?; + if let Some(object) = payload.as_object_mut() { + // 平台注入字段是否存在不影响解析,按 best-effort 摘除即可。 + let _ = + object.remove(crate::editor_generation_queue::EXTERNAL_API_REQUEST_FINGERPRINT_FIELD); + } + match job_kind { + Model3dJobKind::TextToModel => serde_json::from_value(payload) + .map(Model3dJobRequest::TextToModel) + .map_err(|error| format!("文生 3D 任务参数解析失败:{error}")), + Model3dJobKind::ImageToModel => serde_json::from_value(payload) + .map(Model3dJobRequest::ImageToModel) + .map_err(|error| format!("图生 3D 任务参数解析失败:{error}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use shared_contracts::assets::EDITOR_ASSET_LABEL_MAX_CHARS; + + #[test] + fn job_kind_mapping_is_total_and_reversible() { + for kind in Model3dJobKind::ALL { + assert_eq!(Model3dJobKind::from_job_kind(kind.as_str()), Some(kind)); + } + assert_eq!( + Model3dJobKind::from_job_kind("editor_image_generation"), + None + ); + } + + /// 归一落点:项目 ID 只 trim;`project` / 旧 `folder-*` 收敛到当前 owner 的默认素材夹, + /// 其它目录原样透传,因此预检、队列载荷与落库看到的是同一个值。 + #[test] + fn flat_target_is_normalized_before_enqueue() { + let generation = json!({ + "prompt": "一只木箱", + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }); + let mut request: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": generation.clone(), + "projectId": " project-1 " + })) + .expect("测试请求应可反序列化"); + normalize_text_to_model_target(&mut request, "user-1"); + assert_eq!(request.project_id.as_deref(), Some("project-1")); + assert!(request.asset_folder_id.is_none()); + // 没给素材名时按平台默认名落库,口径与其它生成工具一致。 + assert_eq!(request.asset_label.as_deref(), Some("3D 模型")); + + let long_label = format!(" 超长素材名{}", "字".repeat(200)); + let mut request: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": generation.clone(), + "projectId": "project-1", + "assetLabel": long_label + })) + .expect("测试请求应可反序列化"); + normalize_text_to_model_target(&mut request, "user-1"); + assert_eq!( + request + .asset_label + .as_deref() + .map(|label| label.chars().count()), + Some(EDITOR_ASSET_LABEL_MAX_CHARS) + ); + + for raw_folder_id in ["project", "folder-legacy", " folder-legacy "] { + let mut request: Model3dImageToModelRequest = serde_json::from_value(json!({ + "source": { "kind": "asset", "assetId": "asset-1" }, + "generation": { + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "assetFolderId": raw_folder_id + })) + .expect("测试请求应可反序列化"); + normalize_image_to_model_target(&mut request, "user-1"); + assert_eq!( + request.asset_folder_id.as_deref(), + Some("user-1:asset-folder:project"), + "占位 / 旧目录 ID {raw_folder_id} 应收敛到 owner 默认素材夹" + ); + } + + let mut request: Model3dImageToModelRequest = serde_json::from_value(json!({ + "source": { "kind": "asset", "assetId": "asset-1" }, + "generation": { + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "assetFolderId": " editor-asset-folder-real " + })) + .expect("测试请求应可反序列化"); + normalize_image_to_model_target(&mut request, "user-1"); + assert_eq!( + request.asset_folder_id.as_deref(), + Some("editor-asset-folder-real") + ); + } + + #[test] + fn model_version_uses_the_contract_wire_value() { + let payload = json!({ + "generation": { + "prompt": "一只木箱", + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "projectId": "project-1" + }) + .to_string(); + + let request = + parse_model3d_job_request(MODEL3D_TEXT_TO_MODEL_JOB_KIND, &payload).expect("应可解析"); + + assert_eq!( + request.model_version().expect("契约枚举应能取到 wire 值"), + "v3.1-20260211" + ); + } + + /// 派生来源只认请求里的 `source`:图生 3D 的 resource 变体带出资源 id, + /// 素材库来源与文生 3D 都没有可登记的来源资源。 + #[test] + fn source_resource_id_comes_from_the_request_source_only() { + let image_request = |source: Value| { + Model3dJobRequest::ImageToModel( + serde_json::from_value(json!({ + "source": source, + "generation": { + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "projectId": "project-1" + })) + .expect("测试请求应可反序列化"), + ) + }; + + assert_eq!( + image_request(json!({ "kind": "resource", "resourceId": "resource-source" })) + .source_resource_id() + .as_deref(), + Some("resource-source") + ); + assert_eq!( + image_request(json!({ "kind": "asset", "assetId": "asset-1" })).source_resource_id(), + None + ); + + let text = Model3dJobRequest::TextToModel( + serde_json::from_value(json!({ + "generation": { + "prompt": "一只木箱", + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "projectId": "project-1" + })) + .expect("测试请求应可反序列化"), + ); + assert_eq!(text.source_resource_id(), None); + } + + #[test] + fn job_request_is_parsed_after_stripping_platform_fingerprint() { + let payload = json!({ + "generation": { + "prompt": "一只木箱", + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "projectId": "project-1", + "_externalApiRequestFingerprint": "fingerprint-1" + }) + .to_string(); + + let request = + parse_model3d_job_request(MODEL3D_TEXT_TO_MODEL_JOB_KIND, &payload).expect("应可解析"); + + assert_eq!(request.job_kind(), Model3dJobKind::TextToModel); + } + + #[test] + fn job_request_stays_strict_after_stripping_platform_fingerprint() { + // 摘指纹只摘平台注入的那一个字段:其余未知字段仍走 deny_unknown_fields。 + let payload_with_unknown_field = json!({ + "generation": { + "prompt": "一只木箱", + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "projectId": "project-1", + "_externalApiRequestFingerprint": "fingerprint-1", + "unexpectedField": true + }) + .to_string(); + assert!( + parse_model3d_job_request(MODEL3D_TEXT_TO_MODEL_JOB_KIND, &payload_with_unknown_field) + .is_err() + ); + + // 缺 generation / 落点的 payload 与未知 job kind 都必须被拒。 + let payload = json!({ "prompt": "一只木箱" }).to_string(); + assert!(parse_model3d_job_request(MODEL3D_TEXT_TO_MODEL_JOB_KIND, &payload).is_err()); + assert!(parse_model3d_job_request("unknown_job_kind", &payload).is_err()); + } + + /// 原子落库在 SpacetimeDB 侧按 operation_kind 白名单拒绝未知生成结果; + /// 白名单与落库事务共用 shared-contracts 的常量,这里直接断言两个 job kind 都已登记。 + #[test] + fn model3d_job_kinds_are_registered_in_editor_generation_whitelist() { + for kind in Model3dJobKind::ALL { + assert!( + shared_contracts::EDITOR_GENERATION_OPERATION_KINDS.contains(&kind.as_str()), + "{} 必须在编辑器生成 operation_kind 白名单内", + kind.as_str() + ); + } + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/mod.rs b/server-rs/crates/api-server/src/tripo3d/mod.rs new file mode 100644 index 000000000..a5e0f1c7b --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/mod.rs @@ -0,0 +1,27 @@ +//! Tripo 3D 生成的 api-server 侧接入点。 +//! +//! 这里只放 Tripo 生成自己的东西:请求组合校验、泥点定价、入队与产物落库。 +//! 异步执行复用现有 `external_generation_job` 队列与查询接口,不再造第二套任务模型; +//! 与历史 Hyper3D 能力(`platform-hyper3d`、`/api/assets/hyper3d/*`)没有任何共用。 + +use axum::Router; + +use crate::state::AppState; + +pub(crate) mod artifacts; +pub(crate) mod errors; +pub(crate) mod image_source; +pub(crate) mod job; +pub(crate) mod pricing; +pub(crate) mod provider; +pub(crate) mod queue; +pub(crate) mod result; +pub(crate) mod routes; +pub(crate) mod storage; +pub(crate) mod target; +pub(crate) mod validation; +pub(crate) mod worker; + +pub(crate) fn router(state: AppState) -> Router { + routes::router(state) +} diff --git a/server-rs/crates/api-server/src/tripo3d/pricing.rs b/server-rs/crates/api-server/src/tripo3d/pricing.rs new file mode 100644 index 000000000..3d945729a --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/pricing.rs @@ -0,0 +1,529 @@ +//! Tripo 3D 生成的泥点定价表。 +//! +//! 价格形状是「底价 + 可叠加加价项」,现有 `模型 → 档位 → 单价` 查表表达不了, +//! 因此单独一段配置:两个端点各持一段,段内按 `modelVersion × 是否有贴图` 给底价, +//! 加价项按请求参数判定后叠加。单位一律是泥点,不引入 provider 的 credit 概念。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use shared_contracts::model3d::common::{ + Model3dGeometryQuality, Model3dModelVersion, Model3dTextureQuality, +}; +use shared_contracts::model3d::wire_str_lossy; +use tracing::warn; + +/// 生成端点。批量价键与请求入口一一对应,不做 provider 侧的变形。 +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum Model3dEndpoint { + TextToModel, + ImageToModel, +} + +impl Model3dEndpoint { + pub(crate) const ALL: [Self; 2] = [Self::TextToModel, Self::ImageToModel]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::TextToModel => "text-to-model", + Self::ImageToModel => "image-to-model", + } + } +} + +/// 叠加在底价之上的加价项,键名与请求参数的判定规则一一对应。 +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum Model3dAddOn { + HdTexture, + UltraTexture, + HdGeometry, + QuadMesh, + SmartLowPoly, + GenerateParts, +} + +impl Model3dAddOn { + pub(crate) const ALL: [Self; 6] = [ + Self::HdTexture, + Self::UltraTexture, + Self::HdGeometry, + Self::QuadMesh, + Self::SmartLowPoly, + Self::GenerateParts, + ]; +} + +/// 同一模型版本在「不带贴图 / 带贴图」两种形态下的底价。 +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Model3dBasePrice { + pub no_texture: u32, + pub texture: u32, +} + +impl Model3dBasePrice { + pub(crate) fn for_texture(&self, texture: bool) -> u32 { + if texture { + self.texture + } else { + self.no_texture + } + } +} + +/// 单个端点的整套 3D 定价:模型版本底价 + 该端点自己的加价项价目。 +/// +/// 两段加价项当前数值相同,仍各持一份:Tripo 若对某个端点差异化加价,只改那一段。 +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Model3dEndpointPricing { + pub version_prices: BTreeMap, + pub add_on_prices: BTreeMap, +} + +/// 3D 定价段:两个端点并列,各段承载自己的设置,不在一段里用端点做判别键。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Model3dPricingConfig { + pub text_to_model_pricing: Model3dEndpointPricing, + pub image_to_model_pricing: Model3dEndpointPricing, +} + +/// 主站公开读模型里的 3D 段。 +/// +/// 形状保持迁移前的 `basePrices`(端点判别)× `addOnPrices` 单表:画布 3D 入口直接读它、 +/// 没有内置兜底,形状一变入口就会判定「定价缺失」。两段内部结构到这份视图的投影是有损的 +/// ——加价项只有一张表,取文生 3D 段,两段不一致时打告警。前端升级到按端点读取加价项之前, +/// 不要在两段之间差异化加价项。 +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Model3dPricingPublicView { + pub base_prices: BTreeMap>, + pub add_on_prices: BTreeMap, +} + +impl Model3dPricingConfig { + pub(crate) fn endpoint_pricing(&self, endpoint: Model3dEndpoint) -> &Model3dEndpointPricing { + match endpoint { + Model3dEndpoint::TextToModel => &self.text_to_model_pricing, + Model3dEndpoint::ImageToModel => &self.image_to_model_pricing, + } + } + + /// 投影成公开读模型的旧形状。 + pub(crate) fn into_public_view(self) -> Model3dPricingPublicView { + let Model3dPricingConfig { + text_to_model_pricing, + image_to_model_pricing, + } = self; + if text_to_model_pricing.add_on_prices != image_to_model_pricing.add_on_prices { + warn!( + "文生 3D 与图生 3D 的加价项不一致,公开读模型只能给出文生 3D 的加价项:前端按端点读取前不要差异化加价项" + ); + } + let mut base_prices = BTreeMap::new(); + base_prices.insert( + Model3dEndpoint::TextToModel, + text_to_model_pricing.version_prices, + ); + base_prices.insert( + Model3dEndpoint::ImageToModel, + image_to_model_pricing.version_prices, + ); + Model3dPricingPublicView { + base_prices, + add_on_prices: text_to_model_pricing.add_on_prices, + } + } +} + +/// 一次提交的定价输入:只携带判定价格需要的字段,全部来自已校验的请求。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Model3dPricingQuery { + pub(crate) endpoint: Model3dEndpoint, + pub(crate) model_version: Model3dModelVersion, + pub(crate) texture: bool, + pub(crate) add_ons: Model3dAddOnSet, +} + +/// 命中的 add-on 集合;由请求参数判定,不含重复项。 +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct Model3dAddOnSet { + hd_texture: bool, + ultra_texture: bool, + hd_geometry: bool, + quad_mesh: bool, + smart_low_poly: bool, + generate_parts: bool, +} + +impl Model3dAddOnSet { + pub(crate) fn iter(self) -> impl Iterator { + Model3dAddOn::ALL + .into_iter() + .filter(move |add_on| self.contains(*add_on)) + } + + pub(crate) fn contains(self, add_on: Model3dAddOn) -> bool { + match add_on { + Model3dAddOn::HdTexture => self.hd_texture, + Model3dAddOn::UltraTexture => self.ultra_texture, + Model3dAddOn::HdGeometry => self.hd_geometry, + Model3dAddOn::QuadMesh => self.quad_mesh, + Model3dAddOn::SmartLowPoly => self.smart_low_poly, + Model3dAddOn::GenerateParts => self.generate_parts, + } + } +} + +/// add-on 判定规则:只有这些组合加价,`fast` / `standard` 贴图不加价。 +pub(crate) fn model3d_add_ons( + texture: bool, + texture_quality: Model3dTextureQuality, + geometry_quality: Model3dGeometryQuality, + quad: bool, + smart_low_poly: bool, + generate_parts: bool, +) -> Model3dAddOnSet { + Model3dAddOnSet { + hd_texture: texture && matches!(texture_quality, Model3dTextureQuality::Detailed), + ultra_texture: texture && matches!(texture_quality, Model3dTextureQuality::Extreme), + hd_geometry: matches!(geometry_quality, Model3dGeometryQuality::Detailed), + quad_mesh: quad, + smart_low_poly, + generate_parts, + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Model3dPricingError { + /// `model3d` 段整体缺失:3D 定价尚未配置,接口不开放,也不扣费。 + NotConfigured, + MissingBasePrice { + endpoint: Model3dEndpoint, + model_version: Model3dModelVersion, + }, + MissingAddOnPrice(Model3dAddOn), + /// `texture=false` 却带上贴图类加价项:组合自相矛盾,直接报错而不是照价收费。 + InconsistentTextureAddOn(Model3dAddOn), + /// 底价 + 加价项累加超出 `u32`:计费点直接失败退款,不静默截断成错价。 + PriceOverflow, +} + +impl std::fmt::Display for Model3dPricingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotConfigured => f.write_str("Tripo 3D 定价未配置"), + Self::MissingBasePrice { + endpoint, + model_version, + } => write!( + f, + "Tripo 3D 定价缺少底价:endpoint={} model={}", + endpoint.as_str(), + wire_str_lossy(model_version) + ), + Self::MissingAddOnPrice(add_on) => { + write!(f, "Tripo 3D 定价缺少 add-on:{add_on:?}") + } + Self::InconsistentTextureAddOn(add_on) => write!( + f, + "Tripo 3D 定价组合不合法:texture=false 与贴图类 add-on {add_on:?} 冲突" + ), + Self::PriceOverflow => f.write_str("Tripo 3D 定价加总超出 u32 上限"), + } + } +} + +impl std::error::Error for Model3dPricingError {} + +impl Model3dPricingConfig { + /// 加载即校验:两段各自要求每个模型版本 × 两种贴图态与全部加价项键齐全。 + /// + /// 刻意不在这里做「加价项 + 底价会不会超出 u32」的检查:真实查价只用到一个端点的 + /// 底价与它自己的加价项,价格都是运营手填的小数字,撞到 42 亿的上限不现实,而按 + /// 「两段加价 + 全表最高底价」去挡反而会拒掉完全能正常计费的配置。万一真的加满, + /// [`Model3dEndpointPricing::price`] 会在计费点报 [`Model3dPricingError::PriceOverflow`], + /// 不会静默截断收费。 + pub(crate) fn validate(&self) -> Result<(), String> { + for endpoint in Model3dEndpoint::ALL { + self.endpoint_pricing(endpoint).validate(endpoint)?; + } + Ok(()) + } + + /// 查价:底价 + 命中的全部 add-on。缺键直接报错,不回退默认值。 + /// + /// `texture=false` 与贴图类 add-on 同时出现属于自相矛盾的组合,这里失败关闭: + /// 生产构造点([`model3d_add_ons`])已把贴图类加价项挂在 `texture` 上,构造不出该状态。 + pub(crate) fn price(&self, query: &Model3dPricingQuery) -> Result { + if !query.texture + && let Some(add_on) = query.add_ons.iter().find(|add_on| { + matches!(add_on, Model3dAddOn::HdTexture | Model3dAddOn::UltraTexture) + }) + { + return Err(Model3dPricingError::InconsistentTextureAddOn(add_on)); + } + + self.endpoint_pricing(query.endpoint).price(query) + } +} + +impl Model3dEndpointPricing { + pub(crate) fn validate(&self, endpoint: Model3dEndpoint) -> Result<(), String> { + for model_version in Model3dModelVersion::ALL.iter().copied() { + if !self.version_prices.contains_key(&model_version) { + return Err(format!( + "缺少 endpoint {} 模型 {} 的底价", + endpoint.as_str(), + wire_str_lossy(&model_version) + )); + } + } + for model_version in self.version_prices.keys() { + if !Model3dModelVersion::ALL.contains(model_version) { + return Err(format!( + "endpoint {} 出现了契约不支持的模型版本 {}", + endpoint.as_str(), + wire_str_lossy(model_version) + )); + } + } + for add_on in Model3dAddOn::ALL { + if !self.add_on_prices.contains_key(&add_on) { + return Err(format!( + "endpoint {} 缺少 add-on {add_on:?} 的价格", + endpoint.as_str() + )); + } + } + Ok(()) + } + + /// 查价:本段底价 + 命中的全部加价项。缺键直接报错,不回退默认值。 + fn price(&self, query: &Model3dPricingQuery) -> Result { + let base = self.version_prices.get(&query.model_version).ok_or( + Model3dPricingError::MissingBasePrice { + endpoint: query.endpoint, + model_version: query.model_version, + }, + )?; + + query + .add_ons + .iter() + .try_fold(base.for_texture(query.texture), |total, add_on| { + let price = self + .add_on_prices + .get(&add_on) + .ok_or(Model3dPricingError::MissingAddOnPrice(add_on))?; + // 唯一的一道溢出闸就在这里:溢出就报错退款,不静默夹到 u32 上限按错价收费。 + total + .checked_add(*price) + .ok_or(Model3dPricingError::PriceOverflow) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn version_prices( + no_texture: u32, + texture: u32, + ) -> BTreeMap { + Model3dModelVersion::ALL + .iter() + .copied() + .map(|version| { + ( + version, + Model3dBasePrice { + no_texture, + texture, + }, + ) + }) + .collect() + } + + /// 测试夹具价格:两段底价取不同值,加价项各自一份、依次取 5 的倍数。 + fn sample_endpoint_pricing(no_texture: u32, texture: u32) -> Model3dEndpointPricing { + Model3dEndpointPricing { + version_prices: version_prices(no_texture, texture), + add_on_prices: Model3dAddOn::ALL + .into_iter() + .enumerate() + .map(|(index, add_on)| (add_on, (index as u32 + 1) * 5)) + .collect(), + } + } + + fn sample_config() -> Model3dPricingConfig { + Model3dPricingConfig { + text_to_model_pricing: sample_endpoint_pricing(10, 20), + image_to_model_pricing: sample_endpoint_pricing(30, 40), + } + } + + fn text_query(texture: bool, add_ons: Model3dAddOnSet) -> Model3dPricingQuery { + Model3dPricingQuery { + endpoint: Model3dEndpoint::TextToModel, + model_version: Model3dModelVersion::H31, + texture, + add_ons, + } + } + + #[test] + fn config_requires_every_base_price_and_add_on_key() { + sample_config().validate().expect("夹具配置应合法"); + + let mut missing_base = sample_config(); + missing_base + .image_to_model_pricing + .version_prices + .remove(&Model3dModelVersion::P2); + let error = missing_base.validate().expect_err("缺少底价键应加载失败"); + assert!(error.contains("缺少"), "报错应说明缺键,实际为:{error}"); + + let mut missing_add_on = sample_config(); + missing_add_on + .image_to_model_pricing + .add_on_prices + .remove(&Model3dAddOn::QuadMesh); + let error = missing_add_on + .validate() + .expect_err("缺少 add-on 键应加载失败"); + assert!( + error.contains("add-on"), + "报错应说明缺 add-on,实际为:{error}" + ); + } + + #[test] + fn price_is_base_plus_matched_add_ons() { + let config = sample_config(); + + assert_eq!( + config + .price(&text_query(false, Model3dAddOnSet::default())) + .expect("不带贴图底价存在"), + 10 + ); + + let add_ons = model3d_add_ons( + true, + Model3dTextureQuality::Detailed, + Model3dGeometryQuality::Detailed, + false, + false, + false, + ); + assert_eq!( + config + .price(&text_query(true, add_ons)) + .expect("带贴图底价与 add-on 都存在"), + 20 + 5 + 15 + ); + } + + #[test] + fn price_overflow_fails_closed_instead_of_clamping() { + let mut pricing = sample_endpoint_pricing(10, 20); + pricing + .add_on_prices + .insert(Model3dAddOn::HdGeometry, u32::MAX); + pricing.add_on_prices.insert(Model3dAddOn::QuadMesh, 1); + + let query = text_query( + false, + Model3dAddOnSet { + hd_geometry: true, + quad_mesh: true, + ..Model3dAddOnSet::default() + }, + ); + + assert_eq!( + pricing.price(&query), + Err(Model3dPricingError::PriceOverflow) + ); + } + + #[test] + fn texture_add_ons_without_texture_are_rejected() { + let config = sample_config(); + let inconsistent = Model3dAddOnSet { + hd_texture: true, + ..Model3dAddOnSet::default() + }; + + assert_eq!( + config.price(&text_query(false, inconsistent)), + Err(Model3dPricingError::InconsistentTextureAddOn( + Model3dAddOn::HdTexture + )) + ); + + // texture=true 时同一个 add-on 集合是合法组合,价格按底价 + 加价项计算。 + assert_eq!( + config + .price(&text_query(true, inconsistent)) + .expect("带贴图时贴图加价项合法"), + 20 + 5 + ); + } + + #[test] + fn texture_quality_only_charges_detailed_and_extreme() { + for (quality, expected) in [ + (Model3dTextureQuality::Fast, None), + (Model3dTextureQuality::Standard, None), + ( + Model3dTextureQuality::Detailed, + Some(Model3dAddOn::HdTexture), + ), + ( + Model3dTextureQuality::Extreme, + Some(Model3dAddOn::UltraTexture), + ), + ] { + let add_ons = model3d_add_ons( + true, + quality, + Model3dGeometryQuality::Standard, + false, + false, + false, + ); + match expected { + Some(add_on) => assert!(add_ons.contains(add_on), "{quality:?} 应命中 {add_on:?}"), + None => assert_eq!(add_ons.iter().count(), 0, "{quality:?} 不应产生贴图加价"), + } + } + } + + #[test] + fn geometry_quad_smart_low_poly_and_parts_each_add_price() { + let add_ons = model3d_add_ons( + false, + Model3dTextureQuality::Standard, + Model3dGeometryQuality::Detailed, + true, + true, + true, + ); + + assert_eq!(add_ons.iter().count(), 4); + assert_eq!( + sample_config() + .price(&text_query(false, add_ons)) + .expect("add-on 键齐全"), + 10 + 15 + 20 + 25 + 30 + ); + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/provider.rs b/server-rs/crates/api-server/src/tripo3d/provider.rs new file mode 100644 index 000000000..00fa5d309 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/provider.rs @@ -0,0 +1,381 @@ +//! Tripo provider 客户端的构造入口。 +//! +//! 两层防线,职责不同: +//! 1. 启动期([`validate_tripo_startup_config`]):地址与密钥没有默认值,缺失即拒绝启动, +//! 不允许出现「进程起来了、健康检查全绿、用户提交后才失败」的部署形态; +//! 2. 请求期([`tripo_settings`]):仍然按“配置是否可用”返回 503,兜住启动后配置被 +//! 改动、或被其它入口拼出非法 AppConfig 的情况。 + +use std::{env, time::Duration}; + +use axum::http::StatusCode; +use platform_tripo::{TripoError, TripoProviderClient, TripoSettings}; +use serde_json::json; + +use crate::{config::AppConfig, http_error::AppError}; + +pub(crate) const TRIPO_PROVIDER: &str = "tripo-3d"; +const TRIPO_USER_AGENT: &str = "genarrative-api-server-tripo/1"; +const TRIPO_BASE_URL_ENV: &str = "TRIPO_BASE_URL"; +const TRIPO_API_KEY_ENV: &str = "TRIPO_API_KEY"; +const TRIPO_REQUEST_TIMEOUT_MS_ENV: &str = "TRIPO_REQUEST_TIMEOUT_MS"; +const TRIPO_RETRIES_ENV: &str = "TRIPO_RETRIES"; +/// 重试次数直接决定产物下载的挂起时长;超过这个数属于配置写错,启动期就拒。 +const TRIPO_MAX_RETRIES: u32 = 10; + +/// 启动期门禁的输入。 +/// +/// 地址与密钥没有内置默认值,两个运行旋钮(超时 / 重试)缺失时沿用 +/// `config.rs` 的常量,但**显式给出**的值一律按原样校验,不做钳制—— +/// 「`TRIPO_REQUEST_TIMEOUT_MS=0` 被悄悄改成 1ms」这类静默兜底会掩盖配置错误。 +#[derive(Clone, Copy)] +pub(crate) struct TripoStartupValues<'a> { + pub(crate) base_url: &'a str, + pub(crate) api_key: Option<&'a str>, + pub(crate) request_timeout_ms: Option<&'a str>, + pub(crate) retries: Option<&'a str>, +} + +/// 读取原始 env 后校验;返回值直接作为启动失败原因展示给运维。 +/// +/// 只看环境变量是不够的:真正发请求时用的是 [`AppConfig`] 上的字段值,而配置文件、测试与 +/// 代码里直接构造的配置都不经过 `from_env`,非法值(零超时、越界重试)能带着它一路通过 +/// 启动检查,直到请求期才以 503 暴露。因此这里把解析后的字段也一并校验,两处用的是同一份值。 +pub(crate) fn validate_tripo_startup_config(config: &AppConfig) -> Result<(), String> { + validate_tripo_startup_values(TripoStartupValues { + base_url: config.tripo_base_url.as_str(), + api_key: config.tripo_api_key.as_deref(), + request_timeout_ms: env::var(TRIPO_REQUEST_TIMEOUT_MS_ENV).ok().as_deref(), + retries: env::var(TRIPO_RETRIES_ENV).ok().as_deref(), + })?; + validate_tripo_startup_fields(config) +} + +/// 解析后的两个运行旋钮:与 [`tripo_settings`] 读的是同一对字段。 +fn validate_tripo_startup_fields(config: &AppConfig) -> Result<(), String> { + if config.tripo_request_timeout_ms == 0 { + return Err(format!( + "{TRIPO_REQUEST_TIMEOUT_MS_ENV} 必须大于 0:零超时在 timeout 与 reqwest \ + 两处含义相反,放它进来只会得到指向错误方向的报错。" + )); + } + if config.tripo_retries > TRIPO_MAX_RETRIES { + return Err(format!( + "{TRIPO_RETRIES_ENV} 不能超过 {TRIPO_MAX_RETRIES},当前为 {}。", + config.tripo_retries + )); + } + Ok(()) +} + +/// 纯函数形态的校验,便于用例直接覆盖各种坏配置。 +pub(crate) fn validate_tripo_startup_values(values: TripoStartupValues<'_>) -> Result<(), String> { + let base_url = values.base_url.trim(); + if base_url.is_empty() { + return Err(format!( + "{TRIPO_BASE_URL_ENV} 未配置:3D 生成要求显式给出 Tripo 网关地址,缺失时 api-server 拒绝启动。" + )); + } + validate_tripo_base_url_syntax(base_url)?; + if values + .api_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + return Err(format!( + "{TRIPO_API_KEY_ENV} 未配置:3D 生成需要该密钥,缺失时 api-server 拒绝启动。" + )); + } + if let Some(raw) = read_declared_env(values.request_timeout_ms) { + let timeout_ms = raw.parse::().map_err(|_| { + format!("{TRIPO_REQUEST_TIMEOUT_MS_ENV} 必须是正整数毫秒,当前为 {raw}。") + })?; + if timeout_ms == 0 { + return Err(format!( + "{TRIPO_REQUEST_TIMEOUT_MS_ENV} 必须大于 0:零超时在 timeout 与 reqwest \ + 两处含义相反,放它进来只会得到指向错误方向的报错。" + )); + } + } + if let Some(raw) = read_declared_env(values.retries) { + let retries = raw + .parse::() + .map_err(|_| format!("{TRIPO_RETRIES_ENV} 必须是非负整数,当前为 {raw}。"))?; + if retries > TRIPO_MAX_RETRIES { + return Err(format!( + "{TRIPO_RETRIES_ENV} 不能超过 {TRIPO_MAX_RETRIES},当前为 {retries}。" + )); + } + } + Ok(()) +} + +/// 网关地址写法校验:必须是带主机名的 HTTP(S) 绝对地址。 +/// +/// 启动期门禁与请求期兜底共用这一份判据:门禁挡的是环境变量,请求期挡的是 +/// 「启动后才被改坏或不由 `from_env` 构造」的配置,两边口径必须一致。 +fn validate_tripo_base_url_syntax(base_url: &str) -> Result<(), String> { + let url = reqwest::Url::parse(base_url) + .map_err(|error| format!("{TRIPO_BASE_URL_ENV} 不是合法 URL({error})。"))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(format!( + "{TRIPO_BASE_URL_ENV} 必须是 HTTP/HTTPS 地址,当前为 {}。", + url.scheme() + )); + } + if url.host_str().is_none() { + return Err(format!("{TRIPO_BASE_URL_ENV} 缺少主机名。")); + } + Ok(()) +} + +/// 只把「显式声明过」的取值当输入;空串与纯空白视同未声明。 +fn read_declared_env(raw: Option<&str>) -> Option<&str> { + raw.map(str::trim).filter(|value| !value.is_empty()) +} + +pub(crate) fn tripo_settings(config: &AppConfig) -> Result { + let base_url = config.tripo_base_url.trim().trim_end_matches('/'); + if base_url.is_empty() { + return Err(not_configured( + "TRIPO_BASE_URL 未配置,无法调用 Tripo 3D 生成服务。", + )); + } + // 非空但写歪的网关地址(缺 scheme、非 HTTP(S)、没有主机名)在这里就判 503: + // 交给 SDK 只会在请求期变成一条指向连接失败的报错,看不出是配置写错了。 + validate_tripo_base_url_syntax(base_url).map_err(|message| not_configured(&message))?; + let api_key = config + .tripo_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| not_configured("TRIPO_API_KEY 未配置,无法调用 Tripo 3D 生成服务。"))?; + + // 越界重试必须在这里拒掉:`TripoSettings::validate` 只查空值与零超时,不看重试上限, + // 放着它进 artifact_retries 只会把产物下载的挂起时间成倍拉长,还不会有任何 503。 + if config.tripo_retries > TRIPO_MAX_RETRIES { + return Err(not_configured(&format!( + "TRIPO_RETRIES 不能超过 {TRIPO_MAX_RETRIES},当前为 {}。", + config.tripo_retries + ))); + } + + // 取值原样交给构造器:零超时或越界重试是配置错误,由 `TripoSettings::validate` + // 判失败并映射成 503,不再在这里静默钳制(启动期门禁已经先一步拒掉这种配置)。 + TripoSettings::new( + api_key.to_string(), + base_url.to_string(), + Duration::from_millis(config.tripo_request_timeout_ms), + config.tripo_retries, + TRIPO_USER_AGENT.to_string(), + ) + .map_err(map_provider_client_error) +} + +pub(crate) fn tripo_provider_client(config: &AppConfig) -> Result { + let settings = tripo_settings(config)?; + TripoProviderClient::new(settings).map_err(map_provider_client_error) +} + +fn not_configured(message: &str) -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "tripo-not-configured", + "message": message, + })) +} + +fn map_provider_client_error(error: TripoError) -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "tripo-client-init-failed", + "message": error.to_string(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_api_key_is_reported_as_unavailable() { + let mut config = AppConfig::default(); + // 网关地址先给全,否则会先在「缺 TRIPO_BASE_URL」那条分支上返回, + // 这条用例就再也测不到 API Key 的校验。 + config.tripo_base_url = "https://openapi.tripo3d.com/v3".to_string(); + config.tripo_api_key = None; + let error = tripo_settings(&config).expect_err("缺 API Key 必须拒绝"); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + + config.tripo_api_key = Some(" ".to_string()); + assert!(tripo_settings(&config).is_err(), "空白密钥同样必须拒绝"); + } + + fn startup_values<'a>(base_url: &'a str, api_key: Option<&'a str>) -> TripoStartupValues<'a> { + TripoStartupValues { + base_url, + api_key, + request_timeout_ms: None, + retries: None, + } + } + + #[test] + fn startup_rejects_missing_or_blank_gateway_and_key() { + let missing_base_url = validate_tripo_startup_values(startup_values(" ", Some("key"))) + .expect_err("缺网关必须拒绝启动"); + assert!( + missing_base_url.contains("TRIPO_BASE_URL"), + "{missing_base_url}" + ); + + let missing_key = + validate_tripo_startup_values(startup_values("https://openapi.tripo3d.com/v3", None)) + .expect_err("缺密钥必须拒绝启动"); + assert!(missing_key.contains("TRIPO_API_KEY"), "{missing_key}"); + + for blank in ["", " "] { + let error = validate_tripo_startup_values(startup_values( + "https://openapi.tripo3d.com/v3", + Some(blank), + )) + .expect_err("空白密钥等同于未配置"); + assert!(error.contains("TRIPO_API_KEY"), "{error}"); + } + } + + #[test] + fn startup_rejects_unusable_gateway_addresses() { + let not_a_url = + validate_tripo_startup_values(startup_values("openapi.tripo3d.com", Some("key"))) + .expect_err("不是 URL 必须拒绝"); + assert!(not_a_url.contains("TRIPO_BASE_URL"), "{not_a_url}"); + + let wrong_scheme = + validate_tripo_startup_values(startup_values("ftp://example.com/v3", Some("key"))) + .expect_err("非 HTTP(S) 必须拒绝"); + assert!(wrong_scheme.contains("HTTP/HTTPS"), "{wrong_scheme}"); + } + + #[test] + fn startup_validates_declared_knobs_without_clamping_them() { + let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key")); + values.request_timeout_ms = Some("0"); + let zero_timeout = validate_tripo_startup_values(values).expect_err("零超时必须拒绝"); + assert!( + zero_timeout.contains("TRIPO_REQUEST_TIMEOUT_MS"), + "{zero_timeout}" + ); + + let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key")); + values.request_timeout_ms = Some("soon"); + assert!( + validate_tripo_startup_values(values) + .expect_err("非数字超时必须拒绝") + .contains("TRIPO_REQUEST_TIMEOUT_MS") + ); + + let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key")); + values.retries = Some("11"); + let too_many = validate_tripo_startup_values(values).expect_err("越界重试必须拒绝"); + assert!(too_many.contains("TRIPO_RETRIES"), "{too_many}"); + + let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key")); + values.retries = Some("many"); + assert!( + validate_tripo_startup_values(values) + .expect_err("非数字重试必须拒绝") + .contains("TRIPO_RETRIES") + ); + } + + /// 启动检查必须看配置对象里的值,不只是环境变量原文:否则直接构造的非法配置 + /// (零超时、越界重试)能通过启动检查,直到请求期才以 503 暴露。 + #[test] + fn startup_rejects_invalid_parsed_fields_from_any_construction_path() { + let mut config = AppConfig::default(); + config.tripo_base_url = "https://openapi.tripo3d.com/v3".to_string(); + config.tripo_api_key = Some("test-key".to_string()); + assert!(validate_tripo_startup_config(&config).is_ok()); + + config.tripo_request_timeout_ms = 0; + let zero_timeout = validate_tripo_startup_config(&config) + .expect_err("零超时必须拒绝启动,即使它不是从环境变量读来的"); + assert!( + zero_timeout.contains("TRIPO_REQUEST_TIMEOUT_MS"), + "{zero_timeout}" + ); + + config.tripo_request_timeout_ms = crate::config::DEFAULT_TRIPO_REQUEST_TIMEOUT_MS; + config.tripo_retries = TRIPO_MAX_RETRIES + 1; + let too_many = validate_tripo_startup_config(&config) + .expect_err("越界重试必须拒绝启动,即使它不是从环境变量读来的"); + assert!(too_many.contains("TRIPO_RETRIES"), "{too_many}"); + } + + #[test] + fn startup_accepts_declared_knobs_at_their_limits() { + let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key")); + values.request_timeout_ms = Some("1"); + values.retries = Some("10"); + assert!(validate_tripo_startup_values(values).is_ok()); + + // 空串与纯空白视同未声明,沿用 config.rs 的常量,不算坏配置。 + let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key")); + values.request_timeout_ms = Some(" "); + values.retries = Some(""); + assert!(validate_tripo_startup_values(values).is_ok()); + } + + #[test] + fn request_path_no_longer_clamps_bad_knobs() { + let mut config = AppConfig::default(); + config.tripo_base_url = "https://openapi.tripo3d.com/v3".to_string(); + config.tripo_api_key = Some("test-key".to_string()); + config.tripo_request_timeout_ms = 0; + // 启动期会先拒掉这种配置;真走到这里也必须 503,而不是被悄悄改成 1ms 继续跑。 + let error = tripo_settings(&config).expect_err("零超时不得被静默钳制"); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + } + + /// 非空但写歪的网关地址在请求期同样以 503 收口,不留到 SDK 里报连接失败。 + #[test] + fn request_path_rejects_unusable_gateway_addresses() { + for base_url in ["openapi.tripo3d.com", "ftp://example.com/v3", "https://"] { + let mut config = AppConfig::default(); + config.tripo_base_url = base_url.to_string(); + config.tripo_api_key = Some("test-key".to_string()); + let error = tripo_settings(&config).expect_err("写歪的网关地址必须拒绝"); + assert_eq!( + error.status_code(), + StatusCode::SERVICE_UNAVAILABLE, + "{base_url}" + ); + } + } + + /// 越过重试上限的配置必须在请求前就 503,不能落进 artifact_retries 拉长挂起时间。 + #[test] + fn request_path_rejects_out_of_range_retries() { + let mut config = AppConfig::default(); + config.tripo_base_url = "https://openapi.tripo3d.com/v3".to_string(); + config.tripo_api_key = Some("test-key".to_string()); + config.tripo_retries = TRIPO_MAX_RETRIES + 1; + let error = tripo_settings(&config).expect_err("越界重试必须拒绝"); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + + config.tripo_retries = TRIPO_MAX_RETRIES; + assert!(tripo_settings(&config).is_ok(), "上限本身必须放行"); + } + + #[test] + fn configured_settings_trim_trailing_slash() { + let mut config = AppConfig::default(); + config.tripo_base_url = "https://openapi.tripo3d.com/v3/".to_string(); + config.tripo_api_key = Some("test-key".to_string()); + let settings = tripo_settings(&config).expect("配置完整应可构造"); + assert_eq!(settings.base_url, "https://openapi.tripo3d.com/v3"); + assert_eq!(settings.api_key, "test-key"); + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/queue.rs b/server-rs/crates/api-server/src/tripo3d/queue.rs new file mode 100644 index 000000000..19fb14213 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/queue.rs @@ -0,0 +1,96 @@ +//! Tripo 生成的入队与提交响应。 +//! +//! 复用编辑器生成队列的既有幂等能力:`Idempotency-Key` 决定 dedupe key,同键同请求 +//! 返回原 operation,同键不同请求 409。这里不额外造队列,也不复制扣费逻辑。 + +use serde::Serialize; +use shared_contracts::external_generation::ExternalGenerationJobStatusRecord; +use shared_kernel::build_prefixed_uuid_id; +use spacetime_client::ExternalGenerationJobRecord; + +use crate::{ + editor_generation_queue::{ + build_editor_generation_dedupe_key, editor_generation_queue_state, + enqueue_editor_generation_job_with_identity, + }, + http_error::AppError, + state::AppState, +}; + +use super::job::Model3dJobKind; + +/// 3D 生成自己的幂等命名空间,避免与图片 / 视频生成共用 dedupe key。 +const MODEL3D_GENERATION_DEDUPE_PREFIX: &str = "model3d-generation"; + +/// 提交成功后的 operation 描述;状态仍然由既有任务查询接口提供权威读取。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Model3dSubmissionResponse { + pub(crate) operation_id: String, + pub(crate) job_kind: String, + pub(crate) status: ExternalGenerationJobStatusRecord, + pub(crate) status_url: String, +} + +impl Model3dSubmissionResponse { + pub(crate) fn from_job(job: ExternalGenerationJobRecord) -> Self { + // 状态映射只读整行记录:这里不必为了它克隆请求 payload 等大字段。 + let status = editor_generation_queue_state(&job); + let status_url = model3d_job_status_url(job.job_id.as_str()); + Self { + operation_id: job.job_id, + job_kind: job.job_kind, + status, + status_url, + } + } +} + +pub(crate) fn model3d_job_status_url(job_id: &str) -> String { + format!("/api/runtime/external-generation/jobs/{job_id}") +} + +pub(crate) async fn enqueue_model3d_job( + state: &AppState, + owner_user_id: &str, + kind: Model3dJobKind, + source_entity_id: &str, + price_mud_points: u64, + payload: &T, + idempotency_key: &str, +) -> Result +where + T: Serialize, +{ + let dedupe_key = build_editor_generation_dedupe_key( + MODEL3D_GENERATION_DEDUPE_PREFIX, + owner_user_id, + kind.as_str(), + idempotency_key, + ); + enqueue_editor_generation_job_with_identity( + state, + owner_user_id, + kind.as_str(), + source_entity_id.to_string(), + kind.request_label(), + price_mud_points, + payload, + build_prefixed_uuid_id("task-"), + dedupe_key, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_url_points_at_existing_queue_query() { + assert_eq!( + model3d_job_status_url("task-1"), + "/api/runtime/external-generation/jobs/task-1" + ); + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/result.rs b/server-rs/crates/api-server/src/tripo3d/result.rs new file mode 100644 index 000000000..63a38fbbc --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/result.rs @@ -0,0 +1,64 @@ +//! 3D 完成结果的构造。 +//! +//! 结果按端点使用严格 tagged enum,只带正式资源引用与已落地对象元数据; +//! provider task ID、带签名的临时地址与 SDK 类型都不进入结果。 + +use axum::http::StatusCode; +use serde_json::{Value, json}; +use shared_contracts::model3d::Model3dGenerationResult; +use shared_contracts::model3d::common::{Model3dGeneratedArtifact, Model3dGenerationTargetRef}; +use shared_contracts::model3d::image_to_model::Model3dImageToModelResult; +use shared_contracts::model3d::text_to_model::Model3dTextToModelResult; + +use crate::http_error::AppError; + +use super::{job::Model3dJobKind, provider::TRIPO_PROVIDER, storage::StoredModel3dArtifact}; + +pub(crate) fn artifact_metadata(stored: &StoredModel3dArtifact) -> Model3dGeneratedArtifact { + Model3dGeneratedArtifact { + object_key: stored.object_key.clone(), + content_type: stored.content_type.clone(), + content_length: stored.content_length, + sha256: stored.sha256.clone(), + } +} + +pub(crate) fn build_result( + kind: Model3dJobKind, + target: Model3dGenerationTargetRef, + model: &StoredModel3dArtifact, + preview: &StoredModel3dArtifact, +) -> Model3dGenerationResult { + let model = artifact_metadata(model); + let preview = artifact_metadata(preview); + match kind { + Model3dJobKind::TextToModel => { + Model3dGenerationResult::TextToModel(Model3dTextToModelResult { + target, + model, + preview, + }) + } + Model3dJobKind::ImageToModel => { + Model3dGenerationResult::ImageToModel(Model3dImageToModelResult { + target, + model, + preview, + }) + } + } +} + +/// 队列终态结果载荷:外层固定 `result` 字段,值就是严格类型化的完成结果。 +/// +/// 序列化失败按服务端问题上报,由 worker 把任务标记为失败 —— 不能落一条 +/// 「完成但 `result: null`」的终态,消费方会当成成功却反序列化不出结果。 +pub(crate) fn job_result_payload(result: Model3dGenerationResult) -> Result { + let value = serde_json::to_value(result).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": TRIPO_PROVIDER, + "message": format!("序列化 3D 生成结果失败:{error}"), + })) + })?; + Ok(json!({ "result": value })) +} diff --git a/server-rs/crates/api-server/src/tripo3d/routes.rs b/server-rs/crates/api-server/src/tripo3d/routes.rs new file mode 100644 index 000000000..2d75d8220 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/routes.rs @@ -0,0 +1,443 @@ +//! Tripo 生成的内部提交入口。 +//! +//! 执行顺序固定:解析 JSON → 平台组合校验 → 输入预检 → 落点预检 → 查价 → 入队。 +//! 任何一步失败都发生在扣费与 provider 调用之前;状态读取复用既有任务查询接口。 + +use axum::{ + Json, Router, + extract::{State, rejection::JsonRejection}, + http::{HeaderMap, StatusCode}, + response::Response, + routing::post, +}; +use serde::de::DeserializeOwned; +use serde_json::json; +use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest; +use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest; + +use crate::{ + api_response::json_success_body, + auth::{AuthenticatedAccessToken, require_bearer_auth}, + editor_generation_queue::editor_generation_source_entity_id, + http_error::AppError, + request_context::RequestContext, + state::AppState, +}; + +use super::errors::{map_pricing_error, map_pricing_store_error, map_request_error}; +use super::image_source::preflight_image_source; +use super::job::{ + Model3dJobKind, image_to_model_target, normalize_image_to_model_target, + normalize_text_to_model_target, text_to_model_target, +}; +use super::queue::{Model3dSubmissionResponse, enqueue_model3d_job}; +use super::target::preflight_generation_target; +use super::validation::{validate_image_to_model_request, validate_text_to_model_request}; + +pub(crate) const TEXT_TO_MODEL_ROUTE: &str = "/api/assets/tripo/text-to-model"; +pub(crate) const IMAGE_TO_MODEL_ROUTE: &str = "/api/assets/tripo/image-to-model"; + +pub(crate) fn router(state: AppState) -> Router { + Router::new() + .route( + TEXT_TO_MODEL_ROUTE, + post(submit_text_to_model).route_layer(axum::middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + IMAGE_TO_MODEL_ROUTE, + post(submit_image_to_model).route_layer(axum::middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) +} + +pub(crate) async fn submit_text_to_model( + State(state): State, + axum::extract::Extension(request_context): axum::extract::Extension, + axum::extract::Extension(authenticated): axum::extract::Extension, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result<(StatusCode, Json), Response> { + let mut payload = parse_json_payload(&request_context, payload)?; + let idempotency_key = require_idempotency_key(&headers, &request_context)?; + let query = validate_text_to_model_request(&payload) + .map_err(map_request_error) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + // 归一落点并写回请求:预检、入队载荷与落库必须是同一个 canonical 值。 + normalize_text_to_model_target(&mut payload, authenticated.claims().user_id()); + // 落点预检与参数校验一样前置于查价:跨 owner / 不存在的落点必须在这里就被拒, + // 不能等 worker 落库时才发现,那时 provider 预算已经花掉了。 + let target = text_to_model_target(&payload); + preflight_generation_target(&state, authenticated.claims().user_id(), target) + .await + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + let price_mud_points = resolve_price_mud_points(&state, &request_context, &query).await?; + // 队列行的来源身份与其它生成工具同一口径:项目 ID,缺项目时回落到 job kind。 + let source_entity_id = + editor_generation_source_entity_id(target.project_id, Model3dJobKind::TextToModel.as_str()); + let job = enqueue_model3d_job( + &state, + authenticated.claims().user_id(), + Model3dJobKind::TextToModel, + source_entity_id.as_str(), + price_mud_points, + &payload, + idempotency_key, + ) + .await + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + Ok(accepted_response(&request_context, job)) +} + +pub(crate) async fn submit_image_to_model( + State(state): State, + axum::extract::Extension(request_context): axum::extract::Extension, + axum::extract::Extension(authenticated): axum::extract::Extension, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result<(StatusCode, Json), Response> { + let mut payload = parse_json_payload(&request_context, payload)?; + let idempotency_key = require_idempotency_key(&headers, &request_context)?; + let query = validate_image_to_model_request(&payload) + .map_err(map_request_error) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + // 图片输入在扣费与入队之前先做一次元数据预检:跨 owner、未登记或类型不符的引用 + // 直接 400,不产生 operation,也不扣费。worker 执行时会重新确认同一事实。 + preflight_image_source(&state, authenticated.claims().user_id(), &payload.source) + .await + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + // 落点与图片输入一样,必须在查价、扣费与入队之前确认;先归一再预检, + // 入队与落库看到的就是预检过的那个值。 + normalize_image_to_model_target(&mut payload, authenticated.claims().user_id()); + let target = image_to_model_target(&payload); + preflight_generation_target(&state, authenticated.claims().user_id(), target) + .await + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + let price_mud_points = resolve_price_mud_points(&state, &request_context, &query).await?; + // 队列行的来源身份与其它生成工具同一口径:项目 ID,缺项目时回落到 job kind。 + let source_entity_id = editor_generation_source_entity_id( + target.project_id, + Model3dJobKind::ImageToModel.as_str(), + ); + let job = enqueue_model3d_job( + &state, + authenticated.claims().user_id(), + Model3dJobKind::ImageToModel, + source_entity_id.as_str(), + price_mud_points, + &payload, + idempotency_key, + ) + .await + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + Ok(accepted_response(&request_context, job)) +} + +fn accepted_response( + request_context: &RequestContext, + job: spacetime_client::ExternalGenerationJobRecord, +) -> (StatusCode, Json) { + ( + StatusCode::ACCEPTED, + json_success_body( + Some(request_context), + Model3dSubmissionResponse::from_job(job), + ), + ) +} + +async fn resolve_price_mud_points( + state: &AppState, + request_context: &RequestContext, + query: &super::pricing::Model3dPricingQuery, +) -> Result { + let pricing = state + .editor_generation_pricing() + .await + .map_err(map_pricing_store_error) + .map_err(|error| error.into_response_with_context(Some(request_context)))?; + match pricing.model3d_price(query) { + Ok(price_mud_points) => Ok(u64::from(price_mud_points)), + // 查价失败只有 3D 定价这一种原因;其它变体理论上不会出现,真出现时按服务端问题上报。 + Err(crate::editor_generation_config::EditorGenerationPricingError::Model3d(error)) => { + Err(map_pricing_error(error).into_response_with_context(Some(request_context))) + } + Err(error) => { + Err(map_pricing_store_error(error).into_response_with_context(Some(request_context))) + } + } +} + +fn parse_json_payload( + request_context: &RequestContext, + payload: Result, JsonRejection>, +) -> Result { + payload.map(|Json(payload)| payload).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_details(json!({ + "provider": super::provider::TRIPO_PROVIDER, + "message": error.body_text(), + })) + .into_response_with_context(Some(request_context)) + }) +} + +/// 两个提交都必须显式给出 `Idempotency-Key`;缺失或格式非法一律 400,不静默生成键。 +fn require_idempotency_key<'a>( + headers: &'a HeaderMap, + request_context: &RequestContext, +) -> Result<&'a str, Response> { + let value = headers + .get("idempotency-key") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_details(json!({ + "provider": super::provider::TRIPO_PROVIDER, + "message": "3D 生成提交必须携带 Idempotency-Key 请求头。", + })) + .into_response_with_context(Some(request_context)) + })?; + if value.len() > 128 || !value.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_details(json!({ + "provider": super::provider::TRIPO_PROVIDER, + "message": "Idempotency-Key 必须是 1-128 个可打印 ASCII 字符,且不能包含空格。", + })) + .into_response_with_context(Some(request_context))); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{HeaderValue, Request}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; + + #[test] + fn idempotency_key_is_required_and_validated() { + let request_context = RequestContext::new( + "request-idempotency-key".to_string(), + "test".to_string(), + std::time::Duration::ZERO, + false, + ); + let missing = HeaderMap::new(); + assert!(require_idempotency_key(&missing, &request_context).is_err()); + + let mut headers = HeaderMap::new(); + headers.insert("idempotency-key", HeaderValue::from_static("issue-1")); + assert_eq!( + require_idempotency_key(&headers, &request_context).expect("合法键应通过"), + "issue-1" + ); + + let mut blank = HeaderMap::new(); + blank.insert("idempotency-key", HeaderValue::from_static(" ")); + assert!(require_idempotency_key(&blank, &request_context).is_err()); + + let mut spaced = HeaderMap::new(); + spaced.insert("idempotency-key", HeaderValue::from_static("a b")); + assert!(require_idempotency_key(&spaced, &request_context).is_err()); + } + + /// 两个路由必须真的挂在应用路由上并走统一鉴权:路径写错或漏 merge 会退化成 404。 + #[tokio::test] + async fn tripo_submit_routes_require_bearer_auth() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + for route in [TEXT_TO_MODEL_ROUTE, IMAGE_TO_MODEL_ROUTE] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(route) + .header("content-type", "application/json") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{route} 未鉴权时必须 401" + ); + } + } + + /// 缺 `Idempotency-Key` 必须在任何查询与扣费之前拦下,且不产生 operation。 + #[tokio::test] + async fn image_to_model_requires_idempotency_key_before_any_work() { + let (state, token) = authenticated_state().await; + let response = post_json( + &state, + &token, + IMAGE_TO_MODEL_ROUTE, + None, + valid_image_body(), + ) + .await; + + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert!( + response.1.contains("Idempotency-Key"), + "错误文案必须点名缺失的请求头:{}", + response.1 + ); + } + + /// 组合校验前置于定价与 provider:贴图档位与 `texture=false` 冲突时直接 400。 + #[tokio::test] + async fn text_to_model_rejects_param_composition_before_pricing() { + let (state, token) = authenticated_state().await; + let mut body = valid_body(); + body["generation"]["texture"] = json!(false); + body["generation"]["textureQuality"] = json!("standard"); + + let response = post_json(&state, &token, TEXT_TO_MODEL_ROUTE, Some("issue-1"), body).await; + + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert!( + response.1.contains("generation.textureQuality"), + "错误必须定位到冲突字段:{}", + response.1 + ); + } + + /// 组合校验前置于定价与 provider:定价参数缺省时报错并定位到缺失字段。 + #[tokio::test] + async fn text_to_model_rejects_missing_pricing_params_before_pricing() { + let (state, token) = authenticated_state().await; + let mut body = valid_body(); + body["generation"] + .as_object_mut() + .expect("generation 应为对象") + .remove("quad"); + + let response = post_json(&state, &token, TEXT_TO_MODEL_ROUTE, Some("issue-2"), body).await; + + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert!( + response.1.contains("generation.quad"), + "错误必须定位到缺失字段:{}", + response.1 + ); + } + + /// 可解析、可校验的 spec 形状请求体;只在进入组合校验之前使用。 + fn valid_body() -> serde_json::Value { + json!({ + "generation": { + "prompt": "一只木箱", + "model": "v3.1-20260211", + "texture": true, + "textureQuality": "standard", + "pbr": true, + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "assetFolderId": "folder-1", + "assetLabel": "测试" + }) + } + + /// 图生 3D 的参数集与文生不同:没有 `prompt`,多了图片对齐与输入朝向。 + fn valid_image_body() -> serde_json::Value { + json!({ + "source": { "kind": "resource", "resourceId": "resource-1" }, + "generation": { + "model": "v3.1-20260211", + "texture": true, + "textureQuality": "standard", + "pbr": true, + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "assetFolderId": "folder-1", + "assetLabel": "测试" + }) + } + + async fn authenticated_state() -> (AppState, String) { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let user_id = state + .seed_test_phone_user_with_password("13800138121", "secret123") + .await + .id; + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: user_id.clone(), + session_id: state.seed_test_refresh_session_for_user_id(&user_id, "sess_tripo"), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 2, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("3D 用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign"); + (state, token) + } + + async fn post_json( + state: &AppState, + token: &str, + route: &str, + idempotency_key: Option<&str>, + body: serde_json::Value, + ) -> (StatusCode, String) { + let mut request = Request::builder() + .method("POST") + .uri(route) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1"); + if let Some(key) = idempotency_key { + request = request.header("idempotency-key", key); + } + let response = build_router(state.clone()) + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + (status, String::from_utf8_lossy(&bytes).into_owned()) + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/storage.rs b/server-rs/crates/api-server/src/tripo3d/storage.rs new file mode 100644 index 000000000..126143bb9 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/storage.rs @@ -0,0 +1,535 @@ +//! 3D 产物的 OSS 写入与对象登记。 +//! +//! 模型与预览图都是“服务端已经落地的正式对象”,所以这里只做三件事:PUT、HEAD 复核、 +//! 生成 `asset_object` 输入。资源 / 素材行由 worker 走既有原子落库路径写入,本文件 +//! 不碰队列、不碰扣费、也不认识 provider 响应。 +//! +//! TODO(stream): 本期沿用完整字节写入(`Vec`)后一次性 PUT,模型产物约几十 MB; +//! 后续 `platform-oss` 支持流式 / 分片上传后,这里只保留对象键与元数据构造。 + +use std::collections::BTreeMap; + +use axum::http::StatusCode; +use module_assets::{ + AssetObjectAccessPolicy, AssetObjectUpsertInput, build_asset_object_upsert_input, +}; +use platform_oss::{ + LegacyAssetPrefix, OssDeleteObjectRequest, OssHeadObjectRequest, OssObjectAccess, + OssPutObjectRequest, +}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use shared_contracts::editor_generation::editor_generation_stable_asset_object_id; +use tracing::warn; + +use crate::{ + editor_project::{ + EditorGenerationCaller, current_utc_micros, map_editor_asset_field_error, + sanitize_editor_storage_segment, + }, + http_error::AppError, + platform_errors::map_oss_error, + state::AppState, +}; + +use super::{errors::oss_unavailable, job::MODEL3D_PROVIDER_KIND, provider::TRIPO_PROVIDER}; + +/// 3D 结果的语义类别;取值与 api-server 媒体投影共用同一常量,避免两处字面量分叉。 +pub(crate) const MODEL3D_ASSET_KIND: &str = crate::editor_project::EDITOR_MODEL3D_ASSET_KIND; + +/// `asset_object.entity_kind`:说明这个对象属于 3D 生成产物。 +const MODEL3D_OBJECT_ENTITY_KIND: &str = "model3d"; + +/// 产物 PUT 的重试预算:模型最大 512 MiB,一次瞬时网络失败就会废掉一笔已经计费的生成, +/// 而任务本身最多只尝试一次,所以这里按与角色动画一致的口径退避重试三次。 +const MODEL3D_ARTIFACT_PUT_MAX_ATTEMPTS: usize = 3; +const MODEL3D_ARTIFACT_PUT_RETRY_DELAYS_MS: [u64; 2] = [250, 500]; + +const MODEL3D_OBJECT_PATH_SEGMENT: &str = "model3d"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Model3dArtifactSlot { + Model, + Preview, +} + +impl Model3dArtifactSlot { + const fn file_stem(self) -> &'static str { + match self { + Self::Model => "model", + Self::Preview => "preview", + } + } + + /// 本槽位接受的全部 content type。表外的取值一律拒绝:不做归一兜底,也不替 + /// provider 猜一个类型,因为猜错的类型会连同扩展名一起写进 OSS metadata 与 + /// `asset_object.content_type`,比失败更难发现。 + const fn declared_content_types(self) -> &'static [&'static str] { + match self { + Self::Model => &[ + "model/gltf-binary", + "model/gltf+json", + "model/fbx", + "application/x-fbx", + ], + Self::Preview => &["image/png", "image/jpeg", "image/jpg", "image/webp"], + } + } + + /// 已知 content type 对应的对象键扩展名;表外返回 `None`。 + fn artifact_extension(self, content_type: &str) -> Option<&'static str> { + if !self.declared_content_types().contains(&content_type) { + return None; + } + Some(match content_type { + "model/gltf-binary" => "glb", + "model/gltf+json" => "gltf", + "model/fbx" | "application/x-fbx" => "fbx", + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/webp" => "webp", + _ => return None, + }) + } +} + +/// 已落地的产物:对象键与对象元数据来自 HEAD 复核,不是 PUT 的入参原样回填。 +pub(crate) struct StoredModel3dArtifact { + pub(crate) object_key: String, + pub(crate) content_type: String, + pub(crate) content_length: u64, + pub(crate) sha256: String, + pub(crate) asset_object: AssetObjectUpsertInput, +} + +pub(crate) async fn store_model3d_artifact( + state: &AppState, + caller: &EditorGenerationCaller, + slot: Model3dArtifactSlot, + bytes: Vec, + content_type: &str, +) -> Result { + let operation = caller.operation.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": TRIPO_PROVIDER, + "message": "写入 3D 产物时缺少稳定 operation 上下文。", + })) + })?; + let owner_user_id = caller.owner_user_id.as_str(); + let job_id = operation.operation_id.as_str(); + let oss_client = state + .oss_client() + .ok_or_else(|| oss_unavailable("OSS 未完成环境变量配置,无法写入 3D 产物。"))?; + let http_client = state.editor_oss_http_client(); + let content_type = resolve_artifact_content_type(content_type, slot, bytes.as_slice())?; + // 模型产物上限是 512 MiB,同步哈希会把一个 Tokio worker 占住几百毫秒,拖慢同一运行时上的 + // 其它任务。挪到 blocking 线程池,并让任务把 bytes 原样带回来,省掉一次整块复制。 + let (sha256, bytes) = tokio::task::spawn_blocking(move || { + let sha256 = sha256_hex(bytes.as_slice()); + (sha256, bytes) + }) + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": TRIPO_PROVIDER, + "message": format!("3D 产物哈希任务失败:{error}"), + })) + })?; + let extension = slot + .artifact_extension(content_type.as_str()) + .ok_or_else(|| unsupported_artifact_content_type(content_type.as_str(), slot))?; + let file_name = format!("{}.{}", slot.file_stem(), extension); + let put_result = oss_client + .put_object_with_transient_retry( + http_client, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec![ + "editor".to_string(), + MODEL3D_OBJECT_PATH_SEGMENT.to_string(), + sanitize_editor_storage_segment(job_id, "task"), + ], + file_name, + content_type: Some(content_type.clone()), + access: OssObjectAccess::Private, + metadata: BTreeMap::from([ + ("asset_kind".to_string(), MODEL3D_ASSET_KIND.to_string()), + ("owner_user_id".to_string(), owner_user_id.to_string()), + ( + "entity_kind".to_string(), + MODEL3D_OBJECT_ENTITY_KIND.to_string(), + ), + ("entity_id".to_string(), job_id.to_string()), + ("slot".to_string(), slot.file_stem().to_string()), + ("provider".to_string(), MODEL3D_PROVIDER_KIND.to_string()), + ]), + body: bytes, + }, + MODEL3D_ARTIFACT_PUT_MAX_ATTEMPTS, + &MODEL3D_ARTIFACT_PUT_RETRY_DELAYS_MS, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let head = oss_client + .head_object( + http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + // HEAD 复核回来的 Content-Type 才是落库与对外共用的那份:只把本地解析值给调用方, + // 会出现「接口返回的 content type」与「asset_object.content_type」不一致。 + let effective_content_type = head + .content_type + .clone() + .unwrap_or_else(|| content_type.clone()); + let now_micros = current_utc_micros(); + // asset_object 的 ID 必须是 (owner, operation_kind, operation_id, slot) 的稳定派生值, + // 否则 SpacetimeDB 侧会以“生成结果 asset object ID 不是 operation 稳定 ID”拒绝整笔提交。 + let asset_object = build_asset_object_upsert_input( + editor_generation_stable_asset_object_id( + owner_user_id, + operation.operation_kind.as_str(), + job_id, + slot.file_stem(), + ), + head.bucket, + head.object_key.clone(), + AssetObjectAccessPolicy::Private, + Some(effective_content_type.clone()), + head.content_length, + Some(sha256.clone()), + MODEL3D_ASSET_KIND.to_string(), + Some(job_id.to_string()), + Some(owner_user_id.to_string()), + None, + Some(job_id.to_string()), + now_micros, + ) + .map_err(map_editor_asset_field_error)?; + Ok(StoredModel3dArtifact { + object_key: head.object_key, + content_type: effective_content_type, + content_length: head.content_length, + sha256, + asset_object, + }) +} + +/// 丢弃一个已经写进 OSS、但最终没能登记成正式资源的产物对象。 +/// +/// 上传成功、登记失败时如果不清理,bucket 里会一直堆着按 job 命名的私有残留对象。 +/// 删除失败只记警告:这时 job 已经注定失败,删除不成功不该改写对外错误,但残留对象 +/// 需要人工按日志清理。 +pub(crate) async fn discard_model3d_artifact(state: &AppState, artifact: &StoredModel3dArtifact) { + let object_key = artifact.object_key.clone(); + let Some(oss_client) = state.oss_client() else { + warn!( + object_key = %object_key, + "3D 产物登记失败后要删除对象,但 OSS 未配置,需要人工清理" + ); + return; + }; + if let Err(error) = oss_client + .delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + { + warn!( + object_key = %object_key, + error = %error, + "3D 产物登记失败后删除对象也失败,需要人工清理" + ); + } +} + +/// provider 声明的 content type 去参数、去空白、转小写后与槽位白名单比对; +/// 不在表里(含空值、`application/octet-stream`)返回 `None`。 +fn declared_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> Option<&'static str> { + let normalized = raw + .split(';') + .next() + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase(); + slot.declared_content_types() + .iter() + .copied() + .find(|known| *known == normalized) +} + +/// glb 的容器魔数(小端 `glTF`);`model/gltf+json` 的判据是首个非空白字节为 `{`。 +const GLB_MAGIC: &[u8] = b"glTF"; +/// FBX 二进制头,固定为 `Kaydara FBX Binary` 加两个空格与 NUL;截到前缀即可判定。 +const FBX_MAGIC: &[u8] = b"Kaydara FBX Binary"; + +/// 按字节魔数识别模型格式。字节是唯一不会被 provider 说谎的真相来源。 +fn sniff_model_content_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(GLB_MAGIC) { + return Some("model/gltf-binary"); + } + if bytes.starts_with(FBX_MAGIC) { + // `model/fbx` 不是注册媒体类型,但它是本项目自己的产物词汇,客户端按 + // `model/` 族里的 `fbx` 子串判定,比 `application/octet-stream` 可读得多。 + return Some("model/fbx"); + } + let first_meaningful = bytes + .strip_prefix(&[0xEF, 0xBB, 0xBF][..]) + .unwrap_or(bytes) + .iter() + .copied() + .find(|byte| !byte.is_ascii_whitespace()); + if first_meaningful == Some(b'{') { + return Some("model/gltf+json"); + } + None +} + +/// 预览图按字节魔数识别格式。识别范围就是线上真正会拿到的三种渲染图格式,其余返回 `None` +/// 走声明值回落 —— 不为了少见的格式去猜一个可能同样错的类型。 +fn sniff_preview_content_type(bytes: &[u8]) -> Option<&'static str> { + let format = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok()? + .format()?; + match format { + image::ImageFormat::Png => Some("image/png"), + image::ImageFormat::Jpeg => Some("image/jpeg"), + image::ImageFormat::WebP => Some("image/webp"), + _ => None, + } +} + +/// 产物 content type:两个槽位都先嗅探字节,嗅不出来才信 provider 声明;两者都不成立就失败。 +/// +/// provider 对二进制产物常给 `application/octet-stream`,因此「字节优先」是这条链路的前提: +/// 客户端按这份 content type 判定能不能预览,说错就是拿 GLTFLoader 去解 FBX。预览图同理: +/// provider / CDN 常把 PNG 渲染图标成 `application/octet-stream` 或干脆不写 `Content-Type`, +/// 照抄声明就会把 `image/webp` 写进 OSS metadata 与 `asset_object.content_type`,素材库下载 +/// 命名与后台等非浏览器消费方都会拿到错的类型。浏览器 `` 自己能按魔数嗅探, +/// 但那不是让记录值继续说谎的理由。 +/// +/// 声明值只在槽位白名单内才被采信:白名单外的取值会让类型与扩展名互相矛盾 +/// (provider 说 `text/plain`、对象键却只能拼出 `.glb`),并且把 provider 的任意文本 +/// 原样写进 OSS metadata 与 `asset_object.content_type`。字节认不出、声明也不在白名单 +/// 时直接失败,让一次生成失败退款,而不是落一条自相矛盾的元数据。 +/// +/// 这里不把 worker 的 `preview_dimensions` 猜出的格式传下来:那一步是「必须能解码」的正交校验, +/// 与写库时的类型归一各管一件事,重复的只是读文件头这一下,换来判定点只有一个。 +fn resolve_artifact_content_type( + raw: &str, + slot: Model3dArtifactSlot, + bytes: &[u8], +) -> Result { + let sniffed = match slot { + Model3dArtifactSlot::Model => sniff_model_content_type(bytes), + Model3dArtifactSlot::Preview => sniff_preview_content_type(bytes), + }; + if let Some(sniffed) = sniffed { + return Ok(sniffed.to_string()); + } + declared_artifact_content_type(raw, slot) + .map(str::to_string) + .ok_or_else(|| unsupported_artifact_content_type(raw, slot)) +} + +/// 声明类型不在槽位白名单里:不落库,也不猜一个类型继续走。 +fn unsupported_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> AppError { + // 声明值是不可信输入,只回显截断后的一段,避免把任意长度的 provider 文本带进错误体。 + let declared: String = raw.trim().chars().take(64).collect(); + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-artifact-content-type-unsupported", + "message": format!( + "provider 返回的 {} 产物类型不可识别,且字节特征与任何受支持格式都不匹配。", + slot.file_stem() + ), + "declaredContentType": declared, + })) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GLB_BYTES: &[u8] = b"glTF\x02\x00\x00\x00rest-of-glb"; + const FBX_BYTES: &[u8] = b"Kaydara FBX Binary \x00\x1a\x00rest-of-fbx"; + const GLTF_JSON_BYTES: &[u8] = b"\xEF\xBB\xBF\n {\"asset\":{\"version\":\"2.0\"}}"; + const UNKNOWN_BYTES: &[u8] = b"\x00\x01not-a-model"; + const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\nrest-of-png"; + const JPEG_BYTES: &[u8] = b"\xFF\xD8\xFF\xE0rest-of-jpeg"; + const WEBP_BYTES: &[u8] = b"RIFF\x24\x00\x00\x00WEBPVP8 rest-of-webp"; + + /// 期望可归一的用例:失败即 panic,并把真实错误带出来。 + fn resolve(declared: &str, slot: Model3dArtifactSlot, bytes: &[u8]) -> String { + resolve_artifact_content_type(declared, slot, bytes) + .unwrap_or_else(|error| panic!("声明 {declared} 的产物应当可归一:{error}")) + } + + /// 期望被拒的用例:断言按「上游内容不合法」失败。 + fn resolve_rejected(declared: &str, slot: Model3dArtifactSlot, bytes: &[u8]) { + let error = resolve_artifact_content_type(declared, slot, bytes) + .expect_err(&format!("声明 {declared} 的产物必须被拒绝")); + assert_eq!( + error.status_code(), + StatusCode::BAD_GATEWAY, + "声明 {declared} 的产物未通过识别时应按上游内容不合法失败" + ); + } + + #[test] + fn model_content_type_follows_bytes_over_declaration() { + // provider 对二进制产物常给 application/octet-stream,照抄声明会让 FBX 以 .glb 落库。 + let content_type = resolve( + "application/octet-stream", + Model3dArtifactSlot::Model, + FBX_BYTES, + ); + assert_eq!(content_type, "model/fbx"); + assert_eq!( + Model3dArtifactSlot::Model.artifact_extension(content_type.as_str()), + Some("fbx") + ); + } + + #[test] + fn model_content_type_follows_bytes_even_when_declaration_is_wrong() { + // 声明说 glb、字节是 fbx 时以字节为准:这份 content type 会进 asset_object, + // 前端拿它决定禁用 3D 预览与否,不能让它说错。 + assert_eq!( + resolve("model/gltf-binary", Model3dArtifactSlot::Model, FBX_BYTES), + "model/fbx" + ); + } + + #[test] + fn model_content_type_recognizes_each_supported_container() { + assert_eq!( + resolve( + "application/octet-stream", + Model3dArtifactSlot::Model, + GLB_BYTES + ), + "model/gltf-binary" + ); + assert_eq!( + resolve( + "application/octet-stream", + Model3dArtifactSlot::Model, + GLTF_JSON_BYTES + ), + "model/gltf+json" + ); + assert_eq!( + Model3dArtifactSlot::Model.artifact_extension("model/gltf+json"), + Some("gltf") + ); + } + + #[test] + fn model_content_type_rejects_declarations_outside_the_allowlist() { + // 字节认不出、声明又不在白名单时不再兜底成 .glb:放行 text/html 这类类型 + // 会落成「错的 content type + 拼出来的扩展名」,比直接失败更难发现。 + for declared in [ + "application/octet-stream", + "text/html; charset=utf-8", + "text/plain", + "", + ] { + resolve_rejected(declared, Model3dArtifactSlot::Model, UNKNOWN_BYTES); + } + // 白名单内的声明在字节认不出时仍被采信,且扩展名与它一致。 + assert_eq!( + resolve( + "model/gltf-binary", + Model3dArtifactSlot::Model, + UNKNOWN_BYTES + ), + "model/gltf-binary" + ); + assert_eq!( + Model3dArtifactSlot::Model.artifact_extension("binary/octet-stream"), + None + ); + } + + #[test] + fn preview_content_type_follows_bytes_over_declaration() { + // Tripo 的渲染图常带 application/octet-stream(或被 CDN 标错):照抄声明会把 PNG + // 写成 .webp,类型与扩展名一起错到 asset_object.content_type 与素材库下载命名上。 + for (bytes, declared, expected) in [ + (PNG_BYTES, "application/octet-stream", "image/png"), + (PNG_BYTES, "", "image/png"), + (PNG_BYTES, "image/jpeg", "image/png"), + (JPEG_BYTES, "application/octet-stream", "image/jpeg"), + (WEBP_BYTES, "application/octet-stream", "image/webp"), + ] { + assert_eq!( + resolve(declared, Model3dArtifactSlot::Preview, bytes), + expected, + "声明 {declared} 的预览图必须按字节判定" + ); + } + // 类型与对象键扩展名同源于这一次判定:PNG 预览落库是 preview.png。 + assert_eq!( + Model3dArtifactSlot::Preview.artifact_extension("image/png"), + Some("png") + ); + } + + #[test] + fn preview_content_type_keeps_only_allowlisted_declarations() { + // 带参数的声明先去掉参数再比对。 + assert_eq!( + resolve( + "image/jpeg; charset=binary", + Model3dArtifactSlot::Preview, + UNKNOWN_BYTES + ), + "image/jpeg" + ); + // octet-stream、模型字节与白名单外的图片格式都不构成可用声明: + // 预览槽位同样不做「猜一个 webp」的兜底。 + resolve_rejected( + "application/octet-stream", + Model3dArtifactSlot::Preview, + UNKNOWN_BYTES, + ); + resolve_rejected( + "application/octet-stream", + Model3dArtifactSlot::Preview, + GLB_BYTES, + ); + resolve_rejected("image/avif", Model3dArtifactSlot::Preview, UNKNOWN_BYTES); + } + + #[test] + fn artifact_extension_rejects_cross_slot_types() { + assert_eq!( + Model3dArtifactSlot::Model.artifact_extension("application/x-fbx"), + Some("fbx") + ); + assert_eq!( + Model3dArtifactSlot::Model.artifact_extension("image/png"), + None, + "预览图类型不能落进模型槽位" + ); + assert_eq!( + Model3dArtifactSlot::Preview.artifact_extension("model/fbx"), + None, + "模型类型不能落进预览槽位" + ); + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/target.rs b/server-rs/crates/api-server/src/tripo3d/target.rs new file mode 100644 index 000000000..efe068703 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/target.rs @@ -0,0 +1,205 @@ +//! 3D 提交的落点预检。 +//! +//! 与 `image_source` 的预检对称:在查价、扣费与入队之前,先按主键确认落点 +//! (画布项目 / 素材库文件夹)属于当前 owner 且仍然存在。否则一次注定落库失败的提交 +//! 会先成功入队、扣费并花掉 provider 预算,最后才在 worker 落库阶段失败。 +//! +//! 复用其它生成工具同一个定点预检 procedure(`preflight_editor_generation_target`): +//! 项目与素材夹都按主键查,不再为了一个 folder id 读整库。传下去的是 trim 后的原值, +//! 不做 `project` / `folder-*` → owner 默认素材夹的归一:归一值必须一路带到落库, +//! 而落点只在 3D 打开素材库入口后才需要(默认目录由落库 procedure 自己归一)。 +//! +//! 跨 owner、已删除与不存在收敛成同一句 400,避免把「别人是否存在某个 ID」变成可探测信息; +//! 判据是查询给出的 [`EditorPointLookupFailure`] 归类信号——服务身份 403(运行时服务身份 +//! 没配好)、并发 409 与 5xx 都原样上报。worker 落库时会再确认同一事实。 + +use axum::http::StatusCode; +use serde_json::json; +use spacetime_client::editor_project::EditorGenerationTargetPreflightRecordInput; + +use crate::{ + editor_project::{ + EditorPointLookupFailure, EditorPointLookupKind, classify_editor_point_lookup_failure, + }, + http_error::AppError, + state::AppState, +}; + +use super::job::Model3dJobTarget; +use super::provider::TRIPO_PROVIDER; + +/// 提交时的落点预检:只读元数据,不写记录,也不产生 provider 副作用。 +pub(crate) async fn preflight_generation_target( + state: &AppState, + owner_user_id: &str, + target: Model3dJobTarget<'_>, +) -> Result<(), AppError> { + let Some((project_id, asset_folder_id)) = preflight_target_locator(target) else { + // 校验层保证「至少一个落点」,这里只是纵深防御:两个都没给时按 `target` 点名, + // 与 target_unavailable_field 的兜底口径一致(点 projectId 会指错键)。 + return Err(target_unavailable("target")); + }; + let field = target_unavailable_field(project_id.as_deref(), asset_folder_id.as_deref()); + state + .spacetime_client() + .preflight_editor_generation_target(EditorGenerationTargetPreflightRecordInput { + owner_user_id: owner_user_id.trim().to_string(), + project_id, + asset_folder_id, + }) + .await + .map(|_| ()) + .map_err(classify_editor_point_lookup_failure) + .map_err(|failure| collapse_target_unavailable(failure, field)) +} + +/// 平坦落点 → 定点预检入参:返回(项目 ID、素材夹 ID)。 +/// +/// 与其它生成工具同一口径:两个落点可以同时给(前端画布链路两个都发),procedure 会逐个定点查; +/// 传下去的就是 trim 后的原值 —— 校验值与随后入队、落库的值必须逐字一致, +/// 不能校验归一化后的目录却把原始值落库。 +/// 返回 `None` 表示两个落点字段都没给:调用方按「落点不可用」拒绝。 +fn preflight_target_locator( + target: Model3dJobTarget<'_>, +) -> Option<(Option, Option)> { + let project_id = target + .project_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let asset_folder_id = target + .asset_folder_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + (project_id.is_some() || asset_folder_id.is_some()).then_some((project_id, asset_folder_id)) +} + +/// 落点不可用时对外点名的字段:只给一个就点它,两个都给无法归因,回落到 `target`。 +fn target_unavailable_field( + project_id: Option<&str>, + asset_folder_id: Option<&str>, +) -> &'static str { + match (project_id.is_some(), asset_folder_id.is_some()) { + (true, false) => "projectId", + (false, true) => "assetFolderId", + _ => "target", + } +} + +/// 未登记 / 跨 owner / 已删除都收敛成同一句 400;其余失败一律保留原错误。 +/// +/// 只看归类信号:`当前 identity 无权调用模型生成运行时服务` 的 403 与 +/// `模型定价服务身份尚未初始化` 的 400 都是服务端没配好,按状态码分档会把它们误报成 +/// 「你的落点选错了」(见 `classify_editor_point_lookup_failure` 的文案清单)。 +fn collapse_target_unavailable(failure: EditorPointLookupFailure, field: &'static str) -> AppError { + match failure.kind { + EditorPointLookupKind::Unavailable => target_unavailable(field), + EditorPointLookupKind::Other => failure.into_error(), + } +} + +/// 落点不可用:字段名跟随平坦参数,指向调用方实际给错的那个键。 +fn target_unavailable(field: &'static str) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-target-unavailable", + "field": field, + "message": "生成结果的落点必须是当前账号已存在的画布项目或素材库文件夹。", + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_unavailable_is_stable_and_machine_readable() { + let error = target_unavailable("projectId"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + let details = error.details().expect("details 必须存在"); + assert_eq!(details["reason"], json!("model3d-target-unavailable")); + assert_eq!(details["field"], json!("projectId")); + } + + /// 跨 owner / 不存在对外只有一种说法;服务端没配好(403 / 400)不能被误报成用户问题。 + #[test] + fn only_unavailable_lookups_collapse_into_target_unavailable() { + let collapsed = collapse_target_unavailable( + EditorPointLookupFailure { + kind: EditorPointLookupKind::Unavailable, + error: AppError::from_status(StatusCode::NOT_FOUND), + }, + "assetFolderId", + ); + assert_eq!( + collapsed.details(), + target_unavailable("assetFolderId").details() + ); + + for kept in [ + StatusCode::FORBIDDEN, + StatusCode::BAD_REQUEST, + StatusCode::CONFLICT, + StatusCode::INTERNAL_SERVER_ERROR, + ] { + let preserved = collapse_target_unavailable( + EditorPointLookupFailure { + kind: EditorPointLookupKind::Other, + error: AppError::from_status(kept), + }, + "assetFolderId", + ); + assert_eq!(preserved.status_code(), kept); + assert_ne!( + preserved.details(), + target_unavailable("assetFolderId").details() + ); + } + } + + /// 平坦落点收敛成定点预检入参:只做 trim,两个都给就都传,都不给返回 `None`。 + #[test] + fn flat_locator_maps_to_point_lookup_without_rewriting_ids() { + let locator = |project_id, asset_folder_id| { + preflight_target_locator(Model3dJobTarget { + project_id, + canvas_completion: None, + asset_folder_id, + asset_label: None, + }) + }; + + assert_eq!( + locator(Some(" project-1 "), None), + Some((Some("project-1".to_string()), None)) + ); + assert_eq!( + locator(None, Some(" folder-1 ")), + Some((None, Some("folder-1".to_string()))) + ); + assert_eq!( + locator(Some("project-1"), Some("folder-1")), + Some((Some("project-1".to_string()), Some("folder-1".to_string()))) + ); + assert_eq!(locator(None, None), None); + } + + /// 出错字段:只有一个落点时点名它,两个都给时无法归因,回落到 `target`。 + #[test] + fn unavailable_field_names_the_given_locators() { + assert_eq!( + target_unavailable_field(Some("project-1"), None), + "projectId" + ); + assert_eq!( + target_unavailable_field(None, Some("folder-1")), + "assetFolderId" + ); + assert_eq!( + target_unavailable_field(Some("project-1"), Some("folder-1")), + "target" + ); + assert_eq!(target_unavailable_field(None, None), "target"); + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/validation.rs b/server-rs/crates/api-server/src/tripo3d/validation.rs new file mode 100644 index 000000000..a18da5cfc --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/validation.rs @@ -0,0 +1,523 @@ +//! Tripo 生成请求的平台侧组合校验。 +//! +//! 这里只判两类事: +//! 1. 平台自己新增的口径——会改价的参数必须显式给出(`geometryQuality` 与 `textureQuality` +//! 例外:缺省等价于 provider 的 standard,不产生加价),贴图、贴图档位与 pbr 不能互相矛盾; +//! 2. 模型档位与参数的能力组合——直接复用 `platform-tripo` 的请求预检,不维护第二份规则。 +//! +//! 校验必须在扣费与任何 provider 调用之前完成;拿不到合法参数与价格就不提交、不入队。 + +use platform_tripo::{TripoError, validate_image_to_model_params, validate_text_to_model_params}; +use shared_contracts::model3d::common::{ + Model3dGenerationSource, Model3dGeometryQuality, Model3dModelVersion, Model3dTextureQuality, +}; +use shared_contracts::model3d::image_to_model::{ + Model3dImageToModelParams, Model3dImageToModelRequest, +}; +use shared_contracts::model3d::text_to_model::{ + Model3dTextToModelParams, Model3dTextToModelRequest, +}; + +use super::job::{Model3dJobTarget, image_to_model_target, text_to_model_target}; +use super::pricing::{Model3dEndpoint, Model3dPricingQuery, model3d_add_ons}; + +/// 请求被拒绝的原因。`InvalidRequest` 的字段名用 API 请求里的 JSON 字段路径。 +#[derive(Debug)] +pub(crate) enum Model3dRequestError { + InvalidRequest { + field: &'static str, + message: String, + }, + /// provider 参数预检失败;模型档位与参数组合规则由 `platform-tripo` 独家维护。 + Provider(TripoError), +} + +impl Model3dRequestError { + fn invalid(field: &'static str, message: &str) -> Self { + Self::InvalidRequest { + field, + message: message.to_owned(), + } + } + + /// 出错字段名:平台规则直接给字段路径,provider 规则用 provider 的字段名。 + pub(crate) fn field(&self) -> Option { + match self { + Self::InvalidRequest { field, .. } => Some((*field).to_owned()), + Self::Provider(TripoError::InvalidParameters { field, .. }) => { + field.map(|field| field.to_string()) + } + Self::Provider(_) => None, + } + } +} + +impl std::fmt::Display for Model3dRequestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidRequest { field, message } => write!(f, "{field}: {message}"), + Self::Provider(error) => error.fmt(f), + } + } +} + +impl std::error::Error for Model3dRequestError {} + +/// text-to-model 请求校验;返回可直接用于查价的定价输入。 +pub(crate) fn validate_text_to_model_request( + request: &Model3dTextToModelRequest, +) -> Result { + let query = PricingParamView::from(&request.generation) + .validate(Model3dEndpoint::TextToModel, request.generation.model)?; + validate_target(text_to_model_target(request))?; + validate_text_to_model_params(&request.generation).map_err(Model3dRequestError::Provider)?; + Ok(query) +} + +/// image-to-model 请求校验;图片归属与对象解析属于落库前的准备步骤,不在这里做。 +pub(crate) fn validate_image_to_model_request( + request: &Model3dImageToModelRequest, +) -> Result { + validate_source(&request.source)?; + let query = PricingParamView::from(&request.generation) + .validate(Model3dEndpoint::ImageToModel, request.generation.model)?; + validate_target(image_to_model_target(request))?; + validate_image_to_model_params(&request.generation).map_err(Model3dRequestError::Provider)?; + Ok(query) +} + +/// 两个端点的价格相关参数视图;字段名与契约请求保持一致,便于给出定位到字段的报错。 +#[derive(Clone, Copy)] +struct PricingParamView { + texture: Option, + texture_quality: Option, + pbr: Option, + geometry_quality: Option, + quad: Option, + smart_low_poly: Option, + generate_parts: Option, +} + +impl PricingParamView { + fn validate( + self, + endpoint: Model3dEndpoint, + model_version: Model3dModelVersion, + ) -> Result { + let texture = self.texture.ok_or_else(|| { + Model3dRequestError::invalid("generation.texture", "必须显式给出是否生成贴图") + })?; + // 几何质量允许缺省:provider 对 v2.5 / P1 / P2 不支持这个参数,而 v3.x 的缺省就是 + // standard,两边的「缺省」与「standard」同价。缺省按 standard 计价,因此只有显式 + // detailed 会命中 hdGeometry 加价;写死必填只会让这些已定价的模型永远不可达。 + let geometry_quality = self + .geometry_quality + .unwrap_or(Model3dGeometryQuality::Standard); + let quad = self.quad.ok_or_else(|| { + Model3dRequestError::invalid("generation.quad", "必须显式给出是否quad网格") + })?; + let smart_low_poly = self.smart_low_poly.ok_or_else(|| { + Model3dRequestError::invalid("generation.smartLowPoly", "必须显式给出是否智能低模") + })?; + let generate_parts = self.generate_parts.ok_or_else(|| { + Model3dRequestError::invalid("generation.generateParts", "必须显式给出是否生成分件") + })?; + + let texture_quality = match (texture, self.texture_quality) { + (true, Some(quality)) => quality, + (false, Some(_)) => { + return Err(Model3dRequestError::invalid( + "generation.textureQuality", + "texture=false 时不允许出现贴图档位", + )); + } + // 两处缺省都按 standard 计价:texture=true 时 standard 是 provider 的缺省档, + // 也是唯一覆盖全部模型族的档位(v2.5 拒绝任何显式 texture_quality,写死必填就会 + // 让 H25 + texture=true 永远不可达);texture=false 时贴图档位本身无意义, + // add-on 判定已被 texture 挡住。 + (_, None) => Model3dTextureQuality::Standard, + }; + if !texture && self.pbr != Some(false) { + return Err(Model3dRequestError::invalid( + "generation.pbr", + "texture=false 时 pbr 必须显式给 false", + )); + } + + Ok(Model3dPricingQuery { + endpoint, + model_version, + texture, + add_ons: model3d_add_ons( + texture, + texture_quality, + geometry_quality, + quad, + smart_low_poly, + generate_parts, + ), + }) + } +} + +impl From<&Model3dTextToModelParams> for PricingParamView { + fn from(params: &Model3dTextToModelParams) -> Self { + Self { + texture: params.texture, + texture_quality: params.texture_quality, + pbr: params.pbr, + geometry_quality: params.geometry_quality, + quad: params.quad, + smart_low_poly: params.smart_low_poly, + generate_parts: params.generate_parts, + } + } +} + +impl From<&Model3dImageToModelParams> for PricingParamView { + fn from(params: &Model3dImageToModelParams) -> Self { + Self { + texture: params.texture, + texture_quality: params.texture_quality, + pbr: params.pbr, + geometry_quality: params.geometry_quality, + quad: params.quad, + smart_low_poly: params.smart_low_poly, + generate_parts: params.generate_parts, + } + } +} + +fn validate_source(source: &Model3dGenerationSource) -> Result<(), Model3dRequestError> { + match source { + Model3dGenerationSource::Resource { resource_id } if resource_id.trim().is_empty() => Err( + Model3dRequestError::invalid("source.resourceId", "画布资源 ID 不能为空"), + ), + Model3dGenerationSource::Asset { asset_id } if asset_id.trim().is_empty() => Err( + Model3dRequestError::invalid("source.assetId", "素材 ID 不能为空"), + ), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::*; + use crate::tripo3d::pricing::Model3dAddOn; + + /// 基准 generation:所有价格相关参数都显式给出。 + fn generation_with(overrides: Value) -> Value { + let mut params = json!({ + "prompt": "一把木椅", + "model": "v3.1-20260211", + "texture": true, + "textureQuality": "standard", + "pbr": true, + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }); + let params = params.as_object_mut().expect("基准参数是对象"); + for (key, value) in overrides.as_object().expect("覆盖值必须是对象") { + if value.is_null() { + params.remove(key); + } else { + params.insert(key.clone(), value.clone()); + } + } + json!(params) + } + + fn text_request(generation: Value) -> Model3dTextToModelRequest { + serde_json::from_value(json!({ + "generation": generation, + "assetFolderId": "folder-1", + "assetLabel": "木椅" + })) + .expect("测试请求应可反序列化") + } + + /// image-to-model 的生成参数没有 prompt,基准值去掉该字段。 + fn image_generation_with(overrides: Value) -> Value { + let mut params = generation_with(overrides); + params + .as_object_mut() + .expect("基准参数是对象") + .remove("prompt"); + params + } + + #[test] + fn pricing_params_must_be_explicit() { + for field in [ + "generation.texture", + "generation.quad", + "generation.smartLowPoly", + "generation.generateParts", + ] { + let name = field.rsplit('.').next().expect("字段路径含字段名"); + let request = text_request(generation_with(json!({ name: null }))); + let error = validate_text_to_model_request(&request).expect_err("缺少定价参数应被拒绝"); + assert_eq!(error.field().as_deref(), Some(field), "实际报错:{error}"); + } + } + + /// 缺省几何质量按 standard 计价:不报错,也不产生 hdGeometry 加价。 + /// + /// v2.5 这类不支持 `geometry_quality` 的模型因此可达 —— 它们已经在定价表里, + /// 却曾因为「API 层必填 + provider 预检拒绝」的组合永远拿不到价格。 + #[test] + fn geometry_quality_is_optional_and_defaults_to_standard() { + for (label, overrides) in [ + ("v3.1 缺省几何质量", json!({})), + ( + "v2.5 缺省几何质量", + json!({ + "model": "v2.5-20250123", + "texture": false, + "textureQuality": null, + "geometryQuality": null, + "pbr": false + }), + ), + ] { + let request = text_request(generation_with(overrides)); + let query = validate_text_to_model_request(&request) + .unwrap_or_else(|error| panic!("{label} 应通过校验,实际被拒:{error}")); + assert!( + !query.add_ons.contains(Model3dAddOn::HdGeometry), + "{label} 缺省几何质量不产生高清几何加价" + ); + } + } + + #[test] + fn texture_off_rejects_texture_quality_and_requires_explicit_non_pbr() { + let request = text_request(generation_with(json!({ "texture": false }))); + let error = + validate_text_to_model_request(&request).expect_err("texture=false 不允许出现贴图档位"); + assert_eq!( + error.field().as_deref(), + Some("generation.textureQuality"), + "实际报错:{error}" + ); + + let request = text_request(generation_with(json!({ + "texture": false, + "textureQuality": null + }))); + let error = validate_text_to_model_request(&request) + .expect_err("texture=false 且 pbr 未显式 false 应被拒绝"); + assert_eq!( + error.field().as_deref(), + Some("generation.pbr"), + "实际报错:{error}" + ); + } + + #[test] + fn texture_on_defaults_texture_quality_to_standard() { + let request = text_request(generation_with(json!({ "textureQuality": null }))); + let query = validate_text_to_model_request(&request) + .unwrap_or_else(|error| panic!("texture=true 缺省贴图档位应通过校验:{error}")); + assert!( + !query.add_ons.contains(Model3dAddOn::HdTexture) + && !query.add_ons.contains(Model3dAddOn::UltraTexture), + "缺省贴图档位按 standard 计价,不给任何贴图加价" + ); + + // v2.5 拒绝任何显式 texture_quality:必填规则会让 H25 + texture=true 永远不可达。 + let request = text_request(generation_with(json!({ + "model": "v2.5-20250123", + "textureQuality": null, + "geometryQuality": null + }))); + let query = validate_text_to_model_request(&request) + .unwrap_or_else(|error| panic!("v2.5 缺省贴图档位应通过校验:{error}")); + assert!(query.texture, "v2.5 缺省贴图档位仍是带贴图请求"); + } + + #[test] + fn provider_capability_rules_are_delegated_not_duplicated() { + // generate_parts 与 texture=true 互斥由 platform-tripo 判定,这里只验证它确实生效。 + let request = text_request(generation_with(json!({ "generateParts": true }))); + let error = validate_text_to_model_request(&request) + .expect_err("generateParts=true 与贴图同现应被 provider 预检拒绝"); + assert!( + matches!(error, Model3dRequestError::Provider(_)), + "应来自 provider 预检,实际为:{error}" + ); + assert_eq!( + error.field().as_deref(), + Some("generate_parts"), + "实际报错:{error}" + ); + } + + #[test] + fn valid_request_maps_to_pricing_query() { + let request = text_request(generation_with(json!({ + "textureQuality": "detailed", + "geometryQuality": "detailed" + }))); + let query = validate_text_to_model_request(&request).expect("合法请求应通过校验"); + + assert_eq!(query.endpoint, Model3dEndpoint::TextToModel); + assert_eq!(query.model_version, Model3dModelVersion::H31); + assert!(query.texture); + assert!(query.add_ons.contains(Model3dAddOn::HdTexture)); + assert!(query.add_ons.contains(Model3dAddOn::HdGeometry)); + assert!(!query.add_ons.contains(Model3dAddOn::QuadMesh)); + } + + #[test] + fn image_request_rejects_blank_source_and_target_ids() { + let request: Model3dImageToModelRequest = serde_json::from_value(json!({ + "source": { "kind": "asset", "assetId": " " }, + "generation": image_generation_with(json!({})), + "assetFolderId": "folder-1", + "assetLabel": "木椅" + })) + .expect("测试请求应可反序列化"); + let error = validate_image_to_model_request(&request).expect_err("空白素材 ID 应被拒绝"); + assert_eq!(error.field().as_deref(), Some("source.assetId")); + + let request: Model3dImageToModelRequest = serde_json::from_value(json!({ + "source": { "kind": "resource", "resourceId": "resource-1" }, + "generation": image_generation_with(json!({})), + "assetFolderId": " " + })) + .expect("测试请求应可反序列化"); + let error = validate_image_to_model_request(&request).expect_err("空白目录 ID 应被拒绝"); + assert_eq!(error.field().as_deref(), Some("assetFolderId")); + } + + /// 平坦落点的合法组合:项目、素材库都可以给,两个一起给也合法(与其它生成工具一致); + /// 一个都不给、blank 与「素材库落点带画布占位框」都按 400 拒绝。 + #[test] + fn target_accepts_both_locators_but_requires_at_least_one() { + let cases: [(&str, Value, Option<&str>); 7] = [ + ("只给 projectId", json!({ "projectId": "project-1" }), None), + ( + "只给 assetFolderId", + json!({ "assetFolderId": "folder-1" }), + None, + ), + ( + "两个都给", + json!({ "projectId": "project-1", "assetFolderId": "folder-1" }), + None, + ), + ( + "空白 projectId", + json!({ "projectId": " " }), + Some("projectId"), + ), + ( + "空白 assetFolderId", + json!({ "assetFolderId": " " }), + Some("assetFolderId"), + ), + ("都不给", json!({}), Some("projectId")), + ( + "素材库落点带画布占位框", + json!({ + "assetFolderId": "folder-1", + "canvasCompletion": { + "dialogId": "dialog-1", + "title": "木椅", + "placeholder": { + "x": 1, "y": 2, "width": 3, "height": 4, + "originalWidth": 3, "originalHeight": 4 + } + } + }), + Some("canvasCompletion"), + ), + ]; + for (name, target_fields, expected_field) in cases { + let mut payload = json!({ "generation": generation_with(json!({})) }); + for (key, value) in target_fields.as_object().expect("落点字段是对象") { + payload[key] = value.clone(); + } + let request: Model3dTextToModelRequest = + serde_json::from_value(payload).expect("测试请求应可反序列化"); + let error = validate_text_to_model_request(&request); + match expected_field { + None => assert!(error.is_ok(), "{name} 应通过校验:{error:?}"), + Some(field) => assert_eq!( + error.expect_err(name).field().as_deref(), + Some(field), + "{name}" + ), + } + } + } + + /// 画布占位框只在项目资源落点下有意义:落素材库时给它是调用方写错了字段。 + #[test] + fn canvas_completion_is_rejected_outside_project_target() { + let request: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": generation_with(json!({})), + "assetFolderId": "folder-1", + "canvasCompletion": { + "dialogId": "dialog-1", + "title": "木椅", + "placeholder": { + "x": 10, "y": 20, "width": 100, "height": 120, + "originalWidth": 100, "originalHeight": 120 + } + } + })) + .expect("测试请求应可反序列化"); + let error = + validate_text_to_model_request(&request).expect_err("素材库落点不应接受画布占位框"); + assert_eq!(error.field().as_deref(), Some("canvasCompletion")); + } +} + +/// 落点校验:项目资源与素材库至少要给出一个。 +/// +/// 与其它生成接口同一份字段形状,两个落点可以同时给(前端画布链路两个都发); +/// 但 3D 生成更贵,因此不接受「都不给就默认落素材库」—— 落点不明时宁可在扣费前拒绝。 +fn validate_target(target: Model3dJobTarget<'_>) -> Result<(), Model3dRequestError> { + let project_id = target + .project_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let asset_folder_id = target + .asset_folder_id + .map(str::trim) + .filter(|value| !value.is_empty()); + if target.project_id.is_some() && project_id.is_none() { + return Err(Model3dRequestError::invalid( + "projectId", + "项目 ID 不能为空", + )); + } + if target.asset_folder_id.is_some() && asset_folder_id.is_none() { + return Err(Model3dRequestError::invalid( + "assetFolderId", + "素材库目录 ID 不能为空", + )); + } + // 与其它生成工具同一条口径:项目资源与素材库可以同时给(前端画布链路两个都发), + // 但一个都不给就没有结果落点——付费生成不能接受这种提交。 + if project_id.is_none() && asset_folder_id.is_none() { + return Err(Model3dRequestError::invalid( + "projectId", + "必须给出落点:projectId(项目资源)或 assetFolderId(素材库)", + )); + } + // 画布占位框是项目资源落点的回填载荷:没有项目就没有画布可以回填。 + if project_id.is_none() && target.canvas_completion.is_some() { + return Err(Model3dRequestError::invalid( + "canvasCompletion", + "只有项目资源落点才能回填画布占位框", + )); + } + Ok(()) +} diff --git a/server-rs/crates/api-server/src/tripo3d/worker.rs b/server-rs/crates/api-server/src/tripo3d/worker.rs new file mode 100644 index 000000000..54fa60c9b --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/worker.rs @@ -0,0 +1,692 @@ +//! Tripo 3D 生成的 worker 执行链路。 +//! +//! 与其它生成任务的关键差异是 **at-most-once submit**:provider 不接受幂等重放, +//! 所以只有“没有 checkpoint 的 job”才 submit,submit 成功后必须先落 checkpoint 再轮询; +//! 单次尝试(`max_attempts = 1`)失败即终态收口。 +//! +//! 注意这不是「崩溃后接着查询」的续跑能力:3D job 的 `max_attempts = 1`,租约耗尽的 job 会被 +//! 直接判终态失败、不会被重新 claim,所以不存在「重新 claim 后继续查询」的真实路径。 +//! [`existing_checkpoint`] 的分支是一条**防御性硬约束**(任何带 checkpoint 的执行都绝不会 +//! 再 submit 一次),checkpoint 的日常用途是人工对账。 + +use std::time::{Duration, Instant}; + +use axum::http::StatusCode; +use platform_tripo::{TripoProviderClient, TripoTaskHandle, TripoTaskSnapshot}; +use serde_json::json; +use shared_contracts::editor_generation::{ + editor_generation_stable_asset_id, editor_generation_stable_resource_id, +}; +use shared_contracts::model3d::common::{Model3dGenerationTargetRef, Model3dTaskStatus}; +use spacetime_client::{ + EditorAssetCreateRecordInput, EditorProjectResourceCreateRecordInput, + ExternalGenerationJobProviderCheckpointRecordInput, ExternalGenerationJobRecord, + editor_project::EditorGenerationResultPersistItemRecordInput, +}; + +use crate::{ + asset_billing::execute_billable_asset_operation_with_cost, + editor_project::{ + EditorCanvasLayoutPlan, EditorGenerationCaller, build_editor_canvas_placement_item, + current_utc_micros, editor_generation_job_result_payload_json, + editor_media_src_from_object_key, generated_canvas_layer_id, + persist_editor_generation_result_atomically, + }, + http_error::AppError, + state::AppState, +}; + +use super::{ + artifacts::{MODEL3D_MAX_ARTIFACT_BYTES, read_artifact}, + errors::map_provider_error, + image_source::resolve_image_input, + job::{MODEL3D_PROVIDER_KIND, Model3dJobRequest, parse_model3d_job_request}, + provider::{TRIPO_PROVIDER, tripo_provider_client}, + result::{build_result, job_result_payload}, + storage::{ + MODEL3D_ASSET_KIND, Model3dArtifactSlot, StoredModel3dArtifact, discard_model3d_artifact, + store_model3d_artifact, + }, +}; + +/// 轮询间隔:provider 侧单次生成通常要几分钟,秒级轮询只会白烧查询配额。 +const MODEL3D_POLL_INTERVAL: Duration = Duration::from_secs(5); + +const MODEL3D_MODEL_SLOT: &str = "model"; +const MODEL3D_PREVIEW_SLOT: &str = "preview"; + +pub(crate) async fn process_model3d_job( + state: &AppState, + caller: &EditorGenerationCaller, + job: &ExternalGenerationJobRecord, + provider_deadline: Instant, +) -> Result<(), String> { + let request = + parse_model3d_job_request(job.job_kind.as_str(), job.request_payload_json.as_str())?; + execute_billable_asset_operation_with_cost( + state, + caller.owner_user_id.as_str(), + MODEL3D_ASSET_KIND, + job.job_id.as_str(), + job.price_mud_points, + run_model3d_job(state, caller, job, request, provider_deadline), + ) + .await + // 这里返回的字符串会成为 job 的 `last_error_message`,并原样回给前端(见 + // `editor_generation_queue_state` 的 `error` 字段),所以刻意只取 provider 错误体里 + // 人类可读的 message:用户可见文案不带内部 reason / taskStatus 枚举。 + // 评审 #59 提过把结构化 details 一并写进去,按决定不改 —— 那会改掉已落库与前端展示的 + // 文案口径;排障要定位 provider 侧任务时看 job 的 provider task id(checkpoint)。 + .map_err(|error| error.body_text()) +} + +async fn run_model3d_job( + state: &AppState, + caller: &EditorGenerationCaller, + job: &ExternalGenerationJobRecord, + request: Model3dJobRequest, + provider_deadline: Instant, +) -> Result<(), AppError> { + let client = tripo_provider_client(&state.config)?; + let handle = match existing_checkpoint(job) { + Some(task_id) => TripoTaskHandle { task_id }, + None => { + // submit 是不可逆且计费的调用:预算可能在别处就先耗光了(图生 3D 解析与上传 + // 大图、任务排在其他 job 之后),这时提交上去也只会立刻被 deadline 判失败, + // 白花一次 provider 调用。只有还轮询得起才提交。 + ensure_submit_budget(provider_deadline)?; + submit_and_checkpoint(state, caller, job, &client, &request, provider_deadline).await? + } + }; + let snapshot = poll_until_terminal(&client, &handle, provider_deadline).await?; + let model = read_artifact( + client + .download_model(&snapshot, MODEL3D_MAX_ARTIFACT_BYTES) + .await + .map_err(map_provider_error)?, + )?; + let preview = read_artifact( + client + .download_rendered_image(&snapshot, MODEL3D_MAX_ARTIFACT_BYTES) + .await + .map_err(map_provider_error)?, + )?; + let (preview_width, preview_height) = preview_dimensions(preview.bytes.as_slice())?; + let stored_model = store_model3d_artifact( + state, + caller, + Model3dArtifactSlot::Model, + model.bytes, + model.content_type.as_str(), + ) + .await?; + let stored_preview = match store_model3d_artifact( + state, + caller, + Model3dArtifactSlot::Preview, + preview.bytes, + preview.content_type.as_str(), + ) + .await + { + Ok(stored) => stored, + Err(error) => { + discard_model3d_artifact(state, &stored_model).await; + return Err(error); + } + }; + // 产物已经进了 OSS,但还没登记成正式资源:这一步失败就把刚传上去的对象删掉, + // 不让按 job 命名的私有残留对象一直堆在 bucket 里。 + if let Err(error) = persist_result( + state, + caller, + job, + &request, + &stored_model, + &stored_preview, + preview_width, + preview_height, + ) + .await + { + discard_model3d_artifact(state, &stored_model).await; + discard_model3d_artifact(state, &stored_preview).await; + return Err(error); + } + Ok(()) +} + +/// checkpoint 是 provider 侧任务的唯一凭据,也是 at-most-once 的开关:有值就只能查询, +/// 绝不允许再次 submit。 +/// +/// 当前 `max_attempts = 1` 下 job 不会被重新 claim(租约耗尽直接终态失败),所以这里守住的 +/// 是一条防御性路径;它存在的意义是把「不许二次 submit」写成代码里的硬约束,而不是靠配置保证。 +fn existing_checkpoint(job: &ExternalGenerationJobRecord) -> Option { + job.provider_task_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +/// 提交前的预算检查:本次尝试已经没有轮询预算时,不发起不可逆且计费的 provider submit。 +/// +/// 调用点有两处:进入提交流程之前,以及图生 3D 在下载 / 上传源图之后、真正 submit 之前 +/// (那两步都可能耗时到把预算用光)。 +fn ensure_submit_budget(provider_deadline: Instant) -> Result<(), AppError> { + if Instant::now() >= provider_deadline { + return Err(provider_deadline_error()); + } + Ok(()) +} + +async fn submit_and_checkpoint( + state: &AppState, + caller: &EditorGenerationCaller, + job: &ExternalGenerationJobRecord, + client: &TripoProviderClient, + request: &Model3dJobRequest, + provider_deadline: Instant, +) -> Result { + let handle = match request { + Model3dJobRequest::TextToModel(request) => { + ensure_submit_budget(provider_deadline)?; + client.submit_text_to_model(&request.generation).await + } + Model3dJobRequest::ImageToModel(request) => { + // 解析源图要先下载站内图片、再上传给 provider,这一步可能把剩下的预算用光。 + // 提交不可撤销且计费,所以真正发起之前再确认一次:宁可本次尝试判失败, + // 也不要提交上去紧接着被 deadline 判失败,白花一次外部调用。 + let input = resolve_image_input( + state, + client, + caller.owner_user_id.as_str(), + &request.source, + ) + .await?; + ensure_submit_budget(provider_deadline)?; + client + .submit_image_to_model(&input, &request.generation) + .await + } + } + .map_err(map_provider_error)?; + // submit 已经消耗了 provider 额度。checkpoint 写不进去时不能再退回可重试队列, + // 否则下一次 claim 会再次 submit;这里直接失败并保留“provider 已消耗”的对账信息。 + state + .spacetime_client() + .set_external_generation_job_provider_checkpoint( + ExternalGenerationJobProviderCheckpointRecordInput { + job_id: job.job_id.clone(), + worker_id: worker_id(caller)?.to_string(), + lease_token: lease_token(job)?.to_string(), + provider_kind: MODEL3D_PROVIDER_KIND.to_string(), + provider_task_id: handle.task_id.clone(), + }, + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-checkpoint-write-failed", + "message": format!( + "3D 生成任务已提交但 checkpoint 写入失败,本次尝试终态失败,等待人工对账:{error}" + ), + })) + })?; + caller.report_processing_phase(state).await?; + Ok(handle) +} + +async fn poll_until_terminal( + client: &TripoProviderClient, + handle: &TripoTaskHandle, + provider_deadline: Instant, +) -> Result { + loop { + if Instant::now() >= provider_deadline { + return Err(provider_deadline_error()); + } + let snapshot = match client.get_task(handle).await { + Ok(snapshot) => snapshot, + // 查询本身已经带过一轮退避重试;仍是瞬时故障时继续按轮询间隔查到预算用尽, + // 不因为一次 provider 抖动就作废这笔已经提交、不能重来的任务。 + Err(error) if error.is_retryable() => { + // 包括 provider 报的 `unknown` 状态:继续查到截止时间,日志留痕便于区分 + // 「一直是 unknown」和「一次网络抖动」。 + tracing::warn!(error = %error, "3D 生成任务状态查询遇到可重试错误,继续轮询"); + let remaining = provider_deadline.saturating_duration_since(Instant::now()); + tokio::time::sleep(MODEL3D_POLL_INTERVAL.min(remaining)).await; + continue; + } + Err(error) => return Err(map_provider_error(error)), + }; + match snapshot.status { + Model3dTaskStatus::Completed => return Ok(snapshot), + Model3dTaskStatus::Queued | Model3dTaskStatus::Running => {} + Model3dTaskStatus::Failed + | Model3dTaskStatus::Cancelled + | Model3dTaskStatus::Expired => return Err(task_not_completed(&snapshot)), + } + let remaining = provider_deadline.saturating_duration_since(Instant::now()); + tokio::time::sleep(MODEL3D_POLL_INTERVAL.min(remaining)).await; + } +} + +#[allow(clippy::too_many_arguments)] +async fn persist_result( + state: &AppState, + caller: &EditorGenerationCaller, + job: &ExternalGenerationJobRecord, + request: &Model3dJobRequest, + // 取引用而不是拿走所有权:登记失败时调用方还要按对象键把刚传上去的产物删掉。 + model: &StoredModel3dArtifact, + preview: &StoredModel3dArtifact, + preview_width: u32, + preview_height: u32, +) -> Result<(), AppError> { + let operation = caller.operation.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": TRIPO_PROVIDER, + "message": "落库 3D 生成结果时缺少稳定 operation 上下文。", + })) + })?; + let owner_user_id = caller.owner_user_id.as_str(); + // 3D 资源在画布与素材库里按预览图渲染,模型本体留在 objectKey,因此两者互不覆盖。 + let preview_src = editor_media_src_from_object_key(preview.object_key.as_str()); + let completed_at_micros = current_utc_micros(); + let audit_prompt = request.audit_prompt(); + // 落库值必须是契约 wire 取值;取不到就按服务端问题失败,不写入 Debug 兜底名。 + let model_version = request.model_version().map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": TRIPO_PROVIDER, + "message": format!("落库 3D 生成结果时:{error}。"), + })) + })?; + // 落点与其它生成接口同形的平坦字段:校验层保证「至少一个」,两个都给时两条落点都写。 + let target = request.target(); + let project_id = target + .project_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let folder_id = target + .asset_folder_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let label = target + .asset_label + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| MODEL3D_DEFAULT_ASSET_LABEL.to_string()); + let canvas_completion = target.canvas_completion.cloned(); + let canvas_project_id = project_id.clone(); + let resource_id = editor_generation_stable_resource_id( + owner_user_id, + operation.operation_kind.as_str(), + job.job_id.as_str(), + MODEL3D_MODEL_SLOT, + ); + let asset_id = editor_generation_stable_asset_id( + owner_user_id, + operation.operation_kind.as_str(), + job.job_id.as_str(), + MODEL3D_MODEL_SLOT, + ); + let has_resource = project_id.is_some(); + let has_asset = folder_id.is_some(); + // 派生来源只来自请求:资源 / 素材记录的 `source_resource_id` 记的是「从哪个画布资源 + // 派生出来的」,与本次新生成的输出资源 id 无关(图生 3D 的 source,文生 3D 没有)。 + let source_resource_id = request.source_resource_id(); + let project_resource = project_id.map(|project_id| EditorProjectResourceCreateRecordInput { + resource_id: resource_id.clone(), + project_id, + owner_user_id: owner_user_id.to_string(), + asset_object_id: Some(model.asset_object.asset_object_id.clone()), + image_src: preview_src.clone(), + object_key: Some(model.object_key.clone()), + width: preview_width, + height: preview_height, + source_type: "generated".to_string(), + prompt: Some(audit_prompt.clone()), + actual_prompt: None, + model: Some(model_version.to_string()), + provider: Some(MODEL3D_PROVIDER_KIND.to_string()), + // 平台自己的 operation ID;provider task ID 只留在 checkpoint,不落业务行。 + task_id: Some(job.job_id.clone()), + source_resource_id: source_resource_id.clone(), + asset_kind: Some(MODEL3D_ASSET_KIND.to_string()), + generation_inputs_json: None, + updated_at_micros: completed_at_micros, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, + }); + let asset_id_for_result = asset_id.clone(); + let asset = folder_id.map(|folder_id| EditorAssetCreateRecordInput { + asset_id, + owner_user_id: owner_user_id.to_string(), + folder_id, + label, + asset_object_id: Some(model.asset_object.asset_object_id.clone()), + image_src: preview_src.clone(), + object_key: Some(model.object_key.clone()), + width: preview_width, + height: preview_height, + source_type: "generated".to_string(), + prompt: Some(audit_prompt), + actual_prompt: None, + model: Some(model_version.to_string()), + provider: Some(MODEL3D_PROVIDER_KIND.to_string()), + task_id: Some(job.job_id.clone()), + asset_kind: Some(MODEL3D_ASSET_KIND.to_string()), + generation_inputs_json: None, + // 素材行与画布资源行是同一份产物的两种落点:有本项目的资源行时指向它(与其它 + // 生成工具一致),只落素材库时退回请求里的来源资源,不把溯源信息丢掉。 + source_resource_id: has_resource + .then(|| resource_id.clone()) + .or_else(|| source_resource_id.clone()), + generation_cost_mud_points: job.price_mud_points, + now_micros: completed_at_micros, + thumbnail_src: Some(preview_src.clone()), + group_task_id: None, + group_task_expected_asset_count: None, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, + }); + // 结果先构造:它引用的是对象元数据,与随后要搬进 item 的 asset_object 是两份值。 + let result = build_result( + request.job_kind(), + target_ref( + has_resource.then(|| resource_id.clone()), + has_asset.then_some(asset_id_for_result), + ), + model, + preview, + ); + // 预览图单独登记成 asset_object:它没有自己的资源行,但必须属于当前账号, + // 否则后续按 objectKey 解析归属时会认为它未登记。 + let items = vec![ + EditorGenerationResultPersistItemRecordInput { + slot: MODEL3D_MODEL_SLOT.to_string(), + asset_object: Some(model.asset_object.clone()), + project_resource, + asset, + binding: None, + }, + EditorGenerationResultPersistItemRecordInput { + slot: MODEL3D_PREVIEW_SLOT.to_string(), + asset_object: Some(preview.asset_object.clone()), + project_resource: None, + asset: None, + binding: None, + }, + ]; + let canvas_layout_plan = match canvas_completion.as_ref() { + Some(completion) if has_resource => { + let layer_id = generated_canvas_layer_id(resource_id.as_str()); + EditorCanvasLayoutPlan::generation( + owner_user_id, + canvas_project_id.as_deref(), + Some(completion), + vec![build_editor_canvas_placement_item( + completion, + resource_id.as_str(), + preview_width, + preview_height, + layer_id.clone(), + )], + Some(layer_id), + ) + } + _ => EditorCanvasLayoutPlan::None, + }; + let job_result_payload_json = + editor_generation_job_result_payload_json(caller, job_result_payload(result)?); + persist_editor_generation_result_atomically( + state, + caller, + items, + canvas_layout_plan, + job_result_payload_json, + completed_at_micros, + ) + .await?; + Ok(()) +} + +/// 结果里的落点引用:写了项目资源行就带 `resourceId`,写了素材行就带 `assetId`; +/// 两个落点都写(前端画布链路的常态)时两个都带 —— 与请求落点同形的平坦字段。 +fn target_ref(resource_id: Option, asset_id: Option) -> Model3dGenerationTargetRef { + Model3dGenerationTargetRef { + resource_id, + asset_id, + } +} + +fn preview_dimensions(bytes: &[u8]) -> Result<(u32, u32), AppError> { + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(|error| output_schema_error(format!("预览图格式无法识别:{error}")))?; + let (width, height) = reader + .into_dimensions() + .map_err(|error| output_schema_error(format!("预览图尺寸无法读取:{error}")))?; + if width == 0 || height == 0 { + return Err(output_schema_error("预览图尺寸为零".to_string())); + } + Ok((width, height)) +} + +pub(crate) const MODEL3D_DEFAULT_ASSET_LABEL: &str = "3D 模型"; + +fn worker_id(caller: &EditorGenerationCaller) -> Result<&str, AppError> { + caller + .operation + .as_ref() + .and_then(|operation| operation.worker_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| missing_operation_context("worker_id")) +} + +fn lease_token(job: &ExternalGenerationJobRecord) -> Result<&str, AppError> { + job.lease_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| missing_operation_context("lease_token")) +} + +fn missing_operation_context(field: &str) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": TRIPO_PROVIDER, + "message": format!("3D 生成任务缺少 {field},无法写入 provider checkpoint。"), + })) +} + +fn provider_deadline_error() -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "tripo-provider-timeout", + "message": "3D 生成在本次 worker 预算内没有完成,本次尝试终态失败。", + })) +} + +fn task_not_completed(snapshot: &TripoTaskSnapshot) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "tripo-task-failed", + "message": "3D 生成任务未成功完成。", + "taskStatus": snapshot.status, + "providerCode": snapshot.failure.as_ref().and_then(|failure| failure.code), + "providerMessage": snapshot.failure.as_ref().and_then(|failure| failure.message.clone()), + })) +} + +fn output_schema_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "tripo-output-schema", + "message": message, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tripo3d::job::Model3dJobKind; + use module_assets::{AssetObjectAccessPolicy, AssetObjectUpsertInput}; + + /// 预算已经耗尽时不能再提交:submit 不可逆且计费,提交上去也只会立刻被 deadline 判失败。 + #[test] + fn submit_is_rejected_once_the_polling_budget_is_gone() { + let expired = ensure_submit_budget(Instant::now()) + .expect_err("预算已耗尽的尝试不能再发起 provider submit"); + assert_eq!(expired.status_code(), StatusCode::BAD_GATEWAY); + assert!( + ensure_submit_budget(Instant::now() + Duration::from_secs(1)).is_ok(), + "还有预算时必须放行" + ); + } + + /// at-most-once submit 完全依赖这条判定:checkpoint 有值就只能续跑查询。 + /// 空白值必须等同于“没有 checkpoint”,否则一次空写入会把 job 永久锁死。 + #[test] + fn checkpoint_is_only_recognized_when_provider_task_id_is_present() { + let mut job = job_fixture(); + assert_eq!(existing_checkpoint(&job), None); + + job.provider_task_id = Some(" ".to_string()); + assert_eq!(existing_checkpoint(&job), None); + + job.provider_task_id = Some(" task-123 ".to_string()); + assert_eq!(existing_checkpoint(&job).as_deref(), Some("task-123")); + } + + /// 完成结果按端点严格区分,且只带正式对象元数据:provider task ID、SDK 类型与 + /// 带签名的临时地址都不允许出现在返回给调用方的结果里。 + #[test] + fn completed_result_is_strict_per_endpoint_and_free_of_provider_facts() { + let model = stored_artifact("editor/model3d/task-1/model.glb", "model/gltf-binary", 4096); + let preview = stored_artifact("editor/model3d/task-1/preview.webp", "image/webp", 2048); + + for (kind, expected_kind) in [ + (Model3dJobKind::TextToModel, "textToModel"), + (Model3dJobKind::ImageToModel, "imageToModel"), + ] { + let result = build_result( + kind, + Model3dGenerationTargetRef { + resource_id: Some("resource-1".to_string()), + asset_id: None, + }, + &model, + &preview, + ); + let payload = job_result_payload(result).expect("完成结果应可序列化"); + let result = &payload["result"]; + + assert_eq!(result["kind"], json!(expected_kind)); + assert_eq!(result["target"]["resourceId"], json!("resource-1")); + // 只落项目资源时不得凭空带出素材 ID:结果引用与请求落点同形。 + assert_eq!(result["target"].get("assetId"), None); + assert_eq!( + result["model"]["objectKey"], + json!("editor/model3d/task-1/model.glb") + ); + assert_eq!(result["model"]["contentLength"], json!(4096)); + assert_eq!(result["preview"]["contentLength"], json!(2048)); + + let serialized = payload.to_string(); + for forbidden in ["provider", "taskId", "providerTaskId", "https://"] { + assert!( + !serialized.contains(forbidden), + "完成结果不得携带 {forbidden}:{serialized}" + ); + } + } + } + + /// 预览图尺寸是资源行的宽高来源,读不出来就宁可失败退款,也不写占位尺寸。 + #[test] + fn preview_dimensions_require_a_real_decodable_image() { + let image = image::RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 255])); + let mut bytes = Vec::new(); + image + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("测试用 PNG 应可编码"); + + assert_eq!( + preview_dimensions(bytes.as_slice()).expect("应读出尺寸"), + (3, 2) + ); + assert!(preview_dimensions(b"not an image").is_err()); + } + + fn stored_artifact( + object_key: &str, + content_type: &str, + content_length: u64, + ) -> StoredModel3dArtifact { + StoredModel3dArtifact { + object_key: object_key.to_string(), + content_type: content_type.to_string(), + content_length, + sha256: "0".repeat(64), + asset_object: AssetObjectUpsertInput { + asset_object_id: format!("asset-object-{object_key}"), + bucket: "genarrative-test".to_string(), + object_key: object_key.to_string(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some(content_type.to_string()), + content_length, + content_hash: Some("0".repeat(64)), + version: 1, + source_job_id: Some("extgen-1".to_string()), + owner_user_id: Some("user-1".to_string()), + profile_id: None, + entity_id: Some("extgen-1".to_string()), + asset_kind: MODEL3D_ASSET_KIND.to_string(), + updated_at_micros: 1_780_444_800_000_000, + }, + } + } + + fn job_fixture() -> ExternalGenerationJobRecord { + ExternalGenerationJobRecord { + job_id: "extgen-1".to_string(), + dedupe_key: "model3d-generation:user-1:model3d_text_to_model:issue-1".to_string(), + job_kind: "model3d_text_to_model".to_string(), + owner_user_id: "user-1".to_string(), + source_module: "editor-canvas".to_string(), + source_entity_id: "project-1".to_string(), + request_label: "文生 3D 模型".to_string(), + request_payload_json: "{}".to_string(), + status: "running".to_string(), + attempt: 1, + max_attempts: 1, + last_error_message: None, + worker_id: Some("worker-a".to_string()), + lease_expires_at: Some("2026-09-21T00:00:00Z".to_string()), + available_at: "2026-09-21T00:00:00Z".to_string(), + result_payload_json: None, + created_at: "2026-09-21T00:00:00Z".to_string(), + started_at: Some("2026-09-21T00:00:00Z".to_string()), + completed_at: None, + updated_at: "2026-09-21T00:00:00Z".to_string(), + updated_at_micros: 1_780_444_800_000_000, + lease_token: Some("lease-1".to_string()), + price_mud_points: 20, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, + phase: Some("generating".to_string()), + provider_kind: None, + provider_task_id: None, + } + } +} 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 0a77eea5b..ca5aa59cf 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,6 +11,7 @@ use platform_oss::LegacyAssetPrefix; use serde_json::Value; use serde_json::json; use shared_contracts::assets; +use shared_contracts::editor_canvas; use shared_contracts::editor_generation::{ editor_generation_request_fingerprint, editor_generation_stable_asset_binding_id, editor_generation_stable_asset_object_id, @@ -243,7 +244,7 @@ pub async fn generate_editor_sound_effect( return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), + queue_state: editor_generation_queue_state(&queue_job), }, )); } @@ -460,7 +461,7 @@ struct ProductionSoundEffectWorkerStages<'a> { operation_id: &'a str, target: AudioAssetBindingTarget, project_id: Option, - canvas_completion: Option, + canvas_completion: Option, asset_folder_id: Option, asset_label: Option, normalized: &'a NormalizedEditorSoundEffectRequest, @@ -846,7 +847,7 @@ pub async fn generate_editor_background_music( return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), + queue_state: editor_generation_queue_state(&queue_job), }, )); } @@ -1170,7 +1171,7 @@ struct PreparedEditorAudioGenerationInput { prompt: String, model: String, generation_inputs: Option, - canvas_completion: Option, + canvas_completion: Option, price_mud_points: u32, generated: PreparedGeneratedEditorAudio, } diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 4cce2d31a..4f99073ae 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -323,6 +323,7 @@ struct PreparedHeadObject { target_url: reqwest::Url, } +#[derive(Clone)] struct PreparedPutObject { object_key: String, target_url: reqwest::Url, @@ -1401,6 +1402,46 @@ impl OssClient { result } + /// 派生对象(按前缀拼对象键)的受控重试 PUT。 + /// + /// 与 `put_internal_object_with_retry` 用同一套「可判定才重试」规则与退避:传输 / 超时 / + /// 408 / 429 / 5xx 才重试,确定性 4xx 直接失败。body 只准备一次(引用计数字节),每次 + /// attempt 复用同一份,不做重复编码。 + /// + /// `max_attempts` 至少为 1,`retry_delays_ms` 至少提供 `max_attempts - 1` 个退避值; + /// 参数不满足时按配置错误失败关闭,不静默降级成单次请求。 + pub async fn put_object_with_transient_retry( + &self, + client: &reqwest::Client, + request: OssPutObjectRequest, + max_attempts: usize, + retry_delays_ms: &[u64], + ) -> Result { + if max_attempts == 0 { + return Err(OssError::InvalidConfig( + "派生对象 PUT 重试次数至少为 1".to_string(), + )); + } + if retry_delays_ms.len() < max_attempts.saturating_sub(1) { + return Err(OssError::InvalidConfig( + "派生对象 PUT 重试缺少退避配置".to_string(), + )); + } + let prepared = self.prepare_put_object(request)?; + let object_key = prepared.object_key.clone(); + run_internal_put_with_retry(max_attempts, retry_delays_ms, &object_key, move || { + // 每次 attempt 复制一份准备结果(body 是引用计数的字节,不复制内容), + // 让 future 拥有它自己的那份,避免把借用带出闭包。 + let prepared = prepared.clone(); + async move { + self.put_object_once(client, &prepared) + .await + .map(|(response, _status)| response) + } + }) + .await + } + /// 角色动画帧专用的可重试 PUT。调用方传入进程级并发限制器,单次网络 attempt /// 独占一个 permit,退避等待期间不会占用 permit。 pub async fn put_object_with_retry( @@ -3473,6 +3514,84 @@ mod tests { assert_eq!(attempts.load(Ordering::SeqCst), 2); } + #[tokio::test] + async fn derived_put_retry_fails_closed_on_bad_retry_config() { + let client = build_client(); + let http = reqwest::Client::new(); + let request = OssPutObjectRequest { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec!["editor".to_string(), "model3d".to_string()], + file_name: "model.glb".to_string(), + content_type: Some("model/gltf-binary".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::new(), + body: MOCK_PUT_BODY.to_vec(), + }; + + // 次数为 0 或退避不够都必须按配置错误失败,不能静默降级成单次请求。 + let error = client + .put_object_with_transient_retry(&http, request.clone(), 0, &[]) + .await + .expect_err("重试次数为 0 必须失败关闭"); + assert!(matches!(error, OssError::InvalidConfig(_)), "{error:?}"); + + let error = client + .put_object_with_transient_retry(&http, request, 3, &[1]) + .await + .expect_err("退避配置不足必须失败关闭"); + assert!(matches!(error, OssError::InvalidConfig(_)), "{error:?}"); + } + + /// 端点的域名一定解析不出来(`.invalid` 是 RFC 2606 保留给「一定不存在」的顶级域, + /// 前半段保留 `oss-` 形状以便通过 region 解析),于是每次 attempt 都是可重试的 + /// 连接失败,而且不会真的打到任何服务端。 + fn build_unreachable_client() -> OssClient { + OssClient::new( + OssConfig::new( + "genarrative-assets".to_string(), + "oss-cn-shanghai.invalid".to_string(), + "test-access-key-id".to_string(), + "test-access-key-secret".to_string(), + DEFAULT_READ_EXPIRE_SECONDS, + DEFAULT_POST_EXPIRE_SECONDS, + DEFAULT_POST_MAX_SIZE_BYTES, + DEFAULT_SUCCESS_ACTION_STATUS, + ) + .expect("OSS config should be valid"), + ) + } + + #[tokio::test] + async fn derived_put_retry_repeats_transient_transport_failures() { + let client = build_unreachable_client(); + let http = reqwest::Client::new(); + let started_at = Instant::now(); + let error = client + .put_object_with_transient_retry( + &http, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec!["editor".to_string(), "model3d".to_string()], + file_name: "model.glb".to_string(), + content_type: Some("model/gltf-binary".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::new(), + body: MOCK_PUT_BODY.to_vec(), + }, + 3, + &[50, 50], + ) + .await + .expect_err("连不上的域名必须失败"); + assert!(oss_error_is_retryable(&error), "{error:?}"); + // 三次尝试之间的两段退避必须真的等过:只打一次请求不会花这么久。 + assert!( + started_at.elapsed() >= std::time::Duration::from_millis(100), + "退避没有生效:{:?}", + started_at.elapsed() + ); + } + #[test] fn structured_log_labels_are_stable() { assert_eq!( diff --git a/server-rs/crates/platform-tripo/Cargo.toml b/server-rs/crates/platform-tripo/Cargo.toml new file mode 100644 index 000000000..a8fe432a5 --- /dev/null +++ b/server-rs/crates/platform-tripo/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "platform-tripo" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +shared-contracts = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tripo3d-sdk = { workspace = true } +bytes = { workspace = true } +reqwest = { workspace = true, features = ["rustls-tls", "stream"] } +tokio = { workspace = true, features = ["time"] } +url = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "rt-multi-thread"] } diff --git a/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs b/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs new file mode 100644 index 000000000..7bbee3a2e --- /dev/null +++ b/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs @@ -0,0 +1,247 @@ +//! Submit one request through each supported 3D generation endpoint. +//! +//! Configuration is intentionally environment-only: +//! +//! ```text +//! TRIPO_API_KEY=... TRIPO_BASE_URL=https://openapi.tripo3d.com/v3 \ +//! cargo run --locked -p platform-tripo --example tripo_generation_smoke \ +//! --manifest-path server-rs/Cargo.toml +//! ``` +//! +//! The example waits for each submitted task to finish, prints its mapped +//! output, and writes the downloaded model into +//! `/tripo-smoke-artifacts` so a smoke run never leaves +//! tens-of-MB models inside the repository tree. The SDK poller is used only +//! here and is not exposed by `platform-tripo`. + +use std::{env, path::PathBuf, time::Duration}; + +use platform_tripo::{TripoImageInput, TripoProviderClient}; +use shared_contracts::model3d::{ + common::{Model3dModelVersion, Model3dTaskStatus, Model3dTextureQuality}, + image_to_model::Model3dImageToModelParams, + multiview_to_model::{ + Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput, + }, + text_to_model::Model3dTextToModelParams, +}; +use tokio::{fs::File, io::AsyncWriteExt}; +use tripo3d_sdk::{ClientOptions, TripoClient, WaitOptions}; + +const SAMPLE_IMAGE_URL: &str = "https://www.rustacean.net/assets/rustacean-flat-happy.png"; + +/// 冒烟脚本单次允许的产物体积上限,与 api-server 的口径一致(512 MiB)。 +const SMOKE_MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024; + +/// 产物落盘目录:固定在 cargo 的 target 目录下(`server-rs/target`), +/// 那里本来就不入库,示例也就不会在仓库里留下几十 MB 的临时模型。 +fn artifact_output_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target") + .join("tripo-smoke-artifacts") +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let api_key = required_env("TRIPO_API_KEY")?; + let base_url = required_env("TRIPO_BASE_URL")?; + let sdk_client = TripoClient::new(ClientOptions { + api_key: Some(api_key.clone()), + base_url: Some(base_url.clone()), + timeout: Some(Duration::from_secs(60)), + retries: Some(2), + user_agent: Some("genarrative-tripo-smoke/1".to_string()), + })?; + let client = TripoProviderClient::new(platform_tripo::TripoSettings::new( + api_key, + base_url, + Duration::from_secs(60), + 2, + "genarrative-tripo-smoke/1".to_string(), + )?)?; + + let text = client + .submit_text_to_model(&Model3dTextToModelParams { + prompt: "a low-poly wooden treasure chest".to_string(), + model: Model3dModelVersion::H31, + negative_prompt: None, + image_seed: None, + model_seed: None, + texture_seed: None, + texture: Some(true), + pbr: Some(true), + texture_quality: Some(Model3dTextureQuality::Standard), + texture_version: None, + delight: None, + geometry_quality: None, + face_limit: None, + auto_size: None, + quad: None, + smart_low_poly: None, + generate_parts: None, + compress: None, + export_uv: None, + export_orientation: None, + }) + .await?; + println!("text_to_model task_id={}", text.task_id); + + let image = client + .submit_image_to_model( + &TripoImageInput::PublicUrl(SAMPLE_IMAGE_URL.to_string()), + &Model3dImageToModelParams { + model: Model3dModelVersion::H31, + enable_image_autofix: None, + model_seed: None, + texture_seed: None, + texture: Some(true), + pbr: Some(true), + texture_quality: Some(Model3dTextureQuality::Standard), + texture_version: None, + delight: None, + texture_alignment: None, + geometry_quality: None, + face_limit: None, + auto_size: None, + orientation: None, + quad: None, + smart_low_poly: None, + generate_parts: None, + compress: None, + export_uv: None, + export_orientation: None, + }, + ) + .await?; + println!("image_to_model task_id={}", image.task_id); + + let multiview = client + .submit_multiview_to_model(&Model3dMultiviewToModelRequest { + inputs: Model3dMultiviewInputs::Views { + front: Model3dViewInput::Url { + url: SAMPLE_IMAGE_URL.to_string(), + }, + left: Some(Model3dViewInput::Url { + url: SAMPLE_IMAGE_URL.to_string(), + }), + back: None, + right: None, + }, + model: Model3dModelVersion::H31, + model_seed: None, + texture_seed: None, + texture: Some(true), + pbr: Some(true), + texture_quality: Some(Model3dTextureQuality::Standard), + texture_version: None, + delight: None, + geometry_quality: None, + texture_alignment: None, + face_limit: None, + auto_size: None, + orientation: None, + quad: None, + smart_low_poly: None, + generate_parts: None, + compress: None, + export_uv: None, + export_orientation: None, + }) + .await?; + println!("multiview_to_model task_id={}", multiview.task_id); + + let output_dir = artifact_output_dir(); + tokio::fs::create_dir_all(&output_dir).await?; + println!("artifact output dir={}", output_dir.display()); + + for (name, handle) in [ + ("text_to_model", text), + ("image_to_model", image), + ("multiview_to_model", multiview), + ] { + // The SDK poller is used only by this smoke example. It is not part + // of the platform-tripo public API. + // + // 每个端点单独报错并继续:这个示例的目的是一次跑完三个端点, + // 某个端点超时 / 传输失败不应把剩下的端点一起跳过。 + if let Err(error) = sdk_client + .wait_for_task( + &handle.task_id, + WaitOptions { + timeout: Some(Duration::from_secs(30 * 60)), + // Non-success terminal states are reported below by status + // so the remaining endpoints are still exercised. + throw_on_failure: false, + ..WaitOptions::default() + }, + ) + .await + { + eprintln!("{name} skipped: wait failed: {error}"); + continue; + } + + let snapshot = match client.get_task(&handle).await { + Ok(snapshot) => snapshot, + Err(error) => { + eprintln!("{name} skipped: task lookup failed: {error}"); + continue; + } + }; + println!( + "{name} status={:?} progress={:?} output={:?}", + snapshot.status, snapshot.progress, snapshot.output + ); + + if snapshot.status != Model3dTaskStatus::Completed { + eprintln!( + "{name} skipped download: status={:?} failure={:?}", + snapshot.status, snapshot.failure + ); + continue; + } + + let artifact = match client + .download_model(&snapshot, SMOKE_MAX_ARTIFACT_BYTES) + .await + { + Ok(artifact) => artifact, + Err(error) => { + eprintln!("{name} skipped: artifact download failed: {error}"); + continue; + } + }; + let filename = artifact.filename(name); + let path = output_dir.join(&filename); + let mut file = File::create(&path).await?; + file.write_all(&artifact.bytes).await?; + println!( + "{name} artifact={} bytes={} url={}", + path.display(), + artifact.bytes.len(), + artifact.url.redacted() + ); + } + + Ok(()) +} + +fn required_env(name: &str) -> Result { + let value = + env::var(name).map_err(|_| format!("missing required environment variable {name}"))?; + if value.trim().is_empty() { + return Err(format!("environment variable {name} must not be blank")); + } + Ok(value) +} + +// Example result shape from a real smoke run (signed auth_key values omitted): +// text_to_model task_id=ccbb228c-6a0e-46b9-8fcc-46fefbb38039 +// image_to_model task_id=69a8b158-23a4-4856-8996-58c070b4fa75 +// multiview_to_model task_id=7191545c-5007-4b24-aa69-a50feaf7054c +// text_to_model status=Completed progress=Some(100) output=Some(TextToModel(TripoTextToModelResult { model_url: TripoUrl("https://openapi.cdn.tripo3d.com/.../tripo_pbr_model.glb?"), rendered_image_url: TripoUrl("https://openapi.cdn.tripo3d.com/.../legacy_mesh.webp?"), generated_image_url: TripoUrl("https://openapi.cdn.tripo3d.com/.../text2image.jpeg?") })) +// image_to_model status=Completed progress=Some(100) output=Some(ImageToModel(TripoImageToModelResult { model_url: TripoUrl("https://openapi.cdn.tripo3d.com/.../tripo_pbr_model.glb?"), rendered_image_url: TripoUrl("https://openapi.cdn.tripo3d.com/.../legacy_mesh.webp?") })) +// multiview_to_model status=Completed progress=Some(100) output=Some(MultiviewToModel(TripoMultiviewToModelResult { model_url: TripoUrl("https://openapi.cdn.tripo3d.com/.../tripo_pbr_model.glb?"), rendered_image_url: TripoUrl("https://openapi.cdn.tripo3d.com/.../legacy_mesh.webp?") })) +// text_to_model artifact=text_to_model.glb bytes=41443076 url=https://openapi.cdn.tripo3d.com/.../tripo_pbr_model.glb? +// image_to_model artifact=image_to_model.glb bytes=42363084 url=https://openapi.cdn.tripo3d.com/.../tripo_pbr_model.glb? +// multiview_to_model artifact=multiview_to_model.glb bytes=42724784 url=https://openapi.cdn.tripo3d.com/.../tripo_pbr_model.glb? diff --git a/server-rs/crates/platform-tripo/src/common/client.rs b/server-rs/crates/platform-tripo/src/common/client.rs new file mode 100644 index 000000000..f34be5632 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/client.rs @@ -0,0 +1,637 @@ +use std::time::Duration; + +use shared_contracts::model3d::common::Model3dTaskStatus; +use tripo3d_sdk::TripoClient; + +use super::{ + TripoArtifactBytes, TripoDownloadedArtifact, TripoError, TripoSettings, TripoTaskHandle, + TripoTaskOutput, TripoTaskSnapshot, TripoUrl, http_status_is_transient, map_task, + transport_error_is_transient, validate_task_id, +}; + +pub struct TripoProviderClient { + pub(crate) client: TripoClient, + artifact_client: reqwest::Client, + /// 瞬时故障的重试次数:产物下载与任务状态查询共用同一份配置。 + retries: u32, + /// 产物下载的「无数据推进」预算:连接、首字节和之后每个数据块都必须在它之内有进展, + /// 下载总时长不设限。 + artifact_stall_timeout: Duration, +} + +impl TripoProviderClient { + pub fn new(settings: TripoSettings) -> Result { + // 字段是公开的,结构体字面量能绕过 `TripoSettings::new`;真正发起请求的这一端 + // 再校验一次,零超时 / 空白凭据就不会拖到第一次产物下载才以误导性错误暴露。 + settings.validate()?; + let artifact_client = reqwest::Client::builder() + .user_agent(settings.user_agent.clone()) + // 产物是几十 MB 的流,设总超时会把「还在正常下载」判成失败,也会让重试从头重传; + // 这里只限制「多久没有进展」:连接用 connect_timeout,传输过程用 read_timeout。 + .connect_timeout(settings.request_timeout) + .read_timeout(settings.request_timeout) + // 产物地址由 provider 响应给出:不跟随跳转,避免被重定向到内网 / 云元数据地址。 + // 真的需要跳转的 CDN 会以非成功状态暴露出来,而不是被服务端静默代发请求。 + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| TripoError::Request { + message: format!("failed to build artifact download client: {error}"), + status: None, + // 构建期失败是配置 / 环境问题:重试不会变好。 + retryable: false, + })?; + Ok(Self { + client: TripoClient::new(settings.client_options()).map_err(TripoError::from)?, + artifact_client, + retries: settings.retries, + artifact_stall_timeout: settings.request_timeout, + }) + } + + /// 查询任务状态。 + /// + /// 单次查询失败不直接判死:生成本来就要跑几分钟,一次连接抖动不该作废一笔不可重来的 + /// 计费任务。瞬时故障(连接抖动 / 超时、408、429、5xx)按 [`retry_backoff`] 退避重试, + /// 与产物下载同一口径;确定性失败(任务 id 非法、响应结构不符)立刻返回,等在这里也是 + /// 同一个结果。 + pub async fn get_task( + &self, + handle: &TripoTaskHandle, + ) -> Result { + validate_task_id(&handle.task_id)?; + let total_attempts = self.retries.saturating_add(1); + let mut attempt = 1; + loop { + let result = match self.client.get_task(&handle.task_id).await { + Ok(task) => map_task(task), + Err(error) => Err(TripoError::from(error)), + }; + match result { + Ok(snapshot) => return Ok(snapshot), + Err(error) if attempt < total_attempts && error.is_retryable() => { + tokio::time::sleep(retry_backoff(attempt)).await; + attempt += 1; + } + Err(error) => return Err(error), + } + } + } + + /// 下载完整的模型产物(响应头 + 整个 body);`max_bytes` 是单次读取的硬上限。 + pub async fn download_model( + &self, + task: &TripoTaskSnapshot, + max_bytes: u64, + ) -> Result { + let output = completed_output(task)?; + // TODO SDK upstream: expose a streaming artifact API; then replace this + // provider-side reqwest client with the SDK stream and remove the duplicate downloader. + self.download_artifact(&task.handle.task_id, output.model_url(), max_bytes) + .await + } + + /// 下载 provider 渲染出的预览图。 + /// + /// 预览图和模型是同一次 task 结果里的两个产物,共用同一条产物下载链路; + /// 命名按“预览图”而不是“渲染图”,避免调用方把它当成模型对象。 + pub async fn download_rendered_image( + &self, + task: &TripoTaskSnapshot, + max_bytes: u64, + ) -> Result { + let output = completed_output(task)?; + self.download_artifact(&task.handle.task_id, output.rendered_image_url(), max_bytes) + .await + } + + /// 下载一个产物并读完它的 body。 + /// + /// 重试必须包住整个 body:`download_artifact_headers` 拿到响应头就返回,真正的 + /// 几十 MB 字节在 `next_chunk` 里才传输;只重试「拿到头」这一步等于没覆盖传输阶段, + /// 一次 CDN 抖动就会毁掉一次已经扣费、provider 任务也已经跑完的生成。 + async fn download_artifact( + &self, + task_id: &str, + url: &TripoUrl, + max_bytes: u64, + ) -> Result { + let total_attempts = self.retries.saturating_add(1); + let mut attempt = 1; + + loop { + // 每次尝试都从「取响应头」开始:body 已经读到的字节随连接一起丢掉, + // 重下的是完整产物,不做断点续传(签名地址会过期,续传也只是徒增状态)。 + let result = match self.download_artifact_headers(task_id, url).await { + Ok(headers) => headers.read_all(max_bytes).await, + Err(error) => Err(error), + }; + match result { + Ok(artifact) => return Ok(artifact), + Err(error) if attempt < total_attempts && error.is_retryable() => { + tokio::time::sleep(retry_backoff(attempt)).await; + attempt += 1; + } + Err(error) => return Err(error), + } + } + } + + async fn download_artifact_headers( + &self, + task_id: &str, + url: &TripoUrl, + ) -> Result { + // read_timeout 只覆盖响应体,响应头之前没有进展同样要按「无数据推进」判超时, + // 否则连上却不给响应头的服务端会让下载无限挂住。 + let response = tokio::time::timeout( + self.artifact_stall_timeout, + self.artifact_client.get(url.as_str()).send(), + ) + .await + .map_err(|_elapsed| artifact_stall_error(task_id, self.artifact_stall_timeout))? + .map_err(|error| TripoError::Request { + message: format!( + "artifact download transport failure for task {task_id}: {} from {} ({})", + transport_error_kind(&error), + url.redacted(), + transport_error_source(&error), + ), + status: None, + // 传输失败只在确实是瞬时(连接抖动 / 超时)时重试:DNS / TLS / + // 地址配置这类失败再试一次也是同一个结果,早点暴露比拖着强。 + retryable: transport_error_is_transient(&error), + })?; + let status = response.status(); + if !status.is_success() { + return Err(TripoError::Request { + message: format!( + "artifact download failed for task {task_id}: {} {} ({})", + status.as_u16(), + status.canonical_reason().unwrap_or("unknown status"), + url.redacted(), + ), + status: Some(status.as_u16()), + retryable: http_status_is_transient(status.as_u16()), + }); + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let content_length = response.content_length(); + Ok(TripoDownloadedArtifact::new( + task_id.to_string(), + url.clone(), + content_type, + content_length, + response, + )) + } +} + +fn retry_backoff(attempt: u32) -> Duration { + Duration::from_millis(250u64.saturating_mul(2u64.saturating_pow(attempt.min(6)))) +} + +/// 产物下载长时间没有数据推进:连接、响应头或响应体任一段卡住都归一成这个错误。 +fn artifact_stall_error(task_id: &str, stall_timeout: Duration) -> TripoError { + TripoError::Request { + message: format!( + "artifact download stalled for task {task_id}: no data received within {stall_timeout:?}" + ), + status: None, + // 「无数据推进」是典型的瞬时问题:换条连接重下往往就好了。 + retryable: true, + } +} + +/// 取完成态产物:下载入口只接受已完成且带输出的 task 快照。 +/// +/// `TripoTaskSnapshot` 字段全部公开,「完成态才有 output」只是 `map_task` 的约定, +/// 不是类型保证;调用方可以构造出 status 与 output 不一致的快照,所以这里重新校验, +/// 避免把失败 / 取消的任务当成成功产物下载。 +fn completed_output(task: &TripoTaskSnapshot) -> Result<&TripoTaskOutput, TripoError> { + validate_task_id(&task.handle.task_id)?; + if task.status != Model3dTaskStatus::Completed { + return Err(TripoError::OutputSchema { + task_id: task.handle.task_id.clone(), + message: format!("task is not completed (status {:?})", task.status), + }); + } + task.output + .as_ref() + .ok_or_else(|| TripoError::OutputSchema { + task_id: task.handle.task_id.clone(), + message: "task has no completed output".into(), + }) +} + +fn transport_error_kind(error: &reqwest::Error) -> &'static str { + if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connectivity error" + } else { + "request error" + } +} + +/// reqwest 的 `Display` 会带上完整 URL(含签名参数),排障只取 source 链。 +fn transport_error_source(error: &reqwest::Error) -> String { + std::error::Error::source(error) + .map(|source| source.to_string()) + .unwrap_or_else(|| "no source detail".to_string()) +} + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + use std::sync::Arc; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::time::Duration; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::*; + + /// 测试用的「无数据推进」预算:够短让用例跑得快,又给 CI 抖动留出余量。 + const STALL_TIMEOUT: Duration = Duration::from_millis(300); + /// 测试用产物体积上限:足够大,只有专门的超限用例才会撞上。 + const TEST_MAX_ARTIFACT_BYTES: u64 = 1024 * 1024; + /// 正常推进的间隔:明显小于预算,保证用例只在「真的没数据」时才失败。 + const PROGRESS_INTERVAL: Duration = Duration::from_millis(100); + + fn test_client() -> TripoProviderClient { + test_client_with_retries(0) + } + + /// 带重试次数的测试客户端:验证「整体重下」需要至少一次重试。 + /// 默认 base_url 指向一个连不上的端口,避免用例意外打到真实 provider。 + fn test_client_with_retries(retries: u32) -> TripoProviderClient { + test_client_with_base_url("http://127.0.0.1:1", retries) + } + + fn test_client_with_base_url(base_url: &str, retries: u32) -> TripoProviderClient { + let settings = TripoSettings::new( + "test-key".to_string(), + base_url.to_string(), + STALL_TIMEOUT, + retries, + "genarrative-test-tripo/1".to_string(), + ) + .expect("测试配置必须合法"); + TripoProviderClient::new(settings).expect("测试配置必须能建出 client") + } + + fn artifact_url(addr: SocketAddr) -> TripoUrl { + TripoUrl::parse_unchecked_for_test(&format!("http://{addr}/model.glb")) + } + + async fn spawn_mock_server(serve: F) -> SocketAddr + where + F: FnOnce(tokio::net::TcpStream) -> Fut + Send + 'static, + Fut: std::future::Future + Send, + { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server should bind"); + let addr = listener + .local_addr() + .expect("mock server should expose addr"); + tokio::spawn(async move { + let Ok((socket, _)) = listener.accept().await else { + return; + }; + serve(socket).await; + }); + addr + } + + async fn read_request(socket: &mut tokio::net::TcpStream) { + let mut buffer = [0u8; 1024]; + let _ = socket.read(&mut buffer).await; + } + + /// 接受连接后一直不返回响应头,直到远超预算才断开。 + async fn spawn_silent_server() -> SocketAddr { + spawn_mock_server(|_socket| async { + tokio::time::sleep(Duration::from_secs(30)).await; + }) + .await + } + + /// 响应头与第一块数据都正常,之后彻底断流。 + async fn spawn_stalling_body_server() -> SocketAddr { + spawn_mock_server(|mut socket| async move { + read_request(&mut socket).await; + let head = "HTTP/1.1 200 OK\r\n\ + Content-Type: model/gltf-binary\r\n\ + Content-Length: 64\r\n\ + \r\n"; + if socket.write_all(head.as_bytes()).await.is_err() { + return; + } + if socket.write_all(b"glTF").await.is_err() { + return; + } + let _ = socket.flush().await; + tokio::time::sleep(Duration::from_secs(30)).await; + }) + .await + } + + /// 总量固定但推得很慢:总时长远超预算,每次却都有数据推进。 + async fn spawn_slow_progress_server() -> SocketAddr { + spawn_mock_server(|mut socket| async move { + let _ = socket.set_nodelay(true); + read_request(&mut socket).await; + let head = "HTTP/1.1 200 OK\r\n\ + Content-Type: model/gltf-binary\r\n\ + Content-Length: 10\r\n\ + \r\n"; + if socket.write_all(head.as_bytes()).await.is_err() { + return; + } + for _ in 0..5 { + tokio::time::sleep(PROGRESS_INTERVAL).await; + if socket.write_all(b"gl").await.is_err() { + return; + } + let _ = socket.flush().await; + } + }) + .await + } + + #[tokio::test] + async fn artifact_download_times_out_when_response_headers_never_arrive() { + let addr = spawn_silent_server().await; + let error = match test_client() + .download_artifact("task-1", &artifact_url(addr), TEST_MAX_ARTIFACT_BYTES) + .await + { + Ok(_) => panic!("响应头一直不来必须按无数据推进失败"), + Err(error) => error, + }; + assert!(matches!(error, TripoError::Request { .. }), "{error:?}"); + } + + #[tokio::test] + async fn artifact_download_times_out_when_body_stops_progressing() { + let addr = spawn_stalling_body_server().await; + let mut artifact = test_client() + .download_artifact_headers("task-1", &artifact_url(addr)) + .await + .expect("响应头与第一块数据必须正常返回"); + + let first = artifact + .next_chunk() + .await + .expect("第一块数据必须正常返回") + .expect("第一块数据必须存在"); + assert_eq!(first.len(), 4); + + let error = artifact + .next_chunk() + .await + .expect_err("传输中途断流必须按无数据推进失败"); + assert!(matches!(error, TripoError::Request { .. }), "{error:?}"); + assert!(error.is_retryable(), "传输阶段失败必须可重试:{error}"); + } + + #[tokio::test] + async fn artifact_download_survives_slow_but_progressing_body() { + let addr = spawn_slow_progress_server().await; + let mut artifact = test_client() + .download_artifact_headers("task-1", &artifact_url(addr)) + .await + .expect("响应头必须正常返回"); + + let mut received = Vec::new(); + while let Some(chunk) = artifact + .next_chunk() + .await + .expect("只要还在出数据就不能判超时") + { + received.extend_from_slice(&chunk); + } + assert_eq!(received.len(), 10, "慢但持续的下载必须完整收完"); + } + + /// 第一次连接在 body 中途结束:下载入口必须整体重下,第二次读完完整产物。 + #[tokio::test] + async fn artifact_download_retries_the_whole_body_after_a_broken_transfer() { + const COMPLETE_BODY: &[u8] = b"glTF\x02\x00\x00\x00complete-glb"; + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server should bind"); + let addr = listener + .local_addr() + .expect("mock server should expose addr"); + tokio::spawn(async move { + // 第一次:响应头与一小块数据后结束传输,声明长度远大于实收字节。 + let Ok((mut first, _)) = listener.accept().await else { + return; + }; + read_request(&mut first).await; + let head = "HTTP/1.1 200 OK\r\n\ + Content-Type: model/gltf-binary\r\n\ + Content-Length: 64\r\n\ + \r\n"; + let _ = first.write_all(head.as_bytes()).await; + let _ = first.write_all(b"glTF").await; + let _ = first.flush().await; + let _ = first.shutdown().await; + + // 第二次:完整产物。 + let Ok((mut second, _)) = listener.accept().await else { + return; + }; + read_request(&mut second).await; + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: model/gltf-binary\r\nContent-Length: {}\r\n\r\n", + COMPLETE_BODY.len() + ); + let _ = second.write_all(head.as_bytes()).await; + let _ = second.write_all(COMPLETE_BODY).await; + let _ = second.flush().await; + + // 第一次连接保持打开:客户端读到的是「提前结束」而不是连接被重置。 + tokio::time::sleep(Duration::from_secs(30)).await; + drop(first); + }); + + let artifact = test_client_with_retries(1) + .download_artifact("task-1", &artifact_url(addr), TEST_MAX_ARTIFACT_BYTES) + .await + .expect("body 中途失败必须整体重下并成功"); + assert_eq!(artifact.bytes, COMPLETE_BODY); + assert_eq!(artifact.content_length, Some(COMPLETE_BODY.len() as u64)); + } + + /// 第一次查询返回 500,第二次给出正常状态:瞬时故障必须重试后成功。 + #[tokio::test] + async fn task_status_query_retries_transient_failures() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server should bind"); + let addr = listener + .local_addr() + .expect("mock server should expose addr"); + tokio::spawn(async move { + let mut first_attempt = true; + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + read_request(&mut socket).await; + let response = if first_attempt { + first_attempt = false; + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n".to_string() + } else { + let body = serde_json::json!({ + "code": 0, + "data": { + "task_id": "task-1", + "type": "text_to_model", + "status": "running", + "progress": 10 + } + }) + .to_string(); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\n\r\n{body}", + body.len() + ) + }; + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.flush().await; + } + }); + + let snapshot = test_client_with_base_url(&format!("http://{addr}"), 1) + .get_task(&TripoTaskHandle { + task_id: "task-1".to_string(), + }) + .await + .expect("瞬时 500 必须重试后拿到状态"); + assert_eq!(snapshot.status, Model3dTaskStatus::Running); + } + + /// provider 报 `unknown` 状态:按可重试返回,不判终态失败(评审 #57)。 + #[tokio::test] + async fn task_status_query_treats_unknown_status_as_retryable() { + let addr = spawn_mock_server(|mut socket| async move { + read_request(&mut socket).await; + let body = serde_json::json!({ + "code": 0, + "data": { + "task_id": "task-1", + "type": "text_to_model", + "status": "unknown", + "progress": 10 + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.flush().await; + }) + .await; + + let error = test_client_with_base_url(&format!("http://{addr}"), 0) + .get_task(&TripoTaskHandle { + task_id: "task-1".to_string(), + }) + .await + .expect_err("认不出来的状态不能当成正常快照返回"); + assert!( + matches!(error, TripoError::TaskStatusUnknown { .. }), + "{error:?}" + ); + assert!( + error.is_retryable(), + "unknown 是暂时的看不懂,必须可重试:{error}" + ); + } + + /// 404 是确定性失败:只打一次请求就返回,不按退避重试。 + #[tokio::test] + async fn task_status_query_does_not_retry_deterministic_failures() { + let connections = Arc::new(AtomicU32::new(0)); + let observed = Arc::clone(&connections); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server should bind"); + let addr = listener + .local_addr() + .expect("mock server should expose addr"); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + observed.fetch_add(1, Ordering::SeqCst); + read_request(&mut socket).await; + let body = + serde_json::json!({ "code": 1004, "message": "task not found" }).to_string(); + let response = format!( + "HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.flush().await; + } + }); + + let error = test_client_with_base_url(&format!("http://{addr}"), 2) + .get_task(&TripoTaskHandle { + task_id: "task-1".to_string(), + }) + .await + .expect_err("404 是确定性失败,不重试也拿不到状态"); + assert!(!error.is_retryable(), "404 不该被判成可重试:{error:?}"); + tokio::time::sleep(Duration::from_millis(700)).await; + assert_eq!( + connections.load(Ordering::SeqCst), + 1, + "确定性失败只允许打一次请求" + ); + } + + /// 产物超过调用方给的体积上限:按输出违约失败,且不重试。 + #[tokio::test] + async fn artifact_download_rejects_bodies_over_the_caller_limit() { + const BODY: &[u8] = b"glTF\x02\x00\x00\x00oversized"; + let addr = spawn_mock_server(|mut socket| async move { + read_request(&mut socket).await; + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: model/gltf-binary\r\nContent-Length: {}\r\n\r\n", + BODY.len() + ); + let _ = socket.write_all(head.as_bytes()).await; + let _ = socket.write_all(BODY).await; + let _ = socket.flush().await; + }) + .await; + + let error = test_client_with_retries(2) + .download_artifact("task-1", &artifact_url(addr), 8) + .await + .expect_err("超过上限的产物必须被拒绝"); + assert!( + matches!(error, TripoError::OutputSchema { .. }), + "{error:?}" + ); + assert!(!error.is_retryable(), "体积超限不是重试能解决的问题"); + } +} diff --git a/server-rs/crates/platform-tripo/src/common/config.rs b/server-rs/crates/platform-tripo/src/common/config.rs new file mode 100644 index 000000000..d3610cf58 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/config.rs @@ -0,0 +1,202 @@ +use std::time::Duration; + +use tripo3d_sdk::ClientOptions; + +use super::TripoError; + +#[derive(Clone)] +pub struct TripoSettings { + pub api_key: String, + pub base_url: String, + pub request_timeout: Duration, + pub retries: u32, + pub user_agent: String, +} + +impl std::fmt::Debug for TripoSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TripoSettings") + .field("api_key", &"") + .field("base_url", &self.base_url) + .field("request_timeout", &self.request_timeout) + .field("retries", &self.retries) + .field("user_agent", &self.user_agent) + .finish() + } +} + +impl TripoSettings { + /// 构造配置;零超时或空白 `api_key` / `base_url` 属于调用方配置错误,直接在这里失败。 + /// + /// 零超时必须被拒绝,因为同一个值在两条链路里含义相反:`tokio::time::timeout` 认为 + /// 它「已经超时」,`reqwest` 却把它解释成「不超时」;放它进来只会得到「产物下载 + /// stalled」这种指着错误方向的报错,而不是一眼看出是配置写错了。 + pub fn new( + api_key: String, + base_url: String, + request_timeout: Duration, + retries: u32, + user_agent: String, + ) -> Result { + let settings = Self { + api_key, + base_url, + request_timeout, + retries, + user_agent, + }; + settings.validate()?; + Ok(settings) + } + + /// 校验配置自洽。 + /// + /// `base_url` 必须是绝对的 `http(s)` 地址:只会「非空」挡不住 `example.com` 这类少了 + /// scheme 的写法,那种值要等到真正发请求时才失败,报错指向传输层而不是配置本身。 + /// + /// 字段都是公开的,调用方可以用结构体字面量绕过 [`TripoSettings::new`]; + /// [`crate::TripoProviderClient::new`] 会再校验一次,保证真正发起请求的那一端 + /// 拿到的永远是合法配置。 + pub fn validate(&self) -> Result<(), TripoError> { + if self.api_key.trim().is_empty() { + return Err(TripoError::Configuration { + field: "api_key", + message: "api_key must not be blank".to_string(), + }); + } + if self.base_url.trim().is_empty() { + return Err(TripoError::Configuration { + field: "base_url", + message: "base_url must not be blank".to_string(), + }); + } + let base_url = + url::Url::parse(self.base_url.trim()).map_err(|error| TripoError::Configuration { + field: "base_url", + message: format!("base_url must be an absolute URL: {error}"), + })?; + if !matches!(base_url.scheme(), "http" | "https") { + return Err(TripoError::Configuration { + field: "base_url", + message: "base_url must use http or https".to_string(), + }); + } + if base_url.host_str().is_none() { + return Err(TripoError::Configuration { + field: "base_url", + message: "base_url must include a host".to_string(), + }); + } + if self.request_timeout.is_zero() { + return Err(TripoError::Configuration { + field: "request_timeout", + message: "request_timeout must be greater than zero".to_string(), + }); + } + Ok(()) + } + + pub(crate) fn client_options(&self) -> ClientOptions { + ClientOptions { + // 校验用的是 trim 后的值,发出去的也必须是 trim 后的值:密钥被复制时带上 + // 首尾空白,就会在 provider 那边变成一个看不懂的 401。 + api_key: Some(self.api_key.trim().to_string()), + // 与 base_url 校验同口径:校验看的是 trim 后的值,发出去的也得是 trim 后的值, + // 否则带空白的地址会在 SDK 里以低层解析错误暴露出来。 + base_url: Some(self.base_url.trim().to_string()), + timeout: Some(self.request_timeout), + retries: Some(self.retries), + user_agent: Some(self.user_agent.clone()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(request_timeout: Duration, api_key: &str, base_url: &str) -> TripoSettings { + TripoSettings { + api_key: api_key.to_string(), + base_url: base_url.to_string(), + request_timeout, + retries: 2, + user_agent: "genarrative-test-tripo/1".to_string(), + } + } + + #[test] + fn zero_timeout_and_blank_credentials_are_rejected() { + for (label, candidate) in [ + ( + "零超时", + settings(Duration::ZERO, "key", "https://example.com"), + ), + ( + "空密钥", + settings(Duration::from_secs(1), "", "https://example.com"), + ), + ( + "空白密钥", + settings(Duration::from_secs(1), " ", "https://example.com"), + ), + ("空地址", settings(Duration::from_secs(1), "key", "")), + ("空白地址", settings(Duration::from_secs(1), "key", " \t")), + ] { + let error = candidate + .validate() + .expect_err(&format!("{label} 必须被拒绝")); + assert!(matches!(error, TripoError::Configuration { .. }), "{error}"); + assert!(!error.is_retryable(), "{label} 不是可重试错误"); + } + } + + #[test] + fn base_url_must_be_an_absolute_http_url() { + for (label, base_url) in [ + ("缺 scheme", "openapi.tripo3d.com/v3"), + ("非 http(s)", "ftp://openapi.tripo3d.com/v3"), + ("不是 URL", "not-a-url"), + ] { + let error = settings(Duration::from_secs(1), "key", base_url) + .validate() + .expect_err(&format!("{label} 必须被拒绝")); + assert!(matches!(error, TripoError::Configuration { .. }), "{error}"); + } + + // 前后带空白的合法地址仍然放行:先 trim 再校验,与取值口径一致。 + settings( + Duration::from_secs(1), + "key", + " https://openapi.tripo3d.com/v3 ", + ) + .validate() + .expect("带空白的合法地址应可校验通过"); + } + + #[test] + fn new_returns_ready_to_use_settings() { + let settings = TripoSettings::new( + "key".to_string(), + "https://example.com".to_string(), + Duration::from_secs(5), + 1, + "genarrative-test-tripo/1".to_string(), + ) + .expect("合法配置必须构造成功"); + assert_eq!(settings.request_timeout, Duration::from_secs(5)); + } + + #[test] + fn client_options_trim_credentials_that_validation_trimmed() { + let options = settings( + Duration::from_secs(1), + " key ", + " https://example.com/v3 ", + ) + .client_options(); + + assert_eq!(options.api_key.as_deref(), Some("key")); + assert_eq!(options.base_url.as_deref(), Some("https://example.com/v3")); + } +} diff --git a/server-rs/crates/platform-tripo/src/common/error.rs b/server-rs/crates/platform-tripo/src/common/error.rs new file mode 100644 index 000000000..728da330a --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/error.rs @@ -0,0 +1,518 @@ +use std::fmt; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TripoField { + Prompt, + Model, + NegativePrompt, + Input, + Inputs, + ImageSeed, + ModelSeed, + TextureSeed, + Texture, + Pbr, + TextureQuality, + TextureVersion, + GeometryQuality, + FaceLimit, + AutoSize, + Quad, + SmartLowPoly, + GenerateParts, + Compress, + ExportUv, + ExportOrientation, + Delight, + TaskId, +} + +impl fmt::Display for TripoField { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::Prompt => "prompt", + Self::Model => "model", + Self::NegativePrompt => "negative_prompt", + Self::Input => "input", + Self::Inputs => "inputs", + Self::ImageSeed => "image_seed", + Self::ModelSeed => "model_seed", + Self::TextureSeed => "texture_seed", + Self::Texture => "texture", + Self::Pbr => "pbr", + Self::TextureQuality => "texture_quality", + Self::TextureVersion => "texture_version", + Self::GeometryQuality => "geometry_quality", + Self::FaceLimit => "face_limit", + Self::AutoSize => "auto_size", + Self::Quad => "quad", + Self::SmartLowPoly => "smart_low_poly", + Self::GenerateParts => "generate_parts", + Self::Compress => "compress", + Self::ExportUv => "export_uv", + Self::ExportOrientation => "export_orientation", + Self::Delight => "delight", + Self::TaskId => "task_id", + }; + f.write_str(name) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TripoValidationReason { + Required, + InvalidCombination, + /// 取值超出该字段允许的长度或范围,与「参数组合互斥」区分开。 + OutOfRange, +} + +impl fmt::Display for TripoValidationReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Required => "required", + Self::InvalidCombination => "invalid combination", + Self::OutOfRange => "out of range", + }) + } +} + +#[derive(Debug)] +pub enum TripoError { + InvalidParameters { + field: Option, + reason: TripoValidationReason, + message: String, + }, + SdkInvalidArgument(String), + Api { + code: i64, + message: Option, + suggestion: Option, + status: Option, + }, + Request { + message: String, + status: Option, + /// 是否值得重试。由产生错误的一方按真实分类判定:传输层的超时 / 连接失败、 + /// 「无数据推进」超时、body 传输中断都是瞬时的,而客户端构造失败、DNS / TLS + /// 之外的配置问题、响应体格式错误这类「再试一次也一样」的失败不是 —— + /// 只看 `status.is_none()` 会把两者混在一起。 + retryable: bool, + }, + TaskFailure { + task_id: String, + status: String, + message: Option, + }, + OutputSchema { + task_id: String, + message: String, + }, + /// provider 返回了 `unknown` 这一档任务状态。 + /// + /// SDK 自己也不把 `unknown` 算作终态,因此这里按「暂时看不懂」处理,而不是终态失败: + /// Tripo job 固定只尝试一次、判死不可恢复,而 provider 以后新增状态时,旧代码会把 + /// 仍在跑的任务整批判失败。归成可重试后,轮询侧会继续查到截止时间(评审 #57)。 + TaskStatusUnknown { + task_id: String, + }, + /// 客户端配置问题:字段由调用方提供,与请求参数、provider 响应都无关。 + Configuration { + field: &'static str, + message: String, + }, + /// SDK 自身报出的错误(本地 IO、反序列化等),只保留文本。 + /// + /// 按 ADR 0001「不把 `tripo3d-sdk` 的错误模型带出平台边界」,这里不保留 + /// `std::io::Error` / serde 错误的类型,也不实现 `Error::source()`:调用方拿到的是 + /// 平台层归一化后的错误,而不是第三方错误链。评审 #60 提过拆 `SdkIo` / `SdkSerde` + /// 变体或加 `source` 字段,按决定不改 —— 那是公共错误枚举的 breaking 变更, + /// 收益只到排障细分一层。 + Sdk { + message: String, + }, +} + +impl fmt::Display for TripoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidParameters { + field, + reason, + message, + } => { + write!(f, "invalid Tripo parameters ({reason})")?; + match field { + Some(field) => write!(f, " [{field}]: {message}"), + None => write!(f, ": {message}"), + } + } + Self::SdkInvalidArgument(message) => write!(f, "Tripo SDK invalid argument: {message}"), + Self::Api { + code, + message, + suggestion, + status, + } => write!( + f, + "Tripo API error code={code} status={status:?} message={message:?} suggestion={suggestion:?}" + ), + Self::Request { + message, status, .. + } => { + write!(f, "Tripo request error status={status:?}: {message}") + } + Self::TaskFailure { + task_id, + status, + message, + } => write!(f, "Tripo task {task_id} ended with {status}: {message:?}"), + Self::OutputSchema { task_id, message } => { + write!(f, "Tripo task {task_id} output schema error: {message}") + } + Self::TaskStatusUnknown { task_id } => { + write!(f, "Tripo task {task_id} reported the unknown task status") + } + Self::Configuration { field, message } => { + write!(f, "Tripo configuration error [{field}]: {message}") + } + Self::Sdk { message } => write!(f, "Tripo SDK error: {message}"), + } + } +} + +impl std::error::Error for TripoError {} + +#[cfg(test)] +mod tests { + use super::*; + + /// 只有 source 链的假错误,用来验证错误链文本的收集口径。 + #[derive(Debug)] + struct ChainError { + text: &'static str, + source: Option>, + } + + impl fmt::Display for ChainError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.text) + } + } + + impl std::error::Error for ChainError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source.as_deref() + } + } + + /// 声明 64 字节、只给 4 字节就断开:读 body 会得到传输中断类错误。 + async fn spawn_truncated_body_server() -> std::net::SocketAddr { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server 必须能监听"); + let addr = listener.local_addr().expect("mock server 必须有地址"); + tokio::spawn(async move { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let mut request = [0u8; 1024]; + let _ = socket.read(&mut request).await; + let head = "HTTP/1.1 200 OK\r\n\ + Content-Type: model/gltf-binary\r\n\ + Content-Length: 64\r\n\ + \r\n"; + if socket.write_all(head.as_bytes()).await.is_err() { + return; + } + let _ = socket.write_all(b"glTF").await; + let _ = socket.flush().await; + }); + addr + } + + /// 是否可重试不再由「有没有 status」推断:同一个 `None` 既可能是瞬时传输失败, + /// 也可能是「响应体格式不对」这种再试一次也一样的结果。 + #[test] + fn request_retryability_is_explicit() { + let transient = TripoError::Request { + message: "connection reset by peer".to_string(), + status: None, + retryable: true, + }; + assert!(transient.is_retryable()); + + let permanent = TripoError::Request { + message: "malformed response: expected value at line 1 column 1".to_string(), + status: None, + retryable: false, + }; + assert!(!permanent.is_retryable(), "响应体格式错误不能重试"); + } + + #[test] + fn http_status_transience_matches_the_retry_set() { + for status in [408u16, 425, 429, 500, 502, 599] { + assert!(http_status_is_transient(status), "{status} 属于瞬时状态"); + } + for status in [200u16, 400, 404, 409, 422, 600] { + assert!(!http_status_is_transient(status), "{status} 不属于瞬时状态"); + } + } + + #[test] + fn sdk_request_retryability_needs_a_transient_cause() { + assert!(sdk_request_is_retryable(Some(503), None)); + assert!(!sdk_request_is_retryable(Some(404), None)); + assert!( + !sdk_request_is_retryable(None, None), + "空 body / 格式错误这类没有底层传输错误的 Request 不能重试" + ); + } + + /// 底层 reqwest 分类直接决定可重试性:连不上(连接被拒 / 握手失败)可重试, + /// 构建期 / 地址类失败不可重试。 + #[tokio::test] + async fn transport_transience_follows_the_reqwest_classification() { + let refused = reqwest::Client::new() + .get("http://127.0.0.1:1") + .send() + .await + .expect_err("连不上的地址必须失败"); + assert!(transport_error_is_transient(&refused), "{refused}"); + assert!(sdk_request_is_retryable(None, Some(&refused))); + + let malformed = reqwest::Client::new() + .get("http://") + .send() + .await + .expect_err("非法地址必须失败"); + assert!(!transport_error_is_transient(&malformed), "{malformed}"); + assert!(!sdk_request_is_retryable(None, Some(&malformed))); + + // 传输中途截断的响应体:reqwest 归到 Decode(见上面的说明),这里刻意不判可重试, + // 免得「提交请求的响应体解码失败」再发一次造成重复下单。 + let addr = spawn_truncated_body_server().await; + let response = reqwest::Client::new() + .get(format!("http://{addr}/a.glb")) + .send() + .await + .expect("响应头必须正常返回"); + let truncated = response + .bytes() + .await + .expect_err("声明 64 字节却只给 4 字节必须失败"); + assert!( + truncated.is_decode() && !truncated.is_body(), + "截断的响应体应落在 Decode 而不是 Body:{truncated:?}" + ); + assert!( + !transport_error_is_transient(&truncated), + "落在 Decode 的截断体不判可重试(提交请求重发会重复下单):{truncated}" + ); + assert!(!sdk_request_is_retryable(None, Some(&truncated))); + } + + #[test] + fn error_source_chain_skips_the_outermost_error() { + let error = ChainError { + text: "error sending request for url (https://example.com/a?sign=secret)", + source: Some(Box::new(ChainError { + text: "connection reset by peer", + source: Some(Box::new(ChainError { + text: "os error 104", + source: None, + })), + })), + }; + + assert_eq!( + error_source_chain(&error), + vec![ + "connection reset by peer".to_string(), + "os error 104".to_string() + ] + ); + } + + #[test] + fn body_snippet_drops_blank_and_url_bearing_bodies() { + assert_eq!(body_snippet(" "), None); + assert_eq!( + body_snippet("failed to fetch https://cdn.example.com/a.glb?sig=secret"), + None + ); + assert_eq!( + body_snippet(" {\"code\": 1001, \"message\": \"bad params\"} "), + Some("{\"code\": 1001, \"message\": \"bad params\"}".to_string()) + ); + } + + #[test] + fn body_snippet_is_truncated() { + let body = "x".repeat(REQUEST_BODY_SNIPPET_MAX_CHARS + 10); + let snippet = body_snippet(&body).expect("超长响应体仍应给出截断摘要"); + + assert_eq!(snippet.chars().count(), REQUEST_BODY_SNIPPET_MAX_CHARS + 1); + assert!(snippet.ends_with('…')); + } +} + +/// 附加到 provider 请求错误上的响应体上限:这段文案会被持久化进任务错误消息, +/// 不能把整页 HTML 原样塞进去。 +const REQUEST_BODY_SNIPPET_MAX_CHARS: usize = 512; + +/// provider 请求失败的可诊断文案。 +/// +/// SDK 的 `Error::Request` 除了 `message` 还带响应体与底层 `reqwest::Error`,这里一并收进 +/// 文案,重试判定与排障就不必回 SDK 里另找: +/// - 只取错误链上**底层**的文本(hyper / rustls 层,不含地址);`reqwest::Error` 自身的 +/// `Display` 会带上完整 URL(含签名 query),因此不把它写进文案。 +/// - 响应体只在看起来不含 `http(s)://` 时附加,避免把带签名的地址写进会被持久化的消息。 +fn request_failure_message( + message: String, + body: Option<&str>, + source: Option<&reqwest::Error>, +) -> String { + let mut text = message; + if let Some(snippet) = body.and_then(body_snippet) { + text.push_str(&format!("; body={snippet}")); + } + if let Some(source) = source { + let chain = error_source_chain(source); + if !chain.is_empty() { + text.push_str(&format!("; cause={}", chain.join(" <- "))); + } + } + text +} + +/// 去掉首尾空白、截断,并挡掉疑似带地址的响应体。 +fn body_snippet(body: &str) -> Option { + let body = body.trim(); + // 大小写不敏感:`HTTPS://…` 这类写法同样是带地址的响应体,放过去就把签名 URL + // 带进了任务错误信息。这条路径不在热点上,整串转小写即可。 + let lowered = body.to_ascii_lowercase(); + if body.is_empty() || lowered.contains("http://") || lowered.contains("https://") { + return None; + } + let mut chars = body.chars(); + let mut snippet: String = chars + .by_ref() + .take(REQUEST_BODY_SNIPPET_MAX_CHARS) + .collect(); + if chars.next().is_some() { + snippet.push('…'); + } + Some(snippet) +} + +/// 错误链上除最外层之外的文本,按由近到远排列。 +fn error_source_chain(error: &(dyn std::error::Error + 'static)) -> Vec { + let mut texts = Vec::new(); + let mut cursor = error.source(); + while let Some(cause) = cursor { + texts.push(cause.to_string()); + cursor = cause.source(); + } + texts +} + +impl TripoError { + pub fn is_retryable(&self) -> bool { + match self { + Self::Api { status, .. } => matches!(status, Some(408 | 425 | 429 | 500..=599)), + Self::Request { retryable, .. } => *retryable, + // `unknown` 是「这次看不懂」,下次查询可能就看得懂;终态失败不可恢复, + // 所以判成可重试(评审 #57)。 + Self::TaskStatusUnknown { .. } => true, + _ => false, + } + } +} + +/// HTTP 状态是否属于瞬时:与 SDK 的默认重试状态集合一致(408 / 425 / 429 / 5xx)。 +pub(crate) fn http_status_is_transient(status: u16) -> bool { + matches!(status, 408 | 425 | 429 | 500..=599) +} + +/// 传输层失败是否瞬时。`is_connect` 覆盖连接被拒 / 重置与握手阶段失败、`is_timeout` +/// 覆盖连接与读写超时、`is_body` 覆盖块读取阶段的响应体传输中断 —— 对齐 +/// `TripoError::Request.retryable` 文档里列举的瞬时类型;其余(URL 非法、 +/// 构建期配置错误、响应体格式 / 解码失败)重试也是同一个结果。 +/// +/// 注意:传输中途截断的响应体在 reqwest 里落在 `Decode`(source 是 +/// `hyper::Error(Body, IncompleteBody)`),与真正的解码失败在 `reqwest::Error` 层面无法 +/// 区分,因此这里**不**把 `Decode` 判成可重试 —— 提交请求的响应体解码失败再发一次可能 +/// 重复下单。产物下载路径有单独的 `body_read_error`,由那条路径自己标记可重试。 +pub(crate) fn transport_error_is_transient(error: &reqwest::Error) -> bool { + error.is_timeout() || error.is_connect() || error.is_body() +} + +/// SDK 的 `Error::Request` 是否值得重试。 +/// +/// 有状态码时按状态判;没有状态码时只有底层 reqwest 错误确实瞬时才算 —— +/// SDK 里 `source: None` 的 `Request` 是「响应体为空 / 格式不对 / 缺 `data` 字段」, +/// 再试一次也是同一个结果(`is_timeout` / `is_connect` 的传输失败 SDK 自己已经重试过一轮)。 +fn sdk_request_is_retryable(status: Option, source: Option<&reqwest::Error>) -> bool { + match status { + Some(status) => http_status_is_transient(status), + None => source.is_some_and(transport_error_is_transient), + } +} + +impl From for TripoError { + fn from(error: tripo3d_sdk::Error) -> Self { + match error { + tripo3d_sdk::Error::InvalidArgument(message) => Self::SdkInvalidArgument(message), + tripo3d_sdk::Error::Api { + code, + message, + suggestion, + status, + } => Self::Api { + code, + message, + suggestion, + status, + }, + tripo3d_sdk::Error::Request { + message, + status, + body, + source, + } => { + let retryable = sdk_request_is_retryable(status, source.as_ref()); + Self::Request { + message: request_failure_message(message, body.as_deref(), source.as_ref()), + status, + retryable, + } + } + tripo3d_sdk::Error::Task { task } => Self::TaskFailure { + task_id: task.task_id.clone(), + status: task.status.to_string(), + message: task.error_message.clone(), + }, + tripo3d_sdk::Error::Timeout { + task_id, + timeout_ms, + } => Self::Request { + message: format!("unexpected SDK timeout after {timeout_ms}ms for task {task_id}"), + status: None, + // 等待超时是瞬时的:重试一次可能就等到了。 + retryable: true, + }, + // Io 与 Serde 拍平成同一个文本变体:上游 SDK 对这两种错误只保证 Display 文本, + // 平台层也不再向上区分(理由见 `Sdk` 变体的注释,评审 #60)。 + tripo3d_sdk::Error::Io(error) => Self::Sdk { + message: error.to_string(), + }, + tripo3d_sdk::Error::Serde(error) => Self::Sdk { + message: error.to_string(), + }, + } + } +} diff --git a/server-rs/crates/platform-tripo/src/common/extra.rs b/server-rs/crates/platform-tripo/src/common/extra.rs new file mode 100644 index 000000000..569f98007 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/extra.rs @@ -0,0 +1,31 @@ +//! provider 请求的 SDK `extra` 字段构造。 +//! +//! `texture_version` 与 `delight` 不在 SDK params 的具名参数里,只能按 SDK 期望的 +//! 键名透传。键名一旦在某个入口写错,请求体就会静默丢字段,所以三个生成入口共用 +//! 同一份构造逻辑,不在各 endpoint 里各写一遍。 + +use std::collections::HashMap; + +use serde_json::Value; +use shared_contracts::model3d::common::Model3dTextureVersion; + +use super::{TripoError, wire}; + +const EXTRA_TEXTURE_VERSION: &str = "texture_version"; +const EXTRA_DELIGHT: &str = "delight"; + +/// 按 provider 需要的键名构造 `extra`;未提供的参数不写入,由 provider 取默认值; +/// `texture_version` 的取值仍由契约枚举的 serde rename 定义。 +pub(crate) fn extra_fields( + texture_version: Option, + delight: Option, +) -> Result, TripoError> { + let mut extra = HashMap::new(); + if let Some(value) = texture_version { + extra.insert(EXTRA_TEXTURE_VERSION.into(), Value::String(wire(&value)?)); + } + if let Some(value) = delight { + extra.insert(EXTRA_DELIGHT.into(), Value::Bool(value)); + } + Ok(extra) +} diff --git a/server-rs/crates/platform-tripo/src/common/mapping.rs b/server-rs/crates/platform-tripo/src/common/mapping.rs new file mode 100644 index 000000000..8264af87c --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/mapping.rs @@ -0,0 +1,141 @@ +use shared_contracts::model3d::common::Model3dTaskStatus; +use tripo3d_sdk::{Task, TaskOutput, TaskStatus}; + +use super::{ + TripoError, TripoTaskFailure, TripoTaskHandle, TripoTaskOutput, TripoTaskSnapshot, + TripoTaskType, TripoUrl, +}; +use crate::{ + image_to_model::result::TripoImageToModelResult, + multiview_to_model::result::TripoMultiviewToModelResult, + text_to_model::result::TripoTextToModelResult, +}; + +pub(crate) fn map_task(task: Task) -> Result { + let task_id = task.task_id; + let task_type = map_task_type(&task.task_type, &task_id)?; + let status = map_status(task.status, &task_id)?; + let output = match status { + Model3dTaskStatus::Completed => Some(map_output( + &task_id, + &task_type, + task.output + .as_ref() + .ok_or_else(|| TripoError::OutputSchema { + task_id: task_id.clone(), + message: "completed task has no output".into(), + })?, + )?), + _ => None, + }; + + Ok(TripoTaskSnapshot { + handle: TripoTaskHandle { task_id }, + task_type, + status, + progress: task.progress, + output, + // 只有非成功的终态才可能带 provider 失败信息:provider 留下过期错误字段时, + // 已完成的快照不该再挂 failure,否则快照内部自相矛盾。 + failure: is_terminal_failure(status).then(|| TripoTaskFailure { + code: task.error_code, + message: task.error_message, + }), + created_at: task.created_at, + completed_at: task.completed_at, + }) +} + +fn map_task_type(task_type: &str, task_id: &str) -> Result { + match task_type { + "text_to_model" => Ok(TripoTaskType::TextToModel), + "image_to_model" => Ok(TripoTaskType::ImageToModel), + "multiview_to_model" => Ok(TripoTaskType::MultiviewToModel), + other => Err(TripoError::OutputSchema { + task_id: task_id.into(), + message: format!("unsupported task type: {other}"), + }), + } +} + +fn map_status(status: TaskStatus, task_id: &str) -> Result { + match status { + TaskStatus::Queued => Ok(Model3dTaskStatus::Queued), + TaskStatus::Running => Ok(Model3dTaskStatus::Running), + TaskStatus::Success => Ok(Model3dTaskStatus::Completed), + TaskStatus::Failed | TaskStatus::Banned => Ok(Model3dTaskStatus::Failed), + TaskStatus::Cancelled => Ok(Model3dTaskStatus::Cancelled), + TaskStatus::Expired => Ok(Model3dTaskStatus::Expired), + // `unknown` 是「这次看不懂」,不是「任务结束了」:SDK 自己也没把它算进终态。 + // 按可重试错误返回(评审 #57),由轮询侧按轮询间隔继续查到截止时间——判成终态 + // 就等于把一笔已经提交、已经计费的任务永久作废,provider 以后新增状态时更会成批误杀。 + TaskStatus::Unknown => Err(TripoError::TaskStatusUnknown { + task_id: task_id.into(), + }), + } +} + +/// 失败 / 取消 / 过期是「任务已结束且没有可用产物」的终态,只有它们允许携带失败信息。 +fn is_terminal_failure(status: Model3dTaskStatus) -> bool { + matches!( + status, + Model3dTaskStatus::Failed | Model3dTaskStatus::Cancelled | Model3dTaskStatus::Expired + ) +} + +fn map_output( + task_id: &str, + task_type: &TripoTaskType, + output: &TaskOutput, +) -> Result { + let model_url = required_url(task_id, "model_url", output.model_url.as_deref())?; + let rendered_image_url = required_url( + task_id, + "rendered_image_url", + output.rendered_image_url.as_deref(), + )?; + + match task_type { + TripoTaskType::TextToModel => Ok(TripoTaskOutput::TextToModel(TripoTextToModelResult { + model_url, + rendered_image_url, + // Tripo API 文档把 generated_image_url 列为 text_to_model 输出的固定字段, + // 真实 provider smoke 也确认它总是存在(见技术方案《Tripo 3D模型Provider集成》)。 + // 因此按 ADR 0001「结果缺必填字段即以 output schema error 失败关闭」保留必填: + // 真缺失说明上游偏离了文档,属于要暴露的异常,不做静默兜底(评审 #61,按决定不改)。 + generated_image_url: required_url( + task_id, + "generated_image_url", + output.generated_image_url.as_deref(), + )?, + })), + TripoTaskType::ImageToModel => Ok(TripoTaskOutput::ImageToModel(TripoImageToModelResult { + model_url, + rendered_image_url, + })), + TripoTaskType::MultiviewToModel => Ok(TripoTaskOutput::MultiviewToModel( + TripoMultiviewToModelResult { + model_url, + rendered_image_url, + }, + )), + } +} + +fn required_url(task_id: &str, field: &str, value: Option<&str>) -> Result { + let value = value.ok_or_else(|| TripoError::OutputSchema { + task_id: task_id.into(), + message: format!("completed task output is missing required {field}"), + })?; + parse_url(value, task_id) +} + +fn parse_url(value: &str, task_id: &str) -> Result { + TripoUrl::parse(value).map_err(|error| match error { + TripoError::OutputSchema { message, .. } => TripoError::OutputSchema { + task_id: task_id.into(), + message, + }, + other => other, + }) +} diff --git a/server-rs/crates/platform-tripo/src/common/mod.rs b/server-rs/crates/platform-tripo/src/common/mod.rs new file mode 100644 index 000000000..4e2eb6330 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/mod.rs @@ -0,0 +1,25 @@ +mod client; +mod config; +mod error; +mod extra; +mod mapping; +mod types; +mod validation; +mod wire; + +pub use client::TripoProviderClient; +pub use config::TripoSettings; +pub use error::{TripoError, TripoField, TripoValidationReason}; +pub(crate) use error::{http_status_is_transient, transport_error_is_transient}; +pub(crate) use extra::extra_fields; +pub(crate) use mapping::map_task; +pub(crate) use types::TripoDownloadedArtifact; +pub use types::{ + TripoArtifactBytes, TripoTaskFailure, TripoTaskHandle, TripoTaskOutput, TripoTaskSnapshot, + TripoTaskType, TripoUrl, +}; +pub(crate) use validation::{ + TripoGenerationOptions, submitted_task_handle, validate_generation_options, validate_task_id, + wire_geometry_quality, +}; +pub(crate) use wire::{wire, wire_option}; diff --git a/server-rs/crates/platform-tripo/src/common/types.rs b/server-rs/crates/platform-tripo/src/common/types.rs new file mode 100644 index 000000000..16e2e2afa --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/types.rs @@ -0,0 +1,532 @@ +use std::fmt; +use std::net::{Ipv4Addr, Ipv6Addr}; + +use bytes::Bytes; +use reqwest::Response; + +use url::{Host, Url}; + +use shared_contracts::model3d::common::Model3dTaskStatus; + +use crate::{ + common::TripoError, image_to_model::result::TripoImageToModelResult, + multiview_to_model::result::TripoMultiviewToModelResult, + text_to_model::result::TripoTextToModelResult, +}; + +/// 从远端地址推导出的扩展名上限;超出即按不可信处理,退回默认扩展名。 +const MAX_ARTIFACT_FILE_EXTENSION_LEN: usize = 10; + +/// 文件名主干上限。主干同样来自远端(`TripoArtifactBytes::filename` 是公开 API), +/// 不截断就可能拼出超过文件系统名字上限的一长串。 +const MAX_ARTIFACT_FILE_STEM_LEN: usize = 128; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoTaskHandle { + pub task_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TripoTaskType { + TextToModel, + ImageToModel, + MultiviewToModel, +} + +#[derive(Clone, Eq, PartialEq)] +pub struct TripoUrl(Url); + +impl TripoUrl { + pub(crate) fn parse(value: &str) -> Result { + let url = Url::parse(value).map_err(|error| TripoError::OutputSchema { + task_id: "unknown".into(), + message: format!("URL is invalid: {error}"), + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(TripoError::OutputSchema { + task_id: "unknown".into(), + message: "URL must use http or https".into(), + }); + } + // 产物地址不需要凭据;userinfo 只会在日志与调试输出里泄漏账号信息,直接拒收。 + if !url.username().is_empty() || url.password().is_some() { + return Err(TripoError::OutputSchema { + task_id: "unknown".into(), + message: "URL must not contain userinfo".into(), + }); + } + // 这个地址是要我们主动去请求的:本机回环 / 内网 / 链路本地 / 保留段一律拒收, + // 否则 provider 响应(或伪造的响应)就能把工作进程当成打内网的跳板, + // 云元数据地址 169.254.169.254 正落在被拒之列。 + let host = url.host().ok_or_else(|| TripoError::OutputSchema { + task_id: "unknown".into(), + message: "URL must contain a host".into(), + })?; + if !host_is_public(host) { + return Err(TripoError::OutputSchema { + task_id: "unknown".into(), + message: "URL host must be a public address".into(), + }); + } + Ok(Self(url)) + } + + /// 仅供本 crate 的测试夹具使用:跳过「主机必须是公网地址」的检查。 + /// + /// 产物下载的用例本来就跑在 `127.0.0.1` 的本地 mock 服务上,用真实口径构造不出来。 + #[cfg(test)] + pub(crate) fn parse_unchecked_for_test(value: &str) -> Self { + Self(Url::parse(value).expect("测试夹具必须是合法 URL")) + } + + /// 完整 URL 只通过该显式访问器读取。 + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// 日志与调试用脱敏形式:隐藏可能携带签名参数的 query 与 fragment, + /// 并保留原分隔符,不把 fragment 显示成 query。 + /// + /// 覆盖面只到 query / fragment 两段:S3 风格的预签名 URL 把凭据放在 query 里,这条 + /// 规则够用;如果 provider 改成把 token 写进路径的方案(例如 path-style SAS), + /// 路径会原样保留,`Debug` 输出就会泄漏凭据 —— 那时要在这里连路径段一起处理。 + pub fn redacted(&self) -> String { + let full = self.0.as_str(); + match full.find(['?', '#']) { + Some(index) => { + let delimiter = &full[index..index + 1]; + format!("{}{delimiter}", &full[..index]) + } + // 没有 query / fragment:路径按上面的说明原样保留。 + None => full.to_owned(), + } + } +} + +/// 产物地址只允许公网主机:回环、内网、链路本地、组播与保留段都拒收。 +/// +/// 域名只拦最明显的本机名(`localhost` 及其子域);不在这里查 DNS —— 解析结果在这一层 +/// 看不见,域名指向内网地址的情况要挡只能在连接层按解析出的地址判定。 +fn host_is_public(host: Host<&str>) -> bool { + match host { + Host::Domain(name) => !is_local_host_name(name), + Host::Ipv4(ip) => ipv4_is_public(ip), + Host::Ipv6(ip) => ipv6_is_public(ip), + } +} + +fn is_local_host_name(name: &str) -> bool { + let name = name.trim_end_matches('.').to_ascii_lowercase(); + name == "localhost" || name.ends_with(".localhost") +} + +fn ipv4_is_public(ip: Ipv4Addr) -> bool { + !(ip.is_unspecified() + || ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_multicast() + || ip.is_documentation() + || ipv4_is_reserved(ip)) +} + +/// 保留段按 IANA 特殊用途表逐段判定。 +/// +/// `Ipv4Addr::is_reserved` 目前还是 nightly 的(`ip` feature),所以这里自己列: +/// `0.0.0.0/8`、`100.64.0.0/10`(运营商级 NAT)、`192.0.0.0/24`(IETF 协议分配)、 +/// `198.18.0.0/15`(基准测试)、`240.0.0.0/4`(保留,含 `255.255.255.255`)。 +fn ipv4_is_reserved(ip: Ipv4Addr) -> bool { + match ip.octets() { + [0, ..] => true, + [100, second, ..] => (64..=127).contains(&second), + [192, 0, 0, _] => true, + [198, second, ..] => (18..=19).contains(&second), + [first, ..] => first >= 240, + } +} + +fn ipv6_is_public(ip: Ipv6Addr) -> bool { + // IPv4-mapped 地址按内嵌的 IPv4 判定,否则 `::ffff:127.0.0.1` 能绕开回环检查。 + if let Some(embedded) = ip.to_ipv4_mapped() { + return ipv4_is_public(embedded); + } + let segments = ip.segments(); + !(ip.is_unspecified() + || ip.is_loopback() + || ip.is_multicast() + || ip.is_unique_local() + || ip.is_unicast_link_local() + // `2001:db8::/32` 是文档用地址;`Ipv6Addr::is_documentation` 同样是 nightly。 + || (segments[0] == 0x2001 && segments[1] == 0x0db8)) +} + +impl fmt::Debug for TripoUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("TripoUrl").field(&self.redacted()).finish() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoTaskFailure { + pub code: Option, + pub message: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TripoTaskOutput { + TextToModel(TripoTextToModelResult), + ImageToModel(TripoImageToModelResult), + MultiviewToModel(TripoMultiviewToModelResult), +} + +impl TripoTaskOutput { + pub(crate) fn model_url(&self) -> &TripoUrl { + match self { + Self::TextToModel(result) => &result.model_url, + Self::ImageToModel(result) => &result.model_url, + Self::MultiviewToModel(result) => &result.model_url, + } + } + + /// 预览图地址;三个端点都返回渲染图,缺失时按输出结构错误处理。 + pub(crate) fn rendered_image_url(&self) -> &TripoUrl { + match self { + Self::TextToModel(result) => &result.rendered_image_url, + Self::ImageToModel(result) => &result.rendered_image_url, + Self::MultiviewToModel(result) => &result.rendered_image_url, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoTaskSnapshot { + pub handle: TripoTaskHandle, + pub task_type: TripoTaskType, + pub status: Model3dTaskStatus, + pub progress: Option, + pub output: Option, + pub failure: Option, + pub created_at: Option, + pub completed_at: Option, +} + +/// provider 产物的下载句柄(响应头阶段)。模型与预览图共用同一条流式读取路径, +/// 因此类型名按“产物”而不是“模型”命名。 +/// +/// 只在本 crate 内使用:对外只暴露读完的 [`TripoArtifactBytes`],流式消费等 +/// 「产物直送 OSS」落地时再决定是否公开。 +/// +/// 拿到的只是响应头:body 由 [`TripoDownloadedArtifact::next_chunk`] 逐块读出。 +/// [`crate::TripoProviderClient`] 的下载入口会把「响应头 + 读完 body」作为一个整体 +/// 重试单元(见 [`TripoDownloadedArtifact::read_all`]),所以这里的失败不等于调用方失败。 +pub(crate) struct TripoDownloadedArtifact { + task_id: String, + pub(crate) url: TripoUrl, + pub(crate) content_type: Option, + pub(crate) content_length: Option, + response: Response, + status: u16, + received: u64, +} + +/// 已完整读出的 provider 产物:响应头元数据 + 全部字节。 +/// +/// 与 [`TripoDownloadedArtifact`] 的区别是「读完了」:body 中途失败会被下载入口整体重下, +/// 因此拿到这个值就代表这次读取是完整的。(几十 MB 的产物当前一次性读进内存,api-server +/// 侧同样如此;未来接 OSS 流式 / 分片上传时再回到流式句柄。) +pub struct TripoArtifactBytes { + pub url: TripoUrl, + pub content_type: Option, + /// provider 声明的长度,仅用于读取时的完整性校验与内存预分配,不是落库口径。 + pub content_length: Option, + pub bytes: Vec, +} + +impl fmt::Debug for TripoArtifactBytes { + /// 只打印元数据与字节数:`bytes` 是几十 MB 级的产物,派生的 `Debug` 会把每个字节 + /// 写成十进制文本,一次 `{artifact:?}` 就能刷出几百 MB 日志。 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TripoArtifactBytes") + .field("url", &self.url) + .field("content_type", &self.content_type) + .field("content_length", &self.content_length) + .field("bytes_len", &self.bytes.len()) + .finish() + } +} + +impl TripoDownloadedArtifact { + pub(crate) fn new( + task_id: String, + url: TripoUrl, + content_type: Option, + content_length: Option, + response: Response, + ) -> Self { + Self { + task_id, + url, + content_type, + content_length, + status: response.status().as_u16(), + response, + received: 0, + } + } + + pub(crate) async fn next_chunk(&mut self) -> Result, TripoError> { + let chunk = self + .response + .chunk() + .await + // 传输错误不是 HTTP 状态:`status` 描述的是响应头,写进这里会让 + // `is_retryable` 把 200 当成「不值得重试」,CDN 抖动就再也重试不了。 + .map_err(|error| body_read_error(&self.task_id, self.status, format!("{error}")))?; + match chunk { + Some(chunk) => { + self.received += chunk.len() as u64; + Ok(Some(chunk)) + } + None => { + if let Some(expected) = self.content_length { + if self.received != expected { + // body 提前结束同样按传输失败处理:整段重下才有机会拿到完整字节。 + return Err(body_read_error( + &self.task_id, + self.status, + format!( + "artifact length mismatch: expected {expected} bytes, received {}", + self.received + ), + )); + } + } + Ok(None) + } + } + } + + /// 读完整个响应体。单次读取:失败不在这里重试(只有一份连接), + /// 由下载入口用新连接整体重下。 + /// + /// `max_bytes` 是单次读取的硬上限:超过它说明响应异常或产物口径变了, + /// 宁可失败退款也不要把调用方内存打满。 + pub(crate) async fn read_all( + mut self, + max_bytes: u64, + ) -> Result { + let mut bytes = + Vec::with_capacity(self.content_length.unwrap_or(0).min(max_bytes) as usize); + while let Some(chunk) = self.next_chunk().await? { + let next_len = bytes.len() as u64 + chunk.len() as u64; + if next_len > max_bytes { + return Err(TripoError::OutputSchema { + task_id: self.task_id.clone(), + message: format!( + "artifact exceeds the {max_bytes} byte limit implied by the caller: {} bytes and still streaming", + next_len + ), + }); + } + bytes.extend_from_slice(chunk.as_ref()); + } + Ok(TripoArtifactBytes { + url: self.url, + content_type: self.content_type, + content_length: self.content_length, + bytes, + }) + } +} + +impl TripoArtifactBytes { + /// 产物落盘用的文件名。远端地址不可信,扩展名只接受短的字母数字 token: + /// `Url::path_segments` 已做百分号解码,`%2F` / `%5C` 这类编码分隔符会直接 + /// 进到扩展名里,不过滤就会拼出跨目录的路径。 + pub fn filename(&self, name: &str) -> String { + artifact_filename(name, self.content_type.as_deref(), &self.url) + } +} + +/// 响应体读取失败:统一成可重试的传输错误,并把 HTTP 状态留在文案里供排障。 +/// +/// `chunk()` 的失败只可能是传输层问题(超时 / 连接中断 / body 被截断),换条连接 +/// 整段重下才有机会拿到完整字节,因此固定按可重试标记。 +fn body_read_error(task_id: &str, status: u16, detail: String) -> TripoError { + TripoError::Request { + message: format!( + "failed to read artifact response body for task {task_id} (HTTP {status}): {detail}" + ), + status: None, + retryable: true, + } +} + +/// 产物落盘用的文件名。远端地址不可信,扩展名只接受短的字母数字 token: +/// `Url::path_segments` 已做百分号解码,`%2F` / `%5C` 这类编码分隔符会直接 +/// 进到扩展名里,不过滤就会拼出跨目录的路径。 +fn artifact_filename(name: &str, content_type: Option<&str>, url: &TripoUrl) -> String { + let extension = url + .0 + .path_segments() + .and_then(|segments| segments.last()) + .and_then(|segment| segment.rsplit_once('.')) + .map(|(_, extension)| extension) + .filter(|extension| { + !extension.is_empty() + && extension.len() <= MAX_ARTIFACT_FILE_EXTENSION_LEN + && extension.bytes().all(|byte| byte.is_ascii_alphanumeric()) + }) + .unwrap_or_else(|| fallback_artifact_extension(content_type)); + format!("{}.{extension}", sanitize_artifact_file_stem(name)) +} + +/// URL 路径给不出可用扩展名时按 content type 兜底:同一条下载路径也服务预览图, +/// 一律写 `.glb` 会把 PNG / JPEG 产物写成模型文件名。 +fn fallback_artifact_extension(content_type: Option<&str>) -> &'static str { + let content_type = content_type.unwrap_or_default(); + if content_type.starts_with("image/png") { + "png" + } else if content_type.starts_with("image/jpeg") { + "jpg" + } else if content_type.starts_with("image/jpg") { + // `image/jpg` 不是标准写法,但确有 provider 这么回;落到默认 `.glb` 会让一张 + // 预览图带上模型后缀,这里按声明值给回 jpg。 + "jpg" + } else if content_type.starts_with("image/webp") { + "webp" + } else { + "glb" + } +} + +/// 文件名主干同样来自不可信输入:路径分隔符与 `..` 会让拼接结果逃出目标目录, +/// 这里统一中性化成安全字符,并截到文件系统友好的一段长度。 +fn sanitize_artifact_file_stem(name: &str) -> String { + let sanitized: String = name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '_' + } + }) + .take(MAX_ARTIFACT_FILE_STEM_LEN) + .collect(); + if sanitized.trim_matches('_').is_empty() { + "artifact".to_string() + } else { + sanitized + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn artifact_url(path: &str) -> TripoUrl { + TripoUrl::parse(&format!("https://cdn.example.com{path}")).expect("夹具地址必须合法") + } + + #[test] + fn artifact_urls_must_point_at_a_public_host() { + for rejected in [ + "http://127.0.0.1/model.glb", + "http://169.254.169.254/latest/meta-data/", + "http://10.1.2.3/model.glb", + "http://192.168.1.10/model.glb", + "http://172.16.0.9/model.glb", + "http://100.64.0.1/model.glb", + "http://198.18.0.1/model.glb", + "http://240.0.0.1/model.glb", + "http://0.0.0.0/model.glb", + "http://192.0.2.10/model.glb", + "http://[::1]/model.glb", + "http://[fd00::1]/model.glb", + "http://[fe80::1]/model.glb", + "http://[::ffff:127.0.0.1]/model.glb", + "http://[2001:db8::1]/model.glb", + "http://localhost/model.glb", + "http://CDN.LOCALHOST./model.glb", + ] { + assert!( + TripoUrl::parse(rejected).is_err(), + "非公网主机必须拒收:{rejected}" + ); + } + + for accepted in [ + "https://openapi.cdn.tripo3d.com/a/model.glb", + "http://1.1.1.1/model.glb", + "http://8.8.8.8/model.glb", + "http://[2606:4700::1111]/model.glb", + "http://198.20.0.1/model.glb", + "http://192.0.1.1/model.glb", + ] { + assert!( + TripoUrl::parse(accepted).is_ok(), + "公网主机必须放行:{accepted}" + ); + } + // `192.0.0.0/24` 是 IETF 协议分配段:段内拒收,紧邻的 `192.0.1.0/24` 不受影响。 + assert!(TripoUrl::parse("http://192.0.0.9/model.glb").is_err()); + assert!(TripoUrl::parse("http://192.0.1.1/model.glb").is_ok()); + } + + #[test] + fn path_like_and_oversized_stems_are_neutralized_and_capped() { + let name = format!("{}../../etc/passwd", "a".repeat(400)); + let file_name = + artifact_filename(&name, Some("model/gltf-binary"), &artifact_url("/a.glb")); + + let stem = file_name.strip_suffix(".glb").expect("扩展名应取自 URL"); + assert_eq!(stem.chars().count(), MAX_ARTIFACT_FILE_STEM_LEN); + assert!(!stem.contains('/'), "路径分隔符必须被中性化:{stem}"); + assert!(!stem.contains('.'), "`..` 必须被中性化:{stem}"); + } + + #[test] + fn fallback_extension_follows_the_declared_image_content_type() { + assert_eq!( + artifact_filename("preview", Some("image/png"), &artifact_url("/artifact")), + "preview.png" + ); + assert_eq!( + artifact_filename("preview", Some("image/jpg"), &artifact_url("/artifact")), + "preview.jpg" + ); + assert_eq!( + artifact_filename("preview", Some("image/webp"), &artifact_url("/artifact")), + "preview.webp" + ); + // 读不出形态时仍按模型产物兜底。 + assert_eq!( + artifact_filename("model", None, &artifact_url("/artifact")), + "model.glb" + ); + } + + #[test] + fn blank_stem_falls_back_to_a_fixed_name() { + assert_eq!( + artifact_filename("..", Some("model/gltf-binary"), &artifact_url("/a.glb")), + "artifact.glb" + ); + } + + #[test] + fn artifact_debug_reports_metadata_instead_of_the_payload() { + let artifact = TripoArtifactBytes { + url: artifact_url("/a.glb"), + content_type: Some("model/gltf-binary".to_string()), + content_length: Some(8), + bytes: vec![171; 8], + }; + + let debug = format!("{artifact:?}"); + assert!(debug.contains("bytes_len: 8"), "{debug}"); + assert!(!debug.contains("171"), "产物字节不能进 Debug 输出:{debug}"); + } +} diff --git a/server-rs/crates/platform-tripo/src/common/validation.rs b/server-rs/crates/platform-tripo/src/common/validation.rs new file mode 100644 index 000000000..83faf324f --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/validation.rs @@ -0,0 +1,676 @@ +use shared_contracts::model3d::common::{ + Model3dCompression, Model3dGeometryQuality, Model3dModelVersion, Model3dTextureQuality, + Model3dTextureVersion, +}; +use shared_contracts::model3d::image_to_model::Model3dImageToModelParams; +use shared_contracts::model3d::multiview_to_model::Model3dMultiviewToModelRequest; +use shared_contracts::model3d::text_to_model::Model3dTextToModelParams; +use shared_contracts::model3d::wire_str_lossy; + +use super::{TripoError, TripoField, TripoTaskHandle, TripoValidationReason}; + +pub(crate) struct TripoGenerationOptions { + pub model: Model3dModelVersion, + pub texture: Option, + pub pbr: Option, + pub texture_quality: Option, + pub texture_version: Option, + pub geometry_quality: Option, + pub face_limit: Option, + pub auto_size: Option, + pub quad: Option, + pub smart_low_poly: Option, + pub generate_parts: Option, + pub compress: Option, +} + +/// 三个提交入口共享的生成选项字段清单:文本 / 图片 / 多视图三种请求到 +/// [`TripoGenerationOptions`] 的映射全部由这一份清单派生。 +/// +/// 新增共享字段时只改这份清单,三条路径会一起跟上;若某个请求结构缺少该字段, +/// 这里会直接编译失败,而不是在运行时静默漏校验。 +macro_rules! tripo_generation_options_from { + ($($field:ident),+ $(,)?) => { + impl From<&Model3dTextToModelParams> for TripoGenerationOptions { + fn from(params: &Model3dTextToModelParams) -> Self { + Self { $($field: params.$field,)+ } + } + } + + impl From<&Model3dImageToModelParams> for TripoGenerationOptions { + fn from(params: &Model3dImageToModelParams) -> Self { + Self { $($field: params.$field,)+ } + } + } + + impl From<&Model3dMultiviewToModelRequest> for TripoGenerationOptions { + fn from(request: &Model3dMultiviewToModelRequest) -> Self { + Self { $($field: request.$field,)+ } + } + } + }; +} + +tripo_generation_options_from! { + model, + texture, + pbr, + texture_quality, + texture_version, + geometry_quality, + face_limit, + auto_size, + quad, + smart_low_poly, + generate_parts, + compress, +} + +/// v3.x 家族的模型标签,供「只支持 v3.x」类错误文案复用。 +/// +/// 标签取自契约枚举的 serde 取值([`wire_str_lossy`]),契约改名后文案跟着变, +/// 这里不再手写第二份版本字符串。 +fn v3_model_labels() -> String { + format!( + "{} and {}", + wire_str_lossy(&Model3dModelVersion::H31), + wire_str_lossy(&Model3dModelVersion::H30) + ) +} + +pub(crate) fn validate_generation_options( + options: &TripoGenerationOptions, +) -> Result<(), TripoError> { + let family = ModelFamily::from(options.model); + + if options.texture_quality.is_some() && matches!(family, ModelFamily::H25) { + return Err(invalid( + Some(TripoField::TextureQuality), + TripoValidationReason::InvalidCombination, + &format!( + "texture_quality is not supported by {}", + wire_str_lossy(&Model3dModelVersion::H25) + ), + )); + } + + if options.texture_quality.is_some_and(|quality| { + matches!(quality, Model3dTextureQuality::Fast) + && options.texture_version != Some(Model3dTextureVersion::V35) + }) { + return Err(invalid( + Some(TripoField::TextureQuality), + TripoValidationReason::InvalidCombination, + &format!( + "texture_quality=fast requires texture_version={}", + wire_str_lossy(&Model3dTextureVersion::V35) + ), + )); + } + + // 只拒绝「启用加价」的取值,与 quad / smart_low_poly / generate_parts 的处理对称: + // 非 v3 家族把 standard 传出去只会换来 provider 的「不支持该字段」,它等价于不传。 + if options + .geometry_quality + .is_some_and(|quality| matches!(quality, Model3dGeometryQuality::Detailed)) + && !family.supports_geometry_quality() + { + return Err(invalid( + Some(TripoField::GeometryQuality), + TripoValidationReason::InvalidCombination, + &format!( + "geometry_quality=detailed is only supported by {}", + v3_model_labels() + ), + )); + } + + if options.compress.is_some() && !matches!(family, ModelFamily::H31 | ModelFamily::H30) { + return Err(invalid( + Some(TripoField::Compress), + TripoValidationReason::InvalidCombination, + &format!("compress is only supported by {}", v3_model_labels()), + )); + } + + if options.auto_size == Some(true) && matches!(family, ModelFamily::H25) { + return Err(invalid( + Some(TripoField::AutoSize), + TripoValidationReason::InvalidCombination, + &format!( + "auto_size is not supported by {}", + wire_str_lossy(&Model3dModelVersion::H25) + ), + )); + } + + if options + .smart_low_poly + .is_some_and(|enabled| enabled && !matches!(family, ModelFamily::H31 | ModelFamily::H30)) + { + return Err(invalid( + Some(TripoField::SmartLowPoly), + TripoValidationReason::InvalidCombination, + &format!("smart_low_poly is only supported by {}", v3_model_labels()), + )); + } + + if options + .generate_parts + .is_some_and(|enabled| enabled && !matches!(family, ModelFamily::H31 | ModelFamily::H30)) + { + return Err(invalid( + Some(TripoField::GenerateParts), + TripoValidationReason::InvalidCombination, + &format!("generate_parts is only supported by {}", v3_model_labels()), + )); + } + + if options.quad == Some(true) + && !matches!( + family, + ModelFamily::H31 | ModelFamily::H30 | ModelFamily::P2 + ) + { + return Err(invalid( + Some(TripoField::Quad), + TripoValidationReason::InvalidCombination, + &format!( + "quad=true is only supported by v3.x models and {}", + wire_str_lossy(&Model3dModelVersion::P2) + ), + )); + } + + if options.generate_parts == Some(true) { + if options.texture != Some(false) { + return Err(invalid( + Some(TripoField::GenerateParts), + TripoValidationReason::InvalidCombination, + "generate_parts=true requires texture=false", + )); + } + if options.pbr != Some(false) { + return Err(invalid( + Some(TripoField::GenerateParts), + TripoValidationReason::InvalidCombination, + "generate_parts=true requires pbr=false", + )); + } + if options.quad == Some(true) || options.smart_low_poly == Some(true) { + return Err(invalid( + Some(TripoField::GenerateParts), + TripoValidationReason::InvalidCombination, + "generate_parts=true cannot be combined with quad or smart_low_poly", + )); + } + } + + // pbr 依赖贴图:契约里写着「依赖贴图」,api-server 侧也已按同一口径拒绝, + // 这里补上直连调用方这一层,别把「不会成立的组合」拖到 provider 那边才报错。 + if options.texture == Some(false) && options.pbr == Some(true) { + return Err(invalid( + Some(TripoField::Pbr), + TripoValidationReason::InvalidCombination, + "pbr=true requires texture=true", + )); + } + + if let Some(face_limit) = options.face_limit { + let (minimum, maximum) = face_limit_bounds(family, options); + if face_limit < minimum || face_limit > maximum { + return Err(invalid( + Some(TripoField::FaceLimit), + TripoValidationReason::OutOfRange, + &format!( + "face_limit must be between {minimum} and {maximum} for the selected model and options" + ), + )); + } + } + + Ok(()) +} + +#[derive(Clone, Copy)] +enum ModelFamily { + H25, + H30, + H31, + P1, + P2, +} + +impl From for ModelFamily { + fn from(model: Model3dModelVersion) -> Self { + match model { + Model3dModelVersion::H25 => Self::H25, + Model3dModelVersion::H30 => Self::H30, + Model3dModelVersion::H31 => Self::H31, + Model3dModelVersion::P1 => Self::P1, + Model3dModelVersion::P2 => Self::P2, + } + } +} + +impl ModelFamily { + /// 是否支持 `geometry_quality`:只有 v3.x 家族有这一档。 + const fn supports_geometry_quality(self) -> bool { + matches!(self, Self::H31 | Self::H30) + } +} + +/// 把「等于 provider 默认档」的几何质量收敛成「不发」。 +/// +/// 不支持的家族传 `standard` 与不传完全等价(两边的默认档都是 standard,定价也同价), +/// 但传出去会让 provider 以「不支持该字段」拒绝整次提交,所以这里直接不发。 +/// 非默认档(`detailed`)在预检里已经被拒,不会走到这里。 +pub(crate) fn wire_geometry_quality( + model: Model3dModelVersion, + quality: Option, +) -> Option { + match quality { + Some(Model3dGeometryQuality::Standard) + if !ModelFamily::from(model).supports_geometry_quality() => + { + None + } + other => other, + } +} + +fn face_limit_bounds(family: ModelFamily, options: &TripoGenerationOptions) -> (i64, i64) { + if options.smart_low_poly == Some(true) { + return if options.quad == Some(true) { + (500, 10_000) + } else { + (500, 20_000) + }; + } + + match family { + ModelFamily::H25 => (1, 500_000), + ModelFamily::H30 => { + if options.quad == Some(true) { + (1, 150_000) + } else if options.geometry_quality == Some(Model3dGeometryQuality::Detailed) { + (1, 2_000_000) + } else { + (1, 1_000_000) + } + } + ModelFamily::H31 => { + if options.quad == Some(true) { + (1, 150_000) + } else if options.geometry_quality == Some(Model3dGeometryQuality::Detailed) { + (1, 2_000_000) + } else { + (1, 1_500_000) + } + } + ModelFamily::P1 => (50, 20_000), + ModelFamily::P2 => { + if options.quad == Some(true) { + (48, 25_000) + } else { + (48, 50_000) + } + } + } +} + +fn invalid(field: Option, reason: TripoValidationReason, message: &str) -> TripoError { + TripoError::InvalidParameters { + field, + reason, + message: message.into(), + } +} + +pub(crate) fn validate_task_id(task_id: &str) -> Result<(), TripoError> { + if task_id.trim().is_empty() { + return Err(TripoError::InvalidParameters { + field: Some(TripoField::TaskId), + reason: TripoValidationReason::Required, + message: "task_id must not be blank".into(), + }); + } + Ok(()) +} + +/// 提交成功后立刻收口 provider 返回的 task id。 +/// +/// 空白 id 是 provider 违约,不是调用方参数问题:必须在提交调用附近以 `OutputSchema` +/// 失败,而不是拖到下游 `get_task` 才报错——那时任务已经提交出去,成为无法追踪的孤儿。 +pub(crate) fn submitted_task_handle(task_id: String) -> Result { + let task_id = task_id.trim().to_string(); + if task_id.is_empty() { + return Err(TripoError::OutputSchema { + task_id: "submit".into(), + message: "submit response has no task_id".into(), + }); + } + Ok(TripoTaskHandle { task_id }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 只给必填项的选项:不应触发任何组合规则,任何模型版本都必须放行。 + fn minimal_options(model: Model3dModelVersion) -> TripoGenerationOptions { + TripoGenerationOptions { + model, + texture: None, + pbr: None, + texture_quality: None, + texture_version: None, + geometry_quality: None, + face_limit: None, + auto_size: None, + quad: None, + smart_low_poly: None, + generate_parts: None, + compress: None, + } + } + + /// 取出「被拒字段 + 拒绝原因」,用于断言错误确实来自预期的规则。 + fn rejection(error: TripoError) -> Option<(Option, TripoValidationReason)> { + match error { + TripoError::InvalidParameters { field, reason, .. } => Some((field, reason)), + _ => None, + } + } + + #[test] + fn minimal_options_pass_for_every_model_version() { + for model in Model3dModelVersion::ALL { + let options = minimal_options(*model); + if let Err(error) = validate_generation_options(&options) { + panic!("{model:?} 的最简选项应放行,实际被拒:{error}"); + } + } + } + + /// `texture_quality=fast` 必须显式配 `texture_version=v3.5-20260815`。 + #[test] + fn fast_texture_quality_requires_v35_texture_version() { + for texture_version in [ + None, + Some(Model3dTextureVersion::V30), + Some(Model3dTextureVersion::V25), + ] { + let mut options = minimal_options(Model3dModelVersion::H31); + options.texture = Some(true); + options.texture_quality = Some(Model3dTextureQuality::Fast); + options.texture_version = texture_version; + + let error = + validate_generation_options(&options).expect_err("fast 未配 v3.5 贴图版本应被拒"); + assert_eq!( + rejection(error), + Some(( + Some(TripoField::TextureQuality), + TripoValidationReason::InvalidCombination + )), + "texture_version={texture_version:?}" + ); + } + + let mut options = minimal_options(Model3dModelVersion::H31); + options.texture = Some(true); + options.texture_quality = Some(Model3dTextureQuality::Fast); + options.texture_version = Some(Model3dTextureVersion::V35); + validate_generation_options(&options).expect("fast + v3.5 应放行"); + } + + /// `generate_parts=true` 只在「白模 + 不叠几何模式」下成立。 + #[test] + fn generate_parts_requires_plain_geometry_options() { + let cases = [ + ( + "白模且无几何模式", + Some(false), + Some(false), + None, + None, + true, + ), + ("未显式 texture=false", None, Some(false), None, None, false), + ("texture=true", Some(true), Some(false), None, None, false), + ("未显式 pbr=false", Some(false), None, None, None, false), + ("pbr=true", Some(false), Some(true), None, None, false), + ("叠 quad", Some(false), Some(false), Some(true), None, false), + ( + "叠 smart_low_poly", + Some(false), + Some(false), + None, + Some(true), + false, + ), + ]; + + for (label, texture, pbr, quad, smart_low_poly, should_pass) in cases { + let mut options = minimal_options(Model3dModelVersion::H31); + options.texture = texture; + options.pbr = pbr; + options.quad = quad; + options.smart_low_poly = smart_low_poly; + options.generate_parts = Some(true); + + let result = validate_generation_options(&options); + assert_eq!( + result.is_ok(), + should_pass, + "{label} 的期望结果不符:{result:?}" + ); + if let Err(error) = result { + assert_eq!( + rejection(error), + Some(( + Some(TripoField::GenerateParts), + TripoValidationReason::InvalidCombination + )), + "{label}" + ); + } + } + } + + /// pbr 依赖贴图:`texture=false` 与 `pbr=true` 是自相矛盾的组合,提交前就要拒绝。 + #[test] + fn pbr_requires_texture() { + let mut options = minimal_options(Model3dModelVersion::H31); + options.texture = Some(false); + options.pbr = Some(true); + + let error = validate_generation_options(&options) + .expect_err("texture=false 与 pbr=true 同时给出必须被拒"); + assert_eq!( + rejection(error), + Some(( + Some(TripoField::Pbr), + TripoValidationReason::InvalidCombination + )) + ); + + // 显式带贴图时同一个 pbr 取值合法。 + options.texture = Some(true); + validate_generation_options(&options).expect("texture=true 时 pbr=true 应放行"); + } + + /// `quad=true` 只对 v3.x 家族与 P2 放行,其余家族按 Quad 字段拒绝。 + #[test] + fn quad_is_limited_to_v3_families_and_p2() { + for model in Model3dModelVersion::ALL { + let allowed = matches!( + model, + Model3dModelVersion::H31 | Model3dModelVersion::H30 | Model3dModelVersion::P2 + ); + let mut options = minimal_options(*model); + options.quad = Some(true); + + match validate_generation_options(&options) { + Ok(()) => assert!(allowed, "{model:?} 不应允许 quad=true"), + Err(error) => { + assert!(!allowed, "{model:?} 应允许 quad=true,实际被拒:{error}"); + assert_eq!( + rejection(error), + Some(( + Some(TripoField::Quad), + TripoValidationReason::InvalidCombination + )), + "{model:?}" + ); + } + } + } + } + + /// `face_limit` 边界:min-1 拒绝、min / max 放行、max+1 拒绝。 + #[test] + fn face_limit_uses_the_bounds_of_the_selected_mode() { + let cases = [ + ( + "H31 默认", + Model3dModelVersion::H31, + None, + None, + 1, + 1_500_000, + ), + ("H25 默认", Model3dModelVersion::H25, None, None, 1, 500_000), + ("P1 默认", Model3dModelVersion::P1, None, None, 50, 20_000), + ( + "P2 quad", + Model3dModelVersion::P2, + Some(true), + None, + 48, + 25_000, + ), + ( + "H31 智能低模", + Model3dModelVersion::H31, + None, + Some(true), + 500, + 20_000, + ), + ( + "H31 智能低模 + quad", + Model3dModelVersion::H31, + Some(true), + Some(true), + 500, + 10_000, + ), + ]; + + for (label, model, quad, smart_low_poly, minimum, maximum) in cases { + for (face_limit, should_pass) in [ + (minimum - 1, false), + (minimum, true), + (maximum, true), + (maximum + 1, false), + ] { + let mut options = minimal_options(model); + options.quad = quad; + options.smart_low_poly = smart_low_poly; + options.face_limit = Some(face_limit); + + let result = validate_generation_options(&options); + assert_eq!( + result.is_ok(), + should_pass, + "{label} face_limit={face_limit} 期望通过={should_pass},实际 {result:?}" + ); + if let Err(error) = result { + assert_eq!( + rejection(error), + Some(( + Some(TripoField::FaceLimit), + TripoValidationReason::OutOfRange + )), + "{label} face_limit={face_limit}" + ); + } + } + } + } + + /// 几何质量只拒绝「会启用高清几何」的 detailed:非 v3 家族的 standard 必须放行, + /// 否则定价表里已定价的 v2.5 / P1 / P2 又会被这条规则挡回去。 + #[test] + fn geometry_quality_only_rejects_the_enabling_value_for_non_v3_families() { + for model in Model3dModelVersion::ALL { + let supported = matches!(model, Model3dModelVersion::H31 | Model3dModelVersion::H30); + + let mut options = minimal_options(*model); + options.geometry_quality = Some(Model3dGeometryQuality::Standard); + assert!( + validate_generation_options(&options).is_ok(), + "{model:?} 的 geometry_quality=standard 必须放行" + ); + + options.geometry_quality = Some(Model3dGeometryQuality::Detailed); + let result = validate_generation_options(&options); + assert_eq!( + result.is_ok(), + supported, + "{model:?} detailed 的期望结果不符" + ); + if let Err(error) = result { + assert_eq!( + rejection(error), + Some(( + Some(TripoField::GeometryQuality), + TripoValidationReason::InvalidCombination + )), + "{model:?}" + ); + } + } + } + + /// 不支持该参数的家族把 standard 收敛成「不发」:传出去只会换来 provider 的 + /// 「不支持该字段」,而它与不传完全等价(两边默认档都是 standard,定价也同价)。 + #[test] + fn wire_geometry_quality_drops_the_default_for_families_without_the_flag() { + for model in Model3dModelVersion::ALL { + let supported = matches!(model, Model3dModelVersion::H31 | Model3dModelVersion::H30); + assert_eq!( + wire_geometry_quality(*model, Some(Model3dGeometryQuality::Standard)), + supported.then_some(Model3dGeometryQuality::Standard), + "{model:?}" + ); + assert_eq!(wire_geometry_quality(*model, None), None, "{model:?}"); + assert_eq!( + wire_geometry_quality(*model, Some(Model3dGeometryQuality::Detailed)), + Some(Model3dGeometryQuality::Detailed), + "{model:?} 非默认档不在这里处理,交给预检拒绝" + ); + } + } + + /// 空白 task id 是 provider 违约,按 OutputSchema 失败而不是参数错误。 + #[test] + fn blank_submitted_task_id_is_an_output_schema_error() { + for blank in ["", " ", "\t\n"] { + let error = submitted_task_handle(blank.to_string()).expect_err("空白 task id 应被拒"); + assert!( + matches!(error, TripoError::OutputSchema { .. }), + "空白 task id 应按 OutputSchema 失败,实际:{error}" + ); + } + + assert_eq!( + submitted_task_handle(" task-1 ".to_string()) + .expect("非空 task id 应放行") + .task_id, + "task-1" + ); + } +} diff --git a/server-rs/crates/platform-tripo/src/common/wire.rs b/server-rs/crates/platform-tripo/src/common/wire.rs new file mode 100644 index 000000000..64aa5b30f --- /dev/null +++ b/server-rs/crates/platform-tripo/src/common/wire.rs @@ -0,0 +1,25 @@ +//! 契约枚举 → provider 请求里要发的线上取值。 +//! +//! 取值只由 `shared-contracts` 里的 serde `rename` / `rename_all` 定义:provider 侧 +//! 不再维护第二份字面量,两处各写一份、改一处就静默分叉的问题不会再出现。 + +use serde::Serialize; + +use super::TripoError; + +/// 契约枚举 → provider 线上取值。 +/// +/// 契约枚举都是「序列化成字符串」的形态;真拿到非字符串说明契约被改坏,这里归一成 +/// provider 内部错误,而不是 panic 或静默兜底。 +pub(crate) fn wire(value: &T) -> Result { + shared_contracts::model3d::wire_str(value).map_err(|error| TripoError::Sdk { + message: format!("契约枚举未按字符串序列化:{error}"), + }) +} + +/// [`wire`] 的 `Option` 形态:缺省仍是缺省。 +pub(crate) fn wire_option( + value: Option<&T>, +) -> Result, TripoError> { + value.map(wire).transpose() +} diff --git a/server-rs/crates/platform-tripo/src/image_to_model/client.rs b/server-rs/crates/platform-tripo/src/image_to_model/client.rs new file mode 100644 index 000000000..7311e03d2 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/image_to_model/client.rs @@ -0,0 +1,146 @@ +use shared_contracts::model3d::image_to_model::Model3dImageToModelParams; +use tripo3d_sdk::{models::FileInput, params::ImageToModelParams}; + +use crate::common::{ + TripoError, TripoField, TripoProviderClient, TripoTaskHandle, TripoValidationReason, + extra_fields, submitted_task_handle, wire, wire_geometry_quality, wire_option, +}; + +use super::validation::validate_image_to_model_params; + +/// provider 能读取的图片输入形态。 +/// +/// 平台层只接受站内引用,所以调用方只会产出这两种形态:先上传拿 `file_token`, +/// 或给出公网可读地址。不提供裸字符串变体,避免 SDK 把字符串按前缀推断成 task_id。 +/// +/// `PublicUrl` 只做非空白校验、不做 scheme / host 白名单:Tripo API 文档允许 `url` 指向任意 +/// 公网可读地址,本地再收窄会误伤文档允许的形态。当前生产路径全部走 `upload_image` → +/// `FileToken`,`PublicUrl` 只由冒烟示例构造,没有不可信输入的现役入口;将来若把外部传入的 +/// 地址接到这条路径,应在构造侧按 `TripoUrl::parse` 的口径校验 scheme 并拒绝 userinfo / +/// 内网主机(评审 #65,按决定不在本层加校验)。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TripoImageInput { + FileToken(String), + PublicUrl(String), +} + +impl TripoImageInput { + fn to_sdk_file_input(&self) -> Result { + let (value, kind) = match self { + Self::FileToken(value) => (value, "file_token"), + Self::PublicUrl(value) => (value, "url"), + }; + let value = value.trim(); + if value.is_empty() { + return Err(TripoError::InvalidParameters { + field: Some(TripoField::Input), + reason: TripoValidationReason::Required, + message: format!("image {kind} must not be blank"), + }); + } + Ok(match self { + Self::FileToken(_) => FileInput::FileToken(value.to_string()), + Self::PublicUrl(_) => FileInput::Url(value.to_string()), + }) + } +} + +impl TripoProviderClient { + /// 把图片字节上传到 provider,换取可直接用于生成任务的 `file_token`。 + /// + /// 站内图片保存在私有 OSS 上,provider 无法直接读取,所以图片输入统一先上传再提交, + /// 不把带签名的临时地址交给第三方。 + /// + /// TODO(oss-direct-read): 「让 provider 自己读站内对象」(`PublicUrl` 式的签名地址,或 + /// 对端直读 OSS)能省掉这次字节中转,但会把私有对象与带时效的读取凭据交给第三方,安全 + /// 口径与可行性需要先讨论;讨论前只保留 `FileToken` 这条现役路径。 + pub async fn upload_image( + &self, + bytes: Vec, + file_name: &str, + content_type: &str, + ) -> Result { + let file_name = file_name.trim(); + if file_name.is_empty() { + return Err(TripoError::InvalidParameters { + field: Some(TripoField::Input), + reason: TripoValidationReason::Required, + message: "upload file name must not be blank".into(), + }); + } + if bytes.is_empty() { + return Err(TripoError::InvalidParameters { + field: Some(TripoField::Input), + reason: TripoValidationReason::Required, + message: "upload body must not be empty".into(), + }); + } + let content_type = content_type.trim(); + let uploaded = self + .client + .upload_file( + bytes, + file_name.to_string(), + (!content_type.is_empty()).then_some(content_type), + ) + .await + .map_err(TripoError::from)?; + let token = uploaded.file_token.trim().to_string(); + if token.is_empty() { + return Err(TripoError::OutputSchema { + task_id: "upload".into(), + message: "upload response has no file_token".into(), + }); + } + Ok(TripoImageInput::FileToken(token)) + } + + /// 图片输入由调用方解析后传入:provider 只负责把它交给 SDK, + /// 站内资源 / 素材的归属校验属于平台层。 + pub async fn submit_image_to_model( + &self, + input: &TripoImageInput, + params: &Model3dImageToModelParams, + ) -> Result { + let input = input.to_sdk_file_input()?; + validate_image_to_model_params(params)?; + + let task_id = self + .client + .image_to_model(to_sdk_params(input, params)?) + .await + .map_err(TripoError::from)?; + submitted_task_handle(task_id) + } +} + +fn to_sdk_params( + input: FileInput, + params: &Model3dImageToModelParams, +) -> Result { + Ok(ImageToModelParams { + input, + model: Some(wire(¶ms.model)?), + enable_image_autofix: params.enable_image_autofix, + model_seed: params.model_seed, + texture_seed: params.texture_seed, + texture: params.texture, + pbr: params.pbr, + texture_quality: wire_option(params.texture_quality.as_ref())?, + extra: extra_fields(params.texture_version, params.delight)?, + texture_alignment: wire_option(params.texture_alignment.as_ref())?, + geometry_quality: wire_option( + wire_geometry_quality(params.model, params.geometry_quality).as_ref(), + )?, + face_limit: params.face_limit, + auto_size: params.auto_size, + orientation: wire_option(params.orientation.as_ref())?, + quad: params.quad, + smart_low_poly: params.smart_low_poly, + generate_parts: params.generate_parts, + compress: wire_option(params.compress.as_ref())?, + export_uv: params.export_uv, + export_orientation: wire_option(params.export_orientation.as_ref())?, + style: None, + }) +} diff --git a/server-rs/crates/platform-tripo/src/image_to_model/mod.rs b/server-rs/crates/platform-tripo/src/image_to_model/mod.rs new file mode 100644 index 000000000..a4a16bdda --- /dev/null +++ b/server-rs/crates/platform-tripo/src/image_to_model/mod.rs @@ -0,0 +1,6 @@ +mod client; +pub mod result; +mod validation; + +pub use client::TripoImageInput; +pub use validation::validate_image_to_model_params; diff --git a/server-rs/crates/platform-tripo/src/image_to_model/result.rs b/server-rs/crates/platform-tripo/src/image_to_model/result.rs new file mode 100644 index 000000000..0283a6e41 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/image_to_model/result.rs @@ -0,0 +1,7 @@ +use crate::common::TripoUrl; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoImageToModelResult { + pub model_url: TripoUrl, + pub rendered_image_url: TripoUrl, +} diff --git a/server-rs/crates/platform-tripo/src/image_to_model/validation.rs b/server-rs/crates/platform-tripo/src/image_to_model/validation.rs new file mode 100644 index 000000000..e3ad4d223 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/image_to_model/validation.rs @@ -0,0 +1,11 @@ +use shared_contracts::model3d::image_to_model::Model3dImageToModelParams; + +use crate::common::{TripoError, validate_generation_options}; + +/// image-to-model 的请求预检:只覆盖生成参数与模型能力组合, +/// 图片本体是否可解析由调用方保证(provider 在 submit 时仍会拒绝空输入)。 +pub fn validate_image_to_model_params( + params: &Model3dImageToModelParams, +) -> Result<(), TripoError> { + validate_generation_options(¶ms.into()) +} diff --git a/server-rs/crates/platform-tripo/src/lib.rs b/server-rs/crates/platform-tripo/src/lib.rs new file mode 100644 index 000000000..101dfa523 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/lib.rs @@ -0,0 +1,17 @@ +//! Tripo text-to-model provider adapter. +//! +//! The SDK is deliberately private to this crate. Callers receive provider +//! DTOs and `TripoError`, never `tripo3d_sdk` types or its poller API. + +mod common; +pub mod image_to_model; +pub mod multiview_to_model; +pub mod text_to_model; + +pub use common::{ + TripoArtifactBytes, TripoError, TripoField, TripoProviderClient, TripoSettings, + TripoTaskFailure, TripoTaskHandle, TripoTaskOutput, TripoTaskSnapshot, TripoTaskType, TripoUrl, + TripoValidationReason, +}; +pub use image_to_model::{TripoImageInput, validate_image_to_model_params}; +pub use text_to_model::validate_text_to_model_params; diff --git a/server-rs/crates/platform-tripo/src/multiview_to_model/client.rs b/server-rs/crates/platform-tripo/src/multiview_to_model/client.rs new file mode 100644 index 000000000..41d2ffd85 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/multiview_to_model/client.rs @@ -0,0 +1,253 @@ +use serde_json::Value; +use shared_contracts::model3d::multiview_to_model::{ + Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput, +}; +use tripo3d_sdk::params::MultiviewToModelParams; + +use crate::common::{ + TripoError, TripoField, TripoGenerationOptions, TripoProviderClient, TripoTaskHandle, + TripoValidationReason, extra_fields, submitted_task_handle, validate_generation_options, + validate_task_id, wire, wire_geometry_quality, wire_option, +}; + +/// `inputs` 的线上字段名。SDK 的位置数组形态表达不了文档推荐的 view-key 形态, +/// 因此这个字段由平台自己按文档构造后经 `extra` 透传(见 `view_inputs_wire`)。 +const MODEL3D_MULTIVIEW_INPUTS_FIELD: &str = "inputs"; + +impl TripoProviderClient { + pub async fn submit_multiview_to_model( + &self, + request: &Model3dMultiviewToModelRequest, + ) -> Result { + // 与 text / image 路径共用同一份字段映射(见 common::validation 的宏), + // 新增共享字段时三条路径一起跟上。 + validate_generation_options(&TripoGenerationOptions::from(request))?; + // 视图校验与 `inputs` 构造走同一条路径,不在这里重复一遍规则。 + let task_id = self + .client + .multiview_to_model(to_sdk_params(request)?) + .await + .map_err(TripoError::from)?; + submitted_task_handle(task_id) + } +} + +/// 视图输入的线上形态:Tripo 文档推荐的 view-key 数组,每项只含一个视图键,值为显式对象。 +/// +/// 不走 SDK 的 `MultiviewToModelParams::from_views`:它产出的是位置数组 +/// `["<裸字符串>", "", ...]`,服务端只能按前缀猜那是 URL、file_token 还是 task_id +/// (见 review #77)。文档允许把值写成嵌套的 `{url}` / `{file_token}`,这里就按这个形态 +/// 构造,种类由契约字段决定,链路上没有猜测。 +/// +/// 空白(含纯空白)视图按「未提供」处理:校验与请求构造共用这一条规则。 +/// `front` 必填、至少两张视图的口径与 SDK 自己的校验一致,只是提前到提交前报错。 +fn view_inputs_wire(inputs: &Model3dMultiviewInputs) -> Result, TripoError> { + match inputs { + Model3dMultiviewInputs::Views { + front, + left, + back, + right, + } => { + let front = view_value(front).ok_or_else(|| TripoError::InvalidParameters { + field: Some(TripoField::Inputs), + reason: TripoValidationReason::Required, + message: "front view is required".into(), + })?; + let mut entries = vec![view_entry("front", front)]; + let mut provided = 0; + for (key, view) in [("left", left), ("back", back), ("right", right)] { + if let Some(value) = view.as_ref().and_then(view_value) { + provided += 1; + entries.push(view_entry(key, value)); + } + } + if provided == 0 { + return Err(TripoError::InvalidParameters { + field: Some(TripoField::Inputs), + reason: TripoValidationReason::InvalidCombination, + message: "at least two views are required".into(), + }); + } + Ok(entries) + } + Model3dMultiviewInputs::TaskId { task_id } => { + validate_task_id(task_id)?; + Ok(vec![view_entry( + "task_id", + Value::String(task_id.trim().to_string()), + )]) + } + } +} + +/// 单个视图的显式取值:URL 与 file_token 各自一种形态,不合并成裸字符串。 +/// +/// 只做非空白校验,不在这里收窄地址范围(例如拒绝 userinfo / 内网主机)。 +/// +/// TODO(multiview-url-validation): 现在的理由只是「还没接线」,不是在给这个字段发放行条: +/// 能构造 `Model3dMultiviewInputs::Url` 的只有冒烟示例与接口测试,`api-server` 里没有任何 +/// 路由或调用方会构造它,所以「客户端可以提交任意地址」今天不成立。等真正接线时必须先在 +/// 构造侧补校验——协议只允许 http/https、拒绝带账号密码的地址,并拒绝本机回环与内网主机 +/// (口径同 `TripoUrl::parse`)。本条原先写「见 ADR 0001 的边界约定」,但 ADR 0001 里并没有 +/// 这条约定,是引错了(评审 #66);image-to-model 的 `PublicUrl` 是同一处待办。 +fn view_value(view: &Model3dViewInput) -> Option { + match view { + Model3dViewInput::Url { url } => { + let url = url.trim(); + (!url.is_empty()).then(|| serde_json::json!({ "url": url })) + } + Model3dViewInput::FileToken { file_token } => { + let file_token = file_token.trim(); + (!file_token.is_empty()).then(|| serde_json::json!({ "file_token": file_token })) + } + } +} + +fn view_entry(key: &str, value: Value) -> Value { + let mut entry = serde_json::Map::new(); + entry.insert(key.to_string(), value); + Value::Object(entry) +} + +fn to_sdk_params( + request: &Model3dMultiviewToModelRequest, +) -> Result { + let mut params = MultiviewToModelParams::default(); + params.extra.insert( + MODEL3D_MULTIVIEW_INPUTS_FIELD.to_string(), + Value::Array(view_inputs_wire(&request.inputs)?), + ); + params.model = Some(wire(&request.model)?); + params.model_seed = request.model_seed; + params.texture_seed = request.texture_seed; + params.texture = request.texture; + params.pbr = request.pbr; + params.texture_quality = wire_option(request.texture_quality.as_ref())?; + params + .extra + .extend(extra_fields(request.texture_version, request.delight)?); + params.geometry_quality = + wire_option(wire_geometry_quality(request.model, request.geometry_quality).as_ref())?; + params.texture_alignment = wire_option(request.texture_alignment.as_ref())?; + params.face_limit = request.face_limit; + params.auto_size = request.auto_size; + params.orientation = wire_option(request.orientation.as_ref())?; + params.quad = request.quad; + params.smart_low_poly = request.smart_low_poly; + params.generate_parts = request.generate_parts; + params.compress = wire_option(request.compress.as_ref())?; + params.export_uv = request.export_uv; + params.export_orientation = wire_option(request.export_orientation.as_ref())?; + Ok(params) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn views_request(inputs: Value) -> Model3dMultiviewToModelRequest { + serde_json::from_value(json!({ + "inputs": inputs, + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard" + })) + .expect("测试请求应可反序列化") + } + + fn serialized_inputs(inputs: Value) -> Value { + let request = views_request(inputs); + let params = to_sdk_params(&request).expect("视图输入应可映射成 SDK 参数"); + serde_json::to_value(params).expect("SDK 参数应可序列化")["inputs"].clone() + } + + /// 视图按文档推荐的 view-key 形态发出,值是显式对象:服务端不需要按前缀猜 + /// URL / file_token / task_id,file_token 也就不会被误当成 task_id。 + #[test] + fn views_are_sent_as_view_key_entries_with_explicit_values() { + assert_eq!( + serialized_inputs(json!({ + "kind": "views", + "front": { "kind": "url", "url": " https://example.com/front.png " }, + "left": { "kind": "fileToken", "fileToken": "tripo-token-1" }, + "back": null, + "right": null + })), + json!([ + { "front": { "url": "https://example.com/front.png" } }, + { "left": { "file_token": "tripo-token-1" } } + ]) + ); + } + + /// 空白视图(含纯空白)等于没给:不会退化成空对象或空字符串发出去。 + #[test] + fn blank_views_are_treated_as_missing() { + assert_eq!( + serialized_inputs(json!({ + "kind": "views", + "front": { "kind": "url", "url": "https://example.com/front.png" }, + "left": { "kind": "fileToken", "fileToken": " " }, + "back": { "kind": "url", "url": "https://example.com/back.png" }, + "right": { "kind": "fileToken", "fileToken": "" } + })), + json!([ + { "front": { "url": "https://example.com/front.png" } }, + { "back": { "url": "https://example.com/back.png" } } + ]) + ); + } + + /// front 必填、至少两张视图的口径在这里就报错,不把请求发出去。 + #[test] + fn views_require_front_and_at_least_two_entries() { + for (inputs, expected) in [ + ( + json!({ + "kind": "views", + "front": { "kind": "url", "url": " " }, + "left": { "kind": "url", "url": "https://example.com/left.png" } + }), + "front view is required", + ), + ( + json!({ + "kind": "views", + "front": { "kind": "url", "url": "https://example.com/front.png" }, + "left": null, + "back": null, + "right": null + }), + "at least two views are required", + ), + ] { + let error = to_sdk_params(&views_request(inputs)) + .err() + .map(|error| error.to_string()) + .unwrap_or_else(|| panic!("{expected} 应被拒绝")); + assert!(error.contains(expected), "实际错误为:{error}"); + } + } + + /// 复用已有任务时也走同一条构造路径:task id 单独校验后放进 `[{task_id}]`。 + #[test] + fn task_id_reuse_is_validated_and_wrapped() { + assert_eq!( + serialized_inputs(json!({ "kind": "taskId", "taskId": " tripo-task-1 " })), + json!([{ "task_id": "tripo-task-1" }]) + ); + + let error = to_sdk_params(&views_request(json!({ "kind": "taskId", "taskId": " " }))) + .err() + .expect("空白 task id 应被拒绝"); + assert!( + error.to_string().contains("task_id"), + "实际错误为:{}", + error + ); + } +} diff --git a/server-rs/crates/platform-tripo/src/multiview_to_model/mod.rs b/server-rs/crates/platform-tripo/src/multiview_to_model/mod.rs new file mode 100644 index 000000000..61ca8d331 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/multiview_to_model/mod.rs @@ -0,0 +1,2 @@ +mod client; +pub mod result; diff --git a/server-rs/crates/platform-tripo/src/multiview_to_model/result.rs b/server-rs/crates/platform-tripo/src/multiview_to_model/result.rs new file mode 100644 index 000000000..257ba8ac8 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/multiview_to_model/result.rs @@ -0,0 +1,7 @@ +use crate::common::TripoUrl; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoMultiviewToModelResult { + pub model_url: TripoUrl, + pub rendered_image_url: TripoUrl, +} diff --git a/server-rs/crates/platform-tripo/src/text_to_model/client.rs b/server-rs/crates/platform-tripo/src/text_to_model/client.rs new file mode 100644 index 000000000..fa5f9909b --- /dev/null +++ b/server-rs/crates/platform-tripo/src/text_to_model/client.rs @@ -0,0 +1,53 @@ +use crate::common::{ + TripoError, TripoProviderClient, TripoTaskHandle, extra_fields, submitted_task_handle, wire, + wire_geometry_quality, wire_option, +}; +use shared_contracts::model3d::text_to_model::Model3dTextToModelParams; + +use super::validation::validate_text_to_model_params; + +impl TripoProviderClient { + /// 提交 text-to-model 任务;命名与另两个入口 `submit_image_to_model` / + /// `submit_multiview_to_model` 保持一致,方法名自带端点。 + pub async fn submit_text_to_model( + &self, + params: &Model3dTextToModelParams, + ) -> Result { + validate_text_to_model_params(params)?; + let task_id = self + .client + .text_to_model(to_sdk_params(params)?) + .await + .map_err(TripoError::from)?; + submitted_task_handle(task_id) + } +} + +fn to_sdk_params( + params: &Model3dTextToModelParams, +) -> Result { + Ok(tripo3d_sdk::params::TextToModelParams { + prompt: params.prompt.clone(), + model: Some(wire(¶ms.model)?), + negative_prompt: params.negative_prompt.clone(), + image_seed: params.image_seed, + model_seed: params.model_seed, + texture_seed: params.texture_seed, + texture: params.texture, + pbr: params.pbr, + texture_quality: wire_option(params.texture_quality.as_ref())?, + extra: extra_fields(params.texture_version, params.delight)?, + geometry_quality: wire_option( + wire_geometry_quality(params.model, params.geometry_quality).as_ref(), + )?, + face_limit: params.face_limit, + auto_size: params.auto_size, + quad: params.quad, + smart_low_poly: params.smart_low_poly, + generate_parts: params.generate_parts, + compress: wire_option(params.compress.as_ref())?, + export_uv: params.export_uv, + export_orientation: wire_option(params.export_orientation.as_ref())?, + style: None, + }) +} diff --git a/server-rs/crates/platform-tripo/src/text_to_model/mod.rs b/server-rs/crates/platform-tripo/src/text_to_model/mod.rs new file mode 100644 index 000000000..43e3ccce4 --- /dev/null +++ b/server-rs/crates/platform-tripo/src/text_to_model/mod.rs @@ -0,0 +1,5 @@ +mod client; +pub mod result; +mod validation; + +pub use validation::validate_text_to_model_params; diff --git a/server-rs/crates/platform-tripo/src/text_to_model/result.rs b/server-rs/crates/platform-tripo/src/text_to_model/result.rs new file mode 100644 index 000000000..5b4f5a2bf --- /dev/null +++ b/server-rs/crates/platform-tripo/src/text_to_model/result.rs @@ -0,0 +1,8 @@ +use crate::common::TripoUrl; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TripoTextToModelResult { + pub model_url: TripoUrl, + pub rendered_image_url: TripoUrl, + pub generated_image_url: TripoUrl, +} diff --git a/server-rs/crates/platform-tripo/src/text_to_model/validation.rs b/server-rs/crates/platform-tripo/src/text_to_model/validation.rs new file mode 100644 index 000000000..1c3d6751e --- /dev/null +++ b/server-rs/crates/platform-tripo/src/text_to_model/validation.rs @@ -0,0 +1,38 @@ +use shared_contracts::model3d::text_to_model::Model3dTextToModelParams; + +use crate::common::{TripoError, TripoField, TripoValidationReason, validate_generation_options}; + +/// text-to-model 的请求预检:调用方在产生 provider 副作用之前调用, +/// 用于把参数错误映射成调用方的入参错误,而不是等 submit 失败。 +pub fn validate_text_to_model_params(params: &Model3dTextToModelParams) -> Result<(), TripoError> { + if params.prompt.trim().is_empty() { + return Err(TripoError::InvalidParameters { + field: Some(TripoField::Prompt), + reason: TripoValidationReason::Required, + message: "prompt must not be blank".into(), + }); + } + + if params.prompt.chars().count() > 1024 { + return Err(TripoError::InvalidParameters { + field: Some(TripoField::Prompt), + reason: TripoValidationReason::OutOfRange, + message: "prompt must not exceed 1024 characters".into(), + }); + } + if params + .negative_prompt + .as_deref() + .is_some_and(|value| value.chars().count() > 255) + { + return Err(TripoError::InvalidParameters { + field: Some(TripoField::NegativePrompt), + reason: TripoValidationReason::OutOfRange, + message: "negative_prompt must not exceed 255 characters".into(), + }); + } + + validate_generation_options(¶ms.into())?; + + Ok(()) +} diff --git a/server-rs/crates/shared-contracts/Cargo.toml b/server-rs/crates/shared-contracts/Cargo.toml index 277970c0a..2f2f16142 100644 --- a/server-rs/crates/shared-contracts/Cargo.toml +++ b/server-rs/crates/shared-contracts/Cargo.toml @@ -8,13 +8,11 @@ license.workspace = true # 默认给 api-server 等原生后端暴露资产上传 DTO;SpacetimeDB WASM 路径通过 workspace 依赖关闭默认 feature。 default = ["oss-contracts"] oss-contracts = [] -# 导出 TS 绑定:生成物落在 `packages/shared/src/contracts/generated/`。 # 解析边界留痕不用 feature 开关:`shared-contracts` 只暴露一个可注册的回调, # 由壳层(AGC)在启动时接到自己的 `app_log!` 上,见 `asset_kind.rs`。 -ts-bindings = ["dep:ts-rs"] [dependencies] serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } -ts-rs = { workspace = true, optional = true } +ts-rs = { workspace = true } diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index f06c23d9c..17c68664d 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::editor_canvas::EditorCanvasGenerationCompletionPayload; use crate::external_generation::ExternalGenerationJobStatusRecord; pub const EDITOR_ASSET_LABEL_MAX_CHARS: usize = 80; @@ -472,26 +473,6 @@ pub struct EditorVideoGenerateResponse { pub queue_state: Option, } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct EditorCanvasGenerationPlaceholderPayload { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, - pub original_width: f64, - pub original_height: f64, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct EditorCanvasGenerationCompletionPayload { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dialog_id: Option, - pub title: String, - pub placeholder: EditorCanvasGenerationPlaceholderPayload, -} - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetGenerateRequest { @@ -1106,6 +1087,7 @@ pub struct AssetBindingPayload { #[cfg(test)] mod tests { use super::*; + use crate::editor_canvas::EditorCanvasGenerationPlaceholderPayload; use serde_json::json; #[test] diff --git a/server-rs/crates/shared-contracts/src/editor_canvas.rs b/server-rs/crates/shared-contracts/src/editor_canvas.rs new file mode 100644 index 000000000..557f0f73b --- /dev/null +++ b/server-rs/crates/shared-contracts/src/editor_canvas.rs @@ -0,0 +1,36 @@ +//! 图片画布占位框与生成完成回填载荷。 +//! +//! 这些类型是纯 DTO,不依赖 OSS 契约,因此不放在受 `oss-contracts` feature +//! 门控的 `assets` 模块里:3D 生成等不带该 feature 的构建也需要引用它们。 + +use serde::{Deserialize, Serialize}; + +pub(crate) const EDITOR_CANVAS_TS_EXPORT_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/editor-canvas/" +); + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = EDITOR_CANVAS_TS_EXPORT_DIR)] +pub struct EditorCanvasGenerationPlaceholderPayload { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub original_width: f64, + pub original_height: f64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = EDITOR_CANVAS_TS_EXPORT_DIR)] +pub struct EditorCanvasGenerationCompletionPayload { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub dialog_id: Option, + pub title: String, + pub placeholder: EditorCanvasGenerationPlaceholderPayload, +} diff --git a/server-rs/crates/shared-contracts/src/editor_generation.rs b/server-rs/crates/shared-contracts/src/editor_generation.rs index 5ffd1b23c..ca541cf2e 100644 --- a/server-rs/crates/shared-contracts/src/editor_generation.rs +++ b/server-rs/crates/shared-contracts/src/editor_generation.rs @@ -5,6 +5,27 @@ pub const EDITOR_GENERATION_ASSET_ID_PREFIX: &str = "editor-asset-"; pub const EDITOR_GENERATION_ASSET_OBJECT_ID_PREFIX: &str = "assetobj_"; pub const EDITOR_GENERATION_ASSET_BINDING_ID_PREFIX: &str = "assetbind_"; +/// 编辑器生成结果原子落库认可的 operation_kind 白名单。 +/// +/// 入队方(api-server)与原子落库事务(spacetime-module)共用这一份清单:事务会拒绝 +/// 清单外的 operation_kind,所以新增生成类型必须在这里登记;两侧测试都直接引用常量, +/// 不再解析对方的源码文本。 +pub const EDITOR_GENERATION_OPERATION_KINDS: [&str; 12] = [ + "editor_image_generation", + "editor_icon_spec_generation", + "editor_image_edit", + "editor_background_removal", + "editor_icon_spritesheet_generation", + "editor_ui_design_asset_extraction", + "editor_character_animation_generation", + "editor_video_generation", + "editor_sound_effect_generation", + "editor_background_music_generation", + // Tripo 3D 的产物没有图片画布入口,但共用同一原子落库事务;不在这里登记就会被整笔拒绝。 + "model3d_text_to_model", + "model3d_image_to_model", +]; + pub fn editor_generation_request_fingerprint( operation_kind: &str, request_payload_json: &str, diff --git a/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs index 7892d92cc..1413182c5 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs @@ -47,20 +47,17 @@ macro_rules! game_creation_app_asset_kinds { /// JSON 使用 kebab-case;`Unknown` 表示当前边界无法解析输入。 /// 写入方可以保留这个未解析结果,但必须由调用者决定是否拒绝写入或交给后续归类。 #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] - #[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] - #[cfg_attr( - feature = "ts-bindings", - ts( - export, - export_to = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../../packages/shared/src/contracts/generated/" - ) + #[derive(ts_rs::TS)] + #[ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" ) )] pub enum GameCreationAppAssetKind { $( - #[cfg_attr(feature = "ts-bindings", ts(rename = $wire))] + #[ts(rename = $wire)] $variant, )+ } diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 07fbbdf11..d3ae4847f 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -9,18 +9,20 @@ pub mod auth; pub mod client_downloads; pub mod creation_audio; pub mod editor_agent; +pub mod editor_canvas; pub mod editor_generation; pub mod error_reports; pub use editor_generation::{ EDITOR_GENERATION_ASSET_BINDING_ID_PREFIX, EDITOR_GENERATION_ASSET_ID_PREFIX, - EDITOR_GENERATION_ASSET_OBJECT_ID_PREFIX, EDITOR_GENERATION_RESOURCE_ID_PREFIX, - editor_generation_request_fingerprint, editor_generation_stable_asset_binding_id, - editor_generation_stable_asset_id, editor_generation_stable_asset_object_id, - editor_generation_stable_resource_id, + EDITOR_GENERATION_ASSET_OBJECT_ID_PREFIX, EDITOR_GENERATION_OPERATION_KINDS, + EDITOR_GENERATION_RESOURCE_ID_PREFIX, editor_generation_request_fingerprint, + editor_generation_stable_asset_binding_id, editor_generation_stable_asset_id, + editor_generation_stable_asset_object_id, editor_generation_stable_resource_id, }; pub mod external_generation; pub mod game_creation_app; pub mod game_distribution; pub mod hyper3d; pub mod llm; +pub mod model3d; pub mod runtime; diff --git a/server-rs/crates/shared-contracts/src/model3d/common/compression.rs b/server-rs/crates/shared-contracts/src/model3d/common/compression.rs new file mode 100644 index 000000000..f32d2e1f6 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/compression.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dCompression { + #[serde(rename = "geometry")] + Geometry, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/export_orientation.rs b/server-rs/crates/shared-contracts/src/model3d/common/export_orientation.rs new file mode 100644 index 000000000..7fa8109ca --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/export_orientation.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dExportOrientation { + #[serde(rename = "+x")] + PlusX, + #[serde(rename = "-x")] + MinusX, + #[serde(rename = "+y")] + PlusY, + #[serde(rename = "-y")] + MinusY, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/generated_artifact.rs b/server-rs/crates/shared-contracts/src/model3d/common/generated_artifact.rs new file mode 100644 index 000000000..448f4db43 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/generated_artifact.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +/// 单个生成产物的对象元数据。 +/// +/// 只描述服务端已经持久化的正式对象;不带 provider task ID,也不带带签名的临时下载地址。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dGeneratedArtifact { + pub object_key: String, + pub content_type: String, + /// 对象字节数;单体模型产物为几十 MB,远低于 2^53,按 TS number 导出。 + #[ts(type = "number")] + pub content_length: u64, + pub sha256: String, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/generation_source.rs b/server-rs/crates/shared-contracts/src/model3d/common/generation_source.rs new file mode 100644 index 000000000..d19a894e7 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/generation_source.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +/// API 层图片输入:只接受站内的画布资源或素材库对象,二者互斥。 +/// +/// 不接受裸字符串、远程 URL 与 data URL,因此 provider 需要的图片地址由服务端 +/// 按分支校验归属后解析,客户端无法绕过归属校验直接给 provider 传地址。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +// 注:该属性由 serde 生效,变体里的未知字段(例如同时给出两个 ID)会被拒绝; +// 已发布版 ts-rs 无法在 TS 绑定里表达这份严格性,见 model3d_multiview_request_contract.rs 的同类说明。 +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dGenerationSource { + Resource { + #[serde(rename = "resourceId")] + resource_id: String, + }, + Asset { + #[serde(rename = "assetId")] + asset_id: String, + }, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs b/server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs new file mode 100644 index 000000000..6dabe75f0 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/generation_target_ref.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +/// 结果落点引用:与请求落点同形的平坦可选字段,给的是已创建资源 / 素材的正式 ID。 +/// +/// 与其它生成工具一致,项目资源与素材库可以同时产出(前端画布链路两个都发), +/// 因此两个 ID 都是可选的:哪个落点写了行就带哪个 ID,两个都写了就都带。 +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dGenerationTargetRef { + /// 项目资源落点的正式资源 ID;没落画布时缺省。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub resource_id: Option, + /// 素材库落点的正式素材 ID;没落素材库时缺省。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub asset_id: Option, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/geometry_quality.rs b/server-rs/crates/shared-contracts/src/model3d/common/geometry_quality.rs new file mode 100644 index 000000000..a99860a76 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/geometry_quality.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dGeometryQuality { + Standard, + Detailed, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/input_orientation.rs b/server-rs/crates/shared-contracts/src/model3d/common/input_orientation.rs new file mode 100644 index 000000000..393be2331 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/input_orientation.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dInputOrientation { + Default, + AlignImage, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/mod.rs b/server-rs/crates/shared-contracts/src/model3d/common/mod.rs new file mode 100644 index 000000000..3b018f65f --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/mod.rs @@ -0,0 +1,29 @@ +pub(crate) const MODEL3D_TS_EXPORT_DIR: &str = crate::model3d::model3d_ts_export_dir!("common/"); + +mod compression; +mod export_orientation; +mod generated_artifact; +mod generation_source; +mod generation_target_ref; +mod geometry_quality; +mod input_orientation; +mod model_version; +mod output_format; +mod task_status; +mod texture_alignment; +mod texture_quality; +mod texture_version; + +pub use compression::Model3dCompression; +pub use export_orientation::Model3dExportOrientation; +pub use generated_artifact::Model3dGeneratedArtifact; +pub use generation_source::Model3dGenerationSource; +pub use generation_target_ref::Model3dGenerationTargetRef; +pub use geometry_quality::Model3dGeometryQuality; +pub use input_orientation::Model3dInputOrientation; +pub use model_version::Model3dModelVersion; +pub use output_format::Model3dOutputFormat; +pub use task_status::Model3dTaskStatus; +pub use texture_alignment::Model3dTextureAlignment; +pub use texture_quality::Model3dTextureQuality; +pub use texture_version::Model3dTextureVersion; diff --git a/server-rs/crates/shared-contracts/src/model3d/common/model_version.rs b/server-rs/crates/shared-contracts/src/model3d/common/model_version.rs new file mode 100644 index 000000000..3e99f704e --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/model_version.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +/// 3D 模型版本的**唯一**声明表:枚举变体、线上值(serde rename)、ts-rs 绑定与 `ALL` +/// 全部由下面这张表派生,新增版本只需在表里加一行。 +macro_rules! model3d_model_versions { + ($($variant:ident => $wire:literal),+ $(,)?) => { + #[derive( + Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize, ts_rs::TS, + )] + #[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] + pub enum Model3dModelVersion { + $( + #[serde(rename = $wire)] + $variant, + )+ + } + + impl Model3dModelVersion { + /// 契约支持的全部模型版本(按声明顺序);定价配置必须为每个版本给出两个端点的底价。 + pub const ALL: &'static [Self] = &[$(Self::$variant,)+]; + } + }; +} + +model3d_model_versions! { + H31 => "v3.1-20260211", + H30 => "v3.0-20250812", + H25 => "v2.5-20250123", + P1 => "P1-20260311", + P2 => "P2-20260801", +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/output_format.rs b/server-rs/crates/shared-contracts/src/model3d/common/output_format.rs new file mode 100644 index 000000000..bd7717002 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/output_format.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dOutputFormat { + #[serde(rename = "glb")] + Glb, + #[serde(rename = "gltf")] + Gltf, + #[serde(rename = "fbx")] + Fbx, + #[serde(rename = "obj")] + Obj, + #[serde(rename = "stl")] + Stl, + #[serde(rename = "usdz")] + Usdz, + #[serde(rename = "3mf")] + ThreeMf, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/task_status.rs b/server-rs/crates/shared-contracts/src/model3d/common/task_status.rs new file mode 100644 index 000000000..f9a65d575 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/task_status.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dTaskStatus { + Queued, + Running, + Completed, + Failed, + Cancelled, + Expired, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/texture_alignment.rs b/server-rs/crates/shared-contracts/src/model3d/common/texture_alignment.rs new file mode 100644 index 000000000..789d7f9d4 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/texture_alignment.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dTextureAlignment { + OriginalImage, + Geometry, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/texture_quality.rs b/server-rs/crates/shared-contracts/src/model3d/common/texture_quality.rs new file mode 100644 index 000000000..bb95dab97 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/texture_quality.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dTextureQuality { + Fast, + Standard, + Detailed, + Extreme, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/common/texture_version.rs b/server-rs/crates/shared-contracts/src/model3d/common/texture_version.rs new file mode 100644 index 000000000..724d00295 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/common/texture_version.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dTextureVersion { + #[serde(rename = "v3.5-20260815")] + V35, + #[serde(rename = "v3.0-20250812")] + V30, + #[serde(rename = "v2.5-20250123")] + V25, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/generation_result.rs b/server-rs/crates/shared-contracts/src/model3d/generation_result.rs new file mode 100644 index 000000000..b321517c2 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/generation_result.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +use super::image_to_model::Model3dImageToModelResult; +use super::text_to_model::Model3dTextToModelResult; + +pub(crate) const MODEL3D_TS_EXPORT_DIR: &str = crate::model3d::model3d_ts_export_dir!(""); + +/// 完成结果按端点严格 tagged enum:每个分支只携带该端点真正会产出的字段, +/// 不使用“所有端点共用一份大结构 + 一堆 Option”的宽松形态。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dGenerationResult { + TextToModel(Model3dTextToModelResult), + ImageToModel(Model3dImageToModelResult), +} diff --git a/server-rs/crates/shared-contracts/src/model3d/image_to_model/mod.rs b/server-rs/crates/shared-contracts/src/model3d/image_to_model/mod.rs new file mode 100644 index 000000000..e68edf8ef --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/image_to_model/mod.rs @@ -0,0 +1,10 @@ +pub(crate) const MODEL3D_TS_EXPORT_DIR: &str = + crate::model3d::model3d_ts_export_dir!("image-to-model/"); + +mod params; +mod request; +mod result; + +pub use params::Model3dImageToModelParams; +pub use request::Model3dImageToModelRequest; +pub use result::Model3dImageToModelResult; diff --git a/server-rs/crates/shared-contracts/src/model3d/image_to_model/params.rs b/server-rs/crates/shared-contracts/src/model3d/image_to_model/params.rs new file mode 100644 index 000000000..2ffcad127 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/image_to_model/params.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; + +use crate::model3d::common::{ + Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dInputOrientation, + Model3dModelVersion, Model3dTextureAlignment, Model3dTextureQuality, Model3dTextureVersion, +}; + +use super::MODEL3D_TS_EXPORT_DIR; + +/// image-to-model 的 provider 生成参数。 +/// +/// 图片本体不在 provider 参数里:客户端只能给 `Model3dGenerationSource`, +/// 服务端解析归属后把 provider 需要的地址注入调用,见 `api-server::tripo3d`。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dImageToModelParams { + pub model: Model3dModelVersion, + #[ts(optional = nullable)] + pub enable_image_autofix: Option, + // seed 是 i64;对外只按 JSON 数字传递,前端能精确表示的上限是 2^53-1(JS 安全整数), + // 超出该范围的取值没法在前端精确构造,服务端仍按 i64 反序列化。 + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub model_seed: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub texture_seed: Option, + #[ts(optional = nullable)] + pub texture: Option, + #[ts(optional = nullable)] + pub pbr: Option, + #[ts(optional = nullable)] + pub texture_quality: Option, + #[ts(optional = nullable)] + pub texture_version: Option, + #[ts(optional = nullable)] + pub delight: Option, + #[ts(optional = nullable)] + pub texture_alignment: Option, + #[ts(optional = nullable)] + pub geometry_quality: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub face_limit: Option, + #[ts(optional = nullable)] + pub auto_size: Option, + #[ts(optional = nullable)] + pub orientation: Option, + #[ts(optional = nullable)] + pub quad: Option, + #[ts(optional = nullable)] + pub smart_low_poly: Option, + #[ts(optional = nullable)] + pub generate_parts: Option, + #[ts(optional = nullable)] + pub compress: Option, + #[ts(optional = nullable)] + pub export_uv: Option, + #[ts(optional = nullable)] + pub export_orientation: Option, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs b/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs new file mode 100644 index 000000000..473af1895 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/image_to_model/request.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; +use super::params::Model3dImageToModelParams; +use crate::editor_canvas::EditorCanvasGenerationCompletionPayload; +use crate::model3d::common::Model3dGenerationSource; + +/// image-to-model API 请求:站内图片引用 + provider 生成参数 + 平台字段。 +/// +/// 结果落点与 text-to-model 同形:平坦的可选 `projectId` / `canvasCompletion` / +/// `assetFolderId` / `assetLabel`,至少给一个,不用 tagged enum。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dImageToModelRequest { + pub source: Model3dGenerationSource, + pub generation: Model3dImageToModelParams, + /// 项目资源落点;与 `assetFolderId` 至少给一个,两个都给合法。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub project_id: Option, + /// 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub canvas_completion: Option, + /// 素材库落点;与 `projectId` 至少给一个,两个都给合法。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub asset_folder_id: Option, + /// 素材名称;缺省用平台默认名。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub asset_label: Option, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/image_to_model/result.rs b/server-rs/crates/shared-contracts/src/model3d/image_to_model/result.rs new file mode 100644 index 000000000..31812bcf1 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/image_to_model/result.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; +use crate::model3d::common::{Model3dGeneratedArtifact, Model3dGenerationTargetRef}; + +/// image-to-model 的完成结果:只包含正式资源引用与已持久化产物。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dImageToModelResult { + pub target: Model3dGenerationTargetRef, + pub model: Model3dGeneratedArtifact, + pub preview: Model3dGeneratedArtifact, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/mod.rs b/server-rs/crates/shared-contracts/src/model3d/mod.rs new file mode 100644 index 000000000..657dd6a19 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/mod.rs @@ -0,0 +1,55 @@ +//! Provider-neutral 3D model generation contracts. + +/// 3D 模型契约的 ts-rs 生成根目录;各子目录只追加自己的目录名, +/// 避免仓库相对路径在多处重复维护。 +macro_rules! model3d_ts_export_dir { + ($subdirectory:literal) => { + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/model3d/", + $subdirectory + ) + }; +} + +pub(crate) use model3d_ts_export_dir; + +mod generation_result; + +pub mod common; +pub mod image_to_model; +pub mod multiview_to_model; +pub mod text_to_model; + +pub use generation_result::Model3dGenerationResult; + +pub const MODEL3D_CONTRACT_VERSION: &str = "model3d.v1"; + +/// 契约枚举的线上取值。 +/// +/// 取值只由枚举上的 serde `rename` / `rename_all` 定义:调用方需要字符串时走这里, +/// 不要在枚举旁边再手写一份 `as_str` —— 同一份取值出现两处,改一处就会静默分叉。 +/// 契约里的枚举都是「序列化成字符串」的形态;序列化失败或形态不符说明契约被改坏, +/// 因此按错误返回,而不是 panic 或静默兜底。 +pub fn wire_str(value: &T) -> Result +where + T: serde::Serialize + ?Sized, +{ + match serde_json::to_value(value)? { + serde_json::Value::String(text) => Ok(text), + other => Err(serde::ser::Error::custom(format!( + "model3d 契约枚举应序列化成字符串,实际为 {other}" + ))), + } +} + +/// 与 [`wire_str`] 相同,但失败时退回 `Debug` 名称。 +/// +/// 只用于日志与审计文案:这些位置必须给出字符串且不能失败,退回值明显不是线上取值, +/// 不会被误当成正常结果。 +pub fn wire_str_lossy(value: &T) -> String +where + T: serde::Serialize + std::fmt::Debug + ?Sized, +{ + wire_str(value).unwrap_or_else(|_| format!("{value:?}")) +} diff --git a/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/mod.rs b/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/mod.rs new file mode 100644 index 000000000..0b7db88dc --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/mod.rs @@ -0,0 +1,6 @@ +pub(crate) const MODEL3D_TS_EXPORT_DIR: &str = + crate::model3d::model3d_ts_export_dir!("multiview-to-model/"); + +mod request; + +pub use request::{Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput}; diff --git a/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/request.rs b/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/request.rs new file mode 100644 index 000000000..e3010f93c --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/request.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; + +use crate::model3d::common::{ + Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dInputOrientation, + Model3dModelVersion, Model3dTextureAlignment, Model3dTextureQuality, Model3dTextureVersion, +}; + +use super::MODEL3D_TS_EXPORT_DIR; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +// 注:该属性由 serde 生效,JSON 侧会拒绝这些变体里的未知字段 +// (见 tests/model3d_multiview_request_contract.rs);TS 绑定无法表达这份严格性。 +// 已发布版 ts-rs 12.0.1 的 EnumAttr 解析不了它,会打印一条没有 span 的 +// “failed to parse serde attribute” 告警,因此 workspace 固定到上游修复 commit(#491)。 +// 不要为了消警删掉该属性,也不要改用 `no-serde-warnings` 一并静默其它未支持的属性。 +#[serde( + tag = "kind", + rename_all = "camelCase", + // `rename_all` 只改变体名,结构体变体里的字段要单独声明,否则将来出现多词字段 + // 会按 snake_case 发出,与 camelCase 的 TS 绑定对不上。 + rename_all_fields = "camelCase", + deny_unknown_fields +)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dMultiviewInputs { + /// 直接给多视图:`front` 必填,`left` / `back` / `right` 至少再给一张。 + /// + /// 视图顺序由服务端按 [front, left, back, right] 归一,这里的字段名就是视图语义, + /// 与 provider 的数组位置无关。 + Views { + front: Model3dViewInput, + #[ts(optional = nullable)] + left: Option, + #[ts(optional = nullable)] + back: Option, + #[ts(optional = nullable)] + right: Option, + }, + /// 复用已有任务的视图数据:只给 provider task id,不再重复传图。 + TaskId { task_id: String }, +} + +/// 单个视图的输入形态:公网可读地址或 provider 上传返回的 `file_token`,显式二选一。 +/// +/// 不用裸字符串:Tripo 文档里 `inputs` 的取值可以是 URL、file_token 或嵌套 +/// `{url}` / `{file_token}` / `{object:{bucket,key}}`,裸字符串要靠服务端按前缀猜种类, +/// file_token 与 task_id 尤其容易混。契约里写明「哪一种」,平台层就能构造显式对象, +/// 提交链路上不再有猜测。 +/// +/// 平台自己只产出这两种:站内引用先上传换成 `file_token`,公网地址按 `url` 直传。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + // 见上:多词字段必须单独声明 camelCase。 + rename_all_fields = "camelCase", + deny_unknown_fields +)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub enum Model3dViewInput { + /// 公网可读地址;provider 自己去取。 + Url { url: String }, + /// provider 上传接口返回的 `file_token`;私有对象必须先上传再提交。 + FileToken { file_token: String }, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +/// 多视图生成 3D 的请求:`inputs` 必填,其余生成参数可选。 +/// +/// 严格反序列化:顶层与 `inputs` 分支都不接受未知字段,字段取值非法在反序列化阶段即拒绝; +/// 组合合法性(模型能力、参数互斥)由 provider 侧提交前统一校验。 +pub struct Model3dMultiviewToModelRequest { + /// 视图输入:`views` 直接给多视图,`taskId` 复用已有任务的视图数据。 + pub inputs: Model3dMultiviewInputs, + /// 模型版本;决定模型家族与可用的生成能力。 + pub model: Model3dModelVersion, + // seed 是 i64;对外只按 JSON 数字传递,前端能精确表示的上限是 2^53-1(JS 安全整数), + // 超出该范围的取值没法在前端精确构造,服务端仍按 i64 反序列化。 + /// 模型随机种子;固定后可复现同一几何体。 + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub model_seed: Option, + /// 贴图随机种子;固定后可复现同一贴图。 + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub texture_seed: Option, + /// 是否生成贴图;`false` 时只产出白模几何体。 + #[ts(optional = nullable)] + pub texture: Option, + /// 是否生成 PBR 材质;依赖贴图,`generateParts=true` 时必须为 `false`。 + #[ts(optional = nullable)] + pub pbr: Option, + /// 贴图质量;`fast` 必须显式配 `textureVersion=v3.5-20260815`。 + #[ts(optional = nullable)] + pub texture_quality: Option, + /// 贴图模型版本;SDK params 未命名该字段,由 provider 侧按 extra 透传。 + #[ts(optional = nullable)] + pub texture_version: Option, + /// 是否去除输入图光照;SDK params 未命名该字段,由 provider 侧按 extra 透传。 + #[ts(optional = nullable)] + pub delight: Option, + /// 几何质量;仅 v3.1 / v3.0 家族接受 `detailed`。 + #[ts(optional = nullable)] + pub geometry_quality: Option, + /// 贴图对齐方式:`originalImage` 保留输入图朝向,`geometry` 对齐到几何体。 + #[ts(optional = nullable)] + pub texture_alignment: Option, + /// 面数上限;允许区间随模型版本、`quad` 与 `smartLowPoly` 变化,取值不超过 2_000_000。 + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub face_limit: Option, + /// 是否按真实尺度自动缩放输出;v2.5 家族不支持。 + #[ts(optional = nullable)] + pub auto_size: Option, + /// 输入视图的朝向口径;`alignImage` 按图片方向对齐。 + #[ts(optional = nullable)] + pub orientation: Option, + /// 是否输出四边面网格;仅 v3.x 与 P2 家族支持。 + #[ts(optional = nullable)] + pub quad: Option, + /// 是否启用智能低模;仅 v3.x 家族支持。 + #[ts(optional = nullable)] + pub smart_low_poly: Option, + /// 是否拆分模型部件;要求 `texture=false`、`pbr=false`,且不能与 `quad` / `smartLowPoly` 同用。 + #[ts(optional = nullable)] + pub generate_parts: Option, + /// 几何体压缩方式;仅 v3.x 家族支持。 + #[ts(optional = nullable)] + pub compress: Option, + /// 是否在产物里导出 UV。 + #[ts(optional = nullable)] + pub export_uv: Option, + /// 产物朝向,取值 `+x` / `-x` / `+y` / `-y`。 + #[ts(optional = nullable)] + pub export_orientation: Option, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/text_to_model/mod.rs b/server-rs/crates/shared-contracts/src/model3d/text_to_model/mod.rs new file mode 100644 index 000000000..5614b92dd --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/text_to_model/mod.rs @@ -0,0 +1,9 @@ +pub(crate) const MODEL3D_TS_EXPORT_DIR: &str = + crate::model3d::model3d_ts_export_dir!("text-to-model/"); + +mod params; +mod request; +mod result; +pub use params::Model3dTextToModelParams; +pub use request::Model3dTextToModelRequest; +pub use result::Model3dTextToModelResult; diff --git a/server-rs/crates/shared-contracts/src/model3d/text_to_model/params.rs b/server-rs/crates/shared-contracts/src/model3d/text_to_model/params.rs new file mode 100644 index 000000000..ffed027bd --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/text_to_model/params.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; +use crate::model3d::common::{ + Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dModelVersion, + Model3dTextureQuality, Model3dTextureVersion, +}; + +/// text-to-model 的 provider 生成参数。 +/// +/// 这里保持与 provider 一致的宽松形态(未提供的参数由 provider 取默认值), +/// 平台侧的必填口径与组合校验在调用 provider 之前完成,见 `api-server::tripo3d::validation`。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dTextToModelParams { + pub prompt: String, + pub model: Model3dModelVersion, + #[ts(optional = nullable)] + pub negative_prompt: Option, + // seed 是 i64;对外只按 JSON 数字传递,前端能精确表示的上限是 2^53-1(JS 安全整数), + // 超出该范围的取值没法在前端精确构造,服务端仍按 i64 反序列化。 + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub image_seed: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub model_seed: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub texture_seed: Option, + #[ts(optional = nullable)] + pub texture: Option, + #[ts(optional = nullable)] + pub pbr: Option, + #[ts(optional = nullable)] + pub texture_quality: Option, + #[ts(optional = nullable)] + pub texture_version: Option, + #[ts(optional = nullable)] + pub delight: Option, + #[ts(optional = nullable)] + pub geometry_quality: Option, + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub face_limit: Option, + #[ts(optional = nullable)] + pub auto_size: Option, + #[ts(optional = nullable)] + pub quad: Option, + #[ts(optional = nullable)] + pub smart_low_poly: Option, + #[ts(optional = nullable)] + pub generate_parts: Option, + #[ts(optional = nullable)] + pub compress: Option, + #[ts(optional = nullable)] + pub export_uv: Option, + #[ts(optional = nullable)] + pub export_orientation: Option, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs b/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs new file mode 100644 index 000000000..14ab253c6 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/text_to_model/request.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; +use super::params::Model3dTextToModelParams; +use crate::editor_canvas::EditorCanvasGenerationCompletionPayload; + +/// text-to-model API 请求:provider 生成参数 + 平台字段。 +/// +/// 生成参数单独嵌一层而不是摊平到顶层:serde 的 `deny_unknown_fields` 与 `flatten` +/// 不能共存,摊平会让顶层未知字段静默通过。 +/// +/// 结果落点与其它生成接口同形:平坦的可选 `projectId` / `canvasCompletion` / +/// `assetFolderId` / `assetLabel`,不用 tagged enum —— 客户端不必为「落项目还是落素材库」 +/// 多拼一层判别结构。服务端按「至少一个落点」校验:两个都不给拒绝,两个都给合法(两条落点都落); +/// 没有 `projectId` 时结果只落素材库,与其它画布生成工具一致。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dTextToModelRequest { + pub generation: Model3dTextToModelParams, + /// 项目资源落点;与 `assetFolderId` 至少给一个,两个都给合法。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub project_id: Option, + /// 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub canvas_completion: Option, + /// 素材库落点;与 `projectId` 至少给一个,两个都给合法。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub asset_folder_id: Option, + /// 素材名称;缺省用平台默认名。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub asset_label: Option, +} diff --git a/server-rs/crates/shared-contracts/src/model3d/text_to_model/result.rs b/server-rs/crates/shared-contracts/src/model3d/text_to_model/result.rs new file mode 100644 index 000000000..b55ac234c --- /dev/null +++ b/server-rs/crates/shared-contracts/src/model3d/text_to_model/result.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +use super::MODEL3D_TS_EXPORT_DIR; +use crate::model3d::common::{Model3dGeneratedArtifact, Model3dGenerationTargetRef}; + +/// text-to-model 的完成结果:只包含正式资源引用与已持久化产物。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +#[derive(ts_rs::TS)] +#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)] +pub struct Model3dTextToModelResult { + pub target: Model3dGenerationTargetRef, + pub model: Model3dGeneratedArtifact, + pub preview: Model3dGeneratedArtifact, +} diff --git a/server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs b/server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs new file mode 100644 index 000000000..e598d41ee --- /dev/null +++ b/server-rs/crates/shared-contracts/tests/model3d_api_request_contract.rs @@ -0,0 +1,212 @@ +use serde_json::json; +use shared_contracts::model3d::Model3dGenerationResult; +use shared_contracts::model3d::common::{ + Model3dGeneratedArtifact, Model3dGenerationSource, Model3dGenerationTargetRef, +}; +use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest; +use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest; + +#[test] +fn text_to_model_request_accepts_generation_and_flat_target() { + let request: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": { "prompt": "一把木椅", "model": "v3.1-20260211", "texture": true }, + "assetFolderId": "folder-1", + "assetLabel": "木椅" + })) + .expect("合法请求应可反序列化"); + + assert_eq!(request.generation.prompt, "一把木椅"); + assert_eq!(request.asset_folder_id.as_deref(), Some("folder-1")); + assert_eq!(request.asset_label.as_deref(), Some("木椅")); + assert!(request.project_id.is_none()); + assert!(request.canvas_completion.is_none()); +} + +/// 落点是平坦可选字段:契约本身不强制「二选一」,也没给落点时必须照原样透传, +/// 由服务端在扣费前拒绝(见 api-server 的 `validate_target`)。 +#[test] +fn target_fields_are_flat_and_optional() { + let project: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": { "prompt": "一把木椅", "model": "v3.1-20260211" }, + "projectId": "project-1", + "canvasCompletion": { + "dialogId": "dialog-1", + "title": "木椅", + "placeholder": { + "x": 1, "y": 2, "width": 3, "height": 4, + "originalWidth": 3, "originalHeight": 4 + } + } + })) + .expect("项目落点应可反序列化"); + assert_eq!(project.project_id.as_deref(), Some("project-1")); + assert!(project.canvas_completion.is_some()); + assert!(project.asset_folder_id.is_none()); + + let without_target: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": { "prompt": "一把木椅", "model": "v3.1-20260211" } + })) + .expect("缺少落点字段在契约层仍可反序列化"); + assert!(without_target.project_id.is_none()); + assert!(without_target.asset_folder_id.is_none()); + + // 落点是平坦字段:旧的 tagged enum 形状不再是合法输入。 + let error = serde_json::from_value::(json!({ + "generation": { "prompt": "一把木椅", "model": "v3.1-20260211" }, + "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" } + })) + .expect_err("target 已不是请求字段,应被拒绝"); + assert!( + error.to_string().contains("unknown field"), + "旧 target 形状应报 unknown field,实际为:{error}" + ); +} + +#[test] +fn image_source_rejects_ambiguous_and_raw_input() { + let both_ids = json!({ + "source": { "kind": "resource", "resourceId": "r1", "assetId": "a1" }, + "generation": { "model": "v3.1-20260211" }, + "assetFolderId": "folder-1" + }); + let error = serde_json::from_value::(both_ids) + .expect_err("同时给出资源与素材 ID 应被拒绝"); + assert!( + error.to_string().contains("unknown field"), + "同时给出两个 ID 应报 unknown field,实际为:{error}" + ); + + let raw_input = json!({ + "source": "https://example.com/a.png", + "generation": { "model": "v3.1-20260211" }, + "assetFolderId": "folder-1" + }); + let error = serde_json::from_value::(raw_input) + .expect_err("裸字符串图片输入应被拒绝"); + // 拒绝必须发生在 source 本身,不能被 generation / 落点字段的变化“顶替”成通过。 + // 这里只锁结构化分类(数据错),不锁 serde 的 Display 文案 —— 文案会随 serde + // 版本与枚举表示法变化,而「拒的是 source 的形状」这个契约不变。 + assert_eq!( + error.classify(), + serde_json::error::Category::Data, + "裸字符串 source 的报错应判为数据错误,实际为:{error}" + ); + + // 同样把 source 单独反序列化一遍,锁死被拒的是图片输入的形状本身。 + serde_json::from_value::(json!("https://example.com/a.png")) + .expect_err("裸字符串 source 值应被拒绝"); +} + +#[test] +fn api_request_rejects_unknown_top_level_fields() { + let error = serde_json::from_value::(json!({ + "generation": { "prompt": "一把木椅", "model": "v3.1-20260211" }, + "assetFolderId": "folder-1", + "unexpected": true + })) + .expect_err("顶层未知字段应被拒绝"); + + assert!( + error.to_string().contains("unknown field"), + "顶层未知字段应报 unknown field,实际为:{error}" + ); +} + +#[test] +fn generation_result_is_tagged_per_endpoint() { + let result: Model3dGenerationResult = serde_json::from_value(json!({ + "kind": "textToModel", + "target": { "resourceId": "res-1" }, + "model": { + "objectKey": "models/res-1.glb", + "contentType": "model/gltf-binary", + "contentLength": 41_636_076_u64, + "sha256": "0f1e" + }, + "preview": { + "objectKey": "models/res-1.webp", + "contentType": "image/webp", + "contentLength": 20_480_u64, + "sha256": "2a3b" + } + })) + .expect("按端点的结果应可反序列化"); + + let Model3dGenerationResult::TextToModel(text) = result else { + panic!("kind=textToModel 不应解成 imageToModel 分支"); + }; + assert_eq!( + text.target, + Model3dGenerationTargetRef { + resource_id: Some("res-1".into()), + asset_id: None, + } + ); + assert_eq!( + text.model, + Model3dGeneratedArtifact { + object_key: "models/res-1.glb".into(), + content_type: "model/gltf-binary".into(), + content_length: 41_636_076, + sha256: "0f1e".into(), + } + ); + assert_eq!( + text.preview, + Model3dGeneratedArtifact { + object_key: "models/res-1.webp".into(), + content_type: "image/webp".into(), + content_length: 20_480, + sha256: "2a3b".into(), + } + ); + + // 对称覆盖 imageToModel 分支:tag 路由与分支字段都要单独守住。 + let image_result: Model3dGenerationResult = serde_json::from_value(json!({ + "kind": "imageToModel", + "target": { "assetId": "asset-2" }, + "model": { + "objectKey": "models/res-2.glb", + "contentType": "model/gltf-binary", + "contentLength": 41_636_077_u64, + "sha256": "3c4d" + }, + "preview": { + "objectKey": "models/res-2.webp", + "contentType": "image/webp", + "contentLength": 20_481_u64, + "sha256": "4e5f" + } + })) + .expect("按端点的结果应可反序列化"); + + let Model3dGenerationResult::ImageToModel(image) = image_result else { + panic!("kind=imageToModel 不应解成 textToModel 分支"); + }; + // 只落素材库时,平坦引用只带 assetId。 + assert_eq!( + image.target, + Model3dGenerationTargetRef { + resource_id: None, + asset_id: Some("asset-2".into()), + } + ); + assert_eq!( + image.model, + Model3dGeneratedArtifact { + object_key: "models/res-2.glb".into(), + content_type: "model/gltf-binary".into(), + content_length: 41_636_077, + sha256: "3c4d".into(), + } + ); + assert_eq!( + image.preview, + Model3dGeneratedArtifact { + object_key: "models/res-2.webp".into(), + content_type: "image/webp".into(), + content_length: 20_481, + sha256: "4e5f".into(), + } + ); +} diff --git a/server-rs/crates/shared-contracts/tests/model3d_multiview_request_contract.rs b/server-rs/crates/shared-contracts/tests/model3d_multiview_request_contract.rs new file mode 100644 index 000000000..6bc80d0fe --- /dev/null +++ b/server-rs/crates/shared-contracts/tests/model3d_multiview_request_contract.rs @@ -0,0 +1,275 @@ +use serde_json::json; +use shared_contracts::model3d::common::{ + Model3dCompression, Model3dExportOrientation, Model3dGeometryQuality, Model3dInputOrientation, + Model3dModelVersion, Model3dTextureAlignment, Model3dTextureQuality, Model3dTextureVersion, +}; +use shared_contracts::model3d::multiview_to_model::{ + Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput, +}; + +#[test] +fn multiview_inputs_accepts_declared_fields_and_camel_case_tag() { + let inputs: Model3dMultiviewInputs = serde_json::from_value(json!({ + "kind": "views", + "front": { "kind": "url", "url": "front.png" }, + "left": { "kind": "fileToken", "fileToken": "left-token" } + })) + .expect("已声明字段应可反序列化"); + + match inputs { + Model3dMultiviewInputs::Views { front, left, .. } => { + assert_eq!( + front, + Model3dViewInput::Url { + url: "front.png".to_string() + } + ); + assert_eq!( + left, + Some(Model3dViewInput::FileToken { + file_token: "left-token".to_string() + }) + ); + } + Model3dMultiviewInputs::TaskId { .. } => panic!("kind=views 不应解成 taskId 变体"), + } + + let task: Model3dMultiviewInputs = serde_json::from_value(json!({ + "kind": "taskId", + "taskId": "tripo-task-1" + })) + .expect("taskId 变体使用 camelCase 字段名"); + + assert!(matches!(task, Model3dMultiviewInputs::TaskId { .. })); +} + +#[test] +fn multiview_inputs_reject_unknown_fields_inside_variants() { + // enum 级 `deny_unknown_fields` 由 serde 生效,ts-rs 12 无法在 TS 绑定里表达; + // 这条用例钉住该严格性,防止为消除 ts-rs 告警而把它改成非严格模式。 + let error = serde_json::from_value::(json!({ + "kind": "views", + "front": { "kind": "url", "url": "front.png" }, + "left": { "kind": "url", "url": "left.png" }, + "back": { "kind": "url", "url": "back.png" }, + "right": { "kind": "url", "url": "right.png" }, + "unexpected": true + })) + .expect_err("变体里出现未知字段应被拒绝"); + + assert!( + error.to_string().contains("unknown field"), + "未知字段错误应指出 unknown field,实际为:{error}" + ); + + let error = serde_json::from_value::(json!({ + "kind": "taskId", + "taskId": "tripo-task-1", + "unexpected": true + })) + .expect_err("taskId 变体里出现未知字段应被拒绝"); + + assert!( + error.to_string().contains("unknown field"), + "未知字段错误应指出 unknown field,实际为:{error}" + ); +} + +#[test] +fn multiview_request_rejects_unknown_top_level_fields() { + let error = serde_json::from_value::(json!({ + "inputs": { "kind": "taskId", "taskId": "tripo-task-1" }, + "model": "v3.1-20260211", + "unexpected": true + })) + .expect_err("请求体顶层出现未知字段应被拒绝"); + + assert!( + error.to_string().contains("unknown field"), + "未知字段错误应指出 unknown field,实际为:{error}" + ); +} + +#[test] +fn multiview_request_round_trips_every_optional_field() { + // 全字段往返:钉住 camelCase 字段名、枚举线上取值与 Option 的可省略写法, + // 与生成的 TypeScript 绑定保持一致。 + let payload = json!({ + "inputs": { + "kind": "views", + "front": { "kind": "url", "url": "front.png" }, + "left": { "kind": "fileToken", "fileToken": "left-token" }, + "back": { "kind": "url", "url": "back.png" }, + "right": { "kind": "fileToken", "fileToken": "right-token" } + }, + "model": "v3.1-20260211", + "modelSeed": 7, + "textureSeed": 8, + "texture": true, + "pbr": true, + "textureQuality": "standard", + "textureVersion": "v3.0-20250812", + "delight": false, + "geometryQuality": "detailed", + "textureAlignment": "original_image", + "faceLimit": 20_000, + "autoSize": false, + "orientation": "align_image", + "quad": false, + "smartLowPoly": true, + "generateParts": false, + "compress": "geometry", + "exportUv": true, + "exportOrientation": "-y" + }); + + let request: Model3dMultiviewToModelRequest = + serde_json::from_value(payload.clone()).expect("全字段请求应可反序列化"); + + match &request.inputs { + Model3dMultiviewInputs::Views { + front, + left, + back, + right, + } => { + assert_eq!( + front, + &Model3dViewInput::Url { + url: "front.png".to_string() + } + ); + assert_eq!( + left, + &Some(Model3dViewInput::FileToken { + file_token: "left-token".to_string() + }) + ); + assert_eq!( + back, + &Some(Model3dViewInput::Url { + url: "back.png".to_string() + }) + ); + assert_eq!( + right, + &Some(Model3dViewInput::FileToken { + file_token: "right-token".to_string() + }) + ); + } + Model3dMultiviewInputs::TaskId { .. } => panic!("kind=views 不应解成 taskId 变体"), + } + assert_eq!(request.model, Model3dModelVersion::H31); + assert_eq!(request.model_seed, Some(7)); + assert_eq!(request.texture_seed, Some(8)); + assert_eq!(request.texture, Some(true)); + assert_eq!(request.pbr, Some(true)); + assert_eq!( + request.texture_quality, + Some(Model3dTextureQuality::Standard) + ); + assert_eq!(request.texture_version, Some(Model3dTextureVersion::V30)); + assert_eq!(request.delight, Some(false)); + assert_eq!( + request.geometry_quality, + Some(Model3dGeometryQuality::Detailed) + ); + assert_eq!( + request.texture_alignment, + Some(Model3dTextureAlignment::OriginalImage) + ); + assert_eq!(request.face_limit, Some(20_000)); + assert_eq!(request.auto_size, Some(false)); + assert_eq!( + request.orientation, + Some(Model3dInputOrientation::AlignImage) + ); + assert_eq!(request.quad, Some(false)); + assert_eq!(request.smart_low_poly, Some(true)); + assert_eq!(request.generate_parts, Some(false)); + assert_eq!(request.compress, Some(Model3dCompression::Geometry)); + assert_eq!(request.export_uv, Some(true)); + assert_eq!( + request.export_orientation, + Some(Model3dExportOrientation::MinusY) + ); + + assert_eq!( + serde_json::to_value(&request).expect("请求应可回写为 JSON"), + payload + ); +} + +#[test] +fn multiview_request_round_trips_explicit_nulls() { + // 显式 null 是可省略写法之外的另一种合法形态,回写时必须仍是 null 而不是缺字段。 + let payload = json!({ + "inputs": { "kind": "taskId", "taskId": "tripo-task-1" }, + "model": "P2-20260801", + "modelSeed": null, + "textureSeed": null, + "texture": null, + "pbr": null, + "textureQuality": null, + "textureVersion": null, + "delight": null, + "geometryQuality": null, + "textureAlignment": null, + "faceLimit": null, + "autoSize": null, + "orientation": null, + "quad": null, + "smartLowPoly": null, + "generateParts": null, + "compress": null, + "exportUv": null, + "exportOrientation": null + }); + + let request: Model3dMultiviewToModelRequest = + serde_json::from_value(payload.clone()).expect("显式 null 的请求应可反序列化"); + + assert!(matches!( + request.inputs, + Model3dMultiviewInputs::TaskId { .. } + )); + assert_eq!(request.model, Model3dModelVersion::P2); + assert_eq!(request.model_seed, None); + assert_eq!(request.export_orientation, None); + + assert_eq!( + serde_json::to_value(&request).expect("请求应可回写为 JSON"), + payload + ); +} + +#[test] +fn multiview_views_require_typed_inputs_without_unknown_fields() { + // 裸字符串只能靠前缀猜种类(URL / file_token / task_id),契约层直接拒绝, + // 逼调用方写明是 `url` 还是 `fileToken`。 + let error = serde_json::from_value::(json!({ + "kind": "views", + "front": "front.png", + "left": "left.png" + })) + .expect_err("裸字符串视图应被拒绝"); + + assert!( + error.to_string().contains("invalid type"), + "裸字符串应报类型错误,实际为:{error}" + ); + + // 每个视图只允许一种取值:kind=url 时再给 fileToken 会被拒绝。 + let error = serde_json::from_value::(json!({ + "kind": "views", + "front": { "kind": "url", "url": "front.png", "fileToken": "front-token" }, + "left": { "kind": "fileToken", "fileToken": "left-token" } + })) + .expect_err("视图取值里出现未知字段应被拒绝"); + + assert!( + error.to_string().contains("unknown field"), + "未知字段错误应指出 unknown field,实际为:{error}" + ); +} diff --git a/server-rs/crates/spacetime-client/src/active/external_generation.rs b/server-rs/crates/spacetime-client/src/active/external_generation.rs index 12f74f3d2..c693a3776 100644 --- a/server-rs/crates/spacetime-client/src/active/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/active/external_generation.rs @@ -359,6 +359,36 @@ impl SpacetimeClient { .await } + /// 写入 provider 任务 checkpoint。 + /// + /// 只在 submit 成功、拿到 provider task id 之后调用一次;procedure 侧对已有 + /// checkpoint 的 job 直接拒绝,调用方不得用重试来覆盖,这是 at-most-once submit + /// 的数据层保证。写入失败意味着本次 attempt 无法安全续跑,按失败处理而不是重新 submit。 + pub async fn set_external_generation_job_provider_checkpoint( + &self, + input: ExternalGenerationJobProviderCheckpointRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "set_external_generation_job_provider_checkpoint_and_return", + move |connection, sender| { + connection + .procedures() + .set_external_generation_job_provider_checkpoint_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn get_external_generation_job( &self, input: ExternalGenerationJobGetRecordInput, diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 301ed3202..2e7084e18 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -56,13 +56,15 @@ pub use self::editor_project::{ EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput, EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, - EditorCanvasRecord, EditorCanvasViewportRecord, EditorGenerationModelPricingRecord, - EditorGenerationPricingConfigRecord, EditorGenerationPricingConfigUpsertRecordInput, - EditorGenerationPricingTierRecord, EditorProjectCreateRecordInput, - EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, - EditorProjectLayoutSaveRecordInput, EditorProjectLayoutSaveV2AckRecord, - EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, - EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, + EditorCanvasRecord, EditorCanvasViewportRecord, EditorGenerationModel3dAddOnPriceRecord, + EditorGenerationModel3dPricingRecord, EditorGenerationModel3dVersionPriceRecord, + EditorGenerationModelPricingRecord, EditorGenerationPricingConfigRecord, + EditorGenerationPricingConfigUpsertRecordInput, EditorGenerationPricingTierRecord, + EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, + EditorProjectLayoutSaveAckRecord, EditorProjectLayoutSaveRecordInput, + EditorProjectLayoutSaveV2AckRecord, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, + EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, + EditorProjectResourceMediaRepairRecordInput, EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, EditorReferenceRecord, EditorReferenceResolveRecordInput, EditorShowcaseAssetAdminListRecordInput, @@ -87,9 +89,9 @@ pub use self::external_generation::{ ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, ExternalGenerationJobListRecord, ExternalGenerationJobListRecordInput, ExternalGenerationJobPhaseUpdateRecordInput, - ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, - ExternalGenerationJobSummaryListRecord, ExternalGenerationJobSummaryRecord, - ExternalGenerationQueueStatsRecord, + ExternalGenerationJobProviderCheckpointRecordInput, ExternalGenerationJobRecord, + ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationJobSummaryListRecord, + ExternalGenerationJobSummaryRecord, ExternalGenerationQueueStatsRecord, }; pub use self::game_distribution::{ GameDistributionAdminGameRecord, GameDistributionAdminVersionRecord, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs index 671d20708..9072075f0 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs @@ -251,6 +251,27 @@ pub struct EditorGenerationPricingConfigRecord { pub updated_by_admin_user_id: Option, pub updated_at: String, pub updated_at_micros: i64, + pub text_to_model_pricing: Option, + pub image_to_model_pricing: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct EditorGenerationModel3dVersionPriceRecord { + pub model_version: String, + pub no_texture: u32, + pub texture: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct EditorGenerationModel3dAddOnPriceRecord { + pub add_on: String, + pub price: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct EditorGenerationModel3dPricingRecord { + pub version_prices: Vec, + pub add_on_prices: Vec, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -564,6 +585,8 @@ pub struct EditorGenerationPricingConfigUpsertRecordInput { pub models: Vec, pub updated_at_micros: i64, pub bootstrap_secret: String, + pub text_to_model_pricing: Option, + pub image_to_model_pricing: Option, } impl From for crate::module_bindings::EditorProjectCreateInput { @@ -1020,10 +1043,68 @@ impl From .collect(), updated_at_micros: input.updated_at_micros, bootstrap_secret: input.bootstrap_secret, + text_to_model_pricing: input + .text_to_model_pricing + .map(map_editor_generation_model3d_pricing_record), + image_to_model_pricing: input + .image_to_model_pricing + .map(map_editor_generation_model3d_pricing_record), } } } +fn map_editor_generation_model3d_pricing_record( + pricing: EditorGenerationModel3dPricingRecord, +) -> crate::module_bindings::EditorGenerationModel3DPricing { + crate::module_bindings::EditorGenerationModel3DPricing { + version_prices: pricing + .version_prices + .into_iter() + .map( + |entry| crate::module_bindings::EditorGenerationModel3DVersionPrice { + model_version: entry.model_version, + no_texture: entry.no_texture, + texture: entry.texture, + }, + ) + .collect(), + add_on_prices: pricing + .add_on_prices + .into_iter() + .map( + |entry| crate::module_bindings::EditorGenerationModel3DAddOnPrice { + add_on: entry.add_on, + price: entry.price, + }, + ) + .collect(), + } +} + +fn map_editor_generation_model3d_pricing_snapshot( + pricing: crate::module_bindings::EditorGenerationModel3DPricing, +) -> EditorGenerationModel3dPricingRecord { + EditorGenerationModel3dPricingRecord { + version_prices: pricing + .version_prices + .into_iter() + .map(|entry| EditorGenerationModel3dVersionPriceRecord { + model_version: entry.model_version, + no_texture: entry.no_texture, + texture: entry.texture, + }) + .collect(), + add_on_prices: pricing + .add_on_prices + .into_iter() + .map(|entry| EditorGenerationModel3dAddOnPriceRecord { + add_on: entry.add_on, + price: entry.price, + }) + .collect(), + } +} + pub(crate) fn map_editor_project_optional_procedure_result( result: EditorProjectProcedureResult, ) -> Result, SpacetimeClientError> { @@ -1627,6 +1708,12 @@ fn map_editor_generation_pricing_config_snapshot( updated_by_admin_user_id: snapshot.updated_by_admin_user_id, updated_at: format_timestamp_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, + text_to_model_pricing: snapshot + .text_to_model_pricing + .map(map_editor_generation_model3d_pricing_snapshot), + image_to_model_pricing: snapshot + .image_to_model_pricing + .map(map_editor_generation_model3d_pricing_snapshot), } } diff --git a/server-rs/crates/spacetime-client/src/active/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/active/mapper/external_generation.rs index d4ba85760..2919a7a75 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/external_generation.rs @@ -65,6 +65,20 @@ impl From for ExternalGenerationJob } } +impl From + for ExternalGenerationJobProviderCheckpointInput +{ + fn from(input: ExternalGenerationJobProviderCheckpointRecordInput) -> Self { + Self { + job_id: input.job_id, + worker_id: input.worker_id, + lease_token: input.lease_token, + provider_kind: input.provider_kind, + provider_task_id: input.provider_task_id, + } + } +} + impl From for ExternalGenerationJobFailInput { fn from(input: ExternalGenerationJobFailRecordInput) -> Self { Self { @@ -300,6 +314,8 @@ pub(crate) fn map_external_generation_job_snapshot( .map(format_timestamp_micros), notification_acknowledged_at_micros: snapshot.notification_acknowledged_at_micros, phase: snapshot.phase, + provider_kind: snapshot.provider_kind, + provider_task_id: snapshot.provider_task_id, } } @@ -414,6 +430,15 @@ pub struct ExternalGenerationJobAcknowledgeRecordInput { pub acknowledged_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobProviderCheckpointRecordInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub provider_kind: String, + pub provider_task_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ExternalGenerationJobRecord { pub job_id: String, @@ -443,6 +468,9 @@ pub struct ExternalGenerationJobRecord { pub notification_acknowledged_at: Option, pub notification_acknowledged_at_micros: Option, pub phase: Option, + /// provider 任务 checkpoint:有值时只允许续跑查询,不允许再次 submit。 + pub provider_kind: Option, + pub provider_task_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/external_generation.rs b/server-rs/crates/spacetime-client/src/external_generation.rs deleted file mode 100644 index 0b2a2abfc..000000000 --- a/server-rs/crates/spacetime-client/src/external_generation.rs +++ /dev/null @@ -1,611 +0,0 @@ -use super::*; -use crate::mapper::*; -use spacetimedb_sdk::TableWithPrimaryKey; -use tokio::sync::watch; - -const EXTERNAL_GENERATION_QUEUE_WAKE_SUBSCRIPTION_QUERIES: [&str; 2] = [ - "SELECT * FROM external_generation_job WHERE status = 'pending'", - "SELECT * FROM external_generation_job WHERE status = 'running'", -]; - -#[derive(Debug)] -pub enum ExternalGenerationJobPhaseUpdateError { - LeaseFencingRejected(String), - Rejected(String), - Rpc(SpacetimeClientError), -} - -impl ExternalGenerationJobPhaseUpdateError { - pub fn is_lease_fencing_rejected(&self) -> bool { - matches!(self, Self::LeaseFencingRejected(_)) - } - - pub fn is_retryable_transport(&self) -> bool { - matches!( - self, - Self::Rpc( - SpacetimeClientError::Build(_) - | SpacetimeClientError::ConnectDropped - | SpacetimeClientError::Timeout(_) - ) - ) - } -} - -impl std::fmt::Display for ExternalGenerationJobPhaseUpdateError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::LeaseFencingRejected(message) | Self::Rejected(message) => { - formatter.write_str(message) - } - Self::Rpc(error) => std::fmt::Display::fmt(error, formatter), - } - } -} - -impl std::error::Error for ExternalGenerationJobPhaseUpdateError {} - -pub struct ExternalGenerationQueueWakeSubscription { - connection: DbConnection, - _subscriptions: Vec, - _insert_callback: ExternalGenerationJobInsertCallbackId, - _update_callback: ExternalGenerationJobUpdateCallbackId, - _delete_callback: ExternalGenerationJobDeleteCallbackId, - runner: Option>, - receiver: watch::Receiver, -} - -impl ExternalGenerationQueueWakeSubscription { - pub async fn changed(&mut self) -> Result<(), SpacetimeClientError> { - self.receiver - .changed() - .await - .map_err(|_| SpacetimeClientError::ConnectDropped) - } -} - -impl Drop for ExternalGenerationQueueWakeSubscription { - fn drop(&mut self) { - let _ = self.connection.disconnect(); - if let Some(runner) = self.runner.take() { - drop(runner); - } - } -} - -impl SpacetimeClient { - pub async fn subscribe_external_generation_queue_wake( - &self, - ) -> Result { - let config = self.config.clone(); - let operation_timeout = config.procedure_timeout; - let (connected_sender, connected_receiver) = - oneshot::channel::>(); - let connected_sender = Arc::new(Mutex::new(Some(connected_sender))); - let connect_sender = connected_sender.clone(); - let disconnect_sender = connected_sender.clone(); - let connection = timeout( - operation_timeout, - tokio::task::spawn_blocking(move || { - DbConnection::builder() - .with_uri(config.server_url) - .with_database_name(config.database) - .with_token(config.token) - .on_connect(move |_, _, _| { - send_connect_once(&connect_sender, Ok(())); - }) - .on_disconnect(move |_, error| { - let message = error - .map(|error| error.to_string()) - .unwrap_or_else(|| "SpacetimeDB 队列订阅连接已断开".to_string()); - send_connect_once( - &disconnect_sender, - Err(SpacetimeClientError::Procedure(message)), - ); - }) - .build() - .map_err(|error| SpacetimeClientError::Build(error.to_string())) - }), - ) - .await - .map_err(|_| SpacetimeClientError::Timeout(SpacetimeClientStage::ConnectBuild))? - .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))??; - - let runner = connection.run_threaded(); - timeout(operation_timeout, connected_receiver) - .await - .map_err(|_| SpacetimeClientError::Timeout(SpacetimeClientStage::ConnectHandshake))? - .map_err(|_| SpacetimeClientError::ConnectDropped)??; - - let (wake_sender, wake_receiver) = watch::channel(0u64); - let wake_counter = Arc::new(AtomicU64::new(0)); - let insert_sender = wake_sender.clone(); - let insert_counter = wake_counter.clone(); - let update_sender = wake_sender.clone(); - let update_counter = wake_counter.clone(); - let delete_sender = wake_sender.clone(); - let delete_counter = wake_counter.clone(); - let insert_callback = connection - .db() - .external_generation_job() - .on_insert(move |_, row| { - if external_generation_queue_row_should_wake(row) { - send_external_generation_queue_wake(&insert_sender, &insert_counter); - } - }); - let update_callback = - connection - .db() - .external_generation_job() - .on_update(move |_, old, new| { - if external_generation_queue_row_should_wake(old) - || external_generation_queue_row_should_wake(new) - { - send_external_generation_queue_wake(&update_sender, &update_counter); - } - }); - let delete_callback = connection - .db() - .external_generation_job() - .on_delete(move |_, row| { - if external_generation_queue_row_should_wake(row) { - send_external_generation_queue_wake(&delete_sender, &delete_counter); - } - }); - - let mut subscriptions = Vec::new(); - for query in EXTERNAL_GENERATION_QUEUE_WAKE_SUBSCRIPTION_QUERIES { - let (applied_sender, applied_receiver) = - oneshot::channel::>(); - let applied_sender = Arc::new(Mutex::new(Some(applied_sender))); - let on_applied_sender = applied_sender.clone(); - let on_error_sender = applied_sender.clone(); - let subscription = connection - .subscription_builder() - .on_applied(move |_| { - send_connect_once(&on_applied_sender, Ok(())); - }) - .on_error(move |_, error| { - send_connect_once( - &on_error_sender, - Err(SpacetimeClientError::Procedure(error.to_string())), - ); - }) - .subscribe(query); - - timeout(operation_timeout, applied_receiver) - .await - .map_err(|_| { - SpacetimeClientError::Timeout(SpacetimeClientStage::ReadModelSubscribe) - })? - .map_err(|_| SpacetimeClientError::ConnectDropped)??; - subscriptions.push(subscription); - } - send_external_generation_queue_wake(&wake_sender, &wake_counter); - - Ok(ExternalGenerationQueueWakeSubscription { - connection, - _subscriptions: subscriptions, - _insert_callback: insert_callback, - _update_callback: update_callback, - _delete_callback: delete_callback, - runner: Some(runner), - receiver: wake_receiver, - }) - } - - pub async fn enqueue_external_generation_job( - &self, - input: ExternalGenerationJobEnqueueRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "enqueue_external_generation_job_and_return", - move |connection, sender| { - connection - .procedures() - .enqueue_external_generation_job_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_procedure_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn claim_external_generation_jobs( - &self, - input: ExternalGenerationJobClaimRecordInput, - ) -> Result, SpacetimeClientError> { - let procedure_input = input.into(); - - self.call_after_connect( - "claim_external_generation_jobs_and_return", - move |connection, sender| { - connection - .procedures() - .claim_external_generation_jobs_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_claim_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn complete_external_generation_job( - &self, - input: ExternalGenerationJobCompleteRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "complete_external_generation_job_and_return", - move |connection, sender| { - connection - .procedures() - .complete_external_generation_job_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_procedure_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn renew_external_generation_job_lease( - &self, - input: ExternalGenerationJobRenewLeaseRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "renew_external_generation_job_lease_and_return", - move |connection, sender| { - connection - .procedures() - .renew_external_generation_job_lease_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_procedure_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn update_external_generation_job_phase( - &self, - input: ExternalGenerationJobPhaseUpdateRecordInput, - ) -> Result { - let procedure_input = input.into(); - - let outcome = self - .call_after_connect( - "update_external_generation_job_phase_and_return", - move |connection, sender| { - connection - .procedures() - .update_external_generation_job_phase_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .map(map_external_generation_job_phase_update_procedure_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - .map_err(ExternalGenerationJobPhaseUpdateError::Rpc)?; - - match outcome { - ExternalGenerationJobPhaseUpdateProcedureOutcome::Updated(job) => Ok(job), - ExternalGenerationJobPhaseUpdateProcedureOutcome::Rejected { kind, message } => { - match kind { - ExternalGenerationJobPhaseUpdateFailureKind::LeaseFencingRejected => Err( - ExternalGenerationJobPhaseUpdateError::LeaseFencingRejected(message), - ), - ExternalGenerationJobPhaseUpdateFailureKind::OtherRejected => { - Err(ExternalGenerationJobPhaseUpdateError::Rejected(message)) - } - } - } - } - } - - pub async fn fail_external_generation_job( - &self, - input: ExternalGenerationJobFailRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "fail_external_generation_job_and_return", - move |connection, sender| { - connection - .procedures() - .fail_external_generation_job_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_procedure_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn get_external_generation_job( - &self, - input: ExternalGenerationJobGetRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "get_external_generation_job_and_return", - move |connection, sender| { - connection - .procedures() - .get_external_generation_job_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_procedure_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - // TODO icons gen's result query is not prepared for now. - pub async fn get_external_generation_job_generated_artifacts( - &self, - input: ExternalGenerationJobGetRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "get_external_generation_job_result_and_return", - move |connection, sender| { - connection - .procedures() - .get_external_generation_job_result_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then( - map_external_generation_job_result_procedure_generated_artifacts, - ); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn get_external_generation_job_summary( - &self, - input: ExternalGenerationJobGetRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "get_external_generation_job_summary_and_return", - move |connection, sender| { - connection - .procedures() - .get_external_generation_job_summary_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_summary_procedure_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn list_external_generation_jobs( - &self, - input: ExternalGenerationJobListRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "list_external_generation_jobs_and_return", - move |connection, sender| { - connection - .procedures() - .list_external_generation_jobs_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_list_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn list_external_generation_job_summaries( - &self, - input: ExternalGenerationJobListRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "list_external_generation_job_summaries_and_return", - move |connection, sender| { - connection - .procedures() - .list_external_generation_job_summaries_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_summary_list_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn acknowledge_external_generation_jobs( - &self, - input: ExternalGenerationJobAcknowledgeRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "acknowledge_external_generation_jobs_and_return", - move |connection, sender| { - connection - .procedures() - .acknowledge_external_generation_jobs_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_list_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn acknowledge_external_generation_job_summaries( - &self, - input: ExternalGenerationJobAcknowledgeRecordInput, - ) -> Result { - let procedure_input = input.into(); - - self.call_after_connect( - "acknowledge_external_generation_job_summaries_and_return", - move |connection, sender| { - connection - .procedures() - .acknowledge_external_generation_job_summaries_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_summary_list_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await - } - - pub async fn get_external_generation_queue_stats( - &self, - ) -> Result { - self.call_after_connect( - "get_external_generation_queue_stats_and_return", - move |connection, sender| { - connection - .procedures() - .get_external_generation_queue_stats_and_return_then(move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_queue_stats_result); - send_once(&sender, mapped); - }); - }, - ) - .await - } -} - -fn external_generation_queue_row_should_wake(row: &ExternalGenerationJob) -> bool { - matches!(row.status.as_str(), "pending" | "running") -} - -fn send_external_generation_queue_wake(sender: &watch::Sender, counter: &AtomicU64) { - let next = counter.fetch_add(1, Ordering::Relaxed).saturating_add(1); - let _ = sender.send(next); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn phase_update_only_retries_transport_errors() { - let build = ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Build( - "initial websocket connect failed".to_string(), - )); - assert!(build.is_retryable_transport()); - - let timeout = ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Timeout( - SpacetimeClientStage::ProcedureResult, - )); - assert!(timeout.is_retryable_transport()); - assert!(!timeout.is_lease_fencing_rejected()); - - let disconnected = - ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::ConnectDropped); - assert!(disconnected.is_retryable_transport()); - - let lease = ExternalGenerationJobPhaseUpdateError::LeaseFencingRejected( - "lease token 不匹配".to_string(), - ); - assert!(!lease.is_retryable_transport()); - assert!(lease.is_lease_fencing_rejected()); - - let rejected = ExternalGenerationJobPhaseUpdateError::Rejected("phase 非法".to_string()); - assert!(!rejected.is_retryable_transport()); - assert!(!rejected.is_lease_fencing_rejected()); - - let procedure = ExternalGenerationJobPhaseUpdateError::Rpc( - SpacetimeClientError::Procedure("procedure rejected".to_string()), - ); - assert!(!procedure.is_retryable_transport()); - - let runtime = ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Runtime( - "runtime invariant failed".to_string(), - )); - assert!(!runtime.is_retryable_transport()); - } -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 8c316104e..0d38dad5a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -247,6 +247,9 @@ pub mod editor_canvas_table; pub mod editor_canvas_type; pub mod editor_character_animation_normalization_input_type; pub mod editor_character_animation_normalization_procedure_result_type; +pub mod editor_generation_model_3_d_add_on_price_type; +pub mod editor_generation_model_3_d_pricing_type; +pub mod editor_generation_model_3_d_version_price_type; pub mod editor_generation_model_pricing_type; pub mod editor_generation_operation_table; pub mod editor_generation_operation_type; @@ -373,6 +376,7 @@ pub mod external_generation_job_phase_update_failure_kind_type; pub mod external_generation_job_phase_update_input_type; pub mod external_generation_job_phase_update_procedure_result_type; pub mod external_generation_job_procedure_result_type; +pub mod external_generation_job_provider_checkpoint_input_type; pub mod external_generation_job_renew_lease_input_type; pub mod external_generation_job_result_procedure_result_type; pub mod external_generation_job_result_snapshot_type; @@ -750,6 +754,7 @@ pub mod save_editor_project_layout_v_2_ack_procedure; pub mod save_editor_project_layout_v_2_and_return_procedure; pub mod seed_analytics_date_dimensions_reducer; pub mod set_editor_showcase_asset_like_for_viewer_and_return_procedure; +pub mod set_external_generation_job_provider_checkpoint_and_return_procedure; pub mod settle_llm_router_quota_and_return_procedure; pub mod start_ai_task_reducer; pub mod start_ai_task_stage_reducer; @@ -1024,6 +1029,9 @@ pub use editor_canvas_table::*; pub use editor_canvas_type::EditorCanvas; pub use editor_character_animation_normalization_input_type::EditorCharacterAnimationNormalizationInput; pub use editor_character_animation_normalization_procedure_result_type::EditorCharacterAnimationNormalizationProcedureResult; +pub use editor_generation_model_3_d_add_on_price_type::EditorGenerationModel3DAddOnPrice; +pub use editor_generation_model_3_d_pricing_type::EditorGenerationModel3DPricing; +pub use editor_generation_model_3_d_version_price_type::EditorGenerationModel3DVersionPrice; pub use editor_generation_model_pricing_type::EditorGenerationModelPricing; pub use editor_generation_operation_table::*; pub use editor_generation_operation_type::EditorGenerationOperation; @@ -1150,6 +1158,7 @@ pub use external_generation_job_phase_update_failure_kind_type::ExternalGenerati pub use external_generation_job_phase_update_input_type::ExternalGenerationJobPhaseUpdateInput; pub use external_generation_job_phase_update_procedure_result_type::ExternalGenerationJobPhaseUpdateProcedureResult; pub use external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; +pub use external_generation_job_provider_checkpoint_input_type::ExternalGenerationJobProviderCheckpointInput; pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; pub use external_generation_job_result_procedure_result_type::ExternalGenerationJobResultProcedureResult; pub use external_generation_job_result_snapshot_type::ExternalGenerationJobResultSnapshot; @@ -1527,6 +1536,7 @@ pub use save_editor_project_layout_v_2_ack_procedure::save_editor_project_layout pub use save_editor_project_layout_v_2_and_return_procedure::save_editor_project_layout_v_2_and_return; pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions; pub use set_editor_showcase_asset_like_for_viewer_and_return_procedure::set_editor_showcase_asset_like_for_viewer_and_return; +pub use set_external_generation_job_provider_checkpoint_and_return_procedure::set_external_generation_job_provider_checkpoint_and_return; pub use settle_llm_router_quota_and_return_procedure::settle_llm_router_quota_and_return; pub use start_ai_task_reducer::start_ai_task; pub use start_ai_task_stage_reducer::start_ai_task_stage; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_model_3_d_add_on_price_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_model_3_d_add_on_price_type.rs new file mode 100644 index 000000000..e1cd44ee9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_model_3_d_add_on_price_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorGenerationModel3DAddOnPrice { + pub add_on: String, + pub price: u32, +} + +impl __sdk::InModule for EditorGenerationModel3DAddOnPrice { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_model_3_d_pricing_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_model_3_d_pricing_type.rs new file mode 100644 index 000000000..25d27889c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_model_3_d_pricing_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_generation_model_3_d_add_on_price_type::EditorGenerationModel3DAddOnPrice; +use super::editor_generation_model_3_d_version_price_type::EditorGenerationModel3DVersionPrice; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorGenerationModel3DPricing { + pub version_prices: Vec, + pub add_on_prices: Vec, +} + +impl __sdk::InModule for EditorGenerationModel3DPricing { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_model_3_d_version_price_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_model_3_d_version_price_type.rs new file mode 100644 index 000000000..86b9972fa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_model_3_d_version_price_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorGenerationModel3DVersionPrice { + pub model_version: String, + pub no_texture: u32, + pub texture: u32, +} + +impl __sdk::InModule for EditorGenerationModel3DVersionPrice { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_snapshot_type.rs index cc7a3cca7..3eeba2a8a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_snapshot_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::editor_generation_model_3_d_pricing_type::EditorGenerationModel3DPricing; use super::editor_generation_model_pricing_type::EditorGenerationModelPricing; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -13,6 +14,8 @@ pub struct EditorGenerationPricingConfigSnapshot { pub models: Vec, pub updated_by_admin_user_id: Option, pub updated_at_micros: i64, + pub text_to_model_pricing: Option, + pub image_to_model_pricing: Option, } impl __sdk::InModule for EditorGenerationPricingConfigSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_table.rs index 65ada83ed..b76e95afa 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_table.rs @@ -2,6 +2,7 @@ // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. #![allow(unused, clippy::all)] +use super::editor_generation_model_3_d_pricing_type::EditorGenerationModel3DPricing; use super::editor_generation_model_pricing_type::EditorGenerationModelPricing; use super::editor_generation_pricing_config_type::EditorGenerationPricingConfig; use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_type.rs index e6a30ffb9..e248f9ae4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::editor_generation_model_3_d_pricing_type::EditorGenerationModel3DPricing; use super::editor_generation_model_pricing_type::EditorGenerationModelPricing; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -14,6 +15,8 @@ pub struct EditorGenerationPricingConfig { pub updated_by_admin_user_id: Option, pub updated_at: __sdk::Timestamp, pub writer_identity: __sdk::Identity, + pub text_to_model_pricing: Option, + pub image_to_model_pricing: Option, } impl __sdk::InModule for EditorGenerationPricingConfig { @@ -34,6 +37,14 @@ pub struct EditorGenerationPricingConfigCols { pub updated_at: __sdk::__query_builder::Col, pub writer_identity: __sdk::__query_builder::Col, + pub text_to_model_pricing: __sdk::__query_builder::Col< + EditorGenerationPricingConfig, + Option, + >, + pub image_to_model_pricing: __sdk::__query_builder::Col< + EditorGenerationPricingConfig, + Option, + >, } impl __sdk::__query_builder::HasCols for EditorGenerationPricingConfig { @@ -48,6 +59,14 @@ impl __sdk::__query_builder::HasCols for EditorGenerationPricingConfig { ), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), writer_identity: __sdk::__query_builder::Col::new(table_name, "writer_identity"), + text_to_model_pricing: __sdk::__query_builder::Col::new( + table_name, + "text_to_model_pricing", + ), + image_to_model_pricing: __sdk::__query_builder::Col::new( + table_name, + "image_to_model_pricing", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs index a773c4aa4..38046ada2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::editor_generation_model_3_d_pricing_type::EditorGenerationModel3DPricing; use super::editor_generation_model_pricing_type::EditorGenerationModelPricing; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -13,6 +14,8 @@ pub struct EditorGenerationPricingConfigUpsertInput { pub models: Vec, pub updated_at_micros: i64, pub bootstrap_secret: String, + pub text_to_model_pricing: Option, + pub image_to_model_pricing: Option, } impl __sdk::InModule for EditorGenerationPricingConfigUpsertInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_provider_checkpoint_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_provider_checkpoint_input_type.rs new file mode 100644 index 000000000..17ce6b40e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_provider_checkpoint_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationJobProviderCheckpointInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub provider_kind: String, + pub provider_task_id: String, +} + +impl __sdk::InModule for ExternalGenerationJobProviderCheckpointInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs index 8778d141c..5d34d2bf7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs @@ -32,6 +32,8 @@ pub struct ExternalGenerationJobSnapshot { pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, pub phase: Option, + pub provider_kind: Option, + pub provider_task_id: Option, } impl __sdk::InModule for ExternalGenerationJobSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs index f63d494a4..bf47075f3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs @@ -32,6 +32,8 @@ pub struct ExternalGenerationJob { pub refund_ledger_id: Option, pub notification_acknowledged_at: Option<__sdk::Timestamp>, pub phase: Option, + pub provider_kind: Option, + pub provider_task_id: Option, } impl __sdk::InModule for ExternalGenerationJob { @@ -69,6 +71,8 @@ pub struct ExternalGenerationJobCols { pub notification_acknowledged_at: __sdk::__query_builder::Col>, pub phase: __sdk::__query_builder::Col>, + pub provider_kind: __sdk::__query_builder::Col>, + pub provider_task_id: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ExternalGenerationJob { @@ -109,6 +113,8 @@ impl __sdk::__query_builder::HasCols for ExternalGenerationJob { "notification_acknowledged_at", ), phase: __sdk::__query_builder::Col::new(table_name, "phase"), + provider_kind: __sdk::__query_builder::Col::new(table_name, "provider_kind"), + provider_task_id: __sdk::__query_builder::Col::new(table_name, "provider_task_id"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/set_external_generation_job_provider_checkpoint_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/set_external_generation_job_provider_checkpoint_and_return_procedure.rs new file mode 100644 index 000000000..47bef2d83 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/set_external_generation_job_provider_checkpoint_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; +use super::external_generation_job_provider_checkpoint_input_type::ExternalGenerationJobProviderCheckpointInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SetExternalGenerationJobProviderCheckpointAndReturnArgs { + pub input: ExternalGenerationJobProviderCheckpointInput, +} + +impl __sdk::InModule for SetExternalGenerationJobProviderCheckpointAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `set_external_generation_job_provider_checkpoint_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait set_external_generation_job_provider_checkpoint_and_return { + fn set_external_generation_job_provider_checkpoint_and_return( + &self, + input: ExternalGenerationJobProviderCheckpointInput, + ) { + self.set_external_generation_job_provider_checkpoint_and_return_then(input, |_, _| {}); + } + + fn set_external_generation_job_provider_checkpoint_and_return_then( + &self, + input: ExternalGenerationJobProviderCheckpointInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl set_external_generation_job_provider_checkpoint_and_return for super::RemoteProcedures { + fn set_external_generation_job_provider_checkpoint_and_return_then( + &self, + input: ExternalGenerationJobProviderCheckpointInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "set_external_generation_job_provider_checkpoint_and_return", + SetExternalGenerationJobProviderCheckpointAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index c2de622d6..612866448 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -26,18 +26,8 @@ const EDITOR_IMAGE_ASSET_KIND_CLEANUP_CANVAS_MAX_BATCH_SIZE: u32 = 5; const EDITOR_LEGACY_IMAGE_ASSET_KIND: &str = "image"; // 透明图集最多产出 256 个切片,另有 provider 原图和透明整图两个正式 item。 const EDITOR_GENERATION_RESULT_MAX_ITEMS: usize = 258; -const EDITOR_GENERATION_OPERATION_KINDS: [&str; 10] = [ - "editor_image_generation", - "editor_icon_spec_generation", - "editor_image_edit", - "editor_background_removal", - "editor_icon_spritesheet_generation", - "editor_ui_design_asset_extraction", - "editor_character_animation_generation", - "editor_video_generation", - "editor_sound_effect_generation", - "editor_background_music_generation", -]; +// operation_kind 白名单由 shared-contracts 提供,入队方与这里共用同一份清单。 +use shared_contracts::EDITOR_GENERATION_OPERATION_KINDS; const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "character-animation"; const EDITOR_CHARACTER_ANIMATION_OBJECT_KIND: &str = "editor_character_animation"; const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [ @@ -54,7 +44,9 @@ const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [ "assetKind", "generationInputs", ]; -const EDITOR_CANVAS_ASSET_KINDS: [&str; 12] = [ +// 该白名单必须与前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 同步:3D 模型资源在画布上 +// 允许用户把标签在 `model3d` 与未知之间切换,因此它和图片类别一样必须在这里登记。 +const EDITOR_CANVAS_ASSET_KINDS: [&str; 13] = [ "spec", "scene", "character", @@ -67,6 +59,7 @@ const EDITOR_CANVAS_ASSET_KINDS: [&str; 12] = [ "video", "sound-effect", "background-music", + "model3d", ]; const EDITOR_PROJECT_SOURCE_TYPES: [&str; 3] = ["uploaded", "generated", "mock_generated"]; const EDITOR_SHOWCASE_STATUS_PENDING: &str = "pending"; @@ -115,6 +108,28 @@ pub struct EditorGenerationModelPricing { pub prices: Vec, } +/// 一个模型版本在「无贴图 / 带贴图」两种形态下的 3D 生成底价。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorGenerationModel3dVersionPrice { + pub model_version: String, + pub no_texture: u32, + pub texture: u32, +} + +/// 叠加在 3D 生成底价之上的加价项单价。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorGenerationModel3dAddOnPrice { + pub add_on: String, + pub price: u32, +} + +/// 单个 3D 生成端点的整套定价:模型版本底价与该端点自己的加价项价目。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorGenerationModel3dPricing { + pub version_prices: Vec, + pub add_on_prices: Vec, +} + #[spacetimedb::table( accessor = editor_project, index(accessor = by_editor_project_owner_user_id, btree(columns = [owner_user_id])) @@ -462,6 +477,10 @@ pub struct EditorGenerationPricingConfig { updated_by_admin_user_id: Option, updated_at: Timestamp, writer_identity: Identity, + #[default(None::)] + text_to_model_pricing: Option, + #[default(None::)] + image_to_model_pricing: Option, } #[spacetimedb::table(accessor = editor_generation_runtime_identity_rotation)] @@ -964,6 +983,8 @@ pub struct EditorGenerationPricingConfigSnapshot { pub models: Vec, pub updated_by_admin_user_id: Option, pub updated_at_micros: i64, + pub text_to_model_pricing: Option, + pub image_to_model_pricing: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1311,6 +1332,8 @@ pub struct EditorGenerationPricingConfigUpsertInput { pub models: Vec, pub updated_at_micros: i64, pub bootstrap_secret: String, + pub text_to_model_pricing: Option, + pub image_to_model_pricing: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -6799,6 +6822,14 @@ fn upsert_editor_generation_pricing_config( "editor_generation_pricing_config.updated_by_admin_user_id", )?; let models = normalize_editor_generation_pricing_models(input.models)?; + let text_to_model_pricing = normalize_editor_generation_model3d_pricing( + input.text_to_model_pricing, + "editor_generation_pricing_config.text_to_model_pricing", + )?; + let image_to_model_pricing = normalize_editor_generation_model3d_pricing( + input.image_to_model_pricing, + "editor_generation_pricing_config.image_to_model_pricing", + )?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let config_id = EDITOR_GENERATION_PRICING_CONFIG_ID.to_string(); let writer_identity = resolve_editor_generation_pricing_writer_identity( @@ -6819,6 +6850,8 @@ fn upsert_editor_generation_pricing_config( updated_by_admin_user_id: Some(admin_user_id), updated_at, writer_identity, + text_to_model_pricing, + image_to_model_pricing, }); ctx.db .editor_generation_pricing_config() @@ -6857,6 +6890,14 @@ fn initialize_editor_generation_pricing_config_if_missing( "editor_generation_pricing_config.updated_by_admin_user_id", )?; let models = normalize_editor_generation_pricing_models(input.models)?; + let text_to_model_pricing = normalize_editor_generation_model3d_pricing( + input.text_to_model_pricing, + "editor_generation_pricing_config.text_to_model_pricing", + )?; + let image_to_model_pricing = normalize_editor_generation_model3d_pricing( + input.image_to_model_pricing, + "editor_generation_pricing_config.image_to_model_pricing", + )?; let row = ctx .db .editor_generation_pricing_config() @@ -6866,6 +6907,8 @@ fn initialize_editor_generation_pricing_config_if_missing( updated_by_admin_user_id: Some(admin_user_id), updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros), writer_identity: caller, + text_to_model_pricing, + image_to_model_pricing, }); Ok(editor_generation_pricing_config_snapshot_from_row(row)) } @@ -7936,9 +7979,52 @@ fn editor_generation_pricing_config_snapshot_from_row( models: row.models, updated_by_admin_user_id: row.updated_by_admin_user_id, updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + text_to_model_pricing: row.text_to_model_pricing, + image_to_model_pricing: row.image_to_model_pricing, } } +/// 3D 生成定价两个端点的段级校验:段允许整体缺失,非空时必须同时给出底价与加价项、 +/// 键唯一且价格为正。契约支持的模型版本与加价项集合由 api-server 侧的枚举负责, +/// 模块这里只守存储形状,避免把模型版本清单复制成第二份真相。 +fn normalize_editor_generation_model3d_pricing( + pricing: Option, + label: &str, +) -> Result, String> { + let Some(mut pricing) = pricing else { + return Ok(None); + }; + if pricing.version_prices.is_empty() || pricing.add_on_prices.is_empty() { + return Err(format!("{label} 必须同时给出模型版本底价与加价项")); + } + + let mut seen_versions = BTreeSet::new(); + for entry in pricing.version_prices.iter_mut() { + let model_version = normalize_required(&entry.model_version, label)?; + if !seen_versions.insert(model_version.clone()) { + return Err(format!("{label} 包含重复模型版本 {model_version}")); + } + if entry.no_texture == 0 || entry.texture == 0 { + return Err(format!("{label} 的模型版本 {model_version} 价格必须大于 0")); + } + entry.model_version = model_version; + } + + let mut seen_add_ons = BTreeSet::new(); + for entry in pricing.add_on_prices.iter_mut() { + let add_on = normalize_required(&entry.add_on, label)?; + if !seen_add_ons.insert(add_on.clone()) { + return Err(format!("{label} 包含重复加价项 {add_on}")); + } + if entry.price == 0 { + return Err(format!("{label} 的加价项 {add_on} 价格必须大于 0")); + } + entry.add_on = add_on; + } + + Ok(Some(pricing)) +} + fn normalize_editor_generation_pricing_models( models: Vec, ) -> Result, String> { @@ -20036,11 +20122,73 @@ mod tests { models: valid_editor_generation_pricing_models(), updated_by_admin_user_id: Some("admin:test".to_string()), updated_at_micros: 1, + text_to_model_pricing: None, + image_to_model_pricing: None, }; assert!(!format!("{snapshot:?}").contains("writer_identity")); } + #[test] + fn normalize_editor_generation_model3d_pricing_accepts_absent_and_complete_sections() { + assert_eq!( + normalize_editor_generation_model3d_pricing(None, "段"), + Ok(None) + ); + + let pricing = EditorGenerationModel3dPricing { + version_prices: vec![EditorGenerationModel3dVersionPrice { + model_version: "v3.1-20260211".to_string(), + no_texture: 8, + texture: 16, + }], + add_on_prices: vec![EditorGenerationModel3dAddOnPrice { + add_on: " hdTexture ".to_string(), + price: 8, + }], + }; + + let normalized = normalize_editor_generation_model3d_pricing(Some(pricing), "段") + .expect("complete section should normalize"); + let normalized = normalized.expect("section should stay present"); + assert_eq!(normalized.add_on_prices[0].add_on, "hdTexture"); + } + + #[test] + fn normalize_editor_generation_model3d_pricing_rejects_partial_and_duplicate_keys() { + let partial = EditorGenerationModel3dPricing { + version_prices: vec![EditorGenerationModel3dVersionPrice { + model_version: "v3.1-20260211".to_string(), + no_texture: 8, + texture: 16, + }], + add_on_prices: Vec::new(), + }; + assert!(normalize_editor_generation_model3d_pricing(Some(partial), "段").is_err()); + + let duplicate = EditorGenerationModel3dPricing { + version_prices: vec![ + EditorGenerationModel3dVersionPrice { + model_version: "v3.1-20260211".to_string(), + no_texture: 8, + texture: 16, + }, + EditorGenerationModel3dVersionPrice { + model_version: "v3.1-20260211".to_string(), + no_texture: 10, + texture: 18, + }, + ], + add_on_prices: vec![EditorGenerationModel3dAddOnPrice { + add_on: "hdTexture".to_string(), + price: 8, + }], + }; + let error = normalize_editor_generation_model3d_pricing(Some(duplicate), "段") + .expect_err("duplicate model version should fail"); + assert!(error.contains("重复模型版本")); + } + #[test] fn normalize_editor_generation_pricing_models_rejects_missing_required_model() { let mut models = valid_editor_generation_pricing_models(); diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 5e93b162c..eba694174 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -15,6 +15,7 @@ const EXTERNAL_GENERATION_EVENT_LEASE_RENEWED: &str = "lease_renewed"; const EXTERNAL_GENERATION_EVENT_COMPLETED: &str = "completed"; const EXTERNAL_GENERATION_EVENT_FAILED: &str = "failed"; const EXTERNAL_GENERATION_EVENT_ACKNOWLEDGED: &str = "acknowledged"; +const EXTERNAL_GENERATION_EVENT_PROVIDER_CHECKPOINT: &str = "provider_checkpoint"; const EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE: &str = "editor-canvas"; const EXTERNAL_GENERATION_FINAL_ATTEMPT_LEASE_EXPIRED_MESSAGE: &str = "worker 最终执行次数的 lease 已过期,任务已终止"; @@ -90,6 +91,12 @@ pub struct ExternalGenerationJob { pub(crate) notification_acknowledged_at: Option, #[default(None::)] pub(crate) phase: Option, + // 中文注释:provider 任务 checkpoint 晚于主表加入。没有 provider_task_id 才允许 submit, + // 有 checkpoint 的 attempt 只允许续跑查询与落库,避免崩溃重试造成二次提交与二次扣费。 + #[default(None::)] + pub(crate) provider_kind: Option, + #[default(None::)] + pub(crate) provider_task_id: Option, } #[spacetimedb::table( @@ -193,6 +200,15 @@ pub struct ExternalGenerationJobPhaseUpdateInput { pub phase: String, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobProviderCheckpointInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub provider_kind: String, + pub provider_task_id: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] pub enum ExternalGenerationJobPhaseUpdateFailureKind { LeaseFencingRejected, @@ -283,6 +299,8 @@ pub struct ExternalGenerationJobSnapshot { pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, pub phase: Option, + pub provider_kind: Option, + pub provider_task_id: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -504,6 +522,23 @@ pub fn renew_external_generation_job_lease_and_return( } } +#[spacetimedb::procedure] +pub fn set_external_generation_job_provider_checkpoint_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobProviderCheckpointInput, +) -> ExternalGenerationJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + set_external_generation_job_provider_checkpoint_tx(tx, input.clone()) + }) { + Ok(job) => single_external_generation_job_result(job), + Err(message) => failed_external_generation_job_result(message), + } +} + #[spacetimedb::procedure] pub fn update_external_generation_job_phase_and_return( ctx: &mut ProcedureContext, @@ -819,6 +854,8 @@ fn enqueue_external_generation_job_tx( refund_ledger_id: None, notification_acknowledged_at: None, phase: None, + provider_kind: None, + provider_task_id: None, }; persist_external_generation_job_row(ctx, row.clone()); insert_external_generation_job_event( @@ -1578,6 +1615,45 @@ fn update_external_generation_job_phase_tx( Ok(map_external_generation_job_row(row)) } +/// 写入 provider 任务 checkpoint。 +/// +/// 只有持有当前租约的 worker 能写;checkpoint 一旦存在就不再允许覆盖, +/// 这是 at-most-once submit 的数据层保证:崩溃后重新 claim 的 attempt 只能读到既有 +/// provider_task_id 并继续查询,不会再去 submit 一次。 +fn set_external_generation_job_provider_checkpoint_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobProviderCheckpointInput, +) -> Result { + let provider_kind = normalize_optional_text(&input.provider_kind) + .ok_or_else(|| "external_generation_job.provider_kind 不能为空".to_string())?; + let provider_task_id = normalize_optional_text(&input.provider_task_id) + .ok_or_else(|| "external_generation_job.provider_task_id 不能为空".to_string())?; + + let mut row = get_worker_owned_external_generation_job( + ctx, + &input.job_id, + &input.worker_id, + &input.lease_token, + )?; + ensure_external_generation_job_provider_checkpoint_absent(&row)?; + + row.provider_kind = Some(provider_kind); + row.provider_task_id = Some(provider_task_id.clone()); + row.updated_at = ctx.timestamp; + persist_external_generation_job_row(ctx, row.clone()); + insert_external_generation_job_event( + ctx, + &row, + EXTERNAL_GENERATION_EVENT_PROVIDER_CHECKPOINT, + Some(format!( + "worker 已记录 provider 任务 checkpoint {provider_task_id}" + )), + Some(input.worker_id), + ctx.timestamp, + ); + Ok(map_external_generation_job_row(row)) +} + fn fail_external_generation_job_tx( ctx: &ReducerContext, input: ExternalGenerationJobFailInput, @@ -1801,6 +1877,18 @@ fn get_worker_owned_external_generation_job( .map_err(|error| error.message) } +/// at-most-once submit 的数据层不变量:已有 checkpoint 的 job 不允许再写第二次。 +fn ensure_external_generation_job_provider_checkpoint_absent( + row: &ExternalGenerationJob, +) -> Result<(), String> { + // checkpoint 由 provider_kind + provider_task_id 两个字段共同构成, + // 只判 task_id 会让部分写入的行绕过 at-most-once submit 保护。 + if row.provider_kind.is_some() || row.provider_task_id.is_some() { + return Err("external_generation_job.provider checkpoint 已存在,禁止覆盖".to_string()); + } + Ok(()) +} + #[derive(Debug)] struct ExternalGenerationJobPhaseUpdateError { kind: ExternalGenerationJobPhaseUpdateFailureKind, @@ -2573,6 +2661,8 @@ fn map_external_generation_job_row(row: ExternalGenerationJob) -> ExternalGenera refund_ledger_id: row.refund_ledger_id, notification_acknowledged_at_micros, phase: row.phase, + provider_kind: row.provider_kind, + provider_task_id: row.provider_task_id, } } @@ -2641,6 +2731,9 @@ fn map_external_generation_job_summary_to_compat_snapshot( refund_ledger_id: summary.refund_ledger_id, notification_acknowledged_at_micros: summary.notification_acknowledged_at_micros, phase: summary.phase, + // 中文注释:轻量摘要不投影 provider checkpoint,兼容快照按无 checkpoint 返回。 + provider_kind: None, + provider_task_id: None, } } @@ -3095,6 +3188,22 @@ mod tests { } } + #[test] + fn provider_checkpoint_cannot_be_overwritten_after_first_write() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); + assert!( + ensure_external_generation_job_provider_checkpoint_absent(&row).is_ok(), + "首次 checkpoint 必须允许写入" + ); + + row.provider_kind = Some("tripo".to_string()); + row.provider_task_id = Some("task-1".to_string()); + assert!( + ensure_external_generation_job_provider_checkpoint_absent(&row).is_err(), + "已有 checkpoint 必须拒绝覆盖,保证 at-most-once submit" + ); + } + #[test] fn external_generation_job_result_failure_is_structured() { let result = failed_external_generation_job_result("失败".to_string()); @@ -3847,6 +3956,8 @@ mod tests { refund_ledger_id: None, notification_acknowledged_at: None, phase: None, + provider_kind: None, + provider_task_id: None, } } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index e413af575..2402b8bb7 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1350,6 +1350,17 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } + if table_name == "external_generation_job" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:provider 任务 checkpoint 晚于外部生成主表加入,旧迁移包按尚未提交 provider 兼容。 + object + .entry("provider_kind".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("provider_task_id".to_string()) + .or_insert(serde_json::Value::Null); + } + } if table_name == "editor_project_resource" { if let Some(object) = next_value.as_object_mut() { // 中文注释:精选公开开关晚于画布资源表加入,旧生成资源按默认公开兼容。 @@ -1526,6 +1537,23 @@ mod migration_bootstrap_secret_tests { assert_eq!(normalized["warning_message"], serde_json::Value::Null); } + /// 旧迁移包的 external_generation_job 没有 provider checkpoint 两列:导入后必须都是 + /// null(等同 `#[default(None)]`),否则 at-most-once submit 会把旧任务误判成已提交。 + #[test] + fn old_external_generation_job_rows_default_to_unsubmitted_provider() { + let normalized = normalize_migration_row( + "external_generation_job", + &serde_json::json!({ "job_id": "task-1" }), + ); + + // serde_json 的 `Index<&str>` 在键缺失时返回 `Null` 而不是报错,直接断言 + // `normalized["provider_kind"] == Null` 在归一化整段被删掉时也照样通过。 + // 先按对象取键,再锁 null 默认值,才真正守住这两列被插进去这件事。 + let row = normalized.as_object().expect("归一化后的行必须是对象"); + assert_eq!(row.get("provider_kind"), Some(&serde_json::Value::Null)); + assert_eq!(row.get("provider_task_id"), Some(&serde_json::Value::Null)); + } + #[test] fn old_editor_canvas_rows_default_to_legacy_storage() { let normalized = normalize_migration_row( diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 7dcace2fd..6a08db8e4 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -271,6 +271,22 @@ describe('EditorAgentConversationPanelView', () => { zIndex: 2, sourceType: 'uploaded', }, + { + id: 'layer-model3d', + resourceId: 'resource-model3d', + title: '三维角色', + src: '/generated/model3d/preview.webp', + objectKey: 'generated/editor/model3d/task-1/model.glb', + assetKind: 'model3d', + x: 0, + y: 0, + width: 256, + height: 256, + originalWidth: 256, + originalHeight: 256, + zIndex: 3, + sourceType: 'generated', + }, ]} assets={[ { @@ -313,6 +329,12 @@ describe('EditorAgentConversationPanelView', () => { name: '选择画布图片 未入库图层', }), ).toBeNull(); + // 3D 资源的画布媒体类型也是 image,但资源身份是模型本体,不能当参考图。 + expect( + within(attachmentDialog).queryByRole('checkbox', { + name: '选择画布图片 三维角色', + }), + ).toBeNull(); fireEvent.click( within(attachmentDialog).getByRole('checkbox', { name: '选择画布图片 一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯', diff --git a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts index a3a9c0e69..6ef2b1aaf 100644 --- a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts +++ b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts @@ -33,8 +33,20 @@ type AttachmentUpdater = currentAttachments: EditorAgentAttachmentRef[], ) => EditorAgentAttachmentRef[]); +/** + * 3D 模型资源不能当参考图。 + * + * 它的 `mediaType` 就是 `image`(画布上用预览图渲染),但资源身份是模型本体, + * `objectKey` 指向 `.glb`。参考图管线按 objectKey 取对象、按 `image/*` 校验, + * 放进来只会得到一个打不开的附件;"把 3D 结果的渲染图当参考图"是另一个产品决策。 + */ +function isModel3dSource(assetKind: string | null | undefined) { + return assetKind === 'model3d'; +} + function isImageLayer(layer: CanvasLayer) { return ( + !isModel3dSource(layer.assetKind) && (layer.mediaType ?? 'image') === 'image' && Boolean(layer.resourceId?.trim()) && layer.src.trim() @@ -42,7 +54,11 @@ function isImageLayer(layer: CanvasLayer) { } function isImageAsset(asset: EditorAsset) { - return (asset.mediaType ?? 'image') === 'image' && asset.src.trim(); + return ( + !isModel3dSource(asset.assetKind) && + (asset.mediaType ?? 'image') === 'image' && + asset.src.trim() + ); } function createCanvasAttachmentOptions( diff --git a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx index cb5244150..47aaa9458 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx @@ -153,6 +153,36 @@ describe('ImageCanvasAssetRowView', () => { expect(onDownloadAsset).toHaveBeenCalledWith(asset); }); + it('3D 素材用预览图渲染,不拿模型对象去换签名地址', () => { + const asset = createAsset({ + label: '木椅', + assetKind: 'model3d', + mediaType: 'image', + src: '/genarrative-assets/editor/model3d/task-1/preview.webp', + objectKey: 'genarrative-assets/editor/model3d/task-1/model.glb', + }); + renderAssetRow({ asset }); + + const preview = screen.getByAltText('素材:木椅'); + expect(preview.getAttribute('src')).toBe( + '/genarrative-assets/editor/model3d/task-1/preview.webp', + ); + // objectKey 是模型本体,带上它会让缩略图去加载 .glb。 + expect(preview.getAttribute('data-object-key')).toBeNull(); + }); + + it('普通图片素材仍按 objectKey 换签名地址', () => { + const asset = createAsset({ + mediaType: 'image', + objectKey: 'genarrative-assets/editor/images/account-a.webp', + }); + renderAssetRow({ asset }); + + expect( + screen.getByAltText('素材:账号素材A').getAttribute('data-object-key'), + ).toBe('genarrative-assets/editor/images/account-a.webp'); + }); + it('closes the right click menu when row actions run on the same asset', () => { const asset = createAsset({ sourceResourceId: 'resource-asset-1', diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index 77441bcf0..83eceb6b5 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -347,7 +347,13 @@ export function ImageCanvasAssetRowView({ ? AUDIO_ASSET_COVER_SRC : (asset.thumbnailSrc ?? '') } - objectKey={asset.mediaType === 'image' ? asset.objectKey : undefined} + // 3D 素材的 objectKey 是模型本体(.glb),它的展示图是预览图(asset.src): + // 沿用画布 3D 卡片的同一条口径,这里不能拿模型对象去换签名地址,否则缩略图会去加载 .glb。 + objectKey={ + asset.mediaType === 'image' && asset.assetKind !== 'model3d' + ? asset.objectKey + : undefined + } previewNode={videoPreview} titleNode={ isUploadingAsset || isFailedUpload ? ( diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx index ed6b97b37..3f7b955fe 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx @@ -15,11 +15,13 @@ describe('ImageCanvasBottomToolbarView', () => { const specToolWrapRef = createRef(); const musicToolWrapRef = createRef(); const publicationToolWrapRef = createRef(); + const model3dToolWrapRef = createRef(); const view = render( { specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, }, }; } @@ -48,6 +51,7 @@ describe('ImageCanvasBottomToolbarView', () => { specToolWrapRef: RefObject; musicToolWrapRef: RefObject; publicationToolWrapRef: RefObject; + model3dToolWrapRef: RefObject; }, ) { return ( @@ -55,6 +59,7 @@ describe('ImageCanvasBottomToolbarView', () => { specToolWrapRef={refs.specToolWrapRef} musicToolWrapRef={refs.musicToolWrapRef} publicationToolWrapRef={refs.publicationToolWrapRef} + model3dToolWrapRef={refs.model3dToolWrapRef} effectiveTool={effectiveTool} onSwitchTool={vi.fn()} onOpenToolOptions={vi.fn()} @@ -73,10 +78,10 @@ describe('ImageCanvasBottomToolbarView', () => { ); expect( toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-group'), - ).toHaveLength(12); + ).toHaveLength(13); expect( toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-divider'), - ).toHaveLength(2); + ).toHaveLength(3); const toolExpectations = [ ['选择工具', 'select'], @@ -89,6 +94,7 @@ describe('ImageCanvasBottomToolbarView', () => { ['生成角色形象', 'character'], ['生成图标素材', 'icon'], ['生成UI设计图', 'ui-design'], + ['生成 3D 模型', 'model3d'], ['宣发素材', 'publication'], ['生成游戏场景', 'scene'], ] as const; diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx index 013985cf0..453392e4e 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx @@ -5,6 +5,7 @@ import { } from '@genarrative/image-canvas-react'; import { AppWindow, + Box, Clapperboard, ClipboardList, Grid2X2, @@ -22,12 +23,16 @@ import type { RefObject } from 'react'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasTool } from './ImageCanvasEditorTypes'; -type ToolbarOptionTool = Extract; +type ToolbarOptionTool = Extract< + CanvasTool, + 'music' | 'spec' | 'publication' | 'model3d' +>; type ImageCanvasBottomToolbarViewProps = { specToolWrapRef: RefObject; musicToolWrapRef: RefObject; publicationToolWrapRef: RefObject; + model3dToolWrapRef: RefObject; effectiveTool: CanvasTool; highlight?: boolean; onSwitchTool: (tool: CanvasTool) => void; @@ -62,11 +67,22 @@ const canvasTools: Array<{ { id: 'icon', label: '生成图标素材', icon: Grid2X2 }, { id: 'ui-design', label: '生成UI设计图', icon: AppWindow }, { id: 'scene', label: '生成游戏场景', icon: Mountain }, + { + id: 'model3d', + label: '生成 3D 模型', + icon: Box, + separatorBefore: true, + }, { id: 'publication', label: '宣发素材', icon: Megaphone }, ]; function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool { - return tool === 'music' || tool === 'spec' || tool === 'publication'; + return ( + tool === 'music' || + tool === 'spec' || + tool === 'publication' || + tool === 'model3d' + ); } function hasPersistentPressedState(tool: CanvasTool) { @@ -77,12 +93,24 @@ export function ImageCanvasBottomToolbarView({ specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, effectiveTool, highlight = false, onSwitchTool, onOpenToolOptions, onCloseToolOptions, }: ImageCanvasBottomToolbarViewProps) { + // 每个可展开子选项挂在自己那一格上的定位锚点:查表比三层三元链更好加新工具。 + const toolWrapRefs: Record< + ToolbarOptionTool, + RefObject + > = { + spec: specToolWrapRef, + music: musicToolWrapRef, + model3d: model3dToolWrapRef, + publication: publicationToolWrapRef, + }; + return ( {separatorBefore ? : null} { ); expect(isCanvasAssetKindOverrideCompatible('audio', 'icon')).toBe(false); expect(isCanvasAssetKindOverrideCompatible('video', null)).toBe(true); + expect(isCanvasAssetKindOverrideCompatible('model3d', 'model3d')).toBe( + true, + ); + expect(isCanvasAssetKindOverrideCompatible('model3d', null)).toBe(true); + expect(isCanvasAssetKindOverrideCompatible('model3d', 'character')).toBe( + false, + ); + expect(isCanvasAssetKindOverrideCompatible(null, 'model3d')).toBe(false); + }); + + it('hydrates a model3d layer with the preview src and the model object key', () => { + const hydrated = hydrateLayer( + { + layerId: 'layer-model3d', + resourceId: 'resource-model3d', + title: '三维角色', + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'generated', + }, + new Map([ + [ + 'resource-model3d', + { + imageSrc: '/read/model3d-preview.webp', + objectKey: 'generated/editor/model3d/task-1/model.glb', + assetKind: 'model3d', + }, + ], + ]), + ); + + expect(hydrated).toMatchObject({ + id: 'layer-model3d', + mediaType: 'image', + resourceAssetKind: 'model3d', + assetKind: 'model3d', + }); + // 画布卡片消费预览图,模型本体只留在 objectKey 里交给 3D 查看器。 + expect(hydrated?.src).toContain('model3d-preview.webp'); + expect(hydrated?.objectKey).toContain('model.glb'); }); it('hydrates audio display duration only from resource generation inputs', () => { diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 93b168bbf..1d499809e 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -1875,11 +1875,15 @@ type CanvasAssetMediaFamily = | 'image' | 'character-animation' | 'video' - | 'audio'; + | 'audio' + | 'model3d'; function resolveCanvasAssetMediaFamily( assetKind: CanvasAssetKind | null | undefined, ): CanvasAssetMediaFamily { + if (assetKind === 'model3d') { + return 'model3d'; + } if (assetKind === 'character-animation') { return 'character-animation'; } @@ -2150,6 +2154,7 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { value === 'icon' || value === 'icon-spritesheet' || value === 'icon-spec' || + value === 'model3d' || value === 'publication-material' || value === 'ui-design' || value === 'video' || @@ -2189,7 +2194,9 @@ function isCanvasGenerationDialogMode( value === 'character-animation' || value === 'video' || value === 'audio-sound-effect' || - value === 'audio-background-music' + value === 'audio-background-music' || + value === 'model3d-text-to-model' || + value === 'model3d-image-to-model' ); } diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index a5f337aa4..99fa2fad4 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -6,7 +6,12 @@ import { screen, within, } from '@testing-library/react'; -import { createRef, type ReactElement, type ReactNode } from 'react'; +import { + type ComponentProps, + createRef, + type ReactElement, + type ReactNode, +} from 'react'; import { describe, expect, it, vi } from 'vitest'; import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider'; @@ -16,6 +21,7 @@ import type { ImageCanvasMetadataModalViewProps } from './ImageCanvasMetadataMod import type { ImageCanvasSidebarViewProps } from './ImageCanvasSidebarView'; import type { ImageCanvasStageViewProps } from './ImageCanvasStageView'; import type { ImageCanvasTopbarViewProps } from './ImageCanvasTopbarView'; +import { Model3dViewerModal } from './model3d-preview/Model3dViewerModal'; function render(ui: ReactElement) { return testingLibraryRender(ui, { @@ -148,6 +154,7 @@ function createStageProps(): ImageCanvasStageViewProps { specToolWrapRef: createRef(), musicToolWrapRef: createRef(), publicationToolWrapRef: createRef(), + model3dToolWrapRef: createRef(), isPanning: false, effectiveTool: 'select', canvasBackgroundColor: '#f8fafc', @@ -234,6 +241,7 @@ function createStageProps(): ImageCanvasStageViewProps { onQuickEditSelectionPointerMove: vi.fn(), onQuickEditSelectionPointerEnd: vi.fn(), onOpenCharacterAnimationPanel: vi.fn(), + onPreviewModel3d: vi.fn(), onDownloadLayer: vi.fn(), onPasteCanvasClipboard: vi.fn(), onCopyContextLayers: vi.fn(), @@ -276,6 +284,15 @@ function createMetadataProps( }; } +function createModel3dViewerProps( + layer: CanvasLayer | null = null, +): ComponentProps { + return { + layer, + onClose: vi.fn(), + }; +} + describe('ImageCanvasEditorShellView', () => { it('composes upload input, topbar, stage and metadata modal', () => { const handleUploadInputChange = vi.fn(); @@ -289,6 +306,7 @@ describe('ImageCanvasEditorShellView', () => { topbarProps={createTopbarProps()} stageProps={createStageProps()} metadataProps={createMetadataProps(createLayer())} + model3dViewerProps={createModel3dViewerProps()} />, ); @@ -319,6 +337,7 @@ describe('ImageCanvasEditorShellView', () => { topbarProps={createTopbarProps()} stageProps={createStageProps()} metadataProps={createMetadataProps()} + model3dViewerProps={createModel3dViewerProps()} />, ); @@ -340,6 +359,7 @@ describe('ImageCanvasEditorShellView', () => { topbarProps={createTopbarProps()} stageProps={createStageProps()} metadataProps={createMetadataProps(createLayer())} + model3dViewerProps={createModel3dViewerProps()} />, ); @@ -389,6 +409,7 @@ describe('ImageCanvasEditorShellView', () => { topbarProps={createTopbarProps()} stageProps={stageProps} metadataProps={createMetadataProps()} + model3dViewerProps={createModel3dViewerProps()} />, ); diff --git a/src/components/image-editor/ImageCanvasEditorShellView.tsx b/src/components/image-editor/ImageCanvasEditorShellView.tsx index c3a6dc0af..7478423c0 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.tsx @@ -4,6 +4,7 @@ import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView'; import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ImageCanvasStageView } from './ImageCanvasStageView'; import { ImageCanvasTopbarView } from './ImageCanvasTopbarView'; +import { Model3dViewerModal } from './model3d-preview/Model3dViewerModal'; type AssetDragPreview = { x: number; @@ -21,6 +22,7 @@ type ImageCanvasEditorShellViewProps = { topbarProps: ComponentProps; stageProps: ComponentProps; metadataProps: ComponentProps; + model3dViewerProps: ComponentProps; }; export function ImageCanvasEditorShellView({ @@ -33,6 +35,7 @@ export function ImageCanvasEditorShellView({ topbarProps, stageProps, metadataProps, + model3dViewerProps, }: ImageCanvasEditorShellViewProps) { const uploadInputLabel = uploadAccept.includes('video') || uploadAccept.includes('audio') @@ -83,6 +86,7 @@ export function ImageCanvasEditorShellView({ + ); } diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 9e1664d77..ade380681 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -4,6 +4,7 @@ import type { EditorSoundEffectModel, } from '../../../packages/shared/src/contracts/editorAudio'; import type { EditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene'; +import type { Model3dModelVersion } from '../../../packages/shared/src/contracts/model3d'; import type { EditorAssetSnapshot, EditorCharacterAnimationFrameCount, @@ -29,6 +30,7 @@ export type CanvasAssetKind = | 'icon' | 'icon-spritesheet' | 'icon-spec' + | 'model3d' | 'publication-material' | 'ui-design' | 'video' @@ -188,7 +190,8 @@ export type CanvasTool = | 'icon' | 'publication' | 'ui-design' - | 'scene'; + | 'scene' + | 'model3d'; export type SidebarPanel = 'assets' | 'layers'; @@ -260,7 +263,9 @@ export type GenerateDialogState = { | 'video' | 'audio-sound-effect' | 'audio-background-music' - | 'scene'; + | 'scene' + | 'model3d-text-to-model' + | 'model3d-image-to-model'; prompt: string; assetLabel?: string; status: 'idle' | 'generating' | 'pending-confirmation' | 'failed'; @@ -295,6 +300,21 @@ export type GenerateDialogState = { characterAnimationFrameCount?: EditorCharacterAnimationFrameCount; characterAnimationDurationSeconds?: 4 | 5 | 6; characterAnimationResult?: EditorCharacterAnimationGenerationResult; + /** + * 3D 模型版本:用户可选,缺省(含老快照)回落到 `DEFAULT_MODEL3D_MODEL_VERSION`。 + * 报价与请求体都读它,面板上的模型选择器也写它。 + */ + model3dModel?: Model3dModelVersion; + model3dTier?: Model3dGenerationTier; + model3dQuad?: boolean; + model3dSmartLowPoly?: boolean; + model3dGenerateParts?: boolean; + /** + * 3D 生成的尝试代次:真正的 `Idempotency-Key` 由它与请求内容指纹拼成(见 + * `model3d-generation/Model3dGenerationSubmission.ts`)。代次只负责区分「用户主动重试」, + * 因此重试必定换键,而内容一变(含换参考图)也会自动换键。 + */ + model3dAttemptNonce?: string; soundModel?: EditorSoundEffectModel | 'audio1.0'; soundDurationMode?: EditorSoundEffectDurationMode; soundDurationSeconds?: number; @@ -327,6 +347,14 @@ export type GenerateDialogState = { }; }; +/** + * 3D 模型档位:唯一给用户看的参数面,每个档位固定映射成 provider 的完整贴图与几何组合。 + */ +export type Model3dGenerationTier = + | 'textured-standard' + | 'textured-detailed' + | 'geometry-only'; + export type CanvasGenerationDialogMode = Exclude< GenerateDialogState['mode'], 'edit' diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 8aca4173e..ca9f0462b 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -398,6 +398,7 @@ export function ImageCanvasEditorView({ const specToolWrapRef = useRef(null); const musicToolWrapRef = useRef(null); const publicationToolWrapRef = useRef(null); + const model3dToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); const generationReferenceButtonRef = useRef(null); @@ -505,6 +506,8 @@ export function ImageCanvasEditorView({ const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [hoveredLayerId, setHoveredLayerId] = useState(null); const [metadataLayer, setMetadataLayer] = useState(null); + const [model3dPreviewLayer, setModel3dPreviewLayer] = + useState(null); const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] = useState(null); const [imageContextMenu, setImageContextMenu] = @@ -1438,6 +1441,7 @@ export function ImageCanvasEditorView({ specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, generationReferenceButtonRef, @@ -2294,8 +2298,15 @@ export function ImageCanvasEditorView({ ], ); + // 模态打开时画布不再接收交互:信息弹窗与 3D 预览都盖在画布上,若快捷键仍然生效, + // Delete/Backspace 会删掉模态背后那个正在被查看的图层(预览入口还会顺手选中它)。 + const isCanvasInteractionPaused = + isAccountPaymentModalOpen || + Boolean(metadataLayer) || + Boolean(model3dPreviewLayer); + useImageCanvasKeyboardShortcuts({ - isInteractionPaused: isAccountPaymentModalOpen, + isInteractionPaused: isCanvasInteractionPaused, generateDialogRef, selectedLayerIdRef, selectedLayerIdsRef, @@ -2550,7 +2561,8 @@ export function ImageCanvasEditorView({ specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, - isInteractionPaused: isAccountPaymentModalOpen, + model3dToolWrapRef, + isInteractionPaused: isCanvasInteractionPaused, isPanning, effectiveTool, canvasBackgroundColor, @@ -2663,6 +2675,10 @@ export function ImageCanvasEditorView({ onQuickEditSelectionPointerMove: moveQuickEditSelectionPointer, onQuickEditSelectionPointerEnd: endQuickEditSelectionPointer, onOpenCharacterAnimationPanel: openCharacterAnimationPanel, + onPreviewModel3d: (layer: CanvasLayer) => { + setModel3dPreviewLayer(layer); + selectSingleLayer(layer.id); + }, onDownloadLayer: exportLayerImage, onPasteCanvasClipboard: pasteCanvasOrSystemClipboard, onCopyContextLayers: copyContextLayers, @@ -2714,6 +2730,10 @@ export function ImageCanvasEditorView({ layer: metadataLayer, onClose: () => setMetadataLayer(null), }} + model3dViewerProps={{ + layer: model3dPreviewLayer, + onClose: () => setModel3dPreviewLayer(null), + }} /> ) : null} + {(generateDialog?.mode === 'model3d-text-to-model' || + generateDialog?.mode === 'model3d-image-to-model') && + generateDialog.composerOpen !== false && + generationComposerStyle ? ( + + ) : null} + {(generateDialog?.mode === 'audio-sound-effect' || generateDialog?.mode === 'audio-background-music') && generateDialog.composerOpen !== false && diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index c1b69ca4e..2e4153947 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -1537,6 +1537,48 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); + it('routes a picked canvas image into the single 3D image-to-model slot', () => { + const sourceLayer = createLayer({ title: '参考图' }); + const videoLayer = createLayer({ id: 'layer-video', mediaType: 'video' }); + + const next = appendGenerationReference( + { + mode: 'model3d-image-to-model', + prompt: '', + status: 'failed', + errorMessage: '失败', + }, + sourceLayer, + ); + + expect(next).toMatchObject({ + mode: 'model3d-image-to-model', + status: 'idle', + composerOpen: true, + generationReferences: [ + expect.objectContaining({ + id: 'canvas-layer-source', + label: '参考图', + resourceId: 'resource-source', + }), + ], + }); + // 单图槽位:再点一张不会顶掉已选中的那张,用户先删再加。 + expect( + appendGenerationReference( + next, + createLayer({ id: 'layer-other' }), + )?.generationReferences?.map((reference) => reference.id), + ).toEqual(['canvas-layer-source']); + // 非图片图层不构成 3D 输入。 + expect( + appendGenerationReference( + { mode: 'model3d-image-to-model', prompt: '', status: 'idle' }, + videoLayer, + )?.generationReferences, + ).toBeUndefined(); + }); + it('routes picked canvas references to spec, image, video, and UI design fields', () => { const sourceLayer = createLayer({ title: '参考图' }); const videoLayer = createLayer({ @@ -1944,5 +1986,18 @@ describe('ImageCanvasGenerationDialogModel', () => { ...generateDialog, composerOpen: false, }); + + // 3D 两个模式同样要在失焦与关闭时收起面板,与其它生成模式一致。 + for (const mode of ['model3d-text-to-model', 'model3d-image-to-model']) { + const model3dDialog = { ...generateDialog, mode } as GenerateDialogState; + expect(hideGeneratedLayerComposerAfterBlur(model3dDialog), mode).toEqual({ + ...model3dDialog, + composerOpen: false, + }); + expect(closeGenerateComposerDialog(model3dDialog), mode).toEqual({ + ...model3dDialog, + composerOpen: false, + }); + } }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 6c6a5fb0f..81af2a047 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -55,8 +55,14 @@ import { resolveEditorImageGenerationPixelSize, resolveEditorVideoGenerationPixelSize, SPEC_FRAME_ORIGINAL_SIZE, + usesSingleImageReferenceSlot, } from './ImageCanvasGenerationModel'; import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; +import { + createModel3dGenerationDraftFields, + MODEL3D_GENERATION_FRAME_SIZE, + type Model3dGenerationMode, +} from './model3d-generation/Model3dGenerationFormModel'; import { isProjectAssetPickerReference } from './projectAssetReferencePickerModel'; type CanvasSize = { width: number; height: number }; @@ -505,6 +511,30 @@ export function createSoundEffectGenerationDialogDraft({ }; } +export function createModel3dGenerationDialogDraft({ + canvasSize, + viewport, + mode, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; + mode: Model3dGenerationMode; +}): Omit { + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + const frameSize = MODEL3D_GENERATION_FRAME_SIZE; + return { + ...createModel3dGenerationDraftFields(mode), + placeholder: { + x: worldCenter.x - frameSize.width / 2, + y: worldCenter.y - frameSize.height / 2, + width: frameSize.width, + height: frameSize.height, + originalWidth: frameSize.width, + originalHeight: frameSize.height, + }, + }; +} + export function createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport, @@ -2183,7 +2213,8 @@ export function appendGenerationReference( dialog?.mode === 'scene' || dialog?.mode === 'quick-edit' || dialog?.mode === 'icon' || - dialog?.mode === 'ui-design' + dialog?.mode === 'ui-design' || + dialog?.mode === 'model3d-image-to-model' ) { if (getReferenceMediaType(layer) !== 'image') { return dialog; @@ -2225,16 +2256,21 @@ export function replaceProjectAssetPickerReferences( dialog.mode !== 'scene' && dialog.mode !== 'quick-edit' && dialog.mode !== 'icon' && - dialog.mode !== 'ui-design') + dialog.mode !== 'ui-design' && + dialog.mode !== 'model3d-image-to-model') ) { return dialog; } return { ...resetFailedGenerationDialog(dialog), generationReferences: appendLimitedImageReferences( - (dialog.generationReferences ?? []).filter( - (reference) => !isProjectAssetPickerReference(reference), - ), + // 单槽对话框(图生 3D)里素材库选择是替换:留着画布点选的那张只会把这次选择挤掉。 + // 但确认时一个都没选就不算「替换」,否则空确认会把画布点选/上传的那张直接清掉。 + usesSingleImageReferenceSlot(dialog) && references.length > 0 + ? [] + : (dialog.generationReferences ?? []).filter( + (reference) => !isProjectAssetPickerReference(reference), + ), references, resolveDialogExtraImageReferenceLimit(dialog), ), @@ -2379,7 +2415,9 @@ export function hideGeneratedLayerComposerAfterBlur( dialog?.mode === 'video' || dialog?.mode === 'publication' || dialog?.mode === 'audio-sound-effect' || - dialog?.mode === 'audio-background-music') && + dialog?.mode === 'audio-background-music' || + dialog?.mode === 'model3d-text-to-model' || + dialog?.mode === 'model3d-image-to-model') && dialog.status !== 'generating' ? { ...dialog, @@ -2402,7 +2440,9 @@ export function closeGenerateComposerDialog( dialog?.mode === 'video' || dialog?.mode === 'publication' || dialog?.mode === 'audio-sound-effect' || - dialog?.mode === 'audio-background-music' + dialog?.mode === 'audio-background-music' || + dialog?.mode === 'model3d-text-to-model' || + dialog?.mode === 'model3d-image-to-model' ? { ...dialog, composerOpen: false, diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx index 800eef79a..01afb3368 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx @@ -25,6 +25,7 @@ import { resolveDialogExtraImageReferenceLimit, resolveEditorImageSizeLabel, } from './ImageCanvasGenerationModel'; +import { ImageCanvasOptionChoice } from './ImageCanvasOptionChoice'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; type ImageCanvasGenerationImageOptionsViewProps = { @@ -93,34 +94,6 @@ function normalizeImageDialogSelection(dialog: GenerateDialogState) { }; } -function OptionChoice({ - children, - selected, - className, - ariaLabel, - onClick, -}: { - children: ReactNode; - selected: boolean; - className?: string; - ariaLabel?: string; - onClick: () => void; -}) { - return ( - - ); -} - export function ImageCanvasGenerationImageOptionsView({ dialog, setGenerateDialog, @@ -284,7 +257,7 @@ export function ImageCanvasGenerationImageOptionsView({
{selection.options.aspectRatios.map((aspectRatio) => ( - + ))}
@@ -310,7 +283,7 @@ export function ImageCanvasGenerationImageOptionsView({
{selection.options.imageSizes.map((imageSize) => ( - updateDialog({ imageSize })} > {imageSize} - + ))}
@@ -395,7 +368,7 @@ export function ImageCanvasGenerationImageOptionsView({ {modelOptions.map((option) => { const selected = selectedModel === option.value; return ( - + ); })} diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 5a08c5c05..ad6692869 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -69,6 +69,16 @@ describe('ImageCanvasGenerationModel', () => { }); }); + it('caps the 3D image-to-model dialog at a single reference', () => { + expect( + resolveDialogExtraImageReferenceLimit({ + mode: 'model3d-image-to-model', + prompt: '', + status: 'idle', + }), + ).toBe(1); + }); + it('uses the fixed spec model provider limit for multi-reference spec dialogs', () => { expect( resolveDialogExtraImageReferenceLimit({ diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 4929dcedd..b1209eb64 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -462,11 +462,23 @@ export function resolveExtraImageReferenceLimit( ); } +/** + * 只有一个参考图槽位的对话类型:图生 3D 的输入是契约里的单个 `source`。 + * 这类对话框里「再从素材库选一张」是替换而不是追加。 + */ +export function usesSingleImageReferenceSlot(dialog: GenerateDialogState) { + return dialog.mode === 'model3d-image-to-model'; +} + export function resolveDialogExtraImageReferenceLimit( dialog: GenerateDialogState, model: string | null | undefined = dialog.imageModel ?? (dialog.mode === 'spec' ? SPEC_GENERATION_MODEL : undefined), ) { + // 图生 3D 的输入是契约里的单个 `source`,面板也只给一个槽位。 + if (usesSingleImageReferenceSlot(dialog)) { + return 1; + } if (dialog.mode === 'icon') { return resolveExtraImageReferenceLimit( model, @@ -794,6 +806,9 @@ export function getLayerKindLabel(layer: CanvasLayer) { if (layer.assetKind === 'scene') { return '游戏场景'; } + if (layer.assetKind === 'model3d') { + return '3D模型'; + } if (layer.assetKind === 'video' || layer.mediaType === 'video') { return '视频'; } @@ -1051,9 +1066,26 @@ export function applyEditorGenerationPricingConfig( ) { runtimeEditorGenerationPricingConfig = { models: cloneModelPricing(pricing.models), + // 3D 段刻意没有内置兜底:段缺失就是段缺失,入口据此判不可提交,不拿旧数值顶上。 + ...(pricing.model3d + ? { + model3d: { + basePrices: pricing.model3d.basePrices + ? { ...pricing.model3d.basePrices } + : null, + addOnPrices: pricing.model3d.addOnPrices + ? { ...pricing.model3d.addOnPrices } + : null, + }, + } + : {}), }; } +export function readEditorModel3dPricingConfig() { + return runtimeEditorGenerationPricingConfig.model3d ?? null; +} + function cloneModelPricing( value: T, ): EditorGenerationPricingConfig['models'] { @@ -1636,6 +1668,11 @@ export function canOpenRedrawPanel(layer: CanvasLayer) { if (layer.taskId?.startsWith('pixel-art-snap-')) { return false; } + // 3D 生成产物没有「基于本图重绘」语义:画布上的是预览图,模型本体不在 + // 图片生成链路的输入里。 + if (layer.assetKind === 'model3d') { + return false; + } const action = layer.generationInputs?.action; if (action !== undefined) { return isRemixableCanvasGenerationAction(action); @@ -2288,7 +2325,9 @@ export function isCanvasGenerationDialog( dialog.mode === 'character-animation' || dialog.mode === 'video' || dialog.mode === 'audio-sound-effect' || - dialog.mode === 'audio-background-music'), + dialog.mode === 'audio-background-music' || + dialog.mode === 'model3d-text-to-model' || + dialog.mode === 'model3d-image-to-model'), ); } @@ -2304,6 +2343,12 @@ export function getGenerationFrameAriaLabel( if (dialog.mode === 'spec') { return '规范生成占位图'; } + if ( + dialog.mode === 'model3d-text-to-model' || + dialog.mode === 'model3d-image-to-model' + ) { + return '3D 模型生成占位图'; + } if (dialog.mode === 'icon') { return '图标素材生成占位图'; } diff --git a/src/components/image-editor/ImageCanvasOptionChoice.tsx b/src/components/image-editor/ImageCanvasOptionChoice.tsx new file mode 100644 index 000000000..fdc159604 --- /dev/null +++ b/src/components/image-editor/ImageCanvasOptionChoice.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; + +/** + * 画布参数浮层里的「多选一」选项行:模型、尺寸这类选择共用同一条样式与 `aria-pressed` 语义。 + * + * 抽出来是因为它已经在 2 个面板里重复(生成图片面板的模型菜单、3D 生成面板的模型菜单), + * 样式类名一旦各写一份就可能只改到一处。 + */ +export function ImageCanvasOptionChoice({ + children, + selected, + className, + ariaLabel, + onClick, +}: { + children: ReactNode; + selected: boolean; + className?: string; + ariaLabel?: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index a2f6a607e..4340bb967 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -60,6 +60,7 @@ function renderSelectedToolbar( onSplitIconSpritesheet: vi.fn(), onExtractUiDesignAssets: vi.fn(), onOpenCharacterAnimationPanel: vi.fn(), + onPreviewModel3d: vi.fn(), onDownloadLayer: vi.fn(), ...overrides, }; @@ -743,6 +744,75 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy(); }); + it('shows the 3D preview action for a model3d layer with a model object', () => { + const layer = createLayer({ + assetKind: 'model3d', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }); + const props = renderSelectedToolbar({ selectedLayer: layer }); + + const previewButton = screen.getByRole('button', { name: '3D 预览' }); + expect(previewButton.getAttribute('title')).toBe('在 3D 查看器中预览'); + expect((previewButton as HTMLButtonElement).disabled).toBe(false); + + fireEvent.click(previewButton); + expect(props.onPreviewModel3d).toHaveBeenCalledWith(layer); + }); + + it('keeps the 3D preview action clickable when the object key extension is unknown', () => { + // 画布不判格式:判不出来是查看器读模型时报的错,工具条不替它拦人。 + renderSelectedToolbar({ + selectedLayer: createLayer({ + assetKind: 'model3d', + objectKey: 'generated/editor/model3d/task-1/model.obj', + }), + }); + + const previewButton = screen.getByRole('button', { name: '3D 预览' }); + expect(previewButton.getAttribute('title')).toBe('在 3D 查看器中预览'); + expect((previewButton as HTMLButtonElement).disabled).toBe(false); + }); + + it('keeps the 3D preview action visible but disabled without a model file', () => { + renderSelectedToolbar({ + selectedLayer: createLayer({ assetKind: 'model3d' }), + }); + + expect( + ( + screen.getByRole('button', { + name: '3D 预览', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + it('hides the 3D preview action when the host does not declare the capability', () => { + renderSelectedToolbar({ + selectedLayer: createLayer({ + assetKind: 'model3d', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }), + supportedActions: new Set(['quick-edit', 'download']), + }); + + expect(screen.queryByRole('button', { name: '3D 预览' })).toBeNull(); + }); + + it('hides the raster editing group for model3d layers', () => { + renderSelectedToolbar({ + selectedLayer: createLayer({ + assetKind: 'model3d', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }), + }); + + expect(screen.queryByRole('button', { name: '裁扩按钮' })).toBeNull(); + expect(screen.queryByRole('button', { name: '去除背景按钮' })).toBeNull(); + expect(screen.queryByRole('button', { name: '完美像素' })).toBeNull(); + expect(screen.queryByRole('button', { name: '改造' })).toBeNull(); + }); + it('renders nothing without a selected layer or toolbar position', () => { const { rerender } = render( { onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} + onPreviewModel3d={vi.fn()} onDownloadLayer={vi.fn()} />, ); @@ -782,6 +853,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} + onPreviewModel3d={vi.fn()} onDownloadLayer={vi.fn()} />, ); diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index f61909628..f48ed49b1 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -1,6 +1,7 @@ import { CanvasChromeButton } from '@genarrative/image-canvas-react'; import { OverflowActions } from '@genarrative/shared/components'; import { + Box, Crop, Download, Grid2X2, @@ -36,6 +37,7 @@ export type ImageCanvasSelectedToolbarAction = | 'split-icon-spritesheet' | 'extract-ui-design' | 'character-animation' + | 'preview-model3d' | 'download'; type ImageCanvasSelectedLayerToolbarViewProps = { @@ -67,6 +69,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = { onSplitIconSpritesheet: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; + onPreviewModel3d: (layer: CanvasLayer) => void; onDownloadLayer: (layer: CanvasLayer) => void; }; @@ -99,6 +102,7 @@ export function ImageCanvasSelectedLayerToolbarView({ onSplitIconSpritesheet, onExtractUiDesignAssets, onOpenCharacterAnimationPanel, + onPreviewModel3d, onDownloadLayer, }: ImageCanvasSelectedLayerToolbarViewProps) { if (!selectedLayer || !selectedToolbarStyle) { @@ -173,7 +177,14 @@ export function ImageCanvasSelectedLayerToolbarView({ ); } + // 3D 资源的图层画的是预览图,像素级编辑改不到模型本体,整组栅格编辑不适用。 + const isModel3dLayer = selectedLayer.assetKind === 'model3d'; + // 「能不能预览」不在工具条判:这里只看有没有模型对象(资源身份,由后端写入保证), + // 模型格式与可解析性交给查看器读响应与字节判定,判不出来在模态里说原因。 + const hasModel3dSource = + isModel3dLayer && Boolean(selectedLayer.objectKey?.trim()); const canRasterEdit = + !isModel3dLayer && selectedLayer.mediaType !== 'video' && selectedLayer.mediaType !== 'image-sequence' && selectedLayer.assetKind !== 'character-animation'; @@ -197,13 +208,16 @@ export function ImageCanvasSelectedLayerToolbarView({ const showExtractUiDesign = selectedLayer.assetKind === 'ui-design' && isActionSupported('extract-ui-design', true); + const showPreviewModel3d = + isModel3dLayer && isActionSupported('preview-model3d', true); const hasEditingActions = showCropExpand || showRemoveBackground || showPerfectPixel || showSplitIconSpritesheet || showExtractUiDesign || - showCharacterAnimation; + showCharacterAnimation || + showPreviewModel3d; return (
生成动画 ) : null} + {showPreviewModel3d ? ( + } + disabled={!hasModel3dSource} + onClick={() => onPreviewModel3d(selectedLayer)} + > + 3D 预览 + + ) : null} {canRedraw ? ( <> {showQuickEdit || hasEditingActions ? divider : null} diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index cea8dfeab..e23ab7d38 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -51,6 +51,7 @@ export type ImageCanvasStageViewProps = { specToolWrapRef: RefObject; musicToolWrapRef: RefObject; publicationToolWrapRef: RefObject; + model3dToolWrapRef: RefObject; isInteractionPaused?: boolean; isPanning: boolean; effectiveTool: CanvasTool; @@ -170,6 +171,7 @@ export type ImageCanvasStageViewProps = { onQuickEditSelectionPointerMove: (point: { x: number; y: number }) => void; onQuickEditSelectionPointerEnd: () => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; + onPreviewModel3d: (layer: CanvasLayer) => void; onDownloadLayer: (layer: CanvasLayer) => void; onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void; onCopyContextLayers: (options?: { cut?: boolean }) => void; @@ -201,10 +203,10 @@ export type ImageCanvasStageViewProps = { onMinimapPointerDown: (event: ReactPointerEvent) => void; onSwitchTool: (tool: CanvasTool) => void; onOpenToolOptions: ( - tool: Extract, + tool: Extract, ) => void; onCloseToolOptions: ( - tool: Extract, + tool: Extract, ) => void; }; @@ -213,6 +215,7 @@ export function ImageCanvasStageView({ specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, isInteractionPaused = false, isPanning, effectiveTool, @@ -311,6 +314,7 @@ export function ImageCanvasStageView({ onQuickEditSelectionPointerMove, onQuickEditSelectionPointerEnd, onOpenCharacterAnimationPanel, + onPreviewModel3d, onDownloadLayer, onPasteCanvasClipboard, onCopyContextLayers, @@ -433,6 +437,7 @@ export function ImageCanvasStageView({ onSplitIconSpritesheet={onSplitIconSpritesheet} onExtractUiDesignAssets={onExtractUiDesignAssets} onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel} + onPreviewModel3d={onPreviewModel3d} onDownloadLayer={onDownloadLayer} /> @@ -562,6 +567,7 @@ export function ImageCanvasStageView({ specToolWrapRef={specToolWrapRef} musicToolWrapRef={musicToolWrapRef} publicationToolWrapRef={publicationToolWrapRef} + model3dToolWrapRef={model3dToolWrapRef} effectiveTool={effectiveTool} highlight={isToolbarGuideVisible} onSwitchTool={onSwitchTool} diff --git a/src/components/image-editor/ImageCanvasUploadModel.test.ts b/src/components/image-editor/ImageCanvasUploadModel.test.ts index 6119d5214..e77795164 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.test.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.test.ts @@ -696,6 +696,28 @@ describe('ImageCanvasUploadModel', () => { expect(layer.y).toBe(-7.5); }); + it('applies an uploaded image to the single 3D image-to-model slot', () => { + const reference = { + id: 'upload-generation-reference-0', + label: '参考图1', + src: 'data:image/png;base64,ref', + resourceId: 'resource-upload-1', + }; + + expect( + applyGenerationReferenceUpload({ + dialog: createDialog({ mode: 'model3d-image-to-model' }), + target: 'generation-reference', + references: [reference], + }), + ).toMatchObject({ + mode: 'model3d-image-to-model', + status: 'idle', + errorMessage: undefined, + generationReferences: [reference], + }); + }); + it('resizes upload canvas layers around the same canvas point', () => { const resizedLayer = resizeUploadCanvasLayerToImage({ layer: createLayer(), diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index 0528a0193..db802c554 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -250,7 +250,8 @@ export function applyGenerationReferenceUpload({ dialog?.mode === 'scene' || dialog?.mode === 'video' || dialog?.mode === 'icon' || - dialog?.mode === 'ui-design' + dialog?.mode === 'ui-design' || + dialog?.mode === 'model3d-image-to-model' ? { ...setFailedGenerationIdle(dialog), generationReferences: diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index d4ffc48d5..7b6405d1f 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -1194,6 +1194,82 @@ describe('ImageCanvasWorldView', () => { ); }); + it('keeps the 3D model label inside its own media family', () => { + const imageLayer = createLayer({ + resourceAssetKind: null, + assetKind: 'character', + }); + const { unmount } = renderWorldView({ layers: [imageLayer] }); + + fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' })); + + const modelItem = within( + screen.getByRole('menu', { name: '选择素材标签' }), + ).getByRole('menuitem', { name: '3D模型' }); + expect(modelItem.hasAttribute('disabled')).toBe(true); + expect(modelItem.title).toBe('该标签与素材的媒体类型不兼容'); + + unmount(); + + const modelLayer = createLayer({ + resourceAssetKind: 'model3d', + assetKind: 'model3d', + src: 'generated/editor/model3d/task-1/preview.webp', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }); + renderWorldView({ layers: [modelLayer] }); + + fireEvent.click(screen.getByRole('button', { name: '素材标签:3D模型' })); + + const modelMenu = screen.getByRole('menu', { name: '选择素材标签' }); + expect( + within(modelMenu) + .getByRole('menuitem', { name: '3D模型' }) + .hasAttribute('disabled'), + ).toBe(false); + expect( + within(modelMenu) + .getByRole('menuitem', { name: '角色' }) + .hasAttribute('disabled'), + ).toBe(true); + expect( + within(modelMenu) + .getByRole('menuitem', { name: '动作' }) + .hasAttribute('disabled'), + ).toBe(true); + }); + + it('draws a 3D placeholder instead of loading the model file as an image', () => { + const layer = createLayer({ + assetKind: 'model3d', + title: '三维角色', + src: 'generated/editor/model3d/task-1/model.glb', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }); + renderWorldView({ layers: [layer] }); + + expect(screen.queryByAltText('画布图片:三维角色')).toBeNull(); + expect( + screen.getByRole('button', { name: '素材标签:3D模型' }), + ).toBeTruthy(); + }); + + it('renders the model3d preview image without resolving the model object key', () => { + const layer = createLayer({ + assetKind: 'model3d', + title: '三维角色', + src: 'generated/editor/model3d/task-1/preview.webp', + objectKey: 'generated/editor/model3d/task-1/model.glb', + }); + renderWorldView({ layers: [layer] }); + + expect(screen.getByAltText('画布图片:三维角色')).toBeTruthy(); + expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith( + 'generated/editor/model3d/task-1/preview.webp', + expect.objectContaining({ objectKey: null }), + ); + }); + it('hides resolution and duration text for focused audio generation frames', () => { const dialog = createGenerationDialog({ id: 'dialog-audio', diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index a9e1e9622..4242e66ad 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -6,6 +6,7 @@ import { import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AppWindow, + Box, Clapperboard, ClipboardList, Grid2X2, @@ -71,6 +72,7 @@ import { getCanvasMediaPreviewMarker, } from './ImageCanvasMediaModel'; import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; +import { isModel3dFileSource } from './model3d-preview/Model3dViewerModel'; function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) { switch (dialog.mode) { @@ -213,45 +215,102 @@ function getLayerMetadataMediaLabel(layer: CanvasLayer) { if (layer.mediaType === 'image-sequence') { return '序列帧'; } + // 3D 资源的画布卡片是预览图,但资源语义是模型,信息入口不能按图片命名。 + if (layer.assetKind === 'model3d') { + return '3D模型'; + } return '图片'; } +/** + * 类型角标的样式类。 + * + * 每一项直接给出完整类名而不是"修饰符名":既有类别继续用 `index.css` 里已有的 + * `image-canvas-editor__kind-badge--*` 规则,新增类别可以直接写 Tailwind 工具类, + * 不必为了一个颜色再往全局样式表里加规则。`!` 前缀是必需的 —— 手写规则不在 + * Tailwind 的 utilities 层里,层外的声明永远压过层内工具类。 + */ const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{ value: CanvasAssetKind | null; label: string; - className: string; + badgeClassName: string; }> = [ - { value: null, label: '未知', className: 'unknown' }, - { value: 'spec', label: '规范', className: 'spec' }, - { value: 'character', label: '角色', className: 'character' }, + { + value: null, + label: '未知', + badgeClassName: 'image-canvas-editor__kind-badge--unknown', + }, + { + value: 'spec', + label: '规范', + badgeClassName: 'image-canvas-editor__kind-badge--spec', + }, + { + value: 'character', + label: '角色', + badgeClassName: 'image-canvas-editor__kind-badge--character', + }, { value: 'character-animation', label: '动作', - className: 'character-animation', + badgeClassName: 'image-canvas-editor__kind-badge--character-animation', + }, + { + value: 'icon', + label: '图标', + badgeClassName: 'image-canvas-editor__kind-badge--icon', + }, + { + value: 'icon-spritesheet', + label: '图集', + badgeClassName: 'image-canvas-editor__kind-badge--icon-spritesheet', + }, + { + value: 'icon-spec', + label: '图标规范', + badgeClassName: 'image-canvas-editor__kind-badge--icon-spec', }, - { value: 'icon', label: '图标', className: 'icon' }, - { value: 'icon-spritesheet', label: '图集', className: 'icon-spritesheet' }, - { value: 'icon-spec', label: '图标规范', className: 'icon-spec' }, { value: 'publication-material', label: '宣发素材', - className: 'publication-material', + badgeClassName: 'image-canvas-editor__kind-badge--publication-material', + }, + { + value: 'ui-design', + label: 'UI设计', + badgeClassName: 'image-canvas-editor__kind-badge--ui-design', + }, + { + value: 'scene', + label: '场景', + badgeClassName: 'image-canvas-editor__kind-badge--scene', + }, + { + value: 'video', + label: '视频', + badgeClassName: 'image-canvas-editor__kind-badge--video', + }, + { + value: 'model3d', + label: '3D模型', + badgeClassName: 'image-canvas-editor__kind-badge--model3d', + }, + { + value: 'sound-effect', + label: '音效', + badgeClassName: 'image-canvas-editor__kind-badge--sound-effect', }, - { value: 'ui-design', label: 'UI设计', className: 'ui-design' }, - { value: 'scene', label: '场景', className: 'scene' }, - { value: 'video', label: '视频', className: 'video' }, - { value: 'sound-effect', label: '音效', className: 'sound-effect' }, { value: 'background-music', label: '背景音乐', - className: 'background-music', + badgeClassName: 'image-canvas-editor__kind-badge--background-music', }, ]; const UNKNOWN_ASSET_KIND_TAG_OPTION = CANVAS_ASSET_KIND_TAG_OPTIONS[0] as { value: null; label: string; - className: string; + badgeClassName: string; }; function getLayerKindTagMeta(layer: CanvasLayer) { @@ -672,14 +731,17 @@ function ImageCanvasVideoLayer({ function ImageCanvasImageLayer({ layer, mediaTransform, + objectKey = layer.objectKey, }: { layer: CanvasLayer; mediaTransform?: string; + /** 解析签名地址时使用的对象引用;3D 资源必须置空,否则会去读模型本体。 */ + objectKey?: string | null; }) { const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl( layer.src, { - objectKey: layer.objectKey, + objectKey, refreshKey: layer.taskId ?? layer.resourceId, }, ); @@ -723,6 +785,44 @@ function ImageCanvasImageLayer({ ); } +/** + * 3D 资源卡片:只画预览图,模型本体交给 3D 查看器。 + * + * 预览必须单独按 `layer.src` 取签名地址(`objectKey` 是模型本体,带上它会把 `.glb` + * 当图片交给 ``)。预览图缺失时后端把 `src` 回落到模型对象,这里识别出来只画 + * 占位,不去请求模型文件。 + */ +function ImageCanvasModel3dLayer({ + layer, + mediaTransform, +}: { + layer: CanvasLayer; + mediaTransform?: string; +}) { + const hasPreview = + Boolean(layer.src?.trim()) && !isModel3dFileSource(layer.src); + + if (hasPreview) { + return ( + + ); + } + + return ( + + ); +} + function getLayerImageSequenceFrames(layer: CanvasLayer) { return layer.imageSequenceFrames ?? []; } @@ -924,6 +1024,52 @@ function ImageCanvasImageSequenceLayer({ ); } +/** + * 图层正文按媒体类型 / 资源类别分派:早返回写成一列判断,新增类型不会再加深三元链。 + */ +function renderCanvasLayerMedia({ + layer, + mediaTransform, + isHovered, + onSelectLayer, +}: { + layer: CanvasLayer; + mediaTransform?: string; + isHovered: boolean; + onSelectLayer: () => void; +}) { + if (layer.mediaType === 'audio') { + return ( + + ); + } + if (layer.mediaType === 'video') { + return ( + + ); + } + if (layer.mediaType === 'image-sequence') { + return ( + + ); + } + if (layer.assetKind === 'model3d') { + return ( + + ); + } + return ( + + ); +} + const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ layer, isSelected, @@ -978,33 +1124,17 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ onMouseLeave={() => handlers.onLayerMouseLeave(layer.id)} aria-label={`选择${layer.title}`} > - {layer.mediaType === 'audio' ? ( - handlers.onSelectLayer(layer.id)} - /> - ) : layer.mediaType === 'video' ? ( - - ) : layer.mediaType === 'image-sequence' ? ( - - ) : ( - - )} + {renderCanvasLayerMedia({ + layer, + mediaTransform, + isHovered, + onSelectLayer: () => handlers.onSelectLayer(layer.id), + })} diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationForm.tsx b/src/components/image-editor/model3d-generation/Model3dGenerationForm.tsx new file mode 100644 index 000000000..5a4273722 --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationForm.tsx @@ -0,0 +1,332 @@ +import { Check, ChevronDown, Cpu, ImageIcon } from 'lucide-react'; +import type { + CSSProperties, + Dispatch, + ReactNode, + RefObject, + SetStateAction, +} from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +import type { Model3dModelVersion } from '../../../../packages/shared/src/contracts/model3d'; +import { AutoGrowTextArea } from '../../common/AutoGrowTextArea'; +import { + PlatformFloatingMenu, + PlatformFloatingMenuItem, +} from '../../common/PlatformFloatingMenu'; +import { PlatformInlineOptionButton } from '../../common/PlatformInlineOptionButton'; +import type { + CharacterReferenceImage, + GenerateDialogState, + UploadTarget, +} from '../ImageCanvasEditorTypes'; +import { ImageCanvasOptionChoice } from '../ImageCanvasOptionChoice'; +import { ImageCanvasReferenceSlot } from '../ImageCanvasReferenceSlot'; +import { useImageCanvasFloatingOptionDismiss } from '../useImageCanvasFloatingOptionDismiss'; +import { + applyModel3dModelChange, + applyModel3dPromptChange, + applyModel3dReferenceRemoval, + applyModel3dSwitchChange, + applyModel3dTierChange, + isModel3dTierSupported, + MODEL3D_DETAILED_TIER_DISABLED_REASON, + MODEL3D_GENERATE_PARTS_TEXTURE_TIER_REASON, + MODEL3D_GENERATION_TIERS, + MODEL3D_SWITCH_OPTIONS, + MODEL3D_TEXT_PROMPT_MAX_LENGTH, + resolveModel3dModelVersion, +} from './Model3dGenerationFormModel'; +import { resolveModel3dModelCapabilities } from './Model3dGenerationValidation'; + +type Model3dGenerationFormProps = { + dialog: GenerateDialogState; + disabled: boolean; + /** + * 当前端点有底价的模型版本;由面板外壳统一读定价缓存后传入,表单不各自读一次配置。 + */ + modelOptions: ReadonlyArray<{ value: Model3dModelVersion; label: string }>; + updateDialog: ( + updater: (dialog: GenerateDialogState) => GenerateDialogState, + ) => void; + generationReferenceButtonRef?: RefObject; + isGenerationReferenceMenuOpen?: boolean; + setIsGenerationReferenceMenuOpen?: Dispatch>; + setIsPickingGenerationReferenceFromCanvas?: Dispatch>; + onRequestUpload: (target: UploadTarget) => void; + onRequestProjectAssetPicker?: () => void; + renderEditorPortal: (node: ReactNode) => ReactNode; + buildPortalMenuStyle: ( + anchor: HTMLElement | null, + placement: 'above' | 'below', + ) => CSSProperties; +}; + +export function Model3dGenerationForm({ + dialog, + disabled, + modelOptions, + updateDialog, + generationReferenceButtonRef, + isGenerationReferenceMenuOpen = false, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, + onRequestUpload, + onRequestProjectAssetPicker, + renderEditorPortal, + buildPortalMenuStyle, +}: Model3dGenerationFormProps) { + const isImageMode = dialog.mode === 'model3d-image-to-model'; + const references = dialog.generationReferences ?? []; + const tier = dialog.model3dTier ?? 'textured-standard'; + const isTextureTier = tier !== 'geometry-only'; + const model = resolveModel3dModelVersion(dialog.model3dModel); + const capabilities = resolveModel3dModelCapabilities(model); + const selectedModelLabel = + modelOptions.find((option) => option.value === model)?.label ?? + // 定价段缺了当前版本时选项里查不到它:仍把版本号显示出来,价格区会同时报缺价原因。 + model; + const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); + const modelButtonRef = useRef(null); + const modelMenuBoundaryRefs = useMemo(() => [modelButtonRef], []); + const closeModelMenu = useCallback(() => setIsModelMenuOpen(false), []); + + useImageCanvasFloatingOptionDismiss({ + isOpen: isModelMenuOpen, + boundaryRefs: modelMenuBoundaryRefs, + onDismiss: closeModelMenu, + restoreFocusRef: modelButtonRef, + }); + + const toggleSwitch = ( + field: 'model3dQuad' | 'model3dSmartLowPoly' | 'model3dGenerateParts', + checked: boolean, + ) => { + updateDialog((currentDialog) => + applyModel3dSwitchChange(currentDialog, { [field]: checked }), + ); + }; + const removeReference = (reference: CharacterReferenceImage) => { + updateDialog((currentDialog) => + applyModel3dReferenceRemoval(currentDialog, reference.id), + ); + }; + + return ( + <> + {isImageMode ? ( +
+ {references.map((reference) => ( +
+ ) : ( + + updateDialog((currentDialog) => + applyModel3dPromptChange(currentDialog, value), + ) + } + /> + )} +
+
+ } + onClick={() => setIsModelMenuOpen((open) => !open)} + > + + + {selectedModelLabel} + + + {isModelMenuOpen + ? renderEditorPortal( + event.stopPropagation()} + > +
+ {modelOptions.map((option) => { + const selected = model === option.value; + return ( + { + closeModelMenu(); + updateDialog((currentDialog) => + applyModel3dModelChange( + currentDialog, + option.value, + ), + ); + }} + > + + {option.label} + + ); + })} +
+
, + ) + : null} +
+
+ {MODEL3D_GENERATION_TIERS.map((option) => { + const blocked = !isModel3dTierSupported(capabilities, option.value); + return ( + + updateDialog((currentDialog) => + applyModel3dTierChange(currentDialog, option.value), + ) + } + > + {option.label} + + ); + })} +
+ {MODEL3D_SWITCH_OPTIONS.map((option) => { + const capabilityBlocked = !capabilities[option.capability]; + const tierBlocked = + option.field === 'model3dGenerateParts' && isTextureTier; + const blocked = capabilityBlocked || tierBlocked; + const blockedReason = tierBlocked + ? MODEL3D_GENERATE_PARTS_TEXTURE_TIER_REASON + : option.disabledReason; + return ( + + ); + })} +
+ {isImageMode && + isGenerationReferenceMenuOpen && + generationReferenceButtonRef + ? renderEditorPortal( + + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(true); + }} + > + 从画布中选择 + + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(false); + onRequestUpload('generation-reference'); + }} + > + 上传图片 + + {onRequestProjectAssetPicker ? ( + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(false); + onRequestProjectAssetPicker(); + }} + > + 从项目素材中选择 + + ) : null} + , + ) + : null} + + ); +} diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.test.ts b/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.test.ts new file mode 100644 index 000000000..2f551ea9c --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.test.ts @@ -0,0 +1,653 @@ +import { describe, expect, it } from 'vitest'; + +import type { + Model3dModelVersion, + Model3dTextureQuality, +} from '../../../../packages/shared/src/contracts/model3d'; +import type { + Model3dAddOnPriceKey, + Model3dPricingConfig, +} from '../../../services/image-editor/editorProjectClient'; +import type { GenerateDialogState } from '../ImageCanvasEditorTypes'; +import { + applyModel3dModelChange, + applyModel3dPromptChange, + applyModel3dReferenceRemoval, + applyModel3dSwitchChange, + applyModel3dTierChange, + createModel3dGenerationDraftFields, + DEFAULT_MODEL3D_MODEL_VERSION, + mintModel3dAttemptNonce, + MODEL3D_PRICING_UNAVAILABLE_REASON, + type Model3dGenerationMode, + normalizeModel3dModelVersion, + refreshModel3dAttemptNonce, + resolveModel3dAddOnKeys, + resolveModel3dGenerationParams, + resolveModel3dGenerationQuote, + resolveModel3dModelOptions, + resolveModel3dModelVersion, + resolveModel3dPrice, +} from './Model3dGenerationFormModel'; + +function createDialog( + overrides: Partial = {}, +): GenerateDialogState { + return { + id: 'dialog-3d', + ...createModel3dGenerationDraftFields('model3d-text-to-model'), + ...overrides, + } as GenerateDialogState; +} + +/** + * 与后端 `api-server::tripo3d::pricing::model3d_add_ons` 逐条对齐的期望值: + * 前端只有这一份映射,价格差异必须在这里被用例挡住。 + */ +function expectedAddOns({ + texture, + textureQuality, + geometryQuality, + quad, + smartLowPoly, + generateParts, +}: { + texture: boolean; + textureQuality: Model3dTextureQuality; + geometryQuality: 'standard' | 'detailed'; + quad: boolean; + smartLowPoly: boolean; + generateParts: boolean; +}): Model3dAddOnPriceKey[] { + return [ + ...(texture && textureQuality === 'detailed' ? ['hdTexture' as const] : []), + ...(texture && textureQuality === 'extreme' + ? ['ultraTexture' as const] + : []), + ...(geometryQuality === 'detailed' ? ['hdGeometry' as const] : []), + ...(quad ? ['quadMesh' as const] : []), + ...(smartLowPoly ? ['smartLowPoly' as const] : []), + ...(generateParts ? ['generateParts' as const] : []), + ]; +} + +const PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { + 'v3.1-20260211': { noTexture: 100, texture: 200 }, + }, + 'image-to-model': { + 'v3.1-20260211': { noTexture: 150, texture: 250 }, + }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +describe('resolveModel3dGenerationParams', () => { + it('每个档位映射成完整参数,档位与开关不留给 provider 隐式默认', () => { + expect( + resolveModel3dGenerationParams({ + model: DEFAULT_MODEL3D_MODEL_VERSION, + tier: 'textured-standard', + quad: false, + smartLowPoly: false, + generateParts: false, + }), + ).toEqual({ + ok: true, + params: { + texture: true, + textureQuality: 'standard', + geometryQuality: 'standard', + quad: false, + smartLowPoly: false, + generateParts: false, + pbr: false, + }, + }); + expect( + resolveModel3dGenerationParams({ + model: DEFAULT_MODEL3D_MODEL_VERSION, + tier: 'textured-detailed', + quad: true, + smartLowPoly: true, + generateParts: false, + }), + ).toEqual({ + ok: true, + params: { + texture: true, + textureQuality: 'detailed', + geometryQuality: 'detailed', + quad: true, + smartLowPoly: true, + generateParts: false, + pbr: false, + }, + }); + }); + + it('纯几何档位没有贴图档位,pbr 显式为 false', () => { + const result = resolveModel3dGenerationParams({ + model: DEFAULT_MODEL3D_MODEL_VERSION, + tier: 'geometry-only', + quad: false, + smartLowPoly: false, + generateParts: true, + }); + expect(result.ok).toBe(true); + expect(result.ok && result.params).toMatchObject({ + texture: false, + textureQuality: null, + geometryQuality: 'standard', + generateParts: true, + pbr: false, + }); + }); + + it('贴图档位不允许分件,这是后端也会拒的组合', () => { + expect( + resolveModel3dGenerationParams({ + model: DEFAULT_MODEL3D_MODEL_VERSION, + tier: 'textured-standard', + quad: false, + smartLowPoly: false, + generateParts: true, + }).ok, + ).toBe(false); + expect( + resolveModel3dGenerationParams({ + model: DEFAULT_MODEL3D_MODEL_VERSION, + tier: 'textured-detailed', + quad: false, + smartLowPoly: false, + generateParts: true, + }).ok, + ).toBe(false); + }); + + it('分件不允许与方形布线 / 智能低多边形同时开,这是后端也会拒的组合', () => { + const conflictingSwitches: Array<{ quad: boolean; smartLowPoly: boolean }> = + [ + { quad: true, smartLowPoly: false }, + { quad: false, smartLowPoly: true }, + ]; + for (const switches of conflictingSwitches) { + expect( + resolveModel3dGenerationParams({ + model: DEFAULT_MODEL3D_MODEL_VERSION, + tier: 'geometry-only', + quad: switches.quad, + smartLowPoly: switches.smartLowPoly, + generateParts: true, + }), + ).toEqual({ + ok: false, + reason: '分件生成不能与方形布线或智能低多边形同时使用', + }); + } + }); +}); + +describe('resolveModel3dAddOnKeys', () => { + it('与后端 add-on 判定在全部档位与开关组合上一致', () => { + for (const tier of [ + 'textured-standard', + 'textured-detailed', + 'geometry-only', + ] as const) { + for (const quad of [false, true]) { + for (const smartLowPoly of [false, true]) { + for (const generateParts of [false, true]) { + const result = resolveModel3dGenerationParams({ + model: DEFAULT_MODEL3D_MODEL_VERSION, + tier, + quad, + smartLowPoly, + generateParts, + }); + if (!result.ok) { + continue; + } + expect(resolveModel3dAddOnKeys(result.params)).toEqual( + expectedAddOns({ + texture: result.params.texture, + textureQuality: result.params.textureQuality ?? 'standard', + geometryQuality: result.params.geometryQuality, + quad, + smartLowPoly, + generateParts, + }), + ); + } + } + } + } + }); +}); + +const MULTI_VERSION_PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { + 'v3.1-20260211': { noTexture: 100, texture: 200 }, + 'v2.5-20250123': { noTexture: 60, texture: 120 }, + 'P2-20260801': { noTexture: 80, texture: 160 }, + }, + 'image-to-model': { + 'v3.1-20260211': { noTexture: 150, texture: 250 }, + 'P2-20260801': { noTexture: 170, texture: 270 }, + }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +describe('模型版本', () => { + it('缺字段与非法取值都回落到默认版本', () => { + expect(resolveModel3dModelVersion(undefined)).toBe( + DEFAULT_MODEL3D_MODEL_VERSION, + ); + // 老快照里存着已退役版本时,报价与提交也必须一起回落到默认版本,而不是查表抛异常。 + expect( + resolveModel3dModelVersion('v1.0-20190101' as Model3dModelVersion), + ).toBe(DEFAULT_MODEL3D_MODEL_VERSION); + expect(normalizeModel3dModelVersion(' P2-20260801 ')).toBe('P2-20260801'); + // 老快照里的退役版本不发给服务端。 + expect(normalizeModel3dModelVersion('v1.0-20190101')).toBe( + DEFAULT_MODEL3D_MODEL_VERSION, + ); + expect(normalizeModel3dModelVersion(null)).toBe( + DEFAULT_MODEL3D_MODEL_VERSION, + ); + // 原型链上的键(`in` 会命中)不是契约版本,同样按默认版本处理。 + for (const inheritedKey of ['toString', 'constructor', '__proto__']) { + expect(normalizeModel3dModelVersion(inheritedKey)).toBe( + DEFAULT_MODEL3D_MODEL_VERSION, + ); + } + }); + + it('可选项只给当前端点有底价的版本,顺序按契约声明', () => { + expect( + resolveModel3dModelOptions({ + pricing: MULTI_VERSION_PRICING, + endpoint: 'text-to-model', + }), + ).toEqual([ + { value: DEFAULT_MODEL3D_MODEL_VERSION, label: 'v3.1' }, + { value: 'v2.5-20250123', label: 'v2.5' }, + { value: 'P2-20260801', label: 'P2' }, + ]); + expect( + resolveModel3dModelOptions({ + pricing: MULTI_VERSION_PRICING, + endpoint: 'image-to-model', + }), + ).toEqual([ + { value: DEFAULT_MODEL3D_MODEL_VERSION, label: 'v3.1' }, + { value: 'P2-20260801', label: 'P2' }, + ]); + expect( + resolveModel3dModelOptions({ pricing: null, endpoint: 'text-to-model' }), + ).toEqual([]); + }); + + it('报价按对话框选中的版本取底价', () => { + expect( + resolveModel3dGenerationQuote( + createDialog({ model3dModel: 'P2-20260801' }), + MULTI_VERSION_PRICING, + ), + ).toEqual({ ok: true, price: 160, endpoint: 'text-to-model' }); + // 定价段里没有这个版本 → 不可提交,不回落默认版本的价。 + expect( + resolveModel3dGenerationQuote( + createDialog({ model3dModel: 'P1-20260311' }), + MULTI_VERSION_PRICING, + ).ok, + ).toBe(false); + }); + + it('v2.5 的贴图档位不带 textureQuality,其它版本照常给出', () => { + expect( + resolveModel3dGenerationParams({ + model: 'v2.5-20250123', + tier: 'textured-standard', + quad: false, + smartLowPoly: false, + generateParts: false, + }), + ).toMatchObject({ + ok: true, + params: { texture: true, textureQuality: null }, + }); + expect( + resolveModel3dGenerationParams({ + model: DEFAULT_MODEL3D_MODEL_VERSION, + tier: 'textured-standard', + quad: false, + smartLowPoly: false, + generateParts: false, + }), + ).toMatchObject({ + ok: true, + params: { texture: true, textureQuality: 'standard' }, + }); + }); + + it('版本不支持的能力组合直接判非法,不留给服务端拒', () => { + expect( + resolveModel3dGenerationParams({ + model: 'P1-20260311', + tier: 'textured-detailed', + quad: false, + smartLowPoly: false, + generateParts: false, + }), + ).toEqual({ ok: false, reason: '高清几何只支持 v3.1 / v3.0' }); + expect( + resolveModel3dGenerationParams({ + model: 'P2-20260801', + tier: 'textured-standard', + quad: false, + smartLowPoly: true, + generateParts: false, + }), + ).toEqual({ ok: false, reason: '智能低多边形只支持 v3.1 / v3.0' }); + expect( + resolveModel3dGenerationParams({ + model: 'v2.5-20250123', + tier: 'geometry-only', + quad: true, + smartLowPoly: false, + generateParts: false, + }), + ).toEqual({ ok: false, reason: '方形布线只支持 v3.1 / v3.0 / P2' }); + }); + + it('换版本时收敛档位与开关并换代次', () => { + const detailed = createDialog({ + model3dTier: 'textured-detailed', + model3dQuad: true, + model3dSmartLowPoly: true, + }); + const p2 = applyModel3dModelChange(detailed, 'P2-20260801'); + expect(p2.model3dModel).toBe('P2-20260801'); + // 高清贴图只 v3.x 有,降到标准贴图而不是直接掉到纯几何。 + expect(p2.model3dTier).toBe('textured-standard'); + expect(p2.model3dQuad).toBe(true); + expect(p2.model3dSmartLowPoly).toBe(false); + expect(p2.model3dAttemptNonce).not.toBe(detailed.model3dAttemptNonce); + + const v25 = applyModel3dModelChange(detailed, 'v2.5-20250123'); + expect(v25.model3dTier).toBe('textured-standard'); + expect(v25.model3dQuad).toBe(false); + expect(v25.model3dSmartLowPoly).toBe(false); + expect(v25.model3dGenerateParts).toBe(false); + }); + + it('换版本清掉失败态,切档位也按当前版本收敛', () => { + const failed = createDialog({ + status: 'failed', + errorMessage: '3D 模型生成失败,请稍后重试。', + model3dModel: 'P2-20260801', + }); + expect(applyModel3dModelChange(failed, 'P2-20260801')).toMatchObject({ + status: 'idle', + errorMessage: undefined, + }); + // P2 没有高清贴图档位:切过去只能落在标准贴图。 + expect( + applyModel3dTierChange(failed, 'textured-detailed').model3dTier, + ).toBe('textured-standard'); + // 纯几何 + 分件在 v3.x 上仍然成立。 + expect( + applyModel3dTierChange( + createDialog({ model3dGenerateParts: true }), + 'geometry-only', + ).model3dGenerateParts, + ).toBe(true); + }); +}); + +describe('resolveModel3dPrice', () => { + const paramsOf = ( + tier: 'textured-standard' | 'textured-detailed' | 'geometry-only', + switches: { + quad?: boolean; + smartLowPoly?: boolean; + generateParts?: boolean; + } = {}, + ) => { + const result = resolveModel3dGenerationParams({ + model: DEFAULT_MODEL3D_MODEL_VERSION, + tier, + quad: switches.quad ?? false, + smartLowPoly: switches.smartLowPoly ?? false, + generateParts: switches.generateParts ?? false, + }); + if (!result.ok) { + throw new Error('档位组合必须合法'); + } + return result.params; + }; + + it('底价按端点与是否有贴图取,add-on 逐项相加', () => { + expect( + resolveModel3dPrice({ + pricing: PRICING, + endpoint: 'image-to-model', + modelVersion: 'v3.1-20260211', + params: paramsOf('textured-standard'), + }), + ).toEqual({ ok: true, price: 250 }); + expect( + resolveModel3dPrice({ + pricing: PRICING, + endpoint: 'text-to-model', + modelVersion: 'v3.1-20260211', + params: paramsOf('geometry-only'), + }), + ).toEqual({ ok: true, price: 100 }); + }); + + it('缺段、缺底价、缺 add-on 一律不可提交,不按 0 放行', () => { + expect( + resolveModel3dPrice({ + pricing: null, + endpoint: 'text-to-model', + params: paramsOf('textured-standard'), + modelVersion: 'v3.1-20260211', + }), + ).toEqual({ ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON }); + expect( + resolveModel3dPrice({ + pricing: { ...PRICING, basePrices: null }, + endpoint: 'text-to-model', + modelVersion: 'v3.1-20260211', + params: paramsOf('textured-standard'), + }).ok, + ).toBe(false); + expect( + resolveModel3dPrice({ + // 纯几何 + 分件只叠 generateParts 一个 add-on,缺的就是它。 + pricing: { ...PRICING, addOnPrices: { quadMesh: 40 } }, + endpoint: 'text-to-model', + modelVersion: 'v3.1-20260211', + params: paramsOf('geometry-only', { generateParts: true }), + }), + ).toEqual({ ok: false, reason: '3D 模型定价缺少 generateParts 价格' }); + expect( + resolveModel3dPrice({ + pricing: PRICING, + endpoint: 'text-to-model', + modelVersion: 'v3.0-20250812', + params: paramsOf('textured-standard'), + }).ok, + ).toBe(false); + }); +}); + +describe('resolveModel3dGenerationQuote', () => { + it('文生与图生各取自己的端点价格', () => { + expect(resolveModel3dGenerationQuote(createDialog(), PRICING)).toEqual({ + ok: true, + price: 200, + endpoint: 'text-to-model', + }); + expect( + resolveModel3dGenerationQuote( + createDialog({ mode: 'model3d-image-to-model' }), + PRICING, + ), + ).toEqual({ ok: true, price: 250, endpoint: 'image-to-model' }); + }); + + it('定价段缺失时两个子项都拿不到价格', () => { + for (const mode of [ + 'model3d-text-to-model', + 'model3d-image-to-model', + ] as Model3dGenerationMode[]) { + expect( + resolveModel3dGenerationQuote(createDialog({ mode }), null), + ).toEqual({ ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON }); + } + }); +}); + +describe('3D 生成面板的参数变更', () => { + it('换档位时贴图档位强制关掉分件,并换代次', () => { + const geometryDialog = createDialog({ + model3dTier: 'geometry-only', + model3dGenerateParts: true, + }); + const textured = applyModel3dTierChange( + geometryDialog, + 'textured-detailed', + ); + expect(textured.model3dGenerateParts).toBe(false); + expect(textured.model3dAttemptNonce).not.toBe( + geometryDialog.model3dAttemptNonce, + ); + + const back = applyModel3dTierChange(textured, 'geometry-only'); + expect(back.model3dTier).toBe('geometry-only'); + expect(back.model3dAttemptNonce).not.toBe(textured.model3dAttemptNonce); + }); + + it('改开关、改提示词、主动重试都会换代次,并清掉失败态', () => { + const failedDialog = createDialog({ + status: 'failed', + errorMessage: '3D 模型生成失败,请稍后重试。', + }); + expect( + applyModel3dSwitchChange(failedDialog, { model3dQuad: true }), + ).toMatchObject({ model3dQuad: true, status: 'idle' }); + expect( + applyModel3dSwitchChange(failedDialog, { model3dQuad: true }) + .model3dAttemptNonce, + ).not.toBe(failedDialog.model3dAttemptNonce); + expect(applyModel3dPromptChange(failedDialog, '一把木椅')).toMatchObject({ + prompt: '一把木椅', + status: 'idle', + errorMessage: undefined, + }); + expect( + applyModel3dPromptChange(failedDialog, '一把木椅').model3dAttemptNonce, + ).not.toBe(failedDialog.model3dAttemptNonce); + expect(refreshModel3dAttemptNonce(failedDialog)).toMatchObject({ + status: 'idle', + errorMessage: undefined, + }); + expect( + refreshModel3dAttemptNonce(failedDialog).model3dAttemptNonce, + ).not.toBe(failedDialog.model3dAttemptNonce); + }); + + it('分件与四边面 / 智能减面互斥:后打开的赢,先打开的自动关掉', () => { + const geometryDialog = createDialog({ + model3dTier: 'geometry-only', + model3dGenerateParts: true, + }); + + const quad = applyModel3dSwitchChange(geometryDialog, { + model3dQuad: true, + }); + expect(quad).toMatchObject({ + model3dQuad: true, + model3dGenerateParts: false, + }); + + const smartLowPoly = applyModel3dSwitchChange( + { ...geometryDialog, model3dGenerateParts: false }, + { model3dSmartLowPoly: true }, + ); + expect(smartLowPoly.model3dQuad).toBe(false); + + const parts = applyModel3dSwitchChange( + { ...geometryDialog, model3dQuad: true, model3dSmartLowPoly: true }, + { model3dGenerateParts: true }, + ); + expect(parts).toMatchObject({ + model3dGenerateParts: true, + model3dQuad: false, + model3dSmartLowPoly: false, + }); + }); + + it('只关掉一个开关时不动其它开关', () => { + const dialog = createDialog({ + model3dTier: 'geometry-only', + model3dQuad: true, + }); + + expect( + applyModel3dSwitchChange(dialog, { model3dGenerateParts: false }), + ).toMatchObject({ model3dQuad: true, model3dGenerateParts: false }); + }); + + it('删掉参考图回到空槽位并清掉失败态', () => { + const dialog = createDialog({ + mode: 'model3d-image-to-model', + status: 'failed', + errorMessage: '3D 模型生成失败,请稍后重试。', + generationReferences: [ + { id: 'reference-1', label: '参考图', src: 'data:image/png;base64,1' }, + { id: 'reference-2', label: '参考图', src: 'data:image/png;base64,2' }, + ], + }); + + const next = applyModel3dReferenceRemoval(dialog, 'reference-1'); + + expect(next.generationReferences?.map((reference) => reference.id)).toEqual( + ['reference-2'], + ); + expect(next).toMatchObject({ status: 'idle', errorMessage: undefined }); + expect( + applyModel3dReferenceRemoval(next, 'reference-2').generationReferences, + ).toEqual([]); + }); + + it('每次铸造的代次互不相同', () => { + const nonces = new Set( + Array.from({ length: 20 }, () => mintModel3dAttemptNonce()), + ); + expect(nonces.size).toBe(20); + for (const nonce of nonces) { + expect(nonce).toMatch(/^model3d-[0-9a-zA-Z-]+$/); + } + }); +}); diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.ts b/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.ts new file mode 100644 index 000000000..16bd7d847 --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.ts @@ -0,0 +1,512 @@ +import type { + Model3dGeometryQuality, + Model3dModelVersion, + Model3dTextureQuality, +} from '../../../../packages/shared/src/contracts/model3d'; +import type { + Model3dAddOnPriceKey, + Model3dPricingConfig, + Model3dPricingEndpoint, +} from '../../../services/image-editor/editorProjectClient'; +import type { + CharacterReferenceImage, + GenerateDialogState, + Model3dGenerationTier, +} from '../ImageCanvasEditorTypes'; +import { + isModel3dModelVersion, + MODEL3D_MODEL_VERSIONS, + MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED, + MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE, + MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED, + MODEL3D_REASON_QUAD_UNSUPPORTED, + MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED, + type Model3dModelCapabilities, + resolveModel3dModelCapabilities, + resolveModel3dModelFamily, + resolveModel3dModelLabel, + validateModel3dRequestParams, +} from './Model3dGenerationValidation'; + +export type Model3dGenerationMode = + | 'model3d-text-to-model' + | 'model3d-image-to-model'; + +export const MODEL3D_GENERATION_MODES: readonly Model3dGenerationMode[] = [ + 'model3d-text-to-model', + 'model3d-image-to-model', +]; + +export function isModel3dGenerationMode( + mode: GenerateDialogState['mode'], +): mode is Model3dGenerationMode { + return (MODEL3D_GENERATION_MODES as readonly string[]).includes(mode); +} + +export const MODEL3D_GENERATION_DIALOG_LABEL = '3D 模型生成'; +export const MODEL3D_GENERATION_SUBMIT_LABEL = '生成 3D 模型'; +export const MODEL3D_PRICING_UNAVAILABLE_REASON = '3D 模型定价尚未配置'; +/** 当前模型版本在这个端点没有底价:与「整段没配」分开说,用户才知道该改哪里。 */ +export const MODEL3D_PRICING_MISSING_BASE_PRICE_REASON = + '3D 模型定价缺少该档位底价'; +/** 某个加价项缺价:报价必须整项算得出来才算数,缺一项就不可提交。 */ +export function resolveModel3dPricingMissingAddOnReason(addOn: string) { + return `3D 模型定价缺少 ${addOn} 价格`; +} +/** 面板本身不是 3D 生成模式:与「定价缺失」是两回事,不能共用一句话。 */ +export const MODEL3D_MODE_UNSUPPORTED_REASON = '当前面板不是 3D 模型模式'; + +/** + * 档位是唯一给用户看的参数面:每个档位固定映射成 provider 的完整贴图 / 几何组合, + * 用户不直接面对 textureQuality / geometryQuality 这类会改价的枚举。 + */ +export const MODEL3D_GENERATION_TIERS: ReadonlyArray<{ + value: Model3dGenerationTier; + label: string; +}> = [ + { value: 'textured-standard', label: '标准贴图' }, + { value: 'textured-detailed', label: '高清贴图' }, + { value: 'geometry-only', label: '纯几何' }, +]; + +/** + * 开关与「哪个模型能力决定它可用」绑定:面板不再自己判断哪个版本支持什么, + * 只读能力表;禁用原因同样取自校验模块,文案只有一份。 + */ +export const MODEL3D_SWITCH_OPTIONS: ReadonlyArray<{ + field: 'model3dQuad' | 'model3dSmartLowPoly' | 'model3dGenerateParts'; + label: string; + capability: keyof Pick< + Model3dModelCapabilities, + 'quad' | 'smartLowPoly' | 'generateParts' + >; + disabledReason: string; +}> = [ + { + field: 'model3dQuad', + label: '方形布线', + capability: 'quad', + disabledReason: MODEL3D_REASON_QUAD_UNSUPPORTED, + }, + { + field: 'model3dSmartLowPoly', + label: '智能低多边形', + capability: 'smartLowPoly', + disabledReason: MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED, + }, + { + field: 'model3dGenerateParts', + label: '分件', + capability: 'generateParts', + // 模型侧的原因;「贴图档位不允许分件」是档位侧的原因,见 `MODEL3D_TIER_ONLY_REASON`。 + disabledReason: MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED, + }, +]; + +/** 高清贴图档位的禁用原因:档位同时要求 detailed 贴图与 detailed 几何。 */ +export const MODEL3D_DETAILED_TIER_DISABLED_REASON = + MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED; + +/** 分件在贴图档位下被禁用的原因(与校验模块的分件规则同一句文案)。 */ +export const MODEL3D_GENERATE_PARTS_TEXTURE_TIER_REASON = + MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE; + +export const MODEL3D_TEXT_PROMPT_MAX_LENGTH = 1024; +export const MODEL3D_GENERATION_FRAME_SIZE = { width: 1024, height: 1024 }; +export const DEFAULT_MODEL3D_MODEL_VERSION: Model3dModelVersion = + 'v3.1-20260211'; + +export type Model3dGenerationParams = { + texture: boolean; + textureQuality: Model3dTextureQuality | null; + geometryQuality: Model3dGeometryQuality; + quad: boolean; + smartLowPoly: boolean; + generateParts: boolean; + pbr: false; +}; + +export function resolveModel3dEndpoint( + mode: Model3dGenerationMode, +): Model3dPricingEndpoint { + return mode === 'model3d-image-to-model' ? 'image-to-model' : 'text-to-model'; +} + +/** + * 档位到贴图质量的唯一映射。两个「不发这个字段」的例外都来自 provider 预检: + * + * - 纯几何档位没有贴图,本就无档位可给; + * - v2.5 拒绝任何 `textureQuality`,因此它的贴图档位同样不带该字段 —— + * 后端按缺省 standard 计价,与面板报价同一个档位。 + */ +function resolveModel3dTextureQuality( + model: Model3dModelVersion, + tier: Model3dGenerationTier, +): Model3dTextureQuality | null { + if (tier === 'geometry-only') { + return null; + } + if (resolveModel3dModelFamily(model) === 'v25') { + return null; + } + return tier === 'textured-detailed' ? 'detailed' : 'standard'; +} + +/** 档位在当前模型下是否可用:不可用时代表单必须先把档位收敛到可用档位。 */ +export function isModel3dTierSupported( + capabilities: Model3dModelCapabilities, + tier: Model3dGenerationTier, +): boolean { + return tier !== 'textured-detailed' || capabilities.detailedTextureTier; +} + +/** + * 把档位收敛到当前模型支持的档位:高清贴图 → 标准贴图 → 纯几何, + * 逐级降级而不是直接跳到纯几何,避免用户的贴图意图被悄悄改掉。 + */ +export function resolveModel3dSupportedTier( + capabilities: Model3dModelCapabilities, + tier: Model3dGenerationTier, +): Model3dGenerationTier { + if (isModel3dTierSupported(capabilities, tier)) { + return tier; + } + return 'textured-standard'; +} + +/** + * 档位 + 开关 → provider 参数。组合合法性**只有一份实现**:拼出参数后交给 + * `Model3dGenerationValidation` 的规则跑一遍,这里不再写第二份「分件不能配⋯」。 + */ +export function resolveModel3dGenerationParams({ + model, + tier, + quad, + smartLowPoly, + generateParts, +}: { + model: Model3dModelVersion; + tier: Model3dGenerationTier; + quad: boolean; + smartLowPoly: boolean; + generateParts: boolean; +}): + | { ok: true; params: Model3dGenerationParams } + | { ok: false; reason: string } { + const params: Model3dGenerationParams = { + texture: tier !== 'geometry-only', + textureQuality: resolveModel3dTextureQuality(model, tier), + geometryQuality: tier === 'textured-detailed' ? 'detailed' : 'standard', + quad, + smartLowPoly, + generateParts, + pbr: false, + }; + const failure = validateModel3dRequestParams({ model, ...params }); + if (failure) { + return { ok: false, reason: failure.reason }; + } + return { ok: true, params }; +} + +/** + * 提交与报价共用的模型版本:缺字段(老快照)或存的是已退役版本时回落到默认版本。 + * + * 收敛必须发生在读表之前:`resolveModel3dModelFamily` 直接按版本查表,契约里没有的版本会抛异常, + * 面板、报价与提交体因此都得走同一个入口。 + */ +export function resolveModel3dModelVersion( + model: Model3dModelVersion | null | undefined, +): Model3dModelVersion { + return normalizeModel3dModelVersion(model); +} + +/** + * 面板上可选的模型版本:只给当前端点**有底价**的版本。 + * + * 契约的 TS 侧只导出联合类型(`Model3dModelVersion`),运行期没有版本数组, + * 因此以 `MODEL3D_MODEL_VERSIONS` 为顺序、以定价段的键为可用性判据:没定价的版本 + * 选了也出不了价,干脆不出现在选项里(与「缺价不可提交」同一条失败关闭口径)。 + */ +export function resolveModel3dModelOptions({ + pricing, + endpoint, +}: { + pricing: Model3dPricingConfig | null | undefined; + endpoint: Model3dPricingEndpoint; +}): ReadonlyArray<{ value: Model3dModelVersion; label: string }> { + const pricedVersions = pricing?.basePrices?.[endpoint]; + if (!pricedVersions) { + return []; + } + return MODEL3D_MODEL_VERSIONS.filter( + (version) => pricedVersions[version] != null, + ).map((value) => ({ value, label: resolveModel3dModelLabel(value) })); +} + +/** 老快照里的模型版本可能已经不在契约里,按默认版本处理而不是把它发给服务端。 */ +export function normalizeModel3dModelVersion( + model: string | null | undefined, +): Model3dModelVersion { + const trimmed = model?.trim(); + return trimmed && isModel3dModelVersion(trimmed) + ? trimmed + : DEFAULT_MODEL3D_MODEL_VERSION; +} + +/** + * 与后端 `model3d_add_ons` 逐条对齐的加价判定;这里是前端唯一一份实现。 + */ +export function resolveModel3dAddOnKeys( + params: Model3dGenerationParams, +): Model3dAddOnPriceKey[] { + const keys: Model3dAddOnPriceKey[] = []; + if (params.texture && params.textureQuality === 'detailed') { + keys.push('hdTexture'); + } + if (params.texture && params.textureQuality === 'extreme') { + keys.push('ultraTexture'); + } + if (params.geometryQuality === 'detailed') { + keys.push('hdGeometry'); + } + if (params.quad) { + keys.push('quadMesh'); + } + if (params.smartLowPoly) { + keys.push('smartLowPoly'); + } + if (params.generateParts) { + keys.push('generateParts'); + } + return keys; +} + +function readPrice(value: number | undefined) { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** + * 价格只来自实时定价查询的 `model3d` 段:缺段、缺底价或缺 add-on 一律不可提交, + * 不回退内置数值、也不按 0 放行。 + */ +export function resolveModel3dPrice({ + pricing, + endpoint, + modelVersion, + params, +}: { + pricing: Model3dPricingConfig | null | undefined; + endpoint: Model3dPricingEndpoint; + modelVersion: Model3dModelVersion; + params: Model3dGenerationParams; +}): { ok: true; price: number } | { ok: false; reason: string } { + if (!pricing) { + return { ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON }; + } + const basePriceRow = pricing.basePrices?.[endpoint]?.[modelVersion]; + const basePrice = readPrice( + params.texture ? basePriceRow?.texture : basePriceRow?.noTexture, + ); + if (basePrice === null) { + return { ok: false, reason: MODEL3D_PRICING_MISSING_BASE_PRICE_REASON }; + } + let price = basePrice; + for (const key of resolveModel3dAddOnKeys(params)) { + const addOnPrice = readPrice(pricing.addOnPrices?.[key]); + if (addOnPrice === null) { + return { + ok: false, + reason: resolveModel3dPricingMissingAddOnReason(key), + }; + } + price += addOnPrice; + } + return { ok: true, price }; +} + +export function resolveModel3dGenerationQuote( + dialog: GenerateDialogState, + pricing: Model3dPricingConfig | null | undefined, +): + | { ok: true; price: number; endpoint: Model3dPricingEndpoint } + | { ok: false; reason: string } { + if (!isModel3dGenerationMode(dialog.mode)) { + return { ok: false, reason: MODEL3D_MODE_UNSUPPORTED_REASON }; + } + const model = resolveModel3dModelVersion(dialog.model3dModel); + const paramsResult = resolveModel3dGenerationParams({ + model, + tier: dialog.model3dTier ?? 'textured-standard', + quad: dialog.model3dQuad ?? false, + smartLowPoly: dialog.model3dSmartLowPoly ?? false, + generateParts: dialog.model3dGenerateParts ?? false, + }); + if (!paramsResult.ok) { + return { ok: false, reason: paramsResult.reason }; + } + const endpoint = resolveModel3dEndpoint(dialog.mode); + const priceResult = resolveModel3dPrice({ + pricing, + endpoint, + // 报价与提交必须读同一个版本:`buildModel3dSubmissionPlan` 走同一个 resolver。 + modelVersion: model, + params: paramsResult.params, + }); + return priceResult.ok + ? { ok: true, price: priceResult.price, endpoint } + : { ok: false, reason: priceResult.reason }; +} + +/** + * 尝试代次:一次「打开面板 / 改参数 / 主动重试」算一代。真正的 `Idempotency-Key` 由 + * `Model3dGenerationSubmission` 把它与请求内容指纹拼起来,所以这里不必穷举所有会改内容的入口。 + */ +export function mintModel3dAttemptNonce(): string { + const randomUuid = globalThis.crypto?.randomUUID; + const suffix = + typeof randomUuid === 'function' + ? randomUuid.call(globalThis.crypto) + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + return `model3d-${suffix}`; +} + +export function createModel3dGenerationDraftFields( + mode: Model3dGenerationMode, +) { + return { + mode, + prompt: '', + status: 'idle' as const, + composerOpen: true, + generationReferences: [] as CharacterReferenceImage[], + model3dModel: DEFAULT_MODEL3D_MODEL_VERSION, + model3dTier: 'textured-standard' as Model3dGenerationTier, + model3dQuad: false, + model3dSmartLowPoly: false, + model3dGenerateParts: false, + model3dAttemptNonce: mintModel3dAttemptNonce(), + }; +} + +function resetFailedStatus(dialog: GenerateDialogState): GenerateDialogState { + return dialog.status === 'failed' + ? { ...dialog, status: 'idle', errorMessage: undefined } + : dialog; +} + +/** + * 参数一改就换代次:用户这次的意图已经变了,下一次提交属于新的一代。 + */ +function withNewAttemptNonce(dialog: GenerateDialogState): GenerateDialogState { + return { ...dialog, model3dAttemptNonce: mintModel3dAttemptNonce() }; +} + +export function applyModel3dTierChange( + dialog: GenerateDialogState, + tier: Model3dGenerationTier, +): GenerateDialogState { + const capabilities = resolveModel3dModelCapabilities( + resolveModel3dModelVersion(dialog.model3dModel), + ); + const nextTier = resolveModel3dSupportedTier(capabilities, tier); + return withNewAttemptNonce({ + ...resetFailedStatus(dialog), + model3dTier: nextTier, + model3dGenerateParts: + nextTier === 'geometry-only' && capabilities.generateParts + ? (dialog.model3dGenerateParts ?? false) + : false, + }); +} + +/** + * 换模型版本:换代次(这是会影响价格与 payload 的参数),并按新版本的能力收敛参数 —— + * 把档位降到可用档位、把该版本不支持的开关关掉,面板上不留已经失效的勾选。 + */ +export function applyModel3dModelChange( + dialog: GenerateDialogState, + model: Model3dModelVersion, +): GenerateDialogState { + const capabilities = resolveModel3dModelCapabilities(model); + const tier = resolveModel3dSupportedTier( + capabilities, + dialog.model3dTier ?? 'textured-standard', + ); + return withNewAttemptNonce({ + ...resetFailedStatus(dialog), + model3dModel: model, + model3dTier: tier, + model3dQuad: capabilities.quad && (dialog.model3dQuad ?? false), + model3dSmartLowPoly: + capabilities.smartLowPoly && (dialog.model3dSmartLowPoly ?? false), + model3dGenerateParts: + tier === 'geometry-only' && + capabilities.generateParts && + (dialog.model3dGenerateParts ?? false), + }); +} + +/** + * 改开关:换代次、清失败态,并把互斥项一起收干净。 + * + * 「生成部件」与「四边面」/「智能减面」不能同时打开(提交前校验会直接拒绝,见 + * `Model3dGenerationValidation`)。这里让刚刚打开的那个赢、另一个自动关掉:面板上不留 + * 一个永远提交不了的组合,也不用用户自己去猜该关哪一个(评审 #86)。 + */ +export function applyModel3dSwitchChange( + dialog: GenerateDialogState, + patch: Partial< + Pick< + GenerateDialogState, + 'model3dQuad' | 'model3dSmartLowPoly' | 'model3dGenerateParts' + > + >, +): GenerateDialogState { + const next = { ...resetFailedStatus(dialog), ...patch }; + if (patch.model3dGenerateParts === true) { + return withNewAttemptNonce({ + ...next, + model3dQuad: false, + model3dSmartLowPoly: false, + }); + } + if (patch.model3dQuad === true || patch.model3dSmartLowPoly === true) { + return withNewAttemptNonce({ ...next, model3dGenerateParts: false }); + } + return withNewAttemptNonce(next); +} + +export function applyModel3dPromptChange( + dialog: GenerateDialogState, + prompt: string, +): GenerateDialogState { + return withNewAttemptNonce({ ...resetFailedStatus(dialog), prompt }); +} + +/** 删掉当前参考图:图生 3D 只有一个槽位,删掉即回到空槽位重新选。 */ +export function applyModel3dReferenceRemoval( + dialog: GenerateDialogState, + referenceId: string, +): GenerateDialogState { + return { + ...resetFailedStatus(dialog), + errorMessage: undefined, + generationReferences: (dialog.generationReferences ?? []).filter( + (reference) => reference.id !== referenceId, + ), + }; +} + +/** + * 用户主动重试:同键同请求只会拿回原来那个终态 operation,所以重试必须开新一代。 + */ +export function refreshModel3dAttemptNonce( + dialog: GenerateDialogState, +): GenerateDialogState { + return { + ...withNewAttemptNonce(dialog), + status: 'idle', + errorMessage: undefined, + }; +} diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationModal.test.tsx b/src/components/image-editor/model3d-generation/Model3dGenerationModal.test.tsx new file mode 100644 index 000000000..8beaff14a --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationModal.test.tsx @@ -0,0 +1,446 @@ +/* @vitest-environment jsdom */ + +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { type ReactNode, useRef, useState } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient'; +import { setPlainTextEditorValue } from '../../common/AutoGrowTextArea.test-utils'; +import type { + CharacterReferenceImage, + GenerateDialogState, + UploadTarget, +} from '../ImageCanvasEditorTypes'; +import { + applyEditorGenerationPricingConfig, + EDITOR_MODEL_MUD_POINT_CONFIG, +} from '../ImageCanvasGenerationModel'; +import { createModel3dGenerationDraftFields } from './Model3dGenerationFormModel'; +import { Model3dGenerationModal } from './Model3dGenerationModal'; + +const PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { 'v3.1-20260211': { noTexture: 100, texture: 200 } }, + 'image-to-model': { 'v3.1-20260211': { noTexture: 150, texture: 250 } }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +const MULTI_VERSION_PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { + 'v3.1-20260211': { noTexture: 100, texture: 200 }, + 'v2.5-20250123': { noTexture: 60, texture: 120 }, + 'P2-20260801': { noTexture: 80, texture: 160 }, + }, + 'image-to-model': { + 'v3.1-20260211': { noTexture: 150, texture: 250 }, + 'P2-20260801': { noTexture: 170, texture: 270 }, + }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +function applyPricing(model3d: Model3dPricingConfig | null) { + applyEditorGenerationPricingConfig({ + models: EDITOR_MODEL_MUD_POINT_CONFIG, + ...(model3d ? { model3d } : {}), + }); +} + +function createDialog( + mode: 'model3d-text-to-model' | 'model3d-image-to-model', + overrides: Partial = {}, +): GenerateDialogState { + return { + id: 'dialog-3d', + ...createModel3dGenerationDraftFields(mode), + ...overrides, + } as GenerateDialogState; +} + +function canvasReference( + overrides: Partial = {}, +): CharacterReferenceImage { + return { + id: 'reference-1', + src: '/generated-character-drafts/editor/ref.webp', + label: '参考图', + resourceId: 'resource-1', + ...overrides, + }; +} + +function Model3dModalHarness({ + initialDialog, + onSubmit = vi.fn(), + onRequestUpload = vi.fn(), + renderEditorPortal = (node: ReactNode) => node, +}: { + initialDialog: GenerateDialogState; + onSubmit?: (dialog: GenerateDialogState) => void; + onRequestUpload?: (target: UploadTarget) => void; + renderEditorPortal?: (node: ReactNode) => ReactNode; +}) { + const [dialog, setDialog] = useState( + initialDialog, + ); + const [isReferenceMenuOpen, setIsReferenceMenuOpen] = useState(false); + const [isPickingReference, setIsPickingReference] = useState(false); + const referenceButtonRef = useRef(null); + + return ( +
+ {dialog ? ( + [0]['setGenerateDialog'] + } + generationReferenceButtonRef={referenceButtonRef} + isGenerationReferenceMenuOpen={isReferenceMenuOpen} + setIsGenerationReferenceMenuOpen={setIsReferenceMenuOpen} + setIsPickingGenerationReferenceFromCanvas={setIsPickingReference} + onRequestUpload={onRequestUpload} + renderEditorPortal={renderEditorPortal} + onSubmit={onSubmit} + /> + ) : null} + {dialog?.model3dModel ?? '-'} + {dialog?.model3dTier ?? '-'} + + {String(dialog?.model3dGenerateParts)} + + + {dialog?.generationReferences?.length ?? 0} + + + {isPickingReference ? 'picking' : 'idle'} + + + {dialog?.model3dAttemptNonce ?? '-'} + +
+ ); +} + +function submitButton() { + return screen.getByRole('button', { name: '生成 3D 模型' }); +} + +/** 面板内的查询:harness 的 `` 也是 role=status,不能直接按全局角色查。 */ +function panel() { + return within(screen.getByRole('dialog', { name: '3D 模型生成' })); +} + +describe('Model3dGenerationModal', () => { + beforeEach(() => { + applyPricing(PRICING); + }); + + afterEach(() => { + cleanup(); + }); + + it('有定价与描述时展示泥点价格并可提交', () => { + const onSubmit = vi.fn(); + render( + , + ); + + expect(within(submitButton()).getByText('200泥点')).toBeTruthy(); + expect((submitButton() as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(submitButton()); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('描述为空时不可提交', () => { + render( + , + ); + // 价格只取决于参数,不取决于输入是否齐备;缺的是描述,因此按钮不可点但价格照常展示。 + expect((submitButton() as HTMLButtonElement).disabled).toBe(true); + expect(within(submitButton()).getByText('200泥点')).toBeTruthy(); + }); + + it('输入描述后可提交,且改描述会换代次', () => { + render( + , + ); + const before = screen.getByLabelText('当前代次').textContent; + + setPlainTextEditorValue(screen.getByLabelText('模型描述'), '一把木椅'); + + expect((submitButton() as HTMLButtonElement).disabled).toBe(false); + expect(screen.getByLabelText('当前代次').textContent).not.toBe(before); + }); + + it('定价段缺失时展示原因、不发请求,也不显示兜底价格', () => { + applyPricing(null); + const onSubmit = vi.fn(); + render( + , + ); + + expect(panel().getByRole('alert').textContent).toContain( + '3D 模型定价尚未配置', + ); + expect((submitButton() as HTMLButtonElement).disabled).toBe(true); + expect(within(submitButton()).getByText('—')).toBeTruthy(); + fireEvent.click(submitButton()); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('图生 3D 没有参考图时不可提交,选中后按单图定价', () => { + render( + , + ); + expect((submitButton() as HTMLButtonElement).disabled).toBe(true); + + fireEvent.click(screen.getByLabelText('添加参考图')); + expect(screen.getByLabelText('参考图来源')).toBeTruthy(); + fireEvent.click(screen.getByText('从画布中选择')); + expect(screen.getByLabelText('画布选择参考图').textContent).toBe('picking'); + }); + + it('已有参考图时展示图片与删除入口,删除后回到空槽位', () => { + render( + , + ); + + expect(screen.getByLabelText('当前参考图数量').textContent).toBe('1'); + expect((submitButton() as HTMLButtonElement).disabled).toBe(false); + expect(within(submitButton()).getByText('250泥点')).toBeTruthy(); + + fireEvent.click(screen.getByLabelText('删除参考图')); + expect(screen.getByLabelText('当前参考图数量').textContent).toBe('0'); + expect(screen.getByLabelText('添加参考图')).toBeTruthy(); + }); + + it('切到纯几何档位后可开分件,切回贴图档位自动关掉', () => { + render( + , + ); + const generateParts = screen.getByLabelText('分件') as HTMLInputElement; + expect(generateParts.disabled).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: '纯几何' })); + expect(screen.getByLabelText('当前档位').textContent).toBe('geometry-only'); + expect((screen.getByLabelText('分件') as HTMLInputElement).disabled).toBe( + false, + ); + + fireEvent.click(screen.getByLabelText('分件')); + expect(screen.getByLabelText('当前分件').textContent).toBe('true'); + + fireEvent.click(screen.getByRole('button', { name: '高清贴图' })); + expect(screen.getByLabelText('当前分件').textContent).toBe('false'); + // 底价 200 + 高清贴图 10 + 高清几何 30。 + expect(within(submitButton()).getByText('240泥点')).toBeTruthy(); + }); + + it('生成中禁用表单只展示生成中,失败后展示原因与重试', () => { + const onSubmit = vi.fn(); + const { unmount } = render( + , + ); + expect(panel().getByRole('status').textContent).toContain('生成中'); + expect((submitButton() as HTMLButtonElement).disabled).toBe(true); + unmount(); + + const failedDialog = createDialog('model3d-text-to-model', { + prompt: '一把木椅', + status: 'failed', + errorMessage: '3D 模型生成失败,请稍后重试。', + }); + render( + , + ); + + expect(panel().getByRole('alert').textContent).toContain( + '3D 模型生成失败,请稍后重试。', + ); + fireEvent.click(screen.getByRole('button', { name: '重试生成 3D 模型' })); + expect(onSubmit).toHaveBeenCalledTimes(1); + // 重试必须开新一代,否则同键同请求只会拿回原来那个终态 operation。 + expect(onSubmit.mock.calls[0][0].model3dAttemptNonce).not.toBe( + failedDialog.model3dAttemptNonce, + ); + expect(onSubmit.mock.calls[0][0].model3dAttemptNonce).toBeTruthy(); + }); + + it('模型选择器只列出当前端点有底价的版本,选中后按该版本报价', () => { + applyPricing(MULTI_VERSION_PRICING); + render( + , + ); + + expect(within(submitButton()).getByText('200泥点')).toBeTruthy(); + fireEvent.click(screen.getByLabelText('3D 模型版本 v3.1')); + + const menu = screen.getByLabelText('3D 模型版本选项'); + // 只有本端点有底价的版本进选项:P1 有契约、没有价格,选了也出不了价。 + expect(within(menu).queryByRole('button', { name: 'P1' })).toBeNull(); + fireEvent.click(within(menu).getByRole('button', { name: 'P2' })); + + expect(screen.getByLabelText('当前模型版本').textContent).toBe( + 'P2-20260801', + ); + expect(within(submitButton()).getByText('160泥点')).toBeTruthy(); + }); + + it('切到能力更弱的版本时档位与开关一起收敛', () => { + applyPricing(MULTI_VERSION_PRICING); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '高清贴图' })); + fireEvent.click(screen.getByLabelText('方形布线')); + expect(screen.getByLabelText('当前档位').textContent).toBe( + 'textured-detailed', + ); + + fireEvent.click(screen.getByLabelText('3D 模型版本 v3.1')); + fireEvent.click( + within(screen.getByLabelText('3D 模型版本选项')).getByRole('button', { + name: 'v2.5', + }), + ); + + // v2.5 没有高清贴图档位,也没方形布线:档位降级、开关关掉并禁用。 + expect(screen.getByLabelText('当前档位').textContent).toBe( + 'textured-standard', + ); + expect( + (screen.getByLabelText('方形布线') as HTMLInputElement).disabled, + ).toBe(true); + expect( + (screen.getByLabelText('智能低多边形') as HTMLInputElement).disabled, + ).toBe(true); + expect(screen.getByLabelText('当前分件').textContent).toBe('false'); + }); + + it('版本不支持的开关保持可见但禁用,并带上原因', () => { + applyPricing(MULTI_VERSION_PRICING); + render( + , + ); + + // P2 支持方形布线,不支持智能低模与分件。 + expect( + (screen.getByLabelText('方形布线') as HTMLInputElement).disabled, + ).toBe(false); + const smartLowPoly = screen.getByLabelText('智能低多边形'); + expect((smartLowPoly as HTMLInputElement).disabled).toBe(true); + expect(smartLowPoly.closest('label')?.getAttribute('title')).toContain( + '智能低多边形只支持', + ); + }); + + it('版本没有底价时不显示兜底价格,也不可提交', () => { + applyPricing(MULTI_VERSION_PRICING); + render( + , + ); + + expect((submitButton() as HTMLButtonElement).disabled).toBe(true); + expect(within(submitButton()).getByText('—')).toBeTruthy(); + expect(panel().getByRole('alert').textContent).toContain('定价'); + }); + + it('失败态下主按钮也开新一代,不会重现同一次失败', () => { + const onSubmit = vi.fn(); + const failedDialog = createDialog('model3d-text-to-model', { + prompt: '一把木椅', + status: 'failed', + errorMessage: '3D 模型生成失败,请稍后重试。', + }); + render( + , + ); + + // 主按钮在失败态仍是「再生成一次」的自然入口,可以点;但不能沿用旧代次, + // 否则幂等键不变、服务端只会把原来那个终态 operation 还回来。 + expect((submitButton() as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(submitButton()); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].model3dAttemptNonce).not.toBe( + failedDialog.model3dAttemptNonce, + ); + expect(onSubmit.mock.calls[0][0].model3dAttemptNonce).toBeTruthy(); + expect(onSubmit.mock.calls[0][0].status).toBe('idle'); + }); +}); diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationModal.tsx b/src/components/image-editor/model3d-generation/Model3dGenerationModal.tsx new file mode 100644 index 000000000..9cd714bf9 --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationModal.tsx @@ -0,0 +1,203 @@ +import type { + CSSProperties, + Dispatch, + ReactNode, + RefObject, + SetStateAction, +} from 'react'; +import { useMemo } from 'react'; + +import { PlatformActionButton } from '../../common/PlatformActionButton'; +import { PlatformStatusMessage } from '../../common/PlatformStatusMessage'; +import type { + GenerateDialogState, + UploadTarget, +} from '../ImageCanvasEditorTypes'; +import { readEditorModel3dPricingConfig } from '../ImageCanvasGenerationModel'; +import { useImageCanvasFloatingOptionDismiss } from '../useImageCanvasFloatingOptionDismiss'; +import { Model3dGenerationForm } from './Model3dGenerationForm'; +import { + MODEL3D_GENERATION_DIALOG_LABEL, + MODEL3D_GENERATION_SUBMIT_LABEL, + refreshModel3dAttemptNonce, + resolveModel3dGenerationQuote, + resolveModel3dModelOptions, +} from './Model3dGenerationFormModel'; +import { resolveModel3dImageSource } from './Model3dGenerationSubmission'; + +type Model3dGenerationModalProps = { + dialog: GenerateDialogState; + style: CSSProperties; + setGenerateDialog: Dispatch>; + generationReferenceButtonRef?: RefObject; + isGenerationReferenceMenuOpen?: boolean; + setIsGenerationReferenceMenuOpen?: Dispatch>; + setIsPickingGenerationReferenceFromCanvas?: Dispatch>; + onRequestUpload: (target: UploadTarget) => void; + onRequestProjectAssetPicker?: () => void; + renderEditorPortal?: (node: ReactNode) => ReactNode; + buildPortalMenuStyle?: ( + anchor: HTMLElement | null, + placement: 'above' | 'below', + ) => CSSProperties; + hasPendingImageReferenceUploads?: boolean; + onSubmit: (dialog: GenerateDialogState) => void; +}; + +export function Model3dGenerationModal({ + dialog, + style, + setGenerateDialog, + generationReferenceButtonRef, + isGenerationReferenceMenuOpen = false, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, + onRequestUpload, + onRequestProjectAssetPicker, + renderEditorPortal = (node) => node, + buildPortalMenuStyle = () => ({}), + hasPendingImageReferenceUploads = false, + onSubmit, +}: Model3dGenerationModalProps) { + const isGenerating = dialog.status === 'generating'; + const isImageMode = dialog.mode === 'model3d-image-to-model'; + // 定价缓存只读一次:报价与模型选项必须来自同一份快照,否则会出现「报价用的是旧价、 + // 选项用的是新价」这种半截状态。 + const pricing = readEditorModel3dPricingConfig(); + const quote = resolveModel3dGenerationQuote(dialog, pricing); + const modelOptions = resolveModel3dModelOptions({ + pricing, + endpoint: isImageMode ? 'image-to-model' : 'text-to-model', + }); + const hasRequiredInput = isImageMode + ? Boolean(resolveModel3dImageSource(dialog)) + : Boolean(dialog.prompt.trim()); + const canSubmit = + quote.ok && + hasRequiredInput && + !isGenerating && + !hasPendingImageReferenceUploads; + // 幂等键 = 代次 + 请求内容指纹,同键同请求只会拿回原来那个终态 operation。 + // 所以失败态下的任何提交(主按钮或「重试」)都要开新一代,否则点下去只是重现同一次失败; + // 参数一改本来就会换代号次,这里补的是「不改参数直接再点一次」这条路。 + // 代次只在点击那一刻铸:写在渲染体里会让每次重渲染(含 StrictMode 双渲染)都换一个随机值, + // 渲染结果不确定,也和「这一次点击」对不上。 + const resolveSubmitDialog = () => + dialog.status === 'failed' ? refreshModel3dAttemptNonce(dialog) : dialog; + + // 每次都新建数组会让上面的 dismiss effect 每次渲染都重挂监听;ref 对象本身是稳定的。 + const referenceMenuBoundaryRefs = useMemo( + () => [generationReferenceButtonRef], + [generationReferenceButtonRef], + ); + useImageCanvasFloatingOptionDismiss({ + isOpen: isImageMode && isGenerationReferenceMenuOpen, + boundaryRefs: referenceMenuBoundaryRefs, + onDismiss: () => setIsGenerationReferenceMenuOpen?.(false), + }); + + return ( + // TODO(a11y-modal): 这里只标了 role="dialog",没标 aria-modal,也没有把键盘焦点锁在面板内、 + // 关闭后还回原处。读屏软件因此不知道面板之外的页面此时应该失效,Tab 也能跑到面板外面 + // (评审 #74)。单补一个标记而不做焦点管理只会给出错误的提示,而且仓库里 20 多个同类面板 + // 都没有这个标记,只给 3D 面板加反而不一致;要么和焦点管理一起做,要么一次性推广到所有 + // 同类面板。 +
event.stopPropagation()} + > + + setGenerateDialog((currentDialog) => + currentDialog ? updater(currentDialog) : currentDialog, + ) + } + generationReferenceButtonRef={generationReferenceButtonRef} + isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen} + setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen} + setIsPickingGenerationReferenceFromCanvas={ + setIsPickingGenerationReferenceFromCanvas + } + onRequestUpload={onRequestUpload} + onRequestProjectAssetPicker={onRequestProjectAssetPicker} + renderEditorPortal={renderEditorPortal} + buildPortalMenuStyle={buildPortalMenuStyle} + /> +
+ onSubmit(resolveSubmitDialog())} + > + {isGenerating ? ( + '生成中' + ) : ( + <> + {MODEL3D_GENERATION_SUBMIT_LABEL} + + {quote.ok ? `${quote.price}泥点` : '—'} + + + )} + + {/* 重试也必须过 canSubmit:定价失效或参考图还在上传时点它只会在提交管线里被挡回来。 */} + {dialog.status === 'failed' && canSubmit ? ( + onSubmit(resolveSubmitDialog())} + > + 重试 + + ) : null} +
+ {!quote.ok ? ( + + {quote.reason} + + ) : null} + {isGenerating ? ( + + 生成中 + + ) : null} + {dialog.status === 'failed' && quote.ok ? ( + + {dialog.errorMessage} + + ) : null} +
+ ); +} diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts new file mode 100644 index 000000000..1fb0dd227 --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts @@ -0,0 +1,530 @@ +import { describe, expect, it } from 'vitest'; + +import { ApiClientError } from '../../../services/apiClient'; +import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient'; +import type { + CharacterReferenceImage, + GenerateDialogState, +} from '../ImageCanvasEditorTypes'; +import { + createModel3dGenerationDraftFields, + refreshModel3dAttemptNonce, +} from './Model3dGenerationFormModel'; +import { + buildModel3dSubmissionPlan, + MODEL3D_GENERATION_FAILURE_MESSAGE, + MODEL3D_GENERATION_TIMEOUT_MESSAGE, + MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE, + MODEL3D_TARGET_REQUIRED_MESSAGE, + MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE, + resolveModel3dGenerationErrorMessage, + resolveModel3dImageSource, + resolveModel3dRequestKey, + resolveModel3dResultTitle, +} from './Model3dGenerationSubmission'; + +const PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { 'v3.1-20260211': { noTexture: 100, texture: 200 } }, + 'image-to-model': { 'v3.1-20260211': { noTexture: 150, texture: 250 } }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +const MULTI_VERSION_PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { + 'v3.1-20260211': { noTexture: 100, texture: 200 }, + 'P2-20260801': { noTexture: 80, texture: 160 }, + }, + 'image-to-model': { + 'v3.1-20260211': { noTexture: 150, texture: 250 }, + 'P2-20260801': { noTexture: 170, texture: 270 }, + }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +const CANVAS_COMPLETION = { + dialogId: 'dialog-3d', + title: '3D 模型', + placeholder: { + x: 1, + y: 2, + width: 1024, + height: 1024, + originalWidth: 1024, + originalHeight: 1024, + }, +}; + +function createDialog( + mode: 'model3d-text-to-model' | 'model3d-image-to-model', + overrides: Partial = {}, +): GenerateDialogState { + return { + id: 'dialog-3d', + ...createModel3dGenerationDraftFields(mode), + ...overrides, + } as GenerateDialogState; +} + +function canvasReference( + overrides: Partial = {}, +): CharacterReferenceImage { + return { + id: 'reference-1', + src: '/generated-character-drafts/editor/ref.webp', + label: '参考图', + ...overrides, + }; +} + +describe('resolveModel3dImageSource', () => { + it('画布资源与素材库资产各收敛成契约允许的一种引用', () => { + expect( + resolveModel3dImageSource( + createDialog('model3d-image-to-model', { + generationReferences: [canvasReference({ resourceId: 'resource-1' })], + }), + ), + ).toEqual({ kind: 'resource', resourceId: 'resource-1' }); + expect( + resolveModel3dImageSource( + createDialog('model3d-image-to-model', { + generationReferences: [canvasReference({ sourceAssetId: 'asset-1' })], + }), + ), + ).toEqual({ kind: 'asset', assetId: 'asset-1' }); + }); + + it('还没登记上传的本地图片不构成 source,空白 id 同样不算', () => { + expect( + resolveModel3dImageSource( + createDialog('model3d-image-to-model', { + generationReferences: [canvasReference()], + }), + ), + ).toBeNull(); + expect( + resolveModel3dImageSource( + createDialog('model3d-image-to-model', { + generationReferences: [ + canvasReference({ resourceId: ' ', sourceAssetId: '' }), + ], + }), + ), + ).toBeNull(); + expect( + resolveModel3dImageSource(createDialog('model3d-image-to-model')), + ).toBeNull(); + }); +}); + +describe('buildModel3dSubmissionPlan', () => { + it('文生 3D 的请求体全量给出价格相关参数与落点,并同时落素材库', () => { + const result = buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-text-to-model', { prompt: ' 一把木椅 ' }), + projectId: 'project-1', + canvasCompletion: CANVAS_COMPLETION, + assetFolderId: 'project', + assetLabel: ' 一把木椅 ', + pricing: PRICING, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.plan.endpoint).toBe('text-to-model'); + expect(result.plan.body).toEqual({ + generation: { + model: 'v3.1-20260211', + prompt: '一把木椅', + texture: true, + textureQuality: 'standard', + pbr: false, + geometryQuality: 'standard', + quad: false, + smartLowPoly: false, + generateParts: false, + }, + projectId: 'project-1', + // 与其它画布生成工具同形:项目资源与素材库两个落点一起发,结果两边都落。 + assetFolderId: 'project', + assetLabel: '一把木椅', + canvasCompletion: CANVAS_COMPLETION, + }); + }); + + it('提交体跟着对话框选中的模型版本走,且端点与提交体一一配对', () => { + const textPlan = buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-text-to-model', { + prompt: '木椅', + model3dModel: 'P2-20260801', + }), + projectId: 'project-1', + pricing: MULTI_VERSION_PRICING, + }); + expect(textPlan.ok).toBe(true); + if (!textPlan.ok) { + return; + } + expect(textPlan.plan.endpoint).toBe('text-to-model'); + expect(textPlan.plan.body).toMatchObject({ + generation: { model: 'P2-20260801', prompt: '木椅' }, + }); + + const imagePlan = buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-image-to-model', { + model3dModel: 'P2-20260801', + generationReferences: [canvasReference({ resourceId: 'resource-1' })], + }), + projectId: 'project-1', + pricing: MULTI_VERSION_PRICING, + }); + expect(imagePlan.ok).toBe(true); + if (!imagePlan.ok) { + return; + } + expect(imagePlan.plan.endpoint).toBe('image-to-model'); + expect(imagePlan.plan.body).toMatchObject({ + source: { kind: 'resource', resourceId: 'resource-1' }, + generation: { model: 'P2-20260801' }, + }); + }); + + it('没有素材夹时只给 projectId:素材库落点与素材名都不进请求体', () => { + const result = buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-text-to-model', { prompt: '木椅' }), + projectId: 'project-1', + assetFolderId: ' ', + pricing: PRICING, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.plan.body).toEqual({ + generation: { + model: 'v3.1-20260211', + prompt: '木椅', + texture: true, + textureQuality: 'standard', + pbr: false, + geometryQuality: 'standard', + quad: false, + smartLowPoly: false, + generateParts: false, + }, + projectId: 'project-1', + }); + }); + + it('没有画布工程时只给素材夹:不带 projectId,也不带 canvasCompletion', () => { + const result = buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-text-to-model', { prompt: '木椅' }), + projectId: ' ', + canvasCompletion: CANVAS_COMPLETION, + assetFolderId: 'project', + pricing: PRICING, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.plan.body).not.toHaveProperty('projectId'); + expect(result.plan.body).not.toHaveProperty('canvasCompletion'); + expect(result.plan.body).toMatchObject({ + assetFolderId: 'project', + assetLabel: '木椅', + }); + }); + + it('素材夹落点只 trim,素材名缺省回落本次结果标题', () => { + const result = buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-text-to-model', { prompt: '木椅' }), + projectId: 'project-1', + assetFolderId: ' 个人素材夹 ', + pricing: PRICING, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.plan.body).toMatchObject({ + assetFolderId: '个人素材夹', + assetLabel: '木椅', + }); + }); + + it('图生 3D 带 source、不带提示词;纯几何档位不带 textureQuality', () => { + const result = buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-image-to-model', { + model3dTier: 'geometry-only', + model3dGenerateParts: true, + generationReferences: [canvasReference({ sourceAssetId: ' asset-1 ' })], + }), + projectId: 'project-1', + pricing: PRICING, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.plan.endpoint).toBe('image-to-model'); + expect(result.plan.body).toEqual({ + source: { kind: 'asset', assetId: 'asset-1' }, + generation: { + model: 'v3.1-20260211', + texture: false, + pbr: false, + geometryQuality: 'standard', + quad: false, + smartLowPoly: false, + generateParts: true, + }, + projectId: 'project-1', + }); + expect(result.plan.body).not.toHaveProperty('prompt'); + // 后端显式拒绝 texture=false + textureQuality 的组合,缺位是契约要求。 + expect( + result.plan.body.generation as Record, + ).not.toHaveProperty('textureQuality'); + }); + + it('缺落点、缺输入、缺定价都不组装请求', () => { + const textDialog = createDialog('model3d-text-to-model'); + expect( + buildModel3dSubmissionPlan({ + dialog: { ...textDialog, prompt: '木椅' }, + projectId: ' ', + assetFolderId: ' ', + pricing: PRICING, + }), + ).toEqual({ ok: false, message: MODEL3D_TARGET_REQUIRED_MESSAGE }); + expect( + buildModel3dSubmissionPlan({ + dialog: textDialog, + projectId: 'project-1', + pricing: PRICING, + }), + ).toEqual({ ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE }); + expect( + buildModel3dSubmissionPlan({ + dialog: createDialog('model3d-image-to-model'), + projectId: 'project-1', + pricing: PRICING, + }), + ).toEqual({ ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE }); + expect( + buildModel3dSubmissionPlan({ + dialog: { ...textDialog, prompt: '木椅' }, + projectId: 'project-1', + pricing: null, + }), + ).toEqual({ ok: false, message: '3D 模型定价尚未配置' }); + }); +}); + +describe('resolveModel3dResultTitle', () => { + it('文生用截断后的描述做标题,图生与空描述用统一标题', () => { + const longPrompt = '圆'.repeat(30); + expect( + resolveModel3dResultTitle( + createDialog('model3d-text-to-model', { prompt: longPrompt }), + ), + ).toBe('圆'.repeat(24)); + expect( + resolveModel3dResultTitle(createDialog('model3d-text-to-model')), + ).toBe('3D 模型'); + expect( + resolveModel3dResultTitle( + createDialog('model3d-image-to-model', { prompt: '木椅' }), + ), + ).toBe('3D 模型'); + }); +}); + +describe('Idempotency-Key', () => { + const buildKey = (dialog: GenerateDialogState) => { + const result = buildModel3dSubmissionPlan({ + dialog, + projectId: 'project-1', + pricing: PRICING, + }); + if (!result.ok) { + throw new Error('用例里的请求必须可提交'); + } + return result.plan.requestKey; + }; + + it('同一份内容重复提交继续用同一个键,双击不会开出两个 operation', () => { + const dialog = createDialog('model3d-text-to-model', { prompt: '木椅' }); + expect(buildKey(dialog)).toBe(buildKey(dialog)); + }); + + it('键值可以直接当请求头用:可打印 ASCII 且不超长', () => { + const key = buildKey( + createDialog('model3d-text-to-model', { prompt: '木椅' }), + ); + expect(key).toMatch(/^[\x21-\x7e]+$/); + expect(key.length).toBeLessThanOrEqual(128); + }); + + it('内容一变就换键:改提示词、换参考图、换落点都不复用旧键', () => { + const dialog = createDialog('model3d-text-to-model', { prompt: '木椅' }); + const original = buildKey(dialog); + expect(buildKey({ ...dialog, prompt: '木凳' })).not.toBe(original); + expect( + buildKey({ ...dialog, model3dAttemptNonce: 'model3d-next' }), + ).not.toBe(original); + expect( + buildKey({ + ...createDialog('model3d-image-to-model', { + generationReferences: [canvasReference({ resourceId: 'resource-1' })], + }), + }), + ).not.toBe( + buildKey({ + ...createDialog('model3d-image-to-model', { + generationReferences: [canvasReference({ resourceId: 'resource-2' })], + }), + }), + ); + }); + + it('同一个代次下换参考图也会换键——参考图是内容的一部分', () => { + const shared = { + id: 'dialog-3d', + ...createModel3dGenerationDraftFields('model3d-image-to-model'), + model3dAttemptNonce: 'model3d-fixed', + } as GenerateDialogState; + const before = buildKey({ + ...shared, + generationReferences: [canvasReference({ resourceId: 'resource-1' })], + }); + const after = buildKey({ + ...shared, + generationReferences: [canvasReference({ resourceId: 'resource-2' })], + }); + expect(after).not.toBe(before); + expect(after.startsWith('model3d-fixed-')).toBe(true); + expect(before.startsWith('model3d-fixed-')).toBe(true); + }); + + it('用户主动重试换新一代,因此换键', () => { + const dialog = createDialog('model3d-text-to-model', { + prompt: '木椅', + status: 'failed', + errorMessage: '3D 模型生成失败,请稍后重试。', + }); + const retried = refreshModel3dAttemptNonce(dialog); + expect(buildKey(retried)).not.toBe(buildKey(dialog)); + }); + + it('没有代次(老会话)时现场铸造,键仍然可用', () => { + const key = buildKey({ + ...createDialog('model3d-text-to-model', { prompt: '木椅' }), + model3dAttemptNonce: undefined, + }); + expect(key.startsWith('model3d-')).toBe(true); + expect( + resolveModel3dRequestKey({ + attemptNonce: null, + endpoint: 'text-to-model', + body: { + generation: { model: 'v3.1-20260211', prompt: '木椅' }, + projectId: 'project-1', + }, + }), + ).toMatch(/^[\x21-\x7e]{1,128}$/); + }); + + it('字段顺序不同但内容相同的请求得到同一个指纹', () => { + const left = resolveModel3dRequestKey({ + attemptNonce: 'model3d-fixed', + endpoint: 'text-to-model', + body: { + generation: { texture: true, model: 'v3.1-20260211', prompt: '木椅' }, + projectId: 'project-1', + }, + }); + const right = resolveModel3dRequestKey({ + attemptNonce: 'model3d-fixed', + endpoint: 'text-to-model', + body: { + generation: { model: 'v3.1-20260211', texture: true, prompt: '木椅' }, + projectId: 'project-1', + }, + }); + expect(right).toBe(left); + }); +}); + +describe('resolveModel3dGenerationErrorMessage', () => { + it('服务端给的文案原样透出', () => { + expect( + resolveModel3dGenerationErrorMessage( + new ApiClientError({ + message: '3D 生成服务当前限流,请稍后重试。', + status: 429, + code: 'tripo-api-rate-limited', + }), + ), + ).toBe('3D 生成服务当前限流,请稍后重试。'); + expect(resolveModel3dGenerationErrorMessage('落点不可用')).toBe( + '落点不可用', + ); + }); + + it('非服务端来源的错误不把技术文案透给用户', () => { + // 2xx 但 body 不是 JSON 时 requestJson 抛的是原生 SyntaxError。 + expect( + resolveModel3dGenerationErrorMessage( + new SyntaxError('Unexpected token < in JSON at position 0'), + ), + ).toBe(MODEL3D_GENERATION_FAILURE_MESSAGE); + expect( + resolveModel3dGenerationErrorMessage( + new TypeError("Cannot read properties of undefined (reading 'ok')"), + ), + ).toBe(MODEL3D_GENERATION_FAILURE_MESSAGE); + }); + + it('传输层英文技术文案换成中文兜底', () => { + expect( + resolveModel3dGenerationErrorMessage(new TypeError('Failed to fetch')), + ).toBe(MODEL3D_GENERATION_FAILURE_MESSAGE); + expect( + resolveModel3dGenerationErrorMessage( + new DOMException('The operation was aborted.', 'AbortError'), + ), + ).toBe(MODEL3D_GENERATION_FAILURE_MESSAGE); + expect(resolveModel3dGenerationErrorMessage(null)).toBe( + MODEL3D_GENERATION_FAILURE_MESSAGE, + ); + }); + + it('超时单独给出可重试的提示', () => { + const timeout = new Error('请求超时:60000ms'); + timeout.name = 'TimeoutError'; + + expect(resolveModel3dGenerationErrorMessage(timeout)).toBe( + MODEL3D_GENERATION_TIMEOUT_MESSAGE, + ); + }); +}); diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts new file mode 100644 index 000000000..6f411f79a --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts @@ -0,0 +1,332 @@ +import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration'; +import type { + Model3dGenerationSource, + Model3dImageToModelRequest, + Model3dTextToModelRequest, +} from '../../../../packages/shared/src/contracts/model3d'; +import { + ApiClientError, + isAbortError, + isTimeoutError, +} from '../../../services/apiClient'; +import type { + EditorCanvasGenerationCompletionInput, + Model3dGenerationSubmissionResponse, + Model3dPricingEndpoint, +} from '../../../services/image-editor/editorProjectClient'; +import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient'; +import type { GenerateDialogState } from '../ImageCanvasEditorTypes'; +import { + isModel3dGenerationMode, + mintModel3dAttemptNonce, + MODEL3D_MODE_UNSUPPORTED_REASON, + resolveModel3dGenerationParams, + resolveModel3dGenerationQuote, + resolveModel3dModelVersion, +} from './Model3dGenerationFormModel'; + +export const MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE = '请填写 3D 模型描述'; +export const MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE = + '请先选择一张画布图片或素材库图片'; +export const MODEL3D_TARGET_REQUIRED_MESSAGE = + '请先保存当前画布工程或打开素材库,再生成 3D 模型'; +export const MODEL3D_GENERATION_FAILURE_MESSAGE = + '生成 3D 模型失败,请稍后重试。'; +export const MODEL3D_GENERATION_TIMEOUT_MESSAGE = + '3D 生成请求超时,请稍后重试。'; +export const MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE = + '3D 生成任务已提交,但服务端没有返回任务状态,请稍后重试。'; +export const MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE = + '3D 生成任务已提交,但画布没能接收任务状态,请刷新页面查看结果。'; + +/** + * 提交计划按端点做成判别联合:`endpoint` 同时决定提交地址与提交体契约。 + * + * 拆成「endpoint: 联合 + body: 联合」两个独立字段时,图生请求体配文生端点同样能通过 + * 编译,错误只能等远端按字段报出来。判别联合让调用方分支后拿到确定类型的提交体。 + */ +type Model3dSubmissionPlanBase = { + /** + * 本次提交使用的尝试代次;调用方必须把它写回 dialog 状态。 + * + * 老快照恢复出来的 dialog 可能没有代次,这里兜底铸一个;如果调用方不写回, + * 每次提交都会换键,双击去重与重复计费保护就静默失效。 + */ + attemptNonce: string; + requestKey: string; +}; + +export type Model3dSubmissionPlan = + | (Model3dSubmissionPlanBase & { + endpoint: Extract; + /** 提交体就是端点契约本身:形状错误在编译期暴露,而不是等服务端拒绝。 */ + body: Model3dTextToModelRequest; + }) + | (Model3dSubmissionPlanBase & { + endpoint: Extract; + body: Model3dImageToModelRequest; + }); + +export function resolveModel3dResultTitle(dialog: GenerateDialogState): string { + const prompt = dialog.prompt.trim(); + if (dialog.mode === 'model3d-text-to-model' && prompt) { + return prompt.slice(0, 24); + } + return '3D 模型'; +} + +/** + * 图生 3D 只接受站内的画布资源或素材库资产:本地上传必须先经既有上传登记链路 + * 拿到 resourceId / assetId,才可能构成一个合法 `source`。 + */ +export function resolveModel3dImageSource( + dialog: GenerateDialogState, +): Model3dGenerationSource | null { + const reference = dialog.generationReferences?.[0]; + if (!reference) { + return null; + } + const resourceId = reference.resourceId?.trim(); + if (resourceId) { + return { kind: 'resource', resourceId }; + } + const assetId = reference.sourceAssetId?.trim(); + if (assetId) { + return { kind: 'asset', assetId }; + } + return null; +} + +export function buildModel3dSubmissionPlan({ + dialog, + projectId, + canvasCompletion, + assetFolderId, + assetLabel, + pricing, +}: { + dialog: GenerateDialogState; + projectId?: string | null; + canvasCompletion?: EditorCanvasGenerationCompletionInput | null; + /** + * 素材库落点:与其它画布生成工具一样,画布链路会把当前素材夹一起发出去, + * 结果因此同时落画布与素材库;没有画布工程时它是唯一落点。缺省或空串表示本次不落素材库。 + */ + assetFolderId?: string | null; + /** 素材库里的展示名;缺省用本次结果的标题。 */ + assetLabel?: string | null; + pricing?: Model3dPricingConfig | null; +}): { ok: true; plan: Model3dSubmissionPlan } | { ok: false; message: string } { + if (!isModel3dGenerationMode(dialog.mode)) { + return { ok: false, message: MODEL3D_MODE_UNSUPPORTED_REASON }; + } + // 与其它画布生成工具同一条门禁:落点至少给一个(画布工程或素材夹)。 + // 没有画布工程时仍然可以生成,结果只落素材库 —— 3D 不再要求「必须先保存工程」。 + const trimmedProjectId = projectId?.trim(); + const trimmedAssetFolderId = assetFolderId?.trim(); + if (!trimmedProjectId && !trimmedAssetFolderId) { + return { ok: false, message: MODEL3D_TARGET_REQUIRED_MESSAGE }; + } + const quote = resolveModel3dGenerationQuote(dialog, pricing); + if (!quote.ok) { + return { ok: false, message: quote.reason }; + } + const model = resolveModel3dModelVersion(dialog.model3dModel); + const paramsResult = resolveModel3dGenerationParams({ + model, + tier: dialog.model3dTier ?? 'textured-standard', + quad: dialog.model3dQuad ?? false, + smartLowPoly: dialog.model3dSmartLowPoly ?? false, + generateParts: dialog.model3dGenerateParts ?? false, + }); + if (!paramsResult.ok) { + return { ok: false, message: paramsResult.reason }; + } + const params = paramsResult.params; + const attemptNonce = resolveModel3dAttemptNonce(dialog.model3dAttemptNonce); + // 价格相关参数永远全量给出:provider 的隐式默认值会直接改变价格。 + // 两个例外都来自 provider 预检(`platform-tripo::validate_generation_options`): + // texture=false 档位不能带 textureQuality,v2.5 拒绝任何 textureQuality —— + // 两者都按「不发该字段」处理,后端缺省按 standard 计价,与面板报价同一档。 + const generation = { + model, + texture: params.texture, + pbr: params.pbr, + ...(params.textureQuality ? { textureQuality: params.textureQuality } : {}), + geometryQuality: params.geometryQuality, + quad: params.quad, + smartLowPoly: params.smartLowPoly, + generateParts: params.generateParts, + }; + // 落点与其它生成接口同形:平坦的可选字段,不再包一层 tagged enum。 + // 服务端按「至少一个落点」校验,项目资源与素材库可以同时给 —— 其它画布生成工具 + // 就是「projectId + assetFolderId + assetLabel」一起发,结果两边都落;3D 跟随同一口径, + // 否则同一张画布生成出来的东西,只有 3D 不会出现在素材库。 + const placement = { + ...(trimmedProjectId ? { projectId: trimmedProjectId } : {}), + ...(trimmedAssetFolderId + ? { + assetFolderId: trimmedAssetFolderId, + assetLabel: assetLabel?.trim() || resolveModel3dResultTitle(dialog), + } + : {}), + // 画布占位框是项目资源落点的回填载荷:没有工程就没有画布可以回填。 + ...(trimmedProjectId && canvasCompletion ? { canvasCompletion } : {}), + }; + // 一个端点一个收口函数:端点字面量与提交体类型在同一处配对,判别联合据此收窄。 + const finishTextToModel = ( + body: Model3dTextToModelRequest, + ): { ok: true; plan: Model3dSubmissionPlan } => ({ + ok: true, + plan: { + endpoint: 'text-to-model', + attemptNonce, + requestKey: resolveModel3dRequestKey({ + attemptNonce, + endpoint: 'text-to-model', + body, + }), + body, + }, + }); + const finishImageToModel = ( + body: Model3dImageToModelRequest, + ): { ok: true; plan: Model3dSubmissionPlan } => ({ + ok: true, + plan: { + endpoint: 'image-to-model', + attemptNonce, + requestKey: resolveModel3dRequestKey({ + attemptNonce, + endpoint: 'image-to-model', + body, + }), + body, + }, + }); + + if (dialog.mode === 'model3d-image-to-model') { + const source = resolveModel3dImageSource(dialog); + if (!source) { + return { ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE }; + } + return finishImageToModel({ source, generation, ...placement }); + } + + // 契约以后新增端点时在这里显式失败:否则它会静默走成文生请求,丢掉新端点的输入。 + if (dialog.mode !== 'model3d-text-to-model') { + return { ok: false, message: MODEL3D_MODE_UNSUPPORTED_REASON }; + } + const prompt = dialog.prompt.trim(); + if (!prompt) { + return { ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE }; + } + return finishTextToModel({ + generation: { ...generation, prompt }, + ...placement, + }); +} + +/** + * 幂等键 = 尝试代次 + 请求内容指纹。 + * + * 服务端按「键 + payload 指纹」dedupe:同键同请求返回原 operation,同键不同请求 409。 + * 因此键必须跟着内容走,而会改内容的入口散落在好几处通用函数里(换参考图、换上传图、 + * 换项目素材都发生在 `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel`)。 + * 把内容指纹并进键里,这些入口就不需要各自记得重铸键:内容一变,键必变; + * 而同一份内容重复提交(双击)仍会命中同一个 operation。 + */ +export function resolveModel3dRequestKey({ + attemptNonce, + endpoint, + body, +}: { + attemptNonce?: string | null; + endpoint: Model3dPricingEndpoint; + body: Model3dTextToModelRequest | Model3dImageToModelRequest; +}): string { + const nonce = resolveModel3dAttemptNonce(attemptNonce); + return `${nonce}-${fingerprintModel3dRequest(endpoint, body)}`; +} + +/** 取本次提交使用的代次:已有代次直接用,缺失(例如老快照)才现场铸造。 */ +export function resolveModel3dAttemptNonce( + attemptNonce?: string | null, +): string { + return attemptNonce?.trim() || mintModel3dAttemptNonce(); +} + +/** 键排序只求稳定,不承载任何业务语义:同一份内容必须算出同一个键。 */ +function compareModel3dRequestEntryKeys( + [left]: [string, unknown], + [right]: [string, unknown], +) { + if (left < right) { + return -1; + } + return left > right ? 1 : 0; +} + +function canonicalizeModel3dRequestValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeModel3dRequestValue); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(compareModel3dRequestEntryKeys) + .map(([key, entry]) => [key, canonicalizeModel3dRequestValue(entry)]), + ); + } + return value; +} + +/** FNV-1a 64 位,只用来给请求内容定址,不承担任何安全语义。 */ +function fingerprintModel3dRequest( + endpoint: Model3dPricingEndpoint, + body: Record, +) { + const text = `${endpoint}\n${JSON.stringify( + canonicalizeModel3dRequestValue(body), + )}`; + let hash = 0xcbf29ce484222325n; + for (let index = 0; index < text.length; index += 1) { + hash ^= BigInt(text.charCodeAt(index)); + hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn; + } + return hash.toString(16).padStart(16, '0'); +} + +export function model3dQueueStateFromResponse( + response: Model3dGenerationSubmissionResponse | null | undefined, +): ExternalGenerationJobStatusRecord | null { + return response?.status ?? null; +} + +export function resolveModel3dGenerationErrorMessage(error: unknown): string { + if (isTimeoutError(error)) { + return MODEL3D_GENERATION_TIMEOUT_MESSAGE; + } + // 传输层失败的 message 是英文技术文案(`Failed to fetch` / `The operation was + // aborted.`),不能直接当用户提示;只有服务端给出的 message 才是面向用户的。 + if (isAbortError(error) || isFetchFailureError(error)) { + return MODEL3D_GENERATION_FAILURE_MESSAGE; + } + // 只有 ApiClientError 的 message 是按服务端响应拼出来的;`JSON.parse` 失败的 SyntaxError + // 或其它运行时 TypeError 的 message 是英文技术文案,同样不能当用户提示。 + if (error instanceof ApiClientError && error.message.trim()) { + return error.message.trim(); + } + if (typeof error === 'string' && error.trim()) { + return error.trim(); + } + return MODEL3D_GENERATION_FAILURE_MESSAGE; +} + +/** fetch 的网络层失败统一是 `TypeError: Failed to fetch`。 */ +function isFetchFailureError(error: unknown) { + return ( + error instanceof TypeError && /failed to fetch|network/i.test(error.message) + ); +} diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationValidation.test.ts b/src/components/image-editor/model3d-generation/Model3dGenerationValidation.test.ts new file mode 100644 index 000000000..42c80b5f6 --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationValidation.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from 'vitest'; + +import type { Model3dModelVersion } from '../../../../packages/shared/src/contracts/model3d'; +import { + MODEL3D_MODEL_VERSIONS, + MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED, + MODEL3D_REASON_GENERATE_PARTS_CONFLICTS_WITH_GEOMETRY_MODES, + MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE, + MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED, + MODEL3D_REASON_QUAD_UNSUPPORTED, + MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED, + MODEL3D_REASON_TEXTURE_QUALITY_UNSUPPORTED, + type Model3dGenerationOptions, + resolveModel3dModelCapabilities, + resolveModel3dModelFamily, + resolveModel3dModelLabel, + validateModel3dGenerationOptions, + validateModel3dPricingParams, + validateModel3dRequestParams, +} from './Model3dGenerationValidation'; + +const H31: Model3dModelVersion = 'v3.1-20260211'; +const H30: Model3dModelVersion = 'v3.0-20250812'; +const H25: Model3dModelVersion = 'v2.5-20250123'; +const P1: Model3dModelVersion = 'P1-20260311'; +const P2: Model3dModelVersion = 'P2-20260801'; + +/** 只给必填与平台必显式项:任何版本都应放行(对齐 provider 的 minimal_options 用例)。 */ +function minimalOptions(model: Model3dModelVersion): Model3dGenerationOptions { + return { + model, + texture: false, + pbr: false, + quad: false, + smartLowPoly: false, + generateParts: false, + }; +} + +describe('模型版本表', () => { + it('覆盖契约声明的 5 个版本,标签取版本族', () => { + expect(MODEL3D_MODEL_VERSIONS).toEqual([H31, H30, H25, P1, P2]); + expect(MODEL3D_MODEL_VERSIONS.map(resolveModel3dModelLabel)).toEqual([ + 'v3.1', + 'v3.0', + 'v2.5', + 'P1', + 'P2', + ]); + }); + + it('能力面按 provider 的模型族分档', () => { + expect(resolveModel3dModelCapabilities(H31)).toEqual({ + family: resolveModel3dModelFamily(H31), + detailedTextureTier: true, + quad: true, + smartLowPoly: true, + generateParts: true, + }); + expect(resolveModel3dModelCapabilities(H25)).toMatchObject({ + detailedTextureTier: false, + quad: false, + smartLowPoly: false, + generateParts: false, + }); + // P1 只能标准贴图 / 纯几何;P2 多一个方形布线,仍不支持智能低模与分件。 + expect(resolveModel3dModelCapabilities(P1)).toMatchObject({ + quad: false, + smartLowPoly: false, + generateParts: false, + }); + expect(resolveModel3dModelCapabilities(P2)).toMatchObject({ + quad: true, + smartLowPoly: false, + generateParts: false, + }); + }); +}); + +describe('validateModel3dGenerationOptions', () => { + it('最简参数对每个版本都放行', () => { + for (const model of MODEL3D_MODEL_VERSIONS) { + expect( + validateModel3dGenerationOptions(minimalOptions(model)), + ).toBeNull(); + } + }); + + it('贴图档位只有 v2.5 被 provider 拒绝,其余版本放行', () => { + expect( + validateModel3dGenerationOptions({ + ...minimalOptions(H25), + texture: true, + textureQuality: 'standard', + }), + ).toEqual({ + field: 'textureQuality', + reason: MODEL3D_REASON_TEXTURE_QUALITY_UNSUPPORTED, + }); + expect( + validateModel3dGenerationOptions({ + ...minimalOptions(H30), + texture: true, + textureQuality: 'standard', + }), + ).toBeNull(); + }); + + it('高清几何只 v3.x 支持,其余版本被拒', () => { + for (const model of [H31, H30]) { + expect( + validateModel3dGenerationOptions({ + ...minimalOptions(model), + texture: true, + geometryQuality: 'detailed', + }), + ).toBeNull(); + } + for (const model of [H25, P1, P2]) { + expect( + validateModel3dGenerationOptions({ + ...minimalOptions(model), + geometryQuality: 'detailed', + }), + ).toEqual({ + field: 'geometryQuality', + reason: MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED, + }); + } + }); + + it('方形布线只 v3.x 与 P2 支持', () => { + for (const model of [H31, H30, P2]) { + expect( + validateModel3dGenerationOptions({ + ...minimalOptions(model), + quad: true, + }), + ).toBeNull(); + } + for (const model of [H25, P1]) { + expect( + validateModel3dGenerationOptions({ + ...minimalOptions(model), + quad: true, + }), + ).toEqual({ field: 'quad', reason: MODEL3D_REASON_QUAD_UNSUPPORTED }); + } + }); + + it('智能低多边形与分件只 v3.x 支持', () => { + expect( + validateModel3dGenerationOptions({ + ...minimalOptions(P2), + smartLowPoly: true, + }), + ).toEqual({ + field: 'smartLowPoly', + reason: MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED, + }); + expect( + validateModel3dGenerationOptions({ + ...minimalOptions(P2), + generateParts: true, + }), + ).toEqual({ + field: 'generateParts', + reason: MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED, + }); + }); + + it('分件的两个前置条件分开报:贴图 / pbr / 几何模式各自有原因', () => { + expect( + validateModel3dGenerationOptions({ + ...minimalOptions(H31), + texture: true, + pbr: false, + generateParts: true, + }), + ).toEqual({ + field: 'generateParts', + reason: MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE, + }); + expect( + validateModel3dGenerationOptions({ + ...minimalOptions(H31), + generateParts: true, + // 平台口径要求 texture=false 时 pbr 显式 false,这里只验证分件与几何模式的冲突。 + quad: true, + }), + ).toEqual({ + field: 'generateParts', + reason: MODEL3D_REASON_GENERATE_PARTS_CONFLICTS_WITH_GEOMETRY_MODES, + }); + }); + + it('面数上限按版本与几何模式给出边界', () => { + const within = ( + model: Model3dModelVersion, + faceLimit: number, + extra: Partial = {}, + ) => + validateModel3dGenerationOptions({ + ...minimalOptions(model), + faceLimit, + ...extra, + }); + + expect(within(H31, 1_500_000)).toBeNull(); + expect(within(H31, 1_500_001)).not.toBeNull(); + expect(within(H30, 1_000_000)).toBeNull(); + expect(within(H30, 1_000_001)).not.toBeNull(); + expect(within(H25, 500_000)).toBeNull(); + expect(within(H25, 500_001)).not.toBeNull(); + expect(within(P1, 49)).not.toBeNull(); + expect(within(P1, 50)).toBeNull(); + expect(within(P1, 20_000)).toBeNull(); + expect(within(P2, 25_000, { quad: true })).toBeNull(); + expect(within(P2, 25_001, { quad: true })).not.toBeNull(); + // 智能低模把上限锁到 20k(叠 quad 时 10k)。 + expect(within(H31, 20_000, { smartLowPoly: true })).toBeNull(); + expect(within(H31, 20_001, { smartLowPoly: true })).not.toBeNull(); + expect(within(H31, 10_000, { smartLowPoly: true, quad: true })).toBeNull(); + }); +}); + +describe('平台口径与合并校验', () => { + it('会改价的参数必须显式给出', () => { + expect( + validateModel3dPricingParams({ + model: H31, + quad: false, + smartLowPoly: false, + generateParts: false, + }), + ).toEqual({ field: 'texture', reason: '必须显式给出是否生成贴图' }); + expect( + validateModel3dPricingParams({ + model: H31, + texture: false, + pbr: false, + quad: false, + smartLowPoly: false, + }), + ).toEqual({ field: 'generateParts', reason: '必须显式给出是否生成分件' }); + }); + + it('texture=false 时不允许贴图档位、pbr 必须显式 false', () => { + expect( + validateModel3dPricingParams({ + ...minimalOptions(H31), + textureQuality: 'standard', + }), + ).toEqual({ + field: 'textureQuality', + reason: 'texture=false 时不允许出现贴图档位', + }); + expect( + validateModel3dPricingParams({ ...minimalOptions(H31), pbr: true }), + ).toEqual({ + field: 'pbr', + reason: 'texture=false 时 pbr 必须显式给 false', + }); + }); + + it('合并校验按后端顺序:平台口径在前,provider 能力在后', () => { + expect( + validateModel3dRequestParams({ + model: H25, + texture: true, + pbr: false, + quad: false, + smartLowPoly: false, + generateParts: false, + textureQuality: 'standard', + geometryQuality: 'detailed', + }), + ).toEqual({ + field: 'textureQuality', + reason: MODEL3D_REASON_TEXTURE_QUALITY_UNSUPPORTED, + }); + }); +}); diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationValidation.ts b/src/components/image-editor/model3d-generation/Model3dGenerationValidation.ts new file mode 100644 index 000000000..d7f5b2c9f --- /dev/null +++ b/src/components/image-editor/model3d-generation/Model3dGenerationValidation.ts @@ -0,0 +1,334 @@ +import type { + Model3dCompression, + Model3dGeometryQuality, + Model3dModelVersion, + Model3dTextureQuality, + Model3dTextureVersion, +} from '../../../../packages/shared/src/contracts/model3d'; + +/** + * 面板参数的唯一校验实现,逐条对齐后端两份规则: + * + * 1. `platform-tripo::common::validation::validate_generation_options`:模型族与参数取值的 + * 能力组合(provider 会拒的组合在这里先被挡下); + * 2. `api-server::tripo3d::validation::PricingParamView::validate`:平台口径 —— 会改价的 + * 参数必须显式给出、贴图与 pbr 不能互相矛盾。 + * + * 为什么要抄一份:契约与公开读模型只暴露「价格」,不暴露「哪些版本可选、每个版本支持 + * 哪些参数」,面板要在用户点提交前就把会被 provider 拒的组合变成禁用态与原因文案。 + * 抄的是**规则**,不是数值:判据只看模型族与参数取值,`Model3dGenerationFormModel.test.ts` + * 用组合表把两边钉在一起。规则冲突时以 provider 为准(失败关闭),不是以本文件为准。 + * + * 不抄的部分:wire 层收敛(不支持 `geometryQuality` 的族把 `standard` 收敛成不发、 + * `textureQuality` 缺席时的默认档)由 `platform-tripo` 独家负责,前端的职责只是给出 + * 契约层合法的参数。 + */ + +/** provider 的模型族(对齐 `platform-tripo::common::validation::ModelFamily`)。 */ +export type Model3dModelFamily = 'v31' | 'v30' | 'v25' | 'p1' | 'p2'; + +/** + * 版本表是唯一一份前端模型元数据,按契约 `Model3dModelVersion` 的声明顺序书写。 + * + * 类型是 `Record`:契约新增模型版本后,`npm run + * contracts:model3d:generate` 更新 TS 联合类型,这里会直接编译失败,逼迫补齐标签与族, + * 而不是等到运行期才发现面板少一个版本。运行期需要「版本列表」时用 `Object.keys`。 + */ +export const MODEL3D_MODEL_TABLE: Record< + Model3dModelVersion, + { label: string; family: Model3dModelFamily } +> = { + 'v3.1-20260211': { label: 'v3.1', family: 'v31' }, + 'v3.0-20250812': { label: 'v3.0', family: 'v30' }, + 'v2.5-20250123': { label: 'v2.5', family: 'v25' }, + 'P1-20260311': { label: 'P1', family: 'p1' }, + 'P2-20260801': { label: 'P2', family: 'p2' }, +}; + +/** 契约支持的模型版本(运行期列表,顺序即声明顺序)。 */ +export const MODEL3D_MODEL_VERSIONS = Object.keys( + MODEL3D_MODEL_TABLE, +) as Model3dModelVersion[]; + +export function isModel3dModelVersion( + value: string, +): value is Model3dModelVersion { + // 用自有属性判断而不是 `in`:`in` 会把 `toString` / `__proto__` 这类原型链上的键也算命中, + // 之后按版本查表会拿到 undefined。 + return Object.hasOwn(MODEL3D_MODEL_TABLE, value); +} + +export function resolveModel3dModelLabel(model: Model3dModelVersion): string { + return MODEL3D_MODEL_TABLE[model].label; +} + +export function resolveModel3dModelFamily( + model: Model3dModelVersion, +): Model3dModelFamily { + return MODEL3D_MODEL_TABLE[model].family; +} + +/** v3.1 / v3.0:`geometry_quality` 与 `compress` 只在这两个族上存在。 */ +function isV3Family(family: Model3dModelFamily) { + return family === 'v31' || family === 'v30'; +} + +/** + * 面板要用的能力面:只描述「哪个控件该禁用」,规则本身仍只写在下面的校验函数里。 + */ +export type Model3dModelCapabilities = { + family: Model3dModelFamily; + /** 高清贴图档位:需要 detailed 贴图 + detailed 几何,只有 v3.x 同时满足。 */ + detailedTextureTier: boolean; + quad: boolean; + smartLowPoly: boolean; + generateParts: boolean; +}; + +export function resolveModel3dModelCapabilities( + model: Model3dModelVersion, +): Model3dModelCapabilities { + const family = resolveModel3dModelFamily(model); + return { + family, + detailedTextureTier: isV3Family(family), + quad: isV3Family(family) || family === 'p2', + smartLowPoly: isV3Family(family), + generateParts: isV3Family(family), + }; +} + +/** 与后端 `v3_model_labels()` 同口径:文案里的 v3.x 标签取自版本表,不手写第二份版本号。 */ +const V3_MODEL_LABELS = `${MODEL3D_MODEL_TABLE['v3.1-20260211'].label} / ${MODEL3D_MODEL_TABLE['v3.0-20250812'].label}`; +const P2_MODEL_LABEL = MODEL3D_MODEL_TABLE['P2-20260801'].label; +const V25_MODEL_LABEL = MODEL3D_MODEL_TABLE['v2.5-20250123'].label; + +/** 被拒原因文案:面板与禁用态共用同一份常量,不再各自抄一份中文。 */ +export const MODEL3D_REASON_TEXTURE_QUALITY_UNSUPPORTED = `${V25_MODEL_LABEL} 不支持贴图档位,请改用其它版本或纯几何档位`; +export const MODEL3D_REASON_FAST_TEXTURE_REQUIRES_V35 = + '快速贴图档位必须同时指定贴图版本 v3.5'; +export const MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED = `高清几何只支持 ${V3_MODEL_LABELS}`; +export const MODEL3D_REASON_COMPRESS_UNSUPPORTED = `几何压缩只支持 ${V3_MODEL_LABELS}`; +export const MODEL3D_REASON_AUTO_SIZE_UNSUPPORTED = `${V25_MODEL_LABEL} 不支持自动尺寸`; +export const MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED = `智能低多边形只支持 ${V3_MODEL_LABELS}`; +export const MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED = `分件只支持 ${V3_MODEL_LABELS}`; +export const MODEL3D_REASON_QUAD_UNSUPPORTED = `方形布线只支持 ${V3_MODEL_LABELS} / ${P2_MODEL_LABEL}`; +export const MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE = + '分件生成只支持纯几何档位'; +export const MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_PBR = + '分件生成要求 pbr 显式关闭'; +export const MODEL3D_REASON_GENERATE_PARTS_CONFLICTS_WITH_GEOMETRY_MODES = + '分件生成不能与方形布线或智能低多边形同时使用'; +export const MODEL3D_REASON_FACE_LIMIT_OUT_OF_RANGE = + '面数上限超出当前版本与参数的取值范围'; + +/** + * 生成参数视图:字段与 `platform-tripo::common::validation::TripoGenerationOptions` 一一对应, + * 校验只看这些字段。未在本面板暴露的参数(`faceLimit` / `compress` 等)照样参与校验: + * 老快照或将来新入口带出来的取值也必须过同一套规则。 + */ +export type Model3dGenerationOptions = { + model: Model3dModelVersion; + texture?: boolean | null; + pbr?: boolean | null; + textureQuality?: Model3dTextureQuality | null; + textureVersion?: Model3dTextureVersion | null; + geometryQuality?: Model3dGeometryQuality | null; + faceLimit?: number | null; + autoSize?: boolean | null; + quad?: boolean | null; + smartLowPoly?: boolean | null; + generateParts?: boolean | null; + compress?: Model3dCompression | null; +}; + +/** 校验失败:`field` 用契约请求里的字段名,便于直接定位到面板上的控件。 */ +export type Model3dGenerationFailure = { + field: string; + reason: string; +}; + +const FAST_TEXTURE_VERSION: Model3dTextureVersion = 'v3.5-20260815'; + +/** 面数上限边界,与 `platform-tripo::common::validation::face_limit_bounds` 逐条对齐。 */ +function resolveModel3dFaceLimitBounds(options: Model3dGenerationOptions): { + min: number; + max: number; +} { + if (options.smartLowPoly === true) { + return options.quad === true + ? { min: 500, max: 10_000 } + : { min: 500, max: 20_000 }; + } + switch (resolveModel3dModelFamily(options.model)) { + case 'v25': + return { min: 1, max: 500_000 }; + case 'v30': + case 'v31': { + const max = resolveModel3dV3FaceLimitMax(options); + return { min: 1, max }; + } + case 'p1': + return { min: 50, max: 20_000 }; + case 'p2': + return options.quad === true + ? { min: 48, max: 25_000 } + : { min: 48, max: 50_000 }; + } +} + +/** v3.1 的默认上限比 v3.0 高一档,其余分叉相同(quad / detailed 几何各压一档)。 */ +function resolveModel3dV3FaceLimitMax(options: Model3dGenerationOptions) { + if (options.quad === true) { + return 150_000; + } + if (options.geometryQuality === 'detailed') { + return 2_000_000; + } + return options.model === 'v3.0-20250812' ? 1_000_000 : 1_500_000; +} + +/** + * 按 provider 的顺序返回**第一条**失败;顺序与后端一致,因此同一组参数在两边得到同一个 + * 被拒字段与同一条规则,用例可以逐条对照。 + */ +export function validateModel3dGenerationOptions( + options: Model3dGenerationOptions, +): Model3dGenerationFailure | null { + const family = resolveModel3dModelFamily(options.model); + + if (options.textureQuality != null && family === 'v25') { + return { + field: 'textureQuality', + reason: MODEL3D_REASON_TEXTURE_QUALITY_UNSUPPORTED, + }; + } + + if ( + options.textureQuality === 'fast' && + options.textureVersion !== FAST_TEXTURE_VERSION + ) { + return { + field: 'textureQuality', + reason: MODEL3D_REASON_FAST_TEXTURE_REQUIRES_V35, + }; + } + + if (options.geometryQuality === 'detailed' && !isV3Family(family)) { + return { + field: 'geometryQuality', + reason: MODEL3D_REASON_DETAILED_GEOMETRY_UNSUPPORTED, + }; + } + + if (options.compress != null && !isV3Family(family)) { + return { + field: 'compress', + reason: MODEL3D_REASON_COMPRESS_UNSUPPORTED, + }; + } + + if (options.autoSize === true && family === 'v25') { + return { + field: 'autoSize', + reason: MODEL3D_REASON_AUTO_SIZE_UNSUPPORTED, + }; + } + + if (options.smartLowPoly === true && !isV3Family(family)) { + return { + field: 'smartLowPoly', + reason: MODEL3D_REASON_SMART_LOW_POLY_UNSUPPORTED, + }; + } + + if (options.generateParts === true && !isV3Family(family)) { + return { + field: 'generateParts', + reason: MODEL3D_REASON_GENERATE_PARTS_UNSUPPORTED, + }; + } + + if (options.quad === true && !(isV3Family(family) || family === 'p2')) { + return { field: 'quad', reason: MODEL3D_REASON_QUAD_UNSUPPORTED }; + } + + // 分件的三个前置条件与 provider 一样分开判:面板一处按档位禁用、一处按开关禁用, + // 合并成一条会让用户看不出到底哪个条件不满足。 + if (options.generateParts === true && options.texture !== false) { + return { + field: 'generateParts', + reason: MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_TEXTURE, + }; + } + + if (options.generateParts === true && options.pbr !== false) { + return { + field: 'generateParts', + reason: MODEL3D_REASON_GENERATE_PARTS_REQUIRES_NO_PBR, + }; + } + + if ( + options.generateParts === true && + (options.quad === true || options.smartLowPoly === true) + ) { + return { + field: 'generateParts', + reason: MODEL3D_REASON_GENERATE_PARTS_CONFLICTS_WITH_GEOMETRY_MODES, + }; + } + + if (options.faceLimit != null) { + const { min, max } = resolveModel3dFaceLimitBounds(options); + if (options.faceLimit < min || options.faceLimit > max) { + return { + field: 'faceLimit', + reason: MODEL3D_REASON_FACE_LIMIT_OUT_OF_RANGE, + }; + } + } + + return null; +} + +/** + * 平台侧口径(`api-server::tripo3d::validation`):会改价的参数必须显式给出, + * `texture=false` 时不许出现贴图档位、`pbr` 必须显式 `false`。 + */ +export function validateModel3dPricingParams( + options: Model3dGenerationOptions, +): Model3dGenerationFailure | null { + if (options.texture == null) { + return { field: 'texture', reason: '必须显式给出是否生成贴图' }; + } + if (options.quad == null) { + return { field: 'quad', reason: '必须显式给出是否方形布线' }; + } + if (options.smartLowPoly == null) { + return { field: 'smartLowPoly', reason: '必须显式给出是否智能低模' }; + } + if (options.generateParts == null) { + return { field: 'generateParts', reason: '必须显式给出是否生成分件' }; + } + if (options.texture === false && options.textureQuality != null) { + return { + field: 'textureQuality', + reason: 'texture=false 时不允许出现贴图档位', + }; + } + if (options.texture === false && options.pbr !== false) { + return { field: 'pbr', reason: 'texture=false 时 pbr 必须显式给 false' }; + } + return null; +} + +/** 平台口径 + provider 口径,按后端顺序跑完,返回第一条失败。 */ +export function validateModel3dRequestParams( + options: Model3dGenerationOptions, +): Model3dGenerationFailure | null { + return ( + validateModel3dPricingParams(options) ?? + validateModel3dGenerationOptions(options) + ); +} diff --git a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx new file mode 100644 index 000000000..1eec6fd16 --- /dev/null +++ b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx @@ -0,0 +1,435 @@ +/* @vitest-environment jsdom */ + +import { act, renderHook } from '@testing-library/react'; +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration'; +import { ApiClientError } from '../../../services/apiClient'; +import type { + EditorCanvasGenerationCompletionInput, + Model3dGenerationSubmissionResponse, +} from '../../../services/image-editor/editorProjectClient'; +import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient'; +import type { + CanvasGenerationDialogState, + GenerateDialogState, +} from '../ImageCanvasEditorTypes'; +import { + applyEditorGenerationPricingConfig, + EDITOR_MODEL_MUD_POINT_CONFIG, +} from '../ImageCanvasGenerationModel'; +import { + createModel3dGenerationDraftFields, + MODEL3D_PRICING_UNAVAILABLE_REASON, +} from './Model3dGenerationFormModel'; +import { + MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE, + MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE, +} from './Model3dGenerationSubmission'; +import { useModel3dGenerationTask } from './useModel3dGenerationTask'; + +const submitModel3dTextToModelRequestMock = vi.hoisted(() => vi.fn()); +const submitModel3dImageToModelRequestMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../../services/image-editor/editorProjectClient', async () => { + const actual = await vi.importActual< + typeof import('../../../services/image-editor/editorProjectClient') + >('../../../services/image-editor/editorProjectClient'); + return { + ...actual, + submitModel3dTextToModelRequest: submitModel3dTextToModelRequestMock, + submitModel3dImageToModelRequest: submitModel3dImageToModelRequestMock, + }; +}); + +/** 两个端点各一个客户端函数,用例按当前端点取对应那个 mock。 */ +function submitRequestMock(endpoint: 'text-to-model' | 'image-to-model') { + return endpoint === 'text-to-model' + ? submitModel3dTextToModelRequestMock + : submitModel3dImageToModelRequestMock; +} + +const PRICING: Model3dPricingConfig = { + basePrices: { + 'text-to-model': { 'v3.1-20260211': { noTexture: 100, texture: 200 } }, + 'image-to-model': { 'v3.1-20260211': { noTexture: 150, texture: 250 } }, + }, + addOnPrices: { + hdTexture: 10, + ultraTexture: 20, + hdGeometry: 30, + quadMesh: 40, + smartLowPoly: 50, + generateParts: 60, + }, +}; + +const QUEUED_STATUS: ExternalGenerationJobStatusRecord = { + operationId: 'task-1', + status: 'queued', + phaseLabel: '排队中', + phaseDetail: '等待执行', + progress: 0, + updatedAtMicros: 1, +}; + +const PLACEHOLDER: NonNullable< + EditorCanvasGenerationCompletionInput['placeholder'] +> = { + x: 0, + y: 0, + width: 1024, + height: 1024, + originalWidth: 1024, + originalHeight: 1024, +}; + +function createDialog( + overrides: Partial = {}, +): GenerateDialogState { + return { + id: 'dialog-3d', + ...createModel3dGenerationDraftFields('model3d-text-to-model'), + prompt: '一把木椅', + placeholder: PLACEHOLDER, + ...overrides, + } as GenerateDialogState; +} + +function renderTask({ + dialog, + projectId = 'project-1', + assetFolderId, + applyQueuedGeneration = vi.fn(async () => true), + onWalletBalanceMayHaveChanged = vi.fn(), +}: { + dialog: GenerateDialogState; + projectId?: string | null; + assetFolderId?: string | null; + applyQueuedGeneration?: (...args: unknown[]) => Promise; + onWalletBalanceMayHaveChanged?: () => void; +}) { + const onQueuedGenerationTask = vi.fn(); + const view = renderHook(() => { + const [dialogs, setDialogs] = useState([ + dialog as CanvasGenerationDialogState, + ]); + const { submitModel3dGeneration } = useModel3dGenerationTask({ + projectId, + assetFolderId, + updateCanvasGenerationDialogById: (dialogId, updater) => { + setDialogs((currentDialogs) => + currentDialogs.map((currentDialog) => + currentDialog.id === dialogId + ? (updater(currentDialog) ?? currentDialog) + : currentDialog, + ), + ); + }, + getGeneratingDialogPlaceholder: (currentDialog) => + currentDialog.placeholder ?? null, + applyQueuedGeneration: applyQueuedGeneration as never, + onQueuedGenerationTask, + onWalletBalanceMayHaveChanged, + }); + return { + submitModel3dGeneration, + // 用例里只会有一个对话框,收窄成非空便于断言。 + dialog: dialogs[0] as GenerateDialogState, + }; + }); + return { ...view, onQueuedGenerationTask, onWalletBalanceMayHaveChanged }; +} + +describe('useModel3dGenerationTask', () => { + beforeEach(() => { + submitModel3dTextToModelRequestMock.mockReset(); + submitModel3dImageToModelRequestMock.mockReset(); + applyEditorGenerationPricingConfig({ + models: EDITOR_MODEL_MUD_POINT_CONFIG, + model3d: PRICING, + }); + }); + + it('提交成功后交给既有排队链路,并以对话框 id 结算画布占位', async () => { + const submitResponse: Model3dGenerationSubmissionResponse = { + operationId: 'task-1', + jobKind: 'model3d_text_to_model', + status: QUEUED_STATUS, + statusUrl: '/api/runtime/external-generation/jobs/task-1', + }; + submitModel3dTextToModelRequestMock.mockResolvedValue(submitResponse); + const applyQueuedGeneration = vi.fn(async () => true); + const { result, onQueuedGenerationTask } = renderTask({ + dialog: createDialog(), + assetFolderId: 'project', + applyQueuedGeneration, + }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(submitModel3dTextToModelRequestMock).toHaveBeenCalledTimes(1); + const request = submitModel3dTextToModelRequestMock.mock.calls[0][0]; + expect(request.idempotencyKey).toMatch(/^[\x21-\x7e]{1,128}$/); + expect(request.body).toMatchObject({ + generation: { + prompt: '一把木椅', + model: 'v3.1-20260211', + texture: true, + textureQuality: 'standard', + pbr: false, + geometryQuality: 'standard', + quad: false, + smartLowPoly: false, + generateParts: false, + }, + projectId: 'project-1', + assetFolderId: 'project', + assetLabel: '一把木椅', + }); + + expect(applyQueuedGeneration).toHaveBeenCalledTimes(1); + const [, queuedProjectId, , onQueued, , , completionDialogId] = + applyQueuedGeneration.mock.calls[0] as unknown as unknown[]; + expect(queuedProjectId).toBe('project-1'); + expect(completionDialogId).toBe('dialog-3d'); + expect(onQueued).toBe(onQueuedGenerationTask); + expect(result.current.dialog.status).toBe('generating'); + expect(result.current.dialog.composerOpen).toBe(false); + }); + + it('缺代次的老快照兜底铸一个并写回,幂等键跟着这个代次走', async () => { + submitModel3dTextToModelRequestMock.mockResolvedValue({ + operationId: 'task-1', + jobKind: 'model3d_text_to_model', + status: QUEUED_STATUS, + statusUrl: '/api/runtime/external-generation/jobs/task-1', + }); + const legacyDialog = createDialog({ model3dAttemptNonce: undefined }); + const { result } = renderTask({ dialog: legacyDialog }); + + await act(async () => { + await result.current.submitModel3dGeneration(legacyDialog); + }); + + const request = submitModel3dTextToModelRequestMock.mock.calls[0][0]; + const persistedNonce = result.current.dialog.model3dAttemptNonce; + expect(persistedNonce).toBeTruthy(); + expect(request.idempotencyKey.startsWith(`${persistedNonce}-`)).toBe(true); + }); + + it('排队链路没接手时不静默复位成空闲态', async () => { + submitModel3dTextToModelRequestMock.mockResolvedValue({ + operationId: 'task-1', + jobKind: 'model3d_text_to_model', + status: QUEUED_STATUS, + statusUrl: '/api/runtime/external-generation/jobs/task-1', + }); + const { result } = renderTask({ + dialog: createDialog(), + applyQueuedGeneration: vi.fn(async () => false), + }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(result.current.dialog.status).toBe('failed'); + expect(result.current.dialog.errorMessage).toBe( + MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE, + ); + expect(result.current.dialog.composerOpen).toBe(true); + }); + + it('2xx 但没有任务状态时给出失败提示,而不是静默复位', async () => { + // requestJson 在空 body 时返回 null,这里覆盖「成功但没有 status」的形态。 + submitModel3dTextToModelRequestMock.mockResolvedValue(null); + const applyQueuedGeneration = vi.fn(async () => true); + const { result } = renderTask({ + dialog: createDialog(), + applyQueuedGeneration, + }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(applyQueuedGeneration).not.toHaveBeenCalled(); + expect(result.current.dialog.status).toBe('failed'); + expect(result.current.dialog.errorMessage).toBe( + MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE, + ); + }); + + it('请求失败时留在面板上展示失败原因', async () => { + submitModel3dTextToModelRequestMock.mockRejectedValue( + new ApiClientError({ + message: '3D 模型生成失败,请稍后重试。', + status: 502, + code: 'tripo-upstream-error', + }), + ); + const { result } = renderTask({ dialog: createDialog() }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(result.current.dialog.status).toBe('failed'); + expect(result.current.dialog.errorMessage).toBe( + '3D 模型生成失败,请稍后重试。', + ); + expect(result.current.dialog.composerOpen).toBe(true); + }); + + it('构造提交计划时抛的错也落到失败态,而不是漏成未捕获的 rejection', async () => { + // 老快照缺字段:resolveModel3dResultTitle 会读 dialog.prompt.trim(),在计划构造前就抛。 + const { result } = renderTask({ + dialog: createDialog({ prompt: undefined }), + }); + + await act(async () => { + await result.current.submitModel3dGeneration( + createDialog({ prompt: undefined }), + ); + }); + + expect(result.current.dialog.status).toBe('failed'); + expect(result.current.dialog.composerOpen).toBe(true); + expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled(); + }); + + it('提交失败与「2xx 无任务状态」都按可能已扣费刷新钱包', async () => { + submitModel3dTextToModelRequestMock.mockRejectedValue( + new ApiClientError({ + message: '3D 模型生成失败,请稍后重试。', + status: 502, + code: 'tripo-upstream-error', + }), + ); + const failed = renderTask({ dialog: createDialog() }); + + await act(async () => { + await failed.result.current.submitModel3dGeneration(createDialog()); + }); + + expect(failed.onWalletBalanceMayHaveChanged).toHaveBeenCalledTimes(1); + + submitModel3dTextToModelRequestMock.mockResolvedValue(null); + const emptyBody = renderTask({ dialog: createDialog() }); + + await act(async () => { + await emptyBody.result.current.submitModel3dGeneration(createDialog()); + }); + + expect(emptyBody.onWalletBalanceMayHaveChanged).toHaveBeenCalledTimes(1); + }); + + it('定价段缺失时不发请求,只把不可提交的原因写回面板', async () => { + applyEditorGenerationPricingConfig({ + models: EDITOR_MODEL_MUD_POINT_CONFIG, + }); + const applyQueuedGeneration = vi.fn(async () => true); + const { result } = renderTask({ + dialog: createDialog(), + applyQueuedGeneration, + }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled(); + expect(applyQueuedGeneration).not.toHaveBeenCalled(); + expect(result.current.dialog.status).toBe('failed'); + expect(result.current.dialog.errorMessage).toBe( + MODEL3D_PRICING_UNAVAILABLE_REASON, + ); + }); + + it('没有任何落点(既没有工程也没有素材夹)时不发请求', async () => { + const { result } = renderTask({ dialog: createDialog(), projectId: null }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled(); + expect(result.current.dialog.status).toBe('failed'); + expect(result.current.dialog.errorMessage).toContain('保存当前画布工程'); + }); + + it('没有画布工程时照样提交:只发素材夹,不发 projectId 与 canvasCompletion', async () => { + const submitResponse: Model3dGenerationSubmissionResponse = { + operationId: 'task-2', + jobKind: 'model3d_text_to_model', + status: QUEUED_STATUS, + statusUrl: '/api/runtime/external-generation/jobs/task-2', + }; + submitModel3dTextToModelRequestMock.mockResolvedValue(submitResponse); + const { result } = renderTask({ + dialog: createDialog(), + projectId: null, + assetFolderId: 'project', + }); + + await act(async () => { + await result.current.submitModel3dGeneration(createDialog()); + }); + + expect(submitModel3dTextToModelRequestMock).toHaveBeenCalledTimes(1); + const request = submitModel3dTextToModelRequestMock.mock.calls[0][0]; + expect(request.body).not.toHaveProperty('projectId'); + expect(request.body).not.toHaveProperty('canvasCompletion'); + expect(request.body).toMatchObject({ assetFolderId: 'project' }); + }); + + it('图生 3D 走图生客户端函数:端点与提交体不会错配', async () => { + const imageMock = submitRequestMock('image-to-model'); + imageMock.mockResolvedValue({ + operationId: 'task-3', + jobKind: 'model3d_image_to_model', + status: QUEUED_STATUS, + statusUrl: '/api/runtime/external-generation/jobs/task-3', + }); + const dialog = createDialog({ + mode: 'model3d-image-to-model', + generationReferences: [ + { + id: 'reference-1', + label: '参考图', + src: 'data:image/png;base64,1', + resourceId: 'resource-1', + }, + ], + }); + const { result } = renderTask({ dialog }); + + await act(async () => { + await result.current.submitModel3dGeneration(dialog); + }); + + expect(imageMock).toHaveBeenCalledTimes(1); + expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled(); + expect(imageMock.mock.calls[0]?.[0].body).toMatchObject({ + source: { kind: 'resource', resourceId: 'resource-1' }, + }); + }); + + it('非 3D 对话框不做任何事', async () => { + const { result } = renderTask({ + dialog: createDialog({ mode: 'generate' }), + }); + + await act(async () => { + await result.current.submitModel3dGeneration( + createDialog({ mode: 'generate' }), + ); + }); + + expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts new file mode 100644 index 000000000..59987ce66 --- /dev/null +++ b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts @@ -0,0 +1,188 @@ +import { useCallback } from 'react'; + +import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration'; +import type { + EditorCanvasGenerationCompletionInput, + EditorProjectSnapshot, +} from '../../../services/image-editor/editorProjectClient'; +import { + submitModel3dImageToModelRequest, + submitModel3dTextToModelRequest, +} from '../../../services/image-editor/editorProjectClient'; +import type { + CanvasGenerationDialogState, + GenerateDialogState, +} from '../ImageCanvasEditorTypes'; +import { readEditorModel3dPricingConfig } from '../ImageCanvasGenerationModel'; +import { isModel3dGenerationMode } from './Model3dGenerationFormModel'; +import { + buildModel3dSubmissionPlan, + MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE, + MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE, + model3dQueueStateFromResponse, + resolveModel3dGenerationErrorMessage, + resolveModel3dResultTitle, +} from './Model3dGenerationSubmission'; + +type ApplyQueuedGeneration = ( + response: { queueState?: ExternalGenerationJobStatusRecord | null }, + projectId: string | null | undefined, + applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined, + onQueuedGenerationTask?: () => void, + onWalletBalanceMayHaveChanged?: () => void, + onGenerationWarning?: (message: string) => void, + completionDialogId?: string | null, +) => Promise; + +type UpdateCanvasGenerationDialogById = ( + dialogId: string, + updater: ( + dialog: CanvasGenerationDialogState, + ) => CanvasGenerationDialogState | null, +) => void; + +/** + * 3D 生成的提交编排:只负责校验、入队、轮询与收口,请求构造在 + * `Model3dGenerationSubmission`,画布与任务侧栏复用既有链路。 + */ +export function useModel3dGenerationTask({ + projectId, + assetFolderId, + updateCanvasGenerationDialogById, + applyProjectSnapshot, + getGeneratingDialogPlaceholder, + applyQueuedGeneration, + onQueuedGenerationTask, + onWalletBalanceMayHaveChanged, + onGenerationWarning, +}: { + projectId?: string | null; + /** 当前素材夹:与其它画布生成工具一致,结果同时落素材库。 */ + assetFolderId?: string | null; + updateCanvasGenerationDialogById: UpdateCanvasGenerationDialogById; + applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; + getGeneratingDialogPlaceholder: ( + dialog: GenerateDialogState, + ) => EditorCanvasGenerationCompletionInput['placeholder'] | null | undefined; + applyQueuedGeneration: ApplyQueuedGeneration; + onQueuedGenerationTask?: () => void; + onWalletBalanceMayHaveChanged?: () => void; + onGenerationWarning?: (message: string) => void; +}) { + const submitModel3dGeneration = useCallback( + async (dialog: GenerateDialogState) => { + const dialogId = dialog.id; + if (!dialogId || !isModel3dGenerationMode(dialog.mode)) { + return; + } + // 整个提交编排都落在 try 里:取占位框、算标题、构造提交计划同样可能抛 + // (注入的回调、老快照缺字段),漏出去就成了一次没有用户提示的未捕获 rejection。 + try { + const placeholder = getGeneratingDialogPlaceholder(dialog); + const canvasCompletion = + projectId && placeholder + ? { + dialogId, + title: resolveModel3dResultTitle(dialog), + placeholder, + } + : null; + const planResult = buildModel3dSubmissionPlan({ + dialog, + projectId, + canvasCompletion, + assetFolderId, + assetLabel: resolveModel3dResultTitle(dialog), + pricing: readEditorModel3dPricingConfig(), + }); + if (!planResult.ok) { + updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ + ...currentDialog, + status: 'failed', + composerOpen: true, + errorMessage: planResult.message, + })); + return; + } + updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ + ...currentDialog, + // 兜底铸的代次要写回状态:否则老快照每次提交都换幂等键,去重保护失效。 + model3dAttemptNonce: planResult.plan.attemptNonce, + status: 'generating', + composerOpen: false, + errorMessage: undefined, + })); + const plan = planResult.plan; + // 判别联合在这里收窄:文生与图生各走自己的客户端函数,端点与提交体的配对由类型保证。 + const response = + plan.endpoint === 'text-to-model' + ? await submitModel3dTextToModelRequest({ + body: plan.body, + idempotencyKey: plan.requestKey, + }) + : await submitModel3dImageToModelRequest({ + body: plan.body, + idempotencyKey: plan.requestKey, + }); + const queueState = model3dQueueStateFromResponse(response); + if (!queueState) { + // 2xx 但没有任务状态(空 body 或缺 status 的 payload)不能当成功静默复位: + // 用户既看不到任务也没有任何提示,服务端细节被丢掉。 + // 服务端在这条路上也可能已经扣过费,钱包余额同样要按「可能变了」刷新。 + onWalletBalanceMayHaveChanged?.(); + updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ + ...currentDialog, + status: 'failed', + composerOpen: true, + errorMessage: MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE, + })); + return; + } + if ( + await applyQueuedGeneration( + { queueState }, + projectId, + applyProjectSnapshot, + onQueuedGenerationTask, + onWalletBalanceMayHaveChanged, + onGenerationWarning, + dialogId, + ) + ) { + return; + } + // 画布没接住这份任务状态时不能静默复位成 idle:用户会以为没提交过, + // 再点一次就是又一次外部生成(已经扣过费)。按失败收口,让用户先去看结果。 + updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ + ...currentDialog, + status: 'failed', + composerOpen: true, + errorMessage: MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE, + })); + } catch (error) { + // 超时等失败也可能意味着服务端已经扣费(与其它生成链路的 + // runEditorGenerationWithWalletRefresh 同一口径),失败路径也要刷新钱包。 + onWalletBalanceMayHaveChanged?.(); + updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ + ...currentDialog, + status: 'failed', + composerOpen: true, + errorMessage: resolveModel3dGenerationErrorMessage(error), + })); + } + }, + [ + applyProjectSnapshot, + applyQueuedGeneration, + assetFolderId, + getGeneratingDialogPlaceholder, + onGenerationWarning, + onQueuedGenerationTask, + onWalletBalanceMayHaveChanged, + projectId, + updateCanvasGenerationDialogById, + ], + ); + + return { submitModel3dGeneration }; +} diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModal.test.tsx b/src/components/image-editor/model3d-preview/Model3dViewerModal.test.tsx new file mode 100644 index 000000000..824bdc871 --- /dev/null +++ b/src/components/image-editor/model3d-preview/Model3dViewerModal.test.tsx @@ -0,0 +1,236 @@ +/* @vitest-environment jsdom */ + +import type { Model3dViewerStatusDetail } from '@genarrative/model3d-viewer'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { useEffect } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CanvasLayer } from '../ImageCanvasEditorTypes'; +import { Model3dViewerModal } from './Model3dViewerModal'; + +const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn()); +const viewerResetViewMock = vi.hoisted(() => vi.fn()); +const model3dViewerMock = vi.hoisted(() => vi.fn()); +const reportedStatus = vi.hoisted(() => ({ + status: 'ready' as 'idle' | 'loading' | 'ready' | 'failed', + detail: {} as Model3dViewerStatusDetail, +})); + +vi.mock('../../../hooks/useResolvedAssetReadUrl', () => ({ + useResolvedAssetReadUrl: useResolvedAssetReadUrlMock, +})); + +// 只替换渲染器:格式判定必须走查看器包的真实实现,画布不另存一份格式表。 +vi.mock('@genarrative/model3d-viewer', async () => { + const actual = await vi.importActual< + typeof import('@genarrative/model3d-viewer') + >('@genarrative/model3d-viewer'); + return { ...actual, Model3DViewer: model3dViewerMock }; +}); + +function createLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-3d', + resourceId: 'resource-3d', + title: '3D 模型', + // 投影后的 imageSrc 是预览图,模型本体只在 objectKey 上。 + src: '/generated-character-drafts/editor/model3d/task-1/preview.webp', + objectKey: 'generated-character-drafts/editor/model3d/task-1/model.glb', + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'generated', + assetKind: 'model3d', + ...overrides, + }; +} + +function renderModal(layer: CanvasLayer | null, onClose = vi.fn()) { + const result = render(); + return { ...result, onClose }; +} + +describe('Model3dViewerModal', () => { + beforeEach(() => { + model3dViewerMock.mockReset(); + viewerResetViewMock.mockReset(); + useResolvedAssetReadUrlMock.mockReset(); + reportedStatus.status = 'ready'; + reportedStatus.detail = {}; + useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({ + resolvedUrl: source, + isResolving: false, + shouldResolve: Boolean(source), + resolvedObjectKey: source, + })); + // 最小替身:透出「有没有传 format」、拿得到 resetView 句柄、并按用例上报状态。 + model3dViewerMock.mockImplementation( + ({ + ref, + source, + errorText, + onStatusChange, + }: { + ref?: { current: { resetView: () => void } | null }; + source: { kind: 'url'; url: string; format?: string }; + errorText?: string; + onStatusChange?: ( + status: 'idle' | 'loading' | 'ready' | 'failed', + detail: Model3dViewerStatusDetail, + ) => void; + }) => { + // 状态回调必须发生在挂载后:渲染期回调会被 React 丢弃,测出来的按钮状态是假的。 + useEffect(() => { + if (ref) { + ref.current = { resetView: viewerResetViewMock }; + } + if (reportedStatus.status !== 'idle') { + onStatusChange?.(reportedStatus.status, reportedStatus.detail); + } + }, [ref, onStatusChange]); + return ( + <> + + {source.url} + + {errorText ?? ''} + + ); + }, + ); + }); + + it('用 objectKey 解析模型地址,且不向查看器预判格式', () => { + renderModal(createLayer()); + + expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith( + 'generated-character-drafts/editor/model3d/task-1/model.glb', + { + objectKey: 'generated-character-drafts/editor/model3d/task-1/model.glb', + refreshKey: 'resource-3d:model3d', + }, + ); + const viewer = screen.getByTestId('model3d-viewer'); + expect(viewer.textContent).toBe( + 'generated-character-drafts/editor/model3d/task-1/model.glb', + ); + // 画布不判格式:format 必须缺席,由查看器按响应内容类型与字节判定。 + expect(viewer.getAttribute('data-format')).toBe(''); + expect(screen.getByRole('dialog', { name: '3D模型预览' })).toBeTruthy(); + }); + + it('判不出格式时把查看器的原因翻给用户', () => { + reportedStatus.status = 'failed'; + reportedStatus.detail = { reason: 'unsupported-format' }; + + renderModal(createLayer({ objectKey: 'editor/model3d/task-1/model.obj' })); + + expect(screen.getByTestId('model3d-viewer-error').textContent).toBe( + '无法识别模型格式,只支持 glb / gltf / fbx。', + ); + }); + + it('超过体积上限时报出查看器的上限', () => { + reportedStatus.status = 'failed'; + reportedStatus.detail = { reason: 'too-large', byteLength: 70_000_000 }; + + renderModal(createLayer()); + + expect(screen.getByTestId('model3d-viewer-error').textContent).toBe( + '模型超过 64 MiB,无法预览。', + ); + }); + + it('没有 objectKey 时不拿预览图当模型地址', () => { + renderModal(createLayer({ objectKey: undefined })); + + expect(screen.queryByTestId('model3d-viewer')).toBeNull(); + expect(screen.getByText('该资源没有可预览的 3D 模型文件。')).toBeTruthy(); + }); + + it('签名地址未就绪时给出状态提示而不是空舞台', () => { + useResolvedAssetReadUrlMock.mockImplementation(() => ({ + resolvedUrl: '', + isResolving: true, + shouldResolve: true, + resolvedObjectKey: '', + })); + + renderModal(createLayer()); + + expect(screen.getByRole('status').textContent).toBe('正在准备模型地址…'); + expect(screen.queryByTestId('model3d-viewer')).toBeNull(); + }); + + it('换模型的那一帧不拿上一个模型的地址渲染查看器', () => { + // 钩子先返回旧模型的地址、下一轮才换成新模型的:地址字符串没变,查看器不会重跑加载。 + useResolvedAssetReadUrlMock.mockImplementation(() => ({ + resolvedUrl: 'https://signed.example.com/old-model.glb', + isResolving: false, + shouldResolve: true, + resolvedObjectKey: 'editor/model3d/old-task/model.glb', + })); + + renderModal( + createLayer({ + objectKey: 'editor/model3d/new-task/model.glb', + resourceId: 'resource-3d-new', + }), + ); + + expect(screen.queryByTestId('model3d-viewer')).toBeNull(); + expect(screen.getByRole('status').textContent).toBe('正在准备模型地址…'); + }); + + it('重置视角只在查看器就绪后可点', () => { + renderModal(createLayer()); + + const resetButton = screen.getByRole('button', { + name: '重置视角', + }) as HTMLButtonElement; + expect(resetButton.disabled).toBe(false); + + fireEvent.click(resetButton); + expect(viewerResetViewMock).toHaveBeenCalledTimes(1); + }); + + it('未就绪时重置视角不可点', () => { + reportedStatus.status = 'idle'; + + renderModal(createLayer()); + + expect( + (screen.getByRole('button', { name: '重置视角' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it('未选中图层时不渲染面板', () => { + renderModal(null); + + expect(screen.queryByRole('dialog', { name: '3D模型预览' })).toBeNull(); + }); + + it('关闭面板后重开不沿用上一轮的 ready 状态', () => { + const layer = createLayer(); + const { rerender } = renderModal(layer); + const resetButton = () => + screen.getByRole('button', { name: '重置视角' }) as HTMLButtonElement; + + expect(resetButton().disabled).toBe(false); + + rerender(); + // 重开后的新查看器本轮不上报状态,按钮必须保持禁用,不能继承上一轮的 ready。 + reportedStatus.status = 'idle'; + rerender(); + + expect(resetButton().disabled).toBe(true); + }); +}); diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx b/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx new file mode 100644 index 000000000..76423978d --- /dev/null +++ b/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx @@ -0,0 +1,195 @@ +import { + Model3DViewer, + type Model3DViewerHandle, + type Model3DViewerSource, + type Model3dViewerStatus, + type Model3dViewerStatusDetail, +} from '@genarrative/model3d-viewer'; +import { useEffect, useRef, useState } from 'react'; + +import { useResolvedAssetReadUrl } from '../../../hooks/useResolvedAssetReadUrl'; +import { PlatformActionButton } from '../../common/PlatformActionButton'; +import { UnifiedModal } from '../../common/UnifiedModal'; +import type { CanvasLayer } from '../ImageCanvasEditorTypes'; +import { resolveModel3dViewerFailureText } from './Model3dViewerModel'; + +export type Model3dViewerModalProps = { + layer: CanvasLayer | null; + onClose: () => void; +}; + +/** + * 面板该显示的提示文案;返回 null 表示可以进查看器。用早返回而不是嵌套三元, + * 每个分支对应一个前置条件,读起来是清单而不是括号树。 + */ +function resolveModel3dViewerHint({ + hasModelSource, + isResolving, + resolvedUrl, +}: { + hasModelSource: boolean; + isResolving: boolean; + resolvedUrl: string; +}) { + if (!hasModelSource) { + return '该资源没有可预览的 3D 模型文件。'; + } + if (isResolving) { + return '正在准备模型地址…'; + } + if (!resolvedUrl) { + return '模型地址暂不可用,请稍后重试。'; + } + return null; +} + +type Model3dViewerUrlSource = Extract; + +/** 查看器来源:模型对象与它的签名地址。格式由查看器按读响应与字节判定。 */ +function resolveModel3dViewerSource({ + objectKey, + resolvedUrl, +}: { + objectKey: string; + resolvedUrl: string; +}): Model3dViewerUrlSource | null { + if (!objectKey || !resolvedUrl) { + return null; + } + return { kind: 'url', url: resolvedUrl }; +} + +const MODEL3D_VIEWER_FAILURE_FALLBACK_TEXT = '模型加载失败,请关闭后重试。'; + +/** + * 3D 模型预览模态:只做「取地址 + 交给查看器」,不解析模型、不产出缩略图。 + * + * 模型本体只认 `objectKey`:投影后的 `imageSrc` 是预览图,拿它当模型地址会得到一个 + * 图片文件。`objectKey` 缺失就直接判不可预览 —— 宁可少一次"也许能开",也不让同一份 + * 字段在同一期承担两种语义。 + * + * 格式不在这一层判定:查看器读模型时按「响应内容类型 → 字节魔数 → 地址扩展名」判定, + * 判不出来就报 unsupported-format,这里把失败原因翻译成用户能读的文案。 + */ +export function Model3dViewerModal({ + layer, + onClose, +}: Model3dViewerModalProps) { + const modelObjectKey = layer?.objectKey?.trim() ?? ''; + const { resolvedUrl, isResolving, resolvedObjectKey } = + useResolvedAssetReadUrl(modelObjectKey, { + objectKey: layer?.objectKey, + refreshKey: `${layer?.taskId ?? layer?.resourceId ?? ''}:model3d`, + }); + // 取地址是异步的:换模型的那一帧里,钩子手上还是上一个模型的地址。地址字符串没变时 + // 查看器不会重跑加载,屏幕上会继续显示上一个模型(评审 #85),所以只有确认这个地址 + // 确实属于当前模型(钩子按 objectKey 记着它换签时用的是哪一个)才把地址交给查看器。 + const modelUrl = + modelObjectKey && resolvedObjectKey === modelObjectKey ? resolvedUrl : ''; + const viewerRef = useRef(null); + const layerIdentity = layer?.id ?? null; + // 查看器状态连同"它属于哪个模型"一起存:换了模型,旧状态自动失效。 + // 用 effect 在切换时重置不行 —— 它在挂载后运行,会盖掉查看器刚上报的 ready。 + const [viewerStatus, setViewerStatus] = useState<{ + identity: string; + status: Model3dViewerStatus; + detail: Model3dViewerStatusDetail | null; + } | null>(null); + + const modelSource = resolveModel3dViewerSource({ + objectKey: modelObjectKey, + resolvedUrl: modelUrl, + }); + const viewerIdentity = modelSource + ? `${layerIdentity ?? ''}:${modelSource.url}` + : null; + const canResetView = + Boolean(viewerIdentity) && + viewerStatus?.identity === viewerIdentity && + viewerStatus.status === 'ready'; + // 「手上有个地址,但它还不是当前模型的」这一帧同样属于准备中:否则会闪一句「地址不可用」。 + const isModelUrlPending = + Boolean(modelObjectKey) && + !modelUrl && + (isResolving || Boolean(resolvedUrl)); + const hintText = resolveModel3dViewerHint({ + hasModelSource: Boolean(modelObjectKey), + isResolving: isModelUrlPending, + resolvedUrl: modelUrl, + }); + const errorText = resolveModel3dViewerFailureText( + viewerStatus?.identity === viewerIdentity ? viewerStatus.detail : null, + MODEL3D_VIEWER_FAILURE_FALLBACK_TEXT, + ); + + // 面板关闭(layer 变 null)时清掉状态,让下一次打开从禁用的「重置视角」重新开始。 + // 只处理关闭、不处理换模型:换模型时查看器会立刻上报,effect 晚一步会把 loading 盖掉。 + useEffect(() => { + if (viewerIdentity === null) { + setViewerStatus(null); + } + }, [viewerIdentity]); + + return ( + viewerRef.current?.resetView()} + > + 重置视角 + + } + > + {/* 查看器要一个非零尺寸的容器才能量出画布尺寸;画布编辑器只支持桌面端,按视口限高。 */} +
+ {hintText ? ( +

+ {hintText} +

+ ) : null} + {modelSource ? ( + + setViewerStatus((current) => { + if (!viewerIdentity) { + return current; + } + if ( + current?.identity === viewerIdentity && + current.status === status && + current.detail?.reason === detail?.reason && + current.detail?.message === detail?.message + ) { + return current; + } + return { identity: viewerIdentity, status, detail }; + }) + } + /> + ) : null} +
+
+ ); +} diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModel.test.ts b/src/components/image-editor/model3d-preview/Model3dViewerModel.test.ts new file mode 100644 index 000000000..b0c848418 --- /dev/null +++ b/src/components/image-editor/model3d-preview/Model3dViewerModel.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + isModel3dFileSource, + resolveModel3dDownloadExtension, + resolveModel3dViewerFailureText, +} from './Model3dViewerModel'; + +describe('isModel3dFileSource', () => { + it('把带模型扩展名的地址当成模型本体,查询串不影响判定', () => { + expect(isModel3dFileSource('generated/editor/model.glb')).toBe(true); + expect(isModel3dFileSource('/read/model.FBX?sig=1')).toBe(true); + expect(isModel3dFileSource('https://x/model.gltf#a')).toBe(true); + }); + + it('预览图与空值不是模型本体', () => { + expect(isModel3dFileSource('generated/editor/preview.webp')).toBe(false); + expect(isModel3dFileSource('')).toBe(false); + expect(isModel3dFileSource(null)).toBe(false); + expect(isModel3dFileSource(undefined)).toBe(false); + }); +}); + +describe('resolveModel3dDownloadExtension', () => { + it('真实响应头的 MIME 优先于对象键扩展名', () => { + expect( + resolveModel3dDownloadExtension( + 'generated/editor/model.fbx', + 'model/gltf-binary', + ), + ).toBe('glb'); + expect( + resolveModel3dDownloadExtension( + 'generated/editor/model.glb', + 'model/fbx', + ), + ).toBe('fbx'); + }); + + it('拿不到可辨识的 MIME 时按对象键扩展名', () => { + expect( + resolveModel3dDownloadExtension( + 'generated/editor/model.fbx', + 'application/octet-stream', + ), + ).toBe('fbx'); + expect(resolveModel3dDownloadExtension('model.gltf', '')).toBe('gltf'); + }); + + it('两条线索都给不出答案时回落 glb', () => { + expect(resolveModel3dDownloadExtension('generated/editor/model', '')).toBe( + 'glb', + ); + expect(resolveModel3dDownloadExtension(null, null)).toBe('glb'); + }); +}); + +describe('resolveModel3dViewerFailureText', () => { + const fallback = '模型加载失败,请关闭后重试。'; + + it('超限时报出查看器上限', () => { + expect( + resolveModel3dViewerFailureText({ reason: 'too-large' }, fallback), + ).toContain('64 MiB'); + }); + + it('格式判不出时报出支持范围', () => { + expect( + resolveModel3dViewerFailureText( + { reason: 'unsupported-format' }, + fallback, + ), + ).toContain('glb / gltf / fbx'); + }); + + it('WebGL 不可用时说明是环境问题', () => { + expect( + resolveModel3dViewerFailureText( + { reason: 'webgl-unavailable' }, + fallback, + ), + ).toContain('WebGL2'); + }); + + it('其余原因与缺失 detail 都用兜底文案', () => { + expect( + resolveModel3dViewerFailureText({ reason: 'load-failed' }, fallback), + ).toBe(fallback); + expect(resolveModel3dViewerFailureText(null, fallback)).toBe(fallback); + expect(resolveModel3dViewerFailureText(undefined, fallback)).toBe(fallback); + }); +}); diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModel.ts b/src/components/image-editor/model3d-preview/Model3dViewerModel.ts new file mode 100644 index 000000000..8c8600bdd --- /dev/null +++ b/src/components/image-editor/model3d-preview/Model3dViewerModel.ts @@ -0,0 +1,76 @@ +import { + MODEL3D_VIEWER_MAX_MODEL_BYTES, + MODEL3D_VIEWER_SUPPORTED_FORMATS, + type Model3dViewerStatusDetail, + resolveModel3dViewerFormat, +} from '@genarrative/model3d-viewer'; + +/** + * 3D 资源在画布上的媒体语义:卡片与素材缩略图消费预览图,模型本体只在查看器里加载。 + * 模型地址来自资源行的 `objectKey`。 + * + * 「模型是什么格式、能不能解析」的真相在查看器包里(声明类型 → 字节魔数 → 地址扩展名), + * 画布不判格式、也不抄格式清单:它只回答「这个地址是不是模型本体」和「下载该按什么扩展名 + * 命名」这两个不需要解析器的问题,且都复用包里那一份判定。 + */ + +const MODEL3D_VIEWER_MAX_MIB = Math.round( + MODEL3D_VIEWER_MAX_MODEL_BYTES / (1024 * 1024), +); +const MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT = + MODEL3D_VIEWER_SUPPORTED_FORMATS.join(' / '); + +/** + * 判断一个媒体地址是否其实指向模型本体。 + * + * 后端投影只把预览图放进 `imageSrc`(没有预览就留空,不再拿模型对象顶封面), + * 但历史布局与旧快照里可能还留着指向模型本体的地址:图片元素不能去加载模型文件, + * 必须改显示 3D 占位。 + */ +export function isModel3dFileSource(value: string | null | undefined) { + return resolveModel3dViewerFormat({ url: value }) !== null; +} + +/** + * 模型下载扩展名:真实响应头的 MIME 优先,其次地址扩展名,最后回落 glb(后端模型产物的 + * 默认扩展名)。只有 `blob.type` 这类真实响应头才带得动第一层判据。 + */ +export function resolveModel3dDownloadExtension( + value: string | null | undefined, + mimeType?: string | null, +) { + return ( + resolveModel3dViewerFormat({ declaredType: mimeType, url: value }) ?? 'glb' + ); +} + +/** + * 查看器失败时给用户看的文案:按失败原因分开说,用 switch 穷尽所有原因。 + * 原因缺失(宿主没拿到 detail)时用调用方的兜底文案。 + */ +export function resolveModel3dViewerFailureText( + detail: Model3dViewerStatusDetail | null | undefined, + fallbackText: string, +) { + const reason = detail?.reason; + // 加载失败没有更细的可读信息,与原因缺失共用调用方的兜底文案。 + if (reason === undefined || reason === 'load-failed') { + return fallbackText; + } + switch (reason) { + case 'too-large': + return `模型超过 ${MODEL3D_VIEWER_MAX_MIB} MiB,无法预览。`; + case 'unsupported-format': + return `无法识别模型格式,只支持 ${MODEL3D_VIEWER_SUPPORTED_FORMAT_TEXT}。`; + case 'webgl-unavailable': + return '当前环境不支持 WebGL2,无法预览模型。'; + default: { + // 查看器新增失败原因时这里会编译报错,不会静默退化成通用文案。 + // 编译不过就补分支;真到运行时(宿主与查看器版本错配)仍然退回兜底文案, + // 不把 `decode-failed` 这类内部原因码直接显示给用户。 + const unhandledReason: never = reason; + void unhandledReason; + return fallbackText; + } + } +} diff --git a/src/components/image-editor/projectAssetReferencePickerModel.test.ts b/src/components/image-editor/projectAssetReferencePickerModel.test.ts index 057be4c02..1ee72811d 100644 --- a/src/components/image-editor/projectAssetReferencePickerModel.test.ts +++ b/src/components/image-editor/projectAssetReferencePickerModel.test.ts @@ -243,6 +243,59 @@ describe('参考图弹窗素材筛选与快照', () => { expect(next?.composerOpen).toBe(true); }); + it('replaces the 3D image-to-model source with the picked project asset', () => { + const assets = [createAsset({ id: 'asset-hero', label: '主角立绘' })]; + const dialog = { + mode: 'model3d-image-to-model', + prompt: '', + status: 'failed', + errorMessage: '失败', + composerOpen: false, + generationReferences: [ + { + id: 'canvas-layer-1', + label: '画布点选', + src: 'https://example.test/canvas.png', + mediaType: 'image', + }, + ], + } satisfies GenerateDialogState; + + const next = replaceProjectAssetPickerReferences( + dialog, + projectAssetPickerReferences(assets, ['asset-hero']), + ); + + expect( + next?.generationReferences?.map((reference) => reference.id), + ).toEqual(['project-asset-asset-hero']); + expect(next).toMatchObject({ status: 'idle', composerOpen: true }); + }); + + it('keeps the 3D image-to-model source when the picker is confirmed empty', () => { + const dialog = { + mode: 'model3d-image-to-model', + prompt: '', + status: 'failed', + errorMessage: '失败', + composerOpen: false, + generationReferences: [ + { + id: 'canvas-layer-1', + label: '画布点选', + src: 'https://example.test/canvas.png', + mediaType: 'image', + }, + ], + } satisfies GenerateDialogState; + + const next = replaceProjectAssetPickerReferences(dialog, []); + + expect( + next?.generationReferences?.map((reference) => reference.id), + ).toEqual(['canvas-layer-1']); + }); + it('leaves dialogs outside the reference-composer modes untouched', () => { const dialog = { mode: 'video', diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index 3d714997c..96e3c2f0b 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -267,6 +267,251 @@ describe('useImageCanvasAssetExportWorkflow', () => { } }); + it('downloads a 3D model layer with the model extension from the response MIME', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('/api/assets/read-url?')) { + return new Response(null, { status: 404 }); + } + if (url.startsWith('/api/assets/read-bytes?')) { + return new Response(new Uint8Array([0x67, 0x6c, 0x54, 0x46]), { + headers: { 'content-type': 'model/gltf-binary' }, + }); + } + return new Response(null, { status: 404 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + const downloads: Array<{ blob: Blob; name: string }> = []; + let currentBlob: Blob | null = null; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + currentBlob = blob; + return 'blob:model3d-export'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + function click(this: HTMLAnchorElement) { + if (currentBlob) { + downloads.push({ blob: currentBlob, name: this.download }); + } + }, + ); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出单图' })); + await waitFor(() => expect(downloads).toHaveLength(1)); + + expect(downloads[0]?.blob.type).toContain('model/gltf-binary'); + expect(downloads[0]?.name).toMatch(/^三维角色-\d{8}-\d{6}\.glb$/u); + } finally { + globalThis.fetch = originalFetch; + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); + + it('falls back to the object key extension when the 3D response has no model MIME', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('/api/assets/read-url?')) { + return new Response(null, { status: 404 }); + } + if (url.startsWith('/api/assets/read-bytes?')) { + return new Response(new Uint8Array([0x4b, 0x61, 0x79, 0x64]), { + headers: { 'content-type': 'application/octet-stream' }, + }); + } + return new Response(null, { status: 404 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + const downloads: Array<{ blob: Blob; name: string }> = []; + let currentBlob: Blob | null = null; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + currentBlob = blob; + return 'blob:model3d-extension-fallback'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + function click(this: HTMLAnchorElement) { + if (currentBlob) { + downloads.push({ blob: currentBlob, name: this.download }); + } + }, + ); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出单图' })); + await waitFor(() => expect(downloads).toHaveLength(1)); + + expect(downloads[0]?.name).toMatch(/^三维角色-\d{8}-\d{6}\.fbx$/u); + } finally { + globalThis.fetch = originalFetch; + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); + + it('packages 3D model layers into the 3d_models directory', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('/api/assets/read-url?')) { + return new Response(null, { status: 404 }); + } + if (url.startsWith('/api/assets/read-bytes?')) { + return new Response(new Uint8Array([0x67, 0x6c, 0x54, 0x46]), { + headers: { 'content-type': 'model/gltf-binary' }, + }); + } + return new Response(null, { status: 404 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + let exportedBlob: Blob | null = null; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + exportedBlob = blob; + return 'blob:collection-with-model3d'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出画布素材' })); + await waitFor(() => expect(exportedBlob).toBeTruthy()); + + const zip = await JSZip.loadAsync(exportedBlob!); + const files = Object.keys(zip.files); + expect( + files.filter( + (file) => + file.startsWith('导出项目-画布素材/3d_models/') && + !file.endsWith('/'), + ), + ).toEqual(['导出项目-画布素材/3d_models/002-三维角色.glb']); + expect( + files.filter( + (file) => + file.startsWith('导出项目-画布素材/images/') && !file.endsWith('/'), + ), + ).toEqual(['导出项目-画布素材/images/001-普通图片.png']); + const metadata = JSON.parse( + await readZipText(zip, '导出项目-画布素材/metadata.json'), + ); + expect( + metadata.layers.map((layer: { file: string }) => layer.file), + ).toEqual(['images/001-普通图片.png', '3d_models/002-三维角色.glb']); + } finally { + globalThis.fetch = originalFetch; + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); + + it('没有音频 / 3D 内容时不在压缩包里留空目录', async () => { + let exportedBlob: Blob | null = null; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + exportedBlob = blob; + return 'blob:collection-without-empty-folders'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出画布素材' })); + await waitFor(() => expect(exportedBlob).toBeTruthy()); + + const zip = await JSZip.loadAsync(exportedBlob!); + const paths = Object.keys(zip.files); + expect( + paths.filter( + (path) => + path.startsWith('导出项目-画布素材/images/') && !path.endsWith('/'), + ), + ).toEqual(['导出项目-画布素材/images/001-普通图片.png']); + // 目录在第一次写文件时才建:没有音频 / 3D 内容时连空目录都不该出现。 + expect(paths.filter((path) => path.includes('media'))).toEqual([]); + expect(paths.filter((path) => path.includes('3d_models'))).toEqual([]); + } finally { + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); + it('prevents concurrent collection exports before React re-renders', async () => { const originalFetch = globalThis.fetch; let resolveFetch: (response: Response) => void = () => {}; diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index 5433ed802..adef39b33 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -17,7 +17,7 @@ import { type ExportedImageSequenceFrame, formatExportDate, getImageSequenceFrameFileName, - getLayerAssetExtensionFromTypeOrSrc, + getLayerAssetDownloadExtension, getLayerExportKey, getLayerImageSequenceFrames, readLayerAssetBlob, @@ -50,6 +50,51 @@ function hasReadableLayerExportSource(layer: CanvasLayer) { return hasReadableAssetSource(layer.src, layer.objectKey); } +/** + * 导出目录名。名字在「挑目录」「建目录」「按层分流」三处都要用, + * 只留这一份字面量:写错目录名不会编译报错,只会在 zip 里悄悄换目录。 + */ +const EXPORT_FOLDER = { + images: 'images', + media: 'media', + models: '3d_models', +} as const; + +type CanvasAssetExportFolderName = + (typeof EXPORT_FOLDER)[keyof typeof EXPORT_FOLDER]; + +/** 导出目录按媒体族分:图片 `images/`、音视频 `media/`、3D 模型 `3d_models/`。 */ +function resolveLayerExportFolderName( + layer: CanvasLayer, +): CanvasAssetExportFolderName { + if (layer.assetKind === 'model3d') { + return EXPORT_FOLDER.models; + } + return (layer.mediaType ?? 'image') === 'image' + ? EXPORT_FOLDER.images + : EXPORT_FOLDER.media; +} + +/** + * zip 目标目录:三类各一个,模型不进图片目录。 + * + * 目录在第一次真要往里面写文件时才建:`JSZip` 的 `folder()` 一调用就会在压缩包里留下 + * 目录条目,先建好三个目录会让「没有音频的导出」里出现空的 `media/`、`3d_models/` + * (评审 #91)。同一轮导出里每个目录只建一次。 + */ +function createExportFolderResolver(rootFolder: JSZip) { + const createdFolders = new Map(); + return (folderName: CanvasAssetExportFolderName): JSZip => { + const cached = createdFolders.get(folderName); + if (cached) { + return cached; + } + const folder = rootFolder.folder(folderName) ?? rootFolder; + createdFolders.set(folderName, folder); + return folder; + }; +} + type AssetExportPlan = { key: string; layer: CanvasLayer; @@ -68,7 +113,7 @@ type PreparedAssetExport = plan: AssetExportPlan; blob: Blob; bytes: Uint8Array; - folderName: 'images' | 'media'; + folderName: CanvasAssetExportFolderName; fileName: string; } | { @@ -390,11 +435,7 @@ export function useImageCanvasAssetExportWorkflow({ try { const blob = await readLayerAssetBlob(layer); - const extension = getLayerAssetExtensionFromTypeOrSrc( - layer.mediaType ?? 'image', - blob.type, - layer.objectKey ?? layer.src, - ); + const extension = getLayerAssetDownloadExtension(layer, blob.type); const downloaded = triggerBrowserDownload( blob, `${sanitizeExportFilePart(layer.title, 'canvas-layer')}-${formatExportDate(new Date())}.${extension}`, @@ -474,8 +515,9 @@ export function useImageCanvasAssetExportWorkflow({ const rootFolderName = `${projectName}-${archiveLabel}`; const zip = new JSZip(); const rootFolder = zip.folder(rootFolderName) ?? zip; - const imagesFolder = rootFolder.folder('images') ?? rootFolder; - const mediaFolder = rootFolder.folder('media') ?? rootFolder; + // 3D 资源导出的是模型本体(.glb/.gltf/.fbx),混进 images/ 会让人以为 + // zip 里是坏图片,所以单独一个目录;三个目录都在第一次写文件时才建。 + const resolveExportFolder = createExportFolderResolver(rootFolder); const imageByKey = new Map(); const usedFileNames = new Map(); const plannedKeys = new Set(); @@ -579,13 +621,11 @@ export function useImageCanvasAssetExportWorkflow({ bytes: await blobToUint8Array(nextBlob), }; }); - const mediaType = layer.mediaType ?? 'image'; - const extension = getLayerAssetExtensionFromTypeOrSrc( - mediaType, + const extension = getLayerAssetDownloadExtension( + layer, blob.type, - layer.objectKey ?? layer.src, ); - const folderName = mediaType === 'image' ? 'images' : 'media'; + const folderName = resolveLayerExportFolderName(layer); return { status: 'asset', plan, @@ -595,13 +635,8 @@ export function useImageCanvasAssetExportWorkflow({ fileName: `${indexedFileName}.${extension}`, }; } catch (error) { - const mediaType = layer.mediaType ?? 'image'; - const extension = getLayerAssetExtensionFromTypeOrSrc( - mediaType, - '', - layer.objectKey ?? layer.src, - ); - const folderName = mediaType === 'image' ? 'images' : 'media'; + const extension = getLayerAssetDownloadExtension(layer, ''); + const folderName = resolveLayerExportFolderName(layer); return { status: 'error', plan, @@ -624,10 +659,7 @@ export function useImageCanvasAssetExportWorkflow({ continue; } if (preparedExport.status === 'asset') { - const targetFolder = - preparedExport.folderName === 'images' - ? imagesFolder - : mediaFolder; + const targetFolder = resolveExportFolder(preparedExport.folderName); targetFolder.file(preparedExport.fileName, preparedExport.bytes); imageByKey.set(key, { key, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 6232f4241..ebda8f621 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -89,6 +89,8 @@ import type { } from './ImageCanvasUiAssetExtractionModel'; import { resolveUiAssetExtractionGenerationPlan } from './ImageCanvasUiAssetExtractionModel'; import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel'; +import { isModel3dGenerationMode } from './model3d-generation/Model3dGenerationFormModel'; +import { useModel3dGenerationTask } from './model3d-generation/useModel3dGenerationTask'; import type { BackgroundMusicPromptAssistController } from './useImageCanvasBackgroundMusicPromptAssist'; import type { SoundEffectPromptAssistController } from './useImageCanvasSoundEffectPromptAssist'; @@ -1596,9 +1598,25 @@ export function useImageCanvasGenerationSubmissionWorkflow({ [projectId, updateCanvasGenerationDialogById], ); + const { submitModel3dGeneration } = useModel3dGenerationTask({ + projectId, + assetFolderId, + updateCanvasGenerationDialogById, + applyProjectSnapshot, + getGeneratingDialogPlaceholder, + applyQueuedGeneration: applyQueuedEditorGenerationProject, + onQueuedGenerationTask, + onWalletBalanceMayHaveChanged, + onGenerationWarning, + }); + const submitImageGeneration = useCallback( async (dialog: GenerateDialogState) => { const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; + if (canvasDialog && isModel3dGenerationMode(canvasDialog.mode)) { + await submitModel3dGeneration(canvasDialog); + return; + } const backgroundMusicDialog = canvasDialog?.mode === 'audio-background-music' ? canvasDialog : null; const soundEffectDialog = @@ -2493,6 +2511,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ onGenerationWarning, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, + submitModel3dGeneration, ], ); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index 6b2469ffc..ad35dd8b6 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -118,6 +118,7 @@ function GenerationSurfaceHarness() { const specToolWrapRef = useRef(null); const musicToolWrapRef = useRef(null); const publicationToolWrapRef = useRef(null); + const model3dToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); @@ -142,6 +143,7 @@ function GenerationSurfaceHarness() { specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, iconSpecButtonRef, diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index 25c9e2dc9..c734f1886 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -57,7 +57,10 @@ type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null; -type ToolbarOptionTool = Extract; +type ToolbarOptionTool = Extract< + CanvasTool, + 'music' | 'spec' | 'publication' | 'model3d' +>; type ImageCanvasGenerationSurfaceOptions = { layers: CanvasLayer[]; @@ -70,6 +73,7 @@ type ImageCanvasGenerationSurfaceOptions = { specToolWrapRef: RefObject; musicToolWrapRef: RefObject; publicationToolWrapRef: RefObject; + model3dToolWrapRef: RefObject; characterSpecButtonRef: RefObject; characterReferenceButtonRef: RefObject; iconSpecButtonRef: RefObject; @@ -175,6 +179,7 @@ export function useImageCanvasGenerationSurface({ specToolWrapRef, musicToolWrapRef, publicationToolWrapRef, + model3dToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, iconSpecButtonRef, @@ -318,6 +323,7 @@ export function useImageCanvasGenerationSurface({ generationWorkflow.setIsMusicMenuOpen(tool === 'music'); generationWorkflow.setIsSpecMenuOpen(tool === 'spec'); generationWorkflow.setIsPublicationMenuOpen(tool === 'publication'); + generationWorkflow.setIsModel3dMenuOpen(tool === 'model3d'); }, [clearToolbarOptionCloseTimer, generationWorkflow], ); @@ -334,6 +340,10 @@ export function useImageCanvasGenerationSurface({ generationWorkflow.setIsPublicationMenuOpen(false); return; } + if (tool === 'model3d') { + generationWorkflow.setIsModel3dMenuOpen(false); + return; + } generationWorkflow.setIsSpecMenuOpen(false); }, TOOLBAR_OPTION_CLOSE_DELAY_MS); }, @@ -369,6 +379,11 @@ export function useImageCanvasGenerationSurface({ setActiveTool('publication'); return true; } + if (tool === 'model3d') { + openToolbarOptionMenu('model3d'); + setActiveTool('model3d'); + return true; + } if (tool === 'character') { generationWorkflow.openCharacterGenerationDialog(); return true; @@ -636,6 +651,31 @@ export function useImageCanvasGenerationSurface({ , ) : null} + {generationWorkflow.isModel3dMenuOpen + ? renderEditorPortal( + openToolbarOptionMenu('model3d')} + onPointerLeave={() => closeToolbarOptionMenu('model3d')} + onFocus={() => openToolbarOptionMenu('model3d')} + onBlur={() => closeToolbarOptionMenu('model3d')} + > + + 文生 3D 模型 + + + 图生 3D 模型 + + , + ) + : null} ; function getCanvasToolForGenerationMode( @@ -1216,6 +1219,7 @@ export function useImageCanvasGenerationWorkflow({ const [isPickingUiDesignSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas] = useState(false); const [isMusicMenuOpen, setIsMusicMenuOpen] = useState(false); + const [isModel3dMenuOpen, setIsModel3dMenuOpen] = useState(false); const [isPublicationMenuOpen, setIsPublicationMenuOpen] = useState(false); const [isPublicationReferenceMenuOpen, setIsPublicationReferenceMenuOpen] = useState(false); @@ -1485,6 +1489,7 @@ export function useImageCanvasGenerationWorkflow({ setIsUiDesignSpecMenuOpen(false); setIsPickingUiDesignSpecFromCanvas(false); setIsMusicMenuOpen(false); + setIsModel3dMenuOpen(false); setIsPublicationMenuOpen(false); setIsPublicationReferenceMenuOpen(false); setIsPickingPublicationReferenceFromCanvas(false); @@ -1741,6 +1746,31 @@ export function useImageCanvasGenerationWorkflow({ viewport, ]); + const openModel3dGenerationDialog = useCallback( + (mode: 'model3d-text-to-model' | 'model3d-image-to-model') => { + openPlacedCanvasGenerationDialog( + createModel3dGenerationDialogDraft({ canvasSize, viewport, mode }), + ); + activateCanvasGenerationEntry('model3d'); + }, + [ + activateCanvasGenerationEntry, + canvasSize, + openPlacedCanvasGenerationDialog, + viewport, + ], + ); + + const openModel3dTextToModelDialog = useCallback( + () => openModel3dGenerationDialog('model3d-text-to-model'), + [openModel3dGenerationDialog], + ); + + const openModel3dImageToModelDialog = useCallback( + () => openModel3dGenerationDialog('model3d-image-to-model'), + [openModel3dGenerationDialog], + ); + const openEditDialog = useCallback( (sourceLayer: CanvasLayer) => { setMetadataLayer(null); @@ -4539,6 +4569,10 @@ export function useImageCanvasGenerationWorkflow({ setIsUiDesignSpecMenuOpen, isPickingUiDesignSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas, + isModel3dMenuOpen, + setIsModel3dMenuOpen, + openModel3dTextToModelDialog, + openModel3dImageToModelDialog, isMusicMenuOpen, setIsMusicMenuOpen, isPublicationMenuOpen, @@ -4643,6 +4677,10 @@ export function useImageCanvasGenerationWorkflow({ isPickingUiDesignSpecFromCanvas, isUiDesignSpecMenuOpen, isMusicMenuOpen, + isModel3dMenuOpen, + setIsModel3dMenuOpen, + openModel3dTextToModelDialog, + openModel3dImageToModelDialog, isTaskSidebarOpen, taskListRefreshKey, refreshTaskList, diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx index 0ffd68ffd..5034e7fac 100644 --- a/src/hooks/useResolvedAssetReadUrl.test.tsx +++ b/src/hooks/useResolvedAssetReadUrl.test.tsx @@ -372,6 +372,60 @@ describe('useResolvedAssetReadUrl', () => { ).toBe('/creation-type-references/puzzle.webp'); }); + test('换 objectKey 时旧地址的归属先清空,不会冒充新资源', async () => { + const signedResponse = (signedUrl: string) => + new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey: 'oss/first.png', + signedUrl, + expiresAt: '2099-01-01T00:10:00Z', + }, + }, + error: null, + meta: { + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', + latencyMs: 1, + timestamp: '2099-01-01T00:00:00Z', + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + // 第二个 objectKey 的换签一直挂起:这正是「新资源 + 旧地址」同时存在的那一帧。 + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + signedResponse('https://signed.example.com/first.png'), + ) + .mockImplementationOnce(() => new Promise(() => {})); + + function ObjectKeyProbe({ objectKey }: { objectKey: string }) { + const { resolvedUrl, resolvedObjectKey } = useResolvedAssetReadUrl(null, { + objectKey, + }); + return ( + {`${resolvedObjectKey}|${resolvedUrl}`} + ); + } + + const { rerender } = render(); + await waitFor(() => { + expect(screen.getByLabelText('资源地址').textContent).toBe( + 'oss/first.png|https://signed.example.com/first.png', + ); + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByLabelText('资源地址').textContent).toBe('|'); + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + test('普通封面加载失败时切换到显式 fallbackSrc', async () => { render( { if (!normalizedSource && !normalizedObjectKey) { setResolvedUrl(''); setIsResolving(false); + setResolvedObjectKey(''); return; } if (!shouldResolve) { setResolvedUrl(normalizedSource); setIsResolving(false); + // 这一步没有换签,地址就是入参本身,不对应任何 objectKey。 + setResolvedObjectKey(''); return; } @@ -46,6 +53,8 @@ export function useResolvedAssetReadUrl( // 生成资源通常是 OSS 私有对象;签名 URL 未就绪前不能把裸 generated 路径交给 img 触发无鉴权 GET。 setResolvedUrl(''); setIsResolving(true); + // 换签期间清掉归属:调用方据此知道手上的旧地址已经不是当前资源的。 + setResolvedObjectKey(''); const resolvePromise = normalizedObjectKey ? getSignedAssetReadUrl( @@ -67,12 +76,14 @@ export function useResolvedAssetReadUrl( .then((nextUrl) => { if (!cancelled) { setResolvedUrl(nextUrl); + setResolvedObjectKey(normalizedObjectKey); } }) .catch(() => { if (!cancelled) { // 签名失败时保持空 src,避免继续请求无签名的私有对象兼容路径。 setResolvedUrl(''); + setResolvedObjectKey(''); } }) .finally(() => { @@ -96,5 +107,7 @@ export function useResolvedAssetReadUrl( resolvedUrl, isResolving, shouldResolve, + /** `resolvedUrl` 对应的 objectKey;地址与当前资源不匹配或未就绪时为空串。 */ + resolvedObjectKey, }; } diff --git a/src/index.css b/src/index.css index 7b30e49c8..160285b3e 100644 --- a/src/index.css +++ b/src/index.css @@ -5990,6 +5990,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { box-shadow: 0 8px 18px rgba(79, 70, 229, 0.24); } +.image-canvas-editor__kind-badge--model3d { + background: rgba(30, 64, 175, 0.94); + box-shadow: 0 8px 18px rgba(30, 64, 175, 0.26); +} + .image-canvas-editor__kind-menu-anchor { position: absolute; width: 0; @@ -6690,12 +6695,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { ); } +/* + * 画布底部工具栏:13 个工具 + 3 个分隔符在 34rem 上限下会溢出,溢出后的横向滚动 + * 会把尾部工具整段裁掉(滚动条按设计不可见)。这里给足一行放得下的宽度,并在窄屏 + * 交给共享工具栏的换行能力,取「换行」不取「滚动」。 + */ .image-canvas-editor__bottom-toolbar { position: absolute; left: 50%; bottom: 0.85rem; z-index: 18; - max-width: min(calc(100% - 6.6rem), 34rem); + max-width: min(calc(100% - 6.6rem), 42rem); + justify-content: center; transform: translateX(-50%); } @@ -6795,6 +6806,45 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { gap: 0.18rem 0.72rem; } +/* 3D 面板同时带 `--image`:两边的 grid/width/gap 声明特异性相同,只靠先后顺序取胜太脆, + 这里显式把 `--image` 一起写进选择器,规则顺序变了也不会被两栏布局顶掉。 */ +.image-canvas-editor__generation-composer--model3d.image-canvas-editor__generation-composer--image { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto auto; + width: min(34rem, calc(100% - 1.5rem)); + gap: 0.3rem 0.72rem; +} + +.image-canvas-editor__model3d-options { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem 0.85rem; + grid-column: 1 / -1; +} + +.image-canvas-editor__model3d-tier-group { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.16rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.05); +} + +.image-canvas-editor__model3d-tier { + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; +} + +.image-canvas-editor__model3d-tier[aria-pressed='true'] { + background: rgba(255, 255, 255, 0.94); + color: #0f172a; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12); +} + .image-canvas-editor__generation-composer--video { grid-template-rows: auto auto auto; gap: 0.18rem 0.72rem; diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 55003d328..2ea4f035d 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -10,6 +10,11 @@ import { } from '../../../packages/shared/src/contracts/editorAudio'; import type { EditorSceneGenerationRequest } from '../../../packages/shared/src/contracts/editorScene'; import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration'; +import type { + Model3dImageToModelRequest, + Model3dModelVersion, + Model3dTextToModelRequest, +} from '../../../packages/shared/src/contracts/model3d'; import { requestJson } from '../apiClient'; import { EDITOR_GENERATION_REQUEST_RETRY_OPTIONS } from './editorRetryOptions'; @@ -56,9 +61,13 @@ const EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZATION_API = * 覆盖简化两个业务语义轮加单轮 transport retry 的服务端预算,同时为浏览器到 BFF 的 * 悬挂连接提供有界退出;超时后走既有失败路径恢复 idle,不写回候选。 */ +/** 3D 提交只做校验与入队,毫秒级返回,不沿用生成链路的分钟级超时。 */ +export const EDITOR_MODEL3D_SUBMISSION_TIMEOUT_MS = 60_000; export const EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS = 180_000; export const EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS = 180_000; const EDITOR_GENERATION_PRICING_API = '/api/editor/generation-pricing'; +const EDITOR_MODEL3D_TEXT_TO_MODEL_API = '/api/assets/tripo/text-to-model'; +const EDITOR_MODEL3D_IMAGE_TO_MODEL_API = '/api/assets/tripo/image-to-model'; const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; const EDITOR_IMAGE_REFERENCE_LIMIT = 5; const EDITOR_QUICK_EDIT_REFERENCE_LIMIT = 9; @@ -715,8 +724,44 @@ export type EditorGenerationModelPricing = { prices?: Record; }; +export type Model3dPricingEndpoint = 'text-to-model' | 'image-to-model'; + +export type Model3dAddOnPriceKey = + | 'hdTexture' + | 'ultraTexture' + | 'hdGeometry' + | 'quadMesh' + | 'smartLowPoly' + | 'generateParts'; + +/** + * 3D 生成的定价段:底价按 `endpoint × modelVersion × 是否有贴图` 拆分, + * add-on 按请求参数判定后叠加。整段缺失即 3D 生成不可用(后端 fail closed), + * 前端不内置任何兜底数值。 + */ +export type Model3dBasePrice = { + noTexture: number; + texture: number; +}; + +export type Model3dPricingConfig = { + /** + * 底价按 `endpoint × modelVersion` 二维展开。两个键都写成契约枚举而不是 `string`: + * 后端公开读模型保证「每个契约版本 × 两个端点」都有底价,用联合类型做键能让写错的 + * 版本号在编译期暴露,也能在契约新增版本后逼着调用方处理新键。 + */ + basePrices?: Partial< + Record< + Model3dPricingEndpoint, + Partial> + > + > | null; + addOnPrices?: Partial> | null; +}; + export type EditorGenerationPricingConfig = { models: Record; + model3d?: Model3dPricingConfig | null; }; export type EditorProjectSnapshot = { @@ -1691,6 +1736,75 @@ export function optimizeEditorSoundEffectPrompt( ); } +export type Model3dGenerationSubmissionResponse = { + operationId: string; + jobKind: string; + status: ExternalGenerationJobStatusRecord; + statusUrl: string; +}; + +/** + * 3D 文生提交:端点与请求体在同一个函数签名里绑死。 + * + * 单写成「endpoint + 联合请求体」的函数时,端点与提交体是两个彼此独立的联合类型, + * 把 image-to-model 的请求体发到 text-to-model 地址同样编译通过 —— 服务端只会给出 + * 一个与实际错误无关的字段级报错。所以一个端点一个函数,配对关系由类型强制。 + * + * 两个端点都要求 `Idempotency-Key`,键由调用方铸造并与当次请求内容绑定。 + */ +export async function submitModel3dTextToModelRequest({ + body, + idempotencyKey, +}: { + // 提交体直接用端点契约:形状不符(落点缺失、generation 结构错)在编译期就会暴露。 + body: Model3dTextToModelRequest; + idempotencyKey: string; +}) { + return postModel3dGenerationRequest( + EDITOR_MODEL3D_TEXT_TO_MODEL_API, + body, + idempotencyKey, + ); +} + +/** 3D 图生提交:与文生提交同形,只换端点与请求体契约。 */ +export async function submitModel3dImageToModelRequest({ + body, + idempotencyKey, +}: { + body: Model3dImageToModelRequest; + idempotencyKey: string; +}) { + return postModel3dGenerationRequest( + EDITOR_MODEL3D_IMAGE_TO_MODEL_API, + body, + idempotencyKey, + ); +} + +async function postModel3dGenerationRequest( + url: string, + body: Model3dTextToModelRequest | Model3dImageToModelRequest, + idempotencyKey: string, +) { + return requestJson( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(body), + }, + '提交 3D 模型生成失败', + { + timeoutMs: EDITOR_MODEL3D_SUBMISSION_TIMEOUT_MS, + retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, + }, + ); +} + export async function generateEditorBackgroundMusic( input: EditorBackgroundMusicGenerationInput, ) { diff --git a/vitest.config.ts b/vitest.config.ts index 76a7753c4..8d1516d8a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -166,6 +166,8 @@ export default defineConfig({ 'packages/image-canvas-react/src/**/*.test.ts', 'packages/image-canvas-react/src/**/*.test.tsx', 'packages/agc-plugin-sdk/src/**/*.test.ts', + 'packages/model3d-viewer/src/**/*.test.ts', + 'packages/model3d-viewer/src/**/*.test.tsx', ], }, });