Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
886641b
feat(captions): add AI caption generation with timeline insertion
walterlow Apr 17, 2026
a04cf7b
fix: prefer Player path for generated caption scrubs
walterlow Apr 17, 2026
a3d3c18
fix: sync preview timecode and playhead during scrubbing
walterlow Apr 17, 2026
d8124f8
Match source monitor scrubbing to timeline playback
walterlow Apr 17, 2026
e1e16ba
fix: speed up media skimming and preserve program preview
walterlow Apr 17, 2026
4ae03e6
fix: unify ruler scrub handoff to avoid preview flashes
walterlow Apr 17, 2026
1502fc6
fix: lower skim overlay below popovers
walterlow Apr 17, 2026
899965f
fix: keep AI analysis off timeline caption tracks
walterlow Apr 17, 2026
879f2c6
fix: route preview scrub throttle through contract
walterlow Apr 17, 2026
f641614
fix: route analysis imports through infrastructure facade
walterlow Apr 17, 2026
8258ae1
fix: anchor timeline slider zoom at the playhead
walterlow Apr 17, 2026
b07f660
feat: add AI analysis indicators and align proxy colors
walterlow Apr 17, 2026
472e272
fix: transcribe custom-decoded audio from conformed wav
walterlow Apr 17, 2026
659d462
fix: queue and unify transcription progress flows
walterlow Apr 18, 2026
934c91c
fix: move preview and filmstrip caches into workspace fs
walterlow Apr 18, 2026
f6bfd15
Add transcription dialog control and playback-aware pausing
walterlow Apr 18, 2026
d501992
fix: route timeline transcribe dialog through media contract
walterlow Apr 18, 2026
143b2f6
fix: clear lint blockers before push
walterlow Apr 18, 2026
f2e2415
docs: refresh README for workspace-fs and GPU transitions
walterlow Apr 18, 2026
10aaddd
Persist editor timeline layout and improve batch feedback
walterlow Apr 18, 2026
e8ba24d
Promote skim frame on edit start
walterlow Apr 18, 2026
2768f9d
Optimize slip-slide gesture preview updates
walterlow Apr 18, 2026
5a54e21
fix post-edit preview warm runway
walterlow Apr 18, 2026
26fce03
Preserve masked preview overlays during renderer rebuilds
walterlow Apr 18, 2026
2fe403f
Fix stale preview adjustment effects on committed updates
walterlow Apr 18, 2026
8c34578
Add scene browser hotkey and AI caption indexing
walterlow Apr 18, 2026
f47c67b
Fix scene caption parsing and indexing
walterlow Apr 18, 2026
7e162a6
fix: use palette-only ranking for color queries
walterlow Apr 18, 2026
9bb2e2d
fix(scene-browser): remove motion semantic search, tighten color queries
walterlow Apr 18, 2026
b935bc6
(fix): refine color search intent
walterlow Apr 18, 2026
5418fa5
feat(scene-browser): find scenes with a similar palette
walterlow Apr 18, 2026
b94d8dd
feat(scene-browser): add color mode with library palette grid
walterlow Apr 19, 2026
6b19471
feat(scene-browser): grid view, responsive header, analyze service
walterlow Apr 19, 2026
fb6d9c0
feat(scene-browser): grid view, responsive header, analyze service
walterlow Apr 19, 2026
c2577c3
(fix): stage captions safely and reset semantic index
walterlow Apr 19, 2026
8df382d
fix(media-library): hydrate proxies from workspace on fresh origins
walterlow Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 27 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

![FreeCut Timeline Editor](./public/assets/landing/timeline.png)

FreeCut is a browser-based multi-track video editor. No installation, no uploads — everything runs locally in your browser using WebGPU, WebCodecs, OPFS, and the File System Access API.
FreeCut is a browser-based multi-track video editor. No installation, no uploads — everything runs locally in your browser using WebGPU, WebCodecs, and the File System Access API. Projects, media metadata, thumbnails, waveforms, and transcripts are written as plain files to a workspace folder you pick on disk.

## Features

Expand Down Expand Up @@ -45,8 +45,10 @@ Layer masks with keyframeable geometry transforms for compositing and selective

