From a753ac4cd8f48c418f76a15c531620a7bb8cda99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 15:54:39 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E5=89=8D=E7=AB=AF=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E8=B7=9F=E9=9A=8F=E5=85=B6=E5=AE=83=E7=94=BB=E5=B8=83=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=B7=A5=E5=85=B7=E5=90=8C=E6=97=B6=E8=90=BD=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildModel3dSubmissionPlan 接收当前素材夹与素材名,与图片/图标/音效等工具一样同时发 projectId + assetFolderId + assetLabel(素材名缺省回落本次结果标题),没有素材夹时只发 projectId。 - useModel3dGenerationTask 新增 assetFolderId 入参,画布生成链路把当前素材夹透传下来;结果仍走既有画布路径读回,刷新素材库沿用 applyGeneratedProjectSnapshot 的既有行为,不新增 3D 专用刷新。 - 素材库行加 model3d 分支:缩略图按预览图(asset.src)渲染,objectKey(模型本体 .glb)不参与图片签名,与画布 3D 卡片的 objectKey={null} 口径一致,避免素材行去加载模型文件。 - 用例补齐:请求体同时带两个落点、无素材夹时不带 assetFolderId/assetLabel、素材夹只 trim 且素材名回落标题,以及素材行 3D 预览不签模型对象键 / 普通图片仍按 objectKey 换签名地址。 - 验证:npx vitest run src/components/image-editor/model3d-generation src/components/image-editor/ImageCanvasAssetRowView.test.tsx(67 passed)、npm run typecheck、check:encoding / git diff --check 通过。 --- .../ImageCanvasAssetRowView.test.tsx | 30 +++++++++++ .../image-editor/ImageCanvasAssetRowView.tsx | 8 ++- .../Model3dGenerationSubmission.test.ts | 51 ++++++++++++++++++- .../Model3dGenerationSubmission.ts | 20 +++++++- .../useModel3dGenerationTask.test.tsx | 6 +++ .../useModel3dGenerationTask.ts | 6 +++ ...ImageCanvasGenerationSubmissionWorkflow.ts | 1 + 7 files changed, 119 insertions(+), 3 deletions(-) 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/model3d-generation/Model3dGenerationSubmission.test.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts index 94e1e5b7f..9f526ff80 100644 --- a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts @@ -114,11 +114,13 @@ describe('resolveModel3dImageSource', () => { }); describe('buildModel3dSubmissionPlan', () => { - it('文生 3D 的请求体全量给出价格相关参数与落点', () => { + 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); @@ -139,10 +141,57 @@ describe('buildModel3dSubmissionPlan', () => { generateParts: false, }, projectId: 'project-1', + // 与其它画布生成工具同形:项目资源与素材库两个落点一起发,结果两边都落。 + assetFolderId: 'project', + assetLabel: '一把木椅', canvasCompletion: CANVAS_COMPLETION, }); }); + 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('素材夹落点只 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', { diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts index e218c83e5..be9a05a24 100644 --- a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts @@ -83,11 +83,20 @@ 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)) { @@ -126,9 +135,18 @@ export function buildModel3dSubmissionPlan({ generateParts: params.generateParts, }; // 落点与其它生成接口同形:平坦的可选字段,不再包一层 tagged enum。 - // 服务端按「二选一」校验:这里只给 projectId(画布链路没有落素材库的入口)。 + // 服务端按「至少一个落点」校验,项目资源与素材库可以同时给 —— 其它画布生成工具 + // 就是「projectId + assetFolderId + assetLabel」一起发,结果两边都落;3D 跟随同一口径, + // 否则同一张画布生成出来的东西,只有 3D 不会出现在素材库。 + const trimmedAssetFolderId = assetFolderId?.trim(); const placement = { projectId: trimmedProjectId, + ...(trimmedAssetFolderId + ? { + assetFolderId: trimmedAssetFolderId, + assetLabel: assetLabel?.trim() || resolveModel3dResultTitle(dialog), + } + : {}), ...(canvasCompletion ? { canvasCompletion } : {}), }; const finish = ( diff --git a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx index 4e28579f0..c06838063 100644 --- a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx +++ b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx @@ -87,10 +87,12 @@ function createDialog( function renderTask({ dialog, projectId = 'project-1', + assetFolderId, applyQueuedGeneration = vi.fn(async () => true), }: { dialog: GenerateDialogState; projectId?: string | null; + assetFolderId?: string | null; applyQueuedGeneration?: (...args: unknown[]) => Promise; }) { const onQueuedGenerationTask = vi.fn(); @@ -100,6 +102,7 @@ function renderTask({ ]); const { submitModel3dGeneration } = useModel3dGenerationTask({ projectId, + assetFolderId, updateCanvasGenerationDialogById: (dialogId, updater) => { setDialogs((currentDialogs) => currentDialogs.map((currentDialog) => @@ -143,6 +146,7 @@ describe('useModel3dGenerationTask', () => { const applyQueuedGeneration = vi.fn(async () => true); const { result, onQueuedGenerationTask } = renderTask({ dialog: createDialog(), + assetFolderId: 'project', applyQueuedGeneration, }); @@ -167,6 +171,8 @@ describe('useModel3dGenerationTask', () => { generateParts: false, }, projectId: 'project-1', + assetFolderId: 'project', + assetLabel: '一把木椅', }); expect(applyQueuedGeneration).toHaveBeenCalledTimes(1); diff --git a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts index 666d00be8..95ddc2b6c 100644 --- a/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts +++ b/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts @@ -43,6 +43,7 @@ type UpdateCanvasGenerationDialogById = ( */ export function useModel3dGenerationTask({ projectId, + assetFolderId, updateCanvasGenerationDialogById, applyProjectSnapshot, getGeneratingDialogPlaceholder, @@ -52,6 +53,8 @@ export function useModel3dGenerationTask({ onGenerationWarning, }: { projectId?: string | null; + /** 当前素材夹:与其它画布生成工具一致,结果同时落素材库。 */ + assetFolderId?: string | null; updateCanvasGenerationDialogById: UpdateCanvasGenerationDialogById; applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; getGeneratingDialogPlaceholder: ( @@ -81,6 +84,8 @@ export function useModel3dGenerationTask({ dialog, projectId, canvasCompletion, + assetFolderId, + assetLabel: resolveModel3dResultTitle(dialog), pricing: readEditorModel3dPricingConfig(), }); if (!planResult.ok) { @@ -149,6 +154,7 @@ export function useModel3dGenerationTask({ [ applyProjectSnapshot, applyQueuedGeneration, + assetFolderId, getGeneratingDialogPlaceholder, onGenerationWarning, onQueuedGenerationTask, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 5ecb125d8..ebda8f621 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -1600,6 +1600,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const { submitModel3dGeneration } = useModel3dGenerationTask({ projectId, + assetFolderId, updateCanvasGenerationDialogById, applyProjectSnapshot, getGeneratingDialogPlaceholder,