kutlass
v0.2.0
Published
A fully client-side browser video editor React component
Downloads
381
Readme
Kutlass
A fully client-side browser video editor React component. All video processing — decoding, effects, cropping, annotations, and encoding — runs entirely in the browser using WebCodecs and FFmpeg WASM. No server required.
Features
- Trim, crop, and resize video
- Playback speed from 0.25× to 4×
- Per-clip audio: volume, mute, fade in, fade out
- Export presets (Reel/TikTok, Story, Square, YouTube, X, WebM) that set format, resolution, frame rate and crop in one click
- Brightness, contrast, saturation, rotation, and opacity adjustments
- Filter presets (Vivid, Warm, Cool, B&W, Fade, Dramatic, Film, Matte)
- Freehand annotations (pen and eraser)
- Text and sticker overlays
- Multiple clips concatenated into a single export
- Undo/redo with full history
- Zoom and pan preview
- Export to MP4 or WebM — hardware-accelerated via WebCodecs, with an FFmpeg WASM fallback
- Drag-and-drop video import
- Light and dark themes
- Customizable accent color and full color token override
- Imperative ref API and lifecycle callbacks
- Multiple independent editors on one page
Installation
bun add kutlass
# or: npm install kutlassPeer dependencies
bun add react react-dom framer-motion @ffmpeg/ffmpeg @ffmpeg/core @ffmpeg/util
# or: npm install react react-dom framer-motion @ffmpeg/ffmpeg @ffmpeg/core @ffmpeg/utilQuick start
import { Kutlass, setFFmpegPaths } from "kutlass";
import "kutlass/styles.css";
setFFmpegPaths({
coreJS: "/ffmpeg/ffmpeg-core.js",
coreWasm: "/ffmpeg/ffmpeg-core.wasm",
});
function App() {
return (
<Kutlass
style={{ width: 960, height: 640 }}
onExportComplete={(blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "video.mp4";
a.click();
}}
/>
);
}Theming
Kutlass ships with light and dark themes. Set the theme prop to switch:
<Kutlass theme="light" />
<Kutlass theme="dark" /> {/* default */}Accent color
Pass a hex color to accent to change the primary color used for buttons, selection highlights, trim handles, and active states. Hover, subtle, and border variants are derived automatically:
<Kutlass accent="#8b5cf6" />
<Kutlass theme="light" accent="#3b82f6" />Custom color tokens
For full control, use the colors prop to override any individual CSS variable. Keys are token names without the --kt- prefix:
<Kutlass
theme="dark"
accent="#3b82f6"
colors={{
"bg-base": "#0f172a",
"bg-panel": "#1e293b",
"text-primary": "#e2e8f0",
}}
/>Available tokens
| Token | Description |
|---|---|
| bg-base | Main editor background |
| bg-panel | Bottom panel background |
| bg-surface | Secondary surfaces (timeline, playback bar) |
| bg-deep | Deepest background |
| bg-preview | Video preview area |
| bg-overlay | Modal/export overlay |
| bg-subtle | Subtle interactive background |
| bg-subtle-hover | Subtle hover state |
| border | Primary borders |
| border-strong | Heavier borders |
| text-primary | Primary text |
| text-secondary | Secondary text |
| text-tertiary | Tertiary text |
| text-muted | Muted text |
| text-faint | Faintest text |
| accent | Primary accent color |
| accent-hover | Accent hover state |
| accent-text | Text on accent backgrounds |
| accent-subtle-bg | Accent at low opacity (chip backgrounds) |
| accent-subtle-border | Accent border at low opacity |
| accent-strong-border | Accent border at high opacity |
| accent-play | Play button color |
| accent-play-hover | Play button hover |
| accent-play-bar | Progress bar fill |
| slider-track | Slider track background |
| slider-fill | Slider fill color |
| slider-thumb | Slider thumb color |
| success | Success state color |
| danger | Danger/delete color |
You can also override tokens directly in CSS by targeting the data-kt-theme attribute:
[data-kt-theme="dark"] {
--kt-accent: #3b82f6;
--kt-bg-base: #0f172a;
}FFmpeg WASM setup
This is optional. Kutlass exports with WebCodecs — the browser's own
hardware-accelerated encoder — and only falls back to FFmpeg WASM when
WebCodecs cannot handle the source (an exotic codec, or a browser without
VideoEncoder). Set this up if you want that safety net; skip it and those
exports will fail with an error instead of falling back.
- Copy the WASM files to your public directory. They ship in
@ffmpeg/core, which is already a peer dependency:
mkdir -p public/ffmpeg
cp node_modules/@ffmpeg/core/dist/umd/ffmpeg-core.js public/ffmpeg/
cp node_modules/@ffmpeg/core/dist/umd/ffmpeg-core.wasm public/ffmpeg/- Set the required cross-origin headers. FFmpeg WASM requires
SharedArrayBuffer, which needs these response headers on every page that loads the editor:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: cross-originHow you set these depends on your setup:
Next.js (next.config.ts):
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
{ key: "Cross-Origin-Resource-Policy", value: "cross-origin" },
],
},
];
},
};Vite (vite.config.ts):
export default defineConfig({
server: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Resource-Policy": "cross-origin",
},
},
});Nginx:
add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;
add_header Cross-Origin-Resource-Policy cross-origin;Vercel (vercel.json):
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" },
{ "key": "Cross-Origin-Resource-Policy", "value": "cross-origin" }
]
}
]
}Netlify (_headers):
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: cross-originStatic hosting without header support (GitHub Pages, etc.) — use coi-serviceworker to inject the headers client-side via a service worker. Install it with bun add coi-serviceworker, copy the script to your public directory, and add it before any other scripts:
<script src="coi-serviceworker.js"></script>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| theme | "light" \| "dark" | "dark" | Color theme |
| accent | string | — | Primary accent color (hex). Derives hover, subtle, and border variants. |
| colors | KutlassColors | — | Override individual color tokens (keys without --kt- prefix) |
| className | string | — | CSS class for the outer container |
| style | CSSProperties | — | Inline styles for the outer container |
| tools | Tool[] | all | Which tools to show, in order (trim, crop, finetune, filter, speed, audio, annotate, sticker, resize) |
| exportSettings | Partial<ExportSettings> | — | Default export settings (format, resolution, fps, bitrate). Only the keys you pass are pinned; the rest still match the imported source. |
| ffmpegPaths | Partial<FFmpegPaths> | — | Override FFmpeg WASM file URLs |
| onReady | (handle: KutlassHandle) => void | — | Called on mount with the imperative handle |
| onImport | (clips: Clip[]) => void | — | Called after clips are imported |
| onEditChange | (state: KutlassState) => void | — | Called whenever the edit changes |
| onExportProgress | (percent: number) => void | — | Called with 0-100 while exporting |
| onExportError | (error: Error) => void | — | Called if an export fails (not on cancel) |
| onExportComplete | (blob: Blob) => void | — | Called with the exported video blob when export finishes |
API
Imperative handle
Pass a ref to drive the editor from your own UI:
import { useRef } from "react";
import { Kutlass, type KutlassHandle } from "kutlass";
function App() {
const editor = useRef<KutlassHandle>(null);
return (
<>
<button onClick={() => editor.current?.export()}>Export</button>
<button onClick={() => editor.current?.play()}>Play</button>
<Kutlass ref={editor} />
</>
);
}| Method | Description |
| --- | --- |
| export() | Start an export. Resolves when it finishes, fails, or is cancelled. |
| cancelExport() | Cancel an in-progress export. |
| import(files) | Append video files to the timeline. |
| replace(files) | Clear the timeline and import these instead. |
| play() / pause() | Playback control. |
| seek(time) | Move the playhead, in seconds. |
| undo() / redo() | History. |
| clear() | Remove all clips, overlays and annotations. |
| getState() | Current clips, duration, playhead, export status, undo/redo availability. |
| getStore() | Escape hatch to the instance's zustand store. |
Callbacks
<Kutlass
onReady={(handle) => {}}
onImport={(clips) => {}}
onEditChange={(state) => {}}
onExportProgress={(percent) => {}}
onExportError={(error) => {}}
onExportComplete={(blob) => {}}
/>onEditChange fires whenever clips, effects, audio settings, overlays or
annotations change — useful for autosaving an edit.
Multiple editors
Each <Kutlass /> owns its own state, so you can render more than one on a
page:
<Kutlass style={{ height: 480 }} />
<Kutlass style={{ height: 480 }} />setEnginePreference(pref)
Choose which export engine runs. Defaults to "auto".
import { setEnginePreference } from "kutlass";
setEnginePreference("auto"); // WebCodecs, falling back to FFmpeg (default)
setEnginePreference("browser"); // prefer WebCodecs
setEnginePreference("ffmpeg"); // always FFmpeg WASM"auto" and "browser" both fall back to FFmpeg if the source turns out to be
undecodable mid-export, so "browser" is a preference rather than a hard
requirement. "ffmpeg" skips WebCodecs entirely — slower, but useful for
reproducing output across machines.
Presets
The preset tables the editor's own panels use are exported, so you can build your own UI on the same data.
import {
EXPORT_PRESETS, // Reel/TikTok, Story, Square, YouTube, X, WebM…
cropForAspect, // centre-crop maths for a target aspect ratio
FILTER_PRESETS, // Vivid, Warm, Cool, B&W, Fade, Dramatic, Film, Matte
activeFilterPreset, // which filter preset a set of effects matches
CROP_ASPECT_PRESETS,// 16:9, 9:16, 4:3, 3:4, 1:1, free
} from "kutlass";
// Apply the "Reel" preset to a 1080p clip
const reel = EXPORT_PRESETS.find((p) => p.id === "reel")!;
const crop = cropForAspect(1920, 1080, reel.aspect);
// -> { cropX: 0.342, cropY: 0, cropW: 0.316, cropH: 1 }cropForAspect returns normalised 0-1 crop values, or null when no crop is
needed. EXPORT_PRESETS entries carry { id, label, hint, aspect, settings };
an aspect of 0 means "leave the crop alone".
setFFmpegPaths(paths)
Configure where the FFmpeg WASM files are served from. Must be called before the first export.
import { setFFmpegPaths } from "kutlass";
setFFmpegPaths({
coreJS: "/vendor/ffmpeg-core.js",
coreWasm: "/vendor/ffmpeg-core.wasm",
});License
MIT
