图片画布接入 3D 资源:新增 model3d 类别、3D 预览与 3D 生成入口
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 16s
Project CI / Native shell tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 16s
Project CI / Native shell tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
- 后端资源投影按 asset_kind 分叉,新增 project_editor_client_media_src 与 EDITOR_MODEL3D_ASSET_KIND:model3d 只投影预览图,模型本体只留 objectKey,预览缺失才回落历史口径 - tripo3d 存储新增按字节魔数识别模型 content_type 与扩展名映射,不采信 provider 声明与下载响应头 - editor_project_storage 把 model3d 加入 EDITOR_CANVAS_ASSET_KINDS 白名单 - 前端 CanvasAssetKind 与 image-canvas-core 类型新增 model3d:画布卡片渲染预览图或 3D 占位,类型角标新增「3D模型」,覆盖资格只在它与空类别之间成立 - 新增 model3d-preview 子目录(Model3dViewerModal / Model3dViewerModel),Tailwind 内联、不新增 CSS,失败原因分超限 / 格式不可识别 / 无 WebGL2 三类展示 - 选中工具条新增只读 preview-model3d 能力位,仅 objectKey 为空时置灰;打开预览同时选中图层并暂停画布快捷键与舞台交互 - 查看器包源对象 format 改为可选,新增「声明 Content-Type → 字节魔数 → 地址扩展名」三级格式判定并导出支持格式清单 - 导出工作流新增 3d_models/ 目录,模型下载按真实响应头 MIME 定扩展名、对象键扩展名兜底 - 新增 3D 生成入口:底部工具栏浮动子选项与文生 / 图生 3D 面板,落 projectResource + canvasCompletion,复用占位框与任务侧栏 - 3D 定价只读实时定价查询,段缺失即入口不可提交;幂等键由前端铸造,用户重试必须换键 - 新增 ADR 0003 / 0004 与实施计划、里程碑文档;更新 Tripo 技术方案、后端架构、CONTEXT 术语、decision-log 与 pitfalls - .gitignore 忽略仓库根目录手工下载的 3D 样例模型
This commit is contained in:
+22
@@ -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: '选择画布图片 一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,11 +15,13 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const model3dToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const view = render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
model3dToolWrapRef={model3dToolWrapRef}
|
||||
effectiveTool="generate"
|
||||
onSwitchTool={switchTool}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
@@ -36,6 +38,7 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
model3dToolWrapRef,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -48,6 +51,7 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
model3dToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
},
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -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<CanvasTool, 'music' | 'spec' | 'publication'>;
|
||||
type ToolbarOptionTool = Extract<
|
||||
CanvasTool,
|
||||
'music' | 'spec' | 'publication' | 'model3d'
|
||||
>;
|
||||
|
||||
type ImageCanvasBottomToolbarViewProps = {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
model3dToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
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,6 +93,7 @@ export function ImageCanvasBottomToolbarView({
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
model3dToolWrapRef,
|
||||
effectiveTool,
|
||||
highlight = false,
|
||||
onSwitchTool,
|
||||
@@ -106,7 +123,9 @@ export function ImageCanvasBottomToolbarView({
|
||||
? specToolWrapRef
|
||||
: id === 'music'
|
||||
? musicToolWrapRef
|
||||
: publicationToolWrapRef
|
||||
: id === 'model3d'
|
||||
? model3dToolWrapRef
|
||||
: publicationToolWrapRef
|
||||
}
|
||||
className={[
|
||||
'image-canvas-editor__bottom-toolbar-option-wrap',
|
||||
|
||||
@@ -1157,6 +1157,52 @@ describe('ImageCanvasEditorModel', () => {
|
||||
);
|
||||
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', () => {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLSpanElement>(),
|
||||
musicToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
publicationToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
model3dToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
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<typeof Model3dViewerModal> {
|
||||
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()}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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<typeof ImageCanvasTopbarView>;
|
||||
stageProps: ComponentProps<typeof ImageCanvasStageView>;
|
||||
metadataProps: ComponentProps<typeof ImageCanvasMetadataModalView>;
|
||||
model3dViewerProps: ComponentProps<typeof Model3dViewerModal>;
|
||||
};
|
||||
|
||||
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({
|
||||
</div>
|
||||
|
||||
<ImageCanvasMetadataModalView {...metadataProps} />
|
||||
<Model3dViewerModal {...model3dViewerProps} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export type CanvasAssetKind =
|
||||
| 'icon'
|
||||
| 'icon-spritesheet'
|
||||
| 'icon-spec'
|
||||
| 'model3d'
|
||||
| 'publication-material'
|
||||
| 'ui-design'
|
||||
| 'video'
|
||||
@@ -188,7 +189,8 @@ export type CanvasTool =
|
||||
| 'icon'
|
||||
| 'publication'
|
||||
| 'ui-design'
|
||||
| 'scene';
|
||||
| 'scene'
|
||||
| 'model3d';
|
||||
|
||||
export type SidebarPanel = 'assets' | 'layers';
|
||||
|
||||
@@ -260,7 +262,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 +299,16 @@ export type GenerateDialogState = {
|
||||
characterAnimationFrameCount?: EditorCharacterAnimationFrameCount;
|
||||
characterAnimationDurationSeconds?: 4 | 5 | 6;
|
||||
characterAnimationResult?: EditorCharacterAnimationGenerationResult;
|
||||
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 +341,14 @@ export type GenerateDialogState = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 3D 模型档位:唯一给用户看的参数面,每个档位固定映射成 provider 的完整贴图与几何组合。
|
||||
*/
|
||||
export type Model3dGenerationTier =
|
||||
| 'textured-standard'
|
||||
| 'textured-detailed'
|
||||
| 'geometry-only';
|
||||
|
||||
export type CanvasGenerationDialogMode = Exclude<
|
||||
GenerateDialogState['mode'],
|
||||
'edit'
|
||||
|
||||
@@ -398,6 +398,7 @@ export function ImageCanvasEditorView({
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const musicToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const model3dToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -505,6 +506,8 @@ export function ImageCanvasEditorView({
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
|
||||
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
||||
const [model3dPreviewLayer, setModel3dPreviewLayer] =
|
||||
useState<CanvasLayer | null>(null);
|
||||
const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] =
|
||||
useState<CanvasGenerationDialogState | null>(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),
|
||||
}}
|
||||
/>
|
||||
<PlatformDangerConfirmDialog
|
||||
open={Boolean(pendingGenerationDeleteDialog)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
isEditorUserVisibleGenerationInputField,
|
||||
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { resolveModel3dDownloadExtension } from './model3d-preview/Model3dViewerModel';
|
||||
|
||||
export function sanitizeExportFilePart(value: string, fallback: string) {
|
||||
const safeValue = value
|
||||
@@ -66,6 +67,27 @@ export function getImageExtensionFromTypeOrSrc(type: string, src: string) {
|
||||
return getLayerAssetExtensionFromTypeOrSrc('image', type, src);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图层下载文件的扩展名。
|
||||
*
|
||||
* 3D 资源下载的是模型本体:它按图片扩展名命名会得到一个 `.png` 的 `.glb`。模型格式的
|
||||
* 权威是资源对象的 MIME(真实响应头),所以这里把 `blob.type` 交给模型专用解析,而不是
|
||||
* 复用按 `mediaType` 分派的图片/音视频分支。
|
||||
*/
|
||||
export function getLayerAssetDownloadExtension(
|
||||
layer: CanvasLayer,
|
||||
blobType: string,
|
||||
) {
|
||||
if (layer.assetKind === 'model3d') {
|
||||
return resolveModel3dDownloadExtension(layer.objectKey, blobType);
|
||||
}
|
||||
return getLayerAssetExtensionFromTypeOrSrc(
|
||||
layer.mediaType ?? 'image',
|
||||
blobType,
|
||||
layer.objectKey ?? layer.src,
|
||||
);
|
||||
}
|
||||
|
||||
function getExtensionFromSrc(src: string) {
|
||||
const withoutQuery = src.split(/[?#]/u)[0] ?? '';
|
||||
const match = /\.([a-z0-9]+)$/iu.exec(withoutQuery);
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
toSoundEffectGenerationDialog,
|
||||
} from './ImageCanvasSoundEffectPromptModel';
|
||||
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
|
||||
import { Model3dGenerationModal } from './model3d-generation/Model3dGenerationModal';
|
||||
import type { BackgroundMusicPromptAssistComposerController } from './useImageCanvasBackgroundMusicPromptAssist';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
import type { SoundEffectPromptAssistComposerController } from './useImageCanvasSoundEffectPromptAssist';
|
||||
@@ -1693,6 +1694,27 @@ export function ImageCanvasGenerationComposerView({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(generateDialog?.mode === 'model3d-text-to-model' ||
|
||||
generateDialog?.mode === 'model3d-image-to-model') &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
generationComposerStyle ? (
|
||||
<Model3dGenerationModal
|
||||
dialog={generateDialog}
|
||||
style={generationComposerStyle}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
generationReferenceButtonRef={generationReferenceButtonRef}
|
||||
isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen}
|
||||
setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
|
||||
setIsPickingGenerationReferenceFromCanvas={
|
||||
setIsPickingGenerationReferenceFromCanvas
|
||||
}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRequestProjectAssetPicker={onRequestProjectAssetPicker}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(generateDialog?.mode === 'audio-sound-effect' ||
|
||||
generateDialog?.mode === 'audio-background-music') &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<CanvasGenerationDialogState, 'id'> {
|
||||
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,20 @@ 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)
|
||||
? []
|
||||
: (dialog.generationReferences ?? []).filter(
|
||||
(reference) => !isProjectAssetPickerReference(reference),
|
||||
),
|
||||
references,
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<T extends EditorGenerationPricingConfig['models']>(
|
||||
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 '图标素材生成占位图';
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<ImageCanvasSelectedLayerToolbarView
|
||||
@@ -760,6 +830,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
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()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -354,6 +368,25 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
<span>生成动画</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{showPreviewModel3d ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="3D 预览"
|
||||
// 没有模型对象时不藏按钮:按钮消失会被读成"功能没上线",
|
||||
// 置灰加标题才说明"这个资源没有可预览的模型"。可见文字与
|
||||
// 无障碍名称保持同一个,理由只放在 title 里。
|
||||
title={
|
||||
hasModel3dSource
|
||||
? '在 3D 查看器中预览'
|
||||
: '该资源没有可预览的 3D 模型文件'
|
||||
}
|
||||
icon={<Box className="h-4 w-4" />}
|
||||
disabled={!hasModel3dSource}
|
||||
onClick={() => onPreviewModel3d(selectedLayer)}
|
||||
>
|
||||
<span>3D 预览</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{canRedraw ? (
|
||||
<>
|
||||
{showQuickEdit || hasEditingActions ? divider : null}
|
||||
|
||||
@@ -51,6 +51,7 @@ export type ImageCanvasStageViewProps = {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
model3dToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
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<HTMLButtonElement>) => void;
|
||||
onSwitchTool: (tool: CanvasTool) => void;
|
||||
onOpenToolOptions: (
|
||||
tool: Extract<CanvasTool, 'music' | 'spec' | 'publication'>,
|
||||
tool: Extract<CanvasTool, 'music' | 'spec' | 'publication' | 'model3d'>,
|
||||
) => void;
|
||||
onCloseToolOptions: (
|
||||
tool: Extract<CanvasTool, 'music' | 'spec' | 'publication'>,
|
||||
tool: Extract<CanvasTool, 'music' | 'spec' | 'publication' | 'model3d'>,
|
||||
) => 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}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -825,8 +825,9 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(
|
||||
within(layerButton)
|
||||
.getByRole('button', { name: '查看角色主图图片信息' })
|
||||
.parentElement!
|
||||
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
.parentElement!.style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
(
|
||||
@@ -1193,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',
|
||||
|
||||
@@ -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,103 @@ 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:
|
||||
'!bg-[rgba(30,64,175,0.94)] !shadow-[0_8px_18px_rgba(30,64,175,0.26)]',
|
||||
},
|
||||
{
|
||||
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 +732,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 +786,44 @@ function ImageCanvasImageLayer({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D 资源卡片:只画预览图,模型本体交给 3D 查看器。
|
||||
*
|
||||
* 预览必须单独按 `layer.src` 取签名地址(`objectKey` 是模型本体,带上它会把 `.glb`
|
||||
* 当图片交给 `<img>`)。预览图缺失时后端把 `src` 回落到模型对象,这里识别出来只画
|
||||
* 占位,不去请求模型文件。
|
||||
*/
|
||||
function ImageCanvasModel3dLayer({
|
||||
layer,
|
||||
mediaTransform,
|
||||
}: {
|
||||
layer: CanvasLayer;
|
||||
mediaTransform?: string;
|
||||
}) {
|
||||
const hasPreview =
|
||||
Boolean(layer.src?.trim()) && !isModel3dFileSource(layer.src);
|
||||
|
||||
if (hasPreview) {
|
||||
return (
|
||||
<ImageCanvasImageLayer
|
||||
layer={layer}
|
||||
mediaTransform={mediaTransform}
|
||||
objectKey={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
// 预览图缺失时只画占位,模型本体只在 3D 查看器里加载。
|
||||
className="pointer-events-none relative z-[1] flex h-full w-full items-center justify-center rounded-[0.1rem] bg-[linear-gradient(135deg,rgba(15,23,42,0.86),rgba(30,64,175,0.62))] text-slate-200 shadow-[0_16px_38px_rgba(15,23,42,0.18)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Box className="h-8 w-8" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getLayerImageSequenceFrames(layer: CanvasLayer) {
|
||||
return layer.imageSequenceFrames ?? [];
|
||||
}
|
||||
@@ -994,6 +1095,11 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({
|
||||
layer={layer}
|
||||
mediaTransform={mediaTransform}
|
||||
/>
|
||||
) : layer.assetKind === 'model3d' ? (
|
||||
<ImageCanvasModel3dLayer
|
||||
layer={layer}
|
||||
mediaTransform={mediaTransform}
|
||||
/>
|
||||
) : (
|
||||
<ImageCanvasImageLayer
|
||||
layer={layer}
|
||||
@@ -1004,7 +1110,7 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({
|
||||
kindLabel={kindLabel}
|
||||
kindAriaLabel={`素材标签:${kindLabel}`}
|
||||
kindRef={isKindMenuOpen ? openLayerKindTriggerRef : undefined}
|
||||
kindClassName={`image-canvas-editor__kind-badge--${kindTagMeta.className}`}
|
||||
kindClassName={kindTagMeta.badgeClassName}
|
||||
infoLabel={`查看${layer.title}${metadataMediaLabel}信息`}
|
||||
style={inverseScaleStyle}
|
||||
onKindClick={() =>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
import type {
|
||||
CSSProperties,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '../../common/AutoGrowTextArea';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../../common/PlatformInlineOptionButton';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
UploadTarget,
|
||||
} from '../ImageCanvasEditorTypes';
|
||||
import { ImageCanvasReferenceSlot } from '../ImageCanvasReferenceSlot';
|
||||
import {
|
||||
applyModel3dPromptChange,
|
||||
applyModel3dReferenceRemoval,
|
||||
applyModel3dSwitchChange,
|
||||
applyModel3dTierChange,
|
||||
MODEL3D_GENERATION_TIERS,
|
||||
MODEL3D_SWITCH_OPTIONS,
|
||||
MODEL3D_TEXT_PROMPT_MAX_LENGTH,
|
||||
} from './Model3dGenerationFormModel';
|
||||
|
||||
type Model3dGenerationFormProps = {
|
||||
dialog: GenerateDialogState;
|
||||
disabled: boolean;
|
||||
updateDialog: (
|
||||
updater: (dialog: GenerateDialogState) => GenerateDialogState,
|
||||
) => void;
|
||||
generationReferenceButtonRef?: RefObject<HTMLButtonElement | null>;
|
||||
isGenerationReferenceMenuOpen?: boolean;
|
||||
setIsGenerationReferenceMenuOpen?: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingGenerationReferenceFromCanvas?: Dispatch<SetStateAction<boolean>>;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onRequestProjectAssetPicker?: () => void;
|
||||
renderEditorPortal: (node: ReactNode) => ReactNode;
|
||||
buildPortalMenuStyle: (
|
||||
anchor: HTMLElement | null,
|
||||
placement: 'above' | 'below',
|
||||
) => CSSProperties;
|
||||
};
|
||||
|
||||
export function Model3dGenerationForm({
|
||||
dialog,
|
||||
disabled,
|
||||
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 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 ? (
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
{references.map((reference) => (
|
||||
<ImageCanvasReferenceSlot
|
||||
key={reference.id}
|
||||
tone="default"
|
||||
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
|
||||
imageSrc={reference.src}
|
||||
objectKey={reference.objectKey}
|
||||
label={reference.label || '参考图'}
|
||||
ariaLabel={reference.label || '参考图'}
|
||||
title={reference.label}
|
||||
onRemove={() => removeReference(reference)}
|
||||
removeLabel="删除参考图"
|
||||
/>
|
||||
))}
|
||||
{references.length === 0 ? (
|
||||
<ImageCanvasReferenceSlot
|
||||
buttonRef={generationReferenceButtonRef}
|
||||
tone="default"
|
||||
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
|
||||
label="参考图"
|
||||
ariaLabel="添加参考图"
|
||||
disabled={disabled}
|
||||
isAdd
|
||||
onClick={() => setIsGenerationReferenceMenuOpen?.(true)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<AutoGrowTextArea
|
||||
aria-label="模型描述"
|
||||
value={dialog.prompt}
|
||||
disabled={disabled}
|
||||
maxLength={MODEL3D_TEXT_PROMPT_MAX_LENGTH}
|
||||
placeholder="描述要生成的 3D 模型,例如:圆润的卡通小恐龙"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onValueChange={(value) =>
|
||||
updateDialog((currentDialog) =>
|
||||
applyModel3dPromptChange(currentDialog, value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="image-canvas-editor__model3d-options">
|
||||
<div
|
||||
className="image-canvas-editor__model3d-tier-group"
|
||||
role="group"
|
||||
aria-label="3D 模型档位"
|
||||
>
|
||||
{MODEL3D_GENERATION_TIERS.map((option) => (
|
||||
<PlatformInlineOptionButton
|
||||
key={option.value}
|
||||
className="image-canvas-editor__model3d-tier"
|
||||
aria-pressed={tier === option.value}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
updateDialog((currentDialog) =>
|
||||
applyModel3dTierChange(currentDialog, option.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</PlatformInlineOptionButton>
|
||||
))}
|
||||
</div>
|
||||
{MODEL3D_SWITCH_OPTIONS.map((option) => {
|
||||
const blocked =
|
||||
option.field === 'model3dGenerateParts' && isTextureTier;
|
||||
return (
|
||||
<label
|
||||
key={option.field}
|
||||
className="image-canvas-editor__image-style-toggle"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(dialog[option.field])}
|
||||
disabled={disabled || blocked}
|
||||
onChange={(event) =>
|
||||
toggleSwitch(option.field, event.target.checked)
|
||||
}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isImageMode &&
|
||||
isGenerationReferenceMenuOpen &&
|
||||
generationReferenceButtonRef
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
|
||||
label="参考图来源"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
generationReferenceButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
onClick={() => {
|
||||
setIsGenerationReferenceMenuOpen?.(false);
|
||||
setIsPickingGenerationReferenceFromCanvas?.(true);
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
onClick={() => {
|
||||
setIsGenerationReferenceMenuOpen?.(false);
|
||||
setIsPickingGenerationReferenceFromCanvas?.(false);
|
||||
onRequestUpload('generation-reference');
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
{onRequestProjectAssetPicker ? (
|
||||
<PlatformFloatingMenuItem
|
||||
onClick={() => {
|
||||
setIsGenerationReferenceMenuOpen?.(false);
|
||||
setIsPickingGenerationReferenceFromCanvas?.(false);
|
||||
onRequestProjectAssetPicker();
|
||||
}}
|
||||
>
|
||||
从项目素材中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
) : null}
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Model3dTextureQuality } from '../../../../packages/shared/src/contracts/model3d';
|
||||
import type {
|
||||
Model3dAddOnPriceKey,
|
||||
Model3dPricingConfig,
|
||||
} from '../../../services/image-editor/editorProjectClient';
|
||||
import type { GenerateDialogState } from '../ImageCanvasEditorTypes';
|
||||
import {
|
||||
applyModel3dPromptChange,
|
||||
applyModel3dReferenceRemoval,
|
||||
applyModel3dSwitchChange,
|
||||
applyModel3dTierChange,
|
||||
createModel3dGenerationDraftFields,
|
||||
mintModel3dAttemptNonce,
|
||||
MODEL3D_PRICING_UNAVAILABLE_REASON,
|
||||
type Model3dGenerationMode,
|
||||
refreshModel3dAttemptNonce,
|
||||
resolveModel3dAddOnKeys,
|
||||
resolveModel3dGenerationParams,
|
||||
resolveModel3dGenerationQuote,
|
||||
resolveModel3dPrice,
|
||||
} from './Model3dGenerationFormModel';
|
||||
|
||||
function createDialog(
|
||||
overrides: Partial<GenerateDialogState> = {},
|
||||
): 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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
tier: 'textured-standard',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: true,
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
tier: 'textured-detailed',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: true,
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModel3dPrice', () => {
|
||||
const paramsOf = (
|
||||
tier: 'textured-standard' | 'textured-detailed' | 'geometry-only',
|
||||
switches: {
|
||||
quad?: boolean;
|
||||
smartLowPoly?: boolean;
|
||||
generateParts?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const result = resolveModel3dGenerationParams({
|
||||
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 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-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user