UNPKG

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
"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