yuanjing/src/components/video/ReferenceVideo.vue
2026-06-04 11:17:47 +08:00

1048 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>