From 933cf9dab48b206b3685c7b1326f83e18f0c49b3 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 18 Aug 2026 23:36:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(workflow-editor):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=8E=A5=E4=B8=8A=E4=B8=89=E6=B8=B2=E4=BA=8C=E7=9A=84=E5=BB=BA?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E5=85=A5=E5=8F=A3=E4=B8=8E=E8=B7=AF=E7=BA=BF?= =?UTF-8?q?=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 5 个 render3d 端点已合入并上线,但 frontend/src 里 render3d 零命中, 用户在界面上够不到这条路线。 - 母版确认闸:选中候选后先看放大图与零成本预检,再决定是否定为母版 - 建 3D 资产入口:图生 3D + 绑骨,标明 30 积分/约 ¥3.6、每造型一次性 - 动作生成方式多一条三渲二,判据是该造型有没有已确认的 model3dUrl - 去掉 controller 里已不成立的硬拦截(接口在 #277 已提供) --- frontend/src/entities/character/index.test.ts | 18 + frontend/src/entities/character/index.ts | 5 + .../character/outfit-playback.test.ts | 1 + frontend/src/entities/generation/api.test.ts | 1 + frontend/src/entities/generation/api.ts | 3 + frontend/src/entities/index.ts | 14 + frontend/src/entities/render3d/api.test.ts | 124 ++++++ frontend/src/entities/render3d/api.ts | 219 ++++++++++ frontend/src/entities/render3d/index.ts | 97 +++++ frontend/src/entities/workflow-run/README.md | 3 +- frontend/src/entities/workflow-run/index.ts | 5 +- .../export-package/character-export.test.ts | 1 + .../export-package/progressive-export.test.ts | 1 + frontend/src/features/export/index.test.ts | 1 + .../workflow-controller/controller.test.ts | 24 +- .../workflow-controller/controller.ts | 3 - .../workbench/minimal-workbench.test.tsx | 1 + .../pages/playtest/workbench/model.test.ts | 1 + .../src/pages/quick-start/service.test.ts | 7 + frontend/src/pages/quick-start/service.ts | 1 + .../character-template-confirmation.ts | 1 + .../src/pages/workflow-editor/index.test.tsx | 329 ++++++++++++++- frontend/src/pages/workflow-editor/index.tsx | 376 +++++++++++++++++- .../src/pages/workflow-editor/runtime.test.ts | 16 + frontend/src/pages/workflow-editor/runtime.ts | 10 + frontend/src/test/render3d-apis.ts | 59 +++ 26 files changed, 1282 insertions(+), 39 deletions(-) create mode 100644 frontend/src/entities/render3d/api.test.ts create mode 100644 frontend/src/entities/render3d/api.ts create mode 100644 frontend/src/entities/render3d/index.ts create mode 100644 frontend/src/test/render3d-apis.ts diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts index c11ba8d6..6916b77e 100644 --- a/frontend/src/entities/character/index.test.ts +++ b/frontend/src/entities/character/index.test.ts @@ -15,6 +15,7 @@ const characterDto = { name: '常态造型', description: '旅行装束', preview_url: 'https://cdn.windup.test/outfit.png', + model_3d_url: 'https://cdn.windup.test/outfit.glb', actions: [ { id: 'walk', @@ -118,6 +119,7 @@ describe('characterApis', () => { name: '常态造型', description: '旅行装束', previewUrl: 'https://cdn.windup.test/outfit.png', + model3dUrl: 'https://cdn.windup.test/outfit.glb', actions: [ { id: 'walk', @@ -226,6 +228,22 @@ describe('characterApis', () => { }) }) + it('defaults model3dUrl to null when the outfit has no 3D asset yet', async () => { + const characterApis = await loadCharacterApis(async () => + jsonResponse({ + ...characterDto, + character_data: { + version: 2, + outfits: [{ ...characterDto.character_data.outfits[0], model_3d_url: undefined }], + }, + }), + ) + + const character = await characterApis.get('51') + + expect(character.outfits[0]?.model3dUrl).toBeNull() + }) + it('deletes one Character through the backend resource path', async () => { let request: Request | undefined const characterApis = await loadCharacterApis(async (input, init) => { diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index df962658..8af9b5b4 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -42,6 +42,8 @@ export interface Outfit { name: string description: string | null previewUrl: string | null + /** 该造型已确认的绑骨 3D 模型;null = 三渲二在此造型上不可用,动作生成走 i2v。 */ + model3dUrl: string | null actions: Action[] } @@ -106,6 +108,7 @@ interface CharacterOutfitDto { name: string description: string | null preview_url: string | null + model_3d_url?: string | null actions: CharacterActionDto[] } @@ -164,6 +167,7 @@ function mapOutfit(dto: CharacterOutfitDto, characterId: string): Outfit { name: dto.name, description: dto.description, previewUrl: dto.preview_url, + model3dUrl: dto.model_3d_url ?? null, actions: dto.actions.map((action) => mapAction(action, dto.id)), } } @@ -209,6 +213,7 @@ function toOutfitDto(outfit: Outfit): CharacterOutfitDto { name: outfit.name, description: outfit.description, preview_url: outfit.previewUrl, + model_3d_url: outfit.model3dUrl, actions: outfit.actions.map(toActionDto), } } diff --git a/frontend/src/entities/character/outfit-playback.test.ts b/frontend/src/entities/character/outfit-playback.test.ts index 5404cdad..b950fc45 100644 --- a/frontend/src/entities/character/outfit-playback.test.ts +++ b/frontend/src/entities/character/outfit-playback.test.ts @@ -10,6 +10,7 @@ function makeOutfit(frameCount: number): Outfit { name: '常态造型', description: null, previewUrl: null, + model3dUrl: null, actions: [ { id: 'walk', diff --git a/frontend/src/entities/generation/api.test.ts b/frontend/src/entities/generation/api.test.ts index 0ea79157..ee7cb845 100644 --- a/frontend/src/entities/generation/api.test.ts +++ b/frontend/src/entities/generation/api.test.ts @@ -214,6 +214,7 @@ describe('createGenerationApis', () => { reference_video_url: null, reference_image_urls: ['https://cdn.test/frame-1.png', 'https://cdn.test/extra.png'], num_frames: 32, + outfit_id: 'default', }) expect(generation.result).toEqual({ type: 'complete_animation', diff --git a/frontend/src/entities/generation/api.ts b/frontend/src/entities/generation/api.ts index 27af0b14..5eef42c6 100644 --- a/frontend/src/entities/generation/api.ts +++ b/frontend/src/entities/generation/api.ts @@ -511,6 +511,9 @@ export function createGenerationApis(config: GenerationApiConfig): GenerationApi reference_video_url: null, reference_image_urls: referenceImageUrls, num_frames: 32, + // 后端据此查该造型的 model_3d_url 决定路线(三渲二 / i2v,#122);不发就恒为 + // None,路线永远选不中。 + outfit_id: nonEmptyString(input.outfitId, 'outfitId'), }) expectations.set(generation.id, expectation) return generation as Generation diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 368f68bd..dec6e4d8 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -78,6 +78,20 @@ export type { } from './generation' export type { GenerationApiConfig, GenerationTransport } from './generation/api' +/* 三渲二资产 —— 母版预检结果与造型级 3D 模型的建造状态 */ +export { createRender3DApis, render3DApis, Render3DContractError } from './render3d/api' +export type { + MasterFacts, + MasterPrecheckReport, + MasterRejectCode, + MasterWarning, + MasterWarningCode, + Render3DApis, + Render3DAsset, + Render3DAssetCost, + Render3DAssetState, +} from './render3d' + /* 媒体上传 —— 页面只依赖公开工厂与不透明引用,不处理 multipart 协议。 */ export { createMediaApis } from './media/api' export type { MediaApis, MediaCategory, MediaReference } from './media' diff --git a/frontend/src/entities/render3d/api.test.ts b/frontend/src/entities/render3d/api.test.ts new file mode 100644 index 00000000..dfaeacf5 --- /dev/null +++ b/frontend/src/entities/render3d/api.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { ApiClient } from '@/shared/api' +import { createRender3DApis, Render3DContractError } from './api' + +function clientReturning(data: unknown): { client: ApiClient; calls: string[] } { + const calls: string[] = [] + const client: ApiClient = { + request: vi.fn(async (path: string, options?: { method?: string }) => { + calls.push(`${options?.method ?? 'GET'} ${path}`) + return data + }) as ApiClient['request'], + requestList: vi.fn() as ApiClient['requestList'], + } + return { client, calls } +} + +const ASSET = { + asset_key: 'character-7/outfit-default', + state: 'awaiting_review', + model_3d_url: null, + review_model_url: 'https://cdn.test/pending.glb', + error: null, + cost: { + model3d_credits: 20, + autorig_credits: 10, + total_credits: 30, + total_cny: 3.6, + billing: 'postpaid', + scope: 'per_outfit_once', + }, +} + +const REPORT = { + accepted: true, + reject_code: null, + detail: '母版 400×600', + facts: { + width: 400, + height: 600, + subject_ratio: 0.19, + subject_area_ratio: 0.14, + limb_segments: [2, 2, 2, 2], + components: [33600], + }, + warnings: [{ code: 'limbs_fused', detail: '两腿之间量不到空隙' }], +} + +describe('三渲二资产适配器', () => { + it('把后端的蛇形字段翻成实体形状', async () => { + const { client } = clientReturning(ASSET) + const asset = await createRender3DApis(client).getOutfitAsset('7', 'outfit-default') + + expect(asset.state).toBe('awaiting_review') + expect(asset.reviewModelUrl).toBe('https://cdn.test/pending.glb') + expect(asset.cost.totalCredits).toBe(30) + expect(asset.cost.totalCny).toBe(3.6) + }) + + it('四个动作各自打到自己的路径上', async () => { + const { client, calls } = clientReturning(ASSET) + const apis = createRender3DApis(client) + await apis.getOutfitAsset('7', 'outfit-default') + await apis.buildOutfitAsset('7', 'outfit-default') + await apis.approveOutfitAsset('7', 'outfit-default') + await apis.discardOutfitAsset('7', 'outfit-default') + + expect(calls).toEqual([ + 'GET /render3d/characters/7/outfits/outfit-default', + 'POST /render3d/characters/7/outfits/outfit-default/build', + 'POST /render3d/characters/7/outfits/outfit-default/approve', + 'POST /render3d/characters/7/outfits/outfit-default/discard', + ]) + }) + + it('认不出的状态直接拒收', async () => { + const { client } = clientReturning({ ...ASSET, state: 'almost_done' }) + await expect(createRender3DApis(client).getOutfitAsset('7', 'a')).rejects.toBeInstanceOf( + Render3DContractError, + ) + }) + + it('成本字段缺一个就拒收——界面拿它让用户做付费决定', async () => { + const { total_cny: _dropped, ...partial } = ASSET.cost + const { client } = clientReturning({ ...ASSET, cost: partial }) + await expect(createRender3DApis(client).getOutfitAsset('7', 'a')).rejects.toBeInstanceOf( + Render3DContractError, + ) + }) + + it('预检结果带回量到的形态与警告', async () => { + const { client } = clientReturning(REPORT) + const report = await createRender3DApis(client).precheckMaster('https://cdn.test/m.png') + + expect(report.accepted).toBe(true) + expect(report.facts?.limbSegments).toEqual([2, 2, 2, 2]) + expect(report.warnings).toEqual([{ code: 'limbs_fused', detail: '两腿之间量不到空隙' }]) + }) + + it('通过却带着拒绝码属于后端两处判定分叉,必须拒收', async () => { + // 放行的话界面会显示"这张可用"而建资产那一步拒收,用户只看到一个无从解释的失败。 + const { client } = clientReturning({ ...REPORT, reject_code: 'aspect_too_wide' }) + await expect( + createRender3DApis(client).precheckMaster('https://cdn.test/m.png'), + ).rejects.toBeInstanceOf(Render3DContractError) + }) + + it('被拒却没有拒绝码同样拒收', async () => { + const { client } = clientReturning({ ...REPORT, accepted: false, facts: null, warnings: [] }) + await expect( + createRender3DApis(client).precheckMaster('https://cdn.test/m.png'), + ).rejects.toBeInstanceOf(Render3DContractError) + }) + + it('认不出的警告码拒收——界面会按码选文案,静默丢弃等于漏报', async () => { + const { client } = clientReturning({ + ...REPORT, + warnings: [{ code: 'has_text', detail: '画面里有文字' }], + }) + await expect( + createRender3DApis(client).precheckMaster('https://cdn.test/m.png'), + ).rejects.toBeInstanceOf(Render3DContractError) + }) +}) diff --git a/frontend/src/entities/render3d/api.ts b/frontend/src/entities/render3d/api.ts new file mode 100644 index 00000000..daf42644 --- /dev/null +++ b/frontend/src/entities/render3d/api.ts @@ -0,0 +1,219 @@ +import { createApiClient, getApiAccessToken, type ApiClient } from '@/shared/api' + +import type { + MasterFacts, + MasterPrecheckReport, + MasterRejectCode, + MasterWarning, + MasterWarningCode, + Render3DApis, + Render3DAsset, + Render3DAssetCost, + Render3DAssetState, +} from '.' + +/** 后端声称成功、但返回数据不符合 /render3d 契约。 */ +export class Render3DContractError extends Error { + constructor(message: string) { + super(`三渲二资产响应格式错误:${message}`) + this.name = 'Render3DContractError' + } +} + +const REJECT_CODES = new Set([ + 'undecodable', + 'no_subject', + 'subject_too_small', + 'aspect_too_wide', +]) + +const WARNING_CODES = new Set(['limbs_fused', 'extra_component']) + +const ASSET_STATES = new Set([ + 'absent', + 'building', + 'awaiting_review', + 'rigging', + 'ready', + 'failed', +]) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireRecord(value: unknown, field: string): Record { + if (!isRecord(value)) throw new Render3DContractError(`${field} 不是对象`) + return value +} + +function requireNumber(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Render3DContractError(`${field} 不是有限数字`) + } + return value +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== 'string') throw new Render3DContractError(`${field} 不是字符串`) + return value +} + +function nullableString(value: unknown, field: string): string | null { + if (value === null || value === undefined) return null + return requireString(value, field) +} + +function requireIntegers(value: unknown, field: string): number[] { + if (!Array.isArray(value)) throw new Render3DContractError(`${field} 不是数组`) + return value.map((item, index) => requireNumber(item, `${field}[${index}]`)) +} + +function parseFacts(value: unknown): MasterFacts | null { + if (value === null || value === undefined) return null + const facts = requireRecord(value, 'facts') + return { + width: requireNumber(facts.width, 'facts.width'), + height: requireNumber(facts.height, 'facts.height'), + subjectRatio: requireNumber(facts.subject_ratio, 'facts.subject_ratio'), + subjectAreaRatio: requireNumber(facts.subject_area_ratio, 'facts.subject_area_ratio'), + limbSegments: requireIntegers(facts.limb_segments, 'facts.limb_segments'), + components: requireIntegers(facts.components, 'facts.components'), + } +} + +function parseWarnings(value: unknown): MasterWarning[] { + if (!Array.isArray(value)) throw new Render3DContractError('warnings 不是数组') + return value.map((item, index) => { + const warning = requireRecord(item, `warnings[${index}]`) + const code = requireString(warning.code, `warnings[${index}].code`) + if (!WARNING_CODES.has(code as MasterWarningCode)) { + throw new Render3DContractError(`warnings[${index}].code 未知:${code}`) + } + return { + code: code as MasterWarningCode, + detail: requireString(warning.detail, `warnings[${index}].detail`), + } + }) +} + +function parseReport(value: unknown): MasterPrecheckReport { + const raw = requireRecord(value, '预检结果') + if (typeof raw.accepted !== 'boolean') { + throw new Render3DContractError('accepted 不是布尔值') + } + const rejectCode = nullableString(raw.reject_code, 'reject_code') + if (rejectCode !== null && !REJECT_CODES.has(rejectCode as MasterRejectCode)) { + throw new Render3DContractError(`reject_code 未知:${rejectCode}`) + } + // 通过却带着拒绝码、或被拒却没有拒绝码,都说明后端两处判定分叉了。放行的话 + // 界面会显示"这张可用"而下游拒收,用户只会看到一个无从解释的失败。 + if (raw.accepted === (rejectCode !== null)) { + throw new Render3DContractError('accepted 与 reject_code 自相矛盾') + } + return { + accepted: raw.accepted, + rejectCode: rejectCode as MasterRejectCode | null, + detail: requireString(raw.detail, 'detail'), + facts: parseFacts(raw.facts), + warnings: parseWarnings(raw.warnings), + } +} + +function parseCost(value: unknown): Render3DAssetCost { + const cost = requireRecord(value, 'cost') + return { + model3dCredits: requireNumber(cost.model3d_credits, 'cost.model3d_credits'), + autorigCredits: requireNumber(cost.autorig_credits, 'cost.autorig_credits'), + totalCredits: requireNumber(cost.total_credits, 'cost.total_credits'), + totalCny: requireNumber(cost.total_cny, 'cost.total_cny'), + billing: requireString(cost.billing, 'cost.billing'), + scope: requireString(cost.scope, 'cost.scope'), + } +} + +function parseAsset(value: unknown): Render3DAsset { + const raw = requireRecord(value, '3D 资产状态') + const state = requireString(raw.state, 'state') + if (!ASSET_STATES.has(state as Render3DAssetState)) { + throw new Render3DContractError(`state 未知:${state}`) + } + return { + state: state as Render3DAssetState, + model3dUrl: nullableString(raw.model_3d_url, 'model_3d_url'), + reviewModelUrl: nullableString(raw.review_model_url, 'review_model_url'), + error: nullableString(raw.error, 'error'), + cost: parseCost(raw.cost), + } +} + +function outfitPath(characterId: string, outfitId: string): string { + return `/render3d/characters/${encodeURIComponent(characterId)}/outfits/${encodeURIComponent(outfitId)}` +} + +/** + * 创建三渲二资产适配器。 + * + * 每个字段都在网络边界校验:状态与成本决定的是**要不要花 30 积分**,把没校验过的 + * 数据带进界面,等于让用户按一个来路不明的数字做付费决定。 + */ +export function createRender3DApis(client: ApiClient = defaultClient()): Render3DApis { + return { + async precheckMaster(imageUrl, canvas) { + return parseReport( + await client.request('/render3d/master-precheck', { + method: 'POST', + json: { + image_url: imageUrl, + canvas_width: canvas?.width ?? null, + canvas_height: canvas?.height ?? null, + }, + }), + ) + }, + + async getOutfitAsset(characterId, outfitId) { + return parseAsset(await client.request(outfitPath(characterId, outfitId))) + }, + + async buildOutfitAsset(characterId, outfitId) { + return parseAsset( + await client.request(`${outfitPath(characterId, outfitId)}/build`, { + method: 'POST', + }), + ) + }, + + async approveOutfitAsset(characterId, outfitId) { + return parseAsset( + await client.request(`${outfitPath(characterId, outfitId)}/approve`, { + method: 'POST', + }), + ) + }, + + async discardOutfitAsset(characterId, outfitId) { + return parseAsset( + await client.request(`${outfitPath(characterId, outfitId)}/discard`, { + method: 'POST', + }), + ) + }, + } +} + +function defaultClient(): ApiClient { + return createApiClient({ getAccessToken: getApiAccessToken }) +} + +export const render3DApis: Render3DApis = { + precheckMaster: (imageUrl, canvas) => createRender3DApis().precheckMaster(imageUrl, canvas), + getOutfitAsset: (characterId, outfitId) => + createRender3DApis().getOutfitAsset(characterId, outfitId), + buildOutfitAsset: (characterId, outfitId) => + createRender3DApis().buildOutfitAsset(characterId, outfitId), + approveOutfitAsset: (characterId, outfitId) => + createRender3DApis().approveOutfitAsset(characterId, outfitId), + discardOutfitAsset: (characterId, outfitId) => + createRender3DApis().discardOutfitAsset(characterId, outfitId), +} diff --git a/frontend/src/entities/render3d/index.ts b/frontend/src/entities/render3d/index.ts new file mode 100644 index 00000000..a0139b4f --- /dev/null +++ b/frontend/src/entities/render3d/index.ts @@ -0,0 +1,97 @@ +/** + * 三渲二资产 —— 母版预检与造型级 3D 模型的建造状态。 + * + * 两件事放在同一个实体下,是因为它们是同一条链上的两道闸:母版确认闸拿预检结果 + * 决定"这张要不要拿去建 3D",建资产闸拿状态决定"这个模型放不放行进绑骨"。 + */ + +/** 预检**拒绝**的原因。量到就是事实,这几种母版下游根本装不下。 */ +export type MasterRejectCode = + | 'undecodable' + | 'no_subject' + | 'subject_too_small' + | 'aspect_too_wide' + +/** 预检**警告**的原因。近似判据,合法母版也会命中,所以只提示、不挡路。 */ +export type MasterWarningCode = 'limbs_fused' | 'extra_component' + +export interface MasterWarning { + code: MasterWarningCode + detail: string +} + +/** 预检量到的形态。展示用,不参与任何判定 —— 判定后端已经做完了。 */ +export interface MasterFacts { + width: number + height: number + subjectRatio: number + subjectAreaRatio: number + /** 主体高度 70/80/88/94% 四处横切的连通段数;双足人形应有 2 段。 */ + limbSegments: number[] + /** 够大的连通块像素数,从大到小;超过一个 = 画面里还有别的东西。 */ + components: number[] +} + +export interface MasterPrecheckReport { + /** false = 这张母版下游装不下,换一张;警告不影响本字段。 */ + accepted: boolean + rejectCode: MasterRejectCode | null + detail: string + facts: MasterFacts | null + warnings: MasterWarning[] +} + +/** + * 造型 3D 资产走到哪一步。 + * + * `awaiting_review` 是一道**人工确认停点**,不是进度条上的一格:混元的模型生成即最终 + * (拓扑、绑点在生成那一步定死,事后改不动),不合格只能重新生成。所以要在花绑骨那 + * 10 积分之前让人看一眼。它不会自己变成 `rigging`。 + */ +export type Render3DAssetState = + | 'absent' + | 'building' + | 'awaiting_review' + | 'rigging' + | 'ready' + | 'failed' + +/** 建一次的报价。**由后端从计费实现取**,前端不抄常量——抄的那份会在调价时分叉。 */ +export interface Render3DAssetCost { + model3dCredits: number + autorigCredits: number + totalCredits: number + totalCny: number + /** 后付费 / 预付费。 */ + billing: string + /** per_outfit_once = 每造型一次性,不是每动作一次。 */ + scope: string +} + +export interface Render3DAsset { + state: Render3DAssetState + /** 绑骨模型 URL;非 null 即三渲二在该造型上可用。 */ + model3dUrl: string | null + /** + * 待审模型的下载地址。人得先真的看过它才谈得上"确认"——只躺在服务器磁盘上的话, + * 通过按钮就退化成一个必须点的步骤,反而制造了"已经审过"的假象。 + */ + reviewModelUrl: string | null + error: string | null + cost: Render3DAssetCost +} + +export interface Render3DApis { + /** 零成本母版预检。**不触发任何按次计费调用**,确认闸上可以随便调。 */ + precheckMaster( + imageUrl: string, + canvas?: { width: number; height: number }, + ): Promise + getOutfitAsset(characterId: string, outfitId: string): Promise + /** 触发图生 3D。**按次计费**,只能由用户的显式操作调用。 */ + buildOutfitAsset(characterId: string, outfitId: string): Promise + /** 人看过模型点头 → 继续绑骨。 */ + approveOutfitAsset(characterId: string, outfitId: string): Promise + /** 模型不合格 → 丢弃重来。 */ + discardOutfitAsset(characterId: string, outfitId: string): Promise +} diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index 553be5c7..d46fb5d4 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -11,7 +11,8 @@ - 节点通过 `dependsOnNodeIds` 保存直接前置依赖,因此边会与节点一起落库,不再依赖数组顺序猜测连线。 - 每个 Action 使用 `action-first-frame -> action-generation-method -> action-full-frame -> review` 四节点链;多条链共同依赖 `character-template`,角色母版通过后即可并行,不互相阻塞。 -- 资产生成方式当前可选 `video-cropping` 与 `3d-to-2d`。3D 转 2D 后端接口未提供前只保存选择并明确阻止提交,不伪装成视频路线。 +- 资产生成方式当前可选 `video-cropping` 与 `3d-to-2d`(三渲二)。后者只在所选造型已有 `Outfit.model3dUrl` + 时可选,判据是免费查询、不猜;没有资产就不提供这个选项,不让它悄悄退化成视频路线。 - Quick Start 与 Workflow Editor 是两种独立界面,但推进同一张节点图,核心数据不区分 `ai/manual driver`。 - 角色设定节点在 `input.characterId` 中保存所属角色。前端按项目列出 Run 后用该字段定位角色的唯一 Run;旧数据允许暂未绑定。 - 后端不提供 Revision 历史。重做时覆盖旧结果,并用 `nodeId + taskId` 防止旧请求串线。 diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 1c45613d..c657e2d8 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -16,7 +16,10 @@ export type WorkflowNodeStatus = (typeof WORKFLOW_NODE_STATUSES)[number] export type WorkflowNodePhase = (typeof WORKFLOW_NODE_PHASES)[number] export type WorkflowGenerationRole = (typeof WORKFLOW_GENERATION_ROLES)[number] -/** 动作资产的生产路线;3D 转 2D 接口尚未提供,但选择必须随 WorkflowRun 落库。 */ +/** + * 动作资产的生产路线。3D 转 2D(三渲二)只在所选造型已有绑骨 3D 模型时可选—— + * 判据是 `Outfit.model3dUrl`,没有资产就不提供这个选项。 + */ export type ActionGenerationMethod = 'video-cropping' | '3d-to-2d' /** 一个节点对后端 GenerationTask 的引用;节点可关联零个、一个或多个任务。 */ diff --git a/frontend/src/features/export-package/character-export.test.ts b/frontend/src/features/export-package/character-export.test.ts index 20ad244f..80401c7b 100644 --- a/frontend/src/features/export-package/character-export.test.ts +++ b/frontend/src/features/export-package/character-export.test.ts @@ -33,6 +33,7 @@ const character: Character = { name: '常态造型', description: null, previewUrl: '/master.png', + model3dUrl: null, actions: [ { id: 'walk', diff --git a/frontend/src/features/export-package/progressive-export.test.ts b/frontend/src/features/export-package/progressive-export.test.ts index 84fa11e6..d06b5e05 100644 --- a/frontend/src/features/export-package/progressive-export.test.ts +++ b/frontend/src/features/export-package/progressive-export.test.ts @@ -33,6 +33,7 @@ const character: Character = { name: '默认造型', description: null, previewUrl: '/master.png', + model3dUrl: null, actions: [], }, ], diff --git a/frontend/src/features/export/index.test.ts b/frontend/src/features/export/index.test.ts index c4f85e95..f4bf3d15 100644 --- a/frontend/src/features/export/index.test.ts +++ b/frontend/src/features/export/index.test.ts @@ -137,6 +137,7 @@ function characterFixture(): Character { name: '常态造型', description: null, previewUrl: 'https://assets.windup.test/outfit.png', + model3dUrl: null, actions: [ { id: 'idle', diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 0ce7ac39..405df3e9 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -724,7 +724,7 @@ describe('WorkflowController', () => { expect(workflow.getSaved()).toEqual(archived) }) - it('保存 3D 转 2D 选择,但接口提供前不误走视频生成', async () => { + it('选择三渲二生产方式后按该造型的 outfitId 提交完整动画请求', async () => { const run = createRun([ ...completedCharacterNodes(), firstFrameNode({ @@ -742,14 +742,22 @@ describe('WorkflowController', () => { 'action-walk:action-generation-method', '3d-to-2d', ) + await controller.generateCompleteAnimation('action-walk:action-full-frame', { + characterId: 'character-backend-1', + referenceMedia: [], + }) - await expect( - controller.generateCompleteAnimation('action-walk:action-full-frame', { - characterId: 'character-backend-1', - referenceMedia: [], - }), - ).rejects.toThrow('3D 转 2D 接口尚未提供') - expect(generation.apis.create).not.toHaveBeenCalled() + expect(controller.getWorkflow().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'action-walk:action-generation-method', + method: '3d-to-2d', + }), + ]), + ) + expect(generation.apis.create).toHaveBeenCalledWith( + expect.objectContaining({ type: 'complete_animation', outfitId: 'outfit-1' }), + ) }) it('角色母版通过后按显式边同时解锁多个 Action 首帧节点', async () => { diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index caf1b835..b67d6978 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -725,9 +725,6 @@ export function createWorkflowController({ if (node.phase !== 'ready') throw new Error('完整动画节点当前不能生成') const methodNode = findSingleDependencyNode(run, node, 'action-generation-method') if (!methodNode.method) throw new Error('尚未选择动作生成方式') - if (methodNode.method === '3d-to-2d') { - throw new Error('3D 转 2D 接口尚未提供,暂时不能开始生成') - } const firstFrameNode = findSingleDependencyNode(run, methodNode, 'action-first-frame') if (!firstFrameNode.selectedFirstFrameUrl) throw new Error('动作首帧尚未确认') const input: CompleteAnimationGenerationInput = { diff --git a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx index 86c4711c..c7d6eabf 100644 --- a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx +++ b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx @@ -29,6 +29,7 @@ const character: Character = { name: '常态造型', description: null, previewUrl: '/master.png', + model3dUrl: null, actions: [ { id: IDLE_ACTION_ID, diff --git a/frontend/src/pages/playtest/workbench/model.test.ts b/frontend/src/pages/playtest/workbench/model.test.ts index b762dd33..f5f1a2c8 100644 --- a/frontend/src/pages/playtest/workbench/model.test.ts +++ b/frontend/src/pages/playtest/workbench/model.test.ts @@ -20,6 +20,7 @@ const character: Character = { name: '常态造型', description: null, previewUrl: null, + model3dUrl: null, actions: [ { id: 'idle', diff --git a/frontend/src/pages/quick-start/service.test.ts b/frontend/src/pages/quick-start/service.test.ts index ac3218f9..390b69c9 100644 --- a/frontend/src/pages/quick-start/service.test.ts +++ b/frontend/src/pages/quick-start/service.test.ts @@ -134,6 +134,7 @@ function characterWithDefaultOutfit( name: '默认造型', description: null, previewUrl: 'template.png', + model3dUrl: null, actions, }, ], @@ -622,6 +623,7 @@ describe('createQuickStartService', () => { name: '默认造型', description: null, previewUrl: 'template.png', + model3dUrl: null, actions: [], }, ], @@ -842,6 +844,7 @@ describe('createQuickStartService', () => { name: '其他造型', description: null, previewUrl: 'unrelated.png', + model3dUrl: null, actions: [], }, ], @@ -923,6 +926,7 @@ describe('createQuickStartService', () => { name: '默认造型', description: null, previewUrl: 'template.png', + model3dUrl: null, actions: [], }, ], @@ -975,6 +979,7 @@ describe('createQuickStartService', () => { name: '默认造型', description: null, previewUrl: 'replacement.png', + model3dUrl: null, actions: [], }, ], @@ -1181,6 +1186,7 @@ describe('createQuickStartService', () => { name: '默认造型', description: null, previewUrl: 'candidate.png', + model3dUrl: null, actions: [], }, ], @@ -1318,6 +1324,7 @@ describe('createQuickStartService', () => { name: '默认造型', description: null, previewUrl: 'existing.png', + model3dUrl: null, actions: [], }, ], diff --git a/frontend/src/pages/quick-start/service.ts b/frontend/src/pages/quick-start/service.ts index 7b332be4..37f190c8 100644 --- a/frontend/src/pages/quick-start/service.ts +++ b/frontend/src/pages/quick-start/service.ts @@ -348,6 +348,7 @@ export function createQuickStartService({ name: '默认造型', description: null, previewUrl: selectedImageUrl, + model3dUrl: null, actions: [], }, ], diff --git a/frontend/src/pages/workflow-editor/character-template-confirmation.ts b/frontend/src/pages/workflow-editor/character-template-confirmation.ts index ffa815df..2a20729b 100644 --- a/frontend/src/pages/workflow-editor/character-template-confirmation.ts +++ b/frontend/src/pages/workflow-editor/character-template-confirmation.ts @@ -116,6 +116,7 @@ async function prepareCharacter( name: '常态造型', description: null, previewUrl: context.imageUrl, + model3dUrl: null, actions: [], }, ], diff --git a/frontend/src/pages/workflow-editor/index.test.tsx b/frontend/src/pages/workflow-editor/index.test.tsx index 3fc1508e..8e55316d 100644 --- a/frontend/src/pages/workflow-editor/index.test.tsx +++ b/frontend/src/pages/workflow-editor/index.test.tsx @@ -12,10 +12,12 @@ import { type GenerationApis, type MediaReference, type Project, + type Render3DApis, type WorkflowRun, type WorkflowRunApis, } from '@/entities' import { createWorkflowController, type WorkflowController } from '@/features/workflow-controller' +import { absentAsset, acceptedReport, stubRender3DApis } from '@/test/render3d-apis' import { WorkflowEditorPage } from './index' import type { WorkflowEditorSession } from './runtime' @@ -353,7 +355,7 @@ describe('WorkflowEditorPage real runtime boundary', () => { renderEditor('/workflow-editor/42') fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) - fireEvent.click(screen.getByRole('button', { name: '确认身份母版' })) + fireEvent.click(await screen.findByRole('button', { name: '确认为定妆母版' })) await waitFor(() => expect(confirmCharacterTemplate).toHaveBeenCalledWith( @@ -548,6 +550,47 @@ describe('WorkflowEditorPage real runtime boundary', () => { ) }) + it('该造型没有 3D 资产时禁用三渲二选项并给出原因', async () => { + const workflow = selectingGenerationMethodWorkflow() + const session = createSession(workflow, { character: characterFixture() }) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + const render3dButton = await screen.findByRole('button', { name: /三渲二/ }) + expect(render3dButton.hasAttribute('disabled')).toBe(true) + expect(screen.getByText(/暂无绑骨 3D 模型/)).toBeTruthy() + + fireEvent.click(render3dButton) + expect(workflow.nodes.find((node) => node.type === 'action-generation-method')).toMatchObject({ + method: null, + }) + }) + + it('该造型已有 3D 资产时可以选择三渲二并据此提交完整动画', async () => { + const workflow = selectingGenerationMethodWorkflow() + const character = characterFixture() + character.outfits[0]!.model3dUrl = 'https://assets.windup.test/day.glb' + const session = createSession(workflow, { character }) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + const render3dButton = await screen.findByRole('button', { name: '三渲二' }) + expect(render3dButton.hasAttribute('disabled')).toBe(false) + fireEvent.click(render3dButton) + + await waitFor(() => + expect(session.controller.getWorkflow().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'action-generation-method', + method: '3d-to-2d', + status: 'passed', + }), + ]), + ), + ) + }) + it('发布与审核命令失败时显示错误并释放分支锁', async () => { const publishReviewedAction = vi.fn(() => Promise.reject(new Error('审核保存失败'))) const session = createSession(reviewingActionWorkflow(), { @@ -763,9 +806,7 @@ describe('WorkflowEditorPage real runtime boundary', () => { renderEditor('/workflow-editor/42') fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) - expect( - (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, - ).toBe(false) + expect(await screen.findByRole('button', { name: '确认为定妆母版' })).toBeTruthy() act(() => controlled.emit(failedTemplateWorkflow())) fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' })) @@ -776,9 +817,8 @@ describe('WorkflowEditorPage real runtime boundary', () => { ), ) - expect( - (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, - ).toBe(true) + // 选择失效后母版确认闸整个收起来:没有被选中的候选,就没有"这一张"可以确认。 + expect(screen.queryByRole('button', { name: '确认为定妆母版' })).toBeNull() }) it('Generation 候选集合变化时使旧选择失效', async () => { @@ -794,9 +834,8 @@ describe('WorkflowEditorPage real runtime boundary', () => { ), ) - expect( - (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, - ).toBe(true) + // 选择失效后母版确认闸整个收起来:没有被选中的候选,就没有"这一张"可以确认。 + expect(screen.queryByRole('button', { name: '确认为定妆母版' })).toBeNull() }) it('生成接口提交失败后仍保留角色候选重试入口', async () => { @@ -951,6 +990,257 @@ describe('WorkflowEditorPage real runtime boundary', () => { }) }) +/** + * 母版确认闸。挑中一张候选之后、把它当母版用之前的那个停点。 + * + * 值钱的地方是位置:一张母版约 ¥0.29,图生 3D 一次 ¥2.40,而混元的模型生成即最终, + * 母版不合格只能整个重来。所以这里的用例锁的是"能不能在最便宜的位置退回去"。 + */ +describe('母版确认闸', () => { + async function openGate(render3d?: Render3DApis) { + const session = createSession(selectingTemplateWorkflow(3, 'character-task'), { + generationApis: generationApisFixture({ + get: vi.fn().mockResolvedValue(characterGeneration('character')), + }), + render3d, + }) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) + return session + } + + it('挑中候选后先展示放大的母版,不直接进入下一步', async () => { + await openGate() + + expect((await screen.findByRole('img', { name: '待确认定妆母版' })).getAttribute('src')).toBe( + 'https://assets.windup.test/character.png', + ) + expect(screen.getByRole('button', { name: '确认为定妆母版' })).toBeTruthy() + expect(screen.getByRole('button', { name: '重新生成三张' })).toBeTruthy() + }) + + it('预检拒绝时不许确认,并说清为什么', async () => { + await openGate( + stubRender3DApis({ + precheckMaster: async () => + acceptedReport({ + accepted: false, + rejectCode: 'aspect_too_wide', + detail: '主体 w/h=4.10 超过 3.10;下游画布装不下', + facts: null, + }), + }), + ) + + const confirm = await screen.findByRole('button', { name: '确认为定妆母版' }) + await waitFor(() => expect((confirm as HTMLButtonElement).disabled).toBe(true)) + expect(screen.getByRole('alert').textContent).toContain('下游画布装不下') + }) + + it('警告只提示不挡路——侧视角色两腿重叠时这条判据必然误报', async () => { + await openGate( + stubRender3DApis({ + precheckMaster: async () => + acceptedReport({ + warnings: [{ code: 'limbs_fused', detail: '两腿之间量不到空隙' }], + }), + }), + ) + + expect(await screen.findByText(/两腿之间量不到空隙/)).toBeTruthy() + expect( + (screen.getByRole('button', { name: '确认为定妆母版' }) as HTMLButtonElement).disabled, + ).toBe(false) + }) + + it('预检本身跑不通不连带把人挡在外面——它是旁证,不是准入条件', async () => { + await openGate( + stubRender3DApis({ + precheckMaster: async () => { + throw new Error('预检服务未装配') + }, + }), + ) + + expect(await screen.findByText(/预检服务未装配/)).toBeTruthy() + expect( + (screen.getByRole('button', { name: '确认为定妆母版' }) as HTMLButtonElement).disabled, + ).toBe(false) + }) + + it('重新生成三张会先复位节点再重新提交,不复用旧候选', async () => { + const session = await openGate() + const restartFromNode = vi.spyOn(session.controller, 'restartFromNode') + const generateCharacterTemplate = vi.spyOn(session.controller, 'generateCharacterTemplate') + + fireEvent.click(await screen.findByRole('button', { name: '重新生成三张' })) + + await waitFor(() => expect(restartFromNode).toHaveBeenCalledWith('character-template')) + await waitFor(() => expect(generateCharacterTemplate).toHaveBeenCalled()) + expect(generateCharacterTemplate.mock.calls[0]?.[0]).toBe('character-setup') + // 复位后没有被选中的候选,闸自然收起来——旧选择不会被当成对新三张的选择。 + await waitFor(() => expect(screen.queryByRole('button', { name: '确认为定妆母版' })).toBeNull()) + }) +}) + +/** + * 建 3D 资产入口。原本用户看得到"该造型暂无绑骨 3D 模型",却没有任何地方能去建。 + * + * 用例锁两件事:**按次计费的成本必须先说**,以及**人工确认闸不点头就不绑骨**。 + */ +describe('建 3D 资产入口', () => { + function sessionWithConfirmedMaster(render3d: Render3DApis) { + const character = characterFixture() + const session = createSession(completedTemplateWorkflow('42'), { + character: { + ...character, + outfits: [{ ...character.outfits[0]!, previewUrl: 'https://assets.windup.test/42.png' }], + }, + render3d, + }) + defaultSessionLoader.mockResolvedValue(session) + return session + } + + it('母版还没确认时根本没有建 3D 资产这个入口', async () => { + const buildOutfitAsset = vi.fn() + const session = createSession(selectingTemplateWorkflow(3, 'character-task'), { + generationApis: generationApisFixture({ + get: vi.fn().mockResolvedValue(characterGeneration('character')), + }), + render3d: stubRender3DApis({ buildOutfitAsset }), + }) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) + await screen.findByRole('button', { name: '确认为定妆母版' }) + + expect(screen.queryByLabelText('三渲二 3D 资产')).toBeNull() + expect(screen.queryByRole('button', { name: /建 3D 资产/ })).toBeNull() + expect(buildOutfitAsset).not.toHaveBeenCalled() + }) + + it('触发按次计费之前把积分和金额摆在按钮上', async () => { + sessionWithConfirmedMaster(stubRender3DApis()) + renderEditor('/workflow-editor/42') + + const build = await screen.findByRole('button', { + name: '建 3D 资产(30 积分 · 约 ¥3.6)', + }) + expect(build).toBeTruthy() + expect( + screen.getByText(/图生 3D 20 积分 \+ 绑骨 10 积分 = 30 积分(后付费约 ¥3.6)/), + ).toBeTruthy() + expect(screen.getByText(/每造型一次性/)).toBeTruthy() + }) + + it('成本数字来自后端返回,不是前端写死的常量', async () => { + sessionWithConfirmedMaster( + stubRender3DApis({ + getOutfitAsset: async () => + absentAsset({ + cost: { + model3dCredits: 25, + autorigCredits: 10, + totalCredits: 35, + totalCny: 4.2, + billing: 'postpaid', + scope: 'per_outfit_once', + }, + }), + }), + ) + renderEditor('/workflow-editor/42') + + expect( + await screen.findByRole('button', { name: '建 3D 资产(35 积分 · 约 ¥4.2)' }), + ).toBeTruthy() + }) + + it('模型出来后停在确认闸上,没人点头就绝不绑骨', async () => { + const approveOutfitAsset = vi.fn(async () => absentAsset({ state: 'ready' })) + let asset = absentAsset() + sessionWithConfirmedMaster( + stubRender3DApis({ + getOutfitAsset: async () => asset, + buildOutfitAsset: async () => { + asset = absentAsset({ + state: 'awaiting_review', + reviewModelUrl: 'https://assets.windup.test/pending.glb', + }) + return asset + }, + approveOutfitAsset, + }), + ) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: /建 3D 资产/ })) + + expect(await screen.findByText(/模型已生成,等你确认/)).toBeTruthy() + expect(approveOutfitAsset).not.toHaveBeenCalled() + + // 待审模型必须真的能打开——只躺在服务器上的话,"通过"就退化成一个必须点的步骤。 + expect( + (screen.getByRole('link', { name: /下载待审模型/ }) as HTMLAnchorElement).getAttribute( + 'href', + ), + ).toBe('https://assets.windup.test/pending.glb') + + fireEvent.click(screen.getByRole('button', { name: /通过 · 继续绑骨/ })) + await waitFor(() => expect(approveOutfitAsset).toHaveBeenCalledWith('9', 'day')) + }) + + it('判不合格时走丢弃重做,并说清要再花一次图生 3D 的钱', async () => { + const discardOutfitAsset = vi.fn(async () => absentAsset()) + const approveOutfitAsset = vi.fn(async () => absentAsset({ state: 'ready' })) + sessionWithConfirmedMaster( + stubRender3DApis({ + getOutfitAsset: async () => + absentAsset({ + state: 'awaiting_review', + reviewModelUrl: 'https://assets.windup.test/pending.glb', + }), + approveOutfitAsset, + discardOutfitAsset, + }), + ) + renderEditor('/workflow-editor/42') + + fireEvent.click( + await screen.findByRole('button', { name: '不合格 · 重新生成(再花 20 积分)' }), + ) + + await waitFor(() => expect(discardOutfitAsset).toHaveBeenCalledWith('9', 'day')) + expect(approveOutfitAsset).not.toHaveBeenCalled() + }) + + it('在跑的两段付费调用给状态说明,不给一个和真实进度无关的进度条', async () => { + sessionWithConfirmedMaster( + stubRender3DApis({ getOutfitAsset: async () => absentAsset({ state: 'rigging' }) }), + ) + renderEditor('/workflow-editor/42') + + expect(await screen.findByText(/正在自动绑骨/)).toBeTruthy() + expect(screen.queryByRole('progressbar')).toBeNull() + }) + + it('上一次没建成时把原因摆出来,并允许重来', async () => { + sessionWithConfirmedMaster( + stubRender3DApis({ + getOutfitAsset: async () => + absentAsset({ state: 'failed', error: '绑骨轮询 10 分钟仍未出结果' }), + }), + ) + renderEditor('/workflow-editor/42') + + expect(await screen.findByText(/绑骨轮询 10 分钟仍未出结果/)).toBeTruthy() + expect(screen.getByRole('button', { name: /建 3D 资产/ })).toBeTruthy() + }) +}) + function renderEditor(path: string) { return render( @@ -965,6 +1255,7 @@ interface SessionFixtureOptions { character?: Character | null generationApis?: GenerationApis workflowRunApis?: Partial + render3d?: Render3DApis uploadReferenceImage?: WorkflowEditorSession['uploadReferenceImage'] publishReviewedAction?(reviewNodeId: string): Promise } @@ -1000,6 +1291,7 @@ function createSession( controller, project: projectFixture(), character: options.character ?? null, + render3d: options.render3d ?? stubRender3DApis(), uploadReferenceImage: options.uploadReferenceImage ?? vi.fn(() => Promise.reject(new Error('媒体上传服务尚未装配'))), @@ -1098,6 +1390,19 @@ function completedTemplateWorkflow(id: string): WorkflowRun { } } +/** 首帧已确认、正等待用户选择生产方式的工作流——三渲二可选性的判定发生在这一步。 */ +function selectingGenerationMethodWorkflow(): WorkflowRun { + const workflow = reviewingActionWorkflow() + for (const node of workflow.nodes) { + if (node.type === 'action-generation-method') { + node.status = 'active' + node.phase = 'selecting' + node.method = null + } + } + return workflow +} + function reviewingActionWorkflow(...actionIds: string[]): WorkflowRun { const workflow = completedTemplateWorkflow('42') workflow.version = 7 @@ -1230,6 +1535,7 @@ function createGenerationRaceSession( controller, project: projectFixture(), character: null, + render3d: stubRender3DApis(), uploadReferenceImage: vi.fn(() => Promise.reject(new Error('媒体上传服务尚未装配'))), confirmCharacterTemplate: vi.fn(async () => characterFixture()), publishReviewedAction: vi.fn(async () => Promise.reject(new Error('资产发布未装配'))), @@ -1288,6 +1594,7 @@ function createRestartSelectionSession(options: { status?: Generation['status'] controller, project: projectFixture(), character: null, + render3d: stubRender3DApis(), uploadReferenceImage: vi.fn(() => Promise.reject(new Error('媒体上传服务尚未装配'))), confirmCharacterTemplate: vi.fn(async () => characterFixture()), publishReviewedAction: vi.fn(async () => Promise.reject(new Error('资产发布未装配'))), @@ -1346,6 +1653,7 @@ function characterFixture(): Character { name: '常态装', description: null, previewUrl: null, + model3dUrl: null, actions: [], }, { @@ -1354,6 +1662,7 @@ function characterFixture(): Character { name: '夜行装', description: null, previewUrl: null, + model3dUrl: null, actions: [], }, ], diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx index 25b1ecb7..5a6613d0 100644 --- a/frontend/src/pages/workflow-editor/index.tsx +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -18,8 +18,12 @@ import { type CharacterSetupWorkflowNode, type CharacterTemplateWorkflowNode, type Generation, + type MasterPrecheckReport, + type MasterWarning, type MediaReference, type Project, + type Render3DApis, + type Render3DAsset, type ReviewWorkflowNode, type WorkflowGenerationRole, type WorkflowNode, @@ -99,6 +103,15 @@ const CARD_SUMMARY = const CARD_TEXT = 'm-0 text-[11px] leading-[1.6] text-[var(--color-app-muted)]' +/** + * 三渲二判据只有一条:该造型有没有已确认的绑骨 3D 模型(`Outfit.model3dUrl`)。 + * 没有就不提供这个选项——猜一个"反正总能兜底成 i2v"等于让用户在不知情下换了路线。 + * 建模型本身是按次计费、每造型一次性(图生 3D + 绑骨),且生成后要人工确认模型才能继续绑骨。 + */ +const RENDER3D_UNAVAILABLE_HINT = + '该造型暂无绑骨 3D 模型,暂不能使用三渲二。到「身份母版」卡片上的「建 3D 资产」建一份:' + + '图生 3D + 自动绑骨,按次计费、每造型一次性,中间有一道人工确认;不合格只能重新生成,不能修改。' + /** 加号菜单里的条目:撑满菜单宽度的两行文字,跟卡片主按钮完全不同。 */ const MENU_ITEM = 'flex min-h-0 cursor-pointer flex-col gap-0.5 border-0 px-3 py-[9px] text-left ' + @@ -189,6 +202,7 @@ export function WorkflowEditorPage({ loadSession }: WorkflowEditorPageProps = {} uploadReferenceImage: session.uploadReferenceImage, publishReviewedAction: session.publishReviewedAction, project: session.project, + render3d: session.render3d, character, generations, exportModels, @@ -282,6 +296,8 @@ interface ProjectionInput { publishReviewedAction(reviewNodeId: ReviewWorkflowNode['id']): Promise project: Project character: Character | null + /** 母版预检与建 3D 资产;页面不直连适配器,替身注入只有会话这一个入口。 */ + render3d: Render3DApis generations: Record exportModels: ReadonlyMap selectedImages: Record @@ -545,19 +561,17 @@ function CharacterTemplateContent({ ))} - + {selectedImageUrl ? ( + + ) : ( +

先选一张候选,再决定是否把它定为母版。

+ )} ) } @@ -570,6 +584,14 @@ function CharacterTemplateContent({
已确认身份母版 身份已锁定 + {input.character && outfit ? ( + + ) : null} {outfit ? : null}
+ +
+ ) +} + +type MasterPrecheckState = + | { status: 'loading' } + | { status: 'done'; report: MasterPrecheckReport } + | { status: 'error'; message: string } + +/** 预检失败不影响确认:它是旁证,不是准入条件。判据坏了不该连带把人挡在外面。 */ +function useMasterPrecheck(input: ProjectionInput, imageUrl: string): MasterPrecheckState { + const [state, setState] = useState({ status: 'loading' }) + const { render3d } = input + const { width, height } = input.project.spriteSize + + useEffect(() => { + let cancelled = false + setState({ status: 'loading' }) + void render3d + .precheckMaster(imageUrl, { width, height }) + .then((report) => { + if (!cancelled) setState({ status: 'done', report }) + }) + .catch((cause: unknown) => { + if (!cancelled) setState({ status: 'error', message: errorMessage(cause, '母版预检失败') }) + }) + return () => { + cancelled = true + } + }, [height, imageUrl, render3d, width]) + + return state +} + +const WARNING_TITLE: Record = { + limbs_fused: '双腿可能粘连', + extra_component: '画面里还有别的东西', +} + +function MasterPrecheckReadout({ state }: { state: MasterPrecheckState }) { + if (state.status === 'loading') { + return ( +

+ 正在预检母版… +

+ ) + } + if (state.status === 'error') { + return ( +

+ 母版预检没跑成:{state.message}。这不影响确认,但下一步的形态问题得你自己看。 +

+ ) + } + const { report } = state + if (!report.accepted) { + return ( +

+ 这张不能用:{report.detail} +

+ ) + } + return ( +
+

{report.detail}

+ {report.warnings.map((warning) => ( +

+ {WARNING_TITLE[warning.code]} +
+ {warning.detail} +

+ ))} +
+ ) +} + +/** + * 建 3D 资产:把 `Render3DAssetBuilder` 那条链交到用户手里。 + * + * 三件事不能省: + * - **成本先说**。图生 3D + 绑骨按次计费,每造型一次性;数字由后端从计费实现取, + * 这里不抄常量。用户不知情就触发按次计费是红线。 + * - **人工确认闸不能自动放行**。模型出来后停在 `awaiting_review`,等人点头才绑骨。 + * - **不装进度条**。没有 3D 预览能力,就给状态、给模型下载地址、给怎么看的说明, + * 而不是转一个和真实进度无关的圈。 + */ +function Render3DAssetPanel({ + input, + characterId, + outfitId, + hasModel, +}: { + input: ProjectionInput + characterId: string + outfitId: string + hasModel: boolean +}) { + const { render3d } = input + const [asset, setAsset] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [refreshKey, setRefreshKey] = useState(0) + + const inFlight = asset?.state === 'building' || asset?.state === 'rigging' + + useEffect(() => { + let cancelled = false + const read = () => + render3d + .getOutfitAsset(characterId, outfitId) + .then((next) => { + if (!cancelled) setAsset(next) + }) + .catch((cause: unknown) => { + if (!cancelled) setError(errorMessage(cause, '读取 3D 资产状态失败')) + }) + void read() + // 两段付费调用各要几十秒到几分钟,跑在后端线程上,只能轮询。停在闸上时不轮询—— + // 那个状态只会因为人点按钮而改变,轮询它纯属浪费。 + if (!inFlight) + return () => { + cancelled = true + } + const timer = window.setInterval(() => void read(), 3000) + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [characterId, inFlight, outfitId, refreshKey, render3d]) + + const act = (operation: () => Promise) => { + if (busy) return + setBusy(true) + setError(null) + void operation() + .then((next) => setAsset(next)) + .catch((cause: unknown) => setError(errorMessage(cause, '操作 3D 资产失败'))) + .finally(() => { + setBusy(false) + setRefreshKey((key) => key + 1) + }) + } + + if (!asset) { + return

{error ?? '正在读取 3D 资产状态…'}

+ } + + const cost = asset.cost + const costLine = + `图生 3D ${cost.model3dCredits} 积分 + 绑骨 ${cost.autorigCredits} 积分 = ` + + `${cost.totalCredits} 积分(后付费约 ¥${cost.totalCny})。每造型一次性,做多少个动作都不再收。` + + return ( +
+ {error ?

{error}

: null} + {asset.state === 'absent' || asset.state === 'failed' ? ( + <> +

{costLine}

+ {asset.state === 'failed' && asset.error ? ( +

上次没建成:{asset.error}

+ ) : null} + + + ) : null} + + {asset.state === 'building' ? ( +

+ 正在图生 3D({cost.model3dCredits} 积分已计费)。这一步几十秒到几分钟, + 出来后会停下来等你确认,不会自己接着绑骨。 +

+ ) : null} + + {asset.state === 'awaiting_review' ? ( + <> +

+ 模型已生成,等你确认。混元的模型改不动——不合格只能重新生成, + 所以这一步别放水:绑骨还要再花 {cost.autorigCredits} 积分。 +

+ {asset.reviewModelUrl ? ( +

+ + 下载待审模型(.glb) + + :用 Blender 或任意 glTF 查看器打开,看四肢有没有粘连、有没有多出来的物体。 +

+ ) : ( +

待审模型暂时取不到地址,先别放行。

+ )} + + + + ) : null} + + {asset.state === 'rigging' ? ( +

+ 正在自动绑骨({cost.autorigCredits} 积分已计费)。完成后这个造型就能选三渲二了。 +

+ ) : null} + + {asset.state === 'ready' ? ( +

+ 3D 资产已就绪,这个造型可以走三渲二了{hasModel ? '' : '(刷新后生效)'}。 +

+ ) : null} +
+ ) +} + function ActionMenu({ input, templateNodeId }: { input: ProjectionInput; templateNodeId: string }) { const outfits = input.character?.outfits ?? [] const selectedOutfit = outfits.find((outfit) => outfit.id === input.selectedOutfitId) ?? null @@ -925,8 +1249,17 @@ function MethodContent({ const branchKey = branchKeyOf(node, input) const branchBusy = input.busyBranches.has(branchKey) if (node.status === 'failed') return - if (node.phase === 'completed') return

视频裁剪

+ if (node.phase === 'completed') { + return

{node.method === '3d-to-2d' ? '三渲二' : '视频裁剪'}

+ } if (node.status !== 'active') return + + const firstFrameNode = findDependency(input.run, node, 'action-first-frame') + const outfit = firstFrameNode + ? input.character?.outfits.find((candidate) => candidate.id === firstFrameNode.input.outfitId) + : null + const render3dReady = Boolean(outfit?.model3dUrl) + return (
- + {render3dReady ? null :

{RENDER3D_UNAVAILABLE_HINT}

}
) } diff --git a/frontend/src/pages/workflow-editor/runtime.test.ts b/frontend/src/pages/workflow-editor/runtime.test.ts index 10e710dc..0548ee97 100644 --- a/frontend/src/pages/workflow-editor/runtime.test.ts +++ b/frontend/src/pages/workflow-editor/runtime.test.ts @@ -13,6 +13,7 @@ import type { import { WorkflowRunConflictError } from '@/entities' import { registerApiAccessTokenProvider, registerApiUnauthorizedRecovery } from '@/shared/api' import { createDefaultRealWorkflowEditorSession, createRealWorkflowEditorSession } from './runtime' +import { stubRender3DApis } from '@/test/render3d-apis' describe('createRealWorkflowEditorSession', () => { it('通过公开 MediaApis 上传角色参考图并固定用途分类', async () => { @@ -70,6 +71,7 @@ describe('createRealWorkflowEditorSession', () => { workflowRunApis, generationApis, mediaApis: { upload: vi.fn() }, + render3d: stubRender3DApis(), projectApis, characterApis, onAsyncError: vi.fn(), @@ -121,6 +123,7 @@ describe('createRealWorkflowEditorSession', () => { subscribe: vi.fn(() => () => undefined), }, mediaApis: { upload: vi.fn() }, + render3d: stubRender3DApis(), projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, characterApis, onAsyncError: vi.fn(), @@ -144,6 +147,7 @@ describe('createRealWorkflowEditorSession', () => { subscribe: vi.fn(() => () => undefined), }, mediaApis: { upload: vi.fn() }, + render3d: stubRender3DApis(), projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, characterApis: { listByProject: vi.fn().mockResolvedValue({ @@ -192,6 +196,7 @@ describe('createRealWorkflowEditorSession', () => { subscribe: vi.fn(() => () => undefined), }, mediaApis: { upload: vi.fn() }, + render3d: stubRender3DApis(), projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, characterApis: { listByProject: vi.fn().mockResolvedValue({ @@ -292,6 +297,7 @@ describe('createRealWorkflowEditorSession', () => { update: vi.fn(async (character) => structuredClone(character)), remove, }, + render3d: stubRender3DApis(), onAsyncError, }) @@ -375,6 +381,7 @@ describe('createRealWorkflowEditorSession', () => { }), remove: vi.fn(), }, + render3d: stubRender3DApis(), onAsyncError: vi.fn(), }) @@ -424,6 +431,7 @@ describe('createRealWorkflowEditorSession', () => { }), remove: vi.fn(), }, + render3d: stubRender3DApis(), onAsyncError, }) @@ -453,6 +461,7 @@ describe('createRealWorkflowEditorSession', () => { subscribe: vi.fn(() => () => undefined), }, mediaApis: { upload: vi.fn() }, + render3d: stubRender3DApis(), projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, characterApis: { listByProject: vi.fn().mockResolvedValue({ @@ -512,6 +521,7 @@ describe('createRealWorkflowEditorSession', () => { update: vi.fn(), remove: vi.fn(), }, + render3d: stubRender3DApis(), onAsyncError: vi.fn(), }) @@ -556,6 +566,7 @@ describe('createRealWorkflowEditorSession', () => { update: updateCharacter, remove: vi.fn(), }, + render3d: stubRender3DApis(), onAsyncError: vi.fn(), }) @@ -619,6 +630,7 @@ describe('createRealWorkflowEditorSession', () => { update: updateCharacter, remove: vi.fn(), }, + render3d: stubRender3DApis(), onAsyncError: vi.fn(), }) @@ -694,6 +706,7 @@ describe('createRealWorkflowEditorSession', () => { }), remove: vi.fn(), }, + render3d: stubRender3DApis(), onAsyncError: vi.fn(), }) @@ -752,6 +765,7 @@ describe('createRealWorkflowEditorSession', () => { }), remove: vi.fn(), }, + render3d: stubRender3DApis(), onAsyncError, }) @@ -891,6 +905,7 @@ async function createCharacterTemplateSession( remove, }, mediaApis: options.mediaApis ?? { upload: vi.fn() }, + render3d: stubRender3DApis(), onAsyncError: vi.fn(), }) return { session, create, update, remove } @@ -956,6 +971,7 @@ function characterWithOutfitFixture(): Character { name: '常态造型', description: null, previewUrl: null, + model3dUrl: null, actions: [], }, ], diff --git a/frontend/src/pages/workflow-editor/runtime.ts b/frontend/src/pages/workflow-editor/runtime.ts index 77c7b751..83843315 100644 --- a/frontend/src/pages/workflow-editor/runtime.ts +++ b/frontend/src/pages/workflow-editor/runtime.ts @@ -7,6 +7,7 @@ import type { Project, ProjectApis, CharacterTemplateWorkflowNode, + Render3DApis, ReviewWorkflowNode, WorkflowRun, WorkflowRunApis, @@ -16,6 +17,7 @@ import { createAuthenticatedGenerationApis, createMediaApis, projectApis, + render3DApis, workflowRunApis, } from '@/entities' import { createCharacterAssetPublisher } from '@/features/export' @@ -36,6 +38,11 @@ export interface WorkflowEditorSession { uploadReferenceImage(file: File, signal?: AbortSignal): Promise /** 幂等发布动作资产,并与审核节点的保存作为一个用户命令处理。 */ publishReviewedAction(reviewNodeId: ReviewWorkflowNode['id']): Promise + /** + * 母版预检与建 3D 资产。两者都挂在会话上而不是页面直连适配器,理由与其余能力一致: + * 页面只消费会话,替身注入才有单一入口。 + */ + render3d: Render3DApis subscribeErrors(listener: (error: Error) => void): () => void dispose(): void } @@ -46,6 +53,7 @@ export interface RealWorkflowEditorDependencies { mediaApis: Pick projectApis: Pick characterApis: Pick + render3d: Render3DApis onAsyncError(error: Error): void } @@ -143,6 +151,7 @@ export async function createRealWorkflowEditorSession( controller, project, character: loadedCharacter, + render3d: dependencies.render3d, uploadReferenceImage(file, signal) { return dependencies.mediaApis.upload(file, 'reference-image', signal) }, @@ -225,6 +234,7 @@ export function createDefaultRealWorkflowEditorSession( mediaApis: createMediaApis(), projectApis, characterApis, + render3d: render3DApis, onAsyncError: () => undefined, }) } diff --git a/frontend/src/test/render3d-apis.ts b/frontend/src/test/render3d-apis.ts new file mode 100644 index 00000000..bba434dc --- /dev/null +++ b/frontend/src/test/render3d-apis.ts @@ -0,0 +1,59 @@ +import type { MasterPrecheckReport, Render3DApis, Render3DAsset } from '@/entities' + +/** + * 三渲二资产的测试替身。 + * + * 默认档刻意做成"母版通过、无警告 / 资产还没建"——这是最常见的背景,让不关心这条链的 + * 用例不必自己拼一份。要测闸的用例显式覆盖对应方法,覆盖不到的地方仍然是可预测的默认, + * 而不是 undefined 引发的运行时崩溃。 + */ +export const RENDER3D_COST: Render3DAsset['cost'] = { + model3dCredits: 20, + autorigCredits: 10, + totalCredits: 30, + totalCny: 3.6, + billing: 'postpaid', + scope: 'per_outfit_once', +} + +export function acceptedReport( + overrides: Partial = {}, +): MasterPrecheckReport { + return { + accepted: true, + rejectCode: null, + detail: '母版 400×600,主体 80×420(w/h 0.19,占幅 14.0%)', + facts: { + width: 400, + height: 600, + subjectRatio: 0.19, + subjectAreaRatio: 0.14, + limbSegments: [2, 2, 2, 2], + components: [33600], + }, + warnings: [], + ...overrides, + } +} + +export function absentAsset(overrides: Partial = {}): Render3DAsset { + return { + state: 'absent', + model3dUrl: null, + reviewModelUrl: null, + error: null, + cost: RENDER3D_COST, + ...overrides, + } +} + +export function stubRender3DApis(overrides: Partial = {}): Render3DApis { + return { + precheckMaster: async () => acceptedReport(), + getOutfitAsset: async () => absentAsset(), + buildOutfitAsset: async () => absentAsset({ state: 'awaiting_review' }), + approveOutfitAsset: async () => absentAsset({ state: 'ready' }), + discardOutfitAsset: async () => absentAsset(), + ...overrides, + } +} From 5674524fa131f7017a7263f2d9f7ac110856d467 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 18 Aug 2026 23:43:36 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(render3d):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E5=BD=A2=E7=8A=B6=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E7=9A=84=E5=88=86=E6=94=AF=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/entities/render3d/api.test.ts | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/frontend/src/entities/render3d/api.test.ts b/frontend/src/entities/render3d/api.test.ts index dfaeacf5..937bf81a 100644 --- a/frontend/src/entities/render3d/api.test.ts +++ b/frontend/src/entities/render3d/api.test.ts @@ -122,3 +122,77 @@ describe('三渲二资产适配器', () => { ).rejects.toBeInstanceOf(Render3DContractError) }) }) + +describe('三渲二适配器的形状守卫', () => { + // 这些分支挡的是"后端换了形状但没人发现"。放行的话,坏值会一路流到界面上: + // 用户看到的是 NaN 积分或空白状态,而错误的来源在两层之外。 + async function precheckWith(payload: unknown) { + const { client } = clientReturning(payload) + return createRender3DApis(client).precheckMaster('https://cdn.test/master.png', { + width: 64, + height: 64, + }) + } + + it('facts 不是对象时拒收', async () => { + await expect(precheckWith({ ...REPORT, facts: 'not-an-object' })).rejects.toBeInstanceOf( + Render3DContractError, + ) + }) + + it('量出来的尺寸不是有限数字时拒收', async () => { + await expect( + precheckWith({ ...REPORT, facts: { ...REPORT.facts, width: 'wide' } }), + ).rejects.toBeInstanceOf(Render3DContractError) + }) + + it('limb_segments 不是数组时拒收', async () => { + await expect( + precheckWith({ ...REPORT, facts: { ...REPORT.facts, limb_segments: 3 } }), + ).rejects.toBeInstanceOf(Render3DContractError) + }) + + it('limb_segments 里混进非数字时拒收', async () => { + await expect( + precheckWith({ ...REPORT, facts: { ...REPORT.facts, limb_segments: [1, '2'] } }), + ).rejects.toBeInstanceOf(Render3DContractError) + }) + + it('warnings 不是数组时拒收', async () => { + await expect(precheckWith({ ...REPORT, warnings: 'none' })).rejects.toBeInstanceOf( + Render3DContractError, + ) + }) + + it('警告缺 detail 时拒收——界面要拿它告诉用户具体哪里不合格', async () => { + await expect( + precheckWith({ ...REPORT, warnings: [{ code: 'limbs_fused' }] }), + ).rejects.toBeInstanceOf(Render3DContractError) + }) + + it('accepted 不是布尔值时拒收', async () => { + await expect(precheckWith({ ...REPORT, accepted: 'yes' })).rejects.toBeInstanceOf( + Render3DContractError, + ) + }) + + it('facts 缺席时是合法的——预检被拒时后端不量形态', async () => { + const report = await precheckWith({ + ...REPORT, + accepted: false, + reject_code: 'aspect_too_wide', + facts: null, + }) + expect(report.facts).toBeNull() + }) + + it('认不出的拒绝码拒收——界面按码选文案,静默丢弃等于漏报', async () => { + await expect( + precheckWith({ ...REPORT, accepted: false, reject_code: 'no_such_reason' }), + ).rejects.toBeInstanceOf(Render3DContractError) + }) + + it('整个预检结果不是对象时拒收', async () => { + await expect(precheckWith(['报告'])).rejects.toBeInstanceOf(Render3DContractError) + }) +})