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 | shMIT · Node 22.18+ · no npm package — it clones the repo and links the CLI
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.
0.40s0010 1.20s0029 2.00s0048 2.90s0070 3.60s0086 4.60s0110 5.60s0134 6.80s0163 7.80s0187 8.70s0209 9.60s0230 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.
- 01Read the timestamp.It arrives as ?t= on the query string, in seconds.
- 02Draw that moment, synchronously.Set properties from t rather than leaving CSS transitions to their own clock.
- 03Say when the frame is final.Stamp data-seekreel-ready on the document and the shutter goes.
<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.
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 clockOr 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.
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.
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.
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.
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.
"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.
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.
"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 }
]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.
# 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.jsonIt 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.
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.
$ 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
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)
){
"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.
"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 }