// rnx screenshot slides — drive the live screenshot-mode composition from the // terminal. same pipeline as the in-shell UI (rail/strip): every mutation // goes through the shell's screenshotControl bridge so the browser UI // reflects each step live, captures auto-upload, and exports are the // composed App-Store canvases (bg + copy + posed device at export size). import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, isAbsolute, join, resolve } from 'node:path' import { callInBridge, callInBridgeWrite, createBridgeFromParsed, evalInBridge, parseBridgeCliArgs, type WsBridge, } from '../ws-bridge' const HELP = ` rnx screenshot slides — compose App Store screenshots in the live sim usage: rnx screenshot slides list slides (alias: slides list) rnx screenshot slides set [fields] patch a slide's copy/colors rnx screenshot slides add append a blank slide (focuses it) rnx screenshot slides remove delete a slide rnx screenshot slides move reorder a slide rnx screenshot slides focus open a slide (device flips into it) rnx screenshot slides overview back to the gallery rnx screenshot slides capture [] capture the live frame into a slide rnx screenshot slides export [] [--out ] write composed App-Store PNG(s) set fields: --title headline --subtitle subheadline --bg-top gradient top (white gray slate black cyan blue --bg-bottom gradient bottom teal green lime gold orange coral --title-color copy color override, or "auto" red berry magenta --title-size sm | md | lg | none gray-light --underlay black | white | none gray-dark …) --shadow light | medium | strong | none notes: slides are addressed by 1-based index. screenshot mode is auto-enabled. capture shoots whatever the sim currently shows — navigate the app first (rnx do tap-id …), then capture. examples: rnx screenshot slides set 1 --title "The ultimate MMA community." --bg-top cyan --bg-bottom black rnx screenshot slides capture 1 rnx screenshot slides export --out ./exports ` interface SlideRow { index: number id: string title: string subtitle: string bgTop: string bgBottom: string titleColor: string | null titleSize: string | null titleUnderlay: string | null titleShadow: string | null captured: boolean captureMode: string | null focused: boolean active: boolean } interface ControlState { projectName: string canvas: string exportSize: { width: number; height: number } deviceModel: string slides: SlideRow[] } // my value flags, extracted before bridge-arg parsing. const VALUE_FLAGS: Record = { '--title': 'title', '--subtitle': 'subtitle', '--bg-top': 'bgTop', '--bg-bottom': 'bgBottom', '--title-color': 'titleColor', '--title-size': 'titleSize', '--underlay': 'titleUnderlay', '--shadow': 'titleShadow', '--out': 'out', } function extractFlags(args: string[]): { rest: string[] values: Record json: boolean } { const rest: string[] = [] const values: Record = {} let json = false for (let i = 0; i < args.length; i += 1) { const arg = args[i] if (arg === '--json') { json = true continue } const eq = arg.indexOf('=') const flagName = eq > 0 ? arg.slice(0, eq) : arg const key = VALUE_FLAGS[flagName] if (key) { if (eq > 0) { values[key] = arg.slice(eq + 1) } else { values[key] = args[i + 1] ?? '' i += 1 } continue } rest.push(arg) } return { rest, values, json } } function parseSlideRef(raw: string | undefined): number | string | undefined { if (!raw) return undefined const n = Number(raw) return Number.isInteger(n) && n > 0 ? n : raw } async function ensureScreenshotMode(bridge: WsBridge): Promise { const on = await evalInBridge( bridge, `(async () => { if (window.SootSim?.bridges?.settings?.get?.()?.screenshotMode) return true window.dispatchEvent(new CustomEvent('sootsim:shell-command', { detail: { type: 'fire-action', id: 'toggle-screenshot-mode' }, })) const deadline = Date.now() + 2000 while (Date.now() < deadline) { if (window.SootSim?.bridges?.settings?.get?.()?.screenshotMode) return true await new Promise((r) => setTimeout(r, 40)) } return false })()`, { acquireLock: true }, ) if (!on) { throw new Error( 'could not enable screenshot mode — is this a browser sim with the screenshot plugin? (electron has no screenshot mode)', ) } } function printState(state: ControlState) { console.log( ` ${state.projectName} — canvas ${state.canvas} (${state.exportSize.width}×${state.exportSize.height}), device ${state.deviceModel}`, ) for (const s of state.slides) { const marks = [ s.focused ? 'focused' : s.active ? 'active' : null, s.captured ? `captured${s.captureMode === '3d' ? ' 3d' : ''}` : 'no capture', ] .filter(Boolean) .join(', ') const copy = s.title ? `"${s.title}"` : '(no title)' const style = [ `bg ${s.bgTop}→${s.bgBottom}`, s.titleColor ? `color ${s.titleColor}` : null, s.titleSize ? `size ${s.titleSize}` : null, s.titleUnderlay ? `underlay ${s.titleUnderlay}` : null, s.titleShadow ? `shadow ${s.titleShadow}` : null, ] .filter(Boolean) .join(', ') console.log(` ${s.index}. ${copy} — ${style} [${marks}]`) if (s.subtitle) console.log(` ${s.subtitle}`) } } function dataUrlToBytes(dataUrl: string): Buffer { const m = dataUrl.match(/^data:[^;]+;base64,(.+)$/) if (!m) throw new Error('export returned an unexpected payload') return Buffer.from(m[1], 'base64') } export async function runSlides( args: string[], opts: { port?: number; verbose?: boolean }, ): Promise { if (args.includes('--help') || args.includes('-h')) { console.log(HELP) return } const { rest, values, json } = extractFlags(args) const parsed = parseBridgeCliArgs(rest, { port: opts.port, // capture waits on focus animation + frame bake; export composes at // full App-Store resolution — both can exceed the default 15s. commandTimeoutMs: 45_000, }) const sub = parsed.positional[0] ?? 'list' const refArg = parseSlideRef(parsed.positional[1]) const positionArg = Number(parsed.positional[2]) const bridge = createBridgeFromParsed(parsed) const emit = (state: ControlState) => { if (json) console.log(JSON.stringify(state, null, 2)) else printState(state) } try { await ensureScreenshotMode(bridge) switch (sub) { case 'list': { emit( await callInBridge( bridge, 'SootSim.bridges.screenshotControl.getState', ), ) break } case 'set': { if (refArg == null) throw new Error('usage: rnx screenshot slides set --title … (see --help)') const patch: Record = {} for (const key of [ 'title', 'subtitle', 'bgTop', 'bgBottom', 'titleColor', 'titleSize', 'titleUnderlay', 'titleShadow', ]) { if (values[key] != null) patch[key] = values[key] } if (Object.keys(patch).length === 0) { throw new Error('nothing to set — pass at least one field (see --help)') } emit( await callInBridgeWrite( bridge, 'SootSim.bridges.screenshotControl.setSlide', refArg, patch, ), ) break } case 'add': { emit( await callInBridgeWrite( bridge, 'SootSim.bridges.screenshotControl.addSlide', ), ) break } case 'remove': { if (refArg == null) throw new Error('usage: rnx screenshot slides remove ') emit( await callInBridgeWrite( bridge, 'SootSim.bridges.screenshotControl.removeSlide', refArg, ), ) break } case 'move': { if (refArg == null || !Number.isInteger(positionArg) || positionArg < 1) { throw new Error('usage: rnx screenshot slides move ') } emit( await callInBridgeWrite( bridge, 'SootSim.bridges.screenshotControl.moveSlide', refArg, positionArg, ), ) break } case 'focus': { if (refArg == null) throw new Error('usage: rnx screenshot slides focus ') emit( await callInBridgeWrite( bridge, 'SootSim.bridges.screenshotControl.focusSlide', refArg, ), ) break } case 'overview': { emit( await callInBridgeWrite( bridge, 'SootSim.bridges.screenshotControl.overview', ), ) break } case 'capture': { const state = await callInBridgeWrite( bridge, 'SootSim.bridges.screenshotControl.capture', ...(refArg != null ? [refArg] : []), ) emit(state) break } case 'export': { const outDir = values.out ? isAbsolute(values.out) ? values.out : resolve(process.cwd(), values.out) : join(process.cwd(), 'screenshots') const state = await callInBridge( bridge, 'SootSim.bridges.screenshotControl.getState', ) const targets = refArg != null ? state.slides.filter((s) => s.index === refArg || s.id === refArg) : state.slides.filter((s) => s.captured) if (targets.length === 0) { throw new Error( refArg != null ? `no slide ${refArg}` : 'no captured slides to export — capture first', ) } const written: string[] = [] for (const slide of targets) { const result = await callInBridge<{ name: string width: number height: number dataUrl: string }>(bridge, 'SootSim.bridges.screenshotControl.exportSlide', slide.index) const path = join(outDir, result.name) mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, dataUrlToBytes(result.dataUrl)) written.push(path) console.log(` saved: ${path} (${result.width}×${result.height})`) } if (json) console.log(JSON.stringify({ written }, null, 2)) break } default: throw new Error(`unknown subcommand "${sub}" — see rnx screenshot slides --help`) } } finally { bridge.close() } }