Studio for /mod:shorts: polls OpusClip, fetches the source, runs one producer per Short in parallel, renders, checks, and draws every lane in a pane.

A Claude Code mod that /mod:shorts hands its event to. It checks the source against the rights log, watches the OpusClip job, downloads the source and transcript, runs one producer subagent per Short in parallel, renders with Remotion, runs the playbook checklist, and draws every lane in a pane.
setup.sh merges CLAUDE_CODE_PLUGIN_DIRS from config/claude-settings.json into ~/.claude/settings.json. For one session only: claude --plugin-dir ~/Developer/claude-code-i14u/mods/shorts-studio.
It needs ~/Developer/shorts-studio installed (yarn install there) and ffmpeg. /studio reopens the pane.
| Piece | How |
|---|---|
| Tools | event_start, lane_add, dispatch, lane_update, render, finish, status, event_end, listed as mcp__shorts-studio__* |
| Poller | Every 30s while a job runs: opusclip_list_clips, then export_clip (HD), scripts/fetch-clip.ts, opusclip_get_transcript (shifted onto the file's clock for a range job) |
| Parallel lanes | dispatch spawns one shorts-studio:producer (Sonnet) per lane in the background |
| Render | render streams Remotion progress into the pane; at most 2 at once, the rest answer busy; then runs scripts/checklist.ts |
| Finish | finish runs scripts/voice.ts, scripts/captions.ts, then the final render and checklist, in one render slot |
| Lane fallback | A producer that ends without settling its lane: a report.json and out/draft.mp4 newer than the lane settle it as a draft; otherwise it is blocked |
| Wakes | [shorts-studio] <event>: source ready, then one when every lane has a draft or is blocked |
| Guards | Denies OpusClip post, schedule, share and unschedule; a second job for the event; a job for any other URL; a source not approved |
| Pane | Event header (rights, job, credits, stage), one lane per Short with its step track, render bar and checklist counts, guard denials, log |
claude plugin validate mods/shorts-studio
claude plugin test mods/shorts-studio
The mod API is early access. Run both after every Claude Code update.
hooks/register.tsx 652 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Lane, LaneStep, StudioEvent } from '../types'
5import {
6 DEFAULT_CHANNEL,
7 LANE_STEPS,
8 MAX_LANES,
9 MAX_POLL_ERRORS,
10 MAX_RENDERS,
11 POLL_MS,
12 activeEvent,
13 addLog,
14 allSettled,
15 channelOf,
16 checklistFrom,
17 draftsWake,
18 isSettled,
19 moveLane,
20 opusAction,
21 shortsDir,
22 opusDenial,
23 parseJson,
24 producerPrompt,
25 progressFrom,
26 rightsStatus,
27 setLane,
28 settleFromReport,
29 shiftTranscript,
30 sourceCovered,
31 isYouTube,
32 statusLine,
33 wordCount,
34 type Report,
35} from './logic'
36import { sections, type Cell, type Section, type Seg } from './view'
37
38const PLUGIN = 'shorts-studio'
39const PANE = 'shorts-studio'
40const PRODUCER = 'shorts-studio:producer'
41// The claude.ai connector and a local `opusclip` MCP server name the same tools differently.
42const OPUS_SERVERS = ['claude_ai_OpusClip', 'opusclip']
43
44const WAKE_DELAY_MS = 1_000
45
46const events = atom({ plugin: 'shorts-studio', key: 'events' } as const, [] as StudioEvent[])
47
48let repo = ''
49let opusServer: string | undefined
50let poller: { cancel: () => void } | undefined
51let isTicking = false
52let rendersRunning = 0
53
54type Input = Record<string, unknown>
55const str = (input: Input, key: string) => (typeof input[key] === 'string' && input[key] !== '' ? (input[key] as string) : undefined)
56const num = (input: Input, key: string) => (typeof input[key] === 'number' ? (input[key] as number) : undefined)
57
58const SLUG = { type: 'string', description: 'Event slug, e.g. 2026-09-16-fomc' }
59const LANE_ID = { type: 'string', description: 'Lane id, lowercase-dashes, e.g. inflation-3-6' }
60
61const TOOLS = [
62 {
63 name: 'event_start',
64 description:
65 'Start the studio for one event (a press conference, speech or campaign video) on one channel. Checks the source against the rights log and reads OpusClip credits. If source.mp4 and transcript.json are already in the event folder, the event starts ready and needs no OpusClip job. Pass projectId to reuse an existing OpusClip job instead of submitting a new one.',
66 inputSchema: {
67 type: 'object',
68 properties: {
69 slug: SLUG,
70 channel: { type: 'string', description: `Channel folder under channels/, default ${DEFAULT_CHANNEL}; e.g. ai-tech` },
71 title: { type: 'string' },
72 sourceUrl: { type: 'string', description: 'The official video URL' },
73 rightsId: { type: 'string', description: 'source_id in channels/<channel>/rights-log.csv, e.g. fed-youtube' },
74 projectId: { type: 'string', description: 'Existing landscape OpusClip project to reuse' },
75 rangeStartSec: { type: 'number', description: 'rangeStart of the reused project, if it had one' },
76 },
77 required: ['slug', 'title', 'sourceUrl', 'rightsId'],
78 },
79 },
80 {
81 name: 'lane_add',
82 description: `Add one Short to the event (at most ${MAX_LANES}). Call once per picked moment, then dispatch.`,
83 inputSchema: {
84 type: 'object',
85 properties: {
86 slug: SLUG,
87 id: LANE_ID,
88 title: { type: 'string', description: 'Working title' },
89 angle: { type: 'string', description: 'The moment (source seconds) and the viewer takeaway' },
90 },
91 required: ['slug', 'id', 'title', 'angle'],
92 },
93 },
94 {
95 name: 'dispatch',
96 description: 'Start one shorts-studio:producer subagent per queued lane, all in parallel, then end your turn. The studio wakes the session when every lane has a draft or is blocked.',
97 inputSchema: { type: 'object', properties: { slug: SLUG }, required: ['slug'] },
98 },
99 {
100 name: 'lane_update',
101 description: 'Report a lane step (producers call this). A blocker marks the lane blocked.',
102 inputSchema: {
103 type: 'object',
104 properties: {
105 slug: SLUG,
106 id: LANE_ID,
107 step: { type: 'string', enum: LANE_STEPS },
108 note: { type: 'string' },
109 blocker: { type: 'string' },
110 },
111 required: ['slug', 'id'],
112 },
113 },
114 {
115 name: 'render',
116 description:
117 'Render a lane with Remotion (draft: text cards for VO; final: needs the VO takes), then run the checklist. Blocks until done and returns the checklist result. At most 2 renders run at once; others queue.',
118 inputSchema: {
119 type: 'object',
120 properties: { slug: SLUG, id: LANE_ID, mode: { type: 'string', enum: ['draft', 'final'] } },
121 required: ['slug', 'id', 'mode'],
122 },
123 },
124 {
125 name: 'finish',
126 description:
127 'Finish a lane the user approved: voice its VO with the local stock voice (yarn voice), refill captions, render the final and run the checklist. Blocks until done. Uses a render slot; busy slots answer retry.',
128 inputSchema: {
129 type: 'object',
130 properties: { slug: SLUG, id: LANE_ID, voice: { type: 'string', description: 'Kokoro voice, default am_puck' } },
131 required: ['slug', 'id'],
132 },
133 },
134 {
135 name: 'status',
136 description: 'Read the studio state: the event, its OpusClip job, credits and every lane.',
137 inputSchema: { type: 'object', properties: { slug: SLUG } },
138 },
139 {
140 name: 'event_end',
141 description: 'Close the event once drafts are reviewed.',
142 inputSchema: {
143 type: 'object',
144 properties: { slug: SLUG, outcome: { type: 'string', enum: ['drafts-ready', 'final', 'abandoned'] } },
145 required: ['slug', 'outcome'],
146 },
147 },
148] as const
149
150function openPane($: EngineInterface) {
151 return $.ui.open({ id: PANE, title: 'Shorts studio' }).catch(() => undefined)
152}
153
154async function refreshStatus($: EngineInterface) {
155 const event = activeEvent(await read($, events))
156 $.ui.status(event ? statusLine(event) : undefined)
157}
158
159async function setEvent($: EngineInterface, slug: string, change: (event: StudioEvent) => StudioEvent) {
160 await update($, events, (list) => list.map((e) => (e.slug === slug && e.endedAt === undefined ? change(e) : e)))
161 await refreshStatus($)
162}
163
164async function log($: EngineInterface, slug: string, text: string, tone?: 'error' | 'success' | 'warning') {
165 const now = await $.clock.now()
166 await setEvent($, slug, (e) => addLog(e, now, text, tone))
167}
168
169// $.mcp.call returns raw content; a tool.call's text is the model's view, cut when large.
170async function opus($: EngineInterface, action: string, args: Input = {}) {
171 const servers = opusServer ? [opusServer] : OPUS_SERVERS
172 let lastError = 'no OpusClip server answered'
173 for (const server of servers) {
174 try {
175 const reply = await $.mcp.call(server, `opusclip_${action}`, args)
176 const text = reply.content.map((block) => ('text' in block && typeof block.text === 'string' ? block.text : '')).join('')
177 if (reply.isError) throw new Error(text.slice(0, 300) || 'error')
178 opusServer = server
179 return parseJson(text) ?? {}
180 } catch (error) {
181 lastError = String(error).slice(0, 300)
182 }
183 }
184 throw new Error(lastError)
185}
186
187async function credits($: EngineInterface) {
188 const usage = await opus($, 'get_usage').catch(() => undefined)
189 const remaining = (usage?.credits as { remaining_minutes?: unknown } | undefined)?.remaining_minutes
190 return typeof remaining === 'number' ? remaining : undefined
191}
192
193async function wake($: EngineInterface, slug: string, text: string) {
194 await $.prompt.submit({ text: `[shorts-studio] ${slug}: ${text}` })
195 void $.ui.notify(text.split('\n')[0] ?? text, { title: 'Shorts studio' }).catch(() => undefined)
196}
197
198function startPoller($: EngineInterface) {
199 poller ??= $.clock.every(POLL_MS, () => void tick($))
200}
201
202async function sourceMinutes($: EngineInterface, event: StudioEvent) {
203 const ran = await $.process.run(
204 ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', `${repo}/${shortsDir(event)}/${event.slug}/source.mp4`],
205 { timeoutMs: 30_000 },
206 )
207 const sec = Number(ran.stdout.trim())
208 return Number.isFinite(sec) ? sec / 60 : undefined
209}
210
211// processing → exporting → fetching → ready; the session sleeps until the source is on disk.
212async function advance($: EngineInterface, event: StudioEvent) {
213 if (!event.opusProjectId) return
214 if (event.stage === 'processing') {
215 const listed = await opus($, 'list_clips', { projectId: event.opusProjectId })
216 const clips = (listed.clips as { clip_id?: string }[] | undefined) ?? []
217 const stage = String(listed.stage ?? '')
218 await setEvent($, event.slug, (e) => ({ ...e, opusStage: stage, pollErrors: 0 }))
219 if (/FAIL|ERROR|CANCEL/i.test(stage)) throw new Error(`OpusClip job ${event.opusProjectId} is ${stage}`)
220 if (stage === 'COMPLETE' && !clips[0]?.clip_id) throw new Error(`OpusClip job ${event.opusProjectId} completed with no output`)
221 if (stage === 'COMPLETE' && clips[0]?.clip_id) {
222 await setEvent($, event.slug, (e) => ({ ...e, clipId: clips[0]!.clip_id, stage: 'exporting' }))
223 await log($, event.slug, 'OpusClip job complete; exporting HD source')
224 }
225 return
226 }
227 if (event.stage === 'exporting' && event.clipId) {
228 const exported = await opus($, 'export_clip', { projectId: event.opusProjectId, clipId: event.clipId, target: 'hd' })
229 if (exported.status === 'unavailable') throw new Error('HD export unavailable')
230 if (exported.status !== 'ready' || typeof exported.export_url !== 'string') return
231 await setEvent($, event.slug, (e) => ({ ...e, stage: 'fetching' }))
232 await log($, event.slug, 'downloading source.mp4')
233 const fetched = await $.process.run([`${repo}/node_modules/.bin/tsx`, 'scripts/fetch-clip.ts', '--channel', channelOf(event), event.slug, exported.export_url], {
234 cwd: repo,
235 timeoutMs: 600_000,
236 })
237 if (fetched.exitCode !== 0) throw new Error(`fetch-clip: ${fetched.stderr.slice(0, 300)}`)
238 const transcript = await opus($, 'get_transcript', { projectId: event.opusProjectId })
239 const shifted = shiftTranscript(transcript, event.rangeStartSec)
240 await $.fs.write(`${repo}/${shortsDir(event)}/${event.slug}/transcript.json`, JSON.stringify(shifted))
241 const minutes = await sourceMinutes($, event)
242 const left = await credits($)
243 await setEvent($, event.slug, (e) => ({
244 ...e,
245 stage: 'ready',
246 pollErrors: 0,
247 sourceMinutes: minutes,
248 transcriptWords: wordCount(shifted),
249 creditsNow: left ?? e.creditsNow,
250 }))
251 await log($, event.slug, `source ready: ${minutes?.toFixed(1) ?? '?'} min, ${wordCount(shifted)} words`, 'success')
252 await wake($, event.slug, `source ready (${shortsDir(event)}/${event.slug}/source.mp4 and transcript.json, ${wordCount(shifted)} words). Continue at step 4 of /mod:shorts.`)
253 }
254}
255
256async function tick($: EngineInterface) {
257 if (isTicking) return
258 isTicking = true
259 try {
260 const event = activeEvent(await read($, events))
261 if (!event) return
262 const now = await $.clock.now()
263 await setEvent($, event.slug, (e) => ({ ...e, heartbeatAt: now }))
264 if (!['processing', 'exporting'].includes(event.stage)) return
265 try {
266 await advance($, event)
267 } catch (error) {
268 const errors = event.pollErrors + 1
269 // A failed download goes back to exporting, so the next tick fetches again.
270 await setEvent($, event.slug, (e) => ({ ...e, pollErrors: errors, stage: errors >= MAX_POLL_ERRORS ? 'blocked' : e.stage === 'fetching' ? 'exporting' : e.stage }))
271 await log($, event.slug, `poll failed (${errors}/${MAX_POLL_ERRORS}): ${String(error).slice(0, 120)}`, 'error')
272 if (errors >= MAX_POLL_ERRORS) await wake($, event.slug, `the studio failed ${errors} times getting the source (${String(error).slice(0, 200)}). Check OpusClip by hand.`)
273 }
274 } finally {
275 isTicking = false
276 }
277}
278
279// prompt.submit inside a tool call waits on the turn it holds, so wake from a timer.
280async function maybeWakeDrafts($: EngineInterface, slug: string) {
281 let isDue = false
282 await update($, events, (list) =>
283 list.map((e) => {
284 if (e.slug !== slug || e.endedAt !== undefined || !draftsWake(e)) return e
285 isDue = true
286 return { ...e, hasWokenDrafts: true, stage: 'drafts-ready' as const }
287 }),
288 )
289 if (!isDue) return
290 $.clock.after(WAKE_DELAY_MS, async () => {
291 const event = (await read($, events)).find((e) => e.slug === slug && e.endedAt === undefined)
292 const text = event && allSettled(event) ? draftsWake({ ...event, hasWokenDrafts: false }) : undefined
293 if (text) await wake($, slug, text)
294 })
295}
296
297
298// Files older than the lane belong to an earlier run, not this producer.
299async function laneReport($: EngineInterface, event: StudioEvent, lane: Lane): Promise<Report | undefined> {
300 const dir = `${repo}/${shortsDir(event)}/${event.slug}/${lane.id}`
301 try {
302 const [report, draft] = await Promise.all([$.fs.stat(`${dir}/report.json`), $.fs.stat(`${dir}/out/draft.mp4`)])
303 if (report.mtimeMs < lane.startedAt || draft.mtimeMs < lane.startedAt) return undefined
304 return parseJson(await $.fs.read(`${dir}/report.json`)) as Report | undefined
305 } catch {
306 return undefined
307 }
308}
309
310const touchLane = async ($: EngineInterface, slug: string, id: string, change: (l: Lane, now: number) => Lane) => {
311 const now = await $.clock.now()
312 await setEvent($, slug, (e) => setLane(e, id, (l) => change(l, now)))
313}
314
315// Waiting here would spend the hook's own-time budget, so a full slot answers busy.
316async function withSlot($: EngineInterface, slug: string, lane: Lane, work: () => Promise<string>) {
317 if (rendersRunning >= MAX_RENDERS) {
318 await touchLane($, slug, lane.id, (l) => ({ ...l, note: 'waiting for a render slot' }))
319 return `Render slots busy (${rendersRunning} running). Wait about 60s and call again.`
320 }
321 rendersRunning++
322 try {
323 return await work()
324 } finally {
325 rendersRunning--
326 }
327}
328
329async function render($: EngineInterface, event: StudioEvent, lane: Lane, mode: 'draft' | 'final') {
330 const slug = event.slug
331 const spec = `${shortsDir(event)}/${slug}/${lane.id}/spec.json`
332 const renderStep: LaneStep = mode === 'final' ? 'final-render' : 'render'
333 const touch = (change: (l: Lane, now: number) => Lane) => touchLane($, slug, lane.id, change)
334
335 let output = ''
336 let code: number | null = null
337 await touch((l, now) => moveLane(l, renderStep, now, { renderPct: 0, note: undefined }))
338 await log($, slug, `${lane.id}: ${mode} render started`)
339 const child = $.process.spawn({ argv: [`${repo}/node_modules/.bin/tsx`, 'scripts/render.ts', spec, '--mode', mode], cwd: repo })
340 let shown = 0
341 const iterator = child[Symbol.asyncIterator]()
342 for (;;) {
343 const piece = await iterator.next()
344 if (piece.done) {
345 code = piece.value?.code ?? null
346 break
347 }
348 output += piece.value.text
349 const pct = progressFrom(piece.value.text)
350 if (pct !== undefined && (pct - shown >= 5 || pct === 100)) {
351 shown = pct
352 await touch((l) => ({ ...l, renderPct: pct }))
353 }
354 }
355
356 if (code !== 0) {
357 const reason = /^error (.*)$/m.exec(output)?.[1] ?? `render exited ${code}`
358 await touch((l, now) => moveLane(l, mode === 'final' ? 'draft-ready' : 'scenes', now, { note: reason.slice(0, 160) }))
359 await log($, slug, `${lane.id}: render failed: ${reason.slice(0, 120)}`, 'error')
360 return `Render failed: ${reason}\nFix spec.json and call render again.`
361 }
362
363 await touch((l, now) => moveLane(l, 'check', now, { renderPct: 100 }))
364 const checked = await $.process.run([`${repo}/node_modules/.bin/tsx`, 'scripts/checklist.ts', spec], { cwd: repo, timeoutMs: 120_000 })
365 const { counts, fails } = checklistFrom(checked.stdout)
366 const isClean = counts !== undefined && counts.fail === 0
367 // No producer is left to settle a final, so a failed one goes back to draft-ready.
368 const next: LaneStep = !isClean ? (mode === 'final' ? 'draft-ready' : 'check') : mode === 'final' ? 'final' : 'draft-ready'
369 await touch((l, now) => moveLane(l, next, now, { counts, fails }))
370 await log($, slug, `${lane.id}: ${mode} rendered, checklist ✔${counts?.pass ?? '?'} ✘${counts?.fail ?? '?'} 👀${counts?.human ?? '?'}`, counts?.fail ? 'warning' : 'success')
371 await maybeWakeDrafts($, slug)
372 return [
373 `Rendered ${shortsDir(event)}/${slug}/${lane.id}/out/${mode}.mp4.`,
374 checked.stdout.trim(),
375 !isClean ? 'Auto items failed: fix spec.json and render again, or call lane_update with step draft-ready and a note saying why a failure stands.' : '',
376 ]
377 .filter(Boolean)
378 .join('\n')
379}
380
381// Voicing, captions and the final render are fixed steps, so the model makes one call for all three.
382async function finish($: EngineInterface, event: StudioEvent, lane: Lane, voice?: string) {
383 const slug = event.slug
384 const spec = `${shortsDir(event)}/${slug}/${lane.id}/spec.json`
385 const tsx = `${repo}/node_modules/.bin/tsx`
386 const step = async (script: string, args: string[], label: string) => {
387 let reason: string
388 try {
389 const ran = await $.process.run([tsx, `scripts/${script}.ts`, spec, ...args], { cwd: repo, timeoutMs: 600_000 })
390 if (ran.exitCode === 0) return { lines: ran.stdout.trim() }
391 reason = /^error (.*)$/m.exec(`${ran.stderr}\n${ran.stdout}`)?.[1] ?? ran.stderr.trim().split('\n').pop() ?? `${script} exited ${ran.exitCode}`
392 } catch (error) {
393 reason = String(error)
394 }
395 await touchLane($, slug, lane.id, (l, now) => moveLane(l, lane.step === 'final' ? 'final' : 'draft-ready', now, { note: `${label} failed: ${reason.slice(0, 140)}` }))
396 await log($, slug, `${lane.id}: ${label} failed: ${reason.slice(0, 120)}`, 'error')
397 return { error: `${label} failed: ${reason}` }
398 }
399
400 await touchLane($, slug, lane.id, (l, now) => moveLane(l, 'vo-wait', now, { note: `voicing (${voice ?? 'am_puck'})` }))
401 const voiced = await step('voice', voice ? ['--voice', voice] : [], 'voice')
402 if ('error' in voiced) return `${voiced.error}\nShorten the VO lines in spec.json, then call finish again.`
403 const captioned = await step('captions', [], 'captions')
404 if ('error' in captioned) return `${captioned.error}\nFix spec.json, then call finish again.`
405 return [voiced.lines, await render($, event, lane, 'final')].filter(Boolean).join('\n')
406}
407
408async function answerTool($: EngineInterface, tool: string, input: Input): Promise<string> {
409 const now = await $.clock.now()
410 const slug = str(input, 'slug')
411 const list = await read($, events)
412 const event = slug ? list.find((e) => e.slug === slug && e.endedAt === undefined) : activeEvent(list)
413
414 if (tool === 'status') {
415 return JSON.stringify({ now, events: list.filter((e) => e.endedAt === undefined && (!slug || e.slug === slug)) })
416 }
417 if (!slug) return 'Error: slug is required.'
418
419 if (tool === 'event_start') {
420 if (!/^[a-z0-9-]+$/.test(slug)) return 'Error: slug must be lowercase letters, digits and dashes.'
421 const other = activeEvent(list)
422 if (other && other.slug !== slug) return `Error: ${other.slug} is still open. Close it with event_end first.`
423 if (event) {
424 void openPane($)
425 return `Resumed ${slug}: stage ${event.stage}, ${event.lanes.length} lanes.`
426 }
427 const rightsId = str(input, 'rightsId')!
428 const channel = str(input, 'channel') ?? DEFAULT_CHANNEL
429 if (!/^[a-z0-9-]+$/.test(channel)) return 'Error: channel must be lowercase letters, digits and dashes.'
430 const csv = await $.fs.read(`${repo}/channels/${channel}/rights-log.csv`).catch(() => '')
431 const row = rightsStatus(String(csv), rightsId)
432 const sourceUrl = str(input, 'sourceUrl')!
433 let channelUrl: string | undefined
434 if (row && isYouTube(sourceUrl)) {
435 const oembed = await $.http.fetch(`https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(sourceUrl)}`).catch(() => undefined)
436 channelUrl = oembed?.ok ? String(parseJson(oembed.text)?.author_url ?? '') : undefined
437 }
438 // A campaign licence covers the files the brief hands out, wherever they are hosted.
439 const isCovered = !!row && (row.basis === 'campaign_licence' || sourceCovered(sourceUrl, row.url, channelUrl))
440 const status = row?.status === 'approved' && !isCovered ? 'not-this-source' : row?.status ?? 'missing'
441 const left = await credits($)
442 const projectId = str(input, 'projectId')
443 const dir = `${repo}/${shortsDir({ channel })}/${slug}`
444 const onDisk = (await Promise.all([$.fs.stat(`${dir}/source.mp4`), $.fs.stat(`${dir}/transcript.json`)]).then((files) => files.every((f) => f.kind === 'file')).catch(() => false))
445 const fresh: StudioEvent = {
446 slug,
447 channel,
448 title: str(input, 'title') ?? slug,
449 sourceUrl,
450 rightsId,
451 rightsStatus: status,
452 stage: status !== 'approved' ? 'blocked' : onDisk ? 'ready' : projectId ? 'processing' : 'setup',
453 opusProjectId: onDisk ? undefined : projectId,
454 rangeStartSec: num(input, 'rangeStartSec'),
455 creditsStart: left,
456 creditsNow: left,
457 pollErrors: 0,
458 lanes: [],
459 log: [{ at: now, text: `event started; ${rightsId} ${status}${channelUrl ? ` (${channelUrl})` : ''}`, tone: status === 'approved' ? 'success' : 'error' }],
460 denials: [],
461 startedAt: now,
462 heartbeatAt: now,
463 }
464 await update($, events, (all) => [...all.filter((e) => e.slug !== slug), fresh])
465 await refreshStatus($)
466 void openPane($)
467 startPoller($)
468 if (status === 'not-this-source') return `Error: ${sourceUrl} is not on ${rightsId}'s channel or site (${row?.url}; video channel ${channelUrl ?? 'unknown'}). The event is blocked.`
469 if (status !== 'approved') return `Error: ${rightsId} is ${status}. Only approved sources can be clipped; the event is blocked.`
470 if (onDisk) return `Studio started for ${slug} on ${channel}; source.mp4 and transcript.json are already in ${shortsDir({ channel })}/${slug}/. Go to step 4 of /mod:shorts.`
471 return projectId
472 ? `Studio started for ${slug}, reusing OpusClip project ${projectId}. The studio fetches the source and transcript and wakes you. End your turn.`
473 : `Studio started for ${slug}. ${left ?? '?'} credits left. Submit one landscape OpusClip job (skipCurate, captions off) for ${fresh.sourceUrl}, then end your turn; the studio wakes you when the source is on disk.`
474 }
475
476 if (!event) return `Error: no open studio event ${slug}. Call event_start first.`
477
478 if (tool === 'lane_add') {
479 const id = str(input, 'id') ?? ''
480 if (!/^[a-z0-9-]+$/.test(id)) return 'Error: id must be lowercase letters, digits and dashes.'
481 if (event.lanes.some((l) => l.id === id)) return `Error: lane ${id} exists.`
482 if (event.lanes.length >= MAX_LANES) return `Error: at most ${MAX_LANES} Shorts per event.`
483 const lane: Lane = { id, title: str(input, 'title') ?? id, angle: str(input, 'angle') ?? '', step: 'queued', fails: [], startedAt: now, updatedAt: now }
484 await setEvent($, slug, (e) => addLog({ ...e, lanes: [...e.lanes, lane] }, now, `lane ${id} added`))
485 return `Lane ${id} queued (${event.lanes.length + 1}/${MAX_LANES}).`
486 }
487
488 if (tool === 'dispatch') {
489 if (!['ready', 'producing', 'drafts-ready'].includes(event.stage)) return `Error: the source is not ready (stage ${event.stage}).`
490 const queued = event.lanes.filter((l) => l.step === 'queued' && !l.agentId)
491 if (!queued.length) return 'Error: no queued lanes. Call lane_add first.'
492 const started: string[] = []
493 for (const lane of queued) {
494 const spawned = await $.agent.spawn({ subagentType: PRODUCER, prompt: producerPrompt(event, lane, repo), description: `Short ${lane.id}` })
495 if ('deny' in spawned && spawned.deny) {
496 await setEvent($, slug, (e) => setLane(e, lane.id, (l) => moveLane(l, 'blocked', now, { blocker: `spawn denied: ${spawned.deny}` })))
497 continue
498 }
499 const agentId = (spawned as { agentId?: string }).agentId
500 await setEvent($, slug, (e) => setLane(e, lane.id, (l) => moveLane(l, 'script', now, { agentId })))
501 started.push(lane.id)
502 }
503 await setEvent($, slug, (e) => addLog({ ...e, stage: 'producing', hasWokenDrafts: false }, now, `dispatched ${started.join(', ')} in parallel`))
504 return `Started ${started.length} producers in parallel: ${started.join(', ')}. End your turn; the studio wakes you when every lane has a draft or is blocked.`
505 }
506
507 if (tool === 'event_end') {
508 const outcome = str(input, 'outcome') ?? 'drafts-ready'
509 const left = await credits($)
510 await setEvent($, slug, (e) => addLog({ ...e, stage: 'done', outcome, endedAt: now, creditsNow: left ?? e.creditsNow }, now, `event closed: ${outcome}`))
511 return `Closed ${slug}: ${outcome}.`
512 }
513
514 const lane = event.lanes.find((l) => l.id === str(input, 'id'))
515 if (!lane) return `Error: no lane ${str(input, 'id')} in ${slug}.`
516
517 if (tool === 'lane_update') {
518 const blocker = str(input, 'blocker')
519 const step = (blocker ? 'blocked' : str(input, 'step')) as LaneStep | undefined
520 if (step && step !== 'blocked' && !LANE_STEPS.includes(step)) return `Error: unknown step ${step}.`
521 await setEvent($, slug, (e) =>
522 addLog(
523 setLane(e, lane.id, (l) => (step ? moveLane(l, step, now, { blocker, note: str(input, 'note') }) : { ...l, note: str(input, 'note'), updatedAt: now })),
524 now,
525 `${lane.id}: ${step ?? 'note'}${blocker ? ` (${blocker})` : str(input, 'note') ? ` · ${str(input, 'note')}` : ''}`,
526 blocker ? 'error' : undefined,
527 ),
528 )
529 await maybeWakeDrafts($, slug)
530 return `Lane ${lane.id}: ${step ?? lane.step}.`
531 }
532
533 if (tool === 'render') {
534 const mode = str(input, 'mode') === 'final' ? 'final' : 'draft'
535 return withSlot($, slug, lane, () => render($, event, lane, mode))
536 }
537
538 if (tool === 'finish') {
539 if (lane.step === 'blocked') return `Error: lane ${lane.id} is blocked (${lane.blocker ?? 'no reason'}).`
540 if (!['draft-ready', 'check', 'final'].includes(lane.step)) return `Error: lane ${lane.id} has no draft yet (step ${lane.step}).`
541 return withSlot($, slug, lane, () => finish($, event, lane, str(input, 'voice')))
542 }
543
544 return `Error: unknown studio tool ${tool}.`
545}
546
547export const register: Register = (on) => {
548 on('session.start', async ($, e, next) => {
549 const home = (await $.process.run(['printenv', 'HOME'])).stdout.trim()
550 repo = `${home}/Developer/shorts-studio`
551 for (const tool of TOOLS) await $.tool.register({ ...tool, inputSchema: { ...tool.inputSchema } })
552 await $.command.register({ name: 'studio', description: 'Open the Shorts studio pane' })
553 if (activeEvent(await read($, events))) {
554 startPoller($)
555 void openPane($)
556 }
557 await refreshStatus($)
558 return next(e)
559 })
560
561 on('command.run', { command: 'studio' }, async ($) => {
562 await $.ui.open({ id: PANE, title: 'Shorts studio' })
563 return { text: 'Shorts studio opened.' }
564 })
565
566 on('prompt.submit', async ($, e, next) => {
567 if (/^\s*\/mod:shorts\b/.test(e.text)) await openPane($)
568 return next(e)
569 }).catch(($, e, next) => next(e))
570
571 on('tool.call', { tool: /^mcp__shorts-studio__/ }, async ($, e) => {
572 const name = String(e.tool).slice(`mcp__${PLUGIN}__`.length)
573 return { result: await answerTool($, name, e as unknown as Input) }
574 })
575
576 // Guards and bookkeeping on the model's own OpusClip calls.
577 on('tool.call', { tool: /^mcp__.+__opusclip_/i }, async ($, e, next) => {
578 const tool = String(e.tool)
579 const input = e as unknown as Input
580 const event = activeEvent(await read($, events))
581 const denial = opusDenial(tool, input, event)
582 if (denial) {
583 if (event) await setEvent($, event.slug, (ev) => ({ ...ev, denials: [...ev.denials, denial].slice(-10) }))
584 return { deny: `shorts-studio: ${denial}` }
585 }
586 const isSubmit = opusAction(tool)?.toLowerCase() === 'submit_project' && event !== undefined
587 if (isSubmit) {
588 const at = await $.clock.now()
589 await setEvent($, event!.slug, (ev) => ({ ...ev, submittedAt: at }))
590 }
591 const ran = await next(e)
592 if (isSubmit && ran.deny === undefined && !ran.isError) {
593 const project = parseJson(ran.text)?.project_id
594 if (typeof project !== 'string') await log($, event!.slug, 'clip job sent but no project_id read; pass it with event_start projectId', 'warning')
595 if (typeof project === 'string') {
596 opusServer = /^mcp__(.+)__opusclip_/i.exec(tool)?.[1] ?? opusServer
597 await setEvent($, event!.slug, (ev) => ({ ...ev, opusProjectId: project, rangeStartSec: num(input, 'rangeStart') ?? 0, stage: 'processing' }))
598 await log($, event!.slug, `OpusClip job ${project} submitted`)
599 startPoller($)
600 }
601 }
602 return ran
603 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'shorts-studio: guard failed; retry the call' }))
604
605 // A producer that stops before settling its lane leaves the lane in a known state.
606 on('turn.complete', async ($, e, next) => {
607 const done = await next(e)
608 if (!e.agentId) return done
609 const event = activeEvent(await read($, events))
610 const lane = event?.lanes.find((l) => l.agentId === e.agentId)
611 if (!event || !lane || isSettled(lane)) return done
612 const now = await $.clock.now()
613 const settled = settleFromReport(lane, await laneReport($, event, lane), now)
614 await setEvent($, event.slug, (ev) => addLog(setLane(ev, lane.id, () => settled), now, `${lane.id}: producer ended (${settled.step})`, settled.step === 'blocked' ? 'error' : 'warning'))
615 await maybeWakeDrafts($, event.slug)
616 return done
617 }).catch(($, e, next) => next(e))
618
619 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
620 const { Box, Text } = $.ui.resolve(e)
621 const now = await $.clock.now()
622 const all = await read($, events)
623 const event = activeEvent(all) ?? all[all.length - 1]
624 if (!event) return <Text dimColor>Waiting for /mod:shorts to start an event.</Text>
625 const columns = (e as { props?: { bodyColumns?: number } }).props?.bodyColumns ?? 100
626
627 const drawSeg = (s: Seg) => (
628 <Text color={s.tone} bold={s.bold} dimColor={s.dim}>
629 {s.text}
630 </Text>
631 )
632 const drawCell = (c: Cell) => (
633 <Box width={c.width} flexShrink={c.width === undefined ? 1 : 0} flexGrow={c.width === undefined ? 1 : 0} justifyContent={c.align === 'right' ? 'flex-end' : 'flex-start'}>
634 <Text wrap="truncate-end">{c.segs.map(drawSeg)}</Text>
635 </Box>
636 )
637 const drawSection = (s: Section) => (
638 <Box flexDirection="column" borderStyle="round" borderColor={s.tone ?? 'inactive'} paddingX={1}>
639 <Text bold color={s.tone ?? 'claude'}>
640 {s.title}
641 </Text>
642 {s.rows.map((r) => (
643 <Box flexDirection="row" columnGap={1}>
644 {r.map(drawCell)}
645 </Box>
646 ))}
647 </Box>
648 )
649 return <Box flexDirection="column">{sections(event, now, columns).map(drawSection)}</Box>
650 })
651}
652hooks/logic.ts 201 lines1import type { Counts, Lane, LaneStep, StudioEvent } from '../types'
2
3export const POLL_MS = 30_000
4export const MAX_POLL_ERRORS = 5
5export const MAX_LANES = 3
6export const MAX_RENDERS = 2
7export const LOG_MAX = 40
8export const DEFAULT_CHANNEL = 'rates-decoded'
9export const channelOf = (event: Pick<StudioEvent, 'channel'>) => event.channel ?? DEFAULT_CHANNEL
10export const shortsDir = (event: Pick<StudioEvent, 'channel'>) => `channels/${channelOf(event)}/shorts`
11
12export const LANE_STEPS: LaneStep[] = ['queued', 'script', 'scenes', 'render', 'check', 'draft-ready', 'vo-wait', 'final-render', 'final']
13export const SETTLED: LaneStep[] = ['draft-ready', 'vo-wait', 'final', 'blocked']
14
15const OPUS = /^mcp__.+__opusclip_(\w+)$/i
16// Allowlist, so a new publish or share tool is refused until it is reviewed.
17const ALLOWED = new Set([
18 'whoami', 'get_usage', 'list_projects', 'list_clips', 'describe_clip', 'preview_clips', 'get_transcript',
19 'export_clip', 'export_collection', 'list_brand_templates', 'edit_clip', 'duplicate_clip', 'analyze_video',
20 'create_censor_job', 'list_collections', 'list_clips_in_collection', 'create_collection', 'add_clip_to_collection',
21 'create_upload_link', 'list_social_accounts', 'list_scheduled_posts', 'list_pinterest_boards', 'submit_project',
22])
23
24export const opusAction = (tool: string) => OPUS.exec(tool)?.[1]
25
26export const isSettled = (lane: Lane) => SETTLED.includes(lane.step)
27
28export const activeEvent = (events: StudioEvent[]) => events.find((e) => e.endedAt === undefined)
29
30export function laneIndex(step: LaneStep) {
31 return LANE_STEPS.indexOf(step)
32}
33
34// Hard rules: the studio never posts, one OpusClip job per event, approved sources only.
35export function opusDenial(tool: string, input: Record<string, unknown>, event: StudioEvent | undefined): string | undefined {
36 const action = opusAction(tool)
37 if (!action) return undefined
38 if (!ALLOWED.has(action.toLowerCase())) return `the studio never posts, schedules or shares, and allows only reviewed OpusClip tools (${action}); a person publishes in OpusClip`
39 if (action.toLowerCase() !== 'submit_project') return undefined
40 if (!event) return 'start a studio event first (event_start); clip jobs need an approved source'
41 if (event.rightsStatus !== 'approved') return `${event.rightsId} is ${event.rightsStatus} in the rights log, not approved`
42 if (event.opusProjectId || event.submittedAt) return `${event.slug} already has an OpusClip job${event.opusProjectId ? ` (${event.opusProjectId})` : ''}; one job per event`
43 if (normalizeUrl(String(input.videoUrl ?? '')) !== normalizeUrl(event.sourceUrl)) {
44 return `videoUrl is not the event's approved source (${event.sourceUrl})`
45 }
46 return undefined
47}
48
49const YOUTUBE_HOST = /^(www\.|m\.)?youtube\.com$/i
50
51export function normalizeUrl(url: string) {
52 let parsed: URL
53 try {
54 parsed = new URL(url.trim())
55 } catch {
56 return url.trim()
57 }
58 const host = parsed.hostname.toLowerCase()
59 if (YOUTUBE_HOST.test(host)) {
60 const id = parsed.searchParams.get('v') ?? /^\/(?:shorts|live)\/([\w-]{11})/.exec(parsed.pathname)?.[1]
61 if (id) return `youtube:${id}`
62 }
63 if (host === 'youtu.be' && /^\/[\w-]{11}$/.test(parsed.pathname)) return `youtube:${parsed.pathname.slice(1)}`
64 return `${host}${parsed.pathname.replace(/\/$/, '')}${parsed.search}`
65}
66
67export const isYouTube = (url: string) => {
68 try {
69 const host = new URL(url).hostname.toLowerCase()
70 return YOUTUBE_HOST.test(host) || host === 'youtu.be'
71 } catch {
72 return false
73 }
74}
75
76// youtube.com/federalreserve and youtube.com/@federalreserve name the same channel.
77export function channelKey(url: string) {
78 try {
79 return new URL(url).pathname.toLowerCase().replace(/^\/(@|c\/|user\/)?/, '').replace(/\/.*$/, '')
80 } catch {
81 return ''
82 }
83}
84
85// The rights row covers a source only when the video sits on the row's channel (YouTube) or host.
86export function sourceCovered(sourceUrl: string, rowUrl: string, channelUrl?: string) {
87 if (!rowUrl) return false
88 if (isYouTube(rowUrl)) return !!channelUrl && channelKey(channelUrl) !== '' && channelKey(channelUrl) === channelKey(rowUrl)
89 try {
90 return new URL(sourceUrl).hostname.toLowerCase() === new URL(rowUrl).hostname.toLowerCase()
91 } catch {
92 return false
93 }
94}
95
96export function rightsStatus(csv: string, rightsId: string) {
97 for (const line of csv.trim().split('\n').slice(1)) {
98 const cells = line.match(/("[^"]*"|[^,]*)(,|$)/g)?.map((c) => c.replace(/,$/, '').replace(/^"|"$/g, '')) ?? []
99 if (cells[0] === rightsId) return { owner: cells[1] ?? '', url: cells[3] ?? '', basis: cells[4] ?? '', status: cells[7] ?? 'unknown' }
100 }
101 return undefined
102}
103
104export function progressFrom(text: string): number | undefined {
105 const all = [...text.matchAll(/^progress (\d+)$/gm)]
106 const last = all[all.length - 1]
107 return last ? Number(last[1]) : undefined
108}
109
110export function checklistFrom(stdout: string): { counts?: Counts; fails: string[] } {
111 const m = /counts pass=(\d+) fail=(\d+) human=(\d+)/.exec(stdout)
112 const fails = [...stdout.matchAll(/^fail\s+(\S+)\s+(.*)$/gm)].map((f) => `${f[1]}: ${f[2]}`)
113 return { counts: m ? { pass: Number(m[1]), fail: Number(m[2]), human: Number(m[3]) } : undefined, fails }
114}
115
116type OpusWord = { word: string; start_ms: number; end_ms: number }
117type OpusTranscript = { paragraphs?: { words: OpusWord[] }[] }
118
119// A range-limited job's export starts at rangeStart, so word times move onto the file's clock.
120export function shiftTranscript(raw: OpusTranscript, rangeStartSec = 0): OpusTranscript {
121 const offset = Math.round(rangeStartSec * 1000)
122 return {
123 ...raw,
124 paragraphs: (raw.paragraphs ?? []).map((p) => ({
125 ...p,
126 words: p.words.map((w) => ({ ...w, start_ms: w.start_ms - offset, end_ms: w.end_ms - offset })),
127 })),
128 }
129}
130
131export function wordCount(raw: OpusTranscript) {
132 return (raw.paragraphs ?? []).flatMap((p) => p.words).filter((w) => !w.word.includes('__silence')).length
133}
134
135export function parseJson(text: string | undefined): Record<string, unknown> | undefined {
136 try {
137 const value = JSON.parse(text ?? '')
138 return value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
139 } catch {
140 return undefined
141 }
142}
143
144export function setLane(event: StudioEvent, id: string, change: (lane: Lane) => Lane): StudioEvent {
145 return { ...event, lanes: event.lanes.map((l) => (l.id === id ? change(l) : l)) }
146}
147
148export function moveLane(lane: Lane, step: LaneStep, now: number, extra: Partial<Lane> = {}): Lane {
149 const settled = SETTLED.includes(step)
150 const blocker = step === 'blocked' ? extra.blocker ?? lane.blocker : undefined
151 return { ...lane, ...extra, step, blocker, updatedAt: now, settledAt: settled ? lane.settledAt ?? now : undefined }
152}
153
154export type Report = { counts?: Counts; results?: { id: string; status: string; detail?: string }[] }
155
156// A producer whose lane_update calls failed still leaves report.json; settle the lane from it.
157export function settleFromReport(lane: Lane, report: Report | undefined, now: number): Lane {
158 const counts = report?.counts
159 if (!counts) return moveLane(lane, 'blocked', now, { blocker: `producer stopped at ${lane.step}` })
160 const fails = (report.results ?? []).filter((r) => r.status === 'fail').map((r) => `${r.id}: ${r.detail ?? ''}`)
161 const note = counts.fail ? 'producer finished with checklist failures' : 'settled from report.json'
162 return moveLane(lane, 'draft-ready', now, { counts, fails, note })
163}
164
165export function addLog(event: StudioEvent, at: number, text: string, tone?: 'error' | 'success' | 'warning'): StudioEvent {
166 return { ...event, log: [...event.log, { at, text, tone }].slice(-LOG_MAX) }
167}
168
169export const allSettled = (event: StudioEvent) => event.lanes.length > 0 && event.lanes.every(isSettled)
170
171// One wake when every lane has a draft or is blocked; the text is built when the wake goes out.
172export function draftsWake(event: StudioEvent): string | undefined {
173 if (event.hasWokenDrafts || !allSettled(event)) return undefined
174 const ready = event.lanes.filter((l) => l.step !== 'blocked')
175 const rows = event.lanes.map((l) =>
176 l.step === 'blocked'
177 ? `- ${l.id}: blocked (${l.blocker ?? 'no reason'})`
178 : `- ${l.id}: draft ready, checklist pass ${l.counts?.pass ?? '?'} / fail ${l.counts?.fail ?? '?'} / human ${l.counts?.human ?? '?'}${l.fails.length ? `; fails: ${l.fails.join('; ')}` : ''}`,
179 )
180 return [`${ready.length}/${event.lanes.length} drafts ready for ${event.slug}.`, ...rows, 'Continue at step 6 of /mod:shorts.'].join('\n')
181}
182
183export function producerPrompt(event: StudioEvent, lane: Lane, repo: string) {
184 const shorts = shortsDir(event)
185 const dir = `${repo}/${shorts}/${event.slug}/${lane.id}`
186 return [
187 `Produce one Short for the ${channelOf(event)} channel. Event ${event.slug} ("${event.title}"), lane ${lane.id}: ${lane.title}.`,
188 `Angle: ${lane.angle}`,
189 `Source video: ${event.sourceUrl} (rights-log id ${event.rightsId}, title "${event.title}"); use them as source.url and source.title.`,
190 `Repo: ${repo}. In the spec, source.video is "${event.slug}/source.mp4" (relative to ${shorts}/). Transcript on the file's clock: ${shorts}/${event.slug}/transcript.json. Saved texts for citations: ${shorts}/${event.slug}/sources/*.txt.`,
191 `Write ${dir}/spec.json, run \`yarn frames\` on it and read every contact sheet (for the seal on an official source; other brands or panelists on a campaign clip), then call mcp__shorts-studio__render with { slug: "${event.slug}", id: "${lane.id}", mode: "draft" }. If render answers that slots are busy, wait 60s (Bash sleep) and call it again.`,
192 `Follow the shorts-engine skill and the ${channelOf(event)}-shorts channel skill. Report each step with mcp__shorts-studio__lane_update.`,
193 ].join('\n')
194}
195
196export function statusLine(event: StudioEvent) {
197 const done = event.lanes.filter(isSettled).length
198 const credits = event.creditsNow !== undefined ? `, ${event.creditsNow} credits` : ''
199 return `studio ${event.slug}: ${event.stage}${event.lanes.length ? `, ${done}/${event.lanes.length} lanes` : ''}${credits}`
200}
201hooks/view.ts 111 lines1import type { Lane, LaneStep, StudioEvent } from '../types'
2import { laneIndex } from './logic'
3
4export type Tone = 'error' | 'success' | 'warning' | 'claude' | 'subtle' | 'suggestion' | 'inactive'
5export type Seg = { text: string; tone?: Tone; bold?: boolean; dim?: boolean }
6export type Cell = { segs: Seg[]; width?: number; align?: 'right' }
7export type Row = Cell[]
8export type Section = { title: string; tone?: Tone; rows: Row[] }
9
10export const ICON = { done: '✔', fail: '✘', run: '◐', todo: '○', block: '⛔', human: '👀' }
11
12const TRACK: { step: LaneStep; label: string }[] = [
13 { step: 'script', label: 'script' },
14 { step: 'scenes', label: 'art' },
15 { step: 'render', label: 'render' },
16 { step: 'check', label: 'check' },
17 { step: 'draft-ready', label: 'draft' },
18 { step: 'vo-wait', label: 'vo' },
19 { step: 'final', label: 'final' },
20]
21const BAR = 10
22
23const cell = (segs: Seg[] | Seg, width?: number, align?: 'right'): Cell => ({ segs: Array.isArray(segs) ? segs : [segs], width, align })
24
25export function fmtElapsed(ms: number) {
26 const s = Math.max(0, Math.round(ms / 1000))
27 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`
28}
29
30export function bar(pct: number) {
31 const filled = Math.round((Math.min(100, Math.max(0, pct)) / 100) * BAR)
32 return '█'.repeat(filled) + '░'.repeat(BAR - filled)
33}
34
35// One glyph per step: done, current, to do. A blocked lane marks where it stopped.
36export function track(lane: Lane): Seg[] {
37 const at = lane.step === 'blocked' ? -1 : laneIndex(lane.step === 'final-render' ? 'final' : lane.step)
38 return TRACK.flatMap(({ step, label }, i) => {
39 const idx = laneIndex(step)
40 const isCurrent = lane.step === step || (lane.step === 'final-render' && step === 'final')
41 const isDone = at > idx || (lane.step === 'final' && step === 'final')
42 const glyph = isDone ? ICON.done : isCurrent ? ICON.run : ICON.todo
43 const tone: Tone | undefined = isDone ? 'success' : isCurrent ? 'claude' : 'inactive'
44 return [{ text: `${glyph} ${label}`, tone, bold: isCurrent }, ...(i < TRACK.length - 1 ? [{ text: ' ', tone: 'inactive' as Tone }] : [])]
45 })
46}
47
48function headerRows(event: StudioEvent, now: number): Row[] {
49 const rights = event.rightsStatus === 'approved'
50 const used = event.creditsStart !== undefined && event.creditsNow !== undefined ? event.creditsStart - event.creditsNow : undefined
51 return [
52 [cell({ text: 'Source', dim: true }, 9), cell([{ text: `${rights ? ICON.done : ICON.fail} ${event.rightsId} ${event.rightsStatus}`, tone: rights ? 'success' : 'error' }, { text: ` ${event.sourceUrl}`, dim: true }])],
53 [
54 cell({ text: 'OpusClip', dim: true }, 9),
55 cell([
56 { text: event.opusProjectId ?? 'no job yet', bold: !!event.opusProjectId },
57 { text: event.opusStage ? ` ${event.opusStage}` : '', tone: 'claude' },
58 { text: event.sourceMinutes ? ` ${event.sourceMinutes.toFixed(1)} min` : '', dim: true },
59 { text: event.transcriptWords ? ` ${event.transcriptWords} words` : '', dim: true },
60 ]),
61 ],
62 [
63 cell({ text: 'Credits', dim: true }, 9),
64 cell([
65 { text: event.creditsNow !== undefined ? `${event.creditsNow} left` : '?' },
66 { text: used !== undefined ? ` ${used} used this event` : '', tone: used ? 'warning' : 'subtle' },
67 ]),
68 ],
69 [cell({ text: 'Stage', dim: true }, 9), cell([{ text: event.stage, tone: event.stage === 'blocked' ? 'error' : 'claude', bold: true }, { text: ` ${fmtElapsed(now - event.startedAt)}`, dim: true }])],
70 ]
71}
72
73function laneRows(lane: Lane, now: number, columns: number): Row[] {
74 const narrow = columns < 90
75 const counts = lane.counts
76 const status: Seg[] = lane.step === 'blocked'
77 ? [{ text: `${ICON.block} ${lane.blocker ?? 'blocked'}`, tone: 'error' }]
78 : lane.step === 'render' || lane.step === 'final-render'
79 ? [{ text: `${bar(lane.renderPct ?? 0)} ${lane.renderPct ?? 0}%`, tone: 'claude' }]
80 : counts
81 ? [
82 { text: `${ICON.done}${counts.pass} `, tone: 'success' },
83 { text: `${ICON.fail}${counts.fail} `, tone: counts.fail ? 'error' : 'inactive' },
84 { text: `${ICON.human}${counts.human}`, tone: 'warning' },
85 ]
86 : [{ text: lane.note ?? '', dim: true }]
87 const head: Row = [
88 cell({ text: lane.id, bold: true }, narrow ? 14 : 18),
89 cell({ text: lane.title, dim: true }),
90 cell({ text: fmtElapsed((lane.settledAt ?? now) - lane.startedAt), dim: true }, 7, 'right'),
91 ]
92 const rows: Row[] = [head, [cell({ text: '' }, 2), cell(track(lane))], [cell({ text: '' }, 2), cell(status)]]
93 if (lane.fails.length && lane.step !== 'render') rows.push([cell({ text: '' }, 2), cell({ text: lane.fails.slice(0, 2).join(' · '), tone: 'error', dim: true })])
94 return rows
95}
96
97export function sections(event: StudioEvent, now: number, columns: number): Section[] {
98 const out: Section[] = [{ title: `Shorts studio · ${event.title || event.slug}`, tone: event.stage === 'blocked' ? 'error' : 'claude', rows: headerRows(event, now) }]
99 if (event.lanes.length) out.push({ title: `Lanes (${event.lanes.length}, in parallel)`, rows: event.lanes.flatMap((l) => laneRows(l, now, columns)) })
100 if (event.denials.length) out.push({ title: 'Guards', tone: 'warning', rows: event.denials.slice(-3).map((d) => [cell({ text: `${ICON.fail} ${d}`, tone: 'warning' })]) })
101 const log = event.log.slice(-6)
102 if (log.length) {
103 out.push({
104 title: 'Log',
105 tone: 'inactive',
106 rows: log.map((l) => [cell({ text: `${fmtElapsed(l.at - event.startedAt).padStart(6)}`, dim: true }, 7), cell({ text: l.text, tone: l.tone })]),
107 })
108 }
109 return out
110}
111types/index.d.ts 68 lines1export type LaneStep =
2 | 'queued'
3 | 'script'
4 | 'scenes'
5 | 'render'
6 | 'check'
7 | 'draft-ready'
8 | 'vo-wait'
9 | 'final-render'
10 | 'final'
11 | 'blocked'
12
13export type Counts = { pass: number; fail: number; human: number }
14
15export type Lane = {
16 id: string
17 title: string
18 angle: string
19 step: LaneStep
20 agentId?: string
21 renderPct?: number
22 counts?: Counts
23 fails: string[]
24 blocker?: string
25 note?: string
26 startedAt: number
27 updatedAt: number
28 settledAt?: number
29}
30
31export type EventStage = 'setup' | 'processing' | 'exporting' | 'fetching' | 'ready' | 'producing' | 'drafts-ready' | 'done' | 'blocked'
32
33export type LogLine = { at: number; text: string; tone?: 'error' | 'success' | 'warning' }
34
35export type StudioEvent = {
36 slug: string
37 channel?: string
38 title: string
39 sourceUrl: string
40 rightsId: string
41 rightsStatus: string
42 stage: EventStage
43 opusProjectId?: string
44 submittedAt?: number
45 opusStage?: string
46 clipId?: string
47 rangeStartSec?: number
48 sourceMinutes?: number
49 transcriptWords?: number
50 creditsStart?: number
51 creditsNow?: number
52 pollErrors: number
53 lanes: Lane[]
54 log: LogLine[]
55 denials: string[]
56 hasWokenDrafts?: boolean
57 startedAt: number
58 heartbeatAt: number
59 endedAt?: number
60 outcome?: string
61}
62
63declare module 'claude-code' {
64 interface PluginState {
65 'shorts-studio': { events: StudioEvent[] }
66 }
67}
68