Skill: leochens/video-pipeline-bundle (version 1.0.2)

--- name: video-pipeline-bundle description: 视频一站式工作流技能包。整合视频剪辑、转写、烧录、拼接全流程，支持分步执行和用户确认。包含：(1) auto-editor - 视频剪辑去除静音片段；(2) Faster Whisper + MiniMax LLM - 语音转字幕；(3) ffmpeg - 烧录字幕到视频；(4) FFmpeg 工具箱 - 拼接、格式转换；(5) pipeline.py - 完整工作流编排。使用场景：批量处理视频、从原始素材到成品的完整流程。 ⚠️ 重要依赖： - ffmpeg + ffprobe（系统级，需手动安装） - auto-editor: pip3 install auto-editor - faster-whisper + requests: pip3 install faster-whisper requests - MiniMax API Key: 环境变量 MINIMAX_API_KEY 或 --api-key 参数 - 可选: OPENCLAW_TARGET 环境变量用于进度通知 --- # Video Pipeline Bundle 视频一站式工作流技能包，整合剪辑、转写、烧录、拼接全流程。 ## 文件结构 ``` video-pipeline-bundle/ ├── SKILL.md └── scripts/ ├── pipeline.py # 工作流编排 ├── video_clip.py # 视频剪辑（去静音） ├── video_to_text.py # 语音转字幕 ├── burn_subtitle.py # 烧录字幕 └── ffmpeg_tools.py # FFmpeg 工具箱 ``` ## 依赖 - ffmpeg + ffprobe - Python 3.8+ - auto-editor（视频剪辑） - faster-whisper + requests（语音转写） - MiniMax API Key（LLM 纠错） ## 安装与配置 ### 1. 自动安装依赖 首次使用时，脚本会自动检测并安装所需依赖： ```bash # 自动检测并安装 python3 skills/video-pipeline-bundle/scripts/pipeline.py --install-deps ``` **自动安装的内容：** - `pip3 install auto-editor --break-system-packages` - `pip3 install faster-whisper requests` ### 2. 配置 MiniMax API Key **方式一：环境变量（推荐）** ```bash export MINIMAX_API_KEY="your-api-key-here" ``` **方式二：运行时传入** ```bash python3 skills/video-pipeline-bundle/scripts/pipeline.py \ --all \ --input "/path/to/videos" \ --api-key "your-api-key-here" ``` **获取 API Key：** 1. 访问 [MiniMax 开放平台](https://platform.minimaxi.com/) 2. 注册账号并创建 API Key 3. 充值或获取免费额度 ### 3. 依赖检查脚本 如果不确定环境是否满足要求，可以运行： ```bash python3 skills/video-pipeline-bundle/scripts/pipeline.py --check-deps ``` 输出示例： ``` ✅ ffmpeg: 已安装 (6.1.1) ❌ auto-editor: 未安装 ❌ faster-whisper: 未安装 ❌ MINIMAX_API_KEY: 未设置 请运行: python3 skills/video-pipeline-bundle/scripts/pipeline.py --install-deps ``` 如果有存在未安装的，就自动检测用户系统并帮用户安装。 ## 核心功能 ### 1. 视频剪辑 (video_clip.py) 去除视频中的静音片段，保留有效内容。 ```bash python3 skills/video-pipeline-bundle/scripts/video_clip.py \ --input "/path/to/input.mp4" \ --output "/path/to/output.mp4" \ --threshold -40 ``` ### 2. 语音转写 (video_to_text.py) 用 Faster Whisper 识别语音，生成 SRT 字幕，然后用 MiniMax LLM 词级别纠错。 ```bash python3 skills/video-pipeline-bundle/scripts/video_to_text.py \ --input "/path/to/video.mp4" \ --output "/path/to/subtitle.srt" \ --model small \ --api-key "your-api-key" ``` **参数：** | 参数 | 说明 | 默认 | |------|------|------| | --model | Whisper 模型 (tiny/small/base) | small | | --margin | 静音片段缓冲秒数 | 0.5 | | --api-key | MiniMax API Key | 环境变量 MINIMAX_API_KEY | **模型选择：** | 模型 | 内存 | 速度 | |------|------|------| | tiny | ~1GB | 最快 | | **small** | ~2GB | 较快 | | base | ~3GB | 中等 | ### 3. 烧录字幕 (burn_subtitle.py) 将 SRT 字幕烧录进视频。 ```bash python3 skills/video-pipeline-bundle/scripts/burn_subtitle.py \ --input "/path/to/video.mp4" \ --subtitle "/path/to/subtitle.srt" \ --output "/path/to/output.mp4" ``` ### 4. FFmpeg 工具箱 (ffmpeg_tools.py) 支持拼接、格式转换等操作。 ```bash # 拼接视频 python3 skills/video-pipeline-bundle/scripts/ffmpeg_tools.py concat \ --inputs "1.mp4" "2.mp4" \ --output "merged.mp4" # 格式转换 python3 skills/video-pipeline-bundle/scripts/ffmpeg_tools.py convert \ --input "video.mov" \ --output "video.mp4" # 查看视频信息 python3 skills/video-pipeline-bundle/scripts/ffmpeg_tools.py info \ --input "/path/to/videos" ``` ### 5. 完整工作流 (pipeline.py) 一站式处理，支持分步执行。 ```bash # 检查依赖 python3 skills/video-pipeline-bundle/scripts/pipeline.py --check-deps # 安装依赖 python3 skills/video-pipeline-bundle/scripts/pipeline.py --install-deps # 扫描目录 python3 skills/video-pipeline-bundle/scripts/pipeline.py \ --list \ --input "/path/to/videos" # 执行单步 python3 skills/video-pipeline-bundle/scripts/pipeline.py \ --step 1 \ --input "/path/to/videos" \ --output "/path/to/output" # 执行全量 python3 skills/video-pipeline-bundle/scripts/pipeline.py \ --all \ --input "/path/to/videos" \ --output "/path/to/output" \ --subtitle "/path/to/subtitles" \ --api-key "your-api-key" # 带确认模式 python3 skills/video-pipeline-bundle/scripts/pipeline.py \ --all \ --input "/path/to/videos" \ --output "/path/to/output" \ --confirm ``` ## 步骤说明 | 步骤 | 功能 | 输入 | 输出 | |------|------|------|------| | 1 | 剪辑（去静音） | 原始视频 | 已剪辑视频 | | 2 | 转写（生成字幕） | 已剪辑视频 | SRT 字幕 | | 3 | 烧录 | 已剪辑视频 + 字幕 | 已烧录视频 | | 4 | 拼接 | 多个视频 | 合并视频 | ## 输出目录结构 ``` 输入目录/ ├── 文字稿/ # 字幕文件 ├── 项目名/ # 处理后的视频 │ ├── 1_已剪辑.mp4 │ └── 2_已剪辑.mp4 └── 项目名_成品/ # 最终成品 └── 合并视频.mp4 ``` ## ⚠️ 安全注意事项 ### 1. 消息通知（可选，默认关闭） 脚本支持发送进度通知到 Feishu，但： - **默认不发送消息**（`--notify false` 即可禁用） - 如需启用，请设置 `OPENCLAW_TARGET` 环境变量为可信目标 - 通知内容会包含文件名，请注意信息安全 ```bash # 禁用通知（推荐） python3 ... --notify false # 启用通知（仅可信目标） export OPENCLAW_TARGET="your-safe-target" ``` ### 2. 自动安装（默认仅提示） `--install-deps` 选项会检测缺失的依赖并提供安装命令，但： - **不会自动执行 sudo/apt-get/brew** - 仅显示需要手动执行的命令 - 建议在虚拟环境或容器中安装 ### 3. API Key 安全 - 使用环境变量 `MINIMAX_API_KEY` 而非硬编码 - 或使用 `--api-key` 参数（注意命令历史） - 建议使用受限权限的 API Key ### 4. 建议的测试方式 ```bash # 1. 先检查依赖 python3 skills/video-pipeline-bundle/scripts/pipeline.py --check-deps # 2. 手动安装缺失的依赖（不要用 --install-deps 自动安装系统包） # 3. 在隔离环境测试 python3 skills/video-pipeline-bundle/scripts/pipeline.py \ --list \ --input "/path/to/test/videos" \ --notify false ``` ## 常见问题 **Q: 提示 "auto-editor 未安装"** A: 运行 `python3 skills/video-pipeline-bundle/scripts/pipeline.py --install-deps` **Q: 提示 "MINIMAX_API_KEY 未设置"** A: 设置环境变量 `export MINIMAX_API_KEY="your-key"`，或使用 `--api-key` 参数 **Q: 显存不够怎么办？** A: 使用 `--model tiny` 参数，tiny 模型只需要 ~1GB 内存 **Q: ffmpeg 未安装？** A: Ubuntu/Debian: `sudo apt install ffmpeg` | macOS: `brew install ffmpeg` | Windows: 下载 ffmpeg.exe

