修改图生3D,支持多图上传

This commit is contained in:
zhangxiaohua 2026-01-19 14:10:43 +08:00
parent a8b9f658a0
commit 7f8e28a4af
5 changed files with 972 additions and 41 deletions

View File

@ -62,13 +62,44 @@ export class AI3DService {
// 3. 提交到 AI 服务
try {
// 构建生成选项
const options: any = {
generateType: dto.generateType,
faceCount: dto.faceCount,
};
// 处理多视图图片图生3D支持
if (dto.inputType === 'image' && dto.multiViewImages) {
const viewKeyMap: Record<string, string> = {
left: 'left',
right: 'right',
back: 'back',
top: 'top',
bottom: 'bottom',
left45: 'left_front',
right45: 'right_front',
};
const multiViewImages: { viewType: string; imageUrl: string }[] = [];
for (const [key, url] of Object.entries(dto.multiViewImages)) {
if (url && key !== 'front' && viewKeyMap[key]) {
multiViewImages.push({
viewType: viewKeyMap[key],
imageUrl: url,
});
}
}
if (multiViewImages.length > 0) {
options.multiViewImages = multiViewImages;
this.logger.log(`多视图模式: ${multiViewImages.length} 张额外视图`);
}
}
const externalTaskId = await this.ai3dProvider.submitTask(
dto.inputType,
dto.inputContent,
{
generateType: dto.generateType,
faceCount: dto.faceCount,
},
options,
);
// 4. 更新状态为处理中

View File

@ -7,6 +7,7 @@ import {
IsInt,
Min,
Max,
IsObject,
} from 'class-validator';
/**
@ -18,6 +19,21 @@ import {
*/
export type GenerateType = 'Normal' | 'LowPoly' | 'Geometry' | 'Sketch';
/**
*
* left, right, back, top, bottom, left_front (45°), right_front (45°)
*/
export type MultiViewImages = {
front?: string; // 正图(作为主图)
left?: string; // 左视图
right?: string; // 右视图
back?: string; // 后视图
top?: string; // 顶视图
bottom?: string; // 底视图
left45?: string; // 左前45°视图 -> left_front
right45?: string; // 右前45°视图 -> right_front
};
export class CreateTaskDto {
@IsString()
@IsIn(['text', 'image'], { message: '输入类型必须是 text 或 image' })
@ -40,4 +56,8 @@ export class CreateTaskDto {
@Min(10000, { message: '模型面数最小为10000' })
@Max(1500000, { message: '模型面数最大为1500000' })
faceCount?: number;
@IsOptional()
@IsObject({ message: '多视图图片必须是对象' })
multiViewImages?: MultiViewImages;
}

View File

@ -11,6 +11,14 @@ export interface AI3DGenerateResult {
errorMessage?: string; // 错误信息
}
/**
*
*/
export interface MultiViewImage {
viewType: string; // left, right, back, top, bottom, left_front, right_front
imageUrl: string;
}
/**
*
*/
@ -19,6 +27,8 @@ export interface AI3DGenerateOptions {
generateType?: 'Normal' | 'LowPoly' | 'Geometry' | 'Sketch';
/** 模型面数10000-1500000默认500000 */
faceCount?: number;
/** 多视图图片图生3D支持 */
multiViewImages?: MultiViewImage[];
}
/**

View File

@ -85,11 +85,23 @@ export class HunyuanAI3DProvider implements AI3DProvider {
payload.GenerateType = options.generateType;
}
// 多视图图片支持
if (options?.multiViewImages && options.multiViewImages.length > 0) {
payload.MultiViewImages = options.multiViewImages.map((img) => ({
ViewType: img.viewType,
ViewImageUrl: img.imageUrl,
}));
this.logger.log(
`提交图生3D任务多视图: ${options.multiViewImages.length} 张图片 ` +
`[类型: ${options?.generateType || 'Normal'}]`,
);
} else {
this.logger.log(
`提交图生3D任务: ${inputContent.substring(0, 50)}... ` +
`[类型: ${options?.generateType || 'Normal'}]`,
);
}
}
// 生成签名和请求头
const headers = TencentCloudSigner.sign({

View File

@ -111,13 +111,71 @@
<!-- 图生3D上传 -->
<div v-else class="image-input-section">
<div class="input-label">
<PictureOutlined class="label-icon" />
<span>参考图片</span>
<!-- 单张/多张图片切换 -->
<div class="image-mode-switch">
<span
class="mode-link"
:class="{ active: imageMode === 'single' }"
@click="imageMode = 'single'"
>单张图片</span
>
<span
class="mode-link"
:class="{ active: imageMode === 'multiple' }"
@click="imageMode = 'multiple'"
>多张图片</span
>
<!-- 图片上传建议 Popover -->
<a-popover
placement="rightTop"
trigger="click"
:overlayStyle="{ width: '320px' }"
overlayClassName="image-tips-popover"
>
<template #content>
<div class="image-tips-content">
<div class="tips-desc">
支持png/jpg/jpeg/webp格式图片大小最大不超过10M分辨率最低要求128x128
</div>
<div class="input-hint">
上传参考图片AI 将智能识别并生成 3D 模型
<div class="tips-example">
<img
src="https://competition-ms-1325825530.cos.ap-guangzhou.myqcloud.com/img/3d-example.png"
alt="示例图片"
/>
<div class="tips-list">
<div class="tip-item">
<CheckCircleFilled class="tip-icon" />
<span>背景简单尽量纯色</span>
</div>
<div class="tip-item">
<CheckCircleFilled class="tip-icon" />
<span>不包含文字</span>
</div>
<div class="tip-item">
<CheckCircleFilled class="tip-icon" />
<span>单主体</span>
</div>
<div class="tip-item">
<CheckCircleFilled class="tip-icon" />
<span>主体不要过小</span>
</div>
</div>
</div>
</div>
</template>
<template #title>
<span class="tips-title">图片上传建议</span>
</template>
<span class="image-tips-link">
<QuestionCircleOutlined />
<span>图片上传建议</span>
</span>
</a-popover>
</div>
<!-- 单张图片模式 -->
<template v-if="imageMode === 'single'">
<a-upload-dragger
:file-list="[]"
:before-upload="handleBeforeUpload"
@ -138,6 +196,39 @@
<p class="upload-hint">支持 JPGPNG 格式最大 10MB</p>
</template>
</a-upload-dragger>
</template>
<!-- 多张图片模式 -->
<template v-else>
<div class="multi-image-container">
<!-- 缩略图展示条 -->
<div class="thumbnail-bar" @click="openMultiViewModal">
<div v-if="hasMultiImages" class="thumbnail-list">
<div
v-for="(img, key) in multiImages"
:key="key"
class="thumbnail-item"
@click.stop
>
<img :src="img" :alt="getViewName(key)" />
<div
class="thumbnail-delete"
@click="removeMultiImage(key)"
>
<CloseOutlined />
</div>
</div>
</div>
<div class="thumbnail-add">
<PlusOutlined />
</div>
</div>
<div class="upload-tip">
<InfoCircleOutlined />
<span>图片上传建议上传多角度图片可提升生成质量</span>
</div>
</div>
</template>
<!-- 模型类型选择 -->
<div class="model-settings">
@ -154,6 +245,196 @@
</div>
</div>
</div>
<!-- 多视图上传弹框 -->
<a-modal
v-model:open="multiViewModalVisible"
title="添加多视图"
:width="600"
:footer="null"
class="multi-view-modal"
@cancel="multiViewModalVisible = false"
>
<div class="multi-view-grid">
<!-- 顶图 -->
<div class="view-row top-row">
<div class="view-item" @click="triggerUpload('top')">
<template v-if="multiImages.top">
<img :src="multiImages.top" alt="顶图" />
<div
class="view-delete"
@click.stop="removeMultiImage('top')"
>
<CloseOutlined />
</div>
</template>
<template v-else>
<div class="view-placeholder">
<PictureOutlined />
<span>上传顶图</span>
</div>
</template>
</div>
</div>
<!-- 左45° 正图 右45° -->
<div class="view-row middle-row">
<div class="view-item" @click="triggerUpload('left45')">
<template v-if="multiImages.left45">
<img :src="multiImages.left45" alt="左45°图" />
<div
class="view-delete"
@click.stop="removeMultiImage('left45')"
>
<CloseOutlined />
</div>
</template>
<template v-else>
<div class="view-placeholder">
<PictureOutlined />
<span>上传左45°</span>
</div>
</template>
</div>
<div class="view-item required" @click="triggerUpload('front')">
<template v-if="multiImages.front">
<img :src="multiImages.front" alt="正图" />
<div
class="view-delete"
@click.stop="removeMultiImage('front')"
>
<CloseOutlined />
</div>
</template>
<template v-else>
<div class="view-placeholder">
<PictureOutlined />
<span>上传正图 <span class="required-mark">*</span></span>
</div>
</template>
</div>
<div class="view-item" @click="triggerUpload('right45')">
<template v-if="multiImages.right45">
<img :src="multiImages.right45" alt="右45°图" />
<div
class="view-delete"
@click.stop="removeMultiImage('right45')"
>
<CloseOutlined />
</div>
</template>
<template v-else>
<div class="view-placeholder">
<PictureOutlined />
<span>上传右45°</span>
</div>
</template>
</div>
</div>
<!-- 左图 中心预览 右图 -->
<div class="view-row center-row">
<div class="view-item" @click="triggerUpload('left')">
<template v-if="multiImages.left">
<img :src="multiImages.left" alt="左图" />
<div
class="view-delete"
@click.stop="removeMultiImage('left')"
>
<CloseOutlined />
</div>
</template>
<template v-else>
<div class="view-placeholder">
<PictureOutlined />
<span>上传左图</span>
</div>
</template>
</div>
<div class="view-center">
<div class="center-preview">
<img
v-if="multiImages.front"
:src="multiImages.front"
alt="预览"
/>
<div v-else class="center-placeholder">
<AppstoreOutlined />
<span>3D 预览</span>
</div>
</div>
</div>
<div class="view-item" @click="triggerUpload('right')">
<template v-if="multiImages.right">
<img :src="multiImages.right" alt="右图" />
<div
class="view-delete"
@click.stop="removeMultiImage('right')"
>
<CloseOutlined />
</div>
</template>
<template v-else>
<div class="view-placeholder">
<PictureOutlined />
<span>上传右图</span>
</div>
</template>
</div>
</div>
<!-- 背图 -->
<div class="view-row bottom-row">
<div class="view-item" @click="triggerUpload('back')">
<template v-if="multiImages.back">
<img :src="multiImages.back" alt="背图" />
<div
class="view-delete"
@click.stop="removeMultiImage('back')"
>
<CloseOutlined />
</div>
</template>
<template v-else>
<div class="view-placeholder">
<PictureOutlined />
<span>上传背图</span>
</div>
</template>
</div>
</div>
<!-- 底图 -->
<div class="view-row bottom-row">
<div class="view-item" @click="triggerUpload('bottom')">
<template v-if="multiImages.bottom">
<img :src="multiImages.bottom" alt="底图" />
<div
class="view-delete"
@click.stop="removeMultiImage('bottom')"
>
<CloseOutlined />
</div>
</template>
<template v-else>
<div class="view-placeholder">
<PictureOutlined />
<span>上传底图</span>
</div>
</template>
</div>
</div>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="multiImageInput"
type="file"
accept="image/*"
style="display: none"
@change="handleMultiImageUpload"
/>
</a-modal>
</div>
<div class="panel-footer">
@ -375,6 +656,11 @@ import {
FolderOutlined,
SyncOutlined,
EditOutlined,
PlusOutlined,
InfoCircleOutlined,
AppstoreOutlined,
QuestionCircleOutlined,
CheckCircleFilled,
} from "@ant-design/icons-vue"
import {
createAI3DTask,
@ -428,6 +714,36 @@ const imageUrl = ref("")
const generating = ref(false)
const currentSampleIndex = ref(0)
// /
const imageMode = ref<"single" | "multiple">("single")
//
type MultiImageKey =
| "top"
| "left45"
| "front"
| "right45"
| "left"
| "right"
| "back"
| "bottom"
const multiImages = ref<Partial<Record<MultiImageKey, string>>>({})
const multiViewModalVisible = ref(false)
const multiImageInput = ref<HTMLInputElement | null>(null)
const currentUploadKey = ref<MultiImageKey | null>(null)
//
const viewNameMap: Record<MultiImageKey, string> = {
top: "顶图",
left45: "左45°图",
front: "正图",
right45: "右45°图",
left: "左图",
right: "右图",
back: "背图",
bottom: "底图",
}
//
const generateType = ref<"Normal" | "LowPoly" | "Geometry" | "Sketch">("Normal")
const faceCount = ref(500000)
@ -452,6 +768,11 @@ let resizeObserver: ResizeObserver | null = null
//
const currentSamples = computed(() => samplePrompts[currentSampleIndex.value])
//
const hasMultiImages = computed(() => {
return Object.keys(multiImages.value).length > 0
})
const canGenerate = computed(() => {
//
if (!authStore.hasPermission("ai-3d:create")) {
@ -462,7 +783,13 @@ const canGenerate = computed(() => {
if (inputType.value === "text") {
return textContent.value.trim().length > 0
} else {
// 3D
if (imageMode.value === "single") {
return imageUrl.value.length > 0
} else {
//
return !!multiImages.value.front
}
}
})
@ -537,14 +864,76 @@ const handleBeforeUpload = async (file: File) => {
return false //
}
//
const openMultiViewModal = () => {
multiViewModalVisible.value = true
}
const getViewName = (key: string) => {
return viewNameMap[key as MultiImageKey] || key
}
const triggerUpload = (key: MultiImageKey) => {
currentUploadKey.value = key
multiImageInput.value?.click()
}
const handleMultiImageUpload = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !currentUploadKey.value) return
const isImage = file.type.startsWith("image/")
if (!isImage) {
message.error("只能上传图片文件")
input.value = ""
return
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error("图片大小不能超过 10MB")
input.value = ""
return
}
try {
const result: any = await uploadFile(file)
const url = result.data?.url || result.url
if (url) {
multiImages.value[currentUploadKey.value] = url
message.success(`${getViewName(currentUploadKey.value)}上传成功`)
} else {
message.error("上传失败:无法获取图片地址")
}
} catch (error) {
console.error("上传失败:", error)
message.error("图片上传失败")
}
input.value = ""
currentUploadKey.value = null
}
const removeMultiImage = (key: string) => {
delete multiImages.value[key as MultiImageKey]
}
// 3D
const handleGenerate = async () => {
if (!canGenerate.value) return
generating.value = true
try {
const content =
inputType.value === "text" ? textContent.value.trim() : imageUrl.value
let content: string
if (inputType.value === "text") {
content = textContent.value.trim()
} else if (imageMode.value === "single") {
content = imageUrl.value
} else {
// 使
content = multiImages.value.front || ""
}
//
const params: any = {
@ -558,13 +947,28 @@ const handleGenerate = async () => {
params.faceCount = faceCount.value
}
//
if (inputType.value === "image" && imageMode.value === "multiple") {
const multiViewImages: Record<string, string> = {}
Object.entries(multiImages.value).forEach(([key, url]) => {
if (url) {
multiViewImages[key] = url
}
})
if (Object.keys(multiViewImages).length > 0) {
params.multiViewImages = multiViewImages
}
}
const task = await createAI3DTask(params)
//
if (inputType.value === "text") {
textContent.value = ""
} else {
} else if (imageMode.value === "single") {
imageUrl.value = ""
} else {
multiImages.value = {}
}
//
@ -604,7 +1008,7 @@ const startPolling = () => {
pollingTimer = window.setInterval(async () => {
const processingTasks = historyList.value.filter(
(t) => t.status === "pending" || t.status === "processing"
(t) => t.status === "pending" || t.status === "processing",
)
if (processingTasks.length === 0) {
@ -774,7 +1178,7 @@ onMounted(async () => {
//
const hasProcessing = historyList.value.some(
(t) => t.status === "pending" || t.status === "processing"
(t) => t.status === "pending" || t.status === "processing",
)
if (hasProcessing) {
startPolling()
@ -1249,6 +1653,392 @@ $gradient-secondary: linear-gradient(135deg, $primary-light 0%, $primary 100%);
}
}
// /
.image-mode-switch {
display: flex;
gap: 16px;
margin-bottom: 16px;
align-items: center;
.mode-link {
font-size: 14px;
color: $text-muted;
cursor: pointer;
transition: all 0.3s;
position: relative;
&:hover {
color: $primary-light;
}
&.active {
color: $primary;
font-weight: 500;
&::after {
content: "";
position: absolute;
bottom: -4px;
left: 0;
right: 0;
height: 2px;
background: $primary;
border-radius: 1px;
}
}
}
.image-tips-link {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: $text-muted;
cursor: pointer;
margin-left: auto;
transition: color 0.3s;
&:hover {
color: $primary;
}
.anticon {
font-size: 14px;
}
}
}
//
.image-tips-content {
.tips-desc {
font-size: 12px;
color: $text-secondary;
line-height: 1.6;
margin-bottom: 16px;
}
.tips-example {
display: flex;
gap: 16px;
img {
width: 100px;
height: 100px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.tips-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.tip-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: $text;
.tip-icon {
color: $success;
font-size: 14px;
}
}
}
}
.tips-title {
font-weight: 600;
color: $text;
}
//
.multi-image-container {
.thumbnail-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: rgba($surface-light, 0.6);
border: 2px dashed rgba($primary, 0.3);
border-radius: 12px;
min-height: 72px;
cursor: pointer;
transition: all 0.3s;
overflow-x: auto;
&:hover {
border-color: $primary-light;
background: rgba($surface-light, 0.8);
}
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-track {
background: rgba($primary, 0.05);
border-radius: 2px;
}
&::-webkit-scrollbar-thumb {
background: rgba($primary, 0.3);
border-radius: 2px;
}
}
.thumbnail-list {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.thumbnail-item {
position: relative;
width: 48px;
height: 48px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-delete {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 10px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .thumbnail-delete {
opacity: 1;
}
}
.thumbnail-add {
width: 48px;
height: 48px;
border: 2px dashed rgba($primary, 0.4);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: $primary;
font-size: 20px;
flex-shrink: 0;
transition: all 0.3s;
&:hover {
border-color: $primary;
background: rgba($primary, 0.1);
}
}
.upload-tip {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
font-size: 12px;
color: $text-muted;
.anticon {
color: $primary-light;
}
}
}
//
.multi-view-modal {
:deep(.ant-modal-content) {
background: $surface;
border-radius: 16px;
}
:deep(.ant-modal-header) {
background: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
padding: 16px 24px;
.ant-modal-title {
font-size: 16px;
font-weight: 600;
color: $text;
}
}
:deep(.ant-modal-body) {
padding: 24px;
}
:deep(.ant-modal-close) {
color: $text-muted;
}
}
.multi-view-grid {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.view-row {
display: flex;
justify-content: center;
gap: 12px;
&.middle-row,
&.center-row {
width: 100%;
}
}
.view-item {
width: 100px;
height: 100px;
border: 2px dashed rgba($primary, 0.3);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
background: rgba($surface-light, 0.5);
&:hover {
border-color: $primary;
background: rgba($primary, 0.05);
}
&.required {
border-color: rgba($primary, 0.5);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.view-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: $text-muted;
.anticon {
font-size: 24px;
color: $primary-light;
}
span {
font-size: 11px;
text-align: center;
}
.required-mark {
color: #ff4d4f;
}
}
.view-delete {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
&:hover {
background: rgba(255, 77, 79, 0.8);
}
}
&:hover .view-delete {
opacity: 1;
}
}
.view-center {
width: 120px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
.center-preview {
width: 100%;
height: 100%;
border: 2px solid rgba($primary, 0.2);
border-radius: 12px;
overflow: hidden;
background: linear-gradient(
135deg,
rgba($primary, 0.05) 0%,
rgba($primary, 0.1) 100%
);
img {
width: 100%;
height: 100%;
object-fit: contain;
}
.center-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: $text-muted;
.anticon {
font-size: 28px;
color: $primary-light;
}
span {
font-size: 12px;
}
}
}
}
.image-upload {
:deep(.ant-upload-drag) {
background: rgba($surface-light, 0.6) !important;
@ -1470,7 +2260,11 @@ $gradient-secondary: linear-gradient(135deg, $primary-light 0%, $primary 100%);
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
}
&.gradient-2 {
background: linear-gradient(135deg, $primary-dark 0%, darken($primary-dark, 10%) 100%);
background: linear-gradient(
135deg,
$primary-dark 0%,
darken($primary-dark, 10%) 100%
);
}
&.gradient-3 {
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
@ -1960,3 +2754,67 @@ $gradient-secondary: linear-gradient(135deg, $primary-light 0%, $primary 100%);
}
}
</style>
<!-- 全局样式 - 用于 Popover 等渲染到 body 的组件 -->
<style lang="scss">
// Popover
.image-tips-popover {
.ant-popover-inner {
border-radius: 12px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
.ant-popover-title {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
font-weight: 600;
}
.ant-popover-inner-content {
padding: 16px;
}
.image-tips-content {
.tips-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.6;
margin-bottom: 16px;
}
.tips-example {
display: flex;
gap: 16px;
img {
width: 100px;
height: 100px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.tips-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.tip-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: rgba(0, 0, 0, 0.85);
.tip-icon {
color: #52c41a;
font-size: 14px;
}
}
}
}
}
</style>