SLOPSHOPPER

cutroom

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

newpanecommandtoastpromptprocess
v0.1.0MITupdated 2026-10-08leepokai/claude-cutroom
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cutroom
│ ┃ Cutroom ✕ › fix the failing auth test and add an audit log call │ ┃ Cutroom │ ┃ ⏺ Read(src/auth.ts) │ ┃ No HyperFrames project here. Run /cut ⎿ Read 6 lines │ ┃ <project-dir>. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cut │ ⎿ cutroom: Cutroom: no project under this directory. Run /cut <pro │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cutroom
Cutroom No HyperFrames project here. Run /cut <project-dir>.
README

claude-mod-cutroom

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.

Install

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.

Use

  1. /cut in a HyperFrames project (or /cut <dir>). With hyperframes.json in the cwd the pane opens by itself at session start.
  2. Watch. Tap the video (or ▶) to play or pause; ⏮ −1s +1s ⏭ step.
  3. Point. Click a segment to select it and put the playhead there; drag along the track to scrub, the preview follows.
  4. Change it by telling Claude in the chat; when the turn ends the pane reloads the timeline, the frames and the player.
  5. Export renders renders/draft.mp4.

Layout

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.
  • Once loaded from a folder you own, the engine lays its API declarations under .claude-plugin/types/, and tsc -p . type-checks the mod.

License

MIT, see LICENSE.

Source 4 files
hooks/register.tsx 1036 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 &lt;project-dir&gt;.</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}
1036
hooks/timeline.tsx 145 lines
1import 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
145
hooks/tap.tsx 23 lines
1import 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
23
types/index.d.ts 31 lines
1export 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