path: scripts/burn_subtitle.py
content: #!/usr/bin/env python3
"""
视频字幕烧录脚本
用 ffmpeg 将字幕烧录进视频，支持进度通知
"""

import os
import sys
import argparse
import subprocess
import re

TARGET = None

def send_message(msg):
    """发送消息到群聊"""
    if not TARGET:
        print(msg)
        return
    subprocess.run([
        "openclaw", "message", "send",
        "--channel", "feishu",
        "--target", TARGET,
        "--message", msg
    ], capture_output=True)

def find_subtitle(video_path, videos_dir, subtitles_dir):
    """根据视频名找对应的字幕文件"""
    video_name = os.path.splitext(os.path.basename(video_path))[0]
    
    # 去掉常见后缀
    for suffix in ["_已剪除冗余片段", "_已剪辑", "_已烧录字幕"]:
        if video_name.endswith(suffix):
            video_name = video_name[:-len(suffix)]
    
    # 在字幕目录找匹配的文件
    if os.path.exists(subtitles_dir):
        for f in os.listdir(subtitles_dir):
            if f.startswith(video_name) and f.endswith(".srt"):
                return os.path.join(subtitles_dir, f)
    return None

def get_video_info(video_path):
    """获取视频时长"""
    cmd = [
        "ffprobe", "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        video_path
    ]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        return float(result.stdout.strip())
    except:
        return 0

def burn_subtitle(video_path, subtitle_path, output_path, target_id=None):
    """烧录字幕"""
    global TARGET
    if target_id:
        TARGET = target_id
    
    video_name = os.path.basename(video_path)
    output_name = os.path.basename(output_path)
    
    # 获取视频总时长
    duration = get_video_info(video_path)
    
    send_message(f"🎬 开始烧录: {video_name}")
    
    # ffmpeg 命令
    cmd = [
        "ffmpeg", "-i", video_path,
        "-vf", f"subtitles='{subtitle_path}'",
        "-c:a", "copy",
        "-y", output_path
    ]
    
    print(f"执行: {' '.join(cmd)}")
    
    # 执行并捕获进度
    process = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        bufsize=1
    )
    
    frame_pattern = re.compile(r'frame=\s*(\d+)')
    time_pattern = re.compile(r'time=(\d{2}):(\d{2}):(\d{2})')
    last_percent = -1
    
    for line in process.stderr:
        print(line, end='')
        
        # 解析进度
        if duration > 0:
            time_match = time_pattern.search(line)
            if time_match:
                h, m, s = int(time_match.group(1)), int(time_match.group(2)), int(time_match.group(3))
                current_time = h * 3600 + m * 60 + s
                percent = int(current_time / duration * 100)
                
                # 每 25% 通知一次
                if percent >= 25 and percent // 25 > last_percent // 25 and last_percent < percent:
                    send_message(f"⏳ 烧录进度: {percent}% ({video_name})")
                    last_percent = percent
    
    process.wait()
    
    if process.returncode == 0:
        send_message(f"✅ 烧录完成: {output_name}")
        return True
    else:
        send_message(f"❌ 烧录失败: {video_name}")
        return False

def main():
    global TARGET
    parser = argparse.ArgumentParser(description="视频字幕烧录")
    parser.add_argument("--input", "-i", required=True, help="输入视频目录")
    parser.add_argument("--subtitle", "-s", default=None, help="字幕目录（默认同输入目录）")
    parser.add_argument("--output", "-o", default=None, help="输出目录（默认同输入目录）")
    parser.add_argument("--target", "-t", default=None, help="通知目标 ID")
    parser.add_argument("--pattern", "-p", default="*已剪除冗余片段*.mp4", help="视频匹配模式")
    
    args = parser.parse_args()
    
    if args.output is None:
        args.output = args.input
    if args.subtitle is None:
        args.subtitle = args.input
    
    TARGET = args.target or os.environ.get("OPENCLAW_TARGET")
    
    # 查找视频文件（支持任意格式）
    video_extensions = ['.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.m4v', '.webm']
    video_files = []
    for f in os.listdir(args.input):
        ext = os.path.splitext(f)[1].lower()
        if ext in video_extensions and "已烧录" not in f:
            video_files.append(os.path.join(args.input, f))
    
    if not video_files:
        send_message("📁 没有找到待烧录的视频")
        return
    
    video_files.sort()
    total = len(video_files)
    
    send_message(f"🎬 开始烧录字幕！共 {total} 个视频")
    
    success = 0
    for i, video_path in enumerate(video_files, 1):
        video_name = os.path.basename(video_path)
        
        # 找字幕
        subtitle_path = find_subtitle(video_path, args.input, args.subtitle)
        if not subtitle_path:
            send_message(f"⚠️ 找不到字幕: {video_name}")
            continue
        
        # 输出文件（保持原始扩展名）
        name_without_ext = os.path.splitext(video_name)[0]
        ext = os.path.splitext(video_name)[1]
        output_name = name_without_ext + "_已烧录字幕" + ext
        output_path = os.path.join(args.output, output_name)
        
        if burn_subtitle(video_path, subtitle_path, output_path, TARGET):
            success += 1
    
    send_message(f"🎉 烧录完成！成功 {success}/{total}")

if __name__ == "__main__":
    main()


path: scripts/ffmpeg_tools.py
content: #!/usr/bin/env python3
"""
FFmpeg 视频工具箱
支持多种视频操作：拼接、烧录字幕、格式转换、剪辑等
"""

import os
import sys
import argparse
import subprocess
import re
import glob

TARGET = None

# 支持的视频格式
VIDEO_EXTENSIONS = ['.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.m4v', '.webm', '.mpg', '.mpeg', '.3gp', '.ogv']

def send_message(msg):
    """发送消息到群聊"""
    if not TARGET:
        print(msg)
        return
    subprocess.run([
        "openclaw", "message", "send",
        "--channel", "feishu",
        "--target", TARGET,
        "--message", msg
    ], capture_output=True)

def get_video_files(directory, pattern="*"):
    """获取目录下所有视频文件"""
    files = []
    if not os.path.exists(directory):
        return files
    for f in os.listdir(directory):
        ext = os.path.splitext(f)[1].lower()
        if ext in VIDEO_EXTENSIONS:
            files.append(os.path.join(directory, f))
    return sorted(files)

def get_video_info(video_path):
    """获取视频信息"""
    cmd = ["ffprobe", "-v", "error", "-show_entries", 
           "format=duration,size,format_name", "-of", "default=noprint_wrappers=1:nokey=1", video_path]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        lines = result.stdout.strip().split('\n')
        info = {}
        for line in lines:
            if '=' in line:
                key, val = line.split('=', 1)
                info[key] = val
        return info
    except:
        return {}

