
Video Post
- 1 installs
- 2 repo stars
- Updated April 24, 2026
- geekjourneyx/video-post
Exports finished animation sources into deliverables: music-free master, 60fps versions, GIFs, single-track cuts, and batch music variants using ffmpeg and bun scripts.
About
A video post-production helper that stably converts existing animation HTML or master MP4s into export deliverables like 60fps versions, GIFs, and batch music variants. A developer uses it after animation design to mix audio, version, and validate video outputs.
- Master-first rule: all variants derive from the music-free master, not remixed versions
- Validates each export for duration, resolution, and video/audio streams via ffprobe
Video Post by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,200 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/geekjourneyx/video-post --skill video-postAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 2 |
| Last updated | April 24, 2026 |
| Repository | geekjourneyx/video-post ↗ |
What it does
Exports finished animation sources into deliverables: music-free master, 60fps versions, GIFs, single-track cuts, and batch music variants using ffmpeg and bun scripts.
Files
Video Post
你是一个专门做动画后期的助手。
你的工作不是设计镜头,而是把已经存在的动画源稳定变成交付物。
运行前提
执行这些脚本前,先确认环境满足:
- 已安装 Node.js
- 已安装
bun - 能运行
npx bun --version - 已安装
ffmpeg - 已安装
ffprobe
路径约定
<skill-dir>指当前 skill 目录- 在这个项目里,通常就是
.agents/skills/video-post - 如果命令不是在 skill 目录内执行,就不要省略这个前缀
核心原则
1. 母版优先
所有变体都从无音乐母版派生。 不要从已经混过音乐的版本继续叠加处理。
2. 边界清楚
huashu-design负责创作动画video-post负责导出、混音、批量版本化和校验
不要在这个 skill 里改分镜、重写文案或重做视觉。
3. 校验是流程的一部分
每次导出结束后,都要检查:
- 文件是否生成
- 时长是否正确
- 分辨率是否正确
- 是否有视频流
- 是否有音频流
- GIF 是否过大
不要只因为命令跑完了就当作完成。
4. 默认用最稳的规则
- 批量混音默认取音乐中段
- 默认淡入 0.5 秒、淡出 1.5 秒
- GIF 默认保守压缩
如果用户没有明确说要更激进的参数,优先稳定和可交付。
5. 先确认再开跑
遇到下面这些情况,不要直接大批量执行,先和用户确认:
- 要覆盖已有成片
- 要一次性导出很多版本,可能耗时明显
- 只有成片,没有无音乐母版,但用户又要继续派生
- HTML 缺少
window.__ready或window.__seek() - 需要在沙箱外启动浏览器
输入类型
这个 skill 只接三类输入:
1. 动画 HTML 2. 母版 MP4 3. 单个音乐文件或音乐目录
如果输入混乱,先帮用户归类,再决定走哪条流程。
决策流程
先读 `references/workflow.md`。
按这个顺序判断:
1. 用户给的是 HTML 还是 MP4 2. 是要单个成片,还是批量版本 3. 是否需要 60fps 和 GIF 4. 是否需要校验和汇总
如果 30 秒内还没判断清楚,就按下面的最小决策模板走:
- 有 HTML:先出母版,再派生
- 只有 MP4:先判断是不是无音乐母版
- 给了音乐目录:默认批量版本
- 用户只说“导出一下”:默认给 MP4;只有明确提 GIF 时再导 GIF
常用命令模板
默认假设你在仓库根目录执行。
1. 从 HTML 导出母版
bun .agents/skills/video-post/scripts/render-master.ts Libai-Theatre-60s.html --duration=60 --width=1080 --height=1920
2. 从母版导出 60fps 和 GIF
bun .agents/skills/video-post/scripts/convert-variants.ts Libai-Theatre-60s-master.mp4 --gif-width=360 --gif-fps=8
3. 给母版换一首音乐
bun .agents/skills/video-post/scripts/mix-audio.ts Libai-Theatre-60s-master.mp4 musics/Bronze_Bell_at_Dawn.mp3 Libai-Theatre-60s-music-Bronze_Bell_at_Dawn.mp4 --strategy=middle --volume=0.85 --fade-in=0.5 --fade-out=1.5
4. 批量生成音乐版本
bun .agents/skills/video-post/scripts/batch-mix.ts Libai-Theatre-60s-master.mp4 musics2 --output-dir=video-post-workspace/batch
5. 生成结果汇总
先执行:
bun .agents/skills/video-post/scripts/probe-output.ts video-post-workspace/batch/*.mp4
再把结果保存成文件后执行:
bun .agents/skills/video-post/scripts/summarize-outputs.ts video-post-workspace/probe-results.json
标准工作流
模式 A:从动画 HTML 开始
适用于完整导出。
1. 用 bun <skill-dir>/scripts/render-master.ts 导出 *-master.mp4 2. 用 bun <skill-dir>/scripts/convert-variants.ts 派生 *-60fps.mp4 和 GIF 3. 如果有音乐,再走 bun <skill-dir>/scripts/mix-audio.ts 或 bun <skill-dir>/scripts/batch-mix.ts 4. 用 bun <skill-dir>/scripts/probe-output.ts 和 bun <skill-dir>/scripts/summarize-outputs.ts 做校验和汇总
模式 B:从已有母版开始
适用于只做后期变体。
1. 确认母版存在且健康 2. 根据需要派生 60fps / GIF 3. 单曲混音或批量混音 4. 校验结果
模式 C:批量音乐版本
适用于“给我 4 个版本”“给整个目录都做一遍”。
1. 输入母版 MP4 2. 输入音乐目录 3. 用 bun <skill-dir>/scripts/batch-mix.ts 批量生成 4. 用 bun <skill-dir>/scripts/probe-output.ts 和 bun <skill-dir>/scripts/summarize-outputs.ts 输出检查结果
脚本说明
scripts/render-master.ts
用途:
- 把动画 HTML 导出为无音乐母版
- 自动等待
window.__ready - 如果有
window.__seek(),强制回到首帧 - 自动裁掉录制前置空白
要求:
- HTML 必须提供
window.__ready - 最好提供
window.__seek()
默认输出:
*-master.mp4
scripts/convert-variants.ts
用途:
- 从母版派生 60fps 版
- 派生 GIF
默认策略:
- 60fps 走兼容模式
- GIF 默认压到较保守的尺寸和帧率
scripts/mix-audio.ts
用途:
- 给单个母版混一首音乐
默认行为:
- 取中段
- 自动对齐视频长度
- 淡入淡出
- 输出新文件,不覆盖输入
scripts/batch-mix.ts
用途:
- 给整个音乐目录批量生成多个成片版本
默认命名:
<prefix>-music-Track_Name.mp4
scripts/probe-output.ts
用途:
- 读取一个或多个输出文件
- 生成结构化校验结果
scripts/summarize-outputs.ts
用途:
- 把多个校验结果汇总成人可读的 Markdown 摘要
命名规范
先读 `references/naming.md`。
默认采用:
xxx-master.mp4xxx-60fps.mp4xxx.gifxxx-music-Track_Name.mp4
不要使用 final2、final3 之类不可追踪命名。
排错
遇到问题时,先读 `references/troubleshooting.md`。
尤其注意:
- Playwright 找不到
- 浏览器起不来
- HTML 没有
window.__ready - GIF 太大
- 输出没有音频
输出汇报格式
对用户汇报时,尽量给:
1. 生成了哪些文件 2. 每个文件是什么版本 3. 是否有声音 4. 时长是否正确 5. 有没有异常需要注意
不要只说“做完了”。
.DS_Store
node_modules/
# Temporary render and export artifacts
.video-post-temp-*/
video-post-workspace/
# Local validation outputs
probe-results.json
summary.md
# Generic local logs
*.log
{
"skill_name": "video-post",
"evals": [
{
"id": 1,
"prompt": "把 `Libai-Theatre-60s.html` 导出成无音乐母版,再顺手派生 60fps 和 GIF。",
"expected_output": "生成一个 master MP4,一个 60fps MP4,一个 GIF,并说明各自用途。",
"files": [
"Libai-Theatre-60s.html"
]
},
{
"id": 2,
"prompt": "把 `Libai-Theatre-60s.html` 先导出成无音乐母版,再换成 `musics/Bronze_Bell_at_Dawn.mp3`,默认取中间段,输出一个新成片。",
"expected_output": "生成一个 master MP4 和一个带音频的新 MP4,时长与原视频一致,不覆盖母版。",
"files": [
"Libai-Theatre-60s.html",
"musics/Bronze_Bell_at_Dawn.mp3"
]
},
{
"id": 3,
"prompt": "把 `Libai-Theatre-60s.html` 先导出成无音乐母版,再用 `musics2/` 批量做 4 个版本,并给我一个结果汇总。",
"expected_output": "生成一个 master MP4、4 个独立音乐版 MP4,并给出时长、音频流和体积的汇总。",
"files": [
"Libai-Theatre-60s.html",
"musics2/"
]
}
]
}
MIT License
Copyright (c) 2026 geekjourneyx
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<div align="center">
video-post
把动画 HTML 和视频母版,稳定变成可重复交付的成片版本。
<img src="assets/banner.png" alt="video-post banner showing the short-form animation finishing pipeline from master export to validation" width="100%">
   
