3D 前端提交跟随其它画布生成工具同时落素材库

- 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 通过。
This commit is contained in:
2026-09-23 15:54:39 +08:00
parent 158f9b741d
commit a753ac4cd8
7 changed files with 119 additions and 3 deletions
@@ -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',
@@ -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 ? (
@@ -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', {
@@ -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 = (
@@ -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<boolean>;
}) {
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);
@@ -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,
@@ -1600,6 +1600,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const { submitModel3dGeneration } = useModel3dGenerationTask({
projectId,
assetFolderId,
updateCanvasGenerationDialogById,
applyProjectSnapshot,
getGeneratingDialogPlaceholder,