
Video Wrapper
- 232 installs
- 329 repo stars
- Updated February 10, 2026
- op7418/video-wrapper-skills
Wrap ffmpeg, Remotion, or provider video APIs so agents generate, transcode, trim, and compose clips in automated content or marketing pipelines.
About
Provides a video-wrapper skill that standardizes how agents call ffmpeg, Remotion, or third-party video services to create, edit, transcode, and export clips inside automated content and agent media workflows.
- Unified video API wrapper
- Transcode and trim helpers
- Remotion/ffmpeg integration
- Agent-driven clip assembly
- Pipeline-friendly media outputs
Video Wrapper by the numbers
- 232 all-time installs (skills.sh)
- +5 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #581 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/op7418/video-wrapper-skills --skill video-wrapperAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 232 |
|---|---|
| repo stars | ★ 329 |
| Last updated | February 10, 2026 |
| Repository | op7418/video-wrapper-skills ↗ |
What it does
Wrap ffmpeg, Remotion, or provider video APIs so agents generate, transcode, trim, and compose clips in automated content or marketing pipelines.
Files
访谈视频处理器
基于 Python + Playwright + MoviePy 的视频特效处理工具,使用 HTML/CSS/Anime.js 渲染高质量视觉效果。
工作流程
第一步:分析字幕内容
当用户提供视频和字幕文件时,先分析字幕内容,生成特效建议:
1. 读取字幕文件 (.srt) 2. 分析内容,识别:
- 嘉宾信息(用于人物条)
- 话题切换点(用于章节标题)
- 关键词和术语(用于花字)
- 专业名词(用于名词卡片)
- 精彩观点(用于金句卡片)
- 数字数据(用于数据动画)
- 核心要点(用于要点列表)
3. 生成建议列表,展示给用户审核
第二步:用户审核
将建议以 Markdown 格式展示给用户:
## 视觉特效建议
**主题**: notion
### 1. 人物条 (Lower Third)
- **姓名**: Dario Amodei
- **职位**: CEO
- **公司**: Anthropic
- **出现时间**: 1000ms
### 2. 花字高亮 (Fancy Text)
1. **通用人工智能** (emphasis)
时间: 2630ms - 5500ms
原因: 核心概念首次提及
...用户可以:
- 确认全部建议
- 修改部分建议
- 删除不需要的组件
- 添加新的组件
第三步:生成配置并渲染
根据用户审批后的建议生成 config.json,然后渲染视频。
可用组件
| 组件 | 用途 | 配置字段 |
|---|---|---|
| 人物条 (lower_third) | 显示嘉宾信息 | name, role, company, startMs, durationMs |
| 章节标题 (chapter_title) | 话题切换标题 | number, title, subtitle, startMs, durationMs |
| 花字 (fancy_text) | 概括当前观点 | text, style, startMs, endMs, position |
| 名词卡片 (term_card) | 解释术语 | chinese, english, description, firstAppearanceMs |
| 金句卡片 (quote_callout) | 突出精彩观点 | text, author, startMs, durationMs, position |
| 数据动画 (animated_stats) | 展示数字 | prefix, number, unit, label, startMs |
| 要点列表 (bullet_points) | 总结核心要点 | title, points[], startMs, durationMs |
| 社交媒体条 (social_bar) | 关注引导 | platform, label, handle, startMs, durationMs |
花字使用规范
⚠️ 重要:花字必须遵循以下规范:
1. 必须是短语:用简短的句子概括说话人当时的观点
- ✅ 正确:「AI发展是平滑曲线」「智能增长类似摩尔定律」
- ❌ 错误:「人工智能」「摩尔定律」(这些是单词,应该用名词卡片)
2. 与名词卡片互补:
- 花字:概括观点(如「智能每年翻倍增长」)
- 名词卡片:解释术语(如「摩尔定律:集成电路晶体管数量每18-24个月翻一番」)
3. 位置在上方:默认显示在屏幕上方区域(字幕上方),避免遮挡人脸
社交媒体条使用规范
- 默认显示时长:8 秒(比其他组件更长,给用户足够时间记住)
- 通常在视频结尾出现
- 支持平台:twitter, weibo, youtube
主题系统
支持 4 种视觉主题:
| 主题 | 风格 | 适用场景 |
|---|---|---|
notion | 温暖知识风 | 教育、知识分享 |
cyberpunk | 霓虹未来感 | 科技、前沿话题 |
apple | 极简优雅 | 商务、专业访谈 |
aurora | 渐变流光 | 创意、艺术内容 |
配置文件格式
{
"theme": "notion",
"lowerThirds": [
{
"name": "Dario Amodei",
"role": "CEO",
"company": "Anthropic",
"startMs": 1000,
"durationMs": 5000
}
],
"chapterTitles": [
{
"number": "Part 1",
"title": "指数增长的本质",
"subtitle": "The Nature of Exponential Growth",
"startMs": 0,
"durationMs": 4000
}
],
"keyPhrases": [
{
"text": "通用人工智能",
"style": "emphasis",
"startMs": 2630,
"endMs": 5500
}
],
"termDefinitions": [
{
"chinese": "摩尔定律",
"english": "Moore's Law",
"description": "集成电路晶体管数量每18-24个月翻一番",
"firstAppearanceMs": 37550,
"displayDurationSeconds": 6
}
],
"quotes": [
{
"text": "AI 的发展是一个非常平滑的指数曲线",
"author": "— Dario Amodei",
"startMs": 30000,
"durationMs": 5000
}
],
"stats": [
{
"prefix": "增长率 ",
"number": 240,
"unit": "%",
"label": "计算能力年增长",
"startMs": 45000,
"durationMs": 4000
}
],
"bulletPoints": [
{
"title": "核心观点",
"points": [
"AI 发展是平滑的指数曲线",
"类似摩尔定律的智能增长",
"没有突然的奇点时刻"
],
"startMs": 50000,
"durationMs": 6000
}
]
}依赖安装
# 进入虚拟环境
cd ~/.claude/skills/video-wrapper
source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
# 安装 Playwright 浏览器
playwright install chromium命令行使用
# 使用浏览器渲染器(推荐)
python src/video_processor.py video.mp4 subs.srt config.json output.mp4
# 指定渲染器
python src/video_processor.py video.mp4 subs.srt config.json -r browser
python src/video_processor.py video.mp4 subs.srt config.json -r pil技术实现
- 视觉渲染: HTML + CSS + Anime.js (通过 Playwright 截图)
- 视频合成: MoviePy
- 动画引擎: Anime.js (Spring 物理动画)
- 备用渲染: Python PIL
文件结构
~/.claude/skills/video-wrapper/
├── src/
│ ├── video_processor.py # 主处理脚本
│ ├── browser_renderer.py # Playwright 渲染器
│ ├── content_analyzer.py # 内容分析器
│ ├── fancy_text.py # PIL 花字(备用)
│ └── term_card.py # PIL 卡片(备用)
├── templates/
│ ├── fancy-text.html # 花字模板
│ ├── term-card.html # 名词卡片模板
│ ├── lower-third.html # 人物条模板
│ ├── chapter-title.html # 章节标题模板
│ ├── quote-callout.html # 金句卡片模板
│ ├── animated-stats.html # 数据动画模板
│ └── bullet-points.html # 要点列表模板
├── static/
│ ├── css/
│ │ ├── effects.css # 基础效果
│ │ ├── theme-notion.css # Notion 主题
│ │ ├── theme-cyberpunk.css
│ │ ├── theme-apple.css
│ │ └── theme-aurora.css
│ └── js/
│ └── anime.min.js # Anime.js
└── requirements.txt注意事项
- 视频处理需要较长时间,请耐心等待
- 确保有足够的磁盘空间存储输出视频
- Playwright 渲染效果更好,但需要安装 Chromium
- 如果 Playwright 不可用,会自动回退到 PIL 渲染
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Virtual Environment
venv/
.venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
# macOS
.DS_Store
# Test artifacts
*.mp4
agi-config.json
# Temporary files
*.tmp
*.temp
架构文档
系统概览
访谈视频处理器基于分离的渲染后端架构,支持两种视觉效果生成方式:
- Browser Backend: HTML/CSS/Anime.js + Playwright 浏览器自动化(推荐)
- PIL Backend: Python PIL 纯 Python 实现(备选方案)
典型的数据流如下:
视频文件 + 字幕文件
↓
内容分析器(提取建议)
↓
用户审批配置
↓
渲染引擎(生成帧序列)
├→ Browser: HTML → Playwright → 截图
└→ PIL: Python → PIL 绘制
↓
视频合成器(MoviePy)
↓
输出视频核心模块
1. video_processor.py
主入口和协调器,负责:
- 命令行参数解析
- 配置文件加载验证
- 渲染器选择(自动或手动指定)
- 多种组件类型的分发处理
- 视频合成和导出
关键函数:
process_video(): 主处理流程_generate_clips_browser(): 使用浏览器渲染器生成图层_generate_clips_pil(): 使用 PIL 渲染器生成图层
2. browser_renderer.py
Playwright 浏览器自动化渲染器,负责:
- Playwright 浏览器实例管理
- HTML 模板加载与渲染
- 动画状态管理(通过 seek 控制当前帧)
- 截图捕获和图像输出
关键类和方法:
BrowserRenderer: 主类,生命周期管理render_fancy_text_frames(): 花字渲染render_term_card_frames(): 名词卡片渲染render_lower_third_frames(): 人物条渲染- 以及其他 8 个组件的渲染方法
工作原理:
加载 HTML 模板
↓
注入配置参数(JSON)
↓
调用 JavaScript initAnimation()
↓
循环:
- 计算当前帧时间
- 调用 JavaScript seek(t)
- Playwright 截图
- 保存为 PNG3. content_analyzer.py
内容分析引擎,负责:
- 从字幕文件提取信息
- 分析内容,识别:
- 嘉宾信息(人物条)
- 话题切换点(章节标题)
- 关键观点(花字短语)
- 专业术语(名词卡片)
- 精彩言论(金句卡片)
- 数字数据(数据动画)
- 核心要点(要点列表)
- 社交媒体信息(社交条)
包含多个 Dataclass 定义建议类型:
LowerThirdSuggestionChapterTitleSuggestionFancyTextSuggestionTermCardSuggestionQuoteCalloutSuggestionAnimatedStatsSuggestionBulletPointsSuggestionSocialBarSuggestion
4. fancy_text.py(PIL 备选方案)
纯 Python PIL 实现的花字生成器:
- PIL 文字渲染与描边
- 阴影效果实现
- 旋转和缩放变换
- Spring 动画应用
5. term_card.py(PIL 备选方案)
纯 Python PIL 实现的名词卡片生成器:
- 圆角矩形绘制
- 渐变边框(Pillow 模拟)
- 文字布局和自动换行
- 动画效果(滑入、淡出)
6. animations.py
动画工具函数库(用于 PIL 后端):
spring(): Spring 物理引擎实现- 参数:frame, fps, from_value, to_value, damping, stiffness
- 模拟 Remotion 风格弹性动画
interpolate(): 线性插值函数- 支持任意输入/输出范围映射
- 支持超出范围处理(clamp/extend/wrap)
模板系统
9 个 HTML 模板位于 templates/ 目录,每个对应一种组件:
| 模板 | 组件类型 | 用途 |
|---|---|---|
| fancy-text.html | 花字 | 概括观点短语 |
| term-card.html | 名词卡片 | 解释专业术语 |
| lower-third.html | 人物条 | 显示嘉宾信息 |
| chapter-title.html | 章节标题 | 话题切换标题 |
| quote-callout.html | 金句卡片 | 突出精彩言论 |
| animated-stats.html | 数据动画 | 展示数字数据 |
| bullet-points.html | 要点列表 | 总结核心要点 |
| social-bar.html | 社交条 | 社交媒体引导 |
| video-config.json.template | 配置模板 | JSON 配置示例 |
模板特点:
- 独立的 HTML 结构,可单独测试
- JavaScript
initAnimation(config)函数接收配置 seek(timeMs)方法用于帧控制(Playwright 调用)- CSS 变量支持主题切换
- Anime.js 动画库支持
主题系统
CSS 主题在 static/css/ 目录:
| 主题 | 文件 | 特点 | 场景 |
|---|---|---|---|
| notion | theme-notion.css | 温暖知识风,柔和渐变 | 教育、知识分享 |
| cyberpunk | theme-cyberpunk.css | 霓虹未来感,鲜艳对比 | 科技、前沿话题 |
| apple | theme-apple.css | 极简优雅,中性色系 | 商务、专业访谈 |
| aurora | theme-aurora.css | 渐变流光,炫彩效果 | 创意、艺术内容 |
每个主题通过 CSS 变量定义:
:root[data-theme="notion"] {
--primary-color: #f5b041;
--secondary-color: #3498db;
--accent-color: #e74c3c;
/* ... */
}模板通过 data-theme 属性激活主题。
动画引擎
Anime.js 集成
- 用于浏览器后端的帧动画
- 支持 Spring 物理、缓动曲线等高级效果
- 通过
seek()方法实现帧级控制
Spring 动画原理
x(t) = to_value - (to_value - from_value) * exp(-damping*t) * cos(stiffness*t)通过调整 damping 和 stiffness 参数实现不同的弹性效果。
配置文件格式
JSON 配置包含以下顶级字段:
{
"theme": "notion", # 选择主题
"lowerThirds": [...], # 人物条数组
"chapterTitles": [...], # 章节标题数组
"keyPhrases": [...], # 花字数组
"termDefinitions": [...], # 名词卡片数组
"quotes": [...], # 金句卡片数组
"stats": [...], # 数据动画数组
"bulletPoints": [...], # 要点列表数组
"socialBars": [...] # 社交条数组
}数据流详解
1. 配置阶段
用户提供视频 + 字幕
↓
ContentAnalyzer.analyze_subtitle() 读取 .srt
↓
返回 8 种类型的建议对象列表
↓
用户编辑或确认建议
↓
生成或修改 config.json2. 渲染阶段(Browser 后端)
video_processor 加载配置
↓
对每个组件:
- 确定时间范围
- 创建 BrowserRenderer 实例
- 加载对应 HTML 模板
- 通过 JavaScript 注入配置
- 循环渲染帧:
* 计算当前时间
* 调用 seek(timeMs)
* Playwright 截图
* 保存 PNG 序列
- 使用 MoviePy ImageClip 构建视频层
↓
合并所有层(原视频 + 效果层)
↓
导出最终视频3. 渲染阶段(PIL 后端)
video_processor 加载配置
↓
对每个组件:
- 确定时间范围和帧数
- 循环渲染帧:
* 调用 fancy_text.py / term_card.py
* 应用 animations.py 动画函数
* 使用 PIL 绘制到内存
* 保存 PNG 序列
- 使用 MoviePy ImageClip 构建视频层
↓
合并所有层(原视频 + 效果层)
↓
导出最终视频文件依赖关系
video_processor.py (主)
├── browser_renderer.py
│ ├── templates/*.html
│ └── static/css/*.css
│ ├── effects.css
│ └── theme-*.css
├── fancy_text.py (PIL 备选)
│ └── animations.py
├── term_card.py (PIL 备选)
│ └── animations.py
├── content_analyzer.py
├── moviepy
│ ├── VideoFileClip
│ ├── CompositeVideoClip
│ └── ImageClip
└── 配置文件
└── config.json扩展指南
添加新组件
1. 创建 HTML 模板
在 templates/ 目录创建 your-component.html,包含:
<script>
function initAnimation(config) {
// 初始化:使用 config 参数设置 DOM 元素
// 返回 totalMs:动画总时长
}
function seek(timeMs) {
// 关键帧:根据 timeMs 设置动画状态
// 由 Playwright 调用
}
</script>2. 添加渲染方法
在 BrowserRenderer 类中添加:
def render_your_component_frames(self, config, output_dir=None):
# 类似 render_fancy_text_frames 的实现
pass3. 在 video_processor.py 中注册
在 _generate_clips_browser() 中添加分支处理新组件。
4. 更新 content_analyzer.py
添加对应的 Suggestion dataclass。
5. 添加配置验证
在配置加载时验证新组件的必需字段。
添加新主题
1. 创建 CSS 文件
在 static/css/ 目录创建 theme-yourtheme.css:
:root[data-theme="yourtheme"] {
--primary-color: #...;
--secondary-color: #...;
--accent-color: #...;
--bg-color: #...;
/* ... */
}2. 在模板中引用
<link rel="stylesheet" href="../static/css/theme-yourtheme.css">3. 更新文档
在 SKILL.md 中列出新主题。
性能考虑
Browser 后端性能
- 优点:高质量输出,支持复杂 CSS/动画
- 缺点:需要 Chromium,较慢(但可控)
- 优化:
- 使用
--headless模式 - 预热浏览器实例
- 批量渲染多组件时复用实例
PIL 后端性能
- 优点:快速,无额外依赖
- 缺点:效果有限,不支持复杂动画
- 优化:
- 预计算变换矩阵
- 使用 NumPy 加速计算
依赖分析
核心依赖
moviepy>=1.0.3: 视频合成pillow>=10.0.0: 图像处理(两个后端都需要)numpy>=1.24.0: 数值计算pysrt>=1.1.2: SRT 字幕解析playwright>=1.40.0: 浏览器自动化(可选)
依赖大小
- 总计:约 100-150MB(包括 Playwright + Chromium)
- 仅 PIL 后端:约 50-80MB
故障排除
Playwright/Chromium 问题
# 手动安装
pip install playwright
playwright install chromium
# 验证
playwright codegen --browser chromiumMoviePy 依赖问题
# macOS
brew install ffmpeg
# Ubuntu
sudo apt-get install ffmpeg
# 验证
moviepy-script --version内存问题
- 长视频:分段处理或降低分辨率
- 多组件:批量处理时控制并发
测试策略
单元测试
- 动画函数:spring(), interpolate()
- 配置解析和验证
- 渲染器初始化
集成测试
- 完整工作流:输入 → 渲染 → 输出
- 两个后端对比(视觉一致性)
- 不同主题的渲染
性能测试
- 帧渲染速度
- 内存使用
- 长视频处理
<div align="center">
🎬 Video Wrapper
为访谈/播客视频添加综艺风格视觉包装
AI 智能分析字幕内容,自动生成特效建议,一键渲染专业级视觉效果
  