def find_subtitle(video_path, subtitle_dir):
    """根据视频名找对应的字幕文件"""
    video_name = os.path.splitext(os.path.basename(video_path))[0]
    for suffix in ["_已剪除冗余片段", "_已剪辑", "_已烧录字幕"]:
        if video_name.endswith(suffix):
            video_name = video_name[:-len(suffix)]
    
    if os.path.exists(subtitle_dir):
        for f in os.listdir(subtitle_dir):
            if f.startswith(video_name) and f.endswith(".srt"):
                return os.path.join(subtitle_dir, f)
    return None

def cmd_concat(input_dir, output_file, target_id=None):
    """拼接多个视频"""
    global TARGET
    if target_id:
        TARGET = target_id
    
    video_files = get_video_files(input_dir)
    if not video_files:
        send_message("📁 没有找到视频文件")
        return False
    
    # 创建临时文件列表
    list_file = "/tmp/concat_list.txt"
    with open(list_file, "w") as f:
        for vf in video_files:
            f.write(f"file '{vf}'\n")
    
    send_message(f"🔗 开始拼接 {len(video_files)} 个视频...")
    
    cmd = ["ffmpeg", "-f", "concat", "-safe", "0", "-i", list_file, "-c", "copy", "-y", output_file]
    
    process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    for line in process.stdout:
        print(line, end='')
    
    process.wait()
    os.remove(list_file)
    
    if process.returncode == 0:
        send_message(f"✅ 拼接完成: {output_file}")
        return True
    else:
        send_message(f"❌ 拼接失败")
        return False

def cmd_burn(input_dir, subtitle_dir, output_dir, target_id=None):
    """烧录字幕"""
    global TARGET
    if target_id:
        TARGET = target_id
    
    video_files = get_video_files(input_dir)
    video_files = [f for f in video_files if "已烧录" not in os.path.basename(f)]
    
    if not video_files:
        send_message("📁 没有找到待烧录的视频")
        return False
    
    total = len(video_files)
    send_message(f"🎬 开始烧录字幕！共 {total} 个视频")
    
    success = 0
    for i, video_path in enumerate(video_files, 1):
        video_name = os.path.basename(video_path)
        
        subtitle_path = find_subtitle(video_path, subtitle_dir)
        if not subtitle_path:
            send_message(f"⚠️ 找不到字幕: {video_name}")
            continue
        
        name_without_ext = os.path.splitext(video_name)[0]
        ext = os.path.splitext(video_name)[1]
        output_name = name_without_ext + "_已烧录字幕" + ext
        output_path = os.path.join(output_dir, output_name)
        
        send_message(f"▶️ 烧录: {video_name}")
        
        cmd = ["ffmpeg", "-i", video_path, "-vf", f"subtitles='{subtitle_path}'", "-c:a", "copy", "-y", output_path]
        
        process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
        for line in process.stderr:
            print(line, end='')
        
        process.wait()
        
        if process.returncode == 0:
            success += 1
            send_message(f"✅ 完成: {output_name}")
        else:
            send_message(f"❌ 失败: {video_name}")
    
    send_message(f"🎉 烧录完成！成功 {success}/{total}")
    return True

def cmd_convert(input_dir, output_dir, output_ext, target_id=None):
    """格式转换"""
    global TARGET
    if target_id:
        TARGET = target_id
    
    video_files = get_video_files(input_dir)
    if not video_files:
        send_message("📁 没有找到视频文件")
        return False
    
    total = len(video_files)
    send_message(f"🔄 开始转换格式！共 {total} 个视频 → {output_ext}")
    
    success = 0
    for video_path in video_files:
        video_name = os.path.basename(video_path)
        name_without_ext = os.path.splitext(video_name)[0]
        output_name = name_without_ext + output_ext
        output_path = os.path.join(output_dir, output_name)
        
        send_message(f"🔄 转换: {video_name} → {output_ext}")
        
        cmd = ["ffmpeg", "-i", video_path, "-c:a", "copy", "-y", output_path]
        
        process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
        for line in process.stderr:
            print(line, end='')
        
        process.wait()
        
        if process.returncode == 0:
            success += 1
            send_message(f"✅ 完成: {output_name}")
        else:
            send_message(f"❌ 失败: {video_name}")
    
    send_message(f"🎉 转换完成！成功 {success}/{total}")
    return True

def cmd_info(input_dir):
    """查看视频信息"""
    video_files = get_video_files(input_dir)
    if not video_files:
        print("📁 没有找到视频文件")
        return
    
    print(f"\n📹 找到 {len(video_files)} 个视频:\n")
    for vf in video_files:
        info = get_video_info(vf)
        duration = float(info.get('duration', 0))
        size = int(info.get('size', 0))
        size_mb = size / 1024 / 1024
        minutes = int(duration // 60)
        seconds = int(duration % 60)
        
        name = os.path.basename(vf)
        print(f"📄 {name}")
        print(f"   时长: {minutes}分{seconds}秒 | 大小: {size_mb:.1f}MB | 格式: {info.get('format_name', 'N/A')}")
        print()

def main():
    global TARGET
    
    parser = argparse.ArgumentParser(description="FFmpeg 视频工具箱")
    subparsers = parser.add_subparsers(dest="command", help="子命令")
    
    # concat 子命令
    concat_parser = subparsers.add_parser("concat", help="拼接视频")
    concat_parser.add_argument("-i", "--input", required=True, help="输入目录")
    concat_parser.add_argument("-o", "--output", required=True, help="输出文件")
    concat_parser.add_argument("-t", "--target", default=None, help="通知目标")
    
    # burn 子命令
    burn_parser = subparsers.add_parser("burn", help="烧录字幕")
    burn_parser.add_argument("-i", "--input", required=True, help="视频目录")
    burn_parser.add_argument("-s", "--subtitle", required=True, help="字幕目录")
    burn_parser.add_argument("-o", "--output", required=True, help="输出目录")
    burn_parser.add_argument("-t", "--target", default=None, help="通知目标")
    
    # convert 子命令
    convert_parser = subparsers.add_parser("convert", help="格式转换")
    convert_parser.add_argument("-i", "--input", required=True, help="输入目录")
    convert_parser.add_argument("-o", "--output", required=True, help="输出目录")
    convert_parser.add_argument("-e", "--ext", required=True, help="目标扩展名（如 .mp4, .mkv）")
    convert_parser.add_argument("-t", "--target", default=None, help="通知目标")
    
    # info 子命令
    info_parser = subparsers.add_parser("info", help="查看视频信息")
    info_parser.add_argument("-i", "--input", required=True, help="视频目录")
    
    args = parser.parse_args()
    
    TARGET = args.target or os.environ.get("OPENCLAW_TARGET")
    
    if args.command == "concat":
        cmd_concat(args.input, args.output, TARGET)
    elif args.command == "burn":
        cmd_burn(args.input, args.subtitle, args.output, TARGET)
    elif args.command == "convert":
        cmd_convert(args.input, args.output, args.ext, TARGET)
    elif args.command == "info":
        cmd_info(args.input)
    else:
        parser.print_help()

if __name__ == "__main__":
    main()


path: scripts/pipeline.py
content: #!/usr/bin/env python3
"""
视频一站式工作流
流程：剪辑 → 拼接 → 转写 → 烧录
每一步的产出都保留

依赖检查:
- ffmpeg: 系统视频处理工具
- auto-editor: pip3 install auto-editor --break-system-packages
- faster-whisper: pip3 install faster-whisper requests
- MINIMAX_API_KEY: 环境变量或 --api-key 参数
"""

import os
import sys
import argparse
import subprocess
import glob
import shutil

TARGET = None
VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.m4v', '.webm']

def check_deps():
    """检查依赖是否已安装"""
    print("\n" + "="*40)
    print("🔍 检查依赖...")
    print("="*40)
    
    deps_status = {}
    
    # 检查 ffmpeg
    ffmpeg = shutil.which("ffmpeg")
    if ffmpeg:
        try:
            result = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True)
            version = result.stdout.split("\n")[0] if result.stdout else "unknown"
            print(f"✅ ffmpeg: 已安装 ({version})")
            deps_status["ffmpeg"] = True
        except:
            print(f"✅ ffmpeg: 已安装")
            deps_status["ffmpeg"] = True
    else:
        print ffmpeg: 未安装")
       (f"❌ deps_status["ffmpeg"] = False
    
    # 检查 ffprobe
    ffprobe = shutil.which("ffprobe")
    if ffprobe:
        print(f"✅ ffprobe: 已安装")
        deps_status["ffprobe"] = True
    else:
        print(f"❌ ffprobe: 未安装")
        deps_status["ffprobe"] = False
    
    # 检查 auto-editor
    try:
        result = subprocess.run(["auto-editor", "--version"], capture_output=True, text=True)
        version = result.stdout.strip() or result.stderr.strip() or "unknown"
        print(f"✅ auto-editor: 已安装 ({version})")
        deps_status["auto-editor"] = True
    except:
        print(f"❌ auto-editor: 未安装")
        deps_status["auto-editor"] = False
    
    # 检查 faster-whisper
    try:
        import faster_whisper
        print(f"✅ faster-whisper: 已安装")
        deps_status["faster-whisper"] = True
    except ImportError:
        print(f"❌ faster-whisper: 未安装")
        deps_status["faster-whisper"] = False
    
    # 检查 requests
    try:
        import requests
        print(f"✅ requests: 已安装")
        deps_status["requests"] = True
    except ImportError:
        print(f"❌ requests: 未安装")
        deps_status["requests"] = False
    
    # 检查 MiniMax API Key
    [REDACTED_SECRET]"MINIMAX_API_KEY")
    if [REDACTED_SECRET]"✅ MINIMAX_API_KEY: 已设置")
        deps_status["MINIMAX_API_KEY"] = True
    else:
        print(f"❌ MINIMAX_API_KEY: 未设置")
        deps_status["MINIMAX_API_KEY"] = False
    
    print("="*40)
    
    # 总结
    all_ok = all(deps_status.values())
    if all_ok:
        print("✅ 所有依赖都已满足！")
    else:
        missing = [k for k, v in deps_status.items() if not v]
        print(f"❌ 缺少依赖: {', '.join(missing)}")
        print("\n请运行以下命令查看安装指南:")
        print("  python3 pipeline.py --install-deps")
        print("\n或手动安装:")
        if "ffmpeg" not in deps_status or "ffprobe" not in deps_status:
            print("  Ubuntu/Debian: sudo apt install ffmpeg")
            print("  macOS: brew install ffmpeg")
        if not deps_status.get("auto-editor"):
            print("  pip3 install auto-editor --break-system-packages")
        if not deps_status.get("faster-whisper") or not deps_status.get("requests"):
            print("  pip3 install faster-whisper requests")
        if not deps_status.get("MINIMAX_API_KEY"):
            print("  export MINIMAX_API_KEY='your-api-key'")
    
    print("="*40)
    return all_ok

