chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
import { downloadFalMedia, extractFalMediaUrl, getFalApiKey, runFalQueue } from '@/lib/media/falai'
import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing'
export type AudioType = 'speech' | 'music' | 'sfx'
// Latest-generation fal.ai audio models (2026). Speech leads the TTS arena;
// no GPT-4-tier voices. `model` on the tool can override any of these.
export const DEFAULT_AUDIO_MODELS: Record<AudioType, string> = {
speech: 'fal-ai/gemini-3.1-flash-tts',
music: 'fal-ai/minimax-music/v2.6',
sfx: 'fal-ai/elevenlabs/sound-effects/v2',
}
// Zero-shot voice cloning from a reference sample (F5-TTS: ref_audio_url + gen_text).
export const DEFAULT_CLONE_MODEL = 'fal-ai/f5-tts'
export interface GenerateFalAudioParams {
prompt: string
type?: AudioType
model?: string
voice?: string
duration?: number
/** For music: explicit lyrics (with optional [Verse]/[Chorus] tags). Implies a vocal track. */
lyrics?: string
/** For music: true = instrumental (no vocals, default); false = vocal track. */
instrumental?: boolean
/** When set, clones the voice from this reference sample (data URI) via a zero-shot clone model. */
voiceSampleDataUri?: string
}
export interface GeneratedAudio {
buffer: Buffer
contentType: string
type: AudioType
model: string
jobId: string
cost: FalAICostMetadata
}
function buildInput(
type: AudioType,
params: GenerateFalAudioParams,
model: string
): Record<string, unknown> {
const input: Record<string, unknown> = {}
if (type === 'speech') {
// Gemini 3.1 Flash TTS takes the text (with optional inline tags) in `prompt`.
input.prompt = params.prompt
if (params.voice) input.voice = params.voice
} else if (type === 'sfx') {
// ElevenLabs sound-effects take `text`.
input.text = params.prompt
if (params.duration !== undefined) input.duration_seconds = params.duration
} else {
// Music. Two modes, both supported:
// - instrumental bed (default): no vocals, no lyrics required
// - song with vocals: explicit `lyrics`, or auto-written from the prompt
input.prompt = params.prompt
const wantsVocals = params.instrumental === false || Boolean(params.lyrics)
if (model.includes('minimax')) {
// MiniMax Music 2.6 requires `lyrics` unless is_instrumental=true, and rejects a
// top-level `duration` (that combination is the 422 we were hitting on every call).
if (wantsVocals) {
input.is_instrumental = false
if (params.lyrics) input.lyrics = params.lyrics
else input.lyrics_optimizer = true
} else {
input.is_instrumental = true
}
} else if (model.includes('elevenlabs/music')) {
if (!wantsVocals) input.force_instrumental = true
if (params.lyrics) input.prompt = `${params.prompt}\n\nLyrics:\n${params.lyrics}`
if (params.duration !== undefined) input.music_length_ms = Math.round(params.duration * 1000)
} else {
// Other music models: best-effort passthrough.
if (params.instrumental !== undefined) input.instrumental = params.instrumental
if (params.lyrics) input.lyrics = params.lyrics
if (params.duration !== undefined) input.duration = params.duration
}
}
return input
}
export async function generateFalAudio(params: GenerateFalAudioParams): Promise<GeneratedAudio> {
const type: AudioType = params.type || 'speech'
const apiKey = getFalApiKey()
// Voice cloning: a reference sample routes to a zero-shot clone model (F5-TTS),
// which conditions on the sample (ref_audio_url) and speaks the prompt in that voice.
if (params.voiceSampleDataUri) {
const model = params.model || DEFAULT_CLONE_MODEL
const input: Record<string, unknown> = {
gen_text: params.prompt,
ref_audio_url: params.voiceSampleDataUri,
model_type: 'F5-TTS',
}
const { requestId, data } = await runFalQueue(model, input, apiKey)
const url = extractFalMediaUrl(data, ['audio', 'audio_url', 'audio_file', 'output'])
if (!url) throw new Error('No audio URL in Fal.ai clone response')
const { buffer, contentType } = await downloadFalMedia(url)
const cost = await getFalAICostMetadata({ apiKey, endpointId: model, requestId })
return {
buffer,
contentType: contentType.startsWith('audio/') ? contentType : 'audio/mpeg',
type: 'speech',
model,
jobId: requestId,
cost,
}
}
const model = params.model || DEFAULT_AUDIO_MODELS[type]
const input = buildInput(type, params, model)
// For fal audio models the model ID is the queue endpoint.
const { requestId, data } = await runFalQueue(model, input, apiKey)
const url = extractFalMediaUrl(data, ['audio', 'audio_url', 'audio_file', 'output'])
if (!url) throw new Error('No audio URL in Fal.ai response')
const { buffer, contentType } = await downloadFalMedia(url)
const cost = await getFalAICostMetadata({ apiKey, endpointId: model, requestId })
return {
buffer,
contentType: contentType.startsWith('audio/') ? contentType : 'audio/mpeg',
type,
model,
jobId: requestId,
cost,
}
}
+193
View File
@@ -0,0 +1,193 @@
import { isRecordLike } from '@sim/utils/object'
import {
downloadFalMedia,
extractFalMediaUrl,
getFalApiKey,
getNumberProp,
runFalQueue,
} from '@/lib/media/falai'
import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing'
type DurationFormat = 'number' | 'seconds' | 'string'
interface FalVideoModelConfig {
endpoint: string
/** Image-to-video endpoint variant, when the model supports a start-frame image. */
i2vEndpoint?: string
durationFormat?: DurationFormat
supportsAspectRatio?: boolean
supportsResolution?: boolean
supportsGenerateAudio?: boolean
supportsNegativePrompt?: boolean
supportsPromptOptimizer?: boolean
}
// Endpoints mirror app/api/tools/video/route.ts (FALAI_MODEL_CONFIGS), scoped to
// the latest-gen models the generate_video tool exposes.
const VIDEO_MODELS: Record<string, FalVideoModelConfig> = {
'veo-3.1': {
endpoint: 'fal-ai/veo3.1',
i2vEndpoint: 'fal-ai/veo3.1/image-to-video',
durationFormat: 'seconds',
supportsAspectRatio: true,
supportsResolution: true,
supportsGenerateAudio: true,
supportsNegativePrompt: true,
},
'veo-3.1-fast': {
endpoint: 'fal-ai/veo3.1/fast',
i2vEndpoint: 'fal-ai/veo3.1/fast/image-to-video',
durationFormat: 'seconds',
supportsAspectRatio: true,
supportsResolution: true,
supportsGenerateAudio: true,
supportsNegativePrompt: true,
},
'veo-3.1-lite': {
endpoint: 'fal-ai/veo3.1/lite',
i2vEndpoint: 'fal-ai/veo3.1/lite/image-to-video',
durationFormat: 'seconds',
supportsAspectRatio: true,
supportsResolution: true,
supportsGenerateAudio: true,
supportsNegativePrompt: true,
},
'seedance-2.0': {
endpoint: 'bytedance/seedance-2.0/text-to-video',
i2vEndpoint: 'bytedance/seedance-2.0/image-to-video',
durationFormat: 'string',
supportsAspectRatio: true,
supportsResolution: true,
supportsGenerateAudio: true,
},
'seedance-2.0-fast': {
endpoint: 'bytedance/seedance-2.0/fast/text-to-video',
i2vEndpoint: 'bytedance/seedance-2.0/fast/image-to-video',
durationFormat: 'string',
supportsAspectRatio: true,
supportsResolution: true,
supportsGenerateAudio: true,
},
'kling-v3-pro': {
endpoint: 'fal-ai/kling-video/v3/pro/text-to-video',
i2vEndpoint: 'fal-ai/kling-video/v3/pro/image-to-video',
durationFormat: 'string',
supportsAspectRatio: true,
supportsGenerateAudio: true,
},
'minimax-hailuo-2.3-pro': {
endpoint: 'fal-ai/minimax/hailuo-2.3/pro/text-to-video',
supportsPromptOptimizer: true,
},
'wan-2.2-a14b-turbo': {
endpoint: 'fal-ai/wan/v2.2-a14b/text-to-video/turbo',
supportsAspectRatio: true,
supportsResolution: true,
},
'ltx-2.3': {
endpoint: 'fal-ai/ltx-2.3/text-to-video',
durationFormat: 'number',
supportsAspectRatio: true,
supportsResolution: true,
supportsGenerateAudio: true,
},
}
// Default to Veo 3.1 Fast: the same Veo model family — good 1080p video with native
// 48kHz audio + lip-sync — at ~1/3 the cost of Standard (~$0.15/s vs ~$0.40/s). The
// gap is surface detail / 4K, not "good vs bad". The agent overrides to veo-3.1
// (Standard) only when the user explicitly asks for very high / premium quality.
export const DEFAULT_VIDEO_MODEL = 'veo-3.1-fast'
export interface GenerateFalVideoParams {
prompt: string
model?: string
aspectRatio?: string
resolution?: string
duration?: number
generateAudio?: boolean
/** Things to exclude from the generation, e.g. "no background music" (Veo models). */
negativePrompt?: string
promptOptimizer?: boolean
/** Optional start-frame image as a data URI; when set, routes to the model's image-to-video endpoint. */
imageDataUri?: string
}
export interface GeneratedVideo {
buffer: Buffer
contentType: string
width?: number
height?: number
model: string
endpoint: string
jobId: string
cost: FalAICostMetadata
}
function formatDuration(
format: DurationFormat | undefined,
duration?: number
): string | number | undefined {
if (!format || duration === undefined) return undefined
if (format === 'number') return duration
if (format === 'seconds') return `${duration}s`
return String(duration)
}
export async function generateFalVideo(params: GenerateFalVideoParams): Promise<GeneratedVideo> {
const model = params.model || DEFAULT_VIDEO_MODEL
const config = VIDEO_MODELS[model]
if (!config) {
throw new Error(
`Unknown video model: ${model}. Supported: ${Object.keys(VIDEO_MODELS).join(', ')}`
)
}
const apiKey = getFalApiKey()
let endpoint = config.endpoint
const input: Record<string, unknown> = { prompt: params.prompt }
if (params.imageDataUri) {
if (!config.i2vEndpoint) {
throw new Error(
`Image-to-video is not supported for model ${model}. Try veo-3.1, veo-3.1-fast, seedance-2.0, or kling-v3-pro.`
)
}
endpoint = config.i2vEndpoint
input.image_url = params.imageDataUri
}
const duration = formatDuration(config.durationFormat, params.duration)
if (duration !== undefined) input.duration = duration
if (config.supportsAspectRatio && params.aspectRatio) input.aspect_ratio = params.aspectRatio
if (config.supportsResolution && params.resolution) input.resolution = params.resolution
if (config.supportsGenerateAudio && params.generateAudio !== undefined) {
input.generate_audio = params.generateAudio
}
if (config.supportsNegativePrompt && params.negativePrompt) {
input.negative_prompt = params.negativePrompt
}
if (config.supportsPromptOptimizer && params.promptOptimizer !== undefined) {
input.prompt_optimizer = params.promptOptimizer
}
const { requestId, data } = await runFalQueue(endpoint, input, apiKey)
const url = extractFalMediaUrl(data, ['video', 'output'])
if (!url) throw new Error('No video URL in Fal.ai response')
const videoNode = isRecordLike(data.video) ? data.video : undefined
const { buffer, contentType } = await downloadFalMedia(url)
const cost = await getFalAICostMetadata({ apiKey, endpointId: endpoint, requestId })
return {
buffer,
contentType: contentType.startsWith('video/') ? contentType : 'video/mp4',
width: getNumberProp(videoNode, 'width'),
height: getNumberProp(videoNode, 'height'),
model,
endpoint,
jobId: requestId,
cost,
}
}
+230
View File
@@ -0,0 +1,230 @@
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import { isRecordLike } from '@sim/utils/object'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import {
assertKnownSizeWithinLimit,
DEFAULT_MAX_ERROR_BODY_BYTES,
readResponseJsonWithLimit,
readResponseTextWithLimit,
readResponseToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
const logger = createLogger('FalMediaClient')
// Generated media (esp. video) can be large.
export const MAX_MEDIA_BYTES = 250 * 1024 * 1024
const MAX_MEDIA_JSON_BYTES = 4 * 1024 * 1024
const POLL_INTERVAL_MS = 3000
/**
* Resolves a hosted Fal.ai API key from the numbered env pool
* (FALAI_API_KEY_COUNT + FALAI_API_KEY_1..N), round-robined by minute,
* mirroring getRotatingApiKey. Falls back to a single FALAI_API_KEY for dev.
*/
export function getFalApiKey(): string {
const count = Number.parseInt(process.env.FALAI_API_KEY_COUNT || '0', 10)
const keys: string[] = []
for (let i = 1; i <= count; i++) {
const key = process.env[`FALAI_API_KEY_${i}`]
if (key) keys.push(key)
}
if (keys.length === 0 && process.env.FALAI_API_KEY) {
keys.push(process.env.FALAI_API_KEY)
}
if (keys.length === 0) {
throw new Error(
'No hosted Fal.ai API key configured. Set FALAI_API_KEY_COUNT and FALAI_API_KEY_1..N.'
)
}
const index = new Date().getMinutes() % keys.length
return keys[index]
}
export function getStringProp(
record: Record<string, unknown> | undefined,
key: string
): string | undefined {
const value = record?.[key]
return typeof value === 'string' ? value : undefined
}
export function getNumberProp(
record: Record<string, unknown> | undefined,
key: string
): number | undefined {
const value = record?.[key]
return typeof value === 'number' ? value : undefined
}
function falQueueUrl(endpoint: string, requestId: string, path: 'status' | 'response'): string {
return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}`
}
function falErrorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (isRecordLike(error)) return getStringProp(error, 'message') || JSON.stringify(error)
return 'Unknown Fal.ai error'
}
export interface FalQueueResult {
requestId: string
data: Record<string, unknown>
}
/**
* Submit input to a Fal.ai queue endpoint, poll to completion, and return the
* result JSON. Shared by the video and audio generators.
*/
export async function runFalQueue(
endpoint: string,
input: Record<string, unknown>,
apiKey: string
): Promise<FalQueueResult> {
const createResponse = await fetch(`https://queue.fal.run/${endpoint}`, {
method: 'POST',
headers: { Authorization: `Key ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
if (!createResponse.ok) {
const err = await readResponseTextWithLimit(createResponse, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
label: 'Fal.ai create error response',
}).catch(() => '')
throw new Error(`Fal.ai API error: ${createResponse.status} - ${err}`)
}
const createData = await readResponseJsonWithLimit(createResponse, {
maxBytes: MAX_MEDIA_JSON_BYTES,
label: 'Fal.ai create response',
})
if (!isRecordLike(createData)) throw new Error('Invalid Fal.ai queue response')
const requestId = getStringProp(createData, 'request_id')
if (!requestId) throw new Error('Fal.ai queue response missing request_id')
const statusUrl =
getStringProp(createData, 'status_url') || falQueueUrl(endpoint, requestId, 'status')
const responseUrl =
getStringProp(createData, 'response_url') || falQueueUrl(endpoint, requestId, 'response')
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / POLL_INTERVAL_MS)
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await sleep(POLL_INTERVAL_MS)
const statusResponse = await fetch(statusUrl, { headers: { Authorization: `Key ${apiKey}` } })
if (!statusResponse.ok) {
const body = await readResponseTextWithLimit(statusResponse, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
label: 'Fal.ai status error response',
}).catch(() => '')
throw new Error(
`Fal.ai status check failed: ${statusResponse.status}${body ? ` - ${body}` : ''}`
)
}
const statusData = await readResponseJsonWithLimit(statusResponse, {
maxBytes: MAX_MEDIA_JSON_BYTES,
label: 'Fal.ai status response',
})
if (!isRecordLike(statusData)) throw new Error('Invalid Fal.ai status response')
const status = getStringProp(statusData, 'status')
if (status === 'COMPLETED') {
if (statusData.error) {
throw new Error(`Fal.ai generation failed: ${falErrorMessage(statusData.error)}`)
}
const resultResponse = await fetch(getStringProp(statusData, 'response_url') || responseUrl, {
headers: { Authorization: `Key ${apiKey}` },
})
if (!resultResponse.ok) {
const body = await readResponseTextWithLimit(resultResponse, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
label: 'Fal.ai result error response',
}).catch(() => '')
throw new Error(
`Failed to fetch Fal.ai result: ${resultResponse.status}${body ? ` - ${body}` : ''}`
)
}
const resultData = await readResponseJsonWithLimit(resultResponse, {
maxBytes: MAX_MEDIA_JSON_BYTES,
label: 'Fal.ai result response',
})
if (!isRecordLike(resultData)) throw new Error('Invalid Fal.ai result response')
return { requestId, data: resultData }
}
if (['ERROR', 'FAILED', 'CANCELLED'].includes(status || '')) {
throw new Error(`Fal.ai generation failed: ${falErrorMessage(statusData.error)}`)
}
}
throw new Error('Fal.ai generation timed out')
}
/**
* Pull the output media URL out of a Fal.ai result, tolerating the various
* shapes different models return (string url, { url }, nested arrays).
*/
export function extractFalMediaUrl(
data: Record<string, unknown>,
keys: string[]
): string | undefined {
for (const key of keys) {
const value = data[key]
if (typeof value === 'string') return value
if (isRecordLike(value)) {
const url = getStringProp(value, 'url')
if (url) return url
}
if (Array.isArray(value)) {
const first = value.find(isRecordLike) as Record<string, unknown> | undefined
const url = getStringProp(first, 'url')
if (url) return url
}
}
return undefined
}
/** Securely download a generated media URL (or inline data URI) to a buffer. */
export async function downloadFalMedia(
url: string
): Promise<{ buffer: Buffer; contentType: string }> {
if (url.startsWith('data:')) {
const match = /^data:([^;]+);base64,(.+)$/u.exec(url)
if (!match) throw new Error('Invalid data URI media response')
const buffer = Buffer.from(match[2], 'base64')
assertKnownSizeWithinLimit(buffer.length, MAX_MEDIA_BYTES, 'inline media response')
return { contentType: match[1], buffer }
}
const validation = await validateUrlWithDNS(url, 'mediaUrl')
if (!validation.isValid || !validation.resolvedIP) {
throw new Error(validation.error || 'Generated media URL failed validation')
}
const response = await secureFetchWithPinnedIP(url, validation.resolvedIP, {
method: 'GET',
maxResponseBytes: MAX_MEDIA_BYTES,
})
if (!response.ok) {
await readResponseTextWithLimit(response, {
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
label: 'generated media error response',
}).catch(() => '')
throw new Error(`Failed to download generated media: ${response.status}`)
}
const contentType = response.headers.get('content-type') || 'application/octet-stream'
const buffer = await readResponseToBufferWithLimit(response, {
maxBytes: MAX_MEDIA_BYTES,
label: 'generated media download',
})
return { buffer, contentType }
}
export { logger as falMediaLogger }
+573
View File
@@ -0,0 +1,573 @@
import { execSync } from 'node:child_process'
import fsSync from 'node:fs'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { createLogger } from '@sim/logger'
import ffmpegStatic from 'ffmpeg-static'
import ffmpeg from 'fluent-ffmpeg'
const logger = createLogger('MediaFfmpeg')
let ffmpegInitialized = false
let ffmpegPath: string | null = null
/** Lazy FFmpeg binary resolution (ffmpeg-static, then system), mirroring lib/audio/extractor.ts. */
function ensureFfmpeg(): void {
if (ffmpegInitialized) {
if (!ffmpegPath) {
throw new Error(
'FFmpeg not found. Install: brew install ffmpeg (macOS) / apk add ffmpeg (Alpine) / apt-get install ffmpeg (Ubuntu)'
)
}
return
}
ffmpegInitialized = true
if (ffmpegStatic && typeof ffmpegStatic === 'string') {
try {
fsSync.accessSync(ffmpegStatic, fsSync.constants.X_OK)
ffmpegPath = ffmpegStatic
ffmpeg.setFfmpegPath(ffmpegPath)
return
} catch {
// fall through to system ffmpeg
}
}
try {
const cmd = process.platform === 'win32' ? 'where ffmpeg' : 'which ffmpeg'
ffmpegPath = execSync(cmd, { encoding: 'utf-8' }).trim().split('\n')[0]
ffmpeg.setFfmpegPath(ffmpegPath)
} catch {
logger.warn('[FFmpeg] No FFmpeg binary found at init time')
}
}
export type FfmpegOperation =
| 'overlay_audio'
| 'mux'
| 'mix_audio'
| 'concat'
| 'trim'
| 'scale_pad'
| 'overlay_image'
| 'add_text'
| 'fade'
| 'extract_audio'
| 'convert'
| 'thumbnail'
| 'probe'
export interface MediaFile {
buffer: Buffer
mimeType: string
name?: string
}
export interface FfmpegOptions {
text?: string
position?: string
start?: number
end?: number
width?: number
height?: number
aspectRatio?: string
volume?: number
musicVolume?: number
loopToVideo?: boolean
format?: string
}
export interface MediaProbe {
durationSeconds: number
format: string
width?: number
height?: number
videoCodec?: string
audioCodec?: string
hasAudio: boolean
hasVideo: boolean
}
export interface FfmpegResult {
buffer?: Buffer
contentType?: string
ext?: string
probe?: MediaProbe
}
const MIME_TO_EXT: Record<string, string> = {
'video/mp4': 'mp4',
'video/mpeg': 'mp4',
'video/quicktime': 'mov',
'video/x-quicktime': 'mov',
'video/x-msvideo': 'avi',
'video/avi': 'avi',
'video/x-matroska': 'mkv',
'video/webm': 'webm',
'audio/mpeg': 'mp3',
'audio/mp3': 'mp3',
'audio/mp4': 'm4a',
'audio/x-m4a': 'm4a',
'audio/wav': 'wav',
'audio/x-wav': 'wav',
'audio/wave': 'wav',
'audio/ogg': 'ogg',
'audio/flac': 'flac',
'audio/x-flac': 'flac',
'audio/aac': 'aac',
'audio/opus': 'opus',
'audio/webm': 'weba',
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
}
const EXT_TO_MIME: Record<string, string> = {
mp4: 'video/mp4',
mov: 'video/quicktime',
webm: 'video/webm',
mkv: 'video/x-matroska',
avi: 'video/x-msvideo',
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
wav: 'audio/wav',
ogg: 'audio/ogg',
flac: 'audio/flac',
aac: 'audio/aac',
opus: 'audio/opus',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
}
function extFromMime(mime: string): string {
return MIME_TO_EXT[mime] || mime.split('/')[1] || 'bin'
}
function mimeFromExt(ext: string): string {
return EXT_TO_MIME[ext] || 'application/octet-stream'
}
const ASPECT_TARGETS: Record<string, { w: number; h: number }> = {
'16:9': { w: 1920, h: 1080 },
'9:16': { w: 1080, h: 1920 },
'1:1': { w: 1080, h: 1080 },
'4:3': { w: 1440, h: 1080 },
'3:4': { w: 1080, h: 1440 },
'4:5': { w: 1080, h: 1350 },
'21:9': { w: 2560, h: 1080 },
}
const OVERLAY_POSITION: Record<string, string> = {
'top-left': '10:10',
top: '(W-w)/2:10',
'top-right': 'W-w-10:10',
center: '(W-w)/2:(H-h)/2',
'bottom-left': '10:H-h-10',
bottom: '(W-w)/2:H-h-10',
'bottom-right': 'W-w-10:H-h-10',
}
const TEXT_POSITION: Record<string, { x: string; y: string }> = {
top: { x: '(w-text_w)/2', y: 'h*0.08' },
center: { x: '(w-text_w)/2', y: '(h-text_h)/2' },
bottom: { x: '(w-text_w)/2', y: 'h*0.86' },
'top-left': { x: 'w*0.05', y: 'h*0.08' },
'top-right': { x: 'w*0.95-text_w', y: 'h*0.08' },
'bottom-left': { x: 'w*0.05', y: 'h*0.86' },
'bottom-right': { x: 'w*0.95-text_w', y: 'h*0.86' },
}
function escapeDrawtext(text: string): string {
return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/%/g, '\\%')
}
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
ensureFfmpeg()
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'media-ffmpeg-'))
try {
return await fn(dir)
} finally {
await fs.rm(dir, { recursive: true, force: true }).catch(() => {})
}
}
async function writeInput(dir: string, file: MediaFile, index: number): Promise<string> {
const ext = extFromMime(file.mimeType)
const filePath = path.join(dir, `in-${index}.${ext}`)
await fs.writeFile(filePath, file.buffer)
return filePath
}
function runCommand(command: ffmpeg.FfmpegCommand, outputPath: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
command
.on('end', () => resolve())
.on('error', (err) => reject(new Error(`FFmpeg error: ${err.message}`)))
.save(outputPath)
})
}
export async function probeMedia(file: MediaFile): Promise<MediaProbe> {
return withTempDir(async (dir) => {
const inputPath = await writeInput(dir, file, 0)
return probeFile(inputPath)
})
}
function probeFile(filePath: string): Promise<MediaProbe> {
ensureFfmpeg()
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(filePath, (err, metadata) => {
if (err) {
reject(new Error(`FFprobe error: ${err.message}`))
return
}
const video = metadata.streams.find((s) => s.codec_type === 'video')
const audio = metadata.streams.find((s) => s.codec_type === 'audio')
resolve({
durationSeconds: Number(metadata.format?.duration) || 0,
format: metadata.format?.format_name || 'unknown',
width: video?.width,
height: video?.height,
videoCodec: video?.codec_name,
audioCodec: audio?.codec_name,
hasAudio: Boolean(audio),
hasVideo: Boolean(video),
})
})
})
}
/**
* Run a single FFmpeg media operation on the provided input files.
* All inputs/outputs are buffers; temp files are created and cleaned up internally.
*/
export async function runFfmpegOperation(
operation: FfmpegOperation,
inputs: MediaFile[],
options: FfmpegOptions = {}
): Promise<FfmpegResult> {
if (inputs.length === 0) {
throw new Error('At least one input file is required')
}
if (operation === 'probe') {
return { probe: await probeMedia(inputs[0]) }
}
return withTempDir(async (dir) => {
const inputPaths = await Promise.all(inputs.map((f, i) => writeInput(dir, f, i)))
switch (operation) {
case 'overlay_audio':
case 'mux':
return overlayAudio(dir, inputPaths, options)
case 'mix_audio':
return mixAudio(dir, inputPaths, options)
case 'concat':
return concat(dir, inputPaths)
case 'trim':
return trim(dir, inputPaths[0], inputs[0], options)
case 'scale_pad':
return scalePad(dir, inputPaths[0], options)
case 'overlay_image':
return overlayImage(dir, inputPaths, options)
case 'add_text':
return addText(dir, inputPaths[0], options)
case 'fade':
return fade(dir, inputPaths[0], inputs[0], options)
case 'extract_audio':
return extractAudio(dir, inputPaths[0], options)
case 'convert':
return convert(dir, inputPaths[0], options)
case 'thumbnail':
return thumbnail(dir, inputPaths[0], options)
default:
throw new Error(`Unsupported ffmpeg operation: ${operation}`)
}
})
}
async function readOut(outputPath: string, ext: string): Promise<FfmpegResult> {
const buffer = await fs.readFile(outputPath)
return { buffer, ext, contentType: mimeFromExt(ext) }
}
async function overlayAudio(
dir: string,
inputPaths: string[],
options: FfmpegOptions
): Promise<FfmpegResult> {
if (inputPaths.length < 2) throw new Error('overlay_audio requires [video, audio]')
const outputPath = path.join(dir, 'out.mp4')
const command = ffmpeg().input(inputPaths[0])
if (options.loopToVideo) {
command.input(inputPaths[1]).inputOptions(['-stream_loop', '-1'])
} else {
command.input(inputPaths[1])
}
command.outputOptions([
'-map',
'0:v:0',
'-map',
'1:a:0',
'-c:v',
'copy',
'-c:a',
'aac',
'-shortest',
])
await runCommand(command, outputPath)
return readOut(outputPath, 'mp4')
}
async function mixAudio(
dir: string,
inputPaths: string[],
options: FfmpegOptions
): Promise<FfmpegResult> {
if (inputPaths.length < 2) throw new Error('mix_audio requires [voice, music]')
const outputPath = path.join(dir, 'out.mp3')
const voiceVol = options.volume ?? 1
const musicVol = options.musicVolume ?? 0.3
const command = ffmpeg()
.input(inputPaths[0])
.input(inputPaths[1])
.complexFilter([
`[0:a]volume=${voiceVol}[v]`,
`[1:a]volume=${musicVol}[m]`,
`[v][m]amix=inputs=2:duration=longest:dropout_transition=0[a]`,
])
.outputOptions(['-map', '[a]'])
await runCommand(command, outputPath)
return readOut(outputPath, 'mp3')
}
async function concat(dir: string, inputPaths: string[]): Promise<FfmpegResult> {
if (inputPaths.length < 2) throw new Error('concat requires at least 2 clips')
const probes = await Promise.all(inputPaths.map(probeFile))
probes.forEach((p, i) => {
if (!p.hasVideo) {
throw new Error(
`concat input ${i} has no video stream; concat joins video clips (use mix_audio/overlay_audio for audio-only files).`
)
}
})
const width = probes[0].width || 1280
const height = probes[0].height || 720
const fps = 30
// Normalize every clip to identical codec/size/fps/pixfmt, and SYNTHESIZE silent
// audio for clips that have no audio stream. Clips generated without native audio
// (generateAudio:false) otherwise break the concat filtergraph (it referenced a
// non-existent [i:a]), which is the "Error binding filtergraph inputs/outputs" failure.
const normalized: string[] = []
for (let i = 0; i < inputPaths.length; i++) {
const out = path.join(dir, `norm-${i}.mp4`)
const cmd = ffmpeg().input(inputPaths[i])
const maps: string[] = ['-map', '0:v:0']
const extra: string[] = []
if (probes[i].hasAudio) {
maps.push('-map', '0:a:0')
} else {
cmd
.input('anullsrc=channel_layout=stereo:sample_rate=48000')
.inputOptions(['-f', 'lavfi', '-t', String(probes[i].durationSeconds || 1)])
maps.push('-map', '1:a:0')
extra.push('-shortest')
}
cmd
.videoFilters(
`scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${fps},format=yuv420p`
)
.outputOptions([
...maps,
'-c:v',
'libx264',
'-preset',
'medium',
'-crf',
'18',
'-pix_fmt',
'yuv420p',
'-r',
String(fps),
'-video_track_timescale',
'90000',
'-c:a',
'aac',
'-b:a',
'192k',
'-ar',
'48000',
'-ac',
'2',
...extra,
])
await runCommand(cmd, out)
normalized.push(out)
}
// Concatenate the now-uniform clips with the concat demuxer (stream copy: fast + reliable).
const listPath = path.join(dir, 'concat-list.txt')
await fs.writeFile(
listPath,
normalized.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join('\n')
)
const outputPath = path.join(dir, 'out.mp4')
const concatCmd = ffmpeg()
.input(listPath)
.inputOptions(['-f', 'concat', '-safe', '0'])
.outputOptions(['-c', 'copy', '-movflags', '+faststart'])
await runCommand(concatCmd, outputPath)
return readOut(outputPath, 'mp4')
}
async function trim(
dir: string,
inputPath: string,
input: MediaFile,
options: FfmpegOptions
): Promise<FfmpegResult> {
const ext = extFromMime(input.mimeType)
const outputPath = path.join(dir, `out.${ext}`)
const start = options.start ?? 0
const command = ffmpeg(inputPath).setStartTime(start)
if (options.end !== undefined) {
command.setDuration(Math.max(0, options.end - start))
}
await runCommand(command, outputPath)
return readOut(outputPath, ext)
}
async function scalePad(
dir: string,
inputPath: string,
options: FfmpegOptions
): Promise<FfmpegResult> {
let width = options.width
let height = options.height
if ((!width || !height) && options.aspectRatio && ASPECT_TARGETS[options.aspectRatio]) {
width = ASPECT_TARGETS[options.aspectRatio].w
height = ASPECT_TARGETS[options.aspectRatio].h
}
if (!width || !height) {
throw new Error('scale_pad requires width+height or a known aspectRatio (e.g. 9:16)')
}
const outputPath = path.join(dir, 'out.mp4')
const command = ffmpeg(inputPath)
.videoFilters(
`scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1`
)
.outputOptions(['-c:a', 'copy'])
await runCommand(command, outputPath)
return readOut(outputPath, 'mp4')
}
async function overlayImage(
dir: string,
inputPaths: string[],
options: FfmpegOptions
): Promise<FfmpegResult> {
if (inputPaths.length < 2) throw new Error('overlay_image requires [video, image]')
const xy = OVERLAY_POSITION[options.position || 'top-right'] || OVERLAY_POSITION['top-right']
const outputPath = path.join(dir, 'out.mp4')
const command = ffmpeg()
.input(inputPaths[0])
.input(inputPaths[1])
.complexFilter([`[0:v][1:v]overlay=${xy}[v]`])
.outputOptions(['-map', '[v]', '-map', '0:a?', '-c:a', 'copy'])
await runCommand(command, outputPath)
return readOut(outputPath, 'mp4')
}
async function addText(
dir: string,
inputPath: string,
options: FfmpegOptions
): Promise<FfmpegResult> {
if (!options.text) throw new Error('add_text requires text')
const pos = TEXT_POSITION[options.position || 'bottom'] || TEXT_POSITION.bottom
const drawtext = [
`text='${escapeDrawtext(options.text)}'`,
'fontcolor=white',
'fontsize=h/18',
'box=1',
'boxcolor=black@0.5',
'boxborderw=20',
`x=${pos.x}`,
`y=${pos.y}`,
].join(':')
const outputPath = path.join(dir, 'out.mp4')
const command = ffmpeg(inputPath)
.videoFilters(`drawtext=${drawtext}`)
.outputOptions(['-c:a', 'copy'])
await runCommand(command, outputPath)
return readOut(outputPath, 'mp4')
}
async function fade(
dir: string,
inputPath: string,
input: MediaFile,
_options: FfmpegOptions
): Promise<FfmpegResult> {
const probe = await probeFile(inputPath)
const duration = probe.durationSeconds || 0
const fadeDur = Math.min(0.5, duration / 4 || 0.5)
const outStart = Math.max(0, duration - fadeDur)
const isVideo = input.mimeType.startsWith('video/') || probe.hasVideo
const ext = isVideo ? 'mp4' : extFromMime(input.mimeType)
const outputPath = path.join(dir, `out.${ext}`)
const command = ffmpeg(inputPath)
if (isVideo) {
command.videoFilters([`fade=t=in:st=0:d=${fadeDur}`, `fade=t=out:st=${outStart}:d=${fadeDur}`])
}
command.audioFilters([`afade=t=in:st=0:d=${fadeDur}`, `afade=t=out:st=${outStart}:d=${fadeDur}`])
await runCommand(command, outputPath)
return readOut(outputPath, ext)
}
async function extractAudio(
dir: string,
inputPath: string,
options: FfmpegOptions
): Promise<FfmpegResult> {
const ext = (options.format || 'mp3').toLowerCase()
const outputPath = path.join(dir, `out.${ext}`)
const command = ffmpeg(inputPath).noVideo()
await runCommand(command, outputPath)
return readOut(outputPath, ext)
}
async function convert(
dir: string,
inputPath: string,
options: FfmpegOptions
): Promise<FfmpegResult> {
if (!options.format) throw new Error('convert requires a target format')
const ext = options.format.toLowerCase()
const outputPath = path.join(dir, `out.${ext}`)
await runCommand(ffmpeg(inputPath), outputPath)
return readOut(outputPath, ext)
}
async function thumbnail(
dir: string,
inputPath: string,
options: FfmpegOptions
): Promise<FfmpegResult> {
const outputPath = path.join(dir, 'out.jpg')
const command = ffmpeg(inputPath)
.seekInput(options.start ?? 0)
.frames(1)
await runCommand(command, outputPath)
return readOut(outputPath, 'jpg')
}
export { extFromMime, mimeFromExt }