| name | editly |
| description | Programmatic video editing with editly (Node.js + FFmpeg). Use when: user wants to cut, trim, splice, assemble, or edit existing video/image footage with code. Also for slideshows, trailers, social clips, audio mixing, subtitle overlays, and batch video processing. Don't use when: creating motion graphics from scratch (use remotion-best-practices), generating AI video (use veo), or extracting frames (use video-frames). |
| homepage | https://github.com/mifi/editly |
| metadata | {"tags":"editly, video, editing, ffmpeg, declarative, slideshow"} |
editly - Declarative Video Editing
Editly is a Node.js CLI and library for declarative NLE (non-linear video editing). Define edits as JSON5 specs or JS objects. No GUI needed. Streaming-based (no intermediate files, fast, low disk usage).
Platform Compatibility Note
Editly uses headless-gl for GPU-accelerated transitions. This can be tricky to compile on some platforms (notably macOS ARM). If you encounter GL-related errors:
- Disable transitions: Use
transition: null in your specs
- Use built-in transitions only:
directional-left, directional-right, directional-up, directional-down, dummy
- Install with build-from-source:
npm install -g --build-from-source editly
Most other features (video, audio, images, text layers) work without GL.
CLI Usage
editly title:'Intro' clip1.mp4 clip2.mp4 img1.jpg title:'END' \
--audio-file-path music.mp3 --keep-source-audio --fast --out output.mp4
editly spec.json5 --out output.mp4
editly spec.json5 --out output.gif
CLI Options
--out, -o PATH Output path (.mp4, .mkv, .gif)
--fast, -f Low-res/FPS preview mode (use for testing!)
--width WIDTH Output width (default: 640, or first video's width without --fast)
--height HEIGHT Output height (auto from aspect ratio)
--fps FPS Output framerate (default: first video's FPS or 25)
--audio-file-path PATH Background music
--loop-audio Loop audio to match video length
--keep-source-audio Preserve audio from input video clips
--transition-duration SEC Default transition duration (default: 0.5)
--transition-name NAME Default transition type (default: random)
--clip-duration SEC Default clip duration (default: 4)
--font-path PATH Default .ttf font
--output-volume VOL Output volume (0.5 or '10dB')
--allow-remote-requests Allow http/https URLs as input paths
Full Edit Spec Structure
{
outPath: "./output.mp4", // .mp4, .mkv, or .gif
width: 1920, // Default: 640 (or first video width)
height: 1080, // Default: auto from first video aspect ratio
fps: 30, // Default: first video FPS or 25
fast: false, // Preview mode (low res + low FPS)
allowRemoteRequests: false, // Allow URLs as layer paths
// Custom ffmpeg output args (overrides default h264)
customOutputArgs: ["-c:v", "libx264", "-crf", "18", "-preset", "slow"],
// Defaults inherited by all clips/layers
defaults: {
duration: 4, // Seconds per clip (if not specified per-clip)
transition: null, // Transition config or null for hard cuts
layer: {
fontPath: "./font.ttf", // Default font for all text layers
},
layerType: { // Per-type defaults
"fill-color": { color: "#ff6666" },
"image-overlay": { width: 0.1 },
},
},
// THE MAIN CONTENT: array of clips
clips: [
{
duration: 5, // Override default duration for this clip
transition: null, // Override transition for this clip
layers: [ /* layer objects */ ],
}
],
// === AUDIO CONFIG ===
audioFilePath: "./music.mp3", // Background music track
loopAudio: false, // Loop if shorter than video
keepSourceAudio: false, // Keep audio from source video clips
clipsAudioVolume: 1, // Volume of clip audio relative to audioTracks
outputVolume: 1, // Final master volume (number or "10dB")
// Arbitrary additional audio tracks with precise timing
audioTracks: [
{
path: "./voiceover.mp3",
mixVolume: 1, // Relative volume
cutFrom: 0, // Start point in source file (seconds)
cutTo: 30, // End point in source file
start: 5, // When to begin playing in final video (seconds)
}
],
// Audio normalization / auto-ducking
audioNorm: {
enable: false, // Enable to auto-lower music when voice is present
gaussSize: 5, // Smoothing (lower = more responsive)
maxGain: 30, // Max volume boost
},
// Debug
enableFfmpegLog: false,
verbose: false,
}
Layer Types (Complete Reference)
video
Video clip. The workhorse layer.
{
type: "video",
path: "./video.mp4",
cutFrom: 0, // Start time in source (seconds)
cutTo: 10, // End time in source (seconds)
// If clip.duration is set and differs from cutTo-cutFrom, video auto-speeds to fit
// Audio speed limited to 0.5x - 100x
// Positioning (all values 0-1, relative to output dimensions)
width: 1, // 1 = full width
height: 1, // 1 = full height
left: 0, // X position (0 = left edge)
top: 0, // Y position (0 = top edge)
originX: "left", // "left" or "right" (anchor point)
originY: "top", // "top" or "bottom" (anchor point)
resizeMode: "contain-blur", // How to handle aspect ratio mismatch (see Resize Modes)
mixVolume: 1, // Audio volume when mixing
}
Auto-speed: If duration: 3 but cutTo-cutFrom = 6, video plays at 2x speed. If cutTo-cutFrom = 1.5, video plays at 0.5x speed.
image
Static image with optional Ken Burns effect.
{
type: "image",
path: "./photo.jpg",
resizeMode: "contain-blur",
zoomDirection: "in", // "in", "out", "left", "right", or null (no animation)
zoomAmount: 0.1, // Intensity of zoom (default: 0.1)
}
image-overlay
Positioned overlay. Supports PNG/SVG transparency. Great for logos, watermarks, stickers.
{
type: "image-overlay",
path: "./logo.png",
width: 0.2, // Relative to output (0-1)
height: 0.1, // Optional (maintains aspect if omitted)
// Position: shortcut string OR object
position: "top-right",
// Shortcuts: "top", "bottom", "center", "top-left", "top-right",
// "center-left", "center-right", "bottom-left", "bottom-right"
// OR object: { x: 0.95, y: 0.05, originX: "right", originY: "top" }
// Timing within clip
start: 0, // When overlay appears (seconds into clip)
stop: 2, // When overlay disappears
zoomDirection: null, // Optional Ken Burns
}
title
Text overlay, positioned anywhere.
{
type: "title",
text: "My Title",
textColor: "#ffffff",
fontPath: "./font.ttf", // Optional, overrides default
position: "center", // Same position options as image-overlay
zoomDirection: null,
}
title-background
Full-screen title card with background. Use for intros, outros, section dividers.
{
type: "title-background",
text: "Welcome\nMultiline works", // \n for line breaks
textColor: "#ffffff",
fontPath: "./font.ttf",
background: {
type: "fill-color", // or "linear-gradient" or "radial-gradient"
color: "#000000", // for fill-color
// colors: ["#02aab0", "#00cdac"], // for gradients (array of 2+ colors)
}
}
subtitle
Caption text at bottom. Auto-wraps long text.
{
type: "subtitle",
text: "Long caption text that wraps automatically...",
textColor: "#ffffff",
backgroundColor: "rgba(0,0,0,0.5)", // Optional semi-transparent background
fontPath: "./font.ttf",
}
news-title
Breaking news style banner.
{
type: "news-title",
text: "BREAKING NEWS",
textColor: "#ffffff",
backgroundColor: "#d02a42",
position: "top-left",
}
slide-in-text
Text that animates in by sliding.
{
type: "slide-in-text",
text: "Sliding text",
textColor: "#fff",
fontSize: 0.05, // Relative to height
charSpacing: 1,
position: { x: 0.04, y: 0.93, originY: "bottom" },
}
fill-color
Solid color background.
{ type: "fill-color", color: "#ff6666" } // Omit color for random
linear-gradient / radial-gradient
{ type: "linear-gradient", colors: ["#ff0000", "#0000ff"] }
{ type: "radial-gradient", colors: ["#ff0000", "#0000ff"] }
rainbow-colors
Animated cycling rainbow background.
{ type: "rainbow-colors" }
audio
Audio track tied to a clip's timeline.
{
type: "audio",
path: "./sound.mp3",
cutFrom: 0,
cutTo: 10,
mixVolume: 1,
}
detached-audio
Audio with timing relative to clip start (independent of video speed changes). Good for voiceovers.
{
type: "detached-audio",
path: "./voiceover.mp3",
mixVolume: 50,
cutFrom: 2, // Start from 2s into the audio file
start: 1, // Begin 1s after clip starts
}
canvas
Custom HTML5 Canvas rendering via JS function.
function myCanvas({ canvas }) {
return {
async onRender(progress) {
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = `hsl(${progress * 360}, 70%, 50%)`;
ctx.fillRect(0, 0, canvas.width * progress, canvas.height);
},
onClose() {}
};
}
fabric
Custom Fabric.js rendering (richer than canvas: text, shapes, images, filters).
import { registerFont } from "canvas";
registerFont("./font.ttf", { family: "MyFont" });
function myFabric({ width, height, fabric }) {
return {
async onRender(progress, canvas) {
canvas.backgroundColor = "hsl(33, 100%, 50%)";
const text = new fabric.FabricText(`${Math.floor(progress * 100)}%`, {
left: width / 2, top: height / 2,
originX: "center", originY: "center",
fontSize: 60, fontFamily: "MyFont", fill: "white",
});
canvas.add(text);
},
onClose() {}
};
}
fabricImagePostProcessing
Not a layer type, but a property on video/image layers. Applies Fabric.js effects.
{
type: "video",
path: "./video.mp4",
fabricImagePostProcessing: async ({ image, fabric, canvas }) => {
// Example: circular mask
const circle = new fabric.Circle({
radius: Math.min(image.width, image.height) * 0.4,
originX: "center", originY: "center",
});
image.set({ clipPath: circle });
}
}
gl
Custom GLSL fragment shaders. Requires working headless-gl installation.
{
type: "gl",
fragmentPath: "./shader.frag",
vertexPath: "./vertex.vert", // Optional
speed: 1,
}
// Uniforms provided: uniform float time; uniform vec2 resolution;
Resize Modes
| Mode | Behavior | Best For |
|---|
contain | Fit entirely, black letterbox bars | Preserving full content |
contain-blur | Fit entirely, blurred copy fills bars | Best default, avoids ugly black bars |
cover | Fill screen, crop edges as needed | Full-bleed, when cropping is OK |
stretch | Distort to fit dimensions | Almost never use this |
Transitions
Editly supports gl-transitions (~90 types). Note: GL transitions require headless-gl to compile correctly on your platform.
Popular GL transitions: fade, fadecolor, fadegrayscale, crosszoom, simplezoom, dreamyzoom, directionalwipe, directionalwarp, circle, circleopen, circlecrop, radial, swap, doorway, cube, wind, ripple, perlin, morph, glitchdisplace
Built-in (no GL needed): directional-left, directional-right, directional-up, directional-down, random, dummy
// Per-clip transition
{ transition: { name: "fade", duration: 0.8 } }
// Disable transition for one clip
{ transition: null }
// Audio-only crossfade (video cuts instantly, audio fades)
{ transition: { name: "dummy", duration: 0.5, audioOutCurve: "tri", audioInCurve: "tri" } }
Audio crossfade curves: tri, exp, log, ipar, hann (see FFmpeg afade docs)
Audio Best Practices
Auto-ducking (lower music when voice plays)
{
keepSourceAudio: true,
clipsAudioVolume: 50, // High value = voice "wins"
audioTracks: [
{ path: "./music.mp3", mixVolume: 1 },
{ path: "./voiceover.mp3", mixVolume: 50 },
],
audioNorm: { enable: true, gaussSize: 3, maxGain: 100 },
}
Multi-track with precise timing
{
audioTracks: [
{ path: "./bg-music.mp3", mixVolume: 0.3, cutFrom: 30 },
{ path: "./voiceover.mp3", mixVolume: 1.0, start: 5 },
{ path: "./sfx-ding.mp3", mixVolume: 0.8, start: 10, cutFrom: 0, cutTo: 2 },
]
}
Looping short music
{ audioFilePath: "./short-loop.mp3", loopAudio: true }
Platform Presets
| Platform | Width | Height | Aspect | FPS |
|---|
| YouTube | 1920 | 1080 | 16:9 | 30 |
| YouTube 4K | 3840 | 2160 | 16:9 | 60 |
| Instagram Feed | 1080 | 1080 | 1:1 | 30 |
| Instagram Story/Reels/TikTok | 1080 | 1920 | 9:16 | 30 |
| Instagram Portrait | 1080 | 1350 | 4:5 | 30 |
| Twitter/X | 1280 | 720 | 16:9 | 30 |
| LinkedIn | 1080 | 1080 | 1:1 | 30 |
Complete Templates
Slideshow with Ken Burns + Music
{
outPath: "./slideshow.mp4",
width: 1920, height: 1080, fps: 30,
audioFilePath: "./music.mp3",
loopAudio: true,
defaults: { duration: 4, transition: null },
clips: [
{ layers: [{ type: "image", path: "./img1.jpg", zoomDirection: "in" }] },
{ layers: [{ type: "image", path: "./img2.jpg", zoomDirection: "out" }] },
{ layers: [{ type: "image", path: "./img3.jpg", zoomDirection: "left" }] },
]
}
Video Trailer (fast cuts + titles)
{
outPath: "./trailer.mp4",
width: 1920, height: 1080, fps: 30,
defaults: { transition: null },
clips: [
{
duration: 2,
layers: [{ type: "title-background", text: "COMING SOON", background: { type: "radial-gradient", colors: ["#1a1a2e", "#16213e"] } }]
},
{
duration: 1.5,
layers: [
{ type: "video", path: "./clip1.mp4", cutFrom: 5, cutTo: 6.5 },
{ type: "title", text: "FEATURE 1", position: "bottom" }
]
},
{
duration: 1.5,
layers: [
{ type: "video", path: "./clip2.mp4", cutFrom: 12, cutTo: 13.5 },
{ type: "title", text: "FEATURE 2", position: "bottom" }
]
},
{
duration: 3,
layers: [{ type: "title-background", text: "AVAILABLE NOW", background: { type: "fill-color", color: "#000" } }]
}
],
audioFilePath: "./trailer-music.mp3"
}
Tutorial (screen recording + subtitles + sections)
{
outPath: "./tutorial.mp4",
width: 1920, height: 1080,
keepSourceAudio: true,
defaults: { transition: null, layer: { fontPath: "./mono-font.ttf" } },
clips: [
{
duration: 5,
layers: [{ type: "title-background", text: "How to Use X\nStep-by-step", background: { type: "fill-color", color: "#1a1a2e" } }]
},
{
layers: [
{ type: "video", path: "./screen-recording.mp4", cutFrom: 0, cutTo: 30 },
{ type: "subtitle", text: "First, open the app and go to settings.", backgroundColor: "rgba(0,0,0,0.6)" }
]
},
{
duration: 3,
layers: [
{ type: "fill-color", color: "#333" },
{ type: "title", text: "Key Takeaway", position: "center" },
{ type: "slide-in-text", text: "Always save your work!", position: { x: 0.5, y: 0.7, originX: "center" } }
]
}
]
}
Social Clip (vertical, Instagram/TikTok)
{
outPath: "./reel.mp4",
width: 1080, height: 1920, fps: 30,
audioFilePath: "./trending-audio.mp3",
defaults: { transition: null },
clips: [
{
duration: 2,
layers: [
{ type: "video", path: "./clip1.mp4", resizeMode: "cover" },
{ type: "title", text: "PART 1", position: "top" }
]
},
{
duration: 2,
layers: [
{ type: "video", path: "./clip2.mp4", resizeMode: "cover" },
{ type: "title", text: "PART 2", position: "top" }
]
},
{
duration: 3,
layers: [
{ type: "title-background", text: "Follow for more!", background: { type: "linear-gradient", colors: ["#667eea", "#764ba2"] } }
]
}
]
}
Picture-in-Picture (webcam over screen recording)
{
outPath: "./pip.mp4",
width: 1920, height: 1080,
keepSourceAudio: true,
defaults: { transition: null },
clips: [
{
layers: [
{ type: "video", path: "./screen-recording.mp4" },
{
type: "video", path: "./webcam.mp4",
resizeMode: "cover",
width: 0.25, height: 0.25,
left: 0.97, top: 0.03,
originX: "right", originY: "top"
}
]
}
]
}
Podcast (square, with cover art + audio)
{
outPath: "./podcast-clip.mp4",
width: 1080, height: 1080, fps: 30,
defaults: { transition: null },
clips: [
{
duration: 300,
layers: [
{ type: "image", path: "./podcast-cover.jpg" },
{ type: "subtitle", text: "Episode 42: The Big Topic", backgroundColor: "rgba(0,0,0,0.7)" }
]
}
],
audioTracks: [{ path: "./podcast-audio.mp3", mixVolume: 1, cutFrom: 120, cutTo: 420 }]
}
B-Roll Insert (main video with cutaway)
{
clips: [
{
duration: 10,
layers: [
{ type: "video", path: "./interview.mp4", cutFrom: 0, cutTo: 10 },
// B-roll appears from 3-6 seconds into this clip
{
type: "video", path: "./broll.mp4",
cutFrom: 0, cutTo: 3,
width: 1, height: 1,
// Use start/stop to control when the overlay appears
}
]
}
]
}
Remotion + Editly Pipeline
// 1. Render Remotion compositions to MP4 first:
// npx remotion render src/index.ts Intro out/intro.mp4
// npx remotion render src/index.ts LowerThird out/lower-third.mp4
// npx remotion render src/index.ts Outro out/outro.mp4
// 2. Assemble with editly:
{
outPath: "./final.mp4",
width: 1920, height: 1080,
keepSourceAudio: true,
defaults: { transition: null },
clips: [
{ layers: [{ type: "video", path: "./out/intro.mp4" }] },
{
layers: [
{ type: "video", path: "./main-content.mp4" },
{ type: "image-overlay", path: "./logo.png", width: 0.1, position: "top-right" }
]
},
{ layers: [{ type: "video", path: "./out/outro.mp4" }] }
],
audioFilePath: "./bg-music.mp3",
loopAudio: true,
clipsAudioVolume: 0.8
}
Key Behaviors to Remember
- Without --fast: Uses first video's actual width/height/fps. With --fast: uses reduced resolution.
- Duration vs cutFrom/cutTo:
duration = how long the clip appears in output. cutFrom/cutTo = which segment of source to use. If they differ, video speed adjusts automatically.
- Layer order matters: First layer is the background. Later layers render on top.
- Clips with no duration: If a clip has a video layer and no explicit duration, it uses the video's natural length (or cutTo-cutFrom).
- Transition overlap: A transition eats time from both clips. Transition duration can't exceed half of either clip's duration.
- Font handling: Set
defaults.layer.fontPath for project-wide font, or per-layer with fontPath.
- Remote URLs: Set
allowRemoteRequests: true to use http/https URLs as layer paths.
- GIF output: Just set
outPath: "./output.gif". Editly handles conversion.
- WebP output:
outPath: "./output.webp" with customOutputArgs: ["-compression_level", "5", "-qscale", "60", "-vcodec", "libwebp"]
Troubleshooting
- No audio: Set
keepSourceAudio: true for video audio, or audioFilePath for music
- Audio out of sync: Some source videos have mismatched audio/video track lengths. Check with
mediainfo
- Aspect ratio wrong: Set explicit
width/height and use resizeMode: "contain-blur"
- Slow render: Use
--fast for testing, remove for final
- GL/transition error: headless-gl may not compile on your platform. Use
transition: null or built-in transitions
- Video too fast/slow: Check if
duration conflicts with cutFrom/cutTo range
- No module found: Try
npm uninstall -g editly && npm install -g --build-from-source editly