Skip to content
All posts

Building AtomCut

A group is a clip: the timeline primitive I got wrong once

How AtomCut models a group on a timeline: a group is a clip, and its rows are its own. Why the first version of that idea was still wrong.

SShayanBuilding AtomCut

A group on AtomCut's timeline used to be a special case four times over: nesting was one feature, trimming was another, z-order a third, dragging a clip in or out of a group a fourth. Each one had its own logic and its own bugs. Today they're one fact: a group is a clip, and its members live on rows that belong to it, and the four features fell out of that fact for free. It took two tries to get there, and the first try shipped, got used, and was wrong in a way that took weeks to see.

This is the honest version of that story, because the version where I designed it right the first time isn't true and wouldn't be useful to you anyway.

What a group has to do

On a timeline, "group" has to answer four questions at once: can it be dragged, trimmed and keyed like any other clip (nesting); does trimming it hide and reveal its contents without retiming them; where does it sit in the paint order relative to everything else (z); and what happens when you drag a clip across its boundary. Before v66, none of those were one answer. A group was a row in doc.groups — { id, name, mode, parentId } — with membership recorded on the clip as clip.groupId. It had no span, so it couldn't be trimmed or take a transition. It had no transform, so it couldn't be keyframed as a whole. And frameLayerTree placed a group node at its topmost member's lane, then folded the rest back in from wherever they actually sat — so a layer visually inside a group could be, in the data, outside it.

v66: a group is a clip

The fix was to stop treating "group" as a second kind of object and make it a Clip like any other, kind: "group". That one move bought a span, a transform, paint, effects and keyframes for free, because every clip already had them. From the decision record:

A group is a Clip — kind: "group" — and containment is the field every clip already had. clip.groupId points at a group CLIP; a group clip's own groupId is how groups nest. doc.groups and parentId are deleted.

This shipped, and it was a real improvement: a group could finally be dragged, trimmed and animated as one thing. But it kept one old habit — the group owned a lane, and its members occupied the lanes directly beneath it, held together by a compaction pass that ran after every edit (compactGroupLanes). Z-order and containment were sharing one axis, and that's where the second round of bugs came from.

What v66 got wrong

Tying membership to physical position on the track meant a group's lane was the group. "Move the group to another lane" had no real meaning, because the group's lane defined which lanes belonged to it. Two groups couldn't sit side by side on one lane, even though they're siblings and should be able to. And because membership lived on the clip (clip.groupId) while position lived on the track, dragging a member onto the group's own lane landed it as a sibling of its own container instead of inside it — the tree and the rows could disagree about what was in what. Every structural operation — restack, paste, duplicate, precompose — had to remember to keep the run of lanes contiguous, or the two views of the document drifted apart.

The trim rule made this worse in a way you'd only notice by using it: a child whose edge happened to land level with the group's edge would retime along with a group trim, which was right in exactly one case and inexplicable in every other. I shipped this. People used it. It took real time, closer to the "week to see the bug was in the data model, not the interpolation" territory I've written about before, to understand that the problem wasn't in any of the individual rules — it was that z-order (where a lane sits among other lanes) and containment (what's inside a group) were the same variable, and a variable that means two things will eventually be asked a question it can't answer.

v71: the row is the container

The fix was to separate the two axes completely. track.groupId became the only membership edge, and clip.groupId was deleted:

ts
// packages/core/src/schema/document.ts
/**
 * The GROUP whose interior this lane is (v71), or null for a lane at the
 * comp's top level.
 *
 * THE membership edge. A clip belongs to a group because it sits on one of
 * that group's rows — there is no `clip.groupId` — so a track is either a
 * lane of the comp or a row inside exactly one group, and a clip's group is
 * read off its track.
 */
groupId: z.string().nullable().default(null),

A track is either a lane of the comp (groupId: null) or a row of exactly one group. A clip is in a group because it sits on one of that group's rows — full stop. There's no second place to check, no compaction pass to keep two views in sync, because there's only one view. The group clip itself is an ordinary clip on an ordinary lane of its parent scope, which is why a group can now move to any lane like any other clip, and why groups nest the same way at every depth.

The consuming code is frameRows, which walks the tracks and, for every open group, splices its rows in directly beneath the lane its bar sits on:

ts
// packages/core/src/timeline/layer-tree.ts
export function frameRows(
  doc: AtomCutDocument,
  frameId: string,
  collapsed: ReadonlySet<string> = new Set(),
): FrameRow[] {
  const out: FrameRow[] = [];
  const walk = (scope: GroupClip | null, depth: number): void => {
    const scopeId = scope?.id ?? null;
    doc.tracks.forEach((track, index) => {
      if ((track.groupId ?? null) !== scopeId) return;
      // ... emit the lane, then each open group's rows beneath it
    });
  };
  walk(null, 0);
  return out;
}

Nothing here relates a group's rows to the lanes around it. Two groups can share one lane as true siblings, one band after another in time order. A group's rows can sit anywhere in doc.tracks — only their order among themselves is read. There is nothing left to compact, because there was never a second axis pretending to be the first.

Trim became a window, not a sync

The other half of the fix was killing the follow-the-group retime rule outright. A group's span is now a window onto absolute time, nothing more:

ts
// packages/core/src/timeline/group-time.ts
export function withinGroupWindow(doc: AtomCutDocument, clipId: string, timeMs: number): boolean {
  for (const g of clipAncestorGroups(doc, clipId)) {
    if (timeMs < g.startMs || timeMs >= g.startMs + g.durationMs) return false;
  }
  return true;
}

Children keep their own absolute times on the parent's clock. Trimming the group's edge changes what the window reveals; it never touches a child's startMs. Moving the group is the one gesture that carries its contents, because moving genuinely is different from trimming. This mirrors exactly how a nested comp's own boundary works, which is not a coincidence — a group had been trying to half-borrow that behavior since v66 without admitting it.

What fell out for free

Once membership was one field and trim was one rule, a surprising amount of surrounding code got to stay unwritten. Every structural move — group, ungroup, restack, duplicate, paste, precompose, an SVG import landing its members straight into a group — goes through one function, moveClipsToScope(doc, ids, groupId, { at }), which re-scopes a lane in place when every clip on it moves together, and mints a fresh row otherwise. Captions, nested comps and flipbook cells never had to know any of this changed; they use the same tree.

The code is on GitHub, and the full decision record, including the parts I still haven't done, like reordering a group's rows by dragging their headers, is in notes/ADR-groups.md. Read the repo.