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