This commit is contained in:
Connor
2026-01-12 13:17:11 +08:00
parent 95851f8e69
commit 9600fc542c
132 changed files with 35734 additions and 5 deletions

View File

@@ -0,0 +1,337 @@
<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' }
],
image_url: [
{ required: true, message: '请选择图片或输入图片 URL', trigger: 'blur' }
],
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 () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
generating.value = true
try {
if (form.image_gen_id) {
await videoAPI.generateFromImage(form.image_gen_id)
} else {
const params: GenerateVideoRequest = {
drama_id: form.drama_id,
image_url: form.image_url,
prompt: form.prompt,
provider: form.provider
}
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
await videoAPI.generateVideo(params)
}
ElMessage.success('视频生成任务已提交,请稍后查看结果')
emit('success')
handleClose()
} catch (error: any) {
ElMessage.error(error.message || '生成失败')
} finally {
generating.value = false
}
})
}
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>