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
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user