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:
root
2025-07-10 10:43:40 +08:00
parent 289fb4f7e2
commit 96e166725b
14 changed files with 2359 additions and 10 deletions

568
jm_video_ui.md Normal file
View 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()

View 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']

View 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

View 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

View 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()

View File

@@ -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
}

View File

@@ -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");

View File

@@ -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>

View 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

View File

@@ -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
View 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

View File

@@ -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>

View File

@@ -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
}
}
}

View 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
}))