
Srt Remotion Video
- 208 installs
- 31 repo stars
- Updated May 7, 2026
- yangagent/remotion-video-skill
Helps with ai & agent building tasks.
About
srt-remotion-video is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- srt-remotion-video
- AI & Agent Building
- AI-coding skill
Srt Remotion Video by the numbers
- 208 all-time installs (skills.sh)
- +10 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #2,836 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yangagent/remotion-video-skill --skill srt-remotion-videoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 208 |
|---|---|
| repo stars | ★ 31 |
| Last updated | May 7, 2026 |
| Repository | yangagent/remotion-video-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
SRT Remotion Video - 主流程编排
将 SRT 字幕文件转换为 Remotion 视频的完整工作流。
工作流概览
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ ┌──────────┐
│ 获取 SRT │ → │ 依赖预检 │ → │ 项目初始化│ → │ 生成分镜 │ → │ 并行 Creator 规划实现 │ → │ 合成视频 │
│ 文件路径 │ │ / 首次安装│ │ │ │ │ │ scene-plan + code │ │ │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────────────────┘ └──────────┘项目目录结构
<skillRoot>/
├── SKILL.md
├── template/
├── references/
│ ├── storyboard-parser.md
│ └── scene-component-creator.md
└── scripts/
├── ensure-template-deps.js
├── init-project.js
├── generate-storyboard.js
├── generate-creator-scenes.js
├── generate-scenes-registry.js
├── scene-registry-utils.js
├── validate-project.js
└── validate-scene-plan.js
<srtDir>/
├── your-file.srt
└── remotion-video-projects/
└── {yyyy-mm-dd-hh-mm-ss}/Path Contract
主流程和所有 SubAgent 统一使用以下绝对路径约定:
skillRoot: 当前srt-remotion-videoskill 目录的绝对路径templateRoot:{skillRoot}/templatereferencesRoot:{skillRoot}/referencesscriptsRoot:{skillRoot}/scriptssrtPath: 用户提供的 SRT 文件绝对路径projectBaseDir:{dirname(srtPath)}/remotion-video-projectsprojectRoot: 当前项目实例目录,格式为{projectBaseDir}/{projectName}/
强制要求:
- SubAgent prompt 中必须写入展开后的绝对路径,不要只传变量名
- 阶段协议文档只能从
referencesRoot读取 - 脚本只能从
scriptsRoot执行 - 所有运行态状态必须由主 Agent 显式传递
执行流程
步骤 0: 获取 SRT 文件
1. 询问用户 SRT 文件路径 2. 如果用户给的是相对路径,主 Agent 必须先自行判断该文件是否存在 3. 若存在,主 Agent 必须先解析为绝对路径,再将解析后的绝对路径作为 srtPath 4. 若不存在,必须明确反馈用户路径无效,并要求提供正确路径 5. 后续所有步骤统一使用最终确认过的绝对路径 srtPath
步骤 1: 依赖预检与项目初始化
关键:模板始终从 skill 内部复制,所有工作在字幕目录下的独立项目目录进行。
1.0 依赖预检(首次使用时自动安装)
执行:
node "{skillRoot}/scripts/ensure-template-deps.js" "{templateRoot}"脚本会:
1. 检查 {templateRoot}/package.json 和 {templateRoot}/package-lock.json 2. 检查模板关键依赖是否已安装 3. 若未安装,则在 {templateRoot} 下执行一次 npm install 4. 若已安装,则直接跳过安装 5. 安装或校验失败时返回明确错误,并停止主流程
1.1 默认行为:创建新项目
执行:
node "{skillRoot}/scripts/init-project.js" --srt-path "{srtPath}"脚本会:
1. 确保 {dirname(srtPath)}/remotion-video-projects/ 存在 2. 创建新的项目目录 remotion-video-projects/{yyyy-mm-dd-hh-mm-ss}/ 3. 从 {skillRoot}/template/ 复制模板文件;若模板依赖已安装,则一并复制已安装依赖 4. 输出项目信息 JSON
1.2 用户指定项目路径(仅当用户明确指定时)
如果用户明确指定了项目路径,则直接使用该路径作为 projectRoot,跳过默认目录推导。
1.3 记录关键路径
从脚本输出或用户指定路径获取:
projectRootskillRoottemplateRootreferencesRootscriptsRootsrtPath
后续所有步骤都使用这些绝对路径。
步骤 2: 生成分镜脚本
必须使用 SubAgent 执行此步骤。
主 Agent 负责:
1. 计算并展开绝对路径:
storyboardReference = {referencesRoot}/storyboard-parser.mdstoryboardScript = {scriptsRoot}/generate-storyboard.js
2. 启动一个 SubAgent 3. 在 prompt 中写入实际绝对路径值
SubAgent prompt 模板:
你正在执行 srt-remotion-video 工作流的“分镜生成阶段”。
首先读取以下参考协议并严格按其步骤执行:
- {storyboardReference}
输入参数:
- skillRoot: {skillRoot}
- projectRoot: {projectRoot}
- srtPath: {srtPath}
重要:
1. 所有路径都已展开为绝对路径,不要自行猜测
2. 需要执行的脚本位于 {storyboardScript}
3. 完成后必须按参考协议中的“完成后返回”契约,返回结构化结果主流程必须等待返回结果,并读取 storyboard.json 验证结构正确。
步骤 3: 使用 SubAgent 规划并实现场景组件
读取 storyboard.json,获取所有场景数据。
3.0 前置准备:计算分组
const SCENES_PER_CREATOR = 5;
const sceneCount = storyboard.scenes.length;
const creatorCount = Math.ceil(sceneCount / SCENES_PER_CREATOR);creatorId 生成规则固定为:
const creatorId = `creator-${String(index + 1).padStart(2, '0')}`;示例:
- 第 1 个 creator:
creator-01 - 第 2 个 creator:
creator-02 - 第 10 个 creator:
creator-10
主流程必须始终使用该格式,不要省略前导零,不要改成其他命名方式。
3.1 规划 Creator 任务
主流程负责调度:
1. 为每个 creator 生成 scenesDataPath = {projectRoot}/scene-plans/{creatorId}.scenes.json 2. 为每个 creator 计算:
creatorIdplanPath = {projectRoot}/scene-plans/{creatorId}.jsonvalidateScript = {scriptsRoot}/validate-scene-plan.js
3. 对每个 creator 执行:
node "{scriptsRoot}/generate-creator-scenes.js" \
"{projectRoot}/storyboard.json" \
"{creatorId}" \
"{SCENES_PER_CREATOR}" \
"{scenesDataPath}"4. 主 Agent 将 {scenesDataPath} 的绝对路径写入 SubAgent prompt 5. 并行启动全部 creator SubAgent
3.2 并行启动所有 Scene Creator
每个 Creator 的 SubAgent prompt 模板:
你正在执行 srt-remotion-video 工作流的“场景规划与实现阶段”。
首先读取以下参考协议并严格按其步骤执行:
- {referencesRoot}/scene-component-creator.md
输入参数:
- skillRoot: {skillRoot}
- projectRoot: {projectRoot}
- creatorId: {creatorId}
- planPath: {planPath}
- scenesDataPath: {scenesDataPath}
- validateScript: {validateScript}
重要:
1. 所有路径都已展开为绝对路径,不要自行猜测
2. 当前 creator 的场景事实源只允许来自 {scenesDataPath}
3. 规划阶段只使用 {scenesDataPath}、{projectRoot}/cartoon-ui-style-guide.css、{projectRoot}/cartoon-ui-style-guide-reference.md 和 {skillRoot}/../remotion-best-practices/SKILL.md
4. 先生成 scene-plan JSON,再执行校验,校验通过后再编写场景组件
5. `beatPlan` 默认一段字幕对应一个 beat;如果相邻 segments 明显属于同一句连续表达,可以合并
6. 合并仅允许发生在相邻 segments 之间,禁止跳跃式组合
7. `beatPlan` 只声明 `segments` 和 `action`;实际时间必须从 scenesData[].segments 的 `relativeStart / relativeDuration` 推导
8. 场景主节奏必须绑定 scenesData[].segments[].relativeStart / relativeDuration
9. 默认保留宿主背景,在透明根层上围绕画面中部或中上区域组织主视觉;不要重建整屏背景
10. 组件接口固定为 React.FC<{ segments: Segment[] }> 且使用默认导出
11. 只负责产出 {projectRoot}/src/scenes/SceneXXX.tsx;若文件不存在则创建,若已存在则仅修改自己负责的场景文件
12. 不要手改 {projectRoot}/src/compositions/Main.tsx 或 generated-scenes.ts
13. 完成后必须按参考协议中的“完成后返回”契约,返回结构化结果
14. `remotion-best-practices` 与当前 skill 同级,固定入口为 {skillRoot}/../remotion-best-practices/SKILL.md3.3 等待所有 Creator 完成
确认所有目标场景组件文件均已生成。
componentResults 仅用于任务完成反馈,不作为最终注册文件组装或总时长计算的真实来源。步骤 4: 合成视频
4.1 生成场景注册文件
普通视频生成流程不得重写 {projectRoot}/src/compositions/Main.tsx。
模板默认输出规格:1920x1080 / 30fps。
generated-scenes.ts的totalDurationInFrames由generate-scenes-registry.js生成,不要手改Main.tsx中的msToFrames必须使用useVideoConfig().fps动态获取帧率,不能硬编码FPS常量
执行:
node "{scriptsRoot}/generate-scenes-registry.js" \
"{projectRoot}" \
"{projectRoot}/storyboard.json"运行时契约固定:
- 每个
SceneXXX.tsx都通过默认导出暴露组件 generated-scenes.ts负责保存start、duration、segments、ComponentMain.tsx负责用<Component segments={scene.segments} />把分段数据传给场景组件
4.2 Root.tsx 总时长同步
Root.tsx 是模板只读文件,不需要也不允许在普通流程中重写。
要求:
Root.tsx必须保持从generated-scenes.ts读取totalDurationInFrames- 不要在流程中重复手算或手填总帧数
4.3 校验项目产物完整性
渲染前必须执行:
node "{scriptsRoot}/validate-project.js" \
"{projectRoot}" \
"{projectRoot}/storyboard.json"校验失败时必须停止流程,不得继续渲染。
4.4 执行渲染
cd "{projectRoot}"
npx remotion render Main out/output.mp4步骤 5: 完成
通知用户:
- 视频已生成
- 输出路径:
{projectRoot}/out/output.mp4 - 场景数量: N
- 视频时长: X 秒
调试模式与重新渲染
当主视频生成流程(步骤 0–5)完成后,用户可以请求"调试模式"或"重新渲染"。
这两个操作都假定 projectRoot 已存在且场景组件已生成完毕。调试模式
当用户说"调试模式"、"预览模式"或类似表述,并要求添加音频时执行。
TM.0 获取音频文件
1. 如果用户已提供音频文件路径,验证文件存在并解析为绝对路径 audioPath 2. 如果用户未提供音频文件路径,必须向用户询问 3. 验证文件存在,若不存在则反馈用户并停止
TM.1 添加音频到时间轴
1. 确保 {projectRoot}/public/ 目录存在 2. 将音频文件复制为 {projectRoot}/public/audio.mp3
mkdir -p "{projectRoot}/public"
cp "{audioPath}" "{projectRoot}/public/audio.mp3"3. 修改 {projectRoot}/src/compositions/Main.tsx:
- 在 import 行添加
Audio和staticFile:
import { AbsoluteFill, Audio, Sequence, staticFile, useCurrentFrame } from "remotion";- 在
<AbsoluteFill style={{ backgroundColor: ... }}>的直接子级最前面添加:
<Audio src={staticFile("audio.mp3")} />4. 启动 Remotion Studio 供用户预览:
cd "{projectRoot}"
npx remotion studioTM.2 完成通知
告知用户 Remotion Studio 已启动,音频已添加到时间轴,可在浏览器中预览。
重新渲染
当用户说"重新渲染"、"再次渲染"或类似表述时执行。
RR.0 移除时间轴上的音频
渲染最终视频前,必须确保 Main.tsx 中不存在音频组件:
1. 读取 {projectRoot}/src/compositions/Main.tsx 2. 检查是否包含 <Audio 标签 3. 如果存在:
- 移除
<Audio src={staticFile("audio.mp3")} />这一行 - 从 import 语句中移除
Audio和staticFile(如果它们不再被其他代码使用)
4. 如果不存在,跳过此步骤
RR.1 执行渲染
cd "{projectRoot}"
npx remotion render Main out/output.mp4RR.2 完成通知
通知用户视频已重新渲染,输出路径为 {projectRoot}/out/output.mp4。
高分辨率 / 高帧率渲染
当主视频生成流程已经完成,用户要求生成 4K、60fps 或其他高于默认配置(1080p 30fps)的版本时执行。
这个操作假定 projectRoot 已存在且场景组件已生成完毕。关键原则:设计分辨率与输出分辨率分离
场景组件中的所有元素(卡片、图标、文字等)使用绝对像素值,基于 1920x1080 设计。直接将 Root.tsx 的 width/height 改为 3840x2160 会导致所有元素在画面中占比缩小。正确做法是保持设计分辨率 1920x1080 不变,通过 Remotion 的 --scale 参数放大输出分辨率。
常见错误(禁止使用)
| 错误做法 | 后果 |
|---|---|
| 改 Root.tsx 的 width=3840 height=2160 | 所有场景元素占比缩小一半 |
用 --width 3840 --height 2160 CLI 参数 | 同上 |
| 只改 fps 不改 totalDurationInFrames | 视频只有前半段有内容,后半段空白 |
Main.tsx 中硬编码 const FPS = 30 | 改了 Root.tsx fps 后场景时序错乱 |
HR.0 确认用户需求
解析用户需求为具体的输出参数:
| 用户需求 | Root.tsx 修改 | generated-scenes.ts 修改 | 渲染命令 |
|---|---|---|---|
| 4K / 超清 | 不改 | 不改 | --scale 2 |
| 60fps | fps={60} | totalDurationInFrames 按比例换算 | 无需 scale |
| 4K 60fps | fps={60} | totalDurationInFrames 按比例换算 | --scale 2 |
HR.1 修改帧率(仅当用户要求高帧率时)
1. 修改 {projectRoot}/src/Root.tsx 中的 fps 值:
fps={60} // 从 30 改为 602. 修改 {projectRoot}/src/compositions/generated-scenes.ts 中的 totalDurationInFrames:
// 帧数 = 原帧数 × (新fps / 原fps)
// 例如 30→60fps: 1572 × 2 = 3144
export const totalDurationInFrames = {原帧数 × 新fps / 原fps};关键:{projectRoot}/src/Root.tsx 中 <Composition> 的 width 和 height 不要修改,必须保持 1920 和 1080。分辨率放大由渲染时的 --scale 参数完成,而非修改设计分辨率。
Main.tsx中的msToFrames必须使用useVideoConfig().fps动态获取帧率。如果发现Main.tsx中有硬编码的FPS常量,必须先修复为useVideoConfig().fps,否则 fps 变更后场景时序会完全错乱。
HR.2 校验项目
node "{scriptsRoot}/validate-project.js" \
"{projectRoot}" \
"{projectRoot}/storyboard.json"校验失败时必须停止,不得继续渲染。
HR.3 执行渲染
cd "{projectRoot}"
npx remotion render Main out/output-4k.mp4 --scale 2--scale 2:将 1920x1080 的设计画布放大 2 倍渲染为 3840x2160- 矢量元素(文字、SVG)会以更高分辨率渲染,画质更清晰
- 画面布局与 1080p 完全一致,不存在元素缩小的问题
- 如果不需要 4K 只需 60fps,去掉
--scale 2即可
HR.4 完成通知
通知用户:
- 输出路径:
{projectRoot}/out/output-4k.mp4 - 输出分辨率、帧率、时长
- 如有 fps 修改,提醒用户 Root.tsx 中的 fps 已从 30 改为目标值
数据结构参考
storyboard.json
interface Storyboard {
totalDuration: number;
sceneCount: number;
scenes: {
id: string;
startTime: number;
duration: number;
segments: {
text: string;
relativeStart: number;
relativeDuration: number;
}[];
semanticTags?: string[];
visualHint?: string;
}[];
}scene-plan JSON
interface ScenePlanCard {
sceneId: string;
goal: string;
layout: string;
visualCore: string;
surface: string;
emphasis: string;
screenShouldShow: string[];
beatPlan: {
segments: number[];
action: string;
}[];
}SceneComponentResult
interface SceneComponentResult {
sceneId: string;
componentPath: string;
componentName?: string;
planPath?: string;
}Resources
template/
- 轻量模板项目,随 skill 一起分发
- 首次使用时在模板目录执行依赖安装,后续项目复用模板依赖
references/
storyboard-parser.md:分镜生成阶段协议scene-component-creator.md:场景规划与实现阶段协议theme-template-switching.md:主题模板更换指南
scripts/
ensure-template-deps.js:检查模板依赖,必要时执行首次安装init-project.js:根据srtPath初始化项目generate-storyboard.js:根据 SRT 和 groups.json 生成 storyboard.jsongenerate-creator-scenes.js:根据 storyboard.json 为指定 creator 生成 scenesData JSONgenerate-scenes-registry.js:生成generated-scenes.tsscene-registry-utils.js:registry 和校验共用工具validate-project.js:渲染前完整性校验validate-scene-plan.js:校验 scene-plan JSON 结构与 segment 绑定
执行清单
主流程
- [ ] 获取用户提供的 SRT 绝对路径
- [ ] 运行
ensure-template-deps.js检查模板依赖,必要时完成首次安装 - [ ] 运行
init-project.js --srt-path创建项目 - [ ] 获取
projectRoot、skillRoot、templateRoot、referencesRoot、scriptsRoot - [ ] 使用
references/storyboard-parser.md生成storyboard.json - [ ] 验证
storyboard.json结构正确 - [ ] 计算 Creator 分组
- [ ] 使用
references/scene-component-creator.md并行生成 scene-plan 与场景组件 - [ ] 运行
generate-scenes-registry.js - [ ] 运行
validate-project.js - [ ] 执行渲染
调试模式
- [ ] 获取用户提供的音频文件绝对路径
- [ ] 复制音频到
{projectRoot}/public/audio.mp3 - [ ] 修改
Main.tsx添加<Audio>组件 - [ ] 启动 Remotion Studio 供用户预览
重新渲染
- [ ] 检查
Main.tsx是否存在<Audio>标签 - [ ] 若存在则移除
<Audio>及相关 import - [ ] 执行渲染
高分辨率 / 高帧率渲染
- [ ] 解析用户需求为具体的输出分辨率、帧率和 scale 值
- [ ] 确认
Main.tsx中msToFrames使用useVideoConfig().fps而非硬编码常量 - [ ] 仅当需要高帧率时:修改
Root.tsx的fps和generated-scenes.ts的totalDurationInFrames - [ ] 确认
Root.tsx的width/height保持 1920/1080 不变 - [ ] 运行
validate-project.js校验 - [ ] 使用
--scale 2(4K时)执行渲染
注意事项
1. 所有路径必须使用绝对路径 2. SubAgent prompt 中必须传入实际路径值,不能只传变量名 3. 模板资源位于 {skillRoot}/template 4. 模板以轻量形式分发,首次使用时必须先完成 template/ 依赖预检 5. 默认项目目录位于 {dirname(srtPath)}/remotion-video-projects 6. Main.tsx、Root.tsx 属于受保护宿主层 7. 场景组件必须真实消费 segments 8. validate-project.js 失败时不得继续渲染 9. 如果用户想更换主题模板,必须先参考 references/theme-template-switching.md
Scene Component Creator Reference
本文件是 srt-remotion-video 工作流中的“场景规划与实现阶段”参考协议,由主 Agent 指派 SubAgent 读取并执行。
输入契约
skillRoot:srt-remotion-videoskill 的绝对路径projectRoot: 项目根目录绝对路径creatorId: 当前 Creator 标识planPath: 当前 Creator 的 scene-plan 输出路径scenesDataPath: 当前 Creator 的 scenesData 落盘路径validateScript:validate-scene-plan.js的绝对路径
必读资源
开始前必须读取:
1. {scenesDataPath} 2. {projectRoot}/cartoon-ui-style-guide.css 3. {projectRoot}/cartoon-ui-style-guide-reference.md 4. {skillRoot}/../remotion-best-practices/SKILL.md
如果当前场景涉及动画编排、文本动画、时序控制、字幕、音频、资源加载、Composition 配置等 Remotion 常见问题,必须继续按需读取 remotion-best-practices 的相关规则文件。
强制要求:
remotion-best-practices与当前 skill 同级,入口文件固定为{skillRoot}/../remotion-best-practices/SKILL.md- 必须直接读取该文件,不得自行改写为其他目录
- 后续如需读取其规则文件,也必须从该同级 skill 目录继续展开
角色定位
你负责完成局部 scene slice 的规划、校验和实现。
你的职责:
- 以
{scenesDataPath}为准读取当前 creator 的本地 scenesData - 读取设计系统主文件和参考文档并提取所需资源
- 先生成结构化
scene-plan - 运行校验脚本确认
scene-plan合法 - 将通过校验的 plan 落成可渲染的 Remotion 场景组件
- 保持局部场景质量与全局风格一致
你不做的事:
- 不读取完整
storyboard.json - 不维护第二份 CSS 手册
- 不重定义全局宿主层
- 不手改
Main.tsx或generated-scenes.ts - 不把台词原文直接做成字幕卡片
核心原则
1. 先规划,再校验,再实现 2. CSS 主文件和参考文档是设计输入源 3. 负责“为当前场景做出可执行方案”,不是输出全局分析报告 4. 内容转化优先 5. 主视觉优先于容器 6. 主体必须足够大、足够近 7. 避免“组件感”
设计画布规范
所有场景组件统一按 1920x1080 设计画布实现。
- 构图、绝对定位、SVG
viewBox、路径坐标、装饰坐标都按1920x1080设计画布组织 - 可以使用
1920/1080作为设计坐标、SVG viewBox 或局部绘图坐标 - 若使用 SVG,优先写
viewBox="0 0 1920 1080",外层用width="100%" height="100%" useVideoConfig()只用于读取fps做时间换算;不要用 Composition 的width/height推导布局- 禁止写死
30fps;时间换算必须使用useVideoConfig().fps - 若需要画布常量,使用
const DESIGN_WIDTH = 1920、const DESIGN_HEIGHT = 1080
概念模型
scenesData
当前 creator 负责的场景事实数据。它是唯一事实源。
segments
单个 scene 中的字幕分段数组。它是 beat 切分和时序推导的原子单位。
scene-plan
当前 creator 基于 scenesData 生成的结构化规划文件。它是代码实现前的唯一规划产物,也是静态校验输入。
关系约束
scene-plan只描述当前scenesData中的 scenesscene-plan中每个对象只对应一个sceneIdsceneId必须与scenesData[].id一一对应beatPlan只组织当前 scene 的segments- 代码实现阶段只读取
scene-plan和scenesData - 未通过校验前,不得开始编写场景组件
内容转化约束
- 先判断观众最需要看见的关系,再决定哪些词需要上屏
- 优先用图形、结构、图解、关系、动作、对比、流程、空间分布、比喻物表达内容
- 可以保留少量短词、短标签、数字、关键词,作为视觉锚点
- 长句默认不直接上屏;必要概念名或收束锚点可以保留为短文本
screenShouldShow描述最终画面中的图形关系、标签体系、构图重心,不写成长句排版稿beatPlan.action描述画面推进,不写“原句整句出现”“逐字显示原文”之类的动作
文本与图标硬约束
- 屏幕上不得出现超过 6 个连续汉字直接取自台词原文
- 单场景可见文字中,台词原文占比不超过 50%
- 卡片主体必须是图形、结构、图解、关系,而不是完整句子
- 禁止使用 emoji 作为图标、表情提示、项目符号或装饰元素
- 如需表达情绪、提醒、状态、方向、符号语义或轻量图标,优先使用
lucide-react - 若
lucide-react没有合适图标,再使用 React 内联 SVG / SVG 路径自行绘制
Scene Planning
1. 规划输出
{scenesDataPath} 是当前 creator 的本地真值源。该文件由主流程脚本生成。
再生成 {planPath},文件内容必须是 JSON 数组。每个元素对应一个 scene card:
[
{
"sceneId": "scene_001",
"goal": "说明这一段要让观众理解的关系与画面目标",
"layout": "描述主要构图方式",
"visualCore": "描述主视觉关系或信息承载物",
"surface": "描述局部承托材质或主容器",
"emphasis": "描述强调层级或强调手法",
"screenShouldShow": [
"描述观众最终看到的图形关系",
"描述画面中的标签体系或关键词锚点"
],
"beatPlan": [
{
"segments": [0],
"action": "描述这一拍的视觉推进"
},
{
"segments": [1, 2],
"action": "描述连续句式合并后的视觉推进"
}
]
}
]字段说明:
sceneId
当前规划对象对应的场景 ID,必须与 scenesData 中某个 scene 的 id 完全一致。
goal
这一场要让观众理解什么关系,以及为什么用当前画面方案。
layout
这一场的主要构图方式和主视觉组织方式。
visualCore
当前 scene 的主视觉载体或核心关系,不写成一句台词。
surface
当前 scene 使用的局部承托材质或主容器,不作为整屏背景。
emphasis
当前 scene 的强调层级或主要强调手法。
screenShouldShow
观众最终会看到的图形关系、标签体系、关键词锚点和构图重心。
beatPlan
当前 scene 的分拍方案。每一项只声明“哪些 segments 组成这一拍”和“这一拍发生什么视觉推进”,不引入第二套时间锚点。
2. beatPlan 规则
- 默认每个 segment 对应一个 beat
- 如果相邻 segments 明显属于同一句连续表达,可以合并为一个 beat
- 合并仅允许发生在相邻 segments 之间
- 禁止跳跃式组合,例如
[0, 2] - 单个 scene 的全部 segment 必须被完整覆盖且只覆盖一次
beatPlan只声明segments和action- 不写帧数、毫秒数、绝对时间或第二套锚点字段
3. planning preflight
每个 scene 在写代码前必须先完成内部自检:
scene_xxx planning preflight
- goal:
- beatPlan:
- beatSegments:
- screenShouldShow:
- visibleText:
- originalTextRatio:
- primaryInfoCarrier: graphic / text
- redlineCheck: pass / failScene Plan 校验
生成 {planPath} 后,必须执行:
node "{validateScript}" \
"{planPath}" \
"{scenesDataPath}"执行要求:
- 校验失败时,先修正 plan,再重新执行校验
- 只有校验通过后,才允许开始写
SceneXXX.tsx - 不要跳过校验步骤
Scene Implementation
1. 读取已通过校验的规划结果
实现时优先使用以下字段:
1. surface 2. emphasis 3. layout 4. goal 5. beatPlan 6. screenShouldShow
实现时的读取方式:
- 从
scenesData读取事实和时间 - 从
scene-plan读取视觉组织和分拍方案 - 不要把
scene-plan当作新的字幕源或时间源
2. 时间绑定要求
- 每个 beat 的时间都从
scenesData[].segments推导 - 每个 beat 的开始时间,取所绑定第一个 segment 的
relativeStart - 每个 beat 的结束时间,取所绑定最后一个 segment 的
relativeStart + relativeDuration - 不要自己发明第二套时间锚点
3. 动画时长约束
字幕时间用于决定视觉事件的锚点和可见区间,不等于元素入场动画的持续时间。
- 元素入场、点亮、展开、位移、缩放、淡入等动作必须使用短动画窗口
- 常规入场动画建议控制在
8-18帧,复杂主视觉展开可放宽到18-30帧 - 不得把整个 beat 的
[start, end]直接作为单个元素从 0 到 1 的入场进度区间 - 当一个 beat 覆盖多个 segments 时,每个关键元素应优先绑定到对应 segment 的
relativeStart,分别快速入场或点亮 - beat 的
end可用于决定元素保持到何时、何时进入下一状态,但不应用作默认入场动画结束点 - 如果需要错峰,使用固定帧偏移,例如
+6、+10、+14帧,而不是用长 beat progress 的百分比慢慢推迟 interpolate(frame, [beatStart, beatEnd], [0, 1])只适合表示贯穿整段字幕的持续性变化,例如进度条读数、时间轴推进、背景扫描或能量累积;不适合普通卡片、标题、图标、标签的出现动画
推荐写法:
const enterFrames = 12;
const firstStart = msToFrame(segments[2].relativeStart, fps);
const secondStart = msToFrame(segments[3].relativeStart, fps);
const firstEnter = reveal(frame, firstStart, firstStart + enterFrames);
const secondEnter = reveal(frame, secondStart, secondStart + enterFrames);避免写法:
const beatProgress = reveal(frame, beatStart, beatEnd);
const firstEnter = beatProgress;
const secondEnter = Math.max(0, beatProgress - 0.28) / 0.72;这种写法会把元素入场拉满整段字幕,导致卡片、标题或图标像慢动作一样出现。
4. 读取设计系统
只提取当前实现需要的:
- 样式变量值
- surface 定义
- emphasis 定义
- texture / pattern 定义
- 宿主与背景规则
- 已安装的图标资源
5. 组件实现
组件文件路径:
{projectRoot}/src/scenes/Scene{XXX}.tsx
实现要求:
- 组件签名:
const SceneXXX: React.FC<{ segments: Segment[] }> = ({ segments }) => { ... } - 场景文件统一使用
export default SceneXXX - 从
remotion导入并使用useCurrentFrame()、useVideoConfig() - 从
useVideoConfig()中优先只读取fps;布局不要依赖 Composition 的width/height - 使用
segments[]中的relativeStart/relativeDuration计算元素出现帧 - 依据
beatPlan的segments绑定,把对应 segment 的开始时间换算成帧作为视觉事件锚点;普通入场动画必须使用短固定窗口,不得默认拉满整个 beat - scene 的构图和固定坐标必须按
1920x1080设计画布组织 - 主视觉默认做大一档,主体和关键关系应明显占据画面主要可视区域
- 画面默认做满,建立足够的信息密度;在不增加长文案的前提下,优先用空间分组、编号、色块、背景承托、图标状态和节奏动画等把结构撑起来
- 非必要情况不要使用任何连接线、箭头、SVG path 连线、marker 箭头或虚线连接。实践证明这类元素容易制造视觉噪音、比例异常和误导性的流向;只有当字幕明确讲“路线、路径、真实流向、地图轨迹、时间轴刻度、图表线段”等线性对象时,才允许少量使用,并且必须保证不遮挡文字、不跨越主体、不抢主视觉
- 表达流程或关系时,优先使用横向/纵向排布、分区标题、步骤编号、同色归组、卡片层级、出现顺序、缩放/高亮/淡入等方式;不要为了“看起来像流程图”而添加连接线或箭头
- 强化主次层级,中心主体、辅助元素、次要装饰的尺寸和权重应拉开
- 默认追求“海报感”而不是“局部组件感”
- 默认围绕画面中部区域组织主视觉,除非策略明确要求偏置构图
- 优先先建立整屏构图区域,再在该区域内做局部 absolute 定位
- 多元素场景优先围绕中心轴、中心舞台或成组区域展开
- 固定像素定位只用于局部微调
- 元素出现后通常保持可见,形成累积理解
- 容器只做承托,不做场景唯一主角
- 需要图标或符号时,先尝试从
lucide-react选择合适图标 - 若
lucide-react不适配当前语义或风格,再使用内联 SVG 实现
6. 宿主层边界
- 场景组件最外层
<AbsoluteFill>必须保持透明 - 不得重建或覆盖整屏宿主背景
surface只能落在局部容器、局部面板或中央舞台,不得作为整屏底图- 特殊氛围也只能通过局部承托区表达
输出
主要输出:
{planPath}{scenesDataPath}{projectRoot}/src/scenes/Scene{XXX}.tsx
完成反馈:
- 已实现的场景列表
- scene-plan 输出路径
- 新增或复用的实现约定
完成后返回
完成后必须向主 Agent 返回结构化结果,不要只回复“已完成”。
成功时返回:
{
"success": true,
"planPath": "{projectRoot}/scene-plans/creator-1.json",
"implementedScenes": [
{
"sceneId": "scene_001",
"componentPath": "{projectRoot}/src/scenes/Scene001.tsx"
}
]
}失败时返回:
{
"success": false,
"error": "失败原因"
}执行清单
- [ ] 确认
projectRoot是绝对路径 - [ ] 读取
{scenesDataPath},作为{scenesData} - [ ] 读取
cartoon-ui-style-guide.css - [ ] 读取
cartoon-ui-style-guide-reference.md - [ ] 读取
{skillRoot}/../remotion-best-practices/SKILL.md - [ ] 生成
{planPath} - [ ] 执行 scene-plan 校验
- [ ] 完成每个场景的 preflight 自检
- [ ] 确认主要拍点已绑定到
segments[] - [ ] 实现 Remotion 组件
- [ ] 使用默认导出
- [ ] 不修改宿主层文件
Storyboard Parser Reference
本文件是 srt-remotion-video 工作流中的“分镜生成阶段”参考协议,由主 Agent 指派 SubAgent 读取并执行。
输入契约
skillRoot:srt-remotion-videoskill 的绝对路径projectRoot: 项目根目录绝对路径srtPath: SRT 文件绝对路径
强制要求:
- 所有输入路径均由主 Agent 提供为绝对路径
- 不要猜测仓库根目录
- 不要使用旧 skill 路径
- 不要在工具调用中使用相对路径
输出
1. {projectRoot}/groups.json 2. {projectRoot}/storyboard.json 3. 返回:
{
"storyboardPath": "/path/to/storyboard.json",
"sceneCount": 17
}工作流程
步骤 1: 读取并分析 SRT 文件
使用 Read 工具读取 srtPath,记录字幕总条数,并关注每条字幕的开始时间与结束时间。
步骤 2: 语义分组
根据以下原则将连续字幕分组为场景:
1. 语义完整性 2. 主题一致性 3. 时长控制,优先把单场景时长控制在 15 秒以内 4. 自然停顿 5. 节奏清晰,避免一个场景塞入多个独立拍点
时长规则:
- 分组时必须结合字幕时间,不要只看文本条数
- 单场景目标时长:
12-18 秒 - 当累计时长接近
15-18 秒时,应主动寻找可分割边界 - 如果一个分组已经超过
18 秒,且存在合理切分点,应优先拆分 - 只有在语义上确实是同一个连续表达、拆开会明显破坏理解时,才允许超过
18 秒 - 超过
18 秒的分组应尽量控制在20-22 秒内 - 避免生成超过
25 秒的场景;若接近该长度,必须重新检查分组是否过粗
切分时优先寻找这些边界:
- 明显停顿
- 语义转折
- 从结论切到解释
- 从解释切到举例
- 从举例切到总结
- 同一主题下出现新的视觉关系或新的信息层级
时长判断优先级:
- 先保证语义完整
- 在不破坏理解的前提下优先满足时长控制
- 字幕条数不作为主要判断依据
强分组信号:
- 转折词
- 总结词
- 引入词
- 话题明显切换
- 从例子切到定义、从定义切到解释、从解释切到反转、从反转切到总结
- 在
15-18 秒附近出现自然停顿或信息层级切换
弱分组信号:
- 连续列举项
- 同一句话被拆成多条字幕
- 问答对
- 同一视觉动作下的补充说明
- 仅仅因为字幕条数较多,但累计时长仍然较短
为每个分组生成:
sceneIdfromIndextoIndexsemanticTagsvisualHint
visualHint 必须同时说明布局方向和展开方式,例如“左右对比展开”“先主视觉出现,再补充关系线”。
步骤 3: 写入 groups.json
写入 {projectRoot}/groups.json,结构如下:
{
"groups": [
{
"sceneId": "scene_001",
"fromIndex": 1,
"toIndex": 3,
"semanticTags": ["开场", "介绍"],
"visualHint": "大标题居中,逐段揭示主题图标"
}
]
}步骤 4: 验证分组连续性
必须验证:
1. 第一组 fromIndex === 1 2. 每组 fromIndex === 上一组.toIndex + 1 3. 最后一组 toIndex === SRT 总条数 4. sceneId 连续递增:scene_001, scene_002, ... 5. 每个分组都检查累计时长;若明显超过 18 秒,必须确认是否真的无法合理拆分 6. 若存在超过 25 秒 的分组,必须优先回看该分组是否合并过粗
如失败,修正 groups.json 后重新验证。
步骤 5: 运行脚本生成 storyboard.json
执行:
node "{skillRoot}/scripts/generate-storyboard.js" \
"{srtPath}" \
"{projectRoot}/groups.json" \
"{projectRoot}/storyboard.json"脚本负责:
1. 解析 SRT 时间信息 2. 校验 groups.json 3. 计算 startTime、duration 4. 计算 segments[].relativeStart、segments[].relativeDuration 5. 生成 storyboard.json
完成后返回
完成后必须向主 Agent 返回结构化结果,不要只回复“已完成”。
成功时返回:
{
"success": true,
"storyboardPath": "{projectRoot}/storyboard.json",
"groupsPath": "{projectRoot}/groups.json",
"sceneCount": 17
}失败时返回:
{
"success": false,
"error": "失败原因"
}执行清单
- [ ] 确认
skillRoot、projectRoot、srtPath都是绝对路径 - [ ] 读取 SRT 并记录总条数
- [ ] 结合字幕时间评估每个分组的累计时长
- [ ] 语义分组
- [ ] 生成
groups.json - [ ] 验证连续性
- [ ] 检查是否存在明显超过 18 秒且可拆分的场景
- [ ] 执行
generate-storyboard.js - [ ] 返回结构化结果
Theme Template Switching Guide
当用户要求“更换主题模板”“换一套风格”“修改整体视觉调性”时,按本文档执行。
本 skill 的主题模板不是单一 CSS 文件,而是四个文件共同组成的视觉系统:
1. template/cartoon-ui-style-guide.css 2. template/cartoon-ui-style-guide-reference.md 3. template/src/design-system.ts 4. template/src/compositions/Main.tsx
更换主题时必须把这四个文件作为一个整体修改。只改颜色变量或只改参考文档,都会导致 Creator 的规划、宿主画面和实际样式不一致。
四个文件的职责
template/cartoon-ui-style-guide.css
这是视觉规范主文件,供 Creator 规划和实现阶段读取。
应包含:
- 主题设计关键词
- 颜色、字体、阴影、间距、边框、动效等 design tokens
- 常用 surface / component class
- emphasis primitives
- 动画 helper
- 语义清晰、与新主题一致的 class 名
修改重点:
- 把视觉语言完整改成新主题
- 删除原主题的审美规则和旧主题措辞
- 不要留下“旧主题迁移”“兼容旧样式”“文件名沿用”等说明痕迹
- class 名应体现新主题语义,例如
glass-panel、terminal-board、system-panel - 如果保留旧 class 名只是为了兼容,也不要在文档中引导 Creator 使用它们
template/cartoon-ui-style-guide-reference.md
这是 Creator 的主题理解入口,决定后续 AI 如何构图和选择 surface。
应包含:
- 新主题的整体调性描述
- 宿主融合规则
- 单布局 / 多布局规则
- 颜色使用优先级
- 字体、阴影、发光、纹理、动效建议
- 典型规划模式
- 常用 surface 速查表
- 实现提醒
修改重点:
- 让文档像新主题原生规范,而不是“从旧主题改过来”
- 不要写“禁止使用旧主题中的某某元素”这类迁移痕迹
- 直接用正向规则表达新主题应该怎么做
- surface 表和 CSS class 名必须与
cartoon-ui-style-guide.css对齐 - Creator 会优先相信这份文档,所以这里的审美约束要清晰、具体、可执行
template/src/design-system.ts
这是实际宿主层使用的 design token 和装饰参数。
应包含:
- 宿主背景色
- 网格 / 纹理 / 装饰色
- 主强调色、状态色、文字色
- 宿主装饰数据,例如粒子、光束、图案、角标、纹理参数
修改重点:
- 与 CSS 中的核心 token 保持一致
- 删除与新主题无关的装饰数据
- 类型名和字段名应使用新主题语义
- 如果
Main.tsx依赖这里的字段,二者必须同步修改
template/src/compositions/Main.tsx
这是实际渲染的宿主画面,负责全局背景和场景挂载。
应包含:
- 与新主题一致的宿主背景
- 全局装饰层,例如网格、扫描线、光效、纹理、粒子、纸张肌理等
generatedScenes的挂载逻辑
必须保持:
- 不改变
generatedScenes.map(...)的场景挂载契约 - 不改变
<Component segments={scene.segments} /> - 不手写场景时长逻辑替代
generated-scenes.ts - 不把具体场景内容写进宿主层
修改重点:
- 宿主层应成为新主题的第一视觉信号
- 场景组件默认透明叠加在宿主层上
- 不要让宿主装饰遮挡场景主体
- 动态背景应克制,避免影响可读性
推荐执行流程
1. 先与用户确认新主题的样式设计
在开始修改主题前,必须先用清晰易懂的语言向用户描述新主题的视觉方向,并等待用户确认。这个沟通的目的,是让用户确认“看起来会是什么样”,而不是让用户理解实现细节。
沟通时应避免过于技术化,不需要说明要修改哪些文件、哪些变量、哪些组件。重点说明:
- 整体气质:例如冷静专业、未来科技、温暖手账、极简医疗、杂志感、数据仪表盘感
- 主色和辅助色:例如深色背景配青蓝光效、冷白底配低饱和蓝绿、暖纸色配红蓝贴纸
- 画面元素:例如玻璃面板、细线图解、扫描线、纸张纹理、贴纸标签、数据卡片
- 信息呈现方式:例如更像系统架构图、数据仪表盘、课堂板书、手账笔记、产品演示
- 不希望出现的感觉:例如不要太花、不要太儿童化、不要像 PPT 模板、不要过度发光
可以给用户一段简短确认稿,例如:
我建议把主题做成“深色科技风”:整体是深墨色背景,叠加细网格和轻微扫描线;主体信息用半透明玻璃面板承托,重点用青蓝电光强调,成功状态用绿色,风险状态用红色。整体会像一个克制的系统仪表盘,而不是炫光很重的赛博风。这个方向可以吗?用户确认后,再进入文件修改。
2. 定义新主题的设计方向
用 3-6 个关键词描述整体调性,例如:
- 深色科技:深墨背景、青蓝电光、玻璃信息层、扫描线、数据节点
- 极简医学:冷白背景、低饱和蓝绿、细线图解、临床标签、柔和阴影
- 纸质手账:暖纸底、胶带、贴纸、手写标注、轻颗粒纹理
3. 同步改 cartoon-ui-style-guide.css
先改 token,再改 surface / component,再改 emphasis 和动画 helper。
4. 同步改 cartoon-ui-style-guide-reference.md
把 Creator 的构图规则改成新主题原生语言。不要保留旧主题禁忌清单,也不要解释迁移过程。
5. 同步改 design-system.ts
让宿主 token、装饰数据、状态色与 CSS 一致。
6. 同步改 Main.tsx
把宿主背景和全局装饰改成新主题,同时保持场景挂载逻辑不变。
7. 搜索并清理主题痕迹
根据旧主题关键词搜索,例如:
rg -n "旧|兼容|沿用|手绘|纸张|白板|漫画|便签|旧主题|文件名|新语义" \
template/cartoon-ui-style-guide.css \
template/cartoon-ui-style-guide-reference.md搜索词应根据实际旧主题调整。目标是让新文档读起来像原生主题规范。
8. 运行类型检查
cd "{skillRoot}/template"
./node_modules/.bin/tsc --noEmit如果依赖未安装,先按主流程运行 ensure-template-deps.js。
注意事项
- 新建项目时,
init-project.js会复制template/到项目目录;已经生成过的旧项目不会自动同步新主题 - 如果用户要修改某个已生成项目的主题,需要修改该项目目录里的同名文件,而不是只改 skill template
- 不建议改
SKILL.md和scene-component-creator.md中的 style-guide 文件名引用,除非你同时完整更新工作流协议 cartoon-ui-style-guide.css这个文件名是工作流契约的一部分,可以保留文件名,但文件内容不应出现旧主题或迁移解释Root.tsx、generated-scenes.ts、generate-scenes-registry.js不属于主题模板更换范围- 不要把某个具体视频的内容、标题、字幕或业务概念写进主题模板
- 不要只写抽象审美词,要提供 Creator 可直接使用的 surface 名、颜色变量、构图模式和实现提醒
- 更换主题后,应尽量让 CSS class、reference 文档、design token、宿主画面使用同一套语义词汇
完成标准
更换主题模板完成后,应满足:
- 四个主题文件全部更新
- reference 文档清楚说明新主题该如何构图
- CSS 中有新主题可用的 tokens、surfaces、emphasis 和动效
design-system.ts和Main.tsx渲染的新项目宿主层符合新主题- 旧主题的显性措辞、禁忌清单、迁移说明已清理
tsc --noEmit通过
#!/usr/bin/env node
/**
* 模板依赖预检脚本
*
* 用法:
* node ensure-template-deps.js <templateRoot>
*
* 功能:
* - 检查 template/package.json 和 package-lock.json 是否存在
* - 检查模板关键依赖是否已安装
* - 若未安装,则在 templateRoot 下执行 npm install
*/
const fs = require('fs');
const path = require('path');
const {spawnSync} = require('child_process');
const REQUIRED_PACKAGES = [
'remotion',
'react',
'react-dom',
'@remotion/cli',
'lucide-react',
];
function fail(stage, error, extra = {}) {
console.error(JSON.stringify({
success: false,
stage,
error,
...extra,
}, null, 2));
process.exit(1);
}
function ensureTemplateFiles(templateRoot) {
const packageJsonPath = path.join(templateRoot, 'package.json');
const packageLockPath = path.join(templateRoot, 'package-lock.json');
if (!fs.existsSync(templateRoot)) {
fail('precheck', `template 目录不存在: ${templateRoot}`);
}
if (!fs.existsSync(packageJsonPath)) {
fail('precheck', `缺少 package.json: ${packageJsonPath}`);
}
if (!fs.existsSync(packageLockPath)) {
fail('precheck', `缺少 package-lock.json: ${packageLockPath}`);
}
}
function canResolvePackage(templateRoot, packageName) {
try {
require.resolve(`${packageName}/package.json`, {paths: [templateRoot]});
return true;
} catch (error) {
return false;
}
}
function getMissingPackages(templateRoot) {
const nodeModulesPath = path.join(templateRoot, 'node_modules');
if (!fs.existsSync(nodeModulesPath)) {
return [...REQUIRED_PACKAGES];
}
return REQUIRED_PACKAGES.filter((packageName) => !canResolvePackage(templateRoot, packageName));
}
function getNpmCommand() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
function runNpmInstall(templateRoot) {
const npmCommand = getNpmCommand();
const probe = spawnSync(npmCommand, ['--version'], {
cwd: templateRoot,
encoding: 'utf-8',
});
if (probe.error) {
fail('precheck', `无法执行 npm: ${probe.error.message}`);
}
if (probe.status !== 0) {
fail('precheck', 'npm 不可用,无法安装模板依赖', {
stderr: (probe.stderr || '').trim(),
});
}
console.error(`模板依赖缺失,正在安装: ${templateRoot}`);
const install = spawnSync(npmCommand, ['install'], {
cwd: templateRoot,
encoding: 'utf-8',
stdio: 'pipe',
});
if (install.error) {
fail('install', `npm install 执行失败: ${install.error.message}`);
}
if (install.status !== 0) {
fail('install', 'npm install 失败', {
stdout: (install.stdout || '').trim().slice(-4000),
stderr: (install.stderr || '').trim().slice(-4000),
});
}
}
function main() {
const [templateRootArg] = process.argv.slice(2);
if (!templateRootArg) {
console.error('用法: node ensure-template-deps.js <templateRoot>');
process.exit(1);
}
const templateRoot = path.resolve(templateRootArg);
ensureTemplateFiles(templateRoot);
const missingBeforeInstall = getMissingPackages(templateRoot);
const alreadyInstalled = missingBeforeInstall.length === 0;
if (!alreadyInstalled) {
console.error(`检测到模板依赖缺失: ${missingBeforeInstall.join(', ')}`);
runNpmInstall(templateRoot);
} else {
console.error('模板依赖已就绪,跳过安装');
}
const missingAfterInstall = getMissingPackages(templateRoot);
if (missingAfterInstall.length > 0) {
fail('verify', `模板依赖安装后仍缺失: ${missingAfterInstall.join(', ')}`, {
missingPackages: missingAfterInstall,
});
}
console.log(JSON.stringify({
success: true,
templateRoot,
alreadyInstalled,
installedWith: 'npm',
}, null, 2));
}
main();
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
function fail(error, extra = {}) {
console.error(JSON.stringify({
success: false,
error,
...extra,
}, null, 2));
process.exit(1);
}
function parseCreatorNumber(creatorId) {
const match = /^creator-(\d+)$/.exec(String(creatorId).trim());
if (!match) {
fail(`creatorId 格式无效: ${creatorId}`);
}
const numericId = Number(match[1]);
if (!Number.isInteger(numericId) || numericId <= 0) {
fail(`creatorId 序号无效: ${creatorId}`);
}
return numericId;
}
function loadStoryboard(storyboardPath) {
if (!fs.existsSync(storyboardPath)) {
fail(`storyboard.json 不存在: ${storyboardPath}`);
}
const storyboard = JSON.parse(fs.readFileSync(storyboardPath, 'utf-8'));
if (!storyboard || !Array.isArray(storyboard.scenes)) {
fail(`storyboard.json 结构无效: ${storyboardPath}`);
}
return storyboard;
}
function ensureOutputDir(outputPath) {
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, {recursive: true});
}
}
function main() {
const [storyboardPathArg, creatorId, scenesPerCreatorArg, outputPathArg] = process.argv.slice(2);
if (!storyboardPathArg || !creatorId || !scenesPerCreatorArg || !outputPathArg) {
console.error('用法: node generate-creator-scenes.js <storyboardPath> <creatorId> <scenesPerCreator> <outputPath>');
process.exit(1);
}
const storyboardPath = path.resolve(storyboardPathArg);
const outputPath = path.resolve(outputPathArg);
const scenesPerCreator = Number(scenesPerCreatorArg);
if (!Number.isInteger(scenesPerCreator) || scenesPerCreator <= 0) {
fail(`scenesPerCreator 无效: ${scenesPerCreatorArg}`);
}
const creatorNumber = parseCreatorNumber(creatorId);
const storyboard = loadStoryboard(storyboardPath);
const startIndex = (creatorNumber - 1) * scenesPerCreator;
const endIndexExclusive = startIndex + scenesPerCreator;
const scenesData = storyboard.scenes.slice(startIndex, endIndexExclusive);
if (scenesData.length === 0) {
fail(`creator ${creatorId} 没有可分配的 scenes`, {
creatorId,
sceneCount: storyboard.scenes.length,
scenesPerCreator,
});
}
ensureOutputDir(outputPath);
fs.writeFileSync(outputPath, JSON.stringify(scenesData, null, 2), 'utf-8');
console.log(JSON.stringify({
success: true,
creatorId,
storyboardPath,
outputPath,
scenesPerCreator,
sceneIds: scenesData.map((scene) => scene.id),
}, null, 2));
}
main();
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const {
buildGeneratedScenesSource,
getActiveVideoProfile,
loadStoryboard,
loadVideoSettings,
validateSceneComponentExports,
validateSceneFilesAgainstStoryboard,
validateSceneTimingAgainstStoryboard,
validateStoryboardStructure,
} = require('./scene-registry-utils');
function main() {
const args = process.argv.slice(2);
if (args.length < 2 || args.length > 3) {
console.error('用法: node generate-scenes-registry.js <projectRoot> <storyboardPath> [outputPath]');
process.exit(1);
}
const [projectRoot, storyboardPath, outputPathArg] = args;
const outputPath = outputPathArg || path.join(projectRoot, 'src', 'compositions', 'generated-scenes.ts');
try {
const storyboard = loadStoryboard(storyboardPath);
const storyboardValidation = validateStoryboardStructure(storyboard);
if (!storyboardValidation.valid) {
console.error('❌ storyboard 校验失败:');
storyboardValidation.errors.forEach((error) => console.error(` - ${error}`));
process.exit(1);
}
const fileValidation = validateSceneFilesAgainstStoryboard(projectRoot, storyboard);
if (!fileValidation.valid) {
console.error('❌ 场景文件校验失败:');
fileValidation.errors.forEach((error) => console.error(` - ${error}`));
process.exit(1);
}
const exportValidation = validateSceneComponentExports(projectRoot, storyboard);
if (!exportValidation.valid) {
console.error('❌ 场景导出校验失败:');
exportValidation.errors.forEach((error) => console.error(` - ${error}`));
process.exit(1);
}
const timingValidation = validateSceneTimingAgainstStoryboard(projectRoot, storyboard);
if (!timingValidation.valid) {
console.error('❌ 场景时序校验失败:');
timingValidation.errors.forEach((error) => console.error(` - ${error}`));
process.exit(1);
}
if (timingValidation.warnings.length > 0) {
console.warn('⚠️ 场景时序告警:');
timingValidation.warnings.forEach((warning) => console.warn(` - ${warning}`));
}
const videoSettings = loadVideoSettings(projectRoot);
const activeProfile = getActiveVideoProfile(videoSettings);
const source = buildGeneratedScenesSource(storyboard, { videoSettings });
fs.writeFileSync(outputPath, source, 'utf-8');
console.log('✅ generated-scenes.ts 已生成');
console.log(` - 输出文件: ${outputPath}`);
console.log(` - 场景数量: ${storyboard.sceneCount}`);
console.log(` - 输出规格: ${activeProfile.width}x${activeProfile.height} / ${activeProfile.fps}fps (${activeProfile.name})`);
console.log('');
console.log('__RESULT_JSON__');
console.log(JSON.stringify({
success: true,
outputPath,
sceneCount: storyboard.sceneCount,
videoProfile: activeProfile,
}));
} catch (error) {
console.error(`❌ 错误: ${error.message}`);
process.exit(1);
}
}
main();
#!/usr/bin/env node
/**
* generate-storyboard.js
*
* 根据 SRT 文件和 AI 生成的分组信息,生成 storyboard.json
*
* 用法: node generate-storyboard.js <srtPath> <groupsPath> <outputPath>
*
* 输入:
* - srtPath: SRT 字幕文件路径
* - groupsPath: AI 生成的 groups.json 路径
* - outputPath: 输出的 storyboard.json 路径
*
* groups.json 格式:
* {
* "groups": [
* {
* "sceneId": "scene_001",
* "fromIndex": 1,
* "toIndex": 3,
* "semanticTags": ["开场", "介绍"],
* "visualHint": "大标题居中,配合动画演示稿相关图标"
* },
* ...
* ]
* }
*/
const fs = require('fs');
const path = require('path');
// ============ SRT 解析 ============
/**
* 解析时间码为毫秒
* @param {string} timeStr - 格式: "HH:MM:SS,mmm"
* @returns {number} 毫秒数
*/
function parseTimeCode(timeStr) {
const match = timeStr.trim().match(/(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/);
if (!match) {
throw new Error(`无效的时间码格式: ${timeStr}`);
}
const [, hours, minutes, seconds, ms] = match;
return (
parseInt(hours, 10) * 3600000 +
parseInt(minutes, 10) * 60000 +
parseInt(seconds, 10) * 1000 +
parseInt(ms, 10)
);
}
/**
* 解析 SRT 文件
* @param {string} srtContent - SRT 文件内容
* @returns {Array<{index: number, startMs: number, endMs: number, text: string}>}
*/
function parseSRT(srtContent) {
const subtitles = [];
// 按空行分割为块
const blocks = srtContent.trim().split(/\n\s*\n/);
for (const block of blocks) {
const lines = block.trim().split('\n');
if (lines.length < 3) continue;
// 第一行: 序号
const index = parseInt(lines[0].trim(), 10);
if (isNaN(index)) continue;
// 第二行: 时间码
const timeLine = lines[1].trim();
const timeMatch = timeLine.match(/(.+?)\s*-->\s*(.+)/);
if (!timeMatch) continue;
const startMs = parseTimeCode(timeMatch[1]);
const endMs = parseTimeCode(timeMatch[2]);
// 第三行及以后: 文本
const text = lines.slice(2).join('\n').trim();
subtitles.push({ index, startMs, endMs, text });
}
// 按序号排序
subtitles.sort((a, b) => a.index - b.index);
return subtitles;
}
// ============ 分组验证 ============
/**
* 验证分组数据的完整性和连续性
* @param {Array} groups - 分组数组
* @param {number} totalSubtitles - SRT 字幕总数
* @returns {{valid: boolean, errors: string[]}}
*/
function validateGroups(groups, totalSubtitles) {
const errors = [];
if (!groups || groups.length === 0) {
errors.push('分组数据为空');
return { valid: false, errors };
}
// 检查第一个分组的 fromIndex 是否为 1
if (groups[0].fromIndex !== 1) {
errors.push(`第一个分组的 fromIndex 必须为 1,实际为 ${groups[0].fromIndex}`);
}
// 检查最后一个分组的 toIndex 是否等于字幕总数
const lastGroup = groups[groups.length - 1];
if (lastGroup.toIndex !== totalSubtitles) {
errors.push(`最后一个分组的 toIndex 必须为 ${totalSubtitles},实际为 ${lastGroup.toIndex}`);
}
// 检查连续性和 sceneId 格式
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
const expectedSceneId = `scene_${String(i + 1).padStart(3, '0')}`;
// 检查 sceneId 格式
if (group.sceneId !== expectedSceneId) {
errors.push(`分组 ${i + 1} 的 sceneId 应为 ${expectedSceneId},实际为 ${group.sceneId}`);
}
// 检查 fromIndex <= toIndex
if (group.fromIndex > group.toIndex) {
errors.push(`分组 ${group.sceneId} 的 fromIndex (${group.fromIndex}) 大于 toIndex (${group.toIndex})`);
}
// 检查与前一个分组的连续性
if (i > 0) {
const prevGroup = groups[i - 1];
if (group.fromIndex !== prevGroup.toIndex + 1) {
errors.push(`分组 ${group.sceneId} 的 fromIndex (${group.fromIndex}) 与前一个分组的 toIndex (${prevGroup.toIndex}) 不连续`);
}
}
}
return {
valid: errors.length === 0,
errors
};
}
// ============ 生成 Storyboard ============
/**
* 根据分组信息生成场景
* @param {Array} subtitles - 解析后的字幕数组
* @param {Array} groups - 分组数组
* @returns {Array} 场景数组
*/
function generateScenes(subtitles, groups) {
const scenes = [];
// 创建字幕索引映射 (index -> subtitle)
const subtitleMap = new Map();
for (const sub of subtitles) {
subtitleMap.set(sub.index, sub);
}
for (const group of groups) {
// 获取该分组内的所有字幕
const groupSubtitles = [];
for (let idx = group.fromIndex; idx <= group.toIndex; idx++) {
const sub = subtitleMap.get(idx);
if (sub) {
groupSubtitles.push(sub);
}
}
if (groupSubtitles.length === 0) {
console.warn(`警告: 分组 ${group.sceneId} 没有找到对应的字幕`);
continue;
}
// 计算场景的 startTime (第一条字幕的开始时间)
const startTime = groupSubtitles[0].startMs;
// 生成 segments,计算相对时间
const segments = groupSubtitles.map((sub, idx) => {
// relativeStart: 相对于场景开始的时间
// 第一个 segment 的 relativeStart 必须为 0
const relativeStart = idx === 0 ? 0 : sub.startMs - startTime;
// relativeDuration: 该字幕的持续时间
const relativeDuration = sub.endMs - sub.startMs;
return {
text: sub.text,
relativeStart,
relativeDuration
};
});
// 计算场景的 duration (最后一个 segment 的 relativeStart + relativeDuration)
const lastSegment = segments[segments.length - 1];
const duration = lastSegment.relativeStart + lastSegment.relativeDuration;
const scene = {
id: group.sceneId,
startTime,
duration,
segments
};
// 添加可选字段
if (group.semanticTags && group.semanticTags.length > 0) {
scene.semanticTags = group.semanticTags;
}
if (group.visualHint) {
scene.visualHint = group.visualHint;
}
scenes.push(scene);
}
return scenes;
}
/**
* 生成完整的 storyboard 数据
* @param {Array} scenes - 场景数组
* @returns {Object} storyboard 对象
*/
function generateStoryboard(scenes) {
if (scenes.length === 0) {
return {
totalDuration: 0,
sceneCount: 0,
scenes: []
};
}
const lastScene = scenes[scenes.length - 1];
const totalDuration = lastScene.startTime + lastScene.duration;
return {
totalDuration,
sceneCount: scenes.length,
scenes
};
}
function validateGeneratedScenes(storyboard) {
const errors = [];
const scenes = storyboard.scenes;
if (storyboard.sceneCount !== scenes.length) {
errors.push(`sceneCount 应为 ${scenes.length},实际为 ${storyboard.sceneCount}`);
}
for (let i = 0; i < scenes.length; i++) {
const scene = scenes[i];
const expectedId = `scene_${String(i + 1).padStart(3, '0')}`;
if (scene.id !== expectedId) {
errors.push(`场景 ${i + 1} 的 id 应为 ${expectedId},实际为 ${scene.id}`);
}
if (!Array.isArray(scene.segments) || scene.segments.length === 0) {
errors.push(`场景 ${scene.id} 缺少 segments`);
continue;
}
if (typeof scene.startTime !== 'number' || scene.startTime < 0) {
errors.push(`场景 ${scene.id} 的 startTime 无效: ${scene.startTime}`);
}
if (typeof scene.duration !== 'number' || scene.duration <= 0) {
errors.push(`场景 ${scene.id} 的 duration 无效: ${scene.duration}`);
}
let computedDuration = 0;
let previousStart = -1;
for (let j = 0; j < scene.segments.length; j++) {
const segment = scene.segments[j];
if (typeof segment.relativeStart !== 'number' || segment.relativeStart < 0) {
errors.push(`场景 ${scene.id} 的 segment[${j}] relativeStart 无效`);
}
if (typeof segment.relativeDuration !== 'number' || segment.relativeDuration <= 0) {
errors.push(`场景 ${scene.id} 的 segment[${j}] relativeDuration 无效`);
}
if (segment.relativeStart < previousStart) {
errors.push(`场景 ${scene.id} 的 segments 时间未递增`);
}
previousStart = segment.relativeStart;
computedDuration = Math.max(
computedDuration,
segment.relativeStart + segment.relativeDuration,
);
}
if (computedDuration !== scene.duration) {
errors.push(`场景 ${scene.id} 的 duration 应为 ${computedDuration},实际为 ${scene.duration}`);
}
if (i > 0) {
const prev = scenes[i - 1];
if (scene.startTime < prev.startTime) {
errors.push(`场景 ${scene.id} 的 startTime 小于前一个场景`);
}
}
}
const lastScene = scenes[scenes.length - 1];
const expectedTotalDuration = lastScene ? lastScene.startTime + lastScene.duration : 0;
if (storyboard.totalDuration !== expectedTotalDuration) {
errors.push(`totalDuration 应为 ${expectedTotalDuration},实际为 ${storyboard.totalDuration}`);
}
return {
valid: errors.length === 0,
errors,
};
}
// ============ 主函数 ============
function main() {
const args = process.argv.slice(2);
if (args.length < 3) {
console.error('用法: node generate-storyboard.js <srtPath> <groupsPath> <outputPath>');
console.error('');
console.error('参数:');
console.error(' srtPath SRT 字幕文件路径');
console.error(' groupsPath AI 生成的 groups.json 路径');
console.error(' outputPath 输出的 storyboard.json 路径');
process.exit(1);
}
const [srtPath, groupsPath, outputPath] = args;
// 检查文件是否存在
if (!fs.existsSync(srtPath)) {
console.error(`错误: SRT 文件不存在: ${srtPath}`);
process.exit(1);
}
if (!fs.existsSync(groupsPath)) {
console.error(`错误: groups.json 文件不存在: ${groupsPath}`);
process.exit(1);
}
try {
// 1. 解析 SRT 文件
console.log(`📄 解析 SRT 文件: ${srtPath}`);
const srtContent = fs.readFileSync(srtPath, 'utf-8');
const subtitles = parseSRT(srtContent);
console.log(` 找到 ${subtitles.length} 条字幕`);
// 2. 读取分组信息
console.log(`📋 读取分组信息: ${groupsPath}`);
const groupsContent = fs.readFileSync(groupsPath, 'utf-8');
const groupsData = JSON.parse(groupsContent);
const groups = groupsData.groups;
console.log(` 找到 ${groups.length} 个分组`);
// 3. 验证分组数据
console.log('🔍 验证分组数据...');
const validation = validateGroups(groups, subtitles.length);
if (!validation.valid) {
console.error('❌ 分组验证失败:');
validation.errors.forEach(err => console.error(` - ${err}`));
process.exit(1);
}
console.log(' ✅ 分组验证通过');
// 4. 生成场景
console.log('🎬 生成场景...');
const scenes = generateScenes(subtitles, groups);
// 5. 生成 storyboard
const storyboard = generateStoryboard(scenes);
const storyboardValidation = validateGeneratedScenes(storyboard);
if (!storyboardValidation.valid) {
console.error('❌ storyboard 校验失败:');
storyboardValidation.errors.forEach(err => console.error(` - ${err}`));
process.exit(1);
}
// 6. 写入文件
console.log(`💾 写入文件: ${outputPath}`);
fs.writeFileSync(outputPath, JSON.stringify(storyboard, null, 2), 'utf-8');
// 7. 输出摘要
console.log('');
console.log('✅ 生成完成!');
console.log(` - 场景数量: ${storyboard.sceneCount}`);
console.log(` - 总时长: ${(storyboard.totalDuration / 1000).toFixed(1)}s`);
console.log(` - 输出文件: ${outputPath}`);
// 输出 JSON 结果供调用方使用
console.log('');
console.log('__RESULT_JSON__');
console.log(JSON.stringify({
success: true,
storyboardPath: outputPath,
sceneCount: storyboard.sceneCount,
totalDuration: storyboard.totalDuration
}));
} catch (error) {
console.error(`❌ 错误: ${error.message}`);
process.exit(1);
}
}
main();
#!/usr/bin/env node
/**
* 项目初始化脚本
*
* 用法:
* node init-project.js --srt-path <srt-path>
*
* 功能:
* - 从 skill 内部 template 复制创建新项目
* - 默认创建到字幕目录下的 remotion-video-projects/{timestamp}/
* - 若 template 已完成依赖安装,则会一并复制已安装依赖
*
* 输出:
* 成功时输出 JSON: { projectRoot, projectName, createdAt, srtFile }
*/
const fs = require('fs');
const path = require('path');
// 路径配置
const SKILL_ROOT = path.resolve(__dirname, '..');
const TEMPLATE_DIR = path.join(SKILL_ROOT, 'template');
const PROJECTS_DIR_NAME = 'remotion-video-projects';
/**
* 生成时间戳格式的项目名称
*/
function generateProjectName() {
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
}
/**
* 递归复制目录(若 template 中存在 node_modules 也会复制,正确处理符号链接)
*/
function copyDirSync(src, dest) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isSymbolicLink()) {
// 保留符号链接(相对路径链接在复制后仍然有效)
const linkTarget = fs.readlinkSync(srcPath);
try {
fs.symlinkSync(linkTarget, destPath);
} catch (e) {
// 如果符号链接已存在,跳过
if (e.code !== 'EEXIST') throw e;
}
} else if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
function resolveSrtPath(inputPath) {
if (!inputPath) {
console.error(JSON.stringify({ error: '缺少 srtPath' }));
process.exit(1);
}
return path.resolve(inputPath);
}
/**
* 创建新项目
*/
function createNewProject(inputSrtPath) {
// 检查 template 目录
if (!fs.existsSync(TEMPLATE_DIR)) {
console.error(JSON.stringify({ error: 'template 目录不存在' }));
process.exit(1);
}
const srtPath = resolveSrtPath(inputSrtPath);
if (!fs.existsSync(srtPath)) {
console.error(JSON.stringify({ error: `SRT 文件不存在: ${srtPath}` }));
process.exit(1);
}
const srtDir = path.dirname(srtPath);
const projectsDir = path.join(srtDir, PROJECTS_DIR_NAME);
if (!fs.existsSync(projectsDir)) {
fs.mkdirSync(projectsDir, { recursive: true });
}
const projectName = generateProjectName();
const projectRoot = path.join(projectsDir, projectName);
// 复制 template 到新项目目录
console.error(`正在创建项目: ${projectName}`);
copyDirSync(TEMPLATE_DIR, projectRoot);
return {
projectName,
projectRoot,
createdAt: new Date().toISOString(),
srtFile: srtPath
};
}
function parseArgs(args) {
let srtPath = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--srt-path') {
srtPath = args[i + 1] || null;
i += 1;
}
}
return { srtPath };
}
/**
* 主函数
*/
function main() {
const { srtPath } = parseArgs(process.argv.slice(2));
if (!srtPath) {
console.error('用法: node init-project.js --srt-path <srt-path>');
process.exit(1);
}
const project = createNewProject(srtPath);
console.log(JSON.stringify(project, null, 2));
}
main();
const fs = require('fs');
const path = require('path');
const DEFAULT_VIDEO_SETTINGS = {
profile: '1080p30',
design: {
width: 1920,
height: 1080,
},
profiles: {
'1080p30': {
width: 1920,
height: 1080,
fps: 30,
},
'4k60': {
width: 3840,
height: 2160,
fps: 60,
},
},
};
function getActiveVideoProfile(videoSettings = DEFAULT_VIDEO_SETTINGS) {
const profileName = videoSettings.profile;
const profile = videoSettings.profiles && videoSettings.profiles[profileName];
if (!profile) {
throw new Error(`未知视频输出配置: ${profileName}`);
}
return {
name: profileName,
width: profile.width,
height: profile.height,
fps: profile.fps,
};
}
function loadVideoSettings(projectRoot) {
if (!projectRoot) {
return DEFAULT_VIDEO_SETTINGS;
}
const settingsPath = path.join(projectRoot, 'src', 'video-settings.json');
if (!fs.existsSync(settingsPath)) {
return DEFAULT_VIDEO_SETTINGS;
}
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
}
function msToFrames(ms, fps = getActiveVideoProfile().fps) {
return Math.round((ms / 1000) * fps);
}
function sceneIdToNumber(sceneId) {
const match = /^scene_(\d{3})$/.exec(sceneId);
if (!match) {
throw new Error(`无效的 sceneId: ${sceneId}`);
}
return match[1];
}
function sceneIdToComponentName(sceneId) {
return `Scene${sceneIdToNumber(sceneId)}`;
}
function sceneIdToFilename(sceneId) {
return `${sceneIdToComponentName(sceneId)}.tsx`;
}
function loadStoryboard(storyboardPath) {
if (!fs.existsSync(storyboardPath)) {
throw new Error(`storyboard.json 不存在: ${storyboardPath}`);
}
const raw = fs.readFileSync(storyboardPath, 'utf-8');
const storyboard = JSON.parse(raw);
if (!storyboard || !Array.isArray(storyboard.scenes)) {
throw new Error(`storyboard.json 结构无效: ${storyboardPath}`);
}
return storyboard;
}
function getScenesDir(projectRoot) {
return path.join(projectRoot, 'src', 'scenes');
}
function listSceneComponentFiles(projectRoot) {
const scenesDir = getScenesDir(projectRoot);
if (!fs.existsSync(scenesDir)) {
throw new Error(`场景目录不存在: ${scenesDir}`);
}
return fs.readdirSync(scenesDir)
.filter((file) => /^Scene\d{3}\.tsx$/.test(file))
.sort();
}
function validateStoryboardStructure(storyboard) {
const errors = [];
const scenes = storyboard.scenes;
if (storyboard.sceneCount !== scenes.length) {
errors.push(`sceneCount 应为 ${scenes.length},实际为 ${storyboard.sceneCount}`);
}
for (let i = 0; i < scenes.length; i++) {
const scene = scenes[i];
const expectedId = `scene_${String(i + 1).padStart(3, '0')}`;
if (scene.id !== expectedId) {
errors.push(`场景 ${i + 1} 的 id 应为 ${expectedId},实际为 ${scene.id}`);
}
if (!Array.isArray(scene.segments) || scene.segments.length === 0) {
errors.push(`场景 ${scene.id} 缺少 segments`);
continue;
}
if (typeof scene.startTime !== 'number' || scene.startTime < 0) {
errors.push(`场景 ${scene.id} 的 startTime 无效: ${scene.startTime}`);
}
if (typeof scene.duration !== 'number' || scene.duration <= 0) {
errors.push(`场景 ${scene.id} 的 duration 无效: ${scene.duration}`);
}
let computedDuration = 0;
let previousRelativeStart = -1;
for (let j = 0; j < scene.segments.length; j++) {
const segment = scene.segments[j];
if (typeof segment.relativeStart !== 'number' || segment.relativeStart < 0) {
errors.push(`场景 ${scene.id} 的 segment[${j}] relativeStart 无效`);
}
if (typeof segment.relativeDuration !== 'number' || segment.relativeDuration <= 0) {
errors.push(`场景 ${scene.id} 的 segment[${j}] relativeDuration 无效`);
}
if (segment.relativeStart < previousRelativeStart) {
errors.push(`场景 ${scene.id} 的 segments 时间未递增`);
}
previousRelativeStart = segment.relativeStart;
computedDuration = Math.max(
computedDuration,
segment.relativeStart + segment.relativeDuration,
);
}
if (computedDuration !== scene.duration) {
errors.push(`场景 ${scene.id} 的 duration 应为 ${computedDuration},实际为 ${scene.duration}`);
}
if (i > 0) {
const prev = scenes[i - 1];
if (scene.startTime < prev.startTime) {
errors.push(`场景 ${scene.id} 的 startTime 小于前一个场景`);
}
}
}
const lastScene = scenes[scenes.length - 1];
const expectedTotalDuration = lastScene
? lastScene.startTime + lastScene.duration
: 0;
if (storyboard.totalDuration !== expectedTotalDuration) {
errors.push(`totalDuration 应为 ${expectedTotalDuration},实际为 ${storyboard.totalDuration}`);
}
return {
valid: errors.length === 0,
errors,
};
}
function validateSceneFilesAgainstStoryboard(projectRoot, storyboard) {
const errors = [];
const actualFiles = listSceneComponentFiles(projectRoot);
const expectedFiles = storyboard.scenes.map((scene) => sceneIdToFilename(scene.id));
for (const expectedFile of expectedFiles) {
if (!actualFiles.includes(expectedFile)) {
errors.push(`缺少场景文件: ${expectedFile}`);
}
}
for (const actualFile of actualFiles) {
if (!expectedFiles.includes(actualFile)) {
errors.push(`存在未被 storyboard 引用的场景文件: ${actualFile}`);
}
}
return {
valid: errors.length === 0,
errors,
actualFiles,
expectedFiles,
};
}
function validateSceneComponentExports(projectRoot, storyboard) {
const errors = [];
for (const scene of storyboard.scenes) {
const filePath = path.join(getScenesDir(projectRoot), sceneIdToFilename(scene.id));
if (!fs.existsSync(filePath)) {
continue;
}
const content = fs.readFileSync(filePath, 'utf-8');
const componentName = sceneIdToComponentName(scene.id);
if (!content.includes(`export default ${componentName}`)) {
errors.push(`场景 ${scene.id} 必须使用默认导出:export default ${componentName}`);
}
}
return {
valid: errors.length === 0,
errors,
};
}
function analyzeSceneTimingUsage(content) {
const normalized = content.replace(/\r\n/g, '\n');
const segmentAliasNames = new Set();
const segmentAccessorNames = new Set();
const directSegmentIndexes = new Set();
for (const pattern of [/\b(?:const|let|var)\s+(\w+)\s*=\s*segments\b/g]) {
let match;
while ((match = pattern.exec(normalized)) !== null) {
segmentAliasNames.add(match[1]);
}
}
for (const pattern of [
/\bsegments\s*\[\s*(\d+)\s*\]/g,
]) {
let match;
while ((match = pattern.exec(normalized)) !== null) {
directSegmentIndexes.add(Number(match[1]));
}
}
for (const pattern of [
/\bconst\s+(\w+)\s*=\s*\([^)]*\)\s*=>\s*segments\s*\[[^\]]+\]/g,
/\b(?:let|var)\s+(\w+)\s*=\s*\([^)]*\)\s*=>\s*segments\s*\[[^\]]+\]/g,
/\bfunction\s+(\w+)\s*\([^)]*\)\s*\{\s*return\s+segments\s*\[[^\]]+\][\s\S]*?\}/g,
]) {
let match;
while ((match = pattern.exec(normalized)) !== null) {
segmentAccessorNames.add(match[1]);
}
}
const declaresSegmentsProp = /segments\??\s*:\s*Array<\{[\s\S]*?relativeStart[\s\S]*?\}>/.test(normalized)
|| /segments\??\s*:\s*Segment\[\]/.test(normalized)
|| /\(\{\s*segments\s*=/.test(normalized)
|| /\(\{\s*segments\s*\}\s*\)/.test(normalized);
const usesSegmentsCollectionPatterns = [
/segments\.map\(/,
/segments\.forEach\(/,
/segments\.reduce\(/,
/segments\.filter\(/,
/segments\s*\[/,
/for\s*\(\s*const\s+\w+\s+of\s+segments\s*\)/,
/for\s*\(\s*let\s+\w+\s*=\s*0;\s*\w+\s*<\s*segments\.length/,
];
const usesSegmentsCollection = usesSegmentsCollectionPatterns.some((pattern) => pattern.test(normalized))
|| segmentAliasNames.size > 0
|| segmentAccessorNames.size > 0;
const suspiciousTimingPatterns = [
/\bconst\s+(?:beat|b\d+|phase\d+|seg\d+)\w*\s*=\s*(?:msToFrames?|framesFromMs)\(\s*\d{3,}\b/g,
/\bconst\s+(?:beat|b\d+|phase\d+|seg\d+)\w*(?:Ms|Start|End|Frame|Frames)\s*=\s*\d{3,}\b/g,
/\b(?:msToFrames?|framesFromMs)\(\s*\d{3,}\s*,/g,
];
const suspiciousTimingMatches = suspiciousTimingPatterns.flatMap((pattern) => normalized.match(pattern) || []);
const hasSuspiciousHardcodedTiming = suspiciousTimingMatches.length > 0;
const usesLaterBeatEvidencePatterns = [
/segments\s*\[\s*1\s*\]/,
/segments\s*\[\s*2\s*\]/,
/seg2Start/,
/seg3Start/,
/segmentStartFrames?\s*\[\s*1\s*\]/,
/segmentStartFrames?\s*\[\s*2\s*\]/,
/segments\.map\(/,
/for\s*\(\s*const\s+\w+\s+of\s+segments\s*\)/,
];
const usesLaterBeatEvidence = usesLaterBeatEvidencePatterns.some((pattern) => pattern.test(normalized))
|| Array.from(segmentAccessorNames).some((name) => {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\b${escaped}\\s*\\(\\s*[12]\\s*\\)`).test(normalized);
});
return {
declaresSegmentsProp,
directSegmentIndexes: [...directSegmentIndexes].sort((a, b) => a - b),
usesSegmentsCollection,
hasSuspiciousHardcodedTiming,
suspiciousTimingMatches,
usesLaterBeatEvidence,
};
}
function validateSceneTimingAgainstStoryboard(projectRoot, storyboard) {
const errors = [];
const warnings = [];
for (const scene of storyboard.scenes) {
const filePath = path.join(getScenesDir(projectRoot), sceneIdToFilename(scene.id));
if (!fs.existsSync(filePath)) {
continue;
}
const content = fs.readFileSync(filePath, 'utf-8');
const analysis = analyzeSceneTimingUsage(content);
const segmentCount = Array.isArray(scene.segments) ? scene.segments.length : 0;
if (segmentCount > 1 && analysis.declaresSegmentsProp && !analysis.usesSegmentsCollection) {
errors.push(`场景 ${scene.id} 已声明 segments 接口,但实现中未真实消费 segments 数据`);
continue;
}
if (segmentCount > 1 && analysis.hasSuspiciousHardcodedTiming) {
const sample = analysis.suspiciousTimingMatches[0]?.replace(/\s+/g, ' ').trim();
errors.push(
`场景 ${scene.id} 检测到疑似硬编码时序拍点` + (sample ? `(例如:${sample})` : '')
);
continue;
}
if (segmentCount > 1 && !analysis.declaresSegmentsProp && analysis.usesSegmentsCollection) {
warnings.push(`场景 ${scene.id} 可能通过中间变量间接消费 segments,建议检查是否显式按 beat 绑定使用 segments`);
}
if (segmentCount >= 3 && !analysis.usesLaterBeatEvidence) {
warnings.push(`场景 ${scene.id} 可能缺少后续拍点展开,建议检查是否把主要内容过早堆在前段`);
}
const outOfRangeSegmentIndex = analysis.directSegmentIndexes.find((index) => index >= segmentCount);
if (typeof outOfRangeSegmentIndex === 'number') {
errors.push(`场景 ${scene.id} 访问了越界的 segments[${outOfRangeSegmentIndex}],当前仅有 ${segmentCount} 个 segments`);
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
function buildGeneratedScenesSource(storyboard, options = {}) {
const fps = options.fps || getActiveVideoProfile(options.videoSettings).fps;
const imports = storyboard.scenes
.map((scene) => {
const componentName = sceneIdToComponentName(scene.id);
return `import ${componentName} from "../scenes/${componentName}";`;
})
.join('\n');
const sceneEntries = storyboard.scenes
.map((scene) => {
const componentName = sceneIdToComponentName(scene.id);
const segmentsStr = JSON.stringify(scene.segments, null, 6)
.split('\n')
.map((line, i) => i === 0 ? line : ' ' + line)
.join('\n');
return ` {\n start: ${scene.startTime},\n duration: ${scene.duration},\n segments: ${segmentsStr},\n Component: ${componentName},\n },`;
})
.join('\n');
return `import type React from "react";
${imports}
type Segment = {
text: string;
relativeStart: number;
relativeDuration: number;
};
export type GeneratedSceneItem = {
start: number;
duration: number;
segments: Segment[];
Component: React.FC<{ segments: Segment[] }>;
};
export const generatedScenes: GeneratedSceneItem[] = [
${sceneEntries}
];
export const totalDurationInFrames = ${msToFrames(storyboard.totalDuration, fps)};
`;
}
module.exports = {
analyzeSceneTimingUsage,
DEFAULT_VIDEO_SETTINGS,
buildGeneratedScenesSource,
getActiveVideoProfile,
getScenesDir,
listSceneComponentFiles,
loadStoryboard,
loadVideoSettings,
msToFrames,
sceneIdToComponentName,
sceneIdToFilename,
validateSceneComponentExports,
validateSceneFilesAgainstStoryboard,
validateSceneTimingAgainstStoryboard,
validateStoryboardStructure,
};
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const {
buildGeneratedScenesSource,
getActiveVideoProfile,
loadStoryboard,
loadVideoSettings,
validateSceneComponentExports,
validateSceneFilesAgainstStoryboard,
validateSceneTimingAgainstStoryboard,
validateStoryboardStructure,
} = require('./scene-registry-utils');
function validateRoot(rootPath) {
const errors = [];
if (!fs.existsSync(rootPath)) {
errors.push(`缺少 Root.tsx: ${rootPath}`);
return { valid: false, errors };
}
const content = fs.readFileSync(rootPath, 'utf-8');
if (!content.includes('import { totalDurationInFrames } from "./compositions/generated-scenes";')) {
errors.push('Root.tsx 未从 generated-scenes.ts 导入 totalDurationInFrames');
}
if (!content.includes('durationInFrames={totalDurationInFrames}')) {
errors.push('Root.tsx 未使用 totalDurationInFrames 作为 Composition.durationInFrames');
}
return {
valid: errors.length === 0,
errors,
};
}
function validateMain(mainPath) {
const errors = [];
if (!fs.existsSync(mainPath)) {
errors.push(`缺少 Main.tsx: ${mainPath}`);
return { valid: false, errors };
}
const content = fs.readFileSync(mainPath, 'utf-8');
if (!content.includes('import { generatedScenes }')) {
errors.push('Main.tsx 未导入 generatedScenes');
}
if (!content.includes('generatedScenes.map((scene, index) =>')) {
errors.push('Main.tsx 未使用 generatedScenes 渲染场景序列');
}
if (!content.includes('<Component segments={scene.segments} />')) {
errors.push('Main.tsx 未通过 <Component segments={scene.segments} /> 向场景组件传递分段数据');
}
return {
valid: errors.length === 0,
errors,
};
}
function validateRegistry(registryPath, storyboard, videoSettings) {
const errors = [];
if (!fs.existsSync(registryPath)) {
errors.push(`缺少 generated-scenes.ts: ${registryPath}`);
return { valid: false, errors };
}
const actual = fs.readFileSync(registryPath, 'utf-8').trim();
const expected = buildGeneratedScenesSource(storyboard, { videoSettings }).trim();
if (actual !== expected) {
errors.push('generated-scenes.ts 与 storyboard.json / 场景文件不一致');
}
return {
valid: errors.length === 0,
errors,
};
}
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('用法: node validate-project.js <projectRoot> <storyboardPath>');
process.exit(1);
}
const [projectRoot, storyboardPath] = args;
const rootPath = path.join(projectRoot, 'src', 'Root.tsx');
const mainPath = path.join(projectRoot, 'src', 'compositions', 'Main.tsx');
const registryPath = path.join(projectRoot, 'src', 'compositions', 'generated-scenes.ts');
try {
const storyboard = loadStoryboard(storyboardPath);
const videoSettings = loadVideoSettings(projectRoot);
const activeProfile = getActiveVideoProfile(videoSettings);
const validations = [
validateStoryboardStructure(storyboard),
validateSceneFilesAgainstStoryboard(projectRoot, storyboard),
validateSceneComponentExports(projectRoot, storyboard),
validateSceneTimingAgainstStoryboard(projectRoot, storyboard),
validateRegistry(registryPath, storyboard, videoSettings),
validateRoot(rootPath),
validateMain(mainPath),
];
const errors = validations.flatMap((result) => result.errors);
const warnings = validations.flatMap((result) => result.warnings || []);
if (errors.length > 0) {
console.error('❌ 项目校验失败:');
errors.forEach((error) => console.error(` - ${error}`));
process.exit(1);
}
if (warnings.length > 0) {
console.warn('⚠️ 项目校验告警:');
warnings.forEach((warning) => console.warn(` - ${warning}`));
}
console.log('✅ 项目校验通过');
console.log(` - projectRoot: ${projectRoot}`);
console.log(` - storyboard: ${storyboardPath}`);
console.log(` - sceneCount: ${storyboard.sceneCount}`);
console.log(` - videoProfile: ${activeProfile.width}x${activeProfile.height} / ${activeProfile.fps}fps (${activeProfile.name})`);
console.log('');
console.log('__RESULT_JSON__');
console.log(JSON.stringify({
success: true,
projectRoot,
storyboardPath,
sceneCount: storyboard.sceneCount,
videoProfile: activeProfile,
}));
} catch (error) {
console.error(`❌ 错误: ${error.message}`);
process.exit(1);
}
}
main();
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
function fail(errors) {
console.error('❌ scene-plan 校验失败:');
errors.forEach((error) => console.error(` - ${error}`));
console.log('');
console.log('__RESULT_JSON__');
console.log(JSON.stringify({
success: false,
errors,
}));
process.exit(1);
}
function parseArgs() {
const [planPathArg, scenesDataPathArg] = process.argv.slice(2);
if (!planPathArg || !scenesDataPathArg) {
console.error('用法: node validate-scene-plan.js <planPath> <scenesDataPath>');
process.exit(1);
}
return {
planPath: path.resolve(planPathArg),
scenesDataPath: path.resolve(scenesDataPathArg),
};
}
function loadJson(filePath, label) {
if (!fs.existsSync(filePath)) {
throw new Error(`${label} 不存在: ${filePath}`);
}
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function validateCard(card, scene, errors) {
const requiredStringFields = ['sceneId', 'goal', 'layout', 'visualCore', 'surface', 'emphasis'];
requiredStringFields.forEach((field) => {
if (!isNonEmptyString(card[field])) {
errors.push(`scene ${scene.id} 的 ${field} 必须为非空字符串`);
}
});
if (!Array.isArray(card.screenShouldShow) || card.screenShouldShow.length === 0) {
errors.push(`scene ${scene.id} 的 screenShouldShow 必须为非空数组`);
} else if (card.screenShouldShow.some((item) => !isNonEmptyString(item))) {
errors.push(`scene ${scene.id} 的 screenShouldShow 项必须为非空字符串`);
}
if (!Array.isArray(card.beatPlan) || card.beatPlan.length === 0) {
errors.push(`scene ${scene.id} 的 beatPlan 必须为非空数组`);
return;
}
const coverage = [];
card.beatPlan.forEach((beat, beatIndex) => {
if (!beat || !Array.isArray(beat.segments) || beat.segments.length === 0) {
errors.push(`scene ${scene.id} 的 beatPlan[${beatIndex}] 必须包含非空 segments 数组`);
return;
}
if (!isNonEmptyString(beat.action)) {
errors.push(`scene ${scene.id} 的 beatPlan[${beatIndex}] action 必须为非空字符串`);
}
let prev = null;
const seen = new Set();
beat.segments.forEach((segmentIndex) => {
if (!Number.isInteger(segmentIndex)) {
errors.push(`scene ${scene.id} 的 beatPlan[${beatIndex}] 包含非整数 segment 索引`);
return;
}
if (segmentIndex < 0 || segmentIndex >= scene.segments.length) {
errors.push(`scene ${scene.id} 的 beatPlan[${beatIndex}] segment 索引越界: ${segmentIndex}`);
}
if (seen.has(segmentIndex)) {
errors.push(`scene ${scene.id} 的 beatPlan[${beatIndex}] segment 索引重复: ${segmentIndex}`);
}
if (prev !== null && segmentIndex !== prev + 1) {
errors.push(`scene ${scene.id} 的 beatPlan[${beatIndex}] 只能合并相邻 segments`);
}
seen.add(segmentIndex);
prev = segmentIndex;
coverage.push(segmentIndex);
});
});
if (coverage.length > 0) {
const sorted = [...coverage].sort((a, b) => a - b);
for (let i = 0; i < sorted.length; i += 1) {
if (sorted[i] !== i) {
errors.push(`scene ${scene.id} 的 beatPlan 必须完整且唯一覆盖 0 到 ${scene.segments.length - 1} 的全部 segments`);
break;
}
if (i > 0 && sorted[i] === sorted[i - 1]) {
errors.push(`scene ${scene.id} 的 beatPlan 存在重复覆盖的 segment 索引: ${sorted[i]}`);
break;
}
}
if (sorted.length !== scene.segments.length) {
errors.push(`scene ${scene.id} 的 beatPlan 覆盖数量应为 ${scene.segments.length},实际为 ${sorted.length}`);
}
}
}
function main() {
const {planPath, scenesDataPath} = parseArgs();
const errors = [];
let plan;
let scenesData;
try {
plan = loadJson(planPath, 'scene-plan');
scenesData = loadJson(scenesDataPath, 'scenesData');
} catch (error) {
fail([error.message]);
}
if (!Array.isArray(plan)) {
fail(['scene-plan 根结构必须是 JSON 数组']);
}
if (!Array.isArray(scenesData)) {
fail([`scenesData 根结构必须是 JSON 数组: ${scenesDataPath}`]);
}
if (scenesData.length === 0) {
fail(['scenesData 不能为空数组']);
}
if (plan.length !== scenesData.length) {
errors.push(`scene-plan 数量应为 ${scenesData.length},实际为 ${plan.length}`);
}
const scenesDataMap = new Map(scenesData.map((scene) => [scene.id, scene]));
const allowedSceneIds = scenesData.map((scene) => scene.id);
const allowedSet = new Set(allowedSceneIds);
const actualIds = [];
plan.forEach((card, index) => {
if (!card || typeof card !== 'object' || Array.isArray(card)) {
errors.push(`scene-plan[${index}] 必须为对象`);
return;
}
if (!isNonEmptyString(card.sceneId)) {
errors.push(`scene-plan[${index}] 缺少有效的 sceneId`);
return;
}
actualIds.push(card.sceneId);
if (!allowedSet.has(card.sceneId)) {
errors.push(`scene ${card.sceneId} 不在当前 creator 的 scenesData 范围内`);
return;
}
const scene = scenesDataMap.get(card.sceneId);
if (!scene) {
errors.push(`scene ${card.sceneId} 在 scenesData 中不存在`);
return;
}
validateCard(card, scene, errors);
});
const actualSet = new Set(actualIds);
allowedSceneIds.forEach((sceneId) => {
if (!actualSet.has(sceneId)) {
errors.push(`scene-plan 缺少 scene: ${sceneId}`);
}
});
if (actualSet.size !== actualIds.length) {
errors.push('scene-plan 中存在重复的 sceneId');
}
if (errors.length > 0) {
fail(errors);
}
console.log('✅ scene-plan 校验通过');
console.log(` - planPath: ${planPath}`);
console.log(` - scenes: ${allowedSceneIds.join(', ')}`);
console.log('');
console.log('__RESULT_JSON__');
console.log(JSON.stringify({
success: true,
planPath,
validatedSceneIds: allowedSceneIds,
}));
}
main();
Cartoon UI Style Guide Reference
本文件提供 cartoon-ui-style-guide.css 的参考信息、示例和速查内容。
说明
cartoon-ui-style-guide.css是规范主文件- 本文档是参考文档
- Creator 在规划阶段同时读取主文件与本文件
使用建议
宿主融合规则
本节是布局与 surface 选择策略的唯一维护入口。主流程和 Creator 协议只负责读取本文件,不重复维护具体视觉偏好。
- 默认宿主背景是米黄色 + 网格,因此主体承托面应与宿主背景融合,而不是压出一整块生硬的纯白板
- 不要把纯白或近纯白实体大底板作为唯一主体背景,尤其避免大面积
paper-note、torn-paper、comic-panel直接整块铺开 - 单布局 / 单主体画面优先不要使用明显大边框容器,除黑板外默认使用无框中央舞台
- 只有小面积信息卡、术语卡、局部标签、补充纸片可以使用更接近实色的纸白
- 如果场景主要依赖图解、流程、关系或节点组合,单布局优先使用无框分层承托;
whiteboard-zone只作为局部元素容器或多分区子容器,不作为中央大白板
单布局无框舞台规则
单布局 / 单主体画面指:一个主要图解、一个中心关系图、一个图表、一组围绕中心展开的节点、一个流程主视觉,画面没有明确的左右对比、三栏分区、多张独立卡片或漫画分格。
规则:
- 单布局默认使用透明根层 + 无框中央舞台,让主体直接生长在宿主米黄色网格背景上
- 除黑板样式外,单布局不得使用明显大边框容器作为主承托,包括
whiteboard-zone、sketch-border、sketch-border-alt、comic-panel、paper-note、paper-note-folded、torn-paper、main-canvas、wood-frame - 禁止在
surface中写“中央白板区域”“白色大面板”“虚线白板承托”“手绘边框主舞台”“漫画粗边框舞台”等方案 - 图解、流程、关系、节点组合、图表优先通过图形本身、空间分组、轻阴影、色块、标签、编号和节奏动画建立层级
- 黑板
chalkboard-card/chalkboard-enhanced是唯一允许作为单布局主容器的明显边框例外,但仍应作为局部主视觉,不要铺满整屏 - 边框容器可以作为元素级容器,例如小信息卡、术语卡、节点卡、局部标签、对比栏内部卡片、多分区子面板、列表项或补充纸片
- 多布局场景(左右对比、三栏并列、漫画分格、多个独立步骤卡片)可以使用边框子容器,但不要再额外套一个中央大外框
颜色使用优先级
- 主要操作 / 强调:
--primary-yellow - 成功 / 正面:
--primary-green - 信息 / 中性:
--primary-blue - 警告 / 错误:
--accent-red - 背景 / 内容区:
--bg-cream,--bg-paper
字体配对建议
- 标题 + 正文:
--font-title+--font-body - 黑板场景:
--font-chalk - 艺术强调:
--font-accent
阴影使用建议
- 卡片 / 容器:
--shadow-md - 按钮悬浮:
--shadow-sm->--shadow-md - 弹窗 / 模态:
--shadow-xl - 黑板内凹:
--shadow-inset-chalkboard
非规范示例
以下内容仅作为参考模式:
- 列表交错入场
- 黑板场景示例
- 对比卡片示例
- 纹理叠加示例
- 教学场景时序示例
示例 1: 列表交错入场
适合功能点、步骤项、要点清单依次出现的场景。
<ul class="feature-list stagger-children stagger-md">
<li class="feature-item seq-enter-up">功能一</li>
<li class="feature-item seq-enter-up">功能二</li>
<li class="feature-item seq-enter-up">功能三</li>
</ul>.feature-list {
list-style: none;
padding: 0;
margin: 0;
}
.feature-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-xs);
background: var(--bg-paper);
border: var(--border-thin) solid var(--text-dark);
border-radius: var(--radius-lg);
font-family: var(--font-body);
}
.feature-item::before {
content: '✓';
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--primary-green);
color: var(--text-light);
border-radius: var(--radius-circle);
font-size: var(--text-small);
}使用建议:
- 配合
stagger-sm或stagger-md - 适合信息密度中等的解释场景
- 列表不要太长,3-6 项更合适
示例 2: 黑板场景
适合教学说明、要点归纳、公式或结构性讲解。
<div class="chalkboard-scene">
<div class="chalkboard-card chalkboard-enhanced">
<h1 class="text-chalk">今日要点</h1>
<ul class="chalk-list stagger-children">
<li>要点一</li>
<li>要点二</li>
<li>要点三</li>
</ul>
</div>
</div>.chalkboard-scene {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-cream);
padding: var(--space-xl);
}
.chalk-list {
list-style: none;
padding: 0;
margin: var(--space-md) 0 0 0;
}
.chalk-list li {
font-family: var(--font-chalk);
font-size: var(--text-large);
color: var(--text-light);
padding: var(--space-xs) 0;
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.2);
opacity: 0;
animation: sequence-enter-left var(--duration-normal) var(--ease-smooth) forwards;
}
.chalk-list li::before {
content: '→ ';
color: var(--primary-yellow);
}使用建议:
- 黑板容器适合作为局部主视觉,不建议整屏铺满
- 文字数量应控制,优先做“要点呈现”而不是大段段落
- 可与
overlay-chalk或局部粉笔纹理搭配
示例 3: 对比卡片布局
适合展示旧方案 / 新方案、错误 / 正确、A / B 对照。
<div class="comparison-container">
<div class="comparison-card comparison-negative">
<h3>旧方案</h3>
<ul>...</ul>
</div>
<div class="comparison-vs">VS</div>
<div class="comparison-card comparison-positive">
<h3>新方案</h3>
<ul>...</ul>
</div>
</div>.comparison-container {
display: flex;
align-items: stretch;
gap: var(--space-lg);
padding: var(--space-xl);
}
.comparison-card {
flex: 1;
padding: var(--space-lg);
border: var(--border-medium) solid var(--text-dark);
border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;
box-shadow: var(--shadow-md);
}
.comparison-negative {
background: #FDEDEC;
border-color: var(--accent-red);
}
.comparison-positive {
background: var(--deco-light-yellow);
border-color: var(--primary-yellow);
}
.comparison-vs {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-title);
font-size: var(--text-display);
font-weight: var(--weight-bold);
color: var(--text-dark);
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.1);
}使用建议:
- 推荐配合
.check-mark/.cross-mark - 每侧信息量尽量对齐,避免一侧过重
- 中间
VS只在明显二元对照时使用
示例 4: 纹理叠加
适合给内容区增加纸张或白板氛围,而不改变全局宿主背景。
<div class="textured-scene bg-grid">
<div class="content-box vintage-paper">
<h2>复古风格内容</h2>
</div>
</div>.textured-scene {
width: 100%;
height: 100%;
padding: var(--space-xl);
display: flex;
align-items: center;
justify-content: center;
}
.content-box {
max-width: var(--max-width-md);
padding: var(--space-xl);
}使用建议:
bg-grid适合米黄色宿主背景上的局部纸张感vintage-paper适合引用、历史背景、概念定义- 纹理只做辅助,不要盖过主要信息
- 如果容器尺寸已经很大,优先降低白度、提升透明度或改用暖底纹理,不要再叠加纯白整面
示例 5: 教学场景时序模板
适合把“标题出现、内容展开、细节补充、强调出现”分阶段实现。
const TeachingScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const timing = {
sceneEnter: 0,
titleEnter: 0.2 * fps,
contentEnter: 0.4 * fps,
detailsEnter: 0.6 * fps,
stagger: 0.1 * fps,
};
const titleProgress = spring({
frame: frame - timing.titleEnter,
fps,
config: { damping: 15, stiffness: 100 },
});
return (
<AbsoluteFill style={{ background: '#FDF6E3' }}>
<div
style={{
opacity: titleProgress,
transform: `scale(${titleProgress})`,
}}
>
<h1>场景标题</h1>
</div>
<Sequence from={timing.contentEnter}>
<ContentArea />
</Sequence>
</AbsoluteFill>
);
};使用建议:
- 标题、主体、细节、强调不要同帧一起出现
- 一个教学场景通常有 3-4 个主要节奏点就够了
- 如果用字幕分段驱动,优先对齐
segment.relativeStart
快速参考
常用样式变量
- 背景:
--bg-cream - 主要文字:
--text-dark - 主强调:
--primary-yellow - 标准阴影:
--shadow-md - 标准节奏:
--duration-normal
常用 surface
frameless-stage/transparent-stage(单布局默认)whiteboard-zone(仅局部元素容器或多分区子容器)sketch-bordersticky-notevintage-paperspeech-bubbleribbon-bannerindex-cardcomic-panel(仅适合分格、对比、步骤,不适合纯白整面铺底)
容器类型速查表
| 容器 | 视觉特征 | 常见用途 |
|---|---|---|
frameless-stage / transparent-stage | 无明显边框,直接使用宿主背景组织主视觉 | 单布局 / 单主体图解、流程、关系图、图表的默认选择 |
sketch-border | 不规则手绘边框卡片 | 小面积内容容器、多分区子卡片;不用于单布局中央大承托 |
sketch-border-alt | 手绘边框变体 | 小面积内容容器,适合交替使用;不用于单布局中央大承托 |
chalkboard-card | 深绿底 + 粉笔字氛围 | 教学说明、公式、结构讲解 |
chalkboard-enhanced | 增强黑板纹理 | 重要教学内容、重点推导 |
paper-note | 暖纸面便签感,允许宿主底色轻微透出 | 备注、补充说明、旁注,不建议做整屏主底 |
paper-note-folded | 带折角的暖纸片 | 提示信息、补充提醒,不建议放大成唯一主体 |
sticky-note | 黄色便签纸 + 轻纹理 | 要点、记忆点、行动提示 |
vintage-paper | 复古羊皮纸质感 | 引用、历史背景、定义说明 |
wood-frame | 木质边框展示区 | 图片、重点展示、案例画面 |
speech-bubble | 带尖角的气泡容器 | 对话、引用、观点表达 |
ribbon-banner | 手绘标题条 / 丝带标题 | 标题、阶段名、章节分隔 |
torn-paper | 手撕暖纸边缘 | 列表、步骤、笔记片段,不建议作为唯一大背景 |
stamp-badge | 印章 / 徽章式强调 | 关键词、结论、评分、标签 |
whiteboard-zone | 浅暖底虚线白板区 | 局部图解区、多分区子容器;不用于单布局中央大承托 |
index-card | 顶部彩条 + 横线纹理 | 定义、术语、知识点,适合作为局部信息卡 |
comic-panel | 漫画分格承托区,弱化纯白感 | 步骤演示、故事、对比,适合有明确分区时使用;不用于单布局大外框 |
大主体容器选择建议
- 图解 / 流程 / 架构说明:单布局优先
frameless-stage/transparent-stage,不要套whiteboard-zone - 需要纸面氛围但不想显得廉价:只在小面积信息卡或局部说明中使用
vintage-paper、paper-note或index-card - 对比 / 分步 / 漫画式叙事:可用
comic-panel,但必须依赖分格、标签或局部卡片来建立层次,不要只剩一整块白底 - 当画面本身已有足够节点、标签、编号、色块和图形关系时,可以直接使用透明根层 + 局部承托,不必额外补一个大底板
- 黑板
chalkboard-card/chalkboard-enhanced是唯一允许作为单布局明显主容器的例外
常用 emphasis
underline-markerunderline-wavyhand-circlehand-circle-glowcheck-markcross-mark
{
"name": "remotion-video",
"version": "1.0.0",
"description": "Remotion video template project",
"scripts": {
"start": "remotion studio",
"build": "remotion render src/index.ts Main out/output.mp4",
"upgrade": "remotion upgrade"
},
"author": "",
"license": "ISC",
"dependencies": {
"@remotion/cli": "4.0.242",
"lucide-react": "0.563.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"remotion": "4.0.242"
},
"devDependencies": {
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"typescript": "5.7.2"
}
}
import type React from "react";
type Segment = {
text: string;
relativeStart: number;
relativeDuration: number;
};
export type GeneratedSceneItem = {
start: number;
duration: number;
segments: Segment[];
Component: React.FC<{ segments: Segment[] }>;
};
export const generatedScenes: GeneratedSceneItem[] = [];
export const totalDurationInFrames = 150;
import { AbsoluteFill, Sequence, useCurrentFrame } from "remotion";
import { Sparkles } from "lucide-react";
import { designTokens, hostDecor } from "../design-system";
import { videoConfig } from "../video-config";
import { generatedScenes } from "./generated-scenes";
const msToFrames = (ms: number) => Math.round((ms / 1000) * videoConfig.fps);
export const Main: React.FC = () => {
const frame = useCurrentFrame();
const gridOffset = ((frame * hostDecor.gridScrollSpeed) / videoConfig.fps) % hostDecor.gridSizePx;
return (
<AbsoluteFill style={{ backgroundColor: designTokens.background.host, overflow: "hidden" }}>
<div
style={{
position: "absolute",
width: videoConfig.designWidth,
height: videoConfig.designHeight,
transform: `scale(${videoConfig.stageScale})`,
transformOrigin: "top left",
}}
>
<AbsoluteFill
style={{
backgroundImage: `linear-gradient(${designTokens.background.grid} 1.5px, transparent 1.5px), linear-gradient(90deg, ${designTokens.background.grid} 1.5px, transparent 1.5px)`,
backgroundSize: hostDecor.gridSize,
backgroundPosition: `${gridOffset}px ${gridOffset}px`,
opacity: hostDecor.gridOpacity,
}}
/>
{/* 在这里添加你的内容 */}
{hostDecor.sparkles.map((sparkle, index) => (
<div
key={index}
style={{
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
top: sparkle.top,
right: sparkle.right,
bottom: sparkle.bottom,
left: sparkle.left,
color: designTokens.accent.primary,
opacity: sparkle.base + sparkle.amp * Math.sin(frame * sparkle.speed + sparkle.phase),
}}
>
<Sparkles size={sparkle.fontSize} strokeWidth={2.2} />
</div>
))}
{generatedScenes.map((scene, index) => {
const fromFrame = msToFrames(scene.start);
let durationInFrames: number;
if (index < generatedScenes.length - 1) {
const nextStart = generatedScenes[index + 1].start;
durationInFrames = msToFrames(nextStart - scene.start);
} else {
durationInFrames = msToFrames(scene.duration);
}
const Component = scene.Component;
return (
<Sequence key={index} name={`Scene${String(index + 1).padStart(3, '0')}`} from={fromFrame} durationInFrames={durationInFrames}>
<Component segments={scene.segments} />
</Sequence>
);
})}
</div>
</AbsoluteFill>
);
};
type HostSparkle = {
top?: number;
right?: number;
bottom?: number;
left?: number;
fontSize: number;
phase: number;
base: number;
amp: number;
speed: number;
};
export const designTokens = {
background: {
host: "#FDF6E3",
paper: "#FFFEF9",
grid: "#9EA7AD",
},
accent: {
primary: "#F5B041",
},
} as const;
export const hostDecor = {
gridSize: "20px 20px",
gridSizePx: 20,
gridOpacity: 0.28,
gridScrollSpeed: 18,
sparkles: [
{ top: 75, left: 160, fontSize: 14, phase: 0, base: 0.5, amp: 0.3, speed: 0.11 },
{ top: 50, right: 200, fontSize: 10, phase: 0.5, base: 0.4, amp: 0.3, speed: 0.13 },
{ bottom: 85, right: 150, fontSize: 12, phase: 1, base: 0.4, amp: 0.3, speed: 0.14 },
{ bottom: 60, left: 220, fontSize: 10, phase: 2, base: 0.35, amp: 0.3, speed: 0.12 },
{ top: 160, right: 120, fontSize: 8, phase: 1.5, base: 0.3, amp: 0.25, speed: 0.15 },
] satisfies HostSparkle[],
} as const;
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
import { Composition } from "remotion";
import { Main } from "./compositions/Main";
import { totalDurationInFrames } from "./compositions/generated-scenes";
import { videoConfig } from "./video-config";
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="Main"
component={Main}
durationInFrames={totalDurationInFrames}
fps={videoConfig.fps}
width={videoConfig.width}
height={videoConfig.height}
/>
</>
);
};
import videoSettings from "./video-settings.json";
type VideoProfile = keyof typeof videoSettings.profiles;
const profileName = videoSettings.profile as VideoProfile;
const activeProfile = videoSettings.profiles[profileName];
if (!activeProfile) {
throw new Error(`Unknown video profile: ${videoSettings.profile}`);
}
export const videoConfig = {
profile: profileName,
width: activeProfile.width,
height: activeProfile.height,
fps: activeProfile.fps,
designWidth: videoSettings.design.width,
designHeight: videoSettings.design.height,
stageScale: Math.min(
activeProfile.width / videoSettings.design.width,
activeProfile.height / videoSettings.design.height,
),
};
{
"profile": "1080p30",
"design": {
"width": 1920,
"height": 1080
},
"profiles": {
"1080p30": {
"width": 1920,
"height": 1080,
"fps": 30
},
"4k60": {
"width": 3840,
"height": 2160,
"fps": 60
}
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}