def install_deps():
    """自动安装依赖 - 仅显示命令，不自动执行（安全原因）"""
    print("\n" + "="*40)
    print("📦 依赖安装指南")
    print("="*40)
    print("\n请手动执行以下命令安装依赖：\n")
    
    # 检测系统
    system = ""
    if shutil.which("apt-get"):
        system = "Ubuntu/Debian"
    elif shutil.which("brew"):
        system = "macOS"
    elif shutil.which("yum"):
        system = "CentOS/RHEL"
    
    print("="*40)
    print(f"检测到系统: {system or '未知'}")
    print("="*40)
    
    print("\n【系统级依赖】")
    if system == "Ubuntu/Debian":
        print("  sudo apt-get update")
        print("  sudo apt-get install -y ffmpeg")
    elif system == "macOS":
        print("  brew install ffmpeg")
    elif system == "CentOS/RHEL":
        print("  sudo yum install -y ffmpeg")
    else:
        print("  # 请根据您的系统安装 ffmpeg")
        print("  # Ubuntu: sudo apt install ffmpeg")
        print("  # macOS: brew install ffmpeg")
        print("  # Windows: 下载 ffmpeg.exe")
    
    print("\n【Python 依赖】")
    print("  pip3 install auto-editor")
    print("  pip3 install faster-whisper requests")
    
    print("\n【可选：GPU 加速】")
    print("  # CUDA 支持（需要 NVIDIA GPU）")
    print("  pip3 install faster-whisper[cuda]")
    
    print("\n【环境变量】")
    print("  # 方式一: 环境变量")
    print("  export MINIMAX_API_KEY='your-api-key'")
    print("")
    print("  # 方式二: 运行时传入")
    print("  python3 pipeline.py --all -i /path ... --api-key 'your-key'")
    
    print("\n获取 API Key: https://platform.minimaxi.com/")
    print("="*40)
    return False  # 返回 False 表示需要手动安装

ENABLE_NOTIFY = True  # 默认启用通知

def send(msg):
    """发送消息（仅在启用通知且设置了目标时发送）"""
    # 总是打印到控制台
    print(msg)
    
    # 仅当启用通知且有目标时才发送外部消息
    if not ENABLE_NOTIFY or not TARGET:
        return
    
    subprocess.run([
        "openclaw "send",
       ", "message", "--channel", "feishu",
        "--target", TARGET,
        "--message", msg
    ], capture_output=True)

def scan_directory(directory):
    videos = []
    if not os.path.exists(directory):
        return videos
    for f in os.listdir(directory):
        ext = os.path.splitext(f)[1].lower()
        if ext in VIDEO_EXTS:
            full_path = os.path.join(directory, f)
            videos.append({"name": f, "path": full_path, "size": os.path.getsize(full_path)})
    return sorted(videos, key=lambda x: x["name"])

def format_size(bytes_size):
    mb = bytes_size / 1024 / 1024
    return f"{mb:.1f}MB"

def run_step1_edit(input_dir, output_dir):
    """步骤1: 剪辑每个子视频"""
    send("\n" + "="*30)
    send("📹 步骤1: 视频剪辑（去除静音片段）")
    send("="*30)
    
    videos = scan_directory(input_dir)
    send(f"输入: {input_dir}")
    send(f"找到 {len(videos)} 个视频")
    
    # 执行剪辑 - 使用本地脚本
    script_dir = os.path.dirname(os.path.abspath(__file__))
    cmd = f'python3 "{script_dir}/video_clip.py" --input "{input_dir}" --output "{output_dir}" --target "" '
    send("▶️ 开始剪辑...")
    
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    print(result.stdout)
    print(result.stderr)
    
    # 列出剪辑后的文件
    edited = scan_directory(output_dir)
    send(f"✅ 剪辑完成！产出 {len(edited)} 个文件:")
    for v in edited:
        send(f"  📄 {v['name']} ({format_size(v['size'])})")
    
    send("\n💡 下一步：执行步骤2（拼接视频)")
    return output_dir

def run_step2_concat(input_dir, output_file):
    """步骤2: 拼接视频"""
    send("\n" + "="*30)
    send("🔗 步骤2: 拼接视频")
    send("="*30)
    
    videos = scan_directory(input_dir)
    send(f"输入: {input_dir}")
    send(f"找到 {len(videos)} 个视频待拼接")
    
    # 创建文件列表
    list_file = "/tmp/concat_list.txt"
    with open(list_file, "w") as f:
        for v in videos:
            f.write(f"file '{v['path']}'\n")
    
    send("▶️ 开始拼接...")
    cmd = f'ffmpeg -f concat -safe 0 -i "{list_file}" -c copy -y "{output_file}"'
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    
    os.remove(list_file)
    
    if os.path.exists(output_file):
        size = os.path.getsize(output_file)
        send(f"✅ 拼接完成！")
        send(f"  📄 {os.path.basename(output_file)} ({format_size(size)})")
    else:
        send("❌ 拼接失败")
    
    send("\n💡 下一步：执行步骤3（转写字幕）")
    return output_file

