A studio pane for Forge's art: renders of the body map and any image beside the chat, Approve / Redo / Compare, /tune for the body-map knobs, and tools to…

A mod (a Claude Code plugin of function hooks) that gives a session a studio pane for Forge's art. It lives in the repo so every session working on Forge loads the same one.
/studio opens it. It shows the current render with a strip of earlier versions, Approve, Redo (with a note that reaches Claude as the next prompt) and Compare (the previous version beside the current one)./tune striations 0.12 denoise 6, /tune list, /tune reset. It reruns the finish stage of tools/body-map/pipeline.py, shows the result in the pane and asks Claude to republish the review artifact.render (the body-map pipeline, any stage, with knobs), show (any image file into the pane) and cloudinary_enhance (run an image through Cloudinary's improve / upscale / enhance / background removal, which needs CLOUDINARY_URL as a secret in the environment and the Cloudinary hosts allowed on its network policy).Renders land in .studio/ at the repo root, which is ignored by git.
A project plugin in .claude/skills/ loads with the session. To install it explicitly instead, at a terminal session's prompt:
/plugin install forge-studio --marketplace Morrow707/Forge
Or set CLAUDE_CODE_PLUGIN_DIRS to this folder's absolute path in the cloud environment.
hooks/register.tsx 313 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { StudioVersion } from '../types'
5
6// FORGE STUDIO: the picture beside the chat, so a round of art is a look and a button, not a
7// paragraph describing a flaw and a guess at which knob it names. The body map took nine rounds
8// that way (2026-10-18/19). The pipeline it drives is tools/body-map (every number there is a
9// knob); the pane shows what came out; Approve / Redo / Compare come straight back as prompts.
10
11const PLUGIN = 'forge-studio'
12
13/** The repository the pipeline lives in. Taken from the session's own working directory at
14 * session.start -- NOT derived from `$.plugin.root`, which is wherever the plugin was loaded
15 * from (a symlink in a session's mods folder on the first run, where stripping
16 * `.claude/skills/forge-studio` off it finds nothing and every tool looked for the pipeline
17 * in the wrong place). The derivation stays as the fallback for a hook that runs before
18 * session.start has. */
19let repoRoot: string | null = null
20const repoRootFor = ($: { plugin: { root: string } }) =>
21 repoRoot ?? $.plugin.root.replace(/\/\.claude\/skills\/forge-studio$/, '')
22
23/** A tool answer the model reads as text. `result` is what the engine stores; `text` what it shows. */
24const ok = (text: string) => ({ result: text, text })
25const fail = (text: string) => ({ isError: true as const, result: text, text })
26const PANE = 'forge-studio'
27const ARTIFACT = 'https://claude.ai/artifact/547k9BLoUmuYF12LvyA3dM'
28const PY = 'python3.13'
29
30const versions = atom({ plugin: 'forge-studio', key: 'versions' }, [] as StudioVersion[], { shape: 'versions-1' })
31const currentId = atom({ plugin: 'forge-studio', key: 'currentId' }, null as string | null)
32const compareId = atom({ plugin: 'forge-studio', key: 'compareId' }, null as string | null)
33const busy = atom({ plugin: 'forge-studio', key: 'busy' }, null as string | null)
34
35
36const KNOB_HELP = [
37 'denoise 8 (grain removal; 0 off)', 'blur 1.8', 'sharpen 0.38', 'sharpen_sigma 3.0', 'gamma 0.92',
38 'color 1.05', 'striations 0.17 (fibre-line darkness; 0 off)', 'stripe_period 13', 'stripe_min_area 1400',
39 'edge_shade 0.07', 'feather 1.6', 'face_scale 0.96', 'face_fade 14', 'preview_height 520',
40 '-- the smoothing pass (2026-10-24) --',
41 'outline_smooth 2.5 (silhouette blur before threshold)', 'label_smooth 4 (hover-shape blur; 0 = staircase)',
42 'label_min_island 2500 (a group speck under this many px joins its neighbour)', 'label_close 3 (close + fill each group first)',
43 'hand_zone 0.08 (hand = this fraction of figure height above each arm\'s end: skin, no group)', 'foot_zone 0.075 (same, from the bottom)',
44 'flatten 0.6 (pull each blob toward its median colour)', 'flatten_blur 3 (soften the seams)',
45 'skin_smooth 0.85 (ungrouped body pixels toward smooth skin)', 'skin_sigma 7', 'match_color 1 (match each tile to the male front)',
46].join('\n ')
47
48function parseResult(stdout: string, tag: string): Record<string, unknown> | null {
49 const line = stdout.split('\n').reverse().find(l => l.startsWith(tag + ' '))
50 if (!line) return null
51 try { return JSON.parse(line.slice(tag.length + 1)) } catch { return null }
52}
53
54async function readPreviewInfo($: any, dir: string) {
55 try {
56 const text = await $.fs.read(`${dir}/preview.json`)
57 return JSON.parse(text) as { preview: { w: number; h: number }; previewSmall: { w: number; h: number } }
58 } catch { return null }
59}
60
61async function addVersion($: any, v: StudioVersion) {
62 await update($, versions, list => [...list, v].slice(-30))
63 await update($, currentId, () => v.id)
64 await update($, compareId, () => null)
65 await $.ui.open({ id: PANE, title: 'Forge Studio' })
66}
67
68function newId() { return Date.now().toString(36) }
69
70/** Run the body-map pipeline and register what it produced as a version. */
71async function renderBodyMap($: any, cwd: string, args: { stage?: string; knobs?: Record<string, number>; label?: string; reset?: boolean }) {
72 const work = `${cwd}/.studio/bodymap`
73 const argv = [PY, `${cwd}/tools/body-map/pipeline.py`, '--work', work, '--stage', args.stage ?? 'hd']
74 if (args.reset) argv.push('--reset')
75 for (const [k, v] of Object.entries(args.knobs ?? {})) argv.push('--set', `${k}=${v}`)
76 // A first run needs every stage; `hd` alone wants the tiles from an earlier run.
77 if ((args.stage ?? 'hd') === 'hd' && !(await $.fs.exists(`${work}/manifest.json`))) argv[argv.indexOf('--stage') + 1] = 'all'
78 await update($, busy, () => `rendering body map (${argv[argv.indexOf('--stage') + 1]})`)
79 try {
80 const ran = await $.process.run(argv, { cwd, timeoutMs: 600_000 })
81 if (ran.exitCode !== 0) {
82 return { ok: false as const, text: `pipeline failed (${ran.exitCode}):\n${ran.stderr.slice(-1500) || ran.stdout.slice(-1500)}` }
83 }
84 const res = parseResult(ran.stdout, 'RESULT')
85 const info = await readPreviewInfo($, work)
86 if (!res || !info) return { ok: false as const, text: `pipeline ran but wrote no result line:\n${ran.stdout.slice(-800)}` }
87 const knobs = res.knobs as Record<string, number>
88 const v: StudioVersion = {
89 id: newId(), label: args.label ?? `body map ${new Date().toISOString().slice(11, 16)}`, at: Date.now(), kind: 'bodymap',
90 preview: `${work}/preview.webp`, previewSmall: `${work}/preview-sm.webp`, previewPng: `${work}/preview.png`,
91 w: info.preview.w, h: info.preview.h, smallW: info.previewSmall.w, smallH: info.previewSmall.h,
92 knobs, site: String(res.site), decision: null,
93 }
94 // Each version keeps its own preview files, or the strip would show the latest for all.
95 const keep = `${cwd}/.studio/versions/${v.id}`
96 await $.process.run(['mkdir', '-p', keep])
97 await $.process.run(['cp', `${work}/preview.webp`, `${work}/preview-sm.webp`, `${work}/preview.png`, `${work}/preview.json`, keep])
98 v.preview = `${keep}/preview.webp`; v.previewSmall = `${keep}/preview-sm.webp`; v.previewPng = `${keep}/preview.png`
99 await addVersion($, v)
100 return { ok: true as const, version: v, seconds: Number(res.seconds), text: `Rendered ${v.label} in ${res.seconds}s. Knobs: ${JSON.stringify(knobs)}. Site: ${res.site}` }
101 } finally {
102 await update($, busy, () => null)
103 }
104}
105
106/** Show any one image in the pane. */
107async function showImage($: any, cwd: string, path: string, label?: string, kind: StudioVersion['kind'] = 'image') {
108 const id = newId()
109 const out = `${cwd}/.studio/versions/${id}`
110 await update($, busy, () => `preparing ${label ?? path}`)
111 try {
112 const ran = await $.process.run([PY, `${cwd}/tools/body-map/preview.py`, '--image', path, '--out', out], { cwd, timeoutMs: 120_000 })
113 if (ran.exitCode !== 0) return { ok: false as const, text: `could not preview ${path}: ${ran.stderr.slice(-600)}` }
114 const info = await readPreviewInfo($, out)
115 if (!info) return { ok: false as const, text: `no preview written for ${path}` }
116 const v: StudioVersion = {
117 id, label: label ?? path.split('/').pop() ?? path, at: Date.now(), kind,
118 preview: `${out}/preview.webp`, previewSmall: `${out}/preview-sm.webp`, previewPng: `${out}/preview.png`,
119 w: info.preview.w, h: info.preview.h, smallW: info.previewSmall.w, smallH: info.previewSmall.h, decision: null,
120 }
121 await addVersion($, v)
122 return { ok: true as const, version: v, text: `Showing ${v.label} in the studio pane.` }
123 } finally {
124 await update($, busy, () => null)
125 }
126}
127
128export const register: Register = on => {
129 on('session.start', async ($, e, next) => {
130 repoRoot = e.cwd
131 await $.command.register({ name: 'studio', description: 'Open the Forge Studio pane (renders, Approve / Redo / Compare).' })
132 await $.command.register({ name: 'tune', description: 'Turn a body-map knob and re-render: /tune striations 0.12 denoise 6 | list | reset', argumentHint: 'knob value ... | list | reset' })
133 await $.tool.register({
134 name: 'render', isDeferred: false,
135 description: 'Run the Forge body-map pipeline (tools/body-map) and show the result in the studio pane. stage "hd" reruns the finish with the knobs given (fast); "all" rebuilds from the source renders. Knobs: denoise, blur, sharpen, sharpen_sigma, gamma, color, striations, stripe_period, stripe_min_area, edge_shade, feather, outline_smooth, label_smooth, label_min_island, label_close, hand_zone, foot_zone, flatten, flatten_blur, skin_smooth, skin_sigma, match_color, face_scale, face_fade, preview_height. Returns the knobs in force and the site folder to publish the artifact from.',
136 inputSchema: { type: 'object', properties: {
137 stage: { type: 'string', enum: ['hd', 'all'] },
138 knobs: { type: 'object', additionalProperties: { type: 'number' } },
139 label: { type: 'string' }, reset: { type: 'boolean' },
140 } },
141 })
142 await $.tool.register({
143 name: 'show', isDeferred: false,
144 description: 'Show an image file (png, jpg, webp) in the Forge Studio pane beside the chat as a new version, for Scott to approve, redo or compare. Use after rendering any artwork.',
145 inputSchema: { type: 'object', properties: { path: { type: 'string' }, label: { type: 'string' } }, required: ['path'] },
146 })
147 await $.tool.register({
148 name: 'cloudinary_enhance',
149 description: 'Run an image through Cloudinary (improve, upscale, enhance, background_removal, sharpen, auto_contrast) and show the result in the studio pane. Needs CLOUDINARY_URL in the environment; says so when it is missing.',
150 inputSchema: { type: 'object', properties: {
151 path: { type: 'string' }, out: { type: 'string', description: 'where to write the result (png); default beside the input' },
152 effects: { type: 'array', items: { type: 'string' } }, label: { type: 'string' },
153 }, required: ['path'] },
154 })
155 return next(e)
156 })
157
158 on('command.run', { command: 'studio' }, async $ => {
159 await $.ui.open({ id: PANE, title: 'Forge Studio', focus: true })
160 const list = await read($, versions)
161 return { text: list.length ? `Forge Studio open: ${list.length} version(s).` : 'Forge Studio open. Nothing rendered yet: call the render tool, or /tune.' }
162 })
163
164 on('command.run', { command: 'tune' }, async ($, e) => {
165 const words = e.args.trim().split(/\s+/).filter(Boolean)
166 const cwd = repoRootFor($)
167 if (!words.length || words[0] === 'list') {
168 const cur = (await read($, versions)).slice().reverse().find(v => v.kind === 'bodymap')
169 return { text: `Body-map knobs (default in brackets):\n ${KNOB_HELP}\n\nIn force: ${cur?.knobs ? JSON.stringify(cur.knobs) : 'defaults (nothing rendered yet)'}\n\n/tune striations 0.12 denoise 6` }
170 }
171 if (words[0] === 'reset') {
172 const r = await renderBodyMap($, cwd, { stage: 'hd', reset: true, label: 'defaults' })
173 if (r.ok) void $.prompt.submit({ text: `Forge Studio: /tune reset re-rendered the body map at the default knobs (version "${r.version.label}", site ${r.version.site}). Republish the body map artifact ${ARTIFACT} from that site folder (index.html plus img/, lab/, manifest.json, exercises.json) and tell Scott in one line.` })
174 return { text: r.text }
175 }
176 if (words.length % 2 !== 0) return { text: 'Pairs, please: /tune knob value [knob value ...]. /tune list shows the knobs.' }
177 const knobs: Record<string, number> = {}
178 for (let i = 0; i < words.length; i += 2) {
179 const knob = words[i]
180 const raw = words[i + 1]
181 if (!knob || raw === undefined) break
182 const n = Number(raw)
183 if (!Number.isFinite(n)) return { text: `"${raw}" is not a number for ${knob}.` }
184 knobs[knob] = n
185 }
186 const label = Object.entries(knobs).map(([k, v]) => `${k} ${v}`).join(', ')
187 const r = await renderBodyMap($, cwd, { stage: 'hd', knobs, label })
188 if (r.ok) {
189 void $.prompt.submit({ text: `Forge Studio: /tune re-rendered the body map with ${label} (version "${r.version.label}", site ${r.version.site}). Republish the body map artifact ${ARTIFACT} from that site folder (index.html plus img/, lab/, manifest.json, exercises.json) and tell Scott in one line what moved.` })
190 }
191 return { text: r.text }
192 })
193
194 on('tool.call', { tool: 'mcp__forge-studio__render' }, async ($, e) => {
195 const cwd = repoRootFor($)
196 const input = e as unknown as { stage?: string; knobs?: Record<string, number>; label?: string; reset?: boolean }
197 const r = await renderBodyMap($, cwd, input)
198 return r.ok ? ok(r.text) : fail(r.text)
199 })
200
201 on('tool.call', { tool: 'mcp__forge-studio__show' }, async ($, e) => {
202 const cwd = repoRootFor($)
203 const input = e as unknown as { path: string; label?: string }
204 const r = await showImage($, cwd, input.path, input.label)
205 return r.ok ? ok(r.text) : fail(r.text)
206 })
207
208 on('tool.call', { tool: 'mcp__forge-studio__cloudinary_enhance' }, async ($, e) => {
209 const cwd = repoRootFor($)
210 const input = e as unknown as { path: string; out?: string; effects?: string[]; label?: string }
211 const out = input.out ?? input.path.replace(/\.[a-z0-9]+$/i, '') + `.cloudinary.png`
212 await update($, busy, () => 'asking Cloudinary')
213 let ran
214 try {
215 ran = await $.process.run([PY, `${cwd}/tools/body-map/cloudinary_enhance.py`, '--in', input.path, '--out', out, '--effects', (input.effects ?? ['improve']).join(',')], { cwd, timeoutMs: 300_000 })
216 } finally {
217 await update($, busy, () => null)
218 }
219 if (ran.exitCode !== 0) return fail(ran.stderr.trim() || `cloudinary_enhance failed (${ran.exitCode})`)
220 const r = await showImage($, cwd, out, input.label ?? `cloudinary ${(input.effects ?? ['improve']).join('+')}`)
221 const text = `${ran.stdout.trim()}\n${r.text}`
222 return r.ok ? ok(text) : fail(text)
223 })
224
225 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
226 const { Box, Text, Button } = $.ui.resolve(e)
227 const list = await read($, versions)
228 const cur = await read($, currentId)
229 const cmp = await read($, compareId)
230 const running = await read($, busy)
231 const current = list.find(v => v.id === cur) ?? list[list.length - 1]
232 const other = cmp ? list.find(v => v.id === cmp) : undefined
233 const width = Math.max(20, (e.props.bodyColumns ?? e.viewport?.columns ?? 60) - 2)
234
235 const picture = async (v: StudioVersion, small: boolean) => {
236 const path = small ? v.previewSmall : v.preview
237 const w = small ? v.smallW : v.w
238 const h = small ? v.smallH : v.h
239 if (e.surface === 'terminal') {
240 const { Image } = $.ui.resolve(e)
241 const { base64 } = await $.fs.read(v.previewPng, { as: 'bytes' })
242 const cols = small ? Math.floor(width / 2) - 1 : width
243 return <Image key={`img-${v.id}-${small ? 's' : 'l'}`} source={{ png: base64 }} columns={cols} rows={Math.max(6, Math.round((cols * h) / (w * 2)))} alt={v.label} />
244 }
245 const { Svg } = $.ui.resolve(e)
246 const { base64 } = await $.fs.read(path, { as: 'bytes' })
247 const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><image href="data:image/webp;base64,${base64}" width="${w}" height="${h}"/></svg>`
248 return <Svg source={svg} alt={v.label} />
249 }
250
251 const act = (label: string, hotkey: string, fn: () => void, variant?: 'primary') => (
252 <Button key={label.toLowerCase()} label={label} hotkey={hotkey} variant={variant} onPress={fn} />
253 )
254
255 const approve = () => {
256 if (!current) return
257 void update($, versions, l => l.map(v => (v.id === current.id ? { ...v, decision: 'approved' as const } : v)))
258 $.ui.toast(`Approved ${current.label}`)
259 void $.prompt.submit({ text: `Forge Studio: Scott approved "${current.label}"${current.kind === 'bodymap' ? ` (knobs ${JSON.stringify(current.knobs)}, site ${current.site})` : ''}. Treat it as the version to keep.` })
260 }
261 const redo = () => {
262 if (!current) return
263 void update($, versions, l => l.map(v => (v.id === current.id ? { ...v, decision: 'redo' as const } : v)))
264 void $.prompt.fill({ text: `Forge Studio: another pass on "${current.label}": `, mode: 'replace' })
265 }
266 const compare = () => {
267 if (!current) return
268 const i = list.findIndex(v => v.id === current.id)
269 const prev = i > 0 ? list[i - 1] : undefined
270 void update($, compareId, c => (c ? null : (prev?.id ?? null)))
271 }
272
273 if (!current) {
274 return (
275 <Box flexDirection="column">
276 <Text bold>Forge Studio</Text>
277 <Text dimColor>Nothing rendered yet. Ask Claude to render the body map, or type /tune list.</Text>
278 {running && <Text>{running}…</Text>}
279 </Box>
280 )
281 }
282
283 const pictures = other
284 ? [<Box flexDirection="row">{await picture(other, true)}{await picture(current, true)}</Box>]
285 : [await picture(current, false)]
286
287 return (
288 <Box flexDirection="column">
289 <Box flexDirection="row">
290 <Text bold>{current.label}</Text>
291 <Text dimColor> {new Date(current.at).toLocaleTimeString()}{current.decision ? ` · ${current.decision}` : ''}</Text>
292 </Box>
293 {other && <Text dimColor>left: {other.label} · right: {current.label}</Text>}
294 {pictures}
295 {running && <Text dimColor>{running}…</Text>}
296 <Box flexDirection="row">
297 {act('Approve', 'a', approve, 'primary')}
298 {act('Redo', 'r', redo)}
299 {act(other ? 'Uncompare' : 'Compare', 'c', compare)}
300 </Box>
301 {current.kind === 'bodymap' && current.knobs && (
302 <Text dimColor>knobs: {Object.entries(current.knobs).filter(([k]) => k !== 'preview_height').map(([k, v]) => `${k} ${v}`).join(' · ')}</Text>
303 )}
304 <Box flexDirection="row" flexWrap="wrap">
305 {list.slice(-8).map(v => (
306 <Button key={`v-${v.id}`} plain label={`${v.id === current.id ? '▸ ' : ''}${v.label}${v.decision === 'approved' ? ' ✓' : ''}`} onPress={() => { void update($, currentId, () => v.id); void update($, compareId, () => null) }} />
307 ))}
308 </Box>
309 </Box>
310 )
311 })
312}
313types/index.d.ts 35 lines1/** One thing the studio has shown: a body-map render or any one image. */
2export type StudioVersion = {
3 id: string
4 /** What the strip calls it. */
5 label: string
6 /** Epoch ms. */
7 at: number
8 kind: 'bodymap' | 'image'
9 /** Absolute paths of the previews preview.py wrote. */
10 preview: string
11 previewSmall: string
12 previewPng: string
13 /** Pixel sizes of the previews, for the Svg box. */
14 w: number
15 h: number
16 smallW: number
17 smallH: number
18 /** The knobs this render was made with (body map only). */
19 knobs?: Record<string, number>
20 /** The rendered site folder to publish from (body map only). */
21 site?: string
22 decision?: 'approved' | 'redo' | null
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'forge-studio': {
28 versions: Shaped<StudioVersion[]>
29 currentId: string | null
30 compareId: string | null
31 busy: string | null
32 }
33 }
34}
35