SLOPSHOPPER

shorts-studio

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.

newpaneguardcommandstatusprompt
★ 1v0.1.0MITupdated 2026-10-26ilken/claude-code-i14u/mods/shorts-studio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shorts-studio
│ ┃ Shorts studio ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting for /mod:shorts to start an event. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /studio │ ⎿ shorts-studio: Shorts studio opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Shorts studio
Waiting for /mod:shorts to start an event.
README

shorts-studio

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.

Load it

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.

What it does

PieceHow
Toolsevent_start, lane_add, dispatch, lane_update, render, finish, status, event_end, listed as mcp__shorts-studio__*
PollerEvery 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 lanesdispatch spawns one shorts-studio:producer (Sonnet) per lane in the background
Renderrender streams Remotion progress into the pane; at most 2 at once, the rest answer busy; then runs scripts/checklist.ts
Finishfinish runs scripts/voice.ts, scripts/captions.ts, then the final render and checklist, in one render slot
Lane fallbackA 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
GuardsDenies OpusClip post, schedule, share and unschedule; a second job for the event; a job for any other URL; a source not approved
PaneEvent header (rights, job, credits, stage), one lane per Short with its step track, render bar and checklist counts, guard denials, log

Check it

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.

Source 4 files
hooks/register.tsx 652 lines
1import { 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}
652
hooks/logic.ts 201 lines
1import 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}
201
hooks/view.ts 111 lines
1import 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}
111
types/index.d.ts 68 lines
1export 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