SLOPSHOPPER

show-your-work

Claude shows its finished work right in your terminal: screenshots, videos of it clicking through your app, and synced before/after QA

newpanerowsguardcommandtoast
★ 219v0.6.9MITupdated 2026-10-15iamumeransari/claude-show-your-work
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · show-your-work
│ ┃ Settings ✕ › fix the failing auth test and add an audit log call │ ┃ [ ✕ Close ] [ Take a tour ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Show Your Work Settings ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Updates ● Stable releases ⎿ Added 2 lines, removed 1 line │ ┃ Usage data ● Share anonymousl ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Usage data is anonymous and helps fix │ ┃ bugs and improve the product experience. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No sensitive data is ever shared. │ ┃ ✻ Worked for 42s · done 4:20 PM │ │ › /show-your-work │ ⎿ show-your-work: Opened the show-your-work settings. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Settings
[ ✕ Close ] [ Take a tour ] Show Your Work Settings Updates ● Stable releases ○ Latest Usage data ● Share anonymously ○ Don't share Usage data is anonymous and helps fix bugs and improve the product experience. No sensitive data is ever shared.
Pane · show-your-work
Nothing to show yet. Run /img path/to/file
Pane · show-your-work-update
Checking for updates… On 0.0.0 · Stable releases
README

<img src="docs/homepage.jpg" alt="A recording of the Tern homepage plays inside Claude Code, with the list of what changed beside it" width="100%">

<h1 align="center">claude-show-your-work</h1>

<a href="https://github.com/iamumeransari/claude-show-your-work/stargazers"><img src="https://img.shields.io/github/stars/iamumeransari/claude-show-your-work?style=flat&logo=github&label=Stars&color=E3B341" alt="GitHub stars"></a> <a href="#install"><img src="https://img.shields.io/badge/Claude_Code-plugin-D97757?logo=claude&logoColor=white" alt="Claude Code plugin"></a> <a href="#install"><img src="https://img.shields.io/badge/macOS_%7C_Linux_%7C_Windows-555555" alt="macOS, Linux and Windows"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/iamumeransari/claude-show-your-work?color=22C55E" alt="MIT license"></a>

Install

Run these two commands in Claude Code:

/plugin marketplace add https://github.com/iamumeransari/claude-show-your-work.git#releases
/plugin install show-your-work@iamumeransari

It works on macOS, Linux and Windows. You need a terminal that supports the kitty graphics protocol, like Ghostty or Warp, and Claude Code 2.1.286 or later. Videos also need ffmpeg: install it with your package manager (brew install ffmpeg on macOS), or ask Claude to install it.

When the install finishes, a short onboarding shows you around. Run /show-your-work at any time to change your settings.

Usage

Claude shows you its work in the chat whenever it builds or changes something visual. You can also ask for it directly:

  • "Give me four directions for the homepage hero and show them side by side."
  • "Build the sign-up form, test it, and show me the recording."
  • "Fix the billing toggle and show me before and after."

Visual QA with before-and-after recordings

When Claude builds a feature or fixes a bug, it tests the change with Playwright, records the run and plays the recording in the chat, with everything you need to review the work in one place:

  • Before and After: For any change, from a new feature to a bug fix, Claude records the same steps on the old version and the new one. Compare then plays both side by side, in sync.
  • Change Markers: Every change Claude made is listed beside the video with its timestamp, and clicking one jumps straight to the moment it shows up.
  • Live Checks: Each test check turns green or red as the video reaches it, so you can see exactly where the fix works and where something still fails.
  • One Place to Review: You don't need to open a browser, rerun the tests or read through logs, because the recording and its results are right there in the chat.

Images

<img src="docs/concepts.jpg" alt="Four landing page concepts for Tern shown in the chat" width="49%"> <img src="docs/breakpoints.jpg" alt="The Tern homepage at desktop, tablet and phone sizes shown in the chat" width="49%">

Whatever Claude designs, you see it right in the chat: landing pages, app screens, components, design concepts, slides, charts and generated images. Ask for a few directions and they appear side by side, then keep iterating on the one you like.

  • Compare Directions: Several concepts show together, so you can pick one at a glance instead of opening files one by one.
  • Every Version Kept: Each new version lands in the same player, and History lets you step back through every earlier draft.
  • Before and After: Switch between the old and new design, or put them side by side, to see exactly what changed.

Video

<img src="docs/persona.jpg" alt="An ad persona in the chat" width="49%"> <img src="docs/reel.jpg" alt="The ad made from it, playing in the chat" width="49%">

Beyond product QA, Claude can show you a video of almost anything it works on. Building a website and want to see how it feels as you scroll from top to bottom? Just ask, and the recording plays in the chat, animations and all.

Making an ad, an animation or a launch video? Each cut plays right in the chat, in a full-featured video player:

  • Video Controls: Play, pause, scrub through the timeline, and skip forward or back to the moment you want.
  • Timestamps: The sidebar walks you through what happens in the video, step by step. Each step is timestamped, so one click takes you right to that moment.
  • Compare Versions: When Claude makes a new version of a video, it opens in the same player. You can flip between drafts in History or play the old and new versions side by side, in sync.
  • Quick Functions: Open the video in QuickTime Player for a bigger view, or save it straight to your Downloads folder in one click.

Terminals

show-your-work works in any terminal that supports the kitty graphics protocol, on macOS, Linux and Windows.

  • Ghostty and kitty: pictures work straight away.
  • Warp, WezTerm and Konsole: Claude Code leaves pictures off in these until you turn them on. The first time a picture can't show, press Turn on pictures, then restart Claude Code. You can turn it off again in /show-your-work.

Turn on pictures adds one line to your shell profile (~/.zshrc, ~/.bash_profile or ~/.bashrc, or fish's config.fish) that sets CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 only inside that terminal. Turning it off removes the line.

FAQ

Yes, Claude Code can show images in the terminal with the show-your-work plugin. Once it's installed, Claude shows screenshots, designs, charts and other images right in the chat. It works in terminals that support the kitty graphics protocol, like Ghostty and Warp.

Yes, Claude Code can play videos in the terminal with the show-your-work plugin. It plays screen recordings, animations, ads and launch videos inside the Claude Code chat. It comes with a feature-rich video player, with scrubbing, skip controls, clickable timestamps and side-by-side comparisons.

You can see what Claude Code built by installing the show-your-work plugin. Whenever Claude builds or changes something visual, it takes a screenshot or records it and shows you the result in the chat. You don't have to switch to a browser and find the page yourself.

Yes, Claude Code can test your app and show you a recording with the show-your-work plugin. Claude tests the features it builds with Playwright, records each run and plays the recording in the chat. The steps are listed beside the video with timestamps. Each check turns green or red as the video reaches it.

Yes, Claude Code can show you a before and after of a change or bug fix with the show-your-work plugin. Claude records the same Playwright steps on the old version and the new one. Compare plays both recordings side by side, in sync. Each test check turns green or red as the video reaches it.

Yes, show-your-work is a Claude Code plugin for visual QA. It records Claude's Playwright test runs and plays them in the chat. Each recording comes with clickable change markers and checks that turn green or red as the video plays. For a change or bug fix, it plays the before and after side by side, in sync.

The easiest way to check that Claude Code actually fixed a bug is to have it record the fix with show-your-work. Ask Claude to fix the bug and show you the before and after. The old and new versions play side by side, in sync, with the checks that now pass marked in green.

show-your-work goes beyond viewing images in the terminal. Its main strength is visual QA. It plays synced before-and-after Playwright recordings, lists the changes you can click to jump to, and turns each check green or red as the video plays. You can review a feature or a fix without leaving Claude Code.

No, show-your-work barely uses any extra tokens, because it's built from the ground up to be both token and memory efficient. Images and videos are drawn straight into your terminal with the kitty graphics protocol, without using any tokens. Videos are also decoded and streamed efficiently, so very little memory is used.

The show-your-work plugin works in terminals that support the kitty graphics protocol, like Ghostty and Warp, on macOS, Linux and Windows.

Yes, show-your-work is free and open source under the MIT license.

No, you don't need to update it yourself. The plugin updates itself, so you're never left behind. You can choose between Stable and Latest releases during the onboarding, or any time with /show-your-work.

You can see the onboarding again by running /show-your-work tour. It covers recordings, before and after, version history and the settings.

No, it doesn't take up much space. It keeps recent pictures and videos so you can flip back through them, and clears out the oldest once they add up to about 1 GB.

You choose whether to share anonymous usage data. You can make that choice during the onboarding, or change it any time with /show-your-work. It never collects any sensitive data whatsoever. The details are in the Privacy policy.

Here's everything the plugin does on your computer, so you know exactly what you're installing. Your pictures and videos never leave your machine.

To get pictures and videos ready, it uses ffmpeg and ffprobe (turning a video into frames and reading its length) and, on macOS, sips (converting a picture to PNG). When you press View, Show in Finder or Save to Downloads, it opens the file with open on macOS or xdg-open on Linux. On macOS, mdfind helps /img find an image you pasted, and inside tmux it asks tmux whether pictures can get through.

A few short scripts run with sh and perl. They find ffmpeg and your home folder, read the terminal's cell size so pictures fit, tidy the plugin's temp folder, open a viewer on Linux, and switch between the Stable and Latest channels. It also uses cp, mkdir, rm, cat and touch for its own temp files, and claude plugin to install its own updates from this repository, at most once a day. You can turn updates off. The updater will be turned off after the plugin moves to the Claude Code plugin directory, which keeps it up to date instead.

It keeps these files:

  • Video frames and converted pictures go in a temp folder ($TMPDIR/claude-show-your-work-<your user id>), and anything older than a week is cleaned up.
  • A copy of each file Claude shows you goes in .show-your-work/ in your project, so you can look back through versions.
  • Save to Downloads puts a copy in ~/Downloads.
  • Turn on pictures adds one line to your shell profile (see Terminals), and turning it off takes the line back out.
  • With SHOW_YOUR_WORK_DEBUG=1, it writes a debug.log in the temp folder.

Once a day it reads the plugin's latest version number from raw.githubusercontent.com.

If Usage data is set to share in /show-your-work, it sends anonymous usage counts to us.i.posthog.com. These are event names and counts, plus the plugin and Claude Code versions, your terminal, your OS and whether ffmpeg is installed, under a random install id. They never include file names, paths, prompts, anything you type or your IP address. The key in the code is PostHog's public key, which can only send events.

Inside Claude Code, it uses these hooks:

  • prompt.compose adds one short line to Claude's instructions, telling it this terminal can show pictures (or why it can't). It only does this when the plugin's tools are available.
  • prompt.submit closes the settings when you send a message and remembers the names of images you paste, so /img can find them. Your message isn't changed.
  • tool.call handles the plugin's own show_image and show_video tools. It never touches other tools.
  • command.run runs /img and /show-your-work. Right after you install, it runs /show-your-work tour once to show you around.
  • ui.render draws the plugin's player, notices, settings and tour.
  • session.start and session.end set up the temp folder and terminal at the start, and send any waiting usage counts at the end.

Feedback

To report a bug or suggest a change, open an issue.

For developers

The plugin gives Claude two tools, show_image and show_video. A basic call needs only a path. For a before and after, Claude also passes the old file and the test results:

{
  "path": "qa/after.webm",          // the run on the fixed version
  "compare": "qa/before.webm",      // the same steps on the old version
  "title": "Pricing page",
  "url": "http://localhost:3000/pricing",
  "changes": [                      // what changed, and the second it shows up
    { "title": "Annual toggle sets prices", "at": 3.2 },
    { "title": "Plan buttons line up", "at": 5.4 }
  ],
  "summary": [                      // the test results, at most three lines
    { "label": "Billing: toggle works", "ok": true, "passed": 1, "total": 1 },
    { "label": "Layout: plans, buttons", "ok": true, "passed": 3, "total": 3 }
  ]
}

Videos are cut into frames at up to 12 per second, at the width the window shows them (at most 2400 px), with a cap of 3,600 frames (five minutes at full rate). To open a file yourself, run /img path/to/file.png.

To work on the plugin, edit the modules in hooks/ (register.tsx is the entry) and run /reload-plugins to load your changes. Before sending a change, run claude plugin validate . and claude plugin test ..

Status

show-your-work is actively maintained. It is the first plugin to bring pictures, videos and automated visual QA with Playwright recordings into the Claude Code terminal. Its launch video has been viewed more than 275,000 times. It has since become the popular choice among developers who want to see what Claude builds without leaving the terminal. It's listed in some of the biggest Claude Code plugin catalogs and currently ranks among the top trending plugins for Claude Code.

If you want Claude Code to show you screenshots, screen recordings or before-and-after comparisons of its work without leaving the terminal, this plugin is built for exactly that.

License

MIT. Photography in the examples is from Unsplash, used under the Unsplash License. Privacy.

Source 2 files
hooks/bundle.tsx 3909 lines
1// Generated by tools/bundle.mjs from the modules in hooks/. Edit those, then
2// run `node tools/bundle.mjs`; this file is what hooks.json loads.
3
4import type { EngineInterface, Register, Timer } from 'claude-code'
5import type { Check, Frames, KeyChange, Overlay, Shown, Summary } from '../types'
6import { atom, read, update } from 'claude-code'
7
8// ── layout.ts ─────────────────────────────────────────────────────────────
9// Sizing, from the room a player has to the box it draws and the pixels its
10// picture needs, and the helpers that fit text to a width.
11
12
13// The window's fill: a step lighter than a dark terminal, so it reads as one panel.
14const PANEL = '#2c2e35'
15// The QA report beside the picture, and the width the window needs for it.
16export const SIDE = 40
17// The sidebar's fill: darker than the window, so the report stands apart.
18export const SIDEBAR = '#1d1e23'
19const WIDE_ENOUGH = 110
20
21// One path from the room a player has to the box it draws and the pixels its
22// picture needs:
23//   layoutOf(room)        the space: columns and rows for the picture, report beside or below
24//   targetBox(w, h, ...)  the box a picture of that shape fills in it
25//   pixelsFor(box, cell)  the screen pixels that box needs (PX_MIN to PX_MAX)
26//   drawBox(w, h, ...)    the box drawn: the target, cut down to the picture's own pixels
27// The terminal draws a picture at its own pixel size at most, so pictures and
28// frames are made as wide as their box (frameWidth when prepared, sharpen
29// when a player needs more), and drawBox keeps a picture that's still short
30// of pixels whole instead of small in a big box.
31const PX_MIN = 320
32export const PX_MAX = 2400
33export type Room = { columns: number; rows: number; isPane: boolean; hasReport: boolean }
34export type Layout = { isWide: boolean; inner: number; maxCols: number; maxRows: number }
35export function layoutOf(room: Room): Layout {
36  const isWide = room.hasReport && room.columns - 8 >= WIDE_ENOUGH
37  // The window's border and padding; a pane has no row border of its own.
38  const inner = room.columns - (room.isPane ? 6 : 10)
39  const space = inner - (isWide ? SIDE + 3 : 0)
40  const maxCols = Math.max(1, Math.max(Math.min(space, 24), Math.min(space, room.isPane ? 220 : 120)))
41  // Inline, a picture takes at most 55% of the screen's height; a pane keeps 12 rows for its controls.
42  const maxRows = room.isPane ? Math.max(6, room.rows - 12) : Math.max(8, Math.min(40, Math.floor(room.rows * 0.55)))
43  return { isWide, inner, maxCols, maxRows }
44}
45export const targetBox = (width: number, height: number, layout: Layout, px: Cell | null) =>
46  fit(width, height, layout.maxCols, layout.maxRows, px, false)
47export const drawBox = (width: number, height: number, layout: Layout, px: Cell | null) =>
48  fit(width, height, layout.maxCols, layout.maxRows, px, true)
49// Enough for the box's width and, given the picture's shape, its height:
50// boxes are whole rows, so a wide picture sized by width alone falls short.
51export const pixelsFor = (box: { columns: number; rows?: number }, px: Cell | null, aspect = 0) =>
52  px ? Math.max(PX_MIN, Math.min(PX_MAX, Math.ceil(Math.max(box.columns * px.width, (box.rows ?? 0) * px.height * aspect) / 2) * 2)) : 1600
53
54// Fits a picture in a box of cells, its own shape. With the cell's size in
55// pixels known and `isCapped`, never past the picture's own pixels; unknown,
56// a cell is about twice as tall as wide.
57export function fit(width: number, height: number, maxCols: number, maxRows: number, px?: Cell | null, isCapped = true) {
58  if (px) {
59    const scale = Math.min(isCapped ? 1 : Number.POSITIVE_INFINITY, (maxCols * px.width) / width, (maxRows * px.height) / height)
60    // A hair over, so a box that fits exactly (38 rows, computed as 37.999…) keeps its last row.
61    const rows = Math.max(1, Math.min(255, Math.floor((height * scale) / px.height + 1e-9)))
62    const shown = isCapped ? Math.min(height, rows * px.height) : rows * px.height
63    const columns = Math.max(1, Math.min(255, maxCols, Math.ceil((shown * width) / height / px.width)))
64    return { columns, rows }
65  }
66  let columns = Math.max(1, Math.min(255, maxCols))
67  let rows = Math.ceil((columns * height) / width / 2)
68  if (rows > maxRows) {
69    rows = maxRows
70    columns = Math.max(1, Math.ceil((rows * 2 * width) / height))
71  }
72
73  return { columns: Math.min(255, columns), rows: Math.max(1, Math.min(255, rows)) }
74}
75
76// The width a picture or clip is made at when it's prepared: what an inline
77// player in this terminal needs (see layoutOf). 1600 px at most while unmeasured.
78export function frameWidth(width: number, height: number, term: { columns: number; rows: number } | null, px: Cell | null) {
79  if (!term || !px || !width || !height) return Math.min(1600, width)
80  const layout = layoutOf({ columns: term.columns, rows: term.rows, isPane: false, hasReport: false })
81  return pixelsFor(targetBox(width, height, layout, px), px, width / height)
82}
83
84// The drawn box for `room` columns already free of chrome (the pair view).
85export function sizeFor(width: number, height: number, room: number, rows: number, isPane: boolean, px: Cell | null = state.cell) {
86  return drawBox(width, height, layoutOf({ columns: room + (isPane ? 6 : 10), rows, isPane, hasReport: false }), px)
87}
88
89// The terminal's cell in pixels, from the size its tty reports; null where it
90// reports none (tmux, a relay), and measured again when the window changes.
91export type Cell = { width: number; height: number }
92export const CELL_SH = `perl -e 'open(T, "</dev/tty") or exit 1; my $w = " " x 8; ioctl(T, ($^O eq "darwin" ? 0x40087468 : 0x5413), $w) or exit 1; print join(" ", unpack("S4", $w))'`
93// The tty's size is in points; pictures draw in screen pixels, `scale` to a
94// point (2 on Retina).
95export function cellOf(stdout: string, scale = 1): Cell | null {
96  const [rows, columns, x, y] = stdout.trim().split(/\s+/).map(Number)
97  return rows && columns && x && y ? { width: (x / columns) * scale, height: (y / rows) * scale } : null
98}
99const SCALE_SH = `osascript -l JavaScript -e 'ObjC.import("AppKit"); $.NSScreen.mainScreen.backingScaleFactor'`
100export async function measureCells($: EngineInterface) {
101  const [r, s] = await Promise.all([
102    $.process.run(['sh', '-c', CELL_SH]).catch(() => null),
103    state.isMac ? $.process.run(['sh', '-c', SCALE_SH]).catch(() => null) : null,
104  ])
105  const scale = Number(s?.stdout.trim()) || 1
106  state.cell = r?.exitCode === 0 ? cellOf(r.stdout, scale) : null
107  const [rows, columns] = (r?.exitCode === 0 ? r.stdout : '').trim().split(/\s+/).map(Number)
108  state.termSize = rows && columns ? { columns, rows } : null
109}
110// Players of different widths each ask: one measure a second at most, the
111// last ask still measured.
112let measuredAt = 0
113export async function remeasure($: EngineInterface): Promise<boolean> {
114  const wait = measuredAt + 1000 - (await $.clock.now())
115  if (wait > 0) await $.clock.sleep(wait)
116  measuredAt = await $.clock.now()
117  const was = state.cell
118  await measureCells($)
119  return was?.width !== state.cell?.width || was?.height !== state.cell?.height
120}
121
122// The viewport is the whole screen; a docked pane is narrower and shorter, so
123// it sizes to its own body.
124export type PaneProps = { bodyColumns?: number; bodyRows?: number; scroll?: { bodyRows?: number } }
125export const paneSize = (viewport: { columns: number; rows: number } | undefined, props: PaneProps) => {
126  if (!viewport) return viewport
127  const rows = props.bodyRows ?? props.scroll?.bodyRows
128  return { columns: props.bodyColumns || viewport.columns, rows: rows || viewport.rows }
129}
130
131export const clock = (s: number) => `${Math.floor(Math.max(0, s) / 60)}:${String(Math.floor(Math.max(0, s) % 60)).padStart(2, '0')}`
132
133// One line, whatever the length: cut at a word, with … where it was cut.
134export function oneLine(text: string, width: number): string {
135  const flat = text.replace(/\s+/g, ' ').trim()
136  width = Math.max(4, Math.floor(width))
137  if (flat.length <= width) return flat
138  const cut = flat.slice(0, width - 1)
139  const space = cut.lastIndexOf(' ')
140  return `${(space > width / 2 ? cut.slice(0, space) : cut).replace(/[\s,.;:]+$/, '')}…`
141}
142
143// A tab name short enough that every tab fits on one line and stays clickable.
144export const tabLabel = (text: string, max = 12) => (text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text)
145
146// Words wrapped to lines of at most width, so the whole text is always shown.
147export function wrapWords(text: string, width: number): string[] {
148  width = Math.max(1, Math.floor(width))
149  const lines: string[] = []
150  let line = ''
151  for (const word of text.split(/\s+/).filter(Boolean)) {
152    for (let w = word; w; ) {
153      const piece = w.length > width ? w.slice(0, width) : w
154      w = w.slice(piece.length)
155      if (!line) line = piece
156      else if (line.length + 1 + piece.length <= width) line += ` ${piece}`
157      else {
158        lines.push(line)
159        line = piece
160      }
161    }
162  }
163  return lines.length || line ? [...lines, line].filter(Boolean) : ['']
164}
165
166// Splits wrapped items into pages that each fit rows; an item never splits.
167export function pagesOf<T>(items: { item: T; rows: number }[], rows: number): T[][] {
168  const pages: T[][] = [[]]
169  let used = 0
170  for (const { item, rows: need } of items) {
171    if (used + need > rows && pages.at(-1)!.length) {
172      pages.push([])
173      used = 0
174    }
175    pages.at(-1)!.push(item)
176    used += need
177  }
178  return pages
179}
180
181// ── telemetry.ts ──────────────────────────────────────────────────────────
182// Anonymous usage stats, the button presses they count, and the local
183// playback timing log.
184
185
186// A button press, by name only. False, so it chains: act('save') || keep().
187export const act = (action: string): false => {
188  track('action', { action })
189  return false
190}
191// SHOW_YOUR_WORK_DEBUG=1: playback timings, a line per event, in TMP/debug.log.
192const debugLines: string[] = []
193export const debug = (line: string) => { if (state.isDebug) debugLines.push(`${Date.now()} ${line}`) }
194export async function flushDebug($: EngineInterface) {
195  if (!debugLines.length) return
196  await $.fs.write(`${state.TMP}/debug.log`, `${debugLines.slice(-20000).join('\n')}\n`).catch(() => undefined)
197}
198
199// Anonymous usage stats, sent to PostHog: what kind of thing happened and how
200// often, under a random id made once per install. Never a file name, path,
201// URL, title, prompt or anything typed. Off with SHOW_YOUR_WORK_TELEMETRY=0 or
202// DO_NOT_TRACK=1, and nothing is sent while POSTHOG_PROJECT is empty.
203// PostHog's public project id for this plugin: it can only add events, never
204// read them, so it ships. The address is written out at the call, so the
205// directory review can read where the events go.
206const POSTHOG_PROJECT = 'phc_mLGFguVcH3Tei4txwqXFPU4xBgFDKThEZncahTv3LS3W'
207// Events wait here and go out together, at most once a minute and at exit.
208const FLUSH_MS = 60_000
209const MAX_QUEUED = 500
210
211export type Props = Record<string, string | number | boolean | null | undefined | Record<string, string | number | boolean | undefined>>
212export type Event = { event: string; properties: Props; timestamp: string }
213let installId = ''
214let base: Props = {}
215let isFlushing = false
216
217// A made-up name for an install, the same every time for one id, so profiles
218// read as "Swift Otter" rather than a UUID. Nothing in it comes from the person.
219const ADJECTIVES = ['Swift', 'Quiet', 'Brave', 'Clever', 'Sunny', 'Lucky', 'Bold', 'Calm', 'Eager', 'Gentle', 'Happy', 'Jolly', 'Keen', 'Lively', 'Mellow', 'Nimble', 'Plucky', 'Rapid', 'Snappy', 'Tidy', 'Witty', 'Zesty', 'Cosmic', 'Fuzzy']
220const ANIMALS = ['Otter', 'Falcon', 'Panda', 'Fox', 'Koala', 'Lynx', 'Heron', 'Badger', 'Gecko', 'Owl', 'Puffin', 'Raccoon', 'Seal', 'Tiger', 'Wombat', 'Yak', 'Bison', 'Crane', 'Dolphin', 'Ferret', 'Hedgehog', 'Lemur', 'Moose', 'Quokka']
221export function nicknameOf(id: string): string {
222  let hash = 0
223  for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
224  const n = Math.floor(hash / ADJECTIVES.length) % 1000
225
226  return `${ADJECTIVES[hash % ADJECTIVES.length]} ${ANIMALS[Math.floor(hash / 1000) % ANIMALS.length]} ${n}`
227}
228
229// An installed copy runs from Claude Code's plugin cache. One loaded from a
230// folder (--plugin-dir) is someone working on the plugin: it sends nothing.
231export const isInstalledCopy = (root: string) => /\/plugins\/cache\//.test(root)
232
233// SHOW_YOUR_WORK_TELEMETRY=0, DO_NOT_TRACK=1 or CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: nothing is sent.
234export const isStatsBlocked = (optOut?: string, dnt?: string, quiet?: string) =>
235  /^(0|false|off|no)$/i.test(optOut ?? '') || /^(1|true)$/i.test(dnt ?? '') || !!quiet
236
237export async function startTelemetry($: EngineInterface, context: Props) {
238  if (!POSTHOG_PROJECT || !isInstalledCopy($.plugin.root)) return
239  const optOut = await $.env.get('SHOW_YOUR_WORK_TELEMETRY')
240  const dnt = await $.env.get('DO_NOT_TRACK')
241  const quiet = await $.env.get('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')
242  if (isStatsBlocked(optOut, dnt, quiet)) return
243  // Turned back on in the same session: the timer is already running.
244  if (installId) return void (state.isOn = true)
245  installId = String((await $.store.get('installId')) ?? '')
246  const isNew = !installId
247  if (isNew) {
248    installId = crypto.randomUUID()
249    await $.store.set('installId', installId)
250  }
251  state.isOn = true
252  // Each install is one anonymous person, keyed by its random id: the profile
253  // holds the setup it runs on, never anything about who uses it.
254  // No location, ever: no IP is sent, and PostHog is told not to look one up.
255  base = { ...context, $ip: null, $geoip_disable: true }
256  const profile = { $set: { ...context, name: nicknameOf(installId), update_channel: settings.stable_updates ? 'stable' : 'latest', usage_data: 'on' }, $set_once: { first_plugin_version: String(context.plugin_version ?? "") } }
257  if (isNew) track('installed', profile)
258  track('session_started', profile)
259  $.clock.every(FLUSH_MS, () => void flush($))
260}
261
262// Counts and kinds only: callers pass no names, paths or text.
263export function track(event: string, properties: Props = {}) {
264  if (!state.isOn) return
265  if (state.queue.length >= MAX_QUEUED) state.queue.shift()
266  state.queue.push({ event, properties: { ...base, ...properties }, timestamp: new Date().toISOString() })
267}
268
269export async function flush($: EngineInterface) {
270  if (!state.isOn || isFlushing || !state.queue.length) return
271  isFlushing = true
272  const batch = state.queue
273  state.queue = []
274  try {
275    const r = await $.http.fetch('https://us.i.posthog.com/batch/', {
276      method: 'POST',
277      headers: { 'content-type': 'application/json' },
278      body: JSON.stringify({ api_key: POSTHOG_PROJECT, batch: batch.map(e => ({ ...e, distinct_id: installId })) }),
279    })
280    // A refused batch is dropped, not retried: stats are never worth a backlog.
281    if (!r.ok && r.status >= 500) state.queue = [...batch, ...state.queue].slice(-MAX_QUEUED)
282  } catch {
283    state.queue = [...batch, ...state.queue].slice(-MAX_QUEUED)
284  } finally {
285    isFlushing = false
286  }
287}
288
289// ── media.ts ──────────────────────────────────────────────────────────────
290// Getting pictures and videos ready to draw: conversion, frames cut a chunk
291// at a time, the side-by-side clip of two runs, and the frame cache.
292
293
294
295// Deletes the oldest frame folders until the cache is under $1 KB, keeping the
296// newest two and the ones named in $2; only inside a folder this user owns.
297export const TRIM_SH = [
298  '[ -d "$0" ] && [ ! -L "$0" ] && [ -O "$0" ] && cd -P "$0" 2>/dev/null || exit 0',
299  // Only the folders the plugin names (v-<key>, v<n>-<time>, p-<key>,
300  // p2-<key>), one per line: a picture named by the user never matches.
301  'all=$(ls -dtr -- v* p* 2>/dev/null | grep -E "^(v-[0-9a-f]+|v[0-9]+-[0-9]+|p2?-[0-9a-f]+)$"); n=$(printf "%s\\n" "$all" | grep -c .)',
302  'printf "%s\\n" "$all" | head -n $((n > 2 ? n - 2 : 0)) | while IFS= read -r d; do',
303  '  [ -n "$d" ] && [ -d "$d" ] || continue',
304  '  [ "$(du -sk . | cut -f1)" -le "$1" ] && break',
305  '  case " $2 " in *" $d "*) continue ;; esac',
306  '  [ -L "$d" ] || rm -rf "./$d"',
307  'done; true',
308].join('\n')
309// Deletes anything in the cache older than a week, without following links.
310export const CLEAN_SH = [
311  '[ -d "$0" ] && [ ! -L "$0" ] && [ -O "$0" ] || exit 0',
312  'find -P "$0" -mindepth 1 -maxdepth 1 -mtime +7 -exec rm -rf {} + 2>/dev/null; true',
313].join('\n')
314export const VIDEO = /\.(mp4|mov|m4v|webm|mkv|avi|gif)$/i
315// Frames a clip is cut into at most, and the rate it plays at below that.
316// A clip cut whole (no known length) keeps every frame on disk at once, so it
317// stays at MAX_FRAMES. One of known length is cut as it plays and only a few
318// chunks are ever on disk, so it can run to MAX_STREAMED at the full rate.
319const MAX_FRAMES = 900
320const MAX_STREAMED = 3600
321const MAX_FPS = 12
322
323// A typed or dragged-in path: quotes, backslash-escaped spaces and ~ allowed.
324export function expand(path: string, base: string, homeDir: string): string {
325  let p = path.trim().replace(/^(['"])(.*)\1$/, '$2').replace(/\\(.)/g, '$1')
326  if (p === '~' || p.startsWith('~/')) p = homeDir + p.slice(1)
327  if (p.startsWith('/')) return p
328
329  return `${base.replace(/\/$/, '')}/${p}`
330}
331
332// A name with its look-alike spaces made plain: macOS writes a narrow
333// no-break space before AM/PM in screenshot names, which a typed path lacks.
334export const looseName = (name: string) =>
335  name.normalize('NFC').replace(/[  -    ]/g, ' ')
336
337// The file a path means: as given when it exists, else the one entry in its
338// folder whose name matches once spaces are made plain.
339export async function locate($: EngineInterface, raw: string): Promise<string> {
340  const path = expand(raw, state.cwd, state.home)
341  if (await $.fs.stat(path).then(() => true, () => false)) return path
342  const cut = path.lastIndexOf('/')
343  const dir = path.slice(0, cut) || '/'
344  const want = looseName(path.slice(cut + 1))
345  const hit = (await $.fs.list(dir).catch(() => [])).find(f => looseName(f.name) === want)
346
347  return hit ? `${dir.replace(/\/$/, '')}/${hit.name}` : path
348}
349
350export const frameFile = (frames: Frames, i: number) => `${frames.dir}/f${String(i + 1).padStart(5, '0')}.png`
351
352// How fast to sample a clip of known length so it stays under MAX_STREAMED.
353export const sampleRate = (duration: number) =>
354  Math.max(1, Math.min(MAX_FPS, Math.floor(MAX_STREAMED / Math.max(duration, 0.1))))
355
356// A PNG's size from its header (bytes 16 to 24), without any tool.
357export function pngDims(base64: string): { width: number; height: number } | null {
358  const b = Uint8Array.from(atob(base64.slice(0, 44)), c => c.charCodeAt(0))
359  if (b.length < 24 || b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47) return null
360  const at = (i: number) => (((b[i] ?? 0) << 24) | ((b[i + 1] ?? 0) << 16) | ((b[i + 2] ?? 0) << 8) | (b[i + 3] ?? 0)) >>> 0
361
362  return { width: at(16), height: at(20) }
363}
364
365// Finds a pasted image's original file by name with Spotlight (macOS).
366export async function findPasted($: EngineInterface, name: string): Promise<string | null> {
367  if (name.startsWith('/')) return name
368  if (!state.isMac) return null
369  const found = await $.process.run(['mdfind', '-name', name])
370
371  return found.stdout.split('\n').find(line => line.endsWith(`/${name}`)) ?? null
372}
373
374export async function pngSize($: EngineInterface, png: string) {
375  if (!state.isMac) {
376    const d = pngDims(String((await $.fs.read(png, { as: 'bytes' }) as unknown as { base64: string }).base64 ?? ''))
377    if (!d || !d.width || !d.height) throw new Error(`not an image: ${png}`)
378    return d
379  }
380  const dims = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', png])
381  const width = Number(/pixelWidth:\s*(\d+)/.exec(dims.stdout)?.[1] ?? 0)
382  const height = Number(/pixelHeight:\s*(\d+)/.exec(dims.stdout)?.[1] ?? 0)
383  if (!width || !height) throw new Error(`not an image: ${png}`)
384
385  return { width, height }
386}
387
388// Frames on disk are a cache: past 500 MB the oldest folders go, and a window
389// that plays one again decodes it again (ensureChunk). The newest two stay.
390const FRAMES_CAP_KB = 500_000
391// Folders this session is playing are never trimmed.
392export function trimFrames($: EngineInterface) {
393  const inUse = [...watching.keys()].map(d => d.split('/').pop()!).join(' ')
394  return $.process.run(['sh', '-c', TRIM_SH, state.TMP, String(FRAMES_CAP_KB), inUse]).catch(() => undefined)
395}
396
397// A recording often opens on a blank page while the browser loads: white,
398// off-white, grey or dark, maybe with a small spinner or logo. Any frame that
399// is nearly one flat tone counts as blank; the clip starts at the first real one.
400async function whiteLead($: EngineInterface, real: string, duration: number): Promise<number> {
401  const r = await $.process.run([state.ffmpeg, '-v', 'info', '-t', '8', '-i', real,
402    '-vf', 'fps=10,scale=160:-2,signalstats,metadata=print:key=lavfi.signalstats.YLOW:key=lavfi.signalstats.YHIGH',
403    '-an', '-f', 'null', '-']).catch(() => null)
404  return r ? leadFrom(r.stderr, duration) : 0
405}
406
407// The blank opening in that log: the time of the first frame whose 10th to
408// 90th percentile brightness spreads wider than a flat page does, if that
409// leaves something after it.
410export function leadFrom(log: string, duration: number): number {
411  let t = 0, low = -1
412  for (const line of log.split('\n')) {
413    const pts = line.match(/pts_time:([\d.]+)/)
414    if (pts) { t = Number(pts[1]); low = -1; continue }
415    const lo = line.match(/YLOW=([\d.]+)/)
416    if (lo) { low = Number(lo[1]); continue }
417    const hi = line.match(/YHIGH=([\d.]+)/)
418    if (hi && low >= 0) {
419      if (Number(hi[1]) - low > 24) {
420        const lead = Math.round(t * 100) / 100
421        return lead > 0 && (!(duration > 0) || lead < duration - 0.5) ? lead : 0
422      }
423      low = -1
424    }
425  }
426  return 0
427}
428
429// Times given on the clip as it was, moved to the frames that start after
430// its white opening.
431export function shiftTimes<T extends { at?: number }>(list: T[] | undefined, lead: number): T[] | undefined {
432  if (!list || !lead) return list
433  return list.map(c => (c.at === undefined ? c : { ...c, at: Math.max(0, +(c.at - lead).toFixed(2)) }))
434}
435
436// What to tell Claude and the person when a video can't be cut into frames.
437export const NO_FFMPEG =
438  "videos need ffmpeg, and it isn't installed. Pictures still work. Install it with `brew install ffmpeg` " +
439  '(Claude can run that for you), then restart Claude Code. No Homebrew? Install it first from https://brew.sh.'
440
441// On Linux ffmpeg also turns pictures other than PNG into PNG.
442export const NO_FFMPEG_LINUX =
443  "videos and pictures other than PNG need ffmpeg, and it isn't installed. Install it with your package manager, " +
444  'for example `sudo apt install ffmpeg` or `sudo dnf install ffmpeg` (Claude can run that for you), then restart Claude Code.'
445export const noFfmpeg = (mac: boolean) => (mac ? NO_FFMPEG : NO_FFMPEG_LINUX)
446export const FFMPEG_PROMPT_LINUX =
447  "Install ffmpeg for me with this machine's package manager (for example `sudo apt install ffmpeg`). Then show it again."
448
449// What the person pastes back so Claude installs ffmpeg and tries again.
450export const FFMPEG_PROMPT =
451  'Install ffmpeg for me with `brew install ffmpeg`. If Homebrew isn\'t installed, tell me how to install it first. Then show the video again.'
452
453// A failure as the person and Claude see it: a box in place of the picture,
454// one plain message for Claude, and a prompt to paste back. Never raw tool
455// output, never the plugin's internal copy of a file.
456export type NoticeKind = 'missing' | 'not_image' | 'broken_video' | 'no_picture' | 'no_ffmpeg' | 'other'
457export type Notice = { kind: NoticeKind; title: string; text: string; message: string; prompt: string }
458
459// How a failure reads, from whatever the tools said; `path` is the person's own.
460export function noticeOf(err: unknown, path: string, homeDir = ''): Notice {
461  const raw = String((err as Error)?.message ?? err)
462  const shown = homeDir && path.startsWith(`${homeDir}/`) ? `~${path.slice(homeDir.length)}` : path
463  const name = path.split('/').pop() || path
464  if (raw.includes(NO_FFMPEG_LINUX)) return {
465    kind: 'no_ffmpeg', title: 'This needs ffmpeg', message: NO_FFMPEG_LINUX, prompt: FFMPEG_PROMPT_LINUX,
466    text: "ffmpeg isn't installed, so videos and pictures other than PNG can't show here. Copy the prompt and paste it to Claude to install it, or install it yourself, for example `sudo apt install ffmpeg`.",
467  }
468  if (raw.includes(NO_FFMPEG)) return {
469    kind: 'no_ffmpeg', title: 'Videos need ffmpeg', message: NO_FFMPEG, prompt: FFMPEG_PROMPT,
470    text: "ffmpeg isn't installed, so this video can't play here. Pictures still work. Copy the prompt and paste it to Claude to install it, or run `brew install ffmpeg` yourself. No Homebrew? Get it from brew.sh first.",
471  }
472  if (/does not contain any stream|matches no streams|no frames in|Output file is empty/i.test(raw)) return {
473    kind: 'no_picture', title: 'This video has no picture',
474    text: `${name} has sound but no picture, so there's nothing to play.`,
475    message: `${shown} has no video track (sound only), so there's nothing to play. Don't retry it; show a file with a picture instead.`,
476    prompt: `${shown} has no picture, only sound. Find the right video and show it to me again.`,
477  }
478  if (/ffmpeg could not read|Invalid data|moov atom|could not find codec|Error opening input/i.test(raw)) return {
479    kind: 'broken_video', title: "This video can't be read",
480    text: `${name} couldn't be opened as a video. It may be cut off, still being written, or not really a video.`,
481    message: `${shown} couldn't be read as a video: it's damaged, unfinished (still recording or cut off), or not a video. Re-export or re-record it, then show it again.`,
482    prompt: `${shown} couldn't be read as a video. Check it, re-export or re-record it if it's broken, then show it to me again.`,
483  }
484  if (/could not convert|not an image|Cannot extract image|sips/i.test(raw)) return {
485    kind: 'not_image', title: "This isn't a picture",
486    text: `${name} isn't a picture, so it can't be shown. Pictures can be PNG, JPEG, WebP, GIF or TIFF.`,
487    message: `${shown} isn't a picture (show_image opens PNG, JPEG, WebP, GIF and TIFF). Show an image file, or use show_video for a video.`,
488    prompt: `${shown} isn't a picture. Show me the right file instead.`,
489  }
490  if (/ENOENT|no such file|not found/i.test(raw)) return {
491    kind: 'missing', title: 'File not found',
492    text: `There's no file at ${shown}.`,
493    message: `There's no file at ${shown}. Check the path (it may have moved or not been saved yet), then show it again.`,
494    prompt: `There's no file at ${shown}. Find the right file and show it to me again.`,
495  }
496  const detail = (raw.split('\n')[0] ?? '').replace(/\/(?:private\/)?tmp\/\S+|\S*\/\.show-your-work\/\S+/g, name).slice(0, 160)
497  return {
498    kind: 'other', title: "Couldn't show it",
499    text: `${name} couldn't be shown: ${detail}`,
500    message: `${shown} couldn't be shown: ${detail}`,
501    prompt: `${shown} couldn't be shown (${detail}). Look into it and show it to me again.`,
502  }
503}
504
505// The usage-stats reason for a failure, as a kind.
506export const REASONS: Record<NoticeKind, string> = {
507  missing: 'missing_file', no_ffmpeg: 'video_tools', not_image: 'unreadable', broken_video: 'unreadable', no_picture: 'unreadable', other: 'other',
508}
509
510// Cuts a clip into PNG frames the terminal can swap through.
511// A file as it is now: its path, size and modified time. The same unchanged
512// file shown again maps to the same frames folder and skips the decode; a
513// re-rendered one gets a new key and decodes fresh.
514export function keyOf(text: string): string {
515  let hash = 5381
516  for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0
517  return hash.toString(16).padStart(8, '0') + text.length.toString(16)
518}
519export async function identityOf($: EngineInterface, raw: string): Promise<string | undefined> {
520  const st = await $.fs.stat(expand(raw, state.cwd, state.home), { resolve: true }).catch(() => null)
521  return st ? keyOf(`${st.realPath ?? raw}|${st.size}|${st.mtimeMs}`) : undefined
522}
523// What a folder's meta.json holds, unchecked: the caller says what it expects.
524// (No type argument at the call: the directory review reads only fn($, ...).)
525async function readMeta($: EngineInterface, dir: string): Promise<unknown> {
526  const r = await $.process.run(['cat', `${dir}/meta.json`]).catch(() => null)
527  if (!r || r.exitCode !== 0) return null
528  try { return JSON.parse(r.stdout) as unknown } catch { return null }
529}
530function writeMeta($: EngineInterface, dir: string, meta: unknown) {
531  return $.process.run(['sh', '-c', 'printf "%s" "$1" > "$0/meta.json"', dir, JSON.stringify(meta)]).catch(() => undefined)
532}
533
534export async function prepareVideo($: EngineInterface, real: string, title: string, key?: string, cutAt?: number): Promise<Shown> {
535  if (!state.hasVideoTools) {
536    state.hasVideoTools = (await Promise.all([exists($, state.ffmpeg), exists($, state.ffprobe)])).every(Boolean)
537    if (!state.hasVideoTools) throw new Error(noFfmpeg(state.isMac))
538  }
539  const facts = await $.process.run([state.ffprobe, '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height:format=duration', '-of', 'default=nw=1', real])
540  const field = (name: string) => Number(new RegExp(`^${name}=([0-9.]+)`, 'm').exec(facts.stdout)?.[1] ?? Number.NaN)
541  const cut = cutAt ?? frameWidth(field('width'), field('height'), state.termSize, state.cell)
542  const own = field('width') > 0 && field('height') > 0 ? { sourceWidth: field('width'), sourceHeight: field('height') } : {}
543  // A clip is cut once per width: a bigger window cuts it again, bigger.
544  const folder = key && `v-${keyOf(`${key}|${cut}${state.sendBytes ? '|bytes' : ''}`)}`
545  // The same file shown before: its folder, frames and size are already there.
546  if (folder) {
547    const dir = `${state.TMP}/${folder}`
548    const meta = (await readMeta($, dir)) as { frames: Omit<Frames, 'dir'>; width: number; height: number } | null
549    if (meta) {
550      const frames = { ...meta.frames, dir }
551      await $.process.run(['touch', dir]).catch(() => undefined)
552      await ensureChunk($, frames, real, 0)
553      await prefill($, frames, real)
554      return { kind: 'video', path: frameFile(frames, 0), source: real, width: meta.width, height: meta.height, ...own, title, generation: ++state.generation, frames }
555    }
556  }
557  // Some recordings (MediaRecorder webm) carry no duration: sample those at the
558  // top rate. The clip's length is then what the frames cover.
559  const probed = field('duration')
560  const fps = sampleRate(Number.isFinite(probed) && probed > 0 ? probed : 1)
561  state.generation += 1
562  const dir = folder ? `${state.TMP}/${folder}` : `${state.TMP}/v${state.generation}-${Math.floor(await $.clock.now())}`
563  await $.process.run(['mkdir', '-p', dir])
564  const lead = await whiteLead($, real, probed)
565  const known = Number.isFinite(probed) && probed > 0
566  // A clip of known length is decoded as it plays, a chunk at a time (see
567  // ensureChunk); one without (MediaRecorder webm) is cut whole, as before.
568  const total = known ? Math.max(1, Math.min(MAX_STREAMED, Math.floor((probed - lead) * fps))) : 0
569  const made = await $.process.run([
570    // The palette waits for the end of its input: only the first chunk is read.
571    state.ffmpeg, '-v', 'error', ...(lead ? ['-ss', String(lead)] : []), ...(known ? ['-t', chunkSeconds(Math.min(CHUNK, total), fps)] : []), '-i', real,
572    '-vf', `fps=${fps},${scaleFor(cut)}`,
573    '-frames:v', String(known ? Math.min(CHUNK, total) : MAX_FRAMES), '-compression_level', '1', `${dir}/f%05d.png`,
574  ])
575  if (made.exitCode !== 0) throw new Error(`ffmpeg could not read ${real}: ${made.stderr.trim().slice(0, 200)}`)
576  const pngs = (await $.fs.list(dir)).filter(f => f.name.endsWith('.png')).length
577  if (!pngs) throw new Error(`no frames in ${real}`)
578  const count = known ? Math.max(pngs, total) : pngs
579  const frames = { dir, count, fps, duration: count / fps, cut, ...(lead ? { lead } : {}) }
580  const poster = frameFile(frames, 0)
581
582  const size = await pngSize($, poster)
583  if (folder) await writeMeta($, dir, { frames: { count, fps, duration: frames.duration, cut, ...(lead ? { lead } : {}) }, ...size })
584  void trimFrames($)
585  if (known) await prefill($, frames, real)
586  return { kind: 'video', path: poster, source: real, ...size, ...own, title, generation: state.generation, frames }
587}
588
589// Frames are decoded from the clip as it plays, 24 at a time: the chunk on
590// screen and the ones a 5 s skip either way reaches stay on disk, the rest
591// go. A frame asked for again further away (a long seek, a window scrolled
592// back to) is decoded again. The poster, frame 1, always stays.
593const CHUNK = 24
594// Chunks with time to spare (two or more ahead of the playhead) are cut at
595// a lower priority. On macOS that's the utility class: measured on an M1, a
596// 2 s chunk cut in 1.2 s with the machine free, the same as normal, and in
597// 7 s with every core busy, giving way. nice 10 didn't give way at all on
598// macOS, and the background class took 4.7 s even on a free machine. Linux
599// uses nice, which does give way there. The chunk needed next always runs at
600// normal priority, so playback keeps moving however busy the machine is.
601const GENTLY = 'if command -v taskpolicy >/dev/null 2>&1; then exec taskpolicy -c utility "$@"; fi; if command -v nice >/dev/null 2>&1; then exec nice -n 10 "$@"; fi; exec "$@"'
602const gently = (argv: string[]) => ['sh', '-c', GENTLY, 'sh', ...argv]
603// And on one thread: ffmpeg's default spreads one cut over several cores and
604// buffers for each. Measured on an M1 for 1 s of video at 2400 px: default
605// 1.3 s of CPU and 368 MB, one thread 0.85 s and 213 MB, still faster than
606// playback. The first chunk, cut before the video shows, keeps the default
607// so it appears quickly.
608const ONE_THREAD = ['-threads', '1', '-filter_threads', '1']
609const decoding = new Map<string, Promise<void>>()
610export function exists($: EngineInterface, file: string) {
611  return $.fs.stat(file).then(() => true, () => false)
612}
613// Gone only when the disk says so; no answer at all (no fs here) is not gone.
614export function isGone($: EngineInterface, file: string) {
615  return $.fs.stat(file).then(() => false, (e: unknown) => /ENOENT|not found|no such file/i.test(String((e as Error)?.message ?? e)))
616}
617
618// Chunks whose frames were cut: playback waits on any other. Only a hint:
619// cutting still checks the files, so a chunk the cache cleaner took comes back.
620const cutChunks = new Set<string>()
621const chunkKey = (f: Frames, i: number) => `${f.dir}#${Math.floor(Math.max(0, Math.min(i, f.count - 1)) / CHUNK)}`
622export const isFrameCut = (media: Shown, i: number) => !media.frames || cutChunks.has(chunkKey(media.frames, i))
623
624// Over ssh every frame crosses as bytes: 256 colours, one palette per chunk,
625// no dithering, halve them. Frames on this machine go by path in full colour.
626const PALETTE = ',split[a][b];[a]palettegen[p];[b][p]paletteuse=dither=none'
627// How much of the clip a chunk reads, a frame's slack included.
628export const chunkSeconds = (frames: number, fps: number) => String(+((frames + 1) / fps).toFixed(3))
629const scaleFor = (cut?: number) => (cut ? `scale=${cut}:-2` : "scale='min(1600,iw)':-2") + (state.sendBytes ? PALETTE : '')
630
631function ensureChunk($: EngineInterface, f: Frames, source: string | undefined, i: number, isUrgent = true): Promise<void> {
632  const c = Math.floor(Math.max(0, Math.min(i, f.count - 1)) / CHUNK)
633  const key = `${f.dir}#${c}`
634  const running = decoding.get(key)
635  if (running) return running
636  const job = (async () => {
637    const first = c * CHUNK
638    // The frame asked for, not the chunk's first: the poster outlives its chunk.
639    if (await exists($, frameFile(f, Math.max(0, Math.min(i, f.count - 1))))) return
640    if (!source) return
641    const cutAt = Date.now()
642    await $.process.run(['mkdir', '-p', f.dir])
643    const cutChunk = [
644      state.ffmpeg, '-v', 'error', ...ONE_THREAD, '-ss', String(+((f.lead ?? 0) + first / f.fps).toFixed(3)), '-t', chunkSeconds(Math.min(CHUNK, f.count - first), f.fps), '-i', source,
645      '-vf', `fps=${f.fps},${scaleFor(f.cut)}`, '-frames:v', String(Math.min(CHUNK, f.count - first)),
646      '-threads', '1', '-start_number', String(first + 1), '-compression_level', '1', `${f.dir}/f%05d.png`,
647    ]
648    await $.process.run(isUrgent ? cutChunk : gently(cutChunk))
649    debug(`cut chunk=${c} ms=${Date.now() - cutAt}`)
650    await prune($, f.dir, c, f.fps)
651  })().then(() => { cutChunks.add(key) }, () => undefined).finally(() => decoding.delete(key))
652  decoding.set(key, job)
653  return job
654}
655
656// Where each player is in each frames folder, as a chunk number.
657const watching = new Map<string, Map<string, number>>()
658function watch(dir: string, site: string, i: number) {
659  let at = watching.get(dir)
660  if (!at) watching.set(dir, (at = new Map()))
661  at.set(site, Math.floor(i / CHUNK))
662}
663
664// Chunks 5 s behind the playhead stay on disk and 5 s ahead are cut in
665// advance, so « 5s and 5s » land on frames that are ready, and playback has
666// room to spare when cutting runs slower than the clip.
667const BEHIND_SECONDS = 5
668const AHEAD_SECONDS = 5
669const chunksFor = (seconds: number, fps: number) => Math.max(1, Math.ceil((seconds * fps) / CHUNK))
670export const chunksBehind = (fps: number) => chunksFor(BEHIND_SECONDS, fps)
671export const chunksAhead = (fps: number) => chunksFor(AHEAD_SECONDS, fps)
672
673// Keeps, in a frames folder, the chunks a skip back or forward reaches around
674// the chunk just made and around every player on that folder, and the poster.
675async function prune($: EngineInterface, dir: string, c: number, fps: number) {
676  const keep = [c, ...(watching.get(dir)?.values() ?? [])]
677  const behind = chunksBehind(fps)
678  const ahead = chunksAhead(fps) + 1
679  const files = await $.fs.list(dir).catch(() => [])
680  const gone = files.map(f => f.name).filter(name => {
681    const m = /^f(\d+)\.png$/.exec(name)
682    if (!m) return false
683    const i = Number(m[1]) - 1
684    return i > 0 && !keep.some(k => i >= (k - behind) * CHUNK && i < (k + ahead + 1) * CHUNK)
685  })
686  if (gone.length) {
687    for (const k of [...cutChunks]) if (k.startsWith(`${dir}#`) && !keep.some(c2 => { const j = Number(k.slice(dir.length + 1)); return j >= c2 - behind && j <= c2 + ahead })) cutChunks.delete(k)
688    await $.process.run(['rm', '-f', ...gone.map(n => `${dir}/${n}`)]).catch(() => undefined)
689  }
690}
691
692// The frame a side shows, decoded if it has to be; then the chunks a 5s »
693// reaches are cut ahead, one at a time so a busy machine isn't swamped.
694export async function frameReady($: EngineInterface, media: Shown, i: number, site?: string): Promise<void> {
695  const f = media.frames
696  if (!f) return
697  if (site) watch(f.dir, site, i)
698  await ensureChunk($, f, media.source, i)
699  void cutAhead($, f, media.source, i)
700}
701
702// Two cuts at once, nearest first: PNG encoding runs one frame at a time
703// in each ffmpeg, so a second process gets ~20-25% more done on an M1; a
704// third and fourth add little and crowd a busy machine.
705const CUT_WORKERS = 2
706const cuttingAhead = new Set<string>()
707async function cutAhead($: EngineInterface, f: Frames, source: string | undefined, i: number) {
708  if (cuttingAhead.has(f.dir)) return
709  cuttingAhead.add(f.dir)
710  try {
711    // The chunk after this one is needed within 2 s; the rest have time.
712    const todo: { j: number; isUrgent: boolean }[] = []
713    for (let d = 1; d <= chunksAhead(f.fps); d++) {
714      const j = i + d * CHUNK
715      if (j >= f.count) break
716      if (!cutChunks.has(chunkKey(f, j))) todo.push({ j, isUrgent: d === 1 })
717    }
718    let taken = 0
719    const worker = async () => {
720      while (taken < todo.length) {
721        const { j, isUrgent } = todo[taken++]!
722        await ensureChunk($, f, source, j, isUrgent)
723      }
724    }
725    await Promise.all(Array.from({ length: CUT_WORKERS }, worker))
726  } finally {
727    cuttingAhead.delete(f.dir)
728  }
729}
730
731// Before a video is shown, the first 5 s ahead are cut too, so playback
732// starts with room to spare. Never more than PREFILL_MS of waiting.
733const PREFILL_MS = 4000
734async function prefill($: EngineInterface, f: Frames, source: string | undefined) {
735  await Promise.race([cutAhead($, f, source, 0), $.clock.sleep(PREFILL_MS)]).catch(() => undefined)
736}
737
738// Moments both runs share (a check by the same name), as [updated, original]
739// seconds, kept only while both clocks move forward.
740export function anchors(updated: Check[] = [], original: Check[] = []): [number, number][] {
741  const at = new Map(original.filter(c => c.at !== undefined).map(c => [c.name, c.at!]))
742  const pairs = updated
743    .filter(c => c.at !== undefined && at.has(c.name))
744    .map(c => [c.at!, at.get(c.name)!] as [number, number])
745    .sort((a, b) => a[0] - b[0])
746  const kept: [number, number][] = []
747  for (const p of pairs) {
748    const last = kept.at(-1) ?? [0, 0]
749    if (p[0] > last[0] + 0.05 && p[1] > last[1] + 0.05) kept.push(p)
750  }
751
752  return kept
753}
754
755// The original clip's moment that matches a moment of the updated clip.
756export function mapTime(points: [number, number][], t: number, endA: number, endB: number): number {
757  const all: [number, number][] = [[0, 0], ...points, [endA, endB]]
758  for (let i = 1; i < all.length; i++) {
759    const [a0, b0] = all[i - 1]!
760    const [a1, b1] = all[i]!
761    if (t <= a1 || i === all.length - 1) {
762      const k = a1 > a0 ? (b1 - b0) / (a1 - a0) : 1
763      return Math.max(0, Math.min(endB, b0 + (t - a0) * k))
764    }
765  }
766
767  return t
768}
769
770// Maps a frame of one clip to the frame at the same moment in another.
771export function sameMoment(from: Frames | undefined, to: Frames | undefined, frame: number): number {
772  if (!from || !to) return 0
773
774  return Math.max(0, Math.min(to.count - 1, Math.round((frame / from.fps) * to.fps)))
775}
776
777// The same moment on the other clip: updated → original seconds, and back.
778export const toOriginal = (shown: Shown, t: number) =>
779  shown.frames && shown.compare?.frames
780    ? mapTime(shown.sync ?? anchors(shown.checks, shown.compare.checks), t, shown.frames.duration, shown.compare.frames.duration)
781    : t
782export const toUpdated = (shown: Shown, t: number) =>
783  shown.frames && shown.compare?.frames
784    ? mapTime((shown.sync ?? anchors(shown.checks, shown.compare.checks)).map(([a, b]) => [b, a] as [number, number]), t, shown.compare.frames.duration, shown.frames.duration)
785    : t
786
787// The compared side's frame at the moment the shown side is at.
788export const compareFrame = (shown: Shown, frame: number) => {
789  const a = shown.frames, b = shown.compare?.frames
790  if (!a || !b) return 0
791  const t = mapTime(shown.sync ?? anchors(shown.checks, shown.compare?.checks), frame / a.fps, a.duration, b.duration)
792  return Math.max(0, Math.min(b.count - 1, Math.round(t * b.fps)))
793}
794
795// Every `stride`-th frame as a 32×18 grey thumbnail, for matching the two
796// runs by eye. Small and sparse on purpose: matching costs frames × frames.
797async function thumbs($: EngineInterface, media: Shown, stride: number): Promise<number[][]> {
798  const f = media.frames!
799  const r = await $.process.run([
800    'sh', '-c',
801    `"$0" -v error ${f.lead ? `-ss ${f.lead} ` : ''}-i "$1" -vf "fps=${f.fps},select=not(mod(n\\,${stride})),scale=32:18,format=gray" -fps_mode passthrough -frames:v ${Math.ceil(f.count / stride)} -f rawvideo - | od -An -tu1 -v`,
802    state.ffmpeg, media.source ?? '',
803  ])
804  const px = r.stdout.trim().split(/\s+/).map(Number)
805  const out: number[][] = []
806  for (let i = 0; i + 576 <= px.length; i += 576) out.push(px.slice(i, i + 576))
807  return out
808}
809
810// How many frames apart to sample so neither run has more than 240.
811export const strideFor = (a: number, b: number) => Math.max(1, Math.ceil(Math.max(a, b) / 240))
812
813// A match made on sampled frames, spread back over every updated frame.
814export function spreadMatch(sampled: number[], stride: number, count: number, last: number): number[] {
815  return Array.from({ length: count }, (_, i) => {
816    const k = Math.min(sampled.length - 1, Math.floor(i / stride))
817    return Math.min(last, (sampled[k] ?? 0) * stride + (i % stride))
818  })
819}
820
821// What moved since the previous frame: the cursor, a fade, a scroll. The two
822// runs move the same way even where the designs differ.
823const motion = (f: number[][]) => f.map((px, i) => (i ? px.map((v, k) => v - f[i - 1]![k]!) : px.map(() => 0)))
824
825// The cheapest way to walk both runs in order, frame by frame (dynamic time
826// warping), matching how frames look and how they move: [updated, original]
827// frame pairs. Holding one side still costs a little, so it only holds where
828// the runs really differ. Within `band` samples of the same moment only, and
829// straying from it costs `drift`: two runs of the same steps keep time, so a
830// screen that looks different in one (an error the other never shows) can't
831// pull one side ahead of the other.
832export function align(a: number[][], b: number[][], hold = 0.01, band = Number.POSITIVE_INFINITY, drift = 0): [number, number][] {
833  const n = a.length, m = b.length
834  if (!n || !m) return []
835  const slope = n > 1 ? (m - 1) / (n - 1) : 0
836  // Typed rows: the inner loop runs n × m × pixels times.
837  const ta = a.map(x => Int16Array.from(x)), tb = b.map(x => Int16Array.from(x))
838  const ma = motion(a).map(x => Int16Array.from(x)), mb = motion(b).map(x => Int16Array.from(x))
839  const diff = (i: number, j: number) => {
840    let d = 0, e = 0
841    const pa = ta[i]!, pb = tb[j]!, qa = ma[i]!, qb = mb[j]!
842    for (let k = 0; k < pa.length; k++) {
843      const x = pa[k]! - pb[k]!, y = qa[k]! - qb[k]!
844      d += x < 0 ? -x : x
845      e += y < 0 ? -y : y
846    }
847    return (d + 4 * e) / (pa.length * 255)
848  }
849  const cost = new Float64Array(n * m)
850  const step = new Uint8Array(n * m) // 0 both, 1 updated only, 2 original only
851  for (let i = 0; i < n; i++) {
852    for (let j = 0; j < m; j++) {
853      const off = Math.abs(j - i * slope)
854      if (off > band && !(i === n - 1 && j === m - 1) && (i || j)) { cost[i * m + j] = Number.POSITIVE_INFINITY; step[i * m + j] = 0; continue }
855      const d = diff(i, j) + drift * off
856      if (!i && !j) { cost[0] = d; continue }
857      const both = i && j ? cost[(i - 1) * m + j - 1]! : Number.POSITIVE_INFINITY
858      const up = i ? cost[(i - 1) * m + j]! + hold : Number.POSITIVE_INFINITY
859      const left = j ? cost[i * m + j - 1]! + hold : Number.POSITIVE_INFINITY
860      const best = Math.min(both, up, left)
861      cost[i * m + j] = best + d
862      step[i * m + j] = best === both ? 0 : best === up ? 1 : 2
863    }
864  }
865  const path: [number, number][] = []
866  for (let i = n - 1, j = m - 1; ; ) {
867    path.push([i, j])
868    if (!i && !j) break
869    const s = step[i * m + j]
870    if (s === 0) { i--; j-- } else if (s === 1) i--
871    else j--
872  }
873  return path.reverse()
874}
875
876// For each updated frame, the original frame shown beside it.
877export function matchOf(path: [number, number][], count: number): number[] {
878  const out = new Array<number>(count).fill(0)
879  for (const [i, j] of path) if (i < count) out[i] = j
880  for (let i = 1; i < count; i++) out[i] = Math.max(out[i]!, out[i - 1]!)
881  return out
882}
883
884// The path cut to the few turns that matter, in seconds, to keep it small.
885export function turns(match: number[], fpsA: number, fpsB: number): [number, number][] {
886  const points: [number, number][] = []
887  for (let i = 1; i < match.length - 1; i++) {
888    const before = match[i]! - match[i - 1]!, after = match[i + 1]! - match[i]!
889    if ((before === 0) !== (after === 0) || Math.abs(before - after) > 1) points.push([+(i / fpsA).toFixed(2), +(match[i]! / fpsB).toFixed(2)])
890  }
891  return points.length > 40 ? points.filter((_, k) => k % Math.ceil(points.length / 40) === 0) : points
892}
893
894// The largest still kept whole.
895export const MAX_STILL = 3200
896
897// Pixels between Original and Updated in the merged clip.
898export const PAIR_GAP = 40
899// How tall the merged clip is cut: the shorter side's height, 720 px at most.
900// The terminal never draws a picture larger than it is, so 360 px kept the
901// side by side view small in any window.
902export const pairHeightOf = (updated: { height: number }, original: { height: number }) =>
903  Math.max(2, Math.floor(Math.min(720, updated.height || 720, original.height || 720) / 2) * 2)
904
905// Merges the original and updated recordings into one clip, side by side.
906// The original's frames are matched to the updated clip's by how they look,
907// so every step happens at the same moment on both sides.
908export async function preparePair($: EngineInterface, original: Shown, updated: Shown) {
909  const f = updated.frames!, g = original.frames!
910  state.generation += 1
911  // p2: pairs made before the time-first matching are never reused.
912  const dir = `${state.TMP}/p2-${keyOf(`${f.dir}|${g.dir}|${pairHeightOf(updated, original)}`)}`
913  type PairMeta = { pair: Omit<Frames, 'dir'>; pairWidth: number; pairHeight: number; sync: [number, number][] }
914  const known = (await readMeta($, dir)) as PairMeta | null
915  if (known) {
916    const pair = { ...known.pair, dir }
917    await $.process.run(['touch', dir]).catch(() => undefined)
918    await pairChunk($, pair, updated, original, i => compareFrame({ ...updated, compare: original, sync: known.sync }, i), 0)
919    return { pair, pairWidth: known.pairWidth, pairHeight: known.pairHeight, sync: known.sync }
920  }
921  // Two runs of the same steps take about as long: then the same moment on
922  // both sides is the same second (moved to the checks' own moments when
923  // they're given). Only runs that differ a lot are matched by how they look.
924  const byTime = () => Array.from({ length: f.count }, (_, i) => Math.min(g.count - 1, Math.round(mapTime(anchors(updated.checks, original.checks), i / f.fps, f.duration, g.duration) * g.fps)))
925  const isSamePace = Math.abs(f.duration - g.duration) <= 0.15 * Math.max(f.duration, g.duration)
926  const stride = strideFor(f.count, g.count)
927  const [ta, tb] = isSamePace ? [[], []] : await Promise.all([thumbs($, updated, stride), thumbs($, original, stride)])
928  const match = ta.length && tb.length
929    ? spreadMatch(matchOf(align(ta, tb, 0.01, Math.max(2, Math.round((0.6 * f.fps) / stride)), 0.02), ta.length), stride, f.count, g.count - 1)
930    : byTime()
931  await $.process.run(['mkdir', '-p', dir])
932  const pair = { dir, count: f.count, fps: f.fps, duration: f.duration }
933  await pairChunk($, pair, updated, original, i => match[i] ?? 0, 0)
934  if (!(await exists($, frameFile(pair, 0)))) return null
935  const size = await pngSize($, frameFile(pair, 0))
936  const made = { pair, pairWidth: size.width, pairHeight: size.height, sync: turns(match, f.fps, g.fps) }
937  await writeMeta($, dir, { ...made, pair: { count: pair.count, fps: pair.fps, duration: pair.duration } })
938
939  return made
940}
941
942// One chunk of the merged clip, Original left and Updated right, made from
943// the two sides' frames for that stretch (decoded first if they have to be).
944function pairChunk($: EngineInterface, pair: Frames, updated: Shown, original: Shown, jOf: (i: number) => number, i: number): Promise<void> {
945  const c = Math.floor(Math.max(0, Math.min(i, pair.count - 1)) / CHUNK)
946  const key = `${pair.dir}#${c}`
947  const running = decoding.get(key)
948  if (running) return running
949  const f = updated.frames!, g = original.frames!
950  const job = (async () => {
951    const first = c * CHUNK, n = Math.min(CHUNK, pair.count - first)
952    if (await exists($, frameFile(pair, Math.max(0, Math.min(i, pair.count - 1))))) return
953    const js = Array.from({ length: n }, (_, k) => Math.max(0, Math.min(g.count - 1, jOf(first + k))))
954    await ensureChunk($, f, updated.source, first)
955    for (const jc of new Set(js.map(j => Math.floor(j / CHUNK)))) await ensureChunk($, g, original.source, jc * CHUNK)
956    const tmp = `${pair.dir}/c${c}`
957    await $.process.run(['mkdir', '-p', `${tmp}/o`, `${tmp}/u`])
958    const links = js.map((j, k) => `ln -sf "${frameFile(g, j)}" "$0/o/f${String(k + 1).padStart(5, '0')}.png"; ln -sf "${frameFile(f, first + k)}" "$0/u/f${String(k + 1).padStart(5, '0')}.png"`).join('\n')
959    await $.process.run(['sh', '-c', links, tmp])
960    // Needed on screen now: normal priority.
961    await $.process.run([
962      state.ffmpeg, '-v', 'error', ...ONE_THREAD, '-framerate', String(f.fps), '-i', `${tmp}/o/f%05d.png`, '-framerate', String(f.fps), '-i', `${tmp}/u/f%05d.png`,
963      '-filter_complex', `[0:v]scale=-2:${pairHeightOf(updated, original)},pad=iw+${PAIR_GAP}:ih:0:0:color=${SIDEBAR.replace('#', '0x')}[a];[1:v]scale=-2:${pairHeightOf(updated, original)}[b];[a][b]hstack=inputs=2`,
964      '-frames:v', String(n), '-threads', '1', '-start_number', String(first + 1), '-compression_level', '1', `${pair.dir}/f%05d.png`,
965    ])
966    await $.process.run(['rm', '-rf', tmp])
967    await prune($, pair.dir, c, pair.fps)
968  })().catch(() => undefined).finally(() => decoding.delete(key))
969  decoding.set(key, job)
970  return job
971}
972
973// The merged frame on screen, made if it has to be, the next chunk ahead.
974export async function pairReady($: EngineInterface, shown: Shown, i: number, site?: string): Promise<void> {
975  const pair = shown.pair
976  if (!pair || !shown.compare) return
977  if (site) watch(pair.dir, site, i)
978  const jOf = (k: number) => compareFrame(shown, k)
979  await pairChunk($, pair, shown, shown.compare, jOf, i)
980  if (i % CHUNK >= CHUNK / 2 && i + CHUNK < pair.count) void pairChunk($, pair, shown, shown.compare, jOf, i + CHUNK)
981}
982
983// Resolves a path to something the terminal can draw: a PNG (other image
984// formats converted with macOS `sips`), or a video's frames.
985export async function prepare($: EngineInterface, raw: string, title?: string, key?: string): Promise<Shown> {
986  const path = expand(raw, state.cwd, state.home)
987  const stat = await $.fs.stat(path, { resolve: true })
988  const real = stat.realPath ?? path
989  const name = title ?? real.split('/').pop()!
990  if (VIDEO.test(real)) return prepareVideo($, real, name, key ?? (await identityOf($, real)))
991  let png = real
992  if (!/\.png$/i.test(real)) {
993    state.generation += 1
994    png = `${state.TMP}/${real.split('/').pop()!.replace(/\.[^.]+$/, '')}-${state.generation}.png`
995    await $.process.run(['mkdir', '-p', state.TMP])
996    if (!state.isMac && !(await exists($, state.ffmpeg))) throw new Error(noFfmpeg(false))
997    const conv = state.isMac
998      ? await $.process.run(['sips', '-s', 'format', 'png', real, '--out', png])
999      : await $.process.run([state.ffmpeg, '-v', 'error', '-y', '-i', real, '-frames:v', '1', png])
1000    if (conv.exitCode !== 0) throw new Error(`could not convert ${real}: ${conv.stderr.trim()}`)
1001  }
1002  // The terminal reads the file itself, so only a picture past what any
1003  // screen shows is shrunk.
1004  const dims = await pngSize($, png)
1005  if (Math.max(dims.width, dims.height) > MAX_STILL) {
1006    state.generation += 1
1007    const small = `${state.TMP}/${png.split('/').pop()!.replace(/\.png$/i, '')}-${state.generation}-small.png`
1008    await $.process.run(['mkdir', '-p', state.TMP])
1009    const shrunk = state.isMac
1010      ? await $.process.run(['sips', '-Z', String(MAX_STILL), png, '--out', small])
1011      : await $.process.run([state.ffmpeg, '-v', 'error', '-y', '-i', png, '-vf', `scale='if(gt(iw,ih),min(${MAX_STILL},iw),-2)':'if(gt(iw,ih),-2,min(${MAX_STILL},ih))'`, small]).catch(() => ({ exitCode: 1 }))
1012    if (state.isMac || shrunk.exitCode === 0) png = small
1013  }
1014  // A picture narrower than the window shows it is drawn that wide, as a
1015  // video's frames are, so a picture and a video of one shape are one size.
1016  // Never past twice its own width: a small icon stays small.
1017  const now = await pngSize($, png)
1018  const wide = Math.min(frameWidth(now.width, now.height, state.termSize, state.cell), now.width * 2)
1019  if (wide > now.width) {
1020    state.generation += 1
1021    const big = `${state.TMP}/${png.split('/').pop()!.replace(/\.png$/i, '')}-${state.generation}-w${wide}.png`
1022    await $.process.run(['mkdir', '-p', state.TMP])
1023    const grown = state.isMac
1024      ? await $.process.run(['sips', '--resampleWidth', String(wide), png, '--out', big]).catch(() => ({ exitCode: 1 }))
1025      : await $.process.run([state.ffmpeg, '-v', 'error', '-y', '-i', png, '-vf', `scale=${wide}:-2:flags=lanczos`, big]).catch(() => ({ exitCode: 1 }))
1026    if (grown.exitCode === 0) png = big
1027  }
1028  state.generation += 1
1029
1030  return { kind: 'image', path: png, source: real, ...(await pngSize($, png)), sourceWidth: dims.width, sourceHeight: dims.height, title: name, generation: state.generation }
1031}
1032
1033// ── tour.ts ───────────────────────────────────────────────────────────────
1034// The tour's stops, and how a press in the player ticks the one it asked for.
1035
1036
1037// The settings pane's and the tour's redraws. Declared in each file that
1038// reads or updates it: the engine only reads an atom declared in the same file.
1039const welcome = atom({ plugin: 'show-your-work', key: 'welcome' } as const, 0)
1040
1041// Each stop points at one real control (spot) with one short line (tag), and
1042// is done when the person uses it (goal: the act() name its button fires).
1043type Spot = 'play' | 'changes' | 'tabs' | 'history'
1044export type Stop = { tag: string; spot?: Spot; goal?: string; media?: 'splash' | 'recording' | 'compare' | 'history' }
1045export const STOPS: Stop[] = [
1046  { tag: 'Press Play to watch it', spot: 'play', goal: 'play', media: 'splash' },
1047  { tag: 'Click a change to jump to that moment', spot: 'changes', goal: 'change_jump', media: 'recording' },
1048  { tag: 'Press Original to see it before the change', spot: 'tabs', goal: 'original', media: 'compare' },
1049  { tag: 'Open History, then pick a version', spot: 'history', goal: 'version_pick', media: 'history' },
1050  { tag: 'Now ask Claude to show you what it builds' },
1051]
1052
1053// A button press, heard: when it is what the stop asked for, it ticks; the
1054// person moves on with Next when they're ready.
1055export function tourHeard($: EngineInterface, action: string) {
1056  const s = STOPS[state.tourStop]
1057  if (!state.tourSite || state.isTourDone || state.isStopDone || !s?.goal || s.goal !== action) return
1058  state.isStopDone = true
1059  // Sent once per step, numbered from 0 like tour_next_N and tour_skip_N, so a
1060  // funnel can tell people who did the step from people who only pressed Next.
1061  act(`tour_step_done_${state.tourStop}`)
1062  void update($, welcome, n => n + 1)
1063}
1064
1065// ── player.tsx ────────────────────────────────────────────────────────────
1066// The player: playback (frames, swaps, play and seek) and the components that
1067// draw it in the chat.
1068
1069
1070
1071// Playback per drawn site (a transcript row's tool_use_id, or the pane).
1072type Player = {
1073  frame: number
1074  timer?: Timer
1075  isCompare?: boolean
1076  isSplit?: boolean
1077  // which earlier version the compared side is, when there are several
1078  step?: number
1079  // the sidebar lists the versions instead of what changed
1080  isHistory?: boolean
1081  // scrolled out of view since it was last drawn
1082  wasAway?: boolean
1083  // back in view: send the frame on screen again after drawing
1084  resend?: boolean
1085  // state.drawnBytes when this player's picture was last sent
1086  drawnAt?: number
1087  hasAutoplayed?: boolean
1088  // how many bar cells the scrub row last drew, so playback redraws only on a change
1089  segments?: number
1090  // which page of key changes the sidebar shows
1091  changesPage?: number
1092  // where the updated video was, kept while a still original is shown
1093  updatedFrame?: number
1094  // the picture's size in cells as last drawn, for repaints
1095  box?: { columns: number; rows: number }
1096}
1097export const players = new Map<string, Player>()
1098// Calls made since this load: only these autoplay, not every window a reload redraws.
1099export const freshCalls = new Set<string>()
1100// Each window's own repaint count, beside state.version for all of them.
1101export const versions = new Map<string, number>()
1102
1103// A change to one window bumps only its own count, so the others hand back
1104// their kept drawing when the repaint reaches them.
1105export function redraw($: EngineInterface, site?: string) {
1106  if (site) versions.set(site, (versions.get(site) ?? 0) + 1)
1107  else state.version += 1
1108  $.ui.invalidate('ui.render')
1109}
1110export const player = (site: string): Player => {
1111  let one = players.get(site)
1112  if (!one) players.set(site, (one = { frame: 0 }))
1113
1114  return one
1115}
1116
1117// Opens a file, folder or URL with the desktop's default app.
1118//
1119// On Linux, xdg-open on a desktop it doesn't recognise (niri, sway…) runs the
1120// app in the foreground until it quits, and $.process.run kills whatever is
1121// still running at its timeout (30 s by default). So the script below starts
1122// xdg-open in a session of its own, then watches it for up to a second:
1123//   - xdg-open exits within the second (GNOME and KDE hand the file off and
1124//     return; a missing app fails fast): its exit code and error text come back.
1125//   - still running after the second: the app is open in the foreground, so
1126//     the script returns 0 and leaves it running, outside what the timeout kills.
1127// The exit code goes through a file, because a background job that has exited
1128// still answers `kill -0` until it is reaped. The helper's own input and output
1129// go to /dev/null too: $.process.run waits until every holder of its pipes exits.
1130export const OPEN_SH = [
1131  'command -v "$1" >/dev/null || exit 127',
1132  'st="${TMPDIR:-/tmp}/syw-open-$$"',
1133  'run=; command -v setsid >/dev/null && run=setsid',
1134  '$run sh -c \'"$@" </dev/null >/dev/null 2>"$0.err"; echo $? > "$0"; sleep 2; rm -f "$0" "$0.err"\' "$st" "$@" </dev/null >/dev/null 2>&1 &',
1135  'i=0',
1136  'while [ $i -lt 10 ]; do',
1137  '  if [ -s "$st" ]; then code=$(cat "$st"); cat "$st.err" >&2; rm -f "$st" "$st.err"; exit "$code"; fi',
1138  '  sleep 0.1 2>/dev/null || sleep 1; i=$((i + 1))',
1139  'done',
1140  'exit 0',
1141].join('\n')
1142const openArgv = (target: string) => (state.isMac ? ['open', target] : ['sh', '-c', OPEN_SH, 'sh', 'xdg-open', target])
1143
1144// What went wrong opening something, in words: from xdg-open's exit codes
1145// (2 no such file, 3 no app or tool for it, 4 the app failed) and from macOS
1146// open's error text. `what` names the kind of thing, as in "no app opens videos".
1147export function openFailure(r: { exitCode: number; stderr: string }, what: string, path: string) {
1148  const said = r.stderr.trim().split('\n')[0]?.trim().slice(0, 100) ?? ''
1149  if (r.exitCode === 127) return "Couldn't open it: xdg-open is missing. Install xdg-utils and try again."
1150  if (r.exitCode === 2 || /does not exist|no such file/i.test(said)) return `Couldn't open it: nothing is at ${path} any more.`
1151  if (r.exitCode === 3 || /no application knows|no application|no method available|no default/i.test(said)) return `Couldn't open it: no app on this computer is set to open ${what}.`
1152  if (r.exitCode === 4) return said ? `Couldn't open it: ${said}` : `Couldn't open it: the app for ${what} failed to start.`
1153  return said ? `Couldn't open it: ${said}` : `Couldn't open it (error ${r.exitCode}).`
1154}
1155
1156// The narrowest the scrub and button rows get, in columns: room for the play
1157// button, both times, a usable bar and the action buttons on one line.
1158const CONTROLS_MIN = 64
1159
1160// A player whose box needs more pixels than its picture has (a bigger
1161// window, a pane, no report beside it) gets a sharper copy, made once in the
1162// background from the original file and swapped into the same object, so a
1163// playing video carries on. Stills stop at twice their own width (an icon
1164// stays small) and MAX_STILL; videos at PX_MAX.
1165const sharpKey = (m: Shown) => `${m.kind}|${m.source ?? m.path}`
1166const sharpening = new Map<string, number>()
1167const sharpest = new Map<string, Pick<Shown, 'path' | 'width' | 'height' | 'generation' | 'frames'>>()
1168function useSharpest(m: Shown) {
1169  const better = sharpest.get(sharpKey(m))
1170  if (better && better.width > m.width) Object.assign(m, better)
1171}
1172function sharpen($: EngineInterface, m: Shown, need: number) {
1173  const source = m.source
1174  const limit = m.frames ? PX_MAX : Math.min(MAX_STILL, (m.sourceWidth ?? m.width) * 2)
1175  const want = Math.min(limit, need)
1176  const key = sharpKey(m)
1177  if (!source || want < m.width * 1.15 || (sharpening.get(key) ?? 0) >= want) return
1178  sharpening.set(key, want)
1179  void (async () => {
1180    const made = m.frames
1181      ? await prepareVideo($, source, m.title, await identityOf($, source), want)
1182      : await widenStill($, source, want)
1183    if (!made) return
1184    sharpest.set(key, { path: made.path, width: made.width, height: made.height, generation: made.generation, frames: made.frames })
1185    useSharpest(m)
1186    redraw($)
1187  })().catch(() => undefined)
1188}
1189async function widenStill($: EngineInterface, source: string, width: number): Promise<Shown | null> {
1190  const out = `${state.TMP}/w-${keyOf(source)}-${width}.png`
1191  const made = state.isMac
1192    ? await $.process.run(['sips', '-s', 'format', 'png', '--resampleWidth', String(width), source, '--out', out]).catch(() => null)
1193    : await $.process.run([state.ffmpeg, '-v', 'error', '-y', '-i', source, '-frames:v', '1', '-vf', `scale=${width}:-2:flags=lanczos`, out]).catch(() => null)
1194  if (!made || made.exitCode !== 0) return null
1195  state.generation += 1
1196  return { kind: 'image', path: out, source, ...(await pngSize($, out)), title: '', generation: state.generation }
1197}
1198// The window size the cell was last measured at (see Viewer).
1199let cellFor = ''
1200
types/index.d.ts 69 lines
1export type Frames = { dir: string; count: number; fps: number; duration: number; lead?: number; cut?: number }
2
3// One QA check: its name, whether it passed, and when it happens in a clip.
4export type Check = { name: string; ok: boolean; at?: number; url?: string }
5
6// One change to review: what changed, optionally when it shows in the clip and
7// the page it is on.
8export type KeyChange = { title: string; detail?: string; at?: number; url?: string }
9
10// One line of the QA summary: a group of checks, whether it passed, and how
11// many of its checks did.
12export type Summary = { label: string; ok: boolean; passed?: number; total?: number }
13
14// A play button over the centre of a paused video, or none. (A grid of
15// buttons over the whole picture was tried: it erases the picture.)
16export type Overlay = 'center' | 'none'
17
18export type Shown = {
19  kind?: 'image' | 'video'
20  // the PNG the terminal draws (a video's first frame)
21  path: string
22  // the file the user gave, for Open and Reveal
23  source?: string
24  /** The file as the user knows it (source is the kept copy). */
25  origin?: string
26  // the picture drawn (a video's frames), in pixels
27  width: number
28  height: number
29  /** The file's own size, for its label, when the picture drawn is another size. */
30  sourceWidth?: number
31  sourceHeight?: number
32  title: string
33  generation: number
34  frames?: Frames
35  // QA context: the change, its PR, the checks run
36  change?: string
37  pr?: string
38  // the page the recording or picture is of, to open it there
39  url?: string
40  checks?: Check[]
41  // what to review, and the QA in three lines
42  changes?: KeyChange[]
43  summary?: Summary[]
44  // the other side of a before/after, and the two sides' names
45  compare?: Shown
46  /** Earlier kept versions of the same file, oldest first; compare is the last. */
47  history?: { path: string; note?: string }[]
48  /** The heading over the changes list, when they aren't changes. */
49  changesTitle?: string
50  /** One line on this version, for the history list. */
51  note?: string
52  /** Which of history the compared side starts on. */
53  historyAt?: number
54  // Original and Updated merged into one clip, side by side, and its size
55  pair?: Frames
56  pairWidth?: number
57  pairHeight?: number
58  // moments that line up, [updated, original] seconds, matched by how the frames look
59  sync?: [number, number][]
60  label?: string
61  overlay?: Overlay
62}
63
64declare module 'claude-code' {
65  interface PluginState {
66    'show-your-work': { current: Shown | null; welcome: number }
67  }
68}
69