feat: 集成 AI 视频生成功能到 MixVideo V2
🎬 主要功能: - ✅ 完整的 AI 视频生成模块 (Python) - ✅ 图片转视频 API 集成 (字节跳动 Seedance) - ✅ 云存储支持 (腾讯云 COS) - ✅ 单张图片和批量处理模式 - ✅ 现代化 React 界面组件 - ✅ Tauri 桥接通信 🛠️ 技术实现: - Python 模块:VideoGenerator, CloudStorage, APIClient - Rust 命令:generate_ai_video, batch_generate_ai_videos - React 组件:AIVideoGenerator, AIVideoPage - 状态管理:useAIVideoStore (Zustand) - 路由集成:/ai-video 页面 �� 新增文件: - python_core/ai_video/ - AI 视频生成核心模块 - src/components/AIVideoGenerator.tsx - 主要 UI 组件 - src/pages/AIVideoPage.tsx - AI 视频生成页面 - src/stores/useAIVideoStore.ts - 状态管理 🎯 功能特性: - 支持 Lite (720p) 和 Pro (1080p) 模型 - 可配置视频时长 (5秒/10秒) - 实时进度跟踪和任务管理 - 批量处理多张图片 - 云存储自动上传下载 - 错误处理和重试机制 🔗 界面集成: - 侧边栏导航添加 'AI 视频' 入口 - 首页快速操作卡片 - 完整的用户引导和帮助文档 这是从原始 Tkinter GUI 到现代 Web 应用的完整迁移!
This commit is contained in:
568
jm_video_ui.md
Normal file
568
jm_video_ui.md
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
import glob
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, filedialog, scrolledtext, messagebox
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from loguru import logger
|
||||||
|
from qcloud_cos import CosConfig
|
||||||
|
from qcloud_cos import CosS3Client
|
||||||
|
|
||||||
|
cos_bucket_name = 'sucai-1324682537'
|
||||||
|
cos_secret_id = 'AKIDsrihIyjZOBsjimt8TsN8yvv1AMh5dB44'
|
||||||
|
cos_secret_key = 'CPZcxdk6W39Jd4cGY95wvupoyMd0YFqW'
|
||||||
|
cos_region = 'ap-shanghai'
|
||||||
|
|
||||||
|
api_key = '21575c22-14aa-40ca-8aa8-f00ca27a3a17'
|
||||||
|
|
||||||
|
default_prompt = [
|
||||||
|
"女人扭动身体向摄像机展示身材,一只手撩了一下头发,镜头从左向右移动并放大画面",
|
||||||
|
# "女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动",
|
||||||
|
"时尚模特抬头自信的展示身材,扭动身体,一只手放在了头上,镜头逐渐放大聚焦在了下衣上",
|
||||||
|
"女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动",
|
||||||
|
"自信步伐跟拍模特,模特步伐自信地同时行走,镜头紧紧跟随。抬起手捋一捋头发。传递出自信与时尚的气息。",
|
||||||
|
"女生两只手捏着拳头轻盈的左右摇摆跳舞,动作幅度不大,然后把手摊开放在胸口再做出像popping心脏跳动的动作,左右身体都要非常协调",
|
||||||
|
"一个年轻女子自信地在相机前展示了她优美的身材,以自然的流体动作自由地摇摆,左手撩了一下头发之后停在了胸前。一个美女自拍跳舞扭动身体的视频,手从下到上最后放在胸前,妩媚的表情",
|
||||||
|
"美女向后退了一步站在那里展示服装,双手轻轻提了一下裤子两侧,镜头从上到下逐渐放大",
|
||||||
|
"女人低头看向裤子,向镜头展示身材,一只手放在了头上做pose动作",
|
||||||
|
"美女向后退了一步站在那里展示服装,低头并用手抚摸裤子,镜头从上到下逐渐放大",
|
||||||
|
"美女向后退了一步站在那里展示服装,双手从上到下整理衣服,自然扭动身体,自信的表情"
|
||||||
|
]
|
||||||
|
|
||||||
|
default_prompt_str = '\n'.join(default_prompt)
|
||||||
|
|
||||||
|
# pyinstaller -F -w --name seed_video jm_video_ui.py
|
||||||
|
class VideoUtils:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def download_video(video_url: str, save_path: str) -> str:
|
||||||
|
try:
|
||||||
|
file_name = f'{int(time.time() * 1000)}.mp4'
|
||||||
|
response = requests.get(video_url, stream=True)
|
||||||
|
full_path = os.path.join(save_path, file_name)
|
||||||
|
with open(full_path, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=1024 * 1024 * 5):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
return full_path
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def upload_file_to_cos(file_path: str, remove_src_file: bool = False):
|
||||||
|
resp_data = {'status': True, 'data': '', 'msg': ''}
|
||||||
|
mime_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
category = mime_type.split('/')[0]
|
||||||
|
f_name = os.path.basename(file_path)
|
||||||
|
suffix = f_name.split('.')[-1]
|
||||||
|
real_name = f'{int(time.time() * 1000)}.{suffix}'
|
||||||
|
try:
|
||||||
|
object_key = f'tk/{category}/{real_name}'
|
||||||
|
config = CosConfig(Region=cos_region, SecretId=cos_secret_id, SecretKey=cos_secret_key)
|
||||||
|
client = CosS3Client(config)
|
||||||
|
_ = client.upload_file(
|
||||||
|
Bucket=cos_bucket_name,
|
||||||
|
Key=object_key,
|
||||||
|
LocalFilePath=file_path,
|
||||||
|
EnableMD5=False,
|
||||||
|
progress_callback=None
|
||||||
|
)
|
||||||
|
url = f'https://{cos_bucket_name}.cos.ap-shanghai.myqcloud.com/{object_key}'
|
||||||
|
resp_data['data'] = url
|
||||||
|
resp_data['msg'] = '上传成功'
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
resp_data['status'] = False
|
||||||
|
resp_data['msg'] = str(e)
|
||||||
|
finally:
|
||||||
|
if remove_src_file:
|
||||||
|
os.remove(file_path)
|
||||||
|
return resp_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def submit_task(prompt: str, img_url: str, duration: str = '5', model_type:str='lite'):
|
||||||
|
"""
|
||||||
|
:param prompt: 生成视频的提示词
|
||||||
|
:param img_url:
|
||||||
|
:param duration:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if model_type == 'lite':
|
||||||
|
model = 'doubao-seedance-1-0-lite-i2v-250428'
|
||||||
|
resolution = '720p'
|
||||||
|
else:
|
||||||
|
model = 'doubao-seedance-1-0-pro-250528'
|
||||||
|
resolution = '1080p'
|
||||||
|
if duration not in ('5', '10'):
|
||||||
|
logger.error('Duration must be either 5 or 10')
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': f'Bearer {api_key}',
|
||||||
|
}
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
'model': model,
|
||||||
|
'content': [
|
||||||
|
{
|
||||||
|
'type': 'text',
|
||||||
|
'text': f'{prompt} --resolution {resolution} --dur {duration} --camerafixed false',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'image_url',
|
||||||
|
'image_url': {
|
||||||
|
'url': img_url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks',
|
||||||
|
headers=headers,
|
||||||
|
json=json_data)
|
||||||
|
|
||||||
|
# 检查HTTP状态码
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = f"API请求失败,状态码: {response.status_code}, 响应: {response.text}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"status": False, 'msg': error_msg}
|
||||||
|
|
||||||
|
resp_json = response.json()
|
||||||
|
|
||||||
|
# 检查响应中是否包含id字段
|
||||||
|
if 'id' not in resp_json:
|
||||||
|
error_msg = f"API响应缺少id字段,响应内容: {resp_json}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"status": False, 'msg': error_msg}
|
||||||
|
|
||||||
|
job_id = resp_json['id']
|
||||||
|
return {"data": job_id, 'status': True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
return {"status": False, 'msg': str(e)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def query_task_result(job_id, timeout: int = 180, interval: int = 2, progress_callback=None):
|
||||||
|
def query_status(t_id: str):
|
||||||
|
resp_dict = {'status': False, 'data': None, 'msg': ''}
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': f'Bearer {api_key}',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(f'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{t_id}',
|
||||||
|
headers=headers)
|
||||||
|
resp_json = response.json()
|
||||||
|
resp_dict['status'] = resp_json['status'] == 'succeeded'
|
||||||
|
resp_dict['msg'] = resp_json['status']
|
||||||
|
resp_dict['data'] = resp_json['content']['video_url'] if 'content' in resp_json else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
resp_dict['msg'] = str(e)
|
||||||
|
finally:
|
||||||
|
return resp_dict
|
||||||
|
|
||||||
|
end = time.time() + timeout
|
||||||
|
final_result = {"status": False, "data": None, "msg": ""}
|
||||||
|
success = False
|
||||||
|
wait_count = 0
|
||||||
|
|
||||||
|
# 添加开始查询的日志
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f" 开始查询任务状态,任务ID: {job_id}")
|
||||||
|
|
||||||
|
while time.time() < end:
|
||||||
|
tmp_data = query_status(job_id)
|
||||||
|
if tmp_data['status']:
|
||||||
|
final_result['status'] = True
|
||||||
|
final_result['data'] = tmp_data['data']
|
||||||
|
final_result['msg'] = 'succeeded'
|
||||||
|
success = True
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f" ✓ 视频生成完成!")
|
||||||
|
break
|
||||||
|
elif tmp_data['msg'] == 'running':
|
||||||
|
wait_count += 1
|
||||||
|
elapsed = wait_count * interval
|
||||||
|
remaining = max(0, timeout - elapsed)
|
||||||
|
progress_msg = f" ⏳ 任务运行中,已等待{elapsed}秒,预计剩余{remaining}秒..."
|
||||||
|
logger.info(progress_msg)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(progress_msg)
|
||||||
|
time.sleep(interval)
|
||||||
|
elif tmp_data['msg'] == 'failed':
|
||||||
|
final_result['msg'] = '任务执行失败'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f" ✗ 任务执行失败")
|
||||||
|
break
|
||||||
|
elif tmp_data['msg'] == 'pending' or tmp_data['msg'] == 'queued':
|
||||||
|
wait_count += 1
|
||||||
|
elapsed = wait_count * interval
|
||||||
|
remaining = max(0, timeout - elapsed)
|
||||||
|
progress_msg = f" ⏳ 任务排队中,已等待{elapsed}秒,预计剩余{remaining}秒..."
|
||||||
|
logger.info(progress_msg)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(progress_msg)
|
||||||
|
time.sleep(interval)
|
||||||
|
else:
|
||||||
|
# 其他未知状态继续等待
|
||||||
|
wait_count += 1
|
||||||
|
logger.info(f" 未知状态: {tmp_data['msg']},继续等待...")
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f" ❔ 状态: {tmp_data['msg']},继续等待...")
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
if not success and final_result['msg'] == '':
|
||||||
|
final_result['msg'] = '任务超时'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f" ⏰ 任务查询超时({timeout}秒)")
|
||||||
|
return final_result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def local_file_to_video(file_path: str, prompt: str, duration: str = '5', model_type: str = 'lite',
|
||||||
|
timeout: int = 180, interval: int = 2, save_path: str = None, progress_callback=None):
|
||||||
|
"""
|
||||||
|
将本地图片文件转换为视频
|
||||||
|
:param file_path: 本地图片文件路径
|
||||||
|
:param prompt: 视频生成提示词
|
||||||
|
:param duration: 视频时长 ('5' 或 '10')
|
||||||
|
:param model_type: 模型类型 ('lite' 或 'pro')
|
||||||
|
:param timeout: 任务超时时间(秒)
|
||||||
|
:param interval: 查询间隔(秒)
|
||||||
|
:param save_path: 视频保存目录
|
||||||
|
:return: {'status': bool, 'video_path': str, 'msg': str}
|
||||||
|
"""
|
||||||
|
result = {'status': False, 'video_path': '', 'msg': ''}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
result['msg'] = f'文件不存在: {file_path}'
|
||||||
|
logger.error(result['msg'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 步骤1: 上传图片到COS
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(" 📤 正在上传图片到云存储...")
|
||||||
|
logger.info(f"正在上传图片到COS: {file_path}")
|
||||||
|
cos_dict = VideoUtils.upload_file_to_cos(file_path)
|
||||||
|
if not cos_dict['status']:
|
||||||
|
result['msg'] = f"上传图片失败: {cos_dict['msg']}"
|
||||||
|
logger.error(result['msg'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
img_url = cos_dict['data']
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(" ✓ 图片上传成功")
|
||||||
|
logger.info(f"图片上传成功: {img_url}")
|
||||||
|
|
||||||
|
# 步骤2: 提交视频生成任务
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(" 🚀 正在提交视频生成任务...")
|
||||||
|
logger.info("正在提交视频生成任务...")
|
||||||
|
task_dict = VideoUtils.submit_task(prompt, img_url, duration, model_type)
|
||||||
|
if not task_dict['status']:
|
||||||
|
result['msg'] = f"提交任务失败: {task_dict['msg']}"
|
||||||
|
logger.error(result['msg'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
task_id = task_dict['data']
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f" ✓ 任务提交成功,任务ID: {task_id}")
|
||||||
|
logger.info(f"任务提交成功,任务ID: {task_id}")
|
||||||
|
|
||||||
|
# 步骤3: 查询任务结果
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(" ⏳ 正在等待视频生成完成...")
|
||||||
|
logger.info("正在等待视频生成完成...")
|
||||||
|
status_dict = VideoUtils.query_task_result(task_id, timeout=timeout, interval=interval,
|
||||||
|
progress_callback=progress_callback)
|
||||||
|
if not status_dict['status']:
|
||||||
|
result['msg'] = f"视频生成失败: {status_dict['msg']}"
|
||||||
|
logger.error(result['msg'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
video_url = status_dict['data']
|
||||||
|
logger.info(f"视频生成成功: {video_url}")
|
||||||
|
|
||||||
|
# 步骤4: 下载视频到本地
|
||||||
|
if save_path:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(" 📥 正在下载视频到本地...")
|
||||||
|
logger.info("正在下载视频到本地...")
|
||||||
|
video_path = VideoUtils.download_video(video_url, save_path)
|
||||||
|
if video_path and os.path.exists(video_path):
|
||||||
|
result['status'] = True
|
||||||
|
result['video_path'] = video_path
|
||||||
|
result['msg'] = '视频生成并下载成功'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f" ✓ 视频下载成功: {os.path.basename(video_path)}")
|
||||||
|
logger.info(f"视频下载成功: {video_path}")
|
||||||
|
else:
|
||||||
|
result['msg'] = '视频下载失败'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(" ✗ 视频下载失败")
|
||||||
|
logger.error(result['msg'])
|
||||||
|
else:
|
||||||
|
result['status'] = True
|
||||||
|
result['video_path'] = video_url
|
||||||
|
result['msg'] = '视频生成成功'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result['msg'] = f'处理过程中发生异常: {str(e)}'
|
||||||
|
logger.error(result['msg'])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run_actual_script_logic(image_folder, prompt, output_video_dir, video_duration, model_type, add_log_entry_func,
|
||||||
|
other_params=None):
|
||||||
|
add_log_entry_func(f"开始处理任务...")
|
||||||
|
add_log_entry_func(f" 图片文件夹: {image_folder}")
|
||||||
|
add_log_entry_func(f" 提示词: \n{prompt[:100]}{'...' if len(prompt) > 100 else ''}")
|
||||||
|
add_log_entry_func(f" 视频保存目录: {output_video_dir}")
|
||||||
|
add_log_entry_func(f" 视频时长: {video_duration}秒")
|
||||||
|
add_log_entry_func(f" 模型类型: {model_type}")
|
||||||
|
|
||||||
|
if other_params:
|
||||||
|
for key, value in other_params.items():
|
||||||
|
add_log_entry_func(f" 额外参数 {key}: {value}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 筛选图片文件
|
||||||
|
# img_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.webp']
|
||||||
|
img_list = glob.glob(f'{image_folder}/*')
|
||||||
|
add_log_entry_func(f'目录下找到 {len(img_list)} 张图片')
|
||||||
|
|
||||||
|
if len(img_list) == 0:
|
||||||
|
add_log_entry_func("警告: 未找到任何图片文件")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 解析提示词列表
|
||||||
|
prompt_lines = [line.strip() for line in prompt.split('\n') if line.strip()]
|
||||||
|
if not prompt_lines:
|
||||||
|
add_log_entry_func("错误: 提示词不能为空")
|
||||||
|
return
|
||||||
|
|
||||||
|
add_log_entry_func(f'解析到 {len(prompt_lines)} 个提示词,将循环使用')
|
||||||
|
|
||||||
|
# 确保输出目录存在
|
||||||
|
if not os.path.exists(output_video_dir):
|
||||||
|
os.makedirs(output_video_dir)
|
||||||
|
add_log_entry_func(f"创建输出目录: {output_video_dir}")
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
# 处理每个图片文件
|
||||||
|
for i, img_path in enumerate(img_list):
|
||||||
|
img_name = os.path.basename(img_path)
|
||||||
|
# 使用循环提示词
|
||||||
|
current_prompt = prompt_lines[i % len(prompt_lines)]
|
||||||
|
add_log_entry_func(f"[{i + 1}/{len(img_list)}] 处理图片: {img_name}")
|
||||||
|
add_log_entry_func(
|
||||||
|
f" 使用提示词[{i % len(prompt_lines) + 1}]: {current_prompt[:50]}{'...' if len(current_prompt) > 50 else ''}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 调用视频生成功能
|
||||||
|
result = VideoUtils.local_file_to_video(
|
||||||
|
file_path=img_path,
|
||||||
|
prompt=current_prompt,
|
||||||
|
duration=str(video_duration),
|
||||||
|
model_type=model_type,
|
||||||
|
timeout=300, # 5分钟超时
|
||||||
|
interval=3, # 3秒检查一次
|
||||||
|
save_path=output_video_dir,
|
||||||
|
progress_callback=add_log_entry_func
|
||||||
|
)
|
||||||
|
|
||||||
|
if result and result.get('status'):
|
||||||
|
success_count += 1
|
||||||
|
video_path = result.get('video_path', '')
|
||||||
|
add_log_entry_func(f" ✓ 视频生成成功: {os.path.basename(video_path)}")
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
error_msg = result.get('msg', '未知错误') if result else '处理失败'
|
||||||
|
add_log_entry_func(f" ✗ 视频生成失败: {error_msg}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
add_log_entry_func(f" ✗ 处理图片时出错: {str(e)}")
|
||||||
|
|
||||||
|
# 输出最终统计
|
||||||
|
add_log_entry_func("=" * 50)
|
||||||
|
add_log_entry_func(f"处理完成!成功: {success_count}, 失败: {failed_count}")
|
||||||
|
if success_count > 0:
|
||||||
|
add_log_entry_func(f"生成的视频已保存到: {output_video_dir}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
add_log_entry_func(f"处理过程中发生错误: {str(e)}")
|
||||||
|
finally:
|
||||||
|
add_log_entry_func("任务处理结束。")
|
||||||
|
|
||||||
|
|
||||||
|
# --- 核心处理函数结束 ---
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("视频生成工具")
|
||||||
|
self.root.geometry("700x650")
|
||||||
|
self.root.resizable(False, False)
|
||||||
|
# --- 变量 ---
|
||||||
|
self.image_folder_var = tk.StringVar()
|
||||||
|
self.output_video_dir_var = tk.StringVar()
|
||||||
|
self.video_duration_var = tk.IntVar(value=5)
|
||||||
|
self.model_type_var = tk.StringVar(value='lite')
|
||||||
|
# --- 主框架 ---
|
||||||
|
main_frame = ttk.Frame(root, padding="10")
|
||||||
|
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||||
|
root.grid_rowconfigure(0, weight=1)
|
||||||
|
root.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# --- 组件 ---
|
||||||
|
row_idx = 0
|
||||||
|
general_pady = 5
|
||||||
|
|
||||||
|
# 1. 选择图片文件夹
|
||||||
|
ttk.Label(main_frame, text="选择图片文件夹:").grid(row=row_idx, column=0, sticky=tk.W, pady=(general_pady, 2))
|
||||||
|
self.image_folder_entry = ttk.Entry(main_frame, textvariable=self.image_folder_var, width=50)
|
||||||
|
self.image_folder_entry.grid(row=row_idx, column=1, sticky=(tk.W, tk.E), pady=(general_pady, 2), padx=5)
|
||||||
|
ttk.Button(main_frame, text="浏览...", command=self.select_image_folder).grid(row=row_idx, column=2,
|
||||||
|
sticky=tk.E,
|
||||||
|
pady=(general_pady, 2))
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
# 2. 保存视频存储目录
|
||||||
|
ttk.Label(main_frame, text="视频保存目录:").grid(row=row_idx, column=0, sticky=tk.W, pady=general_pady)
|
||||||
|
self.output_video_dir_entry = ttk.Entry(main_frame, textvariable=self.output_video_dir_var, width=50)
|
||||||
|
self.output_video_dir_entry.grid(row=row_idx, column=1, sticky=(tk.W, tk.E), pady=general_pady, padx=5)
|
||||||
|
ttk.Button(main_frame, text="浏览...", command=self.select_output_video_dir).grid(row=row_idx, column=2,
|
||||||
|
sticky=tk.E,
|
||||||
|
pady=general_pady)
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
# 3. 生图提示词
|
||||||
|
ttk.Label(main_frame, text="生成视频提示词:").grid(row=row_idx, column=0, sticky=(tk.W, tk.NW),
|
||||||
|
pady=(general_pady, 2))
|
||||||
|
self.prompt_text = scrolledtext.ScrolledText(main_frame, wrap=tk.WORD, width=60, height=8)
|
||||||
|
self.prompt_text.grid(row=row_idx, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=(general_pady, 2), padx=5)
|
||||||
|
# 设置默认提示词
|
||||||
|
self.prompt_text.insert("1.0", default_prompt_str)
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
# 4. 视频时长
|
||||||
|
duration_frame = ttk.LabelFrame(main_frame, text="视频时长")
|
||||||
|
duration_frame.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=general_pady, padx=5)
|
||||||
|
ttk.Radiobutton(duration_frame, text="5秒", variable=self.video_duration_var, value=5).pack(side=tk.LEFT,
|
||||||
|
padx=10, pady=5)
|
||||||
|
ttk.Radiobutton(duration_frame, text="10秒", variable=self.video_duration_var, value=10).pack(side=tk.LEFT,
|
||||||
|
padx=10, pady=5)
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
# 5. 模型类型
|
||||||
|
model_frame = ttk.LabelFrame(main_frame, text="模型类型")
|
||||||
|
model_frame.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=general_pady, padx=5)
|
||||||
|
ttk.Radiobutton(model_frame, text="lite", variable=self.model_type_var, value='lite').pack(side=tk.LEFT,
|
||||||
|
padx=10, pady=5)
|
||||||
|
ttk.Radiobutton(model_frame, text="pro", variable=self.model_type_var, value='pro').pack(side=tk.LEFT,
|
||||||
|
padx=10, pady=5)
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
# 6. 运行按钮 (新位置)
|
||||||
|
self.run_button = ttk.Button(main_frame, text="运行", command=self.start_processing_thread)
|
||||||
|
# 增加上下边距,使其与上下组件有明显区隔
|
||||||
|
self.run_button.grid(row=row_idx, column=0, columnspan=3, pady=(general_pady + 10, general_pady + 5))
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
# 7. 运行日志 Label
|
||||||
|
ttk.Label(main_frame, text="运行日志:").grid(row=row_idx, column=0, sticky=tk.W,
|
||||||
|
pady=(general_pady, 0)) # 调整pady使其靠近下方的Text
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
# 8. 运行日志 ScrolledText
|
||||||
|
self.log_text = scrolledtext.ScrolledText(main_frame, wrap=tk.WORD, width=70, height=15, state='disabled')
|
||||||
|
self.log_text.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(2, general_pady),
|
||||||
|
padx=5)
|
||||||
|
main_frame.grid_rowconfigure(row_idx, weight=1) # 让日志区域可以扩展
|
||||||
|
# row_idx += 1 # 最后一个组件后面不需要再增加row_idx,除非还要添加东西
|
||||||
|
|
||||||
|
# 配置列的伸缩
|
||||||
|
main_frame.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
def select_image_folder(self):
|
||||||
|
folder_selected = filedialog.askdirectory()
|
||||||
|
if folder_selected:
|
||||||
|
self.image_folder_var.set(folder_selected)
|
||||||
|
self.add_log_entry(f"选择图片文件夹: {folder_selected}")
|
||||||
|
|
||||||
|
def select_output_video_dir(self):
|
||||||
|
folder_selected = filedialog.askdirectory()
|
||||||
|
if folder_selected:
|
||||||
|
self.output_video_dir_var.set(folder_selected)
|
||||||
|
self.add_log_entry(f"选择视频保存目录: {folder_selected}")
|
||||||
|
|
||||||
|
def add_log_entry(self, message):
|
||||||
|
self.log_text.configure(state='normal')
|
||||||
|
self.log_text.insert(tk.END, message + "\n")
|
||||||
|
self.log_text.configure(state='disabled')
|
||||||
|
self.log_text.see(tk.END)
|
||||||
|
|
||||||
|
def start_processing_thread(self):
|
||||||
|
image_folder = self.image_folder_var.get()
|
||||||
|
prompt = self.prompt_text.get("1.0", tk.END).strip()
|
||||||
|
output_video_dir = self.output_video_dir_var.get()
|
||||||
|
video_duration = self.video_duration_var.get()
|
||||||
|
model_type = self.model_type_var.get()
|
||||||
|
|
||||||
|
other_params = {}
|
||||||
|
|
||||||
|
if not image_folder:
|
||||||
|
messagebox.showerror("错误", "请选择图片文件夹!")
|
||||||
|
return
|
||||||
|
if not prompt:
|
||||||
|
messagebox.showerror("错误", "请输入生图提示词!")
|
||||||
|
return
|
||||||
|
if not output_video_dir:
|
||||||
|
messagebox.showerror("错误", "请选择视频保存目录!")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.add_log_entry("=" * 30)
|
||||||
|
self.run_button.config(state="disabled")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=self.run_script_in_background,
|
||||||
|
args=(image_folder, prompt, output_video_dir, video_duration, model_type, other_params))
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def run_script_in_background(self, image_folder, prompt, output_video_dir, video_duration, model_type, other_params):
|
||||||
|
try:
|
||||||
|
run_actual_script_logic(
|
||||||
|
image_folder,
|
||||||
|
prompt,
|
||||||
|
output_video_dir,
|
||||||
|
video_duration,
|
||||||
|
model_type,
|
||||||
|
self.add_log_entry,
|
||||||
|
other_params
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.add_log_entry(f"线程中发生未捕获的错误: {e}")
|
||||||
|
self.root.after(0, lambda: messagebox.showerror("线程错误", f"处理过程中发生错误:\n{e}"))
|
||||||
|
finally:
|
||||||
|
self.root.after(0, self.enable_run_button)
|
||||||
|
|
||||||
|
def enable_run_button(self):
|
||||||
|
self.run_button.config(state="normal")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
root = tk.Tk()
|
||||||
|
app = App(root)
|
||||||
|
root.mainloop()
|
||||||
12
python_core/ai_video/__init__.py
Normal file
12
python_core/ai_video/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
AI Video Generation Module
|
||||||
|
AI 视频生成模块
|
||||||
|
|
||||||
|
This module provides functionality for generating videos from images using AI models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .video_generator import VideoGenerator
|
||||||
|
from .cloud_storage import CloudStorage
|
||||||
|
from .api_client import APIClient
|
||||||
|
|
||||||
|
__all__ = ['VideoGenerator', 'CloudStorage', 'APIClient']
|
||||||
258
python_core/ai_video/api_client.py
Normal file
258
python_core/ai_video/api_client.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
API Client Module
|
||||||
|
API 客户端模块
|
||||||
|
|
||||||
|
Handles communication with AI video generation APIs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Dict, Any, Optional, Callable
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from utils import setup_logger
|
||||||
|
|
||||||
|
logger = setup_logger(__name__)
|
||||||
|
|
||||||
|
class APIClient:
|
||||||
|
"""Client for AI video generation API."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = None, base_url: str = None):
|
||||||
|
"""
|
||||||
|
Initialize API client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API key for authentication
|
||||||
|
base_url: Base URL for API endpoints
|
||||||
|
"""
|
||||||
|
self.api_key = api_key or os.getenv('AI_VIDEO_API_KEY', '21575c22-14aa-40ca-8aa8-f00ca27a3a17')
|
||||||
|
self.base_url = base_url or os.getenv('AI_VIDEO_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3')
|
||||||
|
|
||||||
|
# Model configurations
|
||||||
|
self.models = {
|
||||||
|
'lite': {
|
||||||
|
'name': 'doubao-seedance-1-0-lite-i2v-250428',
|
||||||
|
'resolution': '720p'
|
||||||
|
},
|
||||||
|
'pro': {
|
||||||
|
'name': 'doubao-seedance-1-0-pro-250528',
|
||||||
|
'resolution': '1080p'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def submit_task(self, prompt: str, img_url: str, duration: str = '5', model_type: str = 'lite') -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Submit video generation task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Text prompt for video generation
|
||||||
|
img_url: URL of the input image
|
||||||
|
duration: Video duration ('5' or '10')
|
||||||
|
model_type: Model type ('lite' or 'pro')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with task submission result
|
||||||
|
"""
|
||||||
|
result = {'status': False, 'data': None, 'msg': ''}
|
||||||
|
|
||||||
|
if duration not in ('5', '10'):
|
||||||
|
result['msg'] = 'Duration must be either 5 or 10'
|
||||||
|
return result
|
||||||
|
|
||||||
|
if model_type not in self.models:
|
||||||
|
result['msg'] = f'Model type must be one of: {list(self.models.keys())}'
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
model_config = self.models[model_type]
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': f'Bearer {self.api_key}',
|
||||||
|
}
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
'model': model_config['name'],
|
||||||
|
'content': [
|
||||||
|
{
|
||||||
|
'type': 'text',
|
||||||
|
'text': f'{prompt} --resolution {model_config["resolution"]} --dur {duration} --camerafixed false',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'image_url',
|
||||||
|
'image_url': {
|
||||||
|
'url': img_url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f'{self.base_url}/contents/generations/tasks',
|
||||||
|
headers=headers,
|
||||||
|
json=json_data,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check HTTP status code
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = f"API request failed, status code: {response.status_code}, response: {response.text}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
result['msg'] = error_msg
|
||||||
|
return result
|
||||||
|
|
||||||
|
resp_json = response.json()
|
||||||
|
|
||||||
|
# Check if response contains id field
|
||||||
|
if 'id' not in resp_json:
|
||||||
|
error_msg = f"API response missing id field, response: {resp_json}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
result['msg'] = error_msg
|
||||||
|
return result
|
||||||
|
|
||||||
|
job_id = resp_json['id']
|
||||||
|
result['status'] = True
|
||||||
|
result['data'] = job_id
|
||||||
|
result['msg'] = 'Task submitted successfully'
|
||||||
|
|
||||||
|
logger.info(f"Task submitted successfully, job ID: {job_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to submit task: {str(e)}")
|
||||||
|
result['msg'] = str(e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def query_task_status(self, job_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Query task status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
job_id: Task ID to query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with task status
|
||||||
|
"""
|
||||||
|
result = {'status': False, 'data': None, 'msg': ''}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': f'Bearer {self.api_key}',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
f'{self.base_url}/contents/generations/tasks/{job_id}',
|
||||||
|
headers=headers,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
result['msg'] = f"API request failed, status code: {response.status_code}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
resp_json = response.json()
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
task_status = resp_json.get('status', 'unknown')
|
||||||
|
result['msg'] = task_status
|
||||||
|
|
||||||
|
if task_status == 'succeeded':
|
||||||
|
result['status'] = True
|
||||||
|
result['data'] = resp_json.get('content', {}).get('video_url')
|
||||||
|
elif task_status in ['failed', 'cancelled']:
|
||||||
|
result['status'] = False
|
||||||
|
result['data'] = None
|
||||||
|
else:
|
||||||
|
# Still running, pending, or queued
|
||||||
|
result['status'] = False
|
||||||
|
result['data'] = None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to query task status: {str(e)}")
|
||||||
|
result['msg'] = str(e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def wait_for_completion(self, job_id: str, timeout: int = 180, interval: int = 2,
|
||||||
|
progress_callback: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Wait for task completion with progress updates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
job_id: Task ID to wait for
|
||||||
|
timeout: Maximum wait time in seconds
|
||||||
|
interval: Check interval in seconds
|
||||||
|
progress_callback: Optional callback for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with final result
|
||||||
|
"""
|
||||||
|
result = {'status': False, 'data': None, 'msg': ''}
|
||||||
|
|
||||||
|
end_time = time.time() + timeout
|
||||||
|
wait_count = 0
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"开始查询任务状态,任务ID: {job_id}")
|
||||||
|
|
||||||
|
while time.time() < end_time:
|
||||||
|
status_result = self.query_task_status(job_id)
|
||||||
|
|
||||||
|
if status_result['status']:
|
||||||
|
# Task completed successfully
|
||||||
|
result['status'] = True
|
||||||
|
result['data'] = status_result['data']
|
||||||
|
result['msg'] = 'succeeded'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("✓ 视频生成完成!")
|
||||||
|
break
|
||||||
|
|
||||||
|
elif status_result['msg'] == 'running':
|
||||||
|
wait_count += 1
|
||||||
|
elapsed = wait_count * interval
|
||||||
|
remaining = max(0, timeout - elapsed)
|
||||||
|
progress_msg = f"⏳ 任务运行中,已等待{elapsed}秒,预计剩余{remaining}秒..."
|
||||||
|
logger.info(progress_msg)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(progress_msg)
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
elif status_result['msg'] == 'failed':
|
||||||
|
result['msg'] = '任务执行失败'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("✗ 任务执行失败")
|
||||||
|
break
|
||||||
|
|
||||||
|
elif status_result['msg'] in ['pending', 'queued']:
|
||||||
|
wait_count += 1
|
||||||
|
elapsed = wait_count * interval
|
||||||
|
remaining = max(0, timeout - elapsed)
|
||||||
|
progress_msg = f"⏳ 任务排队中,已等待{elapsed}秒,预计剩余{remaining}秒..."
|
||||||
|
logger.info(progress_msg)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(progress_msg)
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Unknown status, continue waiting
|
||||||
|
wait_count += 1
|
||||||
|
logger.info(f"未知状态: {status_result['msg']},继续等待...")
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"❔ 状态: {status_result['msg']},继续等待...")
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
if not result['status'] and result['msg'] == '':
|
||||||
|
result['msg'] = '任务超时'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"⏰ 任务查询超时({timeout}秒)")
|
||||||
|
|
||||||
|
return result
|
||||||
244
python_core/ai_video/cloud_storage.py
Normal file
244
python_core/ai_video/cloud_storage.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Cloud Storage Module
|
||||||
|
云存储模块
|
||||||
|
|
||||||
|
Handles file upload and download operations with cloud storage services.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import mimetypes
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from utils import setup_logger
|
||||||
|
|
||||||
|
logger = setup_logger(__name__)
|
||||||
|
|
||||||
|
class CloudStorage:
|
||||||
|
"""Cloud storage service for handling file uploads and downloads."""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
bucket_name: str = None,
|
||||||
|
secret_id: str = None,
|
||||||
|
secret_key: str = None,
|
||||||
|
region: str = None):
|
||||||
|
"""
|
||||||
|
Initialize cloud storage client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bucket_name: COS bucket name
|
||||||
|
secret_id: COS secret ID
|
||||||
|
secret_key: COS secret key
|
||||||
|
region: COS region
|
||||||
|
"""
|
||||||
|
# Use provided credentials or fallback to environment variables
|
||||||
|
self.bucket_name = bucket_name or os.getenv('COS_BUCKET_NAME', 'sucai-1324682537')
|
||||||
|
self.secret_id = secret_id or os.getenv('COS_SECRET_ID', 'AKIDsrihIyjZOBsjimt8TsN8yvv1AMh5dB44')
|
||||||
|
self.secret_key = secret_key or os.getenv('COS_SECRET_KEY', 'CPZcxdk6W39Jd4cGY95wvupoyMd0YFqW')
|
||||||
|
self.region = region or os.getenv('COS_REGION', 'ap-shanghai')
|
||||||
|
|
||||||
|
self.client = None
|
||||||
|
self._initialize_client()
|
||||||
|
|
||||||
|
def _initialize_client(self):
|
||||||
|
"""Initialize COS client."""
|
||||||
|
try:
|
||||||
|
from qcloud_cos import CosConfig, CosS3Client
|
||||||
|
|
||||||
|
config = CosConfig(
|
||||||
|
Region=self.region,
|
||||||
|
SecretId=self.secret_id,
|
||||||
|
SecretKey=self.secret_key
|
||||||
|
)
|
||||||
|
self.client = CosS3Client(config)
|
||||||
|
logger.info("COS client initialized successfully")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("qcloud_cos not installed. Cloud storage features will be disabled.")
|
||||||
|
self.client = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize COS client: {str(e)}")
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
def upload_file(self, file_path: str, remove_src_file: bool = False) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Upload file to cloud storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Local file path to upload
|
||||||
|
remove_src_file: Whether to remove source file after upload
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with upload result
|
||||||
|
"""
|
||||||
|
result = {'status': False, 'data': '', 'msg': ''}
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
result['msg'] = 'Cloud storage client not available'
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
result['msg'] = f'File not found: {file_path}'
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine file type and generate object key
|
||||||
|
mime_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
if not mime_type:
|
||||||
|
mime_type = 'application/octet-stream'
|
||||||
|
|
||||||
|
category = mime_type.split('/')[0]
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
suffix = file_name.split('.')[-1] if '.' in file_name else 'bin'
|
||||||
|
real_name = f'{int(time.time() * 1000)}.{suffix}'
|
||||||
|
object_key = f'mixvideo/{category}/{real_name}'
|
||||||
|
|
||||||
|
# Upload file
|
||||||
|
response = self.client.upload_file(
|
||||||
|
Bucket=self.bucket_name,
|
||||||
|
Key=object_key,
|
||||||
|
LocalFilePath=file_path,
|
||||||
|
EnableMD5=False,
|
||||||
|
progress_callback=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate public URL
|
||||||
|
url = f'https://{self.bucket_name}.cos.{self.region}.myqcloud.com/{object_key}'
|
||||||
|
|
||||||
|
result['status'] = True
|
||||||
|
result['data'] = url
|
||||||
|
result['msg'] = 'Upload successful'
|
||||||
|
|
||||||
|
logger.info(f"File uploaded successfully: {url}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to upload file: {str(e)}")
|
||||||
|
result['msg'] = str(e)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if remove_src_file and result['status'] and os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
logger.info(f"Source file removed: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to remove source file: {str(e)}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def download_file(self, url: str, save_path: str, filename: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Download file from URL to local path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: File URL to download
|
||||||
|
save_path: Directory to save the file
|
||||||
|
filename: Optional custom filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Local file path if successful, None if failed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
filename = f'{int(time.time() * 1000)}.mp4'
|
||||||
|
|
||||||
|
full_path = os.path.join(save_path, filename)
|
||||||
|
|
||||||
|
# Ensure save directory exists
|
||||||
|
os.makedirs(save_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Download file
|
||||||
|
response = requests.get(url, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with open(full_path, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=1024 * 1024 * 5):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
logger.info(f"File downloaded successfully: {full_path}")
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download file: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_files(self, prefix: str = 'mixvideo/') -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
List files in cloud storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: Object key prefix to filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with file list
|
||||||
|
"""
|
||||||
|
result = {'status': False, 'files': [], 'msg': ''}
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
result['msg'] = 'Cloud storage client not available'
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.client.list_objects(
|
||||||
|
Bucket=self.bucket_name,
|
||||||
|
Prefix=prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
files = []
|
||||||
|
if 'Contents' in response:
|
||||||
|
for obj in response['Contents']:
|
||||||
|
files.append({
|
||||||
|
'key': obj['Key'],
|
||||||
|
'size': obj['Size'],
|
||||||
|
'last_modified': obj['LastModified'],
|
||||||
|
'url': f'https://{self.bucket_name}.cos.{self.region}.myqcloud.com/{obj["Key"]}'
|
||||||
|
})
|
||||||
|
|
||||||
|
result['status'] = True
|
||||||
|
result['files'] = files
|
||||||
|
result['msg'] = f'Found {len(files)} files'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list files: {str(e)}")
|
||||||
|
result['msg'] = str(e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def delete_file(self, object_key: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete file from cloud storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_key: Object key to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with deletion result
|
||||||
|
"""
|
||||||
|
result = {'status': False, 'msg': ''}
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
result['msg'] = 'Cloud storage client not available'
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.delete_object(
|
||||||
|
Bucket=self.bucket_name,
|
||||||
|
Key=object_key
|
||||||
|
)
|
||||||
|
|
||||||
|
result['status'] = True
|
||||||
|
result['msg'] = 'File deleted successfully'
|
||||||
|
logger.info(f"File deleted: {object_key}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete file: {str(e)}")
|
||||||
|
result['msg'] = str(e)
|
||||||
|
|
||||||
|
return result
|
||||||
363
python_core/ai_video/video_generator.py
Normal file
363
python_core/ai_video/video_generator.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Video Generator Module
|
||||||
|
视频生成器模块
|
||||||
|
|
||||||
|
Main module for AI-powered video generation from images.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
from typing import Dict, Any, List, Optional, Callable
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from utils import setup_logger
|
||||||
|
from .cloud_storage import CloudStorage
|
||||||
|
from .api_client import APIClient
|
||||||
|
|
||||||
|
logger = setup_logger(__name__)
|
||||||
|
|
||||||
|
class VideoGenerator:
|
||||||
|
"""AI video generator for converting images to videos."""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
api_key: str = None,
|
||||||
|
cos_config: Dict[str, str] = None):
|
||||||
|
"""
|
||||||
|
Initialize video generator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API key for video generation service
|
||||||
|
cos_config: Cloud storage configuration
|
||||||
|
"""
|
||||||
|
self.api_client = APIClient(api_key=api_key)
|
||||||
|
|
||||||
|
if cos_config:
|
||||||
|
self.cloud_storage = CloudStorage(**cos_config)
|
||||||
|
else:
|
||||||
|
self.cloud_storage = CloudStorage()
|
||||||
|
|
||||||
|
def generate_video_from_image(self,
|
||||||
|
image_path: str,
|
||||||
|
prompt: str,
|
||||||
|
duration: str = '5',
|
||||||
|
model_type: str = 'lite',
|
||||||
|
save_path: str = None,
|
||||||
|
timeout: int = 180,
|
||||||
|
interval: int = 2,
|
||||||
|
progress_callback: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate video from a single image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to input image
|
||||||
|
prompt: Text prompt for video generation
|
||||||
|
duration: Video duration ('5' or '10')
|
||||||
|
model_type: Model type ('lite' or 'pro')
|
||||||
|
save_path: Directory to save generated video
|
||||||
|
timeout: Task timeout in seconds
|
||||||
|
interval: Status check interval in seconds
|
||||||
|
progress_callback: Optional progress callback function
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with generation result
|
||||||
|
"""
|
||||||
|
result = {'status': False, 'video_path': '', 'video_url': '', 'msg': ''}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if image file exists
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
result['msg'] = f'Image file not found: {image_path}'
|
||||||
|
logger.error(result['msg'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Step 1: Upload image to cloud storage
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("📤 正在上传图片到云存储...")
|
||||||
|
logger.info(f"Uploading image to cloud storage: {image_path}")
|
||||||
|
|
||||||
|
upload_result = self.cloud_storage.upload_file(image_path)
|
||||||
|
if not upload_result['status']:
|
||||||
|
result['msg'] = f"Failed to upload image: {upload_result['msg']}"
|
||||||
|
logger.error(result['msg'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
img_url = upload_result['data']
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("✓ 图片上传成功")
|
||||||
|
logger.info(f"Image uploaded successfully: {img_url}")
|
||||||
|
|
||||||
|
# Step 2: Submit video generation task
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("🚀 正在提交视频生成任务...")
|
||||||
|
logger.info("Submitting video generation task...")
|
||||||
|
|
||||||
|
task_result = self.api_client.submit_task(prompt, img_url, duration, model_type)
|
||||||
|
if not task_result['status']:
|
||||||
|
result['msg'] = f"Failed to submit task: {task_result['msg']}"
|
||||||
|
logger.error(result['msg'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
task_id = task_result['data']
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"✓ 任务提交成功,任务ID: {task_id}")
|
||||||
|
logger.info(f"Task submitted successfully, task ID: {task_id}")
|
||||||
|
|
||||||
|
# Step 3: Wait for task completion
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("⏳ 正在等待视频生成完成...")
|
||||||
|
logger.info("Waiting for video generation to complete...")
|
||||||
|
|
||||||
|
completion_result = self.api_client.wait_for_completion(
|
||||||
|
task_id,
|
||||||
|
timeout=timeout,
|
||||||
|
interval=interval,
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
if not completion_result['status']:
|
||||||
|
result['msg'] = f"Video generation failed: {completion_result['msg']}"
|
||||||
|
logger.error(result['msg'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
video_url = completion_result['data']
|
||||||
|
result['video_url'] = video_url
|
||||||
|
logger.info(f"Video generated successfully: {video_url}")
|
||||||
|
|
||||||
|
# Step 4: Download video if save_path is provided
|
||||||
|
if save_path:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("📥 正在下载视频到本地...")
|
||||||
|
logger.info("Downloading video to local storage...")
|
||||||
|
|
||||||
|
video_path = self.cloud_storage.download_file(video_url, save_path)
|
||||||
|
if video_path and os.path.exists(video_path):
|
||||||
|
result['status'] = True
|
||||||
|
result['video_path'] = video_path
|
||||||
|
result['msg'] = '视频生成并下载成功'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"✓ 视频下载成功: {os.path.basename(video_path)}")
|
||||||
|
logger.info(f"Video downloaded successfully: {video_path}")
|
||||||
|
else:
|
||||||
|
result['msg'] = '视频下载失败'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("✗ 视频下载失败")
|
||||||
|
logger.error(result['msg'])
|
||||||
|
else:
|
||||||
|
result['status'] = True
|
||||||
|
result['video_path'] = video_url
|
||||||
|
result['msg'] = '视频生成成功'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result['msg'] = f'处理过程中发生异常: {str(e)}'
|
||||||
|
logger.error(result['msg'])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def batch_generate_videos(self,
|
||||||
|
image_folder: str,
|
||||||
|
prompts: List[str],
|
||||||
|
output_folder: str,
|
||||||
|
duration: str = '5',
|
||||||
|
model_type: str = 'lite',
|
||||||
|
timeout: int = 300,
|
||||||
|
interval: int = 3,
|
||||||
|
progress_callback: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Batch generate videos from multiple images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_folder: Folder containing input images
|
||||||
|
prompts: List of prompts (will cycle through them)
|
||||||
|
output_folder: Folder to save generated videos
|
||||||
|
duration: Video duration ('5' or '10')
|
||||||
|
model_type: Model type ('lite' or 'pro')
|
||||||
|
timeout: Task timeout in seconds
|
||||||
|
interval: Status check interval in seconds
|
||||||
|
progress_callback: Optional progress callback function
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with batch processing result
|
||||||
|
"""
|
||||||
|
result = {'status': False, 'success_count': 0, 'failed_count': 0, 'results': [], 'msg': ''}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"开始批量处理任务...")
|
||||||
|
progress_callback(f" 图片文件夹: {image_folder}")
|
||||||
|
progress_callback(f" 视频保存目录: {output_folder}")
|
||||||
|
progress_callback(f" 视频时长: {duration}秒")
|
||||||
|
progress_callback(f" 模型类型: {model_type}")
|
||||||
|
|
||||||
|
# Find image files
|
||||||
|
image_extensions = ['*.jpg', '*.jpeg', '*.png', '*.bmp', '*.gif', '*.tiff', '*.webp']
|
||||||
|
image_files = []
|
||||||
|
for ext in image_extensions:
|
||||||
|
image_files.extend(glob.glob(os.path.join(image_folder, ext)))
|
||||||
|
image_files.extend(glob.glob(os.path.join(image_folder, ext.upper())))
|
||||||
|
|
||||||
|
if not image_files:
|
||||||
|
result['msg'] = f"No image files found in {image_folder}"
|
||||||
|
logger.error(result['msg'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f'目录下找到 {len(image_files)} 张图片')
|
||||||
|
|
||||||
|
if not prompts:
|
||||||
|
result['msg'] = "No prompts provided"
|
||||||
|
logger.error(result['msg'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f'解析到 {len(prompts)} 个提示词,将循环使用')
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
# Process each image
|
||||||
|
for i, image_path in enumerate(image_files):
|
||||||
|
image_name = os.path.basename(image_path)
|
||||||
|
# Use cycling prompts
|
||||||
|
current_prompt = prompts[i % len(prompts)]
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"[{i + 1}/{len(image_files)}] 处理图片: {image_name}")
|
||||||
|
progress_callback(f" 使用提示词[{i % len(prompts) + 1}]: {current_prompt[:50]}{'...' if len(current_prompt) > 50 else ''}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Generate video for this image
|
||||||
|
video_result = self.generate_video_from_image(
|
||||||
|
image_path=image_path,
|
||||||
|
prompt=current_prompt,
|
||||||
|
duration=duration,
|
||||||
|
model_type=model_type,
|
||||||
|
save_path=output_folder,
|
||||||
|
timeout=timeout,
|
||||||
|
interval=interval,
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
if video_result and video_result.get('status'):
|
||||||
|
success_count += 1
|
||||||
|
video_path = video_result.get('video_path', '')
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f" ✓ 视频生成成功: {os.path.basename(video_path)}")
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
error_msg = video_result.get('msg', '未知错误') if video_result else '处理失败'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f" ✗ 视频生成失败: {error_msg}")
|
||||||
|
|
||||||
|
result['results'].append({
|
||||||
|
'image_path': image_path,
|
||||||
|
'prompt': current_prompt,
|
||||||
|
'result': video_result
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
error_msg = str(e)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f" ✗ 处理图片时出错: {error_msg}")
|
||||||
|
|
||||||
|
result['results'].append({
|
||||||
|
'image_path': image_path,
|
||||||
|
'prompt': current_prompt,
|
||||||
|
'result': {'status': False, 'msg': error_msg}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Final statistics
|
||||||
|
result['success_count'] = success_count
|
||||||
|
result['failed_count'] = failed_count
|
||||||
|
result['status'] = success_count > 0
|
||||||
|
result['msg'] = f'处理完成!成功: {success_count}, 失败: {failed_count}'
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("=" * 50)
|
||||||
|
progress_callback(f"处理完成!成功: {success_count}, 失败: {failed_count}")
|
||||||
|
if success_count > 0:
|
||||||
|
progress_callback(f"生成的视频已保存到: {output_folder}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result['msg'] = f'批量处理过程中发生错误: {str(e)}'
|
||||||
|
logger.error(result['msg'])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Command line interface for AI video generation."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="AI Video Generator")
|
||||||
|
parser.add_argument("--action", required=True,
|
||||||
|
choices=["single", "batch"],
|
||||||
|
help="Action to perform")
|
||||||
|
parser.add_argument("--image", help="Image file path (for single)")
|
||||||
|
parser.add_argument("--folder", help="Image folder path (for batch)")
|
||||||
|
parser.add_argument("--prompt", help="Generation prompt")
|
||||||
|
parser.add_argument("--prompts", help="JSON string of prompts list (for batch)")
|
||||||
|
parser.add_argument("--output", help="Output directory")
|
||||||
|
parser.add_argument("--duration", default="5", choices=["5", "10"], help="Video duration")
|
||||||
|
parser.add_argument("--model", default="lite", choices=["lite", "pro"], help="Model type")
|
||||||
|
parser.add_argument("--timeout", type=int, default=180, help="Task timeout in seconds")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
generator = VideoGenerator()
|
||||||
|
|
||||||
|
def progress_callback(message):
|
||||||
|
print(message)
|
||||||
|
|
||||||
|
if args.action == "single":
|
||||||
|
if not args.image or not args.prompt or not args.output:
|
||||||
|
raise ValueError("Single mode requires --image, --prompt, and --output")
|
||||||
|
|
||||||
|
result = generator.generate_video_from_image(
|
||||||
|
image_path=args.image,
|
||||||
|
prompt=args.prompt,
|
||||||
|
duration=args.duration,
|
||||||
|
model_type=args.model,
|
||||||
|
save_path=args.output,
|
||||||
|
timeout=args.timeout,
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
elif args.action == "batch":
|
||||||
|
if not args.folder or not args.prompts or not args.output:
|
||||||
|
raise ValueError("Batch mode requires --folder, --prompts, and --output")
|
||||||
|
|
||||||
|
prompts = json.loads(args.prompts)
|
||||||
|
|
||||||
|
result = generator.batch_generate_videos(
|
||||||
|
image_folder=args.folder,
|
||||||
|
prompts=prompts,
|
||||||
|
output_folder=args.output,
|
||||||
|
duration=args.duration,
|
||||||
|
model_type=args.model,
|
||||||
|
timeout=args.timeout,
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_result = {
|
||||||
|
"status": False,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,4 +1,24 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AIVideoRequest {
|
||||||
|
pub image_path: String,
|
||||||
|
pub prompt: String,
|
||||||
|
pub duration: String,
|
||||||
|
pub model_type: String,
|
||||||
|
pub output_path: Option<String>,
|
||||||
|
pub timeout: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BatchAIVideoRequest {
|
||||||
|
pub image_folder: String,
|
||||||
|
pub prompts: Vec<String>,
|
||||||
|
pub output_folder: String,
|
||||||
|
pub duration: String,
|
||||||
|
pub model_type: String,
|
||||||
|
pub timeout: Option<u32>,
|
||||||
|
}
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
@@ -165,3 +185,61 @@ pub async fn load_project(project_path: String) -> Result<ProjectInfo, String> {
|
|||||||
Err(format!("Python script error: {}", error))
|
Err(format!("Python script error: {}", error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn generate_ai_video(request: AIVideoRequest) -> Result<String, String> {
|
||||||
|
let mut args = vec![
|
||||||
|
"python_core/ai_video/video_generator.py".to_string(),
|
||||||
|
"--action".to_string(),
|
||||||
|
"single".to_string(),
|
||||||
|
"--image".to_string(),
|
||||||
|
request.image_path,
|
||||||
|
"--prompt".to_string(),
|
||||||
|
request.prompt,
|
||||||
|
"--duration".to_string(),
|
||||||
|
request.duration,
|
||||||
|
"--model".to_string(),
|
||||||
|
request.model_type,
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(output_path) = request.output_path {
|
||||||
|
args.push("--output".to_string());
|
||||||
|
args.push(output_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(timeout) = request.timeout {
|
||||||
|
args.push("--timeout".to_string());
|
||||||
|
args.push(timeout.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
execute_python_command(&args).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn batch_generate_ai_videos(request: BatchAIVideoRequest) -> Result<String, String> {
|
||||||
|
let prompts_json = serde_json::to_string(&request.prompts)
|
||||||
|
.map_err(|e| format!("Failed to serialize prompts: {}", e))?;
|
||||||
|
|
||||||
|
let mut args = vec![
|
||||||
|
"python_core/ai_video/video_generator.py".to_string(),
|
||||||
|
"--action".to_string(),
|
||||||
|
"batch".to_string(),
|
||||||
|
"--folder".to_string(),
|
||||||
|
request.image_folder,
|
||||||
|
"--prompts".to_string(),
|
||||||
|
prompts_json,
|
||||||
|
"--output".to_string(),
|
||||||
|
request.output_folder,
|
||||||
|
"--duration".to_string(),
|
||||||
|
request.duration,
|
||||||
|
"--model".to_string(),
|
||||||
|
request.model_type,
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(timeout) = request.timeout {
|
||||||
|
args.push("--timeout".to_string());
|
||||||
|
args.push(timeout.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
execute_python_command(&args).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ pub fn run() {
|
|||||||
commands::analyze_audio,
|
commands::analyze_audio,
|
||||||
commands::get_project_info,
|
commands::get_project_info,
|
||||||
commands::save_project,
|
commands::save_project,
|
||||||
commands::load_project
|
commands::load_project,
|
||||||
|
commands::generate_ai_video,
|
||||||
|
commands::batch_generate_ai_videos
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Routes, Route } from 'react-router-dom'
|
|||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
import HomePage from './pages/HomePage'
|
import HomePage from './pages/HomePage'
|
||||||
import EditorPage from './pages/EditorPage'
|
import EditorPage from './pages/EditorPage'
|
||||||
|
import AIVideoPage from './pages/AIVideoPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -11,6 +12,7 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/editor" element={<EditorPage />} />
|
<Route path="/editor" element={<EditorPage />} />
|
||||||
|
<Route path="/ai-video" element={<AIVideoPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
381
src/components/AIVideoGenerator.tsx
Normal file
381
src/components/AIVideoGenerator.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
import { Upload, Play, Settings, Folder, FileText, Clock, Cpu, Trash2, Download } from 'lucide-react'
|
||||||
|
import { useAIVideoStore, useAIVideoJobs, useAIVideoProcessing, useAIVideoSettings } from '../stores/useAIVideoStore'
|
||||||
|
|
||||||
|
interface AIVideoGeneratorProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [mode, setMode] = useState<'single' | 'batch'>('single')
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string>('')
|
||||||
|
const [selectedFolder, setSelectedFolder] = useState<string>('')
|
||||||
|
const [customPrompt, setCustomPrompt] = useState<string>('')
|
||||||
|
const [outputFolder, setOutputFolder] = useState<string>('')
|
||||||
|
const [duration, setDuration] = useState<string>('5')
|
||||||
|
const [modelType, setModelType] = useState<string>('lite')
|
||||||
|
|
||||||
|
// Store
|
||||||
|
const {
|
||||||
|
generateSingleVideo,
|
||||||
|
batchGenerateVideos,
|
||||||
|
removeJob,
|
||||||
|
clearCompletedJobs,
|
||||||
|
setDefaultDuration,
|
||||||
|
setDefaultModelType
|
||||||
|
} = useAIVideoStore()
|
||||||
|
|
||||||
|
const jobs = useAIVideoJobs()
|
||||||
|
const isProcessing = useAIVideoProcessing()
|
||||||
|
const { defaultPrompts, defaultDuration, defaultModelType } = useAIVideoSettings()
|
||||||
|
|
||||||
|
// Initialize settings
|
||||||
|
React.useEffect(() => {
|
||||||
|
setDuration(defaultDuration)
|
||||||
|
setModelType(defaultModelType)
|
||||||
|
}, [defaultDuration, defaultModelType])
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
const handleImageSelect = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
setSelectedImage(file.path || file.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderSelect = () => {
|
||||||
|
folderInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
// Get the directory path from the first file
|
||||||
|
const firstFile = files[0]
|
||||||
|
const path = firstFile.webkitRelativePath || firstFile.name
|
||||||
|
const folderPath = path.split('/')[0]
|
||||||
|
setSelectedFolder(folderPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle generation
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
try {
|
||||||
|
if (mode === 'single') {
|
||||||
|
if (!selectedImage || !customPrompt) {
|
||||||
|
alert('请选择图片文件并输入提示词')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await generateSingleVideo({
|
||||||
|
image_path: selectedImage,
|
||||||
|
prompt: customPrompt,
|
||||||
|
duration,
|
||||||
|
model_type: modelType,
|
||||||
|
output_path: outputFolder || undefined,
|
||||||
|
timeout: 300
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if (!selectedFolder || !outputFolder) {
|
||||||
|
alert('请选择图片文件夹和输出目录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompts = customPrompt
|
||||||
|
? customPrompt.split('\n').filter(p => p.trim())
|
||||||
|
: defaultPrompts
|
||||||
|
|
||||||
|
await batchGenerateVideos({
|
||||||
|
image_folder: selectedFolder,
|
||||||
|
prompts,
|
||||||
|
output_folder: outputFolder,
|
||||||
|
duration,
|
||||||
|
model_type: modelType,
|
||||||
|
timeout: 300
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Generation failed:', error)
|
||||||
|
alert(`生成失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const formatTime = (timestamp: number): string => {
|
||||||
|
return new Date(timestamp).toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format duration
|
||||||
|
const formatDuration = (start: number, end?: number): string => {
|
||||||
|
if (!end) return '进行中...'
|
||||||
|
const duration = Math.round((end - start) / 1000)
|
||||||
|
return `${duration}秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-lg shadow-sm border border-secondary-200 ${className}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-secondary-200">
|
||||||
|
<h2 className="text-xl font-semibold text-secondary-900 mb-4">AI 视频生成</h2>
|
||||||
|
|
||||||
|
{/* Mode Selection */}
|
||||||
|
<div className="flex items-center space-x-4 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('single')}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
mode === 'single'
|
||||||
|
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
||||||
|
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
单张图片
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('batch')}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
mode === 'batch'
|
||||||
|
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
||||||
|
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
批量处理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mode === 'single' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
选择图片文件
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={handleImageSelect}
|
||||||
|
className="flex items-center px-4 py-2 bg-secondary-100 hover:bg-secondary-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Upload size={16} className="mr-2" />
|
||||||
|
选择文件
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
{selectedImage || '未选择文件'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
选择图片文件夹
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={handleFolderSelect}
|
||||||
|
className="flex items-center px-4 py-2 bg-secondary-100 hover:bg-secondary-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Folder size={16} className="mr-2" />
|
||||||
|
选择文件夹
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
{selectedFolder || '未选择文件夹'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={folderInputRef}
|
||||||
|
type="file"
|
||||||
|
webkitdirectory=""
|
||||||
|
multiple
|
||||||
|
onChange={handleFolderChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
视频保存目录
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={outputFolder}
|
||||||
|
onChange={(e) => setOutputFolder(e.target.value)}
|
||||||
|
placeholder="输入保存目录路径"
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prompt Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
生成提示词 {mode === 'batch' && '(每行一个,将循环使用)'}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={customPrompt}
|
||||||
|
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||||
|
placeholder={mode === 'single'
|
||||||
|
? "输入视频生成提示词..."
|
||||||
|
: "输入提示词,每行一个。留空将使用默认提示词。"
|
||||||
|
}
|
||||||
|
rows={mode === 'single' ? 3 : 6}
|
||||||
|
className="input w-full resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
<Clock size={16} className="inline mr-1" />
|
||||||
|
视频时长
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDuration(e.target.value)
|
||||||
|
setDefaultDuration(e.target.value)
|
||||||
|
}}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="5">5秒</option>
|
||||||
|
<option value="10">10秒</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||||
|
<Cpu size={16} className="inline mr-1" />
|
||||||
|
模型类型
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={modelType}
|
||||||
|
onChange={(e) => {
|
||||||
|
setModelType(e.target.value)
|
||||||
|
setDefaultModelType(e.target.value)
|
||||||
|
}}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="lite">Lite (720p)</option>
|
||||||
|
<option value="pro">Pro (1080p)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="btn-primary w-full py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Play size={16} className="mr-2" />
|
||||||
|
{isProcessing ? '生成中...' : '开始生成'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Jobs List */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900">生成任务</h3>
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearCompletedJobs}
|
||||||
|
className="text-sm text-secondary-600 hover:text-secondary-800 transition-colors"
|
||||||
|
>
|
||||||
|
清除已完成
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-secondary-500">
|
||||||
|
<FileText size={32} className="mx-auto mb-2" />
|
||||||
|
<p>暂无生成任务</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{jobs.map(job => (
|
||||||
|
<div
|
||||||
|
key={job.id}
|
||||||
|
className="border border-secondary-200 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
job.status === 'completed' ? 'bg-green-500' :
|
||||||
|
job.status === 'failed' ? 'bg-red-500' :
|
||||||
|
job.status === 'processing' ? 'bg-blue-500' :
|
||||||
|
'bg-yellow-500'
|
||||||
|
}`} />
|
||||||
|
<span className="font-medium text-secondary-900">
|
||||||
|
{job.type === 'single' ? '单张图片' : '批量处理'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
{formatTime(job.startTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
{formatDuration(job.startTime, job.endTime)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeJob(job.id)}
|
||||||
|
className="text-secondary-400 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.status === 'processing' && (
|
||||||
|
<div className="w-full bg-secondary-200 rounded-full h-2 mb-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${job.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.error && (
|
||||||
|
<div className="text-sm text-red-600 bg-red-50 p-2 rounded">
|
||||||
|
{job.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.result && job.status === 'completed' && (
|
||||||
|
<div className="text-sm text-green-600 bg-green-50 p-2 rounded">
|
||||||
|
生成成功!
|
||||||
|
{job.result.video_path && (
|
||||||
|
<button className="ml-2 text-blue-600 hover:text-blue-800">
|
||||||
|
<Download size={14} className="inline mr-1" />
|
||||||
|
查看结果
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AIVideoGenerator
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { Home, Video, Settings, FolderOpen, Music, Image } from 'lucide-react'
|
import { Home, Video, Settings, FolderOpen, Music, Image, Sparkles } from 'lucide-react'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
const Sidebar: React.FC = () => {
|
const Sidebar: React.FC = () => {
|
||||||
@@ -9,6 +9,7 @@ const Sidebar: React.FC = () => {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', icon: Home, label: '首页' },
|
{ path: '/', icon: Home, label: '首页' },
|
||||||
{ path: '/editor', icon: Video, label: '编辑器' },
|
{ path: '/editor', icon: Video, label: '编辑器' },
|
||||||
|
{ path: '/ai-video', icon: Sparkles, label: 'AI 视频' },
|
||||||
{ path: '/projects', icon: FolderOpen, label: '项目' },
|
{ path: '/projects', icon: FolderOpen, label: '项目' },
|
||||||
{ path: '/media', icon: Image, label: '媒体库' },
|
{ path: '/media', icon: Image, label: '媒体库' },
|
||||||
{ path: '/audio', icon: Music, label: '音频' },
|
{ path: '/audio', icon: Music, label: '音频' },
|
||||||
|
|||||||
189
src/pages/AIVideoPage.tsx
Normal file
189
src/pages/AIVideoPage.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Sparkles, Info, Settings, HelpCircle } from 'lucide-react'
|
||||||
|
import AIVideoGenerator from '../components/AIVideoGenerator'
|
||||||
|
|
||||||
|
const AIVideoPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-secondary-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white border-b border-secondary-200 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
|
||||||
|
<Sparkles className="text-white" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">AI 视频生成</h1>
|
||||||
|
<p className="text-secondary-600">使用 AI 技术将图片转换为动态视频</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button className="btn-ghost px-3 py-2">
|
||||||
|
<Settings size={16} className="mr-2" />
|
||||||
|
设置
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost px-3 py-2">
|
||||||
|
<HelpCircle size={16} className="mr-2" />
|
||||||
|
帮助
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<div className="h-full flex">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 p-6 overflow-y-auto">
|
||||||
|
<AIVideoGenerator className="max-w-4xl mx-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="w-80 bg-white border-l border-secondary-200 p-6 overflow-y-auto">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 mb-4">使用说明</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Info Card */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Info className="text-blue-500 mt-0.5" size={16} />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-blue-900 mb-1">功能介绍</h4>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
AI 视频生成功能可以将静态图片转换为动态视频,支持单张图片处理和批量处理模式。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-secondary-900">操作步骤</h4>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="w-6 h-6 bg-primary-100 text-primary-700 rounded-full flex items-center justify-center text-sm font-medium">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-secondary-900">选择模式</p>
|
||||||
|
<p className="text-xs text-secondary-600">单张图片或批量处理</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="w-6 h-6 bg-primary-100 text-primary-700 rounded-full flex items-center justify-center text-sm font-medium">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-secondary-900">选择文件</p>
|
||||||
|
<p className="text-xs text-secondary-600">上传图片文件或选择文件夹</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="w-6 h-6 bg-primary-100 text-primary-700 rounded-full flex items-center justify-center text-sm font-medium">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-secondary-900">输入提示词</p>
|
||||||
|
<p className="text-xs text-secondary-600">描述想要生成的视频内容</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="w-6 h-6 bg-primary-100 text-primary-700 rounded-full flex items-center justify-center text-sm font-medium">
|
||||||
|
4
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-secondary-900">配置参数</p>
|
||||||
|
<p className="text-xs text-secondary-600">设置视频时长和模型类型</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="w-6 h-6 bg-primary-100 text-primary-700 rounded-full flex items-center justify-center text-sm font-medium">
|
||||||
|
5
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-secondary-900">开始生成</p>
|
||||||
|
<p className="text-xs text-secondary-600">等待 AI 处理完成</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-secondary-900">使用技巧</h4>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm text-secondary-700">
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<span className="text-primary-500">•</span>
|
||||||
|
<span>提示词越详细,生成的视频效果越好</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<span className="text-primary-500">•</span>
|
||||||
|
<span>Pro 模型质量更高但处理时间更长</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<span className="text-primary-500">•</span>
|
||||||
|
<span>批量处理时提示词会循环使用</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<span className="text-primary-500">•</span>
|
||||||
|
<span>建议图片分辨率不低于 512x512</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Comparison */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-secondary-900">模型对比</h4>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="border border-secondary-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium text-secondary-900">Lite 模型</span>
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">推荐</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-secondary-600 space-y-1">
|
||||||
|
<p>• 分辨率:720p</p>
|
||||||
|
<p>• 处理速度:快</p>
|
||||||
|
<p>• 适用场景:快速预览、批量处理</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-secondary-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium text-secondary-900">Pro 模型</span>
|
||||||
|
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">高质量</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-secondary-600 space-y-1">
|
||||||
|
<p>• 分辨率:1080p</p>
|
||||||
|
<p>• 处理速度:较慢</p>
|
||||||
|
<p>• 适用场景:最终输出、高质量需求</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Support */}
|
||||||
|
<div className="bg-secondary-50 border border-secondary-200 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium text-secondary-900 mb-2">需要帮助?</h4>
|
||||||
|
<p className="text-sm text-secondary-600 mb-3">
|
||||||
|
如果遇到问题或需要技术支持,请联系我们。
|
||||||
|
</p>
|
||||||
|
<button className="btn-secondary text-sm px-3 py-1">
|
||||||
|
联系支持
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AIVideoPage
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Plus, FolderOpen, Video, Music, Zap, TestTube } from 'lucide-react'
|
import { Plus, FolderOpen, Video, Music, Zap, TestTube, Sparkles } from 'lucide-react'
|
||||||
import { TauriService } from '../services/tauri'
|
import { TauriService } from '../services/tauri'
|
||||||
import { useProjectStore } from '../stores/useProjectStore'
|
import { useProjectStore } from '../stores/useProjectStore'
|
||||||
|
|
||||||
@@ -16,10 +16,11 @@ const HomePage: React.FC = () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
{ icon: Video, label: '新建视频项目', description: '创建一个新的视频编辑项目' },
|
{ icon: Video, label: '新建视频项目', description: '创建一个新的视频编辑项目', path: '/editor' },
|
||||||
{ icon: Music, label: '音频处理', description: '处理音频文件,添加效果' },
|
{ icon: Sparkles, label: 'AI 视频生成', description: '使用 AI 将图片转换为动态视频', path: '/ai-video' },
|
||||||
{ icon: Zap, label: 'AI 自动剪辑', description: '使用 AI 自动生成视频剪辑' },
|
{ icon: Music, label: '音频处理', description: '处理音频文件,添加效果', path: '/audio' },
|
||||||
{ icon: FolderOpen, label: '导入媒体', description: '导入视频、音频和图片文件' },
|
{ icon: Zap, label: 'AI 自动剪辑', description: '使用 AI 自动生成视频剪辑', path: '/editor' },
|
||||||
|
{ icon: FolderOpen, label: '导入媒体', description: '导入视频、音频和图片文件', path: '/media' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const testTauriConnection = async () => {
|
const testTauriConnection = async () => {
|
||||||
@@ -94,9 +95,10 @@ const HomePage: React.FC = () => {
|
|||||||
{quickActions.map((action, index) => {
|
{quickActions.map((action, index) => {
|
||||||
const Icon = action.icon
|
const Icon = action.icon
|
||||||
return (
|
return (
|
||||||
<div
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
className="card p-6 hover:shadow-md transition-shadow cursor-pointer group"
|
to={action.path}
|
||||||
|
className="card p-6 hover:shadow-md transition-shadow cursor-pointer group block"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center text-center space-y-3">
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center group-hover:bg-primary-200 transition-colors">
|
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center group-hover:bg-primary-200 transition-colors">
|
||||||
@@ -105,7 +107,7 @@ const HomePage: React.FC = () => {
|
|||||||
<h3 className="font-medium text-secondary-900">{action.label}</h3>
|
<h3 className="font-medium text-secondary-900">{action.label}</h3>
|
||||||
<p className="text-sm text-secondary-600">{action.description}</p>
|
<p className="text-sm text-secondary-600">{action.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ export interface AudioAnalysisRequest {
|
|||||||
analysis_type: string
|
analysis_type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AIVideoRequest {
|
||||||
|
image_path: string
|
||||||
|
prompt: string
|
||||||
|
duration: string
|
||||||
|
model_type: string
|
||||||
|
output_path?: string
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchAIVideoRequest {
|
||||||
|
image_folder: string
|
||||||
|
prompts: string[]
|
||||||
|
output_folder: string
|
||||||
|
duration: string
|
||||||
|
model_type: string
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectInfo {
|
export interface ProjectInfo {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -356,3 +374,32 @@ export class ProjectService {
|
|||||||
await TauriService.saveProject(projectInfo)
|
await TauriService.saveProject(projectInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI Video generation operations
|
||||||
|
export class AIVideoService {
|
||||||
|
/**
|
||||||
|
* Generate video from single image
|
||||||
|
*/
|
||||||
|
static async generateVideo(request: AIVideoRequest): Promise<any> {
|
||||||
|
try {
|
||||||
|
const result = await invoke('generate_ai_video', { request })
|
||||||
|
return JSON.parse(result as string)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate AI video:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch generate videos from multiple images
|
||||||
|
*/
|
||||||
|
static async batchGenerateVideos(request: BatchAIVideoRequest): Promise<any> {
|
||||||
|
try {
|
||||||
|
const result = await invoke('batch_generate_ai_videos', { request })
|
||||||
|
return JSON.parse(result as string)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to batch generate AI videos:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
202
src/stores/useAIVideoStore.ts
Normal file
202
src/stores/useAIVideoStore.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* AI Video Store
|
||||||
|
* Manages AI video generation state and operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { AIVideoService, AIVideoRequest, BatchAIVideoRequest } from '../services/tauri'
|
||||||
|
|
||||||
|
interface AIVideoJob {
|
||||||
|
id: string
|
||||||
|
type: 'single' | 'batch'
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||||
|
progress: number
|
||||||
|
request: AIVideoRequest | BatchAIVideoRequest
|
||||||
|
result?: any
|
||||||
|
error?: string
|
||||||
|
startTime: number
|
||||||
|
endTime?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIVideoState {
|
||||||
|
// Jobs
|
||||||
|
jobs: AIVideoJob[]
|
||||||
|
|
||||||
|
// Current processing
|
||||||
|
isProcessing: boolean
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
defaultPrompts: string[]
|
||||||
|
defaultDuration: string
|
||||||
|
defaultModelType: string
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
addJob: (request: AIVideoRequest | BatchAIVideoRequest, type: 'single' | 'batch') => string
|
||||||
|
updateJob: (jobId: string, updates: Partial<AIVideoJob>) => void
|
||||||
|
removeJob: (jobId: string) => void
|
||||||
|
clearCompletedJobs: () => void
|
||||||
|
|
||||||
|
// AI Video operations
|
||||||
|
generateSingleVideo: (request: AIVideoRequest) => Promise<string>
|
||||||
|
batchGenerateVideos: (request: BatchAIVideoRequest) => Promise<string>
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
setDefaultPrompts: (prompts: string[]) => void
|
||||||
|
setDefaultDuration: (duration: string) => void
|
||||||
|
setDefaultModelType: (modelType: string) => void
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
setError: (error: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default prompts from the original GUI
|
||||||
|
const DEFAULT_PROMPTS = [
|
||||||
|
"女人扭动身体向摄像机展示身材,一只手撩了一下头发,镜头从左向右移动并放大画面",
|
||||||
|
"时尚模特抬头自信的展示身材,扭动身体,一只手放在了头上,镜头逐渐放大聚焦在了下衣上",
|
||||||
|
"女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动",
|
||||||
|
"自信步伐跟拍模特,模特步伐自信地同时行走,镜头紧紧跟随。抬起手捋一捋头发。传递出自信与时尚的气息。",
|
||||||
|
"女生两只手捏着拳头轻盈的左右摇摆跳舞,动作幅度不大,然后把手摊开放在胸口再做出像popping心脏跳动的动作,左右身体都要非常协调",
|
||||||
|
"一个年轻女子自信地在相机前展示了她优美的身材,以自然的流体动作自由地摇摆,左手撩了一下头发之后停在了胸前。一个美女自拍跳舞扭动身体的视频,手从下到上最后放在胸前,妩媚的表情",
|
||||||
|
"美女向后退了一步站在那里展示服装,双手轻轻提了一下裤子两侧,镜头从上到下逐渐放大",
|
||||||
|
"女人低头看向裤子,向镜头展示身材,一只手放在了头上做pose动作",
|
||||||
|
"美女向后退了一步站在那里展示服装,低头并用手抚摸裤子,镜头从上到下逐渐放大",
|
||||||
|
"美女向后退了一步站在那里展示服装,双手从上到下整理衣服,自然扭动身体,自信的表情"
|
||||||
|
]
|
||||||
|
|
||||||
|
export const useAIVideoStore = create<AIVideoState>((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
jobs: [],
|
||||||
|
isProcessing: false,
|
||||||
|
defaultPrompts: DEFAULT_PROMPTS,
|
||||||
|
defaultDuration: '5',
|
||||||
|
defaultModelType: 'lite',
|
||||||
|
|
||||||
|
// Job management
|
||||||
|
addJob: (request, type) => {
|
||||||
|
const job: AIVideoJob = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
request,
|
||||||
|
startTime: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
set(state => ({
|
||||||
|
jobs: [...state.jobs, job]
|
||||||
|
}))
|
||||||
|
|
||||||
|
return job.id
|
||||||
|
},
|
||||||
|
|
||||||
|
updateJob: (jobId, updates) => {
|
||||||
|
set(state => ({
|
||||||
|
jobs: state.jobs.map(job =>
|
||||||
|
job.id === jobId ? { ...job, ...updates } : job
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
removeJob: (jobId) => {
|
||||||
|
set(state => ({
|
||||||
|
jobs: state.jobs.filter(job => job.id !== jobId)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCompletedJobs: () => {
|
||||||
|
set(state => ({
|
||||||
|
jobs: state.jobs.filter(job => job.status !== 'completed' && job.status !== 'failed')
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI Video operations
|
||||||
|
generateSingleVideo: async (request) => {
|
||||||
|
const { addJob, updateJob } = get()
|
||||||
|
|
||||||
|
const jobId = addJob(request, 'single')
|
||||||
|
|
||||||
|
try {
|
||||||
|
set({ isProcessing: true })
|
||||||
|
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||||
|
|
||||||
|
const result = await AIVideoService.generateVideo(request)
|
||||||
|
|
||||||
|
updateJob(jobId, {
|
||||||
|
status: 'completed',
|
||||||
|
progress: 100,
|
||||||
|
result,
|
||||||
|
endTime: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
return jobId
|
||||||
|
} catch (error) {
|
||||||
|
updateJob(jobId, {
|
||||||
|
status: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
endTime: Date.now()
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
set({ isProcessing: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
batchGenerateVideos: async (request) => {
|
||||||
|
const { addJob, updateJob } = get()
|
||||||
|
|
||||||
|
const jobId = addJob(request, 'batch')
|
||||||
|
|
||||||
|
try {
|
||||||
|
set({ isProcessing: true })
|
||||||
|
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||||
|
|
||||||
|
const result = await AIVideoService.batchGenerateVideos(request)
|
||||||
|
|
||||||
|
updateJob(jobId, {
|
||||||
|
status: 'completed',
|
||||||
|
progress: 100,
|
||||||
|
result,
|
||||||
|
endTime: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
return jobId
|
||||||
|
} catch (error) {
|
||||||
|
updateJob(jobId, {
|
||||||
|
status: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
endTime: Date.now()
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
set({ isProcessing: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
setDefaultPrompts: (prompts) => {
|
||||||
|
set({ defaultPrompts: prompts })
|
||||||
|
},
|
||||||
|
|
||||||
|
setDefaultDuration: (duration) => {
|
||||||
|
set({ defaultDuration: duration })
|
||||||
|
},
|
||||||
|
|
||||||
|
setDefaultModelType: (modelType) => {
|
||||||
|
set({ defaultModelType: modelType })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
setError: (error) => {
|
||||||
|
// This could be used for global error handling
|
||||||
|
console.error('AI Video Error:', error)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Selectors for easier access to specific state
|
||||||
|
export const useAIVideoJobs = () => useAIVideoStore(state => state.jobs)
|
||||||
|
export const useAIVideoProcessing = () => useAIVideoStore(state => state.isProcessing)
|
||||||
|
export const useAIVideoSettings = () => useAIVideoStore(state => ({
|
||||||
|
defaultPrompts: state.defaultPrompts,
|
||||||
|
defaultDuration: state.defaultDuration,
|
||||||
|
defaultModelType: state.defaultModelType
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user