图片画布接入 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

- 后端资源投影按 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:
2026-09-22 10:10:00 +08:00
parent 9fc55a5cac
commit 61fe4c48af
64 changed files with 4964 additions and 119 deletions
@@ -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