Skip to content
Built for both. Reading this as
seekreel

Turn your website into a video.

Put your real components on a page — the same React, CSS and fonts your site ships — say what happens over time, and seekreel renders the MP4. Every size the feed asks for, from one build. Made to be driven by an agent as readily as by you.

  • Launch videos
  • Feature reels
  • Store listings
  • Agent-driven
curl -fsSL https://raw.githubusercontent.com/highnet/seekreel/main/install.sh | sh

MIT · Node 22.18+ · no npm package — it clones the repo and links the CLI

ONE FRAMEPER SEEK.000048096144192240frame 011 / 2884%
t = 5.208s · frame 125 / 28800:05.05 · 24fps · 1080×1080

Drag the scrubber: every moment you land on is exactly the frame the render would write to disk.

The site changed? Rebuild the shot that moved.

A button moved, the brand shifted, the pricing changed. Edit the page and re-render the seconds that changed — check any single moment as a PNG in about a second, seekreel probe 3.25, before committing to a full pass. Your promo video stops being a shoot you have to get right and starts being a file you edit.

  • ONE FRAMEPER SEEK.frame 000 / 2880%
    0.40s0010
  • ONE FRAMEPER SEEK.frame 000 / 2880%
    1.20s0029
  • ONE FRAMEPER SEEK.frame 000 / 2880%
    2.00s0048
  • ONE FRAMEPER SEEK.000048frame 000 / 2880%
    2.90s0070
  • ONE FRAMEPER SEEK.000048096144frame 000 / 2880%
    3.60s0086
  • ONE FRAMEPER SEEK.000048096144192240frame 001 / 2880%
    4.60s0110
  • ONE FRAMEPER SEEK.000048096144192240frame 029 / 28810%
    5.60s0134
  • ONE FRAMEPER SEEK.000048096144192240frame 177 / 28861%
    6.80s0163
  • ONE FRAMEPER SEEK.000048096144192240frame 270 / 28894%
    7.80s0187
  • ONE FRAMEPER SEEK.000048096144192240frame 288 / 288100%H.264
    8.70s0209
  • ONE FRAMEPER SEEK.000048096144192240frame 288 / 288100%H.264
    9.60s0230
  • ONE FRAMEPER SEEK.seekreeldeliver/reel.mp4000048096144192240frame 288 / 288100%H.264
    11.20s0269

If it runs in your browser, it ships as video.

No timeline app to learn, no export plugin, no runtime to install. Your page takes a timestamp and draws that moment — React, GSAP, three.js or no library at all. Three rules, and seekreel does the rest.

  1. 01Read the timestamp.It arrives as ?t= on the query string, in seconds.
  2. 02Draw that moment, synchronously.Set properties from t rather than leaving CSS transitions to their own clock.
  3. 03Say when the frame is final.Stamp data-seekreel-ready on the document and the shutter goes.
stage.html
<div id="box"></div>
<script>
  const t = parseFloat(new URLSearchParams(location.search).get("t") || "0");
  const span = (a, b) => Math.min(1, Math.max(0, (t - a) / (b - a)));

  box.style.opacity = span(0, 1);
  box.style.transform = `translateY(${40 * (1 - span(0, 1))}px)`;

  document.documentElement.setAttribute("data-seekreel-ready", "1");
</script>

Bring your own GSAP timeline

Keep the easing, the staggers and the choreography you already wrote. Build the timeline paused, seek it once, and it renders — just skip callbacks, which a seek passes over.

stage.js
const tl = gsap.timeline({ paused: true });
tl.from("#card", { y: 24, opacity: 0, duration: 0.9, ease: "expo.out" }, 0.1)
  .to("#bar i", { scaleX: 1, duration: 3.4, ease: "power1.inOut" }, 1.2)
  .to(".tray", { opacity: 1, duration: 0.5, stagger: 0.13 }, 6.0);

tl.time(t); // the only line that touches the clock

Or a React component

A tree that is a pure function of the timestamp is already a film. Helpers are served to every render at /_seekreel/stage.js — nothing to install, and nothing to vendor.

stage.jsx
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { time, renderReact } from "/_seekreel/stage.js";

// flushSync is not optional: React 19 returns before it commits, and
// a frame marked ready before the commit screenshots an empty page.
renderReact(
  { root: createRoot(document.getElementById("stage")), flushSync },
  <Film t={time()} />,
);

The whole thing, running here.

Pick a stage — plain DOM, a React tree or a three.js scene — then set the length, the frame rate, the shape and the sound. Every control is a key in the config printed beside it, and the file list underneath is what seekreel build would write. Drag the scrubber: each moment you land on is exactly the frame the render puts on disk.

