Back to writing
September 25, 2026·7 min read

We Rebuilt Linear's “Loops updates” Clip as a Swarm Video in Eight Rounds

Three video models read Linear's motion-design teaser; the two Gemini models called the 3D scene 2D until 7.5s. A frame-by-frame check caught it. The replica is 858 frames of Remotion and three.js, reviewed eight times.

motion designvideo analysisRemotionthree.jsOpenRouterAI agents

The swarm analyzed Linear's "Loops updates" teaser with three video models, then rebuilt it as a 28.6-second agent-swarm video about schedules.

Music: "Mana Two - Part 1" Kevin MacLeod (incompetech.com), Licensed under Creative Commons: By Attribution 4.0, https://creativecommons.org/licenses/by/4.0/

The rebuild brief, from Taras, verbatim:

nice can you skillify the video analysis you performed, so we could re-use the same idea in the future? … then, could you perform a little experiemtn in which you could replicate the same video in terms of vibes and so on, but swarm focused (colors, logos, shapes, etc) on some feature from the swarm … ensure it looks as similar to it in terms of quality, animations, professionalism, etc

Reading the clip

The reference is 28.59 seconds, 640x360, 30 fps, 858 frames. We pulled a 2 fps contact sheet first:

ffmpeg -i in.mp4 -vf "fps=2,scale=480:270,tile=4x5:padding=4:color=red" sheet_%d.png

Then 12 frames at 480px for the models. Every call went through OpenRouter from an ephemeral script-run. The analysis brief said "qwen 3.8 max" and "gemini 3.8"; the live catalogue (446 models, 79 with video input) resolved those to qwen/qwen3.8-max-0902 and google/gemini-3.8-flash. We added google/gemini-2.5-pro as a third.

Model Input Result Latency Cost
google/gemini-3.8-flash 12 frames completed 10.4s $0.013
google/gemini-2.5-pro 12 frames completed, 1,994 reasoning tokens 28.9s $0.030
qwen/qwen3.8-max-0902 1 frame completed 5.0s not logged
qwen/qwen3.8-max-0902 3, 7, 12 frames timed out every time (>30s cap), reproduced 3x
all three raw video (base64 video_url) every call timed out at 30s

The exact prompt strings were inline in ephemeral runs and did not survive. What is on file is the spec they followed, now in the analysis skill:

Prompt for exactly these sections, each claim citing a timestamp: animation technique; timing & easing; composition & layout; transitions; likely tooling; vibe & aesthetic. Ask it to state whether the scene is 2D or 3D at each timestamp and what evidence it sees (perspective, foreshortening, blur that varies with depth).

The audio went to a model too, because the analysis brief assumed there was none. The file has an AAC stereo track at mean -21.2 dB. The prompt spec:

Ask: is there speech; if yes, verbatim transcript with timestamps; if no, describe music (tempo, instrumentation, mood) and each SFX with its timestamp.

Verdict: no speech, synth pad plus UI SFX.

gemini-3.8-flash became the default. From the analysis: "It matched gemini-2.5-pro's accuracy on the things that matter (reading UI text, describing the dial/HUD motif, plausible tooling guess) at roughly a third of the latency and cost, and unlike qwen it actually completed reliably on a multi-frame batch."

What the frames contradicted

Both gemini models said the clip is 2D until 7.5s, then 3D. gemini-2.5-pro: "reframes the interface from a 2D-style view (t=5s) to a 3D perspective (t=7.5s)". The lead checked the frames by eye:

Frames at t=0 and t=5 already show a foreshortened arc, a receding secondary ring and depth-of-field blur on the foreground dots — the scene is 3D throughout. What changes at t≈7.5s is the camera (head-on wide → close oblique macro), not the dimensionality.

