cerevox
Version:
TypeScript SDK for browser automation and secure command execution in highly available and scalable micro computer environments
1,179 lines • 54.7 kB
JavaScript
"use strict";
// videokit-ts: A tiny TypeScript library for AI-driven video assembly via FFmpeg
// ---------------------------------------------------------------------------------
// Features
// - Strongly-typed project spec (tracks/assets/effects/subtitles)
// - Zod runtime validation
// - Subtitles rendering with 3 modes: SRT (basic), ASS+libass (rich), drawtext (no libass)
// - Optional music ducking (sidechaincompress)
// - Compiler that turns JSON spec into ffmpeg args (+ extra files to write)
// - Helper to auto-detect libass, write files, and spawn ffmpeg
//
// Install peer deps:
// npm i zod
// # ffmpeg must be installed in PATH
//
// Optional runtime helpers (Node only):
// npm i execa # if you prefer execa over child_process
//
// Usage (Node):
// import {
// validateProject, compileToFfmpeg, runFfmpeg,
// hasLibass, type VideoProject
// } from "videokit-ts";
//
// const spec: VideoProject = { ... };
// const validated = validateProject(spec);
// const compiled = compileToFfmpeg(validated, { subtitleStrategy: "auto", outFile: "out.mp4" });
// await runFfmpeg(compiled);
//
// ---------------------------------------------------------------------------------
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.zVideoProject = void 0;
exports.validateProject = validateProject;
exports.hasLibass = hasLibass;
exports.compileToFfmpeg = compileToFfmpeg;
exports.runFfmpeg = runFfmpeg;
exports.escapeDrawtextText = escapeDrawtextText;
exports.getMediaDuration = getMediaDuration;
exports.checkVideoHasAudio = checkVideoHasAudio;
exports.compileKenBurnsMotion = compileKenBurnsMotion;
const zod_1 = require("zod");
// Node helpers for writing files and spawning ffmpeg (guarded for browser builds)
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const child_process_1 = require("child_process");
const shell_quote_1 = require("shell-quote");
const image_size_1 = __importDefault(require("image-size"));
const uuid_1 = require("uuid");
const mp4_duration_1 = require("./mp4-duration");
// ---- Zod ----
const zResolution = zod_1.z.object({
width: zod_1.z.number().int().min(16),
height: zod_1.z.number().int().min(16),
});
const zSettings = zod_1.z.object({
fps: zod_1.z.number().positive(),
resolution: zResolution,
pixelFormat: zod_1.z.string(),
sampleRate: zod_1.z.number().int().positive(),
channels: zod_1.z.number().int().min(1),
timebase: zod_1.z.string(),
});
const zAsset = zod_1.z.object({
id: zod_1.z.string(),
type: zod_1.z.enum(['video', 'audio', 'image']),
uri: zod_1.z.string(),
durationMs: zod_1.z.number().int().optional(),
fps: zod_1.z.number().optional(),
});
const zEffect = zod_1.z.object({
name: zod_1.z.string(),
params: zod_1.z.record(zod_1.z.unknown()).optional(),
});
const zFilter = zod_1.z.object({
name: zod_1.z.string(),
params: zod_1.z.record(zod_1.z.unknown()).optional(),
});
const zTransition = zod_1.z.object({
name: zod_1.z.enum([
'xfade',
'fade',
'wipeleft',
'wiperight',
'wipeup',
'wipedown',
'slideleft',
'slideright',
'slideup',
'slidedown',
'circlecrop',
'rectcrop',
'distance',
'fadeblack',
'fadewhite',
'radial',
'smoothleft',
'smoothright',
'smoothup',
'smoothdown',
'circleopen',
'circleclose',
'vertopen',
'vertclose',
'horzopen',
'horzclose',
'dissolve',
'pixelize',
'diagtl',
'diagtr',
'diagbl',
'diagbr',
]),
durationMs: zod_1.z.number().int().min(1),
params: zod_1.z.record(zod_1.z.unknown()).optional(),
});
const zTransform = zod_1.z.object({
scale: zod_1.z.number().optional(),
rotate: zod_1.z.number().optional(),
anchor: zod_1.z
.enum(['center', 'topleft', 'topright', 'bottomleft', 'bottomright'])
.optional(),
position: zod_1.z.object({ x: zod_1.z.number(), y: zod_1.z.number() }).optional(),
fit: zod_1.z.enum(['contain', 'cover', 'stretch']).optional(),
});
const zClip = zod_1.z.object({
id: zod_1.z.string(),
assetId: zod_1.z.string(),
startMs: zod_1.z.number().int().min(0),
inMs: zod_1.z.number().int().min(0),
durationMs: zod_1.z.number().int().min(1),
transform: zTransform.optional(),
speed: zod_1.z.number().positive().optional(),
effects: zod_1.z.array(zEffect).optional(),
filters: zod_1.z.array(zFilter).optional(),
transitionIn: zTransition.optional(),
transitionOut: zTransition.optional(),
});
const zTrack = zod_1.z.object({
id: zod_1.z.string(),
type: zod_1.z.enum(['video', 'audio', 'subtitle']),
muted: zod_1.z.boolean().optional(),
opacity: zod_1.z.number().min(0).max(1).optional(),
clips: zod_1.z.array(zClip),
});
const zDucking = zod_1.z.object({
id: zod_1.z.string(),
musicTrackId: zod_1.z.string(),
dialogTrackId: zod_1.z.string(),
params: zod_1.z
.object({
threshold: zod_1.z.number().optional(),
ratio: zod_1.z.number().optional(),
attackMs: zod_1.z.number().optional(),
releaseMs: zod_1.z.number().optional(),
musicPreGain: zod_1.z.number().optional(),
})
.optional(),
});
const zMix = zod_1.z.object({ ducking: zod_1.z.array(zDucking).optional() });
const zSubtitle = zod_1.z.object({
id: zod_1.z.string(),
text: zod_1.z.string(),
startMs: zod_1.z.number().int().min(0),
endMs: zod_1.z.number().int().min(1),
audio: zod_1.z.string().optional(),
style: zod_1.z
.object({
fontFamily: zod_1.z.string().optional(),
fontSize: zod_1.z.number().optional(),
bold: zod_1.z.boolean().optional(),
color: zod_1.z.string().optional(),
outlineColor: zod_1.z.string().optional(),
outlineWidth: zod_1.z.number().optional(),
align: zod_1.z.enum(['left', 'center', 'right']).optional(),
verticalAlign: zod_1.z.enum(['top', 'middle', 'bottom']).optional(),
position: zod_1.z.object({ x: zod_1.z.number(), y: zod_1.z.number() }).optional(),
})
.optional(),
});
const zExport = zod_1.z.object({
container: zod_1.z.string(),
videoCodec: zod_1.z.string(),
crf: zod_1.z.number().int().optional(),
preset: zod_1.z.string().optional(),
audioCodec: zod_1.z.string(),
audioBitrate: zod_1.z.string().optional(),
outFile: zod_1.z.string().optional(),
});
exports.zVideoProject = zod_1.z.object({
version: zod_1.z.string(),
project: zod_1.z.object({ name: zod_1.z.string(), id: zod_1.z.string() }),
settings: zSettings,
assets: zod_1.z.array(zAsset).min(1),
timeline: zod_1.z.object({ tracks: zod_1.z.array(zTrack).min(1), mix: zMix.optional() }),
subtitles: zod_1.z.array(zSubtitle).optional(),
export: zExport,
});
function validateProject(json) {
const parsed = exports.zVideoProject.parse(json);
// TODO: semantic checks for references, overlaps, bounds, etc.
return parsed;
}
// ======================================
// 2) Subtitles helpers (SRT/ASS/drawtext)
// ======================================
function pad2(n) {
return String(n).padStart(2, '0');
}
function pad3(n) {
return String(n).padStart(3, '0');
}
function msToSrtTime(ms) {
const h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000), s = Math.floor((ms % 60000) / 1000), msRem = Math.floor(ms % 1000);
return `${pad2(h)}:${pad2(m)}:${pad2(s)},${pad3(msRem)}`;
}
function composeSrtFromItems(subs) {
const sorted = [...subs].sort((a, b) => a.startMs - b.startMs);
return sorted
.map((s, i) => `${i + 1}\n${msToSrtTime(Math.max(0, s.startMs))} --> ${msToSrtTime(Math.max(s.endMs, s.startMs + 1))}\n${s.text}\n`)
.join('\n');
}
function hexToAssColor(hex) {
if (!hex)
return undefined;
// 支持 #RRGGBBAA 格式 (8位) 和 #RRGGBB 格式 (6位)
const m8 = /^#?([0-9a-fA-F]{8})$/.exec(hex);
const m6 = /^#?([0-9a-fA-F]{6})$/.exec(hex);
if (m8) {
// #RRGGBBAA 格式
const rr = m8[1].slice(0, 2), gg = m8[1].slice(2, 4), bb = m8[1].slice(4, 6), aa = m8[1].slice(6, 8);
return `&H${aa}${bb}${gg}${rr}`; // &HAABBGGRR
}
else if (m6) {
// #RRGGBB 格式,默认不透明 (AA=00)
const rr = m6[1].slice(0, 2), gg = m6[1].slice(2, 4), bb = m6[1].slice(4, 6);
return `&H00${bb}${gg}${rr}`; // &HAABBGGRR
}
return undefined;
}
function composeAssFromItems(subs, defaultStyle) {
const s = defaultStyle || {};
// 创建样式映射,为每种不同的样式组合创建独立的样式
const styleMap = new Map();
const getStyleName = (style) => {
const styleObj = style || {};
const font = styleObj.fontFamily ?? s.fontFamily ?? 'Arial';
const size = styleObj.fontSize ?? s.fontSize ?? 42;
const color = styleObj.color ?? s.color ?? '#FFFFFF';
const outlineColor = styleObj.outlineColor ?? s.outlineColor ?? '#000000';
const outlineWidth = styleObj.outlineWidth ?? s.outlineWidth ?? 2;
const bold = styleObj.bold ?? s.bold ?? false;
const vertAlign = styleObj.verticalAlign ?? 'bottom';
const horizAlign = styleObj.align ?? s.align ?? 'center';
const styleKey = `${font}_${size}_${color}_${outlineColor}_${outlineWidth}_${bold}_${vertAlign}_${horizAlign}`;
if (!styleMap.has(styleKey)) {
const c = hexToAssColor(color) ?? '&H00FFFFFF';
const oc = hexToAssColor(outlineColor) ?? '&H00000000';
const ow = Math.max(0, outlineWidth);
// ASS alignment: 1-3 bottom row, 4-6 middle row, 7-9 top row
// Within each row: 1/4/7=left, 2/5/8=center, 3/6/9=right
let align;
if (vertAlign === 'top') {
align = horizAlign === 'left' ? 7 : horizAlign === 'right' ? 9 : 8;
}
else if (vertAlign === 'middle') {
align = horizAlign === 'left' ? 4 : horizAlign === 'right' ? 6 : 5;
}
else {
// bottom
align = horizAlign === 'left' ? 1 : horizAlign === 'right' ? 3 : 2;
}
const styleName = `Style${styleMap.size}`;
// 保留默认的 marginL 和 marginR,只有 marginV 在有 position 时设为 0
const hasPosition = styleObj.position !== undefined &&
styleObj.position.x !== undefined &&
styleObj.position.y !== undefined;
const marginL = 60;
const marginR = 60;
const marginV = hasPosition ? 0 : 120;
const styleDefinition = `Style: ${styleName},${font},${size},${c},&H000000FF,${oc},&H64000000,${bold ? -1 : 0},0,0,0,100,100,0,0,1,${ow},0,${align},${marginL},${marginR},${marginV},0`;
styleMap.set(styleKey, styleName);
return { styleName, styleDefinition };
}
return { styleName: styleMap.get(styleKey), styleDefinition: '' };
};
// 收集所有样式定义
const styleDefinitions = [];
const processedSubs = subs.map(sub => {
const { styleName, styleDefinition } = getStyleName(sub.style || {});
if (styleDefinition) {
styleDefinitions.push(styleDefinition);
}
return { ...sub, styleName };
});
const header = `[Script Info]\nScriptType: v4.00+\nCollisions: Normal\nPlayResX: 1920\nPlayResY: 1080\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n${styleDefinitions.join('\n')}\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n`;
const events = processedSubs
.sort((a, b) => a.startMs - b.startMs)
.map(it => {
const sMs = Math.max(0, it.startMs), eMs = Math.max(it.endMs, it.startMs + 1);
const sH = pad2(Math.floor(sMs / 3600000)), sM = pad2(Math.floor((sMs % 3600000) / 60000)), sS = pad2(Math.floor((sMs % 60000) / 1000)), sC = pad2(Math.floor((sMs % 1000) / 10));
const eH = pad2(Math.floor(eMs / 3600000)), eM = pad2(Math.floor((eMs % 3600000) / 60000)), eS = pad2(Math.floor((eMs % 60000) / 1000)), eC = pad2(Math.floor((eMs % 1000) / 10));
const sAss = `${sH}:${sM}:${sS}.${sC}`;
const eAss = `${eH}:${eM}:${eS}.${eC}`;
// 将包含中文的英文单引号替换为中文全角双引号
const processedText = it.text.replace(/'([^']*[\u4e00-\u9fff][^']*)'/g, '"$1"');
// 如果有 position 设置,添加 \pos 标签
let positionTag = '';
if (it.style?.position &&
it.style.position.x !== undefined &&
it.style.position.y !== undefined) {
const x = Math.round(it.style.position.x * 1920); // PlayResX = 1920
const y = Math.round(it.style.position.y * 1080); // PlayResY = 1080
positionTag = `{\\pos(${x},${y})}`;
}
const text = `{\\q3}${positionTag}${processedText.replace(/\n/g, '\\N')}`;
return `Dialogue: 0,${sAss},${eAss},${it.styleName},,0,0,0,,${text}`;
})
.join('\n');
return header + events + '\n';
}
// Function moved to exports section at end of file
// ======================
// 3) libass detection
// ======================
function hasLibass(ffmpegBin = 'ffmpeg') {
try {
const out = (0, child_process_1.execSync)(`${ffmpegBin} -filters`, {
encoding: 'utf-8',
});
return /subtitles.*libass/i.test(out);
}
catch {
return false;
}
}
// Helper function to apply video transitions using xfade
function applyVideoTransitions(track, clipOuts, newLabel, fg, project) {
if (clipOuts.length <= 1)
return clipOuts;
let currentOut = clipOuts[0];
for (let i = 1; i < clipOuts.length; i++) {
const currentClip = track.clips[i];
const prevClip = track.clips[i - 1];
// 新的转场逻辑:transitionIn优先级高于transitionOut
// 如果下一场景定义了transitionIn,则覆盖transitionOut
const hasTransitionIn = currentClip.transitionIn;
const hasTransitionOut = prevClip.transitionOut;
if (hasTransitionIn) {
// 优先使用transitionIn(下一场景的入场转场)
const inDuration = hasTransitionIn.durationMs / 1000;
const inOffset = Math.max(0, currentClip.startMs / 1000 - inDuration);
const inTransition = getXfadeTransition(hasTransitionIn.name);
const l = newLabel();
fg.push(`${currentOut}${clipOuts[i]}xfade=transition=${inTransition}:duration=${inDuration}:offset=${inOffset},fps=${project.settings.fps}[${l}]`);
currentOut = `[${l}]`;
}
else if (hasTransitionOut) {
// 如果没有transitionIn,则使用transitionOut(当前场景的出场转场)
const outDuration = hasTransitionOut.durationMs / 1000;
const outOffset = Math.max(0, currentClip.startMs / 1000 - outDuration);
const outTransition = getXfadeTransition(hasTransitionOut.name);
const l = newLabel();
fg.push(`${currentOut}${clipOuts[i]}xfade=transition=${outTransition}:duration=${outDuration}:offset=${outOffset},fps=${project.settings.fps}[${l}]`);
currentOut = `[${l}]`;
}
else {
// No transition, use concat
const l = newLabel();
fg.push(`${currentOut}${clipOuts[i]}concat=n=2:v=1:a=0,fps=${project.settings.fps}[${l}]`);
currentOut = `[${l}]`;
}
}
return [currentOut];
}
// Helper function to map transition names to xfade types
function getXfadeTransition(transitionName) {
switch (transitionName) {
case 'xfade':
case 'fade':
return 'fade';
case 'wipeleft':
return 'wipeleft';
case 'wiperight':
return 'wiperight';
case 'wipeup':
return 'wipeup';
case 'wipedown':
return 'wipedown';
case 'slideleft':
return 'slideleft';
case 'slideright':
return 'slideright';
case 'slideup':
return 'slideup';
case 'slidedown':
return 'slidedown';
case 'circlecrop':
return 'circlecrop';
case 'rectcrop':
return 'rectcrop';
case 'distance':
return 'distance';
case 'fadeblack':
return 'fadeblack';
case 'fadewhite':
return 'fadewhite';
case 'radial':
return 'radial';
case 'smoothleft':
return 'smoothleft';
case 'smoothright':
return 'smoothright';
case 'smoothup':
return 'smoothup';
case 'smoothdown':
return 'smoothdown';
case 'circleopen':
return 'circleopen';
case 'circleclose':
return 'circleclose';
case 'vertopen':
return 'vertopen';
case 'vertclose':
return 'vertclose';
case 'horzopen':
return 'horzopen';
case 'horzclose':
return 'horzclose';
case 'dissolve':
return 'dissolve';
case 'pixelize':
return 'pixelize';
case 'diagtl':
return 'diagtl';
case 'diagtr':
return 'diagtr';
case 'diagbl':
return 'diagbl';
case 'diagbr':
return 'diagbr';
default:
return 'fade';
}
}
async function compileToFfmpeg(project, opts = {}) {
const ffmpegBin = opts.ffmpegBin ?? 'ffmpeg';
const tmpDir = opts.workingDir ?? process.cwd();
// Collect used assets from tracks
const usedAssetIds = new Set(project.timeline.tracks.flatMap(t => t.clips.map(c => c.assetId)));
// Also collect assets referenced in subtitles
if (project.subtitles?.length) {
for (const subtitle of project.subtitles) {
if (subtitle.audio) {
usedAssetIds.add(subtitle.audio);
}
}
}
const assets = project.assets.filter(a => usedAssetIds.has(a.id));
// Inputs
const inputArgs = [];
const inputIndexByAsset = {};
assets.forEach((a, i) => {
inputArgs.push('-i', a.uri);
inputIndexByAsset[a.id] = i;
});
// Filter graph builders
const fg = [];
const extraFiles = [];
let labelId = 0;
const newLabel = () => `s${labelId++}`;
const videoTrackOuts = [];
const audioTrackOuts = [];
// Per-track clips -> branches
for (const track of project.timeline.tracks) {
const outs = [];
const videoAudioOuts = []; // For audio from video files
for (const clip of track.clips) {
const asset = project.assets.find(a => a.id === clip.assetId);
if (!asset)
throw new Error(`Missing asset: ${clip.assetId}`);
const idx = inputIndexByAsset[asset.id];
let sel;
if (track.type === 'audio') {
sel = `[${idx}:a]`;
}
else if (track.type === 'video') {
sel = `[${idx}:v]`;
// 若视频素材自带音频,把音频也按同样时间处理
if (asset.type === 'video' && (await checkVideoHasAudio(asset.uri))) {
const audioSel = `[${idx}:a]`;
const audioStart = clip.inMs / 1000;
const audioDur = (clip.durationMs +
(clip.transitionIn ? clip.transitionIn.durationMs : 0)) /
1000;
let originalDurationMs = asset.durationMs;
const realDuration = await getMediaDuration(asset.uri);
if (realDuration !== null) {
originalDurationMs = Math.round(realDuration * 1000);
}
else {
originalDurationMs = asset.durationMs || clip.durationMs;
}
const originalDurationSec = originalDurationMs / 1000;
const targetDurationSec = clip.durationMs / 1000;
const speedRatio = originalDurationSec / targetDurationSec;
let audioTrim;
if (Math.abs(originalDurationSec - targetDurationSec) > 0.01) {
audioTrim = `atrim=start=${audioStart}:duration=${originalDurationSec},asetpts=PTS-STARTPTS,atempo=${speedRatio}`;
}
else {
audioTrim = `atrim=start=${audioStart}:duration=${audioDur},asetpts=PTS-STARTPTS`;
}
// (①)更稳的 adelay,并在其后归零
const audioShift = clip.startMs > 0
? `adelay=${Math.round(clip.startMs)}:all=1,asetpts=PTS-STARTPTS`
: '';
const audioLabel = newLabel();
const audioEffects = [];
if (clip.effects) {
for (const ef of clip.effects) {
switch (ef.name) {
case 'fadeIn': {
const d = Number(ef.params?.durationMs ?? 500) / 1000;
audioEffects.push(`afade=t=in:st=0:d=${d}`);
break;
}
case 'fadeOut': {
const d = Number(ef.params?.durationMs ?? 500) / 1000;
audioEffects.push(`afade=t=out:st=${Math.max(0, audioDur - d)}:d=${d}`);
break;
}
case 'gain': {
const p = ef.params;
const db = Number(p?.db ?? 0) || Number(p?.gain ?? 0);
let gain = Math.pow(10, db / 20);
if (p?.volume)
gain *= p.volume;
audioEffects.push(`volume=${gain.toFixed(2)}`);
break;
}
case 'speed': {
const rate = Number(ef.params?.rate ?? 1);
if (rate !== 1)
audioEffects.push(`atempo=${rate.toFixed(3)}`);
break;
}
}
}
}
// (②)仅音频分支进入 amix 前统一采样率/声道
fg.push(`${audioSel}${audioTrim}${audioEffects.length ? ',' + audioEffects.join(',') : ''}${audioShift ? ',' + audioShift : ''},aformat=sample_rates=48000:channel_layouts=stereo[${audioLabel}]`);
videoAudioOuts.push(`[${audioLabel}]`);
}
}
else {
sel = `[${idx}:v]`;
}
const start = clip.inMs / 1000;
const dur = (clip.durationMs +
(clip.transitionIn ? clip.transitionIn.durationMs : 0)) /
1000;
const hasTransition = clip.transitionIn && track.type === 'video';
const transitionDuration = hasTransition
? clip.transitionIn.durationMs / 1000
: 0;
let originalDurationMs = asset.durationMs;
const realDuration = await getMediaDuration(asset.uri);
if (realDuration !== null) {
originalDurationMs = Math.round(realDuration * 1000);
console.debug(`Dynamic duration detection: ${asset.uri} = ${originalDurationMs}ms (was ${asset.durationMs}ms)`);
}
else {
originalDurationMs = asset.durationMs || clip.durationMs;
}
const originalDurationSec = originalDurationMs / 1000;
const targetDurationSec = clip.durationMs / 1000;
const speedRatio = originalDurationSec / targetDurationSec;
let trim;
if (track.type === 'audio') {
if (asset.type === 'audio') {
trim = `atrim=start=${start}:duration=${targetDurationSec},asetpts=PTS-STARTPTS`;
}
else {
if (Math.abs(originalDurationSec - targetDurationSec) > 0.01) {
trim = `atrim=start=${start}:duration=${originalDurationSec},asetpts=PTS-STARTPTS,atempo=${speedRatio}`;
}
else {
trim = `atrim=start=${start}:duration=${dur},asetpts=PTS-STARTPTS`;
}
}
}
else {
if (Math.abs(originalDurationSec - targetDurationSec) > 0.01) {
trim = `trim=start=${start}:duration=${originalDurationSec},setpts=PTS/(${speedRatio}),fps=${project.settings.fps}`;
}
else {
trim = `trim=start=${start}:duration=${dur},setpts=PTS-STARTPTS,fps=${project.settings.fps}`;
}
}
const shift = track.type === 'audio'
? clip.startMs > 0
? `adelay=${Math.round(clip.startMs)}:all=1,asetpts=PTS-STARTPTS`
: ''
: hasTransition
? `tpad=start_duration=${transitionDuration}:start_mode=clone`
: '';
// 背景音乐默认 gain
if (clip.assetId.includes('bgm')) {
const gainEffects = clip.effects?.find(e => e.name === 'gain');
if (!gainEffects) {
clip.effects = clip.effects || [];
clip.effects.push({ name: 'gain', params: { db: -25 } });
}
}
const effects = [];
if (clip.effects)
for (const ef of clip.effects) {
switch (ef.name) {
case 'fadeIn': {
const d = Number(ef.params?.durationMs ?? 500) / 1000;
effects.push(track.type === 'audio'
? `afade=t=in:st=0:d=${d}`
: `fade=t=in:st=0:d=${d}`);
break;
}
case 'fadeOut': {
const d = Number(ef.params?.durationMs ?? 500) / 1000;
effects.push(track.type === 'audio'
? `afade=t=out:st=${Math.max(0, dur - d)}:d=${d}`
: `fade=t=out:st=${Math.max(0, dur - d)}:d=${d}`);
break;
}
case 'gain': {
const p = ef.params;
const db = Number(p?.db ?? 0) || Number(p?.gain ?? 0);
let gain = Math.pow(10, db / 20);
if (p?.volume)
gain *= p.volume;
effects.push(`volume=${gain.toFixed(2)}`);
break;
}
case 'color': {
if (track.type === 'video') {
const p = ef.params;
effects.push(`eq=saturation=${p?.saturation ?? 1}:contrast=${p?.contrast ?? 1}:brightness=${p?.brightness ?? 0}`);
}
break;
}
case 'blur': {
if (track.type === 'video') {
const s = Number(ef.params?.strength ?? 10);
effects.push(`gblur=sigma=${s}`);
}
break;
}
case 'speed': {
const rate = Number(ef.params?.rate ?? 1);
if (rate !== 1) {
if (track.type === 'video')
effects.push(`setpts=${(1 / rate).toFixed(6)}*PTS`);
else
effects.push(`atempo=${rate.toFixed(3)}`);
}
break;
}
default:
break;
}
}
const lIn = newLabel();
const lOut = newLabel();
const effStr = effects.length ? ',' + effects.join(',') : '';
// (②)仅音频分支统一 aformat;视频分支不加 aformat
if (track.type === 'audio') {
fg.push(`${sel}${trim}${effStr},aformat=sample_rates=48000:channel_layouts=stereo[${lIn}]`);
}
else {
fg.push(`${sel}${trim}${effStr}[${lIn}]`);
}
// Scale / shift
if (track.type === 'video') {
const scaleAndPad = `scale=${project.settings.resolution.width}:${project.settings.resolution.height}:force_original_aspect_ratio=decrease,pad=${project.settings.resolution.width}:${project.settings.resolution.height}:(ow-iw)/2:(oh-ih)/2,setsar=1`;
if (shift) {
fg.push(`[${lIn}]${scaleAndPad},${shift}[${lOut}]`);
}
else {
fg.push(`[${lIn}]${scaleAndPad}[${lOut}]`);
}
}
else {
if (shift) {
fg.push(`[${lIn}]${shift}[${lOut}]`);
}
else {
// (④)acopy -> anull
fg.push(`[${lIn}]anull[${lOut}]`);
}
}
outs.push(`[${lOut}]`);
}
// Handle transitions for video tracks
if (track.type === 'video' && outs.length > 1) {
const transitionedOuts = applyVideoTransitions(track, outs, newLabel, fg, project);
if (transitionedOuts.length === 1) {
videoTrackOuts.push(transitionedOuts[0]);
}
else {
const l = newLabel();
fg.push(`${transitionedOuts.join('')}concat=n=${transitionedOuts.length}:v=1:a=0,fps=${project.settings.fps}[${l}]`);
videoTrackOuts.push(`[${l}]`);
}
}
else if (outs.length) {
if (track.type === 'audio') {
const l = newLabel();
// (③)amix 后追加对齐
fg.push(`${outs.join('')}amix=inputs=${outs.length}:normalize=0,aresample=async=1:first_pts=0,asetpts=N/SR/TB[${l}]`);
audioTrackOuts.push(`[${l}]`);
}
else if (track.type === 'video') {
if (outs.length === 1) {
videoTrackOuts.push(outs[0]);
}
else {
const l = newLabel();
fg.push(`${outs.join('')}concat=n=${outs.length}:v=1:a=0,fps=${project.settings.fps}[${l}]`);
videoTrackOuts.push(`[${l}]`);
}
}
}
// Add video audio tracks to the final audio mix
if (videoAudioOuts.length > 0) {
audioTrackOuts.push(...videoAudioOuts);
}
}
// Simple audio mixing: BGM + Dialog
let finalAudioOuts = [...audioTrackOuts];
if (audioTrackOuts.length >= 1) {
const audioTracks = project.timeline.tracks.filter(t => t.type === 'audio');
const bgmTrackIndex = audioTracks.findIndex(t => t.clips[0].assetId.includes('bgm'));
const bgmClipTrackIndex = bgmTrackIndex != null
? bgmTrackIndex
: audioTracks.findIndex(t => t.clips.length === 1);
// 用 label 精确定位 BGM 对应的输出分支,避免被 audioTrackOuts 的顺序变化影响
const bgmOutLabel = bgmClipTrackIndex >= 0 && bgmClipTrackIndex < audioTrackOuts.length
? audioTrackOuts[bgmClipTrackIndex]
: undefined;
const processedTracks = audioTrackOuts.map(track => {
if (track === bgmOutLabel) {
// BGM track (single clip audio track)
const fadeoutLabel = newLabel();
const bgmTrack = audioTracks[bgmClipTrackIndex];
const bgmClip = bgmTrack.clips[bgmTrack.clips.length - 1]; // 多BGM取最后一个
const bgmDurationSec = (bgmClip.startMs + bgmClip.durationMs) / 1000;
const fadeoutStartSec = Math.max(0, bgmDurationSec - 3); // 末尾 3s 淡出
// 去掉 volume=1.0,避免干扰你前面对 BGM 的任何增益调整
fg.push(`${track}afade=t=out:st=${fadeoutStartSec}:d=3[${fadeoutLabel}]`);
return `[${fadeoutLabel}]`;
}
return track;
});
const l = newLabel();
// (③)amix 后追加对齐
fg.push(`${processedTracks.join('')}amix=inputs=${processedTracks.length}:normalize=0,aresample=async=1:first_pts=0,asetpts=N/SR/TB[${l}]`);
finalAudioOuts = [`[${l}]`];
}
// Calculate total video duration
let totalVideoDurationMs = 0;
for (const track of project.timeline.tracks) {
if (track.type === 'video') {
for (const clip of track.clips) {
const endTime = clip.startMs + clip.durationMs;
totalVideoDurationMs = Math.max(totalVideoDurationMs, endTime);
}
}
}
// Mixdown videos
const outV = videoTrackOuts.length > 1
? (() => {
const l = newLabel();
fg.push(`${videoTrackOuts.join('')}concat=n=${videoTrackOuts.length}:v=1:a=0,fps=${project.settings.fps}[${l}]`);
return `[${l}]`;
})()
: videoTrackOuts[0] || '';
// Calculate original video duration in seconds (for audio matching)
const originalVideoDurationSec = totalVideoDurationMs / 1000;
// Video processing completed (no additional freeze)
// Process subtitle audio clips (only if not already processed in timeline tracks)
const dialogAudioOuts = [];
if (project.subtitles?.length && finalAudioOuts.length === 0) {
for (const subtitle of project.subtitles) {
if (subtitle.audio) {
const asset = project.assets.find(a => a.id === subtitle.audio);
if (asset) {
const idx = inputIndexByAsset[asset.id];
if (idx !== undefined) {
const sel = `[${idx}:a]`;
const dur = (subtitle.endMs - subtitle.startMs) / 1000;
const tpadSec = subtitle.startMs / 1000;
const lIn = newLabel();
const lOut = newLabel();
fg.push(`${sel}atrim=start=0:duration=${dur},asetpts=PTS-STARTPTS[${lIn}]`);
// (①)字幕延迟也用 :all=1,并归零
fg.push(`[${lIn}]adelay=${Math.round(tpadSec * 1000)}:all=1,asetpts=PTS-STARTPTS[${lOut}]`);
dialogAudioOuts.push(`[${lOut}]`);
}
}
}
}
}
// Process final audio output
let outA = '';
if (finalAudioOuts.length > 0) {
const audioOut = finalAudioOuts.length > 1
? (() => {
const l = newLabel();
// (③)amix 后追加对齐
fg.push(`${finalAudioOuts.join('')}amix=inputs=${finalAudioOuts.length}:normalize=0,aresample=async=1:first_pts=0,asetpts=N/SR/TB[${l}]`);
return `[${l}]`;
})()
: finalAudioOuts[0];
outA = audioOut;
}
else if (dialogAudioOuts.length > 0) {
outA =
dialogAudioOuts.length > 1
? (() => {
const l = newLabel();
// (③)amix 后追加对齐
fg.push(`${dialogAudioOuts.join('')}amix=inputs=${dialogAudioOuts.length}:normalize=0,aresample=async=1:first_pts=0,asetpts=N/SR/TB[${l}]`);
return `[${l}]`;
})()
: dialogAudioOuts[0];
}
// Subtitles strategy
const strat = opts.subtitleStrategy ?? 'auto';
let finalV = outV;
if (project.subtitles?.length && outV) {
const libassOk = strat === 'ass' || (strat === 'auto' && hasLibass(ffmpegBin));
const defaultForceStyle = `WrapStyle=3,MarginL=60,MarginR=60`;
if (libassOk) {
const def = project.subtitles[0]?.style || {};
const ass = composeAssFromItems(project.subtitles, def);
const assName = opts.subtitlesFileName ?? 'subtitles.ass';
const assPath = path_1.default.join(tmpDir, assName);
extraFiles.push({ path: assPath, content: ass });
const l = newLabel();
const arg = assPath
.replace(/\\/g, '/')
.replace(/:/g, '\\:')
.replace(/'/g, "\\'");
fg.push(`${finalV}subtitles=filename='${arg}':wrap_unicode=1:force_style='${defaultForceStyle}'[${l}]`);
finalV = `[${l}]`;
}
else if (strat === 'srt') {
const srt = composeSrtFromItems(project.subtitles);
const srtName = opts.subtitlesFileName ?? 'subtitles.srt';
const srtPath = path_1.default.join(tmpDir, srtName);
extraFiles.push({ path: srtPath, content: srt });
const l = newLabel();
const arg = srtPath
.replace(/\\/g, '/')
.replace(/:/g, '\\:')
.replace(/'/g, "\\'");
fg.push(`${outV}subtitles=filename='${arg}':wrap_unicode=1:force_style='${defaultForceStyle}'[${l}]`);
finalV = `[${l}]`;
}
else {
for (const sub of project.subtitles) {
const l = newLabel();
const ff = sub.style?.fontFamily
? `:fontfile=${sub.style.fontFamily.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'")}`
: '';
const txt = escapeDrawtextText(sub.text);
const fc = sub.style?.color && sub.style?.color.match(/^#?[0-9a-fA-F]{6}$/)
? `0x${sub.style.color.replace('#', '')}`
: 'white';
const bo = sub.style?.bold ? 1 : 0;
const fs = sub.style?.fontSize ?? 42;
const oc = sub.style?.outlineColor &&
sub.style?.outlineColor.match(/^#?[0-9a-fA-F]{6}$/)
? `0x${sub.style.outlineColor.replace('#', '')}`
: '0x000000';
const ow = Math.max(0, sub.style?.outlineWidth ?? 2);
const align = sub.style?.align ?? 'center';
const vertAlign = sub.style?.verticalAlign ?? 'bottom';
const pos = sub.style?.position;
const exprX = pos
? `w*${pos.x}`
: align === 'left'
? '(0+60)'
: align === 'right'
? '(w-tw-60)'
: '(w/2-tw/2)';
const exprY = pos
? `h*${pos.y}`
: vertAlign === 'top'
? '(0+40)'
: vertAlign === 'middle'
? '(h/2-th/2)'
: '(h-th-40)';
const st = (sub.startMs / 1000).toFixed(3);
const et = (sub.endMs / 1000).toFixed(3);
fg.push(`${finalV}drawtext=text='${txt}'${ff}:fontcolor=${fc}:fontsize=${fs}:fontcolor_outline=${oc}:outline=${ow}:bold=${bo}:x=${exprX}:y=${exprY}:enable='between(t,${st},${et})'[${l}]`);
finalV = `[${l}]`;
}
}
}
const filterGraph = fg.join(';');
// Generate AIGC metadata
const aigcMetadata = {
Label: '1',
ContentProducer: 'cerevox-zerocut',
ProduceID: (0, uuid_1.v4)(),
ReservedCode1: '',
ContentPropagator: 'liubei-ai',
PropagateID: (0, uuid_1.v4)(),
ReservedCode2: '',
};
// ✅ 计算输出文件,并在缺少扩展名时指定格式 (-f)
const container = project.export.container || 'mp4';
const outFile = project.export.outFile || opts.outFile || `output.${container}`;
// 是否有扩展名(简单判断 basename 里是否包含 “.ext”)
const hasExt = /\.[a-zA-Z0-9]{2,5}$/.test(require('path').basename(outFile));
const args = [
'-y',
...inputArgs,
'-filter_complex',
filterGraph,
'-map',
finalV || outV || '0:v:0',
'-map',
outA || '0:a:0?',
'-c:v',
project.export.videoCodec,
...(project.export.crf != null ? ['-crf', String(project.export.crf)] : []),
...(project.export.preset ? ['-preset', project.export.preset] : []),
'-pix_fmt',
project.settings.pixelFormat,
'-c:a',
project.export.audioCodec,
...(project.export.audioBitrate
? ['-b:a', project.export.audioBitrate]
: []),
'-movflags',
'use_metadata_tags',
'-metadata',
`AIGC=${JSON.stringify(aigcMetadata)}`,
// ⬇️ 如果没有扩展名,则明确指定容器格式
...(!hasExt ? ['-f', container] : []),
outFile,
];
const cmd = (0, shell_quote_1.quote)([ffmpegBin, ...args]);
return { cmd, args, filterGraph, extraFiles, ffmpegBin };
}
// ===========================
// 5) Run helper (Node only)
// ===========================
async function runFfmpeg(compiled, writeFiles = true) {
if (writeFiles && compiled.extraFiles?.length) {
for (const f of compiled.extraFiles) {
// Ensure dir exists
const writePath = f.path.replace(/[\\/]+/g, path_1.default.sep); // 因为有可能在不同操作系统下远程运行,所以需要 normalize
const dir = path_1.default.dirname(writePath);
if (!fs_1.default.existsSync(dir))
fs_1.default.mkdirSync(dir, { recursive: true });
fs_1.default.writeFileSync(writePath, f.content, 'utf-8');
}
}
return await new Promise((resolve, reject) => {
const child = (0, child_process_1.spawn)(compiled.ffmpegBin, compiled.args, {
stdio: 'inherit',
});
child.on('error', reject);
child.on('close', code => code === 0
? resolve(0)
: reject(new Error(`ffmpeg exited with code ${code}`)));
});
}
// ===========================
// 6) Small utilities
// ===========================
function escapeDrawtextText(s) {
return s
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/:/g, '\\:')
.replace(/%/g, '\\%');
}
/**
* 动态获取媒体文件的真实时长(秒)
* 使用多种方法,优先使用纯 JavaScript 库,避免依赖外部工具
* @param filePath 媒体文件路径
* @returns 媒体文件的真实时长(秒),如果获取失败返回 null
*/
async function getMediaDuration(filePath) {
try {
// 方法1: 对于 MP3 文件,使用 mp3-duration 库(同步版本)
// if (filePath.toLowerCase().endsWith('.mp3')) {
// try {
// // 使用同步版本避免 async 问题
// const duration = await mp3Duration(filePath);
// if (duration && !isNaN(duration)) return duration;
// } catch (error) {
// console.warn(`mp3-duration failed for ${filePath}:`, error);
// }
// }
// 方法2: 对于视频文件,尝试读取文件头信息
if (filePath.toLowerCase().match(/\.(mp4|mov|avi|mkv|webm)$/)) {
try {
// MP4 文件头解析 (简化版)
if (filePath.toLowerCase().endsWith('.mp4')) {
if (process.env.ZEROCUT_PROJECT_CWD && !path_1.default.isAbsolute(filePath)) {
filePath = path_1.default.join(process.env.ZEROCUT_PROJECT_CWD, filePath);
}
const duration = await (0, mp4_duration_1.getMp4Duration)(filePath);
if (duration !== null)
return duration;
}
}
catch (error) {
console.warn(`File header parsing failed for ${filePath}:`, error);
}
}
return null;
}
catch (error) {
console.warn(`Failed to get duration for ${filePath}:`, error);
return null;
}
}
/**
* 检查视频文件是否包含音频轨道
* @param filePath 视频文件路径
* @returns Promise<boolean> 如果包含音频轨道返回true,否则返回false
*/
async function checkVideoHasAudio(filePath) {
try {
// 处理相对路径
let fullPath = filePath;
if (process.env.ZEROCUT_PROJECT_CWD && !path_1.default.isAbsolute(filePath)) {
fullPath = path_1.default.join(process.env.ZEROCUT_PROJECT_CWD, filePath);
}
// 检查文件扩展名,对于MP4文件使用原生解析
const ext = path_1.default.extname(fullPath).toLowerCase();
if (ext === '.mp4') {
return await (0, mp4_duration_1.checkMp4HasAudio)(fullPath);
}
// 对于其他格式,回退到ffprobe
const cmd = `ffprobe -v quiet -select_streams a -show_entries stream=codec_type -of csv=p=0 "${fullPath}"`;
try {
const output = (0, child_process_1.execSync)(cmd, { encoding: 'utf8', timeout: 5000 });
// 如果有音频流,输出会包含 "audio"
return output.trim().includes('audio');
}
catch (error) {
// ffprobe 失败可能意味着文件不存在或格式不支持
console.warn(`ffprobe failed for ${filePath}:`, error);
return false;
}
}
catch (error) {
console.warn(`Failed to check audio for ${filePath}:`, error);
return false;
}
}
async function compileKenBurnsMotion(imagePath, duration, camera_motion = 'zoom_in', opts) {
const fps = opts?.fps ?? 25;
const zoomRange = opts?.zoomRange ?? [1.0, 1.7];
const output = opts?.output ?? 'kenburns_motion.mp4';
let width = opts?.width;
let height = opts?.height;
if (!width || !height) {
try {
const dimensions = (0, image_size_1.default)(fs_1.default.readFileSync(imagePath));
width = width || dimensions.width || 1920;
height = height || dimensions.height || 1080;
}
catch {
console.warn(`Failed to read image metadata for ${imagePath}, using default size`);
width = width || 1920;
height = height || 1080;
}
}
const frames = Math.max(2, Math.round(duration * fps));
let zStart = Math.max(0.0001, zoomRange[0]);
let zEnd = Math.max(0.0001, zoomRange[1]);
switch (camera_motion) {
case 'zoom_in':
case 'zoom_in_left_top':
case 'zoom_in_right_top':
case 'zoom_in_left_bottom':
case 'zoom_in_right_bottom':
zStart = zoomRange[0];
zEnd = zoomRange[1];
break;
case 'zoom_out':
case 'zoom_out_left_top':
case 'zoom_out_right_top':
case 'zoom_out_left_bottom':
case 'zoom_out_right_bottom':
zStart = zoomRange[1];
zEnd = zoomRange[0];
break;
case 'pan_up':
zStart = 1.0;
zEnd = 1.0;
break;
case 'pan_down':
zStart = 1.0;
zEnd = 1.0;
break;
case 'pan_left':
zStart = 1.0;
zEnd = 1.0;
break;
case 'pan_right':
zStart = 1.0;
zEnd = 1.0;
break;
case 'static':
default:
break;
}
// 根据运镜类型选择合适的x和y表达式
// 对于static类型,使用简单的静态显示
if (camera_motion === 'static') {
// 静态显示:不进行任何缩放或平移
const vf = [
`format=yuv420p`,
`scale=${width}:${height}:force_original_aspect_ratio=decrease`,
`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black`,
`fps=${fps}`,
].join(',');
const cmd = [
`ffmpeg -y`,
`-loop 1`,
`-i "${imagePath.replace(/"/g, '\\"')}"`,
`-vf "${vf}"`,
`-frames:v ${frames}`,
`-an -c:v libx264 -pix_fmt yuv420p -movflags +faststart`,
`"${output.replace(/"/g, '\\"')}"`,
].join(' ');
return cmd;
}
// 对于纯pan类型,使用crop滤镜实现更好的平移效果
else if (camera_motion.includes('pan')) {
// 纯平移类型:先放大到zoomRange[1]倍,从边缘移动到中心,再缩小回来
const panZoom = zoomRange[1];
// 分阶段动画:前一半时间平移,后一半时间缩小
const halfFrames = Math.floor(frames / 2);
// 缩放表达式:前一半保持panZoom倍,后一半使用简单线性插值缩小到1.0倍
const step = (1.0 - panZoom) / (frames - halfFrames);
const zExpr = `if(lt(on,${halfFrames + 1}),${panZoom},${panZoom.toFixed(6)}+${step.toFixed(8)}*(on-${halfFrames}))`;
// 使用绝对锚点写法:先定中心点,再由zoom推出x/y
let centerXExpr;
let centerYExpr;
if (camera_motion === 'pan_left') {
// 中心点从右侧(iw*0.7)移动到中心(iw*0.5)
centerXExpr = `if(lt(on,${halfFrames + 1}),iw*(0.7 - 0.2*(on-1)/${halfFrames - 1}),iw*0.5)`;
centerYExpr = 'ih*0.5';
}
else if (camera_motion === 'pan_right') {
// 中心点从左侧(iw*0.3)移动到中心(iw*0.5)
centerXExpr = `if(lt(on,${halfFrames + 1}),iw*(0.3 + 0.2*(on-1)/${halfFrames - 1}),iw*0.5)`;
centerYExpr = 'ih*0.5';
}
else if (camera_motion === 'pan_up') {
centerXExpr = 'iw*0.5';
// 中心点从下方(ih*0.7)移动到中心(ih*0.5)
centerYExpr = `if(lt(on,${halfFrames + 1}),ih*(0.7 - 0.2*(on-1)/${halfFrames - 1}),ih*0.5)`;
}
else if (camera_motion === 'pan_down') {
centerXExpr = 'iw*0.5';
// 中心点从上方(ih*0.3)移动到中心(ih*0.5)
centerYExpr = `if(lt(on,${halfFrames + 1}),ih*(0.3 + 0