loading three.js…

t = 0.000s · frame 000 / 19224fps · 1080×1080 → 1:1 · webgl

one render call per frame, software GL. Rendering this exact moment on its own is seekreel probe 0.00; re-cutting the shot around it is seekreel render 0 12.

Stage
Duration
Frame rate
Ratio
Fit
Sound
Also write

seekreel.config.json

{
  "stage": "stage.html",
  "duration": 8,
  "fps": 24,
  "width": 1080,
  "height": 1080,
  "poster": 6.0,
  "webgl": true,
  "audio": { "engine": "strudel", "pattern": "music.strudel.js", "cps": 0.5 },
  "variants": [
    { "name": "" },
    { "name": "gif", "format": "gif" }
  ]
}

seekreel build writes 3 files, all from the same 192 frames

  • reel.mp4
  • reel-gif.gif
  • reel-poster.png

None of the three stages accumulates anything — no rotation.y += 0.01, no frame counter carried forward — so every moment is computed rather than played into, which is what makes a seek and a render agree. The three.js one turns "webgl": true on for you: software GL, identical shading on every machine, about a third of a second a frame instead of a fifth.

Never re-record because a font loaded late.

A recording keeps whatever your machine managed to draw that afternoon: dropped frames, a late font, a scroll two pixels off. Render instead and the file is the same every time — which also means it can rebuild itself in CI whenever the page changes.

The trade is speed, and we would rather say it up front: a page load and a screenshot per frame, so budget about a second each. A 43-second film takes roughly twenty minutes. Worth it for something you cut once and re-cut in pieces; wrong for anything interactive.

above · screen recording, 3 frames droppedbelow · seekreel, one frame per timestamp

Already have a screen recording? Cut it here.

A product demo is mostly a recording with things on top: a title, captions, a zoom on the button that matters, a timelapse through the boring part. Record the flow with Playwright, have a voice read the script, and describe the cut as an edit list. seekreel footage turns it into one JPEG per frame, and the stage draws everything else over it.

from / to
Trim each clip to its in and out points in the source.
speed
30 is a timelapse through the waiting; 0.5 is slow motion.
hold
Freeze the last frame for as long as the voice needs.
transition
A cut, or any ffmpeg xfade: fade, fadeblack, wipeleft, slideup, circleopen, zoomin, pixelize…
still / color
Title cards and an end card, between the recordings.

"duration": "footage" makes the film exactly as long as the cut, so re-trimming a clip never means editing the length by hand. seekreel build cuts the footage first whenever the config has some.

Why frames, not a <video>

It was the first thing tried. Headless Chromium reports a seek as done before the decoded picture is painted, so a seeked video showed the right frame most of the time — and frame 0 the rest. Open-source Chromium cannot decode H.264 at all. An image of frame N is right every time.

seekreel.config.json
"duration": "footage",
"footage": {
  "out": "footage",
  "clips": [
    { "label": "title",  "color": "#0d0d10", "duration": 2 },
    { "label": "signup", "src": "rec.webm", "from": 1.2, "to": 9.5,
      "transition": { "type": "fade", "duration": 0.5 } },
    { "label": "wait",   "src": "rec.webm", "from": 9.5, "to": 190,
      "speed": 30, "transition": "wipeleft" },
    { "label": "result", "src": "rec.webm", "from": 190, "to": 204, "hold": 1.5 },
    { "label": "end",    "still": "cta.png", "duration": 3,
      "transition": { "type": "fadeblack", "duration": 0.6 } }
  ]
}

Zooms, overlays and captions, timed to the cut

toFilm says where a moment of the recording landed after trims and speed changes — log your click times while recording and punchIn zooms on each one. shown fades a chip in and out across a clip; captionAt picks the line on screen.

stage.html
import { time, loadFootage, punchIn, shown, captionAt, markReady } from "/_seekreel/stage.js";

const t = time();
const film = await loadFootage("footage");   // what seekreel footage wrote
const img = await film.image(t);             // this frame, decoded
img.style.transformOrigin = "0 0";
// zoom on the click you logged at 6.1s into the recording
img.style.transform = punchIn([{ t: film.toFilm("signup", 6.1), x: 1210, y: 640 }], { scale: 1.25 });
document.body.append(img);

const wait = film.byLabel("wait");
chip.style.opacity = shown(wait.start, wait.end);        // "30×" while the timelapse runs
caption.textContent = captionAt(captions, t)?.text ?? "";
markReady();

examples/screen-demo is a nine-second film that uses all of it — a fade, a 4× timelapse behind a wipe, a freeze, a punch-in and captions — cut to 16:9 and 9:16 from one render.