def run_step3_transcribe(video_path, output_dir):
    """步骤3: 转写（生成字幕）"""
    send("\n" + "="*30)
    send("📝 步骤3: 视频转写（生成字幕）")
    send("="*30)
    
    video_name = os.path.basename(video_path)
    send(f"输入: {video_name}")
    
    # 执行转写
    # 执行转写 - 使用本地脚本
    script_dir = os.path.dirname(os.path.abspath(__file__))
    cmd = f'python3 "{script_dir}/video_to_text.py" --input "{os.path.dirname(video_path)}" --output "{output_dir}" --model small --target "" '
    send("▶️ 开始转写...")
    
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    print(result.stdout)
    
    # 列出字幕文件
    subtitles = glob.glob(os.path.join(output_dir, "*.srt"))
    if subtitles:
        send(f"✅ 转写完成！产出 {len(subtitles)} 个字幕文件")
        for s in subtitles:
            send(f"  📄 {os.path.basename(s)}")
    
    send("\n💡 下一步：执行步骤4（烧录字幕）")
    return output_dir

def run_step4_burn(video_dir, subtitle_dir, output_dir):
    """步骤4: 烧录字幕"""
    send("\n" + "="*30)
    send("🔥 步骤4: 烧录字幕进视频")
    send("="*30)
    
    videos = scan_directory(video_dir)
    subtitles = glob.glob(os.path.join(subtitle_dir, "*.srt"))
    
    send(f"视频: {len(videos)} 个")
    send(f"字幕: {len(subtitles)} 个")
    
    send("▶️ 开始烧录...")
    
    # 对拼接后的视频烧录字幕
    for v in videos:
        if "合并" in v["name"] or "已烧录" in v["name"]:
            continue
        
        # 找对应字幕
        base_name = os.path.splitext(v["name"])[0]
        for suffix in ["_已剪辑", "_已剪除冗余片段"]:
            if base_name.endswith(suffix):
                base_name = base_name[:-len(suffix)]
        
        subtitle_file = None
        for s in subtitles:
            if base_name in s or os.path.splitext(os.path.basename(s))[0] in v["name"]:
                subtitle_file = s
                break
        
        if not subtitle_file:
            send(f"⚠️ 找不到字幕: {v['name']}")
            continue
        
        output_name = v["name"].replace(".mp4", "_已烧录字幕.mp4")
        output_path = os.path.join(output_dir, output_name)
        
        cmd = f'ffmpeg -i "{v["path"]}" -vf "subtitles=\'{subtitle_file}\'" -c:a copy -y "{output_path}"'
        result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
        
        if os.path.exists(output_path):
            send(f"✅ 完成: {output_name}")
    
    # 也对合并后的视频烧录
    merged_videos = [v for v in videos if "合并" in v["name"]]
    for v in merged_videos:
        # 找第一个可用的字幕
        if subtitles:
            subtitle_file = subtitles[0]
            output_name = v["name"].replace(".mp4", "_已烧录字幕.mp4")
            output_path = os.path.join(output_dir, output_name)
            
            cmd = f'ffmpeg -i "{v["path"]}" -vf "subtitles=\'{subtitle_file}\'" -c:a copy -y "{output_path}"'
            result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
            
            if os.path.exists(output_path):
                send(f"✅ 完成: {output_name}")
    
    send("\n" + "="*30)
    send("🎉 全部流程完成！")
    send("="*30)
    send("💡 每一步的产出都已保留，可以查看")

def main():
    global TARGET
    
    parser = argparse.ArgumentParser(description="视频一站式工作流")
    parser.add_argument("--input", "-i", help="输入目录（原始视频）")
    parser.add_argument("--output", "-o", default=None, help="输出目录")
    parser.add_argument("--target", "-t", default=None, help="通知目标")
    parser.add_argument("--step", "-s", type=int, choices=[1,2,3,4], help="执行哪一步")
    parser.add_argument("--all", "-a", action="store_true", help="执行全量流程")
    parser.add_argument("--list", "-l", action="store_true", help="列出视频")
    parser.add_argument("--check-deps", action="store_true", help="检查依赖是否满足")
    parser.add_argument("--install-deps", action="store_true", help="显示依赖安装指南")
    parser.add_argument("--api-key", "-k", default=None, help="MiniMax API Key")
    parser.add_argument("--notify", "-n", default="true", help="是否发送通知 (true/false, 默认true)")
    
    args = parser.parse_args()
    
    # 解析通知设置
    global ENABLE_NOTIFY
    ENABLE_NOTIFY = args.notify.lower() == "true" and args.target
    
    # 检查依赖或安装依赖
    if args.check_deps:
        check_deps()
        return
    
    if args.install_deps:
        install_deps()
        return
    
    if not args.input:
        parser.print_help()
        print("\n示例:")
        print("  python3 pipeline.py --check-deps          # 检查依赖")
        print("  python3 pipeline.py --install-deps        # 查看安装指南")
        print("  python3 pipeline.py --list -i /path/to/videos")
        print("  python3 pipeline.py --all -i /path/in -o /path/out")
        return
    
    TARGET = args.target or os.environ.get("OPENCLAW_TARGET")
    
    # 设置通知开关
    global ENABLE_NOTIFY
    notify_setting = args.notify.lower()
    if notify_setting == "false":
        ENABLE_NOTIFY = False
        print("ℹ️ 通知已禁用 (--notify false)")
    elif TARGET:
        ENABLE_NOTIFY = True
        print(f"ℹ️ 通知目标: {TARGET}")
    else:
        print("ℹ️ 未设置 --target，通知将仅显示在控制台")
        ENABLE_NOTIFY = False
    
    # 设置 API Key
    if args.[REDACTED_SECRET]"MINIMAX_API_KEY"] = args.api_key
    elif not os.environ.get("MINIMAX_API_KEY"):
        print("⚠️ 警告: 未设置 MINIMAX_API_KEY，转写功能可能无法使用")
        print("   设置方式: export MINIMAX_API_KEY='your-key' 或使用 --api-key 参数")
    
    if args.list:
        videos = scan_directory(args.input)
        send(f"📁 {args.input}")
        send(f"找到 {len(videos)} 个视频:")
        for v in videos:
            send(f"  📄 {v['name']} ({format_size(v['size'])})")
        return
    
    if not args.output:
        args.output = args.input
    
    base_dir = args.output
    
    # 目录结构
    edited_dir = os.path.join(base_dir, "1_已剪辑")      # 步骤1产出
    concat_dir = os.path.join(base_dir, "2_已拼接")      # 步骤2产出
    subtitle_dir = os.path.join(base_dir, "3_文字稿")     # 步骤3产出
    final_dir = os.path.join(base_dir, "4_已烧录")        # 步骤4产出
    
    # 创建目录
    for d in [edited_dir, concat_dir, subtitle_dir, final_dir]:
        os.makedirs(d, exist_ok=True)
    
    send("="*40)
    send("🎬 视频一站式工作流")
    send("="*40)
    send(f"输入: {args.input}")
    send(f"输出: {base_dir}")
    send("")
    send("目录结构:")
    send(f"  1_已剪辑  → {os.path.basename(edited_dir)}")
    send(f"  2_已拼接  → {os.path.basename(concat_dir)}")
    send(f"  3_文字稿 → {os.path.basename(subtitle_dir)}")
    send(f"  4_已烧录 → {os.path.basename(final_dir)}")
    send("="*40)
    
    # 执行步骤
    if args.all or args.step == 1:
        edited_dir = run_step1_edit(args.input, edited_dir)
    
    if args.all or args.step == 2:
        concat_file = os.path.join(concat_dir, "合并视频.mp4")
        run_step2_concat(edited_dir, concat_file)
    
    if args.all or args.step == 3:
        concat_file = os.path.join(concat_dir, "合并视频.mp4")
        if os.path.exists(concat_file):
            run_step3_transcribe(concat_file, subtitle_dir)
        else:
            send("⚠️ 找不到拼接后的视频，跳过转写")
    
    if args.all or args.step == 4:
        run_step4_burn(concat_dir, subtitle_dir, final_dir)

