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};
|
||||
|
||||
#[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 tauri::State;
|
||||
|
||||
@@ -165,3 +185,61 @@ pub async fn load_project(project_path: String) -> Result<ProjectInfo, String> {
|
||||
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::get_project_info,
|
||||
commands::save_project,
|
||||
commands::load_project
|
||||
commands::load_project,
|
||||
commands::generate_ai_video,
|
||||
commands::batch_generate_ai_videos
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import HomePage from './pages/HomePage'
|
||||
import EditorPage from './pages/EditorPage'
|
||||
import AIVideoPage from './pages/AIVideoPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
|
||||
function App() {
|
||||
@@ -11,6 +12,7 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/editor" element={<EditorPage />} />
|
||||
<Route path="/ai-video" element={<AIVideoPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</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 { 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'
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
@@ -9,6 +9,7 @@ const Sidebar: React.FC = () => {
|
||||
const navItems = [
|
||||
{ path: '/', icon: Home, label: '首页' },
|
||||
{ path: '/editor', icon: Video, label: '编辑器' },
|
||||
{ path: '/ai-video', icon: Sparkles, label: 'AI 视频' },
|
||||
{ path: '/projects', icon: FolderOpen, label: '项目' },
|
||||
{ path: '/media', icon: Image, 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 { 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 { useProjectStore } from '../stores/useProjectStore'
|
||||
|
||||
@@ -16,10 +16,11 @@ const HomePage: React.FC = () => {
|
||||
]
|
||||
|
||||
const quickActions = [
|
||||
{ icon: Video, label: '新建视频项目', description: '创建一个新的视频编辑项目' },
|
||||
{ icon: Music, label: '音频处理', description: '处理音频文件,添加效果' },
|
||||
{ icon: Zap, label: 'AI 自动剪辑', description: '使用 AI 自动生成视频剪辑' },
|
||||
{ icon: FolderOpen, label: '导入媒体', description: '导入视频、音频和图片文件' },
|
||||
{ icon: Video, label: '新建视频项目', description: '创建一个新的视频编辑项目', path: '/editor' },
|
||||
{ icon: Sparkles, label: 'AI 视频生成', description: '使用 AI 将图片转换为动态视频', path: '/ai-video' },
|
||||
{ icon: Music, label: '音频处理', description: '处理音频文件,添加效果', path: '/audio' },
|
||||
{ icon: Zap, label: 'AI 自动剪辑', description: '使用 AI 自动生成视频剪辑', path: '/editor' },
|
||||
{ icon: FolderOpen, label: '导入媒体', description: '导入视频、音频和图片文件', path: '/media' },
|
||||
]
|
||||
|
||||
const testTauriConnection = async () => {
|
||||
@@ -94,9 +95,10 @@ const HomePage: React.FC = () => {
|
||||
{quickActions.map((action, index) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
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="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>
|
||||
<p className="text-sm text-secondary-600">{action.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,24 @@ export interface AudioAnalysisRequest {
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
@@ -356,3 +374,32 @@ export class ProjectService {
|
||||
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