Files
huobao-drama/web/src/views/generation/components/GenerateVideoDialog.vue
2026-01-13 20:45:48 +08:00

363 lines
10 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>
<el-dialog
v-model="visible"
title="AI 视频生成"
width="700px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="选择剧本" prop="drama_id">
<el-select v-model="form.drama_id" placeholder="选择剧本" @change="onDramaChange">
<el-option
v-for="drama in dramas"
:key="drama.id"
:label="drama.title"
:value="drama.id"
/>
</el-select>
</el-form-item>
<el-form-item label="选择图片" prop="image_gen_id">
<el-select
v-model="form.image_gen_id"
placeholder="选择已生成的图片"
clearable
@change="onImageChange"
>
<el-option
v-for="image in images"
:key="image.id"
:label="truncateText(image.prompt, 50)"
:value="image.id"
>
<div class="image-option">
<img v-if="image.image_url" :src="image.image_url" class="image-thumb" />
<span>{{ truncateText(image.prompt, 40) }}</span>
</div>
</el-option>
</el-select>
<div class="form-tip">或直接输入图片 URL</div>
</el-form-item>
<el-form-item label="图片 URL" prop="image_url">
<el-input
v-model="form.image_url"
placeholder="https://example.com/image.jpg"
:disabled="!!form.image_gen_id"
/>
</el-form-item>
<el-form-item label="视频提示词" prop="prompt">
<el-input
v-model="form.prompt"
type="textarea"
:rows="5"
placeholder="描述视频中的动作和运镜&#10;例如Camera slowly zooms in, wind blowing through hair, cinematic lighting"
maxlength="2000"
show-word-limit
/>
</el-form-item>
<el-form-item label="AI 服务">
<el-select v-model="form.provider" placeholder="选择服务">
<el-option label="豆包视频" value="doubao" />
<el-option label="Runway" value="runway" />
<el-option label="Pika" value="pika" />
</el-select>
</el-form-item>
<el-form-item label="视频时长">
<el-slider
v-model="form.duration"
:min="3"
:max="10"
:marks="durationMarks"
show-stops
/>
<span class="slider-value">{{ form.duration }} </span>
</el-form-item>
<el-form-item label="宽高比">
<el-radio-group v-model="form.aspect_ratio">
<el-radio label="16:9">16:9 (横屏)</el-radio>
<el-radio label="9:16">9:16 (竖屏)</el-radio>
<el-radio label="1:1">1:1 (方形)</el-radio>
</el-radio-group>
</el-form-item>
<el-collapse>
<el-collapse-item title="高级设置" name="advanced">
<el-form-item label="运动强度">
<el-slider
v-model="form.motion_level"
:min="0"
:max="100"
:marks="motionMarks"
/>
<span class="slider-value">{{ form.motion_level }}</span>
</el-form-item>
<el-form-item label="镜头运动">
<el-select v-model="form.camera_motion" placeholder="选择镜头运动" clearable>
<el-option label="静止" value="static" />
<el-option label="推进 (Zoom In)" value="zoom_in" />
<el-option label="拉远 (Zoom Out)" value="zoom_out" />
<el-option label="左移 (Pan Left)" value="pan_left" />
<el-option label="右移 (Pan Right)" value="pan_right" />
<el-option label="上移 (Tilt Up)" value="tilt_up" />
<el-option label="下移 (Tilt Down)" value="tilt_down" />
<el-option label="环绕 (Orbit)" value="orbit" />
</el-select>
</el-form-item>
<el-form-item label="风格" v-if="form.provider === 'doubao'">
<el-input v-model="form.style" placeholder="例如:电影级、动画风格" />
</el-form-item>
<el-form-item label="随机种子">
<el-input-number v-model="form.seed" :min="-1" placeholder="留空随机" />
<span class="form-tip">设置相同种子可复现视频</span>
</el-form-item>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="generating" @click="handleGenerate">
生成视频
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { videoAPI } from '@/api/video'
import { imageAPI } from '@/api/image'
import { dramaAPI } from '@/api/drama'
import type { Drama } from '@/types/drama'
import type { ImageGeneration } from '@/types/image'
import type { GenerateVideoRequest } from '@/types/video'
interface Props {
modelValue: boolean
dramaId?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const formRef = ref<FormInstance>()
const generating = ref(false)
const dramas = ref<Drama[]>([])
const images = ref<ImageGeneration[]>([])
const form = reactive<GenerateVideoRequest & { image_gen_id?: number }>({
drama_id: props.dramaId || '',
image_gen_id: undefined,
image_url: '',
prompt: '',
provider: 'doubao',
duration: 5,
aspect_ratio: '16:9',
motion_level: 50,
camera_motion: undefined,
style: undefined,
seed: undefined
})
const rules: FormRules = {
drama_id: [
{ required: true, message: '请选择剧本', trigger: 'change' }
],
prompt: [
{ required: true, message: '请输入视频提示词', trigger: 'blur' },
{ min: 5, message: '提示词至少5个字符', trigger: 'blur' }
]
}
const durationMarks = {
3: '3s',
5: '5s',
7: '7s',
10: '10s'
}
const motionMarks = {
0: '静态',
50: '适中',
100: '剧烈'
}
watch(() => props.modelValue, (val) => {
if (val) {
loadDramas()
if (props.dramaId) {
form.drama_id = props.dramaId
loadImages(props.dramaId)
}
}
})
const loadDramas = async () => {
try {
const result = await dramaAPI.list({ page: 1, page_size: 100 })
dramas.value = result.items
} catch (error: any) {
console.error('Failed to load dramas:', error)
}
}
const loadImages = async (dramaId: string) => {
try {
const result = await imageAPI.listImages({
drama_id: dramaId,
status: 'completed',
page: 1,
page_size: 100
})
images.value = result.items
} catch (error: any) {
console.error('Failed to load images:', error)
}
}
const onDramaChange = (dramaId: string) => {
form.image_gen_id = undefined
form.image_url = ''
images.value = []
if (dramaId) {
loadImages(dramaId)
}
}
const onImageChange = (imageGenId: number | undefined) => {
if (!imageGenId) {
form.image_url = ''
return
}
const image = images.value.find(img => img.id === imageGenId)
if (image && image.image_url) {
form.image_url = image.image_url
form.prompt = image.prompt
}
}
const truncateText = (text: string, length: number) => {
if (text.length <= length) return text
return text.substring(0, length) + '...'
}
const handleGenerate = async () => {
console.log('handleGenerate called')
if (!formRef.value) {
console.error('formRef is null')
ElMessage.error('表单初始化失败,请刷新页面重试')
return
}
try {
const valid = await formRef.value.validate()
console.log('Form validation result:', valid)
if (!valid) {
console.log('Form validation failed')
return
}
generating.value = true
console.log('Starting video generation...', form)
try {
if (form.image_gen_id) {
console.log('Generating from image:', form.image_gen_id)
await videoAPI.generateFromImage(form.image_gen_id)
} else {
const params: GenerateVideoRequest = {
drama_id: form.drama_id,
prompt: form.prompt,
provider: form.provider
}
// 判断参考图模式
if (form.image_url && form.image_url.trim()) {
params.image_url = form.image_url
params.reference_mode = 'single'
} else {
// 纯文本生成,无参考图
params.reference_mode = 'none'
}
if (form.duration) params.duration = form.duration
if (form.aspect_ratio) params.aspect_ratio = form.aspect_ratio
if (form.motion_level !== undefined) params.motion_level = form.motion_level
if (form.camera_motion) params.camera_motion = form.camera_motion
if (form.style) params.style = form.style
if (form.seed && form.seed > 0) params.seed = form.seed
console.log('Generating video with params:', params)
await videoAPI.generateVideo(params)
}
ElMessage.success('视频生成任务已提交,请稍后查看结果')
emit('success')
handleClose()
} catch (error: any) {
console.error('Video generation failed:', error)
ElMessage.error(error.response?.data?.message || error.message || '生成失败')
} finally {
generating.value = false
}
} catch (error: any) {
console.error('Form validation error:', error)
ElMessage.warning('请检查表单填写是否完整')
}
}
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
</script>
<style scoped>
.form-tip {
margin-top: 4px;
font-size: 12px;
color: #999;
}
.slider-value {
margin-left: 12px;
font-size: 14px;
font-weight: 500;
color: #409eff;
}
.image-option {
display: flex;
align-items: center;
gap: 8px;
}
.image-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
}
</style>