// rnx mode three — toggle the live browser 3d device stage. // // wraps the existing `toggle-three-mode` shell action so CLI, rail, and // menu all use the same plugin-owned state path. import { createHash } from 'node:crypto' import { mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, extname, resolve } from 'node:path' import { isThreeModeSetting, THREE_MODE_SETTING_IDS, } from 'sootsim-engine/screenshots/tokens' import { rnxExit } from '../run-rnx' import { callInBridgeWrite, createBridgeFromParsed, parseBridgeCliArgs, type WsBridge, } from '../ws-bridge' import { runShellBooleanMode, type ShellBooleanModeOptions } from './shell-boolean-mode' type ThreeModeConfig = Record type ThreeModeState = Record & { ready?: boolean disabled?: boolean } const THREE_MODE_READY_TIMEOUT_MS = 12_000 // a supersampled path-traced frame (soft-orbs export) legitimately spends // minutes on shader compile + sample convergence on slower GPUs; raster // timeline batches stay far under this either way. const THREE_MODE_RENDER_TIMEOUT_MS = 600_000 const TIMELINE_ACTIONS = new Set(['load', 'seek', 'play', 'pause', 'render', 'measure']) export async function runThreeMode(args: string[], opts: ShellBooleanModeOptions) { if (args.includes('--help') || args.includes('-h')) { console.log(` rnx mode three — toggle the 3d device stage usage: rnx mode three [on|off|toggle] rnx mode three configure [options] rnx mode three load rnx mode three seek rnx mode three play|pause rnx mode three render --output [options] rnx mode three measure [options] arguments: on enable 3d mode off disable 3d mode toggle flip the current state (default) configure options: --device set the phone body: iphone-17-air | iphone-17-pro | pixel-10 (a film authors its own via initial.device) --background set the 3d background --gradient custom gradient bg: comma-separated css hex stops, e.g. "#0a0e1a,#16243f,#05080f" (overrides --background; evenly spaced, or use "color@offset" for explicit %) --gradient-angle custom gradient angle (default 180 = top→bottom) --colorway set the phone colorway --script apply a camera preset pose --progress <0..1> script progress to sample (default: 1) --animate run the script instead of sampling one frame --slow run the script slowly when --animate is set --environment on|off enable or disable environment objects --env-shape orb | pill | diamond | pane --env-spread tight | medium | wide --env-size small | medium | large --env-color peach | gold | mint | sky | violet --env-glow on|off enable or disable environment glow --env-plastic on|off enable or disable environment plastic material --setting none | room | window-wall | white-backdrop | cyclorama | terrain --focus on|off enable or disable Focus depth of field --focus-amount subtle | medium | strong | intense | extreme --focus-range tight | balanced | wide --reset-pose return to the default hero pose before other changes render options: --output write frame-%05d.png + timeline-render.json --fps frames per second (default: 30) --width exact output width (default: 1920) --height exact output height (default: 1080) --from first timeline time (default: 0) --to exclusive end time (default: timeline duration) --overwrite replace this command's existing frames in the dir measure options (projected bounds only — no pixels rendered): --fps sample rate (default: 30) --width output width the bounds project into (default: 1920) --height output height the bounds project into (default: 1080) --from first timeline time (default: 0) --to exclusive end time (default: timeline duration) --count sample n frames from --from at the --fps spacing --at sample exact timeline times (overrides from/to/count) --output write the measurement JSON here instead of stdout examples: rnx mode three rnx mode three on rnx mode three configure --script hero-arc --background gradient-tide rnx mode three configure --environment on --env-shape pane --setting room rnx mode three render ./cinematic.json --output ./frames --fps 30 rnx mode three measure ./cinematic.json --width 450 --height 900 --at 0,1200 rnx mode three off `) rnxExit(0) } const timelineAction = findTimelineAction(args) if (timelineAction) { await runThreeModeTimelineAction(timelineAction, args, opts) return } if (shouldConfigure(args)) { await runThreeModeConfigure(args, opts) return } const result = await runShellBooleanMode(args, opts, { modeKey: 'threeMode', displayName: 'three-mode', actionId: 'toggle-three-mode', }) if (result.target) { await waitForThreeModeRuntimeReady(args, opts) } } function shouldConfigure(args: string[]) { return ( args[0] === 'configure' || args.some((arg) => [ '--device', '--background', '--gradient', '--gradient-angle', '--colorway', '--script', '--progress', '--animate', '--slow', '--environment', '--env-shape', '--env-spread', '--env-size', '--env-color', '--env-glow', '--env-plastic', '--setting', '--focus', '--focus-amount', '--focus-range', '--reset-pose', ].includes(arg), ) ) } async function runThreeModeConfigure(args: string[], opts: ShellBooleanModeOptions) { const parsed = parseBridgeCliArgs(args, { port: opts.port, stripBooleanFlags: ['--animate', '--slow', '--reset-pose'], stripValueFlags: [ '--device', '--background', '--gradient', '--gradient-angle', '--colorway', '--script', '--progress', '--environment', '--env-shape', '--env-spread', '--env-size', '--env-color', '--env-glow', '--env-plastic', '--setting', '--focus', '--focus-amount', '--focus-range', ], }) const options = buildConfigureOptions(args) const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: THREE_MODE_READY_TIMEOUT_MS + 1000, }) try { await waitForThreeModeRuntimeReadyOnBridge(bridge) const state = await callInBridgeWrite>( bridge, 'SootSim.bridges.threeMode.configure', options, ) console.log(` three-mode: configured (${summarizeState(state)})`) } finally { bridge.close() } } async function waitForThreeModeRuntimeReady( args: string[], opts: ShellBooleanModeOptions, ) { const parsed = parseBridgeCliArgs(args, { port: opts.port }) const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: THREE_MODE_READY_TIMEOUT_MS + 1000, }) try { await waitForThreeModeRuntimeReadyOnBridge(bridge) } finally { bridge.close() } } export async function waitForThreeModeRuntimeReadyOnBridge(bridge: WsBridge) { const state = (await bridge.send({ type: 'evaluate', code: `(async () => { const deadline = Date.now() + ${THREE_MODE_READY_TIMEOUT_MS} while (Date.now() < deadline) { const settings = window.SootSim?.bridges?.settings?.get?.() if (settings && settings.threeMode !== true) { return { ready: false, disabled: true } } const bridge = window.SootSim?.bridges?.threeMode const state = typeof bridge?.getState === 'function' ? bridge.getState() : null if (state?.ready === true) return state await new Promise((resolve) => setTimeout(resolve, 50)) } const bridge = window.SootSim?.bridges?.threeMode const state = typeof bridge?.getState === 'function' ? bridge.getState() : null return state || { ready: false } })()`, })) as ThreeModeState | null if (state?.ready === true) return state if (state?.disabled) { throw new Error('3d mode is off; run `rnx mode three on` before configure') } throw new Error('3d mode runtime did not become ready') } type TimelineSampleResult = { durationMs: number timeMs: number shotId: string | null } type TimelineRenderedFrame = { index: number timeMs: number width: number height: number dataUrl: string alpha: { min: number max: number transparentPixels: number } phoneCollision: { hull: Array<{ x: number; y: number }> } } type TimelineRenderedFrames = { frames: TimelineRenderedFrame[] } type TimelineMeasuredFrame = { index: number timeMs: number camera: { position: [number, number, number] target: [number, number, number] fov: number } targets: Record< string, { hull: Array<{ x: number; y: number }> bounds: { left: number; right: number; top: number; bottom: number } } > } type TimelineMeasuredFrames = { fps: number from: number to: number width: number height: number frames: TimelineMeasuredFrame[] } function findTimelineAction(args: string[]): string | null { for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--sim' || arg === '--port' || arg === '-p') { index += 1 continue } if (arg.startsWith('-')) continue return TIMELINE_ACTIONS.has(arg) ? arg : null } return null } function readFlagValue(args: string[], flag: string) { const index = args.indexOf(flag) return index >= 0 ? args[index + 1] : undefined } function readNumberFlag(args: string[], flag: string, fallback: number, minimum = 0) { const raw = readFlagValue(args, flag) if (raw === undefined) return fallback const value = Number.parseFloat(raw) if (!Number.isFinite(value) || value < minimum) { throw new Error(`${flag} expects a number greater than or equal to ${minimum}`) } return value } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function mimeTypeForAsset(path: string, contentType: unknown) { const extension = extname(path).toLowerCase() if (contentType === 'recording') { if (extension === '.mp4' || extension === '.m4v') return 'video/mp4' return 'video/webm' } if (extension === '.jpg' || extension === '.jpeg') return 'image/jpeg' if (extension === '.webp') return 'image/webp' return 'image/png' } function inlineTimelineAssets(value: unknown, baseDirectory: string): unknown { if (Array.isArray(value)) { return value.map((entry) => inlineTimelineAssets(entry, baseDirectory)) } if (!isRecord(value)) return value const result: Record = {} for (const [key, entry] of Object.entries(value)) { result[key] = inlineTimelineAssets(entry, baseDirectory) } if ( (result.type === 'recording' || result.type === 'screenshot' || result.kind === 'screen') && typeof result.src === 'string' && !/^(?:blob:|data:|https?:)/i.test(result.src) ) { const assetPath = resolve(baseDirectory, result.src) const bytes = readFileSync(assetPath) result.src = `data:${mimeTypeForAsset(assetPath, result.type === 'recording' ? 'recording' : 'screenshot')};base64,${bytes.toString('base64')}` } return result } function readTimelineFile(path: string) { const absolutePath = resolve(path) const parsed: unknown = JSON.parse(readFileSync(absolutePath, 'utf8')) return { absolutePath, timeline: inlineTimelineAssets(parsed, dirname(absolutePath)), } } async function ensureThreeModeRuntimeOnBridge(bridge: WsBridge) { await callInBridgeWrite(bridge, 'SootSim.bridges.settings.set', 'threeMode', true) await waitForThreeModeRuntimeReadyOnBridge(bridge) } async function runThreeModeTimelineAction( action: string, args: string[], opts: ShellBooleanModeOptions, ) { const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: THREE_MODE_RENDER_TIMEOUT_MS, stripBooleanFlags: ['--overwrite'], stripValueFlags: [ '--output', '--fps', '--width', '--height', '--from', '--to', '--count', '--at', ], }) const bridge = createBridgeFromParsed(parsed) try { await ensureThreeModeRuntimeOnBridge(bridge) if (action === 'seek') { const rawTime = parsed.positional[1] const timeMs = rawTime === undefined ? Number.NaN : Number.parseFloat(rawTime) if (!Number.isFinite(timeMs)) throw new Error('three-mode seek expects time in ms') const sample = await callInBridgeWrite( bridge, 'SootSim.bridges.threeMode.seek', timeMs, ) console.log( ` three-mode: ${Math.round(sample.timeMs)}ms / ${Math.round(sample.durationMs)}ms (${sample.shotId ?? 'no shot'})`, ) return } if (action === 'play' || action === 'pause') { const state = await callInBridgeWrite>( bridge, `SootSim.bridges.threeMode.${action}`, ) const timeline = isRecord(state.timeline) ? state.timeline : {} console.log( ` three-mode: ${action} (${Math.round(Number(timeline.timeMs ?? 0))}ms)`, ) return } const timelinePath = parsed.positional[1] if (!timelinePath) throw new Error(`three-mode ${action} expects a timeline JSON path`) const loadedFile = readTimelineFile(timelinePath) const loaded = await callInBridgeWrite( bridge, 'SootSim.bridges.threeMode.loadTimeline', loadedFile.timeline, ) if (action === 'load') { console.log( ` three-mode: loaded ${loadedFile.absolutePath} (${Math.round(loaded.durationMs)}ms)`, ) return } if (action === 'measure') { const fps = readNumberFlag(args, '--fps', 30, 0.001) const width = Math.round(readNumberFlag(args, '--width', 1_920, 1)) const height = Math.round(readNumberFlag(args, '--height', 1_080, 1)) const atValue = readFlagValue(args, '--at') let measured: TimelineMeasuredFrames if (atValue !== undefined) { const times = atValue.split(',').map((token) => { const value = Number.parseFloat(token) if (!Number.isFinite(value) || value < 0) { throw new Error(`--at expects comma-separated times in ms, got "${token}"`) } return Math.min(value, loaded.durationMs) }) const frames: TimelineMeasuredFrame[] = [] for (const timeMs of times) { const single = await callInBridgeWrite( bridge, 'SootSim.bridges.threeMode.measure', { fps, width, height, from: timeMs, count: 1 }, ) frames.push({ ...single.frames[0], index: frames.length }) } measured = { fps, from: times[0], to: times[times.length - 1], width, height, frames, } } else { const from = readNumberFlag(args, '--from', 0, 0) const countValue = readFlagValue(args, '--count') const options: { fps: number width: number height: number from: number to?: number count?: number } = { fps, width, height, from } if (countValue === undefined) { const to = readNumberFlag(args, '--to', loaded.durationMs, 0) if (to <= from || to > loaded.durationMs) { throw new Error( `--to must be greater than --from and at most ${loaded.durationMs}`, ) } options.to = to } else { options.count = Math.round(readNumberFlag(args, '--count', 1, 1)) } measured = await callInBridgeWrite( bridge, 'SootSim.bridges.threeMode.measure', options, ) } const json = `${JSON.stringify(measured, null, 2)}\n` const outputValue = readFlagValue(args, '--output') if (outputValue) { const outputPath = resolve(outputValue) mkdirSync(dirname(outputPath), { recursive: true }) writeFileSync(outputPath, json) // stderr keeps a caller piping stdout (phone-film bounds) JSON-clean. process.stderr.write( ` three-mode: measured ${measured.frames.length} frames → ${outputPath}\n`, ) } else { process.stdout.write(json) } return } const outputValue = readFlagValue(args, '--output') if (!outputValue) throw new Error('three-mode render requires --output ') const outputDirectory = resolve(outputValue) const fps = readNumberFlag(args, '--fps', 30, 0.001) const width = Math.round(readNumberFlag(args, '--width', 1_920, 1)) const height = Math.round(readNumberFlag(args, '--height', 1_080, 1)) const from = readNumberFlag(args, '--from', 0, 0) const to = readNumberFlag(args, '--to', loaded.durationMs, 0) if (to <= from || to > loaded.durationMs) { throw new Error(`--to must be greater than --from and at most ${loaded.durationMs}`) } const frameDurationMs = 1_000 / fps const frameCount = Math.ceil((to - from) / frameDurationMs) mkdirSync(outputDirectory, { recursive: true }) const ownedOutputs = readdirSync(outputDirectory).filter( (name) => /^frame-\d{5}\.png$/.test(name) || name === 'timeline-render.json', ) if (ownedOutputs.length > 0 && !args.includes('--overwrite')) { throw new Error( `${outputDirectory} already has timeline frames; pass --overwrite to replace them`, ) } for (const name of ownedOutputs) unlinkSync(resolve(outputDirectory, name)) const manifestFrames: Array<{ file: string timeMs: number sha256: string alpha: TimelineRenderedFrame['alpha'] phoneCollision: TimelineRenderedFrame['phoneCollision'] }> = [] const batchSize = 4 for (let frameIndex = 0; frameIndex < frameCount; frameIndex += batchSize) { const count = Math.min(batchSize, frameCount - frameIndex) const batchFrom = from + frameIndex * frameDurationMs const batchTo = Math.min(to, batchFrom + count * frameDurationMs) const rendered = await callInBridgeWrite( bridge, 'SootSim.bridges.threeMode.renderFrames', { fps, from: batchFrom, to: batchTo, width, height, count }, ) if (rendered.frames.length !== count) { throw new Error( `3d renderer returned ${rendered.frames.length} frames for a ${count}-frame batch`, ) } for (const frame of rendered.frames) { const globalIndex = frameIndex + frame.index const file = `frame-${String(globalIndex).padStart(5, '0')}.png` const separator = frame.dataUrl.indexOf(',') if ( separator < 0 || !frame.dataUrl.slice(0, separator).includes('image/png;base64') ) { throw new Error(`3d renderer returned a non-PNG frame at ${frame.timeMs}ms`) } const bytes = Buffer.from(frame.dataUrl.slice(separator + 1), 'base64') writeFileSync(resolve(outputDirectory, file), bytes) manifestFrames.push({ file, timeMs: frame.timeMs, sha256: createHash('sha256').update(bytes).digest('hex'), alpha: frame.alpha, phoneCollision: frame.phoneCollision, }) } process.stderr.write( `\r three-mode: rendered ${Math.min(frameIndex + count, frameCount)}/${frameCount}`, ) } process.stderr.write('\n') const manifest = { version: 2, timeline: loadedFile.absolutePath, fps, from, to, width, height, frameCount, transparent: manifestFrames.every((frame) => frame.alpha.transparentPixels > 0), frames: manifestFrames, } writeFileSync( resolve(outputDirectory, 'timeline-render.json'), `${JSON.stringify(manifest, null, 2)}\n`, ) console.log( ` three-mode: wrote ${frameCount} deterministic RGBA frames to ${outputDirectory}`, ) } finally { bridge.close() } } export function buildConfigureOptions(args: string[]): ThreeModeConfig { const value = (flag: string) => args.find((_, i) => args[i - 1] === flag) const options: ThreeModeConfig = {} // the body itself. a timeline authors this through initial.device; this // flag is for driving the live stage by hand. const device = value('--device') if (device) { if ( device !== 'iphone-17-air' && device !== 'iphone-17-pro' && device !== 'pixel-10' ) { throw new Error( `--device expects iphone-17-air, iphone-17-pro, or pixel-10, got "${device}"`, ) } options.device = device } const colorway = value('--colorway') if (colorway) options.colorway = colorway const background = value('--background') if (background) options.background = background // custom gradient: "color[@offset],color[@offset],…". offsets are // 0..1 or 0..100 (auto-detected); omitted offsets spread evenly. const gradient = value('--gradient') if (gradient) { const raw = gradient .split(',') .map((s) => s.trim()) .filter(Boolean) const stops = raw.map((token, i) => { const [color, offsetStr] = token.split('@') let offset = offsetStr !== undefined ? Number.parseFloat(offsetStr) : raw.length > 1 ? i / (raw.length - 1) : 0 if (offset > 1) offset = offset / 100 if (!Number.isFinite(offset)) offset = raw.length > 1 ? i / (raw.length - 1) : 0 return { color: color.trim(), offset } }) const angleStr = value('--gradient-angle') const angleDeg = angleStr === undefined ? undefined : Number.parseFloat(angleStr) if (angleStr !== undefined && !Number.isFinite(angleDeg)) { throw new Error(`--gradient-angle expects a number, got "${angleStr}"`) } options.customGradient = { stops, ...(angleDeg !== undefined ? { angleDeg } : {}) } } const setting = value('--setting') if (setting) { if (!isThreeModeSetting(setting)) { throw new Error( `--setting expects ${THREE_MODE_SETTING_IDS.join(', ')}, got "${setting}"`, ) } options.setting = setting } const focus: Record = {} const focusEnabled = parseBooleanValue(value('--focus'), '--focus') if (focusEnabled !== undefined) focus.enabled = focusEnabled for (const [flag, key] of [ ['--focus-amount', 'amount'], ['--focus-range', 'range'], ] as const) { const setting = value(flag) if (setting) focus[key] = setting } if (Object.keys(focus).length > 0) options.focus = focus if (args.includes('--reset-pose')) options.resetPose = true const environment = parseBooleanValue(value('--environment'), '--environment') const pins: Record = {} const effects: Record = {} for (const [flag, key] of [ ['--env-shape', 'shape'], ['--env-spread', 'spread'], ['--env-size', 'size'], ['--env-color', 'color'], ] as const) { const pin = value(flag) if (pin) pins[key] = pin } const glow = parseBooleanValue(value('--env-glow'), '--env-glow') if (glow !== undefined) effects.glow = glow const plastic = parseBooleanValue(value('--env-plastic'), '--env-plastic') if (plastic !== undefined) effects.plastic = plastic if ( environment !== undefined || Object.keys(pins).length > 0 || Object.keys(effects).length > 0 ) { options.environment = environment === false ? false : { pins, effects, } } const scriptId = value('--script') if (scriptId) { const progressValue = value('--progress') const progress = progressValue === undefined ? 1 : Number.parseFloat(progressValue) if (!Number.isFinite(progress)) { throw new Error(`--progress expects a number from 0 to 1, got "${progressValue}"`) } options.script = { id: scriptId, progress, animate: args.includes('--animate'), slow: args.includes('--slow'), } } return options } function parseBooleanValue(value: string | undefined, flag: string): boolean | undefined { if (value === undefined) return undefined const normalized = value.toLowerCase() if (['on', 'true', 'yes', '1'].includes(normalized)) return true if (['off', 'false', 'no', '0'].includes(normalized)) return false throw new Error(`${flag} expects on or off, got "${value}"`) } function summarizeState(state: Record) { const parts = [ `ready=${Boolean(state.ready)}`, `background=${String(state.background ?? 'unknown')}`, `colorway=${String(state.colorway ?? 'unknown')}`, `environment=${state.environmentActive ? 'on' : 'off'}`, `setting=${String(state.setting ?? 'none')}`, `focus=${ state.focus && typeof state.focus === 'object' && (state.focus as { enabled?: boolean }).enabled ? 'on' : 'off' }`, ] return parts.join(', ') }