Clip Forge — Branded Video Builder
Build videos from a declarative JSON scene list — captions, photos, cutouts, and video clips with crop/trim/zoom and optional custom stat overlays — then export a deterministic MP4. Entirely in your browser: nothing is ever uploaded to a server. Requires the File System Access API and WebCodecs (Chrome or Edge) to open a project folder and export video — the page itself works everywhere.
A template, not an editor
Clip Forge isn't a general-purpose video editor — there's no draggable timeline, no manual keyframing, no frame-by-frame trimming by hand. It exists for a video format you make repeatedly: describe the scene sequence once as JSON, then produce each new video by swapping the text, media, and stats in that same file, and export a deterministic result every time.
Picked once via the OS folder picker and remembered across visits. Each immediate subfolder holding a config.jsonshows up as a project in the sidebar — "Save" creates that subfolder for you the first time.
A separate folder for photos/videos, shared by every project — reference a file by its relative path from a scene's src, clip, or backdrop prop (e.g. "clip.mp4"). "+ Add media…" copies a picked file straight in without leaving the browser. Browser permissions reset every session, so a remembered assets folder needs one click on "Reconnect assets folder…" in the sidebar before it's used again — until then, a path is looked up in the project's own folder instead, with no error shown.
Because every project draws from the same shared assets folder, "+ Insert project…" can copy another project's entire scene list onto the end of this one — no re-uploading. This is the actual repeat-a-format mechanism.
The on-screen @handle chrome and default safe-area insets were originally built around Instagram Reels' own UI, so captions and stats stay clear of its title bar and caption/reply chrome by default — override any inset via meta.safeArea, or turn the chrome off with meta.showHandle: false.
One JSON file, four scene types
A project is a single config.json — an ordered list of scenes, each with a component, a duration, and its own props. Edit the JSON, watch the preview update live.
Background-only scene with a kicker + big line of text. Hooks, statements, plain text beats.
Full-bleed photo with a slow Ken Burns zoom/pan and an optional caption on top.
A background-removed subject floating over an accent-colored glow.
Any video clip in a band, with an optional blurred backdrop still behind it, optional custom stat rows (any icon/label/value), and an optional stopwatch timer.
Deterministic
Every frame rendered standalone — a slow machine only takes longer, never drops a frame
1080 × 1920
Default canvas size (vertical) — configurable per project to any aspect ratio
MP4 / H.264
Encoded client-side via WebCodecs, muxed with mediabunny
30 fps
Fixed export frame rate
Every field in config.json
Canvas resolution, branding, and the scene list all live in one JSON file, edited live in the tool's built-in editor. Everything below is optional except scenes, component, and dur — anything you skip falls back to a sensible default.
{
"name": "My Video", // optional — display name, defaults to "untitled"
"type": "video", // optional — label only, shown in the project list
"width": 1080, // optional — canvas width in px, defaults to 1080
"height": 1920, // optional — canvas height in px, defaults to 1920
"meta": { // optional — branding + look (all fields optional)
"handle": "@yourhandle", // shown in the on-screen handle chrome
"accent": "#7d6fd0", // accent color used across chrome, progress rule, and glow
"bg": "#161826", // default scene background — every component below can override it
"statSize": 42, // default ClipScene stat value font size in px — each stat item can override it
"showHandle": true, // show/hide the @handle + video-number chrome, default true
"grain": true, // film-grain overlay, default true
"showGuides": false, // safe-area guide overlays, default false
"videoNumber": 1, // number shown next to the handle
"safeArea": { // optional — override any subset of the default safe-area insets (px)
"left": 66,
"right": 200,
"top": 250,
"bottom": 300
}
},
"scenes": [ // required — ordered list of scenes, at least one
{
"component": "Caption", // required — Caption | Photo | Cutout | ClipScene
"dur": 3, // required — seconds this scene is on screen
"props": {
"kicker": "kicker", // optional — small label above the line
"line": "Your line here", // optional — main text
"bg": "#1a1030" // optional — overrides meta.bg for just this scene; every component below accepts this too
}
},
{
"component": "Photo",
"dur": 4,
"props": {
"src": "assets/photo.jpg", // required — path to the image inside the assets folder
"from": 1.2, // optional — starting zoom scale, default 1.2
"to": 1.05, // optional — ending zoom scale, default 1.05
"panFrom": 0, // optional — starting vertical pan in px, default 0
"panTo": 0, // optional — ending vertical pan in px, default 0
"dim": 0, // optional — darken amount 0-1, default 0
"kicker": "kicker", // optional
"line": "Caption over photo" // optional
}
},
{
"component": "Cutout",
"dur": 4,
"props": {
"src": "assets/cutout.png", // required — background-removed subject image
"width": 872, // optional — rendered width in px, default 872
"top": 110, // optional — top offset in px, default 110
"glowTop": 690, // optional — vertical center of the accent glow in px, default 690
"kicker": "kicker", // optional
"line": "Caption over cutout" // optional
}
},
{
"component": "ClipScene",
"dur": 6,
"props": {
"clip": "assets/clip.mp4", // required — the video clip
"backdrop": "assets/still.jpg", // optional — blurred still shown behind the video band
"bandTop": 496, // optional — video band top offset in px, default 496
"bandHeight": 640, // optional — video band height in px, default 640
"clipStart": 0, // optional — trim: start second in the source file, default 0
"clipEnd": 120, // optional — trim: end second in the source file
"cropLeft": 0, // optional — crop % off the left edge, default 0
"cropRight": 0, // optional — crop % off the right edge, default 0
"cropTop": 0, // optional — crop % off the top edge, default 0
"cropBottom": 0, // optional — crop % off the bottom edge, default 0
"zoom": 8, // optional — zoom-in amount %, default 0 (off)
"zoomDuration": 6, // optional — seconds to ramp the zoom in (Ken Burns); omit for a static zoom
"showTimer": true, // optional — show the stopwatch-style timer counting up to timeTaken, default false
"timeTaken": "3h 40m", // optional — the timer's target duration ("3h 40m", "45m", "1:02:03" all parse)
"stats": [ // optional — arbitrary icon/label/value rows, nothing is a fixed field
{ "icon": "printer", "label": "Printer", "value": "Bambu X1C" },
{ "icon": "filament", "label": "Material", "value": "PLA" },
{ "icon": "clock", "label": "Time", "value": "3h 40m", "newLine": true, "size": 32 },
{ "icon": "scale", "label": "Weight", "value": "42g", "size": 32, "color": "#9184d9" }
]
}
}
]
}The // comments above are for reference only, since config.json must be valid JSON — the copy button strips them automatically, so what lands on your clipboard is ready to paste in as-is.
A stats item's icon is optional and picks from a small hand-drawn set — printer, filament, clock, scale, tag, flag, star, camera — no icon font or CDN, so it costs nothing extra to load. label and value are free text; items pack onto the same line until one sets newLine: true, which starts a fresh row. size (px, scales the icon and label with it) and color are both optional per item — size falls back to meta.statSize (itself defaulting to 42) when unset.
Common questions
Have feedback or found a bug?
Let us know — we read every message.