init
This commit is contained in:
337
web/src/views/generation/components/GenerateVideoDialog.vue
Normal file
337
web/src/views/generation/components/GenerateVideoDialog.vue
Normal 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="描述视频中的动作和运镜 例如: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>
|
||||
Reference in New Issue
Block a user