if __name__ == "__main__":
    main()


path: scripts/video_clip.py
content: #!/usr/bin/env python3
"""
视频自动剪辑脚本
使用 auto-editor 剪辑视频，支持进度通知
"""

import os
import sys
import subprocess
import argparse
from pathlib import Path

# 配置：从环境变量或参数获取
CHANNEL = os.environ.get("OPENCLAW_CHANNEL", "feishu")
TARGET = os.environ.get("OPENCLAW_TARGET", "")

def check_dependencies():
    """检查并安装依赖"""
    print("🔍 检查依赖...")
    
    # 检查 auto-editor
    result = subprocess.run(["which", "auto-editor"], capture_output=True)
    if result.returncode != 0:
        print("📦 正在安装 auto-editor...")
        try:
            subprocess.run([sys.executable, "-m", "pip", "install", "auto-editor", "--break-system-packages"], 
                         capture_output=True)
            print("  ✅ auto-editor 安装完成")
        except Exception as e:
            print(f"  ❌ auto-editor 安装失败: {e}")
            return False
    else:
        print("  ✅ auto-editor 已安装")
    return True

# 启动前检查依赖
if not check_dependencies():
    print("❌ 依赖检查失败，请手动安装 auto-editor")
    sys.exit(1)


def send_message(message):
    """发送消息到群聊"""
    if not TARGET:
        print(f"📢 (通知未发送，未配置TARGET): {message}")
        return

    cmd = [
        "openclaw",
        "message",
        "send",
        "--channel",
        CHANNEL,
        "--target",
        TARGET,
        "--message",
        message,
    ]
    result = subprocess.run(cmd, capture_output=True)
    if result.returncode != 0:
        print(f"   ⚠️ 发送消息失败: {result.stderr.decode()[:100]}")


def get_video_files(directory, exclude_pattern="已剪辑"):
    """获取目录下所有未剪辑的视频文件（递归扫描子目录）"""
    video_extensions = [".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv"]
    files = []

    if not os.path.exists(directory):
        print(f"❌ 目录不存在: {directory}")
        return files

    # 遍历所有子目录
    for root, dirs, filenames in os.walk(directory):
        # 排除已剪辑的目录
        dirs[:] = [d for d in dirs if exclude_pattern not in d]

        for f in filenames:
            # 排除已剪辑的文件
            if exclude_pattern in f:
                continue

            ext = os.path.splitext(f)[1].lower()
            if ext in video_extensions:
                full_path = os.path.join(root, f)
                # 计算相对路径（相对于输入目录根）
                rel_path = os.path.relpath(full_path, directory)
                files.append(
                    {
                        "full_path": full_path,
                        "rel_path": rel_path,  # 如 "test/1.mp4"
                        "filename": f,
                        "subdir": os.path.dirname(rel_path),  # 如 "test"
                    }
                )

    return sorted(files, key=lambda x: x["rel_path"])


def get_duration(path):
    """获取视频时长（秒）"""
    try:
        cmd = [
            "ffprobe",
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=noprint_wrappers=1:nokey=1",
            path,
        ]
        result = subprocess.run(cmd, capture_output=True, text=True)
        return float(result.stdout.strip())
    except:
        return 0


def get_size(path):
    """获取文件大小（MB）"""
    return os.path.getsize(path) / 1024 / 1024


def ensure_dir(path):
    """确保目录存在"""
    os.makedirs(path, exist_ok=True)


def is_valid_video(path):
    """检查视频文件是否有效"""
    try:
        cmd = [
            "ffprobe",
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=noprint_wrappers=1:nokey=1",
            path,
        ]
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
        return result.returncode == 0 and float(result.stdout.strip()) > 0
    except:
        return False


def clip_video(input_info, output_root, margin, use_cache, progress_callback):
    """剪辑单个视频"""
    input_path = input_info["full_path"]
    rel_path = input_info["rel_path"]
    filename = input_info["filename"]
    subdir = input_info["subdir"]

    name_without_ext = os.path.splitext(filename)[0]
    ext = os.path.splitext(filename)[1]

    # 输出文件名：原名 + _已剪除冗余片段
    output_name = f"{name_without_ext}_已剪除冗余片段{ext}"

    # 确定缓存目录和最终输出目录
    if subdir and subdir != ".":
        final_subdir = os.path.join(output_root, subdir)
    else:
        final_subdir = output_root

    if use_cache:
        cache_root = "/tmp/video_clip_cache"
        if subdir and subdir != ".":
            cache_subdir = os.path.join(cache_root, subdir)
        else:
            cache_subdir = cache_root
        ensure_dir(cache_subdir)
        cache_path = os.path.join(cache_subdir, output_name)
    else:
        ensure_dir(final_subdir)
        cache_path = os.path.join(final_subdir, output_name)

    final_path = os.path.join(final_subdir, output_name)

    # 原文件标记：原名 + _已剪辑
    renamed_name = f"{name_without_ext}_已剪辑{ext}"
    renamed_path = os.path.join(os.path.dirname(input_path), renamed_name)

    print(f"\n📹 处理: {rel_path}")

    # 获取原始信息
    orig_duration = get_duration(input_path)
    orig_size = get_size(input_path)

    # 发送开始消息
    progress_callback("start", filename=filename)

    # 执行剪辑 - 使用 auto-editor，输出到缓存目录
    cmd = ["auto-editor", input_path, "--margin", f"{margin}sec", "-o", cache_path]

    print(f"   🔧 执行: {' '.join(cmd)}")
    result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode != 0:
        print(f"   ❌ 剪辑失败: {result.stderr[-500:]}")
        progress_callback("error", filename=filename, error=result.stderr[-200:])
        return None

    # 验证输出文件是否有效
    print(f"   ✅ 验证输出文件...")
    if not is_valid_video(cache_path):
        print(f"   ❌ 输出文件无效，删除")
        if os.path.exists(cache_path):
            os.remove(cache_path)
        progress_callback("error", filename=filename, error="输出文件无效")
        return None

    # 获取剪辑后信息
    new_duration = get_duration(cache_path)
    new_size = get_size(cache_path)

    # 移动到最终目录（如果使用了缓存）
    if use_cache:
        ensure_dir(final_subdir)
        print(f"   📦 从缓存移动到最终目录...")
        import shutil

        shutil.move(cache_path, final_path)
        print(f"   ✅ 已移动到: {final_path}")

    # 只有输出文件有效，才标记原文件
    if os.path.exists(input_path):
        os.rename(input_path, renamed_path)
        print(f"   ✅ 已标记原文件: {renamed_name}")

    progress_callback(
        "success",
        filename=filename,
        orig_duration=orig_duration,
        new_duration=new_duration,
        orig_size=orig_size,
        new_size=new_size,
    )

    return {
        "rel_path": rel_path,
        "filename": filename,
        "output_name": output_name,
        "output_subdir": final_subdir,
        "orig_duration": orig_duration,
        "new_duration": new_duration,
        "orig_size": orig_size,
        "new_size": new_size,
        "renamed": renamed_name,
    }


