GPT Proto
Home/Skills/video-wrapper

video-wrapper

Add variety show effects (such as styled text, info cards, character lower-thirds, and chapter titles) to interview videos. It supports 4 visual themes, first analyzing the subtitles to generate suggestions for user approval, then rendering the video.

Download for Windows

video_processor.py

#!/usr/bin/env python3
"""
Interview video processor - Main script
Adds fancy text and term definition cards to interview videos

Supports two rendering backends:
- browser: HTML/CSS/Anime.js via Playwright (default, better visual quality)
- pil: Python PIL (fallback, no additional dependencies)
"""
import json
import sys
import os
import argparse
import shutil
import tempfile

# Add src directory to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from moviepy import VideoFileClip, CompositeVideoClip


def check_browser_renderer_available():
    """Check if browser renderer (Playwright) is available"""
    try:
        from browser_renderer import check_playwright_installed
        return check_playwright_installed()
    except ImportError:
        return False


def process_video(video_path, subtitle_path, config_path, output_path, renderer='auto'):
    """
    主处理函数

    参数:
        video_path: 输入视频路径
        subtitle_path: 字幕文件路径(目前未使用,保留用于将来扩展)
        config_path: 配置文件路径
        output_path: 输出视频路径
        renderer: 渲染器类型 ('browser', 'pil', 'auto')
    """
    print(f"🎬 正在处理视频: {video_path}")

    # 验证输入文件存在
    if not os.path.exists(video_path):
        print(f"❌ 错误: 视频文件不存在: {video_path}")
        sys.exit(1)

    if not os.path.exists(config_path):
        print(f"❌ 错误: 配置文件不存在: {config_path}")
        sys.exit(1)

    # 确定渲染器
    if renderer == 'auto':
        if check_browser_renderer_available():
            renderer = 'browser'
            print("🌐 使用浏览器渲染器 (HTML/CSS/Anime.js)")
        else:
            renderer = 'pil'
            print("🎨 使用 PIL 渲染器 (Playwright 不可用)")
    elif renderer == 'browser':
        if not check_browser_renderer_available():
            print("❌ 错误: 浏览器渲染器不可用")
            print("请运行以下命令安装:")
            print("  pip install playwright")
            print("  playwright install chromium")
            sys.exit(1)
        print("🌐 使用浏览器渲染器 (HTML/CSS/Anime.js)")
    else:
        print("🎨 使用 PIL 渲染器")

    # 1. 加载配置
    print("📋 加载配置文件...")
    with open(config_path, 'r', encoding='utf-8') as f:
        config = json.load(f)

    # 2. 加载原始视频
    print("📹 加载原始视频...")
    video = VideoFileClip(video_path)
    print(f"   - 分辨率: {video.w}x{video.h}")
    print(f"   - 帧率: {video.fps} fps")
    print(f"   - 时长: {video.duration:.2f} 秒")

    # Track temp directories for cleanup
    temp_dirs = []

    try:
        if renderer == 'browser':
            text_clips, card_clips = _generate_clips_browser(
                config, video.w, video.h, video.fps, temp_dirs
            )
        else:
            text_clips, card_clips = _generate_clips_pil(
                config, video.w, video.h, video.fps
            )

        # 5. 合成所有图层
        print("🎨 合成视频图层...")
        all_clips = [video] + text_clips + card_clips
        final_video = CompositeVideoClip(all_clips, size=(video.w, video.h))

        # 6. 导出最终视频
        print(f"💾 导出最终视频到: {output_path}")
        print("   (这可能需要几分钟,请耐心等待...)")
        final_video.write_videofile(
            output_path,
            codec='libx264',
            audio_codec='aac',
            fps=video.fps,
            preset='medium',
            threads=4,
            logger='bar'  # 显示进度条
        )

        # 清理
        video.close()
        final_video.close()

    finally:
        # Clean up temp directories
        for temp_dir in temp_dirs:
            if os.path.exists(temp_dir):
                shutil.rmtree(temp_dir, ignore_errors=True)

    print("✅ 处理完成!")
    print(f"📁 输出文件: {output_path}")


