diff --git a/jm_video_ui.md b/jm_video_ui.md new file mode 100644 index 0000000..9f0c28b --- /dev/null +++ b/jm_video_ui.md @@ -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() diff --git a/python_core/ai_video/__init__.py b/python_core/ai_video/__init__.py new file mode 100644 index 0000000..78e3813 --- /dev/null +++ b/python_core/ai_video/__init__.py @@ -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'] diff --git a/python_core/ai_video/api_client.py b/python_core/ai_video/api_client.py new file mode 100644 index 0000000..08f546d --- /dev/null +++ b/python_core/ai_video/api_client.py @@ -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 diff --git a/python_core/ai_video/cloud_storage.py b/python_core/ai_video/cloud_storage.py new file mode 100644 index 0000000..08308c8 --- /dev/null +++ b/python_core/ai_video/cloud_storage.py @@ -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 diff --git a/python_core/ai_video/video_generator.py b/python_core/ai_video/video_generator.py new file mode 100644 index 0000000..38ea63c --- /dev/null +++ b/python_core/ai_video/video_generator.py @@ -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() diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1d42c05..34a7da3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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, + pub timeout: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BatchAIVideoRequest { + pub image_folder: String, + pub prompts: Vec, + pub output_folder: String, + pub duration: String, + pub model_type: String, + pub timeout: Option, +} use std::process::Command; use tauri::State; @@ -165,3 +185,61 @@ pub async fn load_project(project_path: String) -> Result { Err(format!("Python script error: {}", error)) } } + +#[tauri::command] +pub async fn generate_ai_video(request: AIVideoRequest) -> Result { + 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 { + 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 +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ddc6abc..ba0b38f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src/App.tsx b/src/App.tsx index a10022c..f75aa21 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> + } /> } /> diff --git a/src/components/AIVideoGenerator.tsx b/src/components/AIVideoGenerator.tsx new file mode 100644 index 0000000..0320346 --- /dev/null +++ b/src/components/AIVideoGenerator.tsx @@ -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 = ({ className = '' }) => { + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + + // State + const [mode, setMode] = useState<'single' | 'batch'>('single') + const [selectedImage, setSelectedImage] = useState('') + const [selectedFolder, setSelectedFolder] = useState('') + const [customPrompt, setCustomPrompt] = useState('') + const [outputFolder, setOutputFolder] = useState('') + const [duration, setDuration] = useState('5') + const [modelType, setModelType] = useState('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) => { + const file = e.target.files?.[0] + if (file) { + setSelectedImage(file.path || file.name) + } + } + + const handleFolderSelect = () => { + folderInputRef.current?.click() + } + + const handleFolderChange = (e: React.ChangeEvent) => { + 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 ( +
+ {/* Header */} +
+

AI 视频生成

+ + {/* Mode Selection */} +
+ + +
+ + {/* Input Section */} +
+ {mode === 'single' ? ( +
+ +
+ + + {selectedImage || '未选择文件'} + +
+ +
+ ) : ( + <> +
+ +
+ + + {selectedFolder || '未选择文件夹'} + +
+ +
+ +
+ + setOutputFolder(e.target.value)} + placeholder="输入保存目录路径" + className="input w-full" + /> +
+ + )} + + {/* Prompt Input */} +
+ +