$npx -y skills add MatrixReligio/ProductVideoCreator --skill subtitles自动生成视频字幕。解析配音文案,生成同步字幕,使用 Remotion 组件渲染。支持中英文双语字幕。
| 1 | # 字幕自动生成技能 |
| 2 | |
| 3 | ## 快速开始:使用模板 |
| 4 | |
| 5 | 项目提供了可复用的字幕组件模板: |
| 6 | |
| 7 | ```tsx |
| 8 | // 从模板导入 |
| 9 | import { SubtitleDisplay, createSubtitlesFromMetadata } from "./components/SubtitleDisplay"; |
| 10 | import type { Subtitle } from "./config/types"; |
| 11 | ``` |
| 12 | |
| 13 | 模板位置:`templates/components/SubtitleDisplay.tsx` |
| 14 | |
| 15 | --- |
| 16 | |
| 17 | ## 重要:字幕时间同步 |
| 18 | |
| 19 | **字幕时间必须基于 voiceover_metadata.json 的 `actual_duration`(实际时长),而非 `target_duration`(目标时长)。** |
| 20 | |
| 21 | ```tsx |
| 22 | // ✅ 正确:使用 actual_duration 计算 end 时间 |
| 23 | const SUBTITLES: Subtitle[] = [ |
| 24 | { |
| 25 | start: segment.start_time, |
| 26 | end: segment.start_time + segment.actual_duration, // 使用实际时长 |
| 27 | text: segment.text |
| 28 | }, |
| 29 | ]; |
| 30 | |
| 31 | // ❌ 错误:使用 target_duration(可能导致字幕与音频不同步) |
| 32 | const SUBTITLES: Subtitle[] = [ |
| 33 | { |
| 34 | start: segment.start_time, |
| 35 | end: segment.start_time + segment.target_duration, // 不要这样做 |
| 36 | text: segment.text |
| 37 | }, |
| 38 | ]; |
| 39 | ``` |
| 40 | |
| 41 | ### 验证字幕时间 |
| 42 | |
| 43 | ```tsx |
| 44 | import { validateSubtitleTiming } from "./components/SubtitleDisplay"; |
| 45 | |
| 46 | const { valid, warnings } = validateSubtitleTiming(SUBTITLES, VIDEO_DURATION); |
| 47 | if (!valid) { |
| 48 | console.warn("字幕时间警告:", warnings); |
| 49 | } |
| 50 | ``` |
| 51 | |
| 52 | --- |
| 53 | |
| 54 | ## 工作流程 |
| 55 | |
| 56 | ``` |
| 57 | 配音元数据 (voiceover_metadata.json) |
| 58 | ↓ |
| 59 | 解析文案和时间线 |
| 60 | ↓ |
| 61 | 生成字幕数据 (subtitles.json) |
| 62 | ↓ |
| 63 | Remotion 字幕组件渲染 |
| 64 | ``` |
| 65 | |
| 66 | ## 字幕数据格式 |
| 67 | |
| 68 | ### 输入:配音元数据 |
| 69 | |
| 70 | ```json |
| 71 | { |
| 72 | "voice": "zh-CN-YunjianNeural", |
| 73 | "total_duration": 85, |
| 74 | "segments": [ |
| 75 | { |
| 76 | "index": 0, |
| 77 | "start_time": 0.5, |
| 78 | "target_duration": 7.0, |
| 79 | "actual_duration": 6.3, |
| 80 | "text": "1993年,黄仁勋创立NVIDIA...", |
| 81 | "language": "zh" |
| 82 | } |
| 83 | ] |
| 84 | } |
| 85 | ``` |
| 86 | |
| 87 | ### 输出:字幕数据 |
| 88 | |
| 89 | ```json |
| 90 | { |
| 91 | "subtitles": [ |
| 92 | { |
| 93 | "id": 0, |
| 94 | "startFrame": 15, |
| 95 | "endFrame": 204, |
| 96 | "text": "1993年,黄仁勋创立NVIDIA", |
| 97 | "language": "zh" |
| 98 | } |
| 99 | ], |
| 100 | "fps": 30, |
| 101 | "style": { |
| 102 | "fontSize": 48, |
| 103 | "fontFamily": "Noto Sans SC", |
| 104 | "color": "#FFFFFF", |
| 105 | "backgroundColor": "rgba(0, 0, 0, 0.7)", |
| 106 | "position": "bottom" |
| 107 | } |
| 108 | } |
| 109 | ``` |
| 110 | |
| 111 | ## 字幕生成脚本 |
| 112 | |
| 113 | ```python |
| 114 | #!/usr/bin/env python3 |
| 115 | """ |
| 116 | 字幕生成脚本 - 从配音元数据生成字幕 |
| 117 | """ |
| 118 | |
| 119 | import json |
| 120 | from pathlib import Path |
| 121 | |
| 122 | FPS = 30 |
| 123 | MAX_CHARS_PER_LINE = 20 # 中文每行最大字符数 |
| 124 | MAX_WORDS_PER_LINE = 8 # 英文每行最大单词数 |
| 125 | |
| 126 | def split_text(text: str, language: str = "zh") -> list: |
| 127 | """ |
| 128 | 将长文本拆分成多行 |
| 129 | """ |
| 130 | if language == "zh": |
| 131 | # 中文按字符拆分 |
| 132 | lines = [] |
| 133 | current_line = "" |
| 134 | for char in text: |
| 135 | if len(current_line) >= MAX_CHARS_PER_LINE: |
| 136 | lines.append(current_line) |
| 137 | current_line = char |
| 138 | else: |
| 139 | current_line += char |
| 140 | if current_line: |
| 141 | lines.append(current_line) |
| 142 | return lines |
| 143 | else: |
| 144 | # 英文按单词拆分 |
| 145 | words = text.split() |
| 146 | lines = [] |
| 147 | current_line = [] |
| 148 | for word in words: |
| 149 | if len(current_line) >= MAX_WORDS_PER_LINE: |
| 150 | lines.append(" ".join(current_line)) |
| 151 | current_line = [word] |
| 152 | else: |
| 153 | current_line.append(word) |
| 154 | if current_line: |
| 155 | lines.append(" ".join(current_line)) |
| 156 | return lines |
| 157 | |
| 158 | def generate_subtitles(metadata_path: str, output_path: str): |
| 159 | """ |
| 160 | 从配音元数据生成字幕文件 |
| 161 | """ |
| 162 | with open(metadata_path, "r", encoding="utf-8") as f: |
| 163 | metadata = json.load(f) |
| 164 | |
| 165 | subtitles = [] |
| 166 | subtitle_id = 0 |
| 167 | |
| 168 | for segment in metadata["segments"]: |
| 169 | text = segment.get("full_text", segment.get("text", "")) |
| 170 | language = segment.get("language", "zh") |
| 171 | start_time = segment["start_time"] |
| 172 | duration = segment["actual_duration"] |
| 173 | |
| 174 | # 拆分长文本 |
| 175 | lines = split_text(text, language) |
| 176 | |
| 177 | # 计算每行显示时长 |
| 178 | line_duration = duration / len(lines) if lines else duration |
| 179 | |
| 180 | for i, line in enumerate(lines): |
| 181 | line_start = start_time + i * line_duration |
| 182 | line_end = line_start + line_duration |
| 183 | |
| 184 | subtitles.append({ |
| 185 | "id": subtitle_id, |
| 186 | "startFrame": int(line_start * FPS), |
| 187 | "endFrame": int(line_end * FPS), |
| 188 | "text": line.strip(), |
| 189 | "language": language |
| 190 | }) |
| 191 | subtitle_id += 1 |
| 192 | |
| 193 | output_data = { |
| 194 | "subtitles": subtitles, |
| 195 | "fps": FPS, |
| 196 | "style": { |
| 197 | "fontSize": 48, |
| 198 | "fontFamily": "Noto Sans SC" if metadata["segments"][0].get("language") == "zh" else "Inter", |
| 199 | "color": "#FFFFFF", |
| 200 | "backgroundColor": "rgba(0, 0, 0, 0.7)", |
| 201 | "position": "bottom", |
| 202 | "padding": "12px 24px", |
| 203 | "borderRadius": "8px" |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | with open(output_path, "w", encoding="utf-8") as f: |
| 208 | json.dump(output_data, f, ensure_ascii=False, indent=2) |
| 209 | |
| 210 | print(f"✓ 生成 {len(subtitles)} 条字幕") |
| 211 | print(f"✓ 输出: {output_path}") |
| 212 | |
| 213 | if __name__ == "__main__": |
| 214 | generate_subtitles( |
| 215 | metadata_path="public/audio/voiceover_metadata.json", |
| 216 | output_path="public/subtitles.json" |
| 217 | ) |
| 218 | ``` |
| 219 | |
| 220 | ## Remotion 字幕组件 |
| 221 | |
| 222 | ### SubtitleDisplay.tsx |
| 223 | |
| 224 | ```tsx |
| 225 | import React from "react"; |
| 226 | import { useCurrentFrame, interpolate, E |