Cutroom: a cutting-room pane for HyperFrames projects inside Claude Code — frame preview, timeline, trim/split/move/delete/undo, Studio selection bridge, and…

Cutroom is a preview and timeline for HyperFrames videos that lives inside Claude Code. /cut opens it beside the transcript: the video plays live in the pane, and under it one track cut by time into segments, each showing its real frames. No browser and no HyperFrames Studio.
You change the video by talking to Claude in the same window. Click a segment first and whatever you type carries it ("this part"), the playhead, a snapshot of the frame and the timeline, so "make this title bigger" needs no pointing.
demo 10s [Export]
┌──────────────────────────────────────────────────────────────┐
│ (the video, playing live; tap it to play/pause) │
└──────────────────────────────────────────────────────────────┘
00:03.2 / 00:10.0 ⏮ −1s ▶ +1s ⏭ ● Live
0s 2.5s 5s 7.5s 10s
╭─ Intro ───────╮╭─ Graphics ─────────────────────────────────╮
│ frames 2.5s ││ frames 7.5s│
╰───────────────╯╰────────────────────────────────────────────╯
Graphics, 2.5-10s selected. Tell Claude what to change.
Segments: every clip edge is a cut, and each piece is named for the shortest clip covering it, the most specific content there.
claude plugin marketplace add leepokai/claude-mod-cutroom
claude plugin install cutroom@claude-mod-cutroom
For one session without installing: claude --plugin-dir /path/to/claude-mod-cutroom. A symlink to this folder under ~/.claude/skills/cutroom also auto-loads it in every session.
Requirements: Node 22, ffmpeg and curl on PATH, and either the hyperframes Claude Code plugin (claude plugin install hyperframes@hyperframes, whose cached CLI launcher Cutroom uses) or network access for npx hyperframes. The frame preview draws real pixels in terminals that speak the kitty graphics protocol (kitty, Ghostty) and in the Claude desktop app, and half-block cells in every other terminal.
/cut in a HyperFrames project (or /cut <dir>). With hyperframes.json in the cwd the pane opens by itself at session start.▶) to play or pause; ⏮ −1s +1s ⏭ step.Export renders renders/draft.mp4.A Claude Code mod is three files plus a type contract (see the plugin-authoring skill inside Claude Code). This repo is one such mod and doubles as its own plugin marketplace.
.claude-plugin/plugin.json manifest
.claude-plugin/marketplace.json lets `claude plugin marketplace add` point here
hooks/hooks.json names the hooks module
hooks/timeline.tsx the timeline's pointer (and its text drawing in a terminal)
hooks/tap.tsx tap-to-play over the preview
hooks/register.tsx the whole mod: CLI runner, state atoms, edit
verbs, live player bridge, prompt composer, Pane render
player/cutroom-player.mjs plays the composition in headless Chrome, serves frames
types/index.d.ts the $.state contract the pane draws from
tests/editor.test.tsx claude plugin test .
claude plugin validate . lists every hook and $ call the engine will see.claude plugin test . runs the tests against the real engine, with the world beneath the plugin (fs, process, http, store, env, clock) answered by the tests: clips list, nested rows hidden, select, seek, Split reaching the CLI with the exact argv, a split outside the clip refused before anything runs; the selected clip and playhead riding the next typed prompt; and the live player's frames reaching the pane on the desktop and in a terminal..claude-plugin/types/, and tsc -p . type-checks the mod.MIT, see LICENSE.
hooks/register.tsx 1036 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HfClip, HfPreview, HfTimeline } from '../types'
5import type { Segment, TimelineGeo, TimelineProps } from './timeline'
6
7// Cutroom: a CapCut-style cutting room for HyperFrames projects, inside Claude Code.
8//
9// Self-contained: no HyperFrames Studio. A live player (player/cutroom-player.mjs)
10// plays the composition in headless Chrome and streams its frames into the pane
11// (an Svg on the desktop app, kitty pixels or half-block cells in a terminal).
12// Cuts are the HyperFrames CLI's own `timeline` verbs; everything else goes to
13// Claude, with the selected clip, the playhead and a snapshot attached.
14
15const PANE = 'cutroom'
16const WORK = '.hyperframes/cutroom'
17const RGB_W = 192 // width of the raw strip the terminal thumbnail is built from
18const LIVE_W = 1280 // the live player's viewport
19const LIVE_H = 720
20
21const project = atom({ plugin: 'cutroom', key: 'project' } as const, null as string | null)
22const candidates = atom({ plugin: 'cutroom', key: 'candidates' } as const, [] as string[])
23const timeline = atom({ plugin: 'cutroom', key: 'timeline' } as const, null as HfTimeline)
24const selected = atom({ plugin: 'cutroom', key: 'selected' } as const, null as string | null)
25const playhead = atom({ plugin: 'cutroom', key: 'playhead' } as const, 0)
26const preview = atom({ plugin: 'cutroom', key: 'preview' } as const, null as HfPreview)
27const status = atom({ plugin: 'cutroom', key: 'status' } as const, '')
28const busy = atom({ plugin: 'cutroom', key: 'busy' } as const, '')
29const receipts = atom({ plugin: 'cutroom', key: 'receipts' } as const, [] as string[])
30const live = atom({ plugin: 'cutroom', key: 'live' } as const, null as { port: number; duration: number } | null)
31
32type Eng = EngineInterface
33// eslint-disable-next-line @typescript-eslint/no-explicit-any
34type Json = any
35
36const fmt = (n: number) => String(Math.round(n * 100) / 100)
37const base = (p: string) => p.replace(/\/+$/, '').split('/').pop() || p
38const tail = (s: string, n = 200) => s.trim().slice(-n)
39let sayTimer: { cancel: () => void } | undefined
40/** a status line that clears itself after a few seconds */
41const say = ($: Eng, text: string) => {
42 sayTimer?.cancel()
43 if (text) sayTimer = $.clock.after(6000, () => void update($, status, () => '').catch(() => undefined))
44 return update($, status, () => text)
45}
46const working = ($: Eng, text: string) => update($, busy, () => text)
47// background work outlives a hot reload or a test; its late writes are refused and dropped
48const quiet = () => undefined
49
50// ---------- HyperFrames CLI ----------
51
52let hfArgv: string[] | undefined
53async function hf($: Eng): Promise<string[]> {
54 if (hfArgv) return hfArgv
55 const home = (await $.env.get('HOME')) ?? ''
56 const cache = `${home}/.claude/plugins/cache/hyperframes/hyperframes`
57 const versions = (await $.fs.list(cache).catch(() => []))
58 .filter(e => e.kind === 'dir')
59 .map(e => e.name)
60 .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
61 for (const v of versions) {
62 const launcher = `${cache}/${v}/skills/hyperframes/scripts/plugin-cli.mjs`
63 if (await $.fs.exists(launcher)) return (hfArgv = ['node', launcher])
64 }
65 // ponytail: no plugin cache → npx (slower first run); add a userConfig path if it ever matters
66 return (hfArgv = ['npx', '--yes', 'hyperframes'])
67}
68
69async function run($: Eng, dir: string, args: string[], timeoutMs = 120_000) {
70 const argv = [...(await hf($)), ...args]
71 return $.process.run(argv, {
72 cwd: dir,
73 timeoutMs,
74 env: { HYPERFRAMES_NO_UPDATE_CHECK: '1', NO_COLOR: '1', FORCE_COLOR: '0' },
75 })
76}
77
78function json(text: string): Json {
79 const i = text.indexOf('{')
80 if (i < 0) return null
81 try {
82 return JSON.parse(text.slice(i))
83 } catch {
84 return null
85 }
86}
87
88// ---------- project ----------
89
90async function isProject($: Eng, dir: string) {
91 return (await $.fs.exists(`${dir}/hyperframes.json`)) || (await $.fs.exists(`${dir}/index.html`))
92}
93
94async function findProjects($: Eng, cwd: string): Promise<string[]> {
95 const found: string[] = []
96 const has = (d: string) => $.fs.exists(`${d}/hyperframes.json`)
97 const kids = async (d: string) =>
98 (await $.fs.list(d).catch(() => []))
99 .filter(e => e.kind === 'dir' && !e.name.startsWith('.') && e.name !== 'node_modules')
100 .slice(0, 200)
101 .map(e => `${d}/${e.name}`)
102 if (await has(cwd)) found.push(cwd)
103 for (const a of await kids(cwd)) {
104 if (await has(a)) {
105 found.push(a)
106 continue
107 }
108 for (const b of await kids(a)) if (await has(b)) found.push(b)
109 }
110 return found
111}
112
113async function openProject($: Eng, dir: string) {
114 const clean = dir.replace(/\/+$/, '')
115 if (!(await isProject($, clean))) return say($, `not a HyperFrames project: ${clean}`)
116 stopLive()
117 await update($, live, () => null)
118 await update($, project, () => clean)
119 await update($, selected, () => null)
120 await update($, preview, () => null)
121 await update($, playhead, () => 0)
122 await update($, receipts, () => [])
123 await $.store.set(`last:${await $.session.cwd()}`, clean)
124 await say($, '')
125 await loadTimeline($, clean)
126 void capture($, clean, 0).catch(quiet) // a poster frame while the live player starts
127 void startLive($, clean).catch(quiet)
128}
129
130// ---------- timeline ----------
131
132async function loadTimeline($: Eng, dir: string) {
133 const r = await run($, dir, ['timeline', '--json'])
134 const j = json(r.stdout)
135 if (!j?.timeline) return say($, `timeline: ${tail(r.stderr || r.stdout) || 'no output'}`)
136 const clips: HfClip[] = []
137 for (const track of j.timeline.tracks ?? []) {
138 for (const row of track.rows ?? []) {
139 if (row.nested) continue
140 clips.push({
141 id: String(row.id),
142 ref: String(row.ref ?? `#${row.id}`),
143 file: String(row.file ?? 'index.html'),
144 trackKind: String(row.trackKind ?? track.kind ?? 'graphics'),
145 absStart: Number(row.absStart ?? 0),
146 absEnd: Number(row.absEnd ?? 0),
147 src: row.src ? String(row.src) : null,
148 children: Array.isArray(row.children) ? row.children.length : 0,
149 })
150 }
151 }
152 await update($, timeline, () => ({ duration: Number(j.timeline.duration ?? 0), clips }))
153}
154
155async function refresh($: Eng, dir: string) {
156 await loadTimeline($, dir)
157 if (await liveCmd($, { op: 'reload' })) void loadStrip($).catch(quiet)
158 else void capture($, dir, await read($, playhead)).catch(quiet)
159}
160
161// ---------- frame preview ----------
162
163function pngSize(b64: string) {
164 const head = atob(b64.slice(0, 32))
165 const u32 = (o: number) =>
166 ((head.charCodeAt(o) << 24) >>> 0) + (head.charCodeAt(o + 1) << 16) + (head.charCodeAt(o + 2) << 8) + head.charCodeAt(o + 3)
167 return { width: u32(16) || 16, height: u32(20) || 9 }
168}
169
170function fromB64(b64: string): Uint8Array {
171 const s = atob(b64)
172 const out = new Uint8Array(s.length)
173 for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)
174 return out
175}
176
177function toB64(bytes: Uint8Array): string {
178 let s = ''
179 for (let i = 0; i < bytes.length; i += 0x4000) s += String.fromCharCode(...bytes.subarray(i, i + 0x4000))
180 return btoa(s)
181}
182
183/** Half-block cells (▀: top pixel as foreground, bottom pixel as background) box-averaged from the raw rgb strip. */
184function rasterCells(rgb: Uint8Array, srcW: number, srcH: number, cols: number, rows: number): string {
185 const out = new Uint8Array(cols * rows * 12)
186 const dv = new DataView(out.buffer)
187 const pxH = rows * 2
188 const sample = (px: number, py: number) => {
189 const x0 = Math.floor((px * srcW) / cols)
190 const x1 = Math.max(x0 + 1, Math.floor(((px + 1) * srcW) / cols))
191 const y0 = Math.floor((py * srcH) / pxH)
192 const y1 = Math.max(y0 + 1, Math.floor(((py + 1) * srcH) / pxH))
193 let r = 0
194 let g = 0
195 let b = 0
196 let n = 0
197 for (let y = y0; y < y1 && y < srcH; y++) {
198 for (let x = x0; x < x1 && x < srcW; x++) {
199 const i = (y * srcW + x) * 3
200 r += rgb[i] ?? 0
201 g += rgb[i + 1] ?? 0
202 b += rgb[i + 2] ?? 0
203 n++
204 }
205 }
206 if (n === 0) return 0
207 // ponytail: 5 bits per channel keeps distinct colour pairs under the terminal's palette; dither if banding shows
208 const q = (v: number) => Math.round(v / n) & 0xf8
209 return ((q(r) << 16) | (q(g) << 8) | q(b)) >>> 0
210 }
211 let o = 0
212 for (let row = 0; row < rows; row++) {
213 for (let col = 0; col < cols; col++) {
214 dv.setUint32(o, 0x2580, true)
215 dv.setUint32(o + 4, sample(col, row * 2), true)
216 dv.setUint32(o + 8, sample(col, row * 2 + 1), true)
217 o += 12
218 }
219 }
220 return toB64(out)
221}
222
223let captureSeq = 0
224let captureTimer: { cancel: () => void } | undefined
225
226function scheduleCapture($: Eng, dir: string, at: number) {
227 captureTimer?.cancel()
228 captureTimer = $.clock.after(500, () => void capture($, dir, at).catch(quiet))
229}
230
231/** Grabs the frame at `at` with `hyperframes snapshot` (~4 s); the poster before the live player is up. */
232async function grabFrame($: Eng, dir: string, at: number, out: string): Promise<string | null> {
233 const full = `${out}/frame.png`
234 const r = await run($, dir, ['snapshot', '--at', fmt(at), '--no-end', '--describe', 'false', '-o', out])
235 const frames = (await $.fs.list(out).catch(() => []))
236 .filter(f => /^frame-\d+-at-.*\.png$/.test(f.name))
237 .sort((a, b) => b.mtimeMs - a.mtimeMs)
238 const frame = frames[0]
239 if (!frame) {
240 await say($, `snapshot failed: ${tail(r.stderr || r.stdout)}`)
241 return null
242 }
243 await $.process.run(['mv', '-f', `${out}/${frame.name}`, full]).catch(() => undefined)
244 return full
245}
246
247async function capture($: Eng, dir: string, at: number) {
248 const seq = ++captureSeq
249 const out = `${dir}/${WORK}`
250 await working($, `frame @ ${fmt(at)}s…`)
251 const full = await grabFrame($, dir, at, out)
252 if (seq !== captureSeq) return // a later seek superseded this one
253 if (!full) return working($, '')
254 const png = `${out}/preview.png`
255 const jpg = `${out}/preview.jpg`
256 const rgb = `${out}/preview.rgb`
257 const ff = await $.process.run(
258 ['ffmpeg', '-y', '-loglevel', 'error', '-i', full,
259 '-vf', 'scale=960:-2', png,
260 '-vf', 'scale=480:-2', '-q:v', '6', jpg,
261 '-vf', `scale=${RGB_W}:-1:flags=area`, '-f', 'rawvideo', '-pix_fmt', 'rgb24', rgb],
262 { timeoutMs: 30_000 },
263 )
264 if (ff.exitCode !== 0) {
265 await working($, '')
266 return say($, `ffmpeg: ${tail(ff.stderr)}`)
267 }
268 await update($, preview, () => ({ at, png, jpg, rgb, rgbW: RGB_W, gen: seq }))
269 await working($, '')
270}
271
272const fileCache = new Map<string, { gen: number; base64: string }>()
273async function cachedBase64($: Eng, path: string, gen: number) {
274 const hit = fileCache.get(path)
275 if (hit && hit.gen === gen) return hit.base64
276 const { base64 } = await $.fs.read(path, { as: 'bytes' })
277 fileCache.set(path, { gen, base64 })
278 return base64
279}
280
281// ---------- live player ----------
282
283type LiveFrame = { seq: number; jpeg: string; rgb: string | null; rgbW: number; rgbH: number }
284let liveStream: AsyncGenerator<unknown, unknown> | undefined
285let livePort = 0
286let liveSeq = -1
287let liveFrame: LiveFrame | null = null
288let liveT = 0
289let livePaused = true
290let liveTimer: { cancel: () => void } | undefined
291let liveInflight = false
292let rgbWant = 0 // the terminal draws from raw rgb this wide; 0 = jpeg only (desktop)
293
294function stopLive() {
295 liveTimer?.cancel()
296 liveTimer = undefined
297 const stream = liveStream
298 liveStream = undefined
299 void stream?.return(undefined) // leaving the loop kills the helper, which kills Chrome and the play server
300 livePort = 0
301 liveSeq = -1
302 liveFrame = null
303 livePaused = true
304}
305
306async function startLive($: Eng, dir: string) {
307 stopLive()
308 const config = { project: dir, hf: await hf($), width: LIVE_W, height: LIVE_H }
309 const stream = $.process.spawn({ argv: ['node', `${$.plugin.root}/player/cutroom-player.mjs`, JSON.stringify(config)], cwd: dir })
310 liveStream = stream
311 await working($, 'Starting preview…')
312 let head = ''
313 let lastErr = ''
314 try {
315 for await (const { stream: pipe, text } of stream) {
316 if (pipe === 'stderr') {
317 lastErr = text.trim().slice(-200) || lastErr
318 $.ui.log(text.trim().slice(0, 300), { to: 'debug' })
319 continue
320 }
321 if (livePort) continue
322 head += text
323 const nl = head.indexOf('\n')
324 if (nl < 0) continue
325 const first = json(head.slice(0, nl))
326 await working($, '')
327 if (!first?.port) {
328 await say($, `Preview didn't start: ${first?.error ?? head.slice(0, 200)} (showing stills)`)
329 continue
330 }
331 livePort = Number(first.port)
332 await update($, live, () => ({ port: livePort, duration: Number(first.duration ?? 0) }))
333 await liveCmd($, { op: 'seek', t: await read($, playhead) })
334 liveTimer = $.clock.every(30, () => void pollLive($).catch(quiet)) // ~33 fps; a poll in flight skips the next
335 void loadStrip($).catch(quiet)
336 }
337 } catch (err) {
338 if (liveStream === stream) await say($, `Preview stopped: ${String(err).slice(0, 200)}`)
339 }
340 if (liveStream === stream) {
341 if (lastErr) await say($, `Preview stopped: ${lastErr}`)
342 stopLive()
343 await update($, live, () => null)
344 await working($, '')
345 }
346}
347
348async function liveCall($: Eng, cmd: { op: string; t?: number; path?: string; n?: number; w?: number }): Promise<Json> {
349 if (!livePort) return null
350 const q = new URLSearchParams(Object.entries(cmd).map(([k, v]) => [k, String(v)]))
351 const r = await $.http.fetch(`http://127.0.0.1:${livePort}/cmd?${q}`).catch(() => null)
352 return r?.ok ? (json(r.text) ?? {}) : null
353}
354const liveCmd = async ($: Eng, cmd: { op: string; t?: number; path?: string }) => (await liveCall($, cmd)) !== null
355
356// the desktop redraws a pane ten times a second at most: every redraw carries the frames that arrived
357// since the last one, and the Svg flips through them itself (SMIL), so playback runs at the player's rate
358const FLIP_BUDGET = 120_000 // base64 chars of frames per Svg; the Svg holds 131072
359const FLIP_SPAN = 0.1 // seconds one redraw's frames are spread over
360let flip: string[] = []
361
362/** the frames to show this redraw, newest last, as many as fit the budget (always the newest) */
363function takeFlip(): string[] {
364 const out: string[] = []
365 let size = 0
366 for (let i = flip.length - 1; i >= 0; i--) {
367 const f = flip[i] ?? ''
368 if (out.length && size + f.length > FLIP_BUDGET) break
369 out.unshift(f)
370 size += f.length
371 }
372 flip = []
373 return out
374}
375
376/** one Svg that shows `frames` in turn over FLIP_SPAN and holds the last */
377function flipbookSvg(w: number, h: number, frames: string[]) {
378 const dt = FLIP_SPAN / frames.length
379 const img = (f: string, i: number) => {
380 // each frame shows from its slot on; the next one, drawn on top, covers it
381 return `<image href="data:image/jpeg;base64,${f}" width="${w}" height="${h}"${i === 0 ? '' : ' visibility="hidden"'}>${i === 0 ? '' : `<set attributeName="visibility" to="visible" begin="${(i * dt).toFixed(3)}s" fill="freeze"/>`}</image>`
382 }
383 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${frames.map(img).join('')}</svg>`
384}
385
386// thumbnails for the timeline: STRIP_N frames spread over the whole composition
387const STRIP_N = 10
388let strip: string[] = []
389async function loadStrip($: Eng) {
390 const j = await liveCall($, { op: 'strip', n: STRIP_N, w: 320 })
391 const frames = j?.state?.frames
392 if (Array.isArray(frames) && frames.every(f => typeof f === 'string')) {
393 strip = frames
394 $.ui.invalidate('ui.render')
395 }
396}
397
398
399async function pollLive($: Eng) {
400 if (!livePort || liveInflight) return
401 liveInflight = true
402 try {
403 const r = await $.http.fetch(`http://127.0.0.1:${livePort}/frame?since=${liveSeq}${rgbWant ? `&rgb=${rgbWant}` : ''}`).catch(() => null)
404 const j = r?.ok ? json(r.text) : null
405 if (!j) return
406 const wasPaused = livePaused
407 const t = Math.round(Number(j.t ?? 0) * 1000) / 1000
408 const moved = t !== liveT
409 livePaused = Boolean(j.paused)
410 liveT = t
411 if (typeof j.jpeg === 'string') {
412 liveSeq = Number(j.seq)
413 liveFrame = { seq: liveSeq, jpeg: j.jpeg, rgb: typeof j.rgb === 'string' ? j.rgb : null, rgbW: Number(j.rgbW ?? 0), rgbH: Number(j.rgbH ?? 0) }
414 flip.push(j.jpeg)
415 if (flip.length > 8) flip.shift()
416 $.ui.invalidate('ui.render')
417 } else if (moved || wasPaused !== livePaused) $.ui.invalidate('ui.render')
418 if (livePaused && (moved || !wasPaused)) await update($, playhead, () => t)
419 } finally {
420 liveInflight = false
421 }
422}
423
424async function togglePlay($: Eng) {
425 const d = (await read($, timeline))?.duration ?? 0
426 if (livePaused && d > 0 && liveT >= d - 0.05) await liveCmd($, { op: 'seek', t: 0 }) // at the end: play from the top
427 if (!(await liveCmd($, { op: 'toggle' }))) await say($, 'Preview is not running. Run /cut again.')
428}
429
430function rgbToRgba(rgb: Uint8Array): string {
431 const out = new Uint8Array((rgb.length / 3) * 4)
432 for (let i = 0, o = 0; i < rgb.length; i += 3) {
433 out[o++] = rgb[i] ?? 0
434 out[o++] = rgb[i + 1] ?? 0
435 out[o++] = rgb[i + 2] ?? 0
436 out[o++] = 255
437 }
438 return toB64(out)
439}
440
441// ---------- edits (the CLI's own timeline mutations) ----------
442
443async function seek($: Eng, dir: string, to: number) {
444 const tl = await read($, timeline)
445 const max = tl?.duration ?? Number.POSITIVE_INFINITY
446 const at = Math.max(0, Math.min(max, Math.round(to * 1000) / 1000))
447 await update($, playhead, () => at)
448 if (!(await liveCmd($, { op: 'seek', t: at }))) scheduleCapture($, dir, at)
449}
450
451async function selectClip($: Eng, dir: string, clip: HfClip) {
452 await update($, selected, () => clip.id)
453 const t = await read($, playhead)
454 if (t < clip.absStart || t >= clip.absEnd) await seek($, dir, clip.absStart)
455}
456
457async function edit($: Eng, dir: string, label: string, args: string[]) {
458 await working($, `${label}…`)
459 const r = await run($, dir, ['timeline', ...args, '--json'])
460 const j = json(r.stdout)
461 await working($, '')
462 if (!j?.ok) {
463 const why = j?.error?.message ?? (typeof j?.error === 'string' ? j.error : '')
464 return say($, `${label} failed: ${why || tail(r.stderr || r.stdout)}`)
465 }
466 if (j.receipt && typeof j.receipt === 'object' && !Array.isArray(j.receipt)) {
467 const path = `${dir}/${WORK}/receipt-${await $.clock.now()}.json`
468 await $.fs.write(path, JSON.stringify(j.receipt))
469 await update($, receipts, list => [...list, path].slice(-30))
470 }
471 await say($, `${label} done`)
472 await refresh($, dir)
473}
474
475async function undoLast($: Eng, dir: string) {
476 const list = await read($, receipts)
477 const last = list[list.length - 1]
478 if (!last) return say($, 'Nothing to undo')
479 await working($, 'undo…')
480 const r = await run($, dir, ['timeline', 'undo', last, '--json'])
481 const j = json(r.stdout)
482 await working($, '')
483 if (!j?.ok) return say($, `undo failed: ${j?.error?.message ?? tail(r.stderr || r.stdout)}`)
484 await update($, receipts, l => l.slice(0, -1))
485 await say($, 'undo done')
486 await refresh($, dir)
487}
488
489// ---------- upload ----------
490
491/** the system's file dialog (macOS; zenity elsewhere) → the file copied into <project>/assets → Claude places it */
492async function upload($: Eng, dir: string) {
493 await working($, 'Choose a file…')
494 const mac = await $.process
495 .run(['osascript', '-e', 'POSIX path of (choose file with prompt "Add media to the video")'], { timeoutMs: 600_000 })
496 .catch(() => null)
497 const pick = mac ?? (await $.process.run(['zenity', '--file-selection', '--title=Add media to the video'], { timeoutMs: 600_000 }).catch(() => null))
498 await working($, '')
499 const src = pick?.exitCode === 0 ? pick.stdout.trim() : ''
500 if (!src) return say($, 'Upload cancelled')
501 const name = (src.split('/').pop() ?? 'media').replace(/[^\w.\-]+/g, '-')
502 await $.process.run(['mkdir', '-p', `${dir}/assets`])
503 const cp = await $.process.run(['cp', src, `${dir}/assets/${name}`])
504 if (cp.exitCode !== 0) return say($, `Upload failed: ${tail(cp.stderr)}`)
505 pendingRefresh = true
506 const at = livePort && !livePaused ? liveT : await read($, playhead)
507 await say($, `Added assets/${name}`)
508 void $.prompt.submit({ text: `Add the uploaded file assets/${name} to the video, starting at the playhead (${fmt(at)}s).\n\n${await contextBlock($, dir)}` })
509}
510
511// ---------- check / render ----------
512
513async function check($: Eng, dir: string) {
514 await working($, 'check… (lint + runtime + layout + contrast)')
515 const r = await run($, dir, ['check', '--json'], 300_000).catch(err => ({ exitCode: 1, stdout: '', stderr: String(err) }))
516 await working($, '')
517 const j = json(r.stdout)
518 const issues: Json[] = Array.isArray(j?.issues) ? j.issues : Array.isArray(j?.findings) ? j.findings : []
519 const errors = issues.filter(i => i?.severity === 'error').length
520 const counts = issues.length ? ` · ${errors} error(s), ${issues.length - errors} other finding(s)` : ''
521 await say($, r.exitCode === 0 ? `check passed${counts}` : `check failed (exit ${r.exitCode})${counts}. Ask Claude to fix the findings.`)
522}
523
524async function renderDraft($: Eng, dir: string) {
525 const out = 'renders/draft.mp4'
526 await working($, 'rendering draft → renders/draft.mp4 (can take minutes)…')
527 const r = await run($, dir, ['render', '--quality', 'draft', '--quiet', '-o', out], 600_000).catch(err => ({
528 exitCode: 1,
529 stdout: '',
530 stderr: String(err),
531 }))
532 await working($, '')
533 if (r.exitCode !== 0) return say($, `render failed (exit ${r.exitCode}): ${tail(r.stderr || r.stdout)}`)
534 await say($, `rendered ${dir}/${out}`)
535 $.ui.toast(`Cutroom: draft rendered → ${out}`)
536}
537
538// ---------- what Claude is told ----------
539
540let pendingRefresh = false
541
542async function contextBlock($: Eng, dir: string): Promise<string> {
543 const [t, tl, selId, pv] = await Promise.all([read($, playhead), read($, timeline), read($, selected), read($, preview)])
544 const clip = tl?.clips.find(c => c.id === selId)
545 const lines: string[] = [`[Cutroom] The person is editing the HyperFrames project at ${dir} in the Cutroom pane.`]
546 const seg = clip && tl ? segmentsOf(tl.clips, tl.duration).find(g => g.id === clip.id) : undefined
547 if (seg) lines.push(`Selected segment ("this" / 這個 / 這段 means it): ${fmt(seg.start)}–${fmt(seg.end)}s of the video, shown as "${seg.label}".`)
548 lines.push(
549 clip
550 ? `Its clip: ${clip.id} on the ${clip.trackKind} track, ${fmt(clip.absStart)}–${fmt(clip.absEnd)}s, declared in ${clip.file}${clip.src ? ` (src ${clip.src})` : ''}.`
551 : 'Nothing is selected.',
552 )
553 const still = `${dir}/${WORK}/live.jpg`
554 const shot = (await liveCmd($, { op: 'save', path: still })) ? still : pv?.png
555 lines.push(`Playhead: ${fmt(livePort && !livePaused ? liveT : t)}s.${shot ? ` A snapshot of that frame is at ${shot} (Read it to see the picture).` : ''}`)
556 if (tl) {
557 lines.push(
558 `Timeline (${fmt(tl.duration)}s): ${tl.clips.map(c => `${c.id} [${c.trackKind}] ${fmt(c.absStart)}–${fmt(c.absEnd)}${c.src ? ` ${c.src}` : ''}`).join('; ')}`,
559 )
560 }
561 lines.push(
562 'Before editing composition files load the hyperframes skill (and hyperframes-core; hyperframes-keyframes for motion, hyperframes-audio for sound). Use `hyperframes timeline <verb>` for cuts and `hyperframes snapshot --at <t>` to look at any other frame. Change only what the person named, run `hyperframes lint` afterwards, and keep the reply to one or two sentences. The Cutroom pane reloads by itself.',
563 )
564 return lines.join('\n')
565}
566
567// ---------- drawing helpers ----------
568
569/** 3.25 → "00:03.2" */
570const clock = (n: number) => {
571 const v = Math.max(0, n)
572 const m = Math.floor(v / 60)
573 return `${String(m).padStart(2, '0')}:${(v - m * 60).toFixed(1).padStart(4, '0')}`
574}
575const pct = (n: number, d: number) => `${Math.round(Math.max(0, Math.min(100, d > 0 ? (n / d) * 100 : 0)))}%` // the engine takes whole percentages
576
577// a CapCut-like dark editor; one colour per track kind
578// Anthropic's palette: warm slate neutrals, ivory text, Claude's clay as the one accent
579const UI = { bg: '#1f1e1d', panel: '#262624', lane: '#30302e', text: '#f0eee6', dim: '#a6a39a', accent: '#d97757', head: '#faf9f5', black: '#141413' }
580const TRACK_ORDER = ['video', 'graphics', 'captions', 'audio']
581
582// ---------- the timeline: one track, cut by time ----------
583
584// a clip being dragged (moved or trimmed) and where it would be, drawn before the release commits it
585let dragPreview: { id: string; start: number; end: number } | null = null
586
587const nice = (id: string) => {
588 const s = id.replace(/[-_](layer|comp|composition|clip|scene)$/i, '').replace(/[-_]+/g, ' ')
589 return s.charAt(0).toUpperCase() + s.slice(1)
590}
591
592/** every clip edge is a cut; each piece is named for the shortest clip covering it (the most specific content there) */
593function segmentsOf(clips: HfClip[], duration: number): Segment[] {
594 const d = duration
595 const cuts = [...new Set([0, d, ...clips.flatMap(c => [c.absStart, c.absEnd])].filter(x => x >= 0 && x <= d))].sort((a, b) => a - b)
596 const out: Segment[] = []
597 for (let i = 0; i + 1 < cuts.length; i++) {
598 const a = cuts[i] ?? 0
599 const b = cuts[i + 1] ?? d
600 if (b - a < 0.05) continue
601 const cover = clips
602 .filter(c => c.absStart <= a + 1e-6 && c.absEnd >= b - 1e-6)
603 .sort((x, y) => x.absEnd - x.absStart - (y.absEnd - y.absStart) || TRACK_ORDER.indexOf(x.trackKind) - TRACK_ORDER.indexOf(y.trackKind))[0]
604 const id = cover?.id ?? `gap-${i}`
605 const last = out[out.length - 1]
606 if (last && last.id === id) last.end = b
607 else out.push({ id, label: cover ? nice(cover.id) : 'Empty', start: a, end: b, clipStart: cover?.absStart ?? a, clipEnd: cover?.absEnd ?? b })
608 }
609 return out
610}
611
612const TL = { inset: 2, ruler: 26, track: 84, radius: 10, gap: 3 }
613const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
614
615/** the track as one Svg document (real frames inside each segment), plus the geometry the pointer overlay maps against */
616// the track's Svg is the heaviest string in a redraw: rebuild it only when something drawn in it changes
617// (the playhead on a 0.1 s grid), not on every live frame
618let svgCache: { key: string; value: ReturnType<typeof buildTimelineSvg> } | null = null
619function timelineSvg(width: number, duration: number, t: number, selected: string | null, segs: Segment[], frames: string[]) {
620 const key = `${width}|${duration}|${Math.round(t * 10)}|${selected}|${frames.length}|${frames[0]?.length ?? 0}|${segs.map(g => `${g.id}:${g.start}:${g.end}`).join(',')}`
621 if (svgCache?.key !== key || svgCache.value.frames !== frames) svgCache = { key, value: buildTimelineSvg(width, duration, t, selected, segs, frames) }
622 return svgCache.value
623}
624function buildTimelineSvg(width: number, duration: number, t: number, selected: string | null, segs: Segment[], frames: string[]) {
625 const d = duration > 0 ? duration : 1
626 const w = width - 2 * TL.inset
627 const height = TL.ruler + TL.track + 4
628 const x = (s: number) => TL.inset + (Math.max(0, Math.min(d, s)) / d) * w
629 const y = TL.ruler
630 const slot = w / Math.max(1, frames.length)
631 const out: string[] = [
632 `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="-apple-system,BlinkMacSystemFont,'SF Pro Text',system-ui,sans-serif">`,
633 '<defs>',
634 ...frames.map((f, i) => `<image id="f${i}" width="${slot + 1}" height="${TL.track}" preserveAspectRatio="xMidYMid slice" href="data:image/jpeg;base64,${f}" xlink:href="data:image/jpeg;base64,${f}"/>`),
635 `<linearGradient id="shade" x1="0" y1="0" x2="0" y2="1"><stop offset=".45" stop-color="${UI.black}" stop-opacity="0"/><stop offset="1" stop-color="${UI.black}" stop-opacity=".78"/></linearGradient>`,
636 '</defs>',
637 ]
638 // ruler: a tick a second (labelled every quarter)
639 const step = d <= 12 ? 1 : d <= 60 ? 5 : 15
640 for (let s = 0; s <= d + 1e-6; s += step) out.push(`<line x1="${x(s)}" y1="${y - 7}" x2="${x(s)}" y2="${y - 3}" stroke="#5e5d59"/>`)
641 for (let i = 0; i <= 4; i++) {
642 const s = (d * i) / 4
643 out.push(`<text x="${x(s)}" y="${y - 11}" fill="${UI.dim}" font-size="11" text-anchor="${i === 0 ? 'start' : i === 4 ? 'end' : 'middle'}">${fmt(s)}s</text>`)
644 }
645 out.push(`<rect x="${TL.inset}" y="${y}" width="${w}" height="${TL.track}" rx="${TL.radius}" fill="${UI.lane}"/>`)
646 segs.forEach((g, gi) => {
647 const x0 = x(g.start) + (gi === 0 ? 0 : TL.gap / 2)
648 const x1 = x(g.end) - (gi === segs.length - 1 ? 0 : TL.gap / 2)
649 const sw = Math.max(4, x1 - x0)
650 const id = `s${gi}`
651 out.push(`<clipPath id="${id}"><rect x="${x0}" y="${y}" width="${sw}" height="${TL.track}" rx="${TL.radius}"/></clipPath><g clip-path="url(#${id})">`)
652 out.push(`<rect x="${x0}" y="${y}" width="${sw}" height="${TL.track}" fill="${UI.panel}"/>`)
653 frames.forEach((_, i) => {
654 const fs = ((i + 0.5) * d) / frames.length
655 if (x(fs) + slot / 2 < x0 || x(fs) - slot / 2 > x1) return
656 out.push(`<use href="#f${i}" xlink:href="#f${i}" x="${x(fs) - slot / 2}" y="${y}"/>`)
657 })
658 out.push(`<rect x="${x0}" y="${y}" width="${sw}" height="${TL.track}" fill="url(#shade)"/>`)
659 if (sw > 54) {
660 out.push(`<text x="${x0 + 10}" y="${y + TL.track - 12}" fill="${UI.head}" font-size="12.5" font-weight="600">${esc(g.label)}</text>`)
661 if (sw > 120) out.push(`<text x="${x1 - 10}" y="${y + TL.track - 12}" fill="${UI.head}" fill-opacity=".7" font-size="11" text-anchor="end">${fmt(g.end - g.start)}s</text>`)
662 }
663 out.push('</g>')
664 const isSel = g.id === selected
665 out.push(`<rect x="${x0 + 1}" y="${y + 1}" width="${sw - 2}" height="${TL.track - 2}" rx="${TL.radius - 1}" fill="none" stroke="${isSel ? UI.accent : 'rgba(240,238,230,.10)'}" stroke-width="${isSel ? 2.5 : 1}"/>`)
666 })
667 const px = x(t)
668 out.push(`<line x1="${px}" y1="${y - 6}" x2="${px}" y2="${y + TL.track + 3}" stroke="${UI.head}" stroke-width="2" stroke-linecap="round"/>`)
669 out.push(`<circle cx="${px}" cy="${y - 7}" r="4.5" fill="${UI.head}"/>`)
670 out.push('</svg>')
671 const geo: TimelineGeo = { inset: TL.inset / width, ruler: TL.ruler / height }
672 return { source: out.join(''), height, geo, frames }
673}
674
675// ---------- register ----------
676
677export const register: Register = on => {
678 let hasPixels = false // the terminal speaks the kitty graphics protocol
679
680 on('session.start', async ($, e, next) => {
681 await $.command.register({
682 name: 'cut',
683 description: 'Cutroom: live preview and a draggable timeline for a HyperFrames project',
684 argumentHint: '[project-dir]',
685 })
686 const [termProgram, kitty, ghostty] = await Promise.all([
687 $.env.get('TERM_PROGRAM'),
688 $.env.get('KITTY_WINDOW_ID'),
689 $.env.get('GHOSTTY_RESOURCES_DIR'),
690 ])
691 hasPixels = Boolean(kitty || ghostty || /kitty|ghostty/i.test(termProgram ?? ''))
692 const started = await next(e)
693 const open = await read($, project)
694 if (open) {
695 // a reload: redraw the timeline and restart the player
696 void (async () => {
697 await refresh($, open)
698 await startLive($, open)
699 })().catch(quiet)
700 } else if (e.isInteractive && (await $.fs.exists(`${e.cwd}/hyperframes.json`))) {
701 void (async () => {
702 await $.ui.open({ id: PANE, title: 'Cutroom', columns: 100, rows: 24 })
703 await openProject($, e.cwd)
704 })().catch(quiet)
705 }
706 return started
707 })
708
709 on('command.run', { command: 'cut' }, async ($, e) => {
710 const cwd = await $.session.cwd()
711 const home = (await $.env.get('HOME')) ?? ''
712 let arg = e.args.trim()
713 if (arg.startsWith('~')) arg = home + arg.slice(1)
714 await $.ui.open({ id: PANE, title: 'Cutroom', focus: true, columns: 100, rows: 24 })
715 if (arg) {
716 const dir = arg.startsWith('/') ? arg : `${cwd}/${arg}`
717 await openProject($, dir)
718 return { text: `Cutroom: ${dir}` }
719 }
720 const current = await read($, project)
721 if (current) {
722 if (!livePort) void startLive($, current).catch(quiet)
723 return { text: `Cutroom: ${current}` }
724 }
725 const last = await $.store.get(`last:${cwd}`)
726 if (typeof last === 'string' && (await isProject($, last))) {
727 await openProject($, last)
728 return { text: `Cutroom: ${last}` }
729 }
730 const found = await findProjects($, cwd)
731 await update($, candidates, () => found)
732 const only = found[0]
733 if (found.length === 1 && only) {
734 await openProject($, only)
735 return { text: `Cutroom: ${only}` }
736 }
737 return {
738 text: found.length
739 ? `Cutroom: ${found.length} projects found, pick one in the pane`
740 : 'Cutroom: no project under this directory. Run /cut <project-dir>',
741 }
742 })
743
744 // what the person types in the normal prompt carries the selected clip and the playhead
745 on('prompt.submit', async ($, e, next) => {
746 const dir = await read($, project)
747 if (!dir || e.origin.kind !== 'composer') return next(e)
748 pendingRefresh = true
749 return next({ ...e, context: [...(e.context ?? []), await contextBlock($, dir)] })
750 })
751
752 on('turn.complete', async ($, e, next) => {
753 const done = await next(e)
754 const dir = await read($, project)
755 if (pendingRefresh && dir) {
756 pendingRefresh = false
757 void refresh($, dir).catch(quiet)
758 }
759 return done
760 })
761
762 on('ui.close', { id: PANE }, async ($, e, next) => {
763 stopLive()
764 await update($, live, () => null)
765 return next(e)
766 })
767
768 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
769 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
770 const { Box, Text } = $.ui.resolve(e)
771 return (
772 <Box>
773 <Text dimColor>Cutroom needs the terminal or the desktop app.</Text>
774 </Box>
775 )
776 }
777 const { Box, Text, Button, Client } = $.ui.resolve(e)
778 const width = Math.max(48, e.props.bodyColumns - 1)
779 const [dir, tl, selId, t0, pv, msg, job, cands, lv, rc] = await Promise.all([
780 read($, project),
781 read($, timeline),
782 read($, selected),
783 read($, playhead),
784 read($, preview),
785 read($, status),
786 read($, busy),
787 read($, candidates),
788 read($, live),
789 read($, receipts),
790 ])
791
792 if (!dir) {
793 return (
794 <Box flexDirection="column" gap={1}>
795 <Text bold>Cutroom</Text>
796 {cands.length > 0 ? (
797 <Text dimColor>Pick a project:</Text>
798 ) : (
799 <Text dimColor>No HyperFrames project here. Run /cut <project-dir>.</Text>
800 )}
801 {cands.map((c, i) => (
802 <Button
803 key={`cand:${c}`}
804 plain
805 {...(i < 9 ? { hotkey: String(i + 1) } : {})}
806 label={c}
807 onPress={() => void openProject($, c)}
808 />
809 ))}
810 {msg !== '' && <Text dimColor>{msg}</Text>}
811 </Box>
812 )
813 }
814
815 const clips = tl?.clips ?? []
816 const clip = clips.find(c => c.id === selId)
817 const duration = tl?.duration ?? 0
818 const isLive = lv !== null && livePort !== 0
819 const isPlaying = isLive && !livePaused
820 const t = isPlaying ? liveT : t0
821
822 const deskW = Math.max(240, Math.min(LIVE_W, Math.round(e.props.bodyColumns * 7.2) - 24))
823 // the frame: the live player's latest (Svg on the desktop, kitty pixels or half-block
824 // cells in a terminal), else the last still
825 let frame: JSX.Element
826 const inlinePane = e.surface === 'terminal' && e.props.placement === 'inline'
827 const liveCols = Math.max(8, Math.min(width - 2, 255, Math.round(((inlinePane ? 10 : 28) * 2 * LIVE_W) / LIVE_H)))
828 const liveRows = Math.max(1, Math.round((liveCols * LIVE_H) / LIVE_W / 2))
829 if (isLive && e.surface === 'terminal') {
830 const want = hasPixels ? 480 : Math.min(512, liveCols * 2)
831 if (rgbWant !== want || !liveFrame?.rgb) {
832 rgbWant = want
833 liveSeq = -1 // the next poll fetches the current frame again, with rgb at this width
834 }
835 }
836 if (isLive && liveFrame && e.surface !== 'terminal') {
837 const { Svg } = $.ui.resolve(e)
838 const svgW = deskW
839 const svgH = Math.round((svgW * LIVE_H) / LIVE_W)
840 const frames = isPlaying ? takeFlip() : []
841 frame = (
842 <Svg
843 source={frames.length > 1 ? flipbookSvg(svgW, svgH, frames) : `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}"><image href="data:image/jpeg;base64,${frames[0] ?? liveFrame.jpeg}" width="${svgW}" height="${svgH}"/></svg>`}
844 alt={`live @ ${fmt(t)}s`}
845 width={svgW}
846 height={svgH}
847 />
848 )
849 } else if (isLive && e.surface === 'terminal' && liveFrame?.rgb && liveFrame.rgbH > 1) {
850 // ponytail: a redraw per frame (≤30/s); $.ui.blit on the keyed Raster/Image if the pane ever stutters
851 const bytes = fromB64(liveFrame.rgb)
852 if (hasPixels) {
853 const { Image } = $.ui.resolve(e)
854 frame = <Image key="frame" source={{ rgba: rgbToRgba(bytes), width: liveFrame.rgbW, height: liveFrame.rgbH }} columns={liveCols} rows={liveRows} alt={`live @ ${fmt(t)}s`} />
855 } else {
856 const { Raster } = $.ui.resolve(e)
857 frame = <Raster key="frame" columns={liveCols} rows={liveRows} cells={rasterCells(bytes, liveFrame.rgbW, liveFrame.rgbH, liveCols, liveRows)} />
858 }
859 } else if (!pv || !pv.rgb) {
860 frame = <Text color={UI.dim}>{job || 'Loading preview…'}</Text>
861 } else if (e.surface === 'terminal') {
862 const inline = e.props.placement === 'inline'
863 if (hasPixels) {
864 const { Image } = $.ui.resolve(e)
865 const b64 = await cachedBase64($, pv.png, pv.gen)
866 const { width: imgW, height: imgH } = pngSize(b64)
867 const maxRows = inline ? 10 : 30
868 const cols = Math.max(8, Math.min(255, width - 2, Math.round((maxRows * 2.1 * imgW) / imgH)))
869 const rows = Math.max(2, Math.min(255, Math.round((cols * imgH) / imgW / 2.1)))
870 frame = <Image key="frame" source={{ png: b64 }} columns={cols} rows={rows} alt={`frame @ ${fmt(pv.at)}s → ${pv.png}`} />
871 } else {
872 const { Raster } = $.ui.resolve(e)
873 const bytes = fromB64(await cachedBase64($, pv.rgb, pv.gen))
874 const srcW = pv.rgbW
875 const srcH = Math.floor(bytes.length / (srcW * 3))
876 if (srcH < 2) {
877 frame = <Text dimColor>frame @ {fmt(pv.at)}s → {pv.png}</Text>
878 } else {
879 const maxRows = inline ? 10 : 28
880 const cols = Math.max(8, Math.min(width - 2, 512, Math.round((maxRows * 2 * srcW) / srcH)))
881 const rows = Math.max(1, Math.min(256, Math.round((cols * srcH) / srcW / 2)))
882 frame = <Raster key="frame" columns={cols} rows={rows} cells={rasterCells(bytes, srcW, srcH, cols, rows)} />
883 }
884 }
885 } else {
886 const { Svg } = $.ui.resolve(e)
887 const b64 = await cachedBase64($, pv.jpg, pv.gen)
888 const png = await cachedBase64($, pv.png, pv.gen)
889 const { width: w0, height: h0 } = pngSize(png)
890 const svgW = deskW
891 const svgH = Math.max(1, Math.round((svgW * h0) / w0))
892 frame = (
893 <Svg
894 source={`<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}"><image href="data:image/jpeg;base64,${b64}" width="${svgW}" height="${svgH}"/></svg>`}
895 alt={`frame @ ${fmt(pv.at)}s`}
896 width={svgW}
897 height={svgH}
898 />
899 )
900 }
901
902 const go = (to: number) => () => void seek($, dir, to)
903 // key badges help in a terminal; on the desktop they are clutter
904 const hk = (key: string) => (e.surface === 'terminal' ? { hotkey: key } : {})
905
906 const dp = dragPreview
907 const shown = dp ? clips.map(c => (c.id === dp.id ? { ...c, absStart: dp.start, absEnd: dp.end } : c)) : clips
908 const segs = segmentsOf(shown, duration)
909 let timelineEl: JSX.Element
910 if (e.surface === 'desktop') {
911 const { Svg } = $.ui.resolve(e)
912 let svg = timelineSvg(deskW, duration, t, selId, segs, strip)
913 if (svg.source.length > 130_000) svg = timelineSvg(deskW, duration, t, selId, segs, []) // too big with frames
914 const props: TimelineProps = { mode: 'overlay', duration, t, selected: selId, segments: segs, geo: svg.geo }
915 timelineEl = (
916 <Box position="relative" alignSelf="flex-start">
917 <Svg source={svg.source} alt={`timeline: ${segs.map(g => g.label).join(', ')}`} width={deskW} height={svg.height} />
918 <Box position="absolute" top={0} left={0} width="100%" height="100%">
919 <Client key="timeline" module="./timeline.tsx" props={props} width="100%" height="100%" />
920 </Box>
921 </Box>
922 )
923 } else {
924 const props: TimelineProps = { mode: 'text', duration, t, selected: selId, segments: segs }
925 timelineEl = <Client key="timeline" module="./timeline.tsx" props={props} width="100%" height={3} />
926 }
927 const seg = segs.find(g => g.id === selId)
928 const target = seg ?? segs.find(g => t >= g.start && t < g.end)
929 const tclip = clips.find(c => c.id === target?.id)
930 const cut = (label: string, args: (c: HfClip) => string[] | string) => () => {
931 if (!tclip) return void say($, 'Click a segment first')
932 const a = args(tclip)
933 void (typeof a === 'string' ? say($, a) : edit($, dir, label, a))
934 }
935 const inside = (c: HfClip) => t > c.absStart + 0.02 && t < c.absEnd - 0.02
936 const NOT_INSIDE = 'Move the playhead inside the segment first'
937
938 return (
939 <Box flexDirection="column" backgroundColor={UI.bg} paddingX={1} gap={1}>
940 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
941 <Box gap={1}>
942 <Text bold color={UI.text}>
943 {base(dir)}
944 </Text>
945 <Text color={UI.dim}>{fmt(duration)}s</Text>
946 </Box>
947 <Box gap={1}>
948 <Button key="upload" {...hk('n')} variant="secondary" label="Upload" onPress={() => void upload($, dir)} />
949 <Button key="render" {...hk('v')} variant="primary" label="Export" onPress={() => void renderDraft($, dir)} />
950 </Box>
951 </Box>
952
953 <Box justifyContent="center" backgroundColor={UI.black}>
954 <Box position="relative">
955 {frame}
956 <Box position="absolute" top={0} left={0} width="100%" height="100%">
957 <Client key="tap" module="./tap.tsx" props={null} width="100%" height="100%" />
958 </Box>
959 </Box>
960 </Box>
961
962 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
963 <Text color={UI.text}>
964 {clock(t)} <Text color={UI.dim}>/ {clock(duration)}</Text>
965 </Text>
966 <Box gap={2} alignItems="center">
967 <Button key="seek-start" plain {...hk('a')} label="⏮" onPress={go(clip ? clip.absStart : 0)} />
968 <Button key="seek-m1" plain {...hk('j')} label="−1s" onPress={go(t - 1)} />
969 <Button key="play" plain {...hk('p')} label={isPlaying ? '❚❚' : '▶'} onPress={() => void togglePlay($)} />
970 <Button key="seek-p1" plain {...hk('k')} label="+1s" onPress={go(t + 1)} />
971 <Button key="seek-end" plain {...hk('f')} label="⏭" onPress={go(clip ? clip.absEnd - 0.05 : duration)} />
972 </Box>
973 <Text color={isLive ? UI.accent : UI.dim}>{isLive ? '● Live' : '○ Still'}</Text>
974 </Box>
975
976 <Box flexDirection="row" gap={1} alignItems="center">
977 <Button key="split" {...hk('s')} variant="secondary" label="✂ Split" onPress={cut('split', c => (inside(c) ? ['split', c.ref, fmt(t)] : NOT_INSIDE))} />
978 <Button key="trim-in" {...hk('i')} variant="secondary" label="⇤ Trim start" onPress={cut('trim start', c => (inside(c) ? ['trim', c.ref, '--start', fmt(t), '--end', fmt(c.absEnd)] : NOT_INSIDE))} />
979 <Button key="trim-out" {...hk('o')} variant="secondary" label="Trim end ⇥" onPress={cut('trim end', c => (inside(c) ? ['trim', c.ref, '--end', fmt(t)] : NOT_INSIDE))} />
980 <Button key="delete" {...hk('x')} variant="secondary" label="Delete" onPress={cut('delete', c => ['delete', c.ref])} />
981 <Box flexGrow={1} />
982 <Button key="undo" {...hk('u')} variant="secondary" label={`↶ Undo${rc.length ? ` (${rc.length})` : ''}`} onPress={() => void undoLast($, dir)} />
983 </Box>
984 {timelineEl}
985
986 <Text color={UI.dim}>
987 {job ||
988 [seg ? `${seg.label}, ${fmt(seg.start)}-${fmt(seg.end)}s selected. Tell Claude what to change.` : 'Click a segment to select it, drag to scrub. Tell Claude what to change.', msg]
989 .filter(Boolean)
990 .join(' · ')}
991 </Text>
992 </Box>
993 )
994 })
995
996 // the timeline Client posts what the pointer did
997 on('ui.message', { requestId: PANE }, async ($, e) => {
998 const dir = await read($, project)
999 const m = e.data as { op?: string; t?: number; id?: string; start?: number } | null
1000 if (!dir || !m) return {}
1001 const tl = await read($, timeline)
1002 const c = tl?.clips.find(x => x.id === m.id)
1003 if (m.op === 'toggle') {
1004 await togglePlay($)
1005 return {}
1006 }
1007 const n = (k: string) => (typeof (m as Record<string, unknown>)[k] === 'number' ? ((m as Record<string, unknown>)[k] as number) : null)
1008 if (m.op === 'drag' && c) {
1009 const start = n('start')
1010 const end = n('end')
1011 if (start === null || end === null) return {}
1012 dragPreview = { id: c.id, start, end }
1013 await update($, selected, () => c.id)
1014 await seek($, dir, n('show') ?? start) // the preview shows the frame at the edge being dragged
1015 $.ui.invalidate('ui.render')
1016 return {}
1017 }
1018 if (m.op === 'commit' && c) {
1019 const start = n('start') ?? c.absStart
1020 const end = n('end') ?? c.absEnd
1021 const kind = (m as { kind?: string }).kind
1022 const args =
1023 kind === 'move' ? ['move', c.ref, fmt(start)]
1024 : kind === 'trim-start' ? ['trim', c.ref, '--start', fmt(start), '--end', fmt(end)]
1025 : ['trim', c.ref, '--end', fmt(end)]
1026 await edit($, dir, kind === 'move' ? 'move' : 'trim', args) // the reloaded timeline replaces the preview
1027 dragPreview = null
1028 $.ui.invalidate('ui.render')
1029 return {}
1030 }
1031 if (typeof m.t === 'number') await seek($, dir, m.t)
1032 if (m.op === 'press') await update($, selected, () => (c ? c.id : null))
1033 return {}
1034 })
1035}
1036hooks/timeline.tsx 145 lines1import type { ClientModule } from 'claude-code'
2
3// The Cutroom timeline: one track, cut by time into segments.
4// ruler, or the playhead line: press or drag to scrub
5// segment: click to select it (the playhead goes there); drag its middle to move
6// its clip, drag an edge that is the clip's own edge to trim it; the preview
7// follows while dragging and the release commits
8// In a terminal it also draws the track in text cells (mode "text"); on the desktop
9// the hooks module draws it as an Svg with real frames and this region lies over
10// it, transparent (mode "overlay"), mapping the pointer by fractions of the region
11// so it lines up with any font. Every outcome is posted as { op, ... }.
12
13/** a piece of the track, and the clip that names it (whose edges a drag moves) */
14export type Segment = { id: string; label: string; start: number; end: number; clipStart: number; clipEnd: number }
15/** overlay geometry, as fractions of the region: the inset either side of the track, the ruler band */
16export type TimelineGeo = { inset: number; ruler: number }
17export type TimelineProps = {
18 mode: 'text' | 'overlay'
19 duration: number
20 t: number
21 selected: string | null
22 segments: Segment[]
23 geo?: TimelineGeo
24}
25type Kind = 'move' | 'trim-start' | 'trim-end'
26type Gesture =
27 | { kind: 'none' }
28 | { kind: 'scrub' }
29 | { kind: 'press'; seg: Segment; grabT: number; edge: Kind } // down on a segment, not yet a drag
30 | { kind: Kind; seg: Segment; grabT: number; start: number; end: number }
31
32const ACCENT = '#d97757'
33const DIM = '#a6a39a'
34const round = (n: number) => Math.round(n * 100) / 100
35// the gesture in flight; setState lands a frame late and a drag's events outrun it
36// ponytail: module-wide, fine while the pane draws one timeline
37let g: Gesture = { kind: 'none' }
38let tick = 0
39
40const Timeline: ClientModule<TimelineProps, number> = (p, s) => {
41 const { Box, Text } = s.elements
42 const d = p.duration > 0 ? p.duration : 1
43 const cols = Math.max(1, s.columns)
44 const rows = Math.max(1, s.rows)
45 const span = p.mode === 'overlay' && p.geo ? 1 - 2 * p.geo.inset : 1
46 const edgeT = (0.012 / span) * d // how close to an edge a press grabs it: ~1.2% of the track
47 const dragT = d / 150 // movement below this is still a click
48
49 const timeAt = (x: number, fx?: number) => {
50 const u = p.mode === 'overlay' && p.geo ? ((fx ?? x + 0.5) / cols - p.geo.inset) / span : x / cols
51 return Math.max(0, Math.min(d, u * d))
52 }
53 const onRuler = (y: number, fy?: number) => (p.mode === 'overlay' && p.geo ? (fy ?? y + 0.5) / rows < p.geo.ruler : y === 0)
54 const set = (next: Gesture) => {
55 g = next
56 s.setState(++tick) // redraw the text track; the overlay draws nothing either way
57 }
58 const press = (t: number) => {
59 const seg = p.segments.find(x => t >= x.start && t < x.end) ?? p.segments[p.segments.length - 1]
60 s.post({ op: 'press', t: round(t), id: seg?.id ?? null })
61 }
62
63 s.onPointer(ev => {
64 const t = timeAt(ev.x, ev.fine?.x)
65 if (ev.type === 'down' && ev.button === 'left') {
66 // the ruler, or the playhead line itself anywhere down the track, scrubs
67 if (onRuler(ev.y, ev.fine?.y) || Math.abs(t - p.t) <= edgeT) {
68 set({ kind: 'scrub' })
69 s.post({ op: 'seek', t: round(t) })
70 return
71 }
72 const seg = p.segments.find(x => t >= x.start - edgeT && t < x.end + edgeT)
73 if (!seg) return set({ kind: 'none' })
74 // an edge grabs a trim only where it is the clip's own edge
75 const edge: Kind =
76 Math.abs(t - seg.end) <= edgeT && Math.abs(seg.end - seg.clipEnd) < 1e-3
77 ? 'trim-end'
78 : Math.abs(t - seg.start) <= edgeT && Math.abs(seg.start - seg.clipStart) < 1e-3
79 ? 'trim-start'
80 : 'move'
81 set({ kind: 'press', seg, grabT: t, edge })
82 } else if (ev.type === 'move' && ev.button === 'left') {
83 if (g.kind === 'scrub') return s.post({ op: 'seek', t: round(t) })
84 if (g.kind === 'none') return
85 if (g.kind === 'press' && Math.abs(t - g.grabT) < dragT) return
86 const base = g.kind === 'press' ? { kind: g.edge, seg: g.seg, grabT: g.grabT } : g
87 const { seg } = base
88 const dt = t - base.grabT
89 const len = seg.clipEnd - seg.clipStart
90 let start = seg.clipStart
91 let end = seg.clipEnd
92 if (base.kind === 'move') {
93 start = Math.max(0, Math.min(d - len, seg.clipStart + dt))
94 end = start + len
95 } else if (base.kind === 'trim-start') start = Math.max(0, Math.min(seg.clipEnd - 0.1, seg.clipStart + dt))
96 else end = Math.min(d, Math.max(seg.clipStart + 0.1, seg.clipEnd + dt))
97 set({ kind: base.kind, seg, grabT: base.grabT, start, end })
98 s.post({ op: 'drag', id: seg.id, start: round(start), end: round(end), show: round(base.kind === 'trim-end' ? end - 0.04 : start) })
99 } else if (ev.type === 'up') {
100 if (g.kind === 'move' || g.kind === 'trim-start' || g.kind === 'trim-end') {
101 s.post({ op: 'commit', kind: g.kind, id: g.seg.id, start: round(g.start), end: round(g.end) })
102 } else if (g.kind === 'press') press(g.grabT)
103 else if (g.kind === 'none' && ev.button === 'left' && !onRuler(ev.y, ev.fine?.y)) press(t) // a click that only focused the region
104 set({ kind: 'none' })
105 }
106 })
107
108 if (p.mode === 'overlay') return <Box width="100%" height="100%" />
109
110 // text mode: a ruler, the segments as coloured runs (│ at the playhead), a ▲ under it
111 const toX = (t: number) => Math.max(0, Math.min(cols - 1, Math.floor((t / d) * cols)))
112 const playX = toX(p.t)
113 const ruler = Array.from({ length: cols }, () => ' ')
114 for (let i = 0; i <= 4; i++) {
115 const label = `${round((d * i) / 4)}s`
116 const x0 = Math.min(cols - label.length, toX((d * i) / 4))
117 for (let k = 0; k < label.length; k++) ruler[x0 + k] = label[k] ?? ' '
118 }
119 const runs = p.segments.map((seg, i) => {
120 const a = toX(seg.start)
121 const b = i === p.segments.length - 1 ? cols : toX(seg.end)
122 const name = ` ${seg.label}`
123 let text = ''
124 for (let x = a; x < b; x++) text += x === playX ? '│' : x === b - 1 && b < cols ? '▕' : (name[x - a] ?? ' ')
125 const isSel = seg.id === p.selected
126 return { key: `${seg.id}:${i}`, text, bg: isSel ? ACCENT : i % 2 ? '#30302e' : '#3a3936', fg: isSel ? '#141413' : '#f0eee6' }
127 })
128
129 return (
130 <Box flexDirection="column">
131 <Text color={DIM}>{ruler.join('')}</Text>
132 <Text>
133 {runs.map(r => (
134 <Text key={`seg:${r.key}`} backgroundColor={r.bg} color={r.fg}>
135 {r.text}
136 </Text>
137 ))}
138 </Text>
139 <Text color={ACCENT}>{' '.repeat(playX)}▲</Text>
140 </Box>
141 )
142}
143
144export default Timeline
145hooks/tap.tsx 23 lines1import type { ClientModule } from 'claude-code'
2
3// A transparent region over the preview: one tap plays or pauses.
4// A click that only focuses the region may deliver no down, so the up counts too.
5let down = false
6
7const Tap: ClientModule<null, boolean> = (_, s) => {
8 s.onPointer(ev => {
9 if (ev.button !== 'left') return
10 if (ev.type === 'down') {
11 down = true
12 s.post({ op: 'toggle' })
13 } else if (ev.type === 'up') {
14 if (!down) s.post({ op: 'toggle' })
15 down = false
16 }
17 })
18 const { Box } = s.elements
19 return <Box width="100%" height="100%" />
20}
21
22export default Tap
23types/index.d.ts 31 lines1export type HfClip = {
2 id: string
3 ref: string
4 file: string
5 trackKind: string
6 absStart: number
7 absEnd: number
8 src: string | null
9 children: number
10}
11export type HfTimeline = { duration: number; clips: HfClip[] } | null
12/** The frame under the playhead: full-size png, a 480px jpg (desktop Svg) and a 192px-wide raw rgb strip (terminal Raster). */
13export type HfPreview = { at: number; png: string; jpg: string; rgb: string; rgbW: number; gen: number } | null
14declare module 'claude-code' {
15 interface PluginState {
16 cutroom: {
17 project: string | null
18 candidates: string[]
19 timeline: HfTimeline
20 selected: string | null
21 playhead: number
22 preview: HfPreview
23 status: string
24 busy: string
25 receipts: string[]
26 receipts: string[]
27 live: { port: number; duration: number } | null
28 }
29 }
30}
31