Editor
The v4 timeline editor, proxy-backed playback, canvas timeline, trimming, keyboard, and export.
Editor
The editor is the human last mile after the agent's pass. The agent builds the first cut (trims, captions, overlays) and marks the project draft; the editor is where you take over, adjust what it built, and trigger the final render. It opens in the browser from montaj serve and works on the same project.json the agent writes, so anything you change here is exactly what the agent (or the CLI) would have written by hand.
Playback
The preview decodes the clip's editing proxy directly through WebCodecs and paints the result to a canvas, instead of relying on a native <video> element.
Two reasons drive that design, not codec licensing:
- Scrub latency. The proxy is encoded all-intra, so every frame stands on its own, jumping to any point in a clip is instant instead of waiting on the original camera file's long, hard-to-seek encode.
- Consistent behavior. Where the engine runs, it decodes and composites identically instead of inheriting each browser's
<video>quirks. (Whether it runs at all still depends on WebCodecs support. See the fallback below.)
Automatic fallback. The engine is evaluated once per project load. It falls back to the ordinary <video> player, with a one-line console reason, whenever any of these hold:
- a clip has no proxy yet
- the browser can't decode the proxy's codecs (H.264 video + Opus audio) via WebCodecs
- a clip needs its WebM alpha preview (background removal): the engine's demuxer is MP4-only
Once a project falls back, it stays on the <video> player for the rest of that session, even if a proxy finishes encoding moments later, and the reverse: a project that starts on the engine stays there even if a clip added afterwards has no proxy yet (that clip alone shows the preparing placeholder below).
Preparing preview. If a clip's proxy is still encoding, failed to load, or failed to decode, the engine shows a small spinner and "Preparing preview…" over just that clip's range, while the rest of the project keeps playing normally.
Editing proxies
Every clip imported into a project gets a lightweight editing proxy: a full-source, all-intra 720p H.264 + Opus copy, named <clip>_proxy_<look>.mp4 next to the source. It exists purely so the preview above can scrub instantly. Nothing else reads it.
- When they're made. At import, unless the source is longer than a duration gate (8 minutes by default). Sources past the gate log a deferred notice instead of blocking import, and get their proxy backfilled later.
- What they cost. As a rough planning figure, on the reference machine (macOS, hardware decode): about +30 seconds of import time per minute of footage, and about 2GB of disk per hour. Platforms without hardware decode fall back to software and run slower; disk is content-dependent, since the encode is CRF-based (
lib/proxy.py:143). Both costs are reclaimable at any time. - Reclaiming or disabling them.
montaj clean --proxieslists every proxy it would remove, with sizes; add--yesto actually delete them (and clear the now-staleproxySrcpointers).montaj init --no-proxyskips proxy generation for a project entirely, and a workflow can do the same with"proxy": false.--proxy-inline-maxchanges the duration gate. A deferred or missing proxy can be built on demand viaPOST /api/proxy. - The render never touches a proxy. Every export encodes from the original-quality source, full stop, proxies are a preview-only artifact.
The timeline
The timeline is a canvas-rendered set of track rows, not one DOM element per clip. A track rail runs down the left with per-track volume, mute, and skip controls. Video clips show filmstrip frames layered over a full-size waveform. Clips that overlap on the same track are marked with overlap bands. Caption controls sit at the bottom of the timeline.
Trimming and cuts
Selecting a clip grows trim handles on its edges. Dragging a handle (or the clip itself) snaps at two strengths: a strong pull toward the next clip on the same track, a faint one toward anything on another track or the playhead, with a visible indicator showing what it caught.
- Split:
Ssplits the selected clip at the playhead. - Ripple mode: a toolbar toggle. On, deleting or trimming closes the resulting gap by pulling downstream clips forward; off, the gap stays as empty space.
- Ripple-delete:
Shift+Deleteremoves the selection and closes the gap regardless of the ripple-mode toggle.
Underneath the drag gestures, four pure operations do the actual timeline math (montaj_assets/editor/src/video/cuts.ts):
rippleDelete: removes an item and shifts everything after it back to close the gap.rollEdit(Alt+ drag a clip's edge): moves the shared boundary between two adjacent clips. One clip gets longer, its neighbor gets shorter by the same amount, and the overall duration doesn't change.slipItem(Alt+ drag inside a clip's body): shifts which part of the source plays without moving the clip's position or duration on the timeline.slideItem(Cmd/Ctrl+ drag a clip's body): moves the clip itself, and its neighbors absorb the move by adjusting their own source in/out points to stay adjacent.
Multi-select. Shift-click or Cmd-click to select multiple clips across tracks at once. Deleting a multi-selection closes the gaps when ripple mode is on, same as a single-item delete. Resizing one edge of the selection applies the same delta to every selected item rather than just the one you dragged, and muting applies to the whole selection uniformly.
Clip properties
- Reframe: drag the selected clip directly on the preview canvas to reposition it, or corner-drag/scroll to zoom.
- Source crop: a non-destructive crop rectangle applied to the source footage before any reframe, with optional aspect locks (free, 1:1, 16:9, 9:16).
- Speed: per clip, 0.25x–4x (presets at 0.25x, 0.5x, 1x, 2x, 4x), with pitch-corrected audio so sped-up or slowed-down playback doesn't sound chipmunked or dragged.
- Mute and volume: per clip, independent of the track-level controls in the rail, with volume running 0–200% of the source level.
Audio polish
The "Polish audio" button in the toolbar, or the same entry in the command palette, runs four independent cleanups over the clips on the main track. It shows exactly what it proposes to change, and changes nothing until you approve it.
The four cleanups, each toggled on or off on its own:
- Remove silence: cuts the gaps where nobody is speaking.
- Remove filler words: cuts "um," "uh," and similar hesitation sounds. Language-aware.
- Match loudness: levels every clip to one target, YouTube (-14 LUFS), Podcast (-16 LUFS), Broadcast (-23 LUFS), or a custom value.
- Isolate voice: separates the voice from the noise and music behind it and attaches the isolated voice as its own audio track.
Language is a visible control, not a background setting. Choosing the wrong one makes speech detection unreliable, which is exactly what the safety check below exists to catch.
Preview. There's no separate preview mode. The polish is applied to the real project but not saved: the timeline, the preview player, and the audio all show exactly what you'd get. Nothing is written until you click Apply. Cancel restores everything as it was. After Apply, one undo reverses the whole polish, not one step per cut.
Review list. Every proposed cut is listed with its time, its length, and, for filler words, the word heard, each with its own checkbox. Untick one and that audio returns to the timeline immediately.
Safety checks. Montaj never quietly deletes speech. A proposed cut is flagged and starts switched off if either holds:
- it overlaps a word in your captions
- an independent loudness measurement, which knows nothing about words, models, or languages, finds real sound there
That second check is what catches a transcriber mishearing speech in another language as silence. A clip whose total proposed removal exceeds 60% of its length gets a warning too.
Where it doesn't work.
- Loudness is levelled per clip, not mastered across the finished video. Takes recorded at different levels come out even, but the exported file isn't guaranteed to hit an exact platform loudness number: it's a level change with a peak guard against distortion, not a full loudness pass.
- Voice isolation is unavailable on a clip that's sped up or slowed down. Montaj can't play a separate audio track faster or slower than native speed, so the isolated voice would drift out of sync with the picture. Those clips show up as skipped with the reason given; the other three cleanups still run on them.
- On a looping clip, silence removal, filler removal, and voice isolation are all unavailable: a cut found in the source wouldn't land where it was found once the source repeats. Loudness still works on looping clips. Again, shown with a reason rather than silently dropped.
No source file is re-encoded and the original footage is never touched or replaced. The exported video comes from the same renderer as before.
Caption editing
- Text: double-click a caption segment in the timeline to select it and jump to its text field in the sidebar caption list, where you edit it.
Enteror clicking away commits the change;Escapereverts it and leaves the original text untouched. - Timing: drag a segment's left or right edge in the timeline to change where it starts or ends.
- Position: select a segment (from its block in the timeline, or by clicking the caption itself in the preview) and a drag/resize box appears over it on the preview canvas. Drag to move it, drag a corner to scale it. Only that one segment moves. Every other caption stays exactly where it is.
- Rows: a caption isn't limited to a single row. Drag it above the top row in the timeline to open a new one; drag it down into an existing row to join that row instead. A row disappears once its last caption has been dragged out of it. Captions in different rows can be on screen at the same time, and all of them render, with the higher row drawn on top of the lower one, so two rows active at once can visually overlap unless you reposition one clear of the other.
- Sidebar list: the caption list in the sidebar groups its entries by row, each group under a sticky "Row N" header. Once a project has more than one row, a filter-chip strip above the list (All, Row 1, Row 2, …) narrows it to one row at a time, and composes with the search box.
- Style: the "Caption style" section in the sidebar switches the whole track between the seven built-in caption styles, for every row at once. See Steps: caption for what each looks like.
- Size: a font-size slider sits in the same "Caption style" section, 28–120px, live-previewing as you drag and committing when you let go.
- Removal: a "Remove all" button clears every caption from the project. The first click turns it into "Really remove?" as a warning; click it again within a few seconds to confirm, or leave it alone and it reverts on its own.
- Regenerate: rebuilds the whole caption track from scratch, discarding every row the project had, not just the one in view. With more than one row, the button warns first ("This will replace all N caption rows with a single new row") before it runs.
Overlay editing
With an overlay selected, double-click it in the preview (or click the pencil button in the toolbar) to open its props dialog. The dialog renders one control per prop, matched to its type: a text field, a number input, a checkbox, a color swatch picker, or an image thumbnail with a file picker.
Keyboard and the command palette
| Keys | Action |
|---|---|
S | Split at the playhead |
⌘/Ctrl + Z | Undo |
⌘/Ctrl + ⇧ + Z | Redo |
⇧ + Delete | Ripple-delete the selection |
← / → | Step one frame (⇧ for one second) |
J / K / L | Shuttle backward / stop / forward at 1×/2×/4× (repeat a direction to double it). Forward is real playback with pitch-corrected audio; reverse is a silent scrub. |
⌘/Ctrl + K | Open the command palette |
⌘/Ctrl + A | Toggle the preview axis: while it's on, hovering the timeline shows that frame in the preview without moving the playhead |
| Click the time readout | Jump straight to a timecode |
The command palette is a filterable list, type to narrow, arrow keys to move the highlight, Enter to run, Escape to close. Its entries are state-aware: ripple-delete only appears with something selected, undo/redo only appear when there's something to undo or redo. Play/Pause, Split, Zoom to fit, and Go to time are always available.
Undo and redo work off a local history stack of your own edits. If the agent is still writing to the project while you're reviewing it, its updates arrive over the live connection and apply to what you see, but they never enter this stack (only edits you make yourself do) so an agent write can't get undone by mistake, and undoing your own edits can't be disrupted by one arriving mid-session.
There is no Save button. Every edit is written back to project.json automatically, through a queue that keeps saves from racing each other.
Version history
The editor snapshots the project to git as each agent run completes and at status transitions (pending → draft → final). The Versions panel in the sidebar lists one entry per run, newest first, with the run number, its label, and a timestamp. Each carries a Restore → button that rolls the project back to that snapshot.
Export
Clicking Render opens the Export dialog. It shows a progress bar alongside the current phase (preparing, rendering graphics, adding captions, encoding, saving) as the render runs, and lets you pick a cover frame from ten evenly-spaced thumbnails sampled across the project.
For HDR projects, the dialog adds an export choice, match the footage's HDR master (auto), derive a standard-range file (sdr), or produce both, plus a curve picker under an Advanced disclosure for the standard-range look, with a real thumbnail of the project rendered through each curve so you can see the difference before exporting.
See Render Engine for what happens after Export kicks off.
Preview vs. render
The editor's preview and the render pipeline now share one timeline-resolution package (montaj_assets/timeline-core/) for timing, ordering, source selection, and geometry, instead of each keeping its own copy of that logic. The preview calls the package's main entry point, resolveAt(project, t, { variant }), directly ("what is on screen at instant t") and the render pipeline builds its segment plan from the same package's lower-level primitives (sourceWindow, boundariesFrom, activeIn) rather than re-deriving that math independently. Either way, what plays is what renders, in those specific respects. Overlays and captions are the exception: the preview still draws them as CSS-positioned elements in the browser, while the render still rasterizes them frame-by-frame through a separate Puppeteer pipeline. The two are not guaranteed to match pixel-for-pixel, and the render is what matters for the final output.