def _generate_clips_browser(config, width, height, fps, temp_dirs):
    """Generate clips using browser renderer"""
    from browser_renderer import BrowserRenderer
    from moviepy import ImageSequenceClip

    all_effect_clips = []

    # Get theme from config (default: notion)
    theme = config.get('theme', 'notion')
    print(f"🎨 主题: {theme}")

    with BrowserRenderer(width=width, height=height, fps=fps) as renderer:

        # 1. 生成人物条片段
        lower_thirds = config.get('lowerThirds', [])
        if lower_thirds:
            print(f"👤 生成人物条 ({len(lower_thirds)} 个)...")
            for i, lt in enumerate(lower_thirds):
                print(f"   - 人物条: {lt['name']}")
                temp_dir = tempfile.mkdtemp(prefix=f'lower_third_{i}_')
                temp_dirs.append(temp_dir)

                lt_config = {
                    'name': lt['name'],
                    'role': lt.get('role', ''),
                    'company': lt.get('company', ''),
                    'theme': theme,
                    'durationMs': lt.get('durationMs', 5000)
                }
                frame_paths = renderer.render_lower_third_frames(lt_config, temp_dir)
                clip = ImageSequenceClip(frame_paths, fps=fps)
                clip = clip.with_start(lt['startMs'] / 1000.0)
                all_effect_clips.append(clip)

        # 2. 生成章节标题片段
        chapters = config.get('chapterTitles', [])
        if chapters:
            print(f"📑 生成章节标题 ({len(chapters)} 个)...")
            for i, ch in enumerate(chapters):
                print(f"   - 章节: {ch['title']}")
                temp_dir = tempfile.mkdtemp(prefix=f'chapter_{i}_')
                temp_dirs.append(temp_dir)

                ch_config = {
                    'number': ch.get('number', ''),
                    'title': ch['title'],
                    'subtitle': ch.get('subtitle', ''),
                    'theme': theme,
                    'durationMs': ch.get('durationMs', 4000)
                }
                frame_paths = renderer.render_chapter_title_frames(ch_config, temp_dir)
                clip = ImageSequenceClip(frame_paths, fps=fps)
                clip = clip.with_start(ch['startMs'] / 1000.0)
                all_effect_clips.append(clip)

        # 3. 生成花字片段
        key_phrases = config.get('keyPhrases', [])
        if key_phrases:
            print(f"✨ 生成花字动画 ({len(key_phrases)} 个)...")
            for i, phrase in enumerate(key_phrases):
                print(f"   - 花字 {i+1}: {phrase['text']}")
                temp_dir = tempfile.mkdtemp(prefix=f'fancy_text_{i}_')
                temp_dirs.append(temp_dir)

                # Calculate position: above subtitles area, alternating left/right
                # Subtitles typically at bottom 15-20% of screen, so place fancy text at top 15-25%
                x_offset = (i % 2) * 300  # Slight horizontal offset for variety
                position = phrase.get('position', {
                    'x': width // 2 - 150 + x_offset,
                    'y': 120 + (i % 3) * 40  # Top area: 120-200px from top
                })

                frame_config = {
                    'text': phrase['text'],
                    'style': phrase.get('style', 'emphasis'),
                    'theme': theme,
                    'position': position,
                    'startMs': phrase['startMs'],
                    'endMs': phrase['endMs']
                }
                frame_paths = renderer.render_fancy_text_frames(frame_config, temp_dir)
                clip = ImageSequenceClip(frame_paths, fps=fps)
                clip = clip.with_start(phrase['startMs'] / 1000.0)
                all_effect_clips.append(clip)

        # 4. 生成名词卡片片段
        term_defs = config.get('termDefinitions', [])
        if term_defs:
            print(f"📋 生成名词卡片 ({len(term_defs)} 个)...")
            for i, term in enumerate(term_defs):
                print(f"   - 卡片: {term['chinese']}")
                temp_dir = tempfile.mkdtemp(prefix=f'term_card_{i}_')
                temp_dirs.append(temp_dir)

                card_config = {
                    'chinese': term['chinese'],
                    'english': term['english'],
                    'description': term['description'],
                    'theme': theme,
                    'displayDurationSeconds': term.get('displayDurationSeconds', 6)
                }
                frame_paths = renderer.render_term_card_frames(card_config, temp_dir)
                clip = ImageSequenceClip(frame_paths, fps=fps)
                clip = clip.with_start(term['firstAppearanceMs'] / 1000.0)
                all_effect_clips.append(clip)

        # 5. 生成金句卡片片段
        quotes = config.get('quotes', [])
        if quotes:
            print(f"💬 生成金句卡片 ({len(quotes)} 个)...")
            for i, quote in enumerate(quotes):
                print(f"   - 金句: {quote['text'][:20]}...")
                temp_dir = tempfile.mkdtemp(prefix=f'quote_{i}_')
                temp_dirs.append(temp_dir)

                quote_config = {
                    'text': quote['text'],
                    'author': quote.get('author', ''),
                    'theme': theme,
                    'position': quote.get('position', {'x': width // 2, 'y': height // 2}),
                    'durationMs': quote.get('durationMs', 5000)
                }
                frame_paths = renderer.render_quote_callout_frames(quote_config, temp_dir)
                clip = ImageSequenceClip(frame_paths, fps=fps)
                clip = clip.with_start(quote['startMs'] / 1000.0)
                all_effect_clips.append(clip)

        # 6. 生成数据动画片段
        stats = config.get('stats', [])
        if stats:
            print(f"📊 生成数据动画 ({len(stats)} 个)...")
            for i, stat in enumerate(stats):
                print(f"   - 数据: {stat.get('prefix', '')}{stat['number']}{stat.get('unit', '')}")
                temp_dir = tempfile.mkdtemp(prefix=f'stats_{i}_')
                temp_dirs.append(temp_dir)

                stat_config = {
                    'prefix': stat.get('prefix', ''),
                    'number': stat['number'],
                    'unit': stat.get('unit', ''),
                    'label': stat.get('label', ''),
                    'theme': theme,
                    'position': stat.get('position', {'x': width // 2, 'y': height // 2}),
                    'durationMs': stat.get('durationMs', 4000)
                }
                frame_paths = renderer.render_animated_stats_frames(stat_config, temp_dir)
                clip = ImageSequenceClip(frame_paths, fps=fps)
                clip = clip.with_start(stat['startMs'] / 1000.0)
                all_effect_clips.append(clip)

        # 7. 生成要点列表片段
        bullet_points = config.get('bulletPoints', [])
        if bullet_points:
            print(f"📝 生成要点列表 ({len(bullet_points)} 个)...")
            for i, bp in enumerate(bullet_points):
                print(f"   - 要点: {bp.get('title', '要点列表')}")
                temp_dir = tempfile.mkdtemp(prefix=f'bullets_{i}_')
                temp_dirs.append(temp_dir)

                bp_config = {
                    'title': bp.get('title', ''),
                    'points': bp['points'],
                    'theme': theme,
                    'position': bp.get('position', {'x': 100, 'y': 300}),
                    'durationMs': bp.get('durationMs', 6000)
                }
                frame_paths = renderer.render_bullet_points_frames(bp_config, temp_dir)
                clip = ImageSequenceClip(frame_paths, fps=fps)
                clip = clip.with_start(bp['startMs'] / 1000.0)
                all_effect_clips.append(clip)

        # 8. 生成社交媒体条片段
        social_bars = config.get('socialBars', [])
        if social_bars:
            print(f"📱 生成社交媒体条 ({len(social_bars)} 个)...")
            for i, sb in enumerate(social_bars):
                print(f"   - 社交: {sb['handle']}")
                temp_dir = tempfile.mkdtemp(prefix=f'social_{i}_')
                temp_dirs.append(temp_dir)

                sb_config = {
                    'platform': sb.get('platform', 'twitter'),
                    'label': sb.get('label', '关注'),
                    'handle': sb['handle'],
                    'theme': theme,
                    'position': sb.get('position', {'x': width - 320, 'y': height - 130}),
                    'durationMs': sb.get('durationMs', 8000)  # Default 8 seconds for social bar
                }
                frame_paths = renderer.render_social_bar_frames(sb_config, temp_dir)
                clip = ImageSequenceClip(frame_paths, fps=fps)
                clip = clip.with_start(sb['startMs'] / 1000.0)
                all_effect_clips.append(clip)

    # Return all clips (split into two lists for compatibility)
    return all_effect_clips, []


def _generate_clips_pil(config, width, height, fps):
    """Generate clips using PIL renderer (legacy)"""
    from fancy_text import FancyTextGenerator
    from term_card import TermCardGenerator

    # 3. 生成花字片段
    print(f"✨ 生成花字动画 ({len(config.get('keyPhrases', []))} 个)...")
    text_gen = FancyTextGenerator(width=width, height=height, fps=fps)
    text_clips = []
    for i, phrase in enumerate(config.get('keyPhrases', [])):
        print(f"   - 花字 {i+1}: {phrase['text']}")
        clip = text_gen.generate_text_clip(phrase, i)
        text_clips.append(clip)

    # 4. 生成卡片片段
    print(f"📋 生成名词卡片 ({len(config.get('termDefinitions', []))} 个)...")
    card_gen = TermCardGenerator(width=width, height=height, fps=fps)
    card_clips = []
    for term in config.get('termDefinitions', []):
        print(f"   - 卡片: {term['chinese']}")
        clip = card_gen.generate_card_clip(term)
        card_clips.append(clip)

    return text_clips, card_clips


def main():
    """命令行入口"""
    parser = argparse.ArgumentParser(
        description='为访谈视频添加花字和名词解释卡片',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  python video_processor.py video.mp4 subs.srt config.json output.mp4
  python video_processor.py video.mp4 subs.srt config.json -r pil  # 使用 PIL 渲染器
  python video_processor.py video.mp4 subs.srt config.json -r browser  # 使用浏览器渲染器
        """
    )

    parser.add_argument('video', help='输入视频文件路径')
    parser.add_argument('subtitles', help='字幕文件路径 (.srt)')
    parser.add_argument('config', help='配置文件路径 (.json)')
    parser.add_argument('output', nargs='?', default=None, help='输出视频路径 (默认: output.mp4)')
    parser.add_argument(
        '-r', '--renderer',
        choices=['auto', 'browser', 'pil'],
        default='auto',
        help='渲染器类型: auto (自动选择), browser (HTML/CSS), pil (Python PIL)'
    )

    args = parser.parse_args()

    # 获取调用脚本时的工作目录(保存在环境变量中)
    original_cwd = os.environ.get('ORIGINAL_CWD', os.getcwd())

    # 默认输出路径为原始工作目录下的 output.mp4
    if args.output:
        output_path = args.output
        # 如果是相对路径,相对于原始工作目录
        if not os.path.isabs(output_path):
            output_path = os.path.join(original_cwd, output_path)
    else:
        output_path = os.path.join(original_cwd, 'output.mp4')

    try:
        process_video(
            args.video,
            args.subtitles,
            args.config,
            output_path,
            renderer=args.renderer
        )
    except Exception as e:
        print(f"❌ 处理失败: {str(e)}")
        import traceback
        traceback.print_exc()
        sys.exit(1)


if __name__ == '__main__':
    main()