快速开始 • 功能特性 • 效果展示 • 使用场景 • 架构文档
English Documentation
</div>
---
✨ 功能特性
<table> <tr> <td width="50%">
🎨 8 种视觉组件
- 花字高亮 - 短语概括核心观点
- 人物条 - 显示嘉宾姓名职位
- 章节标题 - 话题切换标题卡
- 名词卡片 - 专业术语解释
- 金句卡片 - 精彩言论突出
- 数据动画 - 数字动态展示
- 要点列表 - 核心观点总结
- 社交条 - 关注引导信息
</td> <td width="50%">
🎭 4 种视觉主题
- Notion 🟡 - 温暖知识风格
- Cyberpunk 💜 - 霓虹未来感
- Apple ⚪ - 极简商务风格
- Aurora 🌈 - 渐变流光效果
🤖 智能工作流
1. 📝 AI 分析字幕内容 2. 💡 自动生成特效建议 3. ✅ 用户审批确认 4. 🎬 一键渲染视频
</td> </tr> </table>
🛠️ 双渲染引擎
| 引擎 | 技术栈 | 特点 |
|---|---|---|
| Browser 🌐 | Playwright + HTML/CSS/Anime.js | 高质量,支持复杂动画(推荐) |
| PIL 🎨 | Python PIL | 纯 Python,无需浏览器 |
---
🚀 快速开始
安装 Skill
方式一:一键安装(推荐)
npx skills add https://github.com/op7418/Video-Wrapper-Skills方式二:手动安装
# 克隆到 Claude Skills 目录
cd ~/.claude/skills/
git clone https://github.com/op7418/Video-Wrapper-Skills.git video-wrapper
cd video-wrapper
# 安装依赖
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
playwright install chromium使用
在 Claude Code 中
/video-wrapper interview.mp4 subtitles.srtClaude 会: 1. 📊 分析字幕,识别关键信息 2. 💡 生成特效建议(人物条、花字、名词卡片等) 3. 📝 展示 Markdown 格式建议供审批 4. ✅ 确认后自动渲染输出视频
命令行使用
# 有配置文件时直接渲染
python src/video_processor.py video.mp4 subs.srt config.json output.mp4
# 指定渲染器
python src/video_processor.py video.mp4 subs.srt config.json -r browser # 浏览器渲染
python src/video_processor.py video.mp4 subs.srt config.json -r pil # PIL 渲染---
🎥 效果展示
💡 以下展示不同主题和组件的视觉效果
主题风格对比
<table> <tr> <td align="center" width="25%"><strong>Notion 主题</strong><br/>温暖知识风</td> <td align="center" width="25%"><strong>Cyberpunk 主题</strong><br/>霓虹未来感</td> <td align="center" width="25%"><strong>Apple 主题</strong><br/>极简优雅</td> <td align="center" width="25%"><strong>Aurora 主题</strong><br/>渐变流光</td> </tr> <tr> <td align="center">🟡 教育/知识分享</td> <td align="center">💜 科技/前沿话题</td> <td align="center">⚪ 商务/专业访谈</td> <td align="center">🌈 创意/艺术内容</td> </tr> </table>
组件效果示例
| 组件 | 效果预览 | 使用场景 |
|---|---|---|
| 🏷️ 花字 | _[效果图占位]_ | 嘉宾说到"通用人工智能"时,屏幕上方显示"AI发展是平滑曲线" |
| 👤 人物条 | _[效果图占位]_ | 视频开始时展示"Dario Amodei · CEO · Anthropic" |
| 📖 名词卡片 | _[效果图占位]_ | 首次提到"摩尔定律"时,自动弹出解释卡片 |
| 💬 金句 | _[效果图占位]_ | 精彩观点"AI的发展是一个非常平滑的指数曲线"突出显示 |
---
📋 使用场景
<table> <tr> <td width="33%">
🎓 教育内容
- 知识分享视频
- 课程录制
- 在线讲座
- 术语解释需求多
</td> <td width="33%">
🎙️ 访谈播客
- 人物专访
- 圆桌讨论
- 行业对话
- 需要嘉宾信息展示
</td> <td width="33%">
📱 社交媒体
- YouTube 长视频
- B站 UP主内容
- 播客节目
- 需要精彩片段突出
</td> </tr> </table>
---
🎨 主题系统
根据内容风格选择合适主题:
{
"theme": "notion" // 或 "cyberpunk", "apple", "aurora"
}| 主题 | 色系 | 特点 | 适用内容 |
|---|---|---|---|
| Notion | 暖黄 + 蓝色 | 柔和渐变,知识感 | 教育、知识分享、课程 |
| Cyberpunk | 霓虹紫 + 青色 | 高对比,科技感 | 技术、科幻、前沿话题 |
| Apple | 黑白灰 | 极简,专业感 | 商务、企业、正式访谈 |
| Aurora | 渐变彩虹 | 流光溢彩,艺术感 | 创意、设计、艺术内容 |
---
🧩 组件配置
完整配置示例
<details> <summary>展开查看完整 JSON 配置</summary>
{
"theme": "notion",
"lowerThirds": [
{
"name": "张三",
"role": "首席科学家",
"company": "AI 研究院",
"startMs": 1000,
"durationMs": 5000
}
],
"chapterTitles": [
{
"number": "Part 1",
"title": "AI 的发展历程",
"subtitle": "The History of AI Development",
"startMs": 0,
"durationMs": 4000
}
],
"keyPhrases": [
{
"text": "AI 发展是平滑曲线",
"style": "emphasis",
"startMs": 2630,
"endMs": 5500
}
],
"termDefinitions": [
{
"chinese": "摩尔定律",
"english": "Moore's Law",
"description": "集成电路晶体管数量每18-24个月翻一番",
"firstAppearanceMs": 37550,
"displayDurationSeconds": 6
}
],
"quotes": [
{
"text": "AI 的发展是一个非常平滑的指数曲线",
"author": "— 张三",
"startMs": 30000,
"durationMs": 5000
}
],
"stats": [
{
"prefix": "增长率 ",
"number": 240,
"unit": "%",
"label": "计算能力年增长",
"startMs": 45000,
"durationMs": 4000
}
],
"bulletPoints": [
{
"title": "核心观点",
"points": [
"AI 发展是平滑的指数曲线",
"类似摩尔定律的智能增长",
"没有突然的奇点时刻"
],
"startMs": 50000,
"durationMs": 6000
}
],
"socialBars": [
{
"platform": "twitter",
"label": "关注我们",
"handle": "@ai_research",
"startMs": 52000,
"durationMs": 8000
}
]
}</details>
组件参数速查
| 组件 | 必需参数 | 可选参数 | 说明 |
|---|---|---|---|
| 人物条 | name, role, company, startMs | durationMs (默认5s) | 显示嘉宾信息 |
| 章节标题 | number, title, startMs | subtitle, durationMs | 话题分段 |
| 花字 | text, startMs, endMs | style, position | text 必须是短语 |
| 名词卡片 | chinese, english, firstAppearanceMs | description, displayDurationSeconds | 术语解释 |
| 金句 | text, author, startMs | durationMs, position | 精彩观点 |
| 数据 | number, label, startMs | prefix, unit, durationMs | 数字展示 |
| 要点 | title, points, startMs | durationMs | 列表总结 |
| 社交条 | platform, handle, startMs | label, durationMs | 关注引导 |
⚠️ 花字使用规范:text 必须是短语(如"AI发展是平滑曲线"),不能是单词(如"人工智能")。单词应使用名词卡片。
---
🗂️ 项目结构
video-wrapper/
├── 📄 SKILL.md # Claude Skill 定义
├── 📄 README.md # 英文文档(GitHub 首页)
├── 📄 README_CN.md # 本文档(中文)
├── 📄 ARCHITECTURE.md # 详细架构说明
├── 📄 requirements.txt # Python 依赖
├── 📁 src/ # 源代码
│ ├── video_processor.py # 主处理流程
│ ├── browser_renderer.py # Playwright 渲染器
│ ├── content_analyzer.py # AI 内容分析
│ ├── fancy_text.py # PIL 花字渲染
│ ├── term_card.py # PIL 卡片渲染
│ └── animations.py # 动画函数库
├── 📁 templates/ # HTML 模板
│ ├── fancy-text.html
│ ├── term-card.html
│ ├── lower-third.html
│ ├── chapter-title.html
│ ├── quote-callout.html
│ ├── animated-stats.html
│ ├── bullet-points.html
│ ├── social-bar.html
│ └── video-config.json.template
└── 📁 static/ # 静态资源
├── css/ # 主题样式
│ ├── effects.css
│ ├── theme-notion.css
│ ├── theme-cyberpunk.css
│ ├── theme-apple.css
│ └── theme-aurora.css
└── js/
└── anime.min.js # 动画引擎---
❓ 常见问题
<details> <summary><strong>Q: Playwright 安装失败?</strong></summary>
# 确保 Python 版本 >= 3.8
pip install playwright
playwright install chromium
# macOS 可能需要移除隔离标记
xattr -r -d com.apple.quarantine ~/.cache/ms-playwright
# 验证安装
playwright --version</details>
<details> <summary><strong>Q: 处理速度太慢?</strong></summary>
优化建议: 1. 使用 PIL 渲染器:-r pil(效果略简单但速度快 2-3 倍) 2. 降低视频分辨率(从 1080p 降至 720p) 3. 分段处理长视频(每次处理 5-10 分钟) 4. 减少组件数量(只保留必要的)
</details>
<details> <summary><strong>Q: 内存不足?</strong></summary>
解决方案: 1. 关闭其他应用释放内存 2. 分段处理长视频 3. 使用更低的分辨率(720p 或 480p) 4. 减少同时渲染的组件数量 5. 使用 PIL 渲染器(内存占用更小)
</details>
<details> <summary><strong>Q: 字体显示异常?</strong></summary>
确保系统已安装中文字体:
# macOS - 自带 PingFang SC
# 无需额外安装
# Ubuntu/Debian
sudo apt-get install fonts-noto-cjk
# CentOS/RHEL
sudo yum install google-noto-sans-cjk-fonts
# 验证字体
fc-list :lang=zh</details>
<details> <summary><strong>Q: 如何自定义主题?</strong></summary>
1. 复制现有主题 CSS 文件 2. 修改 CSS 变量 3. 在配置中指定新主题名
详见 ARCHITECTURE.md
</details>
---
🔧 技术栈
| 层级 | 技术 | 说明 |
|---|---|---|
| 视觉渲染 | HTML + CSS + Anime.js | 通过 Playwright 浏览器截图 |
| 视频合成 | MoviePy | Python 视频编辑库 |
| 动画引擎 | Anime.js | Spring 物理动画 |
| 备用渲染 | Python PIL | 纯 Python 图像处理 |
| 内容分析 | AI 分析 | 自动识别关键信息 |
详细架构说明请查看 ARCHITECTURE.md
---
📚 相关资源
---
🤝 贡献
欢迎提交 Issue 和 Pull Request!
在提交 PR 前,请确保:
- ✅ 代码风格符合项目规范
- ✅ 添加了必要的测试
- ✅ 更新了相关文档
---
📄 许可证
MIT License
---
<div align="center">
由 [Claude](https://claude.ai) 强力驱动
如果觉得有用,请给个 ⭐️ Star!
</div>
<div align="center">
🎬 Video Wrapper
Add Variety Show Style Visual Effects to Interview/Podcast Videos
AI-powered subtitle analysis, automatic effect suggestions, one-click professional visual rendering
  
Quick Start • Features • Demo • Use Cases • Architecture
中文文档
</div>
---
✨ Features
<table> <tr> <td width="50%">
🎨 8 Visual Components
- Key Phrases - Short phrases highlighting core ideas
- Lower Thirds - Display guest name and title
- Chapter Titles - Topic transition title cards
- Term Cards - Professional terminology explanations
- Quote Callouts - Highlight memorable quotes
- Animated Stats - Dynamic number displays
- Bullet Points - Core takeaways summary
- Social Bars - Follow call-to-action
</td> <td width="50%">
🎭 4 Visual Themes
- Notion 🟡 - Warm knowledge style
- Cyberpunk 💜 - Neon futuristic
- Apple ⚪ - Minimalist business
- Aurora 🌈 - Gradient flowing
🤖 Smart Workflow
1. 📝 AI analyzes subtitle content 2. 💡 Auto-generates effect suggestions 3. ✅ User approves configuration 4. 🎬 One-click video rendering
</td> </tr> </table>
🛠️ Dual Rendering Engines
| Engine | Tech Stack | Features |
|---|---|---|
| Browser 🌐 | Playwright + HTML/CSS/Anime.js | High quality, complex animations (Recommended) |
| PIL 🎨 | Python PIL | Pure Python, no browser required |
---
🚀 Quick Start
Installation
Method 1: One-Click Install (Recommended)
npx skills add https://github.com/op7418/Video-Wrapper-SkillsMethod 2: Manual Install
# Clone to Claude Skills directory
cd ~/.claude/skills/
git clone https://github.com/op7418/Video-Wrapper-Skills.git video-wrapper
cd video-wrapper
# Install dependencies
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
playwright install chromiumUsage
In Claude Code
/video-wrapper interview.mp4 subtitles.srtClaude will: 1. 📊 Analyze subtitles, identify key information 2. 💡 Generate effect suggestions (lower thirds, key phrases, term cards, etc.) 3. 📝 Present suggestions in Markdown for review 4. ✅ Auto-render output video after confirmation
Command Line
# Direct rendering with config file
python src/video_processor.py video.mp4 subs.srt config.json output.mp4
# Specify renderer
python src/video_processor.py video.mp4 subs.srt config.json -r browser # Browser
python src/video_processor.py video.mp4 subs.srt config.json -r pil # PIL---
🎥 Demo
💡 Visual effects showcase across different themes and components
Theme Comparison
<table> <tr> <td align="center" width="25%"><strong>Notion Theme</strong><br/>Warm Knowledge</td> <td align="center" width="25%"><strong>Cyberpunk Theme</strong><br/>Neon Futuristic</td> <td align="center" width="25%"><strong>Apple Theme</strong><br/>Minimalist Elegant</td> <td align="center" width="25%"><strong>Aurora Theme</strong><br/>Gradient Flow</td> </tr> <tr> <td align="center">🟡 Education/Knowledge</td> <td align="center">💜 Tech/Innovation</td> <td align="center">⚪ Business/Professional</td> <td align="center">🌈 Creative/Artistic</td> </tr> </table>
Component Examples
| Component | Preview | Use Case |
|---|---|---|
| 🏷️ Key Phrase | _[Preview placeholder]_ | When guest mentions "Artificial General Intelligence", show "AI Development is Smooth Curve" |
| 👤 Lower Third | _[Preview placeholder]_ | Show "Dario Amodei · CEO · Anthropic" at video start |
| 📖 Term Card | _[Preview placeholder]_ | Auto popup explanation when "Moore's Law" first mentioned |
| 💬 Quote | _[Preview placeholder]_ | Highlight "AI development is a very smooth exponential curve" |
---
📋 Use Cases
<table> <tr> <td width="33%">
🎓 Educational Content
- Knowledge sharing videos
- Course recordings
- Online lectures
- Heavy terminology needs
</td> <td width="33%">
🎙️ Interviews & Podcasts
- Personal interviews
- Panel discussions
- Industry dialogues
- Guest info display needs
</td> <td width="33%">
📱 Social Media
- YouTube long-form
- B站 content
- Podcast shows
- Highlight clips needed
</td> </tr> </table>
---
🎨 Theme System
Choose the right theme based on content style:
{
"theme": "notion" // or "cyberpunk", "apple", "aurora"
}| Theme | Color Scheme | Characteristics | Best For |
|---|---|---|---|
| Notion | Warm Yellow + Blue | Soft gradients, knowledge-focused | Education, knowledge sharing, courses |
| Cyberpunk | Neon Purple + Cyan | High contrast, tech-forward | Tech, sci-fi, innovation topics |
| Apple | Black/White/Gray | Minimal, professional | Business, corporate, formal interviews |
| Aurora | Rainbow Gradient | Flowing light, artistic | Creative, design, artistic content |
---
🧩 Component Configuration
Complete Configuration Example
<details> <summary>Expand to view full JSON config</summary>
{
"theme": "notion",
"lowerThirds": [
{
"name": "John Doe",
"role": "Chief Scientist",
"company": "AI Research Lab",
"startMs": 1000,
"durationMs": 5000
}
],
"chapterTitles": [
{
"number": "Part 1",
"title": "The Journey of AI",
"subtitle": "The History of AI Development",
"startMs": 0,
"durationMs": 4000
}
],
"keyPhrases": [
{
"text": "AI Development is Smooth Curve",
"style": "emphasis",
"startMs": 2630,
"endMs": 5500
}
],
"termDefinitions": [
{
"chinese": "摩尔定律",
"english": "Moore's Law",
"description": "Number of transistors doubles every 18-24 months",
"firstAppearanceMs": 37550,
"displayDurationSeconds": 6
}
],
"quotes": [
{
"text": "AI development is a very smooth exponential curve",
"author": "— John Doe",
"startMs": 30000,
"durationMs": 5000
}
],
"stats": [
{
"prefix": "Growth Rate ",
"number": 240,
"unit": "%",
"label": "Annual Computing Power Growth",
"startMs": 45000,
"durationMs": 4000
}
],
"bulletPoints": [
{
"title": "Key Takeaways",
"points": [
"AI development is smooth exponential curve",
"Similar to Moore's Law intelligence growth",
"No sudden singularity moment"
],
"startMs": 50000,
"durationMs": 6000
}
],
"socialBars": [
{
"platform": "twitter",
"label": "Follow us",
"handle": "@ai_research",
"startMs": 52000,
"durationMs": 8000
}
]
}</details>
Component Parameters Quick Reference
| Component | Required | Optional | Notes |
|---|---|---|---|
| Lower Third | name, role, company, startMs | durationMs (default 5s) | Guest information |
| Chapter Title | number, title, startMs | subtitle, durationMs | Topic segmentation |
| Key Phrase | text, startMs, endMs | style, position | text must be phrase |
| Term Card | chinese, english, firstAppearanceMs | description, displayDurationSeconds | Terminology explanation |
| Quote | text, author, startMs | durationMs, position | Memorable quotes |
| Stats | number, label, startMs | prefix, unit, durationMs | Number display |
| Bullet Points | title, points, startMs | durationMs | List summary |
| Social Bar | platform, handle, startMs | label, durationMs | Follow CTA |
⚠️ Key Phrase Usage: text must be a phrase (e.g., "AI Development is Smooth Curve"), not a single word (e.g., "Artificial Intelligence"). Use term cards for single words.
---
🗂️ Project Structure
video-wrapper/
├── 📄 SKILL.md # Claude Skill definition
├── 📄 README.md # This document (English)
├── 📄 README_CN.md # Chinese documentation
├── 📄 ARCHITECTURE.md # Detailed architecture
├── 📄 requirements.txt # Python dependencies
├── 📁 src/ # Source code
│ ├── video_processor.py # Main processing flow
│ ├── browser_renderer.py # Playwright renderer
│ ├── content_analyzer.py # AI content analysis
│ ├── fancy_text.py # PIL key phrase rendering
│ ├── term_card.py # PIL card rendering
│ └── animations.py # Animation functions
├── 📁 templates/ # HTML templates
│ ├── fancy-text.html
│ ├── term-card.html
│ ├── lower-third.html
│ ├── chapter-title.html
│ ├── quote-callout.html
│ ├── animated-stats.html
│ ├── bullet-points.html
│ ├── social-bar.html
│ └── video-config.json.template
└── 📁 static/ # Static assets
├── css/ # Theme styles
│ ├── effects.css
│ ├── theme-notion.css
│ ├── theme-cyberpunk.css
│ ├── theme-apple.css
│ └── theme-aurora.css
└── js/
└── anime.min.js # Animation engine---
❓ FAQ
<details> <summary><strong>Q: Playwright installation failed?</strong></summary>
# Ensure Python version >= 3.8
pip install playwright
playwright install chromium
# macOS may need to remove quarantine flag
xattr -r -d com.apple.quarantine ~/.cache/ms-playwright
# Verify installation
playwright --version</details>
<details> <summary><strong>Q: Processing too slow?</strong></summary>
Optimization Tips: 1. Use PIL renderer: -r pil (simpler but 2-3x faster) 2. Lower video resolution (1080p → 720p) 3. Process long videos in segments (5-10 min chunks) 4. Reduce component count (keep only essentials)
</details>
<details> <summary><strong>Q: Out of memory?</strong></summary>
Solutions: 1. Close other applications to free memory 2. Process long videos in segments 3. Use lower resolution (720p or 480p) 4. Reduce number of simultaneous components 5. Use PIL renderer (smaller memory footprint)
</details>
<details> <summary><strong>Q: Font display issues?</strong></summary>
Ensure Chinese fonts are installed:
# macOS - PingFang SC included
# No additional installation needed
# Ubuntu/Debian
sudo apt-get install fonts-noto-cjk
# CentOS/RHEL
sudo yum install google-noto-sans-cjk-fonts
# Verify fonts
fc-list :lang=zh</details>
<details> <summary><strong>Q: How to customize themes?</strong></summary>
1. Copy existing theme CSS file 2. Modify CSS variables 3. Specify new theme name in config
See ARCHITECTURE.md for details
</details>
---
🔧 Tech Stack
| Layer | Technology | Description |
|---|---|---|
| Visual Rendering | HTML + CSS + Anime.js | Screenshot via Playwright browser |
| Video Compositing | MoviePy | Python video editing library |
| Animation Engine | Anime.js | Spring physics animations |
| Fallback Rendering | Python PIL | Pure Python image processing |
| Content Analysis | AI Analysis | Auto-identify key information |
Detailed architecture available in ARCHITECTURE.md
---
📚 Resources
---
🤝 Contributing
Issues and Pull Requests are welcome!
Before submitting a PR, please ensure:
- ✅ Code style follows project conventions
- ✅ Added necessary tests
- ✅ Updated relevant documentation
---
📄 License
MIT License
---
<div align="center">
Powered by [Claude](https://claude.ai)
If you find this useful, please give it a ⭐️ Star!
</div>
moviepy>=1.0.3
pillow>=10.0.0
numpy>=1.24.0
pysrt>=1.1.2
playwright>=1.40.0
"""
Animation functions for video effects
Implements Remotion-style animations in pure Python
"""
import numpy as np
def spring(frame, fps, from_value=0, to_value=1, damping=12, stiffness=200):
"""
模拟 Remotion 的 spring 物理动画
参数:
frame: 当前帧数
fps: 帧率
from_value: 起始值
to_value: 目标值
damping: 阻尼系数
stiffness: 刚度系数
返回:
当前帧的动画值
"""
t = frame / fps
omega = np.sqrt(stiffness)
zeta = damping / (2 * np.sqrt(stiffness))
if zeta < 1: # 欠阻尼
omega_d = omega * np.sqrt(1 - zeta**2)
A = 1
B = zeta * omega / omega_d
value = (A * np.cos(omega_d * t) + B * np.sin(omega_d * t)) * np.exp(-zeta * omega * t)
else: # 临界阻尼或过阻尼
value = (1 + t) * np.exp(-t)
return from_value + (to_value - from_value) * (1 - value)
def interpolate(input_value, input_range, output_range, extrapolate='clamp'):
"""
值映射插值(类似 Remotion 的 interpolate)
参数:
input_value: 输入值
input_range: 输入范围 [min, max]
output_range: 输出范围 [min, max]
extrapolate: 'clamp' 或 'extend'
"""
input_min, input_max = input_range
output_min, output_max = output_range
# 归一化输入
progress = (input_value - input_min) / (input_max - input_min)
if extrapolate == 'clamp':
progress = np.clip(progress, 0, 1)
# 映射到输出范围
return output_min + progress * (output_max - output_min)
def ease_in_out(t):
"""
缓入缓出曲线
"""
return t * t * (3 - 2 * t)
"""
Browser-based renderer using Playwright
Renders HTML/CSS/Anime.js effects to image sequences for video composition
"""
import os
import json
import tempfile
from pathlib import Path
class BrowserRenderer:
"""
Playwright-based renderer for HTML/CSS/Anime.js effects.
Captures frames at specified times for video composition.
"""
def __init__(self, width=1920, height=1080, fps=30):
self.width = width
self.height = height
self.fps = fps
self._browser = None
self._playwright = None
# Get paths relative to this file
self.base_dir = Path(__file__).parent.parent
self.templates_dir = self.base_dir / 'templates'
self.static_dir = self.base_dir / 'static'
def _ensure_browser(self):
"""Lazily initialize browser"""
if self._browser is None:
from playwright.sync_api import sync_playwright
self._playwright = sync_playwright().start()
self._browser = self._playwright.chromium.launch(headless=True)
return self._browser
def close(self):
"""Clean up browser resources"""
if self._browser:
self._browser.close()
self._browser = None
if self._playwright:
self._playwright.stop()
self._playwright = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def render_fancy_text_frames(self, config, output_dir=None):
"""
Render fancy text effect to image sequence.
Args:
config: {
'text': str,
'style': 'emphasis' | 'term' | 'number',
'position': {'x': int, 'y': int},
'startMs': int,
'endMs': int
}
output_dir: Directory to save frames (uses temp dir if None)
Returns:
List of frame file paths
"""
browser = self._ensure_browser()
page = browser.new_page(viewport={'width': self.width, 'height': self.height})
# Load template
template_path = self.templates_dir / 'fancy-text.html'
page.goto(f'file://{template_path}')
# Calculate timing
duration_ms = config['endMs'] - config['startMs']
total_frames = int(duration_ms / 1000 * self.fps)
# Prepare config for JavaScript
js_config = {
'text': config['text'],
'style': config.get('style', 'emphasis'),
'theme': config.get('theme', 'notion'),
'position': config.get('position', {'x': self.width // 2, 'y': 300}),
'durationMs': duration_ms
}
# Initialize animation with config
page.evaluate(f'initAnimation({json.dumps(js_config)})')
# Create output directory
if output_dir is None:
output_dir = tempfile.mkdtemp(prefix='fancy_text_')
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Capture frames
frame_paths = []
for frame_idx in range(total_frames):
time_ms = frame_idx / self.fps * 1000
# Seek animation to current time
page.evaluate(f'seekTo({time_ms})')
# Capture frame with transparent background
frame_path = output_dir / f'frame_{frame_idx:05d}.png'
page.screenshot(path=str(frame_path), omit_background=True)
frame_paths.append(str(frame_path))
page.close()
return frame_paths
def render_term_card_frames(self, config, output_dir=None):
"""
Render term definition card to image sequence.
Args:
config: {
'chinese': str,
'english': str,
'description': str,
'firstAppearanceMs': int,
'displayDurationSeconds': float
}
output_dir: Directory to save frames (uses temp dir if None)
Returns:
List of frame file paths
"""
browser = self._ensure_browser()
page = browser.new_page(viewport={'width': self.width, 'height': self.height})
# Load template
template_path = self.templates_dir / 'term-card.html'
page.goto(f'file://{template_path}')
# Calculate timing
duration_seconds = config.get('displayDurationSeconds', 6)
duration_ms = int(duration_seconds * 1000)
total_frames = int(duration_seconds * self.fps)
# Default position: top-right corner
position = config.get('position', {
'x': self.width - 50 - 400, # 50px margin, 400px card width
'y': 50
})
# Prepare config for JavaScript
js_config = {
'chinese': config['chinese'],
'english': config['english'],
'description': config['description'],
'theme': config.get('theme', 'notion'),
'position': position,
'durationMs': duration_ms
}
# Initialize animation with config
page.evaluate(f'initAnimation({json.dumps(js_config)})')
# Create output directory
if output_dir is None:
output_dir = tempfile.mkdtemp(prefix='term_card_')
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Capture frames
frame_paths = []
for frame_idx in range(total_frames):
time_ms = frame_idx / self.fps * 1000
# Seek animation to current time
page.evaluate(f'seekTo({time_ms})')
# Capture frame with transparent background
frame_path = output_dir / f'frame_{frame_idx:05d}.png'
page.screenshot(path=str(frame_path), omit_background=True)
frame_paths.append(str(frame_path))
page.close()
return frame_paths
def generate_fancy_text_clip(self, keyword, index):
"""
Generate MoviePy clip from fancy text effect.
Args:
keyword: {text, startMs, endMs, style}
index: Effect index for positioning
Returns:
MoviePy ImageSequenceClip
"""
from moviepy import ImageSequenceClip
import shutil
# Calculate position (alternating left/right)
x_offset = (index % 2) * 400
y_offset = (index // 2) * 100
position = {
'x': self.width // 2 - 300 + x_offset,
'y': 300 + y_offset
}
config = {
'text': keyword['text'],
'style': keyword.get('style', 'emphasis'),
'position': position,
'startMs': keyword['startMs'],
'endMs': keyword['endMs']
}
# Render frames
temp_dir = tempfile.mkdtemp(prefix='fancy_text_clip_')
try:
frame_paths = self.render_fancy_text_frames(config, temp_dir)
# Create clip from image sequence
clip = ImageSequenceClip(frame_paths, fps=self.fps)
# Set start time
start_time = keyword['startMs'] / 1000.0
clip = clip.with_start(start_time)
return clip
finally:
# Clean up temp directory after clip is created
# Note: MoviePy reads frames lazily, so we need to keep files
# The caller is responsible for cleanup after video export
pass
def generate_term_card_clip(self, term):
"""
Generate MoviePy clip from term card effect.
Args:
term: {chinese, english, description, firstAppearanceMs, displayDurationSeconds}
Returns:
MoviePy ImageSequenceClip
"""
from moviepy import ImageSequenceClip
config = {
'chinese': term['chinese'],
'english': term['english'],
'description': term['description'],
'displayDurationSeconds': term.get('displayDurationSeconds', 6)
}
# Render frames
temp_dir = tempfile.mkdtemp(prefix='term_card_clip_')
frame_paths = self.render_term_card_frames(config, temp_dir)
# Create clip from image sequence
clip = ImageSequenceClip(frame_paths, fps=self.fps)
# Set start time
start_time = term['firstAppearanceMs'] / 1000.0
clip = clip.with_start(start_time)
return clip
def _render_generic_template(self, template_name, config, output_dir=None):
"""
Generic renderer for any template.
Args:
template_name: Name of the template file (without .html)
config: Configuration dict including 'durationMs' or 'displayDurationSeconds'
output_dir: Directory to save frames
Returns:
List of frame file paths
"""
browser = self._ensure_browser()
page = browser.new_page(viewport={'width': self.width, 'height': self.height})
# Load template
template_path = self.templates_dir / f'{template_name}.html'
page.goto(f'file://{template_path}')
# Calculate timing
if 'durationMs' in config:
duration_ms = config['durationMs']
elif 'displayDurationSeconds' in config:
duration_ms = int(config['displayDurationSeconds'] * 1000)
else:
duration_ms = 5000 # default 5 seconds
total_frames = int(duration_ms / 1000 * self.fps)
# Initialize animation with config
page.evaluate(f'initAnimation({json.dumps(config)})')
# Create output directory
if output_dir is None:
output_dir = tempfile.mkdtemp(prefix=f'{template_name}_')
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Capture frames
frame_paths = []
for frame_idx in range(total_frames):
time_ms = frame_idx / self.fps * 1000
page.evaluate(f'seekTo({time_ms})')
frame_path = output_dir / f'frame_{frame_idx:05d}.png'
page.screenshot(path=str(frame_path), omit_background=True)
frame_paths.append(str(frame_path))
page.close()
return frame_paths
def render_lower_third_frames(self, config, output_dir=None):
"""
Render lower third (人物条) to image sequence.
Args:
config: {
'name': str,
'role': str,
'company': str,
'theme': str,
'durationMs': int
}
"""
return self._render_generic_template('lower-third', config, output_dir)
def render_chapter_title_frames(self, config, output_dir=None):
"""
Render chapter title (章节标题) to image sequence.
Args:
config: {
'number': str (optional, e.g., "Part 1"),
'title': str,
'subtitle': str (optional),
'theme': str,
'durationMs': int
}
"""
return self._render_generic_template('chapter-title', config, output_dir)
def render_quote_callout_frames(self, config, output_dir=None):
"""
Render quote callout (金句卡片) to image sequence.
Args:
config: {
'text': str,
'author': str (optional),
'theme': str,
'position': {'x': int, 'y': int},
'durationMs': int
}
"""
# Set default position if not provided
if 'position' not in config:
config['position'] = {'x': self.width // 2, 'y': self.height // 2}
return self._render_generic_template('quote-callout', config, output_dir)
def render_animated_stats_frames(self, config, output_dir=None):
"""
Render animated stats (数据动画) to image sequence.
Args:
config: {
'prefix': str (optional),
'number': int,
'unit': str (e.g., '%', 'x'),
'label': str (optional),
'theme': str,
'position': {'x': int, 'y': int},
'durationMs': int
}
"""
if 'position' not in config:
config['position'] = {'x': self.width // 2, 'y': self.height // 2}
return self._render_generic_template('animated-stats', config, output_dir)
def render_bullet_points_frames(self, config, output_dir=None):
"""
Render bullet points (要点列表) to image sequence.
Args:
config: {
'title': str (optional),
'points': list[str],
'theme': str,
'position': {'x': int, 'y': int},
'durationMs': int
}
"""
if 'position' not in config:
config['position'] = {'x': 100, 'y': 300}
return self._render_generic_template('bullet-points', config, output_dir)
def render_social_bar_frames(self, config, output_dir=None):
"""
Render social media bar (社交媒体条) to image sequence.
Args:
config: {
'platform': 'twitter' | 'weibo' | 'youtube',
'label': str,
'handle': str,
'theme': str,
'position': {'x': int, 'y': int},
'durationMs': int
}
"""
if 'position' not in config:
config['position'] = {'x': self.width - 320, 'y': self.height - 130}
return self._render_generic_template('social-bar', config, output_dir)
def check_playwright_installed():
"""Check if Playwright is installed and chromium is available"""
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
browser.close()
return True
except Exception as e:
print(f"Playwright check failed: {e}")
print("To install Playwright and Chromium, run:")
print(" pip install playwright")
print(" playwright install chromium")
return False
if __name__ == '__main__':
# Test the renderer
print("Testing BrowserRenderer...")
if not check_playwright_installed():
print("Playwright not available, skipping test")
exit(1)
with BrowserRenderer(width=1920, height=1080, fps=30) as renderer:
# Test fancy text
test_config = {
'text': '测试花字',
'style': 'emphasis',
'startMs': 0,
'endMs': 2000
}
print("Rendering test fancy text frames...")
frames = renderer.render_fancy_text_frames(test_config)
print(f"Generated {len(frames)} frames")
print(f"First frame: {frames[0]}")
# Test term card
test_term = {
'chinese': '人工智能',
'english': 'Artificial Intelligence',
'description': '人工智能是计算机科学的一个分支。',
'firstAppearanceMs': 0,
'displayDurationSeconds': 3
}
print("\nRendering test term card frames...")
frames = renderer.render_term_card_frames(test_term)
print(f"Generated {len(frames)} frames")
print(f"First frame: {frames[0]}")
print("\nTest complete!")
"""
Content analyzer for interview videos
Analyzes subtitles and suggests visual effects components
"""
import re
import json
from dataclasses import dataclass, field, asdict
from typing import List, Optional
from pathlib import Path
@dataclass
class LowerThirdSuggestion:
"""人物条建议"""
component: str = "lower_third"
name: str = ""
role: str = ""
company: str = ""
startMs: int = 0
durationMs: int = 5000
@dataclass
class ChapterTitleSuggestion:
"""章节标题建议"""
component: str = "chapter_title"
number: str = ""
title: str = ""
subtitle: str = ""
startMs: int = 0
durationMs: int = 4000
@dataclass
class FancyTextSuggestion:
"""
花字建议
注意:text 必须是短语形式,概括说话人当时的观点
- ✅ 正确:"AI发展是平滑曲线"、"智能增长类似摩尔定律"
- ❌ 错误:"人工智能"、"摩尔定律"(单词应该用名词卡片)
"""
component: str = "fancy_text"
text: str = "" # 短语观点概括,非单词
style: str = "emphasis" # emphasis, term, number
startMs: int = 0
endMs: int = 0
reason: str = "" # 为什么建议这个
@dataclass
class TermCardSuggestion:
"""名词卡片建议"""
component: str = "term_card"
chinese: str = ""
english: str = ""
description: str = ""
firstAppearanceMs: int = 0
displayDurationSeconds: int = 6
@dataclass
class QuoteCalloutSuggestion:
"""金句卡片建议"""
component: str = "quote_callout"
text: str = ""
author: str = ""
startMs: int = 0
durationMs: int = 5000
reason: str = ""
@dataclass
class AnimatedStatsSuggestion:
"""数据动画建议"""
component: str = "animated_stats"
prefix: str = ""
number: int = 0
unit: str = ""
label: str = ""
startMs: int = 0
durationMs: int = 4000
@dataclass
class BulletPointsSuggestion:
"""要点列表建议"""
component: str = "bullet_points"
title: str = ""
points: List[str] = field(default_factory=list)
startMs: int = 0
durationMs: int = 6000
@dataclass
class SocialBarSuggestion:
"""社交媒体条建议"""
component: str = "social_bar"
platform: str = "twitter" # twitter, weibo, youtube
label: str = "关注"
handle: str = ""
startMs: int = 0
durationMs: int = 8000 # Default 8 seconds for better visibility
@dataclass
class EffectsSuggestion:
"""完整的特效建议"""
theme: str = "notion"
speaker: Optional[LowerThirdSuggestion] = None
chapters: List[ChapterTitleSuggestion] = field(default_factory=list)
fancyTexts: List[FancyTextSuggestion] = field(default_factory=list)
termCards: List[TermCardSuggestion] = field(default_factory=list)
quotes: List[QuoteCalloutSuggestion] = field(default_factory=list)
stats: List[AnimatedStatsSuggestion] = field(default_factory=list)
bulletPoints: List[BulletPointsSuggestion] = field(default_factory=list)
socialBars: List[SocialBarSuggestion] = field(default_factory=list)
def parse_srt(srt_path: str) -> List[dict]:
"""
解析 SRT 字幕文件
返回:
List of {index, start_ms, end_ms, text}
"""
with open(srt_path, 'r', encoding='utf-8') as f:
content = f.read()
# SRT pattern: index, timestamp, text
pattern = r'(\d+)\n(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})\n((?:.*(?:\n|$))*?)(?=\n\d+\n|\Z)'
matches = re.findall(pattern, content, re.MULTILINE)
subtitles = []
for match in matches:
index = int(match[0])
start_time = match[1]
end_time = match[2]
text = match[3].strip()
# Convert timestamp to milliseconds
start_ms = timestamp_to_ms(start_time)
end_ms = timestamp_to_ms(end_time)
subtitles.append({
'index': index,
'start_ms': start_ms,
'end_ms': end_ms,
'text': text
})
return subtitles
def timestamp_to_ms(timestamp: str) -> int:
"""Convert SRT timestamp to milliseconds"""
# Format: HH:MM:SS,mmm
parts = timestamp.replace(',', ':').split(':')
hours = int(parts[0])
minutes = int(parts[1])
seconds = int(parts[2])
ms = int(parts[3])
return hours * 3600000 + minutes * 60000 + seconds * 1000 + ms
def get_full_text(subtitles: List[dict]) -> str:
"""获取完整文本用于分析"""
return '\n'.join([s['text'] for s in subtitles])
def find_subtitle_at_time(subtitles: List[dict], time_ms: int) -> Optional[dict]:
"""找到指定时间点的字幕"""
for s in subtitles:
if s['start_ms'] <= time_ms <= s['end_ms']:
return s
return None
def suggestion_to_config(suggestion: EffectsSuggestion) -> dict:
"""
将建议转换为视频处理配置格式
Args:
suggestion: EffectsSuggestion 对象
Returns:
可直接用于 video_processor 的配置字典
"""
config = {
'theme': suggestion.theme,
'keyPhrases': [],
'termDefinitions': [],
'lowerThirds': [],
'chapterTitles': [],
'quotes': [],
'stats': [],
'bulletPoints': [],
'socialBars': []
}
# 转换花字
for ft in suggestion.fancyTexts:
config['keyPhrases'].append({
'text': ft.text,
'style': ft.style,
'startMs': ft.startMs,
'endMs': ft.endMs
})
# 转换名词卡片
for tc in suggestion.termCards:
config['termDefinitions'].append({
'chinese': tc.chinese,
'english': tc.english,
'description': tc.description,
'firstAppearanceMs': tc.firstAppearanceMs,
'displayDurationSeconds': tc.displayDurationSeconds
})
# 转换人物条
if suggestion.speaker:
config['lowerThirds'].append({
'name': suggestion.speaker.name,
'role': suggestion.speaker.role,
'company': suggestion.speaker.company,
'startMs': suggestion.speaker.startMs,
'durationMs': suggestion.speaker.durationMs
})
# 转换章节标题
for ch in suggestion.chapters:
config['chapterTitles'].append({
'number': ch.number,
'title': ch.title,
'subtitle': ch.subtitle,
'startMs': ch.startMs,
'durationMs': ch.durationMs
})
# 转换金句
for q in suggestion.quotes:
config['quotes'].append({
'text': q.text,
'author': q.author,
'startMs': q.startMs,
'durationMs': q.durationMs
})
# 转换数据动画
for s in suggestion.stats:
config['stats'].append({
'prefix': s.prefix,
'number': s.number,
'unit': s.unit,
'label': s.label,
'startMs': s.startMs,
'durationMs': s.durationMs
})
# 转换要点列表
for bp in suggestion.bulletPoints:
config['bulletPoints'].append({
'title': bp.title,
'points': bp.points,
'startMs': bp.startMs,
'durationMs': bp.durationMs
})
# 转换社交媒体条
for sb in suggestion.socialBars:
config['socialBars'].append({
'platform': sb.platform,
'label': sb.label,
'handle': sb.handle,
'startMs': sb.startMs,
'durationMs': sb.durationMs
})
return config
def format_suggestions_for_review(suggestion: EffectsSuggestion) -> str:
"""
将建议格式化为可读的审核格式
Returns:
Markdown 格式的建议列表
"""
lines = []
lines.append(f"## 视觉特效建议\n")
lines.append(f"**主题**: {suggestion.theme}\n")
# 人物条
if suggestion.speaker:
lines.append("### 1. 人物条 (Lower Third)")
lines.append(f"- **姓名**: {suggestion.speaker.name}")
lines.append(f"- **职位**: {suggestion.speaker.role}")
lines.append(f"- **公司**: {suggestion.speaker.company}")
lines.append(f"- **出现时间**: {suggestion.speaker.startMs}ms")
lines.append("")
# 章节标题
if suggestion.chapters:
lines.append("### 2. 章节标题 (Chapter Titles)")
for i, ch in enumerate(suggestion.chapters, 1):
lines.append(f"**{i}. {ch.number} {ch.title}**")
if ch.subtitle:
lines.append(f" 副标题: {ch.subtitle}")
lines.append(f" 时间: {ch.startMs}ms")
lines.append("")
# 花字
if suggestion.fancyTexts:
lines.append("### 3. 花字高亮 (Fancy Text)")
for i, ft in enumerate(suggestion.fancyTexts, 1):
lines.append(f"{i}. **{ft.text}** ({ft.style})")
lines.append(f" 时间: {ft.startMs}ms - {ft.endMs}ms")
if ft.reason:
lines.append(f" 原因: {ft.reason}")
lines.append("")
# 名词卡片
if suggestion.termCards:
lines.append("### 4. 名词解释卡片 (Term Cards)")
for i, tc in enumerate(suggestion.termCards, 1):
lines.append(f"{i}. **{tc.chinese}** ({tc.english})")
lines.append(f" {tc.description}")
lines.append(f" 时间: {tc.firstAppearanceMs}ms")
lines.append("")
# 金句
if suggestion.quotes:
lines.append("### 5. 金句卡片 (Quote Callouts)")
for i, q in enumerate(suggestion.quotes, 1):
lines.append(f'{i}. "{q.text}"')
if q.author:
lines.append(f" — {q.author}")
lines.append(f" 时间: {q.startMs}ms")
if q.reason:
lines.append(f" 原因: {q.reason}")
lines.append("")
# 数据动画
if suggestion.stats:
lines.append("### 6. 数据动画 (Animated Stats)")
for i, s in enumerate(suggestion.stats, 1):
lines.append(f"{i}. {s.prefix}{s.number}{s.unit}")
if s.label:
lines.append(f" {s.label}")
lines.append(f" 时间: {s.startMs}ms")
lines.append("")
# 要点列表
if suggestion.bulletPoints:
lines.append("### 7. 要点列表 (Bullet Points)")
for i, bp in enumerate(suggestion.bulletPoints, 1):
lines.append(f"{i}. **{bp.title}**")
for point in bp.points:
lines.append(f" - {point}")
lines.append(f" 时间: {bp.startMs}ms")
lines.append("")
# 社交媒体条
if suggestion.socialBars:
lines.append("### 8. 社交媒体条 (Social Bar)")
for i, sb in enumerate(suggestion.socialBars, 1):
lines.append(f"{i}. **{sb.platform}**: {sb.handle}")
lines.append(f" 标签: {sb.label}")
lines.append(f" 时间: {sb.startMs}ms,时长: {sb.durationMs}ms")
lines.append("")
return '\n'.join(lines)
# For Claude to use when analyzing content
ANALYSIS_PROMPT = """
请分析以下访谈字幕内容,并建议适合的视觉特效组件。
## 可用组件类型
1. **人物条 (lower_third)**: 显示嘉宾姓名、职位、公司
- 通常在视频开头出现
2. **章节标题 (chapter_title)**: 话题切换时的大标题
- 当话题明显转换时使用
3. **花字 (fancy_text)**: 用短语概括当前观点
- style: emphasis(强调)、term(术语)、number(数字)
- ⚠️ 重要规范:
- 必须是短语形式,概括说话人当时的观点(如"AI发展是平滑曲线")
- 不能只是单词(如"人工智能")
- 不能与名词卡片的内容重复
- 位置在字幕上方区域,不遮挡人脸
4. **名词卡片 (term_card)**: 解释专业术语
- 用于解释专业名词的定义和含义
- 与花字互补:花字概括观点,名词卡片解释术语
5. **金句卡片 (quote_callout)**: 突出精彩观点
- 用于特别有洞察力或可引用的完整句子
6. **数据动画 (animated_stats)**: 展示统计数字
- 当提到具体数字、百分比时使用
7. **要点列表 (bullet_points)**: 总结核心观点
- 当内容可以概括为几个要点时使用
8. **社交媒体条 (social_bar)**: 关注引导
- platform: twitter/weibo/youtube
- 通常在视频结尾出现,时长建议 8-10 秒
## 字幕内容
{subtitle_content}
## 输出格式
请以 JSON 格式输出建议,包含以下字段:
- theme: 推荐主题 (notion/cyberpunk/apple/aurora)
- speaker: 人物条信息
- chapters: 章节标题列表
- fancyTexts: 花字列表(短语观点,非单词)
- termCards: 名词卡片列表(术语解释)
- quotes: 金句列表
- stats: 数据动画列表
- bulletPoints: 要点列表
- socialBars: 社交媒体条列表
每个组件都需要包含 startMs (开始时间毫秒) 字段。
"""
if __name__ == '__main__':
# Test parsing
import sys
if len(sys.argv) > 1:
srt_path = sys.argv[1]
subtitles = parse_srt(srt_path)
print(f"Parsed {len(subtitles)} subtitles")
for s in subtitles[:5]:
print(f" [{s['start_ms']}ms] {s['text'][:50]}...")
"""
Fancy text generator with pop art style effects
"""
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import numpy as np
from animations import spring, interpolate
class FancyTextGenerator:
def __init__(self, width=1920, height=1080, fps=30):
self.width = width
self.height = height
self.fps = fps
# 字体配置
self.font_paths = [
"/System/Library/Fonts/STHeiti Medium.ttc", # macOS 中文字体(首选)
"/System/Library/Fonts/STHeiti Light.ttc", # macOS 中文字体(备选)
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf", # macOS Unicode 字体
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", # Linux
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", # Linux 文泉驿
"C:\\Windows\\Fonts\\msyh.ttc", # Windows 微软雅黑
"C:\\Windows\\Fonts\\simhei.ttf", # Windows 黑体
]
self.font_size = 52
def _get_font(self, size=None):
"""尝试加载可用字体"""
if size is None:
size = self.font_size
for font_path in self.font_paths:
try:
return ImageFont.truetype(font_path, size)
except:
continue
# 如果都失败,使用默认字体
return ImageFont.load_default()
def create_text_frame(self, text, frame, start_frame, duration_frames, style='emphasis', position=(960, 300)):
"""
创建单帧花字图片
参数:
text: 文字内容
frame: 当前帧
start_frame: 开始帧
duration_frames: 总帧数
style: 样式类型 ('emphasis', 'term', 'number')
position: 位置 (x, y)
返回:
PIL Image 对象(RGBA格式)
"""
relative_frame = frame - start_frame
# 创建透明画布
img = Image.new('RGBA', (self.width, self.height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 加载字体
font = self._get_font()
# 计算spring动画
scale = spring(
relative_frame,
self.fps,
from_value=0.8,
to_value=1.15,
damping=12,
stiffness=200
)
# 计算淡出
fade_start = duration_frames - 10
opacity = interpolate(
relative_frame,
[fade_start, duration_frames],
[1.0, 0.0],
extrapolate='clamp'
)
# 计算抖动
wobble = np.sin(relative_frame * 0.2) * 3 # 度数
# 根据样式选择颜色
if style == 'emphasis':
# 黄色主体 + 红色描边
text_color = (255, 237, 78) # 黄色
stroke_color = (255, 23, 68) # 红色
stroke_width = 4
elif style == 'term':
# 青色主体 + 品红描边
text_color = (0, 229, 255) # 青色
stroke_color = (233, 30, 99) # 品红
stroke_width = 4
else: # number
# 橙色主体 + 深蓝描边
text_color = (255, 109, 0) # 橙色
stroke_color = (26, 35, 126) # 深蓝
stroke_width = 4
# 添加透明度
text_color_with_alpha = (*text_color, int(255 * opacity))
stroke_color_with_alpha = (*stroke_color, int(255 * opacity))
# 获取文字边界
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 创建临时图像(足够大以容纳旋转后的文字)
temp_size = int(max(text_width, text_height) * 2 * scale) + 100
temp_img = Image.new('RGBA', (temp_size, temp_size), (0, 0, 0, 0))
temp_draw = ImageDraw.Draw(temp_img)
# 在临时图像中心绘制文字
temp_center = temp_size // 2
text_x = temp_center - text_width // 2
text_y = temp_center - text_height // 2
# 绘制描边(通过偏移绘制多次)
for offset_x in range(-stroke_width, stroke_width + 1):
for offset_y in range(-stroke_width, stroke_width + 1):
if offset_x != 0 or offset_y != 0:
temp_draw.text(
(text_x + offset_x, text_y + offset_y),
text,
font=font,
fill=stroke_color_with_alpha
)
# 绘制主文字
temp_draw.text((text_x, text_y), text, font=font, fill=text_color_with_alpha)
# 应用缩放和旋转
scaled_size = (int(temp_size * scale), int(temp_size * scale))
temp_img = temp_img.resize(scaled_size, Image.Resampling.LANCZOS)
temp_img = temp_img.rotate(wobble, expand=True, resample=Image.Resampling.BICUBIC)
# 添加阴影(drop shadow)
shadow = temp_img.copy()
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=5))
# 将临时图像粘贴到主画布
paste_x = position[0] - temp_img.width // 2 + 6 # 阴影偏移
paste_y = position[1] - temp_img.height // 2 + 6
img.paste(shadow, (paste_x, paste_y), shadow)
paste_x = position[0] - temp_img.width // 2
paste_y = position[1] - temp_img.height // 2
img.paste(temp_img, (paste_x, paste_y), temp_img)
return img
def generate_text_clip(self, keyword, index):
"""
生成完整的花字视频片段
参数:
keyword: {text, startMs, endMs, style}
index: 花字索引(用于定位)
返回:
MoviePy VideoClip
"""
from moviepy import VideoClip
start_time = keyword['startMs'] / 1000.0
end_time = keyword['endMs'] / 1000.0
duration = end_time - start_time
start_frame = int(start_time * self.fps)
duration_frames = int(duration * self.fps)
# 计算位置(左右交替)
x_offset = (index % 2) * 400
y_offset = (index // 2) * 100
position = (self.width // 2 - 300 + x_offset, 300 + y_offset)
# 生成每一帧
def make_frame(t):
frame = int(t * self.fps)
img = self.create_text_frame(
keyword['text'],
start_frame + frame,
start_frame,
duration_frames,
keyword['style'],
position
)
return np.array(img)
clip = VideoClip(make_frame, duration=duration)
clip = clip.with_start(start_time)
return clip
"""
Term definition card generator
"""
from PIL import Image, ImageDraw, ImageFont
import numpy as np
from animations import spring, interpolate
class TermCardGenerator:
def __init__(self, width=1920, height=1080, fps=30):
self.width = width
self.height = height
self.fps = fps
# 卡片配置
self.card_width = 400
self.card_height = 250
self.position = (self.width - 50 - self.card_width, 50) # 右上角
# 字体路径
self.font_paths_zh = [
"/System/Library/Fonts/STHeiti Medium.ttc", # macOS 华文黑体(首选)
"/System/Library/Fonts/STHeiti Light.ttc", # macOS 华文黑体细体
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf", # macOS Unicode
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", # Linux
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", # Linux 文泉驿
"C:\\Windows\\Fonts\\msyh.ttc", # Windows 微软雅黑
"C:\\Windows\\Fonts\\simhei.ttf", # Windows 黑体
]
self.font_paths_en = [
"/System/Library/Fonts/Supplemental/Arial.ttf", # macOS
"/System/Library/Fonts/Helvetica.ttc", # macOS
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux
"C:\\Windows\\Fonts\\arial.ttf", # Windows
]
def _get_font(self, font_paths, size):
"""尝试加载可用字体"""
for font_path in font_paths:
try:
return ImageFont.truetype(font_path, size)
except:
continue
return ImageFont.load_default()
def create_card_frame(self, term, frame, start_frame, duration_frames):
"""
创建单帧卡片图片
参数:
term: {chinese, english, description, displayDurationSeconds}
frame: 当前帧
start_frame: 开始帧
duration_frames: 总帧数
返回:
PIL Image 对象(RGBA格式)
"""
relative_frame = frame - start_frame
# 创建透明画布
img = Image.new('RGBA', (self.width, self.height), (0, 0, 0, 0))
# Spring 滑入动画
slide_progress = spring(
relative_frame,
self.fps,
from_value=0,
to_value=1,
damping=15,
stiffness=180
)
translate_x = interpolate(
slide_progress,
[0, 1],
[100, 0],
extrapolate='clamp'
)
scale = interpolate(
slide_progress,
[0, 1],
[0.8, 1.0],
extrapolate='clamp'
)
# 淡出动画
exit_start = duration_frames - int(0.5 * self.fps)
opacity = interpolate(
relative_frame,
[exit_start, duration_frames],
[1.0, 0.0],
extrapolate='clamp'
)
# 呼吸效果
breathe = np.sin(relative_frame * 0.1) * 2
# 创建卡片图像
card_width = int(self.card_width * scale)
card_height = int(self.card_height * scale)
card_img = Image.new('RGBA', (card_width, card_height), (0, 0, 0, 0))
card_draw = ImageDraw.Draw(card_img)
# 绘制卡片背景(圆角矩形)
bg_color = (0, 0, 0, int(217 * opacity)) # rgba(0,0,0,0.85) * opacity
card_draw.rounded_rectangle(
[(0, 0), (card_width, card_height)],
radius=16,
fill=bg_color
)
# 绘制渐变边框(简化版,使用多层矩形模拟)
for i in range(2):
border_alpha = int(255 * opacity * (1 - i * 0.5))
border_colors = [
(255, 215, 0, border_alpha), # 金色
(78, 205, 196, border_alpha), # 青色
(255, 107, 107, border_alpha) # 红色
]
color_index = (int(relative_frame * 0.02) + i) % len(border_colors)
card_draw.rounded_rectangle(
[(i, i), (card_width - i, card_height - i)],
radius=16,
outline=border_colors[color_index],
width=2
)
# 加载字体
title_font = self._get_font(self.font_paths_zh, 32)
subtitle_font = self._get_font(self.font_paths_en, 16)
desc_font = self._get_font(self.font_paths_zh, 16)
# 标题(中文术语)
title_color = (255, 215, 0, int(255 * opacity)) # 金色
card_draw.text((24, 24), term['chinese'], font=title_font, fill=title_color)
# 副标题(英文)
subtitle_color = (176, 176, 176, int(255 * opacity)) # 灰色
card_draw.text((24, 64), term['english'], font=subtitle_font, fill=subtitle_color)
# 描述
desc_color = (224, 224, 224, int(255 * opacity)) # 浅灰
# 简单的文字换行
description = term['description']
max_width = card_width - 48
lines = []
words = description
# 简化版:每30个字符一行
for i in range(0, len(words), 30):
lines.append(words[i:i+30])
y_pos = 100
for line in lines[:4]: # 最多4行
card_draw.text((24, y_pos), line, font=desc_font, fill=desc_color)
y_pos += 24
# 计算最终位置
final_x = self.position[0] + translate_x + breathe
final_y = self.position[1]
# 粘贴卡片到主画布
img.paste(card_img, (int(final_x), int(final_y)), card_img)
return img
def generate_card_clip(self, term):
"""
生成完整的卡片视频片段
"""
from moviepy import VideoClip
start_time = term['firstAppearanceMs'] / 1000.0
duration = term.get('displayDurationSeconds', 6)
start_frame = int(start_time * self.fps)
duration_frames = int(duration * self.fps)
def make_frame(t):
frame = int(t * self.fps)
img = self.create_card_frame(
term,
start_frame + frame,
start_frame,
duration_frames
)
return np.array(img)
clip = VideoClip(make_frame, duration=duration)
clip = clip.with_start(start_time)
return clip
#!/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()
/**
* Visual effects CSS for interview video processor
* Replaces PIL-based rendering with native CSS effects
*/
/* ===== Reset & Base ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
overflow: hidden;
}
/* ===== Fancy Text Styles ===== */
.fancy-text {
position: absolute;
font-family: "STHeiti", "Heiti SC", "Microsoft YaHei", "PingFang SC", sans-serif;
font-size: 52px;
font-weight: bold;
white-space: nowrap;
transform-origin: center center;
}
/* Style: emphasis - Yellow text with red stroke */
.fancy-text.style-emphasis {
color: #FFED4E;
-webkit-text-stroke: 4px #FF1744;
text-shadow:
6px 6px 8px rgba(0, 0, 0, 0.5),
0 0 20px rgba(255, 23, 68, 0.3);
paint-order: stroke fill;
}
/* Style: term - Cyan text with magenta stroke */
.fancy-text.style-term {
color: #00E5FF;
-webkit-text-stroke: 4px #E91E63;
text-shadow:
6px 6px 8px rgba(0, 0, 0, 0.5),
0 0 20px rgba(233, 30, 99, 0.3);
paint-order: stroke fill;
}
/* Style: number - Orange text with deep blue stroke */
.fancy-text.style-number {
color: #FF6D00;
-webkit-text-stroke: 4px #1A237E;
text-shadow:
6px 6px 8px rgba(0, 0, 0, 0.5),
0 0 20px rgba(26, 35, 126, 0.3);
paint-order: stroke fill;
}
/* ===== Term Card Styles ===== */
.term-card {
position: absolute;
width: 400px;
padding: 24px;
background: rgba(0, 0, 0, 0.85);
border-radius: 16px;
transform-origin: center center;
/* Glassmorphism effect */
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
/* Animated gradient border */
.term-card::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border-radius: 18px;
background: linear-gradient(
var(--border-angle, 0deg),
#FFD700,
#4ECDC4,
#FF6B6B,
#FFD700
);
z-index: -1;
animation: borderRotate 3s linear infinite;
}
@keyframes borderRotate {
0% { --border-angle: 0deg; }
100% { --border-angle: 360deg; }
}
/* For browsers that don't support @property */
@supports not (background: linear-gradient(var(--border-angle, 0deg), red, blue)) {
.term-card::before {
background: linear-gradient(45deg, #FFD700, #4ECDC4, #FF6B6B, #FFD700);
background-size: 300% 300%;
animation: gradientShift 3s ease infinite;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
}
.term-card .title {
font-family: "STHeiti", "Heiti SC", "Microsoft YaHei", "PingFang SC", sans-serif;
font-size: 32px;
font-weight: bold;
color: #FFD700;
margin-bottom: 8px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.term-card .subtitle {
font-family: Arial, Helvetica, sans-serif;
font-size: 16px;
color: #B0B0B0;
margin-bottom: 16px;
}
.term-card .description {
font-family: "STHeiti", "Heiti SC", "Microsoft YaHei", "PingFang SC", sans-serif;
font-size: 16px;
color: #E0E0E0;
line-height: 1.6;
}
/* Breathing animation for card */
@keyframes breathe {
0%, 100% { transform: translateX(var(--breathe-x, 0px)); }
50% { transform: translateX(calc(var(--breathe-x, 0px) + 3px)); }
}
.term-card.breathing {
animation: breathe 2s ease-in-out infinite;
}
/* ===== Animation States ===== */
/* Initial state for animations */
.fancy-text.animate-init {
opacity: 0;
transform: scale(0.8);
}
.term-card.animate-init {
opacity: 0;
transform: translateX(100px) scale(0.8);
}
/* CSS custom property for border angle animation */
@property --border-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
/**
* Apple Minimalist Theme
* 极简设计,纯净配色,精致排版
*/
:root {
--primary: #1D1D1F;
--secondary: #86868B;
--accent: #0071E3;
--bg-light: rgba(255, 255, 255, 0.72);
--bg-dark: rgba(29, 29, 31, 0.72);
--text-dark: #1D1D1F;
--text-light: #FFFFFF;
}
/* ===== Fancy Text - Apple ===== */
.fancy-text.theme-apple {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
}
.fancy-text.theme-apple.style-emphasis {
color: var(--text-light);
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
-webkit-text-stroke: none;
}
.fancy-text.theme-apple.style-term {
color: var(--text-light);
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
-webkit-text-stroke: none;
}
.fancy-text.theme-apple.style-number {
color: var(--accent);
text-shadow: 0 2px 20px rgba(0, 113, 227, 0.4);
-webkit-text-stroke: none;
}
/* ===== Term Card - Apple ===== */
.term-card.theme-apple {
background: var(--bg-light);
border: none;
border-radius: 18px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.term-card.theme-apple::before {
display: none;
}
.term-card.theme-apple .title {
color: var(--text-dark);
font-family: -apple-system, "SF Pro Display", "PingFang SC", sans-serif;
font-weight: 700;
font-size: 28px;
letter-spacing: -0.02em;
text-shadow: none;
}
.term-card.theme-apple .subtitle {
color: var(--secondary);
font-family: -apple-system, "SF Pro Text", sans-serif;
font-weight: 400;
font-size: 14px;
letter-spacing: 0;
}
.term-card.theme-apple .description {
color: var(--text-dark);
font-family: -apple-system, "SF Pro Text", "PingFang SC", sans-serif;
font-weight: 400;
font-size: 15px;
line-height: 1.5;
opacity: 0.8;
}
/**
* Aurora Gradient Theme
* 彩虹渐变流光效果,梦幻极光感
*/
:root {
--gradient-start: #667EEA;
--gradient-mid: #764BA2;
--gradient-end: #F093FB;
--bg-dark: rgba(15, 15, 35, 0.9);
--text-light: #FFFFFF;
--text-muted: #B8B8D0;
}
/* ===== Fancy Text - Aurora ===== */
.fancy-text.theme-aurora {
font-family: "Avenir Next", "PingFang SC", sans-serif;
font-weight: 700;
letter-spacing: 0.02em;
}
.fancy-text.theme-aurora.style-emphasis {
background: linear-gradient(
135deg,
#667EEA 0%,
#764BA2 50%,
#F093FB 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
filter: drop-shadow(0 4px 20px rgba(102, 126, 234, 0.5));
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.3);
}
.fancy-text.theme-aurora.style-term {
background: linear-gradient(
135deg,
#F093FB 0%,
#F5576C 50%,
#FFD194 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
filter: drop-shadow(0 4px 20px rgba(240, 147, 251, 0.5));
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.3);
}
.fancy-text.theme-aurora.style-number {
background: linear-gradient(
135deg,
#4FACFE 0%,
#00F2FE 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
filter: drop-shadow(0 4px 20px rgba(79, 172, 254, 0.5));
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.3);
}
/* ===== Term Card - Aurora ===== */
.term-card.theme-aurora {
background: var(--bg-dark);
border: none;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
backdrop-filter: blur(12px);
position: relative;
overflow: visible;
}
.term-card.theme-aurora::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(
var(--border-angle, 0deg),
#667EEA,
#764BA2,
#F093FB,
#F5576C,
#667EEA
);
border-radius: 18px;
z-index: -1;
animation: auroraFlow 4s linear infinite;
}
@keyframes auroraFlow {
0% { --border-angle: 0deg; filter: hue-rotate(0deg); }
100% { --border-angle: 360deg; filter: hue-rotate(360deg); }
}
/* Fallback for browsers without @property support */
@supports not (background: linear-gradient(var(--border-angle), red, blue)) {
.term-card.theme-aurora::before {
background: linear-gradient(45deg, #667EEA, #764BA2, #F093FB, #F5576C);
background-size: 400% 400%;
animation: auroraShift 4s ease infinite;
}
@keyframes auroraShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
}
.term-card.theme-aurora .title {
background: linear-gradient(135deg, #667EEA, #F093FB);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-family: "Avenir Next", "PingFang SC", sans-serif;
font-weight: 700;
font-size: 28px;
}
.term-card.theme-aurora .subtitle {
color: var(--text-muted);
font-family: "SF Mono", monospace;
font-size: 13px;
opacity: 0.8;
}
.term-card.theme-aurora .description {
color: var(--text-light);
font-family: -apple-system, "PingFang SC", sans-serif;
font-size: 15px;
line-height: 1.6;
opacity: 0.9;
}
/* CSS custom property for border angle */
@property --border-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
/**
* Cyberpunk Neon Theme
* 霓虹发光效果,青色/品红双色光晕
*/
:root {
--primary: #00F5FF;
--secondary: #FF00FF;
--accent: #FFFF00;
--bg-dark: #0D0D0D;
--bg-card: rgba(13, 13, 13, 0.85);
--text-light: #FFFFFF;
--text-muted: #888888;
}
/* ===== Fancy Text - Cyberpunk ===== */
.fancy-text.theme-cyberpunk {
font-family: "Orbitron", "STHeiti", "Heiti SC", sans-serif;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.fancy-text.theme-cyberpunk.style-emphasis {
color: var(--primary);
text-shadow:
0 0 10px var(--primary),
0 0 20px var(--primary),
0 0 40px var(--primary),
0 0 80px var(--secondary);
-webkit-text-stroke: none;
}
.fancy-text.theme-cyberpunk.style-term {
color: var(--secondary);
text-shadow:
0 0 10px var(--secondary),
0 0 20px var(--secondary),
0 0 40px var(--secondary),
0 0 80px var(--primary);
-webkit-text-stroke: none;
}
.fancy-text.theme-cyberpunk.style-number {
color: var(--accent);
text-shadow:
0 0 10px var(--accent),
0 0 20px var(--accent),
0 0 40px var(--primary);
-webkit-text-stroke: none;
}
/* ===== Term Card - Cyberpunk ===== */
.term-card.theme-cyberpunk {
background: var(--bg-card);
border: 1px solid var(--primary);
border-radius: 4px;
box-shadow:
0 0 20px rgba(0, 245, 255, 0.3),
inset 0 0 20px rgba(0, 245, 255, 0.1);
backdrop-filter: blur(10px);
}
.term-card.theme-cyberpunk::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(
var(--border-angle, 0deg),
var(--primary),
var(--secondary),
var(--primary)
);
border-radius: 6px;
z-index: -1;
opacity: 0.8;
animation: borderGlow 2s ease-in-out infinite alternate;
}
@keyframes borderGlow {
0% { opacity: 0.5; filter: blur(2px); }
100% { opacity: 1; filter: blur(4px); }
}
.term-card.theme-cyberpunk .title {
color: var(--primary);
font-family: "Orbitron", "STHeiti", sans-serif;
font-weight: 700;
text-shadow: 0 0 10px var(--primary);
letter-spacing: 0.05em;
}
.term-card.theme-cyberpunk .subtitle {
color: var(--secondary);
font-family: "Courier New", monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.term-card.theme-cyberpunk .description {
color: var(--text-light);
font-family: "STHeiti", sans-serif;
opacity: 0.9;
}
/**
* Notion Style Theme
* 温暖的奶油色调,手写风格,知识分享感
*/
:root {
--primary: #37352F;
--secondary: #9B9A97;
--accent: #E16259;
--accent-yellow: #DFAB01;
--accent-blue: #0B6E99;
--bg-cream: #FFFDF7;
--bg-card: rgba(255, 253, 247, 0.95);
--bg-highlight: #FBF3DB;
--text-dark: #37352F;
--text-muted: #787774;
}
/* ===== Fancy Text - Notion ===== */
.fancy-text.theme-notion {
font-family: "Georgia", "Noto Serif SC", "Source Han Serif CN", serif;
font-weight: 700;
letter-spacing: 0;
}
/* 带背景色块的花字 */
.fancy-text.theme-notion.style-emphasis {
color: var(--text-dark);
background: linear-gradient(180deg, transparent 50%, #FBF3DB 50%);
padding: 0 8px;
-webkit-text-stroke: none;
text-shadow: none;
transform: rotate(-1deg);
}
.fancy-text.theme-notion.style-term {
color: var(--accent);
background: linear-gradient(180deg, transparent 60%, rgba(225, 98, 89, 0.2) 60%);
padding: 0 8px;
-webkit-text-stroke: none;
text-shadow: none;
transform: rotate(0.5deg);
}
.fancy-text.theme-notion.style-number {
color: var(--accent-blue);
background: linear-gradient(180deg, transparent 60%, rgba(11, 110, 153, 0.15) 60%);
padding: 0 8px;
-webkit-text-stroke: none;
text-shadow: none;
transform: rotate(-0.5deg);
}
/* ===== Term Card - Notion ===== */
.term-card.theme-notion {
background: var(--bg-card);
border: 1px solid rgba(55, 53, 47, 0.1);
border-radius: 8px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.04),
0 4px 12px rgba(0, 0, 0, 0.06);
backdrop-filter: none;
padding: 20px 24px;
}
.term-card.theme-notion::before {
display: none;
}
/* 左侧彩色条纹 */
.term-card.theme-notion::after {
content: '';
position: absolute;
left: 0;
top: 12px;
bottom: 12px;
width: 4px;
background: linear-gradient(180deg, var(--accent-yellow), var(--accent));
border-radius: 0 2px 2px 0;
}
.term-card.theme-notion .title {
color: var(--text-dark);
font-family: "Georgia", "Noto Serif SC", serif;
font-weight: 700;
font-size: 26px;
text-shadow: none;
margin-bottom: 4px;
}
.term-card.theme-notion .subtitle {
color: var(--text-muted);
font-family: "SF Mono", "Consolas", monospace;
font-size: 13px;
background: rgba(135, 131, 120, 0.1);
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
.term-card.theme-notion .description {
color: var(--text-dark);
font-family: -apple-system, "PingFang SC", sans-serif;
font-size: 15px;
line-height: 1.7;
margin-top: 12px;
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1920, height=1080">
<title>Animated Stats</title>
<link rel="stylesheet" href="../static/css/effects.css">
<link id="theme-css" rel="stylesheet" href="../static/css/theme-notion.css">
<style>
html, body {
width: 1920px;
height: 1080px;
margin: 0;
padding: 0;
background: transparent;
overflow: hidden;
}
.container {
position: relative;
width: 1920px;
height: 1080px;
}
.animated-stats {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 40px;
opacity: 0;
}
.animated-stats .prefix {
font-size: 18px;
opacity: 0.7;
}
.animated-stats .number-row {
display: flex;
align-items: baseline;
gap: 4px;
}
.animated-stats .number {
font-size: 72px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.animated-stats .unit {
font-size: 32px;
font-weight: 500;
}
.animated-stats .label {
font-size: 20px;
opacity: 0.8;
}
/* Theme: Notion */
.animated-stats.theme-notion {
background: rgba(255, 253, 247, 0.95);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.animated-stats.theme-notion .prefix {
color: #787774;
font-family: -apple-system, sans-serif;
}
.animated-stats.theme-notion .number {
color: #E16259;
font-family: "Georgia", serif;
}
.animated-stats.theme-notion .unit {
color: #E16259;
font-family: "Georgia", serif;
}
.animated-stats.theme-notion .label {
color: #37352F;
font-family: -apple-system, "PingFang SC", sans-serif;
}
/* Theme: Cyberpunk */
.animated-stats.theme-cyberpunk {
background: rgba(13, 13, 13, 0.95);
border: 1px solid #00F5FF;
border-radius: 4px;
}
.animated-stats.theme-cyberpunk .prefix {
color: #888;
font-family: "Courier New", monospace;
}
.animated-stats.theme-cyberpunk .number {
color: #00F5FF;
font-family: "Orbitron", sans-serif;
text-shadow: 0 0 20px #00F5FF;
}
.animated-stats.theme-cyberpunk .unit {
color: #FF00FF;
font-family: "Orbitron", sans-serif;
}
.animated-stats.theme-cyberpunk .label {
color: #FFF;
font-family: -apple-system, sans-serif;
}
/* Theme: Apple */
.animated-stats.theme-apple {
background: rgba(255, 255, 255, 0.85);
border-radius: 20px;
backdrop-filter: blur(20px);
}
.animated-stats.theme-apple .prefix {
color: #86868B;
font-family: -apple-system, "SF Pro Text", sans-serif;
}
.animated-stats.theme-apple .number {
color: #1D1D1F;
font-family: -apple-system, "SF Pro Display", sans-serif;
font-weight: 600;
}
.animated-stats.theme-apple .unit {
color: #1D1D1F;
font-family: -apple-system, "SF Pro Display", sans-serif;
}
.animated-stats.theme-apple .label {
color: #1D1D1F;
font-family: -apple-system, "SF Pro Text", sans-serif;
}
/* Theme: Aurora */
.animated-stats.theme-aurora {
background: rgba(15, 15, 35, 0.9);
border-radius: 16px;
border: 1px solid rgba(102, 126, 234, 0.3);
}
.animated-stats.theme-aurora .prefix {
color: #B8B8D0;
font-family: -apple-system, sans-serif;
}
.animated-stats.theme-aurora .number {
background: linear-gradient(135deg, #667EEA, #F093FB);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-family: "Avenir Next", sans-serif;
}
.animated-stats.theme-aurora .unit {
background: linear-gradient(135deg, #F093FB, #F5576C);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-family: "Avenir Next", sans-serif;
}
.animated-stats.theme-aurora .label {
color: #FFF;
font-family: -apple-system, "PingFang SC", sans-serif;
}
</style>
</head>
<body>
<div class="container">
<div id="animatedStats" class="animated-stats theme-notion">
<div class="prefix" id="prefixText"></div>
<div class="number-row">
<span class="number" id="numberText">0</span>
<span class="unit" id="unitText"></span>
</div>
<div class="label" id="labelText"></div>
</div>
</div>
<script src="../static/js/anime.min.js"></script>
<script>
let config = {
prefix: "增长率",
number: 240,
unit: "%",
label: "计算能力年增长",
theme: "notion",
position: { x: 960, y: 540 },
durationMs: 4000
};
let animation = null;
let numberAnimation = null;
function initAnimation(cfg) {
config = cfg || config;
const container = document.getElementById('animatedStats');
const prefixEl = document.getElementById('prefixText');
const numberEl = document.getElementById('numberText');
const unitEl = document.getElementById('unitText');
const labelEl = document.getElementById('labelText');
prefixEl.textContent = config.prefix || '';
prefixEl.style.display = config.prefix ? 'block' : 'none';
numberEl.textContent = '0';
unitEl.textContent = config.unit || '';
labelEl.textContent = config.label || '';
labelEl.style.display = config.label ? 'block' : 'none';
container.className = `animated-stats theme-${config.theme || 'notion'}`;
container.style.left = `${config.position.x - 100}px`;
container.style.top = `${config.position.y - 80}px`;
const totalDuration = config.durationMs;
const exitStart = totalDuration - 500;
const targetNumber = config.number;
animation = anime.timeline({
autoplay: false,
easing: 'linear'
});
// Container appears
animation.add({
targets: container,
opacity: [0, 1],
scale: [0.9, 1],
duration: 400,
easing: 'easeOutQuad'
}, 0);
// Number counting animation
const numberObj = { value: 0 };
animation.add({
targets: numberObj,
value: [0, targetNumber],
duration: 1500,
easing: 'easeOutExpo',
round: 1,
update: function() {
numberEl.textContent = Math.round(numberObj.value);
}
}, 200);
// Exit
animation.add({
targets: container,
opacity: [1, 0],
scale: [1, 0.95],
duration: 400,
easing: 'easeInQuad'
}, exitStart);
return animation;
}
function seekTo(timeMs) {
if (animation) animation.seek(timeMs);
}
function getDuration() {
return animation ? animation.duration : config.durationMs;
}
document.addEventListener('DOMContentLoaded', () => initAnimation(config));
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1920, height=1080">
<title>Fancy Text Effect</title>
<link rel="stylesheet" href="../static/css/effects.css">
<link id="theme-css" rel="stylesheet" href="../static/css/theme-notion.css">
<style>
html, body {
width: 1920px;
height: 1080px;
margin: 0;
padding: 0;
background: transparent;
overflow: hidden;
}
.container {
position: relative;
width: 1920px;
height: 1080px;
}
/* Import web fonts for themes */
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@700&display=swap');
</style>
</head>
<body>
<div class="container">
<div id="fancyText" class="fancy-text animate-init"></div>
</div>
<script src="../static/js/anime.min.js"></script>
<script>
// Configuration will be injected by Playwright
let config = {
text: "示例文字",
style: "emphasis",
theme: "notion",
position: { x: 960, y: 300 },
durationMs: 2000
};
// Animation instance (global for frame seeking)
let animation = null;
// Available themes
const themes = ['cyberpunk', 'apple', 'notion', 'aurora'];
function setTheme(themeName) {
const themeLink = document.getElementById('theme-css');
if (themes.includes(themeName)) {
themeLink.href = `../static/css/theme-${themeName}.css`;
}
}
function initAnimation(cfg) {
config = cfg || config;
// Set theme
if (config.theme) {
setTheme(config.theme);
}
const textEl = document.getElementById('fancyText');
textEl.textContent = config.text;
textEl.className = `fancy-text theme-${config.theme || 'notion'} style-${config.style} animate-init`;
// Center the text at the specified position
textEl.style.left = `${config.position.x}px`;
textEl.style.top = `${config.position.y}px`;
textEl.style.transform = 'translate(-50%, -50%) scale(0.8)';
// Calculate animation durations
const totalDuration = config.durationMs;
const enterDuration = 600;
const exitStart = totalDuration - 300;
const exitDuration = 300;
// Create timeline animation
animation = anime.timeline({
autoplay: false,
easing: 'linear'
});
// Theme-specific animation adjustments
let enterScale = [0.8, 1.15];
let settleScale = [1.15, 1.0];
let wobbleRotate = [3, -3, 2, -2, 0];
if (config.theme === 'apple') {
// Apple: more subtle animation
enterScale = [0.95, 1.02];
settleScale = [1.02, 1.0];
wobbleRotate = [0, 0, 0, 0, 0];
} else if (config.theme === 'notion') {
// Notion: playful slight rotation
enterScale = [0.85, 1.05];
settleScale = [1.05, 1.0];
wobbleRotate = [2, -1, 1, -0.5, 0];
} else if (config.theme === 'aurora') {
// Aurora: smooth floating
enterScale = [0.9, 1.08];
settleScale = [1.08, 1.0];
wobbleRotate = [1, -1, 0.5, -0.5, 0];
}
// Enter animation
animation.add({
targets: textEl,
opacity: [0, 1],
scale: enterScale,
rotate: [0, wobbleRotate[0]],
duration: enterDuration,
easing: 'spring(1, 200, 12, 0)'
}, 0);
// Wobble animation
animation.add({
targets: textEl,
rotate: wobbleRotate,
duration: totalDuration - enterDuration - exitDuration,
easing: 'easeInOutSine'
}, enterDuration);
// Scale settle
animation.add({
targets: textEl,
scale: settleScale,
duration: 300,
easing: 'easeOutQuad'
}, enterDuration);
// Exit animation
animation.add({
targets: textEl,
opacity: [1, 0],
scale: [1.0, 0.9],
duration: exitDuration,
easing: 'easeInQuad'
}, exitStart);
return animation;
}
// Seek to specific time (for frame capture)
function seekTo(timeMs) {
if (animation) {
animation.seek(timeMs);
}
}
// Get total duration
function getDuration() {
return animation ? animation.duration : config.durationMs;
}
// Initialize with default config on load
document.addEventListener('DOMContentLoaded', () => {
initAnimation(config);
});
</script>
</body>
</html>