In · Out · Ripple
How a video editor timeline works: tracks, clips, trims and overlaps
Learn the data model behind a non-linear video timeline: tracks, clips, in and out points, trims, ripple edits and how overlapping clips are resolved.
A video editor timeline is the row of blocks, running left to right, that tells a player when each piece of footage should appear. Every drag, trim and split you make on it changes a small set of numbers, and those numbers are what the program saves, undoes and finally renders into frames. This article builds that data model from the bottom up: what a clip is, why a clip needs two time ranges instead of one, how tracks decide what sits on top, and why most trim edits are really rules about which neighboring clips move.
We have built a timeline for our own browser editor, so the worked examples come from that experience. The goal is not to tour every feature. It is to show the shape of the data and the reasons behind it, so you can model a timeline in plain JavaScript and test it without a screen. If the wider idea is new to you, it belongs to a family called non-linear editing, which the first section defines. The complete architecture of a browser video editor shows where the timeline fits.
What is a timeline, and what is a clip?#
Start with three words. A timeline is a horizontal axis measured in seconds, where zero is the first frame of the project. A track is one lane along that axis; most editors show video tracks stacked above audio tracks. A clip is one use of one piece of media on one track. It says, in effect, "play this part of that file, starting here." The media file itself, which we call the source, is never changed by editing. The clip is only a pointer with a few numbers attached.
The term non-linear editing comes from the older method of copying tape from reel to reel in strict order. Wikipedia describes the newer approach as non-destructive: edits are stored as a list of pointers rather than applied to the original files, so the source stays intact and any edit can be changed later.[1] Random access is the other half of the idea. You can jump to any point in the sequence without rewinding the whole tape. Video editing, the broader craft of arranging shots after they are recorded, is outlined in Wikipedia's general article.[2]
In code, a clip can be a plain object. The shape below is a teaching version, not a production schema:
// A clip is a pointer: "play this window of that file, here on the timeline".
// All times are in seconds.
interface Clip {
id: string; // unique, never reused after a delete
mediaId: string; // which source file this clip plays
trackId: string; // which lane the clip sits on
timelineStart: number; // where the clip begins on the timeline
sourceIn: number; // where playback starts inside the source
sourceOut: number; // where playback stops inside the source
}
// A 60-second source. This clip plays seconds 10 to 14 of it,
// starting at second 4 of the project.
const interview: Clip = {
id: "clip-1",
mediaId: "interview.mp4",
trackId: "video-1",
timelineStart: 4,
sourceIn: 10,
sourceOut: 14,
};
Notice what the object does not contain: no pixels, no decoded frames, and no copy of the file. Keeping the model this small is the first design decision. The timeline answers questions about time only. Drawing and decoding happen later, in other parts of the system.
- 1Clip A 0–4sin 2s · out 6s
- 2Clip B 4–12sin 10s · out 18s
The figure above shows a whole track rather than a single clip. Two clips tile it with no gap and no overlap, and the cut sits where the first clip ends. Each clip's detail text names its source range, so you can see that the timeline range and the source range are different numbers.
Why does a clip need two ranges, not one?#
Look at the interview clip again. It occupies the timeline from second 4 to second 8, which is four seconds long. It also reads the source from second 10 to second 14, which is also four seconds long. The two ranges have the same length but different positions. That is the whole reason a clip has two ranges: the timeline range says when something happens, and the source range says which part of the file happens then.
Once you accept that, trimming becomes simple to describe. If you trim the left edge of the clip by one second, the clip's timeline start moves right by one second, and the source in-point also moves right by one second. Both ranges move together, because the frame that was at the edge is now one second later in the file. If you moved the timeline start without moving the source in-point, you would stretch the content instead of cutting it.
Range boundaries are a small but important detail. We treat every range as half-open: it includes its start and excludes its end.[3] In shorthand, [4, 8) contains 4 and stops just before 8. This makes back-to-back clips safe. A clip ending at 8 and a clip starting at 8 do not overlap, because the first one never owns the instant 8.
The table below lists the fields a clip needs and where each one lives. Keep it beside you while you read the next sections.
| Field | Lives on | Unit | What it answers |
|---|---|---|---|
timelineStart | the clip | seconds | When does this clip begin in the project? |
sourceIn | the clip | seconds | Where in the file does playback begin? |
sourceOut | the clip | seconds | Where in the file does playback end? |
trackId | the clip | an id | Which lane is it on? |
mediaId | the clip | an id | Which file supplies the pixels? |
The same fields appear in an edit decision list, a plain-text record of cuts that editors use to hand a sequence from one program to another. An EDL lists each edit with its source in and out points and its timeline in and out points.[4] It is a useful reminder that the timeline is a list of decisions, not a list of media.[5] Positions inside footage are often written as timecode, a sequence of numeric codes generated at regular intervals.[6]
Many editors also store the duration as its own field. Deriving it from sourceOut - sourceIn means the two can never disagree, at the cost of computing it wherever you need it. Either choice works; what matters is that you pick one and stick to it.
How do tracks, stacking and overlaps decide what you see?#
Tracks give the timeline its second dimension. Along one track, clips run in order and do not share time. Across tracks you can stack things: a title over a video, or a music bed under dialogue. In most editors, track order decides what draws on top, with the higher track painting last. Stacking, overlap rules and transitions all start from that single ordering decision.
Overlap rules keep the sequence from becoming ambiguous. If two clips on the same track share a moment, the answer to "what should show at second 6?" is unclear. So most editors refuse that shape, with one deliberate exception. A transition is a declared overlap between two adjacent clips on the same track, such as a crossfade. The transition names the pair and records how much time they share. Anything else is an unowned overlap, and it should be rejected.
Here is the collision check as a pure function, using the half-open rule from the previous section:
// The timeline end of a clip, exclusive: the range is [timelineStart, end).
function clipEnd(c: Clip): number {
return c.timelineStart + (c.sourceOut - c.sourceIn);
}
// Two clips collide when they share a track and their ranges intersect.
// Overlap on DIFFERENT tracks is fine: that is layering, not a conflict.
function collides(a: Clip, b: Clip): boolean {
if (a.id === b.id || a.trackId !== b.trackId) return false;
return a.timelineStart < clipEnd(b) && b.timelineStart < clipEnd(a);
}
Check it by hand. On one track, [4, 8) and [8, 12) do not collide, because 4 is less than 12 but 8 is not less than 8. The pair [4, 8) and [6, 10) does collide, because 4 is less than 10 and 6 is less than 8. Nothing in the function touches the screen, so you can test it with numbers alone.
The one thing you must not confuse is track order with paint order. Track order in a panel is an editing convenience: which lanes exist and how they are arranged. Which pixels win is a compositing decision, made when the frames are drawn. We keep the two apart on purpose, and the Azelify section below explains why.
Ripple, roll, slip and slide: which clips move?#
Trimming has four standard forms, and each name describes what is allowed to change. The table uses the definitions from a working editor's guide to the trim tool.[7]
| Edit | What you drag | This clip's length | Neighboring clips | Total timeline length |
|---|---|---|---|---|
| Ripple | one clip's edge | changes | later clips shift to follow | changes |
| Roll | the cut between two clips | one grows, one shrinks | nothing else moves | stays the same |
| Slip | the footage inside a clip | unchanged | unaffected | stays the same |
| Slide | the clip itself | unchanged | neighbors absorb the gap | stays the same |
Ripple is the only one that alters the length of the whole sequence. Roll moves a single cut point between two neighbors, so one clip gets longer while the other gets shorter. Slip changes which part of the source plays inside a fixed box. Slide moves a box along the track and lets its neighbors shrink or grow to fill the space. Each rule is a small question about which numbers change, and the decision tree below turns those questions into a sequence of yes-or-no answers.
Every one of these edits is a rewrite of the same few fields: timelineStart, sourceIn and sourceOut, on one or two clips. A ripple also rewrites the timelineStart of every later clip on the track. Once you see trims as rewrites of numbers, you can implement them without any user interface at all, and that is what makes them testable.
Why write edits as pure functions?#
A pure function is one whose result depends only on its inputs, and which changes nothing else in the program. Wikipedia gives two defining properties: the return values are identical for identical arguments, and the function has no side effects.[8] For a timeline, that means a trim takes a clip and a number, and returns a new clip. It never modifies the clip it was given and never reaches into the undo history or the screen.
Here is a trim of the left edge written that way:
const MIN_CLIP_SECONDS = 0.2; // hard-coded constant: the shortest clip we allow
// Trim the left edge of a clip by `seconds`. Returns a NEW clip.
// The source window moves with the edge, so the content is cut, not stretched.
function trimStart(clip: Clip, seconds: number): Clip {
const length = clip.sourceOut - clip.sourceIn;
// Keep at least MIN_CLIP_SECONDS on the timeline.
const amount = Math.min(Math.max(seconds, 0), length - MIN_CLIP_SECONDS);
return {
...clip,
timelineStart: clip.timelineStart + amount, // the box moves right
sourceIn: clip.sourceIn + amount, // the window moves with it
};
}
A test for it is short, runs in Node, and states the rule exactly:
import assert from "node:assert/strict";
import { test } from "node:test";
test("trimStart moves the timeline start and the source in-point together", () => {
const before: Clip = {
id: "clip-1", mediaId: "m", trackId: "v1",
timelineStart: 4, sourceIn: 10, sourceOut: 14,
};
const after = trimStart(before, 1);
assert.equal(after.timelineStart, 5);
assert.equal(after.sourceIn, 11);
assert.equal(after.sourceOut, 14); // the right edge did not move
assert.equal(before.timelineStart, 4); // the input was never changed
});
The payoff is in the last assertion. Because the function never mutates its input, the editor can keep the old clip for undo, compare the two versions, or run the same edit twice and get the same answer. Mutable code can pass a happy-path test and still corrupt a history stack, so we treat purity as a design rule rather than a style preference. The companion post on undo and redo builds directly on this property.
From a drag gesture to a saved clip#
A user drags an edge with a mouse, a pen or a finger. Each of those inputs reaches the page as a pointer event (opens in a new tab),[9] so the gesture arrives as pixels, but the model stores seconds. The bridge between the two is the zoom level. If the timeline shows 100 pixels per second, a 10-pixel move is 0.1 seconds. If the user zooms in to 400 pixels per second, the same 10-pixel move is only 0.025 seconds. The conversion has to happen at the moment of the drag, because the zoom can change between gestures.
Snapping follows the same logic. A snap threshold is a distance in pixels within which a dragged edge jumps to a nearby edge, a playhead or a marker. Expressed in time, it is the pixel threshold divided by the current scale. That is why snapping feels the same at every zoom level: the pixel distance stays constant, and the time distance adapts to it.
// Snap a dragged time to the nearest candidate within a pixel threshold.
function snapTime(
dragged: number,
candidates: number[], // edges and markers, in seconds
pixelsPerSecond: number,
snapPixels = 10, // a ten-pixel snap distance, at any zoom
): number {
const threshold = snapPixels / pixelsPerSecond; // pixels -> seconds
let best = dragged;
let bestDistance = threshold;
for (const c of candidates) {
const distance = Math.abs(c - dragged);
if (distance <= bestDistance) {
best = c;
bestDistance = distance;
}
}
return best;
}
The full path from a drag to a saved clip has four stages, which the flow diagram shows.
Each stage is a separate function, so each one can be tested. The gesture produces a candidate time. Snapping adjusts it. Validation checks the overlap rules. Only then does the editor apply the change to the clip record. If validation fails, the clip record is left alone and the user sees a refusal rather than a silent change.
Step-by-step: build a minimal timeline model#
The four steps below take you from an empty file to a tested timeline model. Each step is small enough to finish in one sitting, and each one ends with a check you can run.
Step 1: Define the clip record#
Start with the Clip interface from the first section, and add nothing else yet. Resist the urge to store the file's width, a color or a thumbnail. Those belong to other stages. A clip record needs an id, a media id, a track id and three times. If you find yourself adding a fourth time field, check whether it is derivable from the three you have.
Step 2: Reject overlaps on the same track#
Add collides and a validator that checks every pair. The nested loop is quadratic, which is fine for a teaching example with a few dozen clips. A production editor sorts clips by start time within each track and checks only neighbors, but the rule is identical.
// Returns one message per collision. An empty array means the timeline is valid.
function validateNoOverlap(clips: Clip[]): string[] {
const errors: string[] = [];
for (let i = 0; i < clips.length; i++) {
for (let j = i + 1; j < clips.length; j++) {
if (collides(clips[i], clips[j])) {
errors.push(`${clips[i].id} overlaps ${clips[j].id} on ${clips[i].trackId}`);
}
}
}
return errors;
}
Then write one test with two clips that touch at 8, and one pair that overlaps by a second. The touching pair must pass and the overlapping pair must fail. That test pins the half-open rule in place.
Step 3: Write one edit as a pure function#
Pick the simplest edit first: trimStart from the earlier section. Keep it pure, give it a name that says what it does, and have it return a new clip. Do not connect it to a mouse event yet. A function that only maps numbers is far easier to get right than one that also reads the cursor.
Step 4: Test the boundary cases#
Write one test per edge: a zero-length trim, a trim larger than the clip, and a trim that would make the clip shorter than the minimum. Then add one property you always want to hold, such as "a left trim never moves the right edge." Only when those pass should you connect the function to a gesture. The gesture then becomes a thin layer over logic you have already tested.
How the Azelify editor handles the timeline#
The Azelify editor follows the same shape, with a few decisions that are worth describing as trade-offs. The first is that the timeline owns no storage of its own. Tracks, clips, nested sequences and multicam groups are all fields on the project document, and the timeline code is pure logic over that shape. Keeping storage in one place gives the rest of the system one thing to read. The cost is that every timeline operation has to be expressed through the document rather than through private state.
The second decision is that every change goes through the action layer that records undo. The timeline code has no direct write path of its own. That sounds like plumbing, but it is what makes "undo the last trim" a dependable operation. The trade-off is ceremony: a new kind of edit needs an action definition before it can touch a clip.
The third decision is the overlap rule. Only a declared transition can share time on a track. If an edit would create an unowned overlap, the action validator refuses it, the document is unchanged, and the user sees one consistent message. The benefit is that the renderer never has to guess which of two clips should win. The cost is that the editor sometimes says no where a looser tool would quietly stack the clips.
The fourth decision concerns nested sequences, which the editor calls compound clips. The nested content is stored once, beside the timeline, and the outer timeline holds an ordinary clip that points to it. Two instances can share one definition, so an edit inside reaches both. When you want one instance to change without the other, the editor makes an independent copy. Sharing is efficient, and copying is an explicit choice.
Two smaller choices round out the model. A group is a selection convenience with no enforced propagation. A link is an editing constraint: moving one linked clip moves its partner, which keeps a J-cut's video and its detached audio together. And a three-point edit, which copies a span of source into a span of timeline, derives the fourth of its four numbers from the other three. The solver does not check whether a caller's fourth value agrees with the rest. It overwrites that value. This is a deliberate simplification, and it is worth knowing about if you build the same feature.
Finally, scene splitting does not run a new detection pass inside the editor. It reads cut points that were calculated when the media was imported. That keeps the timeline fast and keeps the analysis in the place where the file is first handled. The next post in the series covers importing video files in the browser, where that analysis begins.
Frequently asked questions#
Is a clip the same thing as a media file?#
No. A media file is the source footage on disk or in storage, and editing never changes it. A clip is a small record that says which window of that file plays, on which track, and starting at which time. Many clips can point at the same file, and deleting a clip does not delete the file.
Why store times in seconds instead of frames?#
Seconds are easy to read and easy to scale with zoom. Frame counts are exact for one frame rate, and rates differ widely, from 50 or 60 frames per second in analog television to 120 or more in newer standards.[10] Decimal seconds can accumulate rounding error, so many editors snap values to a frame grid when an edit is committed. Pick one unit, and convert only at the boundary where you draw or export.
Do I need React to build a timeline?#
No. The data model in this article is plain TypeScript with no framework. A React component is only one way to draw it. Keeping the model separate means you can test trims in Node, reuse the same logic in a worker, and swap the view later without rewriting the rules.
Can two clips play at the same time on one track?#
Normally no. An overlap on one track is refused unless it is a declared transition, such as a crossfade between two neighbors. Clips on different tracks can overlap freely, and that is how titles and music sit on top of footage.
What does ripple mean in a video editor?#
A ripple edit changes one clip's length and pushes every later clip along the track by the same amount. It is the only trim that changes the total length of the sequence. Use it when you want to remove a gap or tighten a scene without leaving a hole.
How do I undo a trim without copying the whole project?#
Store the before and after values of the clip, or store the command that produced the change, and reverse it when asked. Because trims are pure functions, the old clip is still available whenever you need it. The undo post explains the command pattern in detail.
Where to go next#
Read how undo and redo work in a command-based editor to see how the pure edits above become a reversible history. For the playback side, frame-accurate playback in JavaScript explains how the timeline's time becomes the exact frame you see.
Keep reading
Undo and redo in a web editor: the command pattern, explained from zero
Implement undo and redo for a browser editor with the command pattern: commands, history stacks, grouping a drag into one step, and common traps.
Frame-accurate playback in JavaScript: clocks, requestAnimationFrame and A/V sync
Build a playback loop for a web video editor: one master clock, requestAnimationFrame pacing, dropped-frame handling and keeping audio and video in sync.
More in Browser video editor
Designing the project file for a web video editor: schema, versions and autosave
Design the JSON document behind a web video editor: what a project stores, how to version and migrate it, and how to autosave without losing work.
WebCodecs tutorial: decoding video frames in the browser without a video tag
Decode video frame by frame with the WebCodecs VideoDecoder: demux samples, configure the decoder, seek to keyframes and release VideoFrame memory.
How to build a video editor in the browser: the complete architecture
A plain-language map of a browser video editor: the project file, timeline, decoding, compositing, audio and export, and how each piece connects.