Make professional videos with Claude Code on your Mac: a guided brief, a script and storyboard you approve, reusable brand kits, and animated or footage-based…

Make professional videos with Claude Code, on your own Mac. Claude writes the script, designs a storyboard you approve scene by scene, builds the video, adds its sound, and renders it, with a review page at every step where your feedback counts.
Works in Claude Code on macOS (tested on Apple silicon). It doesn't run in Claude's chat apps. It needs about 6 GB of disk space for HyperFrames' browser and models, plus 0.3 to 1.5 GB for each video.
/plugin install video-studio --marketplace wyzowl/video-studio. Start a new session and run /video-studio:setup. It says what it will install and download (about 1 GB), installs it, creates your studio folder and makes a test render./video-studio:new-brand, then start a video with /video-studio:new-video.| Command | What it does |
|---|---|
/video-studio:setup | Checks this Mac, installs what Video Studio needs, creates a studio folder and makes a test render |
/video-studio:new-video | Asks a few questions, sets up the project, then writes a script and makes a storyboard, each for your approval |
/video-studio:new-brand | Builds a reusable brand kit, with a logo ident and its own brand page |
/video-studio:dashboard | Shows the studio's pages: videos, brands, scripts, storyboards, video reviews and the library |
Video Studio works on your Mac. Your scripts, footage, voiceovers, brand kits and videos stay there. Here is everything it runs, downloads and sends, and when.
scripts/studio.mjs), the page server, and the tools for review, footage, captions and sound. They draw storyboard frames and PDFs in HyperFrames' headless browser.npx --yes hyperframes@0.8.149, to make, check, render and take stills of videos, and transcribe speech, all on your Mac. Videos made on an earlier version keep it, through their own scripts.open, to open a page in your browser or show a render or PDF in Finder; and unzip, to unpack SFX Kit. Setup's checks also run git --version, brew --version and sw_vers.npx --yes skills@1.7.4.http://127.0.0.1:4747 (or the next free port up to 4756). It answers only this Mac, serves only your studio's files, and refuses requests from websites.All of this happens only in a session inside a studio folder. Anywhere else, Video Studio stays silent.
scripts/session-start.sh), Video Studio tells Claude about the studio. If anything it needs isn't on your Mac yet (after an update, or a download that didn't finish), it starts downloading it in the background and logs the download in ~/.wyzowl-video-studio/install.log. Once a day, if you've entered a licence key, it checks the full library (below).scripts/page-ready.sh), Video Studio checks whether it's the file behind a studio page (a brand, script, storyboard or video review). If it is, it starts the page server if needed and tells Claude to show the page.hooks/index.tsx):node <plugin>/scripts/studio.mjs ready, and url to open a page, each with your studio's folder) to see what's waiting for your review. It does this when the session starts, after each of Claude's turns, and after each Video Studio tool Claude runs. It looks at Claude's Bash commands only to notice those tools.127.0.0.1 address) in the desktop app's browser pane, through the browser pane's own tool, or else in your web browser, with open.github.com/heygen-com/hyperframes, at its v0.8.149 release;github.com/LottieFiles/motion-design-skill.github.com/wyzowl/sfx-kit's release, checked against its checksum and then file by file.cdn.jsdelivr.net, whenever a browser plays a video (to preview, check or render it) or one of the library's preview pages.app.wyzowl.com):The server says whether the key is valid and whether a newer library is available. Without a key, nothing is sent.
HYPERFRAMES_NO_TELEMETRY=1, HYPERFRAMES_NO_UPDATE_CHECK=1, DISABLE_TELEMETRY=1 and HOMEBREW_NO_ANALYTICS=1).--describe false, so frames are never sent to a service to describe them.~/.wyzowl-video-studio:~/.hyperframes), and its cache (~/.cache/hyperframes): its browser, models and fonts, and each video's edit history.Tidying up. When a video is finished, Video Studio offers to tidy it up. Once you've said yes, it deletes the video's draft renders, review stills, replaced storyboards and footage previews, and its edit history in HyperFrames' cache. It keeps everything the video needs to be changed and rendered again.
Apart from setup's installs, Video Studio changes nothing outside those folders: no shell settings and no system settings.
Video Studio is free to use to make videos, commercial or not, client work included, under the Wyzowl Video Studio Licence. Share links to it rather than copies, and don't release your own versions. SFX Kit is CC0; the bundled fonts are under the SIL Open Font License; and HyperFrames, its skills and the other tools setup installs are under their own licences.
Full documentation: https://github.com/wyzowl/video-studio
hooks/index.tsx 171 lines1// Wyzowl Video Studio's hooks module (one per plugin), both parts only in a session inside a studio (a folder with
2// studio.json): the band above Claude's message box, and the answer box around Claude's final answer.
3//
4// The band: when a script, storyboard or video is waiting for the person's review it says so, with a button that opens
5// that page; while Claude makes something it says what, and the step it's on, with "Watch", which opens the video's
6// overview (where it shows as it happens); otherwise "Working on a video?" with "Open dashboard". The button opens the
7// page beside the chat (the desktop app's browser pane, opened if it's closed) or in the web browser. What's waiting is
8// checked when the session starts, after each of Claude's turns and after each studio tool Claude runs (they mark the
9// steps), never on a timer. Dismissed, the band stays away until something new is waiting.
10//
11// The answer box: Claude's final message of a turn that did some work (used a tool) is drawn in a rounded box with a
12// yellow border and a faint yellow ground, so after a long run of tool calls it's plain where the answer starts. A turn
13// that was only a reply draws as usual, and so does every message before the final one. The final message is told by
14// its text (turn.complete's answer), each answer's fingerprint a member of its own, so a new answer redraws its own
15// message alone.
16//
17// Everything that uses the engine (`$`) or registers a hook (`on`) is in this one file, as Anthropic's plugin directory
18// asks of a mod.
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, Register } from 'claude-code'
21import type { ReadyPage } from '../types'
22
23const studioAt = atom({ plugin: 'video-studio', key: 'studio' } as const, '')
24const readyAt = atom({ plugin: 'video-studio', key: 'ready' } as const, null as ReadyPage | null)
25const dismissedAt = atom({ plugin: 'video-studio', key: 'dismissed' } as const, '')
26// one member per boxed answer, by its fingerprint
27const BOXED = { plugin: 'video-studio', key: 'boxed' } as const
28
29// Homebrew's node on Apple silicon and Intel Macs, ahead of the system's own folders
30const PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'
31// the studio's own tools, which move a stage under way on: what the band says may have changed
32const STUDIO_TOOL = /studio\.mjs|render-frames\.mjs|sheets\.mjs|footage\.mjs/
33
34const YELLOW = '#F9B336'
35// the same yellow at 10%, where the surface paints with transparency (the terminal can't: there, the border alone)
36const TINT = '#F9B3361A'
37
38/** The studio this session works in: the nearest folder at or above its root that holds studio.json, or ''. */
39async function findStudio($: EngineInterface): Promise<string> {
40 let dir = (await $.session.root()).replace(/\/+$/, '') || '/'
41 for (;;) {
42 if (await $.fs.exists(`${dir === '/' ? '' : dir}/studio.json`)) return dir
43 if (dir === '/') return ''
44 dir = dir.replace(/\/[^/]+$/, '') || '/'
45 }
46}
47
48/** Runs the studio helper (scripts/studio.mjs) for the studio; its output, or '' when it fails. */
49async function helper($: EngineInterface, studio: string, args: string[]): Promise<string> {
50 const argv = ['/usr/bin/env', `PATH=${PATH}`, 'node', `${$.plugin.root}/scripts/studio.mjs`, ...args, '--studio', studio]
51 const run = await $.process.run(argv, { timeoutMs: 20000 }).catch(() => null)
52 return run?.exitCode === 0 ? run.stdout.trim() : ''
53}
54
55/** What's waiting for the person's review now, or else what Claude is making (studio.mjs ready), into the band's state. */
56async function checkReady($: EngineInterface, studio: string): Promise<void> {
57 let page: ReadyPage | null = null
58 try {
59 const now = JSON.parse(await helper($, studio, ['ready']))
60 page = now.page ?? now.making ?? null
61 } catch {}
62 await update($, readyAt, () => page)
63}
64
65/** Starts the studio's page server if needed and opens a page: beside the chat, or in the web browser. */
66async function openPage($: EngineInterface, studio: string, path: string): Promise<void> {
67 const url = (await helper($, studio, ['url', path])).split('\n').pop() ?? ''
68 if (!/^http:\/\/127\.0\.0\.1:\d+\//.test(url)) {
69 $.ui.toast(`Couldn't open the studio's pages. In Claude, run /video-studio:dashboard to see why.`)
70 return
71 }
72 const beside = await $.mcp.call('Claude_Browser', 'preview_start', { url }).catch(() => null)
73 if (beside && !beside.isError) return
74 await $.process.run(['/usr/bin/open', url]).catch(() => null)
75}
76
77/** A short, stable name for a message's text: its length and a hash of it, whitespace evened out. */
78function fingerprint(text: string): string {
79 const s = text.replace(/\s+/g, ' ').trim()
80 let hash = 0x811c9dc5
81 for (let i = 0; i < s.length; i++) hash = Math.imul(hash ^ s.charCodeAt(i), 0x01000193)
82 return `${s.length.toString(36)}-${(hash >>> 0).toString(36)}`
83}
84
85export const register: Register = on => {
86 // ───────── the band ─────────
87 on('session.start', async ($, e, next) => {
88 const started = await next(e)
89 const studio = await findStudio($)
90 await update($, studioAt, () => studio)
91 if (studio) await checkReady($, studio)
92 return started
93 })
94
95 // after each of Claude's own turns (not a subagent's), see what's waiting now: a new storyboard, a reviewed video
96 on('turn.complete', async ($, e, next) => {
97 const studio = await read($, studioAt)
98 if (studio && e.agentId === undefined) $.clock.after(0, () => void checkReady($, studio).catch(() => {}))
99 return next(e)
100 })
101
102 // after each studio tool Claude runs, see what it's making now ("Claude is making storyboard v3: make the frames")
103 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
104 const ran = await next(e)
105 try {
106 const studio = await read($, studioAt)
107 if (studio && STUDIO_TOOL.test(String(e.command ?? ''))) $.clock.after(0, () => void checkReady($, studio).catch(() => {}))
108 } catch {}
109 return ran
110 })
111
112 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
113 const studio = await read($, studioAt)
114 const ready = await read($, readyAt)
115 const shows = ready?.path ?? 'idle'
116 if (!studio || e.props.hasSurvey || (await read($, dismissedAt)) === shows) return next(e)
117 const { Box, Text, Button } = $.ui.resolve(e)
118 return (
119 <Box key="studio-bar" flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
120 <Text>{ready ? ready.text : 'Working on a video?'}</Text>
121 <Box flexDirection="row" alignItems="center" gap={1}>
122 <Button
123 key="open"
124 variant="primary"
125 label={ready ? ready.button : 'Open dashboard'}
126 onPress={() => openPage($, studio, ready ? ready.path : '/')}
127 />
128 <Button key="dismiss" role="dismiss" label="Dismiss" onPress={() => update($, dismissedAt, () => shows)} />
129 </Box>
130 </Box>
131 )
132 })
133
134 // ───────── the answer box ─────────
135 // did this turn do any work? (a module value: a reload mid-turn just leaves that one answer unboxed)
136 let worked = false
137
138 on('turn.start', ($, e, next) => {
139 worked = false
140 return next(e)
141 })
142
143 on('tool.call', ($, e, next) => {
144 worked = true
145 return next(e)
146 })
147
148 // a turn that ended with an answer: the main loop's, not a subagent's report (turn.start starts each one afresh)
149 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
150 const done = await next(e)
151 if (e.agentId === undefined) {
152 if (worked && e.answer.trim() && (await read($, studioAt))) await update($, { ...BOXED, id: fingerprint(e.answer) }, () => true)
153 worked = false
154 }
155 return done
156 })
157
158 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
159 if (e.props.isSummary || !e.props.text.trim() || !(await read($, studioAt))) return next(e)
160 if (!(await read($, { ...BOXED, id: fingerprint(e.props.text) }))) return next(e)
161 const drawn = await next(e)
162 const { Box } = $.ui.resolve(e)
163 const ground = e.surface === 'terminal' ? {} : { backgroundColor: TINT }
164 return (
165 <Box key="answer-box" flexDirection="column" borderStyle="round" borderColor={YELLOW} paddingX={1} {...ground}>
166 {drawn}
167 </Box>
168 )
169 })
170}
171types/index.d.ts 15 lines1// The values Wyzowl Video Studio's band (in hooks/index.tsx) keeps for the session: the studio it works in ('' outside
2// one), the page waiting for the person's review (from `studio.mjs ready`), and what the person dismissed (a page's
3// path, or 'idle' for the dashboard band). The answer box (also in hooks/index.tsx) keeps which of Claude's answers to
4// draw boxed, each by a fingerprint of its text.
5export type StudioPath = string
6
7/** A script, storyboard or video waiting for review: its page path, the band's words and its button's label. */
8export type ReadyPage = { path: string; text: string; button: string }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'video-studio': { studio: StudioPath; ready: ReadyPage | null; dismissed: string; boxed: StateFamily<boolean> }
13 }
14}
15