// Name: MoviePy Video Editor
// Description: Convert Python MoviePy script to JavaScript video editing workflow
// Author: tooy-120
import "@johnlindquist/kit"
// Constants for better maintainability
const PROJECT_STRUCTURE = {
assets: "assets",
audio: "audio",
output: "output"
} as const
const VIDEO_RESOLUTIONS = [
{ name: "HD (1280x720)", value: "1280x720" },
{ name: "Full HD (1920x1080)", value: "1920x1080" },
{ name: "4K (3840x2160)", value: "3840x2160" },
{ name: "Square (1080x1080)", value: "1080x1080" },
{ name: "Custom", value: "custom" }
] as const
const AUDIO_FORMATS = ["mp3", "wav", "aac"] as const
const IMAGE_PATTERNS = ["*.jpg", "*.png", "*.jpeg"] as const
// Type definitions for better type safety
interface ProjectConfig {
name: string
resolution: string
fps: number
scenes: unknown[]
audio: {
background: string | null
effects: unknown[]
}
}
interface VideoAction {
name: string
value: string
}
// Utility functions
const createProjectDirectories = async (projectDir: string): Promise<void> => {
await Promise.all([
ensureDir(path.join(projectDir, PROJECT_STRUCTURE.assets)),
ensureDir(path.join(projectDir, PROJECT_STRUCTURE.audio)),
ensureDir(path.join(projectDir, PROJECT_STRUCTURE.output))
])
}
const validateFFmpeg = async (): Promise<boolean> => {
const ffmpegPath = await which("ffmpeg")
if (!ffmpegPath) {
await div(md(`
# FFmpeg Required
This script requires FFmpeg to be installed for video processing.
## Installation Options:
### macOS (Homebrew):
\`\`\`bash
brew install ffmpeg
\`\`\`
### Windows (Chocolatey):
\`\`\`bash
choco install ffmpeg
\`\`\`
### Linux (Ubuntu/Debian):
\`\`\`bash
sudo apt update && sudo apt install ffmpeg
\`\`\`
`))
return false
}
return true
}
const getVideoDuration = async (videoFile: string): Promise<number> => {
try {
const result = await exec(`ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${videoFile}"`)
const duration = parseFloat(result.stdout.trim())
return isNaN(duration) ? 10 : duration // Fallback to 10 seconds if parsing fails
} catch (error) {
console.warn(`Failed to get video duration for ${videoFile}:`, error)
return 10 // Default fallback
}
}
const handleFFmpegError = async (error: Error, operation: string): Promise<void> => {
await div(md(`
# Error During ${operation}
**Error:** ${error.message}
**Common solutions:**
1. Ensure FFmpeg is properly installed
2. Check that input files exist and are valid
3. Verify file permissions
4. Make sure output directory is writable
**Debug tip:** Run the command manually in terminal to see detailed error messages.
`))
}
// Main execution flow
const isFFmpegAvailable = await validateFFmpeg()
if (!isFFmpegAvailable) {
exit()
}
const projectDir = await path({
hint: "Select a directory for your video project",
onlyDirs: true
})
await ensureDir(projectDir)
cd(projectDir)
await createProjectDirectories(projectDir)
const videoActions: VideoAction[] = [
{ name: "Create New Video Project", value: "create" },
{ name: "Convert Images to Video", value: "images-to-video" },
{ name: "Add Audio to Video", value: "add-audio" },
{ name: "Concatenate Videos", value: "concat" },
{ name: "Add Fade Effects", value: "fade" },
{ name: "Resize Video", value: "resize" },
{ name: "Extract Audio from Video", value: "extract-audio" }
]
const action = await arg("What would you like to do?", videoActions)
// Action handlers
const createVideoProject = async (): Promise<void> => {
const projectName = await arg("Enter project name:")
const config: ProjectConfig = {
name: projectName,
resolution: "1280x720",
fps: 24,
scenes: [],
audio: {
background: null,
effects: []
}
}
await writeFile("project.json", JSON.stringify(config, null, 2))
const sampleScript = `# ${projectName} Video Project
## Scene Structure:
1. Opening scene with fade in
2. Main content
3. Closing with fade out
## Assets needed:
- Background images in ./assets/
- Audio files in ./audio/
- Video clips in ./assets/
## Commands to run:
- Convert images to video: npm run images-to-video
- Add background music: npm run add-audio
- Apply effects: npm run effects
`
await writeFile("README.md", sampleScript)
await div(md(`
# Project Created: ${projectName}
Your video project has been set up with:
- Project configuration in \`project.json\`
- Asset directories created
- Sample README with instructions
Next steps:
1. Add your images to the \`assets/\` folder
2. Add audio files to the \`audio/\` folder
3. Run the script again to process your media
`))
}
const imagesToVideo = async (): Promise<void> => {
const imagePattern = await arg("Image pattern (e.g., *.jpg, *.png):", [...IMAGE_PATTERNS])
const duration = await arg("Duration per image (seconds):", ["2", "3", "5"])
const outputName = await arg("Output video name:", "slideshow.mp4")
// Validate inputs
const durationNum = parseInt(duration)
if (isNaN(durationNum) || durationNum <= 0) {
await div(md("Invalid duration. Please enter a positive number."))
return
}
const images = await globby([`${PROJECT_STRUCTURE.assets}/${imagePattern}`])
if (images.length === 0) {
await div(md(`No images found matching pattern: ${imagePattern} in assets/ folder`))
return
}
await div(md(`Found ${images.length} images. Creating video...`))
try {
// Create video from images using ffmpeg with improved settings
await exec(`ffmpeg -y -framerate 1/${durationNum} -pattern_type glob -i '${PROJECT_STRUCTURE.assets}/${imagePattern}' -c:v libx264 -r 24 -pix_fmt yuv420p -movflags +faststart ${PROJECT_STRUCTURE.output}/${outputName}`)
await div(md(`
# Video Created Successfully! 🎬
**Output:** \`${PROJECT_STRUCTURE.output}/${outputName}\`
**Images processed:** ${images.length}
**Duration per image:** ${duration} seconds
**Total duration:** ${images.length * durationNum} seconds
The video has been saved to your output folder.
`))
await revealFile(path.join(projectDir, PROJECT_STRUCTURE.output, outputName))
} catch (error) {
await handleFFmpegError(error as Error, "Image to Video Conversion")
}
}
const addAudioToVideo = async (): Promise<void> => {
const videoFile = await selectFile("Select video file")
const audioFile = await selectFile("Select audio file")
const outputName = await arg("Output filename:", "video_with_audio.mp4")
try {
// Use -y flag to overwrite existing files and add faststart for web compatibility
await exec(`ffmpeg -y -i "${videoFile}" -i "${audioFile}" -c:v copy -c:a aac -shortest -movflags +faststart ${PROJECT_STRUCTURE.output}/${outputName}`)
await div(md(`
# Audio Added Successfully! 🎵
**Output:** \`${PROJECT_STRUCTURE.output}/${outputName}\`
The video with audio has been created.
`))
await revealFile(path.join(projectDir, PROJECT_STRUCTURE.output, outputName))
} catch (error) {
await handleFFmpegError(error as Error, "Audio Addition")
}
}
const concatenateVideos = async (): Promise<void> => {
await div(md("Select multiple video files to concatenate..."))
const videos: string[] = []
// Collect video files
while (true) {
const addMore = await arg("Add video file?", [
{ name: "Select Video File", value: "add" },
{ name: "Done - Concatenate Videos", value: "done" }
])
if (addMore === "done") break
const videoFile = await selectFile("Select video file")
videos.push(videoFile)
await div(md(`Added: ${path.basename(videoFile)}\nTotal videos: ${videos.length}`))
}
if (videos.length < 2) {
await div(md("Need at least 2 videos to concatenate"))
return
}
const outputName = await arg("Output filename:", "concatenated.mp4")
const fileListPath = "filelist.txt"
try {
// Create file list for ffmpeg with proper escaping
const fileList = videos.map(v => `file '${v.replace(/'/g, "'\\''")}'`).join('\n')
await writeFile(fileListPath, fileList)
await exec(`ffmpeg -y -f concat -safe 0 -i ${fileListPath} -c copy -movflags +faststart ${PROJECT_STRUCTURE.output}/${outputName}`)
await div(md(`
# Videos Concatenated Successfully! 🎬
**Output:** \`${PROJECT_STRUCTURE.output}/${outputName}\`
**Videos combined:** ${videos.length}
The concatenated video has been created.
`))
await revealFile(path.join(projectDir, PROJECT_STRUCTURE.output, outputName))
} catch (error) {
await handleFFmpegError(error as Error, "Video Concatenation")
} finally {
// Clean up temporary file
try {
await remove(fileListPath)
} catch (cleanupError) {
console.warn("Failed to clean up temporary file:", cleanupError)
}
}
}
const addFadeEffects = async (): Promise<void> => {
const videoFile = await selectFile("Select video file")
const fadeInDuration = await arg("Fade in duration (seconds):", ["1", "2", "3"])
const fadeOutDuration = await arg("Fade out duration (seconds):", ["1", "2", "3"])
const outputName = await arg("Output filename:", "video_with_fades.mp4")
// Validate fade durations
const fadeInNum = parseInt(fadeInDuration)
const fadeOutNum = parseInt(fadeOutDuration)
if (isNaN(fadeInNum) || isNaN(fadeOutNum) || fadeInNum < 0 || fadeOutNum < 0) {
await div(md("Invalid fade duration. Please enter positive numbers."))
return
}
try {
const videoDuration = await getVideoDuration(videoFile)
const fadeOutStart = Math.max(0, videoDuration - fadeOutNum)
const fadeInFrames = fadeInNum * 24
const fadeOutFrames = fadeOutNum * 24
await exec(`ffmpeg -y -i "${videoFile}" -vf "fade=in:0:${fadeInFrames},fade=out:st=${fadeOutStart}:d=${fadeOutFrames}" -movflags +faststart ${PROJECT_STRUCTURE.output}/${outputName}`)
await div(md(`
# Fade Effects Added Successfully! ✨
**Output:** \`${PROJECT_STRUCTURE.output}/${outputName}\`
**Fade in:** ${fadeInDuration} seconds
**Fade out:** ${fadeOutDuration} seconds
**Video duration:** ${videoDuration.toFixed(1)} seconds
The video with fade effects has been created.
`))
await revealFile(path.join(projectDir, PROJECT_STRUCTURE.output, outputName))
} catch (error) {
await handleFFmpegError(error as Error, "Fade Effects Addition")
}
}
const resizeVideo = async (): Promise<void> => {
const videoFile = await selectFile("Select video file")
let resolution = await arg("Select resolution:", VIDEO_RESOLUTIONS)
if (resolution === "custom") {
resolution = await arg("Enter custom resolution (WxH):", "1280x720")
// Validate custom resolution format
if (!/^\d+x\d+$/.test(resolution)) {
await div(md("Invalid resolution format. Please use WxH format (e.g., 1280x720)"))
return
}
}
const outputName = await arg("Output filename:", `resized_${resolution.replace('x', '_')}.mp4`)
try {
await exec(`ffmpeg -y -i "${videoFile}" -vf scale=${resolution} -movflags +faststart ${PROJECT_STRUCTURE.output}/${outputName}`)
await div(md(`
# Video Resized Successfully! 📐
**Output:** \`${PROJECT_STRUCTURE.output}/${outputName}\`
**New resolution:** ${resolution}
The resized video has been created.
`))
await revealFile(path.join(projectDir, PROJECT_STRUCTURE.output, outputName))
} catch (error) {
await handleFFmpegError(error as Error, "Video Resizing")
}
}
const extractAudio = async (): Promise<void> => {
const videoFile = await selectFile("Select video file")
const audioFormat = await arg("Audio format:", [...AUDIO_FORMATS])
const outputName = await arg("Output filename:", `extracted_audio.${audioFormat}`)
// Map audio formats to codecs
const codecMap: Record<string, string> = {
mp3: "libmp3lame",
wav: "pcm_s16le",
aac: "aac"
}
const codec = codecMap[audioFormat] || audioFormat
try {
await exec(`ffmpeg -y -i "${videoFile}" -vn -acodec ${codec} ${PROJECT_STRUCTURE.output}/${outputName}`)
await div(md(`
# Audio Extracted Successfully! 🎵
**Output:** \`${PROJECT_STRUCTURE.output}/${outputName}\`
**Format:** ${audioFormat}
**Codec:** ${codec}
The audio has been extracted from the video.
`))
await revealFile(path.join(projectDir, PROJECT_STRUCTURE.output, outputName))
} catch (error) {
await handleFFmpegError(error as Error, "Audio Extraction")
}
}
// Execute the selected action
switch (action) {
case "create":
await createVideoProject()
break
case "images-to-video":
await imagesToVideo()
break
case "add-audio":
await addAudioToVideo()
break
case "concat":
await concatenateVideos()
break
case "fade":
await addFadeEffects()
break
case "resize":
await resizeVideo()
break
case "extract-audio":
await extractAudio()
break
default:
await div(md("Unknown action selected"))
}