### Transitions

- **CPU transitions** — fade, wipe, slide, 3D flip, clock wipe, iris — each with directional variants
- **GPU transitions** — dissolve, sparkles, glitch, light leak, pixelate, chromatic aberration, radial blur
All transitions are WebGPU-accelerated with a Canvas 2D fallback for non-WebGPU environments.

- Fade, wipe, slide, 3D flip, clock wipe, iris — each with directional variants
- Dissolve, sparkles, glitch, light leak, pixelate, chromatic aberration, radial blur
- Adjustable duration and alignment

### Keyframe Animation
Expand Down Expand Up @@ -78,7 +80,7 @@ Layer masks with keyframeable geometry transforms for compositing and selective
- **Audio:** MP3, WAV, AAC, OGG, Opus
- **Image:** JPG, PNG, GIF (animated), WebP
- Up to 5 GB per file
- OPFS proxy video generation for smooth preview
- Proxy video generation for smooth preview (cached to the workspace folder)
- Media relinking for moved or deleted files
- Scene detection and optical flow analysis

Expand All @@ -89,19 +91,24 @@ Layer masks with keyframeable geometry transforms for compositing and selective
- Auto-generate caption text items from transcripts
- Multi-language support

### Text-to-Speech

- In-browser voiceover generation via KittenTTS (WebGPU)
- Adds the generated audio clip directly to the timeline

### Other

- Native SVG shapes — rectangle, circle, triangle, ellipse, star, polygon, heart
- Text overlays with custom fonts, colors, and positioning
- Project bundles — export/import projects as ZIP files with Zod-validated schemas
- IndexedDB persistence with content-addressable storage
- Workspace folder persistence via File System Access API — your projects live as plain files on disk, not locked away in browser storage
- Auto-save
- Customizable keyboard shortcuts with preset import/export
- Configurable settings (default FPS, snap, waveforms, filmstrips, preview quality, export defaults, undo depth, auto-save interval)

## Quick Start

**Prerequisites:** Node.js 18+
**Prerequisites:** Node.js 20+