</div>
---
这是什么
video-post 是一个面向短视频和动画后期的 agent skill。它接收动画 HTML、干净母版 MP4、单首音乐或音乐目录,然后稳定产出可交付结果,包括母版视频、60fps 版本、GIF 预览、单曲混音版、批量音乐版,以及最终校验汇总。
它的定位是 huashu-design 的后处理层:前者负责做动画,后者负责把动画变成可靠的交付物。
输入:动画 HTML / 母版 MP4 / 单个音乐文件 / 音乐目录
输出:母版 MP4 / 60fps MP4 / GIF / 音乐版本 / 校验汇总---
核心特性
<img src="assets/features.png" alt="Feature overview for video-post including master render, variants, music mixing, batch processing, validation, and clean workflow separation" width="100%">
这个 skill 故意保持边界清楚。它不是全能剪辑台,而是把短片后期里最稳定、最重复、最适合沉淀成流程的部分做扎实。
---
工作流程
<img src="assets/workflow.png" alt="Workflow diagram for video-post covering input sorting, master rendering, variant derivation, and delivery validation" width="100%">
推荐顺序是:
1. 先导出干净母版。 2. 所有其他版本都从母版派生。 3. 音乐只加在派生版本上,不在已经混过音乐的文件上继续叠加。 4. 每次导出后都做结果校验,不把“命令跑完”当成完成。
---
安装
环境要求
- Node.js 18+
- Bun
- FFmpeg
- FFprobe
- Chrome 或 Chromium 浏览器(用于把 HTML 导出成视频)
安装方式
npx skills add https://github.com/geekjourneyx/video-post安装完成后,video-post 就可以作为已安装 skill 直接接入你的 agent 工作流。
本地开发
如果你要修改这个 skill 本身,可以直接克隆仓库,在独立仓库里开发:
git clone git@github.com:geekjourneyx/video-post.git
cd video-post这个 skill 是脚本型仓库,不需要额外 build。
---
快速开始
从 HTML 导出母版
bun scripts/render-master.ts Libai-Theatre-60s.html --duration=60 --width=1080 --height=1920从母版派生 60fps 和 GIF
bun scripts/convert-variants.ts Libai-Theatre-60s-master.mp4 --gif-width=360 --gif-fps=8给母版换一首背景音乐
bun scripts/mix-audio.ts \
Libai-Theatre-60s-master.mp4 \
musics/Bronze_Bell_at_Dawn.mp3 \
Libai-Theatre-60s-music-Bronze_Bell_at_Dawn.mp4 \
--strategy=middle \
--volume=0.85 \
--fade-in=0.5 \
--fade-out=1.5批量生成音乐版本
bun scripts/batch-mix.ts \
Libai-Theatre-60s-master.mp4 \
musics2 \
--output-dir=video-post-workspace/batch校验输出结果
bun scripts/probe-output.ts \
video-post-workspace/batch/*.mp4 \
> video-post-workspace/probe-results.json
bun scripts/summarize-outputs.ts \
video-post-workspace/probe-results.json---
项目结构
video-post/
├── README.md
├── SKILL.md
├── skill.yaml
├── assets/
├── evals/
├── references/
└── scripts/---
许可证
MIT — 允许自由使用、修改和分发。
---
关于作者
| 个人主页 | jieni |
| GitHub | geekjourneyx |
| @seekjourney | |
| 公众号 | 微信搜 极客杰尼 |
Naming
输出文件命名必须一眼能看懂用途。
推荐规则
- 母版:
<name>-master.mp4 - 60fps:
<name>-60fps.mp4 - GIF:
<name>.gif - 单曲版:
<name>-music-<track>.mp4
轨道名清洗规则
- 空格改成
_ - 连字符保留
- 去掉多余符号
禁止
final2final3ultimatenew-final
这些名字没有追踪价值。
Troubleshooting
Playwright 找不到
先检查:
- 当前工程
node_modules - 全局
npm root -g - 常见本机路径
如果还找不到,提示用户指定 VIDEO_POST_PLAYWRIGHT_PATH。
浏览器起不来
优先尝试系统 Chrome。 如果失败,再尝试 Playwright 自带浏览器。
必要时提示用户允许在沙箱外运行。
输出路径写不进去
不要把导出文件直接写进 .agents/ 里的 skill 目录。 优先写到项目根目录下的独立工作区,例如 video-post-workspace/。
npx bun 跑不起来
先确认:
- 本机已安装 Node.js
bun可执行文件存在npx bun --version能正常返回版本号
如果这里都不成立,不要继续执行脚本,先补运行环境。
命令里找不到脚本
如果你是在仓库根目录执行命令,不要写成 bun scripts/...。 要写成 bun .agents/skills/video-post/scripts/...。
HTML 没有 window.__ready
这是硬错误。 不要继续盲录。
HTML 没有 window.__seek()
允许继续,但要提示首帧可能不稳。
GIF 太大
先降低宽度,再降低 fps。 优先保可交付,不优先保最高画质。
音乐比视频短
第一版不循环,直接从可用段落取。 同时给出提示。
Validation
每次导出后至少检查:
1. 文件存在
确认目标文件确实生成。
2. 时长
时长应与目标视频一致。
3. 分辨率
检查宽高是否符合预期。
4. 视频流
必须有视频流。
5. 音频流
有音乐版必须有音频流。
6. 文件大小
如果 GIF 体积异常大,需要降级重做。
推荐工具
scripts/probe-output.tsscripts/summarize-outputs.ts
Workflow
入口判断
先做 3 个快速检查:
1. 有没有 HTML 或无音乐母版 2. 用户要单个版本还是一组版本 3. 这次是否必须包含校验结果
用户给 HTML
走完整导出流程:
1. bun <skill-dir>/scripts/render-master.ts 2. bun <skill-dir>/scripts/convert-variants.ts 3. 如有音乐,再 bun <skill-dir>/scripts/mix-audio.ts 或 bun <skill-dir>/scripts/batch-mix.ts 4. bun <skill-dir>/scripts/probe-output.ts 5. bun <skill-dir>/scripts/summarize-outputs.ts
用户给 MP4
先判断这个 MP4 是不是母版。
- 如果是母版:直接派生和混音
- 如果不是母版:提醒用户最好回到无音乐母版再派生
- 如果用户坚持直接在成片上继续做:先明确告知风险,再继续
用户给音乐目录
默认认为用户要批量版本,直接走 bun <skill-dir>/scripts/batch-mix.ts。
推荐顺序
1. 先导出无音乐母版 2. 再导出 60fps 和 GIF 3. 再做单曲或批量音乐版 4. 最后做统一校验
默认回答策略
- 用户只说“导出视频”:默认导出 MP4
- 用户说“做 4 个版本”:默认理解为同一画面配不同音乐
- 用户同时提 MP4 和 GIF:先出 MP4,再补 GIF
- 用户没提检查结果:导出后也要至少做一次基础校验
为什么这样做
- 这样每个版本都可追溯
- 换音乐不需要重新渲染画面
- 批量时不会把不同版本混在一起
import fs from "node:fs";
import path from "node:path";
import {
ensureFileExists,
fail,
getNumberOption,
getStringOption,
listAudioFiles,
parseCli,
sanitizeTrackName
} from "./common.ts";
import { mixAudio } from "./mix-audio.ts";
function main(): void {
const { positionals, options } = parseCli();
if (positionals.length < 2) {
fail("Usage: npx bun batch-mix.ts <master.mp4> <music-dir> [--strategy=middle|intro|tail] [--volume=0.85] [--fade-in=0.5] [--fade-out=1.5] [--output-dir=...]");
}
const master = path.resolve(positionals[0]);
const musicDir = path.resolve(positionals[1]);
ensureFileExists(master, "Master video");
ensureFileExists(musicDir, "Music directory");
const strategy = getStringOption(options, "strategy", "middle");
if (!["middle", "intro", "tail"].includes(strategy)) {
fail(`Unsupported strategy: ${strategy}`);
}
const outputDir = path.resolve(getStringOption(options, "output-dir", path.dirname(master)));
fs.mkdirSync(outputDir, { recursive: true });
let prefix = path.basename(master, path.extname(master));
prefix = prefix.replace(/-master$/, "").replace(/-60fps$/, "");
const musicFiles = listAudioFiles(musicDir);
if (musicFiles.length === 0) {
fail(`No supported audio files found in ${musicDir}`);
}
for (const musicFile of musicFiles) {
const trackName = sanitizeTrackName(path.basename(musicFile, path.extname(musicFile)));
const output = path.join(outputDir, `${prefix}-music-${trackName}.mp4`);
mixAudio({
video: master,
music: musicFile,
output,
strategy: strategy as "middle" | "intro" | "tail",
volume: getNumberOption(options, "volume", 0.85),
fadeIn: getNumberOption(options, "fade-in", 0.5),
fadeOut: getNumberOption(options, "fade-out", 1.5)
});
}
}
if (import.meta.main) {
main();
}
import fs from "node:fs";
import path from "node:path";
import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process";
export type CliOptions = Map<string, string | boolean>;
export function fail(message: string): never {
console.error(message);
process.exit(1);
}
export function parseCli(argv: string[] = process.argv.slice(2)): { positionals: string[]; options: CliOptions } {
const positionals: string[] = [];
const options: CliOptions = new Map();
for (const arg of argv) {
if (!arg.startsWith("--")) {
positionals.push(arg);
continue;
}
const eq = arg.indexOf("=");
if (eq === -1) {
options.set(arg.slice(2), true);
} else {
options.set(arg.slice(2, eq), arg.slice(eq + 1));
}
}
return { positionals, options };
}
export function getStringOption(options: CliOptions, name: string, fallback: string): string {
const value = options.get(name);
if (typeof value === "string") return value;
if (value === true) fail(`Option --${name} requires a value.`);
return fallback;
}
export function getNumberOption(options: CliOptions, name: string, fallback: number): number {
const raw = getStringOption(options, name, String(fallback));
const value = Number(raw);
if (!Number.isFinite(value)) fail(`Option --${name} must be a number. Received: ${raw}`);
return value;
}
export function hasOption(options: CliOptions, name: string): boolean {
return options.has(name);
}
export function runText(command: string, args: string[], opts: SpawnSyncOptionsWithStringEncoding = {}): string {
const result = spawnSync(command, args, {
stdio: "pipe",
encoding: "utf8",
...opts
});
if (result.status !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
fail(output || `Command failed: ${command} ${args.join(" ")}`);
}
return result.stdout.trim();
}
export function runPassthrough(command: string, args: string[]): void {
const result = spawnSync(command, args, { stdio: "inherit", encoding: "utf8" });
if (result.status !== 0) {
fail(`Command failed: ${command} ${args.join(" ")}`);
}
}
export function ensureFileExists(filePath: string, label = "File"): void {
if (!fs.existsSync(filePath)) {
fail(`${label} not found: ${filePath}`);
}
}
export function probeDuration(filePath: string): number {
ensureFileExists(filePath);
const raw = runText("ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "csv=p=0",
filePath
]);
const value = Number(raw);
if (!Number.isFinite(value)) {
fail(`Could not read duration from ${filePath}`);
}
return value;
}
export function sanitizeTrackName(name: string): string {
return name
.replace(/\s+/g, "_")
.replace(/[^A-Za-z0-9_-]/g, "");
}
export function listAudioFiles(dirPath: string): string[] {
ensureFileExists(dirPath, "Directory");
const supported = new Set([".mp3", ".m4a", ".wav", ".aac"]);
return fs
.readdirSync(dirPath, { withFileTypes: true })
.filter((entry) => entry.isFile() && supported.has(path.extname(entry.name).toLowerCase()))
.map((entry) => path.join(dirPath, entry.name))
.sort((a, b) => a.localeCompare(b));
}
export function writeJsonStdout(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
import path from "node:path";
import {
ensureFileExists,
fail,
getNumberOption,
hasOption,
parseCli,
runPassthrough
} from "./common.ts";
function main(): void {
const { positionals, options } = parseCli();
if (positionals.length < 1) {
fail("Usage: npx bun convert-variants.ts <master.mp4> [--gif-width=360] [--gif-fps=8] [--minterpolate]");
}
const input = path.resolve(positionals[0]);
ensureFileExists(input, "Master video");
const gifWidth = getNumberOption(options, "gif-width", 360);
const gifFps = getNumberOption(options, "gif-fps", 8);
const useMinterpolate = hasOption(options, "minterpolate");
const dir = path.dirname(input);
let base = path.basename(input, path.extname(input));
base = base.replace(/-master$/, "");
const out60 = path.join(dir, `${base}-60fps.mp4`);
const outGif = path.join(dir, `${base}.gif`);
const palette = path.join(dir, `.palette-${base}.png`);
const videoFilter = useMinterpolate
? "minterpolate=fps=60:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1"
: "fps=60";
runPassthrough("ffmpeg", [
"-y",
"-loglevel", "error",
"-i", input,
"-vf", videoFilter,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.0",
"-crf", "18",
"-preset", "medium",
"-movflags", "+faststart",
out60
]);
runPassthrough("ffmpeg", [
"-y",
"-loglevel", "error",
"-i", input,
"-vf", `fps=${gifFps},scale=${gifWidth}:-1:flags=lanczos,palettegen=stats_mode=diff`,
palette
]);
runPassthrough("ffmpeg", [
"-y",
"-loglevel", "error",
"-i", input,
"-i", palette,
"-lavfi", `fps=${gifFps},scale=${gifWidth}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
outGif
]);
runPassthrough("rm", ["-f", palette]);
console.log(`Created ${out60}`);
console.log(`Created ${outGif}`);
}
if (import.meta.main) {
main();
}
import path from "node:path";
import {
ensureFileExists,
fail,
getNumberOption,
getStringOption,
parseCli,
probeDuration,
runPassthrough
} from "./common.ts";
export type MixAudioArgs = {
video: string;
music: string;
output: string;
strategy?: "middle" | "intro" | "tail";
volume?: number;
fadeIn?: number;
fadeOut?: number;
};
function computeMusicStart(strategy: "middle" | "intro" | "tail", musicDuration: number, videoDuration: number): number {
switch (strategy) {
case "middle":
return Math.max((musicDuration - videoDuration) / 2, 0);
case "intro":
return 0;
case "tail":
return Math.max(musicDuration - videoDuration, 0);
default:
fail(`Unsupported strategy: ${strategy}`);
}
}
export function mixAudio(args: MixAudioArgs): void {
const strategy = args.strategy ?? "middle";
const volume = args.volume ?? 0.85;
const fadeIn = args.fadeIn ?? 0.5;
const fadeOut = args.fadeOut ?? 1.5;
ensureFileExists(args.video, "Video");
ensureFileExists(args.music, "Music");
const videoDuration = probeDuration(args.video);
const musicDuration = probeDuration(args.music);
const musicStart = computeMusicStart(strategy, musicDuration, videoDuration);
const fadeOutStart = Math.max(videoDuration - fadeOut, 0);
const outputDir = path.dirname(args.output);
ensureFileExists(outputDir, "Output directory");
runPassthrough("ffmpeg", [
"-y",
"-loglevel", "error",
"-i", args.video,
"-i", args.music,
"-filter_complex",
`[1:a]atrim=start=${musicStart.toFixed(6)}:duration=${videoDuration.toFixed(6)},apad=whole_dur=${videoDuration.toFixed(6)},asetpts=PTS-STARTPTS,afade=t=in:st=0:d=${fadeIn},afade=t=out:st=${fadeOutStart.toFixed(6)}:d=${fadeOut},volume=${volume}[a]`,
"-map", "0:v",
"-map", "[a]",
"-c:v", "copy",
"-c:a", "aac",
"-b:a", "192k",
args.output
]);
if (musicDuration < videoDuration) {
console.log(`Warning: ${path.basename(args.music)} is shorter than the video. The remaining tail was padded with silence.`);
}
console.log(`Created ${args.output}`);
}
function main(): void {
const { positionals, options } = parseCli();
if (positionals.length < 3) {
fail("Usage: npx bun mix-audio.ts <video.mp4> <music-file> <output.mp4> [--strategy=middle|intro|tail] [--volume=0.85] [--fade-in=0.5] [--fade-out=1.5]");
}
const strategy = getStringOption(options, "strategy", "middle");
if (!["middle", "intro", "tail"].includes(strategy)) {
fail(`Unsupported strategy: ${strategy}`);
}
mixAudio({
video: path.resolve(positionals[0]),
music: path.resolve(positionals[1]),
output: path.resolve(positionals[2]),
strategy: strategy as "middle" | "intro" | "tail",
volume: getNumberOption(options, "volume", 0.85),
fadeIn: getNumberOption(options, "fade-in", 0.5),
fadeOut: getNumberOption(options, "fade-out", 1.5)
});
}
if (import.meta.main) {
main();
}
import fs from "node:fs";
import path from "node:path";
import { fail, parseCli, runText, writeJsonStdout } from "./common.ts";
type ProbeResult = {
file: string;
exists: boolean;
valid: boolean;
error?: string;
duration?: number;
size_bytes?: number;
has_video?: boolean;
has_audio?: boolean;
video_codec?: string | null;
audio_codec?: string | null;
width?: number | null;
height?: number | null;
frame_rate?: string | null;
};
function probeFile(filePath: string): ProbeResult {
const absolute = path.resolve(filePath);
if (!fs.existsSync(absolute)) {
return {
file: absolute,
exists: false,
valid: false,
error: "missing"
};
}
const raw = runText("ffprobe", [
"-v", "error",
"-show_entries", "format=duration,size",
"-show_entries", "stream=index,codec_type,codec_name,width,height,r_frame_rate",
"-of", "json",
absolute
]);
const data = JSON.parse(raw) as {
streams?: Array<Record<string, string | number>>;
format?: Record<string, string>;
};
const streams = data.streams ?? [];
const format = data.format ?? {};
const video = streams.find((stream) => stream.codec_type === "video");
const audio = streams.find((stream) => stream.codec_type === "audio");
return {
file: absolute,
exists: true,
valid: true,
duration: Number(format.duration ?? 0),
size_bytes: Number(format.size ?? 0),
has_video: Boolean(video),
has_audio: Boolean(audio),
video_codec: (video?.codec_name as string | undefined) ?? null,
audio_codec: (audio?.codec_name as string | undefined) ?? null,
width: (video?.width as number | undefined) ?? null,
height: (video?.height as number | undefined) ?? null,
frame_rate: (video?.r_frame_rate as string | undefined) ?? null
};
}
function main(): void {
const { positionals } = parseCli();
if (positionals.length < 1) {
fail("Usage: npx bun probe-output.ts <file> [file...]");
}
writeJsonStdout(positionals.map((filePath) => probeFile(filePath)));
}
if (import.meta.main) {
main();
}
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import {
ensureFileExists,
fail,
getNumberOption,
getStringOption,
parseCli,
runText
} from "./common.ts";
function resolveGlobalNpmRoot(): string | null {
try {
return runText("npm", ["root", "-g"]);
} catch {
return null;
}
}
function requirePlaywright(): { chromium: { launch: (options: Record<string, unknown>) => Promise<any> } } {
const require = createRequire(import.meta.url);
const candidates = [
process.env.VIDEO_POST_PLAYWRIGHT_PATH,
"playwright",
path.join(process.cwd(), "node_modules", "playwright"),
path.join(os.homedir(), "gstack", "node_modules", "playwright"),
path.join(os.homedir(), ".bun", "install", "cache", "playwright")
].filter(Boolean) as string[];
const globalRoot = resolveGlobalNpmRoot();
if (globalRoot) {
candidates.push(path.join(globalRoot, "playwright"));
}
for (const candidate of candidates) {
try {
return require(candidate);
} catch {
// Try next candidate.
}
}
fail([
"Could not resolve Playwright.",
"Tried local install, global npm root, and common local paths.",
"Set VIDEO_POST_PLAYWRIGHT_PATH to an existing Playwright package if needed."
].join("\n"));
}
function detectChromeExecutable(): string | null {
const envPath = process.env.VIDEO_POST_BROWSER_PATH;
if (envPath && fs.existsSync(envPath)) return envPath;
const candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium"
];
return candidates.find((item) => fs.existsSync(item)) ?? null;
}
function encodeMaster(input: string, trimSeconds: number, targetDuration: number, output: string): void {
const result = spawnSync("ffmpeg", [
"-y",
"-ss", String(trimSeconds),
"-i", input,
"-t", String(targetDuration),
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.1",
"-crf", "18",
"-preset", "medium",
"-movflags", "+faststart",
output
], { stdio: "inherit", encoding: "utf8" });
if (result.status !== 0) {
fail("ffmpeg failed while encoding the master video.");
}
}
async function main(): Promise<void> {
const { positionals, options } = parseCli();
if (positionals.length < 1) {
fail("Usage: npx bun render-master.ts <animation.html> [--duration=60] [--width=1080] [--height=1920] [--output=...] [--chromium]");
}
const htmlAbs = path.resolve(positionals[0]);
ensureFileExists(htmlAbs, "HTML file");
const duration = getNumberOption(options, "duration", 60);
const width = getNumberOption(options, "width", 1080);
const height = getNumberOption(options, "height", 1920);
const htmlDir = path.dirname(htmlAbs);
const baseName = path.basename(htmlAbs, path.extname(htmlAbs));
const output = path.resolve(getStringOption(options, "output", path.join(htmlDir, `${baseName}-master.mp4`)));
ensureFileExists(path.dirname(output), "Output directory");
const tempDir = path.join(htmlDir, `.video-post-temp-${Date.now()}-${process.pid}`);
const { chromium } = requirePlaywright();
const executablePath = detectChromeExecutable();
fs.mkdirSync(tempDir, { recursive: true });
const launchOptions: Record<string, unknown> = {
headless: true,
args: ["--no-sandbox"]
};
if (options.has("chromium")) {
if (executablePath) launchOptions.executablePath = executablePath;
} else if (executablePath) {
launchOptions.executablePath = executablePath;
} else {
launchOptions.channel = "chrome";
}
const browser = await chromium.launch(launchOptions);
const url = pathToFileURL(htmlAbs).href;
try {
const warmup = await browser.newContext({ viewport: { width, height } });
const warmupPage = await warmup.newPage();
await warmupPage.goto(url, { waitUntil: "load", timeout: 60_000 });
await warmupPage.waitForTimeout(1_500);
await warmup.close();
const record = await browser.newContext({
viewport: { width, height },
recordVideo: {
dir: tempDir,
size: { width, height }
}
});
await record.addInitScript(() => {
(window as typeof window & { __recording?: boolean }).__recording = true;
const style = document.createElement("style");
style.textContent = ".no-record, [data-record='hidden'] { display: none !important; }";
document.addEventListener("DOMContentLoaded", () => {
document.head.appendChild(style);
}, { once: true });
});
const t0 = Date.now();
const page = await record.newPage();
await page.goto(url, { waitUntil: "load", timeout: 60_000 });
const ready = await page
.waitForFunction(() => (window as typeof window & { __ready?: boolean }).__ready === true, { timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (!ready) {
fail("Animation HTML did not expose window.__ready in time. Export aborted.");
}
const seekable = await page.evaluate(() => {
const candidate = window as typeof window & { __seek?: (value: number) => void };
if (typeof candidate.__seek === "function") {
candidate.__seek(0);
return true;
}
return false;
});
if (seekable) {
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
}
const trim = (Date.now() - t0) / 1000 + 0.05;
await page.waitForTimeout(duration * 1000 + 300);
await page.close();
await record.close();
const webm = fs.readdirSync(tempDir).find((name) => name.endsWith(".webm"));
if (!webm) {
fail("No recorded WebM file was produced.");
}
encodeMaster(path.join(tempDir, webm), trim, duration, output);
console.log(`Created ${output}`);
if (!seekable) {
console.log("Warning: window.__seek() was not available; the first frame may be less stable.");
}
} finally {
await browser.close().catch(() => undefined);
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
if (import.meta.main) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
import fs from "node:fs";
import path from "node:path";
import { fail, parseCli } from "./common.ts";
type SummaryItem = {
file: string;
duration?: number;
size_bytes?: number;
has_video?: boolean;
has_audio?: boolean;
width?: number | null;
height?: number | null;
};
function main(): void {
const { positionals } = parseCli();
if (positionals.length < 1) {
fail("Usage: npx bun summarize-outputs.ts <probe-results.json>");
}
const input = path.resolve(positionals[0]);
const raw = fs.readFileSync(input, "utf8");
const results = JSON.parse(raw) as SummaryItem[];
const lines: string[] = [];
lines.push("# Output Summary");
lines.push("");
lines.push(`共 ${results.length} 个文件`);
lines.push("");
lines.push("| 文件 | 时长 | 画幅 | 视频 | 音频 | 大小 |");
lines.push("|---|---:|---|---|---|---:|");
for (const item of results) {
const duration = typeof item.duration === "number" ? `${item.duration.toFixed(2)}s` : "-";
const size = typeof item.size_bytes === "number" ? `${(item.size_bytes / 1024 / 1024).toFixed(1)}MB` : "-";
const frame = item.width && item.height ? `${item.width}x${item.height}` : "-";
lines.push(`| ${item.file} | ${duration} | ${frame} | ${item.has_video ? "yes" : "no"} | ${item.has_audio ? "yes" : "no"} | ${size} |`);
}
process.stdout.write(`${lines.join("\n")}\n`);
}
if (import.meta.main) {
main();
}
name: video-post
description: 动画后期剪辑与导出 skill。接收现成动画 HTML、母版 MP4、单首音乐或音乐目录,稳定导出无音乐母版、60fps 版、GIF、单曲成片和批量音乐版本。内部脚本统一使用 TypeScript,通过 bun 直接运行。用户提到导出视频、导出 MP4、批量换音乐、取中间音乐、做多个版本、转 GIF、导出 60fps、给动画换 BGM 时都应该使用。
version: 0.1.0
author: Codex
type: skill
tags:
- video
- post-production
- animation
- export
- audio
- batch-processing
license: MIT