def main():
    parser = argparse.ArgumentParser(description="视频自动剪辑脚本")
    parser.add_argument("--input", "-i", default=None, help="输入目录（必填）")
    parser.add_argument("--output", "-o", default=None, help="输出目录（必填）")
    parser.add_argument(
        "--margin", "-m", type=float, default=0.5, help="缓冲时长(秒)，默认0.5"
    )
    parser.add_argument(
        "--threshold", "-t", default="-30dB", help="静音阈值，默认-30dB"
    )
    parser.add_argument(
        "--notify", "-n", action="store_true", default=True, help="启用进度通知"
    )
    parser.add_argument("--target", "-T", default=None, help="通知目标 ID（群聊/用户）")
    parser.add_argument(
        "--cache",
        "-c",
        action="store_true",
        default=True,
        help="先输出到缓存目录再移动到目标（解决外挂硬盘写入问题），默认开启",
    )

    args = parser.parse_args()

    # 如果命令行传入了 target，覆盖环境变量
    global TARGET
    if args.target:
        TARGET = args.target

    # 检查必要参数
    if not args.input or not args.output:
        print("❌ 请指定输入和输出目录")
        print("用法: video_clip.py --input /path/to/input --output /path/to/output")
        sys.exit(1)

    # 确保输出根目录存在
    ensure_dir(args.output)

    # 扫描待剪辑文件
    video_files = get_video_files(args.input)

    if not video_files:
        print("📁 没有找到待剪辑的视频文件")
        send_message("📁 没有找到待剪辑的视频文件")
        return

    total_files = len(video_files)

    # 发送开始任务消息
    if args.notify:
        send_message(
            f"🎬 开始剪辑任务！共 {total_files} 个视频\n输入: {args.input}\n输出: {args.output}"
        )

    # 按子目录分组显示
    subdirs = set(f["subdir"] for f in video_files if f["subdir"] != ".")
    if subdirs:
        print(f"🎬 找到 {total_files} 个视频待处理，分布在 {len(subdirs)} 个子目录:")
        for sd in sorted(subdirs):
            count = len([f for f in video_files if f["subdir"] == sd])
            print(f"   - {sd}/: {count} 个视频")
    else:
        print(f"🎬 找到 {total_files} 个视频待处理")

    print(f"\n📂 输入: {args.input}")
    print(f"📂 输出: {args.output}")
    print(f"⏱️ 缓冲: {args.margin}秒")
    print("-" * 40)

    # 进度回调函数
    last_progress_percent = 0

    def progress_callback(event_type, **kwargs):
        nonlocal last_progress_percent

        if not args.notify:
            return

        current = kwargs.get("current", 0)

        if event_type == "start":
            msg = f"▶️ 开始处理: {kwargs.get('filename')}"
            send_message(msg)

        elif event_type == "progress":
            # 每 10% 发一次
            if current > 0:
                percent = int(current / total_files * 100)
                if percent >= last_progress_percent + 10:
                    msg = f"⏳ 进度: {percent}% ({current}/{total_files})"
                    send_message(msg)
                    last_progress_percent = percent

        elif event_type == "error":
            msg = f"❌ 处理失败: {kwargs.get('filename')}\n错误: {kwargs.get('error', '未知错误')}"
            send_message(msg)

        elif event_type == "success":
            orig = kwargs.get("orig_duration", 0)
            new = kwargs.get("new_duration", 0)
            reduced = orig - new
            msg = f"✅ 完成: {kwargs.get('filename')}\n时长: {orig:.0f}s → {new:.0f}s (-{reduced:.0f}s)"
            send_message(msg)

    results = []

    for i, f in enumerate(video_files, 1):
        print(f"\n[{i}/{total_files}] 🎬 开始处理", flush=True)

        # 发送进度
        progress_callback("progress", current=i)

        result = clip_video(f, args.output, args.margin, args.cache, progress_callback)
        if result:
            results.append(result)
            print(f"✅ 第 {i}/{total_files} 个完成", flush=True)

    # 汇总
    print("\n" + "=" * 50)
    print("✅ 剪辑完成！")
    print("=" * 50)
    print(f"📊 共处理: {len(results)} 个视频\n")

    # 发送完成汇总
    if args.notify and results:
        total_orig_time = sum(r["orig_duration"] for r in results)
        total_new_time = sum(r["new_duration"] for r in results)
        total_orig_size = sum(r["orig_size"] for r in results)
        total_new_size = sum(r["new_size"] for r in results)

        msg = f"""🎉 剪辑完成！

📊 共处理: {len(results)} 个视频
⏱️ 总时长: {total_orig_time:.0f}s → {total_new_time:.0f}s (-{total_orig_time - total_new_time:.0f}s)
💾 总大小: {total_orig_size:.1f}MB → {total_new_size:.1f}MB (-{total_orig_size - total_new_size:.1f}MB)

📁 输出目录: {args.output}"""
        send_message(msg)

    total_orig_time = 0
    total_new_time = 0
    total_orig_size = 0
    total_new_size = 0

    # 按子目录分组显示
    by_subdir = {}
    for r in results:
        sd = r["output_subdir"]
        if sd not in by_subdir:
            by_subdir[sd] = []
        by_subdir[sd].append(r)

    for subdir, items in by_subdir.items():
        print(f"📁 {os.path.basename(subdir)}/ ({len(items)} 个视频)")

        for r in items:
            reduced_time = r["orig_duration"] - r["new_duration"]
            reduced_size = r["orig_size"] - r["new_size"]

            print(f"   📹 {r['filename']}")
            print(
                f"      时长: {r['orig_duration']:.1f}s → {r['new_duration']:.1f}s (-{reduced_time:.1f}s)"
            )
            print(
                f"      大小: {r['orig_size']:.1f}MB → {r['new_size']:.1f}MB (-{reduced_size:.1f}MB)"
            )
            print(f"      输出: {r['output_name']}")

            total_orig_time += r["orig_duration"]
            total_new_time += r["new_duration"]
            total_orig_size += r["orig_size"]
            total_new_size += r["new_size"]
        print()

    print("-" * 40)
    print(f"📈 总计:")
    print(
        f"   时长: {total_orig_time:.1f}s → {total_new_time:.1f}s (减少 {total_orig_time - total_new_time:.1f}s)"
    )
    print(
        f"   大小: {total_orig_size:.1f}MB → {total_new_size:.1f}MB (减少 {total_orig_size - total_new_size:.1f}MB)"
    )


if __name__ == "__main__":
    main()


path: scripts/video_to_text.py
content: #!/usr/bin/env python3
"""
视频转文字稿脚本
1. 用 Faster Whisper 识别语音生成 SRT
2. 用 LLM 词级别纠错
自动检查依赖，支持多种 LLM
"""

import os
import sys
import argparse
import subprocess
import requests

# 配置
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")

# 支持的 LLM 提供商
LLM_PROVIDERS = {
    "minimax": {
        "name": "MiniMax",
        "url": "https://api.minimaxi.com/anthropic",
        "env_key": "MINIMAX_API_KEY",
        "model": "MiniMax-M2.5"
    },
    "openai": {
        "name": "OpenAI",
        "url": "https://api.openai.com/v1",
        "env_key": "OPENAI_API_KEY",
        "model": "gpt-3.5-turbo"
    },
    "anthropic": {
        "name": "Anthropic Claude",
        "url": "https://api.anthropic.com/v1",
        "env_key": "ANTHROPIC_API_KEY",
        "model": "claude-3-haiku-20240307"
    }
}

TARGET = None