One render. Every cut the feed asks for.

Ask for 9:16 and the canvas changes shape while the picture stays exactly where you put it — padded in your own background colour, or cropped if you say cover. Name a size instead when a platform wants pixels. Drop the frame rate for a gif. Rendering is the slow part, so it happens once and every deliverable is cut from the same frames.

mp4
H.264 + AAC. The one every feed accepts.
webm
VP9 + Opus, for autoplay on your own site.
gif
Palette-mapped, for a README or a changelog.
mov
ProRes 422 HQ, for an edit timeline.
16:99:164:51:1one render
seekreel.config.json
"variants": [
  { "name": "" },
  { "name": "4x5",    "ratio": "4:5" },
  { "name": "story",  "ratio": "9:16" },
  { "name": "og",     "size": "1200x628", "fit": "cover" },
  { "name": "web",    "format": "webm" },
  { "name": "loop",   "format": "gif", "fps": 12 },
  { "name": "silent", "audio": false }
]
family.mp4 · 48 s · 1,152 frames · rendered with the pack, unedited

Start from a finished film.

The family sample pack is everything that went into a real seekreel film, made generic: a cast, three sets, the React components on top, and the soundtrack. This movie is its sample project. Copy it, change the words, hang your own photos, render.

cast
Squashy blobs with caps, glasses, buns and bows, a baby and a dog.
sets
An island picnic, a plane to a beach, a pink museum wall.
react
Captions that pop word by word, bubbles, chapter cards, photos.
music
A bossa nova in Strudel, arranged on the same bar lines as the cuts.
terminal
# three.js, Motion, React, Strudel and the font
sh family/setup.sh

# the sample movie: mp4, 9:16, gif and a poster
seekreel build -c family/movie.config.json

It ships with the normal install and stays out of --minimal. Read the guide.

The cast, drawn from t.

These are the pack's own characters, imported straight from family/kit/actors.js and rendered in your browser. Every blink, turn and entrance is computed from the timestamp, so scrub anywhere and you see exactly the frame seekreel would render.

t = 3.000s · frame 072 / 144cap and glasses · bun · bow · sprout · baby · dog

From empty folder to finished cut.

Footage if the config has any, then audio, then every frame, then ffmpeg — one command from a page to the files you upload.

terminal
$ seekreel build
  • footage/*.jpg
  • soundtrack.wav
  • out/*.png
  • deliver/reel.mp4
  • deliver/reel-4x5.mp4

Sound you can move by half a second.

Synthesized, not sampled — no library to licence, no clearance to chase, and no re-editing audio when a shot shifts. Write a Strudel pattern and you get music with a grid of its own, rendered offline in the same Chromium that shoots the frames — set the cycle length to divide your film and the cuts land on downbeats. Or keep the cue sheet: one-off hits placed by timestamp, under a looping bed, from python3 and its standard library.

  • marimba
  • bass
  • pad
  • swell
  • chime
  • pip
  • tick
  • shutter
  • pop
  • whoosh
  • stamp
  • tray
  • shaker
music.strudel.js
setcps(0.625) // a cycle is 1.6s, so a 16s film is exactly ten of them

stack(
  note("c1").s("sine").struct("x ~ ~ ~ x ~ ~ ~").decay(.26).sustain(0),
  s("white").struct("x*8").decay(.03).hpf(7200).gain("[.3 .14]*4"),
  note("<c2 ab1 eb2 bb1>").s("sawtooth").lpf(sine.range(420, 1500).slow(4)),
  note("<c5 eb5 g5 bb4>(3,8)").s("triangle").delay(.4).room(.6)
)
cues.json
{
  "duration": 6,
  "bed": {
    "tempo": 100,
    "progression": [{ "tones": ["D3", "F3", "A3", "C4"], "root": "D2" }],
    "dense": [1.2, 5.2]
  },
  "cues": [
    { "at": 0.1, "sound": "swell", "note": "F2", "dur": 1.4 },
    { "at": 3.0, "sound": "pop" },
    { "at": 4.2, "sound": "chime", "pan": -0.2 }
  ]
}

Voiceover, with the music ducking under it

A narration you recorded, or lines read by a text-to-speech service, goes on the same cue sheet as WAV tracks placed by timestamp. duck lowers the bed and the cues while a voice is sounding.

cues.json
"tracks": [
  { "at": 1.2, "file": "voice/intro.wav" },
  { "at": 9.8, "file": "voice/wizard.wav", "gain": 0.9 }
],
"duck": { "gain": 0.3, "attack": 0.12, "release": 0.45 }