| | import os |
| | import gradio as gr |
| | import tempfile |
| | import random |
| | import subprocess |
| | import shutil |
| | import zipfile |
| | from datetime import datetime |
| | import json |
| | import concurrent.futures |
| | import time |
| | import platform |
| |
|
| | |
| | STORAGE_DIR = os.path.expanduser("~/video_storage") |
| | STORAGE_CONFIG = os.path.join(STORAGE_DIR, "storage_config.json") |
| |
|
| | |
| | def detect_hardware_acceleration(): |
| | """检测可用的硬件加速""" |
| | hardware_accel = { |
| | 'nvidia': False, |
| | 'amd': False, |
| | 'intel': False, |
| | 'encoder': 'libx264', |
| | 'working': False |
| | } |
| | |
| | try: |
| | |
| | result = subprocess.run(['ffmpeg', '-encoders'], capture_output=True, text=True) |
| | if 'h264_nvenc' in result.stdout: |
| | |
| | test_result = subprocess.run([ |
| | 'ffmpeg', '-f', 'lavfi', '-i', 'testsrc=duration=1:size=32x32:rate=1', |
| | '-c:v', 'h264_nvenc', '-f', 'null', '-' |
| | ], capture_output=True, text=True) |
| | |
| | if test_result.returncode == 0: |
| | hardware_accel['nvidia'] = True |
| | hardware_accel['encoder'] = 'h264_nvenc' |
| | hardware_accel['working'] = True |
| | print("✅ 检测到NVIDIA GPU硬件加速支持且工作正常") |
| | else: |
| | print("⚠️ 检测到NVIDIA GPU但无法使用,将回退到软件编码") |
| | |
| | |
| | if not hardware_accel['working'] and 'h264_amf' in result.stdout: |
| | test_result = subprocess.run([ |
| | 'ffmpeg', '-f', 'lavfi', '-i', 'testsrc=duration=1:size=32x32:rate=1', |
| | '-c:v', 'h264_amf', '-f', 'null', '-' |
| | ], capture_output=True, text=True) |
| | |
| | if test_result.returncode == 0: |
| | hardware_accel['amd'] = True |
| | hardware_accel['encoder'] = 'h264_amf' |
| | hardware_accel['working'] = True |
| | print("✅ 检测到AMD GPU硬件加速支持且工作正常") |
| | else: |
| | print("⚠️ 检测到AMD GPU但无法使用,将回退到软件编码") |
| | |
| | |
| | if not hardware_accel['working'] and 'h264_qsv' in result.stdout: |
| | test_result = subprocess.run([ |
| | 'ffmpeg', '-f', 'lavfi', '-i', 'testsrc=duration=1:size=32x32:rate=1', |
| | '-c:v', 'h264_qsv', '-f', 'null', '-' |
| | ], capture_output=True, text=True) |
| | |
| | if test_result.returncode == 0: |
| | hardware_accel['intel'] = True |
| | hardware_accel['encoder'] = 'h264_qsv' |
| | hardware_accel['working'] = True |
| | print("✅ 检测到Intel Quick Sync硬件加速支持且工作正常") |
| | else: |
| | print("⚠️ 检测到Intel Quick Sync但无法使用,将回退到软件编码") |
| | |
| | except Exception as e: |
| | print(f"⚠️ 硬件加速检测失败: {e}") |
| | |
| | if not hardware_accel['working']: |
| | print("📌 将使用软件编码 (libx264)") |
| | |
| | return hardware_accel |
| |
|
| | |
| | HARDWARE_ACCEL = detect_hardware_acceleration() |
| |
|
| | def init_storage(): |
| | """初始化储存空间""" |
| | os.makedirs(STORAGE_DIR, exist_ok=True) |
| | |
| | if not os.path.exists(STORAGE_CONFIG): |
| | config = { |
| | "created_time": datetime.now().isoformat(), |
| | "total_videos": 0, |
| | "total_size_mb": 0 |
| | } |
| | with open(STORAGE_CONFIG, 'w', encoding='utf-8') as f: |
| | json.dump(config, f, ensure_ascii=False, indent=2) |
| |
|
| | def save_to_storage(file_path, metadata=None): |
| | """保存文件到储存空间 [优化:移除即时更新配置]""" |
| | try: |
| | base_name = os.path.basename(file_path) |
| | target_path = os.path.join(STORAGE_DIR, base_name) |
| | |
| | |
| | count = 1 |
| | name, ext = os.path.splitext(base_name) |
| | while os.path.exists(target_path): |
| | target_path = os.path.join(STORAGE_DIR, f"{name}_{count}{ext}") |
| | count += 1 |
| | |
| | shutil.copy2(file_path, target_path) |
| | return target_path |
| | except Exception as e: |
| | print(f"❌ 储存文件失败: {e}") |
| | return None |
| |
|
| | def update_storage_config(): |
| | """更新储存配置信息""" |
| | try: |
| | video_files = [f for f in os.listdir(STORAGE_DIR) if f.lower().endswith(('.mp4', '.mov', '.avi', '.mkv'))] |
| | total_size = sum(os.path.getsize(os.path.join(STORAGE_DIR, f)) for f in video_files) / (1024 * 1024) |
| | |
| | config = { |
| | "updated_time": datetime.now().isoformat(), |
| | "total_videos": len(video_files), |
| | "total_size_mb": round(total_size, 1) |
| | } |
| | |
| | with open(STORAGE_CONFIG, 'w', encoding='utf-8') as f: |
| | json.dump(config, f, ensure_ascii=False, indent=2) |
| | except: |
| | pass |
| |
|
| | def get_storage_info(): |
| | """获取储存空间信息""" |
| | try: |
| | if os.path.exists(STORAGE_CONFIG): |
| | with open(STORAGE_CONFIG, 'r', encoding='utf-8') as f: |
| | config = json.load(f) |
| | else: |
| | config = {"total_videos": 0, "total_size_mb": 0} |
| | |
| | video_files = [] |
| | if os.path.exists(STORAGE_DIR): |
| | for f in os.listdir(STORAGE_DIR): |
| | if f.lower().endswith(('.mp4', '.mov', '.avi', '.mkv')): |
| | file_path = os.path.join(STORAGE_DIR, f) |
| | size_mb = os.path.getsize(file_path) / (1024 * 1024) |
| | mod_time = datetime.fromtimestamp(os.path.getmtime(file_path)) |
| | video_files.append({ |
| | "name": f, |
| | "size_mb": round(size_mb, 1), |
| | "modified": mod_time.strftime('%Y-%m-%d %H:%M') |
| | }) |
| | |
| | video_files.sort(key=lambda x: x["modified"], reverse=True) |
| | return config, video_files |
| | except: |
| | return {"total_videos": 0, "total_size_mb": 0}, [] |
| |
|
| | def download_all_storage(): |
| | """一键下载储存空间所有视频""" |
| | try: |
| | config, video_files = get_storage_info() |
| | |
| | if not video_files: |
| | return None, "⚠️ 储存空间为空,没有可下载的文件" |
| | |
| | |
| | package_dir = tempfile.mkdtemp() |
| | timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') |
| | zip_path = os.path.join(package_dir, f"储存空间全部视频_{timestamp}.zip") |
| | |
| | with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: |
| | for video in video_files: |
| | video_path = os.path.join(STORAGE_DIR, video['name']) |
| | if os.path.exists(video_path): |
| | zipf.write(video_path, video['name']) |
| | |
| | |
| | manifest = f"""# 储存空间视频清单 |
| | ## 📊 下载信息 |
| | - 下载时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |
| | - 视频总数: {len(video_files)} 个 |
| | - 总大小: {config['total_size_mb']}MB |
| | ## 📁 文件列表 |
| | """ |
| | for video in video_files: |
| | manifest += f"- {video['name']} ({video['size_mb']}MB) - {video['modified']}\n" |
| | |
| | manifest += """ |
| | ## 💡 使用说明 |
| | 这些是您的储存空间中保存的所有混剪视频,可直接使用或进一步编辑。 |
| | --- |
| | FFmpeg 储存管理系统 |
| | """ |
| | |
| | zipf.writestr("视频清单.txt", manifest.encode('utf-8')) |
| | |
| | download_msg = f"✅ 已打包 {len(video_files)} 个视频文件,总大小 {config['total_size_mb']}MB" |
| | return zip_path, download_msg |
| | |
| | except Exception as e: |
| | return None, f"❌ 打包下载失败: {str(e)}" |
| |
|
| | def download_selected_storage(selected_files): |
| | """下载选中的储存文件""" |
| | try: |
| | if not selected_files: |
| | return None, "⚠️ 请至少选择一个文件进行下载" |
| | |
| | |
| | package_dir = tempfile.mkdtemp() |
| | timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') |
| | zip_path = os.path.join(package_dir, f"选中视频_{timestamp}.zip") |
| | |
| | total_size = 0 |
| | with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: |
| | valid_files = [] |
| | for filename in selected_files: |
| | video_path = os.path.join(STORAGE_DIR, filename) |
| | if os.path.exists(video_path): |
| | zipf.write(video_path, filename) |
| | size_mb = os.path.getsize(video_path) / (1024 * 1024) |
| | total_size += size_mb |
| | valid_files.append({"name": filename, "size_mb": round(size_mb, 1)}) |
| | |
| | if not valid_files: |
| | return None, "❌ 选中的文件都不存在" |
| | |
| | |
| | manifest = f"""# 选中视频下载清单 |
| | ## 📊 下载信息 |
| | - 下载时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |
| | - 选中文件: {len(valid_files)} 个 |
| | - 总大小: {round(total_size, 1)}MB |
| | ## 📁 文件列表 |
| | """ |
| | for video in valid_files: |
| | manifest += f"- {video['name']} ({video['size_mb']}MB)\n" |
| | |
| | zipf.writestr("下载清单.txt", manifest.encode('utf-8')) |
| | |
| | download_msg = f"✅ 已打包 {len(valid_files)} 个选中文件,总大小 {round(total_size, 1)}MB" |
| | return zip_path, download_msg |
| | |
| | except Exception as e: |
| | return None, f"❌ 选择下载失败: {str(e)}" |
| |
|
| | def delete_storage_file(filename): |
| | """从储存空间删除文件""" |
| | try: |
| | file_path = os.path.join(STORAGE_DIR, filename) |
| | if os.path.exists(file_path): |
| | os.remove(file_path) |
| | update_storage_config() |
| | return f"✅ 已删除文件: {filename}" |
| | else: |
| | return f"❌ 文件不存在: {filename}" |
| | except Exception as e: |
| | return f"❌ 删除失败: {str(e)}" |
| |
|
| | def clear_storage(): |
| | """清空储存空间""" |
| | try: |
| | count = 0 |
| | if os.path.exists(STORAGE_DIR): |
| | for f in os.listdir(STORAGE_DIR): |
| | if f.lower().endswith(('.mp4', '.mov', '.avi', '.mkv')): |
| | os.remove(os.path.join(STORAGE_DIR, f)) |
| | count += 1 |
| | |
| | update_storage_config() |
| | return f"✅ 已清空储存空间,删除了 {count} 个文件" |
| | except Exception as e: |
| | return f"❌ 清空失败: {str(e)}" |
| |
|
| | def get_optimal_threads(): |
| | """获取最优线程数""" |
| | try: |
| | cpu_count = os.cpu_count() |
| | if cpu_count: |
| | |
| | return max(1, min(cpu_count * 2, 16)) |
| | return 4 |
| | except: |
| | return 4 |
| |
|
| | def build_ffmpeg_command(input_path, start_time, duration, output_path, operation='cut'): |
| | """构建优化的FFmpeg命令""" |
| | threads = get_optimal_threads() |
| | encoder = HARDWARE_ACCEL['encoder'] |
| | |
| | |
| | input_path = os.path.abspath(input_path) |
| | output_path = os.path.abspath(output_path) |
| | |
| | base_command = ['ffmpeg', '-threads', str(threads)] |
| | |
| | base_command.extend(['-i', input_path]) |
| | |
| | if operation == 'cut': |
| | base_command.extend(['-ss', str(start_time), '-t', str(duration)]) |
| | |
| | |
| | if HARDWARE_ACCEL['working']: |
| | |
| | if encoder == 'h264_nvenc': |
| | base_command.extend([ |
| | '-c:v', 'h264_nvenc', |
| | '-preset', 'fast', |
| | '-cq', '28', |
| | '-c:a', 'aac', |
| | '-b:a', '128k', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | elif encoder == 'h264_amf': |
| | base_command.extend([ |
| | '-c:v', 'h264_amf', |
| | '-quality', 'speed', |
| | '-rc', 'cqp', |
| | '-cqp_i', '28', |
| | '-cqp_p', '28', |
| | '-c:a', 'aac', |
| | '-b:a', '128k', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | elif encoder == 'h264_qsv': |
| | base_command.extend([ |
| | '-c:v', 'h264_qsv', |
| | '-preset', 'veryfast', |
| | '-global_quality', '28', |
| | '-c:a', 'aac', |
| | '-b:a', '128k', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | else: |
| | |
| | base_command.extend([ |
| | '-c:v', 'libx264', |
| | '-preset', 'ultrafast', |
| | '-crf', '28', |
| | '-tune', 'fastdecode', |
| | '-c:a', 'aac', |
| | '-b:a', '128k', |
| | '-movflags', '+faststart', |
| | '-avoid_negative_ts', 'make_zero', |
| | '-y', output_path |
| | ]) |
| | |
| | elif operation == 'resize': |
| | |
| | if HARDWARE_ACCEL['working']: |
| | |
| | if encoder == 'h264_nvenc': |
| | base_command.extend([ |
| | '-c:v', 'h264_nvenc', |
| | '-preset', 'fast', |
| | '-cq', '28', |
| | '-c:a', 'copy', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | elif encoder == 'h264_amf': |
| | base_command.extend([ |
| | '-c:v', 'h264_amf', |
| | '-quality', 'speed', |
| | '-rc', 'cqp', |
| | '-cqp_i', '28', |
| | '-cqp_p', '28', |
| | '-c:a', 'copy', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | elif encoder == 'h264_qsv': |
| | base_command.extend([ |
| | '-c:v', 'h264_qsv', |
| | '-preset', 'veryfast', |
| | '-global_quality', '28', |
| | '-c:a', 'copy', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | else: |
| | |
| | base_command.extend([ |
| | '-c:v', 'libx264', |
| | '-preset', 'ultrafast', |
| | '-crf', '28', |
| | '-tune', 'fastdecode', |
| | '-c:a', 'copy', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | |
| | return base_command |
| |
|
| | def ffmpeg_cut_video(input_path, start_time, duration, output_path): |
| | """[优化] 快速的视频切割,使用硬件加速和ultrafast预设""" |
| | try: |
| | command = build_ffmpeg_command(input_path, start_time, duration, output_path, 'cut') |
| | print(f"执行FFmpeg命令: {' '.join(command)}") |
| | |
| | |
| | os.makedirs(os.path.dirname(output_path), exist_ok=True) |
| | |
| | result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) |
| | |
| | if result.returncode != 0: |
| | print(f"FFmpeg错误: {result.stderr}") |
| | |
| | |
| | if HARDWARE_ACCEL['working'] and "Cannot load libcuda.so.1" in result.stderr: |
| | print("⚠️ 硬件加速失败,回退到软件编码...") |
| | HARDWARE_ACCEL['working'] = False |
| | HARDWARE_ACCEL['encoder'] = 'libx264' |
| | |
| | |
| | command = build_ffmpeg_command(input_path, start_time, duration, output_path, 'cut') |
| | print(f"重试FFmpeg命令(软件编码): {' '.join(command)}") |
| | |
| | result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) |
| | if result.returncode != 0: |
| | print(f"FFmpeg软件编码错误: {result.stderr}") |
| | return False |
| | |
| | return False |
| | |
| | return os.path.exists(output_path) |
| | except Exception as e: |
| | print(f"切割视频异常: {e}") |
| | return False |
| |
|
| | def ffmpeg_resize_video(input_path, output_path, target_ratio): |
| | """[优化] 快速的比例调整,使用硬件加速和ultrafast预设""" |
| | try: |
| | if target_ratio == '9:16': |
| | filter_complex = "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black" |
| | else: |
| | filter_complex = "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black" |
| | |
| | |
| | command = [ |
| | 'ffmpeg', '-threads', str(get_optimal_threads()), |
| | '-i', input_path, |
| | '-vf', filter_complex |
| | ] |
| | |
| | |
| | if HARDWARE_ACCEL['working']: |
| | |
| | if HARDWARE_ACCEL['encoder'] == 'h264_nvenc': |
| | command.extend([ |
| | '-c:v', 'h264_nvenc', |
| | '-preset', 'fast', |
| | '-cq', '28', |
| | '-c:a', 'copy', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | elif HARDWARE_ACCEL['encoder'] == 'h264_amf': |
| | command.extend([ |
| | '-c:v', 'h264_amf', |
| | '-quality', 'speed', |
| | '-rc', 'cqp', |
| | '-cqp_i', '28', |
| | '-cqp_p', '28', |
| | '-c:a', 'copy', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | elif HARDWARE_ACCEL['encoder'] == 'h264_qsv': |
| | command.extend([ |
| | '-c:v', 'h264_qsv', |
| | '-preset', 'veryfast', |
| | '-global_quality', '28', |
| | '-c:a', 'copy', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | else: |
| | |
| | command.extend([ |
| | '-c:v', 'libx264', |
| | '-preset', 'ultrafast', |
| | '-crf', '28', |
| | '-tune', 'fastdecode', |
| | '-c:a', 'copy', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ]) |
| | |
| | print(f"执行FFmpeg调整命令: {' '.join(command)}") |
| | |
| | |
| | os.makedirs(os.path.dirname(output_path), exist_ok=True) |
| | |
| | result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) |
| | |
| | if result.returncode != 0: |
| | print(f"FFmpeg调整错误: {result.stderr}") |
| | |
| | |
| | if HARDWARE_ACCEL['working'] and "Cannot load libcuda.so.1" in result.stderr: |
| | print("⚠️ 硬件加速失败,回退到软件编码...") |
| | HARDWARE_ACCEL['working'] = False |
| | HARDWARE_ACCEL['encoder'] = 'libx264' |
| | |
| | |
| | command = [ |
| | 'ffmpeg', '-threads', str(get_optimal_threads()), |
| | '-i', input_path, |
| | '-vf', filter_complex, |
| | '-c:v', 'libx264', |
| | '-preset', 'ultrafast', |
| | '-crf', '28', |
| | '-tune', 'fastdecode', |
| | '-c:a', 'copy', |
| | '-movflags', '+faststart', |
| | '-y', output_path |
| | ] |
| | |
| | print(f"重试FFmpeg调整命令(软件编码): {' '.join(command)}") |
| | result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) |
| | if result.returncode != 0: |
| | print(f"FFmpeg软件编码错误: {result.stderr}") |
| | return False |
| | |
| | return False |
| | |
| | return os.path.exists(output_path) |
| | except Exception as e: |
| | print(f"调整视频异常: {e}") |
| | return False |
| |
|
| | def concat_videos(file_list, output_path): |
| | """稳定的视频合并""" |
| | if not file_list: |
| | return False |
| | |
| | valid_files = [f for f in file_list if os.path.exists(f)] |
| | if not valid_files: |
| | return False |
| | |
| | list_file = tempfile.NamedTemporaryFile(delete=False, mode='w', suffix='.txt', encoding='utf-8') |
| | try: |
| | for f in valid_files: |
| | abs_path = os.path.abspath(f) |
| | list_file.write(f"file '{abs_path}'\n") |
| | list_file.close() |
| | |
| | |
| | threads = get_optimal_threads() |
| | command = ['ffmpeg', '-threads', str(threads), '-f', 'concat', '-safe', '0', '-i', list_file.name, '-c', 'copy', '-y', output_path] |
| | |
| | print(f"执行FFmpeg合并命令: {' '.join(command)}") |
| | |
| | |
| | os.makedirs(os.path.dirname(output_path), exist_ok=True) |
| | |
| | result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) |
| | |
| | if result.returncode != 0: |
| | print(f"FFmpeg合并错误: {result.stderr}") |
| | return False |
| | |
| | return os.path.exists(output_path) |
| | finally: |
| | try: |
| | os.unlink(list_file.name) |
| | except: |
| | pass |
| |
|
| | def process_single_video(video_file, clip_duration, temp_dir, max_clips=50): |
| | """[优化] 处理单个视频文件,返回其所有切片路径的列表,限制最大片段数""" |
| | video_path = video_file.name |
| | clips = [] |
| | try: |
| | |
| | cmd = ['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', video_path] |
| | result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) |
| | total_duration = float(result.stdout.strip()) |
| | print(f"视频总时长: {total_duration} 秒") |
| | |
| | |
| | max_possible_clips = int(total_duration / clip_duration) + 1 |
| | actual_max_clips = min(max_possible_clips, max_clips) |
| | print(f"将生成最多 {actual_max_clips} 个片段") |
| | |
| | except Exception as e: |
| | print(f"处理视频 {video_path} 时获取时长失败: {e}") |
| | return clips |
| |
|
| | start = 0.0 |
| | count = 0 |
| | while start < total_duration and count < actual_max_clips: |
| | duration = min(clip_duration, total_duration - start) |
| | |
| | clip_path = os.path.join(temp_dir, f"clip_{os.path.splitext(os.path.basename(video_path))[0]}_{count}.mp4") |
| |
|
| | print(f"正在切割片段 {count}: {start}-{start+duration} 秒 -> {clip_path}") |
| | |
| | if ffmpeg_cut_video(video_path, start, duration, clip_path): |
| | clips.append(clip_path) |
| | print(f"✅ 切片成功: {clip_path}") |
| | else: |
| | print(f"❌ 切片失败: {clip_path}") |
| |
|
| | start += clip_duration |
| | count += 1 |
| |
|
| | print(f"实际生成 {len(clips)} 个片段") |
| | return clips |
| |
|
| | def process_videos_with_storage(video_files, clip_duration, num_output_videos, target_ratio): |
| | """带储存功能的视频处理 [优化版:硬件加速 + 并行切割 + 批量更新 + 进度日志]""" |
| | if not video_files: |
| | return "❌ 请上传视频文件", None, "", "" |
| | |
| | start_time = time.time() |
| | temp_dir = tempfile.mkdtemp() |
| | |
| | try: |
| | print(f"🔍 开始处理 {len(video_files)} 个视频文件...") |
| | print(f"⚡ 硬件加速: {HARDWARE_ACCEL['encoder']} {'(工作正常)' if HARDWARE_ACCEL['working'] else '(不可用,使用软件编码)'}") |
| | print(f"🔧 线程数: {get_optimal_threads()}") |
| | print(f"⏱️ 切片时长: {clip_duration} 秒 | 生成数量: {num_output_videos} 个 | 比例: {target_ratio}") |
| |
|
| | all_clips = [] |
| |
|
| | |
| | print(f"🚀 启动并行切片({min(4, os.cpu_count() or 1)} 线程)...") |
| | with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, os.cpu_count() or 1)) as executor: |
| | |
| | |
| | |
| | |
| | total_needed_clips = num_output_videos * 4 |
| | clips_per_video = max(10, total_needed_clips // len(video_files)) |
| | |
| | future_to_video = { |
| | executor.submit(process_single_video, vf, clip_duration, temp_dir, clips_per_video): vf |
| | for vf in video_files |
| | } |
| | |
| | completed = 0 |
| | for future in concurrent.futures.as_completed(future_to_video): |
| | video_file = future_to_video[future] |
| | try: |
| | video_clips = future.result() |
| | all_clips.extend(video_clips) |
| | completed += 1 |
| | print(f"✅ 已完成切片: {completed}/{len(video_files)} | 文件: {os.path.basename(video_file.name)} | 切片数: {len(video_clips)}") |
| | except Exception as exc: |
| | video_file = future_to_video[future] |
| | print(f"❌ 切片失败: {os.path.basename(video_file.name)} - {exc}") |
| |
|
| | print(f"📊 所有视频切片完成,共生成 {len(all_clips)} 个片段") |
| |
|
| | if not all_clips: |
| | return "❌ 切割失败,请检查视频文件", None, "", "" |
| |
|
| | |
| | if len(all_clips) > total_needed_clips: |
| | print(f"片段数量({len(all_clips)})超过需求({total_needed_clips}),随机选择...") |
| | all_clips = random.sample(all_clips, total_needed_clips) |
| | print(f"选择后剩余 {len(all_clips)} 个片段") |
| |
|
| | random.shuffle(all_clips) |
| | clips_per_video = max(1, len(all_clips) // num_output_videos) |
| | output_files = [] |
| | stored_files = [] |
| |
|
| | print(f"🎬 开始合并生成 {num_output_videos} 个混剪视频...") |
| | for i in range(num_output_videos): |
| | start_idx = i * clips_per_video |
| | end_idx = len(all_clips) if i == num_output_videos - 1 else (start_idx + clips_per_video) |
| | selected_clips = all_clips[start_idx:end_idx] |
| | |
| | if not selected_clips: |
| | print(f"⚠️ 无足够片段生成第 {i+1} 个视频,跳过") |
| | continue |
| | |
| | temp_merged = os.path.join(temp_dir, f"merged_{i+1}.mp4") |
| | print(f"🔗 正在合并 {len(selected_clips)} 个片段 → {temp_merged}...") |
| | if not concat_videos(selected_clips, temp_merged): |
| | print(f"❌ 合并失败: 第 {i+1} 个视频") |
| | continue |
| | |
| | timestamp = datetime.now().strftime('%H%M%S') |
| | final_output = os.path.join(temp_dir, f"混剪视频_{target_ratio.replace(':', 'x')}_{i+1}_{timestamp}.mp4") |
| | print(f"🎬 正在调整比例 {target_ratio} → {final_output}...") |
| | |
| | if ffmpeg_resize_video(temp_merged, final_output, target_ratio): |
| | output_files.append(final_output) |
| | stored_path = save_to_storage(final_output) |
| | if stored_path: |
| | stored_files.append(os.path.basename(stored_path)) |
| | print(f"💾 已保存混剪视频: {os.path.basename(stored_path)}") |
| | else: |
| | print(f"❌ 保存失败: {final_output}") |
| | else: |
| | print(f"❌ 比例调整失败: 第 {i+1} 个视频") |
| |
|
| | if stored_files: |
| | update_storage_config() |
| | print(f"✅ 已统一更新储存配置,共保存 {len(stored_files)} 个视频文件") |
| |
|
| | if not output_files: |
| | return "❌ 生成混剪视频失败", None, "", "" |
| |
|
| | package_dir = tempfile.mkdtemp() |
| | timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') |
| | zip_path = os.path.join(package_dir, f"混剪视频包_{target_ratio.replace(':', 'x')}_{timestamp}.zip") |
| | |
| | print(f"📦 正在打包 {len(output_files)} 个视频文件为 ZIP...") |
| | with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: |
| | for video_file in output_files: |
| | arcname = os.path.basename(video_file) |
| | zipf.write(video_file, arcname) |
| | print(f" ➤ 已添加: {arcname}") |
| | |
| | readme = f"""# 混剪视频包 |
| | ## 📊 生成信息 |
| | - 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |
| | - 视频数量: {len(output_files)} 个 |
| | - 视频比例: {target_ratio} |
| | - 切片时长: {clip_duration} 秒 |
| | - 硬件加速: {HARDWARE_ACCEL['encoder']} {'(工作正常)' if HARDWARE_ACCEL['working'] else '(不可用,使用软件编码)'} |
| | ## 📁 文件列表 |
| | """ |
| | for i, vf in enumerate(output_files, 1): |
| | size_mb = os.path.getsize(vf) / (1024 * 1024) |
| | readme += f"- 混剪视频_{i}.mp4 ({size_mb:.1f}MB)\n" |
| | |
| | readme += f""" |
| | ## 💾 储存信息 |
| | 所有视频已自动保存到本地储存空间: |
| | {', '.join(stored_files)} |
| | 视频已按 {target_ratio} 比例优化,可直接发布。 |
| | """ |
| | zipf.writestr("README.txt", readme.encode('utf-8')) |
| | |
| | total_size = os.path.getsize(zip_path) / (1024 * 1024) |
| | platform_info = "📱 短视频平台" if target_ratio == '9:16' else "🖥️ 长视频平台" |
| | |
| | end_time = time.time() |
| | elapsed = end_time - start_time |
| | print(f"🎉 混剪完成!总耗时: {elapsed:.1f} 秒") |
| | print(f"📥 下载包大小: {total_size:.1f}MB") |
| | print(f"📁 已保存文件: {len(stored_files)} 个") |
| |
|
| | success_msg = f"""✅ 混剪完成并已储存! |
| | 📊 **生成统计:** |
| | • 🎬 混剪视频: {len(output_files)} 个 |
| | • 📐 视频比例: {target_ratio} |
| | • 🎯 适合平台: {platform_info} |
| | • 📦 下载包大小: {total_size:.1f}MB |
| | • ⚡ 硬件加速: {HARDWARE_ACCEL['encoder']} {'(工作正常)' if HARDWARE_ACCEL['working'] else '(不可用,使用软件编码)'} |
| | • 🔧 处理线程: {get_optimal_threads()} |
| | • ⏱️ 处理耗时: {elapsed:.1f} 秒 |
| | 💾 **自动储存:** |
| | • 📁 储存位置: ~/video_storage/ |
| | • 🔥 已保存文件: {len(stored_files)} 个 |
| | • ✅ 永久保存,不会丢失 |
| | ⬇️ **立即下载:** |
| | 点击下方按钮下载打包文件 |
| | """ |
| | |
| | details = f"""🎬 **视频详情:** |
| | """ |
| | for i, vf in enumerate(output_files, 1): |
| | size_mb = os.path.getsize(vf) / (1024 * 1024) |
| | details += f"• 混剪视频_{i}: {size_mb:.1f}MB\n" |
| | |
| | details += f""" |
| | 💾 **储存详情:** |
| | """ |
| | for i, stored_file in enumerate(stored_files, 1): |
| | details += f"• 已储存: {stored_file}\n" |
| | |
| | config, video_list = get_storage_info() |
| | storage_info = f"""📊 **储存空间状态:** |
| | 💾 总计: {config['total_videos']} 个视频 |
| | 📦 总大小: {config['total_size_mb']}MB |
| | 📁 位置: ~/video_storage/ |
| | 📋 **最新文件:** |
| | """ |
| | |
| | for video in video_list[:5]: |
| | storage_info += f"• {video['name']} ({video['size_mb']}MB) - {video['modified']}\n" |
| | |
| | return success_msg, zip_path, details, storage_info |
| | |
| | except Exception as e: |
| | print(f"❌ 处理失败: {str(e)}") |
| | return f"❌ 处理失败: {str(e)}", None, "", "" |
| | |
| | finally: |
| | shutil.rmtree(temp_dir, ignore_errors=True) |
| | print("🧹 临时文件夹已清理") |
| |
|
| | def refresh_storage_display(): |
| | """刷新储存空间显示""" |
| | config, video_list = get_storage_info() |
| | |
| | storage_display = f"""💾 **储存空间概览** |
| | 📊 **统计信息:** |
| | • 总视频数量: {config['total_videos']} 个 |
| | • 总占用空间: {config['total_size_mb']}MB |
| | • 储存位置: ~/video_storage/ |
| | • 硬件加速: {HARDWARE_ACCEL['encoder']} {'(工作正常)' if HARDWARE_ACCEL['working'] else '(不可用,使用软件编码)'} |
| | 📁 **文件列表:** |
| | """ |
| | |
| | if video_list: |
| | for video in video_list: |
| | storage_display += f"• {video['name']} ({video['size_mb']}MB) - {video['modified']}\n" |
| | else: |
| | storage_display += "暂无文件\n" |
| | |
| | file_choices = [video['name'] for video in video_list] |
| | |
| | return (storage_display, |
| | gr.Dropdown(choices=file_choices, label="选择要删除的文件", interactive=True), |
| | gr.CheckboxGroup(choices=file_choices, label="选择要下载的文件", interactive=True)) |
| |
|
| | def handle_delete_file(filename): |
| | """处理文件删除""" |
| | if not filename: |
| | return "⚠️ 请选择要删除的文件", refresh_storage_display()[0], refresh_storage_display()[1], refresh_storage_display()[2] |
| | |
| | result = delete_storage_file(filename) |
| | new_display, new_dropdown, new_checkbox = refresh_storage_display() |
| | return result, new_display, new_dropdown, new_checkbox |
| |
|
| | def handle_clear_storage(): |
| | """处理清空储存""" |
| | result = clear_storage() |
| | new_display, new_dropdown, new_checkbox = refresh_storage_display() |
| | return result, new_display, new_dropdown, new_checkbox |
| |
|
| | def handle_download_all(): |
| | """处理一键下载所有""" |
| | zip_file, message = download_all_storage() |
| | return zip_file, message |
| |
|
| | def handle_download_selected(selected_files): |
| | """处理选择下载""" |
| | zip_file, message = download_selected_storage(selected_files) |
| | return zip_file, message |
| |
|
| | init_storage() |
| |
|
| | def main(): |
| | with gr.Blocks(title="FFmpeg混剪+储存+下载管理", theme=gr.themes.Soft()) as demo: |
| | |
| | gr.HTML(f""" |
| | <div style="text-align: center; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 15px; color: white; margin-bottom: 20px;"> |
| | <h1>🎬 FFmpeg 混剪工具 + 储存管理 + 一键下载</h1> |
| | <p style="margin: 10px 0 0 0;">长视频切片 → 智能混剪 → 比例调整 → 自动储存 → 一键下载</p> |
| | <p style="margin: 5px 0 0 0; font-size: 14px; opacity: 0.9;">⚡ 硬件加速: {HARDWARE_ACCEL['encoder']} {'(工作正常)' if HARDWARE_ACCEL['working'] else '(不可用,使用软件编码)'}</p> |
| | </div> |
| | """) |
| | |
| | with gr.Tabs(): |
| | |
| | with gr.TabItem("🎬 视频混剪"): |
| | with gr.Row(): |
| | with gr.Column(scale=2): |
| | video_input = gr.File( |
| | label="📤 上传视频文件 (支持多个)", |
| | file_types=[".mp4", ".mov", ".avi", ".mkv"], |
| | file_count="multiple", |
| | height=120 |
| | ) |
| | |
| | with gr.Row(): |
| | clip_duration = gr.Number(value=3, label="切片时长(秒)", minimum=1, maximum=3600) |
| | num_output = gr.Number(value=5, label="生成数量", minimum=1, maximum=100) |
| | |
| | ratio_selection = gr.Radio( |
| | choices=["9:16", "16:9"], |
| | value="9:16", |
| | label="📐 视频比例", |
| | info="9:16适合抖音快手 | 16:9适合YouTube B站" |
| | ) |
| | |
| | process_btn = gr.Button("🎬 开始混剪并储存", variant="primary", size="lg") |
| | |
| | with gr.Column(scale=1): |
| | status_output = gr.Textbox(label="📊 处理状态", lines=12, interactive=False) |
| | |
| | with gr.Row(): |
| | with gr.Column(): |
| | download_file = gr.File(label="📦 下载混剪视频包", interactive=False) |
| | |
| | with gr.Column(): |
| | details_output = gr.Textbox(label="📝 处理详情", lines=12, interactive=False) |
| | |
| | with gr.Column(): |
| | storage_status = gr.Textbox(label="💾 储存状态", lines=12, interactive=False) |
| | |
| | |
| | with gr.TabItem("💾 储存管理 + 下载"): |
| | with gr.Row(): |
| | with gr.Column(scale=2): |
| | storage_display = gr.Textbox( |
| | label="📁 储存空间", |
| | lines=15, |
| | interactive=False, |
| | value="点击刷新按钮查看储存状态" |
| | ) |
| | |
| | with gr.Column(scale=1): |
| | refresh_btn = gr.Button("🔄 刷新储存状态", variant="secondary") |
| | |
| | gr.Markdown("### ⬇️ 下载管理") |
| | |
| | download_all_btn = gr.Button("📦 一键下载全部", variant="primary") |
| | |
| | file_selector_download = gr.CheckboxGroup( |
| | choices=[], |
| | label="选择要下载的文件", |
| | interactive=True |
| | ) |
| | |
| | download_selected_btn = gr.Button("📥 下载选中文件", variant="secondary") |
| | |
| | storage_download_file = gr.File(label="📦 储存空间下载", interactive=False) |
| | |
| | gr.Markdown("### 🗑️ 文件管理") |
| | |
| | file_selector = gr.Dropdown(choices=[], label="选择文件", interactive=True) |
| | |
| | with gr.Row(): |
| | delete_btn = gr.Button("🗑️ 删除文件", variant="secondary") |
| | clear_btn = gr.Button("🧹 清空储存", variant="stop") |
| | |
| | operation_result = gr.Textbox(label="操作结果", lines=4, interactive=False) |
| | |
| | |
| | process_btn.click( |
| | fn=process_videos_with_storage, |
| | inputs=[video_input, clip_duration, num_output, ratio_selection], |
| | outputs=[status_output, download_file, details_output, storage_status] |
| | ) |
| | |
| | refresh_btn.click( |
| | fn=refresh_storage_display, |
| | outputs=[storage_display, file_selector, file_selector_download] |
| | ) |
| | |
| | download_all_btn.click( |
| | fn=handle_download_all, |
| | outputs=[storage_download_file, operation_result] |
| | ) |
| | |
| | download_selected_btn.click( |
| | fn=handle_download_selected, |
| | inputs=[file_selector_download], |
| | outputs=[storage_download_file, operation_result] |
| | ) |
| | |
| | delete_btn.click( |
| | fn=handle_delete_file, |
| | inputs=[file_selector], |
| | outputs=[operation_result, storage_display, file_selector, file_selector_download] |
| | ) |
| | |
| | clear_btn.click( |
| | fn=handle_clear_storage, |
| | outputs=[operation_result, storage_display, file_selector, file_selector_download] |
| | ) |
| | |
| | |
| | demo.load( |
| | fn=refresh_storage_display, |
| | outputs=[storage_display, file_selector, file_selector_download] |
| | ) |
| | |
| | gr.Markdown(f""" |
| | --- |
| | ### 📖 功能说明 |
| | |
| | **🎬 视频混剪功能:** |
| | - ⚡ 自动切片和随机混剪 |
| | - 📐 支持9:16/16:9比例调整 |
| | - 📦 打包下载所有生成视频 |
| | - 💾 **自动储存到本地目录** |
| | - 🚀 **硬件加速: {HARDWARE_ACCEL['encoder']} {'(工作正常)' if HARDWARE_ACCEL['working'] else '(不可用,使用软件编码)'}** |
| | - 🔧 **多线程处理: {get_optimal_threads()} 线程** |
| | |
| | **💾 储存管理功能:** |
| | - 📁 所有生成视频自动保存到 `~/video_storage/` |
| | - 🔄 实时查看储存空间使用情况 |
| | - 📊 显示文件详细信息(大小、时间) |
| | |
| | **⬇️ 一键下载功能:** |
| | - 📦 **一键下载全部**: 打包下载储存空间中所有视频 |
| | - 📥 **选择下载**: 勾选特定文件进行批量下载 |
| | - 🗂️ **自动清单**: 下载包含详细文件清单 |
| | - ⚡ **快速打包**: 自动压缩,节省下载时间 |
| | |
| | **🗑️ 文件管理功能:** |
| | - 🗑️ 支持单个文件删除 |
| | - 🧹 支持清空全部储存文件 |
| | - 📱 灵活的文件管理操作 |
| | |
| | **🔥 使用场景:** |
| | - **批量备份**: 一键下载所有混剪作品 |
| | - **选择性导出**: 只下载需要的特定视频 |
| | - **移动设备**: 下载到手机/平板继续编辑 |
| | - **分享协作**: 打包分享给团队成员 |
| | - **存档管理**: 定期下载备份到云盘 |
| | |
| | **⚡ 性能优化:** |
| | - **硬件加速**: 自动检测并使用GPU加速 |
| | - **自动降级**: 硬件加速失败时自动回退到软件编码 |
| | - **多线程处理**: 充分利用多核CPU |
| | - **快速预设**: 使用ultrafast预设提升速度 |
| | - **并行处理**: 多个视频同时处理 |
| | |
| | **🎯 智能切片:** |
| | - **按需生成**: 根据要生成的视频数量智能计算需要的片段数 |
| | - **避免冗余**: 不会生成超过需要的片段,节省处理时间 |
| | - **随机选择**: 从生成的片段中随机选择,确保混剪多样性 |
| | |
| | **⚠️ 注意事项:** |
| | - 下载文件为ZIP格式,需要解压使用 |
| | - 一键下载包含储存空间中所有视频文件 |
| | - 选择下载可以精确控制需要的文件 |
| | - 下载包自动包含详细的文件清单 |
| | """) |
| | |
| | demo.launch() |
| |
|
| | if __name__ == "__main__": |
| | main() |