A Claude Code skill that makes SSW-style done videos, narrated in your own voice clone (a free local draft, then ElevenLabs for the final) and rendered with Remotion.
Watch one: Done Videos with ElevenLabs & Claude Code (3:24), made entirely by this skill. The write-up with copy-paste prompts: harksingh.com/posts/harks-ai-voice-clone.
- What it does
- Requirements
- Install
- Make it yours
- Usage
- Edit a finished video
- Reinstall an old video
- API key permissions
- Voice recipe
- Draft and final
- Local voice
- Recorded voice
- Subtitles
- Shorts
- Scene library
- Puppet
- Components
- Assets
- Pronunciation
- Gotchas
- License
You say "make a done video for ". The skill reads the PR, issue, and commits, writes a script in the SSW done video order (intro, overview, pain, demo, outro), and voices it with your clone: first a free local draft for the rough edits, then the ElevenLabs clone once the draft is right. It checks every scene for accent drift and likeness, then renders a 1080p video with real screenshots, cursor clicks, SSW TV lower thirds, rule-based subtitles, typing and click sounds, and a quiet music bed. The narrator always introduces itself as your AI, so nobody mistakes the clone for you.
- macOS or Linux with Claude Code
- An ElevenLabs plan with Instant Voice Cloning (Starter or above), needed only for the final voice
- For the free local draft voice: an Apple Silicon Mac with about 6 GB of disk
- Node 22, pnpm, Dart 3, ffmpeg, Python 3.11 via
uv, Whisper, andyt-dlp - For the talking camera box on Apple Silicon: Rosetta 2, which runs Rhubarb Lip Sync
- A few minutes of public video of you talking alone, with no music
git clone https://github.com/0xharkirat/harks-ai-studio ~/done-video-studio
ln -s ~/done-video-studio ~/.claude/skills/done-video
ln -s ~/done-video-studio/explainer-video ~/.claude/skills/explainer-video
brew install ffmpeg node pnpm dart uv pipx yt-dlp && pipx install openai-whisper
cd ~/done-video-studio
uv venv -p 3.11 .venv && VIRTUAL_ENV=.venv uv pip install speechbrain librosa "setuptools<70" "huggingface_hub<0.26"Clone the repo anywhere you can write to: your videos are created inside it, in videos/.
Then put your key in a file that never reaches git or shell history:
open -e ~/.zshrc.local # add: export ELEVENLABS_API_KEY="..."
echo '[ -f ~/.zshrc.local ] && source ~/.zshrc.local' >> ~/.zshrcThe voice-check models (SpeechBrain, about 100 MB) and Whisper download on first use.
Set up the local draft voice once (about 4.5 GB model, reused from the Mac when it is already there):
scripts/setup-local-voice.sh # ends with "Ready:"Everything personal lives in one file outside the repo:
mkdir -p ~/.config/done-video && cp config.example.json ~/.config/done-video/config.jsonThe only required change is voice_id: copy it from your clone in the ElevenLabs web app.
The rest is optional, and every field is explained in _notes inside the file:
speaker.ai_name,person,title: what the narrator calls itself, and an optional name bar.speaker.puppet: a cartoon of you as a Character Animator style PSD, which talks in the camera box (see Puppet); leave itnullfor no camera box.prefix: an accent tag such as[Indian English accent], if your clone drifts toward another accent.gate: per-scene checks for accent drift and likeness, off by default.
No clone yet? List a few minutes of your own clean, solo, public videos in a copy of assets/voice/sources.example.json, point voice_sources at it, then run scripts/fetch-voice.sh and scripts/clone-voice.sh.
Only clone your own voice, or one you have permission to use.
In any Claude Code session:
make a done video for https://github.com/<owner>/<repo>/pull/123
The skill follows SKILL.md: preflight, gather the PBI, create the project, write the script, capture screenshots, voice and check the narration, build the scenes, render, deliver.
Every step lists its commands, so it also works by hand.
Once the PBI is tested, say test pass.
UPLOAD.md writes youtube.txt next to the video: title, description, chapters timed from the scenes, version line, and upload settings.
You upload it yourself, send back the link, and the skill posts the Done on the issue.
The skill voices the video as a free DRAFT first and switches to the ElevenLabs clone only after you say the draft is good (see Draft and final).
For a short done video (30-60 s, 4 scenes), start the project with scripts/new-video.sh --short NN-name.
Its camera box is the puppet in a red border with a faint voice glow, and it plays a whoosh on each cut.
examples/01-done-video/script.json is a real done-video script.
examples/02-voice-clone-tutorial/ shows every component in use.
For a 3Blue1Brown-style explainer on any topic, voiced locally with no API key:
make an explainer video on how git rebase replays commits
The explainer-video skill follows explainer-video/SKILL.md, and examples/03-git-rebase-explainer/ is the result.
The same skill makes a change video: a 60-90 s walk-through of 1 code change, for a reviewer.
make a change video for commit ddb49c2
It adds explainer-video/CHANGE.md: a before/after diagram, 1 example end to end, the hunks that matter, and what to check.
scripts/hunk.dart turns a hunk from git diff or gh pr diff into CodeWalk data.
examples/04-tts-provider-change/ is the result.
Change only the lines that need it, without re-voicing the rest.
In DRAFT mode, edit script.json and run tts.dart again: it re-voices only the lines that changed.
The commands below are for a FINAL video voiced by ElevenLabs (retake.dart refuses a local voice).
Run these from the video's project folder:
dart ../../../scripts/retake.dart split # once, before you touch script.json
# edit, add, or delete lines in script.json
dart ../../../scripts/retake.dart build # voices only new or changed lines, then rebuilds every scene
dart ../../../scripts/layout.dartbuild reuses every line whose text is unchanged, so a one-line fix costs about 100 credits.
It plays each new line back through Whisper and prints what it heard next to the script.
A video without node_modules (for example after you freed disk space) needs its packages again.
Run this from the video's project folder:
cd videos/NN-name/project && zsh ../../../scripts/install-deps.shIt converts an old package-lock.json to pnpm-lock.yaml and keeps the same versions.
It also links node_modules/.remotion to .cache/remotion-browser, so every video shares one Chrome Headless Shell.
Remotion downloads that browser (about 100 MB) once, on the first render of any video.
Run the script again at any time: it is safe to repeat.
Give the key only these endpoints; everything else stays on No Access:
| Endpoint | Access | Used for |
|---|---|---|
| Text to Speech | Access | Narration |
| Voices | Write | Creating the clone, listing voices (Read is enough once the clone exists) |
| User | Access | Reading the credit balance |
| Sound Effects | Access | Only if you generate your own sounds |
| Music Generation | Access | Only if you generate your own music |
scripts/permission-test.sh <clip.mp3> proves it against a restricted key in TEST_ELEVENLABS_KEY.
It makes every call the pipeline makes, clones and deletes a 10 s test voice, and expects Models and History to be refused.
Your config (see Make it yours) is the single source of the recipe; scripts/config-path.sh shows which file is in use.
Each new video copies it to src/config.json, so later changes never alter an old video.
The defaults below are what kept the author's clone consistent; the accent tag and the gate are opt-in.
- Model
eleven_v4, stability 0.5, similarity 0.85. v4 ignores speed, style, and speaker boost, so pace comes fromscripts/pad.dart. - Requests can start with an accent audio tag (
prefix). On the author's clone, 7 of 10 scenes drifted American without it, and 0 of 10 with it. - Scenes are merged into chunks of up to 850 characters and stitched with
previous_request_ids, so tone carries through a video. - A fixed
seedper chunk (insrc/seeds.json) gives back the same take for the same text, about 99% identical. scripts/check.dartchecks every scene's words, and, whengateis set, its accent and likeness againstassets/voice/reference;tts.dart rerollredoes only the chunks that fail.
Tried on the author's clone and dropped:
| Idea | Result |
|---|---|
| Stability 0.7 | The accent tag stopped working: 60-68% US |
| Turbo v2.5, v4 Turbo | Half price, but less like the speaker, or American |
| Clones from 2 minutes of one video | Less like the speaker than a 4 minute clone from 2 videos |
| Pitch post-processing (Praat, Rubber Band) | Matched the numbers but sounded rough and noisy |
| Chatterbox (local) | Higher pitch, weaker accent, less like the speaker |
macOS say (Aman) for drafts |
A stock voice, not a clone: the accent and likeness gates cannot run, and the pace and tone differ from the final. Qwen3-TTS replaced it for drafts. say stays as the fallback when the local voice is not set up |
A video is in one of two voice modes. The mode is tts.provider in the video's own src/config.json.
| DRAFT | FINAL | |
|---|---|---|
| Voice | Local Qwen3-TTS clone (qwen) |
Your ElevenLabs clone |
| Cost | No credits | About 1 credit per character |
| Use | Every rough edit: script, visuals, timing | One pass, after you say the draft is good |
| Render name | <title> draft vN.mp4, never uploaded |
<title> vN.mp4 |
A new video starts in DRAFT. If the local voice is not set up, new-video.sh says so in one line and uses say, never ElevenLabs.
Run these from the video's project folder:
zsh ../../../scripts/voice-mode.sh status # DRAFT (qwen, attempts 2) or FINAL (elevenlabs)
zsh ../../../scripts/voice-mode.sh final # prints the credit estimate; calls nothing
dart ../../../scripts/tts.dart --dry-run # lists each ElevenLabs request it would send; calls nothing
dart ../../../scripts/tts.dart # now ElevenLabs is billed
zsh ../../../scripts/voice-mode.sh draft # back to the free voicevoice-mode.sh only edits src/config.json. It never calls ElevenLabs: the next tts.dart run does, in FINAL mode.
It does not touch the recipe (voice_id, model_id, prefix, seed), so FINAL uses what new-video.sh stored.
A video whose config has no tts block is FINAL, which keeps the older videos working.
After the switch, run tts.dart, check.dart, pad.dart, layout.dart, and render once.
A third mode, RECORDED, voices the video with your own recordings and no made voice at all (see Recorded voice).
tts.provider picks the narrator. Set it per video with voice-mode.sh, or in the config for new videos.
| Provider | What | Gates in check.dart |
|---|---|---|
qwen |
Qwen3-TTS clone of your voice, on the Mac (MLX). The draft voice. See local-voice/README.md |
Words, accent, and likeness all run |
say |
macOS say: free, offline, a stock voice. The fallback when qwen is not set up, and the voice of --explainer videos |
Words only: a stock voice is not a clone |
elevenlabs |
Your ElevenLabs clone. The final voice | Words, accent, and likeness |
recorded |
Your own recordings, cut into lines by recorded.dart (see Recorded voice) |
Words only: it is your voice, so there is no clone to check |
tts.dart voices each line on its own, so line times are exact, and keeps each voiced line in .voice-cache/, so a script edit re-voices only the lines that changed.
It writes the same files as the ElevenLabs path, so pad.dart and layout.dart run unchanged.
tts.qwen.python, server, and config are paths relative to the studio and default to local-voice/; tts.qwen.attempts is takes per sentence (2 is the draft default, 4 is full quality).
tts.say.voice names a voice from say -v '?', and tts.say.rate sets words a minute.
retake.dart refuses a local voice, because tts.dart already re-voices only changed lines.
Another local model, such as Kokoro, is one more case in localVoice in scripts/tts.dart.
The local voice is not as lively as ElevenLabs yet. local-voice/README.md holds the energy numbers and the round log.
Record the narration yourself, and the video has your real voice and no AI voice.
voice-mode.sh recorded sets tts.provider to recorded for 1 video, and voice-mode.sh draft or final switches back, so the other voices keep working.
To use your recordings for 1 run only, run tts.dart --recorded.
Put the recordings in the video's recordings/ folder (tts.recorded.dir). Any of wav, m4a, aiff, mp3, or flac works. Name them like this:
| File | Holds |
|---|---|
take.wav |
1 continuous take of the whole script |
intro.wav |
every line of the scene intro, read in order with a short pause between lines |
intro_2.wav |
line 2 of intro alone (lines count from 1, as in the reading sheet) |
A line uses its own file first, then its scene's file, then the take. So 1 bad line costs 1 new recording, not a new take. A file with any other name is ignored, and the build says so.
zsh ../../../scripts/voice-mode.sh recorded
dart ../../../scripts/recorded.dart --dry-run # which file voices each line; changes nothing
dart ../../../scripts/recorded.dart
dart ../../../scripts/pad.dart 0.2 # 0 keeps the pauses recorded.dart made
dart ../../../scripts/layout.dartrecorded.dart writes what tts.dart writes (public/audio/<scene>.mp3, src/timings.raw.json), so pad.dart, layout.dart, check.dart, and the render run unchanged. Per file, it:
- Sets the loudness to -16 LUFS, what
layout.darttargets for the narration (the last render measured -16.4). - Gets word times from the local Whisper (
tts.recorded.model,small.enby default;--model medium.enis slower and more exact). - Matches the heard words to the script, so a take or a scene file is cut into lines. A missing, repeated, or extra word stays local to where it happens.
- Cuts each line in the real silence around it, trims the leading and trailing silence, and keeps
tts.recorded.pad(0.1 s) of clean silence each side. Lines of a scene are joined withtts.recorded.gap(0.25 s).
It prints what Whisper heard next to the script for every line that is not an exact match, as retake.dart build does.
A line with no recording, or one that is far from the script (over 30% of its letters), stops the build and names the line, so nothing wrong reaches a render.
--force builds anyway, and the subtitles then show the script, not what you said. If the script is what changed, edit script.json.
Extra files: public/audio/recorded/<scene>_<n>.wav (1 clip per line), src/recorded.json (which file made which scene; short.dart checks it), and word times in timings.raw.json, which layout.dart copies into layout.json.
With word times, the subtitles in captions.ts cut on the real words. An older video keeps its own captions.ts; copy the template one over it to get them.
--scenes intro..help (or intro,what) builds only those scenes, for a test clip; run without it to build everything.
In this mode the AI voice badge and the camera box's "AI VOICE" tag are gone, check.dart runs the words gate only, and retake.dart refuses.
The intro and outro lines that say "I'm {{ai_name}}" are about the AI voice. Rewrite them, because it is you talking.
template/src/captions.ts follows the Netflix English timed text guide and the BBC subtitle guidelines:
- At most 2 lines of 42 characters, bottom-heavy, with no one- or two-word lines.
- Break after punctuation, then before a conjunction, then before a preposition.
- Never split an article from its noun, a name or title, a pronoun or auxiliary from its verb, or a number from what it counts.
- No cue of 1 or 2 words, unless it is the whole line: a short tail joins the cue before it.
- At least 0.8 s on screen, held 0.5 s after the speech ends.
Add multi-word product names to TERMS in captions.ts so they stay on one line.
Make a vertical Short from 1 chapter (1 scene) of a finished video, or from a run of scenes. Run it from the video's project folder, after layout.dart:
dart ../../../scripts/short.dart what # out/short-what.mp4
dart ../../../scripts/short.dart what..help --hook "One session, or a company" # 2 scenes
dart ../../../scripts/short.dart what --no-render # prepare only- 1080x1920, 60 s at most (a longer run stops the command and lists each scene's length).
- Not a crop:
short-video/vertical/Short.tsxdraws the scenes fromscenes.tsxagain, whole, on a 1920x1080 stage scaled to the width of the frame. The text inside a scene is small on a phone, so the captions carry the words. A channel video (new-video.sh --channel, see Scene library) renders natively instead:short-video/native/NativeShort.tsxdraws the library's scenes at 1080x1920, in their 9:16 layouts, so no text is shrunk. - The hook line is on screen from frame 1:
--hook, else the scene's"hook"inscript.json, else its first line. - Big captions, 1-4 words at a time, light each word as it is spoken. Word times come from
layout.json(recorded voice), or from Whisper on the narration. - The voice is the scene audio the long video plays, set to -16 LUFS, with the quiet music bed and a whoosh on each cut.
When
tts.providerisrecorded, a scene that was not built from your recordings, or whose recording changed after the build, stops the command. - A black card (
"black"inscript.json) is cut, and the camera box and the long video's subtitles are left out. - It ends with the size, length, and loudness of the file. Look at 2 frames before you upload.
short.dart copies short-video/vertical/ to src/short/ on every run; edit it in the studio, not in the video.
short-video/template is a different thing: the 16:9 short done video that new-video.sh --short starts. The vertical Short uses its look: the same type and colours, and a whoosh on each cut.
scenes/ is the For Curious Beings design system: 1 library of Remotion scenes (title card, number reveal, world map, globe, label with a leader line, orbit diagram, chart, the guide character, the channel mark), a theme switch (A clean data, B sketch with the guide, C clean data with the guide, D flat stub), and native layouts for 16:9 and 9:16.
scenes/README.md is the reference.
scripts/new-video.sh --channel 12-sail-line # a faceless video that uses the library
dart ../../../scripts/render.dart --theme B # 1 flag picks the theme, 1920x1080
dart ../../../scripts/render.dart --theme B --tall # the same video laid out natively for 1080x1920
dart ../../../scripts/short.dart beat --theme B # a Short: the 9:16 layouts, not a shrunk 16:9 stagescript.json can name the theme ("theme": "B" on the first scene); layout.dart copies it to layout.json, and a render flag overrides it.
The channel look is a theme switch, not a rebuild, so the style test (channel/style_test.md in the factory repo) can rotate styles with 1 flag.
examples/beach-beat/ is the 14-second demo in all 3 complete themes.
speaker.puppet points at a cartoon of you as a Photoshop file, laid out the way Adobe Character Animator expects.
The camera box shows it for the whole video, intro and outro included, and its mouth moves with the narration.
- A
Headgroup holds the head art and aMouthgroup with 1 layer per mouth shape. Use the Character Animator names; the builder needsNeutral,M,S,D,Ah,Oh,W-Oo,F, andL. - A
Bodygroup is optional; the box shows the top of it. - The eyes stay in the head art:
scripts/puppet.pyfinds the eye whites and draws 3 lid stages for the blinks.
new-video.sh runs scripts/puppet.py to cut the layers into public/puppet/.
layout.dart runs Rhubarb Lip Sync on each scene's narration, with the scene's script as the dialog, and writes src/mouth.json.
The first run downloads Rhubarb 1.14.0 into .cache/ and checks it against the release checksum.
The macOS build is Intel only, so Apple Silicon runs it through Rosetta 2 (softwareupdate --install-rosetta --agree-to-license).
Scenes run in parallel, about 15 s for a 45 s video, and check/mouth/ caches each one, so a re-layout costs nothing.
The mouth follows the narration track alone, and only inside the narration lines. A clip of someone else talking, the music, and the sound effects never move it. The puppet blinks every 2.3-6 s from a fixed seed, and its head sways and lifts a little while the narrator talks. 2 renders give the same frames.
Tried and dropped:
| Idea | Result |
|---|---|
| Neural talking heads (LivePortrait, MuseTalk, LatentSync, Wan S2V) | CUDA only or very slow on a Mac, untested on a bearded cartoon, and several licences bar commercial use |
| Mouth timing from the final mix | The mouth moved on 412 of 614 frames where someone else talked in a clip |
| Cutting the brows out of flat head art | Left smudges; brows that move need their own layers in the PSD |
All in template/src/ui.tsx:
| Component | Use |
|---|---|
IntroCard, OutroCard |
Title card, AI disclosure badge, name lower third, recap |
LowerThird, SswTag |
SSW TV name bar; "â Good example", "â Bad example" and "LEARN MORE" bars |
BrowserShot |
Real screenshot with a gliding cursor, click sound, red highlight, zoom, and blur boxes |
Terminal |
Commands and prompts type out with the typewriter sound |
PermissionPicker |
Endpoint list where the cursor clicks each access level |
PiP |
Bottom-right camera box: the talking puppet (puppet.tsx), the "AI VOICE" label, and a voice meter |
Captions |
Burned-in subtitles |
Header, Chip, Appear, Between, Backdrop |
Titles, chips, and timed reveals |
Explainer pieces, with no SSW branding, are in template/src/explain.tsx:
| Component | Use |
|---|---|
Diagram |
SVG nodes and labelled arrows that draw on at their cues; nodes can fade to a ghost, glide, or flash |
CodeWalk |
A code block that lights the lines each cue names and dims the rest; start and added show a diff hunk's own line numbers and its new lines |
Checklist |
The "what to check" list that closes a change video, 1 item per cue |
Title, Heading |
A centred title card, and a corner label for the scene's one idea |
within(cue, ends, i, f) |
The frame part way through a line, for a part the narration names mid-sentence |
| Path | What |
|---|---|
assets/voice/sources.json |
Which public videos and seconds your voice clips come from; fetch-voice.sh rebuilds them |
assets/voice/*.mp3, assets/voice/reference/*.mp3 |
Your training and held-out clips; never committed |
speaker.puppet in your config |
Optional Character Animator style PSD of you for the camera box; never committed |
assets/sfx/ |
Typewriter loop and mouse click from ElevenLabs sound effects; good-example ding and bad-example buzzer from free YouTube uploads. sources.json lists where each came from |
assets/music/minimal_lofi_bed_4min.mp3 |
A 4 minute quiet bed from ElevenLabs music |
They ship with the repo, so nothing has to be generated.
To use your own, drop any royalty-free files (for example from the YouTube Audio Library) into assets/sfx and assets/music with the same names.
The shipped files sit near -20 LUFS, so match that or adjust the volume values in ui.tsx and Video.tsx.
Put spoken spellings in a line's tts field; say stays the subtitle text.
- Spell acronyms out: "S S W", "P B I", "Y T D L P".
- Write money, percentages, times, and years in words.
- A product name the accent bends ("Claude" heard as "Clod") is only flagged, never re-rolled: subtitles show the right word.
- The
character-costresponse header is the true price of a call;tts.dartprints the total. - Starter caps output at
mp3_44100_128, and unused credits roll over for up to 2 months. - Capture screenshots with the Playwright MCP browser, log in yourself, and blur key hints.
- On YouTube, answer "altered or synthetic content" with Yes.
Code: MIT, Harkirat Singh.
The music and sound effects in assets/ were generated with ElevenLabs on a paid plan; check the ElevenLabs terms before reusing them elsewhere.
No voice clips are included.