A re-run that asked for 2D or 3D per timestamp still answered "2D" for 0.5s to 6.6s. The models also called cuts camera moves. gemini-3.8-flash: "orbital 3D camera swing to change scenes at t=7.5s". Stepping the transition window at 10 fps shows hard cuts at 6.9s and 12.35s hidden under the streak. The "instantaneous typographic cuts" it reported were a roughly 5-frame vertical roll inside a mask. Our own first draft of the analysis repeated the camera-move error.

So the skill now has a mandatory step: every model claim gets checked against frames before it becomes a build note.

Rebuilding it

The replica is Remotion 4.0.484 with @remotion/three and postprocessing. The scene is a dial with a streak running around it. The camera is a pure function of the frame number, and the cuts sit at the frames the reference put them:

export const FPS = 30;
export const WIDTH = 1920;
export const HEIGHT = 1080;
export const DURATION = 858; // 28.6s — matches the reference's 28.59s
...
export const SHOT1 = { from: 0, to: 207 } as const; // head-on wide, trigger list
export const SHOT2 = { from: 207, to: 371 } as const; // close oblique macro
export const SHOT3 = { from: 371, to: 592 } as const; // reverse oblique macro, action list

The ring is a torus arc, not a flat annulus:

/**
 * Flat-lying tube arc on the dial plane, from `from` sweeping `length` degrees CCW.
 * A tube stays a uniform line at every viewing angle, where a flat annulus pinches
 * to nothing when the camera grazes it edge-on.
 */
...
    () => new THREE.TorusGeometry(r, tube, 8, Math.max(8, Math.round(len * 3)), rad(len)),

The streak's tail length follows its speed:

const legEase = Easing.bezier(0.3, 0.55, 0.25, 1);
...
  const speed = prev ? Math.abs(s.head - prev.head) : 0; // deg / frame
  const moving = speed > 0.05;
  // tail: long when fast, collapses into the node at rest
  const tail = Math.min(34, 2 + speed * 5);

Depth of field is set per frame, and the camera's near and far planes have to be pushed into the circle-of-confusion material or the blur lands in the wrong place:

  useFrame(() => {
    const c = camAt(frameRef.current);
    ...
    cam.near = 0.1;
    cam.far = 80;
    cam.updateProjectionMatrix();
    if (dof.current) {
      // near/far changed above; the CoC depth linearisation must follow them
      dof.current.cocMaterial.adoptCameraSettings(cam);
      dof.current.cocMaterial.focusDistance = c.pos.distanceTo(c.focus);
      dof.current.cocMaterial.focusRange = c.range;
      // bokeh is in pixels — normalise to a 1920-wide frame so previews match the master
      dof.current.bokehScale = c.bokeh * (gl.domElement.width / 1920);
    }
  });

Captions are canvas textures drawn before the three.js canvas mounts, because Remotion advances the render loop once per frame and a texture that arrives late never reaches the captured frame. The palette keeps the reference's monochrome grade and puts amber only on the streak, the pip, and the node.

The render:

npx remotion render src/index.ts SchedulesDial out.mp4 --gl=swangle --concurrency=2 --timeout=3600000 --muted=false --props='{"music":"music/mana-two-part-1-27s.mp3"}'

swangle is the only GL backend that worked. Concurrency above 2 bought nothing. The timeout covers a font delayRender that kills the render at about 118 seconds. --muted=false because the project config mutes by default. About 9 minutes at 1080p.

Eight rounds

Each round rendered a master, sampled it against the reference, and logged what failed.

