A window onto Claude Code that makes videos. Drop your material on the left, say what you want in the middle, watch the video appear on the right.
It is a thin wrapper and nothing more. The thing doing the work is Claude Code with the HyperFrames skills, which is what lets the box in the middle take a plain English sentence instead of the fifteen controls an editor would need.
The rendering runs entirely on your own machine: your material is never uploaded and there is no cost per video. The only account it needs to make a video out of your own files is Anthropic's.
If you have never opened a terminal, there is a click-by-click version of everything below, one step at a time: https://oleg.ae/claude-code-instagram
Four things, once.
node -v. Twenty is not enough: the HyperFrames renderer needs 22, and on
an older Node the app installs cleanly and then fails at the moment it
writes the video.curl -fsSL https://claude.ai/install.sh | bash
on macOS and Linux, irm https://claude.ai/install.ps1 | iex in PowerShell
on Windows (npm install -g @anthropic-ai/claude-code gets you the same
binary). Then run claude once in a terminal and sign in. This is the part
that actually makes the videos, so it has to work on its own before the
studio will do anything. It needs a Claude subscription (Pro or better —
the free plan does not include Claude Code) or Console credit.brew install ffmpeg on a Mac, sudo apt install ffmpeg on
Ubuntu, winget install Gyan.FFmpeg.Essentials on Windows. Rendering shells
out to it.And one more, only if you want the studio to find things for you rather than
only cut what you gave it: the heygen CLI, signed in. Stock footage,
generated music, icons, cloned voices and colour grades all come through the
media-use skill, which needs it. Everything made from your own photos, clips,
voice takes and licensed tracks works without it. That is the one place where a
third party is involved, and it is opt-in.
The studio runs claude as a program, not as an editor plug-in, so what has to
work is claude typed into a terminal. Installing the VS Code extension is
convenient — it gives you the same thing in a side panel — but the extension
deliberately does not put claude on your PATH, so it is not on its own enough.
If claude --version prints a number, you are ready.
Platforms. Built and used on macOS; Linux is the same shape. Windows is
best-effort: npm install may need the lockfile deleted first, and the studio
starts a run by spawning claude directly, which Node will refuse to do if what
your install put on the PATH is a .cmd shim rather than a real executable. If
a run dies the instant it starts on Windows, that is the reason, and the fix is
the native installer above rather than the npm one.
git clone https://github.com/melnikoff-oleg/reel-studio.git
cd reel-studio
No git? Open https://github.com/melnikoff-oleg/reel-studio, press the green Code button, choose Download ZIP, and unzip it. The folder you end up with is the whole thing — the app, the agent's instructions, and every HyperFrames skill it uses. Nothing else gets fetched at install time except npm packages.
Put it wherever you keep your own files. It writes only inside itself, so a Documents folder is fine and so is the Desktop.
In a terminal inside the folder:
npm install
npm run dev
Then open http://localhost:4321
On a Mac you can skip the terminal entirely: double-click start.command. It
installs what it needs the first time, starts the studio, and opens your
browser. macOS may ask you to confirm the first time — right-click it and choose
Open. (Windows and Linux: use the two commands above.)
The terminal it leaves behind is the app's engine. Nothing in it needs reading; closing it stops the studio.
npm install takes a minute or two. The first video takes much longer than
later ones: the agent installs the HyperFrames renderer on first use and pulls
down its own copy of Chromium (~150 MB) to render pages into frames, so that
first run needs an internet connection even though the rendering itself is
local. A short video is usually a few minutes; a long one with captions and
music can be fifteen. The middle panel shows a spinner and a clock for as long
as it is working.
A fresh clone has an empty music/ folder, on purpose — a licence is yours,
not this repo's to ship. The studio works without it; ask for music and the
agent will say there is none. Drop your own licensed .mp3 or .wav files into
music/ and they become available to every project at once.
projects/ starts empty too, and stays empty until you actually give the studio
something. Opening the app creates nothing.
claude in a terminal; until that works, the studio can't.npm run dev -- -p 4400
and open that port instead; start.command reads PORT for the same reason.out/ folder. If the agent says it wrote one somewhere else, tell
it: "put the finished video in this project's out folder".package-lock.json and run npm install again.claude
you have works but has no credit or no subscription attached; or it is old
enough not to know --permission-mode dontAsk and exits on the unknown flag
(claude --version must be 2.1 or newer); or you are on Windows and what is
on your PATH is a .cmd shim. Run claude in a terminal and send it one
message; whatever it says there is the real error.node -v. Below 22, the
renderer will not run.music/ folder in this repo, which ships empty; anything found rather
than supplied needs the heygen CLI signed in (see "What you need").There is nothing else to configure and no file to edit. That is the point: a person who does not know what a project folder is can make a video here.
Whatever you can describe. Captions timed to your voice, footage cut to a beat, motion graphics, a talking-head recut with the pauses taken out, a photo montage, a title card, a different look on the same edit.
Nothing on the working screen suggests what to type. Example pills lived under the chat box briefly and were cut: an empty box says "anything", and a pair of examples quietly narrows it to two things. What it can do lives on its own page, read once.
If it asks a question, answer it in the box. It is a conversation, not a form.
"What it can do", top right, is the one page of documentation: what it makes, what it can find for you, and the sentence that gets each one. It reads in a minute and is deliberately the quietest thing in the header — it answers a question you have once, before you start.
When a video looks the way you want, press Save this style, type a name, press return. That is the whole of it — the name is the only thing saving needs from you, because everything else the agent can read off the video it just made.
The saved style then appears as a chip above the chat box, under Your styles. Click it and the sentence lands in the box: "Use my saved X style for this." Add whatever else you want and press Build. It applies to completely different material — that is the point of saving one.
A style is a Claude Code skill, and that is not a metaphor: it is a folder with
a SKILL.md in .claude/skills/, exactly like the workflows that ship with
this app, found by the agent the same way. Nothing about it is specific to this
app, and you can edit or delete one by hand.
The studio knows which skills are yours by knowing which ones came with it — anything else in that folder is a style you saved. No registry, no marker, and no cooperation needed from the agent that wrote it.
A style does not have to come from a video the studio made. New style from a video, at the end of the chips, takes a video you admire — yours or anyone's — uploads it into the project like any other clip, and has the agent study it: a few frames pulled out with ffmpeg and looked at on your own machine, no external service. Name it, press return, and the look joins the rail as a chip you can apply to your own material.
The studio runs Claude Code with this folder as its working directory, which
means the agent reads this folder's CLAUDE.md and this folder's
.claude/skills. Zip the folder, hand it to somebody, and the instructions and
the whole pipeline travel with it.
CLAUDE.md — the ten lines that tell the agent what job it has.claude/skills/ — HyperFrames and its workflows, plus any style you saveprojects/ — one folder per video, your material and your rendersmusic/ — the shared track library; drop your licensed beds in hereWhat does not travel with it is whatever you already have globally: your own
~/.claude/CLAUDE.md, your global skills, your settings and your MCP servers
all still load on top. Nothing here isolates the run from them. On a machine
with a heavily customised global config, the agent is this folder's
instructions plus yours, not this folder's alone.
.claude/skills/ is HeyGen's open-source HyperFrames skill set, vendored so a
clone is self-contained and so a style you save lands beside them. They are
theirs, not this repo's invention.
Every video lives in projects/<date>_<name>/, and the folder listing IS the
list — no database, no index file, so a project copied in by hand appears and a
deleted one is simply gone. The switcher beside the wordmark opens past projects;
it is a chip in the header rather than a gallery screen because the app opens on
the thing you came to make, and a picker in front of that would mean choosing
before starting.
Renaming is in the switcher: open it, press Rename, type, press return. The folder on disk is renamed too — the folder IS the project here, it is what you open in Finder and copy to another machine, and a project called "reel" on disk while the app calls it something else is two names for one thing. The date prefix is kept, since that is what orders the folder and not the part anyone is renaming.
Deleting is in the switcher too: Delete under the current reel, a bin on every row of the list. It takes two clicks — the first turns the button red and makes it say what it is about to do — because it removes the folder from disk and there is no undo. If the reel had a live agent, that run is stopped and forgotten with it.
Two rules that look like bugs if you don't know them:
POST /api/projects make the folder. Otherwise every idle visit would
leave an empty dated folder behind and the switcher would fill with projects
nobody made.?p=<slug>), which is what lets two projects be open in two tabs with a run
going in each. Sessions have always been keyed by folder; only the UI was
single-project.loadState also takes an
explicit slug now, and carries a sequence number so a slow early answer can't
overwrite a fast later one.fetch still cannot report upload
progress, and sixty camera originals is a real wait — a wait with nothing
moving reads as nothing happening. The dock turns into a labelled progress
bar driven by actual bytes.moveSession re-keys it,
and the next message tells the agent the new path, because its opening turn
named the old one and it would otherwise render into a folder that is gone.
That function writes the session's fields directly instead of calling a
method on it, and that is not a style choice: the session map lives on
globalThis so a hot reload cannot orphan a running render, which means it
can hold instances built from an earlier version of the module, whose
prototype is missing any method added since. Calling one throws mid-rename.Two environment variables survive, and they are the whole configuration surface.
REEL_PROJECTS moves the projects folder somewhere other than inside the app —
useful if your videos live on an external disk. REEL_PROJECT points the studio
at one single folder outside the app, which is how it ran before projects
existed.
A video is never right the first time: you render, watch it, change the music,
render again. Those renders accumulate in the project's out/, so the rail under
the picture needs no new bookkeeping — it numbers them oldest-first (v1, v2, v3)
and clicking one plays it.
Clicking an older one also puts an "About v2" chip in the composer, and the next message carries a line telling the agent which cut is meant. The chip is visible and removable on purpose: selecting a version silently changing what your sentence means would be hidden state.
Each render keeps everything it was built from. When a run finishes and
wrote a new .mp4, snapshotRender clones the whole project — composition,
build scripts, stems, notes — into versions/<render-stem>/, minus output and
installs, with an about.json recording what was asked for. Which folders a
build uses is the agent's business and differs per kind of video, so the
snapshot takes everything rather than a list this app maintains. Without it, "go
back to v2 but with the other track" means rebuilding v2 from memory, because
the next render overwrites the composition in place.
This is affordable only because it clones: cp -c makes the tree copy-on-write,
so a measured snapshot of the Paris reel is 76 MB of materials for 0.5 MB of
actual disk, written in 58 ms. A filesystem without clonefile falls back to a
real copy rather than keeping nothing. Snapshotting is wrapped in a catch — it is
a courtesy to your future self, never the thing that fails a finished render.
POST /api/run spawns Claude Code in this app's folder. The whole command,
since it is the one thing worth being able to reproduce by hand:
claude -p <prompt> --output-format stream-json --verbose \
--include-partial-messages --forward-subagent-text \
--permission-mode dontAsk \
--allowedTools Read,Write,Edit,Glob,Grep,Bash,WebFetch,WebSearch,Skill,Task,TodoWrite \
[--resume <session id>]
with the cwd set to this folder, the environment inherited plus
CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0, stdin ignored, and no --model —
so it runs whatever model you have set as your default. The first turn names
the hyperframes skill and the project path; every later turn passes
--resume so feedback lands in the same session. It names the router rather
than one workflow — the router reads the request and picks captions, motion
graphics, a slideshow or whatever fits. Naming a workflow here would make the
studio a machine for one kind of video.
The process is owned by a module-level session, not by the HTTP request, so a render that takes fifteen minutes survives page reloads.
GET /api/events is an SSE feed. Streamed text and reasoning arrive as deltas
and are coalesced server-side into one growing event per block, so the client
upserts on seq instead of appending thousands of fragments.
GET /api/media/<path>?p=<slug> serves photos and the video out of the project
folder, with Range support so the video can be scrubbed. The slug is a single
path segment of safe characters and the path is resolved and confined to the
project root — those two checks are the whole of what stands between a query
parameter and the rest of the disk.
POST /api/upload takes files dropped on the left panel and writes them into
the project. Each kind lands in whichever folder already holds most of that
kind, so a dropped file arrives where its kind lives rather than in a directory
of this app's invention. Photos, video, audio and notes are accepted, the name
is reduced to a basename before it touches the disk, and an existing name is
never overwritten (rec1.m4a next to a rec1.m4a becomes rec1-2.m4a).
The right panel plays a render out of the project's out/ (or reel/out, for
projects made before that convention), and the rail under it lists every one.
The agent chooses the filename; the app discovers it.
The engine can still record a run to
<project>/YYYY-MM-DD_studio_run_HHMM.jsonl (POST /api/run with
{"record": true}) and replay one at 3x pacing (POST /api/replay). Nothing in
the interface exposes either: the record tick-box and the replay picker were two
controls and a filename dropdown that no viewer of a demo needs, and they cost
more clarity than they bought. The capability is kept because a scripted
simulation of a run may be wanted later; if it comes back, it should come back as
something that does not look like a settings panel.
The agent runs unattended, so nothing may block on an approval prompt. The run
uses --permission-mode dontAsk with an explicit allowlist, which denies every
tool not named:
Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch, Skill, Task,
TodoWrite
Bash is unscoped, because rendering shells out to python, ffmpeg and the
HyperFrames renderer. That means an unattended agent can run arbitrary shell
commands with your user's privileges. That is the deal this tool makes to be a
one-click studio: fine on your own machine, not something to expose to an
untrusted prompt or run on a shared box.
WebFetch and WebSearch are there because a link is one of the materials the
left panel accepts, and "make a video about this page" has to be able to read
the page. Without them the only route is a bash curl, which is a worse tool
for the job on a wider surface.
Narrow ALLOWED_TOOLS in lib/session.ts if you want a tighter surface — e.g.
scope bash with Bash(python3 *).
The direction is "darkroom glass": translucent slabs floating over a grainy almost-black ground, with Instagram's palette used as an accent rather than as the surface. Things that are easy to mistake for oversights and undo:
--edge, --lit
and the dark halo in --drop — do not lighten the ground.--brand-mark is three stops and not six — six reads as a flag, three reads as
light falling across the letters — and why the two arrows do not share a
gradient. Everything else is achromatic on purpose, so the photographs and the
reel are the only saturated things on screen. --safelight is held clear of
the spectrum's orange end so an error can never be mistaken for an accent. No
Instagram mark or wordmark appears anywhere — this borrows a palette, it does
not impersonate a brand..ambient is a fixed layer, not background-attachment: fixed. The latter
forces the whole compositing tree to repaint on every scroll.object-fit, never by layout: the panel sizes the
video with height: 100%, and 100% of an auto-sized box is indefinite, so the
video falls back to its intrinsic 1080x1920 and runs off the bottom of the
screen. Measured before and after: 880x1564 in a 880x600 window, then 880x600
with the picture letterboxed at 9:16 inside it.:hover rule and its [data-playing] rule have equal specificity, so for a
while the hover won and crossing the picture with the pointer dropped a pool of
shade and a pause glyph over a reel someone was watching, full screen included.
The playing rule now comes second and takes pointer-events with it, so the
element cannot be hovered while it is invisible and the picture itself is the
click target. Verified in all three states: playing with the pointer parked in
the middle (overlay 0, pointer-events: none, elementFromPoint is the
video), clicking it (paused, overlay back and clickable), and the same again
in full screen. Keyboard focus is the one exception, because a control has to
be findable to be usable.tool_result rows are filtered out
of the stream for the same reason — they were the noisiest thing the engine
emits and the least legible.The left panel is the drop target — the whole panel, not a strip inside it — because a pile of your own photographs is something you throw at a place. It lights up and says "Drop to add" while something is over it, and the files it is already holding are drawn as files: the real filename with its real extension, the real size, and an icon for which of the two kinds it is.
That last part reverses an earlier decision, on purpose. Filenames and sizes were cut from this panel as residue, and they are back because the panel's job changed: once it is somewhere you can put a file, what is in it has to look like what you would put there, or the drop zone is a claim the interface doesn't back up. Dragging is also invisible until you try it, so there is an "Add files" button doing the same job for anyone who never thinks to drag.
Every other label on this page had to earn its place, and most of them didn't. Removed deliberately, so they don't get helpfully added back: the file count on Materials, the project folder's name in the header, the source folder under each shelf, per-shelf counts, photo filenames, the reel's duration, the render's filename, the "9:16" badge, the "Idle" state readout, and the "Cmd + Enter to build · follow-ups stay in the same session" hint.
What is left: a shelf per kind of material with an icon and a one-word name, one row per source file, the dock, and the three panel headings.
useSheetCapacity measures how many whole rows of tiles fit in the height the
panel has left once the Audio shelf and the dock have taken their share, and
shows exactly that many with a +N tile in the last slot. A fixed cap cannot
satisfy both halves of what the panel owes: too high and Audio drops below the
fold, which is the thing the owner objected to most; too low and a tall window
ends in a slab of dead space. At 1680px wide it settles on 12 tiles at 760px
tall, 30 at 1000, 48 at 1300.
It cannot oscillate, because every input is a container the content does not
size — the scroller's height comes from the panel, the audio shelf's from its own
rows, and scrollbar-gutter: stable holds the width whether or not a scrollbar
is showing. Note that the room left over is computed from the elements, never
from scrollHeight: that reports the box's height rather than the content's
whenever the content is the shorter of the two, which hands back the dead space
the measurement exists to remove.
A link is a material. A video is often made about something that lives on
the web, and a panel that only takes files says that kind of source doesn't
exist — so the URL ends up in the chat box, where it scrolls away. Add link puts
it on the shelf beside the photos. They are kept in a plain links.md at the
top of the project, one per line, because the agent reads the folder anyway and
a markdown list needs no parser, no schema and no explaining; editing it by hand
works. A bare bounded.app is what a person types, so a scheme is assumed
rather than refused, and only http and https survive — everything else a
browser will hand over (javascript:, file:, data:) is either useless to
the agent or something it should never be handed at all. A link is also the one
material you can add by mistyping it, so it is the one with an × on the row.
The Clips shelf was cut once, when this made photo reels and only photo reels,
and is back now that it doesn't: a studio that silently swallows the footage you
dropped is worse than one that shows it. Notes stayed cut. A script or a
transcript is something the agent reads, not something you look at while making
a video — dropped text still lands in the project, it just isn't a shelf.
Type is three faces with three jobs. Bricolage Grotesque carries the names (wordmark, panel titles) at 700/800 — a grotesque, so it reads at a glance from across a room, with an irregular skeleton that keeps it off the system-font shelf. DM Sans is the interface voice and is the face already on the channel's thumbnails. JetBrains Mono is the instrument readout: stream, paths, sizes.
lib/thumbs.ts resizes photos with sharp and caches them under the OS temp
directory; the sheet requests ?t=200. This is not an optimisation, it is the
difference between working and not: the photos are camera originals — 158 MB
across 73 files, up to 5712x4284 — and handing those to the browser to paint a
72px tile meant it decoded every one at full resolution. Scroll frames were
hitting 500 ms, with a worst case of 2.7 seconds. With thumbnails the same scroll
never exceeds 10 ms, and a cold cache fills all 73 tiles in about 600 ms.
Only a fixed set of widths is honoured, because an open size parameter is a resize bomb. Cache keys include size and mtime, so editing a photo invalidates its thumbnail without anyone clearing anything.
Both of these look like bugs if you don't know the rule.
scanMaterials skips out/, reel/, build/ and versions/. A build writes
a card and a punch crop of every photo, so without that skip 73 real photos
show up as 260 near-identical tiles — and versions/ holds a clone of all of
that per cut.out/
and reel/out are listed. A build leaves dozens of per-attempt renders around
and dropped footage is an .mp4 too; both would otherwise show up as cuts. The
panel opens on the newest one and the rail is how you get back to the others.music/ library in this folder, so nothing in the project folder is the
track — lib/music.ts lists the library and looks for each filename in the
project's own text instead. A reel can be scored from more than one track and
usually is: this one runs a different bed under its Dubai section and its Paris
section. An earlier version narrowed matches to the ones whose line also named
the project, on the theory that a build script's spec table lists other videos'
beds. That theory was wrong here and it silently hid a track that really is in
the cut, so every referenced track now counts.newestRenderSince, not "newest file". Otherwise every completed turn dragged
the panel onto whatever was newest in reel/out, including experiments you had
deliberately switched away from.tool and
tool_result rows are both filtered out, and the engine's "Session up, N
tools available" is no longer emitted at all — bash, Read and Glob are the
machinery of making a video rather than the making of it, and someone who has
never opened a terminal reads a wall of them as errors. That is only honest
if something says work is still happening, so the stream ends in a spinner
and an elapsed clock for as long as the run lasts. The clock does the real
work: four minutes with a number on it is patience, four minutes without one
is worry. Its start is read off the last asked event rather than kept in
state, so a reload mid-render still shows the true elapsed time.thinking blocks stream with no text. Blocks are emitted lazily, so those
never show up as blank rows.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 is set on the child so a long render
is not cut off by the default background-task grace period./api/media builds its own ReadableStream instead of using
Readable.toWeb. Every seek and every switch between cuts abandons a range
request mid-flight, and that adapter keeps enqueueing into a controller the
runtime has already closed — an ERR_INVALID_STATE thrown from inside it, which
no try/catch here can reach, arriving as an uncaughtException. Six full UI
passes went from 16 of them to none. Don't swap the hand-wired version back.--bare is deliberately not passed. It would skip loading project skills, and
the skill is the entire pipeline.6 commits
JavaScript
49.5%
HTML
38.1%
Python
4.9%
TypeScript
4.8%
Shell
1.4%
CSS
1.2%
A window onto Claude Code that makes videos. Drop your material on the left, say what you want in the middle, watch the video appear on the right.
It is a thin wrapper and nothing more. The thing doing the work is Claude Code with the HyperFrames skills, which is what lets the box in the middle take a plain English sentence instead of the fifteen controls an editor would need.
The rendering runs entirely on your own machine: your material is never uploaded and there is no cost per video. The only account it needs to make a video out of your own files is Anthropic's.
If you have never opened a terminal, there is a click-by-click version of everything below, one step at a time: https://oleg.ae/claude-code-instagram
Four things, once.
node -v. Twenty is not enough: the HyperFrames renderer needs 22, and on
an older Node the app installs cleanly and then fails at the moment it
writes the video.curl -fsSL https://claude.ai/install.sh | bash
on macOS and Linux, irm https://claude.ai/install.ps1 | iex in PowerShell
on Windows (npm install -g @anthropic-ai/claude-code gets you the same
binary). Then run claude once in a terminal and sign in. This is the part
that actually makes the videos, so it has to work on its own before the
studio will do anything. It needs a Claude subscription (Pro or better —
the free plan does not include Claude Code) or Console credit.brew install ffmpeg on a Mac, sudo apt install ffmpeg on
Ubuntu, winget install Gyan.FFmpeg.Essentials on Windows. Rendering shells
out to it.And one more, only if you want the studio to find things for you rather than
only cut what you gave it: the heygen CLI, signed in. Stock footage,
generated music, icons, cloned voices and colour grades all come through the
media-use skill, which needs it. Everything made from your own photos, clips,
voice takes and licensed tracks works without it. That is the one place where a
third party is involved, and it is opt-in.
The studio runs claude as a program, not as an editor plug-in, so what has to
work is claude typed into a terminal. Installing the VS Code extension is
convenient — it gives you the same thing in a side panel — but the extension
deliberately does not put claude on your PATH, so it is not on its own enough.
If claude --version prints a number, you are ready.
Platforms. Built and used on macOS; Linux is the same shape. Windows is
best-effort: npm install may need the lockfile deleted first, and the studio
starts a run by spawning claude directly, which Node will refuse to do if what
your install put on the PATH is a .cmd shim rather than a real executable. If
a run dies the instant it starts on Windows, that is the reason, and the fix is
the native installer above rather than the npm one.
git clone https://github.com/melnikoff-oleg/reel-studio.git
cd reel-studio
No git? Open https://github.com/melnikoff-oleg/reel-studio, press the green Code button, choose Download ZIP, and unzip it. The folder you end up with is the whole thing — the app, the agent's instructions, and every HyperFrames skill it uses. Nothing else gets fetched at install time except npm packages.
Put it wherever you keep your own files. It writes only inside itself, so a Documents folder is fine and so is the Desktop.
In a terminal inside the folder:
npm install
npm run dev
Then open http://localhost:4321
On a Mac you can skip the terminal entirely: double-click start.command. It
installs what it needs the first time, starts the studio, and opens your
browser. macOS may ask you to confirm the first time — right-click it and choose
Open. (Windows and Linux: use the two commands above.)
The terminal it leaves behind is the app's engine. Nothing in it needs reading; closing it stops the studio.
npm install takes a minute or two. The first video takes much longer than
later ones: the agent installs the HyperFrames renderer on first use and pulls
down its own copy of Chromium (~150 MB) to render pages into frames, so that
first run needs an internet connection even though the rendering itself is
local. A short video is usually a few minutes; a long one with captions and
music can be fifteen. The middle panel shows a spinner and a clock for as long
as it is working.
A fresh clone has an empty music/ folder, on purpose — a licence is yours,
not this repo's to ship. The studio works without it; ask for music and the
agent will say there is none. Drop your own licensed .mp3 or .wav files into
music/ and they become available to every project at once.
projects/ starts empty too, and stays empty until you actually give the studio
something. Opening the app creates nothing.
claude in a terminal; until that works, the studio can't.npm run dev -- -p 4400
and open that port instead; start.command reads PORT for the same reason.out/ folder. If the agent says it wrote one somewhere else, tell
it: "put the finished video in this project's out folder".package-lock.json and run npm install again.claude
you have works but has no credit or no subscription attached; or it is old
enough not to know --permission-mode dontAsk and exits on the unknown flag
(claude --version must be 2.1 or newer); or you are on Windows and what is
on your PATH is a .cmd shim. Run claude in a terminal and send it one
message; whatever it says there is the real error.node -v. Below 22, the
renderer will not run.music/ folder in this repo, which ships empty; anything found rather
than supplied needs the heygen CLI signed in (see "What you need").There is nothing else to configure and no file to edit. That is the point: a person who does not know what a project folder is can make a video here.
Whatever you can describe. Captions timed to your voice, footage cut to a beat, motion graphics, a talking-head recut with the pauses taken out, a photo montage, a title card, a different look on the same edit.
Nothing on the working screen suggests what to type. Example pills lived under the chat box briefly and were cut: an empty box says "anything", and a pair of examples quietly narrows it to two things. What it can do lives on its own page, read once.
If it asks a question, answer it in the box. It is a conversation, not a form.
"What it can do", top right, is the one page of documentation: what it makes, what it can find for you, and the sentence that gets each one. It reads in a minute and is deliberately the quietest thing in the header — it answers a question you have once, before you start.
When a video looks the way you want, press Save this style, type a name, press return. That is the whole of it — the name is the only thing saving needs from you, because everything else the agent can read off the video it just made.
The saved style then appears as a chip above the chat box, under Your styles. Click it and the sentence lands in the box: "Use my saved X style for this." Add whatever else you want and press Build. It applies to completely different material — that is the point of saving one.
A style is a Claude Code skill, and that is not a metaphor: it is a folder with
a SKILL.md in .claude/skills/, exactly like the workflows that ship with
this app, found by the agent the same way. Nothing about it is specific to this
app, and you can edit or delete one by hand.
The studio knows which skills are yours by knowing which ones came with it — anything else in that folder is a style you saved. No registry, no marker, and no cooperation needed from the agent that wrote it.
A style does not have to come from a video the studio made. New style from a video, at the end of the chips, takes a video you admire — yours or anyone's — uploads it into the project like any other clip, and has the agent study it: a few frames pulled out with ffmpeg and looked at on your own machine, no external service. Name it, press return, and the look joins the rail as a chip you can apply to your own material.
The studio runs Claude Code with this folder as its working directory, which
means the agent reads this folder's CLAUDE.md and this folder's
.claude/skills. Zip the folder, hand it to somebody, and the instructions and
the whole pipeline travel with it.
CLAUDE.md — the ten lines that tell the agent what job it has.claude/skills/ — HyperFrames and its workflows, plus any style you saveprojects/ — one folder per video, your material and your rendersmusic/ — the shared track library; drop your licensed beds in hereWhat does not travel with it is whatever you already have globally: your own
~/.claude/CLAUDE.md, your global skills, your settings and your MCP servers
all still load on top. Nothing here isolates the run from them. On a machine
with a heavily customised global config, the agent is this folder's
instructions plus yours, not this folder's alone.
.claude/skills/ is HeyGen's open-source HyperFrames skill set, vendored so a
clone is self-contained and so a style you save lands beside them. They are
theirs, not this repo's invention.
Every video lives in projects/<date>_<name>/, and the folder listing IS the
list — no database, no index file, so a project copied in by hand appears and a
deleted one is simply gone. The switcher beside the wordmark opens past projects;
it is a chip in the header rather than a gallery screen because the app opens on
the thing you came to make, and a picker in front of that would mean choosing
before starting.
Renaming is in the switcher: open it, press Rename, type, press return. The folder on disk is renamed too — the folder IS the project here, it is what you open in Finder and copy to another machine, and a project called "reel" on disk while the app calls it something else is two names for one thing. The date prefix is kept, since that is what orders the folder and not the part anyone is renaming.
Deleting is in the switcher too: Delete under the current reel, a bin on every row of the list. It takes two clicks — the first turns the button red and makes it say what it is about to do — because it removes the folder from disk and there is no undo. If the reel had a live agent, that run is stopped and forgotten with it.
Two rules that look like bugs if you don't know them:
POST /api/projects make the folder. Otherwise every idle visit would
leave an empty dated folder behind and the switcher would fill with projects
nobody made.?p=<slug>), which is what lets two projects be open in two tabs with a run
going in each. Sessions have always been keyed by folder; only the UI was
single-project.loadState also takes an
explicit slug now, and carries a sequence number so a slow early answer can't
overwrite a fast later one.fetch still cannot report upload
progress, and sixty camera originals is a real wait — a wait with nothing
moving reads as nothing happening. The dock turns into a labelled progress
bar driven by actual bytes.moveSession re-keys it,
and the next message tells the agent the new path, because its opening turn
named the old one and it would otherwise render into a folder that is gone.
That function writes the session's fields directly instead of calling a
method on it, and that is not a style choice: the session map lives on
globalThis so a hot reload cannot orphan a running render, which means it
can hold instances built from an earlier version of the module, whose
prototype is missing any method added since. Calling one throws mid-rename.Two environment variables survive, and they are the whole configuration surface.
REEL_PROJECTS moves the projects folder somewhere other than inside the app —
useful if your videos live on an external disk. REEL_PROJECT points the studio
at one single folder outside the app, which is how it ran before projects
existed.
A video is never right the first time: you render, watch it, change the music,
render again. Those renders accumulate in the project's out/, so the rail under
the picture needs no new bookkeeping — it numbers them oldest-first (v1, v2, v3)
and clicking one plays it.
Clicking an older one also puts an "About v2" chip in the composer, and the next message carries a line telling the agent which cut is meant. The chip is visible and removable on purpose: selecting a version silently changing what your sentence means would be hidden state.
Each render keeps everything it was built from. When a run finishes and
wrote a new .mp4, snapshotRender clones the whole project — composition,
build scripts, stems, notes — into versions/<render-stem>/, minus output and
installs, with an about.json recording what was asked for. Which folders a
build uses is the agent's business and differs per kind of video, so the
snapshot takes everything rather than a list this app maintains. Without it, "go
back to v2 but with the other track" means rebuilding v2 from memory, because
the next render overwrites the composition in place.
This is affordable only because it clones: cp -c makes the tree copy-on-write,
so a measured snapshot of the Paris reel is 76 MB of materials for 0.5 MB of
actual disk, written in 58 ms. A filesystem without clonefile falls back to a
real copy rather than keeping nothing. Snapshotting is wrapped in a catch — it is
a courtesy to your future self, never the thing that fails a finished render.
POST /api/run spawns Claude Code in this app's folder. The whole command,
since it is the one thing worth being able to reproduce by hand:
claude -p <prompt> --output-format stream-json --verbose \
--include-partial-messages --forward-subagent-text \
--permission-mode dontAsk \
--allowedTools Read,Write,Edit,Glob,Grep,Bash,WebFetch,WebSearch,Skill,Task,TodoWrite \
[--resume <session id>]
with the cwd set to this folder, the environment inherited plus
CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0, stdin ignored, and no --model —
so it runs whatever model you have set as your default. The first turn names
the hyperframes skill and the project path; every later turn passes
--resume so feedback lands in the same session. It names the router rather
than one workflow — the router reads the request and picks captions, motion
graphics, a slideshow or whatever fits. Naming a workflow here would make the
studio a machine for one kind of video.
The process is owned by a module-level session, not by the HTTP request, so a render that takes fifteen minutes survives page reloads.
GET /api/events is an SSE feed. Streamed text and reasoning arrive as deltas
and are coalesced server-side into one growing event per block, so the client
upserts on seq instead of appending thousands of fragments.
GET /api/media/<path>?p=<slug> serves photos and the video out of the project
folder, with Range support so the video can be scrubbed. The slug is a single
path segment of safe characters and the path is resolved and confined to the
project root — those two checks are the whole of what stands between a query
parameter and the rest of the disk.
POST /api/upload takes files dropped on the left panel and writes them into
the project. Each kind lands in whichever folder already holds most of that
kind, so a dropped file arrives where its kind lives rather than in a directory
of this app's invention. Photos, video, audio and notes are accepted, the name
is reduced to a basename before it touches the disk, and an existing name is
never overwritten (rec1.m4a next to a rec1.m4a becomes rec1-2.m4a).
The right panel plays a render out of the project's out/ (or reel/out, for
projects made before that convention), and the rail under it lists every one.
The agent chooses the filename; the app discovers it.
The engine can still record a run to
<project>/YYYY-MM-DD_studio_run_HHMM.jsonl (POST /api/run with
{"record": true}) and replay one at 3x pacing (POST /api/replay). Nothing in
the interface exposes either: the record tick-box and the replay picker were two
controls and a filename dropdown that no viewer of a demo needs, and they cost
more clarity than they bought. The capability is kept because a scripted
simulation of a run may be wanted later; if it comes back, it should come back as
something that does not look like a settings panel.
The agent runs unattended, so nothing may block on an approval prompt. The run
uses --permission-mode dontAsk with an explicit allowlist, which denies every
tool not named:
Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch, Skill, Task,
TodoWrite
Bash is unscoped, because rendering shells out to python, ffmpeg and the
HyperFrames renderer. That means an unattended agent can run arbitrary shell
commands with your user's privileges. That is the deal this tool makes to be a
one-click studio: fine on your own machine, not something to expose to an
untrusted prompt or run on a shared box.
WebFetch and WebSearch are there because a link is one of the materials the
left panel accepts, and "make a video about this page" has to be able to read
the page. Without them the only route is a bash curl, which is a worse tool
for the job on a wider surface.
Narrow ALLOWED_TOOLS in lib/session.ts if you want a tighter surface — e.g.
scope bash with Bash(python3 *).
The direction is "darkroom glass": translucent slabs floating over a grainy almost-black ground, with Instagram's palette used as an accent rather than as the surface. Things that are easy to mistake for oversights and undo:
--edge, --lit
and the dark halo in --drop — do not lighten the ground.--brand-mark is three stops and not six — six reads as a flag, three reads as
light falling across the letters — and why the two arrows do not share a
gradient. Everything else is achromatic on purpose, so the photographs and the
reel are the only saturated things on screen. --safelight is held clear of
the spectrum's orange end so an error can never be mistaken for an accent. No
Instagram mark or wordmark appears anywhere — this borrows a palette, it does
not impersonate a brand..ambient is a fixed layer, not background-attachment: fixed. The latter
forces the whole compositing tree to repaint on every scroll.object-fit, never by layout: the panel sizes the
video with height: 100%, and 100% of an auto-sized box is indefinite, so the
video falls back to its intrinsic 1080x1920 and runs off the bottom of the
screen. Measured before and after: 880x1564 in a 880x600 window, then 880x600
with the picture letterboxed at 9:16 inside it.:hover rule and its [data-playing] rule have equal specificity, so for a
while the hover won and crossing the picture with the pointer dropped a pool of
shade and a pause glyph over a reel someone was watching, full screen included.
The playing rule now comes second and takes pointer-events with it, so the
element cannot be hovered while it is invisible and the picture itself is the
click target. Verified in all three states: playing with the pointer parked in
the middle (overlay 0, pointer-events: none, elementFromPoint is the
video), clicking it (paused, overlay back and clickable), and the same again
in full screen. Keyboard focus is the one exception, because a control has to
be findable to be usable.tool_result rows are filtered out
of the stream for the same reason — they were the noisiest thing the engine
emits and the least legible.The left panel is the drop target — the whole panel, not a strip inside it — because a pile of your own photographs is something you throw at a place. It lights up and says "Drop to add" while something is over it, and the files it is already holding are drawn as files: the real filename with its real extension, the real size, and an icon for which of the two kinds it is.
That last part reverses an earlier decision, on purpose. Filenames and sizes were cut from this panel as residue, and they are back because the panel's job changed: once it is somewhere you can put a file, what is in it has to look like what you would put there, or the drop zone is a claim the interface doesn't back up. Dragging is also invisible until you try it, so there is an "Add files" button doing the same job for anyone who never thinks to drag.
Every other label on this page had to earn its place, and most of them didn't. Removed deliberately, so they don't get helpfully added back: the file count on Materials, the project folder's name in the header, the source folder under each shelf, per-shelf counts, photo filenames, the reel's duration, the render's filename, the "9:16" badge, the "Idle" state readout, and the "Cmd + Enter to build · follow-ups stay in the same session" hint.
What is left: a shelf per kind of material with an icon and a one-word name, one row per source file, the dock, and the three panel headings.
useSheetCapacity measures how many whole rows of tiles fit in the height the
panel has left once the Audio shelf and the dock have taken their share, and
shows exactly that many with a +N tile in the last slot. A fixed cap cannot
satisfy both halves of what the panel owes: too high and Audio drops below the
fold, which is the thing the owner objected to most; too low and a tall window
ends in a slab of dead space. At 1680px wide it settles on 12 tiles at 760px
tall, 30 at 1000, 48 at 1300.
It cannot oscillate, because every input is a container the content does not
size — the scroller's height comes from the panel, the audio shelf's from its own
rows, and scrollbar-gutter: stable holds the width whether or not a scrollbar
is showing. Note that the room left over is computed from the elements, never
from scrollHeight: that reports the box's height rather than the content's
whenever the content is the shorter of the two, which hands back the dead space
the measurement exists to remove.
A link is a material. A video is often made about something that lives on
the web, and a panel that only takes files says that kind of source doesn't
exist — so the URL ends up in the chat box, where it scrolls away. Add link puts
it on the shelf beside the photos. They are kept in a plain links.md at the
top of the project, one per line, because the agent reads the folder anyway and
a markdown list needs no parser, no schema and no explaining; editing it by hand
works. A bare bounded.app is what a person types, so a scheme is assumed
rather than refused, and only http and https survive — everything else a
browser will hand over (javascript:, file:, data:) is either useless to
the agent or something it should never be handed at all. A link is also the one
material you can add by mistyping it, so it is the one with an × on the row.
The Clips shelf was cut once, when this made photo reels and only photo reels,
and is back now that it doesn't: a studio that silently swallows the footage you
dropped is worse than one that shows it. Notes stayed cut. A script or a
transcript is something the agent reads, not something you look at while making
a video — dropped text still lands in the project, it just isn't a shelf.
Type is three faces with three jobs. Bricolage Grotesque carries the names (wordmark, panel titles) at 700/800 — a grotesque, so it reads at a glance from across a room, with an irregular skeleton that keeps it off the system-font shelf. DM Sans is the interface voice and is the face already on the channel's thumbnails. JetBrains Mono is the instrument readout: stream, paths, sizes.
lib/thumbs.ts resizes photos with sharp and caches them under the OS temp
directory; the sheet requests ?t=200. This is not an optimisation, it is the
difference between working and not: the photos are camera originals — 158 MB
across 73 files, up to 5712x4284 — and handing those to the browser to paint a
72px tile meant it decoded every one at full resolution. Scroll frames were
hitting 500 ms, with a worst case of 2.7 seconds. With thumbnails the same scroll
never exceeds 10 ms, and a cold cache fills all 73 tiles in about 600 ms.
Only a fixed set of widths is honoured, because an open size parameter is a resize bomb. Cache keys include size and mtime, so editing a photo invalidates its thumbnail without anyone clearing anything.
Both of these look like bugs if you don't know the rule.
scanMaterials skips out/, reel/, build/ and versions/. A build writes
a card and a punch crop of every photo, so without that skip 73 real photos
show up as 260 near-identical tiles — and versions/ holds a clone of all of
that per cut.out/
and reel/out are listed. A build leaves dozens of per-attempt renders around
and dropped footage is an .mp4 too; both would otherwise show up as cuts. The
panel opens on the newest one and the rail is how you get back to the others.music/ library in this folder, so nothing in the project folder is the
track — lib/music.ts lists the library and looks for each filename in the
project's own text instead. A reel can be scored from more than one track and
usually is: this one runs a different bed under its Dubai section and its Paris
section. An earlier version narrowed matches to the ones whose line also named
the project, on the theory that a build script's spec table lists other videos'
beds. That theory was wrong here and it silently hid a track that really is in
the cut, so every referenced track now counts.newestRenderSince, not "newest file". Otherwise every completed turn dragged
the panel onto whatever was newest in reel/out, including experiments you had
deliberately switched away from.tool and
tool_result rows are both filtered out, and the engine's "Session up, N
tools available" is no longer emitted at all — bash, Read and Glob are the
machinery of making a video rather than the making of it, and someone who has
never opened a terminal reads a wall of them as errors. That is only honest
if something says work is still happening, so the stream ends in a spinner
and an elapsed clock for as long as the run lasts. The clock does the real
work: four minutes with a number on it is patience, four minutes without one
is worry. Its start is read off the last asked event rather than kept in
state, so a reload mid-render still shows the true elapsed time.thinking blocks stream with no text. Blocks are emitted lazily, so those
never show up as blank rows.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 is set on the child so a long render
is not cut off by the default background-task grace period./api/media builds its own ReadableStream instead of using
Readable.toWeb. Every seek and every switch between cuts abandons a range
request mid-flight, and that adapter keeps enqueueing into a controller the
runtime has already closed — an ERR_INVALID_STATE thrown from inside it, which
no try/catch here can reach, arriving as an uncaughtException. Six full UI
passes went from 16 of them to none. Don't swap the hand-wired version back.--bare is deliberately not passed. It would skip loading project skills, and
the skill is the entire pipeline.6 commits
JavaScript
49.5%
HTML
38.1%
Python
4.9%
TypeScript
4.8%
Shell
1.4%
CSS
1.2%