1048 lines
33 KiB
Vue
1048 lines
33 KiB
Vue
<template>
|
||
<div class="reference-to-video">
|
||
<!-- 顶部卡片:放参考素材上传区和提示词输入区 -->
|
||
<el-card class="card">
|
||
<div class="reference-container">
|
||
<div class="reference-header">
|
||
<!-- 标题说明:告诉用户当前模式支持哪些参考素材 -->
|
||
<div class="reference-header__copy">
|
||
<div class="reference-header__title">参考多张图片中的主体生成视频</div>
|
||
<div class="reference-header__desc">
|
||
你可以上传图片,视频,音频作为参考,生成视频
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 隐藏的原生 input:真正负责选择文件,按钮只负责触发点击 -->
|
||
<div class="reference-header__actions">
|
||
<input
|
||
ref="imageInputRef"
|
||
class="hidden-upload-input"
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
@change="handleImageSelect"
|
||
/>
|
||
<input
|
||
ref="videoInputRef"
|
||
class="hidden-upload-input"
|
||
type="file"
|
||
accept="video/*"
|
||
multiple
|
||
@change="handleVideoSelect"
|
||
/>
|
||
<input
|
||
ref="audioInputRef"
|
||
class="hidden-upload-input"
|
||
type="file"
|
||
accept="audio/*"
|
||
@change="handleAudioSelect"
|
||
/>
|
||
|
||
<!-- 图片上传按钮:支持一次选中多张图片 -->
|
||
<button
|
||
class="reference-action-btn"
|
||
type="button"
|
||
:disabled="generating"
|
||
@click="triggerImageUpload"
|
||
>
|
||
<span class="reference-action-btn__icon">+</span>
|
||
<span>图片</span>
|
||
</button>
|
||
<!-- 视频上传按钮:支持一次选中多个视频 -->
|
||
<button
|
||
class="reference-action-btn"
|
||
type="button"
|
||
:disabled="generating"
|
||
@click="triggerVideoUpload"
|
||
>
|
||
<span class="reference-action-btn__icon">+</span>
|
||
<span>视频</span>
|
||
</button>
|
||
<!-- 音频上传按钮:当前只保留一个音频参考 -->
|
||
<button
|
||
class="reference-action-btn"
|
||
type="button"
|
||
:disabled="generating"
|
||
@click="triggerAudioUpload"
|
||
>
|
||
<span class="reference-action-btn__icon">+</span>
|
||
<span>音频</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 已上传素材展示区:有任意一种素材时显示 -->
|
||
<div v-if="imageAssets.length || videoAssets.length || audioAsset" class="reference-assets">
|
||
<!-- 图片素材卡片列表 -->
|
||
<div
|
||
v-for="asset in imageAssets"
|
||
:key="asset.id"
|
||
class="reference-asset-card"
|
||
>
|
||
<img :src="asset.previewUrl" alt="图片预览" class="reference-asset-card__image" />
|
||
<div class="reference-asset-card__meta">
|
||
<span class="reference-asset-card__label">图片</span>
|
||
<div class="reference-asset-card__actions">
|
||
<span class="uploaded-action" @click="handlePreview(asset, 'image')">
|
||
<el-icon><ZoomIn /></el-icon>
|
||
</span>
|
||
<span class="uploaded-action" @click="handleRemoveAsset(asset.id, 'image')">
|
||
<el-icon><Delete /></el-icon>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 视频素材卡片列表 -->
|
||
<div
|
||
v-for="asset in videoAssets"
|
||
:key="asset.id"
|
||
class="reference-asset-card"
|
||
>
|
||
<video
|
||
:src="asset.previewUrl"
|
||
class="reference-asset-card__image"
|
||
muted
|
||
playsinline
|
||
preload="metadata"
|
||
/>
|
||
<div class="reference-asset-card__meta">
|
||
<span class="reference-asset-card__label">视频</span>
|
||
<div class="reference-asset-card__actions">
|
||
<span class="uploaded-action" @click="handlePreview(asset, 'video')">
|
||
<el-icon><ZoomIn /></el-icon>
|
||
</span>
|
||
<span class="uploaded-action" @click="handleRemoveAsset(asset.id, 'video')">
|
||
<el-icon><Delete /></el-icon>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 音频素材卡片:当前只有一个,因此不需要 v-for -->
|
||
<div v-if="audioAsset" class="reference-asset-card reference-asset-card--audio">
|
||
<div class="reference-asset-card__audio">
|
||
<div class="reference-asset-card__audio-icon">A</div>
|
||
<div class="reference-asset-card__audio-info">
|
||
<div class="reference-asset-card__audio-title">音频</div>
|
||
<div class="reference-asset-card__audio-name">{{ audioAsset.name }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="reference-asset-card__meta">
|
||
<span class="reference-asset-card__label">音频</span>
|
||
<div class="reference-asset-card__actions">
|
||
<span class="uploaded-action" @click="handlePreview(audioAsset, 'audio')">
|
||
<el-icon><ZoomIn /></el-icon>
|
||
</span>
|
||
<span class="uploaded-action" @click="handleRemoveAudio">
|
||
<el-icon><Delete /></el-icon>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 提示词输入区:描述希望生成的视频内容 -->
|
||
<div class="input">
|
||
<el-input
|
||
v-model="prompt"
|
||
type="textarea"
|
||
:rows="10"
|
||
maxlength="400"
|
||
:placeholder="promptPlaceholder"
|
||
show-word-limit
|
||
:disabled="generating"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 底部卡片:模型与动态配置参数 -->
|
||
<el-card class="btmcard">
|
||
<div class="select">
|
||
<!-- 模型选择:切换后重新初始化参数默认值 -->
|
||
<div class="select-item">
|
||
<div class="select-title">模型</div>
|
||
<div class="select-content">
|
||
<el-select
|
||
v-model="currentModelId"
|
||
style="width: 100%"
|
||
popper-class="custom-select-popup"
|
||
:disabled="generating"
|
||
@change="handleModelChange"
|
||
>
|
||
<el-option
|
||
v-for="model in models"
|
||
:key="model.id"
|
||
:label="model.model"
|
||
:value="model.id"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 动态字段区:根据当前模型的 input_fields 自动渲染 -->
|
||
<template v-for="field in visibleFields" :key="field.name">
|
||
<div class="select-item">
|
||
<div class="select-title">
|
||
{{ field.label }}
|
||
<span v-if="field.required" class="required-star">*</span>
|
||
</div>
|
||
<div class="select-content">
|
||
<!-- 下拉型字段 -->
|
||
<el-select
|
||
v-if="field.uitype === 'code'"
|
||
v-model="configData[field.name]"
|
||
style="width: 100%"
|
||
popper-class="custom-select-popup"
|
||
:placeholder="`请选择${field.label}`"
|
||
:disabled="generating"
|
||
>
|
||
<el-option
|
||
v-for="option in field.data"
|
||
:key="option.value"
|
||
:label="option.text || option.value"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
|
||
<!-- 文本型字段 -->
|
||
<el-input
|
||
v-else-if="field.uitype === 'text'"
|
||
v-model="configData[field.name]"
|
||
:placeholder="`请输入${field.label}`"
|
||
:type="field.name === 'negative_prompt' ? 'textarea' : 'text'"
|
||
:rows="field.name === 'negative_prompt' ? 3 : 1"
|
||
clearable
|
||
:disabled="generating"
|
||
/>
|
||
|
||
<!-- 数值型字段 -->
|
||
<el-input-number
|
||
v-else-if="field.uitype === 'int'"
|
||
v-model="configData[field.name]"
|
||
:min="1"
|
||
:max="field.max || 15"
|
||
:step="1"
|
||
controls-position="right"
|
||
style="width: 100%"
|
||
:disabled="generating"
|
||
/>
|
||
|
||
<!-- 兜底输入框:处理未显式分类的字段类型 -->
|
||
<el-input
|
||
v-else
|
||
v-model="configData[field.name]"
|
||
:placeholder="`请输入${field.label}`"
|
||
clearable
|
||
:disabled="generating"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 预览弹窗:根据当前预览素材类型切换不同的展示组件 -->
|
||
<el-dialog v-model="dialogVisible">
|
||
<video
|
||
v-if="dialogPreviewType === 'video'"
|
||
class="dialog-preview-media"
|
||
:src="dialogPreviewUrl"
|
||
controls
|
||
autoplay
|
||
/>
|
||
<audio
|
||
v-else-if="dialogPreviewType === 'audio'"
|
||
class="dialog-preview-audio"
|
||
:src="dialogPreviewUrl"
|
||
controls
|
||
autoplay
|
||
/>
|
||
<img v-else class="dialog-preview-media" :src="dialogPreviewUrl" alt="Preview Image" />
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||
import { ElMessage } from 'element-plus';
|
||
import { Delete, ZoomIn } from '@element-plus/icons-vue';
|
||
import { referencevideo, AllModel, queryModelPrice } from '../../apis/Model';
|
||
import type {
|
||
AllModelResponse,
|
||
Model,
|
||
ModelParams,
|
||
PriceQueryParams,
|
||
PriceQueryResponse,
|
||
TextVideoResponse,
|
||
} from '../../types/model';
|
||
|
||
// 向父组件抛出三个事件:
|
||
// 1. price-update:同步当前估价
|
||
// 2. prompt-change:同步“创作按钮是否可点击”
|
||
// 3. task-created:创建成功后通知父组件刷新任务列表
|
||
const emit = defineEmits<{
|
||
(e: 'price-update', price: number): void;
|
||
(e: 'prompt-change', isEmpty: boolean): void;
|
||
(e: 'task-created', taskid: string): void;
|
||
}>();
|
||
|
||
// 动态字段统一收口到这三种值类型,便于配置区双向绑定
|
||
type ConfigValue = string | number | boolean;
|
||
// 预览弹窗支持的素材类型
|
||
type AssetType = 'image' | 'video' | 'audio';
|
||
|
||
// 统一的上传素材结构:
|
||
// - file 用于真正提交 FormData
|
||
// - previewUrl 用于前端本地预览
|
||
// - id 用于列表渲染和删除
|
||
interface UploadedAsset {
|
||
id: string;
|
||
name: string;
|
||
file: File;
|
||
mimeType: string;
|
||
previewUrl: string;
|
||
}
|
||
|
||
const REFERENCE_PROMPT_PLACEHOLDER =
|
||
'支持上传图片、视频或主体作为参考。可参考视频中的动作、表情、运镜、特效、风格与构图等,并支持对元素进行增删和修改';
|
||
const MAX_VIDEO_DURATION_SECONDS = 11;
|
||
|
||
// 模型相关状态
|
||
const models = ref<Model[]>([]);
|
||
const currentModelId = ref('');
|
||
const currentModel = ref<Model | null>(null);
|
||
|
||
// 用户输入的提示词
|
||
const prompt = ref('');
|
||
|
||
// 动态配置字段的当前值集合
|
||
const configData = ref<Record<string, ConfigValue>>({});
|
||
|
||
// 生成中的互斥状态:生成期间禁用交互,避免重复提交
|
||
const generating = ref(false);
|
||
|
||
// 当前估价
|
||
const price = ref(0);
|
||
|
||
// 三类参考素材的本地状态
|
||
const imageAssets = ref<UploadedAsset[]>([]);
|
||
const videoAssets = ref<UploadedAsset[]>([]);
|
||
const audioAsset = ref<UploadedAsset | null>(null);
|
||
|
||
// 三个隐藏文件选择器的 DOM 引用
|
||
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||
const videoInputRef = ref<HTMLInputElement | null>(null);
|
||
const audioInputRef = ref<HTMLInputElement | null>(null);
|
||
|
||
// 预览弹窗状态
|
||
const dialogVisible = ref(false);
|
||
const dialogPreviewType = ref<AssetType>('image');
|
||
const dialogPreviewAsset = ref<UploadedAsset | null>(null);
|
||
|
||
// 价格查询防抖定时器,避免用户输入时频繁请求价格接口
|
||
let priceTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
// 后端返回的状态值不完全统一,这里先归一化,便于后续统一判断
|
||
const normalizeReferenceStatus = (status?: string) => {
|
||
const upperStatus = String(status || '').toUpperCase();
|
||
const statusMap: Record<string, string> = {
|
||
CREATED: 'CREATED',
|
||
CREATEDD: 'CREATED',
|
||
PENDING: 'PENDING',
|
||
QUEUEING: 'PENDING',
|
||
RUNNING: 'RUNNING',
|
||
PROCESSING: 'RUNNING',
|
||
SUCCEEDED: 'SUCCEEDED',
|
||
COMPLETED: 'SUCCEEDED',
|
||
FAILED: 'FAILED',
|
||
};
|
||
|
||
return statusMap[upperStatus] || upperStatus;
|
||
};
|
||
|
||
// 预览弹窗展示的资源地址
|
||
const dialogPreviewUrl = computed(() => dialogPreviewAsset.value?.previewUrl || '');
|
||
|
||
// 只渲染“普通配置字段”,上传类字段和 prompt 在界面上单独处理
|
||
const visibleFields = computed(() => {
|
||
if (!currentModel.value) return [];
|
||
return currentModel.value.input_fields.filter(
|
||
field => !isUploadField(field) && field.name !== 'prompt'
|
||
);
|
||
});
|
||
|
||
// 占位文案:如果 prompt 是必填,就在文案上标出来
|
||
const promptPlaceholder = computed(() => {
|
||
if (!currentModel.value) return REFERENCE_PROMPT_PLACEHOLDER;
|
||
const promptField = currentModel.value.input_fields.find(field => field.name === 'prompt');
|
||
return promptField?.required
|
||
? `${REFERENCE_PROMPT_PLACEHOLDER}(必填)`
|
||
: REFERENCE_PROMPT_PLACEHOLDER;
|
||
});
|
||
|
||
const audioUploadFieldNames = computed(() => {
|
||
if (!currentModel.value) return [];
|
||
return currentModel.value.input_fields
|
||
.filter(field => isAudioField(field))
|
||
.map(field => field.name);
|
||
});
|
||
|
||
// 根据模型字段判断是否属于图片/视频上传字段
|
||
const isAssetField = (field: { name: string; uitype: string }) => {
|
||
const fieldName = field.name.toLowerCase();
|
||
return (
|
||
field.uitype === 'image' ||
|
||
field.uitype === 'video' ||
|
||
fieldName.includes('image') ||
|
||
fieldName.includes('video')
|
||
);
|
||
};
|
||
|
||
// 根据模型字段判断是否属于音频上传字段
|
||
const isAudioField = (field: { name: string; uitype: string }) => {
|
||
return field.uitype === 'audio' || field.name.toLowerCase().includes('audio');
|
||
};
|
||
|
||
// 统一判断“是否是上传类字段”
|
||
const isUploadField = (field: { name: string; uitype: string }) => {
|
||
return isAssetField(field) || isAudioField(field);
|
||
};
|
||
|
||
// 参考生视频至少需要一类视觉素材:图片或视频
|
||
const hasVisualAssets = () => imageAssets.value.length > 0 || videoAssets.value.length > 0;
|
||
|
||
// 用于控制“创作按钮可用性”和价格查询前置条件
|
||
const hasReferenceContent = () => !!prompt.value.trim() || hasVisualAssets();
|
||
|
||
// 模型切换后初始化动态参数:
|
||
// - 跳过 prompt 和上传字段
|
||
// - code 字段优先取默认值,否则取第一个选项
|
||
// - int 字段无默认值时回退为 1
|
||
const initFormData = () => {
|
||
if (!currentModel.value) return;
|
||
|
||
const newConfigData: Record<string, ConfigValue> = {};
|
||
currentModel.value.input_fields.forEach(field => {
|
||
if (field.name === 'prompt' || isUploadField(field)) return;
|
||
|
||
let defaultValue: ConfigValue = field.defaultvalue ?? '';
|
||
if (defaultValue === undefined || defaultValue === null) {
|
||
if (field.uitype === 'code' && field.data && field.data.length > 0) {
|
||
defaultValue = field.data[0].value;
|
||
} else if (field.uitype === 'int') {
|
||
defaultValue = 1;
|
||
} else {
|
||
defaultValue = '';
|
||
}
|
||
}
|
||
newConfigData[field.name] = defaultValue;
|
||
});
|
||
|
||
configData.value = newConfigData;
|
||
};
|
||
|
||
// 构建价格查询参数:
|
||
// 这里只传价格相关的普通字段,不把文件本身传给价格接口
|
||
const buildPriceParams = (): PriceQueryParams | null => {
|
||
if (!currentModel.value) return null;
|
||
|
||
const configDataForPrice: PriceQueryParams['config_data'] = {
|
||
action: currentModel.value.apiname,
|
||
};
|
||
|
||
currentModel.value.input_fields.forEach(field => {
|
||
if (field.name === 'prompt' || isUploadField(field)) return;
|
||
|
||
const value = configData.value[field.name];
|
||
if (value !== undefined) {
|
||
configDataForPrice[field.name] = value;
|
||
}
|
||
});
|
||
|
||
if (audioAsset.value) {
|
||
configDataForPrice.audio = true;
|
||
}
|
||
|
||
if (!configDataForPrice.num && !configDataForPrice.count) {
|
||
configDataForPrice.num = 1;
|
||
}
|
||
|
||
return {
|
||
product_type: 'llm',
|
||
product_id: currentModel.value.id,
|
||
config_data: configDataForPrice,
|
||
};
|
||
};
|
||
|
||
// 价格查询做防抖:
|
||
// 用户频繁输入 prompt 或切换参数时,只在短暂停顿后发一次请求
|
||
const fetchPrice = () => {
|
||
if (priceTimer) clearTimeout(priceTimer);
|
||
|
||
priceTimer = setTimeout(async () => {
|
||
if (!hasReferenceContent()) {
|
||
price.value = 0;
|
||
emit('price-update', 0);
|
||
return;
|
||
}
|
||
|
||
const priceParams = buildPriceParams();
|
||
if (!priceParams) return;
|
||
|
||
try {
|
||
const res = (await queryModelPrice(priceParams)) as PriceQueryResponse;
|
||
if (res.status === 'ok' && res.data) {
|
||
price.value = res.data.amount;
|
||
emit('price-update', price.value);
|
||
} else {
|
||
price.value = 0;
|
||
emit('price-update', 0);
|
||
}
|
||
} catch (error) {
|
||
console.error('价格查询失败:', error);
|
||
price.value = 0;
|
||
emit('price-update', 0);
|
||
}
|
||
}, 500);
|
||
};
|
||
|
||
// 加载模型列表:
|
||
// 优先尝试“参考生视频”,如果没有,再兼容回退“图生视频”
|
||
const loadModels = async () => {
|
||
const candidateTypes: ModelParams['type'][] = ['参考生视频'];
|
||
|
||
try {
|
||
for (const type of candidateTypes) {
|
||
const res = await AllModel<AllModelResponse, ModelParams>({ type });
|
||
if (res.status === 'ok' && Array.isArray(res.data) && res.data.length > 0) {
|
||
models.value = res.data;
|
||
currentModelId.value = res.data[0].id;
|
||
currentModel.value = res.data[0];
|
||
initFormData();
|
||
fetchPrice();
|
||
return;
|
||
}
|
||
}
|
||
|
||
ElMessage.error('获取模型列表失败');
|
||
} catch (error) {
|
||
console.error('加载模型失败:', error);
|
||
ElMessage.error('加载模型列表失败,请刷新重试');
|
||
}
|
||
};
|
||
|
||
// 切换模型后,重新挂载当前模型对象并刷新默认参数与价格
|
||
const handleModelChange = (modelId: string) => {
|
||
const selected = models.value.find(model => model.id === modelId);
|
||
if (!selected) return;
|
||
|
||
currentModel.value = selected;
|
||
initFormData();
|
||
fetchPrice();
|
||
};
|
||
|
||
// 监听动态配置变化:参数变化后重新估价
|
||
watch(
|
||
() => [configData.value, currentModelId.value],
|
||
() => {
|
||
if (currentModel.value) fetchPrice();
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
// 监听核心输入变化:
|
||
// 用于更新父组件中的“创作按钮是否可点”状态,并驱动价格刷新
|
||
watch(
|
||
() => [prompt.value, imageAssets.value.length, videoAssets.value.length, !!audioAsset.value],
|
||
() => {
|
||
const isEmpty = !hasReferenceContent();
|
||
emit('prompt-change', isEmpty);
|
||
if (isEmpty) {
|
||
price.value = 0;
|
||
emit('price-update', 0);
|
||
} else {
|
||
fetchPrice();
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
// 三个按钮只负责触发隐藏 input 的 click
|
||
const triggerImageUpload = () => {
|
||
imageInputRef.value?.click();
|
||
};
|
||
|
||
const triggerVideoUpload = () => {
|
||
videoInputRef.value?.click();
|
||
};
|
||
|
||
const triggerAudioUpload = () => {
|
||
audioInputRef.value?.click();
|
||
};
|
||
|
||
// 用文件名、大小、修改时间生成一个稳定 id,便于去重和删除
|
||
const createAssetId = (file: File) => `${file.name}-${file.size}-${file.lastModified}`;
|
||
|
||
// 读取文件并转成本地预览结构:
|
||
// - file 保留原始文件用于提交
|
||
// - previewUrl 使用对象 URL,本地预览时不再生成 base64
|
||
const readFileAsAsset = async (file: File): Promise<UploadedAsset> => {
|
||
return {
|
||
id: createAssetId(file),
|
||
name: file.name,
|
||
file,
|
||
mimeType: file.type,
|
||
previewUrl: URL.createObjectURL(file),
|
||
};
|
||
};
|
||
|
||
// 读取视频元数据时长,用于在上传前拦截超时素材
|
||
const getVideoDuration = (file: File): Promise<number> => {
|
||
return new Promise((resolve, reject) => {
|
||
const video = document.createElement('video');
|
||
const objectUrl = URL.createObjectURL(file);
|
||
|
||
const cleanup = () => {
|
||
video.removeAttribute('src');
|
||
video.load();
|
||
URL.revokeObjectURL(objectUrl);
|
||
};
|
||
|
||
video.preload = 'metadata';
|
||
video.onloadedmetadata = () => {
|
||
const duration = video.duration;
|
||
cleanup();
|
||
|
||
if (Number.isFinite(duration)) {
|
||
resolve(duration);
|
||
return;
|
||
}
|
||
|
||
reject(new Error('无法读取视频时长'));
|
||
};
|
||
video.onerror = () => {
|
||
cleanup();
|
||
reject(new Error('视频文件读取失败'));
|
||
};
|
||
video.src = objectUrl;
|
||
});
|
||
};
|
||
|
||
// 释放对象 URL,避免频繁上传/删除时在浏览器里留下无效引用
|
||
const revokeAssetPreviewUrl = (asset: UploadedAsset | null) => {
|
||
if (asset?.previewUrl) {
|
||
URL.revokeObjectURL(asset.previewUrl);
|
||
}
|
||
};
|
||
|
||
// 选择文件后清空 input 的 value,
|
||
// 否则重复选择同一个文件时浏览器可能不会再次触发 change
|
||
const resetInputValue = (
|
||
inputRef: typeof imageInputRef | typeof videoInputRef | typeof audioInputRef
|
||
) => {
|
||
if (inputRef.value) {
|
||
inputRef.value.value = '';
|
||
}
|
||
};
|
||
|
||
// 图片选择事件:支持一次选择多张
|
||
const handleImageSelect = async (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const files = Array.from(input.files || []);
|
||
if (files.length === 0) return;
|
||
|
||
await handleImageChange(files);
|
||
resetInputValue(imageInputRef);
|
||
};
|
||
|
||
// 视频选择事件:支持一次选择多个
|
||
const handleVideoSelect = async (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const files = Array.from(input.files || []);
|
||
if (files.length === 0) return;
|
||
|
||
await handleVideoChange(files);
|
||
resetInputValue(videoInputRef);
|
||
};
|
||
|
||
// 音频选择事件:当前只读取第一个文件
|
||
const handleAudioSelect = async (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
if (!file) return;
|
||
|
||
await handleAudioChange(file);
|
||
resetInputValue(audioInputRef);
|
||
};
|
||
|
||
// 通用图片/视频追加逻辑:
|
||
// - 先过滤类型不合法的文件
|
||
// - 再读取成本地素材对象
|
||
// - 再按 id 去重,避免重复添加同一个文件
|
||
const appendAssets = async (
|
||
files: File[],
|
||
type: 'image' | 'video'
|
||
) => {
|
||
const validFiles = files.filter(file =>
|
||
type === 'image' ? file.type.startsWith('image/') : file.type.startsWith('video/')
|
||
);
|
||
|
||
if (validFiles.length !== files.length) {
|
||
ElMessage.warning(type === 'image' ? '包含非图片文件,已自动忽略' : '包含非视频文件,已自动忽略');
|
||
}
|
||
|
||
if (validFiles.length === 0) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
let uploadableFiles = validFiles;
|
||
|
||
if (type === 'video') {
|
||
const durationResults = await Promise.all(
|
||
validFiles.map(async file => {
|
||
try {
|
||
const duration = await getVideoDuration(file);
|
||
return { file, duration, readable: true };
|
||
} catch {
|
||
return { file, duration: 0, readable: false };
|
||
}
|
||
})
|
||
);
|
||
|
||
const readableFiles = durationResults.filter(result => result.readable);
|
||
const tooLongFiles = readableFiles.filter(
|
||
result => result.duration > MAX_VIDEO_DURATION_SECONDS
|
||
);
|
||
|
||
if (readableFiles.length !== validFiles.length) {
|
||
ElMessage.warning('部分视频读取失败,已自动忽略');
|
||
}
|
||
|
||
if (tooLongFiles.length > 0) {
|
||
ElMessage.warning(`视频时长最多 ${MAX_VIDEO_DURATION_SECONDS} 秒,已自动忽略超时视频`);
|
||
}
|
||
|
||
uploadableFiles = readableFiles
|
||
.filter(result => result.duration <= MAX_VIDEO_DURATION_SECONDS)
|
||
.map(result => result.file);
|
||
|
||
if (uploadableFiles.length === 0) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
const nextAssets = await Promise.all(uploadableFiles.map(file => readFileAsAsset(file)));
|
||
const targetAssets = type === 'image' ? imageAssets : videoAssets;
|
||
const existingIds = new Set(targetAssets.value.map(asset => asset.id));
|
||
const dedupedAssets = nextAssets.filter(asset => !existingIds.has(asset.id));
|
||
|
||
targetAssets.value = [...targetAssets.value, ...dedupedAssets];
|
||
ElMessage.success(type === 'image' ? '图片上传成功' : '视频上传成功');
|
||
} catch (error) {
|
||
console.error(type === 'image' ? '图片上传失败:' : '视频上传失败:', error);
|
||
ElMessage.error(type === 'image' ? '图片上传失败,请重试' : '视频上传失败,请重试');
|
||
}
|
||
};
|
||
|
||
// 图片上传入口
|
||
const handleImageChange = async (files: File[]) => {
|
||
await appendAssets(files, 'image');
|
||
};
|
||
|
||
// 视频上传入口
|
||
const handleVideoChange = async (files: File[]) => {
|
||
await appendAssets(files, 'video');
|
||
};
|
||
|
||
// 音频上传入口:
|
||
// 这里只保留一个音频,因此每次上传都会覆盖旧值
|
||
const handleAudioChange = async (file: File) => {
|
||
if (!file.type.startsWith('audio/')) {
|
||
ElMessage.warning('请上传音频文件');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
audioAsset.value = await readFileAsAsset(file);
|
||
ElMessage.success('音频上传成功');
|
||
} catch (error) {
|
||
console.error('音频上传失败:', error);
|
||
ElMessage.error('音频上传失败,请重试');
|
||
}
|
||
};
|
||
|
||
// 删除图片或视频素材
|
||
const handleRemoveAsset = (assetId: string, type: 'image' | 'video') => {
|
||
if (type === 'image') {
|
||
const targetAsset = imageAssets.value.find(asset => asset.id === assetId) || null;
|
||
revokeAssetPreviewUrl(targetAsset);
|
||
imageAssets.value = imageAssets.value.filter(asset => asset.id !== assetId);
|
||
ElMessage.info('已删除图片');
|
||
return;
|
||
}
|
||
|
||
const targetAsset = videoAssets.value.find(asset => asset.id === assetId) || null;
|
||
revokeAssetPreviewUrl(targetAsset);
|
||
videoAssets.value = videoAssets.value.filter(asset => asset.id !== assetId);
|
||
ElMessage.info('已删除视频');
|
||
};
|
||
|
||
// 删除音频素材
|
||
const handleRemoveAudio = () => {
|
||
revokeAssetPreviewUrl(audioAsset.value);
|
||
audioAsset.value = null;
|
||
resetInputValue(audioInputRef);
|
||
ElMessage.info('已删除音频');
|
||
};
|
||
|
||
// 打开预览弹窗,并记录当前要预览的素材和类型
|
||
const handlePreview = (asset: UploadedAsset, type: AssetType) => {
|
||
dialogPreviewAsset.value = asset;
|
||
dialogPreviewType.value = type;
|
||
dialogVisible.value = true;
|
||
};
|
||
|
||
// 构建提交参数:
|
||
// 使用 FormData 包裹文本字段,上传类字段直接追加原始文件
|
||
const buildSubmitParams = (): FormData => {
|
||
const formData = new FormData();
|
||
|
||
// 模型 id 是后端识别当前生成模型的基础字段
|
||
formData.append('llmid', currentModel.value!.id);
|
||
|
||
// prompt 只有在用户实际填写后才提交
|
||
if (prompt.value.trim()) {
|
||
formData.append('prompt', prompt.value);
|
||
}
|
||
|
||
// 动态配置字段逐个塞进 FormData
|
||
Object.entries(configData.value).forEach(([key, value]) => {
|
||
if (value === undefined || value === null || value === '') return;
|
||
formData.append(key, String(value));
|
||
});
|
||
|
||
// 多个图片/视频通过同名字段重复追加,后端可按数组接收
|
||
imageAssets.value.forEach(asset => {
|
||
formData.append('image_files', asset.file, asset.name);
|
||
});
|
||
|
||
videoAssets.value.forEach(asset => {
|
||
formData.append('video_files', asset.file, asset.name);
|
||
});
|
||
|
||
if (audioAsset.value) {
|
||
const targetFieldNames = audioUploadFieldNames.value.length > 0
|
||
? audioUploadFieldNames.value
|
||
: ['audio_files'];
|
||
|
||
targetFieldNames.forEach(fieldName => {
|
||
formData.append(fieldName, audioAsset.value!.file, audioAsset.value!.name);
|
||
});
|
||
}
|
||
|
||
return formData;
|
||
};
|
||
|
||
// 调试输出 FormData:
|
||
// 逐项打印所有字段,方便核对最终提交给后端的参数结构
|
||
const logFormDataEntries = (formData: FormData) => {
|
||
console.group('[ReferenceVideo] FormData Payload');
|
||
|
||
for (const [key, value] of formData.entries()) {
|
||
if (value instanceof File) {
|
||
console.log(`${key}:`, {
|
||
name: value.name,
|
||
type: value.type,
|
||
size: value.size,
|
||
lastModified: value.lastModified,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
console.log(`${key}:`, value);
|
||
}
|
||
|
||
console.groupEnd();
|
||
};
|
||
|
||
// 提交创作:
|
||
// 1. 先做必填校验
|
||
// 2. 再调用后端接口
|
||
// 3. 根据状态给出提示并刷新任务列表
|
||
const generate = async (): Promise<boolean> => {
|
||
if (generating.value) {
|
||
return false;
|
||
}
|
||
|
||
if (!currentModel.value) {
|
||
ElMessage.error('请选择模型');
|
||
return false;
|
||
}
|
||
|
||
if (!hasVisualAssets()) {
|
||
ElMessage.warning('请至少上传一个视频或图片');
|
||
return false;
|
||
}
|
||
|
||
const missingFields: string[] = [];
|
||
currentModel.value.input_fields.forEach(field => {
|
||
if (!field.required) return;
|
||
|
||
if (field.name === 'prompt') {
|
||
if (!prompt.value.trim()) missingFields.push(field.label);
|
||
return;
|
||
}
|
||
|
||
if (isAudioField(field)) {
|
||
if (!audioAsset.value) missingFields.push(field.label);
|
||
return;
|
||
}
|
||
|
||
if (isAssetField(field)) {
|
||
const isImageField = field.name.toLowerCase().includes('image') || field.uitype === 'image';
|
||
const hasAsset = isImageField ? imageAssets.value.length > 0 : videoAssets.value.length > 0;
|
||
if (!hasAsset) missingFields.push(field.label);
|
||
return;
|
||
}
|
||
|
||
const value = configData.value[field.name];
|
||
if (value === '' || value === undefined || value === null) {
|
||
missingFields.push(field.label);
|
||
}
|
||
});
|
||
|
||
if (missingFields.length > 0) {
|
||
ElMessage.error(`请填写必填项:${missingFields.join('、')}`);
|
||
return false;
|
||
}
|
||
|
||
generating.value = true;
|
||
try {
|
||
// 构建 multipart/form-data 请求体
|
||
const params = buildSubmitParams();
|
||
|
||
// 提交前完整打印 FormData 内容,便于联调排查字段问题
|
||
logFormDataEntries(params);
|
||
|
||
// 调用参考生视频创建接口
|
||
const res = (await referencevideo(params)) as TextVideoResponse;
|
||
const taskid = res.data?.taskid || res.taskid;
|
||
|
||
// 先做一次状态归一化,避免后端状态大小写或别名不一致
|
||
const normalizedStatus = normalizeReferenceStatus(res.status);
|
||
|
||
// CREATED / PENDING / RUNNING / SUCCEEDED 都视为“创建链路成功”
|
||
if (['CREATED', 'PENDING', 'RUNNING', 'SUCCEEDED'].includes(normalizedStatus)) {
|
||
const statusMsgMap: Record<string, string> = {
|
||
CREATED: '视频生成任务已提交',
|
||
PENDING: '排队中',
|
||
RUNNING: '运行中',
|
||
SUCCEEDED: '视频生成成功',
|
||
};
|
||
const statusMsg = statusMsgMap[normalizedStatus] || '视频生成任务已提交';
|
||
ElMessage.success(
|
||
normalizedStatus === 'SUCCEEDED'
|
||
? statusMsg
|
||
: `${statusMsg},请稍后在任务列表中查看`
|
||
);
|
||
|
||
// 通知父组件刷新右侧任务列表
|
||
emit('task-created', taskid || '');
|
||
|
||
// 创建成功后清空当前表单,避免用户误重复提交
|
||
resetForm();
|
||
return true;
|
||
}
|
||
|
||
// 明确失败状态直接提示失败
|
||
if (normalizedStatus === 'FAILED') {
|
||
ElMessage.error(res.message || '生成失败,请重试');
|
||
return false;
|
||
}
|
||
|
||
// 其他未知状态统一兜底提示
|
||
ElMessage.error(res.message || `生成失败:${res.status}`);
|
||
return false;
|
||
} catch (error) {
|
||
console.error('生成失败:', error);
|
||
ElMessage.error(error instanceof Error ? error.message : '生成失败,请重试');
|
||
return false;
|
||
} finally {
|
||
generating.value = false;
|
||
}
|
||
};
|
||
|
||
// 重置整个表单:
|
||
// 包括提示词、素材、文件 input、动态配置、价格和父组件状态
|
||
const resetForm = () => {
|
||
imageAssets.value.forEach(asset => revokeAssetPreviewUrl(asset));
|
||
videoAssets.value.forEach(asset => revokeAssetPreviewUrl(asset));
|
||
revokeAssetPreviewUrl(audioAsset.value);
|
||
prompt.value = '';
|
||
imageAssets.value = [];
|
||
videoAssets.value = [];
|
||
audioAsset.value = null;
|
||
resetInputValue(imageInputRef);
|
||
resetInputValue(videoInputRef);
|
||
resetInputValue(audioInputRef);
|
||
initFormData();
|
||
price.value = 0;
|
||
emit('price-update', 0);
|
||
emit('prompt-change', true);
|
||
};
|
||
|
||
// 暴露给父组件的方法:父组件点击“创作”按钮时通过 ref 调用 generate
|
||
defineExpose({
|
||
generate,
|
||
resetForm,
|
||
});
|
||
|
||
// 组件挂载后立即加载模型列表
|
||
onMounted(() => {
|
||
loadModels();
|
||
});
|
||
|
||
// 卸载前清理防抖定时器,避免组件销毁后仍然触发异步更新
|
||
onBeforeUnmount(() => {
|
||
imageAssets.value.forEach(asset => revokeAssetPreviewUrl(asset));
|
||
videoAssets.value.forEach(asset => revokeAssetPreviewUrl(asset));
|
||
revokeAssetPreviewUrl(audioAsset.value);
|
||
if (priceTimer) {
|
||
clearTimeout(priceTimer);
|
||
priceTimer = null;
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
@import url('../../assets/less/Video/refernce/refernce.less');
|
||
|
||
</style>
|
||
|
||
|
||
<style>
|
||
.reference-to-video {
|
||
gap: 16px !important;
|
||
}
|
||
/* 全局样式:自定义下拉框弹出层 */
|
||
.custom-select-popup {
|
||
background-color: #2A2D34 !important;
|
||
border-radius: 12px !important;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
|
||
border: none !important;
|
||
}
|
||
|
||
.custom-select-popup .el-select-dropdown__item {
|
||
color: #ffffff !important;
|
||
background-color: transparent !important;
|
||
font-size: 14px !important;
|
||
}
|
||
|
||
.custom-select-popup .el-select-dropdown__item.selected,
|
||
.custom-select-popup .el-select-dropdown__item:hover {
|
||
background-color: #3a3d46 !important;
|
||
}
|
||
</style> |