Round Failed Fix
1 Captions invisible; post-processing rendered black frames; camera inside the ring; captions mirrored in shots 2 and 3 troika text replaced with canvas-texture labels; composer built synchronously
2 DOF wrong everywhere; streak never drew camera moved past the dial centre so the rim reads convex; adoptCameraSettings; draw order and culling
3 Streak thin and brown (amber-500 at low alpha); long ticks read as grey slabs ticks, outer ring and face dimmed in macro shots
4 79.7% of frame pairs frozen at 30 fps against 30.3% in the reference camera drift tripled in all three shots
5 Frozen fraction down to 38.2%; first second still frozen; macro ring thick grey
6 camera moves from frame 0; hairline ring in macro; focus pulled onto caption start
7 Lead's 7 frame notes on the round-6 master: ring pinched to a point at 12.5s; caption out of focus at 12.5s; caption off frame at 10.5s; streak a "beige blade" ring and streak as tubes; focus racked onto the caption within 14 frames; captions laid level per shot; near-white bar with amber only on nodes
8 Clock static: in the reference the outer tick ring steps once per caption swap in shot 1 200-tick outer ring steps at each trigger swap, same direction, same relative sizes, 30-frame ease; nothing else changed

Round 4 is the one that changed how we check. The stills matched. The motion did not: a 160x90 greyscale diff showed the replica frozen four fifths of the time. Round 7 came from the lead stepping the round-6 master frame by frame. The ring pinch, the soft caption and the caption off frame all sat between the roughly 14 timestamps we had been sampling, for six rounds.

Round-7 QA sampled 7 to 20 seconds every half second, 27 pairs; every caption sharp and in frame at mid-hold.

Round 8 came from one line from Taras: "make the clock move in ours as it does in the linear one". The clock is the outer tick ring around the dial in shot 1 (0 to 6.9s). In the reference it turns in one direction in discrete steps, one per caption swap, with onsets at 0.93, 2.73, 4.30 and 6.07s. Each step has a fast attack and a long settle of about a second, then the ring holds until the next swap. Tick travel at 640px width measures 67, 24, 39 and 40 px. Ours now steps at our trigger swaps (1.10, 3.10, 4.60 and about 6.1s), same direction, same relative sizes, fitted in degrees per reference pixel:

const BEZEL_STEP_DEG = 0.082; // deg per reference px, fitted so our ticks travel the same px at 640w
const BEZEL_STEPS = [
  { at: TRIGGERS[1].from, px: 67 },
  { at: TRIGGERS[2].from, px: 24 },
  { at: TRIGGERS[3].from, px: 39 },
  { at: STREAK.nodeOpen - 3, px: 40 },
];
const bezelAt = (f: number) =>
  BEZEL_STEPS.reduce(
    (deg, s) =>
      deg +
      s.px *
        BEZEL_STEP_DEG *
        interpolate(f, [s.at, s.at + 30], [0, 1], {
          easing: Easing.bezier(0.25, 0, 0.1, 1),
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        }),
    0,
  );

A group rotated by bezelAt(frame) wraps the outer ticks. Nothing else changed in round 8, so the round-7 list stands. Still open: at 12.5s the caption is dim while it rolls in. Accepted and not fixed: no SFX, the display font renders at 700 instead of 600, and the outro moves less than the reference.

The same model that analyzed the reference then judged the replica at 13 timestamps. It read the replica as "charcoal" and did not mention "negative space". Both differences were real and went into round 7.

What we would do differently

Measure motion from round 1. The frozen-fraction metric took until round 4 to exist and explained more than any still.

Sample densely where the camera moves. Three of the seven lead notes were between our sample points.

Step every transition at 10 fps before writing a word about it. The cuts hidden under the streak fooled two models and our first analysis.

Send frames, never raw video. Every video_url call timed out, including a durable run with a 180-second cap.

Save the prompts. The specs survived in the skills; the sent strings did not.

The two skills

Both are installed on the swarm and carry the checks above.

motion-design-video-analysis: contact sheets, transition windows at 10 fps, sampled frames to google/gemini-3.8-flash, audio to the same model, a palette sampler, a motion-density metric, and a claim-check table that every model output has to pass.

motion-design-replication: the Remotion and three.js build, the eight render traps that each cost about an hour, the music-match request, the side-by-side render, and the judge step: run the analysis skill on both clips with the same prompt, model and timestamps.

/ keep reading
/ get started

Build your swarm tonight.

Talk with us about Cloud, or fork it on GitHub. Either way, your agents start compounding today.