feat: 实现分镜视频功能和提示词优化功能
主要功能: 1. 分镜视频创作功能 - 支持文生图生成分镜图 - 支持直接上传分镜图生成视频 - 两步式流程:生成分镜图 -> 生成视频 - 完整的任务管理和状态轮询 2. 提示词优化功能 - 为所有创作页面添加一键优化按钮 - 支持三种优化类型:文生视频、图生视频、分镜视频 - 使用GPT-4o-mini进行智能优化 - 完善的错误处理和用户体验 技术改进: - 使用@Async和@Transactional优化异步处理 - 增强错误处理和超时控制 - 改进前端状态管理和用户体验 - 添加完整的代码审查文档
This commit is contained in:
231
demo/STORYBOARD_VIDEO_CODE_REVIEW.md
Normal file
231
demo/STORYBOARD_VIDEO_CODE_REVIEW.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# 分镜视频功能代码逻辑检查报告
|
||||
|
||||
## 检查日期
|
||||
2025-10-29
|
||||
|
||||
## 总体评估
|
||||
✅ **代码逻辑基本正确**,已修复多个潜在问题。
|
||||
|
||||
---
|
||||
|
||||
## 已修复的问题
|
||||
|
||||
### 1. 前端轮询逻辑优化 ✅
|
||||
**问题**:
|
||||
- 轮询可能在组件卸载后继续运行,造成内存泄漏
|
||||
- 错误处理可能导致轮询未正确清理
|
||||
|
||||
**修复**:
|
||||
- 添加 `pollIntervalId` 保存轮询ID
|
||||
- 在组件卸载时使用 `onBeforeUnmount` 清理轮询
|
||||
- 优化轮询逻辑:先检查最大次数,再处理任务状态
|
||||
- 所有退出路径(成功、失败、超时)都正确清理轮询
|
||||
|
||||
**代码位置**:
|
||||
- `demo/frontend/src/views/StoryboardVideoCreate.vue:287-346`
|
||||
|
||||
### 2. 图片URL转换错误处理增强 ✅
|
||||
**问题**:
|
||||
- 外部URL可能因CORS问题无法加载
|
||||
- 错误信息不够明确
|
||||
|
||||
**修复**:
|
||||
- 添加HTTP状态码检查
|
||||
- 区分base64和普通URL的错误处理
|
||||
- 提供更清晰的错误提示
|
||||
|
||||
**代码位置**:
|
||||
- `demo/frontend/src/views/StoryboardVideoCreate.vue:348-380`
|
||||
|
||||
### 3. 后端权限验证补充 ✅
|
||||
**问题**:
|
||||
- 获取任务详情接口缺少用户权限验证
|
||||
|
||||
**修复**:
|
||||
- 添加用户身份验证
|
||||
- 检查任务所有者是否匹配当前用户
|
||||
- 添加安全日志记录
|
||||
|
||||
**代码位置**:
|
||||
- `demo/src/main/java/com/example/demo/controller/StoryboardVideoApiController.java:85-124`
|
||||
|
||||
---
|
||||
|
||||
## 代码逻辑流程分析
|
||||
|
||||
### 前端流程
|
||||
|
||||
1. **生成分镜图步骤** (`startGenerate`)
|
||||
```
|
||||
用户输入提示词
|
||||
→ 验证输入
|
||||
→ 调用 createStoryboardTask API
|
||||
→ 创建任务成功
|
||||
→ 开始轮询任务状态
|
||||
→ 分镜图生成完成
|
||||
→ 自动切换到视频步骤
|
||||
```
|
||||
|
||||
2. **生成视频步骤** (`startVideoGenerate`)
|
||||
```
|
||||
验证分镜图已生成
|
||||
→ 将图片URL转换为File对象
|
||||
→ 调用图生视频API
|
||||
→ 跳转到视频详情页
|
||||
```
|
||||
|
||||
3. **轮询逻辑** (`pollTaskStatus`)
|
||||
```
|
||||
每2秒查询一次任务状态
|
||||
→ 检查任务状态(COMPLETED/FAILED)
|
||||
→ 达到最大尝试次数(30次=60秒)自动停止
|
||||
→ 所有退出路径都清理定时器
|
||||
```
|
||||
|
||||
### 后端流程
|
||||
|
||||
1. **创建任务** (`StoryboardVideoService.createTask`)
|
||||
```
|
||||
验证参数
|
||||
→ 创建数据库记录(PENDING状态)
|
||||
→ 异步调用 processTaskAsync
|
||||
→ 返回任务ID
|
||||
```
|
||||
|
||||
2. **异步处理任务** (`StoryboardVideoService.processTaskAsync`)
|
||||
```
|
||||
重新加载任务实体
|
||||
→ 更新状态为PROCESSING
|
||||
→ 调用文生图API (RealAIService.submitTextToImageTask)
|
||||
→ 解析API响应获取图片URL
|
||||
→ 更新任务为COMPLETED并保存图片URL
|
||||
```
|
||||
|
||||
3. **文生图API调用** (`RealAIService.submitTextToImageTask`)
|
||||
```
|
||||
转换宽高比为图片尺寸
|
||||
→ 构建请求体(使用ObjectMapper)
|
||||
→ 调用Comfly API
|
||||
→ 返回图片数据(支持url或b64_json)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 潜在风险和注意事项
|
||||
|
||||
### 1. 图片URL跨域问题 ⚠️
|
||||
**风险**:如果文生图API返回的图片URL是外部域名,前端`fetch`可能因CORS策略失败。
|
||||
|
||||
**建议**:
|
||||
- 如果API返回的是外部URL,可能需要后端代理下载后返回base64
|
||||
- 或者使用`<img crossorigin="anonymous">`标签先验证可访问性
|
||||
|
||||
**当前处理**:已在`urlToFile`中添加错误处理,会提示用户图片无法加载。
|
||||
|
||||
### 2. 并发创建任务 ⚠️
|
||||
**风险**:用户快速点击"开始生成"按钮可能创建多个任务。
|
||||
|
||||
**当前处理**:使用`inProgress`标志禁用按钮,但组件卸载后重新进入页面时不会检查是否有进行中的任务。
|
||||
|
||||
**建议**:页面加载时检查是否有未完成的任务,如果有则自动恢复轮询。
|
||||
|
||||
### 3. 事务管理 ✅
|
||||
**状态**:已正确实现
|
||||
- 使用`@Async`注解实现异步执行
|
||||
- 使用`@Transactional`确保数据一致性
|
||||
- 在异步方法中重新加载实体,避免`StaleObjectStateException`
|
||||
|
||||
### 4. 错误处理完整性 ✅
|
||||
**状态**:已完善
|
||||
- 前端:所有API调用都有try-catch和用户提示
|
||||
- 后端:所有异常都有日志记录和错误响应
|
||||
- 轮询失败不会导致无限重试(最多30次)
|
||||
|
||||
---
|
||||
|
||||
## 代码质量评估
|
||||
|
||||
### ✅ 优点
|
||||
|
||||
1. **清晰的步骤分离**:两步流程(生成分镜图 → 生成视频)逻辑清晰
|
||||
2. **完整的错误处理**:所有关键路径都有错误处理
|
||||
3. **良好的用户体验**:自动切换步骤、加载状态提示
|
||||
4. **安全性**:用户权限验证、JWT认证
|
||||
5. **代码可维护性**:模块化设计、清晰的注释
|
||||
|
||||
### ⚠️ 可改进点
|
||||
|
||||
1. **用户体验优化**:
|
||||
- 可以在页面加载时检查是否有未完成的任务
|
||||
- 可以添加任务历史记录展示
|
||||
|
||||
2. **性能优化**:
|
||||
- 图片URL转换可能较慢,可以添加进度提示
|
||||
- 可以考虑使用Web Worker处理图片转换
|
||||
|
||||
3. **日志优化**:
|
||||
- 前端日志较多,生产环境可以移除或使用环境变量控制
|
||||
|
||||
---
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 功能测试
|
||||
|
||||
1. ✅ 正常流程测试:
|
||||
- 输入提示词 → 生成分镜图 → 自动切换 → 生成视频
|
||||
|
||||
2. ✅ 异常流程测试:
|
||||
- 网络错误时的处理
|
||||
- API返回错误时的处理
|
||||
- 图片URL无效时的处理
|
||||
|
||||
3. ✅ 边界测试:
|
||||
- 超长提示词
|
||||
- 空提示词
|
||||
- 快速连续点击按钮
|
||||
- 组件卸载时轮询清理
|
||||
|
||||
### 安全性测试
|
||||
|
||||
1. ✅ 权限验证测试:
|
||||
- 用户A无法访问用户B的任务
|
||||
|
||||
2. ✅ JWT认证测试:
|
||||
- Token过期时的处理
|
||||
- 无效Token时的处理
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
**代码质量**:✅ 良好
|
||||
|
||||
**主要改进**:
|
||||
- ✅ 修复了轮询内存泄漏问题
|
||||
- ✅ 增强了图片URL转换的错误处理
|
||||
- ✅ 补充了后端权限验证
|
||||
|
||||
**建议后续优化**:
|
||||
- 添加任务历史记录功能
|
||||
- 优化图片加载性能
|
||||
- 添加任务恢复机制
|
||||
|
||||
---
|
||||
|
||||
## 相关文件清单
|
||||
|
||||
### 前端
|
||||
- `demo/frontend/src/views/StoryboardVideoCreate.vue` - 主页面组件
|
||||
- `demo/frontend/src/api/storyboardVideo.js` - API客户端
|
||||
- `demo/frontend/src/api/imageToVideo.js` - 图生视频API
|
||||
- `demo/frontend/src/api/request.js` - Axios实例配置
|
||||
|
||||
### 后端
|
||||
- `demo/src/main/java/com/example/demo/controller/StoryboardVideoApiController.java` - REST控制器
|
||||
- `demo/src/main/java/com/example/demo/service/StoryboardVideoService.java` - 业务逻辑服务
|
||||
- `demo/src/main/java/com/example/demo/service/RealAIService.java` - AI API调用服务
|
||||
- `demo/src/main/java/com/example/demo/model/StoryboardVideoTask.java` - 数据模型
|
||||
- `demo/src/main/java/com/example/demo/repository/StoryboardVideoTaskRepository.java` - 数据访问层
|
||||
- `demo/src/main/resources/db/migration/V8__Create_Storyboard_Video_Tasks_Table.sql` - 数据库迁移脚本
|
||||
|
||||
31
demo/frontend/src/api/promptOptimizer.js
Normal file
31
demo/frontend/src/api/promptOptimizer.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import api from './request'
|
||||
|
||||
/**
|
||||
* 优化提示词
|
||||
* @param {string} prompt - 原始提示词
|
||||
* @param {string} type - 优化类型: 'text-to-video' | 'image-to-video' | 'storyboard'
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
export const optimizePrompt = async (prompt, type = 'text-to-video') => {
|
||||
// 参数验证
|
||||
if (!prompt || !prompt.trim()) {
|
||||
throw new Error('提示词不能为空')
|
||||
}
|
||||
|
||||
if (prompt.length > 2000) {
|
||||
throw new Error('提示词过长,请控制在2000字符以内')
|
||||
}
|
||||
|
||||
// 设置较长的超时时间(30秒),因为AI优化可能需要较长时间
|
||||
return api.post('/prompt/optimize', {
|
||||
prompt: prompt.trim(),
|
||||
type
|
||||
}, {
|
||||
timeout: 30000
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
optimizePrompt
|
||||
}
|
||||
|
||||
22
demo/frontend/src/api/storyboardVideo.js
Normal file
22
demo/frontend/src/api/storyboardVideo.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import api from './request'
|
||||
|
||||
/**
|
||||
* 创建分镜视频任务
|
||||
*/
|
||||
export const createStoryboardTask = async (data) => {
|
||||
return api.post('/storyboard-video/create', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
*/
|
||||
export const getStoryboardTask = async (taskId) => {
|
||||
return api.get(`/storyboard-video/task/${taskId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户任务列表
|
||||
*/
|
||||
export const getUserStoryboardTasks = async (page = 0, size = 10) => {
|
||||
return api.get('/storyboard-video/tasks', { params: { page, size } })
|
||||
}
|
||||
@@ -127,6 +127,8 @@
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import cleanupApi from '@/api/cleanup'
|
||||
import api from '@/api/request'
|
||||
|
||||
// 响应式数据
|
||||
const loadingStats = ref(false)
|
||||
@@ -167,18 +169,9 @@ const testGetStats = async () => {
|
||||
statsError.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/cleanup/cleanup-stats', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
})
|
||||
if (response.ok) {
|
||||
statsResult.value = await response.json()
|
||||
ElMessage.success('获取统计信息成功')
|
||||
} else {
|
||||
statsError.value = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
const response = await cleanupApi.getCleanupStats()
|
||||
statsResult.value = response.data
|
||||
ElMessage.success('获取统计信息成功')
|
||||
} catch (error) {
|
||||
statsError.value = error.message
|
||||
ElMessage.error('获取统计信息失败')
|
||||
@@ -193,20 +186,9 @@ const testFullCleanup = async () => {
|
||||
cleanupError.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/cleanup/full-cleanup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
cleanupResult.value = await response.json()
|
||||
ElMessage.success('完整清理执行成功')
|
||||
} else {
|
||||
cleanupError.value = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
const response = await cleanupApi.performFullCleanup()
|
||||
cleanupResult.value = response.data
|
||||
ElMessage.success('完整清理执行成功')
|
||||
} catch (error) {
|
||||
cleanupError.value = error.message
|
||||
ElMessage.error('执行完整清理失败')
|
||||
@@ -224,20 +206,9 @@ const testUserCleanup = async () => {
|
||||
userCleanupError.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/cleanup/user-tasks/${userCleanupForm.username}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
userCleanupResult.value = await response.json()
|
||||
ElMessage.success(`用户 ${userCleanupForm.username} 的任务清理完成`)
|
||||
} else {
|
||||
userCleanupError.value = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
const response = await cleanupApi.cleanupUserTasks(userCleanupForm.username)
|
||||
userCleanupResult.value = response.data
|
||||
ElMessage.success(`用户 ${userCleanupForm.username} 的任务清理完成`)
|
||||
} catch (error) {
|
||||
userCleanupError.value = error.message
|
||||
ElMessage.error('清理用户任务失败')
|
||||
@@ -252,13 +223,9 @@ const testQueueStatus = async () => {
|
||||
queueError.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/diagnostic/queue-status')
|
||||
if (response.ok) {
|
||||
queueResult.value = await response.json()
|
||||
ElMessage.success('获取队列状态成功')
|
||||
} else {
|
||||
queueError.value = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
const response = await api.get('/diagnostic/queue-status')
|
||||
queueResult.value = response.data
|
||||
ElMessage.success('获取队列状态成功')
|
||||
} catch (error) {
|
||||
queueError.value = error.message
|
||||
ElMessage.error('获取队列状态失败')
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
rows="6"
|
||||
></textarea>
|
||||
<div class="optimize-btn">
|
||||
<button class="optimize-button">
|
||||
✨ 一键优化
|
||||
<button class="optimize-button" @click="optimizePromptHandler" :disabled="!inputText.trim() || optimizingPrompt">
|
||||
✨ {{ optimizingPrompt ? '优化中...' : '一键优化' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,6 +277,7 @@ import { ElMessage, ElLoading } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
@@ -301,6 +302,7 @@ const taskStatus = ref('')
|
||||
const stopPolling = ref(null)
|
||||
const showInProgress = ref(false)
|
||||
const watermarkOption = ref('without')
|
||||
const optimizingPrompt = ref(false) // 优化提示词状态
|
||||
|
||||
// 用户菜单相关
|
||||
const showUserMenu = ref(false)
|
||||
@@ -602,6 +604,72 @@ const formatDate = (dateString) => {
|
||||
return `${year}年${month}月${day}日 ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 优化提示词
|
||||
const optimizePromptHandler = async () => {
|
||||
if (!inputText.value.trim()) {
|
||||
ElMessage.warning('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
// 长度检查
|
||||
if (inputText.value.length > 2000) {
|
||||
ElMessage.warning('提示词过长,请控制在2000字符以内')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
optimizingPrompt.value = true
|
||||
const loading = ElLoading.service({
|
||||
lock: false,
|
||||
text: '正在优化提示词,请稍候...',
|
||||
background: 'rgba(0, 0, 0, 0.3)'
|
||||
})
|
||||
|
||||
const response = await optimizePrompt(inputText.value.trim(), 'image-to-video')
|
||||
|
||||
loading.close()
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const data = response.data.data
|
||||
const optimized = data.optimizedPrompt
|
||||
|
||||
// 检查是否真正优化了
|
||||
if (data.optimized && optimized !== inputText.value.trim()) {
|
||||
inputText.value = optimized
|
||||
ElMessage.success('提示词优化成功!')
|
||||
} else {
|
||||
ElMessage.warning('提示词已优化,但可能无明显变化')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || '优化失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('优化提示词失败:', error)
|
||||
|
||||
let errorMessage = '优化提示词失败'
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
if (status === 400) {
|
||||
errorMessage = error.response.data?.message || '请求参数错误'
|
||||
} else if (status === 408 || error.code === 'ECONNABORTED') {
|
||||
errorMessage = '请求超时,请稍后重试'
|
||||
} else if (status >= 500) {
|
||||
errorMessage = '服务器错误,请稍后重试'
|
||||
} else {
|
||||
errorMessage = error.response.data?.message || '优化失败'
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查网络连接'
|
||||
} else {
|
||||
errorMessage = error.message || '优化失败'
|
||||
}
|
||||
|
||||
ElMessage.error(errorMessage)
|
||||
} finally {
|
||||
optimizingPrompt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建同款
|
||||
const createSimilar = () => {
|
||||
// 保持当前设置,重新生成
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图生视频</span>
|
||||
</div>
|
||||
<div class="nav-item active storyboard-item">
|
||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideoCreate">
|
||||
<el-icon><Film /></el-icon>
|
||||
<span>分镜视频</span>
|
||||
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
|
||||
@@ -208,6 +208,10 @@ const goToImageToVideo = () => {
|
||||
router.push('/image-to-video/create')
|
||||
}
|
||||
|
||||
const goToStoryboardVideoCreate = () => {
|
||||
router.push('/storyboard-video/create')
|
||||
}
|
||||
|
||||
const goToCreate = (work) => {
|
||||
// 跳转到分镜视频创作页面
|
||||
router.push('/storyboard-video/create')
|
||||
|
||||
@@ -35,14 +35,26 @@
|
||||
|
||||
<!-- 分镜步骤标签 -->
|
||||
<div class="storyboard-steps">
|
||||
<div class="step active">生成分镜图</div>
|
||||
<div class="step">生成视频</div>
|
||||
<div
|
||||
class="step"
|
||||
:class="{ active: currentStep === 'generate' }"
|
||||
@click="switchToGenerateStep"
|
||||
>
|
||||
生成分镜图
|
||||
</div>
|
||||
<div
|
||||
class="step"
|
||||
:class="{ active: currentStep === 'video', disabled: !generatedImageUrl && !uploadedImage }"
|
||||
@click="switchToVideoStep"
|
||||
>
|
||||
生成视频
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成分镜图区域 -->
|
||||
<div class="storyboard-section">
|
||||
<!-- 生成分镜图区域 - 只在第一步显示 -->
|
||||
<div class="storyboard-section" v-if="currentStep === 'generate'">
|
||||
<div class="image-upload-btn" @click="uploadImage">
|
||||
<span>+ 图片 (可选)</span>
|
||||
<span>+ 上传分镜图 (可直接生成视频)</span>
|
||||
</div>
|
||||
|
||||
<!-- 已上传的图片预览 -->
|
||||
@@ -59,13 +71,21 @@
|
||||
rows="6"
|
||||
></textarea>
|
||||
<div class="optimize-btn">
|
||||
<button class="optimize-button">
|
||||
✨ 一键优化
|
||||
<button class="optimize-button" @click="optimizePromptHandler" :disabled="!inputText.trim() || optimizingPrompt">
|
||||
✨ {{ optimizingPrompt ? '优化中...' : '一键优化' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成视频区域 - 只在第二步显示 -->
|
||||
<div class="storyboard-section" v-if="currentStep === 'video'">
|
||||
<div class="generated-image-preview">
|
||||
<img v-if="generatedImageUrl || uploadedImage" :src="generatedImageUrl || uploadedImage" alt="分镜图" />
|
||||
<div v-else class="placeholder-text">暂无分镜图</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频设置 -->
|
||||
<div class="video-settings">
|
||||
<div class="setting-item">
|
||||
@@ -90,8 +110,12 @@
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<div class="generate-section">
|
||||
<button class="generate-btn" @click="startGenerate">
|
||||
开始生成
|
||||
<button
|
||||
class="generate-btn"
|
||||
@click="handleGenerateClick"
|
||||
:disabled="inProgress || (!uploadedImage && !inputText.trim() && currentStep === 'generate') || (currentStep === 'video' && !generatedImageUrl && !uploadedImage)"
|
||||
>
|
||||
{{ getButtonText() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +129,17 @@
|
||||
</div>
|
||||
|
||||
<div class="preview-content">
|
||||
<div class="preview-placeholder">
|
||||
<!-- 显示上传的图片或生成的图片 -->
|
||||
<div v-if="uploadedImage || generatedImageUrl" class="preview-image">
|
||||
<img :src="uploadedImage || generatedImageUrl" alt="分镜图" />
|
||||
</div>
|
||||
<!-- 显示加载状态 -->
|
||||
<div v-else-if="inProgress" class="preview-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">{{ currentStep === 'generate' ? '正在生成分镜图,请稍候...' : '正在生成视频,请稍候...' }}</div>
|
||||
</div>
|
||||
<!-- 显示占位符 -->
|
||||
<div v-else class="preview-placeholder">
|
||||
<div class="placeholder-text">开始创作您的第一个作品吧!</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,8 +150,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createStoryboardTask, getStoryboardTask } from '@/api/storyboardVideo'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -126,9 +164,14 @@ const inputText = ref('')
|
||||
const aspectRatio = ref('16:9')
|
||||
const hdMode = ref(false)
|
||||
const inProgress = ref(false)
|
||||
const currentStep = ref('generate') // 'generate' 或 'video'
|
||||
|
||||
// 图片上传
|
||||
const uploadedImage = ref('')
|
||||
const generatedImageUrl = ref('')
|
||||
const taskId = ref('')
|
||||
const pollIntervalId = ref(null) // 保存轮询定时器ID
|
||||
const optimizingPrompt = ref(false) // 优化提示词状态
|
||||
|
||||
// 导航函数
|
||||
const goBack = () => {
|
||||
@@ -143,6 +186,70 @@ const goToImageToVideo = () => {
|
||||
router.push('/image-to-video/create')
|
||||
}
|
||||
|
||||
// 切换到生成分镜图步骤
|
||||
const switchToGenerateStep = () => {
|
||||
currentStep.value = 'generate'
|
||||
}
|
||||
|
||||
// 切换到视频步骤
|
||||
const switchToVideoStep = () => {
|
||||
// 如果有上传的图片或生成的分镜图,可以切换
|
||||
if (uploadedImage.value || generatedImageUrl.value) {
|
||||
currentStep.value = 'video'
|
||||
} else {
|
||||
ElMessage.warning('请先上传分镜图或生成分镜图')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取按钮文本
|
||||
const getButtonText = () => {
|
||||
if (currentStep.value === 'video') {
|
||||
return '生成视频'
|
||||
}
|
||||
// 第一步:根据是否有图片和提示词显示不同文本
|
||||
if (uploadedImage.value) {
|
||||
return '使用上传图片生成视频'
|
||||
}
|
||||
if (inputText.value.trim()) {
|
||||
return '开始生成分镜图'
|
||||
}
|
||||
return '开始生成'
|
||||
}
|
||||
|
||||
// 处理生成按钮点击
|
||||
const handleGenerateClick = () => {
|
||||
console.log('handleGenerateClick 被调用,当前步骤:', currentStep.value)
|
||||
|
||||
// 如果已经切换到视频步骤,直接生成视频
|
||||
if (currentStep.value === 'video') {
|
||||
startVideoGenerate()
|
||||
return
|
||||
}
|
||||
|
||||
// 第一步的逻辑:
|
||||
// 1. 如果上传了图片,直接使用上传的图片生成视频
|
||||
if (uploadedImage.value) {
|
||||
// 使用上传的图片作为分镜图
|
||||
generatedImageUrl.value = uploadedImage.value
|
||||
// 切换到视频步骤并生成视频
|
||||
currentStep.value = 'video'
|
||||
// 延迟一点,让UI更新
|
||||
setTimeout(() => {
|
||||
startVideoGenerate()
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 如果只有提示词,生成分镜图
|
||||
if (inputText.value.trim()) {
|
||||
startGenerate()
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 如果都没有,提示用户
|
||||
ElMessage.warning('请上传分镜图或输入提示词')
|
||||
}
|
||||
|
||||
// 图片上传处理
|
||||
const uploadImage = () => {
|
||||
const input = document.createElement('input')
|
||||
@@ -154,6 +261,10 @@ const uploadImage = () => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
uploadedImage.value = e.target.result
|
||||
// 上传图片后,自动使用上传的图片作为分镜图
|
||||
generatedImageUrl.value = e.target.result
|
||||
// 如果有上传的图片,可以自动切换到视频步骤(可选)
|
||||
// currentStep.value = 'video'
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
@@ -162,24 +273,327 @@ const uploadImage = () => {
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
// 保存上传图片的值,用于判断是否与生成的图片相同
|
||||
const wasSameImage = uploadedImage.value && generatedImageUrl.value === uploadedImage.value
|
||||
uploadedImage.value = ''
|
||||
// 如果上传的图片被删除,且它被用作生成的分镜图,也清除生成的图片URL
|
||||
if (wasSameImage) {
|
||||
generatedImageUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const startGenerate = () => {
|
||||
// 优化提示词
|
||||
const optimizePromptHandler = async () => {
|
||||
if (!inputText.value.trim()) {
|
||||
alert('请输入描述文字')
|
||||
ElMessage.warning('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
inProgress.value = true
|
||||
alert('开始生成分镜图...')
|
||||
// 长度检查
|
||||
if (inputText.value.length > 2000) {
|
||||
ElMessage.warning('提示词过长,请控制在2000字符以内')
|
||||
return
|
||||
}
|
||||
|
||||
// 调用真实生成API
|
||||
setTimeout(() => {
|
||||
inProgress.value = false
|
||||
alert('分镜图生成完成!')
|
||||
}, 3000)
|
||||
try {
|
||||
optimizingPrompt.value = true
|
||||
const loading = ElLoading.service({
|
||||
lock: false,
|
||||
text: '正在优化提示词,请稍候...',
|
||||
background: 'rgba(0, 0, 0, 0.3)'
|
||||
})
|
||||
|
||||
const response = await optimizePrompt(inputText.value.trim(), 'storyboard')
|
||||
|
||||
loading.close()
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const data = response.data.data
|
||||
const optimized = data.optimizedPrompt
|
||||
|
||||
// 检查是否真正优化了
|
||||
if (data.optimized && optimized !== inputText.value.trim()) {
|
||||
inputText.value = optimized
|
||||
ElMessage.success('提示词优化成功!')
|
||||
} else {
|
||||
ElMessage.warning('提示词已优化,但可能无明显变化')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || '优化失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('优化提示词失败:', error)
|
||||
|
||||
let errorMessage = '优化提示词失败'
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
if (status === 400) {
|
||||
errorMessage = error.response.data?.message || '请求参数错误'
|
||||
} else if (status === 408 || error.code === 'ECONNABORTED') {
|
||||
errorMessage = '请求超时,请稍后重试'
|
||||
} else if (status >= 500) {
|
||||
errorMessage = '服务器错误,请稍后重试'
|
||||
} else {
|
||||
errorMessage = error.response.data?.message || '优化失败'
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查网络连接'
|
||||
} else {
|
||||
errorMessage = error.message || '优化失败'
|
||||
}
|
||||
|
||||
ElMessage.error(errorMessage)
|
||||
} finally {
|
||||
optimizingPrompt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始生成分镜图
|
||||
const startGenerate = async () => {
|
||||
console.log('startGenerate 被调用')
|
||||
|
||||
if (!inputText.value.trim()) {
|
||||
ElMessage.warning('请输入描述文字')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('开始创建任务,参数:', {
|
||||
prompt: inputText.value,
|
||||
aspectRatio: aspectRatio.value,
|
||||
hdMode: hdMode.value,
|
||||
imageUrl: uploadedImage.value || null
|
||||
})
|
||||
|
||||
inProgress.value = true
|
||||
ElMessage.info('开始生成分镜图...')
|
||||
|
||||
// 调用API创建任务
|
||||
const response = await createStoryboardTask({
|
||||
prompt: inputText.value,
|
||||
aspectRatio: aspectRatio.value,
|
||||
hdMode: hdMode.value,
|
||||
imageUrl: uploadedImage.value || null
|
||||
})
|
||||
|
||||
console.log('API响应:', response)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
ElMessage.success('分镜图任务创建成功!')
|
||||
taskId.value = response.data.data.taskId
|
||||
console.log('Task created:', response.data.data)
|
||||
|
||||
// 开始轮询任务状态,获取生成的图片
|
||||
// inProgress 将在轮询完成时设置为 false
|
||||
pollTaskStatus(response.data.data.taskId)
|
||||
} else {
|
||||
console.error('创建任务失败,响应:', response.data)
|
||||
ElMessage.error(response.data?.message || '创建任务失败')
|
||||
inProgress.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成分镜图失败,完整错误:', error)
|
||||
console.error('错误详情:', {
|
||||
message: error.message,
|
||||
response: error.response,
|
||||
stack: error.stack
|
||||
})
|
||||
ElMessage.error('生成分镜图失败: ' + (error.response?.data?.message || error.message))
|
||||
inProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询任务状态
|
||||
const pollTaskStatus = async (taskId) => {
|
||||
// 清除之前的轮询(如果存在)
|
||||
if (pollIntervalId.value) {
|
||||
clearInterval(pollIntervalId.value)
|
||||
pollIntervalId.value = null
|
||||
}
|
||||
|
||||
const maxAttempts = 30
|
||||
let attempts = 0
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
// 先检查是否超过最大尝试次数
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(poll)
|
||||
pollIntervalId.value = null
|
||||
inProgress.value = false
|
||||
ElMessage.warning('任务超时,请稍后查看')
|
||||
return
|
||||
}
|
||||
|
||||
attempts++
|
||||
|
||||
try {
|
||||
// 调用获取任务详情的API
|
||||
const response = await getStoryboardTask(taskId)
|
||||
console.log('轮询任务状态:', response.data)
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const task = response.data.data
|
||||
console.log('任务状态:', task.status, '图片URL:', task.resultUrl)
|
||||
|
||||
if (task.status === 'COMPLETED' && task.resultUrl) {
|
||||
clearInterval(poll)
|
||||
pollIntervalId.value = null
|
||||
console.log('分镜图生成完成,图片URL:', task.resultUrl)
|
||||
generatedImageUrl.value = task.resultUrl
|
||||
inProgress.value = false
|
||||
ElMessage.success('分镜图生成完成!')
|
||||
// 自动跳转到第二步
|
||||
setTimeout(() => {
|
||||
currentStep.value = 'video'
|
||||
}, 500)
|
||||
return
|
||||
} else if (task.status === 'FAILED') {
|
||||
clearInterval(poll)
|
||||
pollIntervalId.value = null
|
||||
inProgress.value = false
|
||||
ElMessage.error('分镜图生成失败: ' + (task.errorMessage || '未知错误'))
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询任务状态失败:', error)
|
||||
// 错误时继续轮询,直到达到最大次数
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// 保存轮询ID
|
||||
pollIntervalId.value = poll
|
||||
}
|
||||
|
||||
// 将图片URL转换为File对象
|
||||
const urlToFile = async (url, filename) => {
|
||||
try {
|
||||
let blob
|
||||
|
||||
// 如果是base64格式
|
||||
if (url.startsWith('data:image')) {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误: ${response.status}`)
|
||||
}
|
||||
blob = await response.blob()
|
||||
return new File([blob], filename, { type: blob.type })
|
||||
} else {
|
||||
// 如果是普通URL(可能跨域)
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误: ${response.status}`)
|
||||
}
|
||||
blob = await response.blob()
|
||||
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
|
||||
} catch (fetchError) {
|
||||
// 如果fetch失败(可能是CORS),尝试通过代理或提示用户
|
||||
console.error('直接获取图片失败,可能是CORS问题:', fetchError)
|
||||
throw new Error('无法加载图片,请确保图片URL可以访问')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('转换图片URL失败:', error)
|
||||
throw new Error('无法加载图片: ' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
// 生成视频
|
||||
const startVideoGenerate = async () => {
|
||||
console.log('startVideoGenerate 被调用')
|
||||
|
||||
// 优先使用上传的图片,其次使用生成的分镜图
|
||||
const imageUrl = uploadedImage.value || generatedImageUrl.value
|
||||
|
||||
if (!imageUrl) {
|
||||
ElMessage.warning('请先上传分镜图或生成分镜图')
|
||||
return
|
||||
}
|
||||
|
||||
// 提示词可选,如果没有则使用默认提示词或空字符串
|
||||
// 图生视频API可能需要提示词,使用上传图片时的提示词或默认提示词
|
||||
let prompt = inputText.value.trim()
|
||||
if (!prompt && uploadedImage.value) {
|
||||
// 如果用户只上传了图片没有输入提示词,可以使用空字符串或默认提示词
|
||||
prompt = '根据图片生成视频' // 默认提示词
|
||||
}
|
||||
|
||||
try {
|
||||
inProgress.value = true
|
||||
ElMessage.info('开始生成视频...')
|
||||
|
||||
// 将图片URL转换为File对象
|
||||
console.log('转换图片URL:', imageUrl)
|
||||
let imageFile
|
||||
|
||||
// 如果是base64格式(上传的图片),直接转换
|
||||
if (imageUrl.startsWith('data:image')) {
|
||||
const base64Data = imageUrl.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: 'image/jpeg' })
|
||||
imageFile = new File([blob], 'storyboard-image.jpg', { type: 'image/jpeg' })
|
||||
} else {
|
||||
// 如果是URL,使用fetch转换
|
||||
imageFile = await urlToFile(imageUrl, 'storyboard-image.jpg')
|
||||
}
|
||||
|
||||
// 调用图生视频API
|
||||
console.log('调用图生视频API,参数:', {
|
||||
prompt: prompt,
|
||||
aspectRatio: aspectRatio.value,
|
||||
hdMode: hdMode.value,
|
||||
duration: 5,
|
||||
firstFrame: imageFile
|
||||
})
|
||||
|
||||
const response = await imageToVideoApi.createTask({
|
||||
firstFrame: imageFile,
|
||||
prompt: prompt,
|
||||
aspectRatio: aspectRatio.value,
|
||||
duration: 5, // 默认5秒
|
||||
hdMode: hdMode.value
|
||||
})
|
||||
|
||||
console.log('图生视频API响应:', response)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const taskId = response.data.data.taskId
|
||||
ElMessage.success('视频任务创建成功!')
|
||||
console.log('视频任务创建成功,任务ID:', taskId)
|
||||
|
||||
// 跳转到图生视频结果页面或者在这里显示结果
|
||||
// 可以轮询任务状态或跳转到任务详情页
|
||||
router.push(`/image-to-video/detail/${taskId}`)
|
||||
} else {
|
||||
console.error('创建视频任务失败,响应:', response.data)
|
||||
ElMessage.error(response.data?.message || '创建视频任务失败')
|
||||
inProgress.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成视频失败,完整错误:', error)
|
||||
console.error('错误详情:', {
|
||||
message: error.message,
|
||||
response: error.response,
|
||||
stack: error.stack
|
||||
})
|
||||
ElMessage.error('生成视频失败: ' + (error.response?.data?.message || error.message))
|
||||
inProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理轮询
|
||||
onBeforeUnmount(() => {
|
||||
if (pollIntervalId.value) {
|
||||
clearInterval(pollIntervalId.value)
|
||||
pollIntervalId.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -381,11 +795,44 @@ const startGenerate = () => {
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.step:hover:not(.active) {
|
||||
.step:hover:not(.active):not(.disabled) {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.step.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.step.disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 生成的图片预览 */
|
||||
.generated-image-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #0a0a0a;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.generated-image-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.generated-image-preview .placeholder-text {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 分镜图区域 */
|
||||
.storyboard-section {
|
||||
display: flex;
|
||||
@@ -658,6 +1105,55 @@ const startGenerate = () => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 预览图片 */
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-image img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.preview-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #2a2a2a;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.main-content {
|
||||
|
||||
@@ -387,6 +387,7 @@ import {
|
||||
Delete,
|
||||
Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
import cleanupApi from '@/api/cleanup'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -507,18 +508,9 @@ const getAuthHeaders = () => {
|
||||
const refreshStats = async () => {
|
||||
loadingStats.value = true
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/cleanup/cleanup-stats', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
})
|
||||
if (response.ok) {
|
||||
cleanupStats.value = await response.json()
|
||||
ElMessage.success('统计信息刷新成功')
|
||||
} else {
|
||||
ElMessage.error('获取统计信息失败')
|
||||
}
|
||||
const response = await cleanupApi.getCleanupStats()
|
||||
cleanupStats.value = response.data
|
||||
ElMessage.success('统计信息刷新成功')
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error)
|
||||
ElMessage.error('获取统计信息失败')
|
||||
@@ -530,23 +522,11 @@ const refreshStats = async () => {
|
||||
const performFullCleanup = async () => {
|
||||
loadingCleanup.value = true
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/cleanup/full-cleanup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
ElMessage.success('完整清理执行成功')
|
||||
console.log('清理结果:', result)
|
||||
// 刷新统计信息
|
||||
await refreshStats()
|
||||
} else {
|
||||
ElMessage.error('执行完整清理失败')
|
||||
}
|
||||
const response = await cleanupApi.performFullCleanup()
|
||||
ElMessage.success('完整清理执行成功')
|
||||
console.log('清理结果:', response.data)
|
||||
// 刷新统计信息
|
||||
await refreshStats()
|
||||
} catch (error) {
|
||||
console.error('执行完整清理失败:', error)
|
||||
ElMessage.error('执行完整清理失败')
|
||||
@@ -566,6 +546,27 @@ const performUserCleanup = async () => {
|
||||
const valid = await userCleanupFormRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
loadingUserCleanup.value = true
|
||||
try {
|
||||
const response = await cleanupApi.cleanupUserTasks(userCleanupForm.username)
|
||||
ElMessage.success('用户任务清理成功')
|
||||
console.log('清理结果:', response.data)
|
||||
// 刷新统计信息
|
||||
await refreshStats()
|
||||
// 关闭对话框
|
||||
handleCloseUserCleanupDialog()
|
||||
} catch (error) {
|
||||
console.error('清理用户任务失败:', error)
|
||||
ElMessage.error('清理用户任务失败')
|
||||
} finally {
|
||||
loadingUserCleanup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const performUserCleanup_old = async () => {
|
||||
const valid = await userCleanupFormRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
loadingUserCleanup.value = true
|
||||
try {
|
||||
const response = await fetch(`/api/cleanup/user-tasks/${userCleanupForm.username}`, {
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
rows="8"
|
||||
></textarea>
|
||||
<div class="optimize-btn">
|
||||
<button class="optimize-button">
|
||||
✨ 一键优化
|
||||
<button class="optimize-button" @click="optimizePromptHandler" :disabled="!inputText.trim() || optimizingPrompt">
|
||||
✨ {{ optimizingPrompt ? '优化中...' : '一键优化' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,6 +241,7 @@ import { textToVideoApi } from '@/api/textToVideo'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
@@ -257,6 +258,7 @@ const taskStatus = ref('')
|
||||
const stopPolling = ref(null)
|
||||
const showInProgress = ref(false)
|
||||
const watermarkOption = ref('without')
|
||||
const optimizingPrompt = ref(false) // 优化提示词状态
|
||||
|
||||
// 用户菜单相关
|
||||
const showUserMenu = ref(false)
|
||||
@@ -477,6 +479,72 @@ const formatDate = (dateString) => {
|
||||
return `${year}年${month}月${day}日 ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 优化提示词
|
||||
const optimizePromptHandler = async () => {
|
||||
if (!inputText.value.trim()) {
|
||||
ElMessage.warning('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
// 长度检查
|
||||
if (inputText.value.length > 2000) {
|
||||
ElMessage.warning('提示词过长,请控制在2000字符以内')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
optimizingPrompt.value = true
|
||||
const loading = ElLoading.service({
|
||||
lock: false,
|
||||
text: '正在优化提示词,请稍候...',
|
||||
background: 'rgba(0, 0, 0, 0.3)'
|
||||
})
|
||||
|
||||
const response = await optimizePrompt(inputText.value.trim(), 'text-to-video')
|
||||
|
||||
loading.close()
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const data = response.data.data
|
||||
const optimized = data.optimizedPrompt
|
||||
|
||||
// 检查是否真正优化了
|
||||
if (data.optimized && optimized !== inputText.value.trim()) {
|
||||
inputText.value = optimized
|
||||
ElMessage.success('提示词优化成功!')
|
||||
} else {
|
||||
ElMessage.warning('提示词已优化,但可能无明显变化')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || '优化失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('优化提示词失败:', error)
|
||||
|
||||
let errorMessage = '优化提示词失败'
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
if (status === 400) {
|
||||
errorMessage = error.response.data?.message || '请求参数错误'
|
||||
} else if (status === 408 || error.code === 'ECONNABORTED') {
|
||||
errorMessage = '请求超时,请稍后重试'
|
||||
} else if (status >= 500) {
|
||||
errorMessage = '服务器错误,请稍后重试'
|
||||
} else {
|
||||
errorMessage = error.response.data?.message || '优化失败'
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查网络连接'
|
||||
} else {
|
||||
errorMessage = error.message || '优化失败'
|
||||
}
|
||||
|
||||
ElMessage.error(errorMessage)
|
||||
} finally {
|
||||
optimizingPrompt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建同款
|
||||
const createSimilar = () => {
|
||||
// 保持当前设置,重新生成
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.example.demo.service.RealAIService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/prompt")
|
||||
public class PromptOptimizerApiController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(PromptOptimizerApiController.class);
|
||||
|
||||
@Autowired
|
||||
private RealAIService realAIService;
|
||||
|
||||
/**
|
||||
* 优化提示词
|
||||
*
|
||||
* @param request 包含prompt和type的请求体
|
||||
* @param authentication 用户认证信息
|
||||
* @return 优化后的提示词
|
||||
*/
|
||||
@PostMapping("/optimize")
|
||||
public ResponseEntity<?> optimizePrompt(
|
||||
@RequestBody Map<String, Object> request,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
String username = authentication.getName();
|
||||
logger.info("收到优化提示词请求,用户: {}", username);
|
||||
|
||||
// 从请求中提取参数
|
||||
String prompt = (String) request.get("prompt");
|
||||
String type = (String) request.getOrDefault("type", "text-to-video");
|
||||
|
||||
// 参数验证
|
||||
if (prompt == null || prompt.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "提示词不能为空"));
|
||||
}
|
||||
|
||||
// 长度验证(防止过长)
|
||||
if (prompt.length() > 2000) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "提示词过长,请控制在2000字符以内"));
|
||||
}
|
||||
|
||||
// 验证type是否有效
|
||||
if (!isValidType(type)) {
|
||||
logger.warn("无效的优化类型: {}, 使用默认类型: text-to-video", type);
|
||||
type = "text-to-video"; // 默认类型
|
||||
}
|
||||
|
||||
// 调用优化服务
|
||||
String optimizedPrompt = realAIService.optimizePrompt(prompt.trim(), type);
|
||||
|
||||
// 检查优化是否成功(如果返回原始提示词可能是失败)
|
||||
boolean optimized = !optimizedPrompt.equals(prompt.trim());
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", optimized ? "提示词优化成功" : "提示词优化完成(可能使用了原始提示词)");
|
||||
response.put("data", Map.of(
|
||||
"originalPrompt", prompt,
|
||||
"optimizedPrompt", optimizedPrompt,
|
||||
"type", type,
|
||||
"optimized", optimized
|
||||
));
|
||||
|
||||
logger.info("提示词优化完成,用户: {}, 类型: {}, 原始长度: {}, 优化后长度: {}",
|
||||
username, type, prompt.length(), optimizedPrompt.length());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("参数错误: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "参数错误: " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("优化提示词失败", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "优化提示词失败,请稍后重试"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证类型是否有效
|
||||
*/
|
||||
private boolean isValidType(String type) {
|
||||
return type != null && (
|
||||
type.equals("text-to-video") ||
|
||||
type.equals("image-to-video") ||
|
||||
type.equals("storyboard")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.example.demo.model.StoryboardVideoTask;
|
||||
import com.example.demo.service.StoryboardVideoService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/storyboard-video")
|
||||
public class StoryboardVideoApiController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(StoryboardVideoApiController.class);
|
||||
|
||||
@Autowired
|
||||
private StoryboardVideoService storyboardVideoService;
|
||||
|
||||
/**
|
||||
* 创建分镜视频任务
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
public ResponseEntity<?> createTask(
|
||||
@RequestBody Map<String, Object> request,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
String username = authentication.getName();
|
||||
logger.info("收到创建分镜视频任务请求,用户: {}", username);
|
||||
|
||||
// 从请求中提取参数
|
||||
String prompt = (String) request.get("prompt");
|
||||
String aspectRatio = (String) request.getOrDefault("aspectRatio", "16:9");
|
||||
Boolean hdMode = (Boolean) request.getOrDefault("hdMode", false);
|
||||
String imageUrl = (String) request.get("imageUrl");
|
||||
|
||||
if (prompt == null || prompt.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "提示词不能为空"));
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
StoryboardVideoTask task = storyboardVideoService.createTask(
|
||||
username, prompt, aspectRatio, hdMode != null && hdMode, imageUrl
|
||||
);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "任务创建成功");
|
||||
response.put("data", Map.of(
|
||||
"taskId", task.getTaskId(),
|
||||
"status", task.getStatus(),
|
||||
"progress", task.getProgress()
|
||||
));
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("参数错误: {}", e.getMessage(), e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("创建分镜视频任务失败", e);
|
||||
e.printStackTrace(); // 打印完整堆栈
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "创建任务失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
*/
|
||||
@GetMapping("/task/{taskId}")
|
||||
public ResponseEntity<?> getTask(@PathVariable String taskId, Authentication authentication) {
|
||||
try {
|
||||
String username = authentication.getName();
|
||||
logger.info("收到获取分镜视频任务详情请求,任务ID: {}, 用户: {}", taskId, username);
|
||||
|
||||
StoryboardVideoTask task = storyboardVideoService.getTask(taskId);
|
||||
|
||||
// 验证用户权限
|
||||
if (!task.getUsername().equals(username)) {
|
||||
logger.warn("用户 {} 尝试访问任务 {},但任务属于用户 {}", username, taskId, task.getUsername());
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("success", false, "message", "无权访问此任务"));
|
||||
}
|
||||
|
||||
Map<String, Object> taskData = new HashMap<>();
|
||||
taskData.put("taskId", task.getTaskId());
|
||||
taskData.put("status", task.getStatus());
|
||||
taskData.put("progress", task.getProgress());
|
||||
taskData.put("resultUrl", task.getResultUrl());
|
||||
taskData.put("errorMessage", task.getErrorMessage());
|
||||
taskData.put("createdAt", task.getCreatedAt());
|
||||
taskData.put("updatedAt", task.getUpdatedAt());
|
||||
taskData.put("completedAt", task.getCompletedAt());
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"data", taskData
|
||||
));
|
||||
|
||||
} catch (RuntimeException e) {
|
||||
logger.error("获取任务详情失败: {}", e.getMessage());
|
||||
return ResponseEntity.status(404)
|
||||
.body(Map.of("success", false, "message", "任务不存在"));
|
||||
} catch (Exception e) {
|
||||
logger.error("获取任务详情异常", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "查询失败"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户任务列表
|
||||
*/
|
||||
@GetMapping("/tasks")
|
||||
public ResponseEntity<?> getUserTasks(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
String username = authentication.getName();
|
||||
List<StoryboardVideoTask> tasks = storyboardVideoService.getUserTasks(username, page, size);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"data", tasks,
|
||||
"page", page,
|
||||
"size", size
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("获取用户任务列表失败", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "查询失败"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.example.demo.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
/**
|
||||
* 分镜视频任务实体
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "storyboard_video_tasks")
|
||||
public class StoryboardVideoTask {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String taskId;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String username;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String prompt; // 文本描述
|
||||
|
||||
@Column(length = 500)
|
||||
private String imageUrl; // 上传的参考图片URL(可选)
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
private String aspectRatio; // 16:9, 4:3, 1:1, 3:4, 9:16
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean hdMode; // 是否高清模式
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private TaskStatus status;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int progress; // 0-100
|
||||
|
||||
@Column(length = 500)
|
||||
private String resultUrl; // 分镜图URL
|
||||
|
||||
@Column(name = "real_task_id")
|
||||
private String realTaskId;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int costPoints; // 消耗积分
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
public enum TaskStatus {
|
||||
PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED
|
||||
}
|
||||
|
||||
// 构造函数
|
||||
public StoryboardVideoTask() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public StoryboardVideoTask(String username, String prompt, String aspectRatio, boolean hdMode) {
|
||||
this();
|
||||
this.username = username;
|
||||
this.prompt = prompt;
|
||||
this.aspectRatio = aspectRatio;
|
||||
this.hdMode = hdMode;
|
||||
|
||||
// 计算消耗积分
|
||||
this.costPoints = calculateCost();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算任务消耗积分
|
||||
*/
|
||||
private Integer calculateCost() {
|
||||
int baseCost = 10; // 基础消耗
|
||||
int hdCost = hdMode ? 20 : 0; // 高清模式消耗
|
||||
|
||||
return baseCost + hdCost;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务状态
|
||||
*/
|
||||
public void updateStatus(TaskStatus newStatus) {
|
||||
this.status = newStatus;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
|
||||
// 任务结束状态都应该设置完成时间
|
||||
if (newStatus == TaskStatus.COMPLETED || newStatus == TaskStatus.FAILED || newStatus == TaskStatus.CANCELLED) {
|
||||
this.completedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新进度
|
||||
*/
|
||||
public void updateProgress(Integer progress) {
|
||||
this.progress = Math.min(100, Math.max(0, progress));
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getTaskId() { return taskId; }
|
||||
public void setTaskId(String taskId) { this.taskId = taskId; }
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getPrompt() { return prompt; }
|
||||
public void setPrompt(String prompt) { this.prompt = prompt; }
|
||||
public String getImageUrl() { return imageUrl; }
|
||||
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
||||
public String getAspectRatio() { return aspectRatio; }
|
||||
public void setAspectRatio(String aspectRatio) { this.aspectRatio = aspectRatio; }
|
||||
public boolean isHdMode() { return hdMode; }
|
||||
public void setHdMode(boolean hdMode) { this.hdMode = hdMode; }
|
||||
public TaskStatus getStatus() { return status; }
|
||||
public void setStatus(TaskStatus status) { this.status = status; }
|
||||
public int getProgress() { return progress; }
|
||||
public void setProgress(int progress) { this.progress = progress; }
|
||||
public String getResultUrl() { return resultUrl; }
|
||||
public void setResultUrl(String resultUrl) { this.resultUrl = resultUrl; }
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
public String getRealTaskId() { return realTaskId; }
|
||||
public void setRealTaskId(String realTaskId) { this.realTaskId = realTaskId; }
|
||||
public int getCostPoints() { return costPoints; }
|
||||
public void setCostPoints(int costPoints) { this.costPoints = costPoints; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
public LocalDateTime getCompletedAt() { return completedAt; }
|
||||
public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.demo.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import com.example.demo.model.StoryboardVideoTask;
|
||||
|
||||
@Repository
|
||||
public interface StoryboardVideoTaskRepository extends JpaRepository<StoryboardVideoTask, Long> {
|
||||
|
||||
Optional<StoryboardVideoTask> findByTaskId(String taskId);
|
||||
|
||||
List<StoryboardVideoTask> findByUsernameOrderByCreatedAtDesc(String username);
|
||||
|
||||
Page<StoryboardVideoTask> findByUsernameOrderByCreatedAtDesc(String username, Pageable pageable);
|
||||
|
||||
List<StoryboardVideoTask> findByStatus(StoryboardVideoTask.TaskStatus status);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.demo.service;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -30,6 +31,12 @@ public class RealAIService {
|
||||
@Value("${ai.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
|
||||
private String aiApiKey;
|
||||
|
||||
@Value("${ai.image.api.base-url:https://ai.comfly.chat}")
|
||||
private String aiImageApiBaseUrl;
|
||||
|
||||
@Value("${ai.image.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
|
||||
private String aiImageApiKey;
|
||||
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -379,6 +386,83 @@ public class RealAIService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交文生图任务(分镜视频使用)
|
||||
* 调用Qwen文生图API
|
||||
*/
|
||||
public Map<String, Object> submitTextToImageTask(String prompt, String aspectRatio) {
|
||||
try {
|
||||
logger.info("提交文生图任务: prompt={}, aspectRatio={}", prompt, aspectRatio);
|
||||
|
||||
// 根据aspectRatio转换尺寸
|
||||
String size = convertAspectRatioToImageSize(aspectRatio);
|
||||
|
||||
// 使用文生图的API端点(Comfly API)
|
||||
String url = aiImageApiBaseUrl + "/v1/images/generations";
|
||||
|
||||
// 构建请求体
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("prompt", prompt);
|
||||
requestBody.put("size", size);
|
||||
requestBody.put("model", "qwen-image");
|
||||
requestBody.put("n", 1);
|
||||
requestBody.put("response_format", "url");
|
||||
|
||||
String requestBodyJson = objectMapper.writeValueAsString(requestBody);
|
||||
|
||||
logger.info("文生图请求URL: {}", url);
|
||||
logger.info("文生图请求体: {}", requestBodyJson);
|
||||
logger.info("使用的API密钥: {}", aiImageApiKey.substring(0, Math.min(10, aiImageApiKey.length())) + "...");
|
||||
|
||||
HttpResponse<String> response = Unirest.post(url)
|
||||
.header("Authorization", "Bearer " + aiImageApiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(requestBodyJson)
|
||||
.asString();
|
||||
|
||||
logger.info("文生图API响应状态: {}", response.getStatus());
|
||||
logger.info("文生图API响应内容: {}", response.getBody());
|
||||
|
||||
if (response.getStatus() == 200 && response.getBody() != null) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> responseBody = objectMapper.readValue(response.getBody(), Map.class);
|
||||
|
||||
// 检查是否有data字段
|
||||
if (responseBody.get("data") != null) {
|
||||
logger.info("文生图任务提交成功: {}", responseBody);
|
||||
return responseBody;
|
||||
} else {
|
||||
logger.error("文生图任务提交失败: {}", responseBody);
|
||||
throw new RuntimeException("任务提交失败: " + responseBody.get("message"));
|
||||
}
|
||||
} else {
|
||||
logger.error("文生图任务提交失败,HTTP状态: {}", response.getStatus());
|
||||
throw new RuntimeException("任务提交失败,HTTP状态: " + response.getStatus());
|
||||
}
|
||||
|
||||
} catch (UnirestException e) {
|
||||
logger.error("提交文生图任务异常", e);
|
||||
throw new RuntimeException("提交任务失败: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("提交文生图任务异常", e);
|
||||
throw new RuntimeException("提交任务失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将宽高比转换为图片尺寸
|
||||
*/
|
||||
private String convertAspectRatioToImageSize(String aspectRatio) {
|
||||
return switch (aspectRatio) {
|
||||
case "16:9" -> "1024x576";
|
||||
case "9:16" -> "576x1024";
|
||||
case "4:3" -> "1024x768";
|
||||
case "3:4" -> "768x1024";
|
||||
case "1:1" -> "1024x1024";
|
||||
default -> "1024x768";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将宽高比转换为Sora2 API的size参数
|
||||
*/
|
||||
@@ -390,4 +474,203 @@ public class RealAIService {
|
||||
default -> "720x1280"; // 默认竖屏
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化提示词
|
||||
* 使用AI模型将用户输入的简单描述优化为详细、专业的提示词
|
||||
*
|
||||
* @param prompt 原始提示词
|
||||
* @param type 优化类型:text-to-video, image-to-video, storyboard
|
||||
* @return 优化后的提示词,失败时返回原始提示词
|
||||
*/
|
||||
public String optimizePrompt(String prompt, String type) {
|
||||
// 参数验证
|
||||
if (prompt == null || prompt.trim().isEmpty()) {
|
||||
logger.warn("提示词为空,无法优化");
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// 长度限制(防止过长提示词)
|
||||
if (prompt.length() > 1000) {
|
||||
logger.warn("提示词过长({}字符),截取前1000字符", prompt.length());
|
||||
prompt = prompt.substring(0, 1000);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("开始优化提示词: prompt={}, type={}, length={}",
|
||||
prompt.length() > 50 ? prompt.substring(0, 50) + "..." : prompt,
|
||||
type,
|
||||
prompt.length());
|
||||
|
||||
// 根据类型生成不同的优化指令
|
||||
String systemPrompt = getOptimizationPrompt(type);
|
||||
|
||||
// 构建请求体,使用ChatGPT API格式
|
||||
String url = aiApiBaseUrl + "/v1/chat/completions";
|
||||
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("model", "gpt-4o-mini"); // 使用GPT-4o-mini进行优化
|
||||
|
||||
List<Map<String, String>> messages = new java.util.ArrayList<>();
|
||||
Map<String, String> systemMessage = new HashMap<>();
|
||||
systemMessage.put("role", "system");
|
||||
systemMessage.put("content", systemPrompt);
|
||||
messages.add(systemMessage);
|
||||
|
||||
Map<String, String> userMessage = new HashMap<>();
|
||||
userMessage.put("role", "user");
|
||||
userMessage.put("content", "请优化以下提示词,保持原始意图:\n" + prompt);
|
||||
messages.add(userMessage);
|
||||
|
||||
requestBody.put("messages", messages);
|
||||
requestBody.put("temperature", 0.7);
|
||||
requestBody.put("max_tokens", 800); // 增加token限制,允许更详细的优化
|
||||
|
||||
String requestBodyJson = objectMapper.writeValueAsString(requestBody);
|
||||
|
||||
logger.debug("提示词优化请求URL: {}", url);
|
||||
|
||||
// 设置超时时间(30秒)
|
||||
HttpResponse<String> response = Unirest.post(url)
|
||||
.header("Authorization", "Bearer " + aiApiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.socketTimeout(30000)
|
||||
.connectTimeout(10000)
|
||||
.body(requestBodyJson)
|
||||
.asString();
|
||||
|
||||
int statusCode = response.getStatus();
|
||||
logger.info("提示词优化API响应状态: {}", statusCode);
|
||||
|
||||
if (statusCode == 200 && response.getBody() != null) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> responseBody = objectMapper.readValue(response.getBody(), Map.class);
|
||||
|
||||
// 检查API错误响应
|
||||
if (responseBody.containsKey("error")) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> error = (Map<String, Object>) responseBody.get("error");
|
||||
String errorMessage = (String) error.get("message");
|
||||
logger.error("API返回错误: {}", errorMessage);
|
||||
return prompt; // 返回原始提示词
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> choices = (List<Map<String, Object>>) responseBody.get("choices");
|
||||
if (choices != null && !choices.isEmpty()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> choice = choices.get(0);
|
||||
|
||||
// 检查finish_reason
|
||||
String finishReason = (String) choice.get("finish_reason");
|
||||
if ("length".equals(finishReason)) {
|
||||
logger.warn("优化结果被截断,可能需要增加max_tokens");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> message = (Map<String, Object>) choice.get("message");
|
||||
if (message != null) {
|
||||
String optimizedPrompt = (String) message.get("content");
|
||||
if (optimizedPrompt != null && !optimizedPrompt.trim().isEmpty()) {
|
||||
optimizedPrompt = optimizedPrompt.trim();
|
||||
// 移除可能的前后缀标记
|
||||
optimizedPrompt = optimizedPrompt.replaceAll("^\"+|\"+$", "");
|
||||
logger.info("提示词优化成功: 原始长度={}, 优化后长度={}",
|
||||
prompt.length(), optimizedPrompt.length());
|
||||
return optimizedPrompt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn("API响应格式异常,无法解析优化结果");
|
||||
} catch (Exception parseException) {
|
||||
logger.error("解析API响应失败", parseException);
|
||||
}
|
||||
} else {
|
||||
logger.error("提示词优化API请求失败,HTTP状态: {}, 响应: {}",
|
||||
statusCode,
|
||||
response.getBody() != null && response.getBody().length() < 200 ? response.getBody() : "响应过长");
|
||||
}
|
||||
|
||||
// 所有失败情况都返回原始提示词
|
||||
return prompt;
|
||||
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
||||
logger.error("提示词优化JSON处理失败", e);
|
||||
return prompt;
|
||||
} catch (kong.unirest.UnirestException e) {
|
||||
// Unirest异常可能包含超时或连接错误
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof java.net.SocketTimeoutException) {
|
||||
logger.error("提示词优化超时", e);
|
||||
} else if (cause instanceof java.net.ConnectException) {
|
||||
logger.error("提示词优化连接失败", e);
|
||||
} else {
|
||||
logger.error("提示词优化网络异常", e);
|
||||
}
|
||||
return prompt;
|
||||
} catch (Exception e) {
|
||||
logger.error("提示词优化异常", e);
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型获取优化提示词的系统指令
|
||||
* 使用文本块(Text Blocks)优化可读性
|
||||
*/
|
||||
private String getOptimizationPrompt(String type) {
|
||||
return switch (type) {
|
||||
case "text-to-video" -> """
|
||||
你是一个专业的视频生成提示词优化专家。你的任务是将用户提供的简单描述优化为详细、专业、适合AI视频生成的英文提示词。
|
||||
|
||||
优化要求:
|
||||
1. 将中文描述翻译成流畅的英文,确保语义准确
|
||||
2. 添加详细的视觉细节描述(场景环境、人物特征、动作细节、光线效果、色彩风格、构图方式等)
|
||||
3. 使用专业的电影术语和视觉词汇(如:cinematic, wide shot, close-up等)
|
||||
4. 确保提示词清晰、具体、有画面感,能够准确指导AI生成
|
||||
5. 保持原始意图不变,只优化表达方式和补充细节
|
||||
6. 如果原始提示词已经是英文,直接优化,保持语言一致
|
||||
7. 输出优化后的提示词,不要添加额外说明、引号或其他格式标记
|
||||
8. 优化后的提示词应该直接可用,长度控制在合理范围内
|
||||
""";
|
||||
case "image-to-video" -> """
|
||||
你是一个专业的视频生成提示词优化专家。你的任务是将用户提供的简单描述优化为详细、专业、适合AI图生视频的英文提示词。
|
||||
|
||||
优化要求:
|
||||
1. 将中文描述翻译成流畅的英文,确保语义准确
|
||||
2. 重点关注动画效果、镜头运动、动作描述(如:zoom in, pan, fade等)
|
||||
3. 添加时间维度和动态效果描述(持续时间、运动方向、速度变化等)
|
||||
4. 确保提示词能准确表达图片到视频的转换需求,描述动画和运动的细节
|
||||
5. 使用专业的视频制作术语(如:smooth transition, dynamic movement等)
|
||||
6. 如果原始提示词已经是英文,直接优化,保持语言一致
|
||||
7. 输出优化后的提示词,不要添加额外说明、引号或其他格式标记
|
||||
8. 优化后的提示词应该直接可用,长度控制在合理范围内
|
||||
""";
|
||||
case "storyboard" -> """
|
||||
你是一个专业的分镜图提示词优化专家。你的任务是将用户提供的简单描述优化为详细、专业、适合生成故事板分镜图的英文提示词。
|
||||
|
||||
优化要求:
|
||||
1. 将中文描述翻译成流畅的英文,确保语义准确
|
||||
2. 关注镜头构图、画面布局、视觉元素(如:composition, framing, visual hierarchy等)
|
||||
3. 适合生成12格黑白分镜图风格,强调构图和画面元素
|
||||
4. 确保提示词清晰描述每个镜头的关键视觉元素和构图方式
|
||||
5. 使用专业的电影分镜术语(如:establishing shot, medium shot, close-up等)
|
||||
6. 如果原始提示词已经是英文,直接优化,保持语言一致
|
||||
7. 输出优化后的提示词,不要添加额外说明、引号或其他格式标记
|
||||
8. 优化后的提示词应该直接可用,长度控制在合理范围内
|
||||
""";
|
||||
default -> """
|
||||
你是一个专业的提示词优化专家。请将用户提供的简单描述优化为详细、专业的英文提示词。
|
||||
|
||||
优化要求:
|
||||
1. 将中文描述翻译成流畅的英文,如果已经是英文则直接优化
|
||||
2. 添加必要的视觉细节和描述,使提示词更加具体和专业
|
||||
3. 保持原始意图不变,只优化表达方式
|
||||
4. 输出优化后的提示词,不要添加额外说明、引号或其他格式标记
|
||||
5. 优化后的提示词应该直接可用,长度控制在合理范围内
|
||||
""";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.example.demo.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.example.demo.model.StoryboardVideoTask;
|
||||
import com.example.demo.repository.StoryboardVideoTaskRepository;
|
||||
|
||||
/**
|
||||
* 分镜视频服务类
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
public class StoryboardVideoService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(StoryboardVideoService.class);
|
||||
|
||||
@Autowired
|
||||
private StoryboardVideoTaskRepository taskRepository;
|
||||
|
||||
@Autowired
|
||||
private RealAIService realAIService;
|
||||
|
||||
/**
|
||||
* 创建分镜视频任务
|
||||
*/
|
||||
public StoryboardVideoTask createTask(String username, String prompt, String aspectRatio, boolean hdMode, String imageUrl) {
|
||||
try {
|
||||
// 验证参数
|
||||
if (username == null || username.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("用户名不能为空");
|
||||
}
|
||||
if (prompt == null || prompt.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("文本描述不能为空");
|
||||
}
|
||||
|
||||
// 生成任务ID
|
||||
String taskId = generateTaskId();
|
||||
|
||||
// 创建任务
|
||||
StoryboardVideoTask task = new StoryboardVideoTask(username, prompt.trim(), aspectRatio, hdMode);
|
||||
task.setTaskId(taskId);
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.PENDING);
|
||||
task.setProgress(0);
|
||||
|
||||
if (imageUrl != null && !imageUrl.isEmpty()) {
|
||||
task.setImageUrl(imageUrl);
|
||||
}
|
||||
|
||||
// 保存任务
|
||||
task = taskRepository.save(task);
|
||||
|
||||
logger.info("分镜视频任务创建成功: {}, 用户: {}", taskId, username);
|
||||
|
||||
// 异步处理任务
|
||||
processTaskAsync(taskId);
|
||||
|
||||
return task;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("创建分镜视频任务失败", e);
|
||||
throw new RuntimeException("创建任务失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用真实API处理任务(异步)
|
||||
* 使用Spring的@Async注解,自动管理事务边界
|
||||
*/
|
||||
@Async
|
||||
@Transactional
|
||||
public void processTaskAsync(String taskId) {
|
||||
try {
|
||||
logger.info("开始使用真实API处理分镜视频任务: {}", taskId);
|
||||
|
||||
// 重新从数据库加载任务,获取最新状态
|
||||
StoryboardVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("任务未找到: " + taskId));
|
||||
|
||||
// 更新任务状态为处理中
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.PROCESSING);
|
||||
taskRepository.flush(); // 强制刷新到数据库
|
||||
|
||||
// 调用真实文生图API
|
||||
logger.info("分镜视频任务已提交,正在调用文生图API生成分镜图...");
|
||||
|
||||
Map<String, Object> apiResponse = realAIService.submitTextToImageTask(
|
||||
task.getPrompt(),
|
||||
task.getAspectRatio()
|
||||
);
|
||||
|
||||
// 从API响应中提取图片URL
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> data = (List<Map<String, Object>>) apiResponse.get("data");
|
||||
if (data != null && !data.isEmpty()) {
|
||||
String imageUrl = null;
|
||||
Map<String, Object> firstImage = data.get(0);
|
||||
|
||||
// 检查是否有url或b64_json字段
|
||||
if (firstImage.get("url") != null) {
|
||||
imageUrl = (String) firstImage.get("url");
|
||||
} else if (firstImage.get("b64_json") != null) {
|
||||
// base64编码的图片
|
||||
String base64Data = (String) firstImage.get("b64_json");
|
||||
imageUrl = "data:image/png;base64," + base64Data;
|
||||
}
|
||||
|
||||
// 重新加载任务(因为之前的flush可能使实体detached)
|
||||
task = taskRepository.findByTaskId(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("任务未找到: " + taskId));
|
||||
|
||||
// 设置结果
|
||||
task.setResultUrl(imageUrl);
|
||||
task.setRealTaskId(taskId + "_image");
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.COMPLETED);
|
||||
task.updateProgress(100);
|
||||
|
||||
taskRepository.save(task);
|
||||
|
||||
logger.info("分镜图生成完成,任务ID: {}, 图片URL: {}", taskId, imageUrl);
|
||||
} else {
|
||||
throw new RuntimeException("API返回的图片数据为空");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理分镜视频任务失败: {}", taskId, e);
|
||||
try {
|
||||
StoryboardVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("任务未找到: " + taskId));
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(e.getMessage());
|
||||
taskRepository.save(task);
|
||||
} catch (Exception ex) {
|
||||
logger.error("更新任务失败状态失败: {}", taskId, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public StoryboardVideoTask getTask(String taskId) {
|
||||
return taskRepository.findByTaskId(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("任务不存在: " + taskId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户任务列表
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<StoryboardVideoTask> getUserTasks(String username, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
Page<StoryboardVideoTask> taskPage = taskRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
|
||||
return taskPage.getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成任务ID
|
||||
*/
|
||||
private String generateTaskId() {
|
||||
return "sb_" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,14 @@ tencent.ses.region=ap-beijing
|
||||
tencent.ses.from-email=noreply@vionow.com
|
||||
tencent.ses.from-name=AIGC平台
|
||||
|
||||
# AI API配置
|
||||
# 文生视频、图生视频、分镜视频都使用Comfly API
|
||||
ai.api.base-url=https://ai.comfly.chat
|
||||
ai.api.key=sk-jp1O4h5lfN3WWZReF48SDa2osm0o9alC9qetkgq3M7XUjJ4R
|
||||
# 文生图使用Comfly API (在代码中单独配置)
|
||||
ai.image.api.base-url=https://ai.comfly.chat
|
||||
ai.image.api.key=sk-jp1O4h5lfN3WWZReF48SDa2osm0o9alC9qetkgq3M7XUjJ4R
|
||||
|
||||
# 支付宝配置 (开发环境 - 沙箱测试)
|
||||
# 请替换为您的实际配置
|
||||
alipay.app-id=9021000157616562
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 创建分镜视频任务表
|
||||
CREATE TABLE IF NOT EXISTS storyboard_video_tasks (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
prompt TEXT,
|
||||
image_url VARCHAR(500),
|
||||
aspect_ratio VARCHAR(10) NOT NULL,
|
||||
hd_mode BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
progress INT NOT NULL DEFAULT 0,
|
||||
result_url VARCHAR(500),
|
||||
real_task_id VARCHAR(255),
|
||||
error_message TEXT,
|
||||
cost_points INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
completed_at TIMESTAMP,
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_task_id (task_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
Reference in New Issue
Block a user