```bash
git clone https://github.com/walterlow/freecut.git
Expand All @@ -114,12 +121,13 @@ Open [http://localhost:5173](http://localhost:5173) in Chrome.

### Workflow

1. Create a project from the projects page
2. Import media by dragging files into the media library
3. Drag clips to the timeline — trim, arrange, add effects and transitions
4. Animate with the keyframe editor
5. Preview your edit in real time
6. Export directly from the browser
1. Pick a workspace folder when prompted — FreeCut writes all projects, media metadata, and caches into this folder
2. Create a project from the projects page
3. Import media by dragging files into the media library
4. Drag clips to the timeline — trim, arrange, add effects and transitions
5. Animate with the keyframe editor
6. Preview your edit in real time
7. Export directly from the browser

## Browser Support

Expand Down Expand Up @@ -184,7 +192,9 @@ Brave disables the File System Access API by default. To enable it:
- [Tailwind CSS 4](https://tailwindcss.com/) + [shadcn/ui](https://ui.shadcn.com/) — styling and UI components
- [Mediabunny](https://mediabunny.dev/) — media decoding and metadata extraction
- [WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API) — composition rendering and export
- [OPFS](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) + [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) — local persistence
- [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API) — workspace folder persistence
- [Transformers.js](https://huggingface.co/docs/transformers.js) — in-browser Whisper transcription
- [KittenTTS](https://github.com/KittenML/kitten-tts-webgpu) — WebGPU text-to-speech
- Web Workers — heavy processing off the main thread

## Development
Expand Down Expand Up @@ -235,7 +245,7 @@ src/
| |- animation/ # Easing functions and interpolation
| |- projects/ # Project domain types
| \- timeline/ # Transitions (engine, registry, renderers)
|- infrastructure/ # Browser/storage/GPU adapters
|- infrastructure/ # Browser/storage/GPU adapters (workspace-fs, handles-db, gpu facades)
|- lib/
| |- gpu-effects/ # WebGPU effect pipeline + shader definitions
| |- gpu-transitions/ # WebGPU transition pipeline + shaders
Expand All @@ -256,10 +266,11 @@ src/
| |- export/ # WebCodecs export pipeline (Web Worker)
| |- effects/ # GPU effect system and UI panels
| |- keyframes/ # Keyframe animation, Bezier editor, easing
| |- media-library/ # Media import, metadata, OPFS proxies, transcription
| |- media-library/ # Media import, metadata, proxy cache, transcription, TTS
| |- project-bundle/ # Project ZIP export/import
| |- projects/ # Project management
| \- settings/ # App settings, keyboard shortcut editor
| |- settings/ # App settings, keyboard shortcut editor
| \- workspace-gate/ # Workspace folder picker / permission gate
|- shared/ # Shared UI/state/utilities across layers
|- components/ui/ # shadcn/ui components
|- config/hotkeys.ts # Keyboard shortcut definitions
Expand Down
7 changes: 7 additions & 0 deletions src/app/state/editor/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const useEditorStore = create<EditorState & EditorActions>((set) => ({
mediaSkimPreviewFrame: null,
compoundClipSkimPreviewCompositionId: null,
compoundClipSkimPreviewFrame: null,
transcriptionDialogDepth: 0,
sourcePatchVideoEnabled: true,
sourcePatchAudioEnabled: true,
sourcePatchVideoTrackId: null,
Expand Down Expand Up @@ -179,6 +180,12 @@ export const useEditorStore = create<EditorState & EditorActions>((set) => ({
compoundClipSkimPreviewFrame: null,
};
}),
beginTranscriptionDialog: () => set((state) => ({
transcriptionDialogDepth: state.transcriptionDialogDepth + 1,
})),
endTranscriptionDialog: () => set((state) => ({
transcriptionDialogDepth: Math.max(0, state.transcriptionDialogDepth - 1),
})),
setSourcePatchVideoEnabled: (enabled) => set({ sourcePatchVideoEnabled: enabled }),
setSourcePatchAudioEnabled: (enabled) => set({ sourcePatchAudioEnabled: enabled }),
setSourcePatchVideoTrackId: (trackId) => set({ sourcePatchVideoTrackId: trackId }),
Expand Down
3 changes: 3 additions & 0 deletions src/app/state/editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface EditorState {
mediaSkimPreviewFrame: number | null;
compoundClipSkimPreviewCompositionId: string | null;
compoundClipSkimPreviewFrame: number | null;
transcriptionDialogDepth: number;
sourcePatchVideoEnabled: boolean;
sourcePatchAudioEnabled: boolean;
sourcePatchVideoTrackId: string | null;
Expand Down Expand Up @@ -54,6 +55,8 @@ export interface EditorActions {
clearMediaSkimPreview: () => void;
setCompoundClipSkimPreview: (compositionId: string | null, frame?: number | null) => void;
clearCompoundClipSkimPreview: () => void;
beginTranscriptionDialog: () => void;
endTranscriptionDialog: () => void;
setSourcePatchVideoEnabled: (enabled: boolean) => void;
setSourcePatchAudioEnabled: (enabled: boolean) => void;
setSourcePatchVideoTrackId: (trackId: string | null) => void;
Expand Down
2 changes: 2 additions & 0 deletions src/config/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const HOTKEYS = {

// UI
TOGGLE_SNAP: 's',
OPEN_SCENE_BROWSER: 'mod+shift+f',

// Markers
ADD_MARKER: 'm',
Expand Down Expand Up @@ -325,6 +326,7 @@ export const HOTKEY_DESCRIPTIONS: Record<HotkeyKey, string> = {

// UI
TOGGLE_SNAP: 'Toggle snap',
OPEN_SCENE_BROWSER: 'Open Scene Browser (search AI captions)',

// Markers
ADD_MARKER: 'Add marker at playhead',
Expand Down
24 changes: 12 additions & 12 deletions src/features/composition-runtime/utils/audio-decode-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
* Caches decoded AudioBuffers for custom-decoded audio tracks so that
* split clips from the same source share a single decode.
*
* Storage: Decoded audio is persisted to IndexedDB in 10-second bins
* Storage: Decoded audio is persisted to workspace-backed files in 10-second bins
* (Int16 @ 22050 Hz stereo ~ 0.84 MB/bin). This avoids large single
* records and allows progressive persistence during decode.
*
* On refresh, bins are loaded from IndexedDB in parallel and
* On refresh, bins are loaded from the workspace cache in parallel and
* reassembled into an AudioBuffer with no re-decode needed.
*
* Surround (5.1/7.1) sources are downmixed to stereo during decode
Expand Down Expand Up @@ -72,10 +72,10 @@ const PLAYABLE_PARTIAL_PREROLL_SECONDS = 0.25;
const STARTUP_PLAYABLE_PARTIAL_READY_SECONDS = 1;
const PENDING_PLAYBACK_SLICE_REUSE_HEADROOM_SECONDS = 1;

/** Sample rate for IndexedDB storage; 22050 Hz is sufficient for preview. */
/** Sample rate for persisted preview-audio bins; 22050 Hz is sufficient for preview. */
const STORAGE_SAMPLE_RATE = 22050;

/** Bin duration in seconds for chunked IndexedDB storage. */
/** Bin duration in seconds for chunked persisted storage. */
const BIN_DURATION_SEC = 10;

export interface PlaybackAudioSlice {
Expand Down Expand Up @@ -224,7 +224,7 @@ function createInputSource(

/**
* Get a cached AudioBuffer or decode one via mediabunny.
* Checks: memory cache -> IndexedDB bins -> decode (persists bins progressively).
* Checks: memory cache -> persisted bins -> decode (persists bins progressively).
* Concurrent calls for the same mediaId share a single promise.
*/
function ensureDecodeStarted(mediaId: string, src: PreviewAudioSource): Promise<AudioBuffer> {
Expand Down Expand Up @@ -619,11 +619,11 @@ export function clearPreviewAudioCache(): void {
}

// ---------------------------------------------------------------------------
// Load from IndexedDB bins
// Load from persisted bins
// ---------------------------------------------------------------------------

async function loadOrDecodeAudio(mediaId: string, src: PreviewAudioSource): Promise<AudioBuffer> {
// Try IndexedDB
// Try persisted workspace cache
try {
const cached = await getDecodedPreviewAudio(mediaId);
if (cached && 'kind' in cached && cached.kind === 'meta') {
Expand All @@ -638,7 +638,7 @@ async function loadOrDecodeAudio(mediaId: string, src: PreviewAudioSource): Prom
await deleteDecodedPreviewAudio(mediaId).catch(() => undefined);
}
} catch (err) {
log.warn('Failed to load from IndexedDB, will decode', { mediaId, err });
log.warn('Failed to load persisted decoded audio, will decode', { mediaId, err });
}

// Full decode with progressive bin persistence
Expand Down Expand Up @@ -694,7 +694,7 @@ async function loadFromBins(meta: DecodedPreviewAudioMeta): Promise<AudioBuffer>
throw new Error(`Decoded audio bins incomplete: ${offset}/${totalFrames} frames`);
}

log.info('Loaded decoded audio from IndexedDB', {
log.info('Loaded decoded audio from workspace cache', {
mediaId,
binCount,
sampleRate,
Expand Down Expand Up @@ -804,7 +804,7 @@ async function buildPreviewStereoBuffer(
}

/**
* Downsample, convert to Int16, and persist one bin to IndexedDB.
* Downsample, convert to Int16, and persist one bin to workspace-backed storage.
* Returns persisted Int16 data so playback can be assembled without
* retaining a massive full-resolution decode in memory.
*/
Expand Down Expand Up @@ -1015,9 +1015,9 @@ async function decodeFullAudio(
binDurationSec: BIN_DURATION_SEC,
createdAt: Date.now(),
}).then(() => {
log.info('All bins persisted to IndexedDB', { mediaId, binCount: totalBins });
log.info('All bins persisted to workspace cache', { mediaId, binCount: totalBins });
}).catch((err) => {
log.warn('Failed to persist bins to IndexedDB', { mediaId, err });
log.warn('Failed to persist bins to workspace cache', { mediaId, err });
});

return combined;
Expand Down
Loading
Loading