def check_dependencies():
    """检查并安装依赖"""
    print("🔍 检查依赖...")
    
    deps = ["faster-whisper", "requests"]
    for dep in deps:
        try:
            __import__(dep.replace("-", "_"))
            print(f"  ✅ {dep} 已安装")
        except ImportError:
            print(f"  📦 正在安装 {dep}...")
            try:
                subprocess.run([sys.executable, "-m", "pip", "install", dep, "--break-system-packages"], 
                           capture_output=True)
                print(f"  ✅ {dep} 安装完成")
            except Exception as e:
                print(f"  ❌ {dep} 安装失败: {e}")
                return False
    return True

def get_llm_config(provider):
    """获取 LLM 配置"""
    if provider not in LLM_PROVIDERS:
        print(f"❌ 不支持的 LLM 提供商: {provider}")
        print(f"支持的提供商: {', '.join(LLM_PROVIDERS.keys())}")
        return None
    
    config = LLM_PROVIDERS[provider]
    [REDACTED_SECRET]"env_key"])
    
    if not [REDACTED_SECRET]"⚠️ 未设置 {config['env_key']} 环境变量")
        print(f"💡 请设置: export {config['env_key']}='你的APIKey'")
        return None
    
    return config

def send_message(msg):
    if not TARGET:
        print(msg)
        return
    subprocess.run([
        "openclaw", "message", "send",
        "--channel", "feishu",
        "--target", TARGET,
        "--message", msg
    ], capture_output=True)

def get_video_files(directory, exclude_pattern="已转写"):
    video_extensions = ['.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv']
    files = []
    if not os.path.exists(directory):
        return files
    for root, dirs, filenames in os.walk(directory):
        dirs[:] = [d for d in dirs if exclude_pattern not in d]
        for f in filenames:
            if exclude_pattern in f:
                continue
            ext = os.path.splitext(f)[1].lower()
            if ext in video_extensions:
                full_path = os.path.join(root, f)
                rel_path = os.path.relpath(full_path, directory)
                files.append({
                    "full_path": full_path,
                    "rel_path": rel_path,
                    "filename": f,
                    "subdir": os.path.dirname(rel_path)
                })
    return sorted(files, key=lambda x: x["rel_path"])

def ensure_dir(path):
    os.makedirs(path, exist_ok=True)

def format_time(seconds):
    h = int(seconds // 3600)
    m = int((seconds % 3600) // 60)
    s = int(seconds % 60)
    ms = int((seconds % 1) * 1000)
    return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"

def transcribe(video_path, output_srt, model):
    from faster_whisper import WhisperModel
    print(f"🔧 加载模型: {model}")
    whisper_model = WhisperModel(model, device="cpu", compute_type="int8")
    print(f"🎤 转写中: {video_path}")
    segments, info = whisper_model.transcribe(video_path)
    print(f"📝 语言: {info.language}")
    
    with open(output_srt, "w", encoding="utf-8") as f:
        for i, seg in enumerate(segments, 1):
            start = format_time(seg.start)
            end = format_time(seg.end)
            text = seg.text.strip()
            f.write(f"{i}\n{start} --> {end}\n{text}\n\n")
    
    # 纯文本
    segments, _ = whisper_model.transcribe(video_path)
    text = " ".join([s.text.strip() for s in segments])
    return text

def correct_text(text, api_key, provider="minimax"):
    if not [REDACTED_SECRET] text
    print("🤖 LLM 纠错...")
    
    config = LLM_PROVIDERS.get(provider)
    if not config:
        return text
    
    url = f"{config['url']}/messages"
    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
    
    prompt = "请只修正以下演讲稿中的错别字和明显的识别错误（词级别），不修改句子结构，只返回修正后的文字："
    
    if provider == "anthropic":
        headers["anthropic-version"] = "2023-06-01"
        payload = {"model": config["model"], "max_tokens": 4096, 
                  "messages": [{"role": "user", "content": prompt + text}]}
    else:
        payload = {"model": config["model"], "max_tokens": 4096, 
                  "messages": [{"role": "user", "content": prompt + text}]}
    
    try:
        resp = requests.post(url, headers=headers, json=payload, timeout=120)
        result = resp.json()
        if "content" in result:
            for c in result["content"]:
                if c.get("type") == "text":
                    return c.get("text", text)
    except Exception as e:
        print(f"❌ API错误: {e}")
    return text

def correct_srt(srt_path, corrected_text):
    pass  # SRT 保持原始

def process_video(video_info, output_root, model, api_key, provider):
    input_path = video_info["full_path"]
    rel_path = video_info["rel_path"]
    filename = video_info["filename"]
    subdir = video_info["subdir"]
    
    name_without_ext = os.path.splitext(filename)[0]
    ext = os.path.splitext(filename)[1]
    
    if subdir and subdir != ".":
        output_subdir = os.path.join(output_root, subdir)
    else:
        output_subdir = output_root
    ensure_dir(output_subdir)
    
    output_name = f"{name_without_ext}.srt"
    output_path = os.path.join(output_subdir, output_name)
    temp_srt = output_path.replace(".srt", "_temp.srt")
    
    print(f"\n📹 处理: {rel_path}")
    send_message(f"▶️ 开始转写: {filename}")
    
    # 转写
    text = transcribe(input_path, temp_srt, model)
    if not text:
        send_message(f"❌ 转写失败: {filename}")
        return None
    
    # 纠错
    corrected = correct_text(text, api_key, provider)
    
    # 保存校正后SRT
    corrected_srt = correct_srt(temp_srt, corrected)
    with open(output_path, "w", encoding="utf-8") as f:
        # 直接复制原始 SRT（有时间线）
        if os.path.exists(temp_srt):
            with open(temp_srt, "r") as f_orig:
                f.write(f_orig.read())
    
    # 保存纯文本
    txt_path = output_path.replace(".srt", ".txt")
    with open(txt_path, "w", encoding="utf-8") as f:
        f.write(corrected)
    
    if os.path.exists(temp_srt):
        os.remove(temp_srt)
    
    send_message(f"✅ 完成: {filename}\n字数: {len(text)} → {len(corrected)}")
    return True

def main():
    global TARGET
    
    # 检查依赖
    if not check_dependencies():
        print("❌ 依赖检查失败，请手动安装")
        sys.exit(1)
    
    parser = argparse.ArgumentParser(description="视频转文字稿")
    parser.add_argument("--input", "-i", required=True)
    parser.add_argument("--output", "-o", default=None)
    parser.add_argument("--model", "-m", default="small", help="Whisper 模型: tiny, small, base, medium")
    parser.add_argument("--target", "-T", default=None)
    parser.add_argument("--api-key", "-k", default=None, help="LLM API Key")
    parser.add_argument("--provider", "-p", default="minimax", 
                       choices=["minimax", "openai", "anthropic"],
                       help="LLM 提供商")
    args = parser.parse_args()
    
    if args.output is None:
        args.output = args.input
    
    TARGET = args.target
    
    # 获取 API Key
    config = LLM_PROVIDERS[args.provider]
    [REDACTED_SECRET] or os.environ.get(config["env_key"])
    
    if not [REDACTED_SECRET]"\n⚠️ 需要设置 {args.provider} 的 API Key")
        print(f"💡 方式1: --api-key '你的Key'")
        print(f"💡 方式2: export {config['env_key']}='你的Key'")
        print(f"\n支持的 LLM 提供商:")
        for k, v in LLM_PROVIDERS.items():
            print(f"  • {k}: {v['name']} (env: {v['env_key']})")
        sys.exit(1)
    
    video_files = get_video_files(args.input)
    if not video_files:
        print("没有找到视频")
        return
    
    print(f"🎬 找到 {len(video_files)} 个视频")
    send_message(f"🎬 开始转写！共 {len(video_files)} 个视频\nLLM: {config['name']}")
    
    for i, f in enumerate(video_files, 1):
        process_video(f, args.output, args.model, api_key, args.provider)
    
    send_message(f"🎉 全部完成！共处理 {len(video_files)} 个视频")

if __name__ == "__main__